Add option to remove malware if any file is blocked (#621)

This commit is contained in:
Flaminel
2026-05-30 01:33:51 +03:00
committed by GitHub
parent 26b76908eb
commit 084f83efca
51 changed files with 4168 additions and 156 deletions

View File

@@ -18,6 +18,8 @@ public sealed record UpdateMalwareBlockerConfigRequest
public bool ProcessNoContentId { get; init; }
public bool DeleteIfAnyFileBlocked { get; init; }
public BlocklistSettings Sonarr { get; init; } = new();
public BlocklistSettings Radarr { get; init; } = new();
@@ -38,6 +40,7 @@ public sealed record UpdateMalwareBlockerConfigRequest
config.IgnorePrivate = IgnorePrivate;
config.DeletePrivate = DeletePrivate;
config.ProcessNoContentId = ProcessNoContentId;
config.DeleteIfAnyFileBlocked = DeleteIfAnyFileBlocked;
config.Sonarr = Sonarr;
config.Radarr = Radarr;
config.Lidarr = Lidarr;

View File

@@ -11,4 +11,5 @@ public enum DeleteReason
AllFilesSkipped,
AllFilesSkippedByQBit,
AllFilesBlocked,
AtLeastOneFileBlocked,
}

View File

@@ -553,9 +553,9 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
{
}
private void SetMalwareBlockerContext()
private void SetMalwareBlockerContext(ContentBlockerConfig? config = null)
{
ContextProvider.Set(new ContentBlockerConfig());
ContextProvider.Set(config ?? new ContentBlockerConfig());
ContextProvider.Set(nameof(InstanceType), (object)InstanceType.Sonarr);
_fixture.BlocklistProvider
@@ -655,5 +655,45 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
.Received(1)
.ChangeFilesPriority(hash, Arg.Is<List<int>>(p => p.Count == 2 && p[0] == 1 && p[1] == 0));
}
[Fact]
public async Task PartialMalware_WithDeleteIfAnyFileBlocked_MarksForRemoval_AndSkipsChangeFilesPriority()
{
const string hash = "partial-malware-any-hash";
DelugeService sut = _fixture.CreateSut();
SetMalwareBlockerContext(new ContentBlockerConfig { DeleteIfAnyFileBlocked = true });
_fixture.ClientWrapper
.GetTorrentStatus(hash)
.Returns(MakeDownloadStatus(hash));
_fixture.ClientWrapper
.GetTorrentFiles(hash)
.Returns(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
{ "movie.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0, Path = "movie.mkv" } },
{ "malware.exe", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 1, Path = "malware.exe" } },
},
});
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name.EndsWith("malware.exe")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(false);
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name.EndsWith("movie.mkv")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(true);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeTrue();
result.DeleteReason.ShouldBe(DeleteReason.AtLeastOneFileBlocked);
await _fixture.ClientWrapper
.DidNotReceive()
.ChangeFilesPriority(Arg.Any<string>(), Arg.Any<List<int>>());
}
}
}

View File

