test(core/browser/platform): 26 extreme RED/GREEN TDD cycles and full regression hardening (3,235 PASS)
Some checks failed
release / test (push) Failing after 14m5s
release / windows (push) Has been cancelled
release / android (push) Has been cancelled
release / linux (push) Has been cancelled
release / macos (push) Has been cancelled

This commit is contained in:
Yun Chan 2026-09-18 23:32:32 +09:00
parent bcd242691f
commit 8677853822
100 changed files with 4814 additions and 333 deletions

View file

@ -0,0 +1,204 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Threading;
using Paca.Core.Detection;
using Paca.Core.Gateway;
using Paca.Core.Media;
using Xunit;
namespace Paca.Tests.Core;
public class AiMediaAndShaderEnginesExtremeRedTests
{
private const string SampleInput = "C:\\Videos\\source.mp4";
private const string SampleOutput = "C:\\Videos\\shorts.mp4";
// ==========================================
// 1. AiHighlightClipper
// ==========================================
[Theory]
[InlineData(0)]
[InlineData(-100)]
[InlineData(double.NaN)]
public void AiHighlightClipper_DetectHighlights_ZeroOrNegativeDuration_ReturnsEmpty(double duration)
{
var highlights = AiHighlightClipper.DetectHighlights(duration);
Assert.NotNull(highlights);
Assert.Empty(highlights);
}
[Theory]
[InlineData(0)]
[InlineData(-10)]
[InlineData(double.NaN)]
public void AiHighlightClipper_DetectHighlights_ZeroOrNegativeWindowSeconds_DoesNotCrashOrLoopInfinitely(double windowSec)
{
var highlights = AiHighlightClipper.DetectHighlights(120.0, windowSeconds: windowSec);
Assert.NotNull(highlights);
Assert.NotEmpty(highlights);
}
[Fact]
public void AiHighlightClipper_BuildFfmpegShortsCommand_SameInputAndOutput_ThrowsInvalidOperationException()
{
var spec = new ShortsClipSpec();
Assert.Throws<InvalidOperationException>(() =>
AiHighlightClipper.BuildFfmpegShortsCommand(SampleInput, SampleInput, spec));
}
[Theory]
[InlineData(null, SampleOutput)]
[InlineData(SampleInput, null)]
[InlineData("", SampleOutput)]
[InlineData(SampleInput, " ")]
public void AiHighlightClipper_BuildFfmpegShortsCommand_NullOrWhitespacePaths_ThrowsArgumentException(string? inPath, string? outPath)
{
var spec = new ShortsClipSpec();
Assert.Throws<ArgumentException>(() =>
AiHighlightClipper.BuildFfmpegShortsCommand(inPath!, outPath!, spec));
}
[Fact]
public void AiHighlightClipper_BuildFfmpegShortsCommand_NullSpec_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>(() =>
AiHighlightClipper.BuildFfmpegShortsCommand(SampleInput, SampleOutput, null!));
}
[Fact]
public void AiHighlightClipper_BuildFfmpegShortsCommand_PathsWithQuotes_Stripped()
{
var spec = new ShortsClipSpec();
string maliciousIn = "C:\\Videos\\so\"urce.mp4";
string maliciousOut = "C:\\Videos\\sh\"orts.mp4";
var args = AiHighlightClipper.BuildFfmpegShortsCommand(maliciousIn, maliciousOut, spec);
Assert.NotNull(args);
Assert.DoesNotContain(args, a => a.Contains('"'));
}
// ==========================================
// 2. FftAudioWaveformEngine
// ==========================================
[Fact]
public void FftAudioWaveformEngine_BuildFfmpegEqualizerFilter_NullOrEmptyBands_ReturnsEmptyString()
{
Assert.Equal(string.Empty, FftAudioWaveformEngine.BuildFfmpegEqualizerFilter(null!));
Assert.Equal(string.Empty, FftAudioWaveformEngine.BuildFfmpegEqualizerFilter(new List<EqualizerBand>()));
}
[Fact]
public void FftAudioWaveformEngine_BuildFfmpegEqualizerFilter_ListContainingNullBands_SkipsSafely()
{
var bands = new List<EqualizerBand>
{
null!,
new() { FrequencyHz = 1000, GainDb = 3.5 },
null!
};
var filter = FftAudioWaveformEngine.BuildFfmpegEqualizerFilter(bands);
Assert.NotNull(filter);
Assert.Contains("equalizer=f=1000", filter);
}
[Fact]
public void FftAudioWaveformEngine_BuildFfmpegEqualizerFilter_InCommaDecimalCulture_UsesDotDecimalSeparator()
{
var originalCulture = Thread.CurrentThread.CurrentCulture;
try
{
Thread.CurrentThread.CurrentCulture = new CultureInfo("de-DE"); // German culture uses ',' for decimal
var bands = new List<EqualizerBand>
{
new() { FrequencyHz = 500, GainDb = 4.5 }
};
var filter = FftAudioWaveformEngine.BuildFfmpegEqualizerFilter(bands);
Assert.Contains("g=4.5", filter);
Assert.DoesNotContain("g=4,5", filter);
}
finally
{
Thread.CurrentThread.CurrentCulture = originalCulture;
}
}
// ==========================================
// 3. NeuralShaderUpscaler
// ==========================================
[Fact]
public void NeuralShaderUpscaler_GenerateHlslShaderCode_NullConfig_DoesNotThrow()
{
var shader = NeuralShaderUpscaler.GenerateHlslShaderCode(null!);
Assert.NotNull(shader);
Assert.Contains("float4 PS_NeuralUpscale", shader);
}
[Theory]
[InlineData(0, 0)]
[InlineData(-1920, -1080)]
public void NeuralShaderUpscaler_GenerateHlslShaderCode_ZeroOrNegativeDimensions_FallsBackToDefaultResolution(int w, int h)
{
var config = new ShaderUpscaleConfig { TargetWidth = w, TargetHeight = h };
var shader = NeuralShaderUpscaler.GenerateHlslShaderCode(config);
Assert.NotNull(shader);
Assert.DoesNotContain("1.0 / 0", shader);
Assert.DoesNotContain("1.0 / -", shader);
}
[Fact]
public void NeuralShaderUpscaler_GenerateHlslShaderCode_InCommaDecimalCulture_UsesDotDecimalSeparator()
{
var originalCulture = Thread.CurrentThread.CurrentCulture;
try
{
Thread.CurrentThread.CurrentCulture = new CultureInfo("fr-FR"); // French culture uses ',' for decimal
var config = new ShaderUpscaleConfig { Sharpness = 2.5 };
var shader = NeuralShaderUpscaler.GenerateHlslShaderCode(config);
Assert.Contains("2.50", shader);
Assert.DoesNotContain("2,50", shader);
}
finally
{
Thread.CurrentThread.CurrentCulture = originalCulture;
}
}
// ==========================================
// 4. PacaConnectCodeGenerator
// ==========================================
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("PACA-12345")] // 5 chars instead of 6
[InlineData("PACA-1234567")] // 7 chars
[InlineData("PACA-000000")] // '0' is disallowed
[InlineData("PACA-111111")] // '1' is disallowed
[InlineData("PACA-IIIIII")] // 'I' is disallowed
[InlineData("PACA-OOOOOO")] // 'O' is disallowed
[InlineData("OTHER-234567")] // wrong prefix
public void PacaConnectCodeGenerator_IsValid_InvalidCodes_ReturnsFalse(string? code)
{
Assert.False(PacaConnectCodeGenerator.IsValid(code));
}
[Fact]
public void PacaConnectCodeGenerator_GenerateCode_AlwaysProducesValidCode()
{
for (int i = 0; i < 50; i++)
{
var code = PacaConnectCodeGenerator.GenerateCode();
Assert.True(PacaConnectCodeGenerator.IsValid(code), $"Generated code '{code}' should be valid.");
}
}
}

View file

@ -0,0 +1,171 @@
using System;
using System.Collections.Generic;
using Paca.Core;
using Xunit;
namespace Paca.Tests.Core;
public class AudioEnginesExtremeRedTests
{
[Fact]
public void AudioPitchShiftEngine_InPlaceOverwrite_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() =>
AudioPitchShiftEngine.BuildPitchShiftCommandLine("track.mp3", "track.mp3", 2.0));
Assert.Contains("identical", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Theory]
[InlineData(null, "out.mp3")]
[InlineData("", "out.mp3")]
[InlineData(" ", "out.mp3")]
[InlineData("in.mp3", null)]
[InlineData("in.mp3", "")]
[InlineData("in.mp3", " ")]
public void AudioPitchShiftEngine_NullOrEmptyPaths_ThrowsArgumentException(string? inPath, string? outPath)
{
Assert.Throws<ArgumentException>(() =>
AudioPitchShiftEngine.BuildPitchShiftCommandLine(inPath!, outPath!, 2.0));
}
[Theory]
[InlineData(double.NaN)]
[InlineData(double.PositiveInfinity)]
[InlineData(double.NegativeInfinity)]
public void AudioPitchShiftEngine_NaNOrInfinitySemitones_FallsBackToZeroShift(double invalidSemitones)
{
var filter = AudioPitchShiftEngine.BuildPitchShiftFilter(invalidSemitones, 44100);
Assert.DoesNotContain("NaN", filter, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("Infinity", filter, StringComparison.OrdinalIgnoreCase);
Assert.Contains("asetrate=44100", filter);
}
[Theory]
[InlineData(0)]
[InlineData(-44100)]
public void AudioPitchShiftEngine_InvalidSampleRate_ThrowsArgumentOutOfRangeException(int sampleRate)
{
Assert.Throws<ArgumentOutOfRangeException>(() =>
AudioPitchShiftEngine.BuildPitchShiftFilter(2.0, sampleRate));
}
[Fact]
public void AudioDynamicRangeCompressor_InPlaceOverwrite_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() =>
AudioDynamicRangeCompressor.BuildFfmpegArgs("podcast.wav", "podcast.wav", CompressorPreset.NightListening));
Assert.Contains("identical", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Theory]
[InlineData(null, "out.mp3")]
[InlineData("", "out.mp3")]
[InlineData("in.mp3", null)]
[InlineData("in.mp3", "")]
public void AudioDynamicRangeCompressor_NullOrEmptyPaths_ThrowsArgumentException(string? inPath, string? outPath)
{
Assert.Throws<ArgumentException>(() =>
AudioDynamicRangeCompressor.BuildFfmpegArgs(inPath!, outPath!, CompressorPreset.PodcastSpeech));
}
[Fact]
public void AudioDynamicRangeCompressor_InvalidBitrate_DefaultsTo192k()
{
var args = AudioDynamicRangeCompressor.BuildFfmpegArgs("in.mp4", "out.mp4", CompressorPreset.NightListening, audioBitrateKbps: 0);
Assert.Contains("-b:a 192k", args);
}
[Theory]
[InlineData(double.NaN, 4.0, 10.0, 100.0, 4.0, -1.0)]
[InlineData(-20.0, double.PositiveInfinity, 10.0, 100.0, 4.0, -1.0)]
[InlineData(-20.0, 4.0, double.NaN, 100.0, 4.0, -1.0)]
[InlineData(-20.0, -1.0, 10.0, 100.0, 4.0, -1.0)] // Invalid ratio <= 0
public void AudioDynamicRangeCompressor_InvalidCustomParameters_ThrowsArgumentOutOfRangeException(
double th, double ratio, double attack, double release, double makeup, double limit)
{
Assert.Throws<ArgumentOutOfRangeException>(() =>
AudioDynamicRangeCompressor.BuildCustomFilter(th, ratio, attack, release, makeup, limit));
}
[Fact]
public void AudioAlbumTrackSplitter_InPlaceOverwrite_ThrowsArgumentException()
{
var seg = new AlbumTrackSegment(1, 0.0, 60.0, "Track 01");
var ex = Assert.Throws<ArgumentException>(() =>
AudioAlbumTrackSplitter.BuildSplitCommand("album.flac", seg, "album.flac"));
Assert.Contains("identical", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void AudioAlbumTrackSplitter_NullTrack_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>(() =>
AudioAlbumTrackSplitter.BuildSplitCommand("in.mp3", null!, "out.mp3"));
}
[Theory]
[InlineData(double.NaN)]
[InlineData(double.PositiveInfinity)]
[InlineData(-10.0)]
public void AudioAlbumTrackSplitter_ParseSilenceToTracks_InvalidDuration_ReturnsEmpty(double totalDuration)
{
var tracks = AudioAlbumTrackSplitter.ParseSilenceToTracks("silence_start: 10.0\nsilence_end: 12.0", totalDuration);
Assert.Empty(tracks);
}
[Fact]
public void AudioAlbumTrackSplitter_GenerateM3uPlaylist_CrlfInjectionSanitized()
{
var tracks = new List<AlbumTrackSegment>
{
new(1, 0.0, 180.0, "Song\r\n#EXT-X-MALICIOUS-TAG")
};
var m3u = AudioAlbumTrackSplitter.GenerateM3uPlaylist("MyAlbum\r\n#FORGED-HEADER", tracks);
var lines = m3u.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None);
foreach (var line in lines)
{
if (line.StartsWith("#EXTINF:"))
{
Assert.DoesNotContain("\r", line);
Assert.DoesNotContain("\n", line);
Assert.Contains("MyAlbum", line);
}
Assert.False(line.Equals("#EXT-X-MALICIOUS-TAG", StringComparison.OrdinalIgnoreCase));
Assert.False(line.Equals("#FORGED-HEADER", StringComparison.OrdinalIgnoreCase));
}
}
[Fact]
public void AudioVocalStemExtractor_InPlaceOverwrite_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() =>
AudioVocalStemExtractor.BuildStemCommandLine("audio.wav", "audio.wav", AudioStemMode.VocalRemovalKaraoke));
Assert.Contains("identical", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void AudioChannelDownmixer_InPlaceOverwrite_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() =>
AudioChannelDownmixer.BuildDownmixCommandLine("movie.mkv", "movie.mkv", DownmixMode.StandardItuR775));
Assert.Contains("identical", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void AudioVolumeNormalizer_InPlaceOverwrite_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() =>
AudioVolumeNormalizer.BuildNormalizeCommandLine("stream.aac", "stream.aac", AudioVolumeNormalizer.PodcastStreamingPreset));
Assert.Contains("identical", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void AudioVolumeNormalizer_NullPreset_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>(() =>
AudioVolumeNormalizer.BuildNormalizeCommandLine("in.aac", "out.aac", null!));
Assert.Throws<ArgumentNullException>(() =>
AudioVolumeNormalizer.BuildLoudnormFilterArgs(null!));
}
}

