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
73 changes: 73 additions & 0 deletions listenarr.domain/Common/ExceptionCause.cs
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/
namespace Listenarr.Domain.Common
{
/// <summary>
/// One spelling of "why did this fail", for every place that has to put an exception into
/// text a person reads.
/// </summary>
/// <remarks>
/// 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
/// <c>LogRedaction</c> is in the application project and the domain cannot see it.
/// </remarks>
public static class ExceptionCause
{
/// <summary>
/// The exception's type and message, followed by the same for each inner cause.
/// </summary>
/// <param name="exception">The exception to describe. Null yields an empty string.</param>
/// <param name="maxDepth">
/// How many frames of the cause chain to walk. The guard matters because an exception can
/// be constructed with itself as an inner cause.
/// </param>
/// <returns>
/// For example <c>InvalidOperationException: Unable to perform HardlinkCopy -&gt;
/// IOException: Invalid cross-device link</c>. Repeated frames are collapsed, because a
/// wrapper that rethrows with the same message says nothing twice.
/// </returns>
public static string Describe(Exception? exception, int maxDepth = 8)
{
if (exception == null)
{
return string.Empty;
}

var parts = new List<string>();
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);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System.ComponentModel;
using Listenarr.Domain.Common;
using Microsoft.Extensions.Logging;

namespace Listenarr.Infrastructure.FileSystem;

Expand Down Expand Up @@ -106,9 +108,87 @@ 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 reason = ComposeUnsupportedReason(exception, FindSymlinkedAncestor(sourcePath));

_logger.LogWarning(
exception,
"Source publication capability unavailable for {Source}: {Detail} (native error {NativeError})",
LogRedaction.SanitizeText(sourcePath),
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.",
reason,
FilePublicationSourceCapabilityFailureKind.Unavailable);
}
}

/// <summary>
/// The refusal an operator reads, cause first.
/// </summary>
/// <remarks>
/// Every consumer of <c>Reason</c> renders it through <c>LogRedaction.SanitizeText</c>, 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 <c>ExceptionCause</c> 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.
/// </remarks>
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.";
}

/// <summary>
/// The first directory in the path that is a symbolic link, or null if there is none.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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;
}
}
70 changes: 70 additions & 0 deletions tests/Features/Domain/Common/ExceptionCauseTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
using Listenarr.Tests.Common;

namespace Listenarr.Tests.Features.Domain.Common;

/// <summary>
/// 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.
/// </summary>
[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));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
using System.ComponentModel;
using Listenarr.Tests.Common;

namespace Listenarr.Tests.Features.Infrastructure.FileSystem;

/// <summary>
/// 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.
/// </summary>
[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);
}
}
Loading