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>
147 lines
5.2 KiB
Go
147 lines
5.2 KiB
Go
package engine
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/cdnutter/playlink/internal/fsops"
|
|
"github.com/cdnutter/playlink/internal/store"
|
|
)
|
|
|
|
// A cross-folder upgrade: the slave file sits in another folder than the
|
|
// master's, so linking moves it into the master's folder structure.
|
|
func crossFolder(t *testing.T) (ev *env, m store.Match, masterAbs, oldAbs, newAbs string) {
|
|
t.Helper()
|
|
ev = setup(t, false)
|
|
enableApprox(t, ev)
|
|
masterAbs = ev.write(ev.master, "Composer/Game1/01 Song.flac", "lossless")
|
|
oldAbs = ev.write(ev.slave, "Composer/Game Rips/01 Song.mp3", "lossy")
|
|
newAbs = filepath.Join(ev.slave, "Composer/Game1/01 Song.flac")
|
|
ev.scan()
|
|
m = ev.match("Composer/Game Rips/01 Song.mp3")
|
|
if m.Kind != store.KindApprox || LinkTarget(m.RelSlave, m.RelMaster, false) != "Composer/Game1/01 Song.flac" {
|
|
t.Fatalf("setup: unexpected match %s -> %s", m.Kind, m.RelMaster)
|
|
}
|
|
t.Cleanup(func() { testHookBeforeRemove = nil })
|
|
return
|
|
}
|
|
|
|
func exists(p string) bool { _, err := os.Lstat(p); return err == nil }
|
|
|
|
// The reported bug: an unreliable modification time must not leave both the
|
|
// original and the new link behind.
|
|
func TestCrossFolderLinkIgnoresMtimeNoise(t *testing.T) {
|
|
ev, m, masterAbs, oldAbs, newAbs := crossFolder(t)
|
|
testHookBeforeRemove = func(p string) {
|
|
// What a FUSE filesystem may report after the new links were made.
|
|
_ = os.Chtimes(p, time.Now(), time.Now().Add(-time.Hour))
|
|
}
|
|
op, err := ev.e.Link(ctx, m.ID, LinkOptions{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if exists(oldAbs) {
|
|
t.Fatal("the original must not remain next to the new link")
|
|
}
|
|
if !sameInode(t, masterAbs, newAbs) || ev.read(op.QuarantinePath) != "lossy" {
|
|
t.Fatal("expected the link at the master's path and the original in quarantine")
|
|
}
|
|
}
|
|
|
|
// If the original really cannot be removed, the new link is rolled back and
|
|
// the library is left exactly as it was.
|
|
func TestCrossFolderLinkRollsBack(t *testing.T) {
|
|
if os.Geteuid() == 0 {
|
|
t.Skip("root ignores directory permissions")
|
|
}
|
|
ev, m, _, oldAbs, newAbs := crossFolder(t)
|
|
dir := filepath.Dir(oldAbs)
|
|
testHookBeforeRemove = func(string) { _ = os.Chmod(dir, 0o555) } // deletion now fails
|
|
t.Cleanup(func() { _ = os.Chmod(dir, 0o755) })
|
|
op, err := ev.e.Link(ctx, m.ID, LinkOptions{})
|
|
if err == nil || !strings.Contains(err.Error(), "nothing was changed") {
|
|
t.Fatalf("want a rollback error, got %v", err)
|
|
}
|
|
_ = os.Chmod(dir, 0o755)
|
|
if exists(newAbs) || exists(filepath.Dir(newAbs)) {
|
|
t.Fatal("the new link (and its folder) should be rolled back")
|
|
}
|
|
if ev.read(oldAbs) != "lossy" {
|
|
t.Fatal("the original must be untouched")
|
|
}
|
|
stored, _ := ev.st.Op(ctx, op.ID)
|
|
if stored.Status != store.OpFailed || stored.QuarantinePath != "" {
|
|
t.Fatalf("op should be failed with no quarantine left, got %s %q", stored.Status, stored.QuarantinePath)
|
|
}
|
|
if entries, _ := os.ReadDir(filepath.Join(ev.slave, ".playlink-trash")); len(entries) != 0 {
|
|
t.Fatalf("quarantine should be empty after rollback, has %d entries", len(entries))
|
|
}
|
|
}
|
|
|
|
// If the original was swapped for another file mid-way, the quarantine copy
|
|
// is the only copy of the original's data, so it is kept.
|
|
func TestCrossFolderLinkKeepsQuarantineWhenOriginalWasSwapped(t *testing.T) {
|
|
ev, m, _, oldAbs, newAbs := crossFolder(t)
|
|
testHookBeforeRemove = func(p string) {
|
|
_ = os.Remove(p)
|
|
_ = os.WriteFile(p, []byte("someone else's new file"), 0o644)
|
|
}
|
|
op, err := ev.e.Link(ctx, m.ID, LinkOptions{})
|
|
if err == nil || !errors.Is(err, fsops.ErrChanged) {
|
|
t.Fatalf("want a changed-file error, got %v", err)
|
|
}
|
|
if exists(newAbs) {
|
|
t.Fatal("the new link should be rolled back")
|
|
}
|
|
if ev.read(oldAbs) != "someone else's new file" {
|
|
t.Fatal("the new file must not be touched")
|
|
}
|
|
stored, _ := ev.st.Op(ctx, op.ID)
|
|
if stored.QuarantinePath == "" || ev.read(stored.QuarantinePath) != "lossy" {
|
|
t.Fatal("the original's data must stay in quarantine")
|
|
}
|
|
}
|
|
|
|
// Leftovers from the old bug: the master's path already holds the link and
|
|
// the original is still there. Linking again only retires the original.
|
|
func TestRelinkCleansUpLeftoverOriginal(t *testing.T) {
|
|
ev, _, masterAbs, oldAbs, newAbs := crossFolder(t)
|
|
if err := os.MkdirAll(filepath.Dir(newAbs), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Link(masterAbs, newAbs); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ev.scan()
|
|
m := ev.match("Composer/Game Rips/01 Song.mp3")
|
|
op, err := ev.e.Link(ctx, m.ID, LinkOptions{})
|
|
if err != nil {
|
|
t.Fatalf("relinking over an existing link of the master should work: %v", err)
|
|
}
|
|
if exists(oldAbs) || !sameInode(t, masterAbs, newAbs) {
|
|
t.Fatal("the leftover original should be retired and the link kept")
|
|
}
|
|
if err := ev.e.Undo(ctx, op.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if ev.read(oldAbs) != "lossy" || !sameInode(t, masterAbs, newAbs) {
|
|
t.Fatal("undo should restore the original and keep the link that existed before")
|
|
}
|
|
}
|
|
|
|
// A different file at the target is still refused.
|
|
func TestRelinkRefusesForeignFileAtTarget(t *testing.T) {
|
|
ev, m, _, oldAbs, newAbs := crossFolder(t)
|
|
ev.write(ev.slave, "Composer/Game1/01 Song.flac", "unrelated")
|
|
if _, err := ev.e.Link(ctx, m.ID, LinkOptions{}); !errors.Is(err, fsops.ErrExists) {
|
|
t.Fatalf("want ErrExists, got %v", err)
|
|
}
|
|
if ev.read(newAbs) != "unrelated" || ev.read(oldAbs) != "lossy" {
|
|
t.Fatal("nothing may change")
|
|
}
|
|
}
|