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:
1 parent
9e848cd765
commit
176a527ec7
4 files changed
+237
-23
No files matched your search
+81
-18
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user