185 lines
5.8 KiB
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
|
|
}
|