Harden path identity, durable moves, scans, and root relocation - #717
Conversation
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. |
|
Temporary formatter/compiler diagnostic superseded and cleaned up. |
3 similar comments
|
Temporary formatter/compiler diagnostic superseded and cleaned up. |
|
Temporary formatter/compiler diagnostic superseded and cleaned up. |
|
Temporary formatter/compiler diagnostic superseded and cleaned up. |
|
Temporary verbose Windows test diagnostic superseded by compact output. |
Temporary Windows test diagnosticsExit code: |
Temporary Windows test diagnosticsExit code: |
Temporary Windows test diagnosticsExit code: |
Temporary Windows test diagnosticsExit code: |
|
I ran the same repro against All three modes now run to completion, where every one of them previously failed at the gate: I think that is the confirmation you were after, though you are better placed than me to say whether it covers what you wanted checked. The control is the case I expect to fail, so it still failing is what suggests to me the check can detect a broken destination rather than having quietly stopped testing anything. Three of your claims looked observable from the host side, so I checked those rather than taking them on trust. All three seem to hold, as far as I can tell. The lock hierarchy was completely absent at the start. My config directory is created fresh for each run and holds only Permissions came out I could not find anything leaked into the library tree either. Counting every file under both root folders that is not generated audio or my own manifest gives zero. Worth saying what that does not cover. I did not test the symlinked-ancestor rejection or the unavailable-lock-root fail-closed path, since I cannot stage either from outside the container without contriving the filesystem into a state that would not tell you much, and your regressions look like better coverage there anyway. This is also one container runtime on one host, so it says nothing about the Windows path. Thanks for turning it around so quickly. |
Summary
This PR began with the Unix/macOS download-folder whitespace bug in #528 and expanded into a repair of the filesystem contracts used by imports, audiobook moves, root-folder relocation, scans, file registration, organize/rename, deletion, and path-bearing persistence.
The branch now separates three concepts that were previously conflated:
Physical library moves are durable workflows instead of
BasePathpre-saves. Jobs persist endpoint identity, tracked-file manifests, lease/retry state, physical-generation proofs, database-backed mutation journals, target-directory state, cleanup authority, and durable scan handoffs so crashes, cancellation, retries, pathname replacement, and stale workers fail closed.Issues resolved
Closes #528
Closes #598
Closes #765
Closes #438
Related: #766
Platform-specific mutation contract
mkdirat, then reopened and verified without following links. Because POSIX does not atomically return a handle for the generation created bymkdirat, a newly created Unix directory is treated as retained/unowned rather than being granted destructive cleanup authority solely from the create result.Changes
Added
AudiobookDeletionIntentstate so process death between filesystem cleanup and database deletion cannot orphan files without recovery authority.ConfigRootPath/runtime/file-move-locks.Changed
BasePath; metadata changes only after durable completion or through the explicit metadata-only repair path.FileMovercopy/move/hardlink fallbacks to reject aliases and overlaps, verify publication and cleanup, preserve uncertain content, and avoid recursive copy-and-delete success claims.Retained.Pending,Failed, andNeedsAttentionstates rather than inferring repairability from human-readable error text.(2)/(3)duplicates and duplicateAudiobookFilerows.\\remains a filename character instead of being rewritten as a separator, including when the Unix root itself contains a literal backslash.Fixed
BasePathto the author folder (Library scan attributes every book by an author to whichever one is scanned, and sets the author folder as its BasePath #765); the fix/regression approach proposed in fix(scan): don't attribute every book by an author to whichever one is scanned #766 was adapted onto this PR...traversal into case-distinct Windows sibling directories.AudiobookFileIdincorrectly blocking root collision repair forever..NET LocalApplicationDatacould resolve empty when$HOME/.local/sharedid not exist; cross-process move locks now use the Listenarr-owned config runtime directory.Removed
BasePathpre-save behavior.Windows == case-insensitiveassumptions from destructive mutation authorization and durable recovery identity.Testing and current validation status
Local Windows validation of the final unchanged tree
dotnet format --verify-no-changes: passed.dotnet ef migrations has-pending-model-changes: no pending model changes.git diff --check: passed.Native GitHub validation
Exact-head
Run Testsworkflow31510036348is green forac211dba8e4710eaa9be583d035b0dc551d2eebe.Linux (
unit-tests)Windows (
backend-tests-windows)All current exact-head checks, including CodeQL and the
minorversion-label check, are green. There are currently zero unresolved GitHub Code Quality review threads.External Docker/Podman reproduction
The Docker lock-directory defect reported by @m4bard is addressed by moving cross-process locks to the Listenarr-owned config runtime root. The original unprimed-container reproduction was rerun independently against a descendant containing that fix and passed all expected import modes. The independent rerun also confirmed that the runtime lock hierarchy is created from an initially absent
runtime/directory, lock permissions remain current-user-only, and no lock artifacts are leaked into the library tree.Review status
The final unchanged tree completed two consecutive independent adversarial review passes with zero confirmed findings.
The review covered the complete authoritative remediation diff, including:
The final source tree remained unchanged after the second clean review pass and exact-head native validation.