package docs import ( "fmt" "net/url" "os" "path/filepath" "regexp" "slices" "strings" ) // kinds is the set of reference forms a frontmatter key accepts. type kinds uint8 const ( kAdr kinds = 1 << iota kPlan kURL kFile ) type refSpec struct { key string kinds kinds } var ( schemeRe = regexp.MustCompile(`^([A-Za-z][A-Za-z0-9+.-]*):`) durableRe = regexp.MustCompile(`^([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)?#\d+$`) idPattern = `\d{8}T\d{9}Z-[a-z0-9]+(?:-[a-z0-9]+)*` adrPathRe = regexp.MustCompile(`^/docs/adr/` + idPattern + `\.md$`) planPathRe = regexp.MustCompile(`^/docs/plans/` + idPattern + `\.md$`) ) type ref struct { path string // decoded repo-root path; empty for URLs and anchors remote bool } // parseRef checks the syntax of a reference. body allows #anchors, mailto: and #fragments. func parseRef(v string, body bool) (ref, string) { if v == "" { return ref{}, "empty reference" } if strings.Contains(v, `\`) { return ref{}, "backslashes are not allowed" } if body && v[0] == '#' { return ref{}, "" } if m := schemeRe.FindStringSubmatch(v); m != nil { switch strings.ToLower(m[1]) { case "http", "https": u, err := url.Parse(v) switch { case err != nil || u.Hostname() == "": return ref{}, "URL must have a host" case u.User != nil: return ref{}, "URL must not contain credentials" } return ref{remote: true}, "" case "mailto": if body { return ref{}, "" } } return ref{}, fmt.Sprintf("scheme %q is not allowed", m[1]) } if v[0] != '/' { return ref{}, "relative paths are not allowed; use a repo-root path starting with /" } if strings.HasPrefix(v, "//") { return ref{}, "protocol-relative references are not allowed" } p, frag, hasFrag := strings.Cut(v, "#") if hasFrag && !body { return ref{}, "fragments are not allowed here (" + frag + ")" } if strings.Contains(p, "?") { return ref{}, "queries are not allowed" } dec, err := url.PathUnescape(p) if err != nil { return ref{}, "invalid percent-encoding" } if slices.Contains(strings.Split(dec, "/"), "..") || strings.Contains(dec, `\`) { return ref{}, `".." segments and backslashes are not allowed` } return ref{path: dec}, "" } // exists reports why a repo-root path is not a regular file inside the repository. func (c *checker) exists(p string) string { // Match each component's case exactly, so case-insensitive filesystems agree with Linux. dir := c.root for _, part := range strings.Split(p, "/") { if part == "" || part == "." { continue } entries, err := os.ReadDir(dir) if err != nil || !slices.ContainsFunc(entries, func(e os.DirEntry) bool { return e.Name() == part }) { return "target " + p + " does not exist" } dir = filepath.Join(dir, part) } real, err := filepath.EvalSymlinks(filepath.Join(c.root, filepath.FromSlash(p))) if err != nil { return "target " + p + " does not exist" } if !within(c.realRoot, real) { return "target " + p + " resolves outside the repository" } if st, err := os.Stat(real); err != nil || !st.Mode().IsRegular() { return "target " + p + " is not a regular file" } return "" } // checkLink validates a body link destination. func (c *checker) checkLink(r *record, line int, dest string) { ref, msg := parseRef(dest, true) if msg == "" && ref.path != "" { msg = c.exists(ref.path) } if msg != "" { c.add(r.path, line, "link %q: %s", dest, msg) } } // refs validates the reference lists of a record against their specs. func (m *fm) refs(specs ...refSpec) { self := "/" + m.r.path for _, spec := range specs { items, ok := m.list(spec.key) if !ok { continue } seen := map[string]bool{} for _, it := range items { if it.v == marker { continue } if msg := m.checkRef(spec, it.v, self); msg != "" { m.c.add(m.r.path, it.line, "%s: %s", spec.key, msg) } else if seen[it.v] { m.c.add(m.r.path, it.line, "%s: duplicate reference %q", spec.key, it.v) } seen[it.v] = true } } } func (m *fm) checkRef(spec refSpec, v, self string) string { if durableRe.MatchString(v) || slices.Contains([]string{"this pr", "this pull request", "this commit"}, strings.ToLower(v)) { return fmt.Sprintf("%q is not durable; use a full URL", v) } r, msg := parseRef(v, false) if msg != "" { return msg } k := spec.kinds switch { case r.remote: if k&kURL == 0 { return "URLs are not accepted here; accepts " + k.String() } return "" case k&kFile == 0 && !(k&kAdr != 0 && adrPathRe.MatchString(v)) && !(k&kPlan != 0 && planPathRe.MatchString(v)): return fmt.Sprintf("%q is not accepted here; accepts %s", v, k) case r.path == self: return "a record must not reference itself" } return m.c.exists(r.path) } func (k kinds) String() string { var s []string for _, e := range []struct { k kinds s string }{{kAdr, "/docs/adr/.md"}, {kPlan, "/docs/plans/.md"}, {kURL, "http(s) URLs"}, {kFile, "repo-root file paths"}} { if k&e.k != 0 { s = append(s, e.s) } } return strings.Join(s, ", ") } // within reports whether the real path p is root or lies under it. func within(root, p string) bool { rel, err := filepath.Rel(root, p) return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) }