From 3bd6205aef5a550cc3aacf711e9cc439431976ee Mon Sep 17 00:00:00 2001 From: m4bard <304653687+m4bard@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:44:52 -0500 Subject: [PATCH 1/2] fix(parsing): pin machine-format number parses to the invariant culture ffprobe, MyAnonamouse, SABnzbd and Torznab all emit numbers with '.' as the decimal separator whatever locale the emitting process runs under. Six parses read those strings with the ambient culture, so on a server whose culture treats '.' as the group separator the value was read as a different number, and on a server whose decimal separator is ',' it did not parse at all. Measured under de-DE and fr-FR: FfprobeMetadataMapper.cs:50 "43200.250" -> 43200250 s / not parsed MyAnonamouseSizeParser.cs:36 "1.5 GB" -> 15 GB / 0 MyAnonamouseSizeParser.cs:49 "1.5 GB" -> 15 GB / 0 SabnzbdResponseMapper.cs:201 "1.5 M" -> 15 MB/s / 0 SabnzbdResponseMapper.cs:320 "1.5" -> 15 MB / 0 TorznabNewznabValueParser.cs:40 "1.5 GB" -> 15 GB / 0 Each site now passes NumberStyles and CultureInfo.InvariantCulture, matching the parse that was already correct in the same file where there is one (SabnzbdResponseMapper.cs:224 uses NumberStyles.Any, MyAnonamouseSizeParser.cs:73 uses NumberStyles.Float) and NzbgetHistoryReader.cs:157 otherwise. Fixes #796. --- .../MyAnonamouse/MyAnonamouseSizeParser.cs | 4 +- .../Sabnzbd/SabnzbdResponseMapper.cs | 5 +- .../Ffmpeg/Metadata/FfprobeMetadataMapper.cs | 3 +- .../Torznab/TorznabNewznabValueParser.cs | 3 +- .../MachineFormatCultureParsingTests.cs | 184 ++++++++++++++++++ 5 files changed, 193 insertions(+), 6 deletions(-) create mode 100644 tests/Features/Common/MachineFormatCultureParsingTests.cs diff --git a/listenarr.application/Search/Indexers/MyAnonamouse/MyAnonamouseSizeParser.cs b/listenarr.application/Search/Indexers/MyAnonamouse/MyAnonamouseSizeParser.cs index bc85fb0fb..fe7a20f68 100644 --- a/listenarr.application/Search/Indexers/MyAnonamouse/MyAnonamouseSizeParser.cs +++ b/listenarr.application/Search/Indexers/MyAnonamouse/MyAnonamouseSizeParser.cs @@ -33,7 +33,7 @@ public static long ExtractFromDescription(string? description, ILogger logger) var sizeValue = match.Groups[1].Value.Replace(",", ""); var unit = match.Groups[2].Value.ToUpper(); - if (double.TryParse(sizeValue, out var value)) + if (double.TryParse(sizeValue, NumberStyles.Float, CultureInfo.InvariantCulture, out var value)) { var result = ParseDecimalUnit(value, unit, binary: true); logger.LogDebug("Extracted size from MyAnonamouse description formatted: {Value} {Unit} = {Result} bytes", value, unit, result); @@ -46,7 +46,7 @@ public static long ExtractFromDescription(string? description, ILogger logger) { var sizeValue = match.Groups[1].Value.Replace(",", ""); var unit = match.Groups[2].Value.ToUpper(); - if (double.TryParse(sizeValue, out var value)) + if (double.TryParse(sizeValue, NumberStyles.Float, CultureInfo.InvariantCulture, out var value)) { var result = ParseDecimalUnit(value, unit, binary: true); logger.LogDebug("Extracted size from MyAnonamouse description (no bytes): {Value} {Unit} = {Result} bytes", value, unit, result); diff --git a/listenarr.infrastructure/DownloadClients/Sabnzbd/SabnzbdResponseMapper.cs b/listenarr.infrastructure/DownloadClients/Sabnzbd/SabnzbdResponseMapper.cs index bb5f289c2..f53e4cf37 100644 --- a/listenarr.infrastructure/DownloadClients/Sabnzbd/SabnzbdResponseMapper.cs +++ b/listenarr.infrastructure/DownloadClients/Sabnzbd/SabnzbdResponseMapper.cs @@ -198,7 +198,7 @@ public static double ParseSpeed(string speedStr) var parts = speedStr.Split(' ', StringSplitOptions.RemoveEmptyEntries); if (parts.Length == 0) return 0; - if (!double.TryParse(parts[0], out var value)) return 0; + if (!double.TryParse(parts[0], NumberStyles.Any, CultureInfo.InvariantCulture, out var value)) return 0; if (parts.Length > 1) { @@ -317,7 +317,8 @@ private static double GetDouble(JsonElement element, string propertyName) if (property.ValueKind == JsonValueKind.Number) return property.GetDouble(); - if (property.ValueKind == JsonValueKind.String && double.TryParse(property.GetString() ?? "0", out var value)) + if (property.ValueKind == JsonValueKind.String + && double.TryParse(property.GetString() ?? "0", NumberStyles.Any, CultureInfo.InvariantCulture, out var value)) return value; return 0; diff --git a/listenarr.infrastructure/Ffmpeg/Metadata/FfprobeMetadataMapper.cs b/listenarr.infrastructure/Ffmpeg/Metadata/FfprobeMetadataMapper.cs index ca75eb1d1..d702c3f37 100644 --- a/listenarr.infrastructure/Ffmpeg/Metadata/FfprobeMetadataMapper.cs +++ b/listenarr.infrastructure/Ffmpeg/Metadata/FfprobeMetadataMapper.cs @@ -15,6 +15,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +using System.Globalization; using System.Text.Json; namespace Listenarr.Infrastructure.Ffmpeg.Metadata @@ -47,7 +48,7 @@ private static void ApplyFormat(AudioMetadata metadata, JsonElement fmt, string { if (fmt.TryGetProperty("duration", out var durEl) && durEl.ValueKind == JsonValueKind.String - && double.TryParse(durEl.GetString(), out var dur)) + && double.TryParse(durEl.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var dur)) { metadata.Duration = TimeSpan.FromSeconds(dur); } diff --git a/listenarr.infrastructure/Search/Providers/Torznab/TorznabNewznabValueParser.cs b/listenarr.infrastructure/Search/Providers/Torznab/TorznabNewznabValueParser.cs index f36f91e10..8372465a0 100644 --- a/listenarr.infrastructure/Search/Providers/Torznab/TorznabNewznabValueParser.cs +++ b/listenarr.infrastructure/Search/Providers/Torznab/TorznabNewznabValueParser.cs @@ -16,6 +16,7 @@ * along with this program. If not, see . */ +using System.Globalization; using System.Text.RegularExpressions; namespace Listenarr.Infrastructure.Search.Providers.Torznab; @@ -37,7 +38,7 @@ public static long ParseSize(string sizeStr) if (!match.Success) return 0; - if (!double.TryParse(match.Groups[1].Value, out var size)) + if (!double.TryParse(match.Groups[1].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var size)) return 0; var unit = match.Groups[2].Value.ToUpper(); diff --git a/tests/Features/Common/MachineFormatCultureParsingTests.cs b/tests/Features/Common/MachineFormatCultureParsingTests.cs new file mode 100644 index 000000000..d7d9c0344 --- /dev/null +++ b/tests/Features/Common/MachineFormatCultureParsingTests.cs @@ -0,0 +1,184 @@ +/* + * 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 System.Globalization; +using System.Text.Json; +using Listenarr.Infrastructure.Ffmpeg.Metadata; +using Listenarr.Tests.Common; + +namespace Listenarr.Tests.Features.Common +{ + /// + /// ffprobe, MyAnonamouse, SABnzbd and Torznab all emit numbers in machine format: '.' is the + /// decimal separator, whatever locale the emitting process runs under. Parsing those strings + /// with the ambient culture reads them as a different number, or fails to read them at all. + /// + /// A default container runs under the invariant culture and is unaffected. It takes a real + /// culture reaching the process, which happens when LANG or LC_ALL is set, and on a desktop + /// install that inherits the operating system's locale: + /// + /// de-DE: '.' is the GROUP separator, so "43200.250" parsed as 43200250. + /// fr-FR: ',' is the decimal separator, so "43200.250" did not parse at all. + /// + [Trait("Name", "MachineFormatCultureParsingTests")] + [Trait("Category", "Unit")] + public class MachineFormatCultureParsingTests : BaseTests + { + // '' is the invariant culture: what a container with no LANG gets. + // de-DE treats '.' as the group separator; fr-FR treats ',' as the decimal separator. + public static TheoryData ServerCultures => new() { "", "en-US", "de-DE", "fr-FR" }; + + private static void InCulture(string culture, Action body) + { + var originalCulture = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo(culture); + body(); + } + finally + { + CultureInfo.CurrentCulture = originalCulture; + } + } + + private static AudioMetadata MapFfprobeDuration(string duration) + { + using var document = JsonDocument.Parse( + "{\"format\":{\"duration\":\"" + duration + "\"}}"); + return FfprobeMetadataMapper.Map(document.RootElement, "book.m4b"); + } + + // FfprobeMetadataMapper.cs:48-52. ffprobe emits format.duration as a string and always + // uses '.'. Under de-DE a twelve hour book was recorded as 43,200,250 seconds. + [Theory] + [MemberData(nameof(ServerCultures))] + public void FfprobeDuration_ParsesTheSameUnderEveryServerCulture(string culture) + { + InCulture(culture, () => + { + Assert.Equal(TimeSpan.FromSeconds(43200.250), MapFfprobeDuration("43200.250").Duration); + Assert.Equal(TimeSpan.FromSeconds(3600.5), MapFfprobeDuration("3600.5").Duration); + }); + } + + // MyAnonamouseSizeParser.cs:49. The description carries a formatted size and no byte + // count, so the size is whatever this parse makes of "1.5". + [Theory] + [MemberData(nameof(ServerCultures))] + public void MyAnonamouseFormattedSize_ParsesTheSameUnderEveryServerCulture(string culture) + { + InCulture(culture, () => + { + var size = MyAnonamouseSizeParser.ExtractFromDescription( + "Total Size: 1.5 GB", + Mock.Of()); + + Assert.Equal(1610612736L, size); + }); + } + + // MyAnonamouseSizeParser.cs:36. Same parse on the branch that has a byte count, reached + // when the byte count itself is not a usable long. Harder to hit than the branch above, + // and pinned for the same reason. + [Theory] + [MemberData(nameof(ServerCultures))] + public void MyAnonamouseSizeBesideUnusableByteCount_ParsesTheSameUnderEveryServerCulture(string culture) + { + InCulture(culture, () => + { + var size = MyAnonamouseSizeParser.ExtractFromDescription( + "Total Size: 1.5 GB (99999999999999999999999 bytes)", + Mock.Of()); + + Assert.Equal(1610612736L, size); + }); + } + + // TorznabNewznabValueParser.cs:40. The regex pulls "1.5" out of "1.5 GB" and the bare + // parse then decides whether the release is 1.5 GB, 15 GB, or zero. + [Theory] + [MemberData(nameof(ServerCultures))] + public void TorznabSize_ParsesTheSameUnderEveryServerCulture(string culture) + { + InCulture(culture, () => + { + Assert.Equal(1610612736L, TorznabNewznabValueParser.ParseSize("1.5 GB")); + Assert.Equal(1610612736L, TorznabNewznabValueParser.ParseSize("1.5 GiB")); + }); + } + + // SabnzbdResponseMapper.cs:201. SABnzbd reports the queue speed as a formatted string. + [Theory] + [MemberData(nameof(ServerCultures))] + public void SabnzbdSpeed_ParsesTheSameUnderEveryServerCulture(string culture) + { + InCulture(culture, () => + { + Assert.Equal(1.5 * 1024 * 1024, SabnzbdResponseMapper.ParseSpeed("1.5 M")); + }); + } + + // SabnzbdResponseMapper.cs:320. GetDouble reads mb/mbleft/percentage out of a queue slot. + // ParseJsonDouble at :224 already pins the same read; this is its unpinned twin. + [Theory] + [MemberData(nameof(ServerCultures))] + public void SabnzbdQueueSlotSize_ParsesTheSameUnderEveryServerCulture(string culture) + { + InCulture(culture, () => + { + using var document = JsonDocument.Parse( + """ + { + "nzo_id": "SABnzbd_nzo_test", + "filename": "Some Release", + "status": "Downloading", + "cat": "audiobooks", + "mb": "1.5", + "mbleft": "0.5", + "percentage": "66.6" + } + """); + + var item = SabnzbdResponseMapper.MapQueueSlotToQueueItem( + new DownloadClientConfiguration { Name = "sab", Type = "sabnzbd" }, + document.RootElement, + configuredCategory: string.Empty, + speed: 0); + + Assert.NotNull(item); + Assert.Equal((long)(1.5 * 1024 * 1024), item!.Size); + Assert.Equal((long)(1.0 * 1024 * 1024), item.Downloaded); + Assert.Equal(66.6, item.Progress); + }); + } + + // SabnzbdResponseMapper.cs:224, already pinned before this change. Asserted so it stays + // pinned, and so the expected behaviour of the sites above is stated against a site that + // was already right. + [Theory] + [MemberData(nameof(ServerCultures))] + public void SabnzbdJsonDouble_StaysPinnedUnderEveryServerCulture(string culture) + { + InCulture(culture, () => + { + using var document = JsonDocument.Parse("\"1.5\""); + Assert.Equal(1.5, SabnzbdResponseMapper.ParseJsonDouble(document.RootElement)); + }); + } + } +} From 07ab55015da38e0e130a2fcd8b1942b37ed89362 Mon Sep 17 00:00:00 2001 From: m4bard <304653687+m4bard@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:10:49 -0500 Subject: [PATCH 2/2] fix(parsing): match size units and reject non machine-format numbers Three follow-ons to the same idea, all inside the subject of this branch. ToUpperInvariant at the two Torznab size parsers. Both uppercase the matched unit with the ambient culture before switching on it. Under tr-TR the 'i' of "GiB" becomes U+0130, no binary arm matches, and TorznabNewznabValueParser records the release as zero bytes while TorznabResponseParser falls through to the raw mantissa. That is a larger error than any of the six number parses this branch pins, on the line below one of them. Pinning the parse and leaving the unit match to the locale fixes half a function. NumberStyles.Float instead of Any at the two SABnzbd sites. Any carries AllowThousands, AllowCurrencySymbol and AllowParentheses, so under the invariant culture "1,5" reads as 15 and "(1.5)" as -1.5. A size that cannot be read is better than one read as ten times itself. Float is the convention across the three culture branches now. The three MyAnonamousePublishDateParser ages are pinned for uniformity. No damage was measured at these: the values seen in practice are whole numbers, which read the same under every culture. They are the last unpinned machine-format parses in the codebase. tr-TR joins the culture theory, which is what makes the unit match testable, and ParseSizeString becomes internal so the second Torznab parser can be asserted directly. Twenty two of the seventy cases fail with any of the three changes reverted. Co-Authored-By: Claude Fable 5.1 --- .../MyAnonamousePublishDateParser.cs | 12 +- .../TorznabResponseParser.SizeParsing.cs | 15 +- .../Sabnzbd/SabnzbdResponseMapper.cs | 8 +- .../Torznab/TorznabNewznabValueParser.cs | 4 +- .../MachineFormatCultureParsingTests.cs | 173 +++++++++++++++++- 5 files changed, 203 insertions(+), 9 deletions(-) diff --git a/listenarr.application/Search/Indexers/MyAnonamouse/MyAnonamousePublishDateParser.cs b/listenarr.application/Search/Indexers/MyAnonamouse/MyAnonamousePublishDateParser.cs index 3b6365844..69efaa0bc 100644 --- a/listenarr.application/Search/Indexers/MyAnonamouse/MyAnonamousePublishDateParser.cs +++ b/listenarr.application/Search/Indexers/MyAnonamouse/MyAnonamousePublishDateParser.cs @@ -8,6 +8,7 @@ * (at your option) any later version. */ +using System.Globalization; using System.Text.Json; using Microsoft.Extensions.Logging; @@ -65,16 +66,20 @@ internal static class MyAnonamousePublishDateParser double? hours = null; double? minutes = null; + // These three ages are machine format like every other number in this + // indexer's JSON, so they are pinned for uniformity. No damage was + // measured here: the values seen in practice are whole numbers, which + // read the same under every culture. // Prefer explicit ageHours/ageMinutes if present if (item.TryGetProperty("ageHours", out var ah) && (ah.ValueKind == JsonValueKind.Number || ah.ValueKind == JsonValueKind.String)) { if (ah.ValueKind == JsonValueKind.Number) hours = ah.GetDouble(); - else if (double.TryParse(ah.GetString(), out var htmp)) hours = htmp; + else if (double.TryParse(ah.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var htmp)) hours = htmp; } if (item.TryGetProperty("ageMinutes", out var am) && (am.ValueKind == JsonValueKind.Number || am.ValueKind == JsonValueKind.String)) { if (am.ValueKind == JsonValueKind.Number) minutes = am.GetDouble(); - else if (double.TryParse(am.GetString(), out var mtmp)) minutes = mtmp; + else if (double.TryParse(am.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var mtmp)) minutes = mtmp; } // Fallback to 'age' if present. Heuristic: small values (<=48) likely hours; otherwise treat as days. @@ -86,7 +91,8 @@ internal static class MyAnonamousePublishDateParser if (a <= 48) hours = a; else days = (int)Math.Floor(a); } - else if (ageElem.ValueKind == JsonValueKind.String && double.TryParse(ageElem.GetString(), out var adtmp)) + else if (ageElem.ValueKind == JsonValueKind.String + && double.TryParse(ageElem.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var adtmp)) { var a = adtmp; if (a <= 48) hours = a; diff --git a/listenarr.application/Search/Indexers/Torznab/TorznabResponseParser.SizeParsing.cs b/listenarr.application/Search/Indexers/Torznab/TorznabResponseParser.SizeParsing.cs index 669f512af..18bc98d29 100644 --- a/listenarr.application/Search/Indexers/Torznab/TorznabResponseParser.SizeParsing.cs +++ b/listenarr.application/Search/Indexers/Torznab/TorznabResponseParser.SizeParsing.cs @@ -8,7 +8,15 @@ namespace Listenarr.Application.Search.Indexers.Torznab { internal sealed partial class TorznabResponseParser { - private long ParseSizeString(string sizeStr) + /// + /// Reads a size out of a Torznab attribute value, either as plain bytes or as a + /// formatted string such as "1.5 GiB". + /// + /// + /// Internal rather than private so the culture behaviour of the unit match can be + /// asserted against the real method rather than a copy of it. + /// + internal long ParseSizeString(string sizeStr) { if (string.IsNullOrEmpty(sizeStr)) return 0; @@ -26,7 +34,10 @@ private long ParseSizeString(string sizeStr) if (match.Success && double.TryParse(match.Groups[1].Value, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var value)) { - var unit = match.Groups[2].Value.ToUpper(); + // ToUpperInvariant, not ToUpper: under tr-TR the 'i' of "GiB" uppercases to + // 'I' with a dot (U+0130), no binary arm matches, and the size falls through + // to the (long)value default, which is the raw mantissa in bytes. + var unit = match.Groups[2].Value.ToUpperInvariant(); return unit switch { "B" => (long)value, diff --git a/listenarr.infrastructure/DownloadClients/Sabnzbd/SabnzbdResponseMapper.cs b/listenarr.infrastructure/DownloadClients/Sabnzbd/SabnzbdResponseMapper.cs index f53e4cf37..5d26822c8 100644 --- a/listenarr.infrastructure/DownloadClients/Sabnzbd/SabnzbdResponseMapper.cs +++ b/listenarr.infrastructure/DownloadClients/Sabnzbd/SabnzbdResponseMapper.cs @@ -198,7 +198,10 @@ public static double ParseSpeed(string speedStr) var parts = speedStr.Split(' ', StringSplitOptions.RemoveEmptyEntries); if (parts.Length == 0) return 0; - if (!double.TryParse(parts[0], NumberStyles.Any, CultureInfo.InvariantCulture, out var value)) return 0; + // NumberStyles.Float rather than Any: Any carries AllowThousands, AllowCurrencySymbol + // and AllowParentheses, so under the invariant culture "1,5" reads as 15 and "(1.5)" + // as -1.5. SABnzbd emits a plain decimal, and a value that is not one should fail. + if (!double.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var value)) return 0; if (parts.Length > 1) { @@ -317,8 +320,9 @@ private static double GetDouble(JsonElement element, string propertyName) if (property.ValueKind == JsonValueKind.Number) return property.GetDouble(); + // Float rather than Any, for the reason given on ParseSpeed above. if (property.ValueKind == JsonValueKind.String - && double.TryParse(property.GetString() ?? "0", NumberStyles.Any, CultureInfo.InvariantCulture, out var value)) + && double.TryParse(property.GetString() ?? "0", NumberStyles.Float, CultureInfo.InvariantCulture, out var value)) return value; return 0; diff --git a/listenarr.infrastructure/Search/Providers/Torznab/TorznabNewznabValueParser.cs b/listenarr.infrastructure/Search/Providers/Torznab/TorznabNewznabValueParser.cs index 8372465a0..c07865a6c 100644 --- a/listenarr.infrastructure/Search/Providers/Torznab/TorznabNewznabValueParser.cs +++ b/listenarr.infrastructure/Search/Providers/Torznab/TorznabNewznabValueParser.cs @@ -41,7 +41,9 @@ public static long ParseSize(string sizeStr) if (!double.TryParse(match.Groups[1].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var size)) return 0; - var unit = match.Groups[2].Value.ToUpper(); + // ToUpperInvariant, not ToUpper: under tr-TR the 'i' of "GiB" uppercases to 'I' with a + // dot (U+0130), no binary arm matches, and the release is recorded as zero bytes. + var unit = match.Groups[2].Value.ToUpperInvariant(); return unit switch { "TIB" => (long)(size * 1024 * 1024 * 1024 * 1024), diff --git a/tests/Features/Common/MachineFormatCultureParsingTests.cs b/tests/Features/Common/MachineFormatCultureParsingTests.cs index d7d9c0344..84d23912d 100644 --- a/tests/Features/Common/MachineFormatCultureParsingTests.cs +++ b/tests/Features/Common/MachineFormatCultureParsingTests.cs @@ -17,6 +17,7 @@ */ using System.Globalization; using System.Text.Json; +using Listenarr.Application.Search.Indexers.Torznab; using Listenarr.Infrastructure.Ffmpeg.Metadata; using Listenarr.Tests.Common; @@ -40,7 +41,9 @@ public class MachineFormatCultureParsingTests : BaseTests { // '' is the invariant culture: what a container with no LANG gets. // de-DE treats '.' as the group separator; fr-FR treats ',' as the decimal separator. - public static TheoryData ServerCultures => new() { "", "en-US", "de-DE", "fr-FR" }; + // tr-TR is here for the case folding rather than the separator: 'i' uppercases to 'I' + // with a dot (U+0130), so "GiB" under ToUpper() matches no binary unit arm. + public static TheoryData ServerCultures => new() { "", "en-US", "de-DE", "fr-FR", "tr-TR" }; private static void InCulture(string culture, Action body) { @@ -180,5 +183,173 @@ public void SabnzbdJsonDouble_StaysPinnedUnderEveryServerCulture(string culture) Assert.Equal(1.5, SabnzbdResponseMapper.ParseJsonDouble(document.RootElement)); }); } + + // -------------------------------------------------------------------------- + // Case folding. Pinning the number parse and leaving the unit match to the + // ambient culture fixes half of the same function. + // -------------------------------------------------------------------------- + + // TorznabNewznabValueParser.cs:46. Under tr-TR the 'i' of "GiB" uppercases to U+0130, + // no binary arm of the switch matches, and the release is recorded as zero bytes: + // a larger error than any of the six number parses this PR pins. + [Theory] + [MemberData(nameof(ServerCultures))] + public void TorznabBinaryUnits_MatchUnderEveryServerCulture(string culture) + { + InCulture(culture, () => + { + Assert.Equal(1610612736L, TorznabNewznabValueParser.ParseSize("1.5 GiB")); + Assert.Equal(1572864L, TorznabNewznabValueParser.ParseSize("1.5 MiB")); + Assert.Equal(1536L, TorznabNewznabValueParser.ParseSize("1.5 KiB")); + Assert.Equal(1649267441664L, TorznabNewznabValueParser.ParseSize("1.5 TiB")); + + // Not zero: the failure mode being closed is the unmatched switch arm. + Assert.NotEqual(0L, TorznabNewznabValueParser.ParseSize("1.5 GiB")); + }); + } + + // TorznabResponseParser.SizeParsing.cs:32, the other Torznab size parser. Its number + // parse was already invariant on canary, so only the unit match was exposed; under + // tr-TR "1.5 GiB" fell through the switch to the (long)value default, i.e. 1 byte. + [Theory] + [MemberData(nameof(ServerCultures))] + public void TorznabResponseParserBinaryUnits_MatchUnderEveryServerCulture(string culture) + { + InCulture(culture, () => + { + using var httpClient = new HttpClient(); + var parser = new TorznabResponseParser(httpClient, Mock.Of()); + + Assert.Equal(1610612736L, parser.ParseSizeString("1.5 GiB")); + Assert.Equal(1572864L, parser.ParseSizeString("1.5 MiB")); + + // The decimal units were never at risk and are asserted as the control. + Assert.Equal(1500000000L, parser.ParseSizeString("1.5 GB")); + }); + } + + // -------------------------------------------------------------------------- + // NumberStyles.Float across the batch: a value that is not machine format must + // fail the parse rather than come back as a different number. + // -------------------------------------------------------------------------- + + // SabnzbdResponseMapper.cs:204 and :325 were pinned with NumberStyles.Any, which + // carries AllowThousands, AllowCurrencySymbol and AllowParentheses: under the + // invariant culture "1,5" reads as 15 and "(1.5)" as -1.5. Float rejects both. + [Theory] + [MemberData(nameof(ServerCultures))] + public void SabnzbdSpeed_RejectsAValueThatIsNotMachineFormat(string culture) + { + InCulture(culture, () => + { + Assert.Equal(0, SabnzbdResponseMapper.ParseSpeed("1,5 M")); + Assert.Equal(0, SabnzbdResponseMapper.ParseSpeed("(1.5) M")); + + // Control: the machine-format value still parses. + Assert.Equal(1.5 * 1024 * 1024, SabnzbdResponseMapper.ParseSpeed("1.5 M")); + }); + } + + [Theory] + [MemberData(nameof(ServerCultures))] + public void SabnzbdQueueSlotSize_RejectsAValueThatIsNotMachineFormat(string culture) + { + InCulture(culture, () => + { + using var document = JsonDocument.Parse( + """ + { + "nzo_id": "SABnzbd_nzo_test", + "filename": "Some Release", + "status": "Downloading", + "cat": "audiobooks", + "mb": "1,5", + "mbleft": "0.5", + "percentage": "66.6" + } + """); + + var item = SabnzbdResponseMapper.MapQueueSlotToQueueItem( + new DownloadClientConfiguration { Name = "sab", Type = "sabnzbd" }, + document.RootElement, + configuredCategory: string.Empty, + speed: 0); + + Assert.NotNull(item); + + // Zero, not 15 MB. A size that cannot be read is better than one read as ten + // times itself, which is what NumberStyles.Any returned here. + Assert.Equal(0L, item!.Size); + }); + } + + // -------------------------------------------------------------------------- + // MyAnonamousePublishDateParser.cs:77, :82 and :95. No damage was measured at + // these three: the ages seen in practice are whole numbers, which read the same + // under every culture. They are pinned for uniformity, and asserted so the + // uniformity is a property of the build rather than of the reviewer's memory. + // -------------------------------------------------------------------------- + + private static DateTime? ParsePublishDate(string json) + { + using var document = JsonDocument.Parse(json); + return MyAnonamousePublishDateParser.Parse( + document.RootElement, + "Some Release", + Mock.Of()); + } + + [Theory] + [MemberData(nameof(ServerCultures))] + public void MyAnonamouseAgeHours_ParsesTheSameUnderEveryServerCulture(string culture) + { + InCulture(culture, () => + { + var withFraction = ParsePublishDate("{\"ageHours\":\"2.5\"}"); + var whole = ParsePublishDate("{\"ageHours\":\"2\"}"); + + Assert.NotNull(withFraction); + Assert.NotNull(whole); + + // Half an hour apart, not twenty five hours or nothing at all. + var gap = whole!.Value - withFraction!.Value; + Assert.InRange(gap.TotalMinutes, 29, 31); + }); + } + + [Theory] + [MemberData(nameof(ServerCultures))] + public void MyAnonamouseAgeMinutes_ParsesTheSameUnderEveryServerCulture(string culture) + { + InCulture(culture, () => + { + var withFraction = ParsePublishDate("{\"ageMinutes\":\"90.5\"}"); + var whole = ParsePublishDate("{\"ageMinutes\":\"90\"}"); + + Assert.NotNull(withFraction); + Assert.NotNull(whole); + + var gap = whole!.Value - withFraction!.Value; + Assert.InRange(gap.TotalSeconds, 29, 31); + }); + } + + [Theory] + [MemberData(nameof(ServerCultures))] + public void MyAnonamouseAge_ParsesTheSameUnderEveryServerCulture(string culture) + { + InCulture(culture, () => + { + // 1.5 is below the 48 hour threshold, so it is read as hours. + var withFraction = ParsePublishDate("{\"age\":\"1.5\"}"); + var whole = ParsePublishDate("{\"age\":\"1\"}"); + + Assert.NotNull(withFraction); + Assert.NotNull(whole); + + var gap = whole!.Value - withFraction!.Value; + Assert.InRange(gap.TotalMinutes, 29, 31); + }); + } } }