Skip to content

fix(downloads): preserve client path whitespace#717

Open
therobbiedavis wants to merge 35 commits into
canaryfrom
bugfix/unix-folder-name-space
Open

fix(downloads): preserve client path whitespace#717
therobbiedavis wants to merge 35 commits into
canaryfrom
bugfix/unix-folder-name-space

Conversation

@therobbiedavis

@therobbiedavis therobbiedavis commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

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

  • Added OS-aware user-provided directory path validation for Listenarr-owned destination paths.
  • Added regression coverage for qBittorrent and Transmission paths with leading/trailing whitespace-bearing folders.
  • Added root-folder validation coverage for Windows, Linux, macOS, and Docker/Linux path behavior.
  • Added generated-destination regression coverage proving sanitized metadata still passes destination validation.
  • Added move workflow coverage for absolute destinations inside configured root folders/output paths and rejection outside configured boundaries.
  • Added regression coverage for ambiguous leading-whitespace destination paths and case-distinct qBittorrent source files.
  • Added AudiobookContentMoveService to isolate filesystem content moves from move job orchestration.
  • Added real SQLite migration schema tests to catch model/schema drift from undiscovered EF migrations.

Changed

  • Changed torrent source/content path mapping to preserve client-reported path whitespace instead of trimming it.
  • Changed qBittorrent source-file de-duplication to preserve exact case-distinct external path identities.
  • Changed remote path mapping and queue path translation to treat whitespace-bearing paths as valid external filesystem paths.
  • Changed root folder create/update normalization so configured root folders may be filesystem roots, including Windows drive roots, current-drive roots, UNC roots, and Unix root.
  • Changed add-to-library and move workflows to reject invalid concrete destinations instead of silently falling back.
  • Changed move destinations so normalized absolute and relative paths must remain inside configured root folders or the configured output path.
  • Changed move same-path comparisons to follow the host filesystem case-sensitivity.
  • Changed edit-destination behavior so pasted absolute paths under the selected root are normalized before validation.
  • Changed frontend destination validation so current-directory segments are rejected without incorrectly labeling them as path traversal; parent-directory segments remain path traversal.

Fixed

  • Fixed imports failing or missing files when completed torrent folders have legal leading or trailing spaces on Linux, macOS, or Docker installs.
  • Fixed destination validation so concrete audiobook paths reject unsafe parent segments before normalization.
  • Fixed ambiguous leading-whitespace destination paths so visually absolute paths are not accidentally treated as relative child folders.
  • Fixed root-folder validation so configured boundaries must be explicit.
  • Fixed torrent child path mapping so unsafe rooted-looking or parent-segment entries cannot leave the client save path.
  • Fixed invalid custom add-to-library destinations returning success with fallback behavior.
  • Fixed library move destinations so absolute paths no longer bypass configured library/output boundaries.
  • Fixed Windows path syntax checks so legacy source-path validation and user destination validation share the same segment-level rules.
  • Fixed move enqueue behavior so BasePath is not pre-saved before a physical move job succeeds.
  • Fixed move retry behavior so stale partial files in a job temp directory are replaced instead of causing permanent retry failure.
  • Fixed nested target-inside-source cleanup so stale sibling content outside the new target subtree is removed.
  • Fixed EF migration schema drift by restoring the AddMoveJobSourcePath Designer and adding the missing ProcessExecutionLogs migration.

Testing

  • cd fe && npx vitest run --reporter=dot --silent=true utils/path.spec.ts EditAudiobookModal.relativePath.spec.ts
  • cd fe && npm run test:unit
  • cd fe && npm run type-check
  • cd fe && npm run lint:check
  • dotnet build tests/Listenarr.Tests.csproj
  • dotnet test tests/Listenarr.Tests.csproj --no-build --filter "FullyQualifiedName~AudiobookContentMoveServiceTests"
  • dotnet test tests/Listenarr.Tests.csproj --no-build
  • git diff --check

Notes

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.
@therobbiedavis therobbiedavis added the patch patch version bump - backward compatible bug fixes label Jul 1, 2026
@therobbiedavis therobbiedavis marked this pull request as ready for review July 1, 2026 21:04
@therobbiedavis therobbiedavis requested review from a team and Copilot July 1, 2026 21:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread listenarr.infrastructure/DownloadClients/Common/TorrentClientPathMapper.cs Outdated
Comment thread tests/Features/Application/Downloads/Common/DownloadClientGatewayTests.cs Outdated
Comment thread tests/Features/Application/Downloads/Common/DownloadClientGatewayTests.cs Outdated
therobbiedavis and others added 6 commits July 1, 2026 18:29
Cherry-picked PR #727 from 3103996 and adapted the current branch by removing the now-redundant MoveJobs runtime schema repair.

Co-authored-by: Kevin Heneveld <1192102+kevinheneveld@users.noreply.github.com>

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Comment thread fe/src/utils/path.ts Fixed
Comment thread fe/src/utils/path.ts Fixed
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.
var current = path;
while (!string.IsNullOrEmpty(current))
{
if (Directory.Exists(current))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

patch patch version bump - backward compatible bug fixes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Linux/OSX] Folder names with leading/trailing space

4 participants