Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,37 @@ public partial class LibraryAddService
normalizedRequestedBaseDirectory);
}

if (IsExactlyAllowedDestinationRoot(
normalizedRequestedBaseDirectory,
allowedDestinationRoots))
{
// A destination that IS a root folder is a root selection, not
// the audiobook's folder. No audiobook may claim a root as its
// own BasePath: the destination guard rejects every later add
// aimed at that root, and imports would land files loose at the
// library root. Plan the book's folder under the chosen root
// exactly as an omitted destination plans it under the default.
settings ??= await _configurationService.GetApplicationSettingsAsync();
var plannedBasePath = Path.Join(
normalizedRequestedBaseDirectory,
_fileNamingService.ApplyNamingPattern(
settings.FolderNamingPattern,
metadata));
if (!FileUtils.TryNormalizeUserProvidedDirectoryPathForCurrentOs(
plannedBasePath,
out var normalizedPlannedBasePath,
out var plannedValidationReason,
rejectParentTraversal: true))
{
return ValidationFailure(
"destination_path_invalid",
$"Generated library destination is invalid: {plannedValidationReason}",
plannedBasePath);
}

normalizedRequestedBaseDirectory = normalizedPlannedBasePath;
}

audiobook.BasePath = normalizedRequestedBaseDirectory;
}
else
Expand Down Expand Up @@ -131,4 +162,30 @@ public partial class LibraryAddService
destinationBlockingReason,
audiobook.BasePath);
}

private static bool IsExactlyAllowedDestinationRoot(
string normalizedDestination,
IReadOnlyCollection<string> allowedDestinationRoots)
{
// Exact canonical spelling is the conservative test here: the root
// selection this rewrites always arrives as the root's stored path. A
// case-alias spelling of a root simply skips subfolder planning and
// falls through to the destination guard, exactly as before.
var canonicalDestination = FileSystemPathIdentity
.TryCanonicalizeUnambiguousStoredAbsolutePathForHost(
normalizedDestination,
out var normalized,
out _)
? normalized
: normalizedDestination;
return allowedDestinationRoots.Any(root => string.Equals(
FileSystemPathIdentity.TryCanonicalizeUnambiguousStoredAbsolutePathForHost(
root,
out var canonicalRoot,
out _)
? canonicalRoot
: root,
canonicalDestination,
StringComparison.Ordinal));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,36 @@ public async Task AddToLibrary_FinalCommitWaitsForGlobalFilesystemMutation()
Assert.Single(await _audiobookRepository.GetAllAsync());
}

[Fact]
public async Task AddToLibrary_DestinationIsConfiguredRoot_PlansFolderUnderRootInsteadOfClaimingIt()
{
// A destination equal to a configured root folder is a root
// selection. Persisting the root itself as BasePath makes the
// destination guard reject every later add aimed at that root and
// sends the book's eventual import loose into the library root.
var controller = _provider.GetRequiredService<LibraryController>();

var result = await controller.AddToLibrary(new LibraryController.AddToLibraryRequest
{
Metadata = new AudibleBookMetadata
{
Title = "Root Selection Probe",
Authors = ["Rooted Author"]
},
DestinationPath = tempRoot,
Monitored = true
});

Assert.IsType<OkObjectResult>(result);
var audiobook = Assert.Single(await _audiobookRepository.GetAllAsync());
Assert.NotEqual(
Path.GetFullPath(tempRoot),
Path.GetFullPath(audiobook.BasePath!));
Assert.Equal(
Path.GetFullPath(Path.Join(tempRoot, "Rooted Author")),
Path.GetFullPath(audiobook.BasePath!));
}

[Fact]
public async Task AddToLibrary_ConcurrentIdentifierlessSameDestination_CommitsExactlyOnce()
{
Expand Down