From b225297f22092bb2793135a77fafe07fd9a249c2 Mon Sep 17 00:00:00 2001 From: Pete Date: Thu, 20 Aug 2026 17:29:21 +0930 Subject: [PATCH] Fix ArgumentException on NZB titles containing quote characters Release titles containing " (very common in usenet subject lines, e.g. embedded sub-titles) crashed the SABnzbd add-file submission with System.ArgumentException from ContentDispositionHeaderValue, because GenericUsenetSourceResolver.SanitizeFileName() only stripped filesystem-invalid characters and left '"'/'\\' untouched. Those are valid on Linux/macOS filesystems but break the multipart Content-Disposition "filename" quoting used when submitting to SABnzbd, so the download silently never reached the client. Fixes #808. Co-Authored-By: Claude Sonnet 5 --- .../Submission/GenericUsenetSourceResolver.cs | 10 ++++- .../Sabnzbd/SabnzbdAdapterTests.cs | 44 +++++++++++++++++++ tests/Mocks/Api/SabnzbdApiMock.cs | 17 +++++++ 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/listenarr.infrastructure/Downloads/Submission/GenericUsenetSourceResolver.cs b/listenarr.infrastructure/Downloads/Submission/GenericUsenetSourceResolver.cs index 7e402490e..88374f993 100644 --- a/listenarr.infrastructure/Downloads/Submission/GenericUsenetSourceResolver.cs +++ b/listenarr.infrastructure/Downloads/Submission/GenericUsenetSourceResolver.cs @@ -48,7 +48,15 @@ public async Task 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)); } diff --git a/tests/Features/Infrastructure/DownloadClients/Sabnzbd/SabnzbdAdapterTests.cs b/tests/Features/Infrastructure/DownloadClients/Sabnzbd/SabnzbdAdapterTests.cs index c63c417e3..bcafabbf5 100644 --- a/tests/Features/Infrastructure/DownloadClients/Sabnzbd/SabnzbdAdapterTests.cs +++ b/tests/Features/Infrastructure/DownloadClients/Sabnzbd/SabnzbdAdapterTests.cs @@ -43,6 +43,50 @@ public override async Task InitializeAsync() .Build()); } + /// + /// 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. + /// + [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(); + downloader + .Setup(d => d.DownloadAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .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() { diff --git a/tests/Mocks/Api/SabnzbdApiMock.cs b/tests/Mocks/Api/SabnzbdApiMock.cs index dbd0e7b8b..7a8338957 100644 --- a/tests/Mocks/Api/SabnzbdApiMock.cs +++ b/tests/Mocks/Api/SabnzbdApiMock.cs @@ -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 RemovalRequests { get; } = []; + public List AddFileRequests { get; } = []; public SabnzbdApiMock() { AddRoute("api", ProcessRequest, HttpMethod.Get); + AddRoute("api", ProcessRequest, HttpMethod.Post); } public async Task GetHistory(HttpRequestMessage request, CancellationToken ct) @@ -101,6 +103,17 @@ public async Task GetVersion(HttpRequestMessage request, Ca """); } + public async Task AddFile(HttpRequestMessage request, CancellationToken ct) + { + AddFileRequests.Add(request.RequestUri!); + return MockUtils.GetCannedResponse(""" + { + "status": true, + "nzo_ids": ["SABnzbd_nzo_addfile_test"] + } + """); + } + public async Task ProcessRequest(HttpRequestMessage request, CancellationToken ct) { var query = HttpUtility.ParseQueryString(request.RequestUri.Query); @@ -110,6 +123,10 @@ public async Task 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))