diff --git a/internal/protocol/bt/fetcher.go b/internal/protocol/bt/fetcher.go index bc32f741..71aa80ff 100644 --- a/internal/protocol/bt/fetcher.go +++ b/internal/protocol/bt/fetcher.go @@ -411,11 +411,18 @@ func (f *Fetcher) Wait() (err error) { } } -// Path already includes the torrent's root directory. Never prune the download -// directory itself, and only remove parent directories that are empty. +// Path already includes the torrent's root directory. Preserve that directory +// even when empty, and only prune empty directories below it. func removeUnselectedFile(downloadDir, torrentPath string) error { + if !filepath.IsLocal(torrentPath) { + return fmt.Errorf("invalid torrent file path %q", torrentPath) + } root := filepath.Clean(downloadDir) + torrentPath = filepath.Clean(torrentPath) name := filepath.Join(root, torrentPath) + if torrentDir, _, hasDir := strings.Cut(torrentPath, string(filepath.Separator)); hasDir { + root = filepath.Join(root, torrentDir) + } for _, candidate := range []string{name, name + ".part"} { if err := util.SafeRemove(candidate); err != nil { return fmt.Errorf("remove unselected torrent file %q: %w", candidate, err) diff --git a/internal/protocol/bt/selection_test.go b/internal/protocol/bt/selection_test.go index f147cfc5..677cda54 100644 --- a/internal/protocol/bt/selection_test.go +++ b/internal/protocol/bt/selection_test.go @@ -200,13 +200,25 @@ func TestRemoveUnselectedFilePrunesOnlyEmptyParents(t *testing.T) { if err != nil || string(data) != "keep" { t.Fatalf("sibling changed: %q, %v", data, err) } - } else if _, err := os.Stat(filepath.Join(root, "bundle")); !os.IsNotExist(err) { - t.Fatalf("empty torrent directory remains: %v", err) + } + if info, err := os.Stat(filepath.Join(root, "bundle")); err != nil || !info.IsDir() { + t.Fatalf("torrent root directory was removed: %v", err) } if _, err := os.Stat(root); err != nil { t.Fatalf("download directory was removed: %v", err) } - // Repeating cleanup after all files and directories are gone is harmless. + // A file directly inside the torrent root must not cause that root to be pruned. + rootFile := filepath.Join("bundle", "root.txt") + if err := os.WriteFile(filepath.Join(root, rootFile)+".part", []byte("partial"), 0644); err != nil { + t.Fatal(err) + } + if err := removeUnselectedFile(root, rootFile); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(root, "bundle")); err != nil { + t.Fatalf("torrent root directory was removed after root-level cleanup: %v", err) + } + // Repeating cleanup after the file and its subdirectories are gone is harmless. if err := removeUnselectedFile(root, relative); err != nil { t.Fatal(err) }