Files
narratio/internal/fileops/directory_test.go

502 lines
18 KiB
Go

//go:build linux || darwin || windows
package fileops
import (
"bytes"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"
)
func TestPromoteDirectoryCopiesNestedRegularTree(t *testing.T) {
root := t.TempDir()
src := filepath.Join(root, "source")
dst := filepath.Join(root, "promoted")
mustWriteFile(t, filepath.Join(src, "z-last.txt"), []byte("last"), 0o777)
mustWriteFile(t, filepath.Join(src, "nested", "binary.dat"), []byte{0, 1, 2, 0xff}, 0o600)
mustWriteFile(t, filepath.Join(src, "a-first.txt"), []byte("first"), 0o400)
if err := os.Mkdir(filepath.Join(src, "empty"), 0o700); err != nil {
t.Fatalf("Mkdir(empty) error = %v", err)
}
if err := PromoteDirectory(src, dst); err != nil {
t.Fatalf("PromoteDirectory() error = %v", err)
}
wantLayout := []string{".", "a-first.txt", "empty", "nested", "nested/binary.dat", "z-last.txt"}
if got := treeLayout(t, dst); !reflect.DeepEqual(got, wantLayout) {
t.Fatalf("promoted layout = %#v, want %#v", got, wantLayout)
}
assertFileBytes(t, filepath.Join(dst, "a-first.txt"), []byte("first"))
assertFileBytes(t, filepath.Join(dst, "nested", "binary.dat"), []byte{0, 1, 2, 0xff})
assertFileBytes(t, filepath.Join(dst, "z-last.txt"), []byte("last"))
if runtime.GOOS != "windows" {
for _, path := range []string{dst, filepath.Join(dst, "nested"), filepath.Join(dst, "empty")} {
info, err := os.Stat(path)
if err != nil {
t.Fatalf("Stat(%q) error = %v", path, err)
}
if got := info.Mode().Perm(); got != WorkspaceDirectoryMode.Perm() {
t.Fatalf("directory mode for %q = %o, want %o", path, got, WorkspaceDirectoryMode.Perm())
}
}
for _, path := range []string{filepath.Join(dst, "a-first.txt"), filepath.Join(dst, "nested", "binary.dat"), filepath.Join(dst, "z-last.txt")} {
info, err := os.Stat(path)
if err != nil {
t.Fatalf("Stat(%q) error = %v", path, err)
}
if got := info.Mode().Perm(); got != WorkspaceFileMode.Perm() {
t.Fatalf("file mode for %q = %o, want %o", path, got, WorkspaceFileMode.Perm())
}
}
}
assertFileBytes(t, filepath.Join(src, "nested", "binary.dat"), []byte{0, 1, 2, 0xff})
assertNoMatchingTempDirectories(t, root, ".promoted.tmp-")
}
func TestPromoteDirectoryRejectsInvalidPaths(t *testing.T) {
root := t.TempDir()
src := filepath.Join(root, "source")
if err := os.Mkdir(src, 0o755); err != nil {
t.Fatalf("Mkdir(source) error = %v", err)
}
tests := []struct {
name string
src string
dst string
}{
{name: "empty source", src: "", dst: filepath.Join(root, "out-a")},
{name: "empty destination", src: src, dst: " "},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if err := PromoteDirectory(test.src, test.dst); err == nil {
t.Fatal("PromoteDirectory() error = nil, want path validation failure")
}
})
}
}
func TestPromoteDirectoryRejectsExistingDestination(t *testing.T) {
root := t.TempDir()
src := filepath.Join(root, "source")
dst := filepath.Join(root, "destination")
mustWriteFile(t, filepath.Join(src, "value.txt"), []byte("source"), 0o644)
mustWriteFile(t, filepath.Join(dst, "value.txt"), []byte("existing"), 0o644)
err := PromoteDirectory(src, dst)
if err == nil || !strings.Contains(err.Error(), "already exists") {
t.Fatalf("PromoteDirectory() error = %v, want existing destination error", err)
}
assertFileBytes(t, filepath.Join(dst, "value.txt"), []byte("existing"))
assertFileBytes(t, filepath.Join(src, "value.txt"), []byte("source"))
assertNoMatchingTempDirectories(t, root, ".destination.tmp-")
}
func TestPromoteDirectoryRejectsNonDirectorySource(t *testing.T) {
root := t.TempDir()
src := filepath.Join(root, "source.txt")
dst := filepath.Join(root, "destination")
mustWriteFile(t, src, []byte("source"), 0o644)
if err := PromoteDirectory(src, dst); err == nil {
t.Fatal("PromoteDirectory() error = nil, want non-directory source error")
}
assertFileBytes(t, src, []byte("source"))
if _, err := os.Lstat(dst); !os.IsNotExist(err) {
t.Fatalf("Lstat(destination) error = %v, want not exist", err)
}
}
func TestPromoteDirectoryRejectsSymlinksWithoutFollowingThem(t *testing.T) {
root := t.TempDir()
externalFile := filepath.Join(root, "external.txt")
externalDirectory := filepath.Join(root, "external-directory")
mustWriteFile(t, externalFile, []byte("outside"), 0o644)
mustWriteFile(t, filepath.Join(externalDirectory, "secret.txt"), []byte("secret"), 0o644)
tests := []struct {
name string
target string
link string
}{
{name: "file", target: externalFile, link: "file-link"},
{name: "directory", target: externalDirectory, link: "directory-link"},
{name: "escaping", target: filepath.Join("..", "external.txt"), link: "escaping-link"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
src := filepath.Join(root, "source-"+test.name)
dst := filepath.Join(root, "destination-"+test.name)
if err := os.Mkdir(src, 0o755); err != nil {
t.Fatalf("Mkdir(source) error = %v", err)
}
if err := os.Symlink(test.target, filepath.Join(src, test.link)); err != nil {
t.Skipf("Symlink() unavailable: %v", err)
}
if err := PromoteDirectory(src, dst); err == nil {
t.Fatal("PromoteDirectory() error = nil, want symlink rejection")
}
linkInfo, err := os.Lstat(filepath.Join(src, test.link))
if err != nil || linkInfo.Mode()&os.ModeSymlink == 0 {
t.Fatalf("source symlink was not preserved: info=%v err=%v", linkInfo, err)
}
if _, err := os.Lstat(dst); !os.IsNotExist(err) {
t.Fatalf("Lstat(destination) error = %v, want not exist", err)
}
assertNoMatchingTempDirectories(t, root, ".destination-"+test.name+".tmp-")
})
}
assertFileBytes(t, externalFile, []byte("outside"))
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 TestPromoteDirectoryRejectsIdentityPreservingSourceRootSymlinkReplacement(t *testing.T) {
root := t.TempDir()
src := filepath.Join(root, "source")
preserved := filepath.Join(root, "source-preserved")
dst := filepath.Join(root, "destination")
mustWriteFile(t, filepath.Join(src, "value.txt"), []byte("original"), 0o644)
err := promoteDirectoryWithHooks(src, dst, renameDirectoryNoReplace, sourceTraversalHooks{
afterRootInspect: func() {
if err := os.Rename(src, preserved); err != nil {
t.Fatalf("Rename(inspected source) error = %v", err)
}
if err := os.Symlink(filepath.Base(preserved), src); err != nil {
t.Skipf("Symlink() unavailable: %v", err)
}
},
})
if err == nil {
t.Fatal("promoteDirectoryWithHooks() error = nil, want source-root symlink replacement failure")
}
assertFileBytes(t, filepath.Join(preserved, "value.txt"), []byte("original"))
assertPathIsSymlink(t, src)
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 TestPromoteDirectoryRejectsIdentityPreservingFileSymlinkReplacement(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)
}
if err := os.Symlink(filepath.Base(preserved), file); err != nil {
t.Skipf("Symlink() unavailable: %v", err)
}
},
})
if err == nil {
t.Fatal("promoteDirectoryWithHooks() error = nil, want file symlink replacement failure")
}
assertFileBytes(t, preserved, []byte("original"))
assertPathIsSymlink(t, file)
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 TestPromoteDirectoryRejectsIdentityPreservingDirectorySymlinkReplacement(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)
}
if err := os.Symlink(filepath.Base(preserved), child); err != nil {
t.Skipf("Symlink() unavailable: %v", err)
}
},
})
if err == nil {
t.Fatal("promoteDirectoryWithHooks() error = nil, want directory symlink replacement failure")
}
assertFileBytes(t, filepath.Join(preserved, "value.txt"), []byte("original"))
assertPathIsSymlink(t, child)
assertPathMissing(t, dst)
assertNoMatchingTempDirectories(t, root, ".destination.tmp-")
}
func TestPromoteDirectoryDoesNotReplaceDestinationCreatedBeforeInstall(t *testing.T) {
root := t.TempDir()
src := filepath.Join(root, "source")
dst := filepath.Join(root, "destination")
mustWriteFile(t, filepath.Join(src, "value.txt"), []byte("source"), 0o644)
err := promoteDirectory(src, dst, func(temporary, destination string) error {
if err := os.Mkdir(destination, 0o755); err != nil {
t.Fatalf("Mkdir(concurrent destination) error = %v", err)
}
mustWriteFile(t, filepath.Join(destination, "value.txt"), []byte("concurrent"), 0o644)
return renameDirectoryNoReplace(temporary, destination)
})
if err == nil {
t.Fatal("promoteDirectory() error = nil, want no-replace install failure")
}
assertFileBytes(t, filepath.Join(dst, "value.txt"), []byte("concurrent"))
assertFileBytes(t, filepath.Join(src, "value.txt"), []byte("source"))
assertNoMatchingTempDirectories(t, root, ".destination.tmp-")
}
func TestPromoteDirectoryRejectsDestinationInsideSource(t *testing.T) {
root := t.TempDir()
src := filepath.Join(root, "source")
dst := filepath.Join(src, "nested", "destination")
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
t.Fatalf("MkdirAll(destination parent) error = %v", err)
}
if err := PromoteDirectory(src, dst); err == nil {
t.Fatal("PromoteDirectory() error = nil, want nested destination rejection")
}
if _, err := os.Lstat(dst); !os.IsNotExist(err) {
t.Fatalf("Lstat(destination) error = %v, want not exist", err)
}
}
func mustWriteFile(t *testing.T, path string, data []byte, mode os.FileMode) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("MkdirAll(%q) error = %v", filepath.Dir(path), err)
}
if err := os.WriteFile(path, data, mode); err != nil {
t.Fatalf("WriteFile(%q) error = %v", path, err)
}
}
func assertFileBytes(t *testing.T, path string, want []byte) {
t.Helper()
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile(%q) error = %v", path, err)
}
if !bytes.Equal(got, want) {
t.Fatalf("ReadFile(%q) = %v, want %v", path, got, want)
}
}
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 assertPathIsSymlink(t *testing.T, path string) {
t.Helper()
info, err := os.Lstat(path)
if err != nil || info.Mode()&os.ModeSymlink == 0 {
t.Fatalf("Lstat(%q) info = %v, error = %v, want symlink", path, info, err)
}
}
func treeLayout(t *testing.T, root string) []string {
t.Helper()
var layout []string
err := filepath.WalkDir(root, func(path string, _ os.DirEntry, err error) error {
if err != nil {
return err
}
relative, err := filepath.Rel(root, path)
if err != nil {
return err
}
layout = append(layout, filepath.ToSlash(relative))
return nil
})
if err != nil {
t.Fatalf("WalkDir(%q) error = %v", root, err)
}
return layout
}
func assertNoMatchingTempDirectories(t *testing.T, parent, prefix string) {
t.Helper()
entries, err := os.ReadDir(parent)
if err != nil {
t.Fatalf("ReadDir(%q) error = %v", parent, err)
}
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), prefix) {
t.Fatalf("unexpected temporary directory residue: %s", filepath.Join(parent, entry.Name()))
}
}
}