@@ -1,7 +1,10 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using NSubstitute;
using Newtonsoft.Json.Linq;
@@ -1128,4 +1131,218 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
result.DeleteReason.ShouldBe(DeleteReason.None);
}
}
public class BlockUnwantedFilesAsyncScenarios : QBitServiceTests
{
public BlockUnwantedFilesAsyncScenarios(QBitServiceFixture fixture) : base(fixture)
{
}
private void SetMalwareBlockerContext(ContentBlockerConfig? config = null)
{
ContextProvider.Set(config ?? new ContentBlockerConfig());
ContextProvider.Set(nameof(InstanceType), (object)InstanceType.Sonarr);
_fixture.BlocklistProvider
.GetBlocklistType(Arg.Any<InstanceType>())
.Returns(BlocklistType.Blacklist);
_fixture.BlocklistProvider
.GetPatterns(Arg.Any<InstanceType>())
.Returns(new ConcurrentBag<string>());
_fixture.BlocklistProvider
.GetRegexes(Arg.Any<InstanceType>())
.Returns(new ConcurrentBag<Regex>());
}
private static TorrentInfo MakeTorrentInfo(string hash) => new()
{
Hash = hash,
Name = "Malware Torrent",
State = TorrentState.Downloading,
DownloadSpeed = 1000,
};
private static TorrentProperties MakeTorrentProperties(bool isPrivate = false) => new()
{
AdditionalData = new Dictionary<string, JToken>
{
{ "is_private", JToken.FromObject(isPrivate) },
},
};
private void StubClient(string hash, IReadOnlyList<TorrentContent> files, bool isPrivate = false)
{
_fixture.ClientWrapper
.GetTorrentListAsync(Arg.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash)))
.Returns(new[] { MakeTorrentInfo(hash) });
_fixture.ClientWrapper
.GetTorrentTrackersAsync(hash)
.Returns(Array.Empty<TorrentTracker>());
_fixture.ClientWrapper
.GetTorrentPropertiesAsync(hash)
.Returns(MakeTorrentProperties(isPrivate));
_fixture.ClientWrapper
.GetTorrentContentsAsync(hash)
.Returns(files);
}
[Fact]
public async Task AllFilesAreMalware_MarksForRemoval_WithAllFilesBlockedReason()
{
const string hash = "all-malware-hash";
QBitService sut = _fixture.CreateSut();
SetMalwareBlockerContext();
StubClient(hash,
[
new TorrentContent { Name = "malware.exe", Index = 0, Priority = TorrentContentPriority.Normal },
]);
_fixture.FilenameEvaluator
.IsValid(Arg.Any<string>(), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(false);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeTrue();
result.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked);
}
[Fact]
public async Task PartialMalware_CallsSetFilePriority_AndDoesNotMarkForRemoval()
{
const string hash = "partial-malware-hash";
QBitService sut = _fixture.CreateSut();
SetMalwareBlockerContext();
StubClient(hash,
[
new TorrentContent { Name = "movie.mkv", Index = 0, Priority = TorrentContentPriority.Normal },
new TorrentContent { Name = "installer.exe", Index = 1, Priority = TorrentContentPriority.Normal },
]);
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name.EndsWith("installer.exe")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(false);
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name.EndsWith("movie.mkv")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(true);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeFalse();
result.DeleteReason.ShouldBe(DeleteReason.None);
await _fixture.ClientWrapper
.Received(1)
.SetFilePriorityAsync(hash, 1, TorrentContentPriority.Skip);
}
[Fact]
public async Task PartialMalware_WithDeleteIfAnyFileBlocked_MarksForRemoval_AndSkipsSetFilePriority()
{
const string hash = "partial-malware-any-hash";
QBitService sut = _fixture.CreateSut();
SetMalwareBlockerContext(new ContentBlockerConfig { DeleteIfAnyFileBlocked = true });
StubClient(hash,
[
new TorrentContent { Name = "movie.mkv", Index = 0, Priority = TorrentContentPriority.Normal },
new TorrentContent { Name = "installer.exe", Index = 1, Priority = TorrentContentPriority.Normal },
]);
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name.EndsWith("installer.exe")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(false);
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name.EndsWith("movie.mkv")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(true);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeTrue();
result.DeleteReason.ShouldBe(DeleteReason.AtLeastOneFileBlocked);
await _fixture.ClientWrapper
.DidNotReceive()
.SetFilePriorityAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<TorrentContentPriority>());
}
[Fact]
public async Task NoUnwantedFiles_DoesNotMarkForRemoval()
{
const string hash = "clean-hash";
QBitService sut = _fixture.CreateSut();
SetMalwareBlockerContext();
StubClient(hash,
[
new TorrentContent { Name = "movie.mkv", Index = 0, Priority = TorrentContentPriority.Normal },
]);
_fixture.FilenameEvaluator
.IsValid(Arg.Any<string>(), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(true);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeFalse();
result.DeleteReason.ShouldBe(DeleteReason.None);
await _fixture.ClientWrapper
.DidNotReceive()
.SetFilePriorityAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<TorrentContentPriority>());
}
[Fact]
public async Task AlreadySkippedFile_DoesNotTriggerEarlyReturn_WhenDeleteIfAnyFileBlocked()
{
const string hash = "already-skipped-hash";
QBitService sut = _fixture.CreateSut();
SetMalwareBlockerContext(new ContentBlockerConfig { DeleteIfAnyFileBlocked = true });
StubClient(hash,
[
new TorrentContent { Name = "movie.mkv", Index = 0, Priority = TorrentContentPriority.Normal },
new TorrentContent { Name = "installer.exe", Index = 1, Priority = TorrentContentPriority.Skip },
]);
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name.EndsWith("movie.mkv")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(true);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeFalse();
result.DeleteReason.ShouldBe(DeleteReason.None);
}
[Fact]
public async Task AllFilesAlreadySkipped_NoNewMalware_DoesNotMarkForRemoval()
{
const string hash = "all-skipped-hash";
QBitService sut = _fixture.CreateSut();
SetMalwareBlockerContext();
StubClient(hash,
[
new TorrentContent { Name = "movie.mkv", Index = 0, Priority = TorrentContentPriority.Skip },
new TorrentContent { Name = "installer.exe", Index = 1, Priority = TorrentContentPriority.Skip },
]);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeFalse();
result.DeleteReason.ShouldBe(DeleteReason.None);
}
}
}

