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.inputEntries { 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 }