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") } }