Harden bundle promotion against symlink replacement

This commit is contained in:
2026-08-10 02:45:51 +00:00
parent 701b6726d7
commit b99bd38eb4
3 changed files with 535 additions and 284 deletions

View File

@@ -159,6 +159,151 @@ func TestPromoteDirectoryRejectsSymlinksWithoutFollowingThem(t *testing.T) {
assertFileBytes(t, filepath.Join(externalDirectory, "secret.txt"), []byte("secret"))
}
func TestPromoteDirectoryRejectsSymlinkSourceRoot(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "target")
src := filepath.Join(root, "source")
dst := filepath.Join(root, "destination")
mustWriteFile(t, filepath.Join(target, "value.txt"), []byte("outside"), 0o644)
if err := os.Symlink(target, src); err != nil {
t.Skipf("Symlink() unavailable: %v", err)
}
if err := PromoteDirectory(src, dst); err == nil {
t.Fatal("PromoteDirectory() error = nil, want source-root symlink rejection")
}
assertFileBytes(t, filepath.Join(target, "value.txt"), []byte("outside"))
assertPathMissing(t, dst)
assertNoMatchingTempDirectories(t, root, ".destination.tmp-")
}
func TestPromoteDirectoryRejectsSourceRootReplacementBeforeOpen(t *testing.T) {
root := t.TempDir()
src := filepath.Join(root, "source")
preserved := filepath.Join(root, "source-preserved")
replacement := filepath.Join(root, "replacement")
dst := filepath.Join(root, "destination")
mustWriteFile(t, filepath.Join(src, "value.txt"), []byte("original"), 0o644)
mustWriteFile(t, filepath.Join(replacement, "value.txt"), []byte("replacement"), 0o644)
err := promoteDirectoryWithHooks(src, dst, renameDirectoryNoReplace, sourceTraversalHooks{
afterRootInspect: func() {
if err := os.Rename(src, preserved); err != nil {
t.Fatalf("Rename(original source) error = %v", err)
}
if err := os.Rename(replacement, src); err != nil {
t.Fatalf("Rename(replacement source) error = %v", err)
}
},
})
if err == nil {
t.Fatal("promoteDirectoryWithHooks() error = nil, want source identity failure")
}
assertFileBytes(t, filepath.Join(preserved, "value.txt"), []byte("original"))
assertFileBytes(t, filepath.Join(src, "value.txt"), []byte("replacement"))
assertPathMissing(t, dst)
assertNoMatchingTempDirectories(t, root, ".destination.tmp-")
}
func TestPromoteDirectoryRejectsInspectedDirectorySymlinkReplacement(t *testing.T) {
root := t.TempDir()
src := filepath.Join(root, "source")
child := filepath.Join(src, "child")
preserved := filepath.Join(src, "child-preserved")
outside := filepath.Join(root, "outside")
dst := filepath.Join(root, "destination")
mustWriteFile(t, filepath.Join(child, "value.txt"), []byte("original"), 0o644)
mustWriteFile(t, filepath.Join(outside, "sentinel.txt"), []byte("outside"), 0o644)
replaced := false
err := promoteDirectoryWithHooks(src, dst, renameDirectoryNoReplace, sourceTraversalHooks{
afterEntryInspect: func(path string) {
if replaced || path != child {
return
}
replaced = true
if err := os.Rename(child, preserved); err != nil {
t.Fatalf("Rename(inspected child) error = %v", err)
}
if err := os.Symlink(filepath.Join("..", "outside"), child); err != nil {
t.Skipf("Symlink() unavailable: %v", err)
}
if err := os.Mkdir(dst, 0o755); err != nil {
t.Fatalf("Mkdir(concurrent destination) error = %v", err)
}
mustWriteFile(t, filepath.Join(dst, "value.txt"), []byte("concurrent"), 0o644)
},
})
if err == nil {
t.Fatal("promoteDirectoryWithHooks() error = nil, want symlink replacement failure")
}
assertFileBytes(t, filepath.Join(preserved, "value.txt"), []byte("original"))
assertFileBytes(t, filepath.Join(outside, "sentinel.txt"), []byte("outside"))
assertFileBytes(t, filepath.Join(dst, "value.txt"), []byte("concurrent"))
assertPathMissing(t, filepath.Join(dst, "sentinel.txt"))
assertNoMatchingTempDirectories(t, root, ".destination.tmp-")
}
func TestPromoteDirectoryRejectsInspectedFileIdentityMismatch(t *testing.T) {
root := t.TempDir()
src := filepath.Join(root, "source")
file := filepath.Join(src, "value.txt")
preserved := filepath.Join(src, "value-preserved.txt")
dst := filepath.Join(root, "destination")
mustWriteFile(t, file, []byte("original"), 0o644)
replaced := false
err := promoteDirectoryWithHooks(src, dst, renameDirectoryNoReplace, sourceTraversalHooks{
afterEntryInspect: func(path string) {
if replaced || path != file {
return
}
replaced = true
if err := os.Rename(file, preserved); err != nil {
t.Fatalf("Rename(inspected file) error = %v", err)
}
mustWriteFile(t, file, []byte("replacement"), 0o644)
},
})
if err == nil {
t.Fatal("promoteDirectoryWithHooks() error = nil, want file identity failure")
}
assertFileBytes(t, preserved, []byte("original"))
assertFileBytes(t, file, []byte("replacement"))
assertPathMissing(t, dst)
assertNoMatchingTempDirectories(t, root, ".destination.tmp-")
}
func TestPromoteDirectoryRejectsInspectedDirectoryIdentityMismatch(t *testing.T) {
root := t.TempDir()
src := filepath.Join(root, "source")
child := filepath.Join(src, "child")
preserved := filepath.Join(src, "child-preserved")
dst := filepath.Join(root, "destination")
mustWriteFile(t, filepath.Join(child, "value.txt"), []byte("original"), 0o644)
replaced := false
err := promoteDirectoryWithHooks(src, dst, renameDirectoryNoReplace, sourceTraversalHooks{
afterEntryInspect: func(path string) {
if replaced || path != child {
return
}
replaced = true
if err := os.Rename(child, preserved); err != nil {
t.Fatalf("Rename(inspected directory) error = %v", err)
}
mustWriteFile(t, filepath.Join(child, "value.txt"), []byte("replacement"), 0o644)
},
})
if err == nil {
t.Fatal("promoteDirectoryWithHooks() error = nil, want directory identity failure")
}
assertFileBytes(t, filepath.Join(preserved, "value.txt"), []byte("original"))
assertFileBytes(t, filepath.Join(child, "value.txt"), []byte("replacement"))
assertPathMissing(t, dst)
assertNoMatchingTempDirectories(t, root, ".destination.tmp-")
}
func TestPromoteDirectoryDoesNotReplaceDestinationCreatedBeforeInstall(t *testing.T) {
root := t.TempDir()
src := filepath.Join(root, "source")
@@ -217,6 +362,13 @@ func assertFileBytes(t *testing.T, path string, want []byte) {
}
}
func assertPathMissing(t *testing.T, path string) {
t.Helper()
if _, err := os.Lstat(path); !os.IsNotExist(err) {
t.Fatalf("Lstat(%q) error = %v, want not exist", path, err)
}
}
func treeLayout(t *testing.T, root string) []string {
t.Helper()
var layout []string