feat(library): content fingerprint + path-resilient sync + stream self-heal
Stop treating the absolute path as a file's identity so a base-path change (host binary→docker remap, moved media folder, remount) no longer makes the server duplicate and orphan library rows. - fingerprint.go: ComputeFingerprint = sha256(size ‖ first 1MiB ‖ last 1MiB), a stable content identity that survives rename/move/base-path change. Cached in LibraryItem and reused on incremental scans when size+mtime are unchanged. - sync: send fingerprint + rel_path (relative to the scan root) + agent_id in the library-sync request, so the server can move a row in place and scope stale-cleanup per agent. - daemon: force a FULL re-scan (with a user-facing WARNING) when the scan root changed since the last cache, so the server re-maps by fingerprint instead of duplicating. basePathChanged compares filepath.Clean'd roots. - daemon: relocateUnreachable self-heals a stream request whose path is under an old root but whose file still exists under a current allowed root, so playback works immediately without waiting for the re-scan. Conservative: requires a 3-segment tail and re-checks containment after resolving symlinks so it can neither serve the wrong file nor escape the allowed dirs. See docs/plans/unarr-path-resilience.md in the web repo.
This commit is contained in:
parent
e298ff6c05
commit
b6ddeea129
9 changed files with 396 additions and 38 deletions
74
internal/cmd/relocate_test.go
Normal file
74
internal/cmd/relocate_test.go
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func mkfile(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelocateUnreachable(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
// A 3-segment-deep file under the current root.
|
||||
mkfile(t, filepath.Join(root, "Acme Show", "Season 01", "ep.mkv"))
|
||||
// A 2-segment-deep file (too shallow to be matched by a short tail).
|
||||
mkfile(t, filepath.Join(root, "Season 01", "lonely.mkv"))
|
||||
|
||||
roots := []string{root}
|
||||
|
||||
// Base-path change: an old-root path whose 3-seg tail exists under the new
|
||||
// root → relocates to the real file.
|
||||
got := relocateUnreachable("/old/base/Acme Show/Season 01/ep.mkv", roots)
|
||||
want := filepath.Join(root, "Acme Show", "Season 01", "ep.mkv")
|
||||
if got != want {
|
||||
t.Errorf("relocate moved file: got %q want %q", got, want)
|
||||
}
|
||||
|
||||
// Only a 2-segment tail would match → must NOT relocate (ambiguous).
|
||||
if got := relocateUnreachable("/old/Season 01/lonely.mkv", roots); got != "" {
|
||||
t.Errorf("2-segment tail should not match, got %q", got)
|
||||
}
|
||||
|
||||
// Nonexistent file → no relocation.
|
||||
if got := relocateUnreachable("/old/base/Acme Show/Season 01/missing.mkv", roots); got != "" {
|
||||
t.Errorf("missing file should not relocate, got %q", got)
|
||||
}
|
||||
|
||||
// Traversal attempt: ".." segments are cleaned by filepath.Join and the
|
||||
// result is re-validated, so it can't escape.
|
||||
if got := relocateUnreachable("/old/../../../etc/passwd", roots); got != "" {
|
||||
t.Errorf("traversal should not match, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelocateUnreachableSymlinkEscape(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symlink semantics differ on windows")
|
||||
}
|
||||
root := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
// A real file living OUTSIDE any allowed root.
|
||||
mkfile(t, filepath.Join(outside, "sub", "secret.mkv"))
|
||||
// A symlink inside the root pointing at the outside tree.
|
||||
if err := os.Symlink(outside, filepath.Join(root, "link")); err != nil {
|
||||
t.Skipf("symlink unsupported: %v", err)
|
||||
}
|
||||
|
||||
// The lexical candidate root/link/sub/secret.mkv exists (os.Stat follows the
|
||||
// symlink), but after resolving symlinks it's outside the root → must be
|
||||
// rejected so the stream can't escape the allowed dirs.
|
||||
got := relocateUnreachable("/old/link/sub/secret.mkv", []string{root})
|
||||
if got != "" {
|
||||
t.Errorf("symlink escape must be rejected, got %q", got)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue