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 @@ -48,7 +48,15 @@ public async Task<PreparedDownloadSubmission> ResolveAsync(
candidate.SourceDescriptor.FileName ?? $"{SanitizeFileName(candidate.Title)}.nzb");
}

// Beyond filesystem-invalid characters, '"' and '\\' must also be stripped: this
// filename is later passed as the multipart Content-Disposition "filename" parameter
// when submitting to a download client (e.g. SABnzbd), and both characters are valid
// on Linux/macOS filesystems but break .NET's ContentDispositionHeaderValue quoting,
// throwing ArgumentException and silently failing the whole download. See
// https://github.com/Listenarrs/Listenarr/issues/808.
private static string SanitizeFileName(string value)
=> string.Concat(value.Select(character =>
Path.GetInvalidFileNameChars().Contains(character) ? '_' : character));
Path.GetInvalidFileNameChars().Contains(character) || character is '"' or '\\'
? '_'
: character));
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,50 @@ public override async Task InitializeAsync()
.Build());
}

/// <summary>
/// Regression test for https://github.com/Listenarrs/Listenarr/issues/808
/// Release titles containing '"' (common in usenet subject lines) crashed
/// AddAsync with an ArgumentException from ContentDispositionHeaderValue
/// before the request ever reached SABnzbd.
/// </summary>
[Fact]
public async Task AddAsync_TitleContainsQuotesAndCommas_SendsNzbWithoutHeaderEncodingError()
{
// Given: a real-world release title (from DrunkenSlug/NZBgeek) with embedded
// quotes and a comma-decimal size, resolved the way the production pipeline does
var title = "DBS #0762 \"J.K. Rowling - Harry Potter 1-7 Audio Book (english)\" - " +
"\"Audio book - Harry Potter And The Deathly Hallows - J.K. Rowling.part01.rar\" (02_22) - 672,05 MB";
var candidate = new TrustedDownloadCandidate(
"release-1",
title,
"J.K. Rowling",
"Harry Potter",
"DrunkenSlug (Prowlarr)",
"MP3",
"en",
700_000_000,
null,
new DownloadSourceDescriptor(
IndexerId: null,
IndexerImplementation: "Newznab",
Protocol: DownloadProtocol.Usenet,
Locators: [new DownloadSourceLocator(DownloadSourceLocatorKind.NzbUrl, "https://indexer.example/nzb/1")]));
var downloader = new Mock<INzbFileDownloader>();
downloader
.Setup(d => d.DownloadAsync(It.IsAny<string>(), It.IsAny<int?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([1, 2, 3]);
var resolver = new GenericUsenetSourceResolver(downloader.Object);

// When: the title is resolved into a prepared submission and sent to SABnzbd
var submission = await resolver.ResolveAsync(candidate, provisionalDownloadId: null, CancellationToken.None);
var adapter = MockUtils.CreateSabnzbdAdapter(_provider);
var result = await adapter.AddAsync(_client, submission);

// Then: no ArgumentException, and SABnzbd actually received the addfile request
Assert.Equal("SABnzbd_nzo_addfile_test", result.ExternalId);
Assert.Single(sabnzbdApiMock.AddFileRequests);
}

[Fact]
public async Task TestConnectionAsync_NormalizesHostWithSchemeAndPath()
{
Expand Down
17 changes: 17 additions & 0 deletions tests/Mocks/Api/SabnzbdApiMock.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@ public class SabnzbdApiMock : BaseApiMock
public System.Net.HttpStatusCode HistoryStatusCode { get; set; } = System.Net.HttpStatusCode.OK;
public string? HistoryResponseOverride { get; set; }
public List<Uri> RemovalRequests { get; } = [];
public List<Uri> AddFileRequests { get; } = [];

public SabnzbdApiMock()
{
AddRoute("api", ProcessRequest, HttpMethod.Get);
AddRoute("api", ProcessRequest, HttpMethod.Post);
}

public async Task<HttpResponseMessage> GetHistory(HttpRequestMessage request, CancellationToken ct)
Expand Down Expand Up @@ -101,6 +103,17 @@ public async Task<HttpResponseMessage> GetVersion(HttpRequestMessage request, Ca
""");
}

public async Task<HttpResponseMessage> AddFile(HttpRequestMessage request, CancellationToken ct)
{
AddFileRequests.Add(request.RequestUri!);
return MockUtils.GetCannedResponse("""
{
"status": true,
"nzo_ids": ["SABnzbd_nzo_addfile_test"]
}
""");
}

public async Task<HttpResponseMessage> ProcessRequest(HttpRequestMessage request, CancellationToken ct)
{
var query = HttpUtility.ParseQueryString(request.RequestUri.Query);
Expand All @@ -110,6 +123,10 @@ public async Task<HttpResponseMessage> ProcessRequest(HttpRequestMessage request
{
return await GetVersion(request, ct);
}
else if (string.Equals("addfile", mode))
{
return await AddFile(request, ct);
}
else if (string.Equals("history", mode))
{
if (string.Equals("delete", query["name"], StringComparison.Ordinal))
Expand Down