diff --git a/README.md b/README.md index 68376bc..a7b16ff 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,14 @@ Package `fileutils` provides useful, high-level file operations. ## Details -- `IsFile` & `IsDir` checks if file/directory exits -- `CopyFile` copies a file from source to destination +- `IsFile` & `IsDir` checks if file/directory exists +- `CopyFile` copies a file from source to destination, preserving mode - `CopyDir` copies all files recursively from the source to destination directory +- `MoveFile` moves a file, using atomic rename when possible with copy+delete fallback - `ListFiles` returns sorted slice of file paths in directory -- `TempFileName` returns a new temporary file name +- `TempFileName` returns a new temporary file name using secure random generation - `SanitizePath` cleans file path +- `TouchFile` creates an empty file or updates timestamps of existing one ## Install and update diff --git a/fileutils.go b/fileutils.go index 47e9219..55be1b6 100644 --- a/fileutils.go +++ b/fileutils.go @@ -2,21 +2,19 @@ package fileutils import ( + "crypto/rand" + "encoding/hex" "errors" "fmt" "io" - "math/rand" "os" "path/filepath" "regexp" "sort" "strings" - "sync" "time" ) -var once sync.Once - // IsFile returns true if filename exists func IsFile(filename string) bool { return exists(filename, false) @@ -38,10 +36,9 @@ func exists(name string, dir bool) bool { return !info.IsDir() } -// CopyFile copies a file from source to dest. Any existing file will be overwritten -// and attributes will not be copied +// CopyFile copies a file from source to dest, preserving mode. +// Any existing file will be overwritten. func CopyFile(src string, dst string) error { - srcInfo, err := os.Stat(src) if err != nil { return fmt.Errorf("can't stat %s: %w", src, err) @@ -51,22 +48,22 @@ func CopyFile(src string, dst string) error { return fmt.Errorf("can't copy non-regular source file %s (%s)", src, srcInfo.Mode().String()) } - srcFh, err := os.Open(src) //nolint + srcFh, err := os.Open(src) //nolint:gosec if err != nil { return fmt.Errorf("can't open source file %s: %w", src, err) } - defer srcFh.Close() //nolint + defer srcFh.Close() err = os.MkdirAll(filepath.Dir(dst), 0750) if err != nil { return fmt.Errorf("can't make destination directory %s: %w", filepath.Dir(dst), err) } - dstFh, err := os.Create(dst) //nolint + dstFh, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, srcInfo.Mode()) //nolint:gosec if err != nil { return fmt.Errorf("can't create destination file %s: %w", dst, err) } - defer dstFh.Close() //nolint + defer dstFh.Close() size, err := io.Copy(dstFh, srcFh) if err != nil { @@ -75,6 +72,7 @@ func CopyFile(src string, dst string) error { if size != srcInfo.Size() { return fmt.Errorf("incomplete copy, %d of %d", size, srcInfo.Size()) } + return dstFh.Sync() } @@ -120,36 +118,36 @@ func ListFiles(directory string) (list []string, err error) { // for temporary files (see os.TempDir). // Multiple programs calling TempFileName simultaneously // will not choose the same file name. -// some code borrowed from stdlib https://golang.org/src/io/ioutil/tempfile.go func TempFileName(dir, pattern string) (string, error) { - once.Do(func() { - rand.Seed(time.Now().UnixNano()) - }) - // prefixAndSuffix splits pattern by the last wildcard "*", if applicable, - // returning prefix as the part before "*" and suffix as the part after "*". - prefixAndSuffix := func(pattern string) (prefix, suffix string) { - if pos := strings.LastIndex(pattern, "*"); pos != -1 { - prefix, suffix = pattern[:pos], pattern[pos+1:] - } else { - prefix = pattern - } - return - } - if dir == "" { dir = os.TempDir() } - prefix, suffix := prefixAndSuffix(pattern) + // prefixAndSuffix splits pattern by the last wildcard "*", if applicable + prefix, suffix := pattern, "" + if pos := strings.LastIndex(pattern, "*"); pos != -1 { + prefix, suffix = pattern[:pos], pattern[pos+1:] + } - for i := 0; i < 10000; i++ { - name := filepath.Join(dir, prefix+fmt.Sprintf("%x", rand.Int())+suffix) //nolint - _, err := os.Stat(name) - if os.IsNotExist(err) { + // try to generate unique name + const maxTries = 10000 + const randomBytes = 16 // 32 hex chars + + for i := 0; i < maxTries; i++ { + // generate random bytes + b := make([]byte, randomBytes) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("failed to generate random name: %w", err) + } + + // create file name and check if it exists + name := filepath.Join(dir, prefix+hex.EncodeToString(b)+suffix) + if _, err := os.Stat(name); os.IsNotExist(err) { return name, nil } } - return "", errors.New("can't generate temp file name") + + return "", errors.New("failed to create temporary file name after multiple attempts") } var reInvalidPathChars = regexp.MustCompile(`[<>:"|?*]+`) // invalid path characters @@ -169,3 +167,94 @@ func SanitizePath(s string) string { return s } + +// MoveFile moves a file from src to dst. +// If rename fails (e.g., cross-device move), it will fall back to copy+delete. +// It will create destination directories if they don't exist. +func MoveFile(src, dst string) error { + if src == "" { + return errors.New("empty source path") + } + if dst == "" { + return errors.New("empty destination path") + } + + // check if source exists + srcInfo, err := os.Stat(src) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("source file not found: %s", src) + } + return fmt.Errorf("failed to stat source file: %w", err) + } + + // ensure source is a regular file + if !srcInfo.Mode().IsRegular() { + return fmt.Errorf("source is not a regular file: %s", src) + } + + // try atomic rename first + if err = os.Rename(src, dst); err == nil { + return nil + } + + // create destination directory if needed + if err = os.MkdirAll(filepath.Dir(dst), 0o750); err != nil { + return fmt.Errorf("failed to create destination directory: %w", err) + } + + // try rename again after creating directory + if err = os.Rename(src, dst); err == nil { + return nil + } + + // fallback to copy+delete if rename fails + if err = CopyFile(src, dst); err != nil { + return fmt.Errorf("failed to copy file: %w", err) + } + + // verify the copy succeeded and sizes match + dstInfo, err := os.Stat(dst) + if err != nil { + return fmt.Errorf("failed to stat destination file: %w", err) + } + if srcInfo.Size() != dstInfo.Size() { + return fmt.Errorf("size mismatch after copy: source %d, destination %d", srcInfo.Size(), dstInfo.Size()) + } + + // remove the source file + if err := os.Remove(src); err != nil { + return fmt.Errorf("failed to remove source file: %w", err) + } + + return nil +} + +// TouchFile creates an empty file if it doesn't exist, +// or updates access and modification times if it does. +func TouchFile(path string) error { + if path == "" { + return errors.New("empty path") + } + + // try to get file info + _, err := os.Stat(path) + if err != nil { + if !os.IsNotExist(err) { + return fmt.Errorf("failed to stat file: %w", err) + } + // create empty file with default mode + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) //nolint:gosec // intentionally permissive + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + return f.Close() + } + + // file exists, update timestamps + now := time.Now() + return os.Chtimes(path, now, now) +} diff --git a/fileutils_test.go b/fileutils_test.go index e70114c..ddd2037 100644 --- a/fileutils_test.go +++ b/fileutils_test.go @@ -2,9 +2,11 @@ package fileutils import ( "os" + "path/filepath" "strconv" "strings" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -28,27 +30,39 @@ func TestExistsDir(t *testing.T) { } func TestCopyFile(t *testing.T) { - defer func() { _ = os.Remove("/tmp/file1.txt") }() + tmpDir := t.TempDir() - err := CopyFile("testfiles/file1.txt", "/tmp/file1.txt") + // create source file with specific mode + srcFile := filepath.Join(tmpDir, "src.txt") + err := os.WriteFile(srcFile, []byte("test content"), 0o600) require.NoError(t, err) - fi, err := os.Stat("/tmp/file1.txt") - assert.NoError(t, err) - assert.Equal(t, int64(17), fi.Size()) + // get source info for comparison + srcInfo, err := os.Stat(srcFile) + require.NoError(t, err) - err = CopyFile("testfiles/file1.txt", "/tmp/file1.txt") - assert.NoError(t, err) + // copy file + dstFile := filepath.Join(tmpDir, "dst.txt") + err = CopyFile(srcFile, dstFile) + require.NoError(t, err) - err = CopyFile("testfiles/file-not-found.txt", "/tmp/file1.txt") - assert.Error(t, err) + // verify content + content, err := os.ReadFile(dstFile) //nolint:gosec + require.NoError(t, err) + assert.Equal(t, "test content", string(content)) - err = CopyFile("testfiles/file1.txt", "/dev/null") - assert.Error(t, err) + // verify mode + dstInfo, err := os.Stat(dstFile) + require.NoError(t, err) + assert.Equal(t, srcInfo.Mode(), dstInfo.Mode()) - err = CopyFile("testfiles", "/tmp/file1.txt") + // verify error cases + err = CopyFile("notfound.txt", dstFile) assert.Error(t, err) + assert.Contains(t, err.Error(), "can't stat") + err = CopyFile(srcFile, "/dev/null") + assert.Error(t, err) } func TestListFiles(t *testing.T) { @@ -127,3 +141,135 @@ func TestSanitizePath(t *testing.T) { }) } } + +func TestMoveFile(t *testing.T) { + t.Run("same device move", func(t *testing.T) { + // create temp source file + srcFile := filepath.Join(os.TempDir(), "move_test_src.txt") + err := os.WriteFile(srcFile, []byte("test content"), 0600) + require.NoError(t, err) + defer os.Remove(srcFile) + + // create temp destination + dstFile := filepath.Join(os.TempDir(), "move_test_dst.txt") + defer os.Remove(dstFile) + + // perform move + err = MoveFile(srcFile, dstFile) + require.NoError(t, err) + + // verify source is gone and destination exists + _, err = os.Stat(srcFile) + assert.True(t, os.IsNotExist(err), "source file should not exist") + + content, err := os.ReadFile(dstFile) //nolint:gosec + require.NoError(t, err) + assert.Equal(t, "test content", string(content)) + }) + + t.Run("move with copy fallback", func(t *testing.T) { + // create source dir and file + srcDir := t.TempDir() + srcFile := filepath.Join(srcDir, "move_test_src2.txt") + err := os.WriteFile(srcFile, []byte("test content"), 0600) + require.NoError(t, err) + + // create destination dir + dstDir := t.TempDir() + dstFile := filepath.Join(dstDir, "subdir", "move_test_dst.txt") + + // perform move + err = MoveFile(srcFile, dstFile) + require.NoError(t, err) + + // verify move succeeded + _, err = os.Stat(srcFile) + assert.True(t, os.IsNotExist(err), "source file should not exist") + + content, err := os.ReadFile(dstFile) //nolint:gosec + require.NoError(t, err) + assert.Equal(t, "test content", string(content)) + }) + + t.Run("errors", func(t *testing.T) { + tests := []struct { + name string + src string + dst string + wantErr string + }{ + { + name: "source not found", + src: "notfound.txt", + dst: "dst.txt", + wantErr: "source file not found", + }, + { + name: "empty source", + src: "", + dst: "dst.txt", + wantErr: "empty source path", + }, + { + name: "empty destination", + src: "src.txt", + dst: "", + wantErr: "empty destination path", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := MoveFile(tt.src, tt.dst) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + }) + } + }) +} + +func TestTouchFile(t *testing.T) { + t.Run("create new", func(t *testing.T) { + tmpDir := t.TempDir() + newFile := filepath.Join(tmpDir, "new.txt") + + err := TouchFile(newFile) + require.NoError(t, err) + + info, err := os.Stat(newFile) + require.NoError(t, err) + assert.Equal(t, int64(0), info.Size()) + assert.True(t, time.Since(info.ModTime()) < time.Second) + }) + + t.Run("update existing", func(t *testing.T) { + tmpDir := t.TempDir() + existingFile := filepath.Join(tmpDir, "existing.txt") + + err := os.WriteFile(existingFile, []byte("test"), 0600) + require.NoError(t, err) + + // get original time and wait a bit + origInfo, err := os.Stat(existingFile) + require.NoError(t, err) + time.Sleep(time.Millisecond * 100) + + err = TouchFile(existingFile) + require.NoError(t, err) + + // check content preserved and time updated + info, err := os.Stat(existingFile) + require.NoError(t, err) + assert.Equal(t, origInfo.Size(), info.Size()) + assert.True(t, info.ModTime().After(origInfo.ModTime())) + }) + + t.Run("errors", func(t *testing.T) { + err := TouchFile("") + require.Error(t, err) + assert.Contains(t, err.Error(), "empty path") + + err = TouchFile("/dev/null/invalid") + require.Error(t, err) + }) +}