feat(library): add server-driven file deletion with allow_delete config

This commit is contained in:
Deivid Soto 2026-04-10 16:35:12 +02:00
parent 8ad8a5ea47
commit f699b26fa6
9 changed files with 744 additions and 24 deletions

148
internal/library/delete.go Normal file
View file

@ -0,0 +1,148 @@
package library
import (
"fmt"
"log"
"os"
"path/filepath"
"strings"
"github.com/torrentclaw/unarr/internal/agent"
)
// DeleteFiles deletes the given library items from disk and cleans up empty
// parent directories within the configured scan paths.
//
// Safety rules (all must pass before os.Remove is called):
// 1. filePath must be an absolute path.
// 2. filePath must be within one of the configured scanPaths.
// 3. Empty parent directories are removed up to (but not including) the
// scan path root and only if they are not the scan path itself.
//
// Returns the IDs of items successfully deleted.
func DeleteFiles(items []agent.LibraryDeleteRequest, scanPaths []string) []int {
// Sanitize scan paths: reject empty or non-absolute entries.
safe := make([]string, 0, len(scanPaths))
for _, sp := range scanPaths {
if filepath.IsAbs(sp) {
safe = append(safe, sp)
} else {
log.Printf("library: ignoring non-absolute scan path: %q", sp)
}
}
if len(safe) == 0 {
log.Printf("library: no valid scan paths configured — refusing to delete")
return nil
}
confirmed := make([]int, 0, len(items))
for _, item := range items {
if err := deleteOne(item.FilePath, safe); err != nil {
log.Printf("library: delete item %d (%q): %v", item.ItemID, item.FilePath, err)
continue
}
log.Printf("library: deleted item %d: %s", item.ItemID, item.FilePath)
confirmed = append(confirmed, item.ItemID)
}
return confirmed
}
func deleteOne(filePath string, scanPaths []string) error {
if !filepath.IsAbs(filePath) {
return fmt.Errorf("path is not absolute: %q", filePath)
}
clean := filepath.Clean(filePath)
// Resolve symlinks before validation to prevent traversal via symlinks.
real, err := filepath.EvalSymlinks(clean)
if err != nil {
if os.IsNotExist(err) {
// File already gone — idempotent success.
pruneEmptyDirs(filepath.Dir(clean), scanPaths)
return nil
}
return fmt.Errorf("resolve symlinks: %w", err)
}
// Security: resolved file must be within one of the configured scan paths.
if !isWithinScanPaths(real, scanPaths) {
return fmt.Errorf("path %q (resolved: %q) is outside all configured scan paths — refusing to delete", clean, real)
}
// Remove the file (idempotent: not-exist is not an error).
if err := os.Remove(real); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove file: %w", err)
}
// Clean up empty parent directories, stopping at the scan path root.
pruneEmptyDirs(filepath.Dir(real), scanPaths)
return nil
}
// isWithinScanPaths returns true if p is a child of any scan path.
func isWithinScanPaths(p string, scanPaths []string) bool {
for _, sp := range scanPaths {
sp = filepath.Clean(sp)
rel, err := filepath.Rel(sp, p)
if err != nil {
continue
}
// rel must not be "." (exact match = root itself) and must not start with ".."
if rel != "." && !strings.HasPrefix(rel, "..") {
return true
}
}
return false
}
// pruneEmptyDirs walks upward from dir, removing empty directories until it
// reaches a scan path root (which is never removed).
// Max 10 levels to guard against infinite loops on unexpected path shapes.
func pruneEmptyDirs(dir string, scanPaths []string) {
const maxLevels = 10
for i := 0; i < maxLevels; i++ {
dir = filepath.Clean(dir)
// Single pass: stop if dir is a scan root or outside all scan paths.
if !dirEligibleForPrune(dir, scanPaths) {
return
}
entries, err := os.ReadDir(dir)
if err != nil || len(entries) > 0 {
return // non-empty or unreadable — stop
}
if err := os.Remove(dir); err != nil {
log.Printf("library: prune dir %s: %v", dir, err)
return
}
log.Printf("library: removed empty dir: %s", dir)
dir = filepath.Dir(dir)
}
}
// dirEligibleForPrune returns true if dir is a strict child of any scan path
// (i.e. it is inside a scan path but is not the scan root itself).
// Combines the former isScanPathRoot + isWithinScanPaths checks into one loop.
func dirEligibleForPrune(dir string, scanPaths []string) bool {
for _, sp := range scanPaths {
sp = filepath.Clean(sp)
if sp == dir {
return false // dir IS the scan root — never remove it
}
rel, err := filepath.Rel(sp, dir)
if err != nil {
continue
}
if rel != "." && !strings.HasPrefix(rel, "..") {
return true
}
}
return false
}

