diff --git a/.gds/bundle.lock.yaml b/.gds/bundle.lock.yaml index 37203d4..342eced 100644 --- a/.gds/bundle.lock.yaml +++ b/.gds/bundle.lock.yaml @@ -5,14 +5,14 @@ bundle: version: "0.9.6-dev" release_sequence: 0 channel: "development" - source_tree_digest: "sha256:6799d517ad06faba9cc1edc3830dd54d0a890f3573bc1f60a26232f5d1926524" - digest: "sha256:db7dae4282ef3d69f0375c9e54feada9dc11f219d64e591a76559883ea598f0e" + source_tree_digest: "sha256:b9d572eb73bb14ae8ede20dfc1e9317046dcb544270eff95153746f93f094820" + digest: "sha256:a45453995b649d082b39753c1d7eff869d2dcad5180532cb14f85435fae2a7fa" projection: - input_digest: "sha256:37d63f73feba8549ec22aa97b81b7cc0d53b701d73b36661625c0bb61da53998" - output_digest: "sha256:508542eaec283ca3e14bb78c495c0c52a45ebb85b24cce2e104f8655ad1b0bb1" + input_digest: "sha256:90af33f4abbb808f5567e074347f17822cc610029e11a835fb5a8918c376ec9b" + output_digest: "sha256:080add7de51702df727bb8f1b770dcd5fd8343000d8b249af94a7b342a0a240b" files: - path: ".gds/compiled-policy.json" digest: "sha256:211668dd85e9d4bd3f547a1ea0cc9b1a5f486c64bde02a328b53dc3ef665c9fd" - path: ".github/workflows/gds-ci.yml" - digest: "sha256:2e18aafadceafafab54dbf21fd0f68b50c9618aed05d8f2eb39e161ef999c287" + digest: "sha256:eef6659ecb3cd40914b73fde9856ad2bfa96f106ceac52f8c2fda0ef4fc00a79" diff --git a/.github/workflows/gds-ci.yml b/.github/workflows/gds-ci.yml index b892596..253e0b6 100644 --- a/.github/workflows/gds-ci.yml +++ b/.github/workflows/gds-ci.yml @@ -1,8 +1,8 @@ # GENERATED FILE - DO NOT EDIT DIRECTLY # generator: gds # bundle: 0.9.6-dev -# source-tree-digest: sha256:6799d517ad06faba9cc1edc3830dd54d0a890f3573bc1f60a26232f5d1926524 -# input-digest: sha256:37d63f73feba8549ec22aa97b81b7cc0d53b701d73b36661625c0bb61da53998 +# source-tree-digest: sha256:b9d572eb73bb14ae8ede20dfc1e9317046dcb544270eff95153746f93f094820 +# input-digest: sha256:90af33f4abbb808f5567e074347f17822cc610029e11a835fb5a8918c376ec9b # output-digest: sha256:b9bf3d0c64c0fb371596e7d090e82e62aebbfde91929115fc15fb28644e4fd38 # edit-source: # - .gds/repository.yaml diff --git a/core/discovery/local.go b/core/discovery/local.go index e6d4a68..0c585b2 100644 --- a/core/discovery/local.go +++ b/core/discovery/local.go @@ -306,9 +306,15 @@ func pathDepth(relative string) int { } func excludedDirectory(name string) bool { + // Hidden directories other than a repository's own .git boundary are tool + // state, caches, or generated fixtures (".tmp", ".idea", ".venv", ...) — + // never portfolio checkouts. Scanning them manufactures anchor findings + // for synthetic trees and burns time on ignored content. + if name != ".git" && strings.HasPrefix(name, ".") { + return true + } switch name { - case ".cache", ".idea", ".pytest_cache", ".ruff_cache", ".tox", ".venv", - "__pycache__", "node_modules", "target", "vendor": + case "__pycache__", "node_modules", "target", "vendor": return true default: return false diff --git a/core/discovery/local_test.go b/core/discovery/local_test.go index 312ae78..0092c4d 100644 --- a/core/discovery/local_test.go +++ b/core/discovery/local_test.go @@ -190,3 +190,33 @@ func TestDiscoverAcceptsIdentityPinnedBySuperprojectAndCheckedOutStandalone(t *t } } } + +func TestDiscoverSkipsHiddenDirectoryTrees(t *testing.T) { + t.Parallel() + // Boundaries are reported canonicalized: on macOS t.TempDir() lives under + // /var, which is a symlink to /private/var. + root, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + plain := filepath.Join(root, "plain") + fixture := filepath.Join(root, ".tmp", "native-linux", "fixture-repo") + for _, path := range []string{plain, fixture} { + if err := os.MkdirAll(path, 0o755); err != nil { + t.Fatal(err) + } + if output, err := exec.Command("git", "init", "-q", path).CombinedOutput(); err != nil { + t.Fatalf("git init %s: %v\n%s", path, err, output) + } + } + + result, err := newTestDiscovery(t).Discover( + context.Background(), root, Options{MaxDepth: 8, MaxRepositories: 10, Concurrency: 2}, + ) + if err != nil { + t.Fatalf("Discover() error = %v", err) + } + if len(result.Boundaries) != 1 || result.Boundaries[0].Path != plain { + t.Fatalf("boundaries = %#v, want only %s", result.Boundaries, plain) + } +} diff --git a/core/providers/git/handoff.go b/core/providers/git/handoff.go index e8d5c7c..5956b25 100644 --- a/core/providers/git/handoff.go +++ b/core/providers/git/handoff.go @@ -362,7 +362,10 @@ func (runner *MutationRunner) observeRemoteRef( ) (string, error) { result, err := runner.runWithEnvironment( ctx, root, map[int]struct{}{0: {}}, nil, - "-c", "protocol.allow=never", "-c", "protocol.file.allow=always", + "-c", "protocol.allow=never", + "-c", "protocol.file.allow=always", + "-c", "protocol.https.allow=always", + "-c", "protocol.ssh.allow=always", "ls-remote", "--refs", remoteURL, remoteRef, ) if err != nil { diff --git a/core/providers/git/quarantine.go b/core/providers/git/quarantine.go index b72ef5f..3cb25da 100644 --- a/core/providers/git/quarantine.go +++ b/core/providers/git/quarantine.go @@ -168,7 +168,11 @@ func (runner *MutationRunner) observeCheckoutForQuarantine( status.Worktrees[0].Locked || status.Worktrees[0].Prunable { return CheckoutQuarantineEvidence{}, errors.New("checkout is not clean, current, and exclusive") } - remoteURL, err := runner.validatedPushURL(ctx, physical, "origin") + // Quarantine only reads the remote (ls-remote below) to prove the checkout's + // commits are published; it never pushes. The fetch-side URL is therefore + // the right one to observe and network transports are allowed — the + // mutation-only gate (validatedPushURL) does not apply to a read. + _, remoteURL, err := runner.validatedRemoteURL(ctx, physical, "origin") if err != nil { return CheckoutQuarantineEvidence{}, err } diff --git a/core/providers/git/quarantine_test.go b/core/providers/git/quarantine_test.go index d1248d9..77295f7 100644 --- a/core/providers/git/quarantine_test.go +++ b/core/providers/git/quarantine_test.go @@ -3,6 +3,7 @@ package git import ( "context" "crypto/sha256" + "errors" "fmt" "os" "path/filepath" @@ -75,3 +76,45 @@ func TestQuarantineCheckoutPreservesDirtyRepository(t *testing.T) { t.Fatalf("dirty work changed: %q %v", content, err) } } + +func TestQuarantineRemoteValidationAdmitsNetworkTransportsForReadOnlyObservation(t *testing.T) { + client, _ := mutationRepository(t) + runFetchGit(t, client, "remote", "add", "origin", "git@github.com:example/repository.git") + runner, err := NewMutationRunner() + if err != nil { + t.Fatal(err) + } + _, url, err := runner.validatedRemoteURL(context.Background(), client, "origin") + if err != nil { + t.Fatalf("read-only observation must admit the fetch URL: %v", err) + } + if url != "git@github.com:example/repository.git" { + t.Fatalf("url=%q", url) + } + if _, err := runner.validatedPushURL(context.Background(), client, "origin"); !errors.Is(err, ErrNetworkMutationDisabled) { + t.Fatalf("push URL must remain mutation-gated: %v", err) + } +} + +func TestQuarantineCheckoutNetworkRemoteFailsAtObservationNotAtTheMutationGate(t *testing.T) { + fixture := fastForwardFixture(t) + runFetchGit(t, fixture.client, "remote", "set-url", "origin", "ssh://127.0.0.1:1/repository.git") + head := stringsTrim(runFetchGit(t, fixture.client, "rev-parse", "HEAD")) + workspaceRoot := filepath.Dir(fixture.client) + stateRoot := t.TempDir() + quarantine := filepath.Join(stateRoot, "quarantine", "checkouts", "repo_fixture", head) + runner, err := NewMutationRunner() + if err != nil { + t.Fatal(err) + } + _, err = runner.QuarantineCheckout( + context.Background(), workspaceRoot, fixture.client, stateRoot, quarantine, + head, "refs/heads/main", fmt.Sprintf("sha256:%x", sha256.Sum256([]byte("x"))), + ) + if err == nil { + t.Fatal("an unreachable remote must fail the observation") + } + if errors.Is(err, ErrNetworkMutationDisabled) { + t.Fatalf("read-only remote observation must not hit the mutation gate: %v", err) + } +}