206 lines
6.1 KiB
Go
206 lines
6.1 KiB
Go
// 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
|
|
}
|