Files
playlink/internal/engine/ops.go
T
cdnutterandClaude Opus 5.5 42161e4287 Add renaming slave folders to the master's spelling
The "N folders are named differently" warning now offers to rename the
slave folders to match the master, marked as the recommended first step
after a scan (also linked from the dashboard card).

- A preview lists each rename with the differing characters highlighted;
  renames are selectable, and conflicts (the slave already has a folder
  with the master's name, symlinks, the quarantine) are skipped with a
  reason.
- Only renames the server computes itself are applied; a request can
  only narrow them down, never name arbitrary paths. Only slave folders
  are renamed.
- Case-only renames go through a temporary name so they also work on
  case-insensitive filesystems; a rename interrupted by a crash is
  completed on startup (the folder is never removed).
- Renaming everything repeats until folders revealed inside renamed ones
  (an album under a renamed artist) are fixed too, then rescans.
- Each rename is an operation in History that can be undone (which also
  rescans). Dry run records renames as planned.

Also fixes the Review list not refreshing after a job that started and
finished between two status polls, and logs the new path of operations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:20:52 -07:00

959 lines
27 KiB
Go

package engine
import (
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/cdnutter/playlink/internal/fsops"
"github.com/cdnutter/playlink/internal/probe"
"github.com/cdnutter/playlink/internal/store"
)
type LinkOptions struct {
// AllowDowngrade confirms linking when the master copy scored lower.
AllowDowngrade bool
// KeepFolder keeps a cross-folder match in the slave's current folder
// instead of mirroring the master's folder structure.
KeepFolder bool
Trigger string
}
// LinkTarget is where the slave file ends up after linking. A match in the
// same folder keeps the slave's folder (only the file name follows the
// master). A match found in another folder, such as a single matched to its
// album version, mirrors the master's path so the slave gets the album's
// folder structure, unless keepFolder is set.
func LinkTarget(relSlave, relMaster string, keepFolder bool) string {
if relMaster == "" {
return relSlave
}
sd := filepath.Dir(relSlave)
if keepFolder || strings.EqualFold(sd, filepath.Dir(relMaster)) {
return filepath.Join(sd, filepath.Base(relMaster))
}
return relMaster
}
// snapshot holds the verified state of both files right before an operation.
type snapshot struct {
masterAbs, slaveAbs string
mi, si fsops.Info
}
// preflight re-validates a match against the filesystem: both files must be
// plain regular files inside their roots, on one device, and unchanged since
// the scan recorded them.
func preflight(l *libs, m store.Match) (snapshot, error) {
var s snapshot
var err error
if s.masterAbs, err = l.master.Resolve(m.RelMaster); err != nil {
return s, err
}
if s.slaveAbs, err = l.slave.Resolve(m.RelSlave); err != nil {
return s, err
}
if s.mi, err = fsops.RegularFile(s.masterAbs); err != nil {
return s, fmt.Errorf("master file: %w", err)
}
if s.si, err = fsops.RegularFile(s.slaveAbs); err != nil {
return s, fmt.Errorf("slave file: %w", err)
}
if s.mi.Ino != m.MasterIno || !s.mi.Unchanged(m.MasterSize, m.MasterMtime) {
return s, fmt.Errorf("master %w", fsops.ErrChanged)
}
if s.si.Ino != m.SlaveIno || !s.si.Unchanged(m.SlaveSize, m.SlaveMtime) {
return s, fmt.Errorf("slave %w", fsops.ErrChanged)
}
if s.mi.Dev != s.si.Dev || s.mi.Dev != l.dev {
return s, fsops.ErrCrossDevice
}
if s.mi.SameInode(s.si) {
return s, errors.New("already linked")
}
return s, nil
}
func quarantinePath(l *libs, opID int64, side, rel string) string {
return filepath.Join(l.quarantine, strconv.FormatInt(opID, 10), side, rel)
}
// quarantine adds an extra hardlink of p (whose identity is in) inside the
// quarantine. The original stays in place; nothing is lost if a later step
// fails. The caller removes or replaces p afterwards.
func quarantine(p string, in fsops.Info, q string) error {
if err := fsops.MkdirAllLike(filepath.Dir(q)); err != nil {
return err
}
return fsops.LinkNew(p, in, q)
}
// dropQuarantine removes a quarantine link that turned out not to be needed.
func dropQuarantine(l *libs, q string, keep fsops.Info) {
if err := fsops.RemoveLink(q, keep); err == nil {
pruneEmpty(filepath.Dir(q), l.quarantine)
}
}
// removeOriginal deletes an original file after it was safely superseded. The
// inode must still be the one we verified.
//
// With a quarantine copy q, identity is proven by p and q being the same
// file right now. Timestamps are deliberately not compared: FUSE filesystems
// such as Unraid's /mnt/user can report them inconsistently between calls,
// and any change to the file is preserved in quarantine anyway because both
// names point at the same data. Without a quarantine copy (a byte-identical
// file), the size and modification time must also be unchanged.
func removeOriginal(p string, expect fsops.Info, q string) error {
now, err := fsops.RegularFile(p)
if err != nil {
return err
}
if q != "" {
qi, err := fsops.RegularFile(q)
if err != nil {
return fmt.Errorf("quarantine copy: %w", err)
}
if !now.SameInode(qi) {
return fmt.Errorf("%s is no longer the file that was quarantined: %w", p, fsops.ErrChanged)
}
} else {
if !now.SameInode(expect) {
return fmt.Errorf("%s was replaced by another file: %w", p, fsops.ErrChanged)
}
if !now.Unchanged(expect.Size, expect.MtimeNs) {
return fmt.Errorf("%s changed after it was compared (size %d -> %d, mtime %d -> %d): %w",
p, expect.Size, now.Size, expect.MtimeNs, now.MtimeNs, fsops.ErrChanged)
}
}
if err := os.Remove(p); err != nil {
return err
}
return fsops.SyncDir(filepath.Dir(p))
}
// testHookBeforeRemove lets tests change the filesystem between creating the
// new link and removing the original. Always nil outside tests.
var testHookBeforeRemove func(slaveAbs string)
// stillQuarantined reports whether the original at p is intact and still the
// quarantined file, i.e. the quarantine copy is not needed after a rollback.
func stillQuarantined(p, q string) bool {
pi, err1 := fsops.RegularFile(p)
qi, err2 := fsops.RegularFile(q)
return err1 == nil && err2 == nil && pi.SameInode(qi)
}
// pruneEmpty removes empty directories from dir upward, stopping at stop.
func pruneEmpty(dir, stop string) {
for dir != stop && fsops.Within(stop, dir) {
if err := os.Remove(dir); err != nil {
return
}
dir = filepath.Dir(dir)
}
}
func (e *Engine) begin(ctx context.Context, o *store.Operation) error {
o.DryRun = e.st.DryRun(ctx)
if o.Trigger == "" {
o.Trigger = "manual"
}
o.Status = store.OpRunning
if o.DryRun {
o.Status = store.OpPlanned
}
return e.st.CreateOp(ctx, o)
}
func (e *Engine) finish(ctx context.Context, o *store.Operation, err error) error {
if err != nil {
o.Status, o.Error = store.OpFailed, err.Error()
e.log.Warn("operation failed", "op", o.ID, "action", o.Action, "slave", o.RelSlave, "err", err)
} else {
o.Status = store.OpDone
e.log.Info("operation done", "op", o.ID, "action", o.Action, "slave", o.RelSlave, "master", o.RelMaster, "new", o.RelNew)
}
if ferr := e.st.FinishOp(ctx, o.ID, o.Status, o.Error); ferr != nil && err == nil {
return ferr
}
return err
}
func verdictOf(m store.Match) probe.Verdict {
var v probe.Verdict
_ = json.Unmarshal(m.Verdict, &v)
return v
}
// Link replaces the slave file with a hardlink of the master file.
//
// Identical files are re-compared byte for byte right before linking and no
// quarantine copy is kept (the data exists in the master). Non-identical files
// are quarantined first so the operation can be undone.
func (e *Engine) Link(ctx context.Context, matchID int64, opt LinkOptions) (store.Operation, error) {
e.mu.Lock()
defer e.mu.Unlock()
m, err := e.st.Match(ctx, matchID)
if err != nil {
return store.Operation{}, err
}
l, err := e.loadPair(ctx, m.PairID)
if err != nil {
return store.Operation{}, err
}
switch m.Status {
case store.StatusIdentical, store.StatusDifferent, store.StatusSimilar:
default:
return store.Operation{}, fmt.Errorf("cannot link a match with status %q", m.Status)
}
if m.Ignored {
return store.Operation{}, errors.New("match is ignored; un-ignore it first")
}
identical := m.Status == store.StatusIdentical
if !identical && verdictOf(m).Better == "slave" && !opt.AllowDowngrade {
return store.Operation{}, ErrDowngrade
}
s, err := preflight(l, m)
if err != nil {
return store.Operation{}, err
}
if identical {
same, err := fsops.SameContent(s.masterAbs, s.slaveAbs)
if err != nil {
return store.Operation{}, err
}
if !same {
e.refresh(ctx, l, m.RelSlave)
return store.Operation{}, errors.New("files are no longer identical; moved to manual review")
}
}
newRel := LinkTarget(m.RelSlave, m.RelMaster, opt.KeepFolder)
newAbs, err := l.slave.Resolve(newRel)
if err != nil {
return store.Operation{}, err
}
rename := newRel != m.RelSlave
// preexisting: the target already is a hardlink of the master (e.g. left
// behind by an earlier failed link). Then only the original is retired.
preexisting := false
if rename {
if cur, err := fsops.Lstat(newAbs); err == nil {
if !cur.Mode.IsRegular() || !cur.SameInode(s.mi) {
return store.Operation{}, fmt.Errorf("slave already has a different file at %s: %w", newRel, fsops.ErrExists)
}
preexisting = true
}
}
o := store.Operation{PairID: l.pair.ID, Action: store.ActLink, RelMaster: m.RelMaster, RelSlave: m.RelSlave,
RelNew: newRel, Trigger: opt.Trigger, MatchKind: m.Kind, MatchReason: m.Reason, LinkPreexisted: preexisting}
if identical {
o.Bytes = s.si.Size // space freed
} else {
o.QuarantineSide = "slave"
}
if err := e.begin(ctx, &o); err != nil {
return o, err
}
if o.DryRun {
return o, nil
}
err = func() error {
var q string
if !identical {
q = quarantinePath(l, o.ID, "slave", m.RelSlave)
if err := quarantine(s.slaveAbs, s.si, q); err != nil {
return fmt.Errorf("quarantine: %w", err)
}
o.QuarantinePath = q
if _, err := e.st.DB.ExecContext(ctx, `UPDATE operations SET quarantine_path = ? WHERE id = ?`, q, o.ID); err != nil {
dropQuarantine(l, q, s.si)
return err
}
}
if !rename {
if err := fsops.ReplaceWithLink(s.masterAbs, s.mi, s.slaveAbs); err != nil {
if q != "" {
if cur, e2 := fsops.Lstat(s.slaveAbs); e2 == nil && cur.SameInode(s.si) {
dropQuarantine(l, q, s.si) // original untouched
e.clearQuarantine(ctx, o.ID)
}
}
return err
}
return nil
}
if !preexisting {
err := fsops.MkdirAllLike(filepath.Dir(newAbs))
if err == nil {
err = fsops.LinkNew(s.masterAbs, s.mi, newAbs)
}
if err != nil {
pruneEmpty(filepath.Dir(newAbs), l.slave.Path)
if q != "" {
dropQuarantine(l, q, s.si)
e.clearQuarantine(ctx, o.ID)
}
return err
}
}
if testHookBeforeRemove != nil {
testHookBeforeRemove(s.slaveAbs)
}
if err := removeOriginal(s.slaveAbs, s.si, q); err != nil {
// Never leave both copies behind: undo the new link (it is only
// an extra name for the master file) and the quarantine copy.
if !preexisting {
if rbErr := fsops.RemoveLink(newAbs, s.mi); rbErr == nil {
pruneEmpty(filepath.Dir(newAbs), l.slave.Path)
}
}
if q != "" && stillQuarantined(s.slaveAbs, q) {
dropQuarantine(l, q, s.si)
e.clearQuarantine(ctx, o.ID)
}
return fmt.Errorf("could not remove the original slave file, so nothing was changed: %w", err)
}
pruneEmpty(filepath.Dir(s.slaveAbs), l.slave.Path) // e.g. the single's now-empty folder
return nil
}()
err = e.finish(ctx, &o, err)
e.refresh(ctx, l, m.RelSlave, m)
if rename {
e.refresh(ctx, l, newRel, m)
}
return o, err
}
func (e *Engine) clearQuarantine(ctx context.Context, opID int64) {
_, _ = e.st.DB.ExecContext(ctx, `UPDATE operations SET quarantine_path = '' WHERE id = ?`, opID)
}
// Promote is the override path: the slave copy is better, so it replaces the
// master file (whose original is quarantined) and both libraries share it.
func (e *Engine) Promote(ctx context.Context, matchID int64, confirmed bool) (store.Operation, error) {
if !confirmed {
return store.Operation{}, ErrConfirm
}
return e.promote(ctx, matchID, "manual")
}
func (e *Engine) promote(ctx context.Context, matchID int64, trigger string) (store.Operation, error) {
e.mu.Lock()
defer e.mu.Unlock()
m, err := e.st.Match(ctx, matchID)
if err != nil {
return store.Operation{}, err
}
l, err := e.loadPair(ctx, m.PairID)
if err != nil {
return store.Operation{}, err
}
if m.Status != store.StatusDifferent && m.Status != store.StatusSimilar {
return store.Operation{}, fmt.Errorf("cannot promote a match with status %q", m.Status)
}
s, err := preflight(l, m)
if err != nil {
return store.Operation{}, err
}
newMasterRel := filepath.Join(filepath.Dir(m.RelMaster), filepath.Base(m.RelSlave))
newMasterAbs, err := l.master.Resolve(newMasterRel)
if err != nil {
return store.Operation{}, err
}
rename := newMasterRel != m.RelMaster
if rename {
if _, err := os.Lstat(newMasterAbs); err == nil {
return store.Operation{}, fmt.Errorf("master already has %s: %w", newMasterRel, fsops.ErrExists)
}
}
o := store.Operation{PairID: l.pair.ID, Action: store.ActPromote, RelMaster: m.RelMaster, RelSlave: m.RelSlave,
RelNew: newMasterRel, QuarantineSide: "master", Trigger: trigger, MatchKind: m.Kind, MatchReason: m.Reason}
if err := e.begin(ctx, &o); err != nil {
return o, err
}
if o.DryRun {
return o, nil
}
err = func() error {
q := quarantinePath(l, o.ID, "master", m.RelMaster)
if err := quarantine(s.masterAbs, s.mi, q); err != nil {
return fmt.Errorf("quarantine: %w", err)
}
o.QuarantinePath = q
if _, err := e.st.DB.ExecContext(ctx, `UPDATE operations SET quarantine_path = ? WHERE id = ?`, q, o.ID); err != nil {
dropQuarantine(l, q, s.mi)
return err
}
if !rename {
if err := fsops.ReplaceWithLink(s.slaveAbs, s.si, s.masterAbs); err != nil {
if cur, e2 := fsops.Lstat(s.masterAbs); e2 == nil && cur.SameInode(s.mi) {
dropQuarantine(l, q, s.mi)
e.clearQuarantine(ctx, o.ID)
}
return err
}
return nil
}
if err := fsops.LinkNew(s.slaveAbs, s.si, newMasterAbs); err != nil {
dropQuarantine(l, q, s.mi)
e.clearQuarantine(ctx, o.ID)
return err
}
if err := removeOriginal(s.masterAbs, s.mi, q); err != nil {
_ = fsops.RemoveLink(newMasterAbs, s.si)
if stillQuarantined(s.masterAbs, q) {
dropQuarantine(l, q, s.mi)
e.clearQuarantine(ctx, o.ID)
}
return fmt.Errorf("could not remove the original master file, so nothing was changed: %w", err)
}
return nil
}()
err = e.finish(ctx, &o, err)
promoted := m
if err == nil {
promoted.RelMaster = newMasterRel // the master file may have been renamed
}
e.refresh(ctx, l, m.RelSlave, promoted)
return o, err
}
// ---- build (fresh slave library) and remove ----
// expand turns a list of relative files/folders into audio files under root.
func expand(ctx context.Context, l *libs, root fsops.Root, rels []string) ([]string, error) {
set := map[string]fsops.Info{}
for _, rel := range rels {
abs, err := root.Resolve(rel)
if err != nil {
return nil, err
}
fi, err := os.Lstat(abs)
if err != nil {
return nil, err
}
switch {
case fi.IsDir():
sub, err := fsops.NewRoot(abs)
if err != nil {
return nil, err
}
files := map[string]fsops.Info{}
if _, err := walk(ctx, l, sub, files); err != nil {
return nil, err
}
for r, in := range files {
set[filepath.Join(rel, r)] = in
}
case fi.Mode().IsRegular() && l.hasExt(abs):
set[filepath.Clean(rel)] = fsops.Info{}
}
}
return sortedKeys(set), nil
}
// StartBuild hardlinks the chosen master files/folders into the slave library,
// mirroring the master path. Existing slave files are never overwritten.
func (e *Engine) StartBuild(pairID int64, rels []string) (*Job, error) {
return e.startJob(pairID, "build", func(ctx context.Context, j *Job) error {
l, err := e.loadPair(ctx, pairID)
if err != nil {
return err
}
files, err := expand(ctx, l, l.master, rels)
if err != nil {
return err
}
j.phase("Linking into slave library", int64(len(files)))
for _, rel := range files {
if ctx.Err() != nil {
return ctx.Err()
}
e.buildOne(ctx, j, l, rel)
j.tick()
}
return nil
})
}
func (e *Engine) buildOne(ctx context.Context, j *Job, l *libs, rel string) {
e.mu.Lock()
defer e.mu.Unlock()
masterAbs, err := l.master.Resolve(rel)
if err != nil {
j.skip(rel + ": " + err.Error())
return
}
slaveAbs, err := l.slave.Resolve(rel)
if err != nil {
j.skip(rel + ": " + err.Error())
return
}
mi, err := fsops.RegularFile(masterAbs)
if err != nil {
j.skip(rel + ": " + err.Error())
return
}
if si, err := fsops.Lstat(slaveAbs); err == nil {
if si.SameInode(mi) {
j.count("already linked")
} else {
j.skip(rel + ": a different file already exists in the slave library (scan, then use Review)")
}
return
}
o := store.Operation{PairID: l.pair.ID, Action: store.ActBuild, RelMaster: rel, RelSlave: rel, RelNew: rel, Trigger: "build"}
if err := e.begin(ctx, &o); err != nil {
j.skip(rel + ": " + err.Error())
return
}
if o.DryRun {
j.count("planned")
return
}
err = fsops.MkdirAllLike(filepath.Dir(slaveAbs))
if err == nil {
err = fsops.LinkNew(masterAbs, mi, slaveAbs)
}
if err = e.finish(ctx, &o, err); err != nil {
j.skip(rel + ": " + err.Error())
return
}
j.count("linked")
e.refresh(ctx, l, rel)
}
// StartRemove deletes slave files that are hardlinks of the master file at the
// same path. Anything that is not such a link is left alone.
func (e *Engine) StartRemove(pairID int64, rels []string) (*Job, error) {
return e.startJob(pairID, "remove", func(ctx context.Context, j *Job) error {
l, err := e.loadPair(ctx, pairID)
if err != nil {
return err
}
files, err := expand(ctx, l, l.slave, rels)
if err != nil {
return err
}
j.phase("Removing links from slave library", int64(len(files)))
for _, rel := range files {
if ctx.Err() != nil {
return ctx.Err()
}
e.removeOne(ctx, j, l, rel)
j.tick()
}
return nil
})
}
func (e *Engine) removeOne(ctx context.Context, j *Job, l *libs, rel string) {
e.mu.Lock()
defer e.mu.Unlock()
masterAbs, err1 := l.master.Resolve(rel)
slaveAbs, err2 := l.slave.Resolve(rel)
if err1 != nil || err2 != nil {
j.skip(rel + ": invalid path")
return
}
mi, err := fsops.RegularFile(masterAbs)
if err != nil {
j.skip(rel + ": no master file at the same path, so it is not removed")
return
}
si, err := fsops.RegularFile(slaveAbs)
if err != nil {
j.skip(rel + ": " + err.Error())
return
}
if !si.SameInode(mi) {
j.skip(rel + ": not a hardlink of the master file, so it is not removed")
return
}
o := store.Operation{PairID: l.pair.ID, Action: store.ActRemove, RelMaster: rel, RelSlave: rel, Trigger: "build"}
if err := e.begin(ctx, &o); err != nil {
j.skip(rel + ": " + err.Error())
return
}
if o.DryRun {
j.count("planned")
return
}
err = fsops.RemoveLink(slaveAbs, mi)
if err = e.finish(ctx, &o, err); err != nil {
j.skip(rel + ": " + err.Error())
return
}
pruneEmpty(filepath.Dir(slaveAbs), l.slave.Path)
j.count("removed")
e.refresh(ctx, l, rel)
}
// ---- undo, restore and purge ----
// Undo reverts a completed operation. Every branch verifies that the current
// file is still exactly what the operation left behind before touching it.
func (e *Engine) Undo(ctx context.Context, opID int64) error {
e.mu.Lock()
defer e.mu.Unlock()
o, err := e.st.Op(ctx, opID)
if err != nil {
return err
}
if o.Status != store.OpDone || o.UndoneAt != nil {
return errors.New("only completed operations that were not undone can be reverted")
}
if o.PurgedAt != nil {
return errors.New("the quarantined original was already purged")
}
l, err := e.loadPair(ctx, o.PairID)
if err != nil {
return err
}
if o.Action == store.ActRename {
if err := undoRename(l, o); err != nil {
return err
}
if err := e.st.MarkUndone(ctx, o.ID); err != nil {
return err
}
e.log.Info("folder rename undone", "op", o.ID, "folder", o.RelNew)
_, _ = e.StartScan(o.PairID, "undo") // paths changed; ErrBusy just means a job already runs
return nil
}
res := func(r fsops.Root, rel string) string {
p, e := r.Resolve(rel)
if e != nil && err == nil {
err = e
}
return p
}
masterAbs := res(l.master, o.RelMaster)
slaveAbs := res(l.slave, o.RelSlave)
if err != nil {
return err
}
switch o.Action {
case store.ActBuild:
mi, err := fsops.RegularFile(masterAbs)
if err != nil {
return err
}
if err := fsops.RemoveLink(slaveAbs, mi); err != nil {
return err
}
pruneEmpty(filepath.Dir(slaveAbs), l.slave.Path)
case store.ActRemove:
mi, err := fsops.RegularFile(masterAbs)
if err != nil {
return err
}
if err := fsops.MkdirAllLike(filepath.Dir(slaveAbs)); err != nil {
return err
}
if err := fsops.LinkNew(masterAbs, mi, slaveAbs); err != nil {
return err
}
case store.ActLink:
newAbs := res(l.slave, o.RelNew)
if err != nil {
return err
}
mi, err := fsops.RegularFile(masterAbs)
if err != nil {
return err
}
cur, err := fsops.RegularFile(newAbs)
if err != nil {
return err
}
if !cur.SameInode(mi) {
return errors.New("the slave file is no longer linked to the master file; refusing to undo")
}
renamed := o.RelNew != o.RelSlave
if renamed {
if _, err := os.Lstat(slaveAbs); err == nil {
return fmt.Errorf("%s: %w", o.RelSlave, fsops.ErrExists)
}
// The original folder may have been pruned when the link moved
// the file into the master's folder structure.
if err := fsops.MkdirAllLike(filepath.Dir(slaveAbs)); err != nil {
return err
}
}
if o.QuarantinePath == "" {
// Identical link: give the slave its own copy of the bytes again.
if err := fsops.CopyReplace(masterAbs, slaveAbs, mi.Mode); err != nil {
return err
}
} else {
qi, err := e.quarantined(l, o.QuarantinePath)
if err != nil {
return err
}
if renamed {
err = fsops.LinkNew(o.QuarantinePath, qi, slaveAbs)
} else {
err = fsops.ReplaceWithLink(o.QuarantinePath, qi, slaveAbs)
}
if err != nil {
return err
}
dropQuarantine(l, o.QuarantinePath, qi)
}
if renamed && !o.LinkPreexisted {
if err := fsops.RemoveLink(newAbs, mi); err != nil {
return err
}
pruneEmpty(filepath.Dir(newAbs), l.slave.Path)
}
e.refresh(ctx, l, o.RelNew)
case store.ActPromote:
newMasterAbs := res(l.master, o.RelNew)
if err != nil {
return err
}
si, err := fsops.RegularFile(slaveAbs)
if err != nil {
return err
}
cur, err := fsops.RegularFile(newMasterAbs)
if err != nil {
return err
}
if !cur.SameInode(si) {
return errors.New("the master file changed after promotion; refusing to undo")
}
qi, err := e.quarantined(l, o.QuarantinePath)
if err != nil {
return err
}
if o.RelNew == o.RelMaster {
err = fsops.ReplaceWithLink(o.QuarantinePath, qi, masterAbs)
} else {
if _, e2 := os.Lstat(masterAbs); e2 == nil {
return fmt.Errorf("%s: %w", o.RelMaster, fsops.ErrExists)
}
if err = fsops.LinkNew(o.QuarantinePath, qi, masterAbs); err == nil {
err = fsops.RemoveLink(newMasterAbs, si)
}
}
if err != nil {
return err
}
dropQuarantine(l, o.QuarantinePath, qi)
default:
return fmt.Errorf("cannot undo %q", o.Action)
}
if err := e.st.MarkUndone(ctx, o.ID); err != nil {
return err
}
e.log.Info("operation undone", "op", o.ID, "action", o.Action)
// Restore the original pairing (the master path before any promote rename).
e.refresh(ctx, l, o.RelSlave, store.Match{RelMaster: o.RelMaster, Kind: o.MatchKind, Reason: o.MatchReason})
return nil
}
// quarantined validates a quarantine path recorded in the database.
func (e *Engine) quarantined(l *libs, q string) (fsops.Info, error) {
q = filepath.Clean(q)
if !fsops.Within(l.quarantine, q) || q == l.quarantine {
return fsops.Info{}, fmt.Errorf("quarantine path %s: %w", q, fsops.ErrOutsideRoot)
}
return fsops.RegularFile(q)
}
// Purge permanently deletes a quarantined original.
func (e *Engine) Purge(ctx context.Context, opID int64) error {
e.mu.Lock()
defer e.mu.Unlock()
o, err := e.st.Op(ctx, opID)
if err != nil {
return err
}
return e.purge(ctx, o)
}
func (e *Engine) purge(ctx context.Context, o store.Operation) error {
if o.QuarantinePath == "" || o.PurgedAt != nil || o.UndoneAt != nil {
return errors.New("nothing to purge for this operation")
}
l, err := e.loadPair(ctx, o.PairID)
if err != nil {
return err
}
opDir := filepath.Join(l.quarantine, strconv.FormatInt(o.ID, 10))
if !fsops.Within(l.quarantine, filepath.Clean(o.QuarantinePath)) || !fsops.Within(opDir, filepath.Clean(o.QuarantinePath)) {
return fsops.ErrOutsideRoot
}
if err := os.RemoveAll(opDir); err != nil {
return err
}
e.log.Info("quarantine purged", "op", o.ID, "path", o.QuarantinePath)
return e.st.MarkPurged(ctx, o.ID)
}
// Maintenance purges expired quarantine entries and prunes sessions.
func (e *Engine) Maintenance(ctx context.Context) {
e.st.PruneSessions(ctx)
pairs, err := e.st.Pairs(ctx)
if err != nil {
return
}
for _, p := range pairs {
if p.RetentionDays <= 0 {
continue // keep forever
}
cutoff := time.Now().Add(-time.Duration(p.RetentionDays) * 24 * time.Hour).Unix()
ops, err := e.st.ExpiredQuarantine(ctx, p.ID, cutoff)
if err != nil {
continue
}
for _, o := range ops {
e.mu.Lock()
if err := e.purge(ctx, o); err != nil {
e.log.Warn("retention purge failed", "op", o.ID, "err", err)
}
e.mu.Unlock()
}
}
}
// Recover runs at startup: operations left running by a crash are marked
// interrupted and their disposable staging files are removed. Originals are
// never at risk because every replacement is an atomic rename.
func (e *Engine) Recover(ctx context.Context) {
ops, err := e.st.RunningOps(ctx)
if err != nil {
return
}
for _, o := range ops {
if o.Action == store.ActRename {
if p, err := e.st.Pair(ctx, o.PairID); err == nil {
recoverRename(p, o, e.log)
}
_ = e.st.FinishOp(ctx, o.ID, store.OpInterrupted, "interrupted by shutdown; rescan to verify state")
continue
}
if p, err := e.st.Pair(ctx, o.PairID); err == nil {
for _, root := range []string{p.SlaveRoot, p.MasterRoot} {
for _, rel := range []string{o.RelSlave, o.RelNew, o.RelMaster} {
if rel == "" {
continue
}
dir := filepath.Join(root, filepath.Dir(rel))
ents, _ := os.ReadDir(dir)
for _, en := range ents {
if strings.HasPrefix(en.Name(), fsops.TmpPrefix) && en.Type().IsRegular() {
_ = os.Remove(filepath.Join(dir, en.Name()))
}
}
}
}
}
_ = e.st.FinishOp(ctx, o.ID, store.OpInterrupted, "interrupted by shutdown; rescan to verify state")
e.log.Warn("marked interrupted operation", "op", o.ID)
}
}
// ---- browsing (build mode) ----
type TreeEntry struct {
Name string `json:"name"`
Rel string `json:"rel"`
Dir bool `json:"dir"`
Size int64 `json:"size,omitempty"`
State string `json:"state,omitempty"` // files: linked, conflict, missing
Linked int `json:"linked,omitempty"`
}
// Tree lists a master directory and shows how much of it is in the slave.
func (e *Engine) Tree(ctx context.Context, pairID int64, rel string) ([]TreeEntry, error) {
l, err := e.loadPair(ctx, pairID)
if err != nil {
return nil, err
}
dir := l.master.Path
if rel != "" {
if dir, err = l.master.Resolve(rel); err != nil {
return nil, err
}
}
ents, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
linked, _ := e.st.LinkedMasterPaths(ctx, pairID, rel)
perChild := map[string]int{}
for _, p := range linked {
sub := p
if rel != "" {
sub = strings.TrimPrefix(p, rel+string(filepath.Separator))
}
if i := strings.IndexByte(sub, filepath.Separator); i > 0 {
perChild[sub[:i]]++
}
}
out := []TreeEntry{}
for _, en := range ents {
name := en.Name()
if strings.HasPrefix(name, ".") || en.Type()&fs.ModeSymlink != 0 {
continue
}
child := name
if rel != "" {
child = filepath.Join(rel, name)
}
te := TreeEntry{Name: name, Rel: child, Dir: en.IsDir()}
if en.IsDir() {
te.Linked = perChild[name]
} else {
if !l.hasExt(name) {
continue
}
mi, err := fsops.Lstat(filepath.Join(dir, name))
if err != nil {
continue
}
te.Size = mi.Size
te.State = "missing"
if sp, err := l.slave.Resolve(child); err == nil {
if si, err := fsops.Lstat(sp); err == nil {
te.State = "conflict"
if si.SameInode(mi) {
te.State = "linked"
}
}
}
}
out = append(out, te)
}
sort.Slice(out, func(i, j int) bool {
if out[i].Dir != out[j].Dir {
return out[i].Dir
}
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
})
return out, nil
}