431 lines
12 KiB
Go
431 lines
12 KiB
Go
package fileops
|
|
|
|
import (
|
|
"errors"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
var errAtomicReplacementTest = errors.New("atomic replacement test failure")
|
|
|
|
func TestWriteFileAtomicOverwritesAndLeavesNoTempFile(t *testing.T) {
|
|
root := t.TempDir()
|
|
dst := filepath.Join(root, "out", "value.txt")
|
|
|
|
if err := WriteFileAtomic(dst, []byte("one"), 0o644); err != nil {
|
|
t.Fatalf("WriteFileAtomic(first) error = %v", err)
|
|
}
|
|
if err := WriteFileAtomic(dst, []byte("two"), 0o644); err != nil {
|
|
t.Fatalf("WriteFileAtomic(second) error = %v", err)
|
|
}
|
|
|
|
data, err := os.ReadFile(dst)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile() error = %v", err)
|
|
}
|
|
if string(data) != "two" {
|
|
t.Fatalf("file content = %q, want %q", string(data), "two")
|
|
}
|
|
|
|
assertNoMatchingTempFiles(t, filepath.Dir(dst), "."+filepath.Base(dst)+".tmp-")
|
|
}
|
|
|
|
func TestWriteFileAtomicCleansTempFileOnInstallFailure(t *testing.T) {
|
|
root := t.TempDir()
|
|
blockedPath := filepath.Join(root, "blocked")
|
|
if err := os.MkdirAll(blockedPath, 0o755); err != nil {
|
|
t.Fatalf("MkdirAll(blockedPath) error = %v", err)
|
|
}
|
|
|
|
err := WriteFileAtomic(blockedPath, []byte("data"), 0o644)
|
|
if err == nil {
|
|
t.Fatal("WriteFileAtomic() error = nil, want install failure")
|
|
}
|
|
assertNoMatchingTempFiles(t, root, ".blocked.tmp-")
|
|
}
|
|
|
|
func TestCopyFileAtomicWithChecksumMatchesDestination(t *testing.T) {
|
|
root := t.TempDir()
|
|
src := filepath.Join(root, "source.txt")
|
|
dst := filepath.Join(root, "out", "copied.txt")
|
|
if err := os.WriteFile(src, []byte("copied-data"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile(source) error = %v", err)
|
|
}
|
|
|
|
checksum, err := CopyFileAtomicWithChecksum(src, dst, 0o644)
|
|
if err != nil {
|
|
t.Fatalf("CopyFileAtomicWithChecksum() error = %v", err)
|
|
}
|
|
wantChecksum := "6e5c3f239e28cc315d57b2fcfc24169369c44a25802c0616a6d7081707fd24df"
|
|
if checksum != wantChecksum {
|
|
t.Fatalf("checksum = %q, want %q", checksum, wantChecksum)
|
|
}
|
|
|
|
data, err := os.ReadFile(dst)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile(destination) error = %v", err)
|
|
}
|
|
if string(data) != "copied-data" {
|
|
t.Fatalf("destination content = %q, want %q", string(data), "copied-data")
|
|
}
|
|
assertNoMatchingTempFiles(t, filepath.Dir(dst), ".copied.txt.tmp-")
|
|
}
|
|
|
|
func TestCopyFileAtomicCleansTempFileOnInstallFailure(t *testing.T) {
|
|
root := t.TempDir()
|
|
src := filepath.Join(root, "source.txt")
|
|
if err := os.WriteFile(src, []byte("copied-data"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile(source) error = %v", err)
|
|
}
|
|
|
|
blockedPath := filepath.Join(root, "blocked")
|
|
if err := os.MkdirAll(blockedPath, 0o755); err != nil {
|
|
t.Fatalf("MkdirAll(blockedPath) error = %v", err)
|
|
}
|
|
err := CopyFileAtomic(src, blockedPath, 0o644)
|
|
if err == nil {
|
|
t.Fatal("CopyFileAtomic() error = nil, want install failure")
|
|
}
|
|
assertNoMatchingTempFiles(t, root, ".blocked.tmp-")
|
|
}
|
|
|
|
func TestInstallDownloadedTempFileSetsPermissions(t *testing.T) {
|
|
root := t.TempDir()
|
|
tmpPath := filepath.Join(root, ".payload.tmp")
|
|
dst := filepath.Join(root, "out", "payload.json")
|
|
if err := os.WriteFile(tmpPath, []byte("{\"ok\":true}\n"), 0o600); err != nil {
|
|
t.Fatalf("WriteFile(temp) error = %v", err)
|
|
}
|
|
|
|
if err := InstallDownloadedTempFile(tmpPath, dst, 0o644); err != nil {
|
|
t.Fatalf("InstallDownloadedTempFile() error = %v", err)
|
|
}
|
|
if _, err := os.Stat(tmpPath); !os.IsNotExist(err) {
|
|
t.Fatalf("temp file still exists: stat err = %v", err)
|
|
}
|
|
info, err := os.Stat(dst)
|
|
if err != nil {
|
|
t.Fatalf("Stat(destination) error = %v", err)
|
|
}
|
|
if info.Mode().Perm() != 0o644 {
|
|
t.Fatalf("destination mode = %o, want 644", info.Mode().Perm())
|
|
}
|
|
}
|
|
|
|
func TestWriteFileAtomicRejectsSymlinkedDestinationAncestor(t *testing.T) {
|
|
root := t.TempDir()
|
|
outside := t.TempDir()
|
|
sentinel := filepath.Join(outside, "sentinel.txt")
|
|
if err := os.WriteFile(sentinel, []byte("unchanged"), 0o600); err != nil {
|
|
t.Fatalf("WriteFile(sentinel) error = %v", err)
|
|
}
|
|
if err := os.Symlink(outside, filepath.Join(root, "redirect")); err != nil {
|
|
t.Skipf("Symlink unavailable: %v", err)
|
|
}
|
|
|
|
err := WriteFileAtomic(filepath.Join(root, "redirect", "output.txt"), []byte("new"), 0o640)
|
|
if err == nil {
|
|
t.Fatal("WriteFileAtomic() error = nil, want symlink ancestor rejection")
|
|
}
|
|
data, err := os.ReadFile(sentinel)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile(sentinel) error = %v", err)
|
|
}
|
|
if string(data) != "unchanged" {
|
|
t.Fatalf("outside sentinel = %q, want unchanged", data)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(outside, "output.txt")); !os.IsNotExist(err) {
|
|
t.Fatalf("outside output exists: stat err = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestWriteFileAtomicReplacesLeafSymlinkWithoutFollowingIt(t *testing.T) {
|
|
root := t.TempDir()
|
|
outside := filepath.Join(t.TempDir(), "outside.txt")
|
|
if err := os.WriteFile(outside, []byte("outside"), 0o600); err != nil {
|
|
t.Fatalf("WriteFile(outside) error = %v", err)
|
|
}
|
|
destination := filepath.Join(root, "output.txt")
|
|
if err := os.Symlink(outside, destination); err != nil {
|
|
t.Skipf("Symlink unavailable: %v", err)
|
|
}
|
|
|
|
if err := WriteFileAtomic(destination, []byte("inside"), 0o640); err != nil {
|
|
t.Fatalf("WriteFileAtomic() error = %v", err)
|
|
}
|
|
outsideData, err := os.ReadFile(outside)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile(outside) error = %v", err)
|
|
}
|
|
if string(outsideData) != "outside" {
|
|
t.Fatalf("outside file = %q, want unchanged", outsideData)
|
|
}
|
|
info, err := os.Lstat(destination)
|
|
if err != nil || info.Mode()&os.ModeSymlink != 0 {
|
|
t.Fatalf("destination was not replaced with a regular file: info=%v err=%v", info, err)
|
|
}
|
|
}
|
|
|
|
func TestDownloadAndInstallRejectsSymlinkedDestinationAncestor(t *testing.T) {
|
|
root := t.TempDir()
|
|
outside := t.TempDir()
|
|
if err := os.Symlink(outside, filepath.Join(root, "redirect")); err != nil {
|
|
t.Skipf("Symlink unavailable: %v", err)
|
|
}
|
|
|
|
err := DownloadAndInstall(filepath.Join(root, "redirect", "output.txt"), 0o640, func(io.Writer) error {
|
|
return nil
|
|
})
|
|
if err == nil {
|
|
t.Fatal("DownloadAndInstall() error = nil, want symlink ancestor rejection")
|
|
}
|
|
entries, err := os.ReadDir(outside)
|
|
if err != nil {
|
|
t.Fatalf("ReadDir(outside) error = %v", err)
|
|
}
|
|
if len(entries) != 0 {
|
|
t.Fatalf("outside destination received entries: %v", entries)
|
|
}
|
|
}
|
|
|
|
func TestReplaceFileAtomicOrdersDurableOperations(t *testing.T) {
|
|
events := make([]string, 0, 8)
|
|
tmp := &recordingTemporaryFile{name: "/work/.result.tmp-1", events: &events}
|
|
ops := recordingAtomicReplacementOperations(&events, tmp)
|
|
|
|
err := replaceFileAtomicWithOperations(
|
|
"/work/result",
|
|
[]byte("replacement"),
|
|
ReplaceFileOptions{
|
|
Mode: 0o640,
|
|
BeforeRename: func() error {
|
|
events = append(events, "check")
|
|
return nil
|
|
},
|
|
},
|
|
ops,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("replaceFileAtomicWithOperations() error = %v", err)
|
|
}
|
|
|
|
want := []string{"create", "write", "chmod:rw-r-----", "sync", "close", "check", "rename", "sync-directory"}
|
|
if !equalStrings(events, want) {
|
|
t.Fatalf("operation order = %v, want %v", events, want)
|
|
}
|
|
}
|
|
|
|
func TestReplaceFileAtomicFailuresCleanUninstalledTemporaryFile(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
configure func(*recordingTemporaryFile, *atomicReplacementOperations)
|
|
wantErr error
|
|
wantRemove bool
|
|
}{
|
|
{
|
|
name: "short write",
|
|
configure: func(tmp *recordingTemporaryFile, _ *atomicReplacementOperations) {
|
|
tmp.shortWrite = true
|
|
},
|
|
wantErr: io.ErrShortWrite,
|
|
wantRemove: true,
|
|
},
|
|
{
|
|
name: "write",
|
|
configure: func(tmp *recordingTemporaryFile, _ *atomicReplacementOperations) {
|
|
tmp.writeErr = errAtomicReplacementTest
|
|
},
|
|
wantErr: errAtomicReplacementTest,
|
|
wantRemove: true,
|
|
},
|
|
{
|
|
name: "sync",
|
|
configure: func(tmp *recordingTemporaryFile, _ *atomicReplacementOperations) {
|
|
tmp.syncErr = errAtomicReplacementTest
|
|
},
|
|
wantErr: errAtomicReplacementTest,
|
|
wantRemove: true,
|
|
},
|
|
{
|
|
name: "rename",
|
|
configure: func(_ *recordingTemporaryFile, ops *atomicReplacementOperations) {
|
|
ops.rename = func(string, string) error { return errAtomicReplacementTest }
|
|
},
|
|
wantErr: errAtomicReplacementTest,
|
|
wantRemove: true,
|
|
},
|
|
{
|
|
name: "directory sync",
|
|
configure: func(_ *recordingTemporaryFile, ops *atomicReplacementOperations) {
|
|
ops.syncDirectory = func(string) error { return ErrDirectorySyncUnsupported }
|
|
},
|
|
wantErr: ErrDirectorySyncUnsupported,
|
|
wantRemove: false,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
events := make([]string, 0, 8)
|
|
tmp := &recordingTemporaryFile{name: "/work/.result.tmp-1", events: &events}
|
|
ops := recordingAtomicReplacementOperations(&events, tmp)
|
|
test.configure(tmp, &ops)
|
|
|
|
err := replaceFileAtomicWithOperations("/work/result", []byte("replacement"), ReplaceFileOptions{Mode: 0o640}, ops)
|
|
if !errors.Is(err, test.wantErr) {
|
|
t.Fatalf("replaceFileAtomicWithOperations() error = %v, want %v", err, test.wantErr)
|
|
}
|
|
gotRemove := containsString(events, "remove")
|
|
if gotRemove != test.wantRemove {
|
|
t.Fatalf("remove temporary file = %t, want %t; operations = %v", gotRemove, test.wantRemove, events)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestReplaceFileAtomicReportsCleanupFailureWithPrimaryFailure(t *testing.T) {
|
|
events := make([]string, 0, 5)
|
|
tmp := &recordingTemporaryFile{
|
|
name: "/work/.result.tmp-1",
|
|
events: &events,
|
|
writeErr: errAtomicReplacementTest,
|
|
}
|
|
ops := recordingAtomicReplacementOperations(&events, tmp)
|
|
cleanupErr := errors.New("temporary cleanup failed")
|
|
ops.remove = func(string) error {
|
|
events = append(events, "remove")
|
|
return cleanupErr
|
|
}
|
|
|
|
err := replaceFileAtomicWithOperations("/work/result", []byte("replacement"), ReplaceFileOptions{Mode: 0o640}, ops)
|
|
if !errors.Is(err, errAtomicReplacementTest) {
|
|
t.Fatalf("replacement error = %v, want write failure", err)
|
|
}
|
|
if !errors.Is(err, cleanupErr) {
|
|
t.Fatalf("replacement error = %v, want cleanup failure", err)
|
|
}
|
|
}
|
|
|
|
func TestInstallDownloadedTempFileOrdersDurableOperations(t *testing.T) {
|
|
events := make([]string, 0, 4)
|
|
ops := atomicReplacementOperations{
|
|
chmod: func(string, os.FileMode) error {
|
|
events = append(events, "chmod")
|
|
return nil
|
|
},
|
|
syncFile: func(string) error {
|
|
events = append(events, "sync-file")
|
|
return nil
|
|
},
|
|
rename: func(string, string) error {
|
|
events = append(events, "rename")
|
|
return nil
|
|
},
|
|
syncDirectory: func(string) error {
|
|
events = append(events, "sync-directory")
|
|
return nil
|
|
},
|
|
}
|
|
|
|
if err := installDownloadedTempFileWithOperations("/work/.download.tmp", "/work/result", 0o640, ops); err != nil {
|
|
t.Fatalf("installDownloadedTempFileWithOperations() error = %v", err)
|
|
}
|
|
want := []string{"chmod", "sync-file", "rename", "sync-directory"}
|
|
if !equalStrings(events, want) {
|
|
t.Fatalf("operation order = %v, want %v", events, want)
|
|
}
|
|
}
|
|
|
|
type recordingTemporaryFile struct {
|
|
name string
|
|
events *[]string
|
|
shortWrite bool
|
|
writeErr error
|
|
syncErr error
|
|
}
|
|
|
|
func (f *recordingTemporaryFile) Write(data []byte) (int, error) {
|
|
*f.events = append(*f.events, "write")
|
|
if f.writeErr != nil {
|
|
return 0, f.writeErr
|
|
}
|
|
if f.shortWrite {
|
|
return len(data) - 1, nil
|
|
}
|
|
return len(data), nil
|
|
}
|
|
|
|
func (f *recordingTemporaryFile) Name() string { return f.name }
|
|
|
|
func (f *recordingTemporaryFile) Sync() error {
|
|
*f.events = append(*f.events, "sync")
|
|
return f.syncErr
|
|
}
|
|
|
|
func (f *recordingTemporaryFile) Close() error {
|
|
*f.events = append(*f.events, "close")
|
|
return nil
|
|
}
|
|
|
|
func recordingAtomicReplacementOperations(events *[]string, tmp temporaryFile) atomicReplacementOperations {
|
|
return atomicReplacementOperations{
|
|
createTemp: func(string, string) (temporaryFile, error) {
|
|
*events = append(*events, "create")
|
|
return tmp, nil
|
|
},
|
|
chmod: func(_ string, mode os.FileMode) error {
|
|
*events = append(*events, "chmod:"+mode.Perm().String()[1:])
|
|
return nil
|
|
},
|
|
rename: func(string, string) error {
|
|
*events = append(*events, "rename")
|
|
return nil
|
|
},
|
|
remove: func(string) error {
|
|
*events = append(*events, "remove")
|
|
return nil
|
|
},
|
|
syncDirectory: func(string) error {
|
|
*events = append(*events, "sync-directory")
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
func containsString(values []string, target string) bool {
|
|
for _, value := range values {
|
|
if value == target {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func equalStrings(got, want []string) bool {
|
|
if len(got) != len(want) {
|
|
return false
|
|
}
|
|
for i := range got {
|
|
if got[i] != want[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func assertNoMatchingTempFiles(t *testing.T, dir, prefix string) {
|
|
t.Helper()
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
t.Fatalf("ReadDir(%q) error = %v", dir, err)
|
|
}
|
|
for _, e := range entries {
|
|
if strings.HasPrefix(e.Name(), prefix) {
|
|
t.Fatalf("unexpected temp file residue: %s", filepath.Join(dir, e.Name()))
|
|
}
|
|
}
|
|
}
|