diff --git a/internal/fs/fs.go b/internal/fs/fs.go index 39fc7ac..cb9764b 100644 --- a/internal/fs/fs.go +++ b/internal/fs/fs.go @@ -16,19 +16,36 @@ import ( "syscall" ) +// Injected functions for deterministic fault injection +type injFunc struct { + Rename func(string, string) error + RemoveAll func(string) error + MkdirAll func(string, os.FileMode) error + Open func(string) (*os.File, error) + OpenFile func(string, int, os.FileMode) (*os.File, error) + Copy func(io.Writer, io.Reader) (int64, error) + Rel func(string, string) (string, error) + Walk func(string, filepath.WalkFunc) error + Stat func(string) (os.FileInfo, error) +} + +var inj = injFunc{ + Rename: os.Rename, + RemoveAll: os.RemoveAll, + MkdirAll: os.MkdirAll, + Open: os.Open, + OpenFile: os.OpenFile, + Copy: io.Copy, + Rel: filepath.Rel, + Walk: filepath.Walk, +} + // MoveDir moves a directory from source to destination. // // If the source and destination are on different devices, MoveDir transparently falls back // to a recursive copy followed by removal of the source directory. -// -// Parameters: -// - source: full path of the source directory to move. -// - dest: full path of the destination directory. -// -// Returns: -// - error: non-nil if the move or copy operation fails. func MoveDir(source, dest string) error { - err := os.Rename(source, dest) + err := inj.Rename(source, dest) if err == nil { return nil } @@ -42,7 +59,7 @@ func MoveDir(source, dest string) error { return wrap("copyDir failed", err) } - if err := os.RemoveAll(source); err != nil { + if err := inj.RemoveAll(source); err != nil { return wrap("failed to cleanup source after copy", err) } @@ -50,16 +67,6 @@ func MoveDir(source, dest string) error { } // CopyFile copies a single file from src to dst. -// -// It fully preserves the file contents and permissions. Errors are returned -// if any part of the copy operation fails. -// -// Parameters: -// - src: full path to the source file. -// - dst: full path to the destination file. -// -// Returns: -// - error: non-nil if the copy operation fails. func CopyFile(src, dst string) error { sourceFileStat, err := os.Stat(src) if err != nil { @@ -70,27 +77,19 @@ func CopyFile(src, dst string) error { return wrap("source file is not regular", nil) } - source, err := os.Open(src) + source, err := inj.Open(src) if err != nil { return wrap("failed to open source file", err) } - defer func() { - if closeErr := source.Close(); closeErr != nil { - log.Printf("warning: failed to close source file: %v", closeErr) - } - }() + defer safeClose("source", source) - destination, err := os.Create(dst) + destination, err := inj.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, sourceFileStat.Mode()) if err != nil { return wrap("failed to create destination file", err) } - defer func() { - if closeErr := destination.Close(); closeErr != nil { - log.Printf("warning: failed to close destination file: %v", closeErr) - } - }() + defer safeClose("destination", destination) - _, err = io.Copy(destination, source) + _, err = inj.Copy(destination, source) if err != nil { return wrap("failed to copy file content", err) } @@ -99,46 +98,44 @@ func CopyFile(src, dst string) error { } func copyDir(source, dest string) error { - return filepath.Walk(source, func(path string, info os.FileInfo, err error) error { + return inj.Walk(source, func(path string, info os.FileInfo, err error) error { if err != nil { return err } - relPath, err := filepath.Rel(source, path) + relPath, err := inj.Rel(source, path) if err != nil { return err } targetPath := filepath.Join(dest, relPath) if info.IsDir() { - return os.MkdirAll(targetPath, info.Mode()) + return inj.MkdirAll(targetPath, info.Mode()) } - srcFile, err := os.Open(path) + srcFile, err := inj.Open(path) if err != nil { return err } - defer func() { - if closeErr := srcFile.Close(); closeErr != nil { - log.Printf("warning: failed to close source file: %v", closeErr) - } - }() + defer safeClose("source file", srcFile) - destFile, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode()) + destFile, err := inj.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode()) if err != nil { return err } - defer func() { - if closeErr := destFile.Close(); closeErr != nil { - log.Printf("warning: failed to close destination file: %v", closeErr) - } - }() + defer safeClose("dest file", destFile) - _, err = io.Copy(destFile, srcFile) + _, err = inj.Copy(destFile, srcFile) return err }) } +func safeClose(label string, c io.Closer) { + if err := c.Close(); err != nil { + log.Printf("warning: failed to close %s: %v", label, err) + } +} + func wrap(msg string, err error) error { if err == nil { return errors.New(msg) diff --git a/internal/fs/fs_test.go b/internal/fs/fs_test.go index 54282f9..aa45cff 100644 --- a/internal/fs/fs_test.go +++ b/internal/fs/fs_test.go @@ -2,19 +2,33 @@ package fs import ( "fmt" + "io" "os" "path/filepath" + "strings" + "syscall" "testing" ) -var renameFunc = os.Rename +func resetInjection() { + inj = injFunc{ + Rename: os.Rename, + RemoveAll: os.RemoveAll, + MkdirAll: os.MkdirAll, + Open: os.Open, + OpenFile: os.OpenFile, + Copy: io.Copy, + Rel: filepath.Rel, + Walk: filepath.Walk, + Stat: os.Stat, + } +} func TestCopyFile_Success(t *testing.T) { + resetInjection() tempDir := t.TempDir() - srcFile := filepath.Join(tempDir, "source.txt") dstFile := filepath.Join(tempDir, "dest.txt") - content := []byte("test file content") if err := os.WriteFile(srcFile, content, 0644); err != nil { @@ -35,90 +49,144 @@ func TestCopyFile_Success(t *testing.T) { } } -func TestCopyFile_FailureScenarios(t *testing.T) { +func TestCopyFile_Errors(t *testing.T) { + resetInjection() tempDir := t.TempDir() - t.Run("source does not exist", func(t *testing.T) { - err := CopyFile(filepath.Join(tempDir, "no-source.txt"), filepath.Join(tempDir, "dest.txt")) + t.Run("stat fails", func(t *testing.T) { + inj.Stat = func(string) (os.FileInfo, error) { return nil, fmt.Errorf("stat failed") } + err := CopyFile("nonexistent", "out") if err == nil { - t.Errorf("expected error for missing source file, got nil") + t.Errorf("expected error, got nil") + } + if !strings.Contains(err.Error(), "failed to stat source file") { + t.Errorf("unexpected error message: %v", err) } }) - t.Run("source is not regular file", func(t *testing.T) { - dirPath := filepath.Join(tempDir, "some-dir") + t.Run("non-regular file", func(t *testing.T) { + dirPath := filepath.Join(tempDir, "dir") if err := os.Mkdir(dirPath, 0755); err != nil { t.Fatalf("failed to create dir: %v", err) } - err := CopyFile(dirPath, filepath.Join(tempDir, "dest.txt")) + err := CopyFile(dirPath, "out") if err == nil { - t.Errorf("expected error for non-regular source file, got nil") + t.Errorf("expected non-regular file error") } }) } func TestMoveDir_Success(t *testing.T) { + resetInjection() tempDir := t.TempDir() - sourceDir := filepath.Join(tempDir, "source") destDir := filepath.Join(tempDir, "dest") - if err := os.MkdirAll(sourceDir, 0755); err != nil { t.Fatalf("failed to create source dir: %v", err) } - - testFile := filepath.Join(sourceDir, "test.txt") - if err := os.WriteFile(testFile, []byte("move test"), 0644); err != nil { - t.Fatalf("failed to write test file: %v", err) + if err := os.WriteFile(filepath.Join(sourceDir, "file.txt"), []byte("data"), 0644); err != nil { + t.Fatalf("failed to write file: %v", err) } if err := MoveDir(sourceDir, destDir); err != nil { t.Fatalf("MoveDir failed: %v", err) } +} - if _, err := os.Stat(sourceDir); !os.IsNotExist(err) { - t.Errorf("source dir still exists after move") +func TestMoveDir_EXDEV(t *testing.T) { + resetInjection() + tempDir := t.TempDir() + sourceDir := filepath.Join(tempDir, "source") + destDir := filepath.Join(tempDir, "dest") + if err := os.MkdirAll(sourceDir, 0755); err != nil { + t.Fatalf("failed to create source dir: %v", err) + } + if err := os.WriteFile(filepath.Join(sourceDir, "file.txt"), []byte("data"), 0644); err != nil { + t.Fatalf("failed to write file: %v", err) } - read, err := os.ReadFile(filepath.Join(destDir, "test.txt")) - if err != nil { - t.Fatalf("failed to read moved file: %v", err) + inj.Rename = func(_, _ string) error { + return &os.LinkError{ + Op: "rename", + Old: sourceDir, + New: destDir, + Err: syscall.EXDEV, + } } - if string(read) != "move test" { - t.Errorf("content mismatch: expected 'move test', got %q", string(read)) + if err := MoveDir(sourceDir, destDir); err != nil { + t.Fatalf("MoveDir EXDEV failed: %v", err) } } -// simulate copyDir failure by mocking filepath.Walk (advanced scenario - optional in real pipelines) - -func TestMoveDir_FallbackCrossDevice(t *testing.T) { - // here we simulate EXDEV manually to trigger the fallback +func TestFaultInjection_CopyDirFailures(t *testing.T) { + resetInjection() tempDir := t.TempDir() - sourceDir := filepath.Join(tempDir, "source") destDir := filepath.Join(tempDir, "dest") - if err := os.MkdirAll(sourceDir, 0755); err != nil { t.Fatalf("failed to create source dir: %v", err) } - - testFile := filepath.Join(sourceDir, "test.txt") - if err := os.WriteFile(testFile, []byte("move test"), 0644); err != nil { - t.Fatalf("failed to write test file: %v", err) + if err := os.WriteFile(filepath.Join(sourceDir, "file.txt"), []byte("data"), 0644); err != nil { + t.Fatalf("failed to write file: %v", err) } - // replace os.Rename temporarily to simulate EXDEV - originalRename := renameFunc - defer func() { renameFunc = originalRename }() - renameFunc = func(_, _ string) error { - return fmt.Errorf("simulated rename error") - } - if err := MoveDir(sourceDir, destDir); err != nil { - t.Fatalf("MoveDir fallback failed: %v", err) - } + t.Run("Walk fails", func(t *testing.T) { + inj.Walk = func(string, filepath.WalkFunc) error { return fmt.Errorf("walk failed") } + err := copyDir(sourceDir, destDir) + if err == nil { + t.Errorf("expected walk failure") + } + }) - if _, err := os.Stat(sourceDir); !os.IsNotExist(err) { - t.Errorf("source dir still exists after fallback move") - } + t.Run("Rel fails", func(t *testing.T) { + inj.Rel = func(string, string) (string, error) { return "", fmt.Errorf("rel failed") } + err := copyDir(sourceDir, destDir) + if err == nil { + t.Errorf("expected rel failure") + } + }) + + t.Run("MkdirAll fails", func(t *testing.T) { + inj.MkdirAll = func(string, os.FileMode) error { return fmt.Errorf("mkdir failed") } + err := copyDir(sourceDir, destDir) + if err == nil { + t.Errorf("expected mkdir failure") + } + }) + + t.Run("Open fails", func(t *testing.T) { + inj.Open = func(string) (*os.File, error) { return nil, fmt.Errorf("open failed") } + err := copyDir(sourceDir, destDir) + if err == nil { + t.Errorf("expected open failure") + } + }) + + t.Run("OpenFile fails", func(t *testing.T) { + inj.OpenFile = func(string, int, os.FileMode) (*os.File, error) { return nil, fmt.Errorf("openfile failed") } + err := copyDir(sourceDir, destDir) + if err == nil { + t.Errorf("expected openfile failure") + } + }) + + t.Run("Copy fails", func(t *testing.T) { + inj.Copy = func(io.Writer, io.Reader) (int64, error) { return 0, fmt.Errorf("copy failed") } + err := copyDir(sourceDir, destDir) + if err == nil { + t.Errorf("expected copy failure") + } + }) + + t.Run("RemoveAll fails", func(t *testing.T) { + inj.RemoveAll = func(string) error { return fmt.Errorf("removeall failed") } + inj.Rename = func(_, _ string) error { + return &os.LinkError{Op: "rename", Err: syscall.EXDEV} + } + err := MoveDir(sourceDir, destDir) + if err == nil { + t.Errorf("expected removeall failure") + } + }) }