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
This commit is contained in:
MentalBlank 2025-05-22 22:23:49 +10:00
parent 408c475af5
commit 452d8cc1cf
3 changed files with 220 additions and 26 deletions

View file

@ -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<InvalidOperationException>(() => 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<BEncodedDictionary>(enrichedBytes);
// Assert
Assert.True(enrichedDict.ContainsKey("announce"));
Assert.True(enrichedDict.ContainsKey("announce-list"));
var announceList = (BEncodedList)enrichedDict["announce-list"];
var flattened = announceList.Cast<BEncodedList>().SelectMany(l => l.Cast<BEncodedString>().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<BEncodedDictionary>(enrichedBytes);
// Assert
var announceList = (BEncodedList)enrichedDict["announce-list"];
var flattened = announceList.Cast<BEncodedList>().SelectMany(l => l.Cast<BEncodedString>().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<BEncodedDictionary>(enrichedBytes);
// Assert
Assert.True(enrichedDict.ContainsKey("announce-list"));
var announceList = (BEncodedList)enrichedDict["announce-list"];
var flattened = announceList.Cast<BEncodedList>().SelectMany(l => l.Cast<BEncodedString>().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<InvalidOperationException>(() => enricher.EnrichTorrentBytes(originalTorrentBytes));
}
}

View file

@ -11,14 +11,14 @@ public interface IEnricher
}
/// <summary>
/// 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.
/// </summary>
public class Enricher(ILogger<Enricher> logger, ITrackerListGrabber trackerListGrabber) : IEnricher
{
/// <summary>
/// Add trackers from the tracker list grabber to the magnet link.
/// </summary>
/// <param name="magnetLink">Magnet link to add trackres to. Is not modified</param>
/// <param name="magnetLink">Magnet link to add trackers to. Is not modified</param>
/// <returns>Magnet link with additional trackers</returns>
public async Task<String> EnrichMagnetLink(String magnetLink)
{
@ -63,7 +63,11 @@ public class Enricher(ILogger<Enricher> 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<BEncodedDictionary>(torrentBytes);

View file

@ -13,6 +13,21 @@ public class TrackerListGrabber(IHttpClientFactory httpClientFactory, IMemoryCac
public async Task<String[]> 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();
}
}
}
private async Task<String[]> 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;
}
}