Fix cross-folder links leaving the original next to the new link

When a match lives in another folder than the master (e.g. Game Rips/ vs
Video Games/), linking creates the new link at the master's path and then
removes the original. That removal re-checked the original's modification
time, which FUSE filesystems such as Unraid's /mnt/user can report
inconsistently; the check then failed after the link was already made,
leaving both files and a failed operation whose quarantine copy was hidden.

- Once the original is quarantined, verify it by identity (same inode as
  the quarantine copy) instead of by timestamp. Identical files without a
  quarantine copy keep the strict check, now with a detailed error.
- If the original still cannot be removed, roll back: remove the new link
  and the unneeded quarantine copy so nothing changes. If the original was
  swapped for another file mid-way, its quarantine copy is kept.
- Linking again when the master's path already holds a hardlink of the
  master only retires the leftover original, which cleans up files left
  by the old behaviour; undo then keeps that pre-existing link
  (migration 5 records this).
- Promote gets the same identity check and rollback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-25 13:40:59 -07:00
1 parent 9e848cd765
commit 176a527ec7
4 files changed
+237 -23

No files matched your search

+81 -18
View File
@@ -105,13 +105,34 @@ func dropQuarantine(l *libs, q string, keep fsops.Info) {
// removeOriginal deletes an original file after it was safely superseded. The
// inode must still be the one we verified.
func removeOriginal(p string, expect fsops.Info) error {
//
// 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 !now.SameInode(expect) || !now.Unchanged(expect.Size, expect.MtimeNs) {
return fmt.Errorf("%s %w", p, fsops.ErrChanged)
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
@@ -119,6 +140,18 @@ func removeOriginal(p string, expect fsops.Info) error {
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) {
@@ -211,14 +244,20 @@ func (e *Engine) Link(ctx context.Context, matchID int64, opt LinkOptions) (stor
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 _, err := os.Lstat(newAbs); err == nil {
return store.Operation{}, fmt.Errorf("slave already has %s: %w", newRel, fsops.ErrExists)
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}
RelNew: newRel, Trigger: opt.Trigger, MatchKind: m.Kind, MatchReason: m.Reason, LinkPreexisted: preexisting}
if identical {
o.Bytes = s.si.Size // space freed
} else {
@@ -256,20 +295,36 @@ func (e *Engine) Link(ctx context.Context, matchID int64, opt LinkOptions) (stor
}
return nil
}
err := fsops.MkdirAllLike(filepath.Dir(newAbs))
if err == nil {
err = fsops.LinkNew(s.masterAbs, s.mi, newAbs)
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 err != nil {
pruneEmpty(filepath.Dir(newAbs), l.slave.Path)
if q != "" {
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 err
}
if err := removeOriginal(s.slaveAbs, s.si); err != nil {
return err
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
@@ -359,7 +414,15 @@ func (e *Engine) promote(ctx context.Context, matchID int64, trigger string) (st
e.clearQuarantine(ctx, o.ID)
return err
}
return removeOriginal(s.masterAbs, s.mi)
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
@@ -645,7 +708,7 @@ func (e *Engine) Undo(ctx context.Context, opID int64) error {
}
dropQuarantine(l, o.QuarantinePath, qi)
}
if renamed {
if renamed && !o.LinkPreexisted {
if err := fsops.RemoveLink(newAbs, mi); err != nil {
return err
}