View File

@@ -1,6 +1,11 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using Cleanuparr.Domain.Entities.RTorrent.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Shouldly;
@@ -768,4 +773,176 @@ public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
result.DeleteReason.ShouldBe(DeleteReason.None);
}
}
public class BlockUnwantedFilesAsyncScenarios : RTorrentServiceTests
{
public BlockUnwantedFilesAsyncScenarios(RTorrentServiceFixture fixture) : base(fixture)
{
}
private void SetMalwareBlockerContext(ContentBlockerConfig? config = null)
{
ContextProvider.Set(config ?? new ContentBlockerConfig());
ContextProvider.Set(nameof(InstanceType), (object)InstanceType.Sonarr);
_fixture.BlocklistProvider
.GetBlocklistType(Arg.Any<InstanceType>())
.Returns(BlocklistType.Blacklist);
_fixture.BlocklistProvider
.GetPatterns(Arg.Any<InstanceType>())
.Returns(new ConcurrentBag<string>());
_fixture.BlocklistProvider
.GetRegexes(Arg.Any<InstanceType>())
.Returns(new ConcurrentBag<Regex>());
}
private static RTorrentTorrent MakeDownload(string hash, bool isPrivate = false) => new()
{
Hash = hash,
Name = "Malware Torrent",
IsPrivate = isPrivate ? 1 : 0,
State = 1,
Complete = 0,
DownRate = 1000,
SizeBytes = 1000,
CompletedBytes = 500,
};
private void StubClient(string hash, IReadOnlyList<RTorrentFile> files, bool isPrivate = false)
{
_fixture.ClientWrapper.GetTorrentAsync(hash).Returns(MakeDownload(hash, isPrivate));
_fixture.ClientWrapper.GetTrackersAsync(hash).Returns(new List<string>());
_fixture.ClientWrapper.GetTorrentFilesAsync(hash).Returns(files.ToList());
}
[Fact]
public async Task AllFilesAreMalware_MarksForRemoval_WithAllFilesBlockedReason()
{
const string hash = "ALL-MALWARE-HASH";
RTorrentService sut = _fixture.CreateSut();
SetMalwareBlockerContext();
StubClient(hash, [new RTorrentFile { Index = 0, Path = "malware.exe", Priority = 1 }]);
_fixture.FilenameEvaluator
.IsValid(Arg.Any<string>(), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(false);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeTrue();
result.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked);
}
[Fact]
public async Task PartialMalware_CallsSetFilePriority_AndDoesNotMarkForRemoval()
{
const string hash = "PARTIAL-MALWARE-HASH";
RTorrentService sut = _fixture.CreateSut();
SetMalwareBlockerContext();
StubClient(hash,
[
new RTorrentFile { Index = 0, Path = "movie.mkv", Priority = 1 },
new RTorrentFile { Index = 1, Path = "installer.exe", Priority = 1 },
]);
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name.EndsWith("installer.exe")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(false);
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name.EndsWith("movie.mkv")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(true);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeFalse();
result.DeleteReason.ShouldBe(DeleteReason.None);
await _fixture.ClientWrapper
.Received(1)
.SetFilePriorityAsync(hash, 1, 0);
}
[Fact]
public async Task PartialMalware_WithDeleteIfAnyFileBlocked_MarksForRemoval_AndSkipsSetFilePriority()
{
const string hash = "PARTIAL-MALWARE-ANY-HASH";
RTorrentService sut = _fixture.CreateSut();
SetMalwareBlockerContext(new ContentBlockerConfig { DeleteIfAnyFileBlocked = true });
StubClient(hash,
[
new RTorrentFile { Index = 0, Path = "movie.mkv", Priority = 1 },
new RTorrentFile { Index = 1, Path = "installer.exe", Priority = 1 },
]);
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name.EndsWith("installer.exe")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(false);
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name.EndsWith("movie.mkv")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(true);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeTrue();
result.DeleteReason.ShouldBe(DeleteReason.AtLeastOneFileBlocked);
await _fixture.ClientWrapper
.DidNotReceive()
.SetFilePriorityAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<int>());
}
[Fact]
public async Task NoUnwantedFiles_DoesNotMarkForRemoval()
{
const string hash = "CLEAN-HASH";
RTorrentService sut = _fixture.CreateSut();
SetMalwareBlockerContext();
StubClient(hash, [new RTorrentFile { Index = 0, Path = "movie.mkv", Priority = 1 }]);
_fixture.FilenameEvaluator
.IsValid(Arg.Any<string>(), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(true);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeFalse();
result.DeleteReason.ShouldBe(DeleteReason.None);
await _fixture.ClientWrapper
.DidNotReceive()
.SetFilePriorityAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<int>());
}
[Fact]
public async Task AlreadySkippedFile_DoesNotTriggerEarlyReturn_WhenDeleteIfAnyFileBlocked()
{
const string hash = "ALREADY-SKIPPED-HASH";
RTorrentService sut = _fixture.CreateSut();
SetMalwareBlockerContext(new ContentBlockerConfig { DeleteIfAnyFileBlocked = true });
StubClient(hash,
[
new RTorrentFile { Index = 0, Path = "movie.mkv", Priority = 1 },
new RTorrentFile { Index = 1, Path = "installer.exe", Priority = 0 },
]);
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name.EndsWith("movie.mkv")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(true);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeFalse();
result.DeleteReason.ShouldBe(DeleteReason.None);
}
}
}

View File

@@ -1,7 +1,12 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using NSubstitute;
using Transmission.API.RPC.Arguments;
using Transmission.API.RPC.Entity;
using Shouldly;
using Xunit;
@@ -528,4 +533,163 @@ public class TransmissionServiceTests : IClassFixture<TransmissionServiceFixture
result.ChangeCategory.ShouldBeTrue();
}
}
public class BlockUnwantedFilesAsyncScenarios : TransmissionServiceTests
{
public BlockUnwantedFilesAsyncScenarios(TransmissionServiceFixture fixture) : base(fixture)
{
}
private void SetMalwareBlockerContext(ContentBlockerConfig? config = null)
{
ContextProvider.Set(config ?? new ContentBlockerConfig());
ContextProvider.Set(nameof(InstanceType), (object)InstanceType.Sonarr);
_fixture.BlocklistProvider
.GetBlocklistType(Arg.Any<InstanceType>())
.Returns(BlocklistType.Blacklist);
_fixture.BlocklistProvider
.GetPatterns(Arg.Any<InstanceType>())
.Returns(new ConcurrentBag<string>());
_fixture.BlocklistProvider
.GetRegexes(Arg.Any<InstanceType>())
.Returns(new ConcurrentBag<Regex>());
}
private void StubClient(string hash, (string Name, bool Wanted)[] files, bool isPrivate = false)
{
TorrentInfo torrentInfo = new()
{
Id = 42,
HashString = hash,
Name = "Malware Torrent",
Status = 4,
IsPrivate = isPrivate,
Files = files.Select(f => new TransmissionTorrentFiles { Name = f.Name }).ToArray(),
FileStats = files.Select(f => new TransmissionTorrentFileStats { Wanted = f.Wanted }).ToArray(),
};
_fixture.ClientWrapper
.TorrentGetAsync(Arg.Any<string[]>(), hash)
.Returns(new TransmissionTorrents { Torrents = new[] { torrentInfo } });
}
[Fact]
public async Task AllFilesAreMalware_MarksForRemoval_WithAllFilesBlockedReason()
{
const string hash = "all-malware-hash";
TransmissionService sut = _fixture.CreateSut();
SetMalwareBlockerContext();
StubClient(hash, [("malware.exe", true)]);
_fixture.FilenameEvaluator
.IsValid(Arg.Any<string>(), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(false);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeTrue();
result.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked);
}
[Fact]
public async Task PartialMalware_CallsTorrentSet_AndDoesNotMarkForRemoval()
{
const string hash = "partial-malware-hash";
TransmissionService sut = _fixture.CreateSut();
SetMalwareBlockerContext();
StubClient(hash, [("movie.mkv", true), ("installer.exe", true)]);
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name.EndsWith("installer.exe")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(false);
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name.EndsWith("movie.mkv")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(true);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeFalse();
result.DeleteReason.ShouldBe(DeleteReason.None);
await _fixture.ClientWrapper
.Received(1)
.TorrentSetAsync(Arg.Any<TorrentSettings>());
}
[Fact]
public async Task PartialMalware_WithDeleteIfAnyFileBlocked_MarksForRemoval_AndSkipsTorrentSet()
{
const string hash = "partial-malware-any-hash";
TransmissionService sut = _fixture.CreateSut();
SetMalwareBlockerContext(new ContentBlockerConfig { DeleteIfAnyFileBlocked = true });
StubClient(hash, [("movie.mkv", true), ("installer.exe", true)]);
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name.EndsWith("installer.exe")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(false);
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name.EndsWith("movie.mkv")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(true);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeTrue();
result.DeleteReason.ShouldBe(DeleteReason.AtLeastOneFileBlocked);
await _fixture.ClientWrapper
.DidNotReceive()
.TorrentSetAsync(Arg.Any<TorrentSettings>());
}
[Fact]
public async Task NoUnwantedFiles_DoesNotMarkForRemoval()
{
const string hash = "clean-hash";
TransmissionService sut = _fixture.CreateSut();
SetMalwareBlockerContext();
StubClient(hash, [("movie.mkv", true)]);
_fixture.FilenameEvaluator
.IsValid(Arg.Any<string>(), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(true);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeFalse();
result.DeleteReason.ShouldBe(DeleteReason.None);
await _fixture.ClientWrapper
.DidNotReceive()
.TorrentSetAsync(Arg.Any<TorrentSettings>());
}
[Fact]
public async Task AlreadyUnwantedFile_DoesNotTriggerEarlyReturn_WhenDeleteIfAnyFileBlocked()
{
const string hash = "already-skipped-hash";
TransmissionService sut = _fixture.CreateSut();
SetMalwareBlockerContext(new ContentBlockerConfig { DeleteIfAnyFileBlocked = true });
StubClient(hash, [("movie.mkv", true), ("installer.exe", false)]);
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name.EndsWith("movie.mkv")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(true);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeFalse();
result.DeleteReason.ShouldBe(DeleteReason.None);
}
}
}