View file

@ -0,0 +1,414 @@
package library
import (
"os"
"path/filepath"
"runtime"
"testing"
"github.com/torrentclaw/unarr/internal/agent"
)
// ---------------------------------------------------------------------------
// isWithinScanPaths
// ---------------------------------------------------------------------------
func TestIsWithinScanPaths(t *testing.T) {
tests := []struct {
name string
path string
scanPaths []string
want bool
}{
{
name: "file inside scan path",
path: "/media/movies/Inception.mkv",
scanPaths: []string{"/media/movies"},
want: true,
},
{
name: "file in subdirectory of scan path",
path: "/media/movies/2024/Inception/Inception.mkv",
scanPaths: []string{"/media/movies"},
want: true,
},
{
name: "file at scan path root itself",
path: "/media/movies",
scanPaths: []string{"/media/movies"},
want: false, // rel == "."
},
{
name: "file outside all scan paths",
path: "/tmp/evil.mkv",
scanPaths: []string{"/media/movies", "/media/shows"},
want: false,
},
{
name: "dotdot traversal attempt",
path: "/media/movies/../../../etc/passwd",
scanPaths: []string{"/media/movies"},
want: false,
},
{
name: "multiple scan paths file in second",
path: "/media/shows/Breaking.Bad.S01E01.mkv",
scanPaths: []string{"/media/movies", "/media/shows"},
want: true,
},
{
name: "empty scan paths",
path: "/media/movies/file.mkv",
scanPaths: []string{},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isWithinScanPaths(tt.path, tt.scanPaths)
if got != tt.want {
t.Errorf("isWithinScanPaths(%q, %v) = %v, want %v", tt.path, tt.scanPaths, got, tt.want)
}
})
}
}
// ---------------------------------------------------------------------------
// dirEligibleForPrune
// ---------------------------------------------------------------------------
func TestDirEligibleForPrune(t *testing.T) {
tests := []struct {
name string
dir string
scanPaths []string
want bool
}{
{
name: "scan root itself is NOT eligible",
dir: "/media/movies",
scanPaths: []string{"/media/movies"},
want: false,
},
{
name: "subdirectory IS eligible",
dir: "/media/movies/2024",
scanPaths: []string{"/media/movies"},
want: true,
},
{
name: "parent of scan path is NOT eligible",
dir: "/media",
scanPaths: []string{"/media/movies"},
want: false,
},
{
name: "trailing slash normalization — root not eligible",
dir: "/media/movies",
scanPaths: []string{"/media/movies/"},
want: false, // filepath.Clean removes trailing slash
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := dirEligibleForPrune(tt.dir, tt.scanPaths)
if got != tt.want {
t.Errorf("dirEligibleForPrune(%q, %v) = %v, want %v", tt.dir, tt.scanPaths, got, tt.want)
}
})
}
}
// ---------------------------------------------------------------------------
// deleteOne
// ---------------------------------------------------------------------------
func TestDeleteOne(t *testing.T) {
t.Run("delete existing file inside scan path", func(t *testing.T) {
root := t.TempDir()
file := filepath.Join(root, "movie.mkv")
if err := os.WriteFile(file, []byte("data"), 0644); err != nil {
t.Fatal(err)
}
if err := deleteOne(file, []string{root}); err != nil {
t.Fatalf("deleteOne returned error: %v", err)
}
if _, err := os.Stat(file); !os.IsNotExist(err) {
t.Error("file should have been deleted")
}
})
t.Run("reject relative path", func(t *testing.T) {
root := t.TempDir()
err := deleteOne("relative/path.mkv", []string{root})
if err == nil {
t.Fatal("expected error for relative path")
}
if got := err.Error(); got != `path is not absolute: "relative/path.mkv"` {
t.Errorf("unexpected error message: %s", got)
}
})
t.Run("reject path outside scan paths", func(t *testing.T) {
scanRoot := t.TempDir()
outsideDir := t.TempDir()
file := filepath.Join(outsideDir, "secret.txt")
if err := os.WriteFile(file, []byte("secret"), 0644); err != nil {
t.Fatal(err)
}
err := deleteOne(file, []string{scanRoot})
if err == nil {
t.Fatal("expected error for path outside scan paths")
}
// File must NOT have been deleted.
if _, statErr := os.Stat(file); statErr != nil {
t.Error("file outside scan path should NOT have been deleted")
}
})
t.Run("file already deleted is idempotent", func(t *testing.T) {
root := t.TempDir()
// Reference a file that does not exist.
file := filepath.Join(root, "gone.mkv")
if err := deleteOne(file, []string{root}); err != nil {
t.Fatalf("expected idempotent success, got error: %v", err)
}
})
t.Run("symlink pointing outside scan path is rejected", func(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlinks require elevated privileges on Windows")
}
scanRoot := t.TempDir()
outsideDir := t.TempDir()
outsideFile := filepath.Join(outsideDir, "real.mkv")
if err := os.WriteFile(outsideFile, []byte("real"), 0644); err != nil {
t.Fatal(err)
}
link := filepath.Join(scanRoot, "link.mkv")
if err := os.Symlink(outsideFile, link); err != nil {
t.Fatal(err)
}
err := deleteOne(link, []string{scanRoot})
if err == nil {
t.Fatal("expected error: symlink target is outside scan paths")
}
// The real file must NOT have been deleted.
if _, statErr := os.Stat(outsideFile); statErr != nil {
t.Error("symlink target outside scan path should NOT have been deleted")
}
})
t.Run("symlink pointing inside scan path is allowed", func(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlinks require elevated privileges on Windows")
}
scanRoot := t.TempDir()
subdir := filepath.Join(scanRoot, "sub")
if err := os.Mkdir(subdir, 0755); err != nil {
t.Fatal(err)
}
realFile := filepath.Join(subdir, "real.mkv")
if err := os.WriteFile(realFile, []byte("data"), 0644); err != nil {
t.Fatal(err)
}
link := filepath.Join(scanRoot, "link.mkv")
if err := os.Symlink(realFile, link); err != nil {
t.Fatal(err)
}
if err := deleteOne(link, []string{scanRoot}); err != nil {
t.Fatalf("deleteOne returned error: %v", err)
}
// The real file should have been deleted (os.Remove on resolved path).
if _, statErr := os.Stat(realFile); !os.IsNotExist(statErr) {
t.Error("resolved target inside scan path should have been deleted")
}
})
}
// ---------------------------------------------------------------------------
// pruneEmptyDirs
// ---------------------------------------------------------------------------
func TestPruneEmptyDirs(t *testing.T) {
t.Run("empty parent dir is removed", func(t *testing.T) {
root := t.TempDir()
sub := filepath.Join(root, "show")
if err := os.Mkdir(sub, 0755); err != nil {
t.Fatal(err)
}
pruneEmptyDirs(sub, []string{root})
if _, err := os.Stat(sub); !os.IsNotExist(err) {
t.Error("empty subdirectory should have been removed")
}
// Scan root must still exist.
if _, err := os.Stat(root); err != nil {
t.Error("scan path root should NOT have been removed")
}
})
t.Run("non-empty parent dir is NOT removed", func(t *testing.T) {
root := t.TempDir()
sub := filepath.Join(root, "show")
if err := os.Mkdir(sub, 0755); err != nil {
t.Fatal(err)
}
// Put a file inside so it's not empty.
if err := os.WriteFile(filepath.Join(sub, "keep.txt"), []byte("x"), 0644); err != nil {
t.Fatal(err)
}
pruneEmptyDirs(sub, []string{root})
if _, err := os.Stat(sub); err != nil {
t.Error("non-empty directory should NOT have been removed")
}
})
t.Run("stops at scan path root", func(t *testing.T) {
root := t.TempDir()
// Create an empty dir that IS the scan root.
// pruneEmptyDirs should refuse to remove it.
pruneEmptyDirs(root, []string{root})
if _, err := os.Stat(root); err != nil {
t.Error("scan path root should never be removed")
}
})
t.Run("multi-level cleanup", func(t *testing.T) {
root := t.TempDir()
deep := filepath.Join(root, "a", "b", "c")
if err := os.MkdirAll(deep, 0755); err != nil {
t.Fatal(err)
}
pruneEmptyDirs(deep, []string{root})
// All three levels (a, a/b, a/b/c) should be removed.
for _, dir := range []string{
filepath.Join(root, "a", "b", "c"),
filepath.Join(root, "a", "b"),
filepath.Join(root, "a"),
} {
if _, err := os.Stat(dir); !os.IsNotExist(err) {
t.Errorf("directory should have been removed: %s", dir)
}
}
// Scan root must still exist.
if _, err := os.Stat(root); err != nil {
t.Error("scan path root should NOT have been removed")
}
})
}
// ---------------------------------------------------------------------------
// DeleteFiles (integration)
// ---------------------------------------------------------------------------
func TestDeleteFiles(t *testing.T) {
t.Run("multiple items some valid some invalid", func(t *testing.T) {
root := t.TempDir()
outsideDir := t.TempDir()
goodFile := filepath.Join(root, "good.mkv")
if err := os.WriteFile(goodFile, []byte("ok"), 0644); err != nil {
t.Fatal(err)
}
outsideFile := filepath.Join(outsideDir, "outside.mkv")
if err := os.WriteFile(outsideFile, []byte("nope"), 0644); err != nil {
t.Fatal(err)
}
items := []agent.LibraryDeleteRequest{
{ItemID: 1, FilePath: goodFile}, // valid → deleted
{ItemID: 2, FilePath: "relative/bad.mkv"}, // relative → rejected
{ItemID: 3, FilePath: outsideFile}, // outside scan paths → rejected
{ItemID: 4, FilePath: filepath.Join(root, "gone.mkv")}, // not-exist → idempotent success
}
confirmed := DeleteFiles(items, []string{root})
// Items 1 and 4 should succeed. Item 2 (relative) and 3 (outside) should fail.
want := map[int]bool{1: true, 4: true}
got := make(map[int]bool, len(confirmed))
for _, id := range confirmed {
got[id] = true
}
if len(got) != len(want) {
t.Fatalf("confirmed = %v, want IDs %v", confirmed, want)
}
for id := range want {
if !got[id] {
t.Errorf("expected item %d to be confirmed", id)
}
}
// outsideFile must NOT have been deleted.
if _, err := os.Stat(outsideFile); err != nil {
t.Error("file outside scan paths should NOT have been deleted")
}
// good.mkv should be deleted.
if _, err := os.Stat(goodFile); !os.IsNotExist(err) {
t.Error("good.mkv should have been deleted")
}
})
t.Run("empty scan paths returns nil", func(t *testing.T) {
items := []agent.LibraryDeleteRequest{
{ItemID: 1, FilePath: "/some/file.mkv"},
}
confirmed := DeleteFiles(items, []string{})
if confirmed != nil {
t.Errorf("expected nil, got %v", confirmed)
}
})
t.Run("all relative scan paths returns nil", func(t *testing.T) {
items := []agent.LibraryDeleteRequest{
{ItemID: 1, FilePath: "/some/file.mkv"},
}
confirmed := DeleteFiles(items, []string{"relative/path", "another/relative"})
if confirmed != nil {
t.Errorf("expected nil, got %v", confirmed)
}
})
t.Run("mixed absolute and relative scan paths uses only absolute", func(t *testing.T) {
root := t.TempDir()
file := filepath.Join(root, "movie.mkv")
if err := os.WriteFile(file, []byte("data"), 0644); err != nil {
t.Fatal(err)
}
items := []agent.LibraryDeleteRequest{
{ItemID: 10, FilePath: file},
}
confirmed := DeleteFiles(items, []string{"relative/bad", root})
if len(confirmed) != 1 || confirmed[0] != 10 {
t.Errorf("confirmed = %v, want [10]", confirmed)
}
if _, err := os.Stat(file); !os.IsNotExist(err) {
t.Error("file should have been deleted via the absolute scan path")
}
})
}