package fileops import ( "bytes" "os" "path/filepath" "reflect" "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")) 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 != promotedDirectoryMode { t.Fatalf("directory mode for %q = %o, want %o", path, got, promotedDirectoryMode) } } 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 != promotedFileMode { t.Fatalf("file mode for %q = %o, want %o", path, got, promotedFileMode) } } 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 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 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())) } } }