View File

@@ -1,6 +1,11 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using Cleanuparr.Domain.Entities.UTorrent.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Shouldly;
@@ -711,4 +716,178 @@ public class UTorrentServiceTests : IClassFixture<UTorrentServiceFixture>
result.ChangeCategory.ShouldBeTrue();
}
}
public class BlockUnwantedFilesAsyncScenarios : UTorrentServiceTests
{
public BlockUnwantedFilesAsyncScenarios(UTorrentServiceFixture fixture) : base(fixture)
{
}
private void SetMalwareBlockerContext(ContentBlockerConfig? config = null)
{
ContextProvider.Set(config ?? new ContentBlockerConfig());
ContextProvider.Set(nameof(InstanceType), (object)InstanceType.Sonarr);
_fixture.BlocklistProvider
.GetBlocklistType(Arg.Any<InstanceType>())
.Returns(BlocklistType.Blacklist);
_fixture.BlocklistProvider
.GetPatterns(Arg.Any<InstanceType>())
.Returns(new ConcurrentBag<string>());
_fixture.BlocklistProvider
.GetRegexes(Arg.Any<InstanceType>())
.Returns(new ConcurrentBag<Regex>());
}
private void StubClient(string hash, IReadOnlyList<UTorrentFile> files, bool isPrivate = false)
{
UTorrentItem item = new()
{
Hash = hash,
Name = "Malware Torrent",
Status = 9,
DownloadSpeed = 1000,
};
UTorrentProperties properties = new()
{
Hash = hash,
Pex = isPrivate ? -1 : 0,
Trackers = string.Empty,
};
_fixture.ClientWrapper.GetTorrentAsync(hash).Returns(item);
_fixture.ClientWrapper.GetTorrentPropertiesAsync(hash).Returns(properties);
_fixture.ClientWrapper.GetTorrentFilesAsync(hash).Returns(files.ToList());
}
[Fact]
public async Task AllFilesAreMalware_MarksForRemoval_WithAllFilesBlockedReason()
{
const string hash = "all-malware-hash";
UTorrentService sut = _fixture.CreateSut();
SetMalwareBlockerContext();
StubClient(hash, [new UTorrentFile { Name = "malware.exe", Index = 0, Priority = 2, Size = 1024, Downloaded = 1024 }]);
_fixture.FilenameEvaluator
.IsValid(Arg.Any<string>(), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(false);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeTrue();
result.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked);
}
[Fact]
public async Task PartialMalware_CallsSetFilesPriority_AndDoesNotMarkForRemoval()
{
const string hash = "partial-malware-hash";
UTorrentService sut = _fixture.CreateSut();
SetMalwareBlockerContext();
StubClient(hash,
[
new UTorrentFile { Name = "movie.mkv", Index = 0, Priority = 2, Size = 32_768, Downloaded = 32_768 },
new UTorrentFile { Name = "installer.exe", Index = 1, Priority = 2, Size = 1024, Downloaded = 1024 },
]);
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name.EndsWith("installer.exe")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(false);
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name.EndsWith("movie.mkv")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(true);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeFalse();
result.DeleteReason.ShouldBe(DeleteReason.None);
await _fixture.ClientWrapper
.Received(1)
.SetFilesPriorityAsync(hash, Arg.Is<List<int>>(idx => idx.Count == 1 && idx[0] == 1), 0);
}
[Fact]
public async Task PartialMalware_WithDeleteIfAnyFileBlocked_MarksForRemoval_AndSkipsSetFilesPriority()
{
const string hash = "partial-malware-any-hash";
UTorrentService sut = _fixture.CreateSut();
SetMalwareBlockerContext(new ContentBlockerConfig { DeleteIfAnyFileBlocked = true });
StubClient(hash,
[
new UTorrentFile { Name = "movie.mkv", Index = 0, Priority = 2, Size = 32_768, Downloaded = 32_768 },
new UTorrentFile { Name = "installer.exe", Index = 1, Priority = 2, Size = 1024, Downloaded = 1024 },
]);
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name.EndsWith("installer.exe")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(false);
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name.EndsWith("movie.mkv")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(true);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeTrue();
result.DeleteReason.ShouldBe(DeleteReason.AtLeastOneFileBlocked);
await _fixture.ClientWrapper
.DidNotReceive()
.SetFilesPriorityAsync(Arg.Any<string>(), Arg.Any<List<int>>(), Arg.Any<int>());
}
[Fact]
public async Task NoUnwantedFiles_DoesNotMarkForRemoval()
{
const string hash = "clean-hash";
UTorrentService sut = _fixture.CreateSut();
SetMalwareBlockerContext();
StubClient(hash, [new UTorrentFile { Name = "movie.mkv", Index = 0, Priority = 2, Size = 32_768, Downloaded = 32_768 }]);
_fixture.FilenameEvaluator
.IsValid(Arg.Any<string>(), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(true);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeFalse();
result.DeleteReason.ShouldBe(DeleteReason.None);
await _fixture.ClientWrapper
.DidNotReceive()
.SetFilesPriorityAsync(Arg.Any<string>(), Arg.Any<List<int>>(), Arg.Any<int>());
}
[Fact]
public async Task AlreadySkippedFile_DoesNotTriggerEarlyReturn_WhenDeleteIfAnyFileBlocked()
{
const string hash = "already-skipped-hash";
UTorrentService sut = _fixture.CreateSut();
SetMalwareBlockerContext(new ContentBlockerConfig { DeleteIfAnyFileBlocked = true });
StubClient(hash,
[
new UTorrentFile { Name = "movie.mkv", Index = 0, Priority = 2, Size = 32_768, Downloaded = 32_768 },
new UTorrentFile { Name = "installer.exe", Index = 1, Priority = 0, Size = 1024, Downloaded = 1024 },
]);
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name.EndsWith("movie.mkv")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(true);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeFalse();
result.DeleteReason.ShouldBe(DeleteReason.None);
}
}
}

