fix(downloads): preserve client path whitespace#717
Open
therobbiedavis wants to merge 35 commits into
Open
Conversation
Preserve download-client reported path whitespace for torrent source/content paths while keeping destination validation separate. Add user-provided library destination validation for add, move, and root-folder workflows, allowing filesystem roots for root folders while rejecting parent traversal for concrete destinations.
Allow root-folder configuration to accept filesystem roots, including Windows current-drive roots, while keeping concrete destination paths strict against root-only and parent-traversal targets. Add cross-platform root-folder validation coverage and standardize the new torrent path mapper test header.
Contributor
There was a problem hiding this comment.
Pull request overview
This PR fixes download/import path handling so client-reported torrent paths preserve meaningful leading/trailing whitespace (important on Unix-like filesystems), while adding stricter, OS-aware validation for Listenarr-owned destination paths (root folders, add-to-library, and move operations).
Changes:
- Preserve whitespace in torrent client path mapping and remote path translation by avoiding whitespace-trimming/whitespace-only rejection in key path plumbing.
- Add OS-aware normalization/validation for user-provided destination directories, including explicit support for filesystem roots in root-folder configuration and blocking parent traversal for concrete destinations.
- Add regression tests covering whitespace-bearing torrent folders (Transmission/qBittorrent), root-folder root paths, and invalid destination rejection in library add/move workflows.
Reviewed changes
Copilot reviewed 20 out of 20 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/Mocks/Api/TransmissionApiMock.cs | Adds a canned Transmission torrent response with whitespace-bearing folder names for regression coverage. |
| tests/Features/Infrastructure/DownloadClients/Common/TorrentClientPathMapperTests.cs | New tests asserting whitespace preservation and rooted-child handling for torrent file mapping. |
| tests/Features/Infrastructure/DownloadClients/Common/DownloadClientAdapterTests.cs | Adds Transmission adapter regression asserting whitespace-bearing ContentPath/SourceFiles are preserved. |
| tests/Features/Domain/Utils/FileUtilsTests.cs | Adds tests for whitespace-preserving combining/normalization and new destination-path validation rules. |
| tests/Features/Application/Downloads/Common/DownloadClientGatewayTests.cs | Adds tests ensuring remote path mapping and directory expansion keep whitespace-bearing paths intact. |
| tests/Features/Application/Audiobooks/RootFolders/RootFolderServiceTests.cs | Adds coverage for allowing filesystem roots and validating/normalizing root folder paths. |
| tests/Features/Api/Features/Library/LibraryController_MoveTests.cs | Adds API test ensuring invalid move destinations are rejected with 400. |
| tests/Features/Api/Features/Library/LibraryController_AddToLibraryTests.cs | Updates custom path test to be OS-safe and adds parent-traversal/invalid-path rejection tests. |
| listenarr.infrastructure/DownloadClients/Transmission/TransmissionImportPathResolver.cs | Stops treating whitespace-bearing strings as “empty” when building content/source paths. |
| listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentImportPathResolver.cs | Same as above for qBittorrent source file translation. |
| listenarr.infrastructure/DownloadClients/Common/TorrentClientPathMapper.cs | Reworks torrent path mapping to avoid trimming path-segment whitespace and better handle rooted-looking child paths. |
| listenarr.infrastructure/Configuration/Paths/RemotePathMappingService.cs | Adjusts empty-path detection to avoid rejecting whitespace-bearing remote paths. |
| listenarr.domain/Common/FileUtils.UserProvidedPaths.cs | New OS-aware user-provided directory path validation/normalization helper with root/traversal options. |
| listenarr.domain/Common/FileUtils.PathCombining.cs | Documents CombineWithOptionalBase’s whitespace-preserving intent and relaxes base-path emptiness check. |
| listenarr.application/Downloads/Common/DownloadClientGateway.cs | Preserves whitespace-bearing paths during mapping/translation and directory-expansion flows. |
| listenarr.application/Audiobooks/RootFolders/RootFolderService.cs | Root folder create/update now uses OS-aware normalization and allows filesystem root paths. |
| listenarr.application/Audiobooks/Contracts/ILibraryAddService.cs | Extends result contract to surface validation failures cleanly to API workflows. |
| listenarr.application/Audiobooks/Catalog/LibraryAddService.cs | Validates custom/generated destination paths with OS-aware normalization and blocks parent traversal. |
| listenarr.api/Features/Library/LibraryMoveWorkflow.cs | Validates and normalizes move destinations (blocking traversal) instead of silently normalizing invalid targets. |
| listenarr.api/Features/Library/LibraryAddWorkflow.cs | Returns 400 for validation failures and validates custom destination paths via OS-aware normalization. |
| await _rootIdentityGate.WaitAsync(cancellationToken); | ||
| try | ||
| { | ||
| if (_rootIdentitiesReconciled) return; |
Comment on lines
+213
to
+224
| foreach (var directoryEntry in manifest.Where(entry => entry.EntryType == MoveJobEntryType.Directory)) | ||
| { | ||
| if (FileSystemPathIdentity.TryResolveRelativePathWithinBase( | ||
| source, | ||
| directoryEntry.RelativePath, | ||
| semantics, | ||
| out var sourceDirectory) | ||
| && Directory.Exists(sourceDirectory)) | ||
| { | ||
| expectedAtSource.Add(directoryEntry); | ||
| } | ||
| } |
Comment on lines
+308
to
+322
| foreach (var directoryEntry in manifest | ||
| .Where(entry => entry.EntryType == MoveJobEntryType.Directory) | ||
| .OrderByDescending(entry => entry.RelativePath.Length)) | ||
| { | ||
| if (FileSystemPathIdentity.TryResolveRelativePathWithinBase( | ||
| source, | ||
| directoryEntry.RelativePath, | ||
| semantics, | ||
| out var directory) | ||
| && Directory.Exists(directory) | ||
| && !Directory.EnumerateFileSystemEntries(directory).Any()) | ||
| { | ||
| Directory.Delete(directory, false); | ||
| } | ||
| } |
Comment on lines
+67
to
+69
| catch (OperationCanceledException) when (heartbeatCancellation.IsCancellationRequested) | ||
| { | ||
| } |
Make root-folder operations semantics-aware by threading FileSystemPathSemantics through root path checks, repository migrations, deletion safeguards, and unmatched-file filtering, and block new roots that overlap active relocation boundaries. Improve relocation/move reliability with atomic-rename recovery markers, stricter cleanup reconciliation (including ambiguous quarantine detection), resilient heartbeat/broadcast error handling, and retry/reconcile support for superseded move jobs. Remove the temporary frontend debug test placeholder.
Adds lease-generation fencing for move jobs so only the active claimant can heartbeat, update status/phase, and persist move-manifest cleanup state. Move processing now cancels when a lease is lost, and persistence now increments/stores LeaseGeneration with a migration. Path handling was also made semantics-driven across backend and frontend: remote/client path mapping, scan/planning, metadata parsing, and destination validation now honor explicit syntax/case-sensitivity instead of host defaults or path-shape guesses. Tests were updated to cover lease loss behavior, cross-platform path rules, and new architecture boundaries.
Refactor UpdateJobStatusAsync to isolate persistence, relocation, and broadcast into separate try/catch blocks so persistence failures propagate without broadcasting. Add specific catch blocks in MoveBackgroundService and MoveJobProcessor for MoveLeaseLostException and PersistenceException to prevent lease-lost scenarios from marking jobs as Failed. Update root folder change toast messages for clarity. Add tests covering persistence failure propagation, post-commit relocation failures, and lease-loss handling.
Diagnosis: LibraryMoveWorkflow compares user-library source and destination paths, not internal system paths. The relevant semantics are the destination root/output path boundary semantics already resolved from configured root folders or output path. This changes no public API surface; LibraryMoveWorkflow is DI-constructed and callers continue through LibraryController.
Diagnosis: DownloadImportService plans user-library destination paths under the audiobook destination volume. The correct semantics already flow in through resolved destination semantics. This changes no public API surface; ImportDestinationPlanner now separates planning from committing so a failed file action cannot reserve a batch destination.
Diagnosis: these are user-library path boundaries. Manual import source validation should use source/root semantics, preview/scan containment should use the selected root semantics, and durable content moves already receive resolved semantics from MoveJobProcessor. AudiobookContentMoveRequest is internal-only, so making semantics required does not change public API surface.
Diagnosis: this is enforcement, not a migration of one runtime path. Permanent allow-list entries are internal/runtime helpers; temporary entries are user-library sites that still need explicit semantics. The guard converts future drift into a CI failure while the temporary list is reduced subsystem by subsystem.
Diagnosis: MetadataRescanProcessor compares user-library audiobook and file paths when pruning non-audio file rows. The correct semantics come from the configured root containing the audiobook path, falling back to probing the path boundary. No public API changes are required because the processor already resolves scoped services from DI.
Diagnosis: organize preview and execution compare user-library audiobook paths within a single audiobook boundary. The correct semantics are resolved once from the audiobook current path/root and threaded through ordering, equality, containment, allowed roots, and path summary updates. The public IRenameService API is unchanged; only the service constructor and internal helpers changed.
Diagnosis: AudiobookFileService compares user-library paths while deciding whether a scanned or imported audio file is safe to associate with an audiobook. These are not internal system paths. The correct semantics come from the configured root containing the existing audiobook directory or base path, with boundary probing as fallback. IAudiobookFileService remains unchanged because semantics are resolved inside the service.
Diagnosis: LibraryPathPlanner and FileNamingService compare user-library roots and scan directories, not internal system paths. Scan base-path calculation now receives explicit scan-root semantics, and output-root comparison uses resolved configured-root semantics. IFileNamingService remains source-compatible because semantics resolution is injected optionally and used internally.
Diagnosis: scan queue dedupe compares user-library paths. These are not internal paths, but failures only duplicate or miss queue dedupe rather than mutating files. Active scan and unmatched-scan dedupe now resolve the queued path semantics before comparing. The unmatched last-job cache keeps exact original keys because the controller normally queries the same root-folder path, avoiding a synchronous resolver API change.
Diagnosis: DownloadClientGateway dedupes mapped local source-file paths, which are filesystem identities after remote path mapping. This is not an internal string operation. The gateway now resolves semantics from the local source/content boundary and uses exact matching only if identity resolution is unavailable. Public gateway API remains unchanged.
Diagnosis: moved audiobook image and legacy file path rewrites operate on user-library paths during a move. MoveJobProcessor already has resolved move semantics, so those semantics are now threaded into the rewriter for containment and relative path resolution. No public API changes.
Diagnosis: RemotePathMappingService is an internal path translation boundary. It maps client-reported remote relative paths onto local native base paths and uses host-native semantics only to keep the mapped result inside the configured local base. It is not a user-library identity comparison. Stale delete-service allow-list entries were removed after that subsystem cleanup.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fix download/import path handling so Listenarr preserves valid whitespace in client-reported source paths while keeping Listenarr-owned destination paths validated and constrained separately.
This addresses #528, where torrent paths could be treated like user-entered text and lose meaningful leading or trailing path-segment whitespace on Unix-like filesystems. It also hardens destination validation so root folders remain explicit filesystem boundaries, concrete audiobook targets stay inside configured library/output boundaries, and client child paths cannot escape the save path.
This PR also intentionally includes Kevin Heneveld PR #727 / commit 3103996, cherry-picked with Kevin credited as author. That migration fix restores EF discovery for AddMoveJobSourcePath, adds the missing ProcessExecutionLogs migration, and adds real SQLite migration schema coverage. Future migrations must use IDs later than 20260702200000.
Changes
Added
Changed
Fixed
Testing
Notes