primeiro commit
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
// Package files lists the files a rename operates on and applies a finished
|
||||
// rename plan to disk.
|
||||
package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Entry pairs a file currently on disk with the name proposed for it.
|
||||
type Entry struct {
|
||||
Original string
|
||||
Proposed string
|
||||
}
|
||||
|
||||
// pending is one file staged under a temporary name during a rename.
|
||||
type pending struct {
|
||||
original string
|
||||
temp string
|
||||
proposed string
|
||||
}
|
||||
|
||||
// Changed reports whether the entry would actually be renamed.
|
||||
func (e Entry) Changed() bool { return e.Original != e.Proposed }
|
||||
|
||||
// List returns the names of the files directly inside dir, sorted by name.
|
||||
// Subdirectories are ignored: the listing is one level deep and never
|
||||
// recursive. The returned names are base names, not paths.
|
||||
func List(ctx context.Context, dir string) ([]string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading directory %s: %w", dir, err)
|
||||
}
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
names = append(names, entry.Name())
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// Conflicts checks a rename plan and returns, keyed by entry index, the reason
|
||||
// each problematic entry cannot be renamed. An empty result means the plan is
|
||||
// safe to apply. dir is inspected for filesystem entries that are not part of
|
||||
// the plan but would block a destination; a directory that cannot be read
|
||||
// simply contributes no such conflicts.
|
||||
func Conflicts(ctx context.Context, dir string, entries []Entry) map[int]string {
|
||||
problems := make(map[int]string)
|
||||
|
||||
targets := make(map[string][]int, len(entries))
|
||||
for i, e := range entries {
|
||||
switch {
|
||||
case strings.TrimSpace(e.Proposed) == "":
|
||||
problems[i] = "new name is empty"
|
||||
case e.Proposed == "." || e.Proposed == "..":
|
||||
problems[i] = "new name is a directory reference"
|
||||
case strings.ContainsRune(e.Proposed, os.PathSeparator), strings.ContainsRune(e.Proposed, '/'):
|
||||
problems[i] = "new name contains a path separator"
|
||||
}
|
||||
targets[e.Proposed] = append(targets[e.Proposed], i)
|
||||
}
|
||||
|
||||
for _, indexes := range targets {
|
||||
if len(indexes) < 2 {
|
||||
continue
|
||||
}
|
||||
for _, i := range indexes {
|
||||
if _, taken := problems[i]; !taken {
|
||||
problems[i] = "several files would get this name"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
planned := make(map[string]bool, len(entries))
|
||||
for _, e := range entries {
|
||||
planned[e.Original] = true
|
||||
}
|
||||
existing, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return problems
|
||||
}
|
||||
onDisk := make(map[string]bool, len(existing))
|
||||
for _, entry := range existing {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return problems
|
||||
}
|
||||
onDisk[entry.Name()] = true
|
||||
}
|
||||
for i, e := range entries {
|
||||
if _, taken := problems[i]; taken || !e.Changed() {
|
||||
continue
|
||||
}
|
||||
if onDisk[e.Proposed] && !planned[e.Proposed] {
|
||||
problems[i] = "a filesystem entry with this name already exists"
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// Rename applies the plan inside dir and returns the number of files renamed.
|
||||
// Entries whose name is unchanged are skipped. Renaming happens in two passes
|
||||
// through temporary names so that files can swap or rotate names, and a
|
||||
// failure in the first pass is rolled back before the error is returned.
|
||||
func Rename(ctx context.Context, dir string, entries []Entry) (int, error) {
|
||||
problems := Conflicts(ctx, dir, entries)
|
||||
for i, e := range entries {
|
||||
if reason, bad := problems[i]; bad {
|
||||
return 0, fmt.Errorf("cannot rename %s: %s", e.Original, reason)
|
||||
}
|
||||
}
|
||||
|
||||
var staged []pending
|
||||
|
||||
rollbackStaged := func() {
|
||||
for i := len(staged) - 1; i >= 0; i-- {
|
||||
_ = os.Rename(filepath.Join(dir, staged[i].temp), filepath.Join(dir, staged[i].original))
|
||||
}
|
||||
}
|
||||
|
||||
for i, e := range entries {
|
||||
if !e.Changed() {
|
||||
continue
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
rollbackStaged()
|
||||
return 0, err
|
||||
}
|
||||
temp, err := tempName(dir, i, e.Original)
|
||||
if err != nil {
|
||||
rollbackStaged()
|
||||
return 0, fmt.Errorf("preparing rename of %s: %w", e.Original, err)
|
||||
}
|
||||
if err := os.Rename(filepath.Join(dir, e.Original), filepath.Join(dir, temp)); err != nil {
|
||||
rollbackStaged()
|
||||
return 0, fmt.Errorf("preparing rename of %s: %w", e.Original, err)
|
||||
}
|
||||
staged = append(staged, pending{original: e.Original, temp: temp, proposed: e.Proposed})
|
||||
}
|
||||
|
||||
renamed := 0
|
||||
for i, p := range staged {
|
||||
if err := ctx.Err(); err != nil {
|
||||
rollbackCommitted(dir, staged, i)
|
||||
return renamed, err
|
||||
}
|
||||
if err := os.Rename(filepath.Join(dir, p.temp), filepath.Join(dir, p.proposed)); err != nil {
|
||||
rollbackCommitted(dir, staged, i)
|
||||
return renamed, fmt.Errorf("renaming to %s: %w", p.proposed, err)
|
||||
}
|
||||
renamed++
|
||||
}
|
||||
return renamed, nil
|
||||
}
|
||||
|
||||
// maxNameLen is the longest file name accepted by common file systems.
|
||||
const maxNameLen = 255
|
||||
|
||||
// rollbackCommitted moves completed destinations back to temporary names,
|
||||
// then restores every staged file to its original name. The first phase is
|
||||
// necessary for swaps, where an original name may currently be a destination.
|
||||
func rollbackCommitted(dir string, staged []pending, committed int) {
|
||||
for i := committed - 1; i >= 0; i-- {
|
||||
_ = os.Rename(filepath.Join(dir, staged[i].proposed), filepath.Join(dir, staged[i].temp))
|
||||
}
|
||||
for i := len(staged) - 1; i >= 0; i-- {
|
||||
_ = os.Rename(filepath.Join(dir, staged[i].temp), filepath.Join(dir, staged[i].original))
|
||||
}
|
||||
}
|
||||
|
||||
// tempName returns an unused, hard-to-guess intermediate name, short enough
|
||||
// to stay valid on common file systems.
|
||||
func tempName(dir string, i int, original string) (string, error) {
|
||||
var random [8]byte
|
||||
if _, err := rand.Read(random[:]); err != nil {
|
||||
return "", fmt.Errorf("generating temporary name: %w", err)
|
||||
}
|
||||
prefix := fmt.Sprintf(".aehenamer-%d-%s-", i, hex.EncodeToString(random[:]))
|
||||
remaining := maxNameLen - len(prefix)
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
if len(original) > remaining {
|
||||
original = original[:remaining]
|
||||
}
|
||||
name := prefix + original
|
||||
if _, err := os.Lstat(filepath.Join(dir, name)); err == nil {
|
||||
return "", fmt.Errorf("temporary name %q already exists", name)
|
||||
} else if !os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("checking temporary name %q: %w", name, err)
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
package files_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"aehenamer/internal/files"
|
||||
)
|
||||
|
||||
// makeDir creates a temporary directory containing the given files.
|
||||
func makeDir(t *testing.T, names ...string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
for _, name := range names {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(name), 0o600); err != nil {
|
||||
t.Fatalf("creating %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// namesOn returns the sorted file names currently in dir.
|
||||
func namesOn(t *testing.T, dir string) []string {
|
||||
t.Helper()
|
||||
got, err := files.List(context.Background(), dir)
|
||||
if err != nil {
|
||||
t.Fatalf("listing %s: %v", dir, err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func TestList(t *testing.T) {
|
||||
dir := makeDir(t, "b.txt", "a.txt", ".hidden")
|
||||
if err := os.Mkdir(filepath.Join(dir, "sub"), 0o750); err != nil {
|
||||
t.Fatalf("creating subdirectory: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "sub", "deep.txt"), nil, 0o600); err != nil {
|
||||
t.Fatalf("creating nested file: %v", err)
|
||||
}
|
||||
|
||||
got := namesOn(t, dir)
|
||||
want := []string{".hidden", "a.txt", "b.txt"}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Errorf("List() = %v, want %v (directories and nested files must be skipped)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListErrors(t *testing.T) {
|
||||
t.Run("missing directory", func(t *testing.T) {
|
||||
_, err := files.List(context.Background(), filepath.Join(t.TempDir(), "nope"))
|
||||
if err == nil {
|
||||
t.Fatal("List() of a missing directory returned no error")
|
||||
}
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
t.Errorf("error %v does not wrap os.ErrNotExist", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cancelled context", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := files.List(ctx, t.TempDir()); !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("List() = %v, want context.Canceled", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestConflicts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
onDisk []string
|
||||
directories []string
|
||||
entries []files.Entry
|
||||
want map[int]string
|
||||
}{
|
||||
{
|
||||
name: "clean plan",
|
||||
onDisk: []string{"a.txt", "b.txt"},
|
||||
entries: []files.Entry{
|
||||
{Original: "a.txt", Proposed: "1.txt"},
|
||||
{Original: "b.txt", Proposed: "2.txt"},
|
||||
},
|
||||
want: map[int]string{},
|
||||
},
|
||||
{
|
||||
name: "swapping names is allowed",
|
||||
onDisk: []string{"a.txt", "b.txt"},
|
||||
entries: []files.Entry{
|
||||
{Original: "a.txt", Proposed: "b.txt"},
|
||||
{Original: "b.txt", Proposed: "a.txt"},
|
||||
},
|
||||
want: map[int]string{},
|
||||
},
|
||||
{
|
||||
name: "duplicate targets",
|
||||
onDisk: []string{"a.txt", "b.txt"},
|
||||
entries: []files.Entry{
|
||||
{Original: "a.txt", Proposed: "same.txt"},
|
||||
{Original: "b.txt", Proposed: "same.txt"},
|
||||
},
|
||||
want: map[int]string{0: "several files would get this name", 1: "several files would get this name"},
|
||||
},
|
||||
{
|
||||
name: "empty name",
|
||||
onDisk: []string{"a.txt"},
|
||||
entries: []files.Entry{
|
||||
{Original: "a.txt", Proposed: " "},
|
||||
},
|
||||
want: map[int]string{0: "new name is empty"},
|
||||
},
|
||||
{
|
||||
name: "path separator",
|
||||
onDisk: []string{"a.txt"},
|
||||
entries: []files.Entry{
|
||||
{Original: "a.txt", Proposed: "sub/a.txt"},
|
||||
},
|
||||
want: map[int]string{0: "new name contains a path separator"},
|
||||
},
|
||||
{
|
||||
name: "directory reference",
|
||||
onDisk: []string{"a.txt"},
|
||||
entries: []files.Entry{
|
||||
{Original: "a.txt", Proposed: ".."},
|
||||
},
|
||||
want: map[int]string{0: "new name is a directory reference"},
|
||||
},
|
||||
{
|
||||
name: "would overwrite a file that is not part of the plan",
|
||||
onDisk: []string{"a.txt", "keep.txt"},
|
||||
entries: []files.Entry{
|
||||
{Original: "a.txt", Proposed: "keep.txt"},
|
||||
},
|
||||
want: map[int]string{0: "a filesystem entry with this name already exists"},
|
||||
},
|
||||
{
|
||||
name: "would collide with an existing directory",
|
||||
onDisk: []string{"a.txt"},
|
||||
directories: []string{"archive"},
|
||||
entries: []files.Entry{
|
||||
{Original: "a.txt", Proposed: "archive"},
|
||||
},
|
||||
want: map[int]string{0: "a filesystem entry with this name already exists"},
|
||||
},
|
||||
{
|
||||
name: "unchanged names never conflict with themselves",
|
||||
onDisk: []string{"a.txt"},
|
||||
entries: []files.Entry{
|
||||
{Original: "a.txt", Proposed: "a.txt"},
|
||||
},
|
||||
want: map[int]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dir := makeDir(t, tt.onDisk...)
|
||||
for _, name := range tt.directories {
|
||||
if err := os.Mkdir(filepath.Join(dir, name), 0o750); err != nil {
|
||||
t.Fatalf("creating directory %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
got := files.Conflicts(context.Background(), dir, tt.entries)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("Conflicts() = %v, want %v", got, tt.want)
|
||||
}
|
||||
for index, reason := range tt.want {
|
||||
if got[index] != reason {
|
||||
t.Errorf("entry %d: got %q, want %q", index, got[index], reason)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRename(t *testing.T) {
|
||||
t.Run("renames and skips unchanged files", func(t *testing.T) {
|
||||
dir := makeDir(t, "a.txt", "b.txt")
|
||||
renamed, err := files.Rename(context.Background(), dir, []files.Entry{
|
||||
{Original: "a.txt", Proposed: "1.txt"},
|
||||
{Original: "b.txt", Proposed: "b.txt"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Rename() error: %v", err)
|
||||
}
|
||||
if renamed != 1 {
|
||||
t.Errorf("Rename() = %d, want 1", renamed)
|
||||
}
|
||||
if got, want := namesOn(t, dir), []string{"1.txt", "b.txt"}; !slices.Equal(got, want) {
|
||||
t.Errorf("directory holds %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("swaps names", func(t *testing.T) {
|
||||
dir := makeDir(t, "a.txt", "b.txt")
|
||||
if _, err := files.Rename(context.Background(), dir, []files.Entry{
|
||||
{Original: "a.txt", Proposed: "b.txt"},
|
||||
{Original: "b.txt", Proposed: "a.txt"},
|
||||
}); err != nil {
|
||||
t.Fatalf("Rename() error: %v", err)
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(dir, "a.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("reading a.txt: %v", err)
|
||||
}
|
||||
if string(content) != "b.txt" {
|
||||
t.Errorf("a.txt holds %q, want the contents of the old b.txt", content)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rotates names", func(t *testing.T) {
|
||||
dir := makeDir(t, "a.txt", "b.txt", "c.txt")
|
||||
if _, err := files.Rename(context.Background(), dir, []files.Entry{
|
||||
{Original: "a.txt", Proposed: "b.txt"},
|
||||
{Original: "b.txt", Proposed: "c.txt"},
|
||||
{Original: "c.txt", Proposed: "a.txt"},
|
||||
}); err != nil {
|
||||
t.Fatalf("Rename() error: %v", err)
|
||||
}
|
||||
for name, want := range map[string]string{"b.txt": "a.txt", "c.txt": "b.txt", "a.txt": "c.txt"} {
|
||||
content, err := os.ReadFile(filepath.Join(dir, name))
|
||||
if err != nil {
|
||||
t.Fatalf("reading %s: %v", name, err)
|
||||
}
|
||||
if string(content) != want {
|
||||
t.Errorf("%s holds %q, want %q", name, content, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("refuses a conflicting plan", func(t *testing.T) {
|
||||
dir := makeDir(t, "a.txt", "b.txt")
|
||||
_, err := files.Rename(context.Background(), dir, []files.Entry{
|
||||
{Original: "a.txt", Proposed: "same.txt"},
|
||||
{Original: "b.txt", Proposed: "same.txt"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Rename() accepted a conflicting plan")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "a.txt") {
|
||||
t.Errorf("error %v does not name the offending file", err)
|
||||
}
|
||||
if got, want := namesOn(t, dir), []string{"a.txt", "b.txt"}; !slices.Equal(got, want) {
|
||||
t.Errorf("directory holds %v, want the untouched %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rolls back when a file disappears", func(t *testing.T) {
|
||||
dir := makeDir(t, "a.txt", "b.txt")
|
||||
if err := os.Remove(filepath.Join(dir, "b.txt")); err != nil {
|
||||
t.Fatalf("removing b.txt: %v", err)
|
||||
}
|
||||
_, err := files.Rename(context.Background(), dir, []files.Entry{
|
||||
{Original: "a.txt", Proposed: "1.txt"},
|
||||
{Original: "b.txt", Proposed: "2.txt"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Rename() succeeded although a file was missing")
|
||||
}
|
||||
if got, want := namesOn(t, dir), []string{"a.txt"}; !slices.Equal(got, want) {
|
||||
t.Errorf("directory holds %v, want the rolled back %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rolls back when the final pass fails", func(t *testing.T) {
|
||||
dir := makeDir(t, "a.txt", "b.txt")
|
||||
tooLong := strings.Repeat("x", 256)
|
||||
|
||||
renamed, err := files.Rename(context.Background(), dir, []files.Entry{
|
||||
{Original: "a.txt", Proposed: "1.txt"},
|
||||
{Original: "b.txt", Proposed: tooLong},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Rename() succeeded with an overlong destination name")
|
||||
}
|
||||
if renamed != 1 {
|
||||
t.Errorf("Rename() = %d completed renames before rollback, want 1", renamed)
|
||||
}
|
||||
if got, want := namesOn(t, dir), []string{"a.txt", "b.txt"}; !slices.Equal(got, want) {
|
||||
t.Errorf("directory holds files %v, want the rolled back %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cancelled context stops before touching anything", func(t *testing.T) {
|
||||
dir := makeDir(t, "a.txt")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := files.Rename(ctx, dir, []files.Entry{{Original: "a.txt", Proposed: "1.txt"}}); !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("Rename() = %v, want context.Canceled", err)
|
||||
}
|
||||
if got, want := namesOn(t, dir), []string{"a.txt"}; !slices.Equal(got, want) {
|
||||
t.Errorf("directory holds %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user