From 452d8cc1cf5193599606dedaae1b23c3be112580 Mon Sep 17 00:00:00 2001 From: MentalBlank Date: Thu, 22 May 2025 22:23:49 +1000 Subject: [PATCH] Split tracker grabbing and parsing logic. Split grab and parse logic, recheck cache after semaphore lock, timeouts, add URL validation check, add torrent file tests, replace test hash with valid SHA1 format, tracker URL validation, fix token disposal issue --- .../Services/EnricherTest.cs | 149 +++++++++++++++++- server/RdtClient.Service/Services/Enricher.cs | 10 +- .../Services/TrackerListGrabber.cs | 87 ++++++++-- 3 files changed, 220 insertions(+), 26 deletions(-) diff --git a/server/RdtClient.Service.Test/Services/EnricherTest.cs b/server/RdtClient.Service.Test/Services/EnricherTest.cs index 4c7ceb7..41ae6a2 100644 --- a/server/RdtClient.Service.Test/Services/EnricherTest.cs +++ b/server/RdtClient.Service.Test/Services/EnricherTest.cs @@ -23,7 +23,8 @@ public class EnricherTest : IDisposable _mockRepository.VerifyAll(); } - private const String TestMagnetLink = "magnet:?xt=urn:btih:1234567890123456789012345678901234567890&dn=TestFile&tr=http%3A%2F%2Ftracker1.com%2Fannounce&tr=http%3A%2F%2Ftracker2.com%2Fannounce"; + private const String TestMagnetLink = + "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=TestFile&tr=http%3A%2F%2Ftracker1.com%2Fannounce&tr=http%3A%2F%2Ftracker2.com%2Fannounce"; [Fact] public async Task EnrichMagnetLink_AddsNoTrackers_WhenNoTrackersFromTrackerGrabber() @@ -46,10 +47,10 @@ public class EnricherTest : IDisposable // Arrange SetupTrackerListGrabber(["http://new-tracker.com/announce"]); - var Enricher = new Enricher(_loggerMock.Object, _trackerListGrabberMock.Object); + var enricher = new Enricher(_loggerMock.Object, _trackerListGrabberMock.Object); // Act - var enriched = await Enricher.EnrichMagnetLink(TestMagnetLink); + var enriched = await enricher.EnrichMagnetLink(TestMagnetLink); // Assert Assert.Equal(TestMagnetLink + $"&tr={Uri.EscapeDataString("http://new-tracker.com/announce")}", enriched); @@ -84,7 +85,6 @@ public class EnricherTest : IDisposable await Assert.ThrowsAsync(() => enricher.EnrichMagnetLink(TestMagnetLink)); } - [Fact] public async Task EnrichTorrentBytes_AddsTrackers_WhenTrackersFromTrackerGrabber() { @@ -96,14 +96,17 @@ public class EnricherTest : IDisposable { ["announce"] = new BEncodedString(originalTracker), ["announce-list"] = new BEncodedList + { + new BEncodedList { - new BEncodedList { new BEncodedString(originalTracker) } + new BEncodedString(originalTracker) } + } }; var originalTorrentBytes = torrentDict.Encode(); - SetupTrackerListGrabber(new[] { newTracker }); + SetupTrackerListGrabber([newTracker]); var enricher = new Enricher(_loggerMock.Object, _trackerListGrabberMock.Object); // Act @@ -131,4 +134,138 @@ public class EnricherTest : IDisposable .ReturnsAsync(trackerList) .Verifiable(); } + + [Fact] + public async Task EnrichTorrentBytes_DoesNotAddTrackers_WhenNoTrackersFromTrackerGrabber() + { + // Arrange + var originalTracker = "http://tracker1.com/announce"; + + var torrentDict = new BEncodedDictionary + { + ["announce"] = new BEncodedString(originalTracker), + ["announce-list"] = new BEncodedList + { + new BEncodedList + { + new BEncodedString(originalTracker) + } + } + }; + + var originalTorrentBytes = torrentDict.Encode(); + + SetupTrackerListGrabber([]); + var enricher = new Enricher(_loggerMock.Object, _trackerListGrabberMock.Object); + + // Act + var enrichedBytes = await enricher.EnrichTorrentBytes(originalTorrentBytes); + var enrichedDict = BEncodedValue.Decode(enrichedBytes); + + // Assert + Assert.True(enrichedDict.ContainsKey("announce")); + Assert.True(enrichedDict.ContainsKey("announce-list")); + + var announceList = (BEncodedList)enrichedDict["announce-list"]; + var flattened = announceList.Cast().SelectMany(l => l.Cast().Select(s => s.Text)).ToList(); + + Assert.Single(flattened); + Assert.Contains(originalTracker, flattened); + } + + [Fact] + public async Task EnrichTorrentBytes_DoesNotAddDuplicateTrackers() + { + // Arrange + var originalTracker = "http://tracker1.com/announce"; + var duplicateTracker = "http://tracker1.com/announce"; + + var torrentDict = new BEncodedDictionary + { + ["announce"] = new BEncodedString(originalTracker), + ["announce-list"] = new BEncodedList + { + new BEncodedList + { + new BEncodedString(originalTracker) + } + } + }; + + var originalTorrentBytes = torrentDict.Encode(); + + SetupTrackerListGrabber([duplicateTracker]); + var enricher = new Enricher(_loggerMock.Object, _trackerListGrabberMock.Object); + + // Act + var enrichedBytes = await enricher.EnrichTorrentBytes(originalTorrentBytes); + var enrichedDict = BEncodedValue.Decode(enrichedBytes); + + // Assert + var announceList = (BEncodedList)enrichedDict["announce-list"]; + var flattened = announceList.Cast().SelectMany(l => l.Cast().Select(s => s.Text)).ToList(); + + Assert.Single(flattened); + Assert.Contains(originalTracker, flattened); + } + + [Fact] + public async Task EnrichTorrentBytes_AddsTrackers_WhenNoAnnounceListPresent() + { + // Arrange + var originalTracker = "http://tracker1.com/announce"; + var newTracker = "http://new-tracker.com/announce"; + + var torrentDict = new BEncodedDictionary + { + ["announce"] = new BEncodedString(originalTracker) + + // No "announce-list" + }; + + var originalTorrentBytes = torrentDict.Encode(); + + SetupTrackerListGrabber([newTracker]); + var enricher = new Enricher(_loggerMock.Object, _trackerListGrabberMock.Object); + + // Act + var enrichedBytes = await enricher.EnrichTorrentBytes(originalTorrentBytes); + var enrichedDict = BEncodedValue.Decode(enrichedBytes); + + // Assert + Assert.True(enrichedDict.ContainsKey("announce-list")); + var announceList = (BEncodedList)enrichedDict["announce-list"]; + var flattened = announceList.Cast().SelectMany(l => l.Cast().Select(s => s.Text)).ToList(); + + Assert.Contains(originalTracker, flattened); + Assert.Contains(newTracker, flattened); + } + + [Fact] + public async Task EnrichTorrentBytes_Throws_WhenTrackerGrabberThrows() + { + // Arrange + _trackerListGrabberMock + .Setup(t => t.GetTrackers()) + .ThrowsAsync(new InvalidOperationException("Unable to fetch tracker list for enrichment.")); + + var torrentDict = new BEncodedDictionary + { + ["announce"] = new BEncodedString("http://tracker1.com/announce"), + ["announce-list"] = new BEncodedList + { + new BEncodedList + { + new BEncodedString("http://tracker1.com/announce") + } + } + }; + + var originalTorrentBytes = torrentDict.Encode(); + + var enricher = new Enricher(_loggerMock.Object, _trackerListGrabberMock.Object); + + // Act & Assert + await Assert.ThrowsAsync(() => enricher.EnrichTorrentBytes(originalTorrentBytes)); + } } \ No newline at end of file diff --git a/server/RdtClient.Service/Services/Enricher.cs b/server/RdtClient.Service/Services/Enricher.cs index 140d07a..f0e1d12 100644 --- a/server/RdtClient.Service/Services/Enricher.cs +++ b/server/RdtClient.Service/Services/Enricher.cs @@ -11,14 +11,14 @@ public interface IEnricher } /// -/// Enriches magnet links and torrents by adding trackers from the tracker list grabber. +/// Enriches magnet links and torrents by adding trackers from the tracker list grabber. /// public class Enricher(ILogger logger, ITrackerListGrabber trackerListGrabber) : IEnricher { /// /// Add trackers from the tracker list grabber to the magnet link. /// - /// Magnet link to add trackres to. Is not modified + /// Magnet link to add trackers to. Is not modified /// Magnet link with additional trackers public async Task EnrichMagnetLink(String magnetLink) { @@ -63,7 +63,11 @@ public class Enricher(ILogger logger, ITrackerListGrabber trackerListG { var newTrackers = await trackerListGrabber.GetTrackers(); - if (torrentBytes == null) throw new ArgumentNullException(nameof(torrentBytes)); + if (torrentBytes == null) + { + throw new ArgumentNullException(nameof(torrentBytes)); + } + var torrentDict = BEncodedValue.Decode(torrentBytes); diff --git a/server/RdtClient.Service/Services/TrackerListGrabber.cs b/server/RdtClient.Service/Services/TrackerListGrabber.cs index f9f64d9..f7a34c5 100644 --- a/server/RdtClient.Service/Services/TrackerListGrabber.cs +++ b/server/RdtClient.Service/Services/TrackerListGrabber.cs @@ -13,6 +13,21 @@ public class TrackerListGrabber(IHttpClientFactory httpClientFactory, IMemoryCac public async Task GetTrackers() { + var trackerUrlList = Settings.Get.General.TrackerEnrichmentList; + + if (String.IsNullOrWhiteSpace(trackerUrlList)) + { + return []; + } + + if (!Uri.TryCreate(trackerUrlList, UriKind.Absolute, out var trackerUri) || + (trackerUri.Scheme != Uri.UriSchemeHttp && trackerUri.Scheme != Uri.UriSchemeHttps)) + { + logger.LogError("Invalid tracker list URL: {Url}", trackerUrlList); + + throw new InvalidOperationException("Invalid tracker list URL."); + } + var currentExpiration = Settings.Get.General.TrackerEnrichmentCacheExpiration; var useCache = currentExpiration > 0; @@ -39,29 +54,23 @@ public class TrackerListGrabber(IHttpClientFactory httpClientFactory, IMemoryCac } } - await Semaphore.WaitAsync(); + await Semaphore.WaitAsync().ConfigureAwait(false); try { - logger.LogDebug("Tracker cache miss or cache disabled. Fetching tracker list."); - var trackerUrlList = Settings.Get.General.TrackerEnrichmentList; - - if (String.IsNullOrWhiteSpace(trackerUrlList)) + if (useCache) { - return []; + if (memoryCache.TryGetValue(CacheKey, out String[]? cachedTrackers) && cachedTrackers is { Length: > 0 }) + { + logger.LogDebug("Using cached tracker list (after lock)."); + + return cachedTrackers; + } } - var httpClient = httpClientFactory.CreateClient(); - var response = await httpClient.GetAsync(trackerUrlList); + logger.LogDebug("Tracker cache miss or cache disabled. Fetching tracker list."); - response.EnsureSuccessStatusCode(); - - var result = await response.Content.ReadAsStringAsync(); - - var trackers = result - .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); + var trackers = await FetchAndParseTrackersAsync(trackerUri).ConfigureAwait(false); if (useCache) { @@ -75,6 +84,12 @@ public class TrackerListGrabber(IHttpClientFactory httpClientFactory, IMemoryCac return trackers; } + catch (TaskCanceledException ex) + { + logger.LogError(ex, "Fetching tracker list was canceled (timeout or cancellation)."); + + throw new InvalidOperationException("Fetching tracker list was canceled due to timeout or cancellation.", ex); + } catch (Exception ex) { logger.LogError(ex, "Unable to fetch tracker list."); @@ -86,4 +101,42 @@ public class TrackerListGrabber(IHttpClientFactory httpClientFactory, IMemoryCac Semaphore.Release(); } } -} \ No newline at end of file + + private async Task FetchAndParseTrackersAsync(Uri trackerUri) + { + logger.LogDebug("Fetching tracker list from URL: {TrackerUrl}", trackerUri); + + var httpClient = httpClientFactory.CreateClient(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var token = cts.Token; + using var response = await httpClient.GetAsync(trackerUri, token).ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); + + await using var contentStream = await response.Content.ReadAsStreamAsync(token).ConfigureAwait(false); + using var reader = new StreamReader(contentStream); + var result = await reader.ReadToEndAsync(token).ConfigureAwait(false); + + String[] trackers; + + try + { + trackers = result + .Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Where(line => !String.IsNullOrWhiteSpace(line)) + .Select(t => t.EndsWith("/") ? t.TrimEnd('/') : t) + .Where(t => Uri.TryCreate(t, UriKind.Absolute, out var uri) && + (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + catch (Exception ex) + { + logger.LogError(ex, "Error parsing tracker list response."); + + throw new InvalidOperationException("Failed to parse tracker list response.", ex); + } + + return trackers; + } +}