From cac0da4fceb6a88f36c41b21f9d90aed4db1bb6d Mon Sep 17 00:00:00 2001 From: m4bard <304653687+m4bard@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:52:21 -0500 Subject: [PATCH 1/4] fix(filesystem): say why a source file could not be pinned CheckAsync catches seven exception types and returned one sentence naming none of them, with the exception bound and never used and no logger call anywhere in the file. A locked file, a permissions problem and a path reached through a symlink were indistinguishable afterwards. That gate refuses the import before any destination is created and before FileMover attempts anything, so nothing further down produces a better message. DownloadImportService logs only this reason string, which is why #890 reports that the logs contain nothing explaining the failure. Now logs the exception with its NativeErrorCode, which a Win32Exception keeps out of Message when a custom message is supplied, and names the offending segment when the cause is a symlinked ancestor. The refusal itself is unchanged, and CheckPublicationSource_LinkedAncestor_ReturnsUnsupported still passes. Co-Authored-By: Claude Opus 5 (1M context) --- .../FileSystem/FileMover.SourceCapability.cs | 54 +++++++++++- .../PinnedDirectoryCreation.Hierarchy.cs | 1 + .../FileMoverSymlinkedSourcePathTests.cs | 87 +++++++++++++++++++ 3 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 tests/Features/Infrastructure/FileSystem/FileMoverSymlinkedSourcePathTests.cs diff --git a/listenarr.infrastructure/FileSystem/FileMover.SourceCapability.cs b/listenarr.infrastructure/FileSystem/FileMover.SourceCapability.cs index 5e10de23d..761358daa 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.SourceCapability.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.SourceCapability.cs @@ -1,4 +1,5 @@ using System.ComponentModel; +using Microsoft.Extensions.Logging; namespace Listenarr.Infrastructure.FileSystem; @@ -106,9 +107,60 @@ IOException or UnauthorizedAccessException or Win32Exception or InvalidOperationException or NotSupportedException or PathTooLongException or System.Security.SecurityException) { + // Seven exception types reach here and used to leave through one sentence that named + // none of them. A locked file, a permissions problem and an unreadable mount were + // indistinguishable afterwards, and this is the gate that refuses the import, so the + // one line an operator gets is the only thing they have to go on. + var linkedAncestor = FindSymlinkedAncestor(sourcePath); + var detail = linkedAncestor == null + ? $"{exception.GetType().Name}: {exception.Message}" + : $"the path is reached through a symbolic link at '{linkedAncestor}', which cannot be pinned; " + + $"configure the real path instead ({exception.GetType().Name}: {exception.Message})"; + + _logger.LogWarning( + exception, + "Source publication capability unavailable for {Source}: {Detail} (native error {NativeError})", + LogRedaction.SanitizeText(sourcePath), + detail, + (exception as Win32Exception)?.NativeErrorCode ?? 0); + return FilePublicationSourceCapabilityResult.Unsupported( - "The source file cannot be pinned to a durable physical generation and content proof.", + $"The source file cannot be pinned to a durable physical generation and content proof: {detail}", FilePublicationSourceCapabilityFailureKind.Unavailable); } } + + /// + /// The first directory in the path that is a symbolic link, or null if there is none. + /// + /// + /// Refusing a linked ancestor is deliberate and covered by + /// CheckPublicationSource_LinkedAncestor_ReturnsUnsupported, so this does not change the + /// answer. It only says which segment caused it, because the raw failure is an ENOTDIR from + /// openat and gives an operator nothing to act on. + /// + private static string? FindSymlinkedAncestor(string sourcePath) + { + try + { + var current = Path.GetDirectoryName(Path.GetFullPath(sourcePath)); + while (!string.IsNullOrEmpty(current)) + { + if (Directory.Exists(current) + && Directory.ResolveLinkTarget(current, returnFinalTarget: false) != null) + { + return current; + } + current = Path.GetDirectoryName(current); + } + } + catch (Exception exception) when (exception is + IOException or UnauthorizedAccessException or NotSupportedException + or System.Security.SecurityException) + { + // Best effort only. The caller still reports the original failure. + } + + return null; + } } diff --git a/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.Hierarchy.cs b/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.Hierarchy.cs index a91815c8e..1ead2ae3c 100644 --- a/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.Hierarchy.cs +++ b/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.Hierarchy.cs @@ -35,6 +35,7 @@ internal static PinnedDirectoryAnchor OpenPinnedBoundary(string boundaryPath) "The managed directory boundary changed while it was being pinned."); } + internal static PinnedDirectoryAnchor OpenPinnedHierarchyNoFollow( string path, bool createMissing) diff --git a/tests/Features/Infrastructure/FileSystem/FileMoverSymlinkedSourcePathTests.cs b/tests/Features/Infrastructure/FileSystem/FileMoverSymlinkedSourcePathTests.cs new file mode 100644 index 000000000..0e35ea185 --- /dev/null +++ b/tests/Features/Infrastructure/FileSystem/FileMoverSymlinkedSourcePathTests.cs @@ -0,0 +1,87 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using Listenarr.Application.Common.Contracts; +using Listenarr.Infrastructure.FileSystem; +using Listenarr.Tests.Common; + +namespace Listenarr.Tests.Features.Infrastructure.FileSystem; + +/// +/// A source reached through a symlinked directory is refused, and the refusal says so. +/// +/// Refusing a linked ancestor is deliberate and already covered by +/// CheckPublicationSource_LinkedAncestor_ReturnsUnsupported. What was missing is any way for an +/// operator to know that is what happened: the underlying failure is an ENOTDIR from openat with +/// O_NOFOLLOW, reported as one fixed sentence about durable physical generations. +/// +[Trait("Area", "FileSystem")] +[Trait("Name", "FileMoverSymlinkedSourcePathTests")] +[Trait("Category", "PublicationCapability")] +public sealed class FileMoverSymlinkedSourcePathTests : BaseTests +{ + private (string Direct, string ViaSymlink) CreateSymlinkedLayout(string name) + { + var root = FileService.GetTempDirectory(name); + var real = Path.Join(root, "real-downloads", "completed"); + Directory.CreateDirectory(real); + var file = Path.Join(real, "book.m4b"); + File.WriteAllText(file, "audio"); + + // The shape this reproduces: one component of the path is a symlink to the real + // directory, which is what a cache tier or a pooled mount usually looks like. + var link = Path.Join(root, "downloads"); + Directory.CreateSymbolicLink(link, Path.Join(root, "real-downloads")); + + return (file, Path.Join(link, "completed", "book.m4b")); + } + + [DirectoryLinkFact] + [Trait("Scenario", "The same file is publishable by its real path")] + public async Task CheckAsync_RealPath_IsSupported() + { + // The control. Both paths name the same file on the same filesystem, so anything that + // fails for one and not the other is about the path, not the file. + var layout = CreateSymlinkedLayout("symlink-source-control"); + var capability = Assert.IsAssignableFrom( + _provider.GetRequiredService()); + + var result = await capability.CheckAsync(layout.Direct); + + Assert.True(result.IsSupported, result.Reason); + } + + [DirectoryLinkFact] + [Trait("Scenario", "The refusal names the symlinked directory that caused it")] + public async Task CheckAsync_PathThroughSymlinkedDirectory_NamesTheLink() + { + var layout = CreateSymlinkedLayout("symlink-source-refused"); + Assert.True(File.Exists(layout.ViaSymlink), "the file must be reachable through the link"); + + var capability = Assert.IsAssignableFrom( + _provider.GetRequiredService()); + + var result = await capability.CheckAsync(layout.ViaSymlink); + + // Still refused. That is the intended boundary and this test does not argue with it. + Assert.False(result.IsSupported); + + // But the reason must now be actionable rather than a fixed sentence about generations. + Assert.Contains("symbolic link", result.Reason, StringComparison.OrdinalIgnoreCase); + Assert.Contains("configure the real path", result.Reason, StringComparison.OrdinalIgnoreCase); + } +} From fe8fbc4cfb7eb1567b3e966633bdb9a3667f0d9a Mon Sep 17 00:00:00 2001 From: m4bard <304653687+m4bard@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:25:11 -0500 Subject: [PATCH 2/4] fix(filesystem): sanitize the pinning cause before it reaches the log The new warning interpolated two attacker-influenced strings straight into the log record. A download client picks the file name, and the file name is what most of these exception messages quote, so a name containing a newline could append a second, fabricated log line. Every other call site in this directory already routes such text through LogRedaction.SanitizeText; these two did not. The native error code is now reported as absent rather than as zero for the six exception types that carry no code. Zero is a real errno meaning success, so logging it was a false reading rather than a missing one. Co-Authored-By: Claude Fable 5.1 --- .../FileSystem/FileMover.SourceCapability.cs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/listenarr.infrastructure/FileSystem/FileMover.SourceCapability.cs b/listenarr.infrastructure/FileSystem/FileMover.SourceCapability.cs index 761358daa..470354698 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.SourceCapability.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.SourceCapability.cs @@ -111,18 +111,25 @@ or InvalidOperationException or NotSupportedException // none of them. A locked file, a permissions problem and an unreadable mount were // indistinguishable afterwards, and this is the gate that refuses the import, so the // one line an operator gets is the only thing they have to go on. + // Both interpolated values are attacker-influenced: a download client writes the + // file name, and the file name is what most of these exception messages quote. A + // newline in either would let a crafted name forge a second log record, so they go + // through the same SanitizeText every other call site in this directory uses. var linkedAncestor = FindSymlinkedAncestor(sourcePath); + var cause = $"{exception.GetType().Name}: {LogRedaction.SanitizeText(exception.Message)}"; var detail = linkedAncestor == null - ? $"{exception.GetType().Name}: {exception.Message}" - : $"the path is reached through a symbolic link at '{linkedAncestor}', which cannot be pinned; " - + $"configure the real path instead ({exception.GetType().Name}: {exception.Message})"; + ? cause + : $"the path is reached through a symbolic link at '{LogRedaction.SanitizeText(linkedAncestor)}', " + + $"which cannot be pinned; configure the real path instead ({cause})"; _logger.LogWarning( exception, "Source publication capability unavailable for {Source}: {Detail} (native error {NativeError})", LogRedaction.SanitizeText(sourcePath), detail, - (exception as Win32Exception)?.NativeErrorCode ?? 0); + // Nullable on purpose. Zero is a real errno meaning success, so reporting it for + // the six exception types that carry no native code would be a false reading. + (exception as Win32Exception)?.NativeErrorCode); return FilePublicationSourceCapabilityResult.Unsupported( $"The source file cannot be pinned to a durable physical generation and content proof: {detail}", From e024036a869ea68958eff8fd81789cff8f8210cd Mon Sep 17 00:00:00 2001 From: m4bard <304653687+m4bard@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:25:11 -0500 Subject: [PATCH 3/4] test(filesystem): fail the refusal test when no cause is captured Both assertions in CheckAsync_PathThroughSymlinkedDirectory_NamesTheLink matched literals from the format string, so a build that recorded an empty cause passed them. The test now also requires the linked segment, which comes from the ancestor walk, and an exception type name, which comes from the caught exception. Also drops two edits that changed nothing: a blank line added to PinnedDirectoryCreation.Hierarchy.cs, which is otherwise untouched by this branch, and a using directive the test project already declares globally and which the build reported as IDE0005. Co-Authored-By: Claude Fable 5.1 --- .../FileSystem/PinnedDirectoryCreation.Hierarchy.cs | 1 - .../FileSystem/FileMoverSymlinkedSourcePathTests.cs | 12 ++++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.Hierarchy.cs b/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.Hierarchy.cs index 1ead2ae3c..a91815c8e 100644 --- a/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.Hierarchy.cs +++ b/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.Hierarchy.cs @@ -35,7 +35,6 @@ internal static PinnedDirectoryAnchor OpenPinnedBoundary(string boundaryPath) "The managed directory boundary changed while it was being pinned."); } - internal static PinnedDirectoryAnchor OpenPinnedHierarchyNoFollow( string path, bool createMissing) diff --git a/tests/Features/Infrastructure/FileSystem/FileMoverSymlinkedSourcePathTests.cs b/tests/Features/Infrastructure/FileSystem/FileMoverSymlinkedSourcePathTests.cs index 0e35ea185..137b1c9a6 100644 --- a/tests/Features/Infrastructure/FileSystem/FileMoverSymlinkedSourcePathTests.cs +++ b/tests/Features/Infrastructure/FileSystem/FileMoverSymlinkedSourcePathTests.cs @@ -15,8 +15,6 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using Listenarr.Application.Common.Contracts; -using Listenarr.Infrastructure.FileSystem; using Listenarr.Tests.Common; namespace Listenarr.Tests.Features.Infrastructure.FileSystem; @@ -34,7 +32,7 @@ namespace Listenarr.Tests.Features.Infrastructure.FileSystem; [Trait("Category", "PublicationCapability")] public sealed class FileMoverSymlinkedSourcePathTests : BaseTests { - private (string Direct, string ViaSymlink) CreateSymlinkedLayout(string name) + private (string Direct, string ViaSymlink, string Link) CreateSymlinkedLayout(string name) { var root = FileService.GetTempDirectory(name); var real = Path.Join(root, "real-downloads", "completed"); @@ -47,7 +45,7 @@ public sealed class FileMoverSymlinkedSourcePathTests : BaseTests var link = Path.Join(root, "downloads"); Directory.CreateSymbolicLink(link, Path.Join(root, "real-downloads")); - return (file, Path.Join(link, "completed", "book.m4b")); + return (file, Path.Join(link, "completed", "book.m4b"), link); } [DirectoryLinkFact] @@ -83,5 +81,11 @@ public async Task CheckAsync_PathThroughSymlinkedDirectory_NamesTheLink() // But the reason must now be actionable rather than a fixed sentence about generations. Assert.Contains("symbolic link", result.Reason, StringComparison.OrdinalIgnoreCase); Assert.Contains("configure the real path", result.Reason, StringComparison.OrdinalIgnoreCase); + + // Both phrases above are literals in the format string, so they survive a build that + // captures no cause at all. These two do not: the linked segment comes from the walk and + // the type name comes from the caught exception, so an empty cause fails here. + Assert.Contains(Path.GetFileName(layout.Link), result.Reason, StringComparison.Ordinal); + Assert.Matches(@"\w+Exception: \S", result.Reason!); } } From 45af623a38c26edb24500673ab9be13a03a2e52b Mon Sep 17 00:00:00 2001 From: m4bard <304653687+m4bard@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:22:10 -0500 Subject: [PATCH 4/4] fix(filesystem): lead the refusal with the cause, through the shared formatter Every consumer of the capability result renders Reason through LogRedaction.SanitizeText, whose 200-character default cut the exception off the end. With a realistic symlinked path the operator was left the fixed sentence they already knew and lost both the cause and the instruction. Putting the cause first means truncation costs the half that can be reconstructed from the code rather than the half that cannot. The cause is now formatted by ExceptionCause, the same helper the import result uses to persist a failure into History, so the two records stop disagreeing about what the cause is. It also gives this gate the inner chain it had no way to reach while it formatted the outer exception alone. The composition moves into ComposeUnsupportedReason so it can be tested without provoking a real ENOTDIR, which is what let the ordering and the truncation be pinned directly. Co-Authored-By: Claude Opus 5 (1M context) --- listenarr.domain/Common/ExceptionCause.cs | 73 ++++++++++++++++++ .../FileSystem/FileMover.SourceCapability.cs | 45 ++++++++--- .../Domain/Common/ExceptionCauseTests.cs | 70 +++++++++++++++++ .../FileMoverSourceCapabilityReasonTests.cs | 75 +++++++++++++++++++ 4 files changed, 251 insertions(+), 12 deletions(-) create mode 100644 listenarr.domain/Common/ExceptionCause.cs create mode 100644 tests/Features/Domain/Common/ExceptionCauseTests.cs create mode 100644 tests/Features/Infrastructure/FileSystem/FileMoverSourceCapabilityReasonTests.cs diff --git a/listenarr.domain/Common/ExceptionCause.cs b/listenarr.domain/Common/ExceptionCause.cs new file mode 100644 index 000000000..827eb3006 --- /dev/null +++ b/listenarr.domain/Common/ExceptionCause.cs @@ -0,0 +1,73 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +namespace Listenarr.Domain.Common +{ + /// + /// One spelling of "why did this fail", for every place that has to put an exception into + /// text a person reads. + /// + /// + /// Two sites grew their own copy of this: the import result that a History row persists, and + /// the file publication capability gate that refuses a source file. They disagreed, so the + /// same wrapped failure read as a chain in one record and as a single line in the other. This + /// is the seam they should have shared. It lives in the domain because the domain references + /// nothing, so the application and infrastructure projects can both reach it. + /// + /// It formats and nothing else. Redaction and truncation belong to the caller, because + /// LogRedaction is in the application project and the domain cannot see it. + /// + public static class ExceptionCause + { + /// + /// The exception's type and message, followed by the same for each inner cause. + /// + /// The exception to describe. Null yields an empty string. + /// + /// How many frames of the cause chain to walk. The guard matters because an exception can + /// be constructed with itself as an inner cause. + /// + /// + /// For example InvalidOperationException: Unable to perform HardlinkCopy -> + /// IOException: Invalid cross-device link. Repeated frames are collapsed, because a + /// wrapper that rethrows with the same message says nothing twice. + /// + public static string Describe(Exception? exception, int maxDepth = 8) + { + if (exception == null) + { + return string.Empty; + } + + var parts = new List(); + var current = exception; + var guard = 0; + while (current != null && guard++ < maxDepth) + { + var text = $"{current.GetType().Name}: {current.Message}"; + if (!parts.Contains(text)) + { + parts.Add(text); + } + + current = current.InnerException; + } + + return string.Join(" -> ", parts); + } + } +} diff --git a/listenarr.infrastructure/FileSystem/FileMover.SourceCapability.cs b/listenarr.infrastructure/FileSystem/FileMover.SourceCapability.cs index 470354698..526c4504a 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.SourceCapability.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.SourceCapability.cs @@ -1,4 +1,5 @@ using System.ComponentModel; +using Listenarr.Domain.Common; using Microsoft.Extensions.Logging; namespace Listenarr.Infrastructure.FileSystem; @@ -111,32 +112,52 @@ or InvalidOperationException or NotSupportedException // none of them. A locked file, a permissions problem and an unreadable mount were // indistinguishable afterwards, and this is the gate that refuses the import, so the // one line an operator gets is the only thing they have to go on. - // Both interpolated values are attacker-influenced: a download client writes the - // file name, and the file name is what most of these exception messages quote. A - // newline in either would let a crafted name forge a second log record, so they go - // through the same SanitizeText every other call site in this directory uses. - var linkedAncestor = FindSymlinkedAncestor(sourcePath); - var cause = $"{exception.GetType().Name}: {LogRedaction.SanitizeText(exception.Message)}"; - var detail = linkedAncestor == null - ? cause - : $"the path is reached through a symbolic link at '{LogRedaction.SanitizeText(linkedAncestor)}', " - + $"which cannot be pinned; configure the real path instead ({cause})"; + var reason = ComposeUnsupportedReason(exception, FindSymlinkedAncestor(sourcePath)); _logger.LogWarning( exception, "Source publication capability unavailable for {Source}: {Detail} (native error {NativeError})", LogRedaction.SanitizeText(sourcePath), - detail, + reason, // Nullable on purpose. Zero is a real errno meaning success, so reporting it for // the six exception types that carry no native code would be a false reading. (exception as Win32Exception)?.NativeErrorCode); return FilePublicationSourceCapabilityResult.Unsupported( - $"The source file cannot be pinned to a durable physical generation and content proof: {detail}", + reason, FilePublicationSourceCapabilityFailureKind.Unavailable); } } + /// + /// The refusal an operator reads, cause first. + /// + /// + /// Every consumer of Reason renders it through LogRedaction.SanitizeText, whose + /// 200-character default used to cut the exception off the end and leave behind only the fixed + /// sentence the operator already knew. Leading with the cause means the half that survives + /// truncation is the half that says what went wrong. + /// + /// The cause is formatted by ExceptionCause rather than here, because the import result + /// that persists a failure into History has to say the same thing this does. It also gives + /// this gate the inner chain it had no way to reach while it formatted the outer exception + /// on its own. + /// + /// Both interpolated values are attacker-influenced: a download client writes the file name, + /// and the file name is what most of these exception messages quote. A newline in either would + /// let a crafted name forge a second log record, so they go through the same SanitizeText + /// every other call site in this directory uses. + /// + internal static string ComposeUnsupportedReason(Exception exception, string? linkedAncestor) + { + var cause = LogRedaction.SanitizeText(ExceptionCause.Describe(exception)); + + return linkedAncestor == null + ? $"{cause} The source file could not be pinned to a durable physical generation and content proof." + : $"{cause} The source file could not be pinned: it is reached through a symbolic link at " + + $"'{LogRedaction.SanitizeText(linkedAncestor)}', which cannot be pinned, so configure the real path instead."; + } + /// /// The first directory in the path that is a symbolic link, or null if there is none. /// diff --git a/tests/Features/Domain/Common/ExceptionCauseTests.cs b/tests/Features/Domain/Common/ExceptionCauseTests.cs new file mode 100644 index 000000000..628784444 --- /dev/null +++ b/tests/Features/Domain/Common/ExceptionCauseTests.cs @@ -0,0 +1,70 @@ +using Listenarr.Tests.Common; + +namespace Listenarr.Tests.Features.Domain.Common; + +/// +/// Two sites had grown their own copy of "why did this fail", and they disagreed: one walked the +/// inner chain and one formatted the outer exception alone, so the same wrapped failure read +/// differently depending on which record you looked at. These pin the one spelling both now use. +/// +[Trait("Area", "Common")] +[Trait("Name", "ExceptionCauseTests")] +[Trait("Category", "Domain")] +public sealed class ExceptionCauseTests : BaseTests +{ + [Fact] + public void Describe_SingleException_IsTypeThenMessage() + { + Assert.Equal("IOException: Disk full", ExceptionCause.Describe(new IOException("Disk full"))); + } + + [Fact] + public void Describe_WalksTheWholeChain() + { + // The exact shape the file layer produces: the real reason wrapped in a message that + // names the operation. + var root = new UnauthorizedAccessException("Access to the path is denied"); + var middle = new IOException("Invalid cross-device link", root); + var outer = new InvalidOperationException("Unable to perform HardlinkCopy", middle); + + Assert.Equal( + "InvalidOperationException: Unable to perform HardlinkCopy -> " + + "IOException: Invalid cross-device link -> " + + "UnauthorizedAccessException: Access to the path is denied", + ExceptionCause.Describe(outer)); + } + + [Fact] + public void Describe_RepeatedFrame_IsNotRepeated() + { + // A rethrow of the same type and text would otherwise say nothing twice. + var inner = new IOException("Disk full"); + + Assert.Equal("IOException: Disk full", ExceptionCause.Describe(new IOException("Disk full", inner))); + } + + [Fact] + public void Describe_StopsAtTheDepthGuard() + { + // The guard is why this walks rather than recurses: an exception can be constructed with + // itself somewhere in its own chain, and the frames here are distinct so nothing is + // collapsed on the way. Stopping at two proves the parameter is honoured rather than + // hardcoded, which a private copy in either call site would have been. + Exception current = new IOException("frame 0"); + for (var depth = 1; depth <= 4; depth++) + { + current = new IOException($"frame {depth}", current); + } + + Assert.Equal("IOException: frame 4 -> IOException: frame 3", ExceptionCause.Describe(current, maxDepth: 2)); + Assert.Equal(5, ExceptionCause.Describe(current).Split(" -> ").Length); + } + + [Fact] + public void Describe_Null_IsEmpty() + { + // The call sites take a non-nullable exception, so this is a guard rather than a path. + // It is here because the behaviour it replaced was a NullReferenceException. + Assert.Equal(string.Empty, ExceptionCause.Describe(null)); + } +} diff --git a/tests/Features/Infrastructure/FileSystem/FileMoverSourceCapabilityReasonTests.cs b/tests/Features/Infrastructure/FileSystem/FileMoverSourceCapabilityReasonTests.cs new file mode 100644 index 000000000..6cccb7fe0 --- /dev/null +++ b/tests/Features/Infrastructure/FileSystem/FileMoverSourceCapabilityReasonTests.cs @@ -0,0 +1,75 @@ +using System.ComponentModel; +using Listenarr.Tests.Common; + +namespace Listenarr.Tests.Features.Infrastructure.FileSystem; + +/// +/// The refusal string this gate hands back is the only thing an operator gets when a source file +/// cannot be pinned, and every consumer of it logs it through LogRedaction.SanitizeText. These +/// assert what survives that, and that the cause is spelled the way the import record spells it. +/// +[Trait("Area", "FileSystem")] +[Trait("Name", "FileMoverSourceCapabilityReasonTests")] +[Trait("Category", "Infrastructure")] +public sealed class FileMoverSourceCapabilityReasonTests : BaseTests +{ + [Fact] + public void ComposeUnsupportedReason_LeadsWithTheCause() + { + var reason = FileMover.ComposeUnsupportedReason( + new Win32Exception("Could not open a newly created pinned directory."), + linkedAncestor: null); + + Assert.StartsWith("Win32Exception: Could not open a newly created pinned directory.", reason, StringComparison.Ordinal); + } + + [Fact] + public void ComposeUnsupportedReason_SymlinkAdvice_SurvivesNeitherHalfBeingDropped() + { + // The realistic shape: a pooled mount reached through a link, with a path long enough + // that the whole reason exceeds what a consumer will render. + var reason = FileMover.ComposeUnsupportedReason( + new Win32Exception("Could not open a newly created pinned directory."), + linkedAncestor: "/mnt/pool/media/library/downloads/completed/audiobooks"); + + Assert.Contains("symbolic link", reason, StringComparison.OrdinalIgnoreCase); + Assert.Contains("configure the real path", reason, StringComparison.OrdinalIgnoreCase); + Assert.Contains("/mnt/pool/media/library/downloads/completed/audiobooks", reason, StringComparison.Ordinal); + + // The point of the ordering. Every consumer renders the reason through SanitizeText, + // whose 200-character default cut the exception off the end while the advice led. The + // cause is the half that cannot be reconstructed from anywhere else, so it goes first + // and the truncation costs the fixed sentence instead. + Assert.True(reason.Length > 200, $"the case is only meaningful when truncation bites: {reason.Length}"); + var rendered = LogRedaction.SanitizeText(reason); + Assert.Contains("Win32Exception: Could not open a newly created pinned directory.", rendered, StringComparison.Ordinal); + } + + [Fact] + public void ComposeUnsupportedReason_UsesTheSharedCauseSpelling_NotItsOwn() + { + // The seam. While this site formatted its own cause it saw the outer exception only, so a + // wrapped failure arriving here lost its reason while the same failure on the import path + // kept it. Both now read the chain the same way. + var native = new Win32Exception("Could not open a newly created pinned directory."); + var wrapped = new IOException("Unable to pin the source directory", native); + + var reason = FileMover.ComposeUnsupportedReason(wrapped, linkedAncestor: null); + + Assert.StartsWith(ExceptionCause.Describe(wrapped), reason, StringComparison.Ordinal); + Assert.Contains("Could not open a newly created pinned directory.", reason, StringComparison.Ordinal); + } + + [Fact] + public void ComposeUnsupportedReason_NewlineInTheMessage_CannotForgeASecondRecord() + { + // A download client picks the file name, and the file name is what most of these + // exception messages quote. + var reason = FileMover.ComposeUnsupportedReason( + new IOException("book.m4b\nfatal: everything is fine"), + linkedAncestor: null); + + Assert.DoesNotContain('\n', reason); + Assert.DoesNotContain('\r', reason); + } +}