eb0ff3aa69
PR Ready Gate / clear-labels (pull_request) Successful in 1s
CI / test (pull_request) Successful in 17s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 40s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 1m0s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m3s
Add a comment explaining that validateDocmapPath calls EvalSymlinks
internally, so the returned path is always the fully-resolved real path
and can never equal the symlink entry itself.
Addresses sonnet bot NIT (review 4810) against d6bab7a9.
697 lines
21 KiB
Go
697 lines
21 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// makeDocmapYAML writes a YAML string to a temp file and returns its path.
|
|
// The file is created in t.TempDir() — use makeDocmapInDir when the docmap
|
|
// must be located inside a specific repo-root directory.
|
|
func makeDocmapYAML(t *testing.T, content string) string {
|
|
t.Helper()
|
|
f, err := os.CreateTemp(t.TempDir(), "doc-map-*.yml")
|
|
if err != nil {
|
|
t.Fatalf("CreateTemp: %v", err)
|
|
}
|
|
defer f.Close()
|
|
if _, err := f.WriteString(content); err != nil {
|
|
t.Fatalf("WriteString: %v", err)
|
|
}
|
|
return f.Name()
|
|
}
|
|
|
|
// makeDocmapInDir writes a YAML string to a file inside dir and returns the
|
|
// file path. Use this instead of makeDocmapYAML when also passing --repo-root,
|
|
// because validateDocmapPath requires the docmap to be within the repo root.
|
|
func makeDocmapInDir(t *testing.T, dir, content string) string {
|
|
t.Helper()
|
|
if err := os.MkdirAll(filepath.Join(dir, ".review-bot"), 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
path := filepath.Join(dir, ".review-bot", "doc-map.yml")
|
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
// makeDocFile creates a file (and any parent dirs) at the given path relative to dir.
|
|
func makeDocFile(t *testing.T, dir, rel string) {
|
|
t.Helper()
|
|
full := filepath.Join(dir, rel)
|
|
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
if err := os.WriteFile(full, []byte("# doc\n"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
}
|
|
|
|
// captureOutput redirects outWriter/errWriter to buffers for the duration of f.
|
|
func captureOutput(f func()) (stdout, stderr string) {
|
|
var outBuf, errBuf bytes.Buffer
|
|
origOut, origErr := outWriter, errWriter
|
|
outWriter = &outBuf
|
|
errWriter = &errBuf
|
|
defer func() {
|
|
outWriter = origOut
|
|
errWriter = origErr
|
|
}()
|
|
f()
|
|
return outBuf.String(), errBuf.String()
|
|
}
|
|
|
|
func TestRunValidateDocmap_Clean(t *testing.T) {
|
|
dir := t.TempDir()
|
|
makeDocFile(t, dir, "docs/foo.md")
|
|
|
|
docmap := makeDocmapInDir(t, dir, `
|
|
mappings:
|
|
- paths:
|
|
- "lib/foo/**"
|
|
docs:
|
|
- docs/foo.md
|
|
`)
|
|
|
|
// A covered file with all docs existing → clean.
|
|
code, stdout, _ := stdinValidateDocmap(t,
|
|
"lib/foo/bar.ex\n",
|
|
[]string{"--docmap", docmap, "--repo-root", dir},
|
|
)
|
|
if code != 0 {
|
|
t.Errorf("expected exit 0 for clean, got %d", code)
|
|
}
|
|
if !strings.Contains(stdout, "OK") {
|
|
t.Errorf("expected 'OK' in stdout, got %q", stdout)
|
|
}
|
|
}
|
|
|
|
func TestRunValidateDocmap_MissingDocmapFlag(t *testing.T) {
|
|
var code int
|
|
_, stderr := captureOutput(func() {
|
|
code = runValidateDocmap([]string{})
|
|
})
|
|
if code != 2 {
|
|
t.Errorf("expected exit 2 for missing --docmap, got %d", code)
|
|
}
|
|
if !strings.Contains(stderr, "--docmap") {
|
|
t.Errorf("expected --docmap in stderr, got %q", stderr)
|
|
}
|
|
}
|
|
|
|
func TestRunValidateDocmap_BadYAML(t *testing.T) {
|
|
dir := t.TempDir()
|
|
docmap := makeDocmapInDir(t, dir, "mappings: [{{invalid")
|
|
var code int
|
|
_, stderr := captureOutput(func() {
|
|
code = runValidateDocmap([]string{"--docmap", docmap, "--repo-root", dir})
|
|
})
|
|
if code != 2 {
|
|
t.Errorf("expected exit 2 for bad YAML, got %d", code)
|
|
}
|
|
if !strings.Contains(stderr, "failed to parse") {
|
|
t.Errorf("expected parse error in stderr, got %q", stderr)
|
|
}
|
|
}
|
|
|
|
func TestRunValidateDocmap_StaleDocs(t *testing.T) {
|
|
dir := t.TempDir()
|
|
// docs/foo.md does NOT exist on disk.
|
|
|
|
docmap := makeDocmapInDir(t, dir, `
|
|
mappings:
|
|
- paths:
|
|
- "lib/foo/**"
|
|
docs:
|
|
- docs/foo.md
|
|
`)
|
|
|
|
var code int
|
|
_, stderr := captureOutput(func() {
|
|
code = runValidateDocmap([]string{
|
|
"--docmap", docmap,
|
|
"--repo-root", dir,
|
|
})
|
|
})
|
|
if code != 1 {
|
|
t.Errorf("expected exit 1 for stale docs, got %d", code)
|
|
}
|
|
if !strings.Contains(stderr, "docs/foo.md") {
|
|
t.Errorf("expected stale path in stderr, got %q", stderr)
|
|
}
|
|
if !strings.Contains(stderr, "stale docmap") {
|
|
t.Errorf("expected 'stale docmap' in stderr, got %q", stderr)
|
|
}
|
|
}
|
|
|
|
// stdinValidateDocmap runs runValidateDocmap with a synthetic stdin.
|
|
//
|
|
// Implementation note: we write stdinContent to a temp file and point
|
|
// os.Stdin at it. The defer f.Close() fires after stdinValidateDocmap
|
|
// returns, which is after runValidateDocmap has finished reading stdin
|
|
// synchronously — so the file is not closed while still in use.
|
|
// Tests must not call t.Parallel() while sharing the global os.Stdin.
|
|
func stdinValidateDocmap(t *testing.T, stdinContent string, args []string) (code int, stdout, stderr string) {
|
|
t.Helper()
|
|
// Write stdin content to a temp file and redirect os.Stdin.
|
|
f, err := os.CreateTemp(t.TempDir(), "stdin-*")
|
|
if err != nil {
|
|
t.Fatalf("CreateTemp for stdin: %v", err)
|
|
}
|
|
defer f.Close()
|
|
if _, err := f.WriteString(stdinContent); err != nil {
|
|
t.Fatalf("WriteString for stdin: %v", err)
|
|
}
|
|
if _, err := f.Seek(0, 0); err != nil {
|
|
t.Fatalf("Seek for stdin: %v", err)
|
|
}
|
|
|
|
origStdin := os.Stdin
|
|
os.Stdin = f
|
|
defer func() { os.Stdin = origStdin }()
|
|
|
|
stdout, stderr = captureOutput(func() {
|
|
code = runValidateDocmap(args)
|
|
})
|
|
return
|
|
}
|
|
|
|
func TestRunValidateDocmap_UncoveredFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
makeDocFile(t, dir, "docs/foo.md")
|
|
|
|
docmap := makeDocmapInDir(t, dir, `
|
|
mappings:
|
|
- paths:
|
|
- "lib/foo/**"
|
|
docs:
|
|
- docs/foo.md
|
|
`)
|
|
|
|
code, _, stderr := stdinValidateDocmap(t,
|
|
"lib/bar/uncovered.ex\n",
|
|
[]string{"--docmap", docmap, "--repo-root", dir},
|
|
)
|
|
if code != 1 {
|
|
t.Errorf("expected exit 1 for uncovered file, got %d", code)
|
|
}
|
|
if !strings.Contains(stderr, "lib/bar/uncovered.ex") {
|
|
t.Errorf("expected uncovered file in stderr, got %q", stderr)
|
|
}
|
|
if !strings.Contains(stderr, "no docmap coverage") {
|
|
t.Errorf("expected 'no docmap coverage' in stderr, got %q", stderr)
|
|
}
|
|
}
|
|
|
|
func TestRunValidateDocmap_BothFailures(t *testing.T) {
|
|
dir := t.TempDir()
|
|
// docs/foo.md intentionally missing
|
|
|
|
docmap := makeDocmapInDir(t, dir, `
|
|
mappings:
|
|
- paths:
|
|
- "lib/foo/**"
|
|
docs:
|
|
- docs/foo.md
|
|
`)
|
|
|
|
code, _, stderr := stdinValidateDocmap(t,
|
|
"lib/bar/uncovered.ex\n",
|
|
[]string{"--docmap", docmap, "--repo-root", dir},
|
|
)
|
|
if code != 1 {
|
|
t.Errorf("expected exit 1 for both failures, got %d", code)
|
|
}
|
|
if !strings.Contains(stderr, "no docmap coverage") {
|
|
t.Errorf("expected coverage error in stderr, got %q", stderr)
|
|
}
|
|
if !strings.Contains(stderr, "stale docmap") {
|
|
t.Errorf("expected stale-docs error in stderr, got %q", stderr)
|
|
}
|
|
}
|
|
|
|
func TestRunValidateDocmap_EmptyStdin(t *testing.T) {
|
|
dir := t.TempDir()
|
|
makeDocFile(t, dir, "docs/foo.md")
|
|
|
|
docmap := makeDocmapInDir(t, dir, `
|
|
mappings:
|
|
- paths:
|
|
- "lib/foo/**"
|
|
docs:
|
|
- docs/foo.md
|
|
`)
|
|
|
|
code, stdout, _ := stdinValidateDocmap(t,
|
|
"",
|
|
[]string{"--docmap", docmap, "--repo-root", dir},
|
|
)
|
|
if code != 0 {
|
|
t.Errorf("expected exit 0 for empty stdin, got %d", code)
|
|
}
|
|
if !strings.Contains(stdout, "OK") {
|
|
t.Errorf("expected 'OK' in stdout, got %q", stdout)
|
|
}
|
|
}
|
|
|
|
func TestRunValidateDocmap_BlankLinesSkipped(t *testing.T) {
|
|
dir := t.TempDir()
|
|
makeDocFile(t, dir, "docs/foo.md")
|
|
|
|
docmap := makeDocmapInDir(t, dir, `
|
|
mappings:
|
|
- paths:
|
|
- "lib/foo/**"
|
|
docs:
|
|
- docs/foo.md
|
|
`)
|
|
|
|
// stdin with only blank lines → effectively empty, should be clean
|
|
code, stdout, _ := stdinValidateDocmap(t,
|
|
"\n \n\n",
|
|
[]string{"--docmap", docmap, "--repo-root", dir},
|
|
)
|
|
if code != 0 {
|
|
t.Errorf("expected exit 0 for blank-only stdin, got %d", code)
|
|
}
|
|
if !strings.Contains(stdout, "OK") {
|
|
t.Errorf("expected 'OK' in stdout for blank-only stdin, got %q", stdout)
|
|
}
|
|
}
|
|
|
|
func TestRunValidateDocmap_DuplicateDocsDeduped(t *testing.T) {
|
|
dir := t.TempDir()
|
|
// docs/shared.md intentionally missing — but it appears in TWO mappings.
|
|
// Should appear only once in stale list.
|
|
|
|
docmap := makeDocmapInDir(t, dir, `
|
|
mappings:
|
|
- paths:
|
|
- "lib/foo/**"
|
|
docs:
|
|
- docs/shared.md
|
|
- paths:
|
|
- "lib/bar/**"
|
|
docs:
|
|
- docs/shared.md
|
|
`)
|
|
|
|
code, _, stderr := stdinValidateDocmap(t,
|
|
"",
|
|
[]string{"--docmap", docmap, "--repo-root", dir},
|
|
)
|
|
if code != 1 {
|
|
t.Errorf("expected exit 1 for stale doc, got %d", code)
|
|
}
|
|
count := strings.Count(stderr, "docs/shared.md")
|
|
if count != 1 {
|
|
t.Errorf("expected docs/shared.md to appear exactly once in stderr (deduplicated), got %d occurrences: %q", count, stderr)
|
|
}
|
|
}
|
|
|
|
// TestCheckStaleDocs_PathTraversal verifies that checkStaleDocs rejects
|
|
// traversal and absolute paths without touching the host filesystem.
|
|
func TestCheckStaleDocs_PathTraversal(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
// Baseline: a valid doc that exists.
|
|
makeDocFile(t, dir, "docs/valid.md")
|
|
|
|
tests := []struct {
|
|
name string
|
|
docPath string
|
|
wantStale bool
|
|
}{
|
|
{"dot-dot traversal", "../../etc/passwd", true},
|
|
{"dot-dot single", "../outside", true},
|
|
{"absolute path", "/etc/passwd", true},
|
|
{"valid present path", "docs/valid.md", false},
|
|
{"valid missing path", "docs/missing.md", true},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
docmap := makeDocmapInDir(t, dir, `
|
|
mappings:
|
|
- paths:
|
|
- "lib/**"
|
|
docs:
|
|
- `+tc.docPath+`
|
|
`)
|
|
code, _, stderr := stdinValidateDocmap(t,
|
|
"",
|
|
[]string{"--docmap", docmap, "--repo-root", dir},
|
|
)
|
|
|
|
if tc.wantStale {
|
|
if code != 1 {
|
|
t.Errorf("path %q: expected exit 1 (stale/invalid), got %d; stderr: %q", tc.docPath, code, stderr)
|
|
}
|
|
} else {
|
|
if code != 0 {
|
|
t.Errorf("path %q: expected exit 0 (valid), got %d; stderr: %q", tc.docPath, code, stderr)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCheckStaleDocs_SymlinkOutside verifies that a symlink under repoRoot
|
|
// pointing outside the repo is treated as stale (not followed).
|
|
func TestCheckStaleDocs_SymlinkOutside(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
// Create a symlink inside repoRoot pointing to a file outside the repo.
|
|
// We point at /etc/hostname (exists on Linux CI) but the test does not
|
|
// depend on that file existing — Lstat must reject the symlink itself.
|
|
linkPath := filepath.Join(dir, "docs", "secret.md")
|
|
if err := os.MkdirAll(filepath.Dir(linkPath), 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
if err := os.Symlink("/etc/hostname", linkPath); err != nil {
|
|
t.Fatalf("Symlink: %v", err)
|
|
}
|
|
|
|
docmap := makeDocmapInDir(t, dir, `
|
|
mappings:
|
|
- paths:
|
|
- "lib/**"
|
|
docs:
|
|
- docs/secret.md
|
|
`)
|
|
|
|
code, _, stderr := stdinValidateDocmap(t,
|
|
"",
|
|
[]string{"--docmap", docmap, "--repo-root", dir},
|
|
)
|
|
if code != 1 {
|
|
t.Errorf("expected exit 1 for symlink doc, got %d; stderr: %q", code, stderr)
|
|
}
|
|
if !strings.Contains(stderr, "docs/secret.md") {
|
|
t.Errorf("expected stale path in stderr, got %q", stderr)
|
|
}
|
|
}
|
|
|
|
// TestCheckStaleDocs_SymlinkInsideRepo verifies that a symlink pointing to
|
|
// another file *within* the repo is also treated as stale. We refuse all
|
|
// symlinks regardless of target to keep the check simple and safe.
|
|
func TestCheckStaleDocs_SymlinkInsideRepo(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
// Real doc file.
|
|
makeDocFile(t, dir, "docs/real.md")
|
|
|
|
// Symlink inside repo pointing at the real file.
|
|
linkPath := filepath.Join(dir, "docs", "link.md")
|
|
if err := os.Symlink(filepath.Join(dir, "docs", "real.md"), linkPath); err != nil {
|
|
t.Fatalf("Symlink: %v", err)
|
|
}
|
|
|
|
docmap := makeDocmapInDir(t, dir, `
|
|
mappings:
|
|
- paths:
|
|
- "lib/**"
|
|
docs:
|
|
- docs/link.md
|
|
`)
|
|
|
|
code, _, stderr := stdinValidateDocmap(t,
|
|
"",
|
|
[]string{"--docmap", docmap, "--repo-root", dir},
|
|
)
|
|
if code != 1 {
|
|
t.Errorf("expected exit 1 for symlink doc (even intra-repo), got %d; stderr: %q", code, stderr)
|
|
}
|
|
}
|
|
|
|
// TestRunValidateDocmap_SymlinkRepoRoot verifies that a --repo-root that is
|
|
// itself a symlink to a valid directory resolves correctly.
|
|
func TestRunValidateDocmap_SymlinkRepoRoot(t *testing.T) {
|
|
realDir := t.TempDir()
|
|
makeDocFile(t, realDir, "docs/foo.md")
|
|
|
|
// Create a symlink pointing at realDir.
|
|
symlinkDir := filepath.Join(t.TempDir(), "link-root")
|
|
if err := os.Symlink(realDir, symlinkDir); err != nil {
|
|
t.Fatalf("Symlink: %v", err)
|
|
}
|
|
|
|
// Place the docmap inside realDir so it passes the confinement check.
|
|
// (symlinkDir resolves to realDir, so files inside realDir are also inside
|
|
// the resolved repo-root.)
|
|
docmap := makeDocmapInDir(t, realDir, `
|
|
mappings:
|
|
- paths:
|
|
- "lib/**"
|
|
docs:
|
|
- docs/foo.md
|
|
`)
|
|
|
|
// Using the symlinked repo-root: the real doc exists → should be clean.
|
|
code, stdout, stderr := stdinValidateDocmap(t,
|
|
"lib/foo.go\n",
|
|
[]string{"--docmap", docmap, "--repo-root", symlinkDir},
|
|
)
|
|
if code != 0 {
|
|
t.Errorf("expected exit 0 for symlinked repo-root with existing doc, got %d; stderr: %q", code, stderr)
|
|
}
|
|
if !strings.Contains(stdout, "OK") {
|
|
t.Errorf("expected 'OK' in stdout, got %q", stdout)
|
|
}
|
|
}
|
|
|
|
// TestValidateDocmapPath_Symlink verifies that --docmap pointing at a symlink
|
|
// whose resolved target is outside --repo-root is rejected (prevents reading
|
|
// arbitrary host files via PR-controlled symlinks).
|
|
//
|
|
// Note: after the EvalSymlinks fix (issue #150), in-repo symlinks whose
|
|
// targets also reside within the repo root are now allowed — the confinement
|
|
// check is applied to the resolved path, not the symlink entry itself. The
|
|
// security invariant is: the resolved destination must be within the root.
|
|
func TestValidateDocmapPath_Symlink(t *testing.T) {
|
|
dir := t.TempDir()
|
|
outside := t.TempDir()
|
|
|
|
// Create a docmap file OUTSIDE the repo root to serve as the symlink
|
|
// target. EvalSymlinks will resolve to this path, which the Rel check
|
|
// must then reject.
|
|
if err := os.MkdirAll(filepath.Join(outside, ".review-bot"), 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
outsideDocmap := filepath.Join(outside, ".review-bot", "doc-map.yml")
|
|
if err := os.WriteFile(outsideDocmap, []byte("mappings: []\n"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
|
|
// Create a symlink inside dir pointing to the file outside the repo.
|
|
if err := os.MkdirAll(filepath.Join(dir, ".review-bot"), 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
symlinkPath := filepath.Join(dir, ".review-bot", "doc-map-link.yml")
|
|
if err := os.Symlink(outsideDocmap, symlinkPath); err != nil {
|
|
t.Fatalf("Symlink: %v", err)
|
|
}
|
|
|
|
code, _, stderr := stdinValidateDocmap(t,
|
|
"",
|
|
[]string{"--docmap", symlinkPath, "--repo-root", dir},
|
|
)
|
|
if code != 2 {
|
|
t.Errorf("expected exit 2 for out-of-repo symlink docmap, got %d; stderr: %q", code, stderr)
|
|
}
|
|
if !strings.Contains(stderr, "invalid") && !strings.Contains(stderr, "repo-root") {
|
|
t.Errorf("expected confinement rejection in stderr, got %q", stderr)
|
|
}
|
|
}
|
|
|
|
// TestValidateDocmapPath_OutsideRepoRoot verifies that --docmap pointing
|
|
// outside --repo-root is rejected (prevents reading arbitrary host files).
|
|
func TestValidateDocmapPath_OutsideRepoRoot(t *testing.T) {
|
|
repoDir := t.TempDir()
|
|
|
|
// Create a docmap in a separate temp dir (outside the repo root).
|
|
outside := makeDocmapYAML(t, `
|
|
mappings:
|
|
- paths:
|
|
- "lib/**"
|
|
docs:
|
|
- docs/foo.md
|
|
`)
|
|
|
|
code, _, stderr := stdinValidateDocmap(t,
|
|
"",
|
|
[]string{"--docmap", outside, "--repo-root", repoDir},
|
|
)
|
|
if code != 2 {
|
|
t.Errorf("expected exit 2 for docmap outside repo-root, got %d; stderr: %q", code, stderr)
|
|
}
|
|
if !strings.Contains(stderr, "invalid") && !strings.Contains(stderr, "repo-root") {
|
|
t.Errorf("expected confinement rejection in stderr, got %q", stderr)
|
|
}
|
|
}
|
|
|
|
// TestValidateDocmapPath_SizeLimit verifies that --docmap files exceeding
|
|
// maxDocmapBytes are rejected before reading (prevents memory exhaustion).
|
|
func TestValidateDocmapPath_SizeLimit(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
// Write a file larger than maxDocmapBytes.
|
|
bigPath := filepath.Join(dir, ".review-bot", "big-doc-map.yml")
|
|
if err := os.MkdirAll(filepath.Dir(bigPath), 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
// Exceed the limit by one byte.
|
|
bigContent := make([]byte, maxDocmapBytes+1)
|
|
if err := os.WriteFile(bigPath, bigContent, 0o644); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
|
|
code, _, stderr := stdinValidateDocmap(t,
|
|
"",
|
|
[]string{"--docmap", bigPath, "--repo-root", dir},
|
|
)
|
|
if code != 2 {
|
|
t.Errorf("expected exit 2 for oversized docmap, got %d; stderr: %q", code, stderr)
|
|
}
|
|
if !strings.Contains(stderr, "limit") && !strings.Contains(stderr, "size") && !strings.Contains(stderr, "invalid") {
|
|
t.Errorf("expected size limit error in stderr, got %q", stderr)
|
|
}
|
|
}
|
|
|
|
// TestValidateDocmapPath_DirSymlinkBypass verifies that a directory-symlink
|
|
// inside the repo pointing outside cannot be used to read arbitrary host files.
|
|
//
|
|
// Attack vector: a PR commits .review-bot/ as a directory symlink targeting a
|
|
// directory outside the repo. The textual path of the docmap file is inside
|
|
// the repo root, so the old Rel-only check passed — but the actual file is
|
|
// outside. This is closed by calling EvalSymlinks on the full path before the
|
|
// confinement check.
|
|
func TestValidateDocmapPath_DirSymlinkBypass(t *testing.T) {
|
|
repoDir := t.TempDir()
|
|
outsideDir := t.TempDir()
|
|
|
|
// Secret file outside the repo.
|
|
secretPath := filepath.Join(outsideDir, "secret.yml")
|
|
if err := os.WriteFile(secretPath, []byte("mappings: []\n"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
|
|
// Create .review-bot/ as a directory symlink pointing outside the repo.
|
|
reviewBotDir := filepath.Join(repoDir, ".review-bot")
|
|
if err := os.Symlink(outsideDir, reviewBotDir); err != nil {
|
|
t.Skipf("cannot create dir symlink (platform may not support it): %v", err)
|
|
}
|
|
|
|
// Textually inside repo — .review-bot/secret.yml — but resolves outside.
|
|
attackPath := filepath.Join(repoDir, ".review-bot", "secret.yml")
|
|
|
|
// Resolve repoDir to a symlink-free path, as runValidateDocmap does.
|
|
resolvedRoot, err := filepath.EvalSymlinks(repoDir)
|
|
if err != nil {
|
|
t.Fatalf("EvalSymlinks(repoDir): %v", err)
|
|
}
|
|
|
|
if _, err := validateDocmapPath(attackPath, resolvedRoot); err == nil {
|
|
t.Error("expected rejection of dir-symlink bypass, got nil error")
|
|
}
|
|
}
|
|
|
|
// TestValidateDocmapPath_NonRegularFile verifies that --docmap pointing at a
|
|
// non-regular file (e.g. a directory) is rejected with a clear error before
|
|
// ParseDocMapConfig is called.
|
|
func TestValidateDocmapPath_NonRegularFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
// Use the directory itself as the docmap path — directories pass Lstat but
|
|
// are not regular files.
|
|
reviewBotDir := filepath.Join(dir, ".review-bot")
|
|
if err := os.MkdirAll(reviewBotDir, 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
|
|
code, _, stderr := stdinValidateDocmap(t,
|
|
"",
|
|
[]string{"--docmap", reviewBotDir, "--repo-root", dir},
|
|
)
|
|
if code != 2 {
|
|
t.Errorf("expected exit 2 for directory docmap, got %d; stderr: %q", code, stderr)
|
|
}
|
|
if !strings.Contains(stderr, "regular file") && !strings.Contains(stderr, "invalid") {
|
|
t.Errorf("expected regular-file rejection in stderr, got %q", stderr)
|
|
}
|
|
}
|
|
|
|
// TestRunValidateDocmap_DotSlashPrefix verifies that paths emitted with a
|
|
// leading "./" (e.g. from `find` or `ls`) match doc-map globs correctly.
|
|
// Without TrimPrefix, "./cmd/foo.go" would not match the pattern "cmd/**".
|
|
func TestRunValidateDocmap_DotSlashPrefix(t *testing.T) {
|
|
dir := t.TempDir()
|
|
makeDocFile(t, dir, "docs/foo.md")
|
|
|
|
docmap := makeDocmapInDir(t, dir, `
|
|
mappings:
|
|
- paths:
|
|
- "cmd/**"
|
|
docs:
|
|
- docs/foo.md
|
|
`)
|
|
|
|
// File with a leading "./" should be treated as covered.
|
|
code, _, stderr := stdinValidateDocmap(t,
|
|
"./cmd/foo.go\n",
|
|
[]string{"--docmap", docmap, "--repo-root", dir},
|
|
)
|
|
if code != 0 {
|
|
t.Errorf("expected exit 0 for './' prefixed covered file, got %d; stderr: %q", code, stderr)
|
|
}
|
|
}
|
|
|
|
// TestValidateDocmapPath_InRepoSymlinkAllowed verifies that an in-repo
|
|
// file-level symlink whose resolved target is still within the repo root is
|
|
// accepted. This is the positive case for the issue #150 behavioral change:
|
|
// only symlinks that escape the root are rejected; intra-repo symlinks are
|
|
// allowed because EvalSymlinks resolves the target and the confinement check
|
|
// is applied to the resolved path, not the symlink entry itself.
|
|
func TestValidateDocmapPath_InRepoSymlinkAllowed(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
// Create the real docmap file inside the repo root.
|
|
if err := os.MkdirAll(filepath.Join(dir, ".review-bot"), 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
realDocmap := filepath.Join(dir, ".review-bot", "doc-map-real.yml")
|
|
if err := os.WriteFile(realDocmap, []byte("mappings: []\n"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
|
|
// Create a symlink inside the repo root that points to the real file
|
|
// (also inside the root).
|
|
symlinkPath := filepath.Join(dir, ".review-bot", "doc-map-link.yml")
|
|
if err := os.Symlink(realDocmap, symlinkPath); err != nil {
|
|
t.Skipf("cannot create symlink (platform may not support it): %v", err)
|
|
}
|
|
|
|
// Resolve dir to a symlink-free root, as runValidateDocmap does.
|
|
resolvedRoot, err := filepath.EvalSymlinks(dir)
|
|
if err != nil {
|
|
t.Fatalf("EvalSymlinks(dir): %v", err)
|
|
}
|
|
|
|
// In-repo symlink whose target is within root: must be accepted.
|
|
resolved, err := validateDocmapPath(symlinkPath, resolvedRoot)
|
|
if err != nil {
|
|
t.Fatalf("expected in-repo symlink to be accepted, got error: %v", err)
|
|
}
|
|
// The returned resolved path must be the real file (not the symlink entry).
|
|
// validateDocmapPath calls filepath.EvalSymlinks internally, so the returned
|
|
// path is always the fully-resolved real path — it can never equal the
|
|
// symlink entry itself.
|
|
if resolved == symlinkPath {
|
|
t.Errorf("expected resolved path to differ from symlink path")
|
|
}
|
|
}
|