View File

@@ -362,6 +362,76 @@ public class MalwareBlockerTests : IDisposable
);
}
[Fact]
public async Task ProcessInstanceAsync_WhenShouldRemoveWithAtLeastOneFileBlocked_PublishesRemoveRequest()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
EnableSonarrBlocklist();
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
IArrClient mockArrClient = Substitute.For<IArrClient>();
mockArrClient.IsRecordValid(Arg.Any<QueueRecord>()).Returns(true);
mockArrClient.HasContentId(Arg.Any<QueueRecord>()).Returns(true);
_fixture.ArrClientFactory
.GetClient(InstanceType.Sonarr, Arg.Any<float>())
.Returns(mockArrClient);
QueueRecord queueRecord = new()
{
Id = 1,
DownloadId = "any-file-blocked-download-id",
Title = "Mixed Malware Download",
Protocol = "torrent",
SeriesId = 1,
EpisodeId = 1
};
_fixture.ArrQueueIterator
.Iterate(
Arg.Any<IArrClient>(),
Arg.Any<ArrInstance>(),
Arg.Any<Func<IReadOnlyList<QueueRecord>, Task>>()
)
.Returns(ci =>
{
var callback = ci.ArgAt<Func<IReadOnlyList<QueueRecord>, Task>>(2);
return callback([queueRecord]);
});
IDownloadService mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.BlockUnwantedFilesAsync(
Arg.Any<string>(),
Arg.Any<List<string>>()
)
.Returns(new BlockFilesResult
{
Found = true,
ShouldRemove = true,
IsPrivate = false,
DeleteReason = DeleteReason.AtLeastOneFileBlocked
});
_fixture.DownloadServiceFactory
.GetDownloadService(Arg.Any<DownloadClientConfig>())
.Returns(mockDownloadService);
MalwareBlockerJob sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
await _fixture.MessageBus.Received(1).Publish(
Arg.Is<QueueItemRemoveRequest<SeriesSearchItem>>(r =>
r.DeleteReason == DeleteReason.AtLeastOneFileBlocked
),
Arg.Any<CancellationToken>()
);
}
[Fact]
public async Task ProcessInstanceAsync_WhenPrivateAndDeletePrivateFalse_DoesNotRemoveFromClient()
{

View File

@@ -92,6 +92,14 @@ public partial class DelugeService
priority = 0;
hasPriorityUpdates = true;
_logger.LogInformation("unwanted file found | {file}", file.Path);
if (malwareBlockerConfig.DeleteIfAnyFileBlocked)
{
_logger.LogDebug("at least one file is blocked for {name}", download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.AtLeastOneFileBlocked;
return;
}
}
_logger.LogTrace("File is valid | {file}", file.Path);

View File

@@ -99,6 +99,15 @@ public partial class QBitService
}
_logger.LogInformation("unwanted file found | {file}", file.Name);
if (malwareBlockerConfig.DeleteIfAnyFileBlocked)
{
_logger.LogDebug("at least one file is blocked for {name}", download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.AtLeastOneFileBlocked;
return result;
}
unwantedFiles.Add(file.Index.Value);
totalUnwantedFiles++;
}