View file

@ -0,0 +1,104 @@
using System;
using Paca.Core.Platform;
using Paca.Core.QuickActions;
using Xunit;
namespace Paca.Tests.Core;
public class ClipboardAndEncoderSecurityExtremeRedTests
{
private readonly QuickActionClipboardDetector _detector = new();
[Theory]
[InlineData("---")]
[InlineData("----")]
[InlineData("+++")]
[InlineData("***")]
[InlineData("///")]
[InlineData("+ - * /")]
[InlineData("(+)")]
[InlineData("====")]
public void QuickActionClipboardDetector_NonMathSymbols_DoesNotDetectAsMathExpression(string input)
{
var result = _detector.DetectIntent(input);
Assert.NotEqual(ClipboardIntentType.Expression, result.IntentType);
}
[Theory]
[InlineData("12 + 34")]
[InlineData("100 / 5")]
[InlineData("(3 + 4) * 2")]
[InlineData("2 ^ 8")]
[InlineData("50 * 1.1")]
public void QuickActionClipboardDetector_ValidArithmetic_DetectsAsExpression(string input)
{
var result = _detector.DetectIntent(input);
Assert.Equal(ClipboardIntentType.Expression, result.IntentType);
}
[Theory]
[InlineData("https://evil.com/login?ref=youtube.com")]
[InlineData("https://phishing.org/index.html?redirect=twitter.com")]
[InlineData("https://attacker.site/steal?feed=tiktok.com")]
[InlineData("https://notyoutube.com/watch?v=123")]
public void QuickActionClipboardDetector_PhishingDomainQuery_DoesNotDetectAsMediaDownload(string input)
{
var result = _detector.DetectIntent(input);
Assert.NotEqual(ClipboardIntentType.MediaDownload, result.IntentType);
Assert.Equal(ClipboardIntentType.BrowseUrl, result.IntentType);
}
[Theory]
[InlineData("https://www.youtube.com/watch?v=dQw4w9WgXcQ")]
[InlineData("https://x.com/user/status/123456")]
[InlineData("https://stream.example.com/playlist.m3u8")]
[InlineData("https://video.example.com/movie.mp4")]
public void QuickActionClipboardDetector_RealMediaUrls_DetectsAsMediaDownload(string input)
{
var result = _detector.DetectIntent(input);
Assert.Equal(ClipboardIntentType.MediaDownload, result.IntentType);
}
private sealed class LocalMockRunner : IProcessRunner
{
public Task<ProcessResult> RunAsync(ProcessSpec spec, Action<string, string>? onLine, System.Threading.CancellationToken ct) =>
Task.FromResult(new ProcessResult(0, "", ""));
}
[Fact]
public void HardwareEncoderDetector_NullCapabilities_SafelyFallsBackToSoftwareCodecs()
{
var detector = new HardwareEncoderDetector(new LocalMockRunner());
Assert.Equal("libx264", detector.GetBestH264Encoder(null!));
Assert.Equal("libx265", detector.GetBestHevcEncoder(null!));
Assert.Equal("libsvtav1", detector.GetBestAv1Encoder(null!));
}
[Fact]
public void HardwareEncoderDetector_BuildEncodingArgs_NullOrWhitespaceEncoder_DefaultsToLibx264()
{
var args = HardwareEncoderDetector.BuildEncodingArgs(null!, 23, "fast");
Assert.Contains("-c:v", args);
Assert.Contains("libx264", args);
}
[Fact]
public void HardwareEncoderDetector_BuildEncodingArgs_NullPreset_DefaultsToFast()
{
var args = HardwareEncoderDetector.BuildEncodingArgs("libx264", 23, null!);
Assert.Contains("-preset", args);
Assert.Contains("fast", args);
}
[Theory]
[InlineData(-10, 0)]
[InlineData(100, 63)]
public void HardwareEncoderDetector_BuildEncodingArgs_CrfClamping_ClampsBetweenZeroAnd63(int inputCrf, int expectedCrf)
{
var args = HardwareEncoderDetector.BuildEncodingArgs("libx264", inputCrf, "fast");
var crfIdx = Array.IndexOf(args, "-crf");
Assert.True(crfIdx >= 0);
Assert.Equal(expectedCrf.ToString(), args[crfIdx + 1]);
}
}

View file

@ -0,0 +1,155 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Paca.Core.Hls;
using Paca.Core.Platform;
using Xunit;
namespace Paca.Tests.Core;
/// <summary>
/// Cycle 4: 극한 RED 시나리오 - 카오스 멀티스레딩 동시성 무결성 및 경로 순회(Path Traversal) / 파일 락 방어선 검증
/// </summary>
public class ConcurrencyAndCacheIntegrityExtremeRedTests
{
[Fact]
public void Cycle4_Gate1_VideoDeduplicator_ConcurrentStress_MustNotThrowOrCorrupt()
{
var deduplicator = new VideoDeduplicator(maxHammingThreshold: 5);
const int TaskCount = 32;
const int IterationsPerTask = 200;
Parallel.For(0, TaskCount, taskId =>
{
for (int i = 0; i < IterationsPerTask; i++)
{
var filePath = $"C:/media/video_{taskId}_{i}.mp4";
ulong hash = (ulong)(taskId * 10000 + i);
// 동시 등록
deduplicator.RegisterVideo(filePath, hash);
// 동시 검색
var match = deduplicator.FindDuplicate(hash);
Assert.NotNull(match);
Assert.Equal(0, match.HammingDistance);
}
});
Assert.Equal(TaskCount * IterationsPerTask, deduplicator.RegisteredCount);
}
[Fact]
public void Cycle4_Gate2_MultiSourceSegmentAccelerator_ConcurrentStress_MustNotThrow()
{
var accelerator = new MultiSourceSegmentAccelerator();
for (int i = 0; i < 5; i++)
{
accelerator.AddMirror($"https://cdn{i}.example.com/hls/");
}
const int TaskCount = 32;
const int IterationsPerTask = 200;
Parallel.For(0, TaskCount, taskId =>
{
for (int i = 0; i < IterationsPerTask; i++)
{
var targetMirror = $"https://cdn{i % 5}.example.com/hls/";
if (i % 3 == 0)
{
accelerator.RecordFailure(targetMirror);
}
else if (i % 3 == 1)
{
accelerator.RecordLatency(targetMirror, TimeSpan.FromMilliseconds(20 + i));
}
else
{
var mirror = accelerator.SelectBestMirror(i);
Assert.NotEmpty(mirror);
var fastest = accelerator.GetFastestMirror();
Assert.NotNull(fastest);
}
}
});
}
[Fact]
public void Cycle4_Gate3_PredictiveChunkPrefetcher_ConcurrentStress_MustNotThrow()
{
var prefetcher = new PredictiveChunkPrefetcher(initialWindowSize: 3, minWindowSize: 1, maxWindowSize: 10, maxSamples: 10);
const int TaskCount = 32;
const int IterationsPerTask = 200;
Parallel.For(0, TaskCount, taskId =>
{
for (int i = 0; i < IterationsPerTask; i++)
{
long bytes = 1_000_000 + (taskId * 50_000);
var duration = TimeSpan.FromMilliseconds(200 + (i % 100));
prefetcher.RecordChunkDownload(bytes, duration);
var window = prefetcher.CalculateOptimalPrefetchWindow();
Assert.InRange(window, 1, 10);
}
});
}
[Fact]
public void Cycle4_Gate4_PartialChunkIntegrityCache_PathTraversalDefense_ReconcileAndClean()
{
var tempRoot = Path.Combine(Path.GetTempPath(), "paca_traversal_" + Guid.NewGuid().ToString("N"));
var partsDir = Path.Combine(tempRoot, "parts");
Directory.CreateDirectory(partsDir);
// 상위 디렉터리에 피해를 줄 수 있는 카나리아 파일 생성
var canaryFile = Path.Combine(tempRoot, "canary_sensitive_data.txt");
File.WriteAllText(canaryFile, "CRITICAL DATA DO NOT DELETE");
try
{
var cache = new PartialChunkIntegrityCache();
// 악의적인 path traversal 청크 이름 주입 ("../../canary_sensitive_data.txt")
var report = cache.ReconcileAndClean(
partsDir,
totalSegments: 1,
getChunkName: _ => "../../canary_sensitive_data.txt"
);
// parts 디렉터리 외부의 카나리아 파일이 삭제되지 않고 온전히 보존되어야 함
Assert.True(File.Exists(canaryFile), "상위 디렉터리의 카나리아 파일이 Path Traversal 로 인해 삭제되었습니다!");
Assert.Equal("CRITICAL DATA DO NOT DELETE", File.ReadAllText(canaryFile));
}
finally
{
try { if (Directory.Exists(tempRoot)) Directory.Delete(tempRoot, true); } catch { }
}
}
[Fact]
public void Cycle4_Gate5_PartialChunkIntegrityCache_VerifyChunkFile_LockedFileDefense()
{
var tempFile = Path.Combine(Path.GetTempPath(), "paca_locked_" + Guid.NewGuid().ToString("N") + ".seg");
File.WriteAllBytes(tempFile, new byte[1024]);
try
{
var cache = new PartialChunkIntegrityCache();
// 백신 또는 외부 프로세스가 독점 락(FileShare.None)을 잡고 있는 상황 시뮬레이션
using var lockedStream = new FileStream(tempFile, FileMode.Open, FileAccess.Read, FileShare.None);
// unhandled IOException 크래시 없이 Corrupt 또는 Partial 로 안전하게 처리되어야 함
var state = cache.VerifyChunkFile(tempFile, expectedBytes: 1024, expectedSha256: "dummy_hash");
Assert.True(state == ChunkIntegrityState.Corrupt || state == ChunkIntegrityState.Partial);
}
finally
{
try { if (File.Exists(tempFile)) File.Delete(tempFile); } catch { }
}
}
}

View file

@ -0,0 +1,93 @@
using System;
using System.IO;
using Paca.Core.Platform;
using Paca.Core.Util;
using Xunit;
namespace Paca.Tests.Core;
/// <summary>
/// Cycle 1: 극한 RED 시나리오 - 파일명 정제 및 윈도우 장치 예약어, 트로이목마 Bidi 스푸핑, 경로 누수 방어 검증
/// </summary>
public class FileNameUtilAndCleanupExtremeRedTests
{
[Theory]
[InlineData("CON_long_stream_title", 3, "_CON")]
[InlineData("PRN_document_capture", 3, "_PRN")]
[InlineData("AUX_recording_session", 3, "_AUX")]
[InlineData("NUL_blackhole_stream", 3, "_NUL")]
[InlineData("COM1_serial_diagnostic", 4, "_COM1")]
[InlineData("LPT1_parallel_port", 4, "_LPT1")]
[InlineData("con.something.video", 3, "_con")]
public void Cycle1_Gate1_TruncatedToReservedName_MustPrependUnderscore(string input, int maxLen, string expectedPrefix)
{
// Act
var sanitized = FileNameUtil.Sanitize(input, maxLen);
// Assert: 윈도우 장치 예약어(CON, PRN, AUX, NUL, COM1-9, LPT1-9)와 일치해서는 안 됨
Assert.StartsWith(expectedPrefix, sanitized, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotMatch(@"^(?i)(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$", sanitized);
}
[Fact]
public void Cycle1_Gate2_BidiTrojanSourceAndZeroWidth_MustBeSanitized()
{
// CVE 클래스: Trojan Source / Unicode Bidi Spoofing
// \u202E (Right-to-Left Override): "video\u202E4pm.exe" -> 화면에는 "videoexe.mp4" 로 착시 유도
var trojanBidi = "my_video\u202E4pm.exe";
var sanitizedBidi = FileNameUtil.Sanitize(trojanBidi);
Assert.DoesNotContain('\u202E', sanitizedBidi);
Assert.DoesNotContain('\u202A', sanitizedBidi);
Assert.DoesNotContain('\u202B', sanitizedBidi);
Assert.DoesNotContain('\u202C', sanitizedBidi);
Assert.DoesNotContain('\u202D', sanitizedBidi);
// 제로 너비 공백(\u200B), BOM(\uFEFF) 등 인비저블 캐릭터 제거
var zeroWidth = "video\u200B\u200C\u200D\uFEFFtitle";
var sanitizedZero = FileNameUtil.Sanitize(zeroWidth);
Assert.DoesNotContain('\u200B', sanitizedZero);
Assert.DoesNotContain('\u200C', sanitizedZero);
Assert.DoesNotContain('\u200D', sanitizedZero);
Assert.DoesNotContain('\uFEFF', sanitizedZero);
}
[Theory]
[InlineData("............", 5, "video")]
[InlineData(" ", 8, "video")]
[InlineData("valid_name", 0, "video")]
[InlineData("valid_name", -5, "video")]
[InlineData("...", 1, "video")]
public void Cycle1_Gate3_DegenerateMaxLenAndDotSpaceCollapse_MustFallbackToVideo(string input, int maxLen, string expected)
{
// Act
var result = FileNameUtil.Sanitize(input, maxLen);
// Assert: 빈 문자열이나 ArgumentOutOfRangeException 없이 안전한 기본값 반환
Assert.Equal(expected, result);
}
[Fact]
public void Cycle1_Gate4_DownloadMetaPathResolver_TempAndParts_MustBeInPacaHiddenDirectory()
{
var tempRoot = Path.Combine(Path.GetTempPath(), "paca_test_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempRoot);
try
{
var outputBase = Path.Combine(tempRoot, "sample_video");
var partsDir = DownloadMetaPathResolver.GetPartsDirectory(outputBase);
var tempPartFile = DownloadMetaPathResolver.GetTempPartFilePath(outputBase);
// .paca 격리 서브디렉터리 내부에 생성되어야 함
Assert.Contains(".paca", partsDir);
Assert.Contains(".paca", tempPartFile);
Assert.EndsWith("sample_video.parts", partsDir);
Assert.EndsWith("sample_video.part", tempPartFile);
}
finally
{
try { if (Directory.Exists(tempRoot)) Directory.Delete(tempRoot, true); } catch { }
}
}
}

View file

@ -0,0 +1,100 @@
using System;
using System.IO;
using System.Security.Cryptography;
using Paca.Core.Config;
using Paca.Core.Dash;
using Paca.Core.Hls;
using Paca.Core.Models;
using Xunit;
namespace Paca.Tests.Core;
/// <summary>
/// Cycle 2: 극한 RED 시나리오 - HLS AES-128 암호화 IV/키 경계값 패딩, 블록 크기 손상 예외 정밀화, DASH 매니페스트 퍼징 검증
/// </summary>
public class HlsAndDashResilienceExtremeRedTests
{
[Fact]
public void Cycle2_Gate1_ResolveIv_ShortHex_PadsTo16BytesBigEndian()
{
// RFC 8216: IV 속성은 128비트(16바이트) 빅엔디언 정수 16진수 표현
// 0x1 과 같이 축약된 hex 가 주어졌을 때 1바이트가 아닌 16바이트로 좌측 0 패딩되어야 함
var keyInfo = new KeyInfo(new Uri("https://example.com/key.bin"), "0x1");
var iv = HlsDownloader.ResolveIv(keyInfo, 100);
Assert.Equal(16, iv.Length);
for (int i = 0; i < 15; i++)
{
Assert.Equal(0, iv[i]);
}
Assert.Equal(1, iv[15]);
// 3바이트 hex (6글자)
var keyHex6 = new KeyInfo(new Uri("https://example.com/key.bin"), "0xABCDEF");
var iv6 = HlsDownloader.ResolveIv(keyHex6, 100);
Assert.Equal(16, iv6.Length);
Assert.Equal(0xAB, iv6[13]);
Assert.Equal(0xCD, iv6[14]);
Assert.Equal(0xEF, iv6[15]);
// 잘못된 비-16진수("0xZZZZ") 주입 시 크래시 없이 시퀀스 번호(100)로 안전 폴백
var malformedKey = new KeyInfo(new Uri("https://example.com/key.bin"), "0xZZZZ");
var ivFallback = HlsDownloader.ResolveIv(malformedKey, 100);
Assert.Equal(16, ivFallback.Length);
Assert.Equal(100, ivFallback[15]);
}
[Fact]
public void Cycle2_Gate2_DecryptSegment_InvalidKeyOrCorruptedBlock_ThrowsInvalidDataException()
{
var config = new AppConfig();
var downloader = new HlsDownloader(new System.Net.Http.HttpClient(), config);
var keyUri = new Uri("https://example.com/aes.key");
var seg = new Segment(new Uri("https://example.com/seg1.ts"), 0, 1, new KeyInfo(keyUri, "0x1"));
// 1. 비정상 키 길이 (16바이트가 아닌 10바이트 등) -> InvalidDataException 발생 확인
downloader.SetPreloadedKeyForTesting(keyUri, new byte[10]);
var exKey = Assert.Throws<InvalidDataException>(() => downloader.DecryptSegmentForTesting(new byte[16], seg));
Assert.Contains("16바이트", exKey.Message);
// 2. 비정상 데이터 블록 크기 (AES CBC는 16의 배수여야 함, 15바이트 주입) -> InvalidDataException 발생 확인
downloader.SetPreloadedKeyForTesting(keyUri, new byte[16]);
var exData = Assert.Throws<InvalidDataException>(() => downloader.DecryptSegmentForTesting(new byte[15], seg));
Assert.Contains("블록 크기", exData.Message);
}
[Fact]
public void Cycle2_Gate3_DashParser_TimescaleZeroOrNegative_DefaultsToOne()
{
var mpdXml = """
<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" mediaPresentationDuration="PT10S">
<Period>
<AdaptationSet contentType="video">
<Representation id="1" bandwidth="2000000">
<SegmentTemplate timescale="0" duration="2" media="chunk_$Number$.m4s" startNumber="1" />
</Representation>
</AdaptationSet>
</Period>
</MPD>
""";
var manifest = DashParser.Parse(mpdXml, new Uri("https://cdn.example.com/manifest.mpd"));
Assert.NotNull(manifest);
Assert.NotNull(manifest.Video);
// timescale="0"이어도 0으로 나누기 예외 없이 1로 폴백되어 5개 세그먼트 생성
Assert.NotEmpty(manifest.Video.Segments);
}
[Theory]
[InlineData("P1D", 86400.0)]
[InlineData("P1DT2H", 93600.0)]
[InlineData("PT1H30M", 5400.0)]
[InlineData("PT1.5S", 1.5)]
[InlineData("", 0.0)]
[InlineData("INVALID_DURATION", 0.0)]
public void Cycle2_Gate4_DashParser_Iso8601Duration_SupportsDaysAndFractions(string isoDuration, double expectedSec)
{
double actual = DashParser.ParseDuration(isoDuration);
Assert.Equal(expectedSec, actual, precision: 2);
}
}

View file

@ -0,0 +1,131 @@
using System;
using Paca.Core.Hls;
using Paca.Core.Models;
using Xunit;
namespace Paca.Tests.Core;
public class M3u8ParserByteRangeAndfMp4ExtremeRedTests
{
private static readonly Uri Base = new("https://cdn.example.com/stream/playlist.m3u8");
[Fact]
public void M3u8Parser_ByteRange_ExplicitLengthAndOffset_ParsedCorrectly()
{
var text = @"#EXTM3U
#EXT-X-TARGETDURATION:10
#EXT-X-BYTERANGE:1000@500
segment.ts";
var playlist = M3u8Parser.Parse(text, Base);
Assert.Single(playlist.Segments);
var seg = playlist.Segments[0];
Assert.Equal(1000, seg.ByteRangeLength);
Assert.Equal(500, seg.ByteRangeOffset);
}
[Fact]
public void M3u8Parser_ByteRange_SequentialImplicitOffset_AccumulatesCorrectly()
{
var text = @"#EXTM3U
#EXT-X-TARGETDURATION:10
#EXT-X-BYTERANGE:1000@0
segment.ts
#EXT-X-BYTERANGE:2000
segment.ts
#EXT-X-BYTERANGE:1500
segment.ts";
var playlist = M3u8Parser.Parse(text, Base);
Assert.Equal(3, playlist.Segments.Count);
Assert.Equal(1000, playlist.Segments[0].ByteRangeLength);
Assert.Equal(0, playlist.Segments[0].ByteRangeOffset);
Assert.Equal(2000, playlist.Segments[1].ByteRangeLength);
Assert.Equal(1000, playlist.Segments[1].ByteRangeOffset);
Assert.Equal(1500, playlist.Segments[2].ByteRangeLength);
Assert.Equal(3000, playlist.Segments[2].ByteRangeOffset);
}
[Fact]
public void M3u8Parser_ByteRange_InvalidNegativeOrNonNumeric_IgnoredGracefully()
{
var text = @"#EXTM3U
#EXT-X-TARGETDURATION:10
#EXT-X-BYTERANGE:-500@-100
segment1.ts
#EXT-X-BYTERANGE:invalid@nan
segment2.ts";
var playlist = M3u8Parser.Parse(text, Base);
Assert.Equal(2, playlist.Segments.Count);
Assert.Null(playlist.Segments[0].ByteRangeLength);
Assert.Null(playlist.Segments[0].ByteRangeOffset);
Assert.Null(playlist.Segments[1].ByteRangeLength);
Assert.Null(playlist.Segments[1].ByteRangeOffset);
}
[Fact]
public void M3u8Parser_InitSegment_WithByteRange_ParsedCorrectly()
{
var text = @"#EXTM3U
#EXT-X-TARGETDURATION:10
#EXT-X-MAP:URI=""init.mp4"",BYTERANGE=""800@0""
#EXTINF:6.0,
segment1.m4s";
var playlist = M3u8Parser.Parse(text, Base);
Assert.NotNull(playlist.InitSegment);
Assert.Equal("https://cdn.example.com/stream/init.mp4", playlist.InitSegment.AbsoluteUri);
Assert.Equal(800, playlist.InitByteRangeLength);
Assert.Equal(0, playlist.InitByteRangeOffset);
}
[Fact]
public void M3u8Parser_NullBaseUrl_ReturnsEmptyPlaylistWithoutCrashing()
{
var text = @"#EXTM3U
#EXT-X-TARGETDURATION:10
segment.ts";
var playlist = M3u8Parser.Parse(text, null!);
Assert.NotNull(playlist);
Assert.Empty(playlist.Segments);
}
[Fact]
public void Variant_NegativeOrMalformedResolution_ClampsToZero()
{
var v = new Variant(new Uri("https://example.com/v.m3u8"), 1000, "-1920x-1080", "avc1");
Assert.Equal(0, v.Width);
Assert.Equal(0, v.Height);
}
[Fact]
public void Variant_ComplexResolution_ParsesWidthAndHeight()
{
var v = new Variant(new Uri("https://example.com/v.m3u8"), 1000, "1920x1080@60fps", "avc1");
Assert.Equal(1920, v.Width);
Assert.Equal(1080, v.Height);
}
[Fact]
public void M3u8Parser_MediaSequence_Int64Overflow_DoesNotGoNegative()
{
var text = @"#EXTM3U
#EXT-X-MEDIA-SEQUENCE:9223372036854775807
#EXTINF:6.0,
seg1.ts
#EXTINF:6.0,
seg2.ts";
var playlist = M3u8Parser.Parse(text, Base);
Assert.Equal(2, playlist.Segments.Count);
Assert.True(playlist.Segments[0].Sequence >= 0);
Assert.True(playlist.Segments[1].Sequence >= 0);
}
}

View file

@ -0,0 +1,164 @@
using System;
using System.Collections.Generic;
using Paca.Core;
using Xunit;
namespace Paca.Tests.Core;
public class MediaMuxerAndMetadataExtremeRedTests
{
private const string SampleIn1 = "C:\\Media\\video.mp4";
private const string SampleAudio = "C:\\Media\\audio.m4a";
private const string SampleOut = "C:\\Media\\output.mp4";
// ==========================================
// 1. MediaMetadataTagWriter
// ==========================================
[Fact]
public void MediaMetadataTagWriter_BuildFfmpegArgs_NullTag_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>(() =>
MediaMetadataTagWriter.BuildFfmpegArgs(null!, SampleIn1, SampleOut));
}
[Theory]
[InlineData(null, SampleOut)]
[InlineData(SampleIn1, null)]
[InlineData("", SampleOut)]
[InlineData(SampleIn1, " ")]
public void MediaMetadataTagWriter_BuildFfmpegArgs_NullOrWhitespacePaths_ThrowsArgumentException(string? inPath, string? outPath)
{
var tag = new MediaMetadataTag { Title = "Song" };
Assert.Throws<ArgumentException>(() =>
MediaMetadataTagWriter.BuildFfmpegArgs(tag, inPath!, outPath!));
}
[Fact]
public void MediaMetadataTagWriter_BuildFfmpegArgs_SameInputAndOutput_ThrowsInvalidOperationException()
{
var tag = new MediaMetadataTag { Title = "Song" };
Assert.Throws<InvalidOperationException>(() =>
MediaMetadataTagWriter.BuildFfmpegArgs(tag, SampleIn1, SampleIn1));
}
[Fact]
public void MediaMetadataTagWriter_BuildFfmpegArgs_PathsWithQuotes_EscapedOrStripped()
{
var tag = new MediaMetadataTag { Title = "Song" };
string maliciousIn = "C:\\Media\\in\"put.mp4";
string maliciousOut = "C:\\Media\\out\"put.mp4";
var cmd = MediaMetadataTagWriter.BuildFfmpegArgs(tag, maliciousIn, maliciousOut);
Assert.NotNull(cmd);
Assert.DoesNotContain("in\"put", cmd);
Assert.DoesNotContain("out\"put", cmd);
}
// ==========================================
// 2. MultiTrackMediaMuxer
// ==========================================
[Fact]
public void MultiTrackMediaMuxer_BuildMuxCommandLine_NullJob_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>(() =>
MultiTrackMediaMuxer.BuildMuxCommandLine(null!));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void MultiTrackMediaMuxer_BuildMuxCommandLine_NullOrWhitespaceOutputPath_ThrowsArgumentException(string? badOut)
{
var job = new MuxJobSpecification
{
VideoTrack = new TrackSpec(SampleIn1),
OutputPath = badOut!
};
Assert.Throws<ArgumentException>(() =>
MultiTrackMediaMuxer.BuildMuxCommandLine(job));
}
[Fact]
public void MultiTrackMediaMuxer_BuildMuxCommandLine_NoTracks_ThrowsInvalidOperationException()
{
var job = new MuxJobSpecification
{
OutputPath = SampleOut
};
Assert.Throws<InvalidOperationException>(() =>
MultiTrackMediaMuxer.BuildMuxCommandLine(job));
}
[Fact]
public void MultiTrackMediaMuxer_BuildMuxCommandLine_OutputPathMatchesAnyInput_ThrowsInvalidOperationException()
{
var job = new MuxJobSpecification
{
VideoTrack = new TrackSpec(SampleIn1),
AudioTracks = new List<TrackSpec> { new(SampleAudio) },
OutputPath = SampleAudio // Overwriting audio source!
};
Assert.Throws<InvalidOperationException>(() =>
MultiTrackMediaMuxer.BuildMuxCommandLine(job));
}
[Fact]
public void MultiTrackMediaMuxer_BuildMuxCommandLine_TrackTitleWithQuotes_EscapedSafely()
{
var job = new MuxJobSpecification
{
VideoTrack = new TrackSpec(SampleIn1),
AudioTracks = new List<TrackSpec> { new(SampleAudio, Title: "Director's \"Commentary\"") },
OutputPath = SampleOut
};
var cmd = MultiTrackMediaMuxer.BuildMuxCommandLine(job);
Assert.NotNull(cmd);
Assert.DoesNotContain("title=\"Director's \"Commentary\"\"", cmd);
}
// ==========================================
// 3. VideoTonemapEnhancer
// ==========================================
[Theory]
[InlineData(null, SampleOut)]
[InlineData(SampleIn1, null)]
public void VideoTonemapEnhancer_BuildFfmpegArgs_NullOrWhitespacePaths_ThrowsArgumentException(string? inPath, string? outPath)
{
Assert.Throws<ArgumentException>(() =>
VideoTonemapEnhancer.BuildFfmpegArgs(inPath!, outPath!, TonemapAlgorithm.Hable, ColorGradePreset.None));
}
[Fact]
public void VideoTonemapEnhancer_BuildFfmpegArgs_SameInputAndOutput_ThrowsInvalidOperationException()
{
Assert.Throws<InvalidOperationException>(() =>
VideoTonemapEnhancer.BuildFfmpegArgs(SampleIn1, SampleIn1, TonemapAlgorithm.Hable, ColorGradePreset.None));
}
[Fact]
public void VideoTonemapEnhancer_BuildFfmpegArgs_PathsWithQuotes_EscapedOrStripped()
{
string maliciousIn = "C:\\Media\\in\"put.mp4";
string maliciousOut = "C:\\Media\\out\"put.mp4";
var cmd = VideoTonemapEnhancer.BuildFfmpegArgs(maliciousIn, maliciousOut, TonemapAlgorithm.Hable, ColorGradePreset.None);
Assert.NotNull(cmd);
Assert.DoesNotContain("in\"put", cmd);
Assert.DoesNotContain("out\"put", cmd);
}
[Fact]
public void VideoTonemapEnhancer_BuildFfmpegArgs_InvalidEncoderOrCrf_ClampedSafely()
{
var cmd = VideoTonemapEnhancer.BuildFfmpegArgs(
SampleIn1, SampleOut, TonemapAlgorithm.Hable, ColorGradePreset.None,
crf: 999, videoEncoder: "libx264; echo pwned");
Assert.NotNull(cmd);
Assert.Contains("-crf 51", cmd);
Assert.DoesNotContain(";", cmd);
}
}

View file

@ -0,0 +1,92 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Paca.Core.Platform;
using Xunit;
namespace Paca.Tests.Core;
/// <summary>
/// Cycle 3: 극한 RED 시나리오 - 인플레이스 덮어쓰기 원본 파괴 방어, FFmpeg 필터 인젝션 이스케이프, 메타데이터 문법 오염 차단
/// </summary>
public class MediaProcessingExtremeRedTests
{
private sealed class DummyProcessRunner : IProcessRunner
{
public Task<ProcessResult> RunAsync(ProcessSpec spec, Action<string, string>? onLine, CancellationToken ct) =>
Task.FromResult(new ProcessResult(0, "", ""));
}
[Fact]
public async Task Cycle3_Gate1_InPlaceOverwrite_MustThrowArgumentException_ToPreventSourceDestruction()
{
var runner = new DummyProcessRunner();
var trimmer = new LosslessSmartTrimmer(runner);
var normalizer = new AudioNormalizer(runner);
var burner = new VideoWatermarkBurner(runner);
var path = "C:/media/target_video.mp4";
// 1. 트리머가 동일 경로에 덮어쓰기를 시도하면 원본 파괴(0바이트 절단) 방지를 위해 ArgumentException 발생
var exTrimmer = await Assert.ThrowsAsync<ArgumentException>(() =>
trimmer.TrimLosslessAsync("ffmpeg.exe", path, path, 0, 10, default));
Assert.Contains("동일", exTrimmer.Message);
// 2. 오디오 정규화기가 동일 경로에 덮어쓰기 시 ArgumentException 발생
var exNormalizer = await Assert.ThrowsAsync<ArgumentException>(() =>
normalizer.NormalizeAudioAsync("ffmpeg.exe", path, path, LoudnessSpec.WebStandard, default));
Assert.Contains("동일", exNormalizer.Message);
// 3. 워터마크 버너가 동일 경로에 덮어쓰기 시 ArgumentException 발생
var exBurner = await Assert.ThrowsAsync<ArgumentException>(() =>
burner.ApplyWatermarkAsync("ffmpeg.exe", path, path, new WatermarkSpec("wm.png", WatermarkPosition.TopLeft), default));
Assert.Contains("동일", exBurner.Message);
}
[Fact]
public void Cycle3_Gate2_DrawTextFilter_EscapesSingleQuotesColonsAndBackslashes()
{
// FFmpeg filtergraph syntax: drawtext filter requires escaping for ', :, \, %
var dangerousText = "It's a: sample\\path %date%";
var filter = VideoWatermarkBurner.BuildDrawTextFilter(dangerousText);
// 텍스트 인젝션 방어: 따옴표, 콜론, 백슬래시, 퍼센트가 안전하게 이스케이프되었는지 검증
Assert.DoesNotContain("text='It's", filter); // 원시 따옴표 탈출 불가
Assert.Contains(@"\'", filter); // 따옴표 이스케이프
Assert.Contains(@"\:", filter); // 콜론 이스케이프
Assert.Contains(@"\\", filter); // 백슬래시 이스케이프
Assert.Contains("%%", filter); // 퍼센트 매크로 이스케이프
}
[Fact]
public void Cycle3_Gate3_ChapterSplitter_GenerateFfmetadata_EscapesSpecialCharacters()
{
// FFMETADATA1 포맷 규격: =, ;, #, \, 개행은 백슬래시로 이스케이프되어야 함
var chapters = new List<ChapterInfo>
{
new(1, @"Part#1; Season=2 \ Special" + "\n" + "Subtitle", 0.0, 10.0)
};
var metadata = ChapterSplitter.GenerateFfmetadata(chapters, @"Main=Title; #1 \ Top");
// FFMETADATA1 헤더 검증
Assert.StartsWith(";FFMETADATA1", metadata);
// 특수문자 이스케이프 검증
Assert.Contains(@"Main\=Title\; \#1 \\ Top", metadata);
Assert.Contains(@"Part\#1\; Season\=2 \\ Special", metadata);
}
[Theory]
[InlineData(-10.5, "00:00:00.000")]
[InlineData(-0.001, "00:00:00.000")]
[InlineData(double.NaN, "00:00:00.000")]
[InlineData(double.PositiveInfinity, "99:59:59.999")]
[InlineData(3661.123, "01:01:01.123")]
public void Cycle3_Gate4_VoiceActivitySubtitleAdapter_FormatTimecode_ClampsNegativeAndHandlesDegenerate(double inputSec, string expected)
{
var result = VoiceActivitySubtitleAdapter.FormatTimecode(inputSec);
Assert.Equal(expected, result);
}
}

View file

@ -0,0 +1,252 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Security.Cryptography;
using System.Text.Json;
using Paca.Core.Net;
using Paca.Core.Security;
using Xunit;
namespace Paca.Tests.Core;
public class P2pAndE2eeVaultExtremeRedTests
{
// ==========================================
// 1. P2pSwarmEngine Extreme Scenarios
// ==========================================
[Theory]
[InlineData(0)]
[InlineData(-1024)]
public void P2pSwarmEngine_CreateManifestFromBytes_ZeroOrNegativePieceLength_ClampsToDefault512KB(int pieceLength)
{
byte[] dummyData = new byte[1024 * 1024]; // 1MB
var manifest = P2pSwarmEngine.CreateManifestFromBytes("test.mp4", dummyData, pieceLength);
Assert.NotNull(manifest);
Assert.True(manifest.PieceLength >= 16 * 1024, "PieceLength must be safely clamped to at least 16KB");
}
[Fact]
public void P2pSwarmEngine_CreateManifestFromBytes_NullData_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>(() =>
P2pSwarmEngine.CreateManifestFromBytes("test.mp4", null!));
}
[Fact]
public void P2pSwarmEngine_InitializeSession_NullManifest_ThrowsArgumentNullException()
{
var engine = new P2pSwarmEngine();
Assert.Throws<ArgumentNullException>(() => engine.InitializeSession(null!));
}
[Fact]
public void P2pSwarmEngine_AddPeer_NullPeer_DoesNotThrow()
{
var engine = new P2pSwarmEngine();
var ex = Record.Exception(() => engine.AddPeer(null!));
Assert.Null(ex);
Assert.Equal(0, engine.ConnectedPeerCount);
}
[Fact]
public void P2pSwarmEngine_AddPeer_NullBitfield_InitializesSafeBitfield()
{
var engine = new P2pSwarmEngine();
var peer = new SwarmPeer { PeerId = "peer1", Bitfield = null! };
var ex = Record.Exception(() => engine.AddPeer(peer));
Assert.Null(ex);
Assert.NotNull(peer.Bitfield);
}
[Fact]
public void P2pSwarmEngine_IngestPieceData_NullData_ReturnsFalseWithoutThrowing()
{
var engine = new P2pSwarmEngine();
var manifest = P2pSwarmEngine.CreateManifestFromBytes("sample.mp4", new byte[100], 50);
engine.InitializeSession(manifest);
bool ingested = engine.IngestPieceData(0, null!);
Assert.False(ingested);
}
[Fact]
public void P2pSwarmEngine_IngestPieceData_LengthMismatch_ReturnsFalse()
{
var engine = new P2pSwarmEngine();
var data = new byte[100];
var manifest = P2pSwarmEngine.CreateManifestFromBytes("sample.mp4", data, 50);
engine.InitializeSession(manifest);
// Piece 0 expects 50 bytes, passing 10 bytes must fail even if empty
bool ingested = engine.IngestPieceData(0, new byte[10]);
Assert.False(ingested);
}
// ==========================================
// 2. CredentialDataExchange Extreme Scenarios
// ==========================================
[Fact]
public void CredentialDataExchange_ExportEncryptedVault_NullRecords_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>(() =>
CredentialDataExchange.ExportEncryptedVault(null!, "MasterPass123!"));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void CredentialDataExchange_ExportEncryptedVault_NullOrWhitespacePassword_ThrowsArgumentException(string? badPass)
{
var records = new List<SiteCredentialRecord>
{
new() { Id = "1", Domain = "example.com", Username = "user", DecryptedPassword = "pw" }
};
Assert.Throws<ArgumentException>(() =>
CredentialDataExchange.ExportEncryptedVault(records, badPass!));
}
[Fact]
public void CredentialDataExchange_ImportEncryptedVault_DeclaredLengthExceedsAvailableData_ThrowsInvalidDataException()
{
// Valid header with huge declared ciphertext length
byte[] magic = System.Text.Encoding.ASCII.GetBytes("PACAVAULT_V1");
using var ms = new MemoryStream();
using var bw = new BinaryWriter(ms);
bw.Write(magic);
bw.Write(new byte[32]); // salt
bw.Write(new byte[12]); // nonce
bw.Write(new byte[16]); // tag
bw.Write(10_000_000); // claimed 10MB
bw.Write(new byte[10]); // but only 10 bytes present!
bw.Flush();
var malformedData = ms.ToArray();
Assert.Throws<InvalidDataException>(() =>
CredentialDataExchange.ImportEncryptedVault(malformedData, "MasterPass123!"));
}
[Fact]
public void CredentialDataExchange_ExportCsv_NullRecords_ReturnsHeaderOnly()
{
var csv = CredentialDataExchange.ExportCsv(null!);
Assert.NotNull(csv);
Assert.Contains("name,url,username,password", csv);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void CredentialDataExchange_ImportCsv_NullOrWhitespace_ReturnsEmptyList(string? badCsv)
{
var list = CredentialDataExchange.ImportCsv(badCsv!);
Assert.NotNull(list);
Assert.Empty(list);
}
[Fact]
public void CredentialDataExchange_ExportCsv_FormulaInjectionPrefixes_SanitizedSafely()
{
var maliciousRecord = new SiteCredentialRecord
{
Id = "evil",
SiteName = "=cmd|' /C calc'!A0",
Domain = "+1234567890",
Username = "@dangerousUser",
DecryptedPassword = "-unsafePassword",
Notes = "\tmaliciousTab"
};
var csv = CredentialDataExchange.ExportCsv(new[] { maliciousRecord });
Assert.NotNull(csv);
// Formula injection prefixes (=, +, -, @, tab) should be sanitized
var lines = csv.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
Assert.True(lines.Length >= 2);
string dataRow = lines[1];
Assert.DoesNotContain(",=cmd", dataRow);
Assert.DoesNotContain(",+123", dataRow);
Assert.DoesNotContain(",@dan", dataRow);
}
// ==========================================
// 3. CrdtVaultSyncEngine Extreme Scenarios
// ==========================================
[Fact]
public void CrdtVaultSyncEngine_Merge_NullRemote_ReturnsZeroWithoutThrowing()
{
var engine = new CrdtVaultSyncEngine<string>();
int applied = engine.Merge(null!);
Assert.Equal(0, applied);
}
[Fact]
public void CrdtVaultSyncEngine_Merge_NullOrEmptyIds_SkippedSafely()
{
var engine = new CrdtVaultSyncEngine<string>();
var items = new List<CrdtItemRecord<string>>
{
null!,
new() { Id = null!, Value = "A" },
new() { Id = "", Value = "B" },
new() { Id = "valid_1", Value = "C", LamportTimestamp = 1 }
};
int applied = engine.Merge(items);
Assert.Equal(1, applied);
Assert.Single(engine.GetActiveItems());
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void CrdtVaultSyncEngine_Set_NullOrEmptyId_ThrowsArgumentException(string? invalidId)
{
var engine = new CrdtVaultSyncEngine<string>();
Assert.Throws<ArgumentException>(() => engine.Set(invalidId!, "value"));
}
// ==========================================
// 4. E2eeRemoteBackupProvider Extreme Scenarios
// ==========================================
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void E2eeRemoteBackupProvider_EncryptPayload_NullOrEmptyPassword_ThrowsArgumentException(string? badPass)
{
Assert.Throws<ArgumentException>(() =>
E2eeRemoteBackupProvider.EncryptPayload("{}", badPass!));
}
[Fact]
public void E2eeRemoteBackupProvider_DecryptPayload_InvalidMagic_ThrowsInvalidDataException()
{
var bogusHeader = new VaultBackupHeader
{
Magic = "HACKERVAULT",
SaltBase64 = Convert.ToBase64String(new byte[16]),
NonceBase64 = Convert.ToBase64String(new byte[12]),
TagBase64 = Convert.ToBase64String(new byte[16]),
CiphertextBase64 = Convert.ToBase64String(new byte[32])
};
string json = JsonSerializer.Serialize(bogusHeader);
Assert.Throws<InvalidDataException>(() =>
E2eeRemoteBackupProvider.DecryptPayload(json, "MasterPassword123!"));
}
[Theory]
[InlineData("not-json")]
[InlineData("{\"Magic\":\"PACAVAULT\",\"SaltBase64\":\"%%%not-base64%%%\"}")]
public void E2eeRemoteBackupProvider_DecryptPayload_CorruptedBase64_ThrowsInvalidDataException(string corruptJson)
{
Assert.Throws<InvalidDataException>(() =>
E2eeRemoteBackupProvider.DecryptPayload(corruptJson, "MasterPassword123!"));
}
}

View file

@ -0,0 +1,63 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Paca.Core.Platform;
using Xunit;
namespace Paca.Tests.Core;
/// <summary>
/// Cycle 5: 극한 RED 시나리오 - 프로세스 실행 시 따옴표 감싸인 ExePath, Null 인자 방어 및 고속 취소 시 스트림 무결성 검증
/// </summary>
public class ProcessExecutionExtremeRedTests
{
[Fact]
public async Task Cycle5_Gate1_DesktopProcessRunner_QuotedExePath_MustBeUnquotedAndExecute()
{
// Windows 에서 UseShellExecute=false 일 때 ExePath 에 따옴표가 있으면 Win32Exception 발생
// DesktopProcessRunner 는 따옴표를 자동 정제하여 정상 실행해야 함
var runner = new DesktopProcessRunner();
var cmdExe = Environment.GetEnvironmentVariable("ComSpec") ?? "cmd.exe";
var quotedCmd = $"\"{cmdExe}\"";
var spec = new ProcessSpec(quotedCmd, new[] { "/c", "echo", "PACA_DESKTOP_PROCESS_RUNNER_OK" });
var result = await runner.RunAsync(spec, null, CancellationToken.None);
Assert.Equal(0, result.ExitCode);
Assert.Contains("PACA_DESKTOP_PROCESS_RUNNER_OK", result.Stdout);
}
[Fact]
public async Task Cycle5_Gate2_DesktopProcessRunner_NullArgsInSpec_MustNotThrowArgumentNullException()
{
var runner = new DesktopProcessRunner();
var cmdExe = Environment.GetEnvironmentVariable("ComSpec") ?? "cmd.exe";
// 동적 인자 빌더 등에서 전달될 수 있는 null 원소 방어
var args = new string[] { "/c", "echo", null!, "NULL_ARG_DEFENSE_OK" };
var spec = new ProcessSpec(cmdExe, args);
var result = await runner.RunAsync(spec, null, CancellationToken.None);
Assert.Equal(0, result.ExitCode);
Assert.Contains("NULL_ARG_DEFENSE_OK", result.Stdout);
}
[Fact]
public async Task Cycle5_Gate3_DesktopProcessRunner_FastCancellation_MustKillTreeWithoutDisposedStreamFault()
{
var runner = new DesktopProcessRunner();
var cmdExe = Environment.GetEnvironmentVariable("ComSpec") ?? "cmd.exe";
// 10초간 대기하는 자식 프로세스
var spec = new ProcessSpec(cmdExe, new[] { "/c", "ping", "127.0.0.1", "-n", "10" });
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(50));
// 고속 취소 발생 시 ObjectDisposedException 이나 UnobservedTaskException 없이 OperationCanceledException 으로 종료
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
{
await runner.RunAsync(spec, null, cts.Token);
});
}
}

View file

@ -0,0 +1,124 @@
using System;
using System.Text;
using System.Threading.Tasks;
using Paca.Core.QuickActions;
using Xunit;
namespace Paca.Tests.Core;
public class QuickActionsExtremeRedTests
{
// ==========================================
// 1. QuickActionFuzzyMatcher Memory & DoS
// ==========================================
[Fact]
public void QuickActionFuzzyMatcher_LevenshteinDistance_LargeStrings_DoesNotThrowOutOfMemory()
{
// 5000 character strings would allocate ~100MB in 2D array if unbounded
string large1 = new string('A', 3000);
string large2 = new string('B', 3000);
var ex = Record.Exception(() => QuickActionFuzzyMatcher.LevenshteinDistance(large1, large2));
Assert.Null(ex);
}
[Theory]
[InlineData(null, null)]
[InlineData(null, "abc")]
[InlineData("xyz", null)]
public void QuickActionFuzzyMatcher_LevenshteinDistance_Nulls_HandledGracefully(string? s, string? t)
{
var ex = Record.Exception(() => QuickActionFuzzyMatcher.LevenshteinDistance(s, t));
Assert.Null(ex);
}
// ==========================================
// 2. QuickActionInlineEvaluator Hardening
// ==========================================
[Theory]
[InlineData("-5 + 10", "5")]
[InlineData("10 * -2", "-20")]
[InlineData("-4 * -3", "12")]
public void QuickActionInlineEvaluator_TryEvaluateMath_NegativeNumbers_CalculatesCorrectly(string expr, string expected)
{
var eval = new QuickActionInlineEvaluator();
bool success = eval.TryEvaluate(expr, out var res);
Assert.True(success, $"Expression '{expr}' should be successfully evaluated");
Assert.NotNull(res);
Assert.Equal(expected, res.Value);
}
[Theory]
[InlineData("#fff", "rgb(255, 255, 255)")]
[InlineData("#F00", "rgb(255, 0, 0)")]
[InlineData("hex 000", "rgb(0, 0, 0)")]
public void QuickActionInlineEvaluator_TryEvaluateDev_Hex3DigitShorthand_ExpandsCorrectly(string hexInput, string expectedRgb)
{
var eval = new QuickActionInlineEvaluator();
bool success = eval.TryEvaluate(hexInput, out var res);
Assert.True(success, $"Shorthand hex '{hexInput}' should be parsed");
Assert.NotNull(res);
Assert.Equal(expectedRgb, res.Value);
}
[Fact]
public void QuickActionInlineEvaluator_TryEvaluateDev_Base64WithNullBytes_DoesNotReturnRawNullChars()
{
var eval = new QuickActionInlineEvaluator();
// Base64 containing null bytes: "\0\0\0" -> "AAAA"
bool success = eval.TryEvaluate("b64d AAAA", out var res);
Assert.True(success);
Assert.NotNull(res);
Assert.DoesNotContain('\0', res.Value);
}
// ==========================================
// 3. QuickActionClipboardDetector Security & Resilience
// ==========================================
[Fact]
public void QuickActionClipboardDetector_DetectIntent_EnormousClipboard_DoesNotSpikeOrHang()
{
var detector = new QuickActionClipboardDetector();
string hugeText = new string('x', 500_000); // 500KB
var intent = detector.DetectIntent(hugeText);
Assert.NotNull(intent);
Assert.Equal(ClipboardIntentType.None, intent.IntentType);
}
[Theory]
[InlineData("youtube.com/watch?v=dQw4w9WgXcQ")]
[InlineData("www.youtube.com/watch?v=dQw4w9WgXcQ")]
[InlineData("youtu.be/dQw4w9WgXcQ")]
public void QuickActionClipboardDetector_DetectIntent_MediaUrlWithoutScheme_DetectedAsMediaDownload(string rawUrl)
{
var detector = new QuickActionClipboardDetector();
var intent = detector.DetectIntent(rawUrl);
Assert.NotNull(intent);
Assert.Equal(ClipboardIntentType.MediaDownload, intent.IntentType);
}
// ==========================================
// 4. QuickActionParameterRouter Null Context Defenses
// ==========================================
[Theory]
[InlineData("b64 hello")]
[InlineData("b64d aGVsbG8=")]
[InlineData("hash password123")]
[InlineData("note sample note")]
public async Task QuickActionParameterRouter_Handlers_NullContext_DoesNotThrowNullReferenceException(string command)
{
var router = new QuickActionParameterRouter();
bool parsed = router.TryParseCommand(command, out var action);
Assert.True(parsed);
Assert.NotNull(action);
var ex = await Record.ExceptionAsync(async () => await action!.Handler!(null!));
Assert.Null(ex);
}
}

View file

@ -0,0 +1,119 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Paca.Core.Config;
using Paca.Core.Security;
using Xunit;
namespace Paca.Tests.Core;
public class SecurityAndConfigExtremeRedTests
{
private const string TestBase32Secret = "JBSWY3DPEHPK3PXP"; // "Hello!" in Base32
[Theory]
[InlineData(0)]
[InlineData(-10)]
[InlineData(-1)]
public void TotpEngine_ZeroOrNegativeTimeStep_DoesNotThrowDivideByZero(int timeStep)
{
var ex = Record.Exception(() =>
TotpEngine.GenerateCurrentCode(TestBase32Secret, timeStepSeconds: timeStep));
Assert.Null(ex);
}
[Theory]
[InlineData(0)]
[InlineData(-30)]
public void TotpEngine_GetRemainingSeconds_ZeroOrNegativeTimeStep_DoesNotThrowDivideByZero(int timeStep)
{
var ex = Record.Exception(() =>
TotpEngine.GetRemainingSeconds(timeStepSeconds: timeStep));
Assert.Null(ex);
var remaining = TotpEngine.GetRemainingSeconds(timeStepSeconds: timeStep);
Assert.True(remaining > 0);
}
[Theory]
[InlineData(0)]
[InlineData(-5)]
[InlineData(20)]
public void TotpEngine_InvalidDigits_ClampedToValidRange(int digits)
{
var code = TotpEngine.GenerateCurrentCode(TestBase32Secret, digits: digits);
Assert.True(code.Length >= 6 && code.Length <= 8);
}
[Fact]
public void CredentialSecurityAuditor_NullCredentials_ReturnsSafeReport()
{
var report = CredentialSecurityAuditor.PerformAudit(null!);
Assert.NotNull(report);
Assert.Equal(0, report.TotalAccounts);
Assert.Equal(100, report.OverallHealthScore);
}
[Fact]
public void CredentialSecurityAuditor_NegativeDays_ClampsToPositive()
{
var cred = new SiteCredentialRecord
{
Id = "1",
Domain = "example.com",
Username = "user",
DecryptedPassword = "SuperSecurePassword123!",
CreatedAt = DateTime.UtcNow,
LastModifiedAt = DateTime.UtcNow
};
var report = CredentialSecurityAuditor.PerformAudit(new[] { cred }, oldPasswordDays: -10);
Assert.Equal(0, report.OldPasswordCount);
}
[Fact]
public void CredentialSecurityAuditor_RecentlyModified_NotMarkedAsOldEvenIfCreatedLongAgo()
{
var cred = new SiteCredentialRecord
{
Id = "1",
Domain = "example.com",
Username = "user",
DecryptedPassword = "SuperSecurePassword123!",
CreatedAt = DateTime.UtcNow.AddDays(-200), // Created 200 days ago
LastModifiedAt = DateTime.UtcNow // But modified today!
};
var report = CredentialSecurityAuditor.PerformAudit(new[] { cred }, oldPasswordDays: 90);
Assert.Equal(0, report.OldPasswordCount);
Assert.DoesNotContain(report.Issues, i => i.IssueType == "OldPassword");
}
[Fact]
public void WorkspaceConfigSanitizer_ExcessiveWorkspaces_CappedTo20()
{
var many = Enumerable.Range(1, 100).Select(i => $"Workspace_{i}").ToList();
var sanitized = WorkspaceConfigSanitizer.Sanitize(many);
Assert.True(sanitized.Count <= 20);
}
[Fact]
public void WorkspaceConfigSanitizer_OverlyLongName_TruncatedSafely()
{
var longName = new string('A', 200);
var sanitized = WorkspaceConfigSanitizer.Sanitize(new[] { longName });
Assert.Single(sanitized);
Assert.True(sanitized[0].Length <= 30);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void PasswordSecurityEngine_EvaluateStrength_NullOrWhitespace_ReturnsZeroScore(string? emptyPass)
{
var result = PasswordSecurityEngine.EvaluateStrength(emptyPass!);
Assert.NotNull(result);
Assert.Equal(0, result.Score);
Assert.Equal(PasswordGrade.VeryWeak, result.Grade);
}
}

View file

@ -0,0 +1,96 @@
using System;
using System.IO;
using Paca.Browser.Content;
using Paca.Browser.Extensions;
using Paca.Core.Net;
using Xunit;
namespace Paca.Tests.Core;
public class SecurityAndRateLimitingExtremeRedTests
{
// ==========================================
// 1. ExtensionCspValidator
// ==========================================
[Fact]
public void ExtensionCspValidator_Validate_ManifestV3_UnsafeInlineInScriptSrc_ReturnsInvalid()
{
var result = ExtensionCspValidator.Validate("script-src 'self' 'unsafe-inline'; object-src 'self';", manifestVersion: 3);
Assert.False(result.IsValid);
Assert.NotEmpty(result.Violations);
Assert.Contains(result.Violations, v => v.Contains("unsafe-inline", StringComparison.OrdinalIgnoreCase));
}
[Theory]
[InlineData("script-src 'self' data:;")]
[InlineData("script-src 'self' blob:;")]
public void ExtensionCspValidator_Validate_ManifestV3_DataOrBlobInScriptSrc_ReturnsInvalid(string csp)
{
var result = ExtensionCspValidator.Validate(csp, manifestVersion: 3);
Assert.False(result.IsValid);
Assert.NotEmpty(result.Violations);
}
[Theory]
[InlineData(";; ; ;")]
[InlineData(" ; ; ")]
public void ExtensionCspValidator_Validate_EmptySemicolons_FallsBackToDefaultCsp(string csp)
{
var result = ExtensionCspValidator.Validate(csp, manifestVersion: 3);
Assert.True(result.IsValid);
Assert.Equal(ExtensionCspValidator.DefaultManifestV3Csp, result.NormalizedCsp);
}
// ==========================================
// 2. SiteAutoMutePolicy
// ==========================================
[Fact]
public void SiteAutoMutePolicy_AddAutoMutePattern_WildcardAsterisk_MutesAllSites()
{
var policy = new SiteAutoMutePolicy();
policy.AddAutoMutePattern("*");
Assert.True(policy.IsAutoMute("https://youtube.com/watch?v=123"));
Assert.True(policy.IsAutoMute("https://news.naver.com"));
Assert.True(policy.IsAutoMute("https://example.org"));
}
[Fact]
public void SiteAutoMutePolicy_Save_InvalidOrReadOnlyPath_DoesNotThrowUnhandledException()
{
// Using an invalid directory path that cannot be created on Windows
var policy = new SiteAutoMutePolicy("Z:\\nonexistent_drive_9999\\invalid\\mute.json");
var ex = Record.Exception(() => policy.AddAutoMutePattern("example.com"));
Assert.Null(ex);
}
// ==========================================
// 3. HostRateLimiter
// ==========================================
[Theory]
[InlineData("example.com:8080/api/v1", "example.com")]
[InlineData("https://sub.4cdn.org/image.png", "sub.4cdn.org")]
[InlineData("user:pass@reddit.com:443/r/funny", "reddit.com")]
[InlineData(null, "")]
[InlineData(" ", "")]
public void HostRateLimiter_NormalizeHost_VariousInputs_NormalizesCleanly(string? input, string expected)
{
var host = HostRateLimiter.NormalizeHost(input);
Assert.Equal(expected, host);
}
[Theory]
[InlineData("i.4cdn.org", 1200)]
[InlineData("boards.4channel.org", 1200)]
[InlineData("v.redd.it", 800)]
[InlineData("www.reddit.com", 800)]
[InlineData("generic-site.com", 200)]
public void HostRateLimiter_GetIntervalMs_RecognizesDomainPresets(string host, int expectedMs)
{
var interval = HostRateLimiter.GetIntervalMs(host);
Assert.Equal(expectedMs, interval);
}
}

View file

@ -0,0 +1,71 @@
using System;
using Paca.Core.Detection;
using Xunit;
namespace Paca.Tests.Core;
/// <summary>
/// Cycle 6: 극한 RED 시나리오 - 소셜 사이트 감지기의 상대 URI 크래시, 비-HTTP 스킴, 변칙/불완전 URL 방어선 검증
/// </summary>
public class SocialSiteDetectorExtremeRedTests
{
[Fact]
public void Cycle6_Gate1_RelativeUri_MustNotThrowInvalidOperationException()
{
// 브라우저 탭 주소나 페이지 내 상대 링크에서 상대 URI가 유입될 때
// url.Host 접근으로 인한 InvalidOperationException 크래시 방지
var relUri1 = new Uri("/watch?v=dQw4w9WgXcQ", UriKind.Relative);
var relUri2 = new Uri("shorts/dQw4w9WgXcQ", UriKind.Relative);
var relUri3 = new Uri("media.mp4", UriKind.Relative);
bool matched1 = SocialSiteDetector.TryMatch(relUri1, out _, out _);
Assert.False(matched1);
bool matched2 = SocialSiteDetector.TryMatch(relUri2, out _, out _);
Assert.False(matched2);
bool matched3 = SocialSiteDetector.TryMatch(relUri3, out _, out _);
Assert.False(matched3);
}
[Theory]
[InlineData("data:text/html,<html>test</html>")]
[InlineData("javascript:alert(1)")]
[InlineData("file:///C:/videos/test.mp4")]
[InlineData("about:blank")]
public void Cycle6_Gate2_NonHttpSchemes_MustReturnFalseSafely(string uriString)
{
var uri = new Uri(uriString);
bool matched = SocialSiteDetector.TryMatch(uri, out _, out _);
Assert.False(matched);
}
[Theory]
[InlineData("https://www.youtube.com/watch?v=short")]
[InlineData("https://www.youtube.com/watch?v=waytoolongid1234567890")]
[InlineData("https://www.youtube.com/watch?v=invalid@id!")]
[InlineData("https://www.youtube.com/watch?v=")]
[InlineData("https://www.youtube.com/watch")]
[InlineData("https://www.youtube.com/channel/UC123456")]
[InlineData("https://www.youtube.com/user/pewdiepie")]
public void Cycle6_Gate3_YouTubeIdBoundaryFuzzing_MustRejectInvalidUrls(string urlString)
{
var uri = new Uri(urlString);
bool matched = SocialSiteDetector.TryMatch(uri, out _, out _);
Assert.False(matched);
}
[Theory]
[InlineData("https://www.instagram.com/p/")]
[InlineData("https://www.instagram.com/reel/")]
[InlineData("https://www.tiktok.com/@someuser")]
[InlineData("https://www.tiktok.com/")]
[InlineData("https://www.facebook.com/watch/")]
[InlineData("https://www.facebook.com/reel/")]
public void Cycle6_Gate4_InstagramAndTikTok_MalformedPaths_MustReject(string urlString)
{
var uri = new Uri(urlString);
bool matched = SocialSiteDetector.TryMatch(uri, out _, out _);
Assert.False(matched);
}
}

View file

@ -0,0 +1,144 @@
using System;
using System.Collections.Generic;
using Paca.Core;
using Xunit;
namespace Paca.Tests.Core;
public class SpeedAndBitrateScheduleExtremeRedTests
{
private const string SampleInput = "C:\\Media\\input.mp4";
private const string SampleOutput = "C:\\Media\\output.mp4";
// ==========================================
// 1. MediaPlaybackSpeedPitchEngine
// ==========================================
[Fact]
public void MediaPlaybackSpeedPitchEngine_BuildCommandLine_SameInputAndOutput_ThrowsInvalidOperationException()
{
Assert.Throws<InvalidOperationException>(() =>
MediaPlaybackSpeedPitchEngine.BuildSpeedCommandLine(SampleInput, SampleInput, 1.5));
}
[Theory]
[InlineData(null, SampleOutput)]
[InlineData(SampleInput, null)]
[InlineData("", SampleOutput)]
[InlineData(SampleInput, " ")]
public void MediaPlaybackSpeedPitchEngine_BuildCommandLine_NullOrWhitespacePaths_ThrowsArgumentException(string? inPath, string? outPath)
{
Assert.Throws<ArgumentException>(() =>
MediaPlaybackSpeedPitchEngine.BuildSpeedCommandLine(inPath!, outPath!, 1.5));
}
[Fact]
public void MediaPlaybackSpeedPitchEngine_BuildCommandLine_PathsWithQuotes_EscapedOrStripped()
{
string maliciousIn = "C:\\Media\\in\"put.mp4";
string maliciousOut = "C:\\Media\\out\"put.mp4";
var cmd = MediaPlaybackSpeedPitchEngine.BuildSpeedCommandLine(maliciousIn, maliciousOut, 2.0);
Assert.NotNull(cmd);
Assert.DoesNotContain("in\"put", cmd);
Assert.DoesNotContain("out\"put", cmd);
}
// ==========================================
// 2. MediaBitrateEstimator
// ==========================================
[Fact]
public void MediaBitrateEstimator_BuildTwoPassCommands_SameInputAndOutput_ThrowsInvalidOperationException()
{
var plan = new BitrateEstimationResult(true, 2500, 128, 50_000_000);
Assert.Throws<InvalidOperationException>(() =>
MediaBitrateEstimator.BuildTwoPassCommands(SampleInput, SampleInput, plan));
}
[Theory]
[InlineData(null, SampleOutput)]
[InlineData(SampleInput, null)]
[InlineData("", SampleOutput)]
[InlineData(SampleInput, " ")]
public void MediaBitrateEstimator_BuildTwoPassCommands_NullOrWhitespacePaths_ThrowsArgumentException(string? inPath, string? outPath)
{
var plan = new BitrateEstimationResult(true, 2500, 128, 50_000_000);
Assert.Throws<ArgumentException>(() =>
MediaBitrateEstimator.BuildTwoPassCommands(inPath!, outPath!, plan));
}
[Fact]
public void MediaBitrateEstimator_BuildTwoPassCommands_PathsWithQuotes_EscapedOrStripped()
{
var plan = new BitrateEstimationResult(true, 2500, 128, 50_000_000);
string maliciousIn = "C:\\Media\\in\"put.mp4";
string maliciousOut = "C:\\Media\\out\"put.mp4";
var (p1, p2) = MediaBitrateEstimator.BuildTwoPassCommands(maliciousIn, maliciousOut, plan);
Assert.NotNull(p1);
Assert.NotNull(p2);
Assert.DoesNotContain("in\"put", p1);
Assert.DoesNotContain("out\"put", p2);
}
[Theory]
[InlineData(0)]
[InlineData(-1024)]
public void MediaBitrateEstimator_CalculateBitrate_ZeroOrNegativeTarget_ReturnsNotPossible(long badTarget)
{
var result = MediaBitrateEstimator.CalculateBitrate(60.0, badTarget);
Assert.NotNull(result);
Assert.False(result.IsPossible);
}
// ==========================================
// 3. BandwidthScheduleProfileEngine
// ==========================================
[Fact]
public void BandwidthScheduleProfileEngine_GetActiveProfile_NullConfig_ReturnsSafeDefault()
{
var profile = BandwidthScheduleProfileEngine.GetActiveProfile(DateTime.UtcNow, null!);
Assert.NotNull(profile);
Assert.Equal("Default", profile.ProfileName);
}
[Fact]
public void BandwidthScheduleProfileEngine_GetActiveProfile_ConfigWithNullRules_SkipsNullRulesSafely()
{
var config = new BandwidthScheduleConfig
{
IsEnabled = true,
DefaultProfile = new BandwidthProfile("Default", 0, 4),
Rules = new List<BandwidthTimeRule>
{
null!,
new(TimeSpan.FromHours(1), TimeSpan.FromHours(2), null!)
}
};
var ex = Record.Exception(() => BandwidthScheduleProfileEngine.GetActiveProfile(DateTime.UtcNow, config));
Assert.Null(ex);
}
// ==========================================
// 4. HlsManifestValidator
// ==========================================
[Fact]
public void HlsManifestValidator_Analyze_NullManifest_ReturnsSafeEmptyResult()
{
var result = HlsManifestValidator.Analyze(null!);
Assert.NotNull(result);
Assert.False(result.IsMasterPlaylist);
Assert.Equal(0, result.SegmentCount);
}
[Fact]
public void HlsManifestValidator_StripAdBlocks_NullManifest_ReturnsEmptyString()
{
var stripped = HlsManifestValidator.StripAdBlocks(null!);
Assert.NotNull(stripped);
}
}

View file

@ -0,0 +1,159 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Paca.Core;
using Paca.Core.Utils;
using Xunit;
namespace Paca.Tests.Core;
public class StreamingAndResourceGuardianExtremeRedTests
{
// ==========================================
// 1. TranscodeProfileManager & ThermalGuard
// ==========================================
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void TranscodeProfileManager_GetFfmpegProfileArgs_NullOrWhitespace_ReturnsCopySafeFallback(string? badProfile)
{
var manager = new TranscodeProfileManager();
var args = manager.GetFfmpegProfileArgs(badProfile!);
Assert.NotNull(args);
Assert.Equal("-c copy", args);
}
[Theory]
[InlineData(0)]
[InlineData(-5)]
public void ThermalThrottlingGuard_AdjustedSegments_ZeroOrNegativeRequested_ReturnsAtLeastOne(int badSegments)
{
var guard = new ThermalThrottlingGuard();
int adjusted = guard.GetAdjustedSegments(badSegments, 25.0); // Normal temp
Assert.True(adjusted >= 1, "Segments must be at least 1 even if requested was <= 0");
}
// ==========================================
// 2. RemoteStreamProxyBuffer Bound & Safety
// ==========================================
[Fact]
public void RemoteStreamProxyBuffer_NullData_DoesNotThrowNullReference()
{
var buffer = new RemoteStreamProxyBuffer(1024);
var ex = Record.Exception(() => buffer.Write(null!));
Assert.Null(ex);
Assert.Equal(0, buffer.BufferedBytes);
}
[Fact]
public void RemoteStreamProxyBuffer_ExceedingCapacity_EvictsOldestBytesToRespectCapacity()
{
const int capacity = 100;
var buffer = new RemoteStreamProxyBuffer(capacity);
// Write 60 bytes of 0x01
byte[] chunk1 = new byte[60];
Array.Fill(chunk1, (byte)1);
buffer.Write(chunk1);
Assert.Equal(60, buffer.BufferedBytes);
// Write 60 bytes of 0x02 (Total would be 120, exceeding capacity 100)
byte[] chunk2 = new byte[60];
Array.Fill(chunk2, (byte)2);
buffer.Write(chunk2);
// Capacity must NOT be exceeded
Assert.True(buffer.BufferedBytes <= capacity, $"Buffered bytes ({buffer.BufferedBytes}) must not exceed capacity ({capacity})");
// Reading remaining bytes should contain the newer bytes (0x02)
byte[] readTarget = new byte[capacity];
int readCount = buffer.Read(readTarget, 0, capacity);
Assert.Equal(buffer.CapacityBytes, readCount);
Assert.Equal(2, readTarget[readCount - 1]); // Last byte must be from chunk2
}
// ==========================================
// 3. DiskSpaceGuardian OS HResult & Unnesting
// ==========================================
[Fact]
public void DiskSpaceGuardian_IsDiskFullException_HResultCheck_ReturnsTrueEvenWithUnknownLanguageMessage()
{
// 0x80070070 = HRESULT for ERROR_DISK_FULL (112)
var foreignEx = new IOException("Espace disque insuffisant sur le lecteur C:", unchecked((int)0x80070070));
bool isFull = DiskSpaceGuardian.IsDiskFullException(foreignEx);
Assert.True(isFull, "Must detect disk full via HResult 0x80070070 regardless of OS language");
}
[Fact]
public void DiskSpaceGuardian_IsDiskFullException_WrappedInAggregateException_DetectsInnerDiskFull()
{
var inner = new IOException("There is not enough space on the disk.");
var aggregate = new AggregateException("Task failed", inner);
bool isFull = DiskSpaceGuardian.IsDiskFullException(aggregate);
Assert.True(isFull, "Must unnest AggregateException to find inner disk full exception");
}
[Theory]
[InlineData(0)]
[InlineData(-100)]
public void DiskSpaceGuardian_HasSufficientSpace_ZeroOrNegativeBytes_ReturnsTrue(long bytes)
{
bool result = DiskSpaceGuardian.HasSufficientSpace("C:\\fake\\path.mp4", bytes);
Assert.True(result);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void DiskSpaceGuardian_HasSufficientSpace_NullOrWhitespacePath_ReturnsTrue(string? badPath)
{
bool result = DiskSpaceGuardian.HasSufficientSpace(badPath!, 1024);
Assert.True(result);
}
// ==========================================
// 4. ThrottledStream & BufferPool Safety
// ==========================================
[Fact]
public void ThrottledStream_NullBaseStream_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>(() => new ThrottledStream(null!, 1024));
}
[Fact]
public void ThrottledStream_ZeroOrNegativeMaxBytes_DoesNotThrowAndReadsNormally()
{
byte[] source = new byte[] { 1, 2, 3, 4, 5 };
using var ms = new MemoryStream(source);
using var throttled = new ThrottledStream(ms, maxBytesPerSecond: 0); // Unthrottled
byte[] dest = new byte[5];
int read = throttled.Read(dest, 0, 5);
Assert.Equal(5, read);
Assert.Equal(source, dest);
}
[Fact]
public void BufferPoolManager_Return_NullArray_DoesNotThrow()
{
var ex = Record.Exception(() => BufferPoolManager.Return(null!));
Assert.Null(ex);
}
[Theory]
[InlineData(0)]
[InlineData(-100)]
public void AdaptiveChunkSizingEngine_NegativeOrZeroSpeed_ReturnsMinimum512KB(int speed)
{
var engine = new AdaptiveChunkSizingEngine();
int size = engine.CalculateOptimalChunkSize(speed);
Assert.Equal(512 * 1024, size);
}
}

View file

@ -0,0 +1,111 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Paca.Core;
using Paca.Core.Net;
using Xunit;
namespace Paca.Tests.Core;
public class SubtitleAndNetworkResilienceExtremeRedTests
{
[Fact]
public void SubtitleBurnInEngine_InPlaceOverwrite_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() =>
SubtitleBurnInEngine.BuildBurnInCommandLine("movie.mp4", "sub.srt", "movie.mp4"));
Assert.Contains("identical", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Theory]
[InlineData(null, "sub.srt", "out.mp4")]
[InlineData("", "sub.srt", "out.mp4")]
[InlineData("in.mp4", null, "out.mp4")]
[InlineData("in.mp4", "", "out.mp4")]
[InlineData("in.mp4", "sub.srt", null)]
[InlineData("in.mp4", "sub.srt", "")]
public void SubtitleBurnInEngine_NullOrEmptyPaths_ThrowsArgumentException(string? inVid, string? sub, string? outVid)
{
Assert.Throws<ArgumentException>(() =>
SubtitleBurnInEngine.BuildBurnInCommandLine(inVid!, sub!, outVid!));
}
[Fact]
public void SubtitleBurnInEngine_EscapePath_EscapesSquareBrackets()
{
var path = @"C:\Downloads\[SubsPlease] Video [1080p].srt";
var escaped = SubtitleBurnInEngine.EscapeSubtitlePathForFfmpeg(path);
Assert.Contains(@"\[", escaped);
Assert.Contains(@"\]", escaped);
}
[Fact]
public void SubtitleBurnInEngine_FontNameInjection_Sanitized()
{
var style = new SubtitleBurnInStyle
{
FontName = "Arial,Bold;drop=all",
FontSize = 24
};
var forceStyle = SubtitleBurnInEngine.BuildForceStyleString(style);
Assert.DoesNotContain("Arial,Bold", forceStyle);
Assert.DoesNotContain(";", forceStyle);
}
[Theory]
[InlineData(0)]
[InlineData(-5)]
public void SubtitleBurnInEngine_InvalidFontSize_ThrowsArgumentOutOfRangeException(int invalidSize)
{
var style = new SubtitleBurnInStyle { FontSize = invalidSize };
Assert.Throws<ArgumentOutOfRangeException>(() =>
SubtitleBurnInEngine.BuildForceStyleString(style));
}
[Theory]
[InlineData("https://i.4cdn.org/wsg/1234567.webm", 1200)]
[InlineData("http://is2.4chan.org/pol/src/123.jpg", 1200)]
[InlineData("i.4cdn.org:443", 1200)]
[InlineData("https://v.redd.it/xyz987/DASH_720.mp4", 800)]
[InlineData("reddit.com:80", 800)]
[InlineData("https://example.com/video.mp4", 200)]
public void HostRateLimiter_FullUrlAndPort_NormalizesAndRecognizesDomain(string input, int expectedInterval)
{
var interval = HostRateLimiter.GetDefaultIntervalMs(input);
Assert.Equal(expectedInterval, interval);
}
[Fact]
public async Task MultiCdnMeshDownloader_NullMirrorList_ReturnsFailureGracefully()
{
var downloader = new MultiCdnMeshDownloader();
var result = await downloader.RaceDownloadSegmentAsync(null!);
Assert.False(result.IsSuccess);
}
[Fact]
public async Task MultiCdnMeshDownloader_RelativeUri_DoesNotThrowInvalidOperationException()
{
var downloader = new MultiCdnMeshDownloader();
var mirrors = new List<Uri> { new("/relative/path/seg0.ts", UriKind.Relative) };
var ex = await Record.ExceptionAsync(() => downloader.RaceDownloadSegmentAsync(mirrors));
Assert.Null(ex);
}
[Fact]
public async Task MultiCdnMeshDownloader_NegativeStagger_DoesNotThrow()
{
var downloader = new MultiCdnMeshDownloader();
var mirrors = new List<Uri>
{
new("https://cdn1.example.com/seg0.ts"),
new("https://cdn2.example.com/seg0.ts")
};
var ex = await Record.ExceptionAsync(() => downloader.RaceDownloadSegmentAsync(mirrors, staggerDelayMs: -50));
Assert.Null(ex);
}
}

View file

@ -0,0 +1,167 @@
using System;
using Paca.Core;
using Xunit;
namespace Paca.Tests.Core;
public class VideoEnginesExtremeRedTests
{
private const string SampleInput = "C:\\Videos\\input.mp4";
private const string SampleOutput = "C:\\Videos\\output.mp4";
// ==========================================
// 1. VideoAspectAutoCropper
// ==========================================
[Fact]
public void VideoAspectAutoCropper_BuildCommandLine_SameInputAndOutput_ThrowsInvalidOperationException()
{
Assert.Throws<InvalidOperationException>(() =>
VideoAspectAutoCropper.BuildReframingCommandLine(SampleInput, SampleInput, ReframingMode.CenterCrop));
}
[Theory]
[InlineData(null, SampleOutput)]
[InlineData(SampleInput, null)]
[InlineData("", SampleOutput)]
[InlineData(SampleInput, " ")]
public void VideoAspectAutoCropper_BuildCommandLine_NullOrWhitespace_ThrowsArgumentException(string? inPath, string? outPath)
{
Assert.Throws<ArgumentException>(() =>
VideoAspectAutoCropper.BuildReframingCommandLine(inPath!, outPath!, ReframingMode.CenterCrop));
}
// ==========================================
// 2. VideoIntroOutroTrimmer
// ==========================================
[Fact]
public void VideoIntroOutroTrimmer_BuildCommandLine_SameInputAndOutput_ThrowsInvalidOperationException()
{
Assert.Throws<InvalidOperationException>(() =>
VideoIntroOutroTrimmer.BuildLosslessTrimCommandLine(SampleInput, SampleInput, 0, 10));
}
[Theory]
[InlineData(null, SampleOutput)]
[InlineData(SampleInput, null)]
public void VideoIntroOutroTrimmer_BuildCommandLine_NullOrWhitespace_ThrowsArgumentException(string? inPath, string? outPath)
{
Assert.Throws<ArgumentException>(() =>
VideoIntroOutroTrimmer.BuildLosslessTrimCommandLine(inPath!, outPath!, 0, 10));
}
[Fact]
public void VideoIntroOutroTrimmer_BuildCommandLine_NegativeStartOrDuration_ClampsToSafeBounds()
{
var cmd = VideoIntroOutroTrimmer.BuildLosslessTrimCommandLine(SampleInput, SampleOutput, -10.5, -5.0);
Assert.NotNull(cmd);
Assert.Contains("-ss 0.00", cmd);
Assert.DoesNotContain("-t -", cmd);
}
// ==========================================
// 3. VideoPosterExtractor
// ==========================================
[Fact]
public void VideoPosterExtractor_BuildCommandLine_SameInputAndOutput_ThrowsInvalidOperationException()
{
var spec = new PosterExtractionSpec(5.0, 640, 360, PosterFormat.Jpeg);
Assert.Throws<InvalidOperationException>(() =>
VideoPosterExtractor.BuildPosterCommandLine(SampleInput, SampleInput, spec));
}
[Theory]
[InlineData(null, SampleOutput)]
[InlineData(SampleInput, null)]
public void VideoPosterExtractor_BuildCommandLine_NullOrWhitespace_ThrowsArgumentException(string? inPath, string? outPath)
{
var spec = new PosterExtractionSpec(5.0, 640, 360, PosterFormat.Jpeg);
Assert.Throws<ArgumentException>(() =>
VideoPosterExtractor.BuildPosterCommandLine(inPath!, outPath!, spec));
}
// ==========================================
// 4. VideoSpriteSheetGenerator
// ==========================================
[Fact]
public void VideoSpriteSheetGenerator_BuildCommandLine_SameInputAndOutput_ThrowsInvalidOperationException()
{
var config = new SpriteSheetConfig(5, 5, 160, 90, 10);
Assert.Throws<InvalidOperationException>(() =>
VideoSpriteSheetGenerator.BuildFfmpegSpriteCommandLine(SampleInput, SampleInput, config));
}
[Theory]
[InlineData(null, SampleOutput)]
[InlineData(SampleInput, null)]
public void VideoSpriteSheetGenerator_BuildCommandLine_NullOrWhitespace_ThrowsArgumentException(string? inPath, string? outPath)
{
var config = new SpriteSheetConfig(5, 5, 160, 90, 10);
Assert.Throws<ArgumentException>(() =>
VideoSpriteSheetGenerator.BuildFfmpegSpriteCommandLine(inPath!, outPath!, config));
}
// ==========================================
// 5. VideoFrameRateConverter
// ==========================================
[Fact]
public void VideoFrameRateConverter_BuildFfmpegArgs_SameInputAndOutput_ThrowsInvalidOperationException()
{
Assert.Throws<InvalidOperationException>(() =>
VideoFrameRateConverter.BuildFfmpegArgs(SampleInput, SampleInput, 60, FrameRateConversionMode.Simple));
}
[Theory]
[InlineData(null, SampleOutput)]
[InlineData(SampleInput, null)]
public void VideoFrameRateConverter_BuildFfmpegArgs_NullOrWhitespace_ThrowsArgumentException(string? inPath, string? outPath)
{
Assert.Throws<ArgumentException>(() =>
VideoFrameRateConverter.BuildFfmpegArgs(inPath!, outPath!, 60, FrameRateConversionMode.Simple));
}
[Fact]
public void VideoFrameRateConverter_BuildFfmpegArgs_ExtremeFpsAndCrf_ClampedToValidRange()
{
var cmd = VideoFrameRateConverter.BuildFfmpegArgs(SampleInput, SampleOutput, 1000, FrameRateConversionMode.Simple, crf: 99);
Assert.NotNull(cmd);
Assert.Contains("fps=240", cmd);
Assert.Contains("-crf 51", cmd);
}
// ==========================================
// 6. VideoDenoiseEnhancer
// ==========================================
[Fact]
public void VideoDenoiseEnhancer_BuildCommandLine_SameInputAndOutput_ThrowsInvalidOperationException()
{
Assert.Throws<InvalidOperationException>(() =>
VideoDenoiseEnhancer.BuildDenoiseCommandLine(SampleInput, SampleInput, true, true));
}
[Theory]
[InlineData(null, SampleOutput)]
[InlineData(SampleInput, null)]
public void VideoDenoiseEnhancer_BuildCommandLine_NullOrWhitespace_ThrowsArgumentException(string? inPath, string? outPath)
{
Assert.Throws<ArgumentException>(() =>
VideoDenoiseEnhancer.BuildDenoiseCommandLine(inPath!, outPath!, true, true));
}
[Fact]
public void VideoEngines_PathsWithQuotes_EscapedOrStrippedSafely()
{
string maliciousIn = "C:\\test\\in\"jection.mp4";
string maliciousOut = "C:\\test\\out\"jection.mp4";
var cmd = VideoAspectAutoCropper.BuildReframingCommandLine(maliciousIn, maliciousOut, ReframingMode.CenterCrop);
Assert.NotNull(cmd);
Assert.DoesNotContain("in\"jection", cmd);
Assert.DoesNotContain("out\"jection", cmd);
}
}