Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 45 additions & 48 deletions internal/fs/fs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -42,24 +59,14 @@ 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)
}

return nil
}

// 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 {
Expand All @@ -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)
}
Expand All @@ -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)
Expand Down
160 changes: 114 additions & 46 deletions internal/fs/fs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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")
}
})
}
Loading