View File

@@ -98,6 +98,15 @@ public partial class RTorrentService
hasPriorityUpdates = true;
priorityUpdates.Add((file.Index, 0));
_logger.LogInformation("unwanted file found | {file}", file.Path);
if (malwareBlockerConfig.DeleteIfAnyFileBlocked)
{
_logger.LogDebug("at least one file is blocked for {name}", download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.AtLeastOneFileBlocked;
return result;
}
continue;
}

View File

@@ -82,6 +82,15 @@ public partial class TransmissionService
}
_logger.LogInformation("unwanted file found | {file}", download.Files[i].Name);
if (malwareBlockerConfig.DeleteIfAnyFileBlocked)
{
_logger.LogDebug("at least one file is blocked for {name}", download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.AtLeastOneFileBlocked;
return result;
}
unwantedFiles.Add(i);
totalUnwantedFiles++;
}

View File

@@ -78,6 +78,14 @@ public partial class UTorrentService
totalUnwantedFiles++;
fileIndexes.Add(i);
_logger.LogInformation("unwanted file found | {file}", file.Name);
if (malwareBlockerConfig.DeleteIfAnyFileBlocked)
{
_logger.LogDebug("at least one file is blocked for {name}", download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.AtLeastOneFileBlocked;
return result;
}
}
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class AddDeleteIfAnyFileBlocked : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "delete_if_any_file_blocked",
table: "content_blocker_configs",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "delete_if_any_file_blocked",
table: "content_blocker_configs");
}
}
}

View File

@@ -748,6 +748,10 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("TEXT")
.HasColumnName("cron_expression");
b.Property<bool>("DeleteIfAnyFileBlocked")
.HasColumnType("INTEGER")
.HasColumnName("delete_if_any_file_blocked");
b.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("delete_private");

View File

@@ -22,6 +22,8 @@ public sealed record ContentBlockerConfig : IJobConfig
public bool ProcessNoContentId { get; set; }
public bool DeleteIfAnyFileBlocked { get; set; }
public BlocklistSettings Sonarr { get; set; } = new();
public BlocklistSettings Radarr { get; set; } = new();

View File

@@ -105,6 +105,7 @@ export class DocumentationService {
'ignorePrivate': 'ignore-private',
'deletePrivate': 'delete-private',
'processNoContentId': 'process-downloads-with-no-content-id',
'deleteIfAnyFileBlocked': 'delete-if-any-file-is-blocked',
'sonarr.enabled': 'enable-blocklist',
'sonarr.blocklistPath': 'blocklist-path',
'sonarr.blocklistType': 'blocklist-type',

View File

@@ -33,6 +33,9 @@
<app-toggle label="Process downloads with no content ID" [(checked)]="processNoContentId"
hint="Process downloads from the queue that are not linked to any content in the arr app. Cleanuparr will not be able to trigger a search for a replacement when this happens."
helpKey="malware-blocker:processNoContentId" />
<app-toggle label="Delete if any file is blocked" [(checked)]="deleteIfAnyFileBlocked"
hint="When enabled, the entire download will be removed if any file in it matches the blocklist. When disabled, the download is only removed when all of its files match."
helpKey="malware-blocker:deleteIfAnyFileBlocked" />
<div class="form-divider"></div>

View File

@@ -63,6 +63,7 @@ export class MalwareBlockerComponent implements OnInit, HasPendingChanges {
readonly ignorePrivate = signal(false);
readonly deletePrivate = signal(false);
readonly processNoContentId = signal(false);
readonly deleteIfAnyFileBlocked = signal(false);
readonly arrExpanded = signal(false);
readonly scheduleIntervalOptions = computed(() => {
@@ -165,6 +166,7 @@ export class MalwareBlockerComponent implements OnInit, HasPendingChanges {
this.ignorePrivate.set(config.ignorePrivate);
this.deletePrivate.set(config.deletePrivate);
this.processNoContentId.set(config.processNoContentId);
this.deleteIfAnyFileBlocked.set(config.deleteIfAnyFileBlocked);
const blocklists: Record<string, any> = {};
for (const name of ARR_NAMES) {
@@ -221,6 +223,7 @@ export class MalwareBlockerComponent implements OnInit, HasPendingChanges {
ignorePrivate: this.ignorePrivate(),
deletePrivate: this.deletePrivate(),
processNoContentId: this.processNoContentId(),
deleteIfAnyFileBlocked: this.deleteIfAnyFileBlocked(),
sonarr: { enabled: blocklists['sonarr'].enabled, blocklistPath: blocklists['sonarr'].blocklistPath, blocklistType: blocklists['sonarr'].blocklistType as BlocklistType },
radarr: { enabled: blocklists['radarr'].enabled, blocklistPath: blocklists['radarr'].blocklistPath, blocklistType: blocklists['radarr'].blocklistType as BlocklistType },
lidarr: { enabled: blocklists['lidarr'].enabled, blocklistPath: blocklists['lidarr'].blocklistPath, blocklistType: blocklists['lidarr'].blocklistType as BlocklistType },
@@ -257,6 +260,7 @@ export class MalwareBlockerComponent implements OnInit, HasPendingChanges {
ignorePrivate: this.ignorePrivate(),
deletePrivate: this.deletePrivate(),
processNoContentId: this.processNoContentId(),
deleteIfAnyFileBlocked: this.deleteIfAnyFileBlocked(),
arrBlocklists: this.arrBlocklists(),
});
}

View File

@@ -22,6 +22,7 @@ export interface MalwareBlockerConfig {
ignorePrivate: boolean;
deletePrivate: boolean;
processNoContentId: boolean;
deleteIfAnyFileBlocked: boolean;
sonarr: BlocklistSettings;
radarr: BlocklistSettings;
lidarr: BlocklistSettings;