Files
ReleaseKit/internal/validation/markdown_check.go

185 lines
5.8 KiB
Go

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
}