package library import ( "errors" "os" "path/filepath" "testing" ) // newLib builds a media root with the four tree/section dirs and returns it. func newLib(t *testing.T) *Library { t.Helper() root := t.TempDir() for _, tree := range []string{SourceTree, KidsTree} { for _, s := range Sections { if err := os.MkdirAll(filepath.Join(root, tree, string(s)), 0o755); err != nil { t.Fatal(err) } } } return New(root) } // writeSrc creates a source file with content, making parents as needed. func writeSrc(t *testing.T, l *Library, s Section, rel, content string) string { t.Helper() p := filepath.Join(l.sourceRoot(s), rel) if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(p, []byte(content), 0o644); err != nil { t.Fatal(err) } return p } func TestValidateNameRejectsTraversal(t *testing.T) { bad := []string{ "", ".", "..", "../etc", "..", "a/b", "/abs", "/etc/passwd", "sub/../..", "./x", "x/", "a\x00b", "..\\evil", } for _, name := range bad { if err := ValidateName(name); err == nil { t.Errorf("ValidateName(%q) = nil, want error", name) } else if !errors.Is(err, ErrInvalidName) { t.Errorf("ValidateName(%q) error %v, want ErrInvalidName", name, err) } } good := []string{"The Muppets", "Movie (2019)", "a.b.c", "...", "dot.dir", "Ünïcødé"} for _, name := range good { if err := ValidateName(name); err != nil { t.Errorf("ValidateName(%q) = %v, want nil", name, err) } } } func TestPathsRejectsTraversalAndKeepsContainment(t *testing.T) { l := newLib(t) if _, _, err := l.paths(SectionMovies, "../../etc"); err == nil { t.Fatal("paths accepted a traversal name") } src, kids, err := l.paths(SectionMovies, "Nemo") if err != nil { t.Fatal(err) } if !under(l.sourceRoot(SectionMovies), src) || !under(l.kidsRoot(SectionMovies), kids) { t.Fatalf("resolved paths escaped roots: %s %s", src, kids) } } // TestMarkOperationsCannotEscapeRoot proves a crafted name never touches a file // outside the media root, even when the target already exists there. func TestMarkOperationsCannotEscapeRoot(t *testing.T) { l := newLib(t) outside := filepath.Join(t.TempDir(), "victim") if err := os.MkdirAll(outside, 0o755); err != nil { t.Fatal(err) } for _, name := range []string{"../../../" + filepath.Base(outside), "..", "../movies"} { if _, err := l.Mark(SectionMovies, name); !errors.Is(err, ErrInvalidName) { t.Errorf("Mark(%q) error = %v, want ErrInvalidName", name, err) } if err := l.Unmark(SectionMovies, name); !errors.Is(err, ErrInvalidName) { t.Errorf("Unmark(%q) error = %v, want ErrInvalidName", name, err) } } if _, err := os.Stat(outside); err != nil { t.Fatalf("victim directory was disturbed: %v", err) } } func TestMarkHardlinksTreeAndIsIdempotent(t *testing.T) { l := newLib(t) writeSrc(t, l, SectionTVSeries, "Bluey/Season 01/e01.mkv", "one") writeSrc(t, l, SectionTVSeries, "Bluey/Season 01/e02.mkv", "two") writeSrc(t, l, SectionTVSeries, "Bluey/poster.jpg", "art") res, err := l.Mark(SectionTVSeries, "Bluey") if err != nil { t.Fatal(err) } if res.Linked != 3 { t.Fatalf("Linked = %d, want 3", res.Linked) } // Every kids file must share an inode with its source. srcFile := filepath.Join(l.sourceRoot(SectionTVSeries), "Bluey/Season 01/e01.mkv") kidsFile := filepath.Join(l.kidsRoot(SectionTVSeries), "Bluey/Season 01/e01.mkv") if !sameFile(srcFile, kidsFile) { t.Fatal("kids file is not a hardlink of the source") } // Re-running links nothing new. res2, err := l.Mark(SectionTVSeries, "Bluey") if err != nil { t.Fatal(err) } if res2.Linked != 0 || res2.Unchanged != 3 { t.Fatalf("second Mark = %+v, want 0 linked / 3 unchanged", res2) } } func TestMarkSyncsNewFilesAndReplacesStaleLinks(t *testing.T) { l := newLib(t) writeSrc(t, l, SectionTVSeries, "Bluey/e01.mkv", "one") if _, err := l.Mark(SectionTVSeries, "Bluey"); err != nil { t.Fatal(err) } // A new episode arrives and an existing file is replaced upstream (a new // inode with the same name, as an *arr upgrade does). writeSrc(t, l, SectionTVSeries, "Bluey/e02.mkv", "two") if err := os.Remove(filepath.Join(l.sourceRoot(SectionTVSeries), "Bluey/e01.mkv")); err != nil { t.Fatal(err) } writeSrc(t, l, SectionTVSeries, "Bluey/e01.mkv", "one-upgraded") tit, err := l.Stat(SectionTVSeries, "Bluey") if err != nil { t.Fatal(err) } if !tit.NeedsSync || tit.UnlinkedFiles != 2 { t.Fatalf("drift = %+v, want NeedsSync with 2 unlinked", tit) } res, err := l.Mark(SectionTVSeries, "Bluey") if err != nil { t.Fatal(err) } if res.Linked != 1 || res.Replaced != 1 { t.Fatalf("sync = %+v, want 1 linked / 1 replaced", res) } tit, err = l.Stat(SectionTVSeries, "Bluey") if err != nil { t.Fatal(err) } if tit.NeedsSync || tit.UnlinkedFiles != 0 { t.Fatalf("post-sync drift = %+v, want none", tit) } b, err := os.ReadFile(filepath.Join(l.kidsRoot(SectionTVSeries), "Bluey/e01.mkv")) if err != nil || string(b) != "one-upgraded" { t.Fatalf("stale link not replaced: %q %v", b, err) } } func TestMarkSkipsSymlinks(t *testing.T) { l := newLib(t) writeSrc(t, l, SectionMovies, "Nemo/movie.mkv", "film") link := filepath.Join(l.sourceRoot(SectionMovies), "Nemo", "elsewhere.mkv") if err := os.Symlink("/dev/null", link); err != nil { t.Skipf("symlinks unsupported: %v", err) } res, err := l.Mark(SectionMovies, "Nemo") if err != nil { t.Fatal(err) } if res.Linked != 1 || res.Skipped != 1 { t.Fatalf("res = %+v, want 1 linked / 1 skipped", res) } if _, err := os.Lstat(filepath.Join(l.kidsRoot(SectionMovies), "Nemo", "elsewhere.mkv")); !os.IsNotExist(err) { t.Fatal("symlink was copied into the kids tree") } } func TestUnmarkLeavesSourceIntact(t *testing.T) { l := newLib(t) writeSrc(t, l, SectionMovies, "Nemo/movie.mkv", "film") if _, err := l.Mark(SectionMovies, "Nemo"); err != nil { t.Fatal(err) } if err := l.Unmark(SectionMovies, "Nemo"); err != nil { t.Fatal(err) } if _, err := os.Stat(filepath.Join(l.kidsRoot(SectionMovies), "Nemo")); !os.IsNotExist(err) { t.Fatal("kids directory survived unmark") } b, err := os.ReadFile(filepath.Join(l.sourceRoot(SectionMovies), "Nemo/movie.mkv")) if err != nil || string(b) != "film" { t.Fatalf("source damaged by unmark: %q %v", b, err) } // Unmarking again is a no-op, not an error. if err := l.Unmark(SectionMovies, "Nemo"); err != nil { t.Fatalf("second Unmark = %v, want nil", err) } } func TestStatAndListReportSizeCountAndMarks(t *testing.T) { l := newLib(t) writeSrc(t, l, SectionMovies, "Zootopia/movie.mkv", "0123456789") writeSrc(t, l, SectionMovies, "Zootopia/extra.nfo", "abc") writeSrc(t, l, SectionMovies, "aladdin/movie.mkv", "xy") if _, err := l.Mark(SectionMovies, "Zootopia"); err != nil { t.Fatal(err) } got, err := l.Stat(SectionMovies, "Zootopia") if err != nil { t.Fatal(err) } if got.FileCount != 2 || got.SizeBytes != 13 || !got.Marked || got.NeedsSync { t.Fatalf("Stat = %+v, want 2 files / 13 bytes / marked / synced", got) } list, err := l.List(SectionMovies) if err != nil { t.Fatal(err) } if len(list) != 2 { t.Fatalf("List returned %d titles, want 2", len(list)) } // Case-insensitive ordering: "aladdin" before "Zootopia". if list[0].Name != "aladdin" || list[1].Name != "Zootopia" { t.Fatalf("List order = %q, %q", list[0].Name, list[1].Name) } if list[0].Marked || !list[1].Marked { t.Fatalf("marked flags wrong: %+v", list) } } func TestListMissingSectionIsEmptyNotError(t *testing.T) { l := New(t.TempDir()) got, err := l.List(SectionMovies) if err != nil { t.Fatalf("List = %v, want nil error", err) } if len(got) != 0 { t.Fatalf("List = %v, want empty", got) } } func TestStatMissingTitleIsNotFound(t *testing.T) { l := newLib(t) if _, err := l.Stat(SectionMovies, "Ghost"); !errors.Is(err, ErrNotFound) { t.Fatalf("Stat = %v, want ErrNotFound", err) } if _, err := l.Mark(SectionMovies, "Ghost"); !errors.Is(err, ErrNotFound) { t.Fatalf("Mark = %v, want ErrNotFound", err) } } func TestParseSection(t *testing.T) { for _, ok := range []string{"movies", "tvseries"} { if _, err := ParseSection(ok); err != nil { t.Errorf("ParseSection(%q) = %v", ok, err) } } for _, bad := range []string{"", "Movies", "music", "../movies", "tvseries/x"} { if _, err := ParseSection(bad); !errors.Is(err, ErrInvalidSection) { t.Errorf("ParseSection(%q) = %v, want ErrInvalidSection", bad, err) } } } func TestReady(t *testing.T) { l := newLib(t) if err := l.Ready(); err != nil { t.Fatalf("Ready = %v", err) } if err := New(filepath.Join(t.TempDir(), "absent")).Ready(); err == nil { t.Fatal("Ready on a missing root = nil, want error") } f := filepath.Join(t.TempDir(), "file") if err := os.WriteFile(f, nil, 0o644); err != nil { t.Fatal(err) } if err := New(f).Ready(); err == nil { t.Fatal("Ready on a file root = nil, want error") } }