fix(sandbox): do not synthesize a .git inside an ancestor repository

A missing .git meant "this directory may become a repository", so the Windows
plan materialized .git/config and .git/hooks to get the deny ACE in place
before git first ran. That is right for a standalone directory. It is wrong
when the workspace is a subdirectory of an existing repository: the created
directory is a control directory competing with the ancestor's for git's
discovery walk, synthesized inside a repository Zero does not own.

The carveouts are now skipped when an ancestor carries git metadata, which is
git's own discovery rule. That is the same argument the linked-worktree branch
already makes: the metadata governing this workspace lives outside the write
root, the sandboxed principal has no inherited access to it, so it needs no
carveout here. The non-materialized deny-delete on <root>/.git is emitted
separately and still guards the name if a repository is ever created.

Ancestor detection accepts .git of either shape, since a linked worktree or
submodule ancestor carries it as a pointer file and still owns the directory.

Reported by jatmn. Worth recording that the stated consequence did not
reproduce here: a .git holding only config and hooks is not a valid repository,
so this git version walks past it and status, log and rev-parse still resolve
to the ancestor. The fix stands on the narrower ground that creating
repository metadata Zero does not own is wrong regardless, and anything later
adding a HEAD there would break discovery for real.
This commit is contained in:
Vasanthdev2004 committed 2026-09-11 10:17:31 +05:30
1 parent 7920d3be3d
commit 26958ac6dd
2 files changed
+159

No files matched your search

@@ -0,0 +1,118 @@
package sandbox
import (
"os"
"path/filepath"
"testing"
)
// ZERO MUST NOT SYNTHESIZE A CONTROL DIRECTORY IN SOMEBODY ELSE'S REPOSITORY.
//
// A missing .git used to mean "this directory may become a repository", so the
// Windows plan materialized .git/config and .git/hooks to get the deny ACE in
// place before git first ran. That is right for a standalone directory. It is
// wrong when the workspace is a subdirectory of an existing repository: the
// created .git competes with the ancestor's for git's discovery walk, inside a
// repository Zero does not own.
//
// The metadata for such a workspace belongs to the ancestor, sits outside this
// write root, and the sandboxed principal has no inherited access to it, which
// is the same reason the linked-worktree branch denies only the pointer file.
func TestCarveoutsAreNotSynthesizedInsideAnAncestorRepository(t *testing.T) {
parent := t.TempDir()
if err := os.MkdirAll(filepath.Join(parent, ".git"), 0o700); err != nil {
t.Fatal(err)
}
workspace := filepath.Join(parent, "sub", "project")
if err := os.MkdirAll(workspace, 0o700); err != nil {
t.Fatal(err)
}
// SETUP: the workspace itself has no .git, or this is a different case.
if _, err := os.Lstat(filepath.Join(workspace, ".git")); err == nil {
t.Fatal("SETUP INVALID: the workspace already carries .git")
}
specs := gitMetadataWriteCarveoutSpecs(workspace)
for _, spec := range specs {
if spec.Path == filepath.Join(workspace, ".git", "config") || spec.Path == filepath.Join(workspace, ".git", "hooks") {
t.Fatalf("a nested workspace asks to materialize %s, which creates a control directory competing with the ancestor repository at %s", spec.Path, parent)
}
}
}
// And a standalone directory keeps the materialized carveouts, or the guard
// above would be satisfied by returning nothing everywhere and the protection
// would be gone for the case it was written for.
func TestCarveoutsStillCoverAStandaloneDirectory(t *testing.T) {
workspace := t.TempDir()
if gitMetadataGovernedByAncestor(workspace) {
t.Skip("this temp directory sits inside a repository, so it is not the standalone case")
}
specs := gitMetadataWriteCarveoutSpecs(workspace)
wantConfig := filepath.Join(workspace, ".git", "config")
wantHooks := filepath.Join(workspace, ".git", "hooks")
var sawConfig, sawHooks bool
for _, spec := range specs {
switch spec.Path {
case wantConfig:
sawConfig = true
if !spec.IsFile {
t.Errorf("%s is planned as a directory; creating .git/config as a directory makes git init fail", spec.Path)
}
case wantHooks:
sawHooks = true
}
}
if !sawConfig || !sawHooks {
t.Fatalf("a standalone directory lost its carveouts: config=%t hooks=%t specs=%+v", sawConfig, sawHooks, specs)
}
}
// A linked worktree is unchanged: the pointer file is denied and nothing beneath
// it is named, which is what keeps elevated setup from descending through a
// regular file.
func TestCarveoutsStillDenyTheLinkedWorktreePointer(t *testing.T) {
workspace := t.TempDir()
pointer := filepath.Join(workspace, ".git")
if err := os.WriteFile(pointer, []byte("gitdir: ../real/.git/worktrees/w\n"), 0o600); err != nil {
t.Fatal(err)
}
specs := gitMetadataWriteCarveoutSpecs(workspace)
if len(specs) != 1 || specs[0].Path != pointer || !specs[0].IsFile {
t.Fatalf("linked worktree carveouts = %+v, want exactly the file-shaped pointer %s", specs, pointer)
}
}
// The ancestor test itself has to distinguish shapes, because a linked worktree
// or submodule ancestor carries .git as a FILE and still owns this directory.
func TestAncestorDetectionAcceptsBothGitShapes(t *testing.T) {
for _, shape := range []struct {
name string
make func(t *testing.T, parent string)
}{
{name: "directory", make: func(t *testing.T, parent string) {
if err := os.MkdirAll(filepath.Join(parent, ".git"), 0o700); err != nil {
t.Fatal(err)
}
}},
{name: "pointer file", make: func(t *testing.T, parent string) {
if err := os.WriteFile(filepath.Join(parent, ".git"), []byte("gitdir: elsewhere\n"), 0o600); err != nil {
t.Fatal(err)
}
}},
} {
t.Run(shape.name, func(t *testing.T) {
parent := t.TempDir()
shape.make(t, parent)
workspace := filepath.Join(parent, "nested", "deep")
if err := os.MkdirAll(workspace, 0o700); err != nil {
t.Fatal(err)
}
if !gitMetadataGovernedByAncestor(workspace) {
t.Fatalf("an ancestor carrying .git as a %s was not recognised as governing %s", shape.name, workspace)
}
})
}
}
+41
View File
@@ -157,12 +157,53 @@ func gitMetadataWriteCarveoutSpecs(root string) []gitMetadataCarveout {
if info, err := os.Lstat(gitPath); err == nil && !info.IsDir() {
return []gitMetadataCarveout{{Path: gitPath, IsFile: true}}
}
// A MISSING .git IS NOT THE SAME AS "THIS WILL BECOME A REPOSITORY".
//
// The directory-shaped carveouts below are materialized by the Windows plan so
// the deny ACE is in place before git first runs. That is right for a
// standalone directory somebody may later git init. It is wrong when this
// workspace already sits inside a repository: creating .git/config and
// .git/hooks synthesizes a control directory that competes with the ancestor's
// for git's discovery walk, in a repository Zero does not own.
//
// The same argument the linked-worktree branch above makes applies here. This
// workspace's git metadata belongs to the ancestor, it lives outside this write
// root, and the sandboxed principal has no inherited access to it, so it needs
// no carveout here. The non-materialized deny-delete on <root>/.git is emitted
// separately and still guards the name if a repository is ever created here.
if gitMetadataGovernedByAncestor(root) {
return nil
}
return []gitMetadataCarveout{
{Path: filepath.Join(root, ".git", "hooks")},
{Path: filepath.Join(root, ".git", "config"), IsFile: true},
}
}
// gitMetadataGovernedByAncestor reports whether an ancestor of root carries git
// metadata, which is git's own discovery rule: the nearest ancestor with a .git
// entry owns this directory.
//
// Lstat rather than a resolved walk, because a .git of either shape answers the
// question: a directory is an ordinary repository root and a file is a linked
// worktree or submodule pointer. Both mean the metadata is not ours to create.
func gitMetadataGovernedByAncestor(root string) bool {
current := filepath.Clean(strings.TrimSpace(root))
if current == "" || current == "." {
return false
}
for {
parent := filepath.Dir(current)
if parent == current {
return false
}
if _, err := os.Lstat(filepath.Join(parent, ".git")); err == nil {
return true
}
current = parent
}
}
// gitMetadataCarveoutSuffixBase is a sentinel root used only to recover the
// trailing segments of the carveout specs. It is never touched on disk.
const gitMetadataCarveoutSuffixBase = string(filepath.Separator) + "zero-carveout-base"