diff --git a/.golangci.yaml b/.golangci.yaml index 16a0424..bf10515 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -55,6 +55,7 @@ linters: - gocognit - goconst - gocritic + - goheader - gomodguard_v2 - goprintffuncname - gosec @@ -93,6 +94,9 @@ linters: - wsl_v5 settings: + goheader: + template: Copyright {{ YEAR }} The Graft Authors + govet: enable: - nilness @@ -113,3 +117,9 @@ linters: desc: Use github.com/go-resty/resty/v2 instead - pkg: github.com/aws/smithy-go/ptr$ desc: Use github.com/aws/aws-sdk-go-v2/aws instead + + exclusions: + rules: + - path: _test\.go + linters: + - goconst diff --git a/cmd/graft/completion_test.go b/cmd/graft/completion_test.go index cff387b..35a8002 100644 --- a/cmd/graft/completion_test.go +++ b/cmd/graft/completion_test.go @@ -1,4 +1,5 @@ // Copyright 2026 The Graft Authors + package main import ( diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..0b268f1 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,8 @@ +coverage: + status: + patch: + default: + target: 80% + # Allow one untestable line (e.g. an early-return error-wrap branch + # with no reachable failure point) without failing the patch check. + threshold: 5% diff --git a/internal/gitrun/gitrun.go b/internal/gitrun/gitrun.go index 6f6f4f8..174e317 100644 --- a/internal/gitrun/gitrun.go +++ b/internal/gitrun/gitrun.go @@ -80,7 +80,10 @@ func Run(ctx context.Context, dir string, args ...string) (string, error) { // invocation — used to give a checkout its own GIT_INDEX_FILE so parallel // checkouts of one bare repository never share an index. func RunEnv(ctx context.Context, dir string, extraEnv []string, args ...string) (string, error) { - cmd := exec.CommandContext(ctx, "git", args...) //nolint:gosec // Args are built from validated manifest values. + //nolint:gosec // Fetch/remote callers place "--" before any repo/ref/version + // operand; commit SHAs passed elsewhere (rev-parse, checkout, ls-tree) are + // constrained to hex by resolver.hexRe before they ever reach here. + cmd := exec.CommandContext(ctx, "git", args...) cmd.Dir = dir cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") diff --git a/internal/gitrun/gitrun_test.go b/internal/gitrun/gitrun_test.go index d2d4c05..455e52d 100644 --- a/internal/gitrun/gitrun_test.go +++ b/internal/gitrun/gitrun_test.go @@ -3,9 +3,16 @@ package gitrun_test import ( + "errors" + "os" + "path/filepath" + "strings" "testing" + "time" + "github.com/min0625/graft/internal/clierr" "github.com/min0625/graft/internal/gitrun" + "github.com/min0625/graft/internal/gittest" ) func TestCanonicalRepo(t *testing.T) { @@ -36,3 +43,317 @@ func TestCanonicalRepo(t *testing.T) { } } } + +// TestRemoteURL verifies scheme-less repos become HTTPS while explicit URLs +// and scp-like SSH remotes are used as written (spec §3.1, §7). +func TestRemoteURL(t *testing.T) { + t.Parallel() + + tests := []struct { + in, want string + }{ + {"github.com/org/repo", "https://github.com/org/repo"}, + {"https://github.com/org/repo.git", "https://github.com/org/repo.git"}, + {"ssh://git@github.com/org/repo.git", "ssh://git@github.com/org/repo.git"}, + {"git@github.com:org/repo.git", "git@github.com:org/repo.git"}, + } + + for _, tt := range tests { + if got := gitrun.RemoteURL(tt.in); got != tt.want { + t.Errorf("RemoteURL(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +// TestRun_capturesStdoutAndStderr checks that a successful command's stdout is +// returned and a failing command's error carries git's stderr. +func TestRun_capturesStdoutAndStderr(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + if _, err := gitrun.Run(t.Context(), "", "init", "--quiet", dir); err != nil { + t.Fatalf("git init: %v", err) + } + + out, err := gitrun.Run(t.Context(), dir, "rev-parse", "--git-dir") + if err != nil { + t.Fatalf("rev-parse: %v", err) + } + + if strings.TrimSpace(out) == "" { + t.Error("Run returned empty stdout for a successful command") + } + + // No --quiet: git writes a "fatal: ..." diagnostic to stderr, which Run + // must fold into the returned error. + _, err = gitrun.Run(t.Context(), dir, "rev-parse", "--verify", "nonexistent^{commit}") + if err == nil { + t.Fatal("Run succeeded resolving a nonexistent commit, want an error") + } + + if !strings.Contains(err.Error(), "fatal:") { + t.Errorf("error %q does not carry git's stderr", err.Error()) + } +} + +// TestRunEnv_setsExtraEnv verifies that extraEnv entries reach the child +// process, using GIT_INDEX_FILE the way Checkout does. +func TestRunEnv_setsExtraEnv(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + if _, err := gitrun.Run(t.Context(), "", "init", "--quiet", dir); err != nil { + t.Fatalf("git init: %v", err) + } + + if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + + index := filepath.Join(t.TempDir(), "custom-index") + + if _, err := gitrun.RunEnv(t.Context(), dir, []string{"GIT_INDEX_FILE=" + index}, "add", "--all"); err != nil { + t.Fatalf("add with custom index: %v", err) + } + + if _, err := os.Stat(index); err != nil { + t.Errorf("custom GIT_INDEX_FILE was not used: %v", err) + } + + // The default index must be untouched — everything landed in the custom one. + if _, err := os.Stat(filepath.Join(dir, ".git", "index")); err == nil { + t.Error("default .git/index was written; GIT_INDEX_FILE was not honored") + } +} + +// TestNetworkErr verifies the wrapped error carries the spec §4.5 network +// exit code and mentions the repo and the underlying reason. +func TestNetworkErr(t *testing.T) { + t.Parallel() + + err := gitrun.NetworkErr("github.com/org/repo", errors.New("connection refused")) + + if clierr.ExitCode(err) != int(clierr.CodeNetwork) { + t.Errorf("ExitCode = %d, want %d", clierr.ExitCode(err), clierr.CodeNetwork) + } + + if !strings.Contains(err.Error(), "github.com/org/repo") { + t.Errorf("error %q does not mention the repo", err.Error()) + } +} + +// TestReachable checks a local fixture repo answers and a nonexistent path +// does not. +func TestReachable(t *testing.T) { + t.Parallel() + + r := gittest.New(t) + + if !gitrun.Reachable(t.Context(), r.URL()) { + t.Error("Reachable = false for a live local repo, want true") + } + + missing := filepath.Join(t.TempDir(), "does-not-exist.git") + + if gitrun.Reachable(t.Context(), missing) { + t.Error("Reachable = true for a nonexistent repo, want false") + } +} + +// TestFetchSHA fetches a known commit at depth 1 into a fresh repository. +func TestFetchSHA(t *testing.T) { + t.Parallel() + + r := gittest.New(t) + r.WriteFile("a.txt", "x\n") + sha := r.Commit("first") + + dir := t.TempDir() + if _, err := gitrun.Run(t.Context(), "", "init", "--quiet", dir); err != nil { + t.Fatalf("git init: %v", err) + } + + if err := gitrun.FetchSHA(t.Context(), dir, r.URL(), sha); err != nil { + t.Fatalf("FetchSHA: %v", err) + } + + if _, err := gitrun.Run(t.Context(), dir, "cat-file", "-e", sha); err != nil { + t.Errorf("fetched commit not present: %v", err) + } +} + +// TestFetchAll fetches every branch and tag from a fixture remote, landing +// them under refs/remotes/origin and refs/tags respectively. +func TestFetchAll(t *testing.T) { + t.Parallel() + + r := gittest.New(t) + r.WriteFile("a.txt", "x\n") + r.Commit("first") + r.Tag("v1.0.0") + r.Branch("feature") + + dir := t.TempDir() + if _, err := gitrun.Run(t.Context(), "", "init", "--quiet", dir); err != nil { + t.Fatalf("git init: %v", err) + } + + if err := gitrun.FetchAll(t.Context(), dir, r.URL()); err != nil { + t.Fatalf("FetchAll: %v", err) + } + + if _, err := gitrun.Run( + t.Context(), + dir, + "rev-parse", + "--verify", + "--quiet", + "refs/remotes/origin/feature", + ); err != nil { + t.Error("FetchAll did not fetch the feature branch") + } + + if _, err := gitrun.Run(t.Context(), dir, "rev-parse", "--verify", "--quiet", "refs/tags/v1.0.0"); err != nil { + t.Error("FetchAll did not fetch the v1.0.0 tag") + } +} + +// TestFetchAll_networkError verifies an unreachable remote is classified as +// the spec §4.5 exit-3 network error, not a generic failure. +func TestFetchAll_networkError(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + if _, err := gitrun.Run(t.Context(), "", "init", "--quiet", dir); err != nil { + t.Fatalf("git init: %v", err) + } + + missing := filepath.Join(t.TempDir(), "does-not-exist.git") + + err := gitrun.FetchAll(t.Context(), dir, missing) + if err == nil { + t.Fatal("FetchAll succeeded against a nonexistent remote, want an error") + } + + if clierr.ExitCode(err) != int(clierr.CodeNetwork) { + t.Errorf("ExitCode = %d, want %d (network)", clierr.ExitCode(err), clierr.CodeNetwork) + } +} + +// TestCommitTime verifies the committer timestamp is parsed as UTC. +func TestCommitTime(t *testing.T) { + t.Parallel() + + r := gittest.New(t) + r.WriteFile("a.txt", "x\n") + sha := r.Commit("first") + + dir := t.TempDir() + if _, err := gitrun.Run(t.Context(), "", "init", "--quiet", dir); err != nil { + t.Fatalf("git init: %v", err) + } + + if err := gitrun.FetchSHA(t.Context(), dir, r.URL(), sha); err != nil { + t.Fatalf("FetchSHA: %v", err) + } + + ct, err := gitrun.CommitTime(t.Context(), dir, sha) + if err != nil { + t.Fatal(err) + } + + if ct.IsZero() { + t.Error("CommitTime returned zero time") + } + + if ct.Location() != time.UTC { + t.Errorf("CommitTime location = %v, want UTC", ct.Location()) + } +} + +// TestCommitTime_rejectsNonCommitObject verifies the "^{commit}" peel rejects +// a SHA that resolves to a non-commit object (here, a blob). +func TestCommitTime_rejectsNonCommitObject(t *testing.T) { + t.Parallel() + + r := gittest.New(t) + r.WriteFile("a.txt", "x\n") + r.Commit("first") + blobSHA := strings.TrimSpace(r.Git("rev-parse", "HEAD:a.txt")) + + dir := t.TempDir() + if _, err := gitrun.Run(t.Context(), "", "init", "--quiet", dir); err != nil { + t.Fatalf("git init: %v", err) + } + + if err := gitrun.FetchAll(t.Context(), dir, r.URL()); err != nil { + t.Fatalf("FetchAll: %v", err) + } + + if _, err := gitrun.CommitTime(t.Context(), dir, blobSHA); err == nil { + t.Fatal("CommitTime succeeded on a blob SHA, want an error") + } +} + +// TestRemoveAll verifies a normal tree is removed, and that removal still +// succeeds when a directory inside the tree starts out read-only — the +// permission-fixing fallback os.RemoveAll alone cannot handle. +func TestRemoveAll(t *testing.T) { + t.Parallel() + + t.Run("plain tree", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + + if err := gitrun.RemoveAll(dir); err != nil { + t.Fatalf("RemoveAll: %v", err) + } + + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Errorf("dir still exists after RemoveAll: %v", err) + } + }) + + t.Run("missing path", func(t *testing.T) { + t.Parallel() + + if err := gitrun.RemoveAll(filepath.Join(t.TempDir(), "nope")); err != nil { + t.Errorf("RemoveAll on a missing path: %v", err) + } + }) + + t.Run("read-only subdirectory", func(t *testing.T) { + t.Parallel() + + root := t.TempDir() + sub := filepath.Join(root, "sub") + + if err := os.Mkdir(sub, 0o755); err != nil { //nolint:gosec // test needs a traversable dir before chmod below + t.Fatal(err) + } + + if err := os.WriteFile(filepath.Join(sub, "a.txt"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + + // Strip write permission on sub so the first RemoveAll pass cannot + // unlink a.txt or remove sub itself. + if err := os.Chmod(sub, 0o500); err != nil { //nolint:gosec // read-only dir is the scenario under test + t.Fatal(err) + } + + if err := gitrun.RemoveAll(root); err != nil { + t.Fatalf("RemoveAll on a tree with a read-only directory: %v", err) + } + + if _, err := os.Stat(root); !os.IsNotExist(err) { + t.Errorf("root still exists after RemoveAll: %v", err) + } + }) +} diff --git a/internal/projlock/projlock_test.go b/internal/projlock/projlock_test.go index ef97f78..5b4b272 100644 --- a/internal/projlock/projlock_test.go +++ b/internal/projlock/projlock_test.go @@ -3,9 +3,12 @@ package projlock_test import ( + "bytes" "context" "io" + "strings" "testing" + "time" "github.com/min0625/graft/internal/cachedir" "github.com/min0625/graft/internal/projlock" @@ -78,3 +81,63 @@ func TestAcquire_canceledContext(t *testing.T) { release() } + +// TestAcquire_blocksUntilContextCanceled verifies that a second Acquire on an +// already-held lock blocks and returns ctx's error once the context is +// canceled, instead of failing immediately or hanging forever. +func TestAcquire_blocksUntilContextCanceled(t *testing.T) { + t.Setenv(cachedir.EnvOverride, t.TempDir()) + + root := t.TempDir() + + release1, err := projlock.Acquire(t.Context(), root, io.Discard) + if err != nil { + t.Fatal(err) + } + defer release1() + + ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond) + defer cancel() + + start := time.Now() + + _, err = projlock.Acquire(ctx, root, io.Discard) + if err == nil { + t.Fatal("second Acquire on a held lock succeeded, want the context deadline error") + } + + if elapsed := time.Since(start); elapsed < 200*time.Millisecond { + t.Errorf("Acquire returned after %v, want it to have blocked until the deadline", elapsed) + } +} + +// TestAcquire_printsWaitHintAfterOneSecond verifies the cargo/uv-style hint is +// printed once contention has lasted over a second, and that the waiting +// caller still succeeds once the lock is released. +func TestAcquire_printsWaitHintAfterOneSecond(t *testing.T) { + t.Setenv(cachedir.EnvOverride, t.TempDir()) + + root := t.TempDir() + + release1, err := projlock.Acquire(t.Context(), root, io.Discard) + if err != nil { + t.Fatal(err) + } + + go func() { + time.Sleep(1200 * time.Millisecond) + release1() + }() + + var buf bytes.Buffer + + release2, err := projlock.Acquire(t.Context(), root, &buf) + if err != nil { + t.Fatalf("Acquire after contention: %v", err) + } + defer release2() + + if !strings.Contains(buf.String(), "waiting for another graft process") { + t.Errorf("warn output = %q, want it to mention waiting for another process", buf.String()) + } +} diff --git a/internal/repocache/repocache.go b/internal/repocache/repocache.go index 12090a1..c28d592 100644 --- a/internal/repocache/repocache.go +++ b/internal/repocache/repocache.go @@ -90,7 +90,7 @@ func ensureBare(ctx context.Context, bare, repo string) error { return fmt.Errorf("git init --bare: %w", err) } - if _, err := bareGit(ctx, bare, "remote", "add", "origin", gitrun.RemoteURL(repo)); err != nil { + if _, err := bareGit(ctx, bare, "remote", "add", "--", "origin", gitrun.RemoteURL(repo)); err != nil { return fmt.Errorf("configure origin remote: %w", err) } @@ -155,7 +155,7 @@ func fetchRef(ctx context.Context, bare, ref string, filtered bool) error { args = append(args, "--filter=blob:none") } - args = append(args, "origin", ref) + args = append(args, "--", "origin", ref) _, err := bareGit(ctx, bare, args...) @@ -191,7 +191,7 @@ func fetchAllRefs(ctx context.Context, bare string, filtered bool) error { args = append(args, "--filter=blob:none") } - args = append(args, "origin", + args = append(args, "--", "origin", "+refs/heads/*:refs/remotes/origin/*", "+refs/tags/*:refs/tags/*") _, err := bareGit(ctx, bare, args...) diff --git a/internal/repocache/repocache_internal_test.go b/internal/repocache/repocache_internal_test.go new file mode 100644 index 0000000..43c1d4b --- /dev/null +++ b/internal/repocache/repocache_internal_test.go @@ -0,0 +1,175 @@ +// Copyright 2026 The Graft Authors + +package repocache + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/min0625/graft/internal/clierr" + "github.com/min0625/graft/internal/gittest" +) + +// TestFetchRef_rejectsOptionLikeRef guards fetchRef against option +// injection: the middle fallback step of fetchCommit (spec §5.5) passes the +// manifest/lockfile "version" string to fetchRef verbatim, and that string +// is not validated against git's flag syntax. A ref shaped like a git option +// (e.g. "--upload-pack=...") must be rejected as a literal, unmatched ref — +// never parsed by git as a flag. +func TestFetchRef_rejectsOptionLikeRef(t *testing.T) { + t.Parallel() + + cache := t.TempDir() + r := gittest.New(t) + bare := BarePath(cache, r.URL()) + + if err := ensureBare(t.Context(), bare, r.URL()); err != nil { + t.Fatalf("ensureBare: %v", err) + } + + canary := filepath.Join(t.TempDir(), "canary") + + if err := fetchRef(t.Context(), bare, "--upload-pack=touch "+canary, false); err == nil { + t.Fatal("fetchRef succeeded on an option-like ref, want an error") + } + + if _, err := os.Stat(canary); err == nil { + t.Fatal("ref was parsed as a git option instead of a literal ref") + } +} + +// TestIsTag checks that an empty version and a pseudo-version are excluded, +// while any other non-empty string counts as a tag (spec §5.5's middle +// fallback step only applies to real tags). +func TestIsTag(t *testing.T) { + t.Parallel() + + tests := []struct { + version string + want bool + }{ + {"", false}, + {"v1.0.0", true}, + {"v0.0.0-20240101000000-abcdef012345", false}, // pseudo-version + {"main", true}, + } + + for _, tt := range tests { + if got := isTag(tt.version); got != tt.want { + t.Errorf("isTag(%q) = %v, want %v", tt.version, got, tt.want) + } + } +} + +// TestPseudoVersion checks the v0.0.0-<14-digit timestamp>-<12-hex-sha> shape +// is recognized, and that real tags and malformed near-misses are not. +func TestPseudoVersion(t *testing.T) { + t.Parallel() + + tests := []struct { + version string + want bool + }{ + {"v0.0.0-20240101000000-abcdef012345", true}, + {"v1.0.0", false}, + {"", false}, + {"v0.0.0-2024-abcdef012345", false}, // timestamp too short + {"v0.0.0-20240101000000-abcdef01234", false}, // sha too short + {"v0.0.0-20240101000000", false}, // missing sha segment + } + + for _, tt := range tests { + if got := pseudoVersion(tt.version); got != tt.want { + t.Errorf("pseudoVersion(%q) = %v, want %v", tt.version, got, tt.want) + } + } +} + +// TestCommitGoneErr checks the exit-1 error (spec §6) names the repo and a +// truncated commit SHA. +func TestCommitGoneErr(t *testing.T) { + t.Parallel() + + err := commitGoneErr("github.com/org/repo", "abcdef0123456789abcdef0123456789abcdef01") + + if clierr.ExitCode(err) != int(clierr.CodeGeneral) { + t.Errorf("ExitCode = %d, want %d", clierr.ExitCode(err), clierr.CodeGeneral) + } + + if !strings.Contains(err.Error(), "github.com/org/repo") { + t.Errorf("error %q does not mention the repo", err.Error()) + } + + if !strings.Contains(err.Error(), "abcdef012345") { + t.Errorf("error %q does not mention the truncated commit", err.Error()) + } +} + +// TestFetchAllRefs verifies the always-correct fallback (spec §5.5 step 3) +// fetches every branch and tag from origin into the bare repo. +func TestFetchAllRefs(t *testing.T) { + t.Parallel() + + cache := t.TempDir() + r := gittest.New(t) + r.WriteFile("a.txt", "x\n") + r.Commit("first") + r.Tag("v1.0.0") + r.Branch("feature") + + bare := BarePath(cache, r.URL()) + if err := ensureBare(t.Context(), bare, r.URL()); err != nil { + t.Fatalf("ensureBare: %v", err) + } + + if err := fetchAllRefs(t.Context(), bare, false); err != nil { + t.Fatalf("fetchAllRefs: %v", err) + } + + if _, err := bareGit( + t.Context(), + bare, + "rev-parse", + "--verify", + "--quiet", + "refs/remotes/origin/feature", + ); err != nil { + t.Error("fetchAllRefs did not fetch the feature branch") + } + + if _, err := bareGit(t.Context(), bare, "rev-parse", "--verify", "--quiet", "refs/tags/v1.0.0"); err != nil { + t.Error("fetchAllRefs did not fetch the v1.0.0 tag") + } +} + +// TestFetchAll_unreachable verifies that fetchAll classifies a failure +// against an unreachable remote as the spec §4.5 exit-3 network error. +func TestFetchAll_unreachable(t *testing.T) { + t.Parallel() + + cache := t.TempDir() + r := gittest.New(t) + r.WriteFile("a.txt", "x\n") + r.Commit("first") + + bare := BarePath(cache, r.URL()) + if err := ensureBare(t.Context(), bare, r.URL()); err != nil { + t.Fatalf("ensureBare: %v", err) + } + + // Remove the remote so origin is no longer reachable. + if err := os.RemoveAll(r.Dir); err != nil { + t.Fatal(err) + } + + err := fetchAll(t.Context(), bare, r.URL(), false) + if err == nil { + t.Fatal("fetchAll succeeded against a removed remote, want an error") + } + + if clierr.ExitCode(err) != int(clierr.CodeNetwork) { + t.Errorf("ExitCode = %d, want %d (network)", clierr.ExitCode(err), clierr.CodeNetwork) + } +} diff --git a/internal/repocache/repocache_test.go b/internal/repocache/repocache_test.go index 5e298be..23f5dc0 100644 --- a/internal/repocache/repocache_test.go +++ b/internal/repocache/repocache_test.go @@ -99,6 +99,84 @@ func TestEnsureCommit_tagFallback(t *testing.T) { } } +// TestEnsureCommit_allRefsFallback covers the final fallback step: a non-tip +// commit with no tag (isTag false) skips the middle step entirely and must +// still be found by fetching every ref (spec §5.5 step 3). +func TestEnsureCommit_allRefsFallback(t *testing.T) { + t.Parallel() + + cache := t.TempDir() + r := gittest.New(t) + r.WriteFile("a.txt", "old\n") + old := r.Commit("first") + r.WriteFile("a.txt", "new\n") + r.Commit("second") + + bare, err := repocache.EnsureCommit(t.Context(), cache, r.URL(), old, "", "") + if err != nil { + t.Fatalf("EnsureCommit via all-refs fallback: %v", err) + } + + dst := filepath.Join(t.TempDir(), "tree") + if err := repocache.Checkout(t.Context(), bare, old, "", dst); err != nil { + t.Fatalf("Checkout: %v", err) + } + + data, err := os.ReadFile(filepath.Join(dst, "a.txt")) //nolint:gosec // Test-controlled path. + if err != nil { + t.Fatal(err) + } + + if string(data) != "old\n" { + t.Errorf("a.txt = %q, want the non-tip commit's content", data) + } +} + +// TestEnsureCommit_rejectsOptionLikeVersion is the end-to-end counterpart of +// repocache_internal_test.go's TestFetchRef_rejectsOptionLikeRef: it drives +// the attack through the real EnsureCommit entry point (the manifest/lockfile +// "version" is copied verbatim, per lockfile.Entry's doc comment, so nothing +// upstream constrains its shape). A non-tip commit forces the SHA fetch to +// fail and isTag's middle step to run fetchRef with the option-like version +// itself, proving the "--" guard added to fetchRef holds on this path, not +// just when fetchRef is called directly. +func TestEnsureCommit_rejectsOptionLikeVersion(t *testing.T) { + t.Parallel() + + cache := t.TempDir() + r := gittest.New(t) + r.WriteFile("a.txt", "old\n") + old := r.Commit("first") + r.WriteFile("a.txt", "new\n") + r.Commit("second") + + canary := filepath.Join(t.TempDir(), "canary") + version := "--upload-pack=touch " + canary + + bare, err := repocache.EnsureCommit(t.Context(), cache, r.URL(), old, version, "") + if err != nil { + t.Fatalf("EnsureCommit with an option-like version: %v", err) + } + + if _, err := os.Stat(canary); err == nil { + t.Fatal("version was parsed as a git option instead of a literal ref") + } + + dst := filepath.Join(t.TempDir(), "tree") + if err := repocache.Checkout(t.Context(), bare, old, "", dst); err != nil { + t.Fatalf("Checkout: %v", err) + } + + data, err := os.ReadFile(filepath.Join(dst, "a.txt")) //nolint:gosec // Test-controlled path. + if err != nil { + t.Fatal(err) + } + + if string(data) != "old\n" { + t.Errorf("a.txt = %q, want the non-tip commit's content", data) + } +} + // TestEnsureCommit_reusesBareAcrossCommits checks that a second commit from // the same repo lands in the same bare repository (one entry per repo). func TestEnsureCommit_reusesBareAcrossCommits(t *testing.T) { diff --git a/internal/vendordir/linkmatch_internal_test.go b/internal/vendordir/linkmatch_internal_test.go new file mode 100644 index 0000000..703aa7f --- /dev/null +++ b/internal/vendordir/linkmatch_internal_test.go @@ -0,0 +1,31 @@ +// Copyright 2026 The Graft Authors + +package vendordir + +import ( + "path/filepath" + "testing" +) + +// TestCleanLinkTarget checks that a Windows junction's extended-length +// prefix is stripped before comparison, and that an ordinary path is only +// Clean-normalized. Expected values run through filepath.FromSlash since +// filepath.Clean rewrites separators to the host OS's convention. +func TestCleanLinkTarget(t *testing.T) { + t.Parallel() + + tests := []struct { + in, want string + }{ + {`\\?\C:\store\ab\cd1234`, `C:\store\ab\cd1234`}, + {"/store/ab/cd1234", filepath.FromSlash("/store/ab/cd1234")}, + {"/store/ab//cd1234", filepath.FromSlash("/store/ab/cd1234")}, + {"/store/ab/./cd1234", filepath.FromSlash("/store/ab/cd1234")}, + } + + for _, tt := range tests { + if got := cleanLinkTarget(tt.in); got != tt.want { + t.Errorf("cleanLinkTarget(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} diff --git a/internal/vendordir/vendordir_test.go b/internal/vendordir/vendordir_test.go index a295485..41fba05 100644 --- a/internal/vendordir/vendordir_test.go +++ b/internal/vendordir/vendordir_test.go @@ -453,3 +453,44 @@ func TestReconcile_defaultFetchConcurrency(t *testing.T) { t.Errorf("max concurrent fetches = %d, want >= %d (defaultFetchJobs)", maxSeen, wantConcurrent) } } + +// TestLinkMatches verifies the cheap link-mode validation of spec §5.6: true +// only when dest is a symlink pointing at storePath, false for a mismatched +// target, a plain file, or a missing dest. +func TestLinkMatches(t *testing.T) { + t.Parallel() + + root := t.TempDir() + storePath := filepath.Join(root, "store", "ab", "cd1234") + dest := filepath.Join(root, "vendor", "dep") + + if err := os.MkdirAll(filepath.Dir(dest), 0o750); err != nil { + t.Fatal(err) + } + + // ponytail: skip on platforms where symlinks need elevated privileges (e.g. Windows without developer mode) + if err := os.Symlink(storePath, dest); err != nil { + t.Skip("symlinks not supported:", err) + } + + if !vendordir.LinkMatches(dest, storePath) { + t.Error("LinkMatches = false for a symlink pointing at storePath, want true") + } + + if vendordir.LinkMatches(dest, filepath.Join(root, "store", "ab", "other")) { + t.Error("LinkMatches = true for a mismatched store path, want false") + } + + plainFile := filepath.Join(root, "vendor", "plain") + if err := os.WriteFile(plainFile, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + + if vendordir.LinkMatches(plainFile, storePath) { + t.Error("LinkMatches = true for a plain file, want false") + } + + if vendordir.LinkMatches(filepath.Join(root, "vendor", "missing"), storePath) { + t.Error("LinkMatches = true for a missing dest, want false") + } +}