Check committed Markdown destinations
This commit is contained in:
184
internal/validation/markdown_check.go
Normal file
184
internal/validation/markdown_check.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package validation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
|
||||
)
|
||||
|
||||
func checkMarkdownLinks(ctx context.Context, tree *CandidateTree, enforceCheckoutBoundary bool, output io.Writer) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return &Failure{Check: MarkdownLinks, Cause: err}
|
||||
}
|
||||
cache := newMarkdownCache(tree)
|
||||
var findings []Finding
|
||||
for _, entry := range tree.Entries() {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return &Failure{Check: MarkdownLinks, Findings: findings, Cause: err}
|
||||
}
|
||||
if !isRegularFile(entry) || !strings.EqualFold(path.Ext(entry.Path), ".md") {
|
||||
continue
|
||||
}
|
||||
document, err := cache.document(ctx, entry.Path)
|
||||
if err != nil {
|
||||
return &Failure{
|
||||
Check: MarkdownLinks,
|
||||
Findings: []Finding{{Path: entry.Path, Message: "could not inspect Markdown document"}},
|
||||
Cause: fmt.Errorf("read committed Markdown document %q: %w", entry.Path, err),
|
||||
}
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return &Failure{Check: MarkdownLinks, Findings: findings, Cause: err}
|
||||
}
|
||||
for _, link := range document.links {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return &Failure{Check: MarkdownLinks, Findings: findings, Cause: err}
|
||||
}
|
||||
linkFindings, err := checkMarkdownLink(ctx, tree, cache, entry.Path, link, enforceCheckoutBoundary, output)
|
||||
findings = append(findings, linkFindings...)
|
||||
if err != nil {
|
||||
return &Failure{Check: MarkdownLinks, Findings: findings, Cause: err}
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return &Failure{Check: MarkdownLinks, Findings: findings, Cause: err}
|
||||
}
|
||||
if len(findings) != 0 {
|
||||
return &Failure{Check: MarkdownLinks, Findings: findings}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkMarkdownLink(ctx context.Context, tree *CandidateTree, cache *markdownCache, sourcePath string, link markdownLink, enforceCheckoutBoundary bool, output io.Writer) ([]Finding, error) {
|
||||
uriPath, fragment, local, err := parseMarkdownDestination(link.Destination)
|
||||
if err != nil {
|
||||
return []Finding{markdownFinding(sourcePath, link, "destination contains an invalid percent escape")}, nil
|
||||
}
|
||||
if !local {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
destination := Destination{Kind: DestinationEntry, Path: sourcePath}
|
||||
if uriPath != "" {
|
||||
destination, err = tree.ResolveDestination(ctx, sourcePath, uriPath)
|
||||
if err != nil {
|
||||
finding := markdownFinding(sourcePath, link, "could not inspect destination")
|
||||
return []Finding{finding}, fmt.Errorf("resolve Markdown destination %q from %q: %w", link.Destination, sourcePath, err)
|
||||
}
|
||||
}
|
||||
|
||||
switch destination.Kind {
|
||||
case DestinationMissing:
|
||||
return []Finding{markdownFinding(sourcePath, link, "destination does not exist")}, nil
|
||||
case DestinationSymlinkCycle:
|
||||
return []Finding{markdownFinding(sourcePath, link, "destination contains a symlink cycle")}, nil
|
||||
case DestinationOutsideCheckout:
|
||||
if enforceCheckoutBoundary {
|
||||
return []Finding{markdownFinding(sourcePath, link, "destination is outside the checkout")}, nil
|
||||
}
|
||||
return nil, writeMarkdownObservation(output, sourcePath, link, "destination is outside the checkout")
|
||||
case DestinationSubmodule:
|
||||
return nil, writeMarkdownObservation(output, sourcePath, link, "destination enters uninspected submodule content")
|
||||
case DestinationEntry:
|
||||
if fragment == "" || !strings.EqualFold(path.Ext(destination.Path), ".md") {
|
||||
return nil, nil
|
||||
}
|
||||
targetEntry, ok := tree.Entry(destination.Path)
|
||||
if !ok || !isRegularFile(targetEntry) {
|
||||
return nil, nil
|
||||
}
|
||||
targetDocument, err := cache.document(ctx, destination.Path)
|
||||
if err != nil {
|
||||
finding := markdownFinding(sourcePath, link, "could not inspect destination anchor")
|
||||
return []Finding{finding}, fmt.Errorf("read committed Markdown destination %q: %w", destination.Path, err)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, ok := targetDocument.anchors[fragment]; !ok {
|
||||
return []Finding{markdownFinding(sourcePath, link, "heading anchor does not exist")}, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func parseMarkdownDestination(destination string) (path, fragment string, local bool, err error) {
|
||||
pathPart := destination
|
||||
if index := strings.IndexByte(pathPart, '#'); index >= 0 {
|
||||
fragment = pathPart[index+1:]
|
||||
pathPart = pathPart[:index]
|
||||
}
|
||||
if index := strings.IndexByte(pathPart, '?'); index >= 0 {
|
||||
pathPart = pathPart[:index]
|
||||
}
|
||||
if strings.HasPrefix(pathPart, "//") || hasURLScheme(pathPart) {
|
||||
return "", "", false, nil
|
||||
}
|
||||
path, err = url.PathUnescape(pathPart)
|
||||
if err != nil {
|
||||
return "", "", true, err
|
||||
}
|
||||
fragment, err = url.PathUnescape(fragment)
|
||||
if err != nil {
|
||||
return "", "", true, err
|
||||
}
|
||||
return path, strings.ToLower(fragment), true, nil
|
||||
}
|
||||
|
||||
func hasURLScheme(value string) bool {
|
||||
colon := strings.IndexByte(value, ':')
|
||||
if colon <= 0 {
|
||||
return false
|
||||
}
|
||||
if slash := strings.IndexByte(value, '/'); slash >= 0 && slash < colon {
|
||||
return false
|
||||
}
|
||||
for index, r := range value[:colon] {
|
||||
if index == 0 {
|
||||
if !isASCIIAlpha(r) {
|
||||
return false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !isASCIIAlpha(r) && (r < '0' || r > '9') && r != '+' && r != '-' && r != '.' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isASCIIAlpha(r rune) bool {
|
||||
return r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z'
|
||||
}
|
||||
|
||||
func isRegularFile(entry git.TreeEntry) bool {
|
||||
return entry.Type == "blob" && (entry.Mode == "100644" || entry.Mode == "100755")
|
||||
}
|
||||
|
||||
func markdownFinding(sourcePath string, link markdownLink, message string) Finding {
|
||||
return Finding{
|
||||
Path: sourcePath,
|
||||
Line: link.Location.Line,
|
||||
Column: link.Location.Column,
|
||||
Message: message,
|
||||
Target: link.Destination,
|
||||
}
|
||||
}
|
||||
|
||||
func writeMarkdownObservation(output io.Writer, sourcePath string, link markdownLink, message string) error {
|
||||
if output == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := fmt.Fprintf(output, "markdown-links: %s:%d:%d: %s (%q; out of scope)\n",
|
||||
sourcePath, link.Location.Line, link.Location.Column, message, link.Destination)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write Markdown destination observation: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user