From 46bc076b8febb08756e148ff8908cc71bdb7b4d7 Mon Sep 17 00:00:00 2001 From: Meiam <91270@qq.com> Date: Mon, 31 Aug 2026 10:12:47 +0800 Subject: [PATCH] fix: strengthen Thunder subtitle validation --- Shared/SubtitleContentValidator.cs | 97 +++++++++---------- .../SubtitleContentValidatorTests.cs | 36 +++++++ 2 files changed, 83 insertions(+), 50 deletions(-) diff --git a/Shared/SubtitleContentValidator.cs b/Shared/SubtitleContentValidator.cs index 4aab87b..4871972 100644 --- a/Shared/SubtitleContentValidator.cs +++ b/Shared/SubtitleContentValidator.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Text; @@ -19,26 +20,34 @@ namespace MeiamSubtitles.Shared throw new InvalidDataException("Compressed subtitle responses are not supported by Thunder."); } - var sample = DecodeSample(data); if (mediaType?.IndexOf("html", StringComparison.OrdinalIgnoreCase) >= 0 || mediaType?.IndexOf("json", StringComparison.OrdinalIgnoreCase) >= 0) { throw new InvalidDataException("Thunder returned an error document instead of a subtitle."); } - var valid = format == "srt" - ? sample.IndexOf("-->", StringComparison.Ordinal) >= 0 - : sample.IndexOf("[Script Info]", StringComparison.OrdinalIgnoreCase) >= 0 || - sample.IndexOf("Dialogue:", StringComparison.OrdinalIgnoreCase) >= 0; - - if (valid) + var errorDocument = false; + foreach (var sample in DecodeSamples(data)) { - return; + if (sample.StartsWith("<", StringComparison.Ordinal) || + sample.StartsWith("{", StringComparison.Ordinal)) + { + errorDocument = true; + continue; + } + + if (IsValidSubtitle(sample, format)) + { + return; + } + + if (sample.StartsWith("[", StringComparison.Ordinal)) + { + errorDocument = true; + } } - if (sample.StartsWith("<", StringComparison.Ordinal) || - sample.StartsWith("{", StringComparison.Ordinal) || - sample.StartsWith("[", StringComparison.Ordinal)) + if (errorDocument) { throw new InvalidDataException("Thunder returned an error document instead of a subtitle."); } @@ -46,63 +55,51 @@ namespace MeiamSubtitles.Shared throw new InvalidDataException($"Downloaded content is not a valid {format} subtitle."); } - private static string DecodeSample(byte[] data) + private static bool IsValidSubtitle(string sample, string format) { - var length = Math.Min(data.Length, 4096); - var encoding = DetectEncoding(data); - return encoding.GetString(data, 0, length) - .TrimStart('\uFEFF', ' ', '\r', '\n', '\t'); + return format == "srt" + ? sample.IndexOf("-->", StringComparison.Ordinal) >= 0 + : sample.IndexOf("[Script Info]", StringComparison.OrdinalIgnoreCase) >= 0 || + sample.IndexOf("Dialogue:", StringComparison.OrdinalIgnoreCase) >= 0; } - private static Encoding DetectEncoding(byte[] data) + private static IEnumerable DecodeSamples(byte[] data) { + var length = Math.Min(data.Length, 4096); + if (data.Length >= 2) { if (data[0] == 0xFF && data[1] == 0xFE) { - return Encoding.Unicode; + yield return DecodeSample(data, length, Encoding.Unicode); + yield break; } if (data[0] == 0xFE && data[1] == 0xFF) { - return Encoding.BigEndianUnicode; + yield return DecodeSample(data, length, Encoding.BigEndianUnicode); + yield break; } } - var length = Math.Min(data.Length, 512); - var pairs = length / 2; - if (pairs >= 4) + if (data.Length >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF) { - // Some Thunder SRT responses are UTF-16 without a BOM. Detect the - // characteristic null-byte pattern before falling back to UTF-8. - var littleEndianNulls = 0; - var bigEndianNulls = 0; - for (var i = 0; i < pairs * 2; i += 2) - { - if (data[i + 1] == 0) - { - littleEndianNulls++; - } - - if (data[i] == 0) - { - bigEndianNulls++; - } - } - - var threshold = Math.Max(2, pairs / 4); - if (littleEndianNulls >= threshold && littleEndianNulls > bigEndianNulls * 2) - { - return Encoding.Unicode; - } - - if (bigEndianNulls >= threshold && bigEndianNulls > littleEndianNulls * 2) - { - return Encoding.BigEndianUnicode; - } + yield return DecodeSample(data, length, Encoding.UTF8); + yield break; } - return new UTF8Encoding(false, false); + // Thunder may return UTF-16 subtitles without a BOM. Try the common + // single-byte/UTF-8 representation and both UTF-16 byte orders, then + // accept only a candidate that contains the expected subtitle syntax. + yield return DecodeSample(data, length, new UTF8Encoding(false, false)); + yield return DecodeSample(data, length, Encoding.Unicode); + yield return DecodeSample(data, length, Encoding.BigEndianUnicode); + } + + private static string DecodeSample(byte[] data, int length, Encoding encoding) + { + return encoding.GetString(data, 0, length) + .TrimStart('\uFEFF', ' ', '\r', '\n', '\t'); } } } diff --git a/tests/MeiamSubtitles.Tests/SubtitleContentValidatorTests.cs b/tests/MeiamSubtitles.Tests/SubtitleContentValidatorTests.cs index c7f5082..8da86fe 100644 --- a/tests/MeiamSubtitles.Tests/SubtitleContentValidatorTests.cs +++ b/tests/MeiamSubtitles.Tests/SubtitleContentValidatorTests.cs @@ -23,6 +23,30 @@ public class SubtitleContentValidatorTests SubtitleContentValidator.Validate(data, "srt", "application/octet-stream"); } + [Fact] + public void AcceptsUtf16LittleEndianSrtWithoutBomWithLongChineseCue() + { + var content = "1\r\n00:00:01,000 --> 00:00:20,000\r\n" + new string('中', 300) + "\r\n"; + var data = Encoding.Unicode.GetBytes(content); + + SubtitleContentValidator.Validate(data, "srt", "application/octet-stream"); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void AcceptsUtf16SrtInEitherByteOrder(bool bigEndian, bool withBom) + { + var encoding = new UnicodeEncoding(bigEndian, withBom); + var content = "1\r\n00:00:01,000 --> 00:00:02,000\r\n你好\r\n"; + var body = encoding.GetBytes(content); + var data = withBom ? encoding.GetPreamble().Concat(body).ToArray() : body; + + SubtitleContentValidator.Validate(data, "srt", "application/octet-stream"); + } + [Theory] [InlineData("temporary error", "application/octet-stream")] [InlineData("{\"error\":\"temporary error\"}", "application/json")] @@ -45,4 +69,16 @@ public class SubtitleContentValidatorTests Assert.Contains("Compressed", exception.Message, StringComparison.Ordinal); } + + [Theory] + [InlineData("Error", "srt")] + [InlineData("{\"error\":\"download --> failed\"}", "srt")] + [InlineData("Dialogue: download failed", "ass")] + public void RejectsErrorDocumentsThatContainSubtitleMarkers(string content, string format) + { + var exception = Assert.Throws(() => + SubtitleContentValidator.Validate(Encoding.UTF8.GetBytes(content), format, "application/octet-stream")); + + Assert.Contains("error document", exception.Message, StringComparison.OrdinalIgnoreCase); + } }