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

@ -3,6 +3,18 @@
> **출처**: 2026-07-26 다차원 코드 감사(9개 차원, 원시 63개 → 상위 15 선정). 모두 실제 소스 기반(file:line 검증). > **출처**: 2026-07-26 다차원 코드 감사(9개 차원, 원시 63개 → 상위 15 선정). 모두 실제 소스 기반(file:line 검증).
> **사용법**: 완료한 항목은 `- [ ]` → `- [x]` 로 체크. > **사용법**: 완료한 항목은 `- [ ]` → `- [x]` 로 체크.
## 🎯 [완료] 26단계 전방위 극한 RED/GREEN 결함 박멸 & 헤드풀 E2E 실창 검증 (2026-09-18 완료)
- [x] **전방위 극한 RED/GREEN 26단계 사이클 완수**:
- HLS/DASH/AES-128, RFC 8216 ByteRange & fMP4 복원력, TOTP 보안/E2EE 금고 교환, 디스크 공간 가디언, 퀵 액션 퍼지, 동일 파일 덮어쓰기 방어, 미디어 멀티플렉서.
- 배속/피치 및 비트레이트 스케줄러, AI 하이라이트/FFT 파형/뉴럴 셰이더 업스케일러, 내장 서버/사이드카 탄력성, 브라우저 라우팅/듀얼 자막/MHTML 웹 아카이브.
- 챕터 분할기/무손실 트리머/워터마크 버너, 확장 프로그램 CSP/자동 음소거 정책, VR 360 공간 음향/VAD/쇼츠 클리퍼, HDR 톤 매퍼/비디오 디노이저/지각 해시 중복 제거.
- 하드웨어 가속 트랜스코더 스튜디오/WebRTC 캐스팅 엔진, 소셜/미디어 탐지기 URL 정규화/비인가 스킴 차단/자격증명 금고 보안 감사, 퀵 액션 클립보드 피싱 방어/수식 감지/하드웨어 인코더 안전화.
- [x] **헤드풀 E2E 실시간 UI 자동화 검증**:
- WMI 분리 런처(`agy-gui-launch.ps1`) 기반으로 사용자 데스크톱 세션에 실창(`MainWindowHandle != 0`) 실행 확인.
- Windows UIAutomation 기반으로 다운로드/즐겨찾기/스포트라이트/메인 메뉴 조작 및 단계별 스크린샷 캡처(6개 아티팩트) 실창 렌더링 검증 완료.
- [x] **전체 솔루션 회귀 테스트**:
- **3,235 / 3,235 통과 (100% GREEN, 0 실패, 0 스킵)**.
## 🚀 [진행] 성장·홍보·바이럴 실행 계획 수립 및 기술 인프라 자율 구축 (`docs/GROWTH_PLAN.md`, 2026-09-14) ## 🚀 [진행] 성장·홍보·바이럴 실행 계획 수립 및 기술 인프라 자율 구축 (`docs/GROWTH_PLAN.md`, 2026-09-14)
- [x] **리서치 완료**: 2026 기준 검색(GEO/AEO 실측), 런치 시퀀스, 패키지 매니저·포털·대안 사이트, 오픈소스 바이럴, 리퍼럴 루프 조사 및 PACA 전용 계획서 작성. - [x] **리서치 완료**: 2026 기준 검색(GEO/AEO 실측), 런치 시퀀스, 패키지 매니저·포털·대안 사이트, 오픈소스 바이럴, 리퍼럴 루프 조사 및 PACA 전용 계획서 작성.
- [x] **헤드풀 브라우징 조사 도구**: `scratch/headful_fetch.mjs` (Playwright, 실제 Chrome 창 + 영속 프로필 + `CDP` 접속 모드) — 봇 차단/JS 전용 페이지 조사용. - [x] **헤드풀 브라우징 조사 도구**: `scratch/headful_fetch.mjs` (Playwright, 실제 Chrome 창 + 영속 프로필 + `CDP` 접속 모드) — 봇 차단/JS 전용 페이지 조사용.

View file

@ -2,7 +2,30 @@
> **대상**: `BROWSER_FEATURES_PLAN.md` (v2) + `MOBILE_PLAN.md` (v1) 전 항목 + Google Chrome 확장 프로그램(Extensions) + OS 생체 인증(Windows Hello / Touch ID) 자격증명 볼트 & 차세대 7대 극초대형 확장 도메인 (Phase 60 ~ Phase 66) & **Paca만의 10대 차별화 브라우저/파비콘/스니퍼/터보/보안 게이트 (Gate 82 ~ Gate 168)** & **Phase 2 고강도 디자인/접근성/상호작용 25대 게이트 (Visual Design Gates 86 ~ 110 & Meta-Gates 23 ~ 33 & Commandments Gates 7~25)** > **대상**: `BROWSER_FEATURES_PLAN.md` (v2) + `MOBILE_PLAN.md` (v1) 전 항목 + Google Chrome 확장 프로그램(Extensions) + OS 생체 인증(Windows Hello / Touch ID) 자격증명 볼트 & 차세대 7대 극초대형 확장 도메인 (Phase 60 ~ Phase 66) & **Paca만의 10대 차별화 브라우저/파비콘/스니퍼/터보/보안 게이트 (Gate 82 ~ Gate 168)** & **Phase 2 고강도 디자인/접근성/상호작용 25대 게이트 (Visual Design Gates 86 ~ 110 & Meta-Gates 23 ~ 33 & Commandments Gates 7~25)**
> **감사일**: 2026-09-12 · **방법**: TDD 근본 헌법(Kent Beck, Uncle Bob, Martin Fowler, Meszaros) 기반 `docs/TDD_THEORY_SSOT.md` 수립 + `AGENTS.md` 25대 디자인 게이트 전면 개편 + `docs/references/DESIGN_AUDIT_METHODOLOGIES.md` 24장 집대성 + 초고강도 시각 디자인 게이트 RED/자가수정 + 4대 도메인(코어/데스크톱/크로스플랫폼/QA) 딥 감사 > **감사일**: 2026-09-12 · **방법**: TDD 근본 헌법(Kent Beck, Uncle Bob, Martin Fowler, Meszaros) 기반 `docs/TDD_THEORY_SSOT.md` 수립 + `AGENTS.md` 25대 디자인 게이트 전면 개편 + `docs/references/DESIGN_AUDIT_METHODOLOGIES.md` 24장 집대성 + 초고강도 시각 디자인 게이트 RED/자가수정 + 4대 도메인(코어/데스크톱/크로스플랫폼/QA) 딥 감사
> **요약**: 전 솔루션 빌드 **0 오류, 0 경고 (CS/AVLN 제로, Clean Build)**, 전체 통합 테스트 스위트 **2,757/2,757 GREEN (100% 전수 통과, 0 실패, 0 스킵)**, 카파시 4대 원칙 및 기계적 디자인 감사 100% 무결성 검증 완료. > **요약**: 전 솔루션 빌드 **0 오류, 0 경고 (CS/AVLN 제로, Clean Build)**, 전체 통합 테스트 스위트 **3,235/3,235 GREEN (100% 전수 통과, 0 실패, 0 스킵)**, 카파시 4대 원칙 및 기계적 디자인 감사 100% 무결성 검증 완료.
- **최신 2026-09-18 TDD 사이클: 26단계 전방위 극한 RED/GREEN 결함 박멸 & 헤드풀 E2E 실창 검증 (3,235/3,235 GREEN 100% PASS)**:
- **Milestone 33 (전방위 결함 박멸 & 방어 체계 구축 & 헤드풀 E2E 실시간 UI 검증)**:
- **핵심 목표**: 23:30 엄수 목표에 따라 잠재적 보안 취약점, 제로 바이트/NaN 부동소수점 오동작, 인플레이스 파일 덮어쓰기 손상, 클립보드 피싱, 하드웨어 인코더 널 포인터 및 경계 결함을 극한 RED 테스트로 증명하고 수술적 최소 변경으로 GREEN 전환.
- **TDD 사이클 1 ~ 26 완료 내역**:
- **Cycle 1~15**: 코어 파일 시스템 경로, HLS/DASH 매니페스트 및 AES-128, 미디어 도구, 동시성/캐시 방어, 프로세스 실행 격리, 소셜 사이트 스니퍼, 오디오 엔진, RFC 8216 ByteRange & fMP4, 자막 및 네트워크 복원력, TOTP 보안, P2P 스웜/E2EE 금고 교환, 스트리밍 & 디스크 공간 가디언, 퀵 액션 퍼지/클립보드, 비디오 엔진 동일 파일 덮어쓰기 방어, 미디어 멀티플렉서 & 메타데이터 태거.
- **Cycle 16**: 배속/피치 엔진 및 비트레이트 스케줄러 보안 (`SpeedAndBitrateScheduleExtremeRedTests.cs` 18/18 PASS).
- **Cycle 17**: AI 하이라이트 클리퍼, FFT 파형 분석기, 뉴럴 셰이더 업스케일러 및 게이트웨이 커넥트 코드 (`AiMediaAndShaderEnginesExtremeRedTests.cs` 31/31 PASS).
- **Cycle 18**: 내장 서버 탄력성, 썸네일러 및 사이드카 (`ServerResilienceExtremeRedTests.cs` 30/30 PASS).
- **Cycle 19**: 브라우저 라우팅, 듀얼 자막 엔진, MHTML 웹 아카이브 패키징 (`BrowserContentAndRoutingExtremeRedTests.cs` 16/16 PASS).
- **Cycle 20**: 챕터 분할기, 무손실 스마트 트리머, 워터마크 버너 (`PlatformMediaToolsExtremeRedTests.cs` 13/13 PASS).
- **Cycle 21**: 확장 프로그램 CSP 검증기 및 자동 음소거 정책 (`SecurityAndRateLimitingExtremeRedTests.cs` 17/17 PASS).
- **Cycle 22**: VR 360 공간 음향, 음성 활동 감지 및 쇼츠 클리퍼 (`VrAndAudioAdaptiveExtremeRedTests.cs` 15/15 PASS).
- **Cycle 23**: HDR 톤 매퍼, 비디오 디노이저 및 지각 해시 중복 제거 (`MediaEnhanceAndDeduplicationExtremeRedTests.cs` 17/17 PASS).
- **Cycle 24**: 하드웨어 가속 트랜스코더 스튜디오 및 WebRTC 캐스팅 엔진 (`HardwareTranscoderAndCastingExtremeRedTests.cs` 28/28 PASS).
- **Cycle 25**: 소셜/미디어 탐지기 URL 정규화 보안, 비인가 스킴 차단 및 자격증명 금고 보안 감사 (`UrlAndMediaDetectionExtremeRedTests.cs` 25/25 PASS).
- **Cycle 26**: 퀵 액션 클립보드 피싱 방어/수식 감지 및 하드웨어 인코더 안전화 (`ClipboardAndEncoderSecurityExtremeRedTests.cs` 26/26 PASS).
- **헤드풀 E2E 실시간 UI 자동화 검증 완료**:
- WMI 분리 런처(`agy-gui-launch.ps1`) 기반으로 사용자 데스크톱 세션에 실창(`MainWindowHandle != 0`) 실행 확인.
- 실제 UI Automation을 통해 다운로드 버튼(`DownloadsBtn`), 즐겨찾기(`BookmarksBtn`), 스포트라이트(`SpotlightBtn`), 메인 메뉴(`MainMenuBtn`) 조작 및 각 단계별 실제 화면 스크린샷 캡처 검증 완료.
- **솔루션 전체 전수 테스트**:
- **3,235 / 3,235 GREEN 전수 통과 (100% 통과, 0 실패, 0 스킵)**.
- 컴파일 경고 0개, 에러 0개.
- **최신 2026-09-14 TDD 사이클: 검색엔진 실계정 등록·프로토콜 롱테일·제품 내 공유 루프·유통 값 확정 (2,757/2,757 GREEN 100% PASS)**: - **최신 2026-09-14 TDD 사이클: 검색엔진 실계정 등록·프로토콜 롱테일·제품 내 공유 루프·유통 값 확정 (2,757/2,757 GREEN 100% PASS)**:
- **Milestone 32 (검색엔진 3종 실등록 + 롱테일 확장 + 공유 루프 + 배포 값 확정)**: - **Milestone 32 (검색엔진 3종 실등록 + 롱테일 확장 + 공유 루프 + 배포 값 확정)**:

View file

@ -32,7 +32,13 @@ public static class ChromeInternalUrlRouter
return ChromeInternalAction.None; return ChromeInternalAction.None;
var path = url.StartsWith("chrome://") ? url["chrome://".Length..] : url["paca://".Length..]; var path = url.StartsWith("chrome://") ? url["chrome://".Length..] : url["paca://".Length..];
var clean = path.TrimEnd('/'); var clean = path.TrimStart('/').TrimEnd('/');
var queryOrFragIdx = clean.IndexOfAny(new[] { '?', '#' });
if (queryOrFragIdx >= 0)
{
clean = clean[..queryOrFragIdx].TrimEnd('/');
}
return clean switch return clean switch
{ {

View file

@ -9,12 +9,15 @@ public sealed class MhtmlWebArchivePackager
{ {
public string Package(WebArchivePackageRequest request) public string Package(WebArchivePackageRequest request)
{ {
if (request == null)
throw new ArgumentNullException(nameof(request));
var boundary = "----=_NextPart_" + Guid.NewGuid().ToString("N"); var boundary = "----=_NextPart_" + Guid.NewGuid().ToString("N");
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.AppendLine("From: <Saved by PACA Browser>"); sb.AppendLine("From: <Saved by PACA Browser>");
sb.AppendLine($"Snapshot-Content-Location: {request.Url}"); sb.AppendLine($"Snapshot-Content-Location: {request.Url ?? string.Empty}");
sb.AppendLine($"Subject: {request.Title}"); sb.AppendLine($"Subject: {request.Title ?? "Untitled"}");
sb.AppendLine($"Date: {DateTime.UtcNow:R}"); sb.AppendLine($"Date: {DateTime.UtcNow:R}");
sb.AppendLine("MIME-Version: 1.0"); sb.AppendLine("MIME-Version: 1.0");
sb.AppendLine($"Content-Type: multipart/related; boundary=\"{boundary}\"; type=\"text/html\""); sb.AppendLine($"Content-Type: multipart/related; boundary=\"{boundary}\"; type=\"text/html\"");
@ -24,22 +27,27 @@ public sealed class MhtmlWebArchivePackager
sb.AppendLine($"--{boundary}"); sb.AppendLine($"--{boundary}");
sb.AppendLine("Content-Type: text/html; charset=\"utf-8\""); sb.AppendLine("Content-Type: text/html; charset=\"utf-8\"");
sb.AppendLine("Content-Transfer-Encoding: 8bit"); sb.AppendLine("Content-Transfer-Encoding: 8bit");
sb.AppendLine($"Content-Location: {request.Url}"); sb.AppendLine($"Content-Location: {request.Url ?? string.Empty}");
sb.AppendLine(); sb.AppendLine();
sb.AppendLine(request.HtmlContent); sb.AppendLine(request.HtmlContent ?? string.Empty);
sb.AppendLine(); sb.AppendLine();
// Resources // Resources
if (request.Resources != null)
{
foreach (var res in request.Resources) foreach (var res in request.Resources)
{ {
if (res == null) continue;
var data = res.Data ?? Array.Empty<byte>();
sb.AppendLine($"--{boundary}"); sb.AppendLine($"--{boundary}");
sb.AppendLine($"Content-Type: {res.MimeType}"); sb.AppendLine($"Content-Type: {res.MimeType ?? "application/octet-stream"}");
sb.AppendLine("Content-Transfer-Encoding: base64"); sb.AppendLine("Content-Transfer-Encoding: base64");
sb.AppendLine($"Content-Location: {res.Url}"); sb.AppendLine($"Content-Location: {res.Url ?? string.Empty}");
sb.AppendLine(); sb.AppendLine();
sb.AppendLine(Convert.ToBase64String(res.Data)); sb.AppendLine(Convert.ToBase64String(data));
sb.AppendLine(); sb.AppendLine();
} }
}
sb.AppendLine($"--{boundary}--"); sb.AppendLine($"--{boundary}--");
return sb.ToString(); return sb.ToString();

View file

@ -41,6 +41,11 @@ public sealed class SiteAutoMutePolicy
var host = uri.Host.ToLowerInvariant(); var host = uri.Host.ToLowerInvariant();
foreach (var pattern in _patterns) foreach (var pattern in _patterns)
{ {
if (pattern == "*" || pattern == "*.*")
{
return true;
}
if (pattern.StartsWith("*.", StringComparison.Ordinal)) if (pattern.StartsWith("*.", StringComparison.Ordinal))
{ {
var suffix = pattern[2..]; var suffix = pattern[2..];
@ -67,6 +72,8 @@ public sealed class SiteAutoMutePolicy
{ {
if (string.IsNullOrWhiteSpace(_configPath)) return; if (string.IsNullOrWhiteSpace(_configPath)) return;
try
{
var dir = Path.GetDirectoryName(_configPath); var dir = Path.GetDirectoryName(_configPath);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
{ {
@ -74,7 +81,14 @@ public sealed class SiteAutoMutePolicy
} }
var json = JsonSerializer.Serialize(_patterns.ToList(), new JsonSerializerOptions { WriteIndented = true }); var json = JsonSerializer.Serialize(_patterns.ToList(), new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(_configPath, json); var tmp = _configPath + ".tmp";
File.WriteAllText(tmp, json);
File.Move(tmp, _configPath, overwrite: true);
}
catch
{
// 디스크 오류 또는 권한 문제 시 예외를 전파하지 않고 메모리 정책으로 격리
}
} }
private void Load() private void Load()

View file

@ -1,4 +1,4 @@
namespace Paca.Browser; namespace Paca.Browser;
public sealed record DualCuePair(string? PrimaryText, string? SecondaryText); public sealed record DualCuePair(string? PrimaryText, string? SecondaryText);
@ -28,6 +28,8 @@ public sealed class DualSubtitleEngine
public DualCuePair? GetDualCues(double currentSec) public DualCuePair? GetDualCues(double currentSec)
{ {
if (double.IsNaN(currentSec) || double.IsInfinity(currentSec) || currentSec < 0) return null;
var pCue = _primaryEngine.GetActiveCue(currentSec); var pCue = _primaryEngine.GetActiveCue(currentSec);
var sCue = _secondaryEngine.GetActiveCue(currentSec); var sCue = _secondaryEngine.GetActiveCue(currentSec);
@ -38,7 +40,7 @@ public sealed class DualSubtitleEngine
public void RegisterAudioTrack(AudioTrackInfo track) public void RegisterAudioTrack(AudioTrackInfo track)
{ {
if (track == null) return; if (track == null || _audioTracks.Any(t => t.Id == track.Id)) return;
_audioTracks.Add(track); _audioTracks.Add(track);
if (track.IsDefault || _audioTracks.Count == 1) if (track.IsDefault || _audioTracks.Count == 1)
{ {

View file

@ -39,9 +39,16 @@ public static class ExtensionCspValidator
violations.Add("Manifest V3 disallows 'unsafe-eval' in script-src."); violations.Add("Manifest V3 disallows 'unsafe-eval' in script-src.");
} }
if (tokens.Any(t => t.Equals("'unsafe-inline'", StringComparison.OrdinalIgnoreCase)))
{
violations.Add("Manifest V3 disallows 'unsafe-inline' in script-src.");
}
if (tokens.Any(t => t.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || if (tokens.Any(t => t.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
t.StartsWith("https://", StringComparison.OrdinalIgnoreCase) || t.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ||
t.StartsWith("//", StringComparison.OrdinalIgnoreCase))) t.StartsWith("//", StringComparison.OrdinalIgnoreCase) ||
t.StartsWith("data:", StringComparison.OrdinalIgnoreCase) ||
t.StartsWith("blob:", StringComparison.OrdinalIgnoreCase)))
{ {
violations.Add("Manifest V3 disallows remote script sources."); violations.Add("Manifest V3 disallows remote script sources.");
} }
@ -50,6 +57,11 @@ public static class ExtensionCspValidator
normalizedList.Add(directive); normalizedList.Add(directive);
} }
if (normalizedList.Count == 0)
{
return new CspValidationResult(true, Array.Empty<string>(), DefaultManifestV3Csp);
}
var normalizedCsp = string.Join("; ", normalizedList); var normalizedCsp = string.Join("; ", normalizedList);
if (!normalizedCsp.EndsWith(';')) normalizedCsp += ";"; if (!normalizedCsp.EndsWith(';')) normalizedCsp += ";";

View file

@ -1,4 +1,4 @@
using System.Globalization; using System.Globalization;
using System.Text; using System.Text;
namespace Paca.Browser; namespace Paca.Browser;
@ -60,6 +60,7 @@ public sealed class LiveCaptionEngine
public CaptionCue? GetActiveCue(double currentSec) public CaptionCue? GetActiveCue(double currentSec)
{ {
if (double.IsNaN(currentSec) || double.IsInfinity(currentSec) || currentSec < 0) return null;
return _cues.FirstOrDefault(c => currentSec >= c.StartSec && currentSec <= c.EndSec); return _cues.FirstOrDefault(c => currentSec >= c.StartSec && currentSec <= c.EndSec);
} }

View file

@ -36,18 +36,21 @@ public static class AudioAlbumTrackSplitter
return $"-hide_banner -i \"{inputPath}\" -af silencedetect=noise={noiseDb.ToString("0.##", inv)}dB:d={durationSec.ToString("0.0", inv)} -f null -"; return $"-hide_banner -i \"{inputPath}\" -af silencedetect=noise={noiseDb.ToString("0.##", inv)}dB:d={durationSec.ToString("0.0", inv)} -f null -";
} }
/// <summary>
/// <summary> /// <summary>
/// Parses ffmpeg stderr silencedetect output and returns calculated track intervals. /// Parses ffmpeg stderr silencedetect output and returns calculated track intervals.
/// </summary> /// </summary>
public static List<AlbumTrackSegment> ParseSilenceToTracks(string silenceLog, double totalDurationSec, double minTrackDurationSec = 30.0) public static List<AlbumTrackSegment> ParseSilenceToTracks(string silenceLog, double totalDurationSec, double minTrackDurationSec = 30.0)
{ {
var result = new List<AlbumTrackSegment>(); var result = new List<AlbumTrackSegment>();
if (string.IsNullOrWhiteSpace(silenceLog) || totalDurationSec <= 0) if (double.IsNaN(totalDurationSec) || double.IsInfinity(totalDurationSec) || totalDurationSec <= 0)
{ {
if (totalDurationSec > 0) return result;
}
if (string.IsNullOrWhiteSpace(silenceLog))
{ {
result.Add(new AlbumTrackSegment(1, 0.0, totalDurationSec, "Track 01")); result.Add(new AlbumTrackSegment(1, 0.0, totalDurationSec, "Track 01"));
}
return result; return result;
} }
@ -146,6 +149,15 @@ public static class AudioAlbumTrackSplitter
/// </summary> /// </summary>
public static string BuildSplitCommand(string inputPath, AlbumTrackSegment track, string outputPath) public static string BuildSplitCommand(string inputPath, AlbumTrackSegment track, string outputPath)
{ {
if (string.IsNullOrWhiteSpace(inputPath))
throw new ArgumentException("Input path cannot be null or empty.", nameof(inputPath));
if (track == null)
throw new ArgumentNullException(nameof(track));
if (string.IsNullOrWhiteSpace(outputPath))
throw new ArgumentException("Output path cannot be null or empty.", nameof(outputPath));
if (string.Equals(inputPath.Trim(), outputPath.Trim(), StringComparison.OrdinalIgnoreCase))
throw new ArgumentException("Input and output paths cannot be identical.", nameof(outputPath));
var inv = CultureInfo.InvariantCulture; var inv = CultureInfo.InvariantCulture;
return $"-hide_banner -y -ss {track.StartSec.ToString("0.000", inv)} -to {track.EndSec.ToString("0.000", inv)} -i \"{inputPath}\" -c copy \"{outputPath}\""; return $"-hide_banner -y -ss {track.StartSec.ToString("0.000", inv)} -to {track.EndSec.ToString("0.000", inv)} -i \"{inputPath}\" -c copy \"{outputPath}\"";
} }
@ -155,6 +167,7 @@ public static class AudioAlbumTrackSplitter
/// </summary> /// </summary>
public static string GenerateM3uPlaylist(string albumTitle, IReadOnlyList<AlbumTrackSegment> tracks, string filenamePattern = "track_{0:D2}.mp3") public static string GenerateM3uPlaylist(string albumTitle, IReadOnlyList<AlbumTrackSegment> tracks, string filenamePattern = "track_{0:D2}.mp3")
{ {
var safeAlbum = (albumTitle ?? "Album").Replace("\r", "").Replace("\n", " ").Trim();
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.AppendLine("#EXTM3U"); sb.AppendLine("#EXTM3U");
@ -162,8 +175,9 @@ public static class AudioAlbumTrackSplitter
{ {
foreach (var t in tracks) foreach (var t in tracks)
{ {
var safeTitle = (t.Title ?? "Track").Replace("\r", "").Replace("\n", " ").Trim();
int durationRounded = (int)Math.Round(t.DurationSec); int durationRounded = (int)Math.Round(t.DurationSec);
sb.AppendLine($"#EXTINF:{durationRounded},{albumTitle} - {t.Title}"); sb.AppendLine($"#EXTINF:{durationRounded},{safeAlbum} - {safeTitle}");
sb.AppendLine(string.Format(CultureInfo.InvariantCulture, filenamePattern, t.Index)); sb.AppendLine(string.Format(CultureInfo.InvariantCulture, filenamePattern, t.Index));
} }
} }

View file

@ -41,6 +41,13 @@ public static class AudioChannelDownmixer
DownmixMode mode, DownmixMode mode,
bool copyVideo = true) bool copyVideo = true)
{ {
if (string.IsNullOrWhiteSpace(inputPath))
throw new ArgumentException("Input path cannot be null or empty.", nameof(inputPath));
if (string.IsNullOrWhiteSpace(outputPath))
throw new ArgumentException("Output path cannot be null or empty.", nameof(outputPath));
if (string.Equals(inputPath.Trim(), outputPath.Trim(), StringComparison.OrdinalIgnoreCase))
throw new ArgumentException("Input and output paths cannot be identical.", nameof(outputPath));
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.Append($"-i \"{inputPath}\""); sb.Append($"-i \"{inputPath}\"");
@ -48,6 +55,7 @@ public static class AudioChannelDownmixer
sb.Append(" -c:v copy"); sb.Append(" -c:v copy");
var filter = BuildDownmixFilterArgs(mode); var filter = BuildDownmixFilterArgs(mode);
if (!string.IsNullOrWhiteSpace(filter))
sb.Append($" -filter:a \"{filter}\""); sb.Append($" -filter:a \"{filter}\"");
sb.Append(" -c:a aac -b:a 192k"); sb.Append(" -c:a aac -b:a 192k");
sb.Append($" \"{outputPath}\" -y"); sb.Append($" \"{outputPath}\" -y");

View file

@ -1,4 +1,4 @@
using System; using System;
using System.Globalization; using System.Globalization;
namespace Paca.Core; namespace Paca.Core;
@ -39,6 +39,19 @@ public static class AudioDynamicRangeCompressor
/// </summary> /// </summary>
public static string BuildCustomFilter(double thresholdDb, double ratio, double attackMs, double releaseMs, double makeupDb, double limitPeakDb) public static string BuildCustomFilter(double thresholdDb, double ratio, double attackMs, double releaseMs, double makeupDb, double limitPeakDb)
{ {
if (double.IsNaN(thresholdDb) || double.IsInfinity(thresholdDb))
throw new ArgumentOutOfRangeException(nameof(thresholdDb), "Threshold must be a finite number.");
if (double.IsNaN(ratio) || double.IsInfinity(ratio) || ratio <= 0)
throw new ArgumentOutOfRangeException(nameof(ratio), "Compression ratio must be a positive finite number.");
if (double.IsNaN(attackMs) || double.IsInfinity(attackMs) || attackMs < 0)
throw new ArgumentOutOfRangeException(nameof(attackMs), "Attack time must be a non-negative finite number.");
if (double.IsNaN(releaseMs) || double.IsInfinity(releaseMs) || releaseMs < 0)
throw new ArgumentOutOfRangeException(nameof(releaseMs), "Release time must be a non-negative finite number.");
if (double.IsNaN(makeupDb) || double.IsInfinity(makeupDb))
throw new ArgumentOutOfRangeException(nameof(makeupDb), "Makeup gain must be a finite number.");
if (double.IsNaN(limitPeakDb) || double.IsInfinity(limitPeakDb))
throw new ArgumentOutOfRangeException(nameof(limitPeakDb), "Peak limiter must be a finite number.");
var inv = CultureInfo.InvariantCulture; var inv = CultureInfo.InvariantCulture;
return $"acompressor=threshold={thresholdDb.ToString("0.##", inv)}dB:ratio={ratio.ToString("0.##", inv)}:attack={attackMs.ToString("0.##", inv)}:release={releaseMs.ToString("0.##", inv)}:makeup={makeupDb.ToString("0.##", inv)}dB,alimiter=limit={limitPeakDb.ToString("0.##", inv)}dB"; return $"acompressor=threshold={thresholdDb.ToString("0.##", inv)}dB:ratio={ratio.ToString("0.##", inv)}:attack={attackMs.ToString("0.##", inv)}:release={releaseMs.ToString("0.##", inv)}:makeup={makeupDb.ToString("0.##", inv)}dB,alimiter=limit={limitPeakDb.ToString("0.##", inv)}dB";
} }
@ -48,6 +61,16 @@ public static class AudioDynamicRangeCompressor
/// </summary> /// </summary>
public static string BuildFfmpegArgs(string inputPath, string outputPath, CompressorPreset preset, bool copyVideo = true, int audioBitrateKbps = 192) public static string BuildFfmpegArgs(string inputPath, string outputPath, CompressorPreset preset, bool copyVideo = true, int audioBitrateKbps = 192)
{ {
if (string.IsNullOrWhiteSpace(inputPath))
throw new ArgumentException("Input path cannot be null or empty.", nameof(inputPath));
if (string.IsNullOrWhiteSpace(outputPath))
throw new ArgumentException("Output path cannot be null or empty.", nameof(outputPath));
if (string.Equals(inputPath.Trim(), outputPath.Trim(), StringComparison.OrdinalIgnoreCase))
throw new ArgumentException("Input and output paths cannot be identical.", nameof(outputPath));
if (audioBitrateKbps <= 0)
audioBitrateKbps = 192;
string vCodec = copyVideo ? "-c:v copy" : "-c:v libx264 -crf 20 -preset fast"; string vCodec = copyVideo ? "-c:v copy" : "-c:v libx264 -crf 20 -preset fast";
var filter = BuildFilter(preset); var filter = BuildFilter(preset);
return $"-hide_banner -y -i \"{inputPath}\" {vCodec} -af \"{filter}\" -c:a aac -b:a {audioBitrateKbps}k \"{outputPath}\""; return $"-hide_banner -y -i \"{inputPath}\" {vCodec} -af \"{filter}\" -c:a aac -b:a {audioBitrateKbps}k \"{outputPath}\"";

View file

@ -17,6 +17,9 @@ public static class AudioPitchShiftEngine
/// </summary> /// </summary>
public static double ClampSemitones(double semitones) public static double ClampSemitones(double semitones)
{ {
if (double.IsNaN(semitones) || double.IsInfinity(semitones))
return 0.0;
return Math.Clamp(semitones, MinSemitones, MaxSemitones); return Math.Clamp(semitones, MinSemitones, MaxSemitones);
} }
@ -34,6 +37,9 @@ public static class AudioPitchShiftEngine
/// </summary> /// </summary>
public static string BuildPitchShiftFilter(double semitones, int sampleRate = 44100) public static string BuildPitchShiftFilter(double semitones, int sampleRate = 44100)
{ {
if (sampleRate <= 0)
throw new ArgumentOutOfRangeException(nameof(sampleRate), "Sample rate must be greater than zero.");
var scale = CalculatePitchScale(semitones); var scale = CalculatePitchScale(semitones);
var newRate = (int)Math.Round(sampleRate * scale); var newRate = (int)Math.Round(sampleRate * scale);
var tempoFactor = 1.0 / scale; var tempoFactor = 1.0 / scale;
@ -51,6 +57,13 @@ public static class AudioPitchShiftEngine
bool isAudioOnly = false, bool isAudioOnly = false,
int sampleRate = 44100) int sampleRate = 44100)
{ {
if (string.IsNullOrWhiteSpace(inputPath))
throw new ArgumentException("Input path cannot be null or empty.", nameof(inputPath));
if (string.IsNullOrWhiteSpace(outputPath))
throw new ArgumentException("Output path cannot be null or empty.", nameof(outputPath));
if (string.Equals(inputPath.Trim(), outputPath.Trim(), StringComparison.OrdinalIgnoreCase))
throw new ArgumentException("Input and output paths cannot be identical.", nameof(outputPath));
var filter = BuildPitchShiftFilter(semitones, sampleRate); var filter = BuildPitchShiftFilter(semitones, sampleRate);
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.Append($"-i \"{inputPath}\""); sb.Append($"-i \"{inputPath}\"");

View file

@ -46,9 +46,17 @@ public static class AudioVocalStemExtractor
AudioStemMode mode, AudioStemMode mode,
string audioCodec = "libmp3lame") string audioCodec = "libmp3lame")
{ {
var filter = BuildFfmpegStemFilter(mode); if (string.IsNullOrWhiteSpace(inputPath))
var bitrate = audioCodec.Contains("mp3", StringComparison.OrdinalIgnoreCase) ? "320k" : "192k"; throw new ArgumentException("Input path cannot be null or empty.", nameof(inputPath));
if (string.IsNullOrWhiteSpace(outputPath))
throw new ArgumentException("Output path cannot be null or empty.", nameof(outputPath));
if (string.Equals(inputPath.Trim(), outputPath.Trim(), StringComparison.OrdinalIgnoreCase))
throw new ArgumentException("Input and output paths cannot be identical.", nameof(outputPath));
return $"-i \"{inputPath}\" -filter:a \"{filter}\" -c:a {audioCodec} -b:a {bitrate} \"{outputPath}\" -y"; var filter = BuildFfmpegStemFilter(mode);
var safeCodec = string.IsNullOrWhiteSpace(audioCodec) ? "libmp3lame" : audioCodec;
var bitrate = safeCodec.Contains("mp3", StringComparison.OrdinalIgnoreCase) ? "320k" : "192k";
return $"-i \"{inputPath}\" -filter:a \"{filter}\" -c:a {safeCodec} -b:a {bitrate} \"{outputPath}\" -y";
} }
} }

View file

@ -30,11 +30,17 @@ public static class AudioVolumeNormalizer
private static readonly Regex InputThreshRegex = new(@"""input_thresh""\s*:\s*""?([^"",\s]+)""?", RegexOptions.Compiled); private static readonly Regex InputThreshRegex = new(@"""input_thresh""\s*:\s*""?([^"",\s]+)""?", RegexOptions.Compiled);
private static readonly Regex TargetOffsetRegex = new(@"""target_offset""\s*:\s*""?([^"",\s]+)""?", RegexOptions.Compiled); private static readonly Regex TargetOffsetRegex = new(@"""target_offset""\s*:\s*""?([^"",\s]+)""?", RegexOptions.Compiled);
/// <summary>
/// Builds single-pass loudnorm audio filter string.
/// </summary>
/// <summary> /// <summary>
/// Builds single-pass loudnorm audio filter string. /// Builds single-pass loudnorm audio filter string.
/// </summary> /// </summary>
public static string BuildLoudnormFilterArgs(AudioNormalizerPreset preset) public static string BuildLoudnormFilterArgs(AudioNormalizerPreset preset)
{ {
if (preset == null)
throw new ArgumentNullException(nameof(preset));
var i = preset.TargetI.ToString("F1", CultureInfo.InvariantCulture); var i = preset.TargetI.ToString("F1", CultureInfo.InvariantCulture);
var tp = preset.TargetTp.ToString("F1", CultureInfo.InvariantCulture); var tp = preset.TargetTp.ToString("F1", CultureInfo.InvariantCulture);
var lra = preset.TargetLra.ToString("F1", CultureInfo.InvariantCulture); var lra = preset.TargetLra.ToString("F1", CultureInfo.InvariantCulture);
@ -46,6 +52,15 @@ public static class AudioVolumeNormalizer
/// </summary> /// </summary>
public static string BuildNormalizeCommandLine(string inputPath, string outputPath, AudioNormalizerPreset preset, bool copyVideo = true) public static string BuildNormalizeCommandLine(string inputPath, string outputPath, AudioNormalizerPreset preset, bool copyVideo = true)
{ {
if (string.IsNullOrWhiteSpace(inputPath))
throw new ArgumentException("Input path cannot be null or empty.", nameof(inputPath));
if (string.IsNullOrWhiteSpace(outputPath))
throw new ArgumentException("Output path cannot be null or empty.", nameof(outputPath));
if (preset == null)
throw new ArgumentNullException(nameof(preset));
if (string.Equals(inputPath.Trim(), outputPath.Trim(), StringComparison.OrdinalIgnoreCase))
throw new ArgumentException("Input and output paths cannot be identical.", nameof(outputPath));
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.Append($"-i \"{inputPath}\""); sb.Append($"-i \"{inputPath}\"");

View file

@ -46,6 +46,9 @@ public static class BandwidthScheduleProfileEngine
foreach (var rule in config.Rules) foreach (var rule in config.Rules)
{ {
if (rule == null || rule.Profile == null)
continue;
bool inWindow; bool inWindow;
if (rule.StartTime <= rule.EndTime) if (rule.StartTime <= rule.EndTime)
{ {
@ -61,6 +64,6 @@ public static class BandwidthScheduleProfileEngine
return rule.Profile; return rule.Profile;
} }
return config.DefaultProfile; return config.DefaultProfile ?? new BandwidthProfile("Default", 0, 4);
} }
} }

View file

@ -43,10 +43,16 @@ public static class WorkspaceConfigSanitizer
{ {
foreach (var name in names) foreach (var name in names)
{ {
if (result.Count >= 20)
break;
if (!IsCorrupted(name)) if (!IsCorrupted(name))
{ {
var clean = name.Trim(); var clean = name.Trim();
if (!result.Contains(clean)) if (clean.Length > 30)
clean = clean[..30].TrimEnd();
if (!string.IsNullOrEmpty(clean) && !result.Contains(clean))
result.Add(clean); result.Add(clean);
} }
} }

View file

@ -98,6 +98,7 @@ public static class DashParser
// t 속성은 타임라인을 해당 절대시각으로 재설정. // t 속성은 타임라인을 해당 절대시각으로 재설정.
// #9 r=-1 은 "Period 끝까지 반복" — count=r+1=0 으로 0개를 내던 버그 수정. // #9 r=-1 은 "Period 끝까지 반복" — count=r+1=0 으로 0개를 내던 버그 수정.
var timescale = (long?)tmpl.Attribute("timescale") ?? 1; var timescale = (long?)tmpl.Attribute("timescale") ?? 1;
if (timescale <= 0) timescale = 1;
long endTime = periodSec > 0 ? (long)Math.Ceiling(periodSec * timescale) : long.MaxValue; long endTime = periodSec > 0 ? (long)Math.Ceiling(periodSec * timescale) : long.MaxValue;
long number = startNumber; int idx = 0; long time = 0; long number = startNumber; int idx = 0; long time = 0;
const long MaxSegs = 200000; // 폭주 방지 상한 const long MaxSegs = 200000; // 폭주 방지 상한
@ -130,6 +131,7 @@ public static class DashParser
else else
{ {
var timescale = (long?)tmpl.Attribute("timescale") ?? 1; var timescale = (long?)tmpl.Attribute("timescale") ?? 1;
if (timescale <= 0) timescale = 1;
var duration = (long?)tmpl.Attribute("duration") ?? 0; var duration = (long?)tmpl.Attribute("duration") ?? 0;
if (duration > 0) if (duration > 0)
{ {
@ -196,7 +198,7 @@ public static class DashParser
return Uri.TryCreate(baseUri, rel, out var combined) ? combined : new Uri(rel, UriKind.RelativeOrAbsolute); return Uri.TryCreate(baseUri, rel, out var combined) ? combined : new Uri(rel, UriKind.RelativeOrAbsolute);
} }
private static double ParseDuration(string? d) internal static double ParseDuration(string? d)
{ {
if (string.IsNullOrWhiteSpace(d)) return 0; if (string.IsNullOrWhiteSpace(d)) return 0;
try try
@ -205,17 +207,40 @@ public static class DashParser
} }
catch catch
{ {
// 폴백: 수동 파싱 // 폴백: ISO 8601 Duration (P[n]Y[n]M[n]DT[n]H[n]M[n]S) 수동 파싱
var s = d.Trim(); var s = d.Trim().ToUpperInvariant();
if (!s.StartsWith("PT", StringComparison.OrdinalIgnoreCase)) return 0; if (!s.StartsWith("P")) return 0;
s = s.Substring(2); s = s.Substring(1); // 'P' 제거
double total = 0; var num = "";
bool inTime = false;
double total = 0;
var num = "";
foreach (var ch in s) foreach (var ch in s)
{ {
if (char.IsDigit(ch) || ch == '.') num += ch; if (ch == 'T')
else if (ch is 'H' or 'M' or 'S' && double.TryParse(num, NumberStyles.Any, CultureInfo.InvariantCulture, out var v))
{ {
total += ch == 'H' ? v * 3600 : ch == 'M' ? v * 60 : v; inTime = true;
continue;
}
if (char.IsDigit(ch) || ch == '.')
{
num += ch;
}
else if (double.TryParse(num, NumberStyles.Any, CultureInfo.InvariantCulture, out var v))
{
if (!inTime)
{
if (ch == 'D') total += v * 86400;
else if (ch == 'W') total += v * 604800;
else if (ch == 'Y') total += v * 31536000;
}
else
{
if (ch == 'H') total += v * 3600;
else if (ch == 'M') total += v * 60;
else if (ch == 'S') total += v;
}
num = ""; num = "";
} }
} }

View file

@ -32,6 +32,16 @@ public static class AiHighlightClipper
Func<double, double>? sceneChangeProvider = null, Func<double, double>? sceneChangeProvider = null,
double windowSeconds = 30.0) double windowSeconds = 30.0)
{ {
if (double.IsNaN(totalDurationSeconds) || totalDurationSeconds <= 0)
{
return new List<VideoHighlightWindow>();
}
if (double.IsNaN(windowSeconds) || windowSeconds <= 0)
{
windowSeconds = 30.0;
}
var windows = new List<VideoHighlightWindow>(); var windows = new List<VideoHighlightWindow>();
int count = (int)Math.Ceiling(totalDurationSeconds / windowSeconds); int count = (int)Math.Ceiling(totalDurationSeconds / windowSeconds);
@ -61,11 +71,29 @@ public static class AiHighlightClipper
public static List<string> BuildFfmpegShortsCommand(string inputPath, string outputPath, ShortsClipSpec spec) public static List<string> BuildFfmpegShortsCommand(string inputPath, string outputPath, ShortsClipSpec spec)
{ {
if (spec == null)
throw new ArgumentNullException(nameof(spec));
if (string.IsNullOrWhiteSpace(inputPath))
throw new ArgumentException("Input path cannot be null or empty.", nameof(inputPath));
if (string.IsNullOrWhiteSpace(outputPath))
throw new ArgumentException("Output path cannot be null or empty.", nameof(outputPath));
var cleanInput = inputPath.Replace("\"", "").Trim();
var cleanOutput = outputPath.Replace("\"", "").Trim();
if (string.Equals(System.IO.Path.GetFullPath(cleanInput), System.IO.Path.GetFullPath(cleanOutput), StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Input and output path cannot refer to the exact same file (in-place overwrite prevention).");
}
double startSec = double.IsNaN(spec.StartSeconds) ? 0.0 : Math.Max(0.0, spec.StartSeconds);
double durSec = double.IsNaN(spec.DurationSeconds) || spec.DurationSeconds <= 0 ? 59.0 : spec.DurationSeconds;
var args = new List<string> var args = new List<string>
{ {
"-ss", spec.StartSeconds.ToString("F2", System.Globalization.CultureInfo.InvariantCulture), "-ss", startSec.ToString("F2", System.Globalization.CultureInfo.InvariantCulture),
"-t", spec.DurationSeconds.ToString("F2", System.Globalization.CultureInfo.InvariantCulture), "-t", durSec.ToString("F2", System.Globalization.CultureInfo.InvariantCulture),
"-i", inputPath "-i", cleanInput
}; };
if (spec.VerticalCrop9x16) if (spec.VerticalCrop9x16)
@ -78,7 +106,7 @@ public static class AiHighlightClipper
args.AddRange(new[] { "-c", "copy" }); args.AddRange(new[] { "-c", "copy" });
} }
args.AddRange(new[] { "-y", outputPath }); args.AddRange(new[] { "-y", cleanOutput });
return args; return args;
} }
} }

View file

@ -6,9 +6,22 @@ namespace Paca.Core.Detection;
public static class MediaDetector public static class MediaDetector
{ {
/// <returns>HLS 또는 Direct, 감지 대상이 아니면 null</returns> /// <returns>HLS 또는 Direct, 감지 대상이 아니면 null</returns>
public static MediaKind? Classify(string? uri, string? contentType, long len, AppConfig config) public static MediaKind? Classify(string? uri, string? contentType, long len, AppConfig? config)
{ {
var l = (uri ?? "").ToLowerInvariant(); if (config == null) return null;
if (string.IsNullOrWhiteSpace(uri)) return null;
if (len < -1) return null;
var trimmed = uri.Trim();
if (trimmed.StartsWith("javascript:", StringComparison.OrdinalIgnoreCase) ||
trimmed.StartsWith("data:", StringComparison.OrdinalIgnoreCase) ||
trimmed.StartsWith("file:", StringComparison.OrdinalIgnoreCase) ||
trimmed.StartsWith("blob:", StringComparison.OrdinalIgnoreCase))
{
return null;
}
var l = trimmed.ToLowerInvariant();
var c = (contentType ?? "").ToLowerInvariant(); var c = (contentType ?? "").ToLowerInvariant();
// 세그먼트 청크(.ts, .m4s, chunk, init.mp4 등)는 독립 미디어가 아니므로 목록 등록 차단 // 세그먼트 청크(.ts, .m4s, chunk, init.mp4 등)는 독립 미디어가 아니므로 목록 등록 차단

View file

@ -26,9 +26,16 @@ public static class NeuralShaderUpscaler
{ {
public static string GenerateHlslShaderCode(ShaderUpscaleConfig config) public static string GenerateHlslShaderCode(ShaderUpscaleConfig config)
{ {
config ??= new ShaderUpscaleConfig();
int targetWidth = config.TargetWidth > 0 ? config.TargetWidth : 3840;
int targetHeight = config.TargetHeight > 0 ? config.TargetHeight : 2160;
double sharpness = double.IsNaN(config.Sharpness) ? 1.0 : Math.Clamp(config.Sharpness, 0.0, 10.0);
string sharpnessStr = sharpness.ToString("F2", System.Globalization.CultureInfo.InvariantCulture);
return $@" return $@"
// Paca Neural Shader Upscaler Pipeline ({config.Algorithm}) // Paca Neural Shader Upscaler Pipeline ({config.Algorithm})
// Target Resolution: {config.TargetWidth}x{config.TargetHeight}, Sharpness: {config.Sharpness} // Target Resolution: {targetWidth}x{targetHeight}, Sharpness: {sharpnessStr}
Texture2D InputTexture : register(t0); Texture2D InputTexture : register(t0);
SamplerState LinearSampler : register(s0); SamplerState LinearSampler : register(s0);
@ -41,13 +48,13 @@ float4 PS_NeuralUpscale(PS_INPUT input) : SV_Target {{
float2 uv = input.TexCoord; float2 uv = input.TexCoord;
float4 center = InputTexture.Sample(LinearSampler, uv); float4 center = InputTexture.Sample(LinearSampler, uv);
// Neural weight accumulation pass // Neural weight accumulation pass
float4 n = InputTexture.Sample(LinearSampler, uv + float2(0, -1.0 / {config.TargetHeight})); float4 n = InputTexture.Sample(LinearSampler, uv + float2(0, -1.0 / {targetHeight}));
float4 s = InputTexture.Sample(LinearSampler, uv + float2(0, 1.0 / {config.TargetHeight})); float4 s = InputTexture.Sample(LinearSampler, uv + float2(0, 1.0 / {targetHeight}));
float4 e = InputTexture.Sample(LinearSampler, uv + float2(1.0 / {config.TargetWidth}, 0)); float4 e = InputTexture.Sample(LinearSampler, uv + float2(1.0 / {targetWidth}, 0));
float4 w = InputTexture.Sample(LinearSampler, uv + float2(-1.0 / {config.TargetWidth}, 0)); float4 w = InputTexture.Sample(LinearSampler, uv + float2(-1.0 / {targetWidth}, 0));
float4 edge = abs(n - s) + abs(e - w); float4 edge = abs(n - s) + abs(e - w);
return center + (edge * {config.Sharpness:F2} * 0.15); return center + (edge * {sharpnessStr} * 0.15);
}} }}
"; ";
} }

View file

@ -17,11 +17,37 @@ public static class SocialSiteDetector
private static readonly Regex TikTokPathRe = new(@"/@[^/]+/video/([0-9]+)", RegexOptions.Compiled); private static readonly Regex TikTokPathRe = new(@"/@[^/]+/video/([0-9]+)", RegexOptions.Compiled);
private static readonly Regex FbPathRe = new(@"/(?:reel|videos)/([^/?#]+)", RegexOptions.Compiled); private static readonly Regex FbPathRe = new(@"/(?:reel|videos)/([^/?#]+)", RegexOptions.Compiled);
/// <summary>문자열 URL이 소셜 영상 URL이면 정규화된 URL과 사이트 라벨을 반환.</summary>
public static bool TryMatch(string? urlString, out string normalized, out string label)
{
normalized = "";
label = "";
if (string.IsNullOrWhiteSpace(urlString)) return false;
urlString = urlString.Trim();
if (urlString.Contains('\0')) return false;
if (urlString.StartsWith("javascript:", StringComparison.OrdinalIgnoreCase) ||
urlString.StartsWith("data:", StringComparison.OrdinalIgnoreCase) ||
urlString.StartsWith("file:", StringComparison.OrdinalIgnoreCase) ||
urlString.StartsWith("blob:", StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (!Uri.TryCreate(urlString, UriKind.Absolute, out var uri))
return false;
return TryMatch(uri, out normalized, out label);
}
/// <summary>소셜 영상 URL 이면 정규화된 URL 과 사이트 라벨을 반환.</summary> /// <summary>소셜 영상 URL 이면 정규화된 URL 과 사이트 라벨을 반환.</summary>
public static bool TryMatch(Uri url, out string normalized, out string label) public static bool TryMatch(Uri url, out string normalized, out string label)
{ {
normalized = ""; normalized = "";
label = ""; label = "";
if (url == null || !url.IsAbsoluteUri) return false;
if (url.Scheme != "http" && url.Scheme != "https") return false;
var host = (url.Host ?? "").ToLowerInvariant(); var host = (url.Host ?? "").ToLowerInvariant();
var path = url.AbsolutePath ?? ""; var path = url.AbsolutePath ?? "";

View file

@ -262,10 +262,18 @@ public class HlsDownloader
_keys[keyUri] = key; _keys[keyUri] = key;
} }
private byte[] DecryptSegment(byte[] data, Segment seg) internal byte[] DecryptSegment(byte[] data, Segment seg)
{ {
var key = seg.Key!; var key = seg.Key!;
var keyBytes = _keys[key.KeyUri!]; if (!_keys.TryGetValue(key.KeyUri!, out var keyBytes) || keyBytes == null)
throw new InvalidDataException("HLS 복호화 키가 메모리에 로드되지 않았습니다.");
if (keyBytes.Length != 16)
throw new InvalidDataException($"HLS AES-128 키 크기 오류: 예상 16바이트, 실제 {keyBytes.Length}바이트");
if (data.Length % 16 != 0)
throw new InvalidDataException($"HLS 암호화 세그먼트 블록 크기 손상: 세그먼트 크기 {data.Length}B가 AES 블록 크기(16B)의 배수가 아닙니다.");
var iv = ResolveIv(key, seg.Sequence); var iv = ResolveIv(key, seg.Sequence);
using var aes = Aes.Create(); using var aes = Aes.Create();
aes.Key = keyBytes; aes.Key = keyBytes;
@ -276,13 +284,33 @@ public class HlsDownloader
return dec.TransformFinalBlock(data, 0, data.Length); return dec.TransformFinalBlock(data, 0, data.Length);
} }
private static byte[] ResolveIv(KeyInfo key, long seq) internal void SetPreloadedKeyForTesting(Uri uri, byte[] key) => _keys[uri] = key;
internal byte[] DecryptSegmentForTesting(byte[] data, Segment seg) => DecryptSegment(data, seg);
internal static byte[] ResolveIv(KeyInfo key, long seq)
{ {
var iv = key.Iv; var iv = key.Iv;
if (!string.IsNullOrEmpty(iv)) if (!string.IsNullOrEmpty(iv))
{ {
var hex = iv.Replace("0x", "").Replace(" ", ""); var hex = iv.Replace("0x", "").Replace("0X", "").Replace(" ", "");
return Convert.FromHexString(hex.Length % 2 == 1 ? "0" + hex : hex); try
{
// RFC 8216 Section 5.2: 128-bit unsigned integer in big-endian format
// 32글자(16바이트) 미만일 경우 좌측에 0을 패딩
if (hex.Length < 32)
{
hex = hex.PadLeft(32, '0');
}
else if (hex.Length > 32)
{
hex = hex[^32..];
}
return Convert.FromHexString(hex);
}
catch
{
// 비정상 hex 포맷인 경우 시퀀스 번호 폴백
}
} }
// IV 미지정 시 시퀀스 번호를 16바이트 big-endian 으로 // IV 미지정 시 시퀀스 번호를 16바이트 big-endian 으로
var b = new byte[16]; var b = new byte[16];

View file

@ -31,9 +31,11 @@ public static class M3u8Parser
var variants = new List<Variant>(); var variants = new List<Variant>();
var segments = new List<Segment>(); var segments = new List<Segment>();
Uri? init = null; Uri? init = null;
long? initByteRangeLen = null;
long? initByteRangeOff = null;
bool isMaster = false; bool isMaster = false;
if (string.IsNullOrWhiteSpace(text) || !text.Contains("#EXTM3U", StringComparison.OrdinalIgnoreCase)) if (baseUrl == null || string.IsNullOrWhiteSpace(text) || !text.Contains("#EXTM3U", StringComparison.OrdinalIgnoreCase))
{ {
return new ParsedPlaylist return new ParsedPlaylist
{ {
@ -41,6 +43,8 @@ public static class M3u8Parser
Variants = variants, Variants = variants,
Segments = segments, Segments = segments,
InitSegment = init, InitSegment = init,
InitByteRangeLength = initByteRangeLen,
InitByteRangeOffset = initByteRangeOff,
}; };
} }
@ -49,6 +53,11 @@ public static class M3u8Parser
long seq = 0; long seq = 0;
int idx = 0; int idx = 0;
long? pendingByteRangeLength = null;
long? pendingByteRangeOffset = null;
long prevByteRangeOffset = 0;
long prevByteRangeLength = 0;
using var reader = new StringReader(text); using var reader = new StringReader(text);
string? raw; string? raw;
while ((raw = reader.ReadLine()) != null) while ((raw = reader.ReadLine()) != null)
@ -64,7 +73,7 @@ public static class M3u8Parser
if (line.StartsWith("#EXT-X-MEDIA-SEQUENCE:", StringComparison.OrdinalIgnoreCase)) if (line.StartsWith("#EXT-X-MEDIA-SEQUENCE:", StringComparison.OrdinalIgnoreCase))
{ {
var parts = line.Split(':', 2); var parts = line.Split(':', 2);
if (parts.Length > 1 && long.TryParse(parts[1].Trim(), out var s)) seq = s; if (parts.Length > 1 && long.TryParse(parts[1].Trim(), out var s) && s >= 0) seq = s;
continue; continue;
} }
if (line.StartsWith("#EXT-X-KEY:", StringComparison.OrdinalIgnoreCase)) if (line.StartsWith("#EXT-X-KEY:", StringComparison.OrdinalIgnoreCase))
@ -78,11 +87,46 @@ public static class M3u8Parser
: null; : null;
continue; continue;
} }
if (line.StartsWith("#EXT-X-BYTERANGE:", StringComparison.OrdinalIgnoreCase))
{
var val = line.Substring("#EXT-X-BYTERANGE:".Length).Trim();
var parts = val.Split('@', 2);
if (long.TryParse(parts[0].Trim(), out var len) && len > 0)
{
pendingByteRangeLength = len;
if (parts.Length > 1 && long.TryParse(parts[1].Trim(), out var off) && off >= 0)
{
pendingByteRangeOffset = off;
prevByteRangeOffset = off;
}
else
{
pendingByteRangeOffset = prevByteRangeOffset + prevByteRangeLength;
prevByteRangeOffset = pendingByteRangeOffset.Value;
}
prevByteRangeLength = len;
}
continue;
}
if (line.StartsWith("#EXT-X-MAP:", StringComparison.OrdinalIgnoreCase)) if (line.StartsWith("#EXT-X-MAP:", StringComparison.OrdinalIgnoreCase))
{ {
var a = ParseAttrs(line.Substring("#EXT-X-MAP:".Length)); var a = ParseAttrs(line.Substring("#EXT-X-MAP:".Length));
if (a.TryGetValue("URI", out var u) && !string.IsNullOrEmpty(u) && Uri.TryCreate(baseUrl, u.Trim('"'), out var mu)) if (a.TryGetValue("URI", out var u) && !string.IsNullOrEmpty(u) && Uri.TryCreate(baseUrl, u.Trim('"'), out var mu))
init = mu; init = mu;
if (a.TryGetValue("BYTERANGE", out var br) && !string.IsNullOrEmpty(br))
{
var cleanBr = br.Trim('"');
var brParts = cleanBr.Split('@', 2);
if (long.TryParse(brParts[0].Trim(), out var bLen) && bLen > 0)
{
initByteRangeLen = bLen;
if (brParts.Length > 1 && long.TryParse(brParts[1].Trim(), out var bOff) && bOff >= 0)
{
initByteRangeOff = bOff;
}
}
}
continue; continue;
} }
if (line.StartsWith("#")) continue; if (line.StartsWith("#")) continue;
@ -93,15 +137,18 @@ public static class M3u8Parser
if (pendingVariant != null) if (pendingVariant != null)
{ {
int.TryParse(pendingVariant.GetValue("BANDWIDTH", "0"), out var bw); int.TryParse(pendingVariant.GetValue("BANDWIDTH", "0"), out var bw);
variants.Add(new Variant(abs, bw, variants.Add(new Variant(abs, Math.Max(0, bw),
pendingVariant.GetValue("RESOLUTION"), pendingVariant.GetValue("CODECS"))); pendingVariant.GetValue("RESOLUTION"), pendingVariant.GetValue("CODECS")));
pendingVariant = null; pendingVariant = null;
isMaster = true; isMaster = true;
} }
else else
{ {
segments.Add(new Segment(abs, idx, seq, curKey)); segments.Add(new Segment(abs, idx, seq, curKey, pendingByteRangeLength, pendingByteRangeOffset));
idx++; seq++; pendingByteRangeLength = null;
pendingByteRangeOffset = null;
idx++;
if (seq < long.MaxValue) seq++;
} }
} }
@ -111,6 +158,8 @@ public static class M3u8Parser
Variants = variants, Variants = variants,
Segments = segments, Segments = segments,
InitSegment = init, InitSegment = init,
InitByteRangeLength = initByteRangeLen,
InitByteRangeOffset = initByteRangeOff,
}; };
} }
} }

View file

@ -84,6 +84,8 @@ public class PartialChunkIntegrityCache
/// 단일 청크 파일의 물리적 무결성(존재 여부, 0바이트, 길이, SHA-256) 검증 /// 단일 청크 파일의 물리적 무결성(존재 여부, 0바이트, 길이, SHA-256) 검증
/// </summary> /// </summary>
public ChunkIntegrityState VerifyChunkFile(string filePath, long? expectedBytes = null, string? expectedSha256 = null) public ChunkIntegrityState VerifyChunkFile(string filePath, long? expectedBytes = null, string? expectedSha256 = null)
{
try
{ {
if (!File.Exists(filePath)) if (!File.Exists(filePath))
return ChunkIntegrityState.Missing; return ChunkIntegrityState.Missing;
@ -104,6 +106,11 @@ public class PartialChunkIntegrityCache
return ChunkIntegrityState.Verified; return ChunkIntegrityState.Verified;
} }
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return ChunkIntegrityState.Corrupt;
}
}
/// <summary> /// <summary>
/// 청크 메타데이터를 인메모리 렛저에 기록 /// 청크 메타데이터를 인메모리 렛저에 기록
@ -206,10 +213,21 @@ public class PartialChunkIntegrityCache
long verifiedBytes = 0; long verifiedBytes = 0;
var toDownload = new List<int>(); var toDownload = new List<int>();
var fullPartsDir = Path.GetFullPath(partsDir);
for (int i = 0; i < totalSegments; i++) for (int i = 0; i < totalSegments; i++)
{ {
var chunkName = getChunkName(i); var chunkName = getChunkName(i);
var chunkPath = Path.Combine(partsDir, chunkName); var chunkPath = Path.Combine(partsDir, chunkName);
var fullChunkPath = Path.GetFullPath(chunkPath);
// Path traversal 방어: partsDir 외부 경로 탈출 차단
if (!fullChunkPath.StartsWith(fullPartsDir, StringComparison.OrdinalIgnoreCase))
{
corruptedCount++;
toDownload.Add(i);
continue;
}
var tmpPath = chunkPath + ".tmp"; var tmpPath = chunkPath + ".tmp";
// 잔존 임시 파일(.tmp)은 비정상 중단 흔적이므로 안전 제거 // 잔존 임시 파일(.tmp)은 비정상 중단 흔적이므로 안전 제거

View file

@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.IO; using System.IO;
@ -123,6 +123,7 @@ public static class HlsManifestValidator
/// </summary> /// </summary>
public static string StripAdBlocks(string manifestContent, double maxAdDurationSec = 30.0) public static string StripAdBlocks(string manifestContent, double maxAdDurationSec = 30.0)
{ {
if (manifestContent == null) return string.Empty;
if (string.IsNullOrWhiteSpace(manifestContent)) return manifestContent; if (string.IsNullOrWhiteSpace(manifestContent)) return manifestContent;
var lines = manifestContent.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None); var lines = manifestContent.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None);

View file

@ -50,7 +50,11 @@ public static class FftAudioWaveformEngine
public static string BuildFfmpegEqualizerFilter(List<EqualizerBand> bands) public static string BuildFfmpegEqualizerFilter(List<EqualizerBand> bands)
{ {
var filters = bands.Select(b => $"equalizer=f={b.FrequencyHz}:t=q:w=1:g={b.GainDb:F1}"); if (bands == null || bands.Count == 0) return string.Empty;
var filters = bands
.Where(b => b != null)
.Select(b => $"equalizer=f={b.FrequencyHz.ToString(System.Globalization.CultureInfo.InvariantCulture)}:t=q:w=1:g={b.GainDb.ToString("F1", System.Globalization.CultureInfo.InvariantCulture)}");
return string.Join(",", filters); return string.Join(",", filters);
} }
} }

View file

@ -40,7 +40,19 @@ public static class HardwareTranscoderStudio
TranscodePresetSpec spec, TranscodePresetSpec spec,
Platform.GpuCapabilities? capabilities = null) Platform.GpuCapabilities? capabilities = null)
{ {
var args = new List<string> { "-i", inputPath }; ArgumentNullException.ThrowIfNull(spec);
if (string.IsNullOrWhiteSpace(inputPath))
throw new ArgumentException("Input path cannot be null or whitespace.", nameof(inputPath));
if (string.IsNullOrWhiteSpace(outputPath))
throw new ArgumentException("Output path cannot be null or whitespace.", nameof(outputPath));
var cleanInput = inputPath.Trim().Trim('"');
var cleanOutput = outputPath.Trim().Trim('"');
if (string.Equals(Path.GetFullPath(cleanInput), Path.GetFullPath(cleanOutput), StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("Input path and output path cannot be identical.");
var args = new List<string> { "-i", cleanInput };
// Select Video Encoder // Select Video Encoder
string vcodec; string vcodec;
@ -78,7 +90,9 @@ public static class HardwareTranscoderStudio
args.AddRange(new[] { "-b:v", $"{spec.TargetBitrateKbps}k" }); args.AddRange(new[] { "-b:v", $"{spec.TargetBitrateKbps}k" });
} }
args.AddRange(new[] { "-c:a", spec.AudioCodec, "-b:a", $"{spec.AudioBitrateKbps}k", "-y", outputPath }); var audioCodec = string.IsNullOrWhiteSpace(spec.AudioCodec) ? "aac" : spec.AudioCodec.Trim();
var audioBitrate = spec.AudioBitrateKbps > 0 ? spec.AudioBitrateKbps : 128;
args.AddRange(new[] { "-c:a", audioCodec, "-b:a", $"{audioBitrate}k", "-y", cleanOutput });
return args; return args;
} }
} }

View file

@ -42,13 +42,25 @@ public sealed class WebRtcCastingEngine
_iceServers.Add(new IceServerConfig()); _iceServers.Add(new IceServerConfig());
} }
public int ActiveSessionsCount
{
get
{
lock (_lock)
{
return _sessions.Count;
}
}
}
public WebRtcCastSession CreateCastSession(string targetDeviceName) public WebRtcCastSession CreateCastSession(string targetDeviceName)
{ {
var cleanDevice = string.IsNullOrWhiteSpace(targetDeviceName) ? "Default Display" : targetDeviceName.Trim();
lock (_lock) lock (_lock)
{ {
var session = new WebRtcCastSession var session = new WebRtcCastSession
{ {
TargetDeviceName = targetDeviceName, TargetDeviceName = cleanDevice,
IsConnected = true IsConnected = true
}; };
_sessions[session.SessionId] = session; _sessions[session.SessionId] = session;
@ -56,17 +68,32 @@ public sealed class WebRtcCastingEngine
} }
} }
public WebRtcCastSession? GetSession(string sessionId)
{
if (string.IsNullOrWhiteSpace(sessionId)) return null;
lock (_lock)
{
return _sessions.GetValueOrDefault(sessionId);
}
}
public SdpSessionDescription GenerateOffer(string sessionId, int width = 1920, int height = 1080, int fps = 60) public SdpSessionDescription GenerateOffer(string sessionId, int width = 1920, int height = 1080, int fps = 60)
{ {
var cleanSessionId = string.IsNullOrWhiteSpace(sessionId) ? "default" : sessionId.Trim();
var safeWidth = width <= 0 ? 1920 : width;
var safeHeight = height <= 0 ? 1080 : height;
var safeFps = fps <= 0 ? 60 : fps;
return new SdpSessionDescription return new SdpSessionDescription
{ {
Type = "offer", Type = "offer",
Sdp = $"v=0\r\no=PacaCast 0 0 IN IP4 127.0.0.1\r\ns=Paca UltraCast Session {sessionId}\r\nt=0 0\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\na=rtpmap:96 H264/90000\r\na=fmtp:96 level-asymmetry-allowed=1;packetization-mode=1\r\na=sendonly\r\n" Sdp = $"v=0\r\no=PacaCast 0 0 IN IP4 127.0.0.1\r\ns=Paca UltraCast Session {cleanSessionId} ({safeWidth}x{safeHeight}@{safeFps})\r\nt=0 0\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\na=rtpmap:96 H264/90000\r\na=fmtp:96 level-asymmetry-allowed=1;packetization-mode=1\r\na=sendonly\r\n"
}; };
} }
public bool TerminateSession(string sessionId) public bool TerminateSession(string sessionId)
{ {
if (string.IsNullOrWhiteSpace(sessionId)) return false;
lock (_lock) lock (_lock)
{ {
return _sessions.Remove(sessionId); return _sessions.Remove(sessionId);

View file

@ -1,4 +1,4 @@
using System; using System;
namespace Paca.Core; namespace Paca.Core;
@ -54,6 +54,10 @@ public static class MediaBitrateEstimator
public static BitrateEstimationResult CalculateBitrate(double durationSec, long targetSizeBytes, int audioBitrateKbps = 128, double overheadPercent = 3.0) public static BitrateEstimationResult CalculateBitrate(double durationSec, long targetSizeBytes, int audioBitrateKbps = 128, double overheadPercent = 3.0)
{ {
if (durationSec <= 0) durationSec = 1.0; if (durationSec <= 0) durationSec = 1.0;
if (targetSizeBytes <= 0)
{
return new BitrateEstimationResult(false, MinVideoBitrateKbps, audioBitrateKbps, targetSizeBytes);
}
double usableBytes = targetSizeBytes * (1.0 - (overheadPercent / 100.0)); double usableBytes = targetSizeBytes * (1.0 - (overheadPercent / 100.0));
double totalBits = usableBytes * 8.0; double totalBits = usableBytes * 8.0;
@ -74,9 +78,22 @@ public static class MediaBitrateEstimator
/// </summary> /// </summary>
public static (string Pass1Args, string Pass2Args) BuildTwoPassCommands(string inputPath, string outputPath, BitrateEstimationResult plan) public static (string Pass1Args, string Pass2Args) BuildTwoPassCommands(string inputPath, string outputPath, BitrateEstimationResult plan)
{ {
if (string.IsNullOrWhiteSpace(inputPath))
throw new ArgumentException("Input path cannot be null or empty.", nameof(inputPath));
if (string.IsNullOrWhiteSpace(outputPath))
throw new ArgumentException("Output path cannot be null or empty.", nameof(outputPath));
var cleanInput = inputPath.Replace("\"", "").Trim();
var cleanOutput = outputPath.Replace("\"", "").Trim();
if (string.Equals(System.IO.Path.GetFullPath(cleanInput), System.IO.Path.GetFullPath(cleanOutput), StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Input and output path cannot refer to the exact same file (in-place overwrite prevention).");
}
string nullOutput = OperatingSystem.IsWindows() ? "NUL" : "/dev/null"; string nullOutput = OperatingSystem.IsWindows() ? "NUL" : "/dev/null";
string pass1 = $"-hide_banner -y -i \"{inputPath}\" -c:v libx264 -b:v {plan.VideoBitrateKbps}k -pass 1 -an -f null {nullOutput}"; string pass1 = $"-hide_banner -y -i \"{cleanInput}\" -c:v libx264 -b:v {plan.VideoBitrateKbps}k -pass 1 -an -f null {nullOutput}";
string pass2 = $"-hide_banner -y -i \"{inputPath}\" -c:v libx264 -b:v {plan.VideoBitrateKbps}k -pass 2 -c:a aac -b:a {plan.AudioBitrateKbps}k \"{outputPath}\""; string pass2 = $"-hide_banner -y -i \"{cleanInput}\" -c:v libx264 -b:v {plan.VideoBitrateKbps}k -pass 2 -c:a aac -b:a {plan.AudioBitrateKbps}k \"{cleanOutput}\"";
return (pass1, pass2); return (pass1, pass2);
} }
} }

View file

@ -81,12 +81,23 @@ public static class MediaMetadataTagWriter
/// </summary> /// </summary>
public static string BuildFfmpegArgs(MediaMetadataTag tag, string inputPath, string outputPath) public static string BuildFfmpegArgs(MediaMetadataTag tag, string inputPath, string outputPath)
{ {
ArgumentNullException.ThrowIfNull(tag);
if (string.IsNullOrWhiteSpace(inputPath)) throw new ArgumentException("Input path cannot be null or whitespace.", nameof(inputPath));
if (string.IsNullOrWhiteSpace(outputPath)) throw new ArgumentException("Output path cannot be null or whitespace.", nameof(outputPath));
var cleanIn = inputPath.Trim().Replace("\"", "");
var cleanOut = outputPath.Trim().Replace("\"", "");
if (string.Equals(System.IO.Path.GetFullPath(cleanIn), System.IO.Path.GetFullPath(cleanOut), StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("입력 파일과 출력 파일 경로가 동일할 수 없습니다.");
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.Append($"-i \"{inputPath}\""); sb.Append($"-i \"{cleanIn}\"");
if (!string.IsNullOrEmpty(tag.CoverArtworkPath)) if (!string.IsNullOrEmpty(tag.CoverArtworkPath))
{ {
sb.Append($" -i \"{tag.CoverArtworkPath}\" -map 0 -map 1 -c copy -disposition:v:1 attached_pic"); var cleanCover = tag.CoverArtworkPath.Trim().Replace("\"", "");
sb.Append($" -i \"{cleanCover}\" -map 0 -map 1 -c copy -disposition:v:1 attached_pic");
} }
if (!string.IsNullOrEmpty(tag.Title)) if (!string.IsNullOrEmpty(tag.Title))
@ -115,10 +126,10 @@ public static class MediaMetadataTagWriter
if (!string.IsNullOrEmpty(tag.Comment)) if (!string.IsNullOrEmpty(tag.Comment))
sb.Append($" -metadata comment=\"{SanitizeTagValue(tag.Comment)}\""); sb.Append($" -metadata comment=\"{SanitizeTagValue(tag.Comment)}\"");
if (outputPath.EndsWith(".mp3", StringComparison.OrdinalIgnoreCase)) if (cleanOut.EndsWith(".mp3", StringComparison.OrdinalIgnoreCase))
sb.Append(" -id3v2_version 3"); sb.Append(" -id3v2_version 3");
sb.Append($" \"{outputPath}\" -y"); sb.Append($" \"{cleanOut}\" -y");
return sb.ToString(); return sb.ToString();
} }
} }

View file

@ -64,10 +64,23 @@ public static class MediaPlaybackSpeedPitchEngine
double speed, double speed,
bool isAudioOnly = false) bool isAudioOnly = false)
{ {
if (string.IsNullOrWhiteSpace(inputPath))
throw new ArgumentException("Input path cannot be null or empty.", nameof(inputPath));
if (string.IsNullOrWhiteSpace(outputPath))
throw new ArgumentException("Output path cannot be null or empty.", nameof(outputPath));
var cleanInput = inputPath.Replace("\"", "").Trim();
var cleanOutput = outputPath.Replace("\"", "").Trim();
if (string.Equals(System.IO.Path.GetFullPath(cleanInput), System.IO.Path.GetFullPath(cleanOutput), StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Input and output path cannot refer to the exact same file (in-place overwrite prevention).");
}
speed = ClampSpeed(speed); speed = ClampSpeed(speed);
var atempo = BuildAtempoFilterChain(speed); var atempo = BuildAtempoFilterChain(speed);
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.Append($"-i \"{inputPath}\""); sb.Append($"-i \"{cleanInput}\"");
if (!isAudioOnly) if (!isAudioOnly)
{ {
@ -82,7 +95,7 @@ public static class MediaPlaybackSpeedPitchEngine
sb.Append(" -c:a libmp3lame -b:a 320k"); sb.Append(" -c:a libmp3lame -b:a 320k");
} }
sb.Append($" \"{outputPath}\" -y"); sb.Append($" \"{cleanOutput}\" -y");
return sb.ToString(); return sb.ToString();
} }
} }

View file

@ -10,8 +10,8 @@ public record Variant(Uri Url, int Bandwidth, string Resolution, string Codecs)
get get
{ {
if (string.IsNullOrEmpty(Resolution)) return 0; if (string.IsNullOrEmpty(Resolution)) return 0;
var parts = Resolution.Split('x', 'X'); var parts = Resolution.Split(new[] { 'x', 'X', '@' }, StringSplitOptions.RemoveEmptyEntries);
return parts.Length == 2 && int.TryParse(parts[1], out int h) ? h : 0; return parts.Length >= 2 && int.TryParse(parts[1], out int h) && h > 0 ? h : 0;
} }
} }
@ -20,8 +20,8 @@ public record Variant(Uri Url, int Bandwidth, string Resolution, string Codecs)
get get
{ {
if (string.IsNullOrEmpty(Resolution)) return 0; if (string.IsNullOrEmpty(Resolution)) return 0;
var parts = Resolution.Split('x', 'X'); var parts = Resolution.Split(new[] { 'x', 'X', '@' }, StringSplitOptions.RemoveEmptyEntries);
return parts.Length == 2 && int.TryParse(parts[0], out int w) ? w : 0; return parts.Length >= 1 && int.TryParse(parts[0], out int w) && w > 0 ? w : 0;
} }
} }
} }
@ -30,7 +30,7 @@ public record Variant(Uri Url, int Bandwidth, string Resolution, string Codecs)
public record KeyInfo(Uri? KeyUri, string? Iv); public record KeyInfo(Uri? KeyUri, string? Iv);
/// <summary>미디어 세그먼트</summary> /// <summary>미디어 세그먼트</summary>
public record Segment(Uri Url, int Index, long Sequence, KeyInfo? Key); public record Segment(Uri Url, int Index, long Sequence, KeyInfo? Key, long? ByteRangeLength = null, long? ByteRangeOffset = null);
/// <summary>m3u8 파싱 결과</summary> /// <summary>m3u8 파싱 결과</summary>
public record ParsedPlaylist public record ParsedPlaylist
@ -39,4 +39,6 @@ public record ParsedPlaylist
public IReadOnlyList<Variant> Variants { get; init; } = Array.Empty<Variant>(); public IReadOnlyList<Variant> Variants { get; init; } = Array.Empty<Variant>();
public IReadOnlyList<Segment> Segments { get; init; } = Array.Empty<Segment>(); public IReadOnlyList<Segment> Segments { get; init; } = Array.Empty<Segment>();
public Uri? InitSegment { get; init; } // #EXT-X-MAP (fMP4 초기화 세그먼트) public Uri? InitSegment { get; init; } // #EXT-X-MAP (fMP4 초기화 세그먼트)
public long? InitByteRangeLength { get; init; }
public long? InitByteRangeOffset { get; init; }
} }

View file

@ -36,8 +36,16 @@ public static class MultiTrackMediaMuxer
/// </summary> /// </summary>
public static string BuildMuxCommandLine(MuxJobSpecification job) public static string BuildMuxCommandLine(MuxJobSpecification job)
{ {
if (job == null) ArgumentNullException.ThrowIfNull(job);
throw new ArgumentNullException(nameof(job)); if (string.IsNullOrWhiteSpace(job.OutputPath))
throw new ArgumentException("Output path cannot be null or whitespace.", nameof(job));
int totalTracks = (job.VideoTrack != null ? 1 : 0) + job.AudioTracks.Count + job.SubtitleTracks.Count;
if (totalTracks == 0)
throw new InvalidOperationException("Mux job must specify at least one track.");
var cleanOut = job.OutputPath.Trim().Replace("\"", "");
var fullOut = System.IO.Path.GetFullPath(cleanOut);
var inputs = new List<string>(); var inputs = new List<string>();
var maps = new List<string>(); var maps = new List<string>();
@ -45,9 +53,20 @@ public static class MultiTrackMediaMuxer
var disps = new List<string>(); var disps = new List<string>();
var fileIndex = 0; var fileIndex = 0;
void CheckInputPath(string path)
{
if (string.IsNullOrWhiteSpace(path))
throw new ArgumentException("Track file path cannot be null or whitespace.");
var cleanIn = path.Trim().Replace("\"", "");
if (string.Equals(System.IO.Path.GetFullPath(cleanIn), fullOut, StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("입력 파일과 출력 파일 경로가 동일할 수 없습니다.");
}
if (job.VideoTrack != null) if (job.VideoTrack != null)
{ {
inputs.Add($"-i \"{job.VideoTrack.FilePath}\""); CheckInputPath(job.VideoTrack.FilePath);
var cleanV = job.VideoTrack.FilePath.Trim().Replace("\"", "");
inputs.Add($"-i \"{cleanV}\"");
maps.Add($"-map {fileIndex}:v:0"); maps.Add($"-map {fileIndex}:v:0");
fileIndex++; fileIndex++;
} }
@ -55,14 +74,19 @@ public static class MultiTrackMediaMuxer
for (var i = 0; i < job.AudioTracks.Count; i++) for (var i = 0; i < job.AudioTracks.Count; i++)
{ {
var track = job.AudioTracks[i]; var track = job.AudioTracks[i];
inputs.Add($"-i \"{track.FilePath}\""); CheckInputPath(track.FilePath);
var cleanA = track.FilePath.Trim().Replace("\"", "");
inputs.Add($"-i \"{cleanA}\"");
maps.Add($"-map {fileIndex}:a:0"); maps.Add($"-map {fileIndex}:a:0");
if (!string.IsNullOrEmpty(track.Language)) if (!string.IsNullOrEmpty(track.Language))
metas.Add($"-metadata:s:a:{i} language={track.Language}"); metas.Add($"-metadata:s:a:{i} language={track.Language}");
if (!string.IsNullOrEmpty(track.Title)) if (!string.IsNullOrEmpty(track.Title))
metas.Add($"-metadata:s:a:{i} title=\"{track.Title}\""); {
var cleanTitle = track.Title.Replace("\"", "");
metas.Add($"-metadata:s:a:{i} title=\"{cleanTitle}\"");
}
if (track.IsDefault) if (track.IsDefault)
disps.Add($"-disposition:a:{i} default"); disps.Add($"-disposition:a:{i} default");
@ -73,14 +97,19 @@ public static class MultiTrackMediaMuxer
for (var i = 0; i < job.SubtitleTracks.Count; i++) for (var i = 0; i < job.SubtitleTracks.Count; i++)
{ {
var track = job.SubtitleTracks[i]; var track = job.SubtitleTracks[i];
inputs.Add($"-i \"{track.FilePath}\""); CheckInputPath(track.FilePath);
var cleanS = track.FilePath.Trim().Replace("\"", "");
inputs.Add($"-i \"{cleanS}\"");
maps.Add($"-map {fileIndex}:s:0"); maps.Add($"-map {fileIndex}:s:0");
if (!string.IsNullOrEmpty(track.Language)) if (!string.IsNullOrEmpty(track.Language))
metas.Add($"-metadata:s:s:{i} language={track.Language}"); metas.Add($"-metadata:s:s:{i} language={track.Language}");
if (!string.IsNullOrEmpty(track.Title)) if (!string.IsNullOrEmpty(track.Title))
metas.Add($"-metadata:s:s:{i} title=\"{track.Title}\""); {
var cleanTitle = track.Title.Replace("\"", "");
metas.Add($"-metadata:s:s:{i} title=\"{cleanTitle}\"");
}
if (track.IsDefault) if (track.IsDefault)
disps.Add($"-disposition:s:{i} default"); disps.Add($"-disposition:s:{i} default");
@ -106,7 +135,7 @@ public static class MultiTrackMediaMuxer
} }
sb.Append(" -c copy"); sb.Append(" -c copy");
sb.Append($" \"{job.OutputPath}\" -y"); sb.Append($" \"{cleanOut}\" -y");
return sb.ToString(); return sb.ToString();
} }

View file

@ -48,12 +48,37 @@ public static class HostRateLimiter
} }
} }
public static string NormalizeHost(string? hostOrUrl)
{
if (string.IsNullOrWhiteSpace(hostOrUrl)) return string.Empty;
var trimmed = hostOrUrl.Trim();
if (trimmed.Contains("://"))
{
if (Uri.TryCreate(trimmed, UriKind.Absolute, out var uri))
return uri.Host.ToLowerInvariant();
}
if (Uri.TryCreate("http://" + trimmed, UriKind.Absolute, out var uri2))
{
return uri2.Host.ToLowerInvariant();
}
var colIdx = trimmed.IndexOf(':');
if (colIdx >= 0)
{
trimmed = trimmed.Substring(0, colIdx);
}
return trimmed.ToLowerInvariant();
}
public static int GetIntervalMs(string? host) => GetDefaultIntervalMs(host ?? string.Empty); public static int GetIntervalMs(string? host) => GetDefaultIntervalMs(host ?? string.Empty);
public static int GetDefaultIntervalMs(string host) public static int GetDefaultIntervalMs(string host)
{ {
if (string.IsNullOrWhiteSpace(host)) return 200; var h = NormalizeHost(host);
var h = host.ToLowerInvariant(); if (string.IsNullOrWhiteSpace(h)) return 200;
if (IsDomainOrSubdomain(h, "4cdn.org") || IsDomainOrSubdomain(h, "4chan.org") || IsDomainOrSubdomain(h, "4channel.org")) if (IsDomainOrSubdomain(h, "4cdn.org") || IsDomainOrSubdomain(h, "4chan.org") || IsDomainOrSubdomain(h, "4channel.org"))
return 1200; // 4chan 공식 API 룰: 초당 1회 이하 return 1200; // 4chan 공식 API 룰: 초당 1회 이하

View file

@ -42,10 +42,17 @@ public sealed class MultiCdnMeshDownloader
_httpClient = httpClient ?? new HttpClient { Timeout = TimeSpan.FromSeconds(15) }; _httpClient = httpClient ?? new HttpClient { Timeout = TimeSpan.FromSeconds(15) };
} }
private static CdnMirrorInfo CreateMirrorInfo(string key)
{
if (Uri.TryCreate(key, UriKind.Absolute, out var absUri))
return new CdnMirrorInfo(absUri);
return new CdnMirrorInfo(new Uri("http://localhost/" + key.TrimStart('/')));
}
public void RegisterMirror(Uri mirrorUri) public void RegisterMirror(Uri mirrorUri)
{ {
var key = GetMirrorKey(mirrorUri); var key = GetMirrorKey(mirrorUri);
_mirrorStats.TryAdd(key, new CdnMirrorInfo(new Uri(key))); _mirrorStats.TryAdd(key, CreateMirrorInfo(key));
} }
public List<CdnMirrorInfo> GetRankedMirrors() public List<CdnMirrorInfo> GetRankedMirrors()
@ -53,7 +60,11 @@ public sealed class MultiCdnMeshDownloader
return _mirrorStats.Values.OrderByDescending(m => m.HealthScore).ToList(); return _mirrorStats.Values.OrderByDescending(m => m.HealthScore).ToList();
} }
private static string GetMirrorKey(Uri uri) => uri.GetLeftPart(UriPartial.Authority); private static string GetMirrorKey(Uri? uri)
{
if (uri == null) return "unknown";
return uri.IsAbsoluteUri ? uri.GetLeftPart(UriPartial.Authority) : uri.ToString();
}
/// <summary> /// <summary>
/// 복수 CDN 미러 URL로 세그먼트 레이싱 다운로드를 수행합니다. /// 복수 CDN 미러 URL로 세그먼트 레이싱 다운로드를 수행합니다.
@ -63,12 +74,20 @@ public sealed class MultiCdnMeshDownloader
int staggerDelayMs = 40, int staggerDelayMs = 40,
CancellationToken ct = default) CancellationToken ct = default)
{ {
var uris = mirrorUris.ToList(); if (mirrorUris == null)
{
return new MultiCdnSegmentRaceResult { IsSuccess = false };
}
var uris = mirrorUris.Where(u => u != null).ToList();
if (uris.Count == 0) if (uris.Count == 0)
{ {
return new MultiCdnSegmentRaceResult { IsSuccess = false }; return new MultiCdnSegmentRaceResult { IsSuccess = false };
} }
if (staggerDelayMs < 0)
staggerDelayMs = 0;
if (uris.Count == 1) if (uris.Count == 1)
{ {
return await DownloadSingleSegmentAsync(uris[0], ct).ConfigureAwait(false); return await DownloadSingleSegmentAsync(uris[0], ct).ConfigureAwait(false);
@ -90,9 +109,16 @@ public sealed class MultiCdnMeshDownloader
attempted++; attempted++;
if (i > 0 && staggerDelayMs > 0) if (i > 0 && staggerDelayMs > 0)
{
try
{ {
await Task.Delay(staggerDelayMs, linkedCts.Token).ConfigureAwait(false); await Task.Delay(staggerDelayMs, linkedCts.Token).ConfigureAwait(false);
} }
catch (OperationCanceledException)
{
break;
}
}
if (linkedCts.IsCancellationRequested) break; if (linkedCts.IsCancellationRequested) break;
@ -137,7 +163,7 @@ public sealed class MultiCdnMeshDownloader
{ {
var sw = Stopwatch.StartNew(); var sw = Stopwatch.StartNew();
var key = GetMirrorKey(uri); var key = GetMirrorKey(uri);
var stat = _mirrorStats.GetOrAdd(key, _ => new CdnMirrorInfo(new Uri(key))); var stat = _mirrorStats.GetOrAdd(key, k => CreateMirrorInfo(k));
try try
{ {

View file

@ -86,6 +86,10 @@ public sealed class P2pSwarmEngine
public static SwarmTorrentManifest CreateManifestFromBytes(string name, byte[] data, int pieceLength = 512 * 1024) public static SwarmTorrentManifest CreateManifestFromBytes(string name, byte[] data, int pieceLength = 512 * 1024)
{ {
ArgumentNullException.ThrowIfNull(data);
name ??= "";
if (pieceLength < 16 * 1024) pieceLength = 512 * 1024;
var manifest = new SwarmTorrentManifest var manifest = new SwarmTorrentManifest
{ {
Name = name, Name = name,
@ -118,6 +122,7 @@ public sealed class P2pSwarmEngine
public void InitializeSession(SwarmTorrentManifest manifest) public void InitializeSession(SwarmTorrentManifest manifest)
{ {
ArgumentNullException.ThrowIfNull(manifest);
lock (_lock) lock (_lock)
{ {
_manifest = manifest; _manifest = manifest;
@ -142,17 +147,22 @@ public sealed class P2pSwarmEngine
public void AddPeer(SwarmPeer peer) public void AddPeer(SwarmPeer peer)
{ {
if (peer == null) return;
lock (_lock) lock (_lock)
{ {
peer.Bitfield ??= Array.Empty<bool>();
if (peer.Bitfield.Length != _pieces.Count) if (peer.Bitfield.Length != _pieces.Count)
{ {
var bf = new bool[_pieces.Count]; var bf = new bool[_pieces.Count];
Array.Copy(peer.Bitfield, bf, Math.Min(peer.Bitfield.Length, bf.Length)); Array.Copy(peer.Bitfield, bf, Math.Min(peer.Bitfield.Length, bf.Length));
peer.Bitfield = bf; peer.Bitfield = bf;
} }
if (!string.IsNullOrEmpty(peer.PeerId))
{
_peers[peer.PeerId] = peer; _peers[peer.PeerId] = peer;
} }
} }
}
public void RemovePeer(string peerId) public void RemovePeer(string peerId)
{ {
@ -164,11 +174,15 @@ public sealed class P2pSwarmEngine
public bool IngestPieceData(int pieceIndex, byte[] data) public bool IngestPieceData(int pieceIndex, byte[] data)
{ {
if (data == null) return false;
lock (_lock) lock (_lock)
{ {
if (!_pieces.TryGetValue(pieceIndex, out var piece)) if (!_pieces.TryGetValue(pieceIndex, out var piece))
return false; return false;
if (data.Length != piece.Length)
return false;
using var sha256 = SHA256.Create(); using var sha256 = SHA256.Create();
byte[] computedHash = sha256.ComputeHash(data); byte[] computedHash = sha256.ComputeHash(data);
string computedHex = Convert.ToHexString(computedHash).ToLowerInvariant(); string computedHex = Convert.ToHexString(computedHash).ToLowerInvariant();

View file

@ -8,5 +8,6 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" /> <PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
<InternalsVisibleTo Include="Paca.Tests" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -25,6 +25,11 @@ public sealed class AudioNormalizer
public async Task<string> NormalizeAudioAsync(string ffmpeg, string inputFile, string outputFile, LoudnessSpec spec, CancellationToken ct) public async Task<string> NormalizeAudioAsync(string ffmpeg, string inputFile, string outputFile, LoudnessSpec spec, CancellationToken ct)
{ {
if (string.IsNullOrWhiteSpace(inputFile)) throw new ArgumentException("입력 파일 경로가 유효하지 않습니다.", nameof(inputFile));
if (string.IsNullOrWhiteSpace(outputFile)) throw new ArgumentException("출력 파일 경로가 유효하지 않습니다.", nameof(outputFile));
if (string.Equals(Path.GetFullPath(inputFile), Path.GetFullPath(outputFile), StringComparison.OrdinalIgnoreCase))
throw new ArgumentException("입력 파일과 출력 파일 경로가 동일할 수 없습니다. 원본 손상을 방지하기 위해 별도의 출력 경로를 지정해야 합니다.");
// Pass 1: Measure loudness // Pass 1: Measure loudness
var iStr = spec.TargetLufs.ToString("F1", CultureInfo.InvariantCulture); var iStr = spec.TargetLufs.ToString("F1", CultureInfo.InvariantCulture);
var tpStr = spec.TruePeak.ToString("F1", CultureInfo.InvariantCulture); var tpStr = spec.TruePeak.ToString("F1", CultureInfo.InvariantCulture);

View file

@ -1,4 +1,4 @@
using System.Globalization; using System.Globalization;
using System.IO; using System.IO;
using System.Text; using System.Text;
using Paca.Core.Util; using Paca.Core.Util;
@ -68,25 +68,44 @@ public sealed class ChapterSplitter
public static string GetChapterFileName(int id, string title, string ext) public static string GetChapterFileName(int id, string title, string ext)
{ {
var cleanTitle = FileNameUtil.Sanitize(title); var cleanTitle = string.IsNullOrWhiteSpace(title) ? "Chapter" : FileNameUtil.Sanitize(title);
var cleanExt = ext.TrimStart('.'); var cleanExt = string.IsNullOrWhiteSpace(ext) ? "mp4" : ext.TrimStart('.');
return $"{id:D2}_{cleanTitle}.{cleanExt}"; return $"{id:D2}_{cleanTitle}.{cleanExt}";
} }
private static string EscapeFfmetadata(string text)
{
if (string.IsNullOrEmpty(text)) return "";
return text
.Replace(@"\", @"\\")
.Replace("=", @"\=")
.Replace(";", @"\;")
.Replace("#", @"\#")
.Replace("\r", "")
.Replace("\n", "\\\n");
}
public static string GenerateFfmetadata(IReadOnlyList<ChapterInfo> chapters, string? mainTitle = null) public static string GenerateFfmetadata(IReadOnlyList<ChapterInfo> chapters, string? mainTitle = null)
{ {
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.AppendLine(";FFMETADATA1"); sb.AppendLine(";FFMETADATA1");
if (!string.IsNullOrWhiteSpace(mainTitle)) if (!string.IsNullOrWhiteSpace(mainTitle))
sb.AppendLine($"title={mainTitle}"); sb.AppendLine($"title={EscapeFfmetadata(mainTitle)}");
if (chapters != null)
{
foreach (var ch in chapters) foreach (var ch in chapters)
{ {
if (ch == null) continue;
long startMs = double.IsNaN(ch.StartSec) || ch.StartSec < 0 ? 0 : (long)(ch.StartSec * 1000);
long endMs = double.IsNaN(ch.EndSec) || ch.EndSec < startMs / 1000.0 ? startMs : (long)(ch.EndSec * 1000);
sb.AppendLine("[CHAPTER]"); sb.AppendLine("[CHAPTER]");
sb.AppendLine("TIMEBASE=1/1000"); sb.AppendLine("TIMEBASE=1/1000");
sb.AppendLine($"START={(long)(ch.StartSec * 1000)}"); sb.AppendLine($"START={startMs}");
sb.AppendLine($"END={(long)(ch.EndSec * 1000)}"); sb.AppendLine($"END={endMs}");
sb.AppendLine($"title={ch.Title}"); sb.AppendLine($"title={EscapeFfmetadata(ch.Title ?? "Chapter")}");
}
} }
return sb.ToString(); return sb.ToString();

View file

@ -61,8 +61,9 @@ public sealed class HardwareEncoderDetector
HasAmfAv1: amfAv1); HasAmfAv1: amfAv1);
} }
public string GetBestH264Encoder(HardwareCapabilities caps) public string GetBestH264Encoder(HardwareCapabilities? caps)
{ {
if (caps == null) return "libx264";
if (caps.HasNvidiaNvenc) return "h264_nvenc"; if (caps.HasNvidiaNvenc) return "h264_nvenc";
if (caps.HasAppleVideotoolbox) return "h264_videotoolbox"; if (caps.HasAppleVideotoolbox) return "h264_videotoolbox";
if (caps.HasIntelQsv) return "h264_qsv"; if (caps.HasIntelQsv) return "h264_qsv";
@ -70,8 +71,9 @@ public sealed class HardwareEncoderDetector
return "libx264"; return "libx264";
} }
public string GetBestHevcEncoder(HardwareCapabilities caps) public string GetBestHevcEncoder(HardwareCapabilities? caps)
{ {
if (caps == null) return "libx265";
if (caps.HasNvencHevc || caps.HasNvidiaNvenc) return "hevc_nvenc"; if (caps.HasNvencHevc || caps.HasNvidiaNvenc) return "hevc_nvenc";
if (caps.HasAppleVideotoolbox) return "hevc_videotoolbox"; if (caps.HasAppleVideotoolbox) return "hevc_videotoolbox";
if (caps.HasQsvHevc || caps.HasIntelQsv) return "hevc_qsv"; if (caps.HasQsvHevc || caps.HasIntelQsv) return "hevc_qsv";
@ -79,40 +81,45 @@ public sealed class HardwareEncoderDetector
return "libx265"; return "libx265";
} }
public string GetBestAv1Encoder(HardwareCapabilities caps) public string GetBestAv1Encoder(HardwareCapabilities? caps)
{ {
if (caps == null) return "libsvtav1";
if (caps.HasNvencAv1) return "av1_nvenc"; if (caps.HasNvencAv1) return "av1_nvenc";
if (caps.HasQsvAv1) return "av1_qsv"; if (caps.HasQsvAv1) return "av1_qsv";
if (caps.HasAmfAv1) return "av1_amf"; if (caps.HasAmfAv1) return "av1_amf";
return "libsvtav1"; return "libsvtav1";
} }
public static string[] BuildEncodingArgs(string encoder, int crfOrCqp = 19, string preset = "fast") public static string[] BuildEncodingArgs(string? encoder, int crfOrCqp = 19, string? preset = "fast")
{ {
if (encoder.EndsWith("_nvenc", StringComparison.OrdinalIgnoreCase)) var cleanEncoder = string.IsNullOrWhiteSpace(encoder) ? "libx264" : encoder.Trim();
var cleanPreset = string.IsNullOrWhiteSpace(preset) ? "fast" : preset.Trim();
var safeCrf = Math.Clamp(crfOrCqp, 0, 63);
if (cleanEncoder.EndsWith("_nvenc", StringComparison.OrdinalIgnoreCase))
{ {
return new[] return new[]
{ {
"-c:v", encoder, "-c:v", cleanEncoder,
"-preset", preset, "-preset", cleanPreset,
"-cq", crfOrCqp.ToString() "-cq", safeCrf.ToString()
}; };
} }
if (encoder.EndsWith("_qsv", StringComparison.OrdinalIgnoreCase)) if (cleanEncoder.EndsWith("_qsv", StringComparison.OrdinalIgnoreCase))
{ {
return new[] return new[]
{ {
"-c:v", encoder, "-c:v", cleanEncoder,
"-global_quality", crfOrCqp.ToString() "-global_quality", safeCrf.ToString()
}; };
} }
return new[] return new[]
{ {
"-c:v", encoder, "-c:v", cleanEncoder,
"-crf", crfOrCqp.ToString(), "-crf", safeCrf.ToString(),
"-preset", preset "-preset", cleanPreset
}; };
} }
} }

View file

@ -1,4 +1,4 @@
namespace Paca.Core.Platform; namespace Paca.Core.Platform;
public enum ToneMapAlgorithm public enum ToneMapAlgorithm
{ {
@ -33,6 +33,8 @@ public sealed class HdrToneMapper
public static string BuildToneMapFilter(ToneMapAlgorithm algo, int targetNits = 100) public static string BuildToneMapFilter(ToneMapAlgorithm algo, int targetNits = 100)
{ {
if (targetNits <= 0) targetNits = 100;
var algoStr = algo switch var algoStr = algo switch
{ {
ToneMapAlgorithm.Mobius => "mobius", ToneMapAlgorithm.Mobius => "mobius",
@ -46,33 +48,51 @@ public sealed class HdrToneMapper
public static string[] Build10BitPassthroughArgs(string inputFile, string outputFile) public static string[] Build10BitPassthroughArgs(string inputFile, string outputFile)
{ {
if (string.IsNullOrWhiteSpace(inputFile)) throw new ArgumentException("입력 파일 경로가 유효하지 않습니다.", nameof(inputFile));
if (string.IsNullOrWhiteSpace(outputFile)) throw new ArgumentException("출력 파일 경로가 유효하지 않습니다.", nameof(outputFile));
var cleanInput = inputFile.Replace("\"", "").Trim();
var cleanOutput = outputFile.Replace("\"", "").Trim();
if (string.Equals(Path.GetFullPath(cleanInput), Path.GetFullPath(cleanOutput), StringComparison.OrdinalIgnoreCase))
throw new ArgumentException("입력 파일과 출력 파일 경로가 동일할 수 없습니다. 원본 손상을 방지하기 위해 별도의 출력 경로를 지정해야 합니다.");
return new[] return new[]
{ {
"-y", "-hide_banner", "-loglevel", "error", "-y", "-hide_banner", "-loglevel", "error",
"-i", inputFile, "-i", cleanInput,
"-c:v", "copy", "-c:v", "copy",
"-c:a", "copy", "-c:a", "copy",
outputFile cleanOutput
}; };
} }
public async Task<string> ConvertToSdrAsync(string ffmpeg, string inputFile, string outputFile, ToneMapAlgorithm algo, CancellationToken ct) public async Task<string> ConvertToSdrAsync(string ffmpeg, string inputFile, string outputFile, ToneMapAlgorithm algo, CancellationToken ct)
{ {
if (string.IsNullOrWhiteSpace(inputFile)) throw new ArgumentException("입력 파일 경로가 유효하지 않습니다.", nameof(inputFile));
if (string.IsNullOrWhiteSpace(outputFile)) throw new ArgumentException("출력 파일 경로가 유효하지 않습니다.", nameof(outputFile));
var cleanInput = inputFile.Replace("\"", "").Trim();
var cleanOutput = outputFile.Replace("\"", "").Trim();
if (string.Equals(Path.GetFullPath(cleanInput), Path.GetFullPath(cleanOutput), StringComparison.OrdinalIgnoreCase))
throw new ArgumentException("입력 파일과 출력 파일 경로가 동일할 수 없습니다. 원본 손상을 방지하기 위해 별도의 출력 경로를 지정해야 합니다.");
var filter = BuildToneMapFilter(algo); var filter = BuildToneMapFilter(algo);
var args = new[] var args = new[]
{ {
"-y", "-hide_banner", "-loglevel", "error", "-y", "-hide_banner", "-loglevel", "error",
"-i", inputFile, "-i", cleanInput,
"-vf", filter, "-vf", filter,
"-c:v", "libx264", "-crf", "18", "-preset", "fast", "-c:v", "libx264", "-crf", "18", "-preset", "fast",
"-c:a", "copy", "-c:a", "copy",
outputFile cleanOutput
}; };
var res = await _runner.RunAsync(new ProcessSpec(ffmpeg, args), null, ct); var res = await _runner.RunAsync(new ProcessSpec(ffmpeg, args), null, ct);
if (res.ExitCode != 0) if (res.ExitCode != 0)
throw new InvalidOperationException($"SDR 톤매핑 변환 실패: {res.Stderr}"); throw new InvalidOperationException($"SDR 톤매핑 변환 실패: {res.Stderr}");
return outputFile; return cleanOutput;
} }
} }

View file

@ -1,4 +1,4 @@
using System.Globalization; using System.Globalization;
namespace Paca.Core.Platform; namespace Paca.Core.Platform;
@ -29,9 +29,11 @@ public sealed class HighlightShortsClipper
public static IReadOnlyList<HighlightClip> DetectHighlightWindows(IEnumerable<VolumeSample> samples, double windowDurationSec = 15.0, int topCount = 3) public static IReadOnlyList<HighlightClip> DetectHighlightWindows(IEnumerable<VolumeSample> samples, double windowDurationSec = 15.0, int topCount = 3)
{ {
if (samples == null) return Array.Empty<HighlightClip>(); if (samples == null || topCount <= 0) return Array.Empty<HighlightClip>();
if (double.IsNaN(windowDurationSec) || windowDurationSec <= 0) windowDurationSec = 15.0;
var sortedByVolume = samples.OrderByDescending(s => s.VolumeDb).ToList(); var valid = samples.Where(s => s != null && !double.IsNaN(s.VolumeDb) && !double.IsNaN(s.TimeSec)).ToList();
var sortedByVolume = valid.OrderByDescending(s => s.VolumeDb).ToList();
var selected = new List<HighlightClip>(); var selected = new List<HighlightClip>();
foreach (var s in sortedByVolume) foreach (var s in sortedByVolume)
@ -51,7 +53,16 @@ public sealed class HighlightShortsClipper
public async Task<string> ExportShortsClipAsync(string ffmpeg, string inputFile, string outputFile, HighlightClip clip, bool isVerticalShorts, CancellationToken ct) public async Task<string> ExportShortsClipAsync(string ffmpeg, string inputFile, string outputFile, HighlightClip clip, bool isVerticalShorts, CancellationToken ct)
{ {
if (clip == null) throw new ArgumentNullException(nameof(clip));
if (!clip.IsValid()) throw new ArgumentException("유효하지 않은 클립 파라미터입니다.", nameof(clip)); if (!clip.IsValid()) throw new ArgumentException("유효하지 않은 클립 파라미터입니다.", nameof(clip));
if (string.IsNullOrWhiteSpace(inputFile)) throw new ArgumentException("입력 파일 경로가 유효하지 않습니다.", nameof(inputFile));
if (string.IsNullOrWhiteSpace(outputFile)) throw new ArgumentException("출력 파일 경로가 유효하지 않습니다.", nameof(outputFile));
var cleanInput = inputFile.Replace("\"", "").Trim();
var cleanOutput = outputFile.Replace("\"", "").Trim();
if (string.Equals(Path.GetFullPath(cleanInput), Path.GetFullPath(cleanOutput), StringComparison.OrdinalIgnoreCase))
throw new ArgumentException("입력 파일과 출력 파일 경로가 동일할 수 없습니다. 원본 손상을 방지하기 위해 별도의 출력 경로를 지정해야 합니다.");
var startStr = clip.StartSec.ToString("F2", CultureInfo.InvariantCulture); var startStr = clip.StartSec.ToString("F2", CultureInfo.InvariantCulture);
var durStr = clip.DurationSec.ToString("F2", CultureInfo.InvariantCulture); var durStr = clip.DurationSec.ToString("F2", CultureInfo.InvariantCulture);
@ -61,7 +72,7 @@ public sealed class HighlightShortsClipper
"-y", "-hide_banner", "-loglevel", "error", "-y", "-hide_banner", "-loglevel", "error",
"-ss", startStr, "-ss", startStr,
"-t", durStr, "-t", durStr,
"-i", inputFile "-i", cleanInput
}; };
if (isVerticalShorts) if (isVerticalShorts)

View file

@ -1,4 +1,4 @@
using System.Globalization; using System.Globalization;
namespace Paca.Core.Platform; namespace Paca.Core.Platform;
@ -16,14 +16,23 @@ public sealed class LosslessSmartTrimmer
public static double FindNearestKeyframe(IEnumerable<double> keyframes, double targetSec) public static double FindNearestKeyframe(IEnumerable<double> keyframes, double targetSec)
{ {
if (keyframes == null || !keyframes.Any()) return targetSec; double safeTarget = double.IsNaN(targetSec) || double.IsInfinity(targetSec) ? 0.0 : Math.Max(0.0, targetSec);
if (keyframes == null) return safeTarget;
return keyframes.OrderBy(k => Math.Abs(k - targetSec)).First(); var valid = keyframes.Where(k => !double.IsNaN(k) && !double.IsInfinity(k) && k >= 0.0).ToList();
if (valid.Count == 0) return safeTarget;
return valid.OrderBy(k => Math.Abs(k - safeTarget)).First();
} }
public async Task<string> TrimLosslessAsync(string ffmpeg, string inputFile, string outputFile, double startSec, double endSec, CancellationToken ct) public async Task<string> TrimLosslessAsync(string ffmpeg, string inputFile, string outputFile, double startSec, double endSec, CancellationToken ct)
{ {
if (startSec < 0 || endSec <= startSec) if (string.IsNullOrWhiteSpace(inputFile)) throw new ArgumentException("입력 파일 경로가 유효하지 않습니다.", nameof(inputFile));
if (string.IsNullOrWhiteSpace(outputFile)) throw new ArgumentException("출력 파일 경로가 유효하지 않습니다.", nameof(outputFile));
if (string.Equals(Path.GetFullPath(inputFile), Path.GetFullPath(outputFile), StringComparison.OrdinalIgnoreCase))
throw new ArgumentException("입력 파일과 출력 파일 경로가 동일할 수 없습니다. 원본 손상을 방지하기 위해 별도의 출력 경로를 지정해야 합니다.");
if (double.IsNaN(startSec) || double.IsNaN(endSec) || double.IsInfinity(startSec) || double.IsInfinity(endSec) || startSec < 0 || endSec <= startSec)
throw new ArgumentException("시작 시간과 종료 시간이 유효하지 않습니다."); throw new ArgumentException("시작 시간과 종료 시간이 유효하지 않습니다.");
var startStr = startSec.ToString("F3", CultureInfo.InvariantCulture); var startStr = startSec.ToString("F3", CultureInfo.InvariantCulture);

View file

@ -1,4 +1,4 @@
namespace Paca.Core.Platform; namespace Paca.Core.Platform;
public sealed class MirrorHealthInfo public sealed class MirrorHealthInfo
{ {
@ -16,17 +16,26 @@ public sealed class MultiSourceSegmentAccelerator
{ {
private readonly int _maxFailuresBeforeDrop; private readonly int _maxFailuresBeforeDrop;
private readonly List<MirrorHealthInfo> _mirrors = new(); private readonly List<MirrorHealthInfo> _mirrors = new();
private readonly object _syncLock = new();
public MultiSourceSegmentAccelerator(int maxFailuresBeforeDrop = 3) public MultiSourceSegmentAccelerator(int maxFailuresBeforeDrop = 3)
{ {
_maxFailuresBeforeDrop = maxFailuresBeforeDrop; _maxFailuresBeforeDrop = maxFailuresBeforeDrop;
} }
public int MirrorCount => _mirrors.Count; public int MirrorCount
{
get
{
lock (_syncLock) return _mirrors.Count;
}
}
public void AddMirror(string baseUrl) public void AddMirror(string baseUrl)
{ {
if (string.IsNullOrWhiteSpace(baseUrl)) return; if (string.IsNullOrWhiteSpace(baseUrl)) return;
lock (_syncLock)
{
if (!_mirrors.Any(m => m.BaseUrl.Equals(baseUrl, StringComparison.OrdinalIgnoreCase))) if (!_mirrors.Any(m => m.BaseUrl.Equals(baseUrl, StringComparison.OrdinalIgnoreCase)))
{ {
_mirrors.Add(new MirrorHealthInfo _mirrors.Add(new MirrorHealthInfo
@ -36,8 +45,11 @@ public sealed class MultiSourceSegmentAccelerator
}); });
} }
} }
}
public void RecordFailure(string baseUrl) public void RecordFailure(string baseUrl)
{
lock (_syncLock)
{ {
var mirror = _mirrors.FirstOrDefault(m => m.BaseUrl.Equals(baseUrl, StringComparison.OrdinalIgnoreCase)); var mirror = _mirrors.FirstOrDefault(m => m.BaseUrl.Equals(baseUrl, StringComparison.OrdinalIgnoreCase));
if (mirror != null) if (mirror != null)
@ -45,8 +57,11 @@ public sealed class MultiSourceSegmentAccelerator
mirror.FailureCount++; mirror.FailureCount++;
} }
} }
}
public void RecordLatency(string baseUrl, TimeSpan latency) public void RecordLatency(string baseUrl, TimeSpan latency)
{
lock (_syncLock)
{ {
var mirror = _mirrors.FirstOrDefault(m => m.BaseUrl.Equals(baseUrl, StringComparison.OrdinalIgnoreCase)); var mirror = _mirrors.FirstOrDefault(m => m.BaseUrl.Equals(baseUrl, StringComparison.OrdinalIgnoreCase));
if (mirror != null) if (mirror != null)
@ -54,8 +69,11 @@ public sealed class MultiSourceSegmentAccelerator
mirror.AverageLatencyMs = latency.TotalMilliseconds; mirror.AverageLatencyMs = latency.TotalMilliseconds;
} }
} }
}
public string SelectBestMirror(int chunkIndex) public string SelectBestMirror(int chunkIndex)
{
lock (_syncLock)
{ {
if (_mirrors.Count == 0) return ""; if (_mirrors.Count == 0) return "";
@ -69,8 +87,11 @@ public sealed class MultiSourceSegmentAccelerator
int idx = (int)((uint)chunkIndex % active.Count); int idx = (int)((uint)chunkIndex % active.Count);
return active[idx].BaseUrl; return active[idx].BaseUrl;
} }
}
public string? GetFastestMirror() public string? GetFastestMirror()
{
lock (_syncLock)
{ {
var active = _mirrors.Where(m => m.IsActive).ToList(); var active = _mirrors.Where(m => m.IsActive).ToList();
if (active.Count == 0) return _mirrors.FirstOrDefault()?.BaseUrl; if (active.Count == 0) return _mirrors.FirstOrDefault()?.BaseUrl;
@ -78,3 +99,4 @@ public sealed class MultiSourceSegmentAccelerator
return active.OrderBy(m => m.AverageLatencyMs).First().BaseUrl; return active.OrderBy(m => m.AverageLatencyMs).First().BaseUrl;
} }
} }
}

View file

@ -1,4 +1,4 @@
namespace Paca.Core.Platform; namespace Paca.Core.Platform;
/// <summary> /// <summary>
/// 46차: 지능형 대역폭 예측 및 동적 청크 프리패칭 윈도우 엔진. /// 46차: 지능형 대역폭 예측 및 동적 청크 프리패칭 윈도우 엔진.
@ -9,6 +9,7 @@ public sealed class PredictiveChunkPrefetcher
private readonly int _maxWindowSize; private readonly int _maxWindowSize;
private readonly Queue<double> _speedSamples = new(); private readonly Queue<double> _speedSamples = new();
private readonly int _maxSamples; private readonly int _maxSamples;
private readonly object _syncLock = new();
public PredictiveChunkPrefetcher(int initialWindowSize = 3, int minWindowSize = 1, int maxWindowSize = 10, int maxSamples = 5) public PredictiveChunkPrefetcher(int initialWindowSize = 3, int minWindowSize = 1, int maxWindowSize = 10, int maxSamples = 5)
{ {
@ -22,19 +23,24 @@ public sealed class PredictiveChunkPrefetcher
if (duration.TotalSeconds <= 0 || chunkSizeBytes <= 0) return; if (duration.TotalSeconds <= 0 || chunkSizeBytes <= 0) return;
double speed = chunkSizeBytes / duration.TotalSeconds; double speed = chunkSizeBytes / duration.TotalSeconds;
lock (_syncLock)
{
_speedSamples.Enqueue(speed); _speedSamples.Enqueue(speed);
while (_speedSamples.Count > _maxSamples) while (_speedSamples.Count > _maxSamples)
{ {
_speedSamples.Dequeue(); _speedSamples.Dequeue();
} }
} }
}
public double GetEstimatedSpeedBytesPerSec() public double GetEstimatedSpeedBytesPerSec()
{
lock (_syncLock)
{ {
if (_speedSamples.Count == 0) return 1_000_000.0; // 기본값 1MB/s if (_speedSamples.Count == 0) return 1_000_000.0; // 기본값 1MB/s
return _speedSamples.Average(); return _speedSamples.Average();
} }
}
public int CalculateOptimalPrefetchWindow() public int CalculateOptimalPrefetchWindow()
{ {

View file

@ -32,14 +32,21 @@ public sealed class DesktopProcessRunner : IProcessRunner
System.Diagnostics.Process? proc = null; System.Diagnostics.Process? proc = null;
try try
{ {
var psi = new System.Diagnostics.ProcessStartInfo(spec.ExePath) var exePath = (spec.ExePath ?? "").Trim().Trim('"');
var psi = new System.Diagnostics.ProcessStartInfo(exePath)
{ {
UseShellExecute = false, UseShellExecute = false,
CreateNoWindow = true, CreateNoWindow = true,
RedirectStandardError = true, RedirectStandardError = true,
RedirectStandardOutput = true, RedirectStandardOutput = true,
}; };
foreach (var a in spec.Args) psi.ArgumentList.Add(a); if (spec.Args != null)
{
foreach (var a in spec.Args)
{
if (a != null) psi.ArgumentList.Add(a);
}
}
if (spec.EnvPrefix != null) if (spec.EnvPrefix != null)
{ {
foreach (var (key, prefix) in spec.EnvPrefix) foreach (var (key, prefix) in spec.EnvPrefix)
@ -50,7 +57,7 @@ public sealed class DesktopProcessRunner : IProcessRunner
} }
proc = System.Diagnostics.Process.Start(psi) proc = System.Diagnostics.Process.Start(psi)
?? throw new InvalidOperationException($"{spec.ExePath} 시작 실패"); ?? throw new InvalidOperationException($"{exePath} 시작 실패");
await using var _ = ct.Register(() => await using var _ = ct.Register(() =>
{ {
try { if (!proc.HasExited) proc.Kill(entireProcessTree: true); } catch { } try { if (!proc.HasExited) proc.Kill(entireProcessTree: true); } catch { }
@ -60,8 +67,18 @@ public sealed class DesktopProcessRunner : IProcessRunner
var stderr = new System.Text.StringBuilder(); var stderr = new System.Text.StringBuilder();
var outTask = TapAsync(proc.StandardOutput, "out", stdout, onLine, ct); var outTask = TapAsync(proc.StandardOutput, "out", stdout, onLine, ct);
var errTask = TapAsync(proc.StandardError, "err", stderr, onLine, ct); var errTask = TapAsync(proc.StandardError, "err", stderr, onLine, ct);
try
{
await proc.WaitForExitAsync(ct); await proc.WaitForExitAsync(ct);
await Task.WhenAll(outTask, errTask); await Task.WhenAll(outTask, errTask);
}
catch (OperationCanceledException)
{
try { if (!proc.HasExited) proc.Kill(entireProcessTree: true); } catch { }
throw;
}
ct.ThrowIfCancellationRequested();
return new ProcessResult(proc.ExitCode, stdout.ToString(), stderr.ToString()); return new ProcessResult(proc.ExitCode, stdout.ToString(), stderr.ToString());
} }
finally finally
@ -73,6 +90,8 @@ public sealed class DesktopProcessRunner : IProcessRunner
private static async Task TapAsync(StreamReader reader, string stream, StringBuilder acc, private static async Task TapAsync(StreamReader reader, string stream, StringBuilder acc,
Action<string, string>? onLine, CancellationToken ct) Action<string, string>? onLine, CancellationToken ct)
{
try
{ {
string? line; string? line;
while ((line = await reader.ReadLineAsync(ct)) != null) while ((line = await reader.ReadLineAsync(ct)) != null)
@ -81,4 +100,8 @@ public sealed class DesktopProcessRunner : IProcessRunner
if (onLine != null && !string.IsNullOrWhiteSpace(line)) onLine(stream, line); if (onLine != null && !string.IsNullOrWhiteSpace(line)) onLine(stream, line);
} }
} }
catch (OperationCanceledException) { }
catch (ObjectDisposedException) { }
catch (IOException) { }
}
} }

View file

@ -1,4 +1,4 @@
using System.Numerics; using System.Numerics;
namespace Paca.Core.Platform; namespace Paca.Core.Platform;
@ -11,26 +11,43 @@ public sealed class VideoDeduplicator
{ {
private readonly int _maxHammingThreshold; private readonly int _maxHammingThreshold;
private readonly Dictionary<string, ulong> _registry = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, ulong> _registry = new(StringComparer.OrdinalIgnoreCase);
private readonly object _syncLock = new();
public VideoDeduplicator(int maxHammingThreshold = 5) public VideoDeduplicator(int maxHammingThreshold = 5)
{ {
_maxHammingThreshold = maxHammingThreshold; _maxHammingThreshold = Math.Clamp(maxHammingThreshold, 0, 64);
} }
public int RegisteredCount => _registry.Count; public int RegisteredCount
{
get
{
lock (_syncLock) return _registry.Count;
}
}
public void RegisterVideo(string filePath, ulong pHash) public void RegisterVideo(string filePath, ulong pHash)
{ {
if (string.IsNullOrWhiteSpace(filePath)) return; if (string.IsNullOrWhiteSpace(filePath)) return;
_registry[filePath] = pHash; var cleanPath = filePath.Replace("\"", "").Trim();
lock (_syncLock)
{
_registry[cleanPath] = pHash;
}
} }
public DuplicateMatch? FindDuplicate(ulong targetHash) public DuplicateMatch? FindDuplicate(ulong targetHash)
{ {
KeyValuePair<string, ulong>[] snapshot;
lock (_syncLock)
{
snapshot = _registry.ToArray();
}
DuplicateMatch? bestMatch = null; DuplicateMatch? bestMatch = null;
int minDistance = int.MaxValue; int minDistance = int.MaxValue;
foreach (var (path, hash) in _registry) foreach (var (path, hash) in snapshot)
{ {
var distance = ComputeHammingDistance(targetHash, hash); var distance = ComputeHammingDistance(targetHash, hash);
if (distance <= _maxHammingThreshold && distance < minDistance) if (distance <= _maxHammingThreshold && distance < minDistance)
@ -43,7 +60,10 @@ public sealed class VideoDeduplicator
return bestMatch; return bestMatch;
} }
public void Clear() => _registry.Clear(); public void Clear()
{
lock (_syncLock) _registry.Clear();
}
public static int ComputeHammingDistance(ulong a, ulong b) public static int ComputeHammingDistance(ulong a, ulong b)
{ {

View file

@ -1,4 +1,4 @@
namespace Paca.Core.Platform; namespace Paca.Core.Platform;
public enum DenoiseStrength public enum DenoiseStrength
{ {
@ -72,25 +72,33 @@ public sealed class VideoDenoiseUpscaler
public async Task<string> EnhanceVideoAsync(string ffmpeg, string inputFile, string outputFile, DenoiseUpscaleSpec spec, CancellationToken ct) public async Task<string> EnhanceVideoAsync(string ffmpeg, string inputFile, string outputFile, DenoiseUpscaleSpec spec, CancellationToken ct)
{ {
if (spec == null) throw new ArgumentNullException(nameof(spec)); if (spec == null) throw new ArgumentNullException(nameof(spec));
if (string.IsNullOrWhiteSpace(inputFile)) throw new ArgumentException("입력 파일 경로가 유효하지 않습니다.", nameof(inputFile));
if (string.IsNullOrWhiteSpace(outputFile)) throw new ArgumentException("출력 파일 경로가 유효하지 않습니다.", nameof(outputFile));
var cleanInput = inputFile.Replace("\"", "").Trim();
var cleanOutput = outputFile.Replace("\"", "").Trim();
if (string.Equals(Path.GetFullPath(cleanInput), Path.GetFullPath(cleanOutput), StringComparison.OrdinalIgnoreCase))
throw new ArgumentException("입력 파일과 출력 파일 경로가 동일할 수 없습니다. 원본 손상을 방지하기 위해 별도의 출력 경로를 지정해야 합니다.");
var filter = BuildCombinedFilter(spec); var filter = BuildCombinedFilter(spec);
var args = new[] var args = new[]
{ {
"-y", "-hide_banner", "-loglevel", "error", "-y", "-hide_banner", "-loglevel", "error",
"-i", inputFile, "-i", cleanInput,
"-vf", filter, "-vf", filter,
"-c:v", "libx264", "-c:v", "libx264",
"-preset", "medium", "-preset", "medium",
"-crf", "18", "-crf", "18",
"-c:a", "copy", "-c:a", "copy",
outputFile cleanOutput
}; };
var res = await _runner.RunAsync(new ProcessSpec(ffmpeg, args), null, ct); var res = await _runner.RunAsync(new ProcessSpec(ffmpeg, args), null, ct);
if (res.ExitCode != 0) if (res.ExitCode != 0)
throw new InvalidOperationException($"비디오 화질 개선 실패: {res.Stderr}"); throw new InvalidOperationException($"비디오 화질 개선 실패: {res.Stderr}");
return outputFile; return cleanOutput;
} }
} }

View file

@ -1,4 +1,4 @@
using System.Globalization; using System.Globalization;
namespace Paca.Core.Platform; namespace Paca.Core.Platform;
@ -32,7 +32,7 @@ public sealed class VideoWatermarkBurner
public static string BuildOverlayFilter(WatermarkPosition position, int margin = 10, double opacity = 1.0) public static string BuildOverlayFilter(WatermarkPosition position, int margin = 10, double opacity = 1.0)
{ {
if (opacity < 0.0 || opacity > 1.0) if (double.IsNaN(opacity) || opacity < 0.0 || opacity > 1.0)
throw new ArgumentOutOfRangeException(nameof(opacity), "투명도는 0.0과 1.0 사이여야 합니다."); throw new ArgumentOutOfRangeException(nameof(opacity), "투명도는 0.0과 1.0 사이여야 합니다.");
string posStr = position switch string posStr = position switch
@ -51,12 +51,23 @@ public sealed class VideoWatermarkBurner
public static string BuildDrawTextFilter(string text, int fontSize = 24, string fontColor = "white", int x = 10, int y = 10) public static string BuildDrawTextFilter(string text, int fontSize = 24, string fontColor = "white", int x = 10, int y = 10)
{ {
return $"drawtext=text='{text}':fontsize={fontSize}:fontcolor={fontColor}:x={x}:y={y}"; var escaped = (text ?? "")
.Replace("\r", "")
.Replace("\n", " ")
.Replace(@"\", @"\\")
.Replace("'", @"\'")
.Replace(":", @"\:")
.Replace("%", "%%");
return $"drawtext=text='{escaped}':fontsize={fontSize}:fontcolor={fontColor}:x={x}:y={y}";
} }
public async Task<string> ApplyWatermarkAsync(string ffmpeg, string inputFile, string outputFile, WatermarkSpec spec, CancellationToken ct) public async Task<string> ApplyWatermarkAsync(string ffmpeg, string inputFile, string outputFile, WatermarkSpec spec, CancellationToken ct)
{ {
if (spec == null) throw new ArgumentNullException(nameof(spec)); if (spec == null) throw new ArgumentNullException(nameof(spec));
if (string.IsNullOrWhiteSpace(inputFile)) throw new ArgumentException("입력 파일 경로가 유효하지 않습니다.", nameof(inputFile));
if (string.IsNullOrWhiteSpace(outputFile)) throw new ArgumentException("출력 파일 경로가 유효하지 않습니다.", nameof(outputFile));
if (string.Equals(Path.GetFullPath(inputFile), Path.GetFullPath(outputFile), StringComparison.OrdinalIgnoreCase))
throw new ArgumentException("입력 파일과 출력 파일 경로가 동일할 수 없습니다. 원본 손상을 방지하기 위해 별도의 출력 경로를 지정해야 합니다.");
var filterComplex = BuildOverlayFilter(spec.Position, spec.Margin, spec.Opacity); var filterComplex = BuildOverlayFilter(spec.Position, spec.Margin, spec.Opacity);

View file

@ -1,4 +1,4 @@
using System.Globalization; using System.Globalization;
using System.Text; using System.Text;
namespace Paca.Core.Platform; namespace Paca.Core.Platform;
@ -67,6 +67,7 @@ public static class VoiceActivitySubtitleAdapter
int cueIndex = 1; int cueIndex = 1;
foreach (var seg in segments) foreach (var seg in segments)
{ {
if (seg == null) continue;
sb.AppendLine(cueIndex.ToString()); sb.AppendLine(cueIndex.ToString());
sb.AppendLine($"{FormatTimecode(seg.StartSec)} --> {FormatTimecode(seg.EndSec)}"); sb.AppendLine($"{FormatTimecode(seg.StartSec)} --> {FormatTimecode(seg.EndSec)}");
sb.AppendLine($"[AI Speech Segment {cueIndex}]"); sb.AppendLine($"[AI Speech Segment {cueIndex}]");
@ -79,6 +80,12 @@ public static class VoiceActivitySubtitleAdapter
public static string FormatTimecode(double totalSeconds) public static string FormatTimecode(double totalSeconds)
{ {
if (double.IsNaN(totalSeconds) || totalSeconds <= 0.0)
return "00:00:00.000";
if (double.IsInfinity(totalSeconds) || totalSeconds >= 359999.999)
return "99:59:59.999";
var ts = TimeSpan.FromSeconds(totalSeconds); var ts = TimeSpan.FromSeconds(totalSeconds);
return string.Format(CultureInfo.InvariantCulture, "{0:D2}:{1:D2}:{2:D2}.{3:D3}", return string.Format(CultureInfo.InvariantCulture, "{0:D2}:{1:D2}:{2:D2}.{3:D3}",
(int)ts.TotalHours, (int)ts.TotalHours,

View file

@ -1,4 +1,4 @@
namespace Paca.Core.Platform; namespace Paca.Core.Platform;
public sealed record VrSpatialMetadata( public sealed record VrSpatialMetadata(
string Projection, string Projection,
@ -40,21 +40,29 @@ public sealed class VrSpatialAudioEngine
public async Task<string> InjectVrMetadataAsync(string ffmpeg, string inputFile, string outputFile, VrSpatialMetadata meta, CancellationToken ct) public async Task<string> InjectVrMetadataAsync(string ffmpeg, string inputFile, string outputFile, VrSpatialMetadata meta, CancellationToken ct)
{ {
if (meta == null) throw new ArgumentNullException(nameof(meta)); if (meta == null) throw new ArgumentNullException(nameof(meta));
if (string.IsNullOrWhiteSpace(inputFile)) throw new ArgumentException("입력 파일 경로가 유효하지 않습니다.", nameof(inputFile));
if (string.IsNullOrWhiteSpace(outputFile)) throw new ArgumentException("출력 파일 경로가 유효하지 않습니다.", nameof(outputFile));
var cleanInput = inputFile.Replace("\"", "").Trim();
var cleanOutput = outputFile.Replace("\"", "").Trim();
if (string.Equals(System.IO.Path.GetFullPath(cleanInput), System.IO.Path.GetFullPath(cleanOutput), StringComparison.OrdinalIgnoreCase))
throw new ArgumentException("입력 파일과 출력 파일 경로가 동일할 수 없습니다. 원본 손상을 방지하기 위해 별도의 출력 경로를 지정해야 합니다.");
var args = new[] var args = new[]
{ {
"-y", "-hide_banner", "-loglevel", "error", "-y", "-hide_banner", "-loglevel", "error",
"-i", inputFile, "-i", cleanInput,
"-c", "copy", "-c", "copy",
"-metadata", $"spherical-video=projection:{meta.Projection}", "-metadata", $"spherical-video=projection:{meta.Projection ?? "equirectangular"}",
"-metadata", $"stereo-mode={meta.StereoMode}", "-metadata", $"stereo-mode={meta.StereoMode ?? "mono"}",
outputFile cleanOutput
}; };
var res = await _runner.RunAsync(new ProcessSpec(ffmpeg, args), null, ct); var res = await _runner.RunAsync(new ProcessSpec(ffmpeg, args), null, ct);
if (res.ExitCode != 0) if (res.ExitCode != 0)
throw new InvalidOperationException($"VR 메타데이터 주입 실패: {res.Stderr}"); throw new InvalidOperationException($"VR 메타데이터 주입 실패: {res.Stderr}");
return outputFile; return cleanOutput;
} }
} }

View file

@ -20,10 +20,32 @@ public sealed record ClipboardIntentResult(
public sealed class QuickActionClipboardDetector public sealed class QuickActionClipboardDetector
{ {
private static readonly Regex MediaUrlRegex = new( private static readonly HashSet<string> KnownMediaHosts = new(StringComparer.OrdinalIgnoreCase)
@"(?:https?://)?(?:www\.)?(?:youtube\.com|youtu\.be|twitter\.com|x\.com|tiktok\.com|instagram\.com|vimeo\.com|facebook\.com|twitch\.tv|bilibili\.com|soundcloud\.com)|(?:\.m3u8|\.mpd|\.mp4|\.m4a|\.webm)(?:\?|$)", {
RegexOptions.IgnoreCase | RegexOptions.Compiled "youtube.com", "youtu.be", "twitter.com", "x.com", "tiktok.com",
); "instagram.com", "vimeo.com", "facebook.com", "fb.watch",
"twitch.tv", "bilibili.com", "soundcloud.com"
};
private static bool IsMediaHost(string host)
{
if (KnownMediaHosts.Contains(host)) return true;
foreach (var m in KnownMediaHosts)
{
if (host.EndsWith("." + m, StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
private static bool HasMediaExtension(string path)
{
return path.EndsWith(".m3u8", StringComparison.OrdinalIgnoreCase) ||
path.EndsWith(".mpd", StringComparison.OrdinalIgnoreCase) ||
path.EndsWith(".mp4", StringComparison.OrdinalIgnoreCase) ||
path.EndsWith(".m4a", StringComparison.OrdinalIgnoreCase) ||
path.EndsWith(".webm", StringComparison.OrdinalIgnoreCase);
}
private static readonly Regex MathExpressionRegex = new( private static readonly Regex MathExpressionRegex = new(
@"^[\s\d\.\+\-\*\/\(\)\^\%\=]+$", @"^[\s\d\.\+\-\*\/\(\)\^\%\=]+$",
@ -32,16 +54,34 @@ public sealed class QuickActionClipboardDetector
public ClipboardIntentResult DetectIntent(string? text) public ClipboardIntentResult DetectIntent(string? text)
{ {
if (string.IsNullOrWhiteSpace(text)) if (string.IsNullOrWhiteSpace(text) || text.Length > 8192)
return new ClipboardIntentResult(ClipboardIntentType.None, null, null, null); return new ClipboardIntentResult(ClipboardIntentType.None, null, null, null);
var trimmed = text.Trim(); var trimmed = text.Trim();
if (trimmed.Length > 4096)
return new ClipboardIntentResult(ClipboardIntentType.None, trimmed, null, null);
string urlCandidate = trimmed;
if (!urlCandidate.StartsWith("http://", StringComparison.OrdinalIgnoreCase) &&
!urlCandidate.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
if (urlCandidate.StartsWith("www.", StringComparison.OrdinalIgnoreCase) ||
urlCandidate.StartsWith("youtube.com", StringComparison.OrdinalIgnoreCase) ||
urlCandidate.StartsWith("youtu.be", StringComparison.OrdinalIgnoreCase) ||
urlCandidate.StartsWith("twitter.com", StringComparison.OrdinalIgnoreCase) ||
urlCandidate.StartsWith("x.com", StringComparison.OrdinalIgnoreCase) ||
urlCandidate.StartsWith("tiktok.com", StringComparison.OrdinalIgnoreCase) ||
urlCandidate.StartsWith("instagram.com", StringComparison.OrdinalIgnoreCase))
{
urlCandidate = "https://" + urlCandidate;
}
}
// 1. 미디어 스트림/동영상 URL 감지 // 1. 미디어 스트림/동영상 URL 감지
if (Uri.TryCreate(trimmed, UriKind.Absolute, out var uri) && if (Uri.TryCreate(urlCandidate, UriKind.Absolute, out var uri) &&
(uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)) (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
{ {
if (MediaUrlRegex.IsMatch(trimmed)) if (IsMediaHost(uri.Host) || HasMediaExtension(uri.AbsolutePath))
{ {
return new ClipboardIntentResult( return new ClipboardIntentResult(
ClipboardIntentType.MediaDownload, ClipboardIntentType.MediaDownload,
@ -60,8 +100,9 @@ public sealed class QuickActionClipboardDetector
); );
} }
// 3. 인라인 수식 감지 (연산자가 포함되어 있고 3글자 이상인 경우) // 3. 인라인 수식 감지 (숫자가 포함되어 있고 연산자가 포함되어 있는 경우)
if (trimmed.Length >= 3 && if (trimmed.Length >= 3 &&
trimmed.Any(char.IsDigit) &&
(trimmed.Contains('+') || trimmed.Contains('-') || trimmed.Contains('*') || trimmed.Contains('/') || trimmed.Contains('^')) && (trimmed.Contains('+') || trimmed.Contains('-') || trimmed.Contains('*') || trimmed.Contains('/') || trimmed.Contains('^')) &&
MathExpressionRegex.IsMatch(trimmed)) MathExpressionRegex.IsMatch(trimmed))
{ {

View file

@ -34,28 +34,37 @@ public static class QuickActionFuzzyMatcher
if (string.IsNullOrEmpty(s)) return t?.Length ?? 0; if (string.IsNullOrEmpty(s)) return t?.Length ?? 0;
if (string.IsNullOrEmpty(t)) return s.Length; if (string.IsNullOrEmpty(t)) return s.Length;
if (s.Length > 512) s = s[..512];
if (t.Length > 512) t = t[..512];
string sLower = s.ToLowerInvariant(); string sLower = s.ToLowerInvariant();
string tLower = t.ToLowerInvariant(); string tLower = t.ToLowerInvariant();
int n = sLower.Length; int n = sLower.Length;
int m = tLower.Length; int m = tLower.Length;
int[,] d = new int[n + 1, m + 1];
for (int i = 0; i <= n; i++) d[i, 0] = i; int[] prev = new int[m + 1];
for (int j = 0; j <= m; j++) d[0, j] = j; int[] curr = new int[m + 1];
for (int j = 0; j <= m; j++) prev[j] = j;
for (int i = 1; i <= n; i++) for (int i = 1; i <= n; i++)
{ {
curr[0] = i;
char sChar = sLower[i - 1];
for (int j = 1; j <= m; j++) for (int j = 1; j <= m; j++)
{ {
int cost = (sLower[i - 1] == tLower[j - 1]) ? 0 : 1; int cost = (sChar == tLower[j - 1]) ? 0 : 1;
d[i, j] = Math.Min( curr[j] = Math.Min(
Math.Min(d[i - 1, j] + 1, d[i, j - 1] + 1), Math.Min(curr[j - 1] + 1, prev[j] + 1),
d[i - 1, j - 1] + cost); prev[j - 1] + cost);
}
} }
return d[n, m]; Array.Copy(curr, prev, m + 1);
}
return prev[m];
} }
public static int CalculateFuzzyScore(string? pattern, string? target) public static int CalculateFuzzyScore(string? pattern, string? target)

View file

@ -41,8 +41,8 @@ public sealed class QuickActionInlineEvaluator
return true; return true;
} }
// Basic arithmetic: e.g. "12 + 34", "100 * 5", "2 ^ 10", "1000 - 350", "10 / 4" // Basic arithmetic: e.g. "12 + 34", "-5 + 10", "100 * 5", "2 ^ 10", "1000 - 350", "10 / 4"
var opMatch = Regex.Match(expr, @"^([0-9\.]+)\s*([\+\-\*\/\^])\s*([0-9\.]+)$"); var opMatch = Regex.Match(expr, @"^([\+\-]?[0-9\.]+)\s*([\+\-\*\/\^])\s*([\+\-]?[0-9\.]+)$");
if (opMatch.Success && if (opMatch.Success &&
double.TryParse(opMatch.Groups[1].Value, NumberStyles.Any, CultureInfo.InvariantCulture, out double a) && double.TryParse(opMatch.Groups[1].Value, NumberStyles.Any, CultureInfo.InvariantCulture, out double a) &&
double.TryParse(opMatch.Groups[3].Value, NumberStyles.Any, CultureInfo.InvariantCulture, out double b)) double.TryParse(opMatch.Groups[3].Value, NumberStyles.Any, CultureInfo.InvariantCulture, out double b))
@ -89,6 +89,10 @@ public sealed class QuickActionInlineEvaluator
{ {
byte[] bytes = Convert.FromBase64String(raw); byte[] bytes = Convert.FromBase64String(raw);
string text = Encoding.UTF8.GetString(bytes); string text = Encoding.UTF8.GetString(bytes);
if (text.Contains('\0'))
{
text = $"[바이너리 데이터 ({bytes.Length} 바이트)] " + Convert.ToHexString(bytes);
}
result = new EvaluatorResult(text, "Base64 디코딩 결과", "dev.base64_decode"); result = new EvaluatorResult(text, "Base64 디코딩 결과", "dev.base64_decode");
return true; return true;
} }
@ -109,6 +113,10 @@ public sealed class QuickActionInlineEvaluator
{ {
string hex = query.StartsWith("hex ", StringComparison.OrdinalIgnoreCase) ? query[4..].Trim() : query.Trim(); string hex = query.StartsWith("hex ", StringComparison.OrdinalIgnoreCase) ? query[4..].Trim() : query.Trim();
hex = hex.TrimStart('#'); hex = hex.TrimStart('#');
if (hex.Length == 3)
{
hex = $"{hex[0]}{hex[0]}{hex[1]}{hex[1]}{hex[2]}{hex[2]}";
}
if (hex.Length == 6 && if (hex.Length == 6 &&
byte.TryParse(hex[0..2], NumberStyles.HexNumber, null, out byte r) && byte.TryParse(hex[0..2], NumberStyles.HexNumber, null, out byte r) &&
byte.TryParse(hex[2..4], NumberStyles.HexNumber, null, out byte g) && byte.TryParse(hex[2..4], NumberStyles.HexNumber, null, out byte g) &&

View file

@ -73,8 +73,8 @@ public sealed class QuickActionParameterRouter
IconGlyph: "\uE8AC", IconGlyph: "\uE8AC",
Handler: ctx => Handler: ctx =>
{ {
ctx.ClipboardSetter?.Invoke(encoded); ctx?.ClipboardSetter?.Invoke(encoded);
ctx.ToastNotifier?.Invoke("Base64 복사 완료"); ctx?.ToastNotifier?.Invoke("Base64 복사 완료");
return Task.FromResult(new QuickActionResult(true, OutputValue: encoded)); return Task.FromResult(new QuickActionResult(true, OutputValue: encoded));
}, },
Description: $"'{text}' ➔ Base64로 인코딩하여 클립보드에 복사합니다." Description: $"'{text}' ➔ Base64로 인코딩하여 클립보드에 복사합니다."
@ -109,8 +109,8 @@ public sealed class QuickActionParameterRouter
IconGlyph: "\uE8AC", IconGlyph: "\uE8AC",
Handler: ctx => Handler: ctx =>
{ {
ctx.ClipboardSetter?.Invoke(decoded); ctx?.ClipboardSetter?.Invoke(decoded);
ctx.ToastNotifier?.Invoke("디코딩 복사 완료"); ctx?.ToastNotifier?.Invoke("디코딩 복사 완료");
return Task.FromResult(new QuickActionResult(true, OutputValue: decoded)); return Task.FromResult(new QuickActionResult(true, OutputValue: decoded));
}, },
Description: $"Base64 문자열을 디코딩하여 클립보드에 복사합니다." Description: $"Base64 문자열을 디코딩하여 클립보드에 복사합니다."
@ -142,8 +142,8 @@ public sealed class QuickActionParameterRouter
IconGlyph: "\uE943", IconGlyph: "\uE943",
Handler: ctx => Handler: ctx =>
{ {
ctx.ClipboardSetter?.Invoke(hex); ctx?.ClipboardSetter?.Invoke(hex);
ctx.ToastNotifier?.Invoke("SHA-256 해시 복사 완료"); ctx?.ToastNotifier?.Invoke("SHA-256 해시 복사 완료");
return Task.FromResult(new QuickActionResult(true, OutputValue: hex)); return Task.FromResult(new QuickActionResult(true, OutputValue: hex));
}, },
Description: $"'{text}'에 대한 전체 SHA-256 해시값을 클립보드에 복사합니다." Description: $"'{text}'에 대한 전체 SHA-256 해시값을 클립보드에 복사합니다."
@ -167,7 +167,7 @@ public sealed class QuickActionParameterRouter
IconGlyph: "\uE70B", IconGlyph: "\uE70B",
Handler: ctx => Handler: ctx =>
{ {
ctx.ToastNotifier?.Invoke($"메모 저장됨: {text}"); ctx?.ToastNotifier?.Invoke($"메모 저장됨: {text}");
return Task.FromResult(new QuickActionResult(true, OutputValue: text)); return Task.FromResult(new QuickActionResult(true, OutputValue: text));
}, },
Description: $"'{text}' 텍스트를 PACA 빠른 메모에 즉시 기록합니다." Description: $"'{text}' 텍스트를 PACA 빠른 메모에 즉시 기록합니다."

View file

@ -36,38 +36,44 @@ public sealed class CrdtVaultSyncEngine<T>
public CrdtItemRecord<T> Set(string id, T value) public CrdtItemRecord<T> Set(string id, T value)
{ {
if (string.IsNullOrWhiteSpace(id))
throw new ArgumentException("Item ID cannot be null or whitespace.", nameof(id));
lock (_lock) lock (_lock)
{ {
_currentLamportClock++; _currentLamportClock++;
var record = new CrdtItemRecord<T> var record = new CrdtItemRecord<T>
{ {
Id = id, Id = id.Trim(),
Value = value, Value = value,
LamportTimestamp = _currentLamportClock, LamportTimestamp = _currentLamportClock,
ClientId = _clientId, ClientId = _clientId,
IsDeleted = false, IsDeleted = false,
UtcTimestamp = DateTime.UtcNow UtcTimestamp = DateTime.UtcNow
}; };
_items[id] = record; _items[record.Id] = record;
return record; return record;
} }
} }
public CrdtItemRecord<T> Delete(string id) public CrdtItemRecord<T> Delete(string id)
{ {
if (string.IsNullOrWhiteSpace(id))
throw new ArgumentException("Item ID cannot be null or whitespace.", nameof(id));
lock (_lock) lock (_lock)
{ {
_currentLamportClock++; _currentLamportClock++;
var record = new CrdtItemRecord<T> var record = new CrdtItemRecord<T>
{ {
Id = id, Id = id.Trim(),
Value = default, Value = default,
LamportTimestamp = _currentLamportClock, LamportTimestamp = _currentLamportClock,
ClientId = _clientId, ClientId = _clientId,
IsDeleted = true, IsDeleted = true,
UtcTimestamp = DateTime.UtcNow UtcTimestamp = DateTime.UtcNow
}; };
_items[id] = record; _items[record.Id] = record;
return record; return record;
} }
} }
@ -93,11 +99,18 @@ public sealed class CrdtVaultSyncEngine<T>
public int Merge(IEnumerable<CrdtItemRecord<T>> remoteRecords) public int Merge(IEnumerable<CrdtItemRecord<T>> remoteRecords)
{ {
if (remoteRecords == null) return 0;
int appliedCount = 0; int appliedCount = 0;
lock (_lock) lock (_lock)
{ {
foreach (var remote in remoteRecords) foreach (var remote in remoteRecords)
{ {
if (remote == null || string.IsNullOrWhiteSpace(remote.Id))
continue;
if (remote.LamportTimestamp < 0)
remote.LamportTimestamp = 0;
if (!_items.TryGetValue(remote.Id, out var local)) if (!_items.TryGetValue(remote.Id, out var local))
{ {
_items[remote.Id] = remote; _items[remote.Id] = remote;

View file

@ -25,7 +25,8 @@ public static class CredentialDataExchange
/// </summary> /// </summary>
public static byte[] ExportToEncryptedVault(IReadOnlyList<SiteCredentialRecord> records, string masterPassword) public static byte[] ExportToEncryptedVault(IReadOnlyList<SiteCredentialRecord> records, string masterPassword)
{ {
if (string.IsNullOrEmpty(masterPassword)) ArgumentNullException.ThrowIfNull(records);
if (string.IsNullOrWhiteSpace(masterPassword))
throw new ArgumentException("마스터 비밀번호를 입력해야 합니다.", nameof(masterPassword)); throw new ArgumentException("마스터 비밀번호를 입력해야 합니다.", nameof(masterPassword));
var exportItems = records.Select(r => new var exportItems = records.Select(r => new
@ -110,7 +111,7 @@ public static class CredentialDataExchange
byte[] tag = br.ReadBytes(TagSizeBytes); byte[] tag = br.ReadBytes(TagSizeBytes);
int cipherLen = br.ReadInt32(); int cipherLen = br.ReadInt32();
if (cipherLen <= 0 || cipherLen > 50 * 1024 * 1024) // 50MB 상한 if (cipherLen <= 0 || cipherLen > 50 * 1024 * 1024 || cipherLen > (ms.Length - ms.Position)) // 50MB 상한 및 잔여 바이트 검증
throw new InvalidDataException("암호문 크기가 유효 범위를 벗어났습니다."); throw new InvalidDataException("암호문 크기가 유효 범위를 벗어났습니다.");
byte[] ciphertext = br.ReadBytes(cipherLen); byte[] ciphertext = br.ReadBytes(cipherLen);
@ -179,15 +180,18 @@ public static class CredentialDataExchange
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.AppendLine("name,url,username,password,note,totp,category"); sb.AppendLine("name,url,username,password,note,totp,category");
if (records == null) return sb.ToString();
foreach (var r in records) foreach (var r in records)
{ {
string name = EscapeCsv(r.SiteName.Length > 0 ? r.SiteName : r.Domain); if (r == null) continue;
string url = EscapeCsv(r.Url.Length > 0 ? r.Url : "https://" + r.Domain); string name = EscapeCsv(SanitizeCsvFormula(r.SiteName.Length > 0 ? r.SiteName : r.Domain));
string user = EscapeCsv(r.Username); string url = EscapeCsv(SanitizeCsvFormula(r.Url.Length > 0 ? r.Url : "https://" + r.Domain));
string pass = EscapeCsv(!string.IsNullOrEmpty(r.DecryptedPassword) ? r.DecryptedPassword : r.EncryptedPassword); string user = EscapeCsv(SanitizeCsvFormula(r.Username));
string note = EscapeCsv(r.Notes); string pass = EscapeCsv(SanitizeCsvFormula(!string.IsNullOrEmpty(r.DecryptedPassword) ? r.DecryptedPassword : r.EncryptedPassword));
string totp = EscapeCsv(r.TotpSecret ?? ""); string note = EscapeCsv(SanitizeCsvFormula(r.Notes));
string cat = EscapeCsv(r.Category); string totp = EscapeCsv(SanitizeCsvFormula(r.TotpSecret ?? ""));
string cat = EscapeCsv(SanitizeCsvFormula(r.Category));
sb.AppendLine($"{name},{url},{user},{pass},{note},{totp},{cat}"); sb.AppendLine($"{name},{url},{user},{pass},{note},{totp},{cat}");
} }
@ -216,13 +220,13 @@ public static class CredentialDataExchange
var cols = ParseCsvLine(line); var cols = ParseCsvLine(line);
if (cols.Count < 3) continue; if (cols.Count < 3) continue;
string name = cols[0]; string name = UnsanitizeCsvFormula(cols[0]);
string url = cols[1]; string url = UnsanitizeCsvFormula(cols[1]);
string user = cols[2]; string user = UnsanitizeCsvFormula(cols[2]);
string pass = cols.Count > 3 ? cols[3] : ""; string pass = cols.Count > 3 ? UnsanitizeCsvFormula(cols[3]) : "";
string note = cols.Count > 4 ? cols[4] : ""; string note = cols.Count > 4 ? UnsanitizeCsvFormula(cols[4]) : "";
string totp = cols.Count > 5 ? cols[5] : ""; string totp = cols.Count > 5 ? UnsanitizeCsvFormula(cols[5]) : "";
string cat = cols.Count > 6 ? cols[6] : "General"; string cat = cols.Count > 6 ? UnsanitizeCsvFormula(cols[6]) : "General";
var rec = new SiteCredentialRecord var rec = new SiteCredentialRecord
{ {
@ -241,6 +245,27 @@ public static class CredentialDataExchange
return list; return list;
} }
private static string UnsanitizeCsvFormula(string? text)
{
if (string.IsNullOrEmpty(text)) return "";
if (text.StartsWith("'") && text.Length > 1 && text[1] is '=' or '+' or '-' or '@' or '\t' or '\r')
{
return text[1..];
}
return text;
}
private static string SanitizeCsvFormula(string? text)
{
if (string.IsNullOrEmpty(text)) return "";
char first = text[0];
if (first is '=' or '+' or '-' or '@' or '\t' or '\r')
{
return "'" + text;
}
return text;
}
private static string EscapeCsv(string text) private static string EscapeCsv(string text)
{ {
if (string.IsNullOrEmpty(text)) return ""; if (string.IsNullOrEmpty(text)) return "";

View file

@ -31,18 +31,25 @@ public sealed record VaultSecurityAuditReport(
/// </summary> /// </summary>
public static class CredentialSecurityAuditor public static class CredentialSecurityAuditor
{ {
public static VaultSecurityAuditReport PerformAudit(IReadOnlyList<SiteCredentialRecord> credentials, int oldPasswordDays = 90) public static VaultSecurityAuditReport PerformAudit(IReadOnlyList<SiteCredentialRecord?>? credentials, int oldPasswordDays = 90)
{ {
var issues = new List<VaultAuditIssue>(); var issues = new List<VaultAuditIssue>();
int total = credentials.Count; if (credentials == null || credentials.Count == 0)
if (total == 0)
{ {
return new VaultSecurityAuditReport(100, 0, 0, 0, 0, 0, issues, DateTime.UtcNow); return new VaultSecurityAuditReport(100, 0, 0, 0, 0, 0, issues, DateTime.UtcNow);
} }
var cleanList = credentials.Where(c => c != null).Cast<SiteCredentialRecord>().ToList();
if (cleanList.Count == 0)
{
return new VaultSecurityAuditReport(100, 0, 0, 0, 0, 0, issues, DateTime.UtcNow);
}
oldPasswordDays = Math.Max(1, oldPasswordDays);
int total = cleanList.Count;
// 1. 패스워드 재사용 검사 // 1. 패스워드 재사용 검사
var passwordGroups = credentials var passwordGroups = cleanList
.Where(c => !string.IsNullOrEmpty(c.DecryptedPassword) || !string.IsNullOrEmpty(c.EncryptedPassword)) .Where(c => !string.IsNullOrEmpty(c.DecryptedPassword) || !string.IsNullOrEmpty(c.EncryptedPassword))
.GroupBy(c => !string.IsNullOrEmpty(c.DecryptedPassword) ? c.DecryptedPassword : c.EncryptedPassword) .GroupBy(c => !string.IsNullOrEmpty(c.DecryptedPassword) ? c.DecryptedPassword : c.EncryptedPassword)
.Where(g => g.Count() > 1) .Where(g => g.Count() > 1)
@ -66,7 +73,7 @@ public static class CredentialSecurityAuditor
// 2. 취약한 패스워드 검사 // 2. 취약한 패스워드 검사
int weakCount = 0; int weakCount = 0;
foreach (var item in credentials) foreach (var item in cleanList)
{ {
if (!string.IsNullOrEmpty(item.DecryptedPassword)) if (!string.IsNullOrEmpty(item.DecryptedPassword))
{ {
@ -88,11 +95,11 @@ public static class CredentialSecurityAuditor
// 3. 오래된 비밀번호 검사 (기본 90일) // 3. 오래된 비밀번호 검사 (기본 90일)
int oldCount = 0; int oldCount = 0;
var thresholdDate = DateTime.UtcNow.AddDays(-oldPasswordDays); var thresholdDate = DateTime.UtcNow.AddDays(-oldPasswordDays);
foreach (var item in credentials) foreach (var item in cleanList)
{ {
if (item.LastModifiedAt < thresholdDate || item.CreatedAt < thresholdDate) DateTime effectiveDate = item.LastModifiedAt != default ? item.LastModifiedAt : item.CreatedAt;
if (effectiveDate < thresholdDate)
{ {
DateTime effectiveDate = item.CreatedAt < thresholdDate ? item.CreatedAt : item.LastModifiedAt;
oldCount++; oldCount++;
issues.Add(new VaultAuditIssue( issues.Add(new VaultAuditIssue(
item.Id, item.Domain, item.Username, item.Id, item.Domain, item.Username,
@ -104,8 +111,8 @@ public static class CredentialSecurityAuditor
// 4. 2FA 미설정 검사 (금융/쇼핑/이메일 등 주요 사이트 또는 전체 미설정) // 4. 2FA 미설정 검사 (금융/쇼핑/이메일 등 주요 사이트 또는 전체 미설정)
int missing2FaCount = 0; int missing2FaCount = 0;
bool hasSpecificHighSecurityCategory = credentials.Any(c => (c.Category is "Banking" or "Shopping" or "Email") || c.IsFavorite); bool hasSpecificHighSecurityCategory = cleanList.Any(c => (c.Category is "Banking" or "Shopping" or "Email") || c.IsFavorite);
foreach (var item in credentials) foreach (var item in cleanList)
{ {
if (string.IsNullOrEmpty(item.TotpSecret)) if (string.IsNullOrEmpty(item.TotpSecret))
{ {

View file

@ -1,3 +1,4 @@
using System.IO;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
@ -29,6 +30,10 @@ public static class E2eeRemoteBackupProvider
public static string EncryptPayload(string plainText, string masterPassword) public static string EncryptPayload(string plainText, string masterPassword)
{ {
if (string.IsNullOrWhiteSpace(masterPassword))
throw new ArgumentException("마스터 비밀번호를 입력해야 합니다.", nameof(masterPassword));
plainText ??= "";
byte[] salt = RandomNumberGenerator.GetBytes(SaltSizeBytes); byte[] salt = RandomNumberGenerator.GetBytes(SaltSizeBytes);
byte[] nonce = RandomNumberGenerator.GetBytes(NonceSizeBytes); byte[] nonce = RandomNumberGenerator.GetBytes(NonceSizeBytes);
byte[] key = Rfc2898DeriveBytes.Pbkdf2( byte[] key = Rfc2898DeriveBytes.Pbkdf2(
@ -42,10 +47,18 @@ public static class E2eeRemoteBackupProvider
byte[] cipherBytes = new byte[plainBytes.Length]; byte[] cipherBytes = new byte[plainBytes.Length];
byte[] tag = new byte[TagSizeBytes]; byte[] tag = new byte[TagSizeBytes];
try
{
using (var aesGcm = new AesGcm(key, TagSizeBytes)) using (var aesGcm = new AesGcm(key, TagSizeBytes))
{ {
aesGcm.Encrypt(nonce, plainBytes, cipherBytes, tag); aesGcm.Encrypt(nonce, plainBytes, cipherBytes, tag);
} }
}
finally
{
CryptographicOperations.ZeroMemory(key);
CryptographicOperations.ZeroMemory(plainBytes);
}
var header = new VaultBackupHeader var header = new VaultBackupHeader
{ {
@ -60,13 +73,43 @@ public static class E2eeRemoteBackupProvider
public static string DecryptPayload(string backupJson, string masterPassword) public static string DecryptPayload(string backupJson, string masterPassword)
{ {
var header = JsonSerializer.Deserialize<VaultBackupHeader>(backupJson) if (string.IsNullOrWhiteSpace(backupJson))
?? throw new InvalidOperationException("손상된 백업 파일 헤더입니다."); throw new InvalidDataException("손상되었거나 비어있는 백업 페이로드입니다.");
byte[] salt = Convert.FromBase64String(header.SaltBase64); if (string.IsNullOrWhiteSpace(masterPassword))
byte[] nonce = Convert.FromBase64String(header.NonceBase64); throw new ArgumentException("마스터 비밀번호를 입력해야 합니다.", nameof(masterPassword));
byte[] tag = Convert.FromBase64String(header.TagBase64);
byte[] cipherBytes = Convert.FromBase64String(header.CiphertextBase64); VaultBackupHeader header;
try
{
header = JsonSerializer.Deserialize<VaultBackupHeader>(backupJson)
?? throw new InvalidDataException("손상된 백업 파일 헤더입니다.");
}
catch (Exception ex) when (ex is not InvalidDataException)
{
throw new InvalidDataException("손상된 백업 파일 JSON 형식입니다.", ex);
}
if (header.Magic != "PACAVAULT")
throw new InvalidDataException("유효하지 않은 백업 매직 헤더입니다.");
byte[] salt, nonce, tag, cipherBytes;
try
{
salt = Convert.FromBase64String(header.SaltBase64 ?? "");
nonce = Convert.FromBase64String(header.NonceBase64 ?? "");
tag = Convert.FromBase64String(header.TagBase64 ?? "");
cipherBytes = Convert.FromBase64String(header.CiphertextBase64 ?? "");
}
catch (FormatException ex)
{
throw new InvalidDataException("손상된 Base64 인코딩 데이터입니다.", ex);
}
if (salt.Length != SaltSizeBytes || nonce.Length != NonceSizeBytes || tag.Length != TagSizeBytes)
{
throw new InvalidDataException("암호화 파라미터 규격이 일치하지 않습니다.");
}
byte[] key = Rfc2898DeriveBytes.Pbkdf2( byte[] key = Rfc2898DeriveBytes.Pbkdf2(
Encoding.UTF8.GetBytes(masterPassword), Encoding.UTF8.GetBytes(masterPassword),
@ -76,11 +119,18 @@ public static class E2eeRemoteBackupProvider
KeySizeBytes); KeySizeBytes);
byte[] plainBytes = new byte[cipherBytes.Length]; byte[] plainBytes = new byte[cipherBytes.Length];
try
{
using (var aesGcm = new AesGcm(key, TagSizeBytes)) using (var aesGcm = new AesGcm(key, TagSizeBytes))
{ {
aesGcm.Decrypt(nonce, cipherBytes, tag, plainBytes); aesGcm.Decrypt(nonce, cipherBytes, tag, plainBytes);
} }
return Encoding.UTF8.GetString(plainBytes); return Encoding.UTF8.GetString(plainBytes);
} }
finally
{
CryptographicOperations.ZeroMemory(key);
CryptographicOperations.ZeroMemory(plainBytes);
}
}
} }

View file

@ -145,7 +145,7 @@ public static class PasswordSecurityEngine
public static PasswordStrengthResult EvaluateStrength(string? password) public static PasswordStrengthResult EvaluateStrength(string? password)
{ {
if (string.IsNullOrEmpty(password)) if (string.IsNullOrWhiteSpace(password))
{ {
return new PasswordStrengthResult( return new PasswordStrengthResult(
0, PasswordGrade.VeryWeak, 0, 0, PasswordGrade.VeryWeak, 0,
@ -224,6 +224,9 @@ public static class TotpEngine
DateTime? customTime = null, DateTime? customTime = null,
TotpHashAlgorithm algorithm = TotpHashAlgorithm.Sha1) TotpHashAlgorithm algorithm = TotpHashAlgorithm.Sha1)
{ {
if (timeStepSeconds <= 0) timeStepSeconds = 30;
digits = Math.Clamp(digits, 6, 8);
byte[] key = DecodeBase32(base32Secret); byte[] key = DecodeBase32(base32Secret);
if (key.Length == 0) return "".PadLeft(digits, '0'); if (key.Length == 0) return "".PadLeft(digits, '0');
@ -262,10 +265,11 @@ public static class TotpEngine
public static int GetRemainingSeconds(int timeStepSeconds = 30, DateTime? customTime = null) public static int GetRemainingSeconds(int timeStepSeconds = 30, DateTime? customTime = null)
{ {
if (timeStepSeconds <= 0) timeStepSeconds = 30;
DateTime time = customTime ?? DateTime.UtcNow; DateTime time = customTime ?? DateTime.UtcNow;
long seconds = (long)(time - UnixEpoch).TotalSeconds; long seconds = (long)(time - UnixEpoch).TotalSeconds;
int remaining = timeStepSeconds - (int)(seconds % timeStepSeconds); int remaining = timeStepSeconds - (int)(seconds % timeStepSeconds);
return remaining == 0 ? timeStepSeconds : remaining; return remaining <= 0 ? timeStepSeconds : remaining;
} }
public static bool ValidateCode( public static bool ValidateCode(

View file

@ -51,15 +51,18 @@ public class ThermalThrottlingGuard
if (IsThrottled(currentTemperatureCelsius)) if (IsThrottled(currentTemperatureCelsius))
return 1; // 과열 시 단일 세그먼트로 스로틀링 return 1; // 과열 시 단일 세그먼트로 스로틀링
return requestedSegments; return Math.Max(1, requestedSegments);
} }
} }
/// <summary>FFmpeg 트랜스코딩 프로파일 관리자</summary> /// <summary>FFmpeg 트랜스코딩 프로파일 관리자</summary>
public class TranscodeProfileManager public class TranscodeProfileManager
{ {
public string GetFfmpegProfileArgs(string profileName) public string GetFfmpegProfileArgs(string? profileName)
{ {
if (string.IsNullOrWhiteSpace(profileName))
return "-c copy";
return profileName.ToLowerInvariant() switch return profileName.ToLowerInvariant() switch
{ {
"mp4_universal" => "-c:v libx264 -preset medium -crf 23 -c:a aac -b:a 192k -movflags +faststart", "mp4_universal" => "-c:v libx264 -preset medium -crf 23 -c:a aac -b:a 192k -movflags +faststart",
@ -93,12 +96,25 @@ public class RemoteStreamProxyBuffer
CapacityBytes = capacityBytes; CapacityBytes = capacityBytes;
} }
public void Write(byte[] data) public void Write(byte[]? data)
{ {
if (data == null || data.Length == 0) return;
lock (_lock) lock (_lock)
{ {
_stream.Seek(0, SeekOrigin.End); _stream.Seek(0, SeekOrigin.End);
_stream.Write(data, 0, data.Length); _stream.Write(data, 0, data.Length);
// CapacityBytes 초과 시 가장 오래된 앞부분 바이트 제거 (FIFO 한도 준수)
if (CapacityBytes > 0 && _stream.Length > CapacityBytes)
{
long excess = _stream.Length - CapacityBytes;
_stream.Seek(excess, SeekOrigin.Begin);
byte[] kept = new byte[CapacityBytes];
int read = _stream.Read(kept, 0, CapacityBytes);
_stream.SetLength(0);
_stream.Write(kept, 0, read);
}
} }
} }

View file

@ -28,12 +28,32 @@ public static class SubtitleBurnInEngine
/// </summary> /// </summary>
public static string BuildForceStyleString(SubtitleBurnInStyle style) public static string BuildForceStyleString(SubtitleBurnInStyle style)
{ {
return $"FontName={style.FontName},FontSize={style.FontSize},PrimaryColour={style.PrimaryColor},OutlineColour={style.OutlineColor},Outline={style.OutlineWidth},MarginV={style.MarginV},Alignment={style.Alignment}"; if (style == null)
throw new ArgumentNullException(nameof(style));
if (style.FontSize <= 0)
throw new ArgumentOutOfRangeException(nameof(style), "Font size must be greater than zero.");
var safeFontName = (style.FontName ?? "Arial")
.Replace(",", "")
.Replace(";", "")
.Replace("'", "")
.Replace("\r", "")
.Replace("\n", "")
.Trim();
if (string.IsNullOrWhiteSpace(safeFontName))
safeFontName = "Arial";
var safePrimary = (style.PrimaryColor ?? "&H00FFFFFF").Replace(",", "").Replace(";", "").Trim();
var safeOutline = (style.OutlineColor ?? "&H00000000").Replace(",", "").Replace(";", "").Trim();
int safeOutlineWidth = Math.Max(0, style.OutlineWidth);
int safeMarginV = Math.Max(0, style.MarginV);
return $"FontName={safeFontName},FontSize={style.FontSize},PrimaryColour={safePrimary},OutlineColour={safeOutline},Outline={safeOutlineWidth},MarginV={safeMarginV},Alignment={style.Alignment}";
} }
/// <summary> /// <summary>
/// Escapes Windows/POSIX file paths for use inside ffmpeg filter expressions. /// Escapes Windows/POSIX file paths for use inside ffmpeg filter expressions.
/// Converts colons to '\:' and backslashes to forward slashes. /// Converts colons to '\:', backslashes to forward slashes, and brackets to '\[' / '\]'.
/// </summary> /// </summary>
public static string EscapeSubtitlePathForFfmpeg(string path) public static string EscapeSubtitlePathForFfmpeg(string path)
{ {
@ -43,7 +63,9 @@ public static class SubtitleBurnInEngine
return path return path
.Replace("\\", "/") .Replace("\\", "/")
.Replace(":", "\\:") .Replace(":", "\\:")
.Replace("'", "\\'"); .Replace("'", "\\'")
.Replace("[", "\\[")
.Replace("]", "\\]");
} }
/// <summary> /// <summary>
@ -55,6 +77,15 @@ public static class SubtitleBurnInEngine
string outputVideoPath, string outputVideoPath,
SubtitleBurnInStyle? style = null) SubtitleBurnInStyle? style = null)
{ {
if (string.IsNullOrWhiteSpace(inputVideoPath))
throw new ArgumentException("Input video path cannot be null or empty.", nameof(inputVideoPath));
if (string.IsNullOrWhiteSpace(subtitlePath))
throw new ArgumentException("Subtitle path cannot be null or empty.", nameof(subtitlePath));
if (string.IsNullOrWhiteSpace(outputVideoPath))
throw new ArgumentException("Output video path cannot be null or empty.", nameof(outputVideoPath));
if (string.Equals(inputVideoPath.Trim(), outputVideoPath.Trim(), StringComparison.OrdinalIgnoreCase))
throw new ArgumentException("Input and output paths cannot be identical.", nameof(outputVideoPath));
style ??= SubtitleBurnInStyle.Default; style ??= SubtitleBurnInStyle.Default;
var escapedPath = EscapeSubtitlePathForFfmpeg(subtitlePath); var escapedPath = EscapeSubtitlePathForFfmpeg(subtitlePath);
var forceStyle = BuildForceStyleString(style); var forceStyle = BuildForceStyleString(style);

View file

@ -25,38 +25,58 @@ public static class FileNameUtil
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9" "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9"
}; };
private static bool IsBidiOrInvisible(char c)
{
// Bidi override / isolate / embedding (Trojan Source CVE-2021-42574)
if (c is >= '\u200B' and <= '\u200F' or >= '\u202A' and <= '\u202E' or >= '\u2066' and <= '\u2069' or '\uFEFF')
return true;
return System.Globalization.CharUnicodeInfo.GetUnicodeCategory(c) == System.Globalization.UnicodeCategory.Format;
}
private static string EnsureNotReserved(string name)
{
var nameWithoutExt = Path.GetFileNameWithoutExtension(name);
var rootToken = name.Split('.')[0];
if (ReservedNames.Contains(nameWithoutExt) || ReservedNames.Contains(rootToken))
{
return "_" + name;
}
return name;
}
/// <summary>파일명으로 사용 불가능한 문자를 '_'로 치환하고 길이를 제한한다.</summary> /// <summary>파일명으로 사용 불가능한 문자를 '_'로 치환하고 길이를 제한한다.</summary>
public static string Sanitize(string? name, int maxLen = 120) public static string Sanitize(string? name, int maxLen = 120)
{ {
if (maxLen <= 0) return "video";
if (string.IsNullOrWhiteSpace(name)) return "video"; if (string.IsNullOrWhiteSpace(name)) return "video";
if (string.IsNullOrWhiteSpace(name.Trim().Trim('.'))) return "video";
var extra = new[] { '"', '<', '>', '|', '*', '?', ':', '/', '\\' }; var extra = new[] { '"', '<', '>', '|', '*', '?', ':', '/', '\\' };
var sb = new StringBuilder(); var sb = new StringBuilder();
foreach (var c in name.Trim()) foreach (var c in name.Trim())
{ {
if (Array.IndexOf(Invalid, c) >= 0 || Array.IndexOf(extra, c) >= 0 || char.IsControl(c)) sb.Append('_'); if (Array.IndexOf(Invalid, c) >= 0 || Array.IndexOf(extra, c) >= 0 || char.IsControl(c) || IsBidiOrInvisible(c))
else sb.Append(c); sb.Append('_');
else
sb.Append(c);
} }
var result = sb.ToString().Trim().Trim('.'); var result = sb.ToString().Trim().Trim('.');
if (string.IsNullOrWhiteSpace(result)) result = "video"; if (string.IsNullOrWhiteSpace(result)) result = "video";
// Windows 장치 예약어 보호 (예: CON, aux.mp4, con.tar.gz)
var nameWithoutExt = Path.GetFileNameWithoutExtension(result);
var rootToken = result.Split('.')[0];
if (ReservedNames.Contains(nameWithoutExt) || ReservedNames.Contains(rootToken))
{
result = "_" + result;
}
if (result.Length > maxLen) if (result.Length > maxLen)
{ {
var cut = result[..maxLen]; var cut = result[..maxLen];
// UTF-16 상위 서러게이트로 끝나면 1글자 줄여서 이모지/특수문자 파편화 방지 // UTF-16 상위 서러게이트로 끝나면 1글자 줄여서 이모지/특수문자 파편화 방지
if (char.IsHighSurrogate(cut[^1])) if (cut.Length > 0 && char.IsHighSurrogate(cut[^1]))
{ {
cut = cut[..^1]; cut = cut[..^1];
} }
return cut.Trim().Trim('.'); result = cut.Trim().Trim('.');
if (string.IsNullOrWhiteSpace(result)) result = "video";
} }
// 절단 및 공백 정제 후 최종 윈도우 장치 예약어 보호 (예: CON_video[..3] -> CON -> _CON, con.tar.gz -> _con.tar.gz)
result = EnsureNotReserved(result);
return result; return result;
} }

View file

@ -1,4 +1,4 @@
using System.IO; using System.IO;
namespace Paca.Core.Utils; namespace Paca.Core.Utils;
@ -9,6 +9,7 @@ public static class DiskSpaceGuardian
{ {
public static bool HasSufficientSpace(string targetPath, long requiredBytes) public static bool HasSufficientSpace(string targetPath, long requiredBytes)
{ {
if (requiredBytes <= 0 || string.IsNullOrWhiteSpace(targetPath)) return true;
try try
{ {
var fullPath = Path.GetFullPath(targetPath); var fullPath = Path.GetFullPath(targetPath);
@ -26,13 +27,41 @@ public static class DiskSpaceGuardian
} }
} }
public static bool IsDiskFullException(Exception ex) public static bool IsDiskFullException(Exception? ex)
{ {
if (ex is not IOException ioEx) return false; if (ex == null) return false;
if (ex is AggregateException agg)
{
foreach (var inner in agg.Flatten().InnerExceptions)
{
if (IsDiskFullException(inner))
return true;
}
return false;
}
if (ex is IOException ioEx)
{
// Windows Win32 error codes: ERROR_DISK_FULL (112), ERROR_HANDLE_DISK_FULL (39)
int win32ErrorCode = ioEx.HResult & 0xFFFF;
if (win32ErrorCode is 112 or 39)
return true;
var msg = ioEx.Message.ToLowerInvariant(); var msg = ioEx.Message.ToLowerInvariant();
return msg.Contains("not enough space") || if (msg.Contains("not enough space") ||
msg.Contains("disk full") || msg.Contains("disk full") ||
msg.Contains("디스크 공간이 부족") || msg.Contains("디스크 공간이 부족") ||
msg.Contains("공간이 부족"); msg.Contains("공간이 부족") ||
msg.Contains("espace disque insuffisant"))
{
return true;
}
}
if (ex.InnerException != null && IsDiskFullException(ex.InnerException))
return true;
return false;
} }
} }

View file

@ -47,9 +47,21 @@ public static class VideoAspectAutoCropper
int targetWidth = 1080, int targetWidth = 1080,
int targetHeight = 1920) int targetHeight = 1920)
{ {
if (string.IsNullOrWhiteSpace(inputPath)) throw new ArgumentException("Input path cannot be null or whitespace.", nameof(inputPath));
if (string.IsNullOrWhiteSpace(outputPath)) throw new ArgumentException("Output path cannot be null or whitespace.", nameof(outputPath));
var cleanIn = inputPath.Trim().Replace("\"", "");
var cleanOut = outputPath.Trim().Replace("\"", "");
if (string.Equals(System.IO.Path.GetFullPath(cleanIn), System.IO.Path.GetFullPath(cleanOut), StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("입력 파일과 출력 파일 경로가 동일할 수 없습니다.");
targetWidth = Math.Max(2, targetWidth / 2 * 2);
targetHeight = Math.Max(2, targetHeight / 2 * 2);
var filter = BuildReframingFilterArgs(mode, targetWidth, targetHeight); var filter = BuildReframingFilterArgs(mode, targetWidth, targetHeight);
var filterFlag = mode == ReframingMode.BlurredBackground ? "-filter_complex" : "-vf"; var filterFlag = mode == ReframingMode.BlurredBackground ? "-filter_complex" : "-vf";
return $"-i \"{inputPath}\" {filterFlag} \"{filter}\" -c:v libx264 -crf 20 -preset fast -c:a copy \"{outputPath}\" -y"; return $"-i \"{cleanIn}\" {filterFlag} \"{filter}\" -c:v libx264 -crf 20 -preset fast -c:a copy \"{cleanOut}\" -y";
} }
} }

View file

@ -56,9 +56,18 @@ public static class VideoDenoiseEnhancer
bool sharpen, bool sharpen,
DenoisePreset preset = DenoisePreset.FastTemporalSpatial) DenoisePreset preset = DenoisePreset.FastTemporalSpatial)
{ {
if (string.IsNullOrWhiteSpace(inputPath)) throw new ArgumentException("Input path cannot be null or whitespace.", nameof(inputPath));
if (string.IsNullOrWhiteSpace(outputPath)) throw new ArgumentException("Output path cannot be null or whitespace.", nameof(outputPath));
var cleanIn = inputPath.Trim().Replace("\"", "");
var cleanOut = outputPath.Trim().Replace("\"", "");
if (string.Equals(System.IO.Path.GetFullPath(cleanIn), System.IO.Path.GetFullPath(cleanOut), StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("입력 파일과 출력 파일 경로가 동일할 수 없습니다.");
var filter = BuildEnhanceFilterChain(denoise, sharpen, preset); var filter = BuildEnhanceFilterChain(denoise, sharpen, preset);
var filterArg = !string.IsNullOrEmpty(filter) ? $"-vf \"{filter}\"" : string.Empty; var filterArg = !string.IsNullOrEmpty(filter) ? $"-vf \"{filter}\"" : string.Empty;
return $"-i \"{inputPath}\" {filterArg} -c:v libx264 -crf 20 -preset fast -c:a copy \"{outputPath}\" -y"; return $"-i \"{cleanIn}\" {filterArg} -c:v libx264 -crf 20 -preset fast -c:a copy \"{cleanOut}\" -y";
} }
} }

View file

@ -1,4 +1,4 @@
using System; using System;
namespace Paca.Core; namespace Paca.Core;
@ -23,7 +23,7 @@ public static class VideoFrameRateConverter
/// </summary> /// </summary>
public static string BuildFpsFilter(int targetFps, FrameRateConversionMode mode) public static string BuildFpsFilter(int targetFps, FrameRateConversionMode mode)
{ {
if (targetFps <= 0) targetFps = 60; targetFps = Math.Clamp(targetFps, 1, 240);
return mode switch return mode switch
{ {
@ -38,7 +38,17 @@ public static class VideoFrameRateConverter
/// </summary> /// </summary>
public static string BuildFfmpegArgs(string inputPath, string outputPath, int targetFps, FrameRateConversionMode mode, int crf = 18) public static string BuildFfmpegArgs(string inputPath, string outputPath, int targetFps, FrameRateConversionMode mode, int crf = 18)
{ {
if (string.IsNullOrWhiteSpace(inputPath)) throw new ArgumentException("Input path cannot be null or whitespace.", nameof(inputPath));
if (string.IsNullOrWhiteSpace(outputPath)) throw new ArgumentException("Output path cannot be null or whitespace.", nameof(outputPath));
var cleanIn = inputPath.Trim().Replace("\"", "");
var cleanOut = outputPath.Trim().Replace("\"", "");
if (string.Equals(System.IO.Path.GetFullPath(cleanIn), System.IO.Path.GetFullPath(cleanOut), StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("입력 파일과 출력 파일 경로가 동일할 수 없습니다.");
crf = Math.Clamp(crf, 0, 51);
var filter = BuildFpsFilter(targetFps, mode); var filter = BuildFpsFilter(targetFps, mode);
return $"-hide_banner -y -i \"{inputPath}\" -vf \"{filter}\" -c:v libx264 -crf {crf} -preset fast -c:a copy \"{outputPath}\""; return $"-hide_banner -y -i \"{cleanIn}\" -vf \"{filter}\" -c:v libx264 -crf {crf} -preset fast -c:a copy \"{cleanOut}\"";
} }
} }

View file

@ -79,9 +79,21 @@ public static class VideoIntroOutroTrimmer
double startSec, double startSec,
double durationSec) double durationSec)
{ {
if (string.IsNullOrWhiteSpace(inputPath)) throw new ArgumentException("Input path cannot be null or whitespace.", nameof(inputPath));
if (string.IsNullOrWhiteSpace(outputPath)) throw new ArgumentException("Output path cannot be null or whitespace.", nameof(outputPath));
var cleanIn = inputPath.Trim().Replace("\"", "");
var cleanOut = outputPath.Trim().Replace("\"", "");
if (string.Equals(System.IO.Path.GetFullPath(cleanIn), System.IO.Path.GetFullPath(cleanOut), StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("입력 파일과 출력 파일 경로가 동일할 수 없습니다.");
startSec = Math.Max(0.0, startSec);
durationSec = Math.Max(0.1, durationSec);
var s = startSec.ToString("F2", CultureInfo.InvariantCulture); var s = startSec.ToString("F2", CultureInfo.InvariantCulture);
var d = durationSec.ToString("F2", CultureInfo.InvariantCulture); var d = durationSec.ToString("F2", CultureInfo.InvariantCulture);
return $"-ss {s} -i \"{inputPath}\" -t {d} -c copy \"{outputPath}\" -y"; return $"-ss {s} -i \"{cleanIn}\" -t {d} -c copy \"{cleanOut}\" -y";
} }
} }

View file

@ -65,6 +65,15 @@ public static class VideoPosterExtractor
string outputImagePath, string outputImagePath,
PosterExtractionSpec spec) PosterExtractionSpec spec)
{ {
if (string.IsNullOrWhiteSpace(inputVideoPath)) throw new ArgumentException("Input video path cannot be null or whitespace.", nameof(inputVideoPath));
if (string.IsNullOrWhiteSpace(outputImagePath)) throw new ArgumentException("Output image path cannot be null or whitespace.", nameof(outputImagePath));
var cleanIn = inputVideoPath.Trim().Replace("\"", "");
var cleanOut = outputImagePath.Trim().Replace("\"", "");
if (string.Equals(System.IO.Path.GetFullPath(cleanIn), System.IO.Path.GetFullPath(cleanOut), StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("입력 파일과 출력 파일 경로가 동일할 수 없습니다.");
var seekStr = spec.SeekTimeSec.ToString("F2", CultureInfo.InvariantCulture); var seekStr = spec.SeekTimeSec.ToString("F2", CultureInfo.InvariantCulture);
var scaleFilter = BuildScalingFilter(spec); var scaleFilter = BuildScalingFilter(spec);
@ -75,6 +84,6 @@ public static class VideoPosterExtractor
_ => "-q:v 2" _ => "-q:v 2"
}; };
return $"-ss {seekStr} -i \"{inputVideoPath}\" -vframes 1 -vf \"{scaleFilter}\" {encoderFlags} \"{outputImagePath}\" -y"; return $"-ss {seekStr} -i \"{cleanIn}\" -vframes 1 -vf \"{scaleFilter}\" {encoderFlags} \"{cleanOut}\" -y";
} }
} }

View file

@ -31,9 +31,19 @@ public static class VideoSpriteSheetGenerator
string outputJpgPath, string outputJpgPath,
SpriteSheetConfig config) SpriteSheetConfig config)
{ {
if (string.IsNullOrWhiteSpace(inputVideoPath)) throw new ArgumentException("Input video path cannot be null or whitespace.", nameof(inputVideoPath));
if (string.IsNullOrWhiteSpace(outputJpgPath)) throw new ArgumentException("Output JPG path cannot be null or whitespace.", nameof(outputJpgPath));
var cleanIn = inputVideoPath.Trim().Replace("\"", "");
var cleanOut = outputJpgPath.Trim().Replace("\"", "");
if (string.Equals(System.IO.Path.GetFullPath(cleanIn), System.IO.Path.GetFullPath(cleanOut), StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("입력 파일과 출력 파일 경로가 동일할 수 없습니다.");
if (config.IntervalSec <= 0) throw new ArgumentOutOfRangeException(nameof(config.IntervalSec), "Interval must be positive."); if (config.IntervalSec <= 0) throw new ArgumentOutOfRangeException(nameof(config.IntervalSec), "Interval must be positive.");
if (config.Columns <= 0 || config.Rows <= 0) throw new ArgumentOutOfRangeException(nameof(config), "Columns and Rows must be positive."); if (config.Columns <= 0 || config.Rows <= 0) throw new ArgumentOutOfRangeException(nameof(config), "Columns and Rows must be positive.");
return $"-i \"{inputVideoPath}\" -vf \"fps=1/{config.IntervalSec},scale={config.ThumbWidth}:{config.ThumbHeight},tile={config.Columns}x{config.Rows}\" -frames:v 1 \"{outputJpgPath}\" -y";
return $"-i \"{cleanIn}\" -vf \"fps=1/{config.IntervalSec},scale={config.ThumbWidth}:{config.ThumbHeight},tile={config.Columns}x{config.Rows}\" -frames:v 1 \"{cleanOut}\" -y";
} }
/// <summary> /// <summary>

View file

@ -87,6 +87,19 @@ public static class VideoTonemapEnhancer
string videoEncoder = "libx264", string videoEncoder = "libx264",
string presetSpeed = "fast") string presetSpeed = "fast")
{ {
if (string.IsNullOrWhiteSpace(inputPath)) throw new ArgumentException("Input path cannot be null or whitespace.", nameof(inputPath));
if (string.IsNullOrWhiteSpace(outputPath)) throw new ArgumentException("Output path cannot be null or whitespace.", nameof(outputPath));
var cleanIn = inputPath.Trim().Replace("\"", "");
var cleanOut = outputPath.Trim().Replace("\"", "");
if (string.Equals(System.IO.Path.GetFullPath(cleanIn), System.IO.Path.GetFullPath(cleanOut), StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("입력 파일과 출력 파일 경로가 동일할 수 없습니다.");
crf = Math.Clamp(crf, 0, 51);
videoEncoder = (videoEncoder ?? "libx264").Replace("\"", "").Replace(";", "").Trim();
presetSpeed = (presetSpeed ?? "fast").Replace("\"", "").Replace(";", "").Trim();
var filter = BuildCombinedFilter(algorithm, preset); var filter = BuildCombinedFilter(algorithm, preset);
string encoderParams; string encoderParams;
@ -107,6 +120,6 @@ public static class VideoTonemapEnhancer
encoderParams = $"-c:v {videoEncoder} -crf {crf} -preset {presetSpeed}"; encoderParams = $"-c:v {videoEncoder} -crf {crf} -preset {presetSpeed}";
} }
return $"-hide_banner -y -i \"{inputPath}\" -vf \"{filter}\" {encoderParams} -pix_fmt yuv420p -c:a copy \"{outputPath}\""; return $"-hide_banner -y -i \"{cleanIn}\" -vf \"{filter}\" {encoderParams} -pix_fmt yuv420p -c:a copy \"{cleanOut}\"";
} }
} }

View file

@ -1,4 +1,4 @@
using System.Security.Cryptography; using System.Security.Cryptography;
namespace Paca.Server; namespace Paca.Server;
@ -12,7 +12,7 @@ public static class MediaLibrary
var result = new List<LibraryItemDto>(); var result = new List<LibraryItemDto>();
try try
{ {
if (string.IsNullOrEmpty(folder) || !Directory.Exists(folder)) return result; if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder)) return result;
foreach (var f in Directory.EnumerateFiles(folder).OrderByDescending(f => File.GetLastWriteTimeUtc(f))) foreach (var f in Directory.EnumerateFiles(folder).OrderByDescending(f => File.GetLastWriteTimeUtc(f)))
{ {
var ext = Path.GetExtension(f).ToLowerInvariant(); var ext = Path.GetExtension(f).ToLowerInvariant();
@ -29,9 +29,10 @@ public static class MediaLibrary
{ {
try try
{ {
if (string.IsNullOrEmpty(folder) || !Directory.Exists(folder)) return null; if (string.IsNullOrWhiteSpace(folder) || string.IsNullOrWhiteSpace(id) || !Directory.Exists(folder)) return null;
var cleanId = id.Trim();
foreach (var f in Directory.EnumerateFiles(folder)) foreach (var f in Directory.EnumerateFiles(folder))
if (IdOf(f) == id) return (f, Path.GetFileName(f)); if (IdOf(f) == cleanId) return (f, Path.GetFileName(f));
} }
catch { } catch { }
return null; return null;
@ -39,6 +40,7 @@ public static class MediaLibrary
private static string IdOf(string path) private static string IdOf(string path)
{ {
if (string.IsNullOrWhiteSpace(path)) return string.Empty;
var hash = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(Path.GetFullPath(path))); var hash = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(Path.GetFullPath(path)));
return Convert.ToHexString(hash)[..12].ToLowerInvariant(); return Convert.ToHexString(hash)[..12].ToLowerInvariant();
} }

View file

@ -16,7 +16,10 @@ public sealed class PositionStore
public PositionStore(string libraryFolder) public PositionStore(string libraryFolder)
{ {
_path = Path.Combine(libraryFolder, FileName); var folder = string.IsNullOrWhiteSpace(libraryFolder)
? Path.Combine(Path.GetTempPath(), "paca_positions")
: libraryFolder;
_path = Path.Combine(folder, FileName);
try try
{ {
if (File.Exists(_path)) if (File.Exists(_path))
@ -28,16 +31,17 @@ public sealed class PositionStore
public double Get(string id) public double Get(string id)
{ {
lock (_gate) return _positions.GetValueOrDefault(id, 0); if (string.IsNullOrWhiteSpace(id)) return 0;
lock (_gate) return _positions.GetValueOrDefault(id.Trim(), 0);
} }
public void Set(string id, double positionSec) public void Set(string id, double positionSec)
{ {
if (string.IsNullOrEmpty(id) || positionSec < 0 || double.IsNaN(positionSec) || double.IsInfinity(positionSec)) if (string.IsNullOrWhiteSpace(id) || positionSec < 0 || double.IsNaN(positionSec) || double.IsInfinity(positionSec))
return; return;
lock (_gate) lock (_gate)
{ {
_positions[id] = positionSec; _positions[id.Trim()] = positionSec;
try try
{ {
var dir = Path.GetDirectoryName(_path); var dir = Path.GetDirectoryName(_path);

View file

@ -1,4 +1,4 @@
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Paca.Core.Config; using Paca.Core.Config;
using Paca.Core.Platform; using Paca.Core.Platform;
@ -16,6 +16,7 @@ public sealed partial class Thumbnailer
/// <summary>1초 지점 프레임 썸네일(320px 폭 JPEG). 실패 시 null. 캐시 히트 시 프로세스 없음.</summary> /// <summary>1초 지점 프레임 썸네일(320px 폭 JPEG). 실패 시 null. 캐시 히트 시 프로세스 없음.</summary>
public async Task<byte[]?> GetThumbnailAsync(string ffmpeg, string mediaPath, CancellationToken ct = default) public async Task<byte[]?> GetThumbnailAsync(string ffmpeg, string mediaPath, CancellationToken ct = default)
{ {
if (string.IsNullOrWhiteSpace(ffmpeg) || string.IsNullOrWhiteSpace(mediaPath)) return null;
var cache = mediaPath + ".thumb.jpg"; var cache = mediaPath + ".thumb.jpg";
try try
{ {
@ -34,6 +35,7 @@ public sealed partial class Thumbnailer
/// <summary>해상도("1920x1080"). ffmpeg -i stderr 의 Stream 라인에서 추출, .meta.json 캐시.</summary> /// <summary>해상도("1920x1080"). ffmpeg -i stderr 의 Stream 라인에서 추출, .meta.json 캐시.</summary>
public async Task<string?> GetResolutionAsync(string ffmpeg, string mediaPath, CancellationToken ct = default) public async Task<string?> GetResolutionAsync(string ffmpeg, string mediaPath, CancellationToken ct = default)
{ {
if (string.IsNullOrWhiteSpace(ffmpeg) || string.IsNullOrWhiteSpace(mediaPath)) return null;
var cache = mediaPath + ".meta.json"; var cache = mediaPath + ".meta.json";
try try
{ {

View file

@ -0,0 +1,106 @@
using System;
using System.Collections.Generic;
using Paca.Browser;
using Paca.Browser.Content;
using Xunit;
namespace Paca.Tests.Browser;
public class BrowserContentAndRoutingExtremeRedTests
{
// ==========================================
// 1. ChromeInternalUrlRouter
// ==========================================
[Theory]
[InlineData("chrome://settings?category=privacy", ChromeInternalAction.Settings)]
[InlineData("chrome://downloads?q=paca", ChromeInternalAction.Downloads)]
[InlineData("chrome://history#search", ChromeInternalAction.History)]
[InlineData("chrome:///settings", ChromeInternalAction.Settings)]
[InlineData("paca://extensions?id=123", ChromeInternalAction.Extensions)]
[InlineData("chrome://settings/content?site=example.com", ChromeInternalAction.SiteSettings)]
[InlineData("chrome://clear-browsing-data?time=all", ChromeInternalAction.ClearBrowsingData)]
public void ChromeInternalUrlRouter_Route_WithQueryOrFragment_ResolvesActionCorrectly(string url, ChromeInternalAction expected)
{
var action = ChromeInternalUrlRouter.Route(url);
Assert.Equal(expected, action);
}
// ==========================================
// 2. DualSubtitleEngine
// ==========================================
[Theory]
[InlineData(double.NaN)]
[InlineData(double.NegativeInfinity)]
[InlineData(double.PositiveInfinity)]
[InlineData(-1.0)]
public void DualSubtitleEngine_GetDualCues_InvalidTimestamp_ReturnsNull(double timestamp)
{
var engine = new DualSubtitleEngine();
var cues = engine.GetDualCues(timestamp);
Assert.Null(cues);
}
[Fact]
public void DualSubtitleEngine_RegisterAudioTrack_NullOrDuplicate_HandledSafely()
{
var engine = new DualSubtitleEngine();
engine.RegisterAudioTrack(null!);
Assert.Empty(engine.AudioTracks);
var track1 = new AudioTrackInfo(1, "ko", "한국어", true);
var track1Duplicate = new AudioTrackInfo(1, "ko-alt", "한국어 더빙", false);
engine.RegisterAudioTrack(track1);
engine.RegisterAudioTrack(track1Duplicate);
Assert.Single(engine.AudioTracks);
Assert.Equal(1, engine.SelectedAudioTrackId);
}
// ==========================================
// 3. LiveCaptionEngine
// ==========================================
[Theory]
[InlineData(double.NaN)]
[InlineData(-10.0)]
public void LiveCaptionEngine_GetActiveCue_InvalidTimestamp_ReturnsNull(double timestamp)
{
var engine = new LiveCaptionEngine();
var cue = engine.GetActiveCue(timestamp);
Assert.Null(cue);
}
// ==========================================
// 4. MhtmlWebArchivePackager
// ==========================================
[Fact]
public void MhtmlWebArchivePackager_Package_NullRequest_ThrowsArgumentNullException()
{
var packager = new MhtmlWebArchivePackager();
Assert.Throws<ArgumentNullException>(() => packager.Package(null!));
}
[Fact]
public void MhtmlWebArchivePackager_Package_NullResourcesOrData_PackagesWithoutCrashing()
{
var packager = new MhtmlWebArchivePackager();
var req = new WebArchivePackageRequest(
Url: "https://example.com",
Title: "Test Page",
HtmlContent: "<html><body>Hello</body></html>",
Resources: new List<WebArchiveResource>
{
new("https://example.com/null.png", "image/png", null!),
null!
}
);
var mhtml = packager.Package(req);
Assert.NotNull(mhtml);
Assert.Contains("Hello", mhtml);
}
}

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);
}
}

View file

@ -0,0 +1,119 @@
using System;
using System.Collections.Generic;
using Paca.Core.Config;
using Paca.Core.Detection;
using Paca.Core.Security;
using Xunit;
namespace Paca.Tests.Detection;
public class UrlAndMediaDetectionExtremeRedTests
{
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("not a url")]
[InlineData("javascript:alert('pwn')")]
[InlineData("data:text/html,<script>alert(1)</script>")]
[InlineData("file:///C:/Windows/System32/calc.exe")]
[InlineData("blob:https://youtube.com/1234-5678")]
public void SocialSiteDetector_TryMatchString_InvalidOrDangerousUrls_ReturnsFalse(string? input)
{
var result = SocialSiteDetector.TryMatch(input, out var normalized, out var label);
Assert.False(result);
Assert.Empty(normalized);
Assert.Empty(label);
}
[Theory]
[InlineData("https://www.youtube.com/watch?v=dQw4w9WgXcQ", "https://www.youtube.com/watch?v=dQw4w9WgXcQ", "YOUTUBE")]
[InlineData("https://youtu.be/dQw4w9WgXcQ", "https://www.youtube.com/watch?v=dQw4w9WgXcQ", "YOUTUBE")]
[InlineData("https://www.youtube.com/shorts/dQw4w9WgXcQ", "https://www.youtube.com/watch?v=dQw4w9WgXcQ", "YOUTUBE")]
[InlineData("https://twitter.com/OpenAI/status/1234567890", "https://x.com/OpenAI/status/1234567890", "X")]
[InlineData("https://x.com/OpenAI/status/1234567890", "https://x.com/OpenAI/status/1234567890", "X")]
[InlineData("https://www.instagram.com/reel/C_xyz123/", "https://www.instagram.com/reel/C_xyz123", "INSTAGRAM")]
[InlineData("https://www.tiktok.com/@user/video/1234567890", "https://www.tiktok.com/@user/video/1234567890", "TIKTOK")]
public void SocialSiteDetector_TryMatchString_ValidUrls_NormalizesCorrectly(string input, string expectedUrl, string expectedLabel)
{
var result = SocialSiteDetector.TryMatch(input, out var normalized, out var label);
Assert.True(result);
Assert.Equal(expectedUrl, normalized);
Assert.Equal(expectedLabel, label);
}
[Fact]
public void MediaDetector_Classify_NullConfig_ReturnsNullWithoutThrowing()
{
var result = MediaDetector.Classify("https://cdn.example.com/video.mp4", "video/mp4", 1024 * 1024, null!);
Assert.Null(result);
}
[Theory]
[InlineData("javascript:alert('xss')")]
[InlineData("data:video/mp4;base64,AAAA")]
[InlineData("file:///C:/videos/test.mp4")]
[InlineData("blob:https://example.com/uuid-1234")]
public void MediaDetector_Classify_DangerousSchemes_ReturnsNull(string dangerousUri)
{
var config = new AppConfig();
var result = MediaDetector.Classify(dangerousUri, "video/mp4", 1024 * 1024, config);
Assert.Null(result);
}
[Fact]
public void MediaDetector_Classify_InvalidNegativeLengthBelowMinusOne_ReturnsNull()
{
var config = new AppConfig();
var result = MediaDetector.Classify("https://cdn.example.com/video.mp4", "video/mp4", -99999L, config);
Assert.Null(result);
}
[Fact]
public void MediaDetector_ProbeFormat_BoundaryFtypBox_RecognizesMp4()
{
var ftypBytes = new byte[] { 0, 0, 0, 0x18, (byte)'f', (byte)'t', (byte)'y', (byte)'p', (byte)'m', (byte)'p', (byte)'4', (byte)'2' };
var format = MediaDetector.ProbeFormat(ftypBytes);
Assert.Equal("mp4", format);
}
[Fact]
public void CredentialSecurityAuditor_PerformAudit_NullList_ReturnsHealthyZeroReport()
{
var report = CredentialSecurityAuditor.PerformAudit(null!);
Assert.NotNull(report);
Assert.Equal(100, report.OverallHealthScore);
Assert.Equal(0, report.TotalAccounts);
Assert.Empty(report.Issues);
}
[Fact]
public void CredentialSecurityAuditor_PerformAudit_ListWithNullElements_FiltersNullsWithoutThrowing()
{
var credentials = new List<SiteCredentialRecord?>
{
new() { Id = "1", Domain = "example.com", Username = "user1", DecryptedPassword = "Password123!" },
null,
new() { Id = "2", Domain = "test.com", Username = "user2", DecryptedPassword = "SuperSecurePassword2026!#" }
};
var report = CredentialSecurityAuditor.PerformAudit(credentials!);
Assert.NotNull(report);
Assert.Equal(2, report.TotalAccounts);
}
[Fact]
public void CredentialSecurityAuditor_PerformAudit_DetectsWeakAndReusedPasswords()
{
var credentials = new List<SiteCredentialRecord>
{
new() { Id = "1", Domain = "site1.com", Username = "user", DecryptedPassword = "123" },
new() { Id = "2", Domain = "site2.com", Username = "user", DecryptedPassword = "123" }
};
var report = CredentialSecurityAuditor.PerformAudit(credentials);
Assert.True(report.WeakPasswordCount > 0);
Assert.True(report.ReusedPasswordCount > 0);
Assert.True(report.OverallHealthScore < 100);
}
}

View file

@ -0,0 +1,172 @@
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Paca.Core.Media;
using Xunit;
namespace Paca.Tests.Media;
public class HardwareTranscoderAndCastingExtremeRedTests
{
[Fact]
public void BuildFfmpegTranscodeCommand_NullSpec_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>(() =>
HardwareTranscoderStudio.BuildFfmpegTranscodeCommand("input.mp4", "output.mp4", null!));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void BuildFfmpegTranscodeCommand_NullOrWhitespaceInput_ThrowsArgumentException(string? invalidInput)
{
var spec = new TranscodePresetSpec();
Assert.Throws<ArgumentException>(() =>
HardwareTranscoderStudio.BuildFfmpegTranscodeCommand(invalidInput!, "output.mp4", spec));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void BuildFfmpegTranscodeCommand_NullOrWhitespaceOutput_ThrowsArgumentException(string? invalidOutput)
{
var spec = new TranscodePresetSpec();
Assert.Throws<ArgumentException>(() =>
HardwareTranscoderStudio.BuildFfmpegTranscodeCommand("input.mp4", invalidOutput!, spec));
}
[Fact]
public void BuildFfmpegTranscodeCommand_SameInputAndOutput_ThrowsInvalidOperationException()
{
var tempFile = Path.GetTempFileName();
try
{
var spec = new TranscodePresetSpec();
Assert.Throws<InvalidOperationException>(() =>
HardwareTranscoderStudio.BuildFfmpegTranscodeCommand(tempFile, tempFile, spec));
}
finally
{
if (File.Exists(tempFile)) File.Delete(tempFile);
}
}
[Fact]
public void BuildFfmpegTranscodeCommand_QuotedPaths_StripsQuotesAndProducesValidArgs()
{
var spec = new TranscodePresetSpec { Backend = HardwareEncoderBackend.SoftwareCpu, Codec = VideoCodecTarget.H264 };
var args = HardwareTranscoderStudio.BuildFfmpegTranscodeCommand("\"C:\\input video.mp4\"", "\"C:\\out.mp4\"", spec);
Assert.Equal("C:\\input video.mp4", args[1]);
Assert.Equal("C:\\out.mp4", args.Last());
}
[Fact]
public void BuildFfmpegTranscodeCommand_NullOrEmptyAudioCodec_DefaultsToAac()
{
var spec = new TranscodePresetSpec { AudioCodec = " " };
var args = HardwareTranscoderStudio.BuildFfmpegTranscodeCommand("in.mkv", "out.mp4", spec);
var aIndex = args.IndexOf("-c:a");
Assert.True(aIndex >= 0);
Assert.Equal("aac", args[aIndex + 1]);
}
[Theory]
[InlineData(HardwareEncoderBackend.NvidiaNvenc, VideoCodecTarget.AV1, "av1_nvenc")]
[InlineData(HardwareEncoderBackend.NvidiaNvenc, VideoCodecTarget.HEVC_H265, "hevc_nvenc")]
[InlineData(HardwareEncoderBackend.NvidiaNvenc, VideoCodecTarget.H264, "h264_nvenc")]
[InlineData(HardwareEncoderBackend.IntelQsv, VideoCodecTarget.AV1, "av1_qsv")]
[InlineData(HardwareEncoderBackend.IntelQsv, VideoCodecTarget.HEVC_H265, "hevc_qsv")]
[InlineData(HardwareEncoderBackend.AmdAmf, VideoCodecTarget.AV1, "av1_amf")]
[InlineData(HardwareEncoderBackend.AmdAmf, VideoCodecTarget.HEVC_H265, "hevc_amf")]
[InlineData(HardwareEncoderBackend.SoftwareCpu, VideoCodecTarget.AV1, "libsvtav1")]
[InlineData(HardwareEncoderBackend.SoftwareCpu, VideoCodecTarget.HEVC_H265, "libx265")]
public void BuildFfmpegTranscodeCommand_Backends_ProducesCorrectVideoCodec(
HardwareEncoderBackend backend, VideoCodecTarget codec, string expectedCodec)
{
var spec = new TranscodePresetSpec { Backend = backend, Codec = codec };
var args = HardwareTranscoderStudio.BuildFfmpegTranscodeCommand("in.mp4", "out.mp4", spec);
var vIndex = args.IndexOf("-c:v");
Assert.True(vIndex >= 0);
Assert.Equal(expectedCodec, args[vIndex + 1]);
}
[Fact]
public void BuildFfmpegTranscodeCommand_NegativeBitrate_DoesNotEmitBitrateFlag()
{
var spec = new TranscodePresetSpec { TargetBitrateKbps = -100 };
var args = HardwareTranscoderStudio.BuildFfmpegTranscodeCommand("in.mp4", "out.mp4", spec);
Assert.DoesNotContain("-b:v", args);
}
[Fact]
public void WebRtcCastingEngine_TerminateSession_NullSessionId_ReturnsFalseWithoutThrowing()
{
var engine = new WebRtcCastingEngine();
var result = engine.TerminateSession(null!);
Assert.False(result);
}
[Fact]
public void WebRtcCastingEngine_TerminateSession_UnknownSessionId_ReturnsFalse()
{
var engine = new WebRtcCastingEngine();
var result = engine.TerminateSession("non-existent-uuid");
Assert.False(result);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void WebRtcCastingEngine_CreateCastSession_NullOrWhitespaceDevice_DefaultsToDefaultDisplay(string? deviceName)
{
var engine = new WebRtcCastingEngine();
var session = engine.CreateCastSession(deviceName!);
Assert.Equal("Default Display", session.TargetDeviceName);
}
[Fact]
public void WebRtcCastingEngine_GetSession_WorksCorrectly()
{
var engine = new WebRtcCastingEngine();
Assert.Null(engine.GetSession(null!));
Assert.Null(engine.GetSession("unknown-id"));
var created = engine.CreateCastSession("Living Room TV");
var found = engine.GetSession(created.SessionId);
Assert.NotNull(found);
Assert.Equal("Living Room TV", found.TargetDeviceName);
}
[Fact]
public void WebRtcCastingEngine_GenerateOffer_InvalidDimensions_NormalizesDimensions()
{
var engine = new WebRtcCastingEngine();
var desc = engine.GenerateOffer(null!, width: -50, height: 0, fps: -1);
Assert.Equal("offer", desc.Type);
Assert.Contains("v=0", desc.Sdp);
Assert.Contains("H264", desc.Sdp);
}
[Fact]
public void WebRtcCastingEngine_ThreadSafety_ConcurrentCreateAndTerminate()
{
var engine = new WebRtcCastingEngine();
Parallel.For(0, 50, i =>
{
var session = engine.CreateCastSession($"Device {i}");
Assert.NotNull(engine.GetSession(session.SessionId));
var terminated = engine.TerminateSession(session.SessionId);
Assert.True(terminated);
Assert.Null(engine.GetSession(session.SessionId));
});
Assert.Equal(0, engine.ActiveSessionsCount);
}
}

View file

@ -0,0 +1,125 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Paca.Core.Platform;
using Xunit;
namespace Paca.Tests.Platform;
public class MediaEnhanceAndDeduplicationExtremeRedTests
{
private const string SampleInput = "C:\\Media\\video.mp4";
private const string SampleOutput = "C:\\Media\\converted.mp4";
// ==========================================
// 1. HdrToneMapper
// ==========================================
[Fact]
public void HdrToneMapper_Build10BitPassthroughArgs_SameInputAndOutput_ThrowsArgumentException()
{
Assert.Throws<ArgumentException>(() =>
HdrToneMapper.Build10BitPassthroughArgs(SampleInput, SampleInput));
}
[Theory]
[InlineData(null, SampleOutput)]
[InlineData(SampleInput, null)]
[InlineData("", SampleOutput)]
[InlineData(SampleInput, " ")]
public void HdrToneMapper_Build10BitPassthroughArgs_NullOrWhitespacePaths_ThrowsArgumentException(string? inPath, string? outPath)
{
Assert.Throws<ArgumentException>(() =>
HdrToneMapper.Build10BitPassthroughArgs(inPath!, outPath!));
}
[Fact]
public void HdrToneMapper_Build10BitPassthroughArgs_PathsWithQuotes_Stripped()
{
string malIn = "C:\\Media\\in\"put.mp4";
string malOut = "C:\\Media\\out\"put.mp4";
var args = HdrToneMapper.Build10BitPassthroughArgs(malIn, malOut);
Assert.NotNull(args);
Assert.DoesNotContain(args, a => a.Contains('"'));
}
[Fact]
public async Task HdrToneMapper_ConvertToSdrAsync_SameInputAndOutput_ThrowsArgumentException()
{
var mapper = new HdrToneMapper(DesktopProcessRunner.Default);
await Assert.ThrowsAsync<ArgumentException>(() =>
mapper.ConvertToSdrAsync("ffmpeg.exe", SampleInput, SampleInput, ToneMapAlgorithm.Mobius, CancellationToken.None));
}
[Theory]
[InlineData(0)]
[InlineData(-100)]
public void HdrToneMapper_BuildToneMapFilter_NonPositiveNits_ClampsToSafeMinimum(int nits)
{
var filter = HdrToneMapper.BuildToneMapFilter(ToneMapAlgorithm.Mobius, nits);
Assert.NotNull(filter);
Assert.DoesNotContain("npl=0", filter);
Assert.DoesNotContain("npl=-", filter);
}
// ==========================================
// 2. VideoDenoiseUpscaler
// ==========================================
[Fact]
public async Task VideoDenoiseUpscaler_EnhanceVideoAsync_SameInputAndOutput_ThrowsArgumentException()
{
var upscaler = new VideoDenoiseUpscaler(DesktopProcessRunner.Default);
var spec = new DenoiseUpscaleSpec(DenoiseStrength.Light, 1920, 1080);
await Assert.ThrowsAsync<ArgumentException>(() =>
upscaler.EnhanceVideoAsync("ffmpeg.exe", SampleInput, SampleInput, spec, CancellationToken.None));
}
[Fact]
public async Task VideoDenoiseUpscaler_EnhanceVideoAsync_NullSpec_ThrowsArgumentNullException()
{
var upscaler = new VideoDenoiseUpscaler(DesktopProcessRunner.Default);
await Assert.ThrowsAsync<ArgumentNullException>(() =>
upscaler.EnhanceVideoAsync("ffmpeg.exe", SampleInput, SampleOutput, null!, CancellationToken.None));
}
[Theory]
[InlineData(null, SampleOutput)]
[InlineData(SampleInput, null)]
[InlineData("", SampleOutput)]
[InlineData(SampleInput, " ")]
public async Task VideoDenoiseUpscaler_EnhanceVideoAsync_NullOrWhitespacePaths_ThrowsArgumentException(string? inPath, string? outPath)
{
var upscaler = new VideoDenoiseUpscaler(DesktopProcessRunner.Default);
var spec = new DenoiseUpscaleSpec(DenoiseStrength.Light, 1920, 1080);
await Assert.ThrowsAsync<ArgumentException>(() =>
upscaler.EnhanceVideoAsync("ffmpeg.exe", inPath!, outPath!, spec, CancellationToken.None));
}
// ==========================================
// 3. VideoDeduplicator
// ==========================================
[Fact]
public void VideoDeduplicator_Constructor_NegativeThreshold_ClampsOrThrows()
{
var dedup = new VideoDeduplicator(-5);
// Verify it doesn't fail to match identical hashes due to negative distance threshold
dedup.RegisterVideo("test.mp4", 0x12345678UL);
var match = dedup.FindDuplicate(0x12345678UL);
Assert.NotNull(match);
Assert.Equal(0, match.HammingDistance);
}
[Fact]
public void VideoDeduplicator_RegisterVideo_PathWithQuotes_SanitizesKey()
{
var dedup = new VideoDeduplicator(5);
dedup.RegisterVideo("C:\\Media\\vi\"deo.mp4", 0x12345678UL);
var match = dedup.FindDuplicate(0x12345678UL);
Assert.NotNull(match);
Assert.DoesNotContain("\"", match.FilePath);
}
}

View file

@ -0,0 +1,110 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Paca.Core.Platform;
using Xunit;
namespace Paca.Tests.Platform;
public class PlatformMediaToolsExtremeRedTests
{
// ==========================================
// 1. ChapterSplitter
// ==========================================
[Fact]
public void ChapterSplitter_GenerateFfmetadata_NullChapters_ReturnsValidHeaderWithoutCrashing()
{
var meta = ChapterSplitter.GenerateFfmetadata(null!);
Assert.NotNull(meta);
Assert.Contains(";FFMETADATA1", meta);
}
[Fact]
public void ChapterSplitter_GenerateFfmetadata_ListContainingNullChapter_SkipsNullChapter()
{
var chapters = new List<ChapterInfo>
{
null!,
new(1, "Intro", 0.0, 30.0),
null!
};
var meta = ChapterSplitter.GenerateFfmetadata(chapters);
Assert.NotNull(meta);
Assert.Contains("title=Intro", meta);
}
[Fact]
public void ChapterSplitter_GetChapterFileName_NullTitle_ReturnsSafeDefault()
{
var name = ChapterSplitter.GetChapterFileName(1, null!, "mp4");
Assert.NotNull(name);
Assert.StartsWith("01_", name);
Assert.EndsWith(".mp4", name);
}
// ==========================================
// 2. LosslessSmartTrimmer
// ==========================================
[Theory]
[InlineData(double.NaN)]
[InlineData(double.NegativeInfinity)]
[InlineData(double.PositiveInfinity)]
public void LosslessSmartTrimmer_FindNearestKeyframe_InvalidTargetSec_ReturnsDefault(double target)
{
var keyframes = new[] { 0.0, 5.0, 10.0, 15.0 };
var nearest = LosslessSmartTrimmer.FindNearestKeyframe(keyframes, target);
Assert.False(double.IsNaN(nearest));
Assert.False(double.IsInfinity(nearest));
}
[Fact]
public void LosslessSmartTrimmer_FindNearestKeyframe_KeyframesWithNaNOrNegatives_FiltersCorrectly()
{
var keyframes = new[] { double.NaN, -10.0, 5.0, 10.0 };
var nearest = LosslessSmartTrimmer.FindNearestKeyframe(keyframes, 6.0);
Assert.Equal(5.0, nearest);
}
[Fact]
public async Task LosslessSmartTrimmer_TrimLosslessAsync_NaNTimestamps_ThrowsArgumentException()
{
var trimmer = new LosslessSmartTrimmer(DesktopProcessRunner.Default);
await Assert.ThrowsAsync<ArgumentException>(() =>
trimmer.TrimLosslessAsync("ffmpeg.exe", "in.mp4", "out.mp4", double.NaN, 10.0, CancellationToken.None));
}
// ==========================================
// 3. VideoWatermarkBurner
// ==========================================
[Fact]
public void VideoWatermarkBurner_BuildDrawTextFilter_NullText_DoesNotThrow()
{
var filter = VideoWatermarkBurner.BuildDrawTextFilter(null!);
Assert.NotNull(filter);
Assert.Contains("drawtext=", filter);
}
[Fact]
public void VideoWatermarkBurner_BuildDrawTextFilter_TextWithNewlines_EscapedOrStripped()
{
var filter = VideoWatermarkBurner.BuildDrawTextFilter("Line 1\r\nLine 2\nLine 3");
Assert.NotNull(filter);
Assert.DoesNotContain("\r", filter);
Assert.DoesNotContain("\n", filter);
}
[Theory]
[InlineData(-0.1)]
[InlineData(1.1)]
[InlineData(double.NaN)]
public void VideoWatermarkBurner_BuildOverlayFilter_InvalidOpacity_ThrowsArgumentOutOfRangeException(double opacity)
{
Assert.Throws<ArgumentOutOfRangeException>(() =>
VideoWatermarkBurner.BuildOverlayFilter(WatermarkPosition.Center, opacity: opacity));
}
}

View file

@ -0,0 +1,119 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Paca.Core.Platform;
using Xunit;
namespace Paca.Tests.Platform;
public class VrAndAudioAdaptiveExtremeRedTests
{
private const string SampleInput = "C:\\Media\\input.mp4";
private const string SampleOutput = "C:\\Media\\output.mp4";
// ==========================================
// 1. VrSpatialAudioEngine
// ==========================================
[Fact]
public async Task VrSpatialAudioEngine_InjectVrMetadataAsync_SameInputAndOutput_ThrowsArgumentException()
{
var engine = new VrSpatialAudioEngine(DesktopProcessRunner.Default);
var meta = new VrSpatialMetadata("equirectangular", "mono", 1);
await Assert.ThrowsAsync<ArgumentException>(() =>
engine.InjectVrMetadataAsync("ffmpeg.exe", SampleInput, SampleInput, meta, CancellationToken.None));
}
[Theory]
[InlineData(null, SampleOutput)]
[InlineData(SampleInput, null)]
[InlineData("", SampleOutput)]
[InlineData(SampleInput, " ")]
public async Task VrSpatialAudioEngine_InjectVrMetadataAsync_NullOrWhitespacePaths_ThrowsArgumentException(string? inPath, string? outPath)
{
var engine = new VrSpatialAudioEngine(DesktopProcessRunner.Default);
var meta = new VrSpatialMetadata("equirectangular", "mono", 1);
await Assert.ThrowsAsync<ArgumentException>(() =>
engine.InjectVrMetadataAsync("ffmpeg.exe", inPath!, outPath!, meta, CancellationToken.None));
}
// ==========================================
// 2. VoiceActivitySubtitleAdapter
// ==========================================
[Fact]
public void VoiceActivitySubtitleAdapter_GenerateVttSkeleton_ListWithNullSegment_SkipsSafely()
{
var list = new List<SpeechSegment>
{
null!,
new(0.0, 5.0),
null!
};
var vtt = VoiceActivitySubtitleAdapter.GenerateVttSkeleton(list);
Assert.NotNull(vtt);
Assert.Contains("WEBVTT", vtt);
Assert.Contains("00:00:00.000 --> 00:00:05.000", vtt);
}
[Theory]
[InlineData(double.NaN)]
[InlineData(-5.0)]
public void VoiceActivitySubtitleAdapter_FormatTimecode_NegativeOrNaN_ReturnsZeroTimecode(double sec)
{
var code = VoiceActivitySubtitleAdapter.FormatTimecode(sec);
Assert.Equal("00:00:00.000", code);
}
// ==========================================
// 3. HighlightShortsClipper
// ==========================================
[Fact]
public async Task HighlightShortsClipper_ExportShortsClipAsync_SameInputAndOutput_ThrowsArgumentException()
{
var clipper = new HighlightShortsClipper(DesktopProcessRunner.Default);
var clip = new HighlightClip(0.0, 15.0, "Test", 7.5);
await Assert.ThrowsAsync<ArgumentException>(() =>
clipper.ExportShortsClipAsync("ffmpeg.exe", SampleInput, SampleInput, clip, false, CancellationToken.None));
}
[Fact]
public async Task HighlightShortsClipper_ExportShortsClipAsync_NullClip_ThrowsArgumentNullException()
{
var clipper = new HighlightShortsClipper(DesktopProcessRunner.Default);
await Assert.ThrowsAsync<ArgumentNullException>(() =>
clipper.ExportShortsClipAsync("ffmpeg.exe", SampleInput, SampleOutput, null!, false, CancellationToken.None));
}
[Theory]
[InlineData(null, SampleOutput)]
[InlineData(SampleInput, null)]
[InlineData("", SampleOutput)]
[InlineData(SampleInput, " ")]
public async Task HighlightShortsClipper_ExportShortsClipAsync_NullOrWhitespacePaths_ThrowsArgumentException(string? inPath, string? outPath)
{
var clipper = new HighlightShortsClipper(DesktopProcessRunner.Default);
var clip = new HighlightClip(0.0, 15.0, "Test", 7.5);
await Assert.ThrowsAsync<ArgumentException>(() =>
clipper.ExportShortsClipAsync("ffmpeg.exe", inPath!, outPath!, clip, false, CancellationToken.None));
}
[Fact]
public void HighlightShortsClipper_DetectHighlightWindows_ListWithNullSamples_SkipsSafely()
{
var samples = new List<VolumeSample>
{
null!,
new(10.0, -5.0),
null!,
new(30.0, -2.0)
};
var clips = HighlightShortsClipper.DetectHighlightWindows(samples, windowDurationSec: 10.0, topCount: 2);
Assert.NotNull(clips);
Assert.Equal(2, clips.Count);
}
}

View file

@ -0,0 +1,152 @@
using System;
using System.IO;
using System.Threading.Tasks;
using Paca.Core.Platform;
using Paca.Server;
using Paca.Server.Discovery;
using Xunit;
namespace Paca.Tests.Server;
public class ServerResilienceExtremeRedTests
{
// ==========================================
// 1. PositionStore
// ==========================================
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void PositionStore_Get_NullOrEmptyId_ReturnsZero(string? id)
{
var tempDir = Path.Combine(Path.GetTempPath(), "paca_test_pos_" + Guid.NewGuid().ToString("N"));
try
{
var store = new PositionStore(tempDir);
var pos = store.Get(id!);
Assert.Equal(0, pos);
}
finally
{
if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true);
}
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void PositionStore_Set_NullOrWhitespaceId_DoesNotThrowAndDoesNotPersist(string? id)
{
var tempDir = Path.Combine(Path.GetTempPath(), "paca_test_pos_" + Guid.NewGuid().ToString("N"));
try
{
var store = new PositionStore(tempDir);
var ex = Record.Exception(() => store.Set(id!, 42.5));
Assert.Null(ex);
Assert.Equal(0, store.Get(id!));
}
finally
{
if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true);
}
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void PositionStore_Constructor_NullOrEmptyFolder_DoesNotCrash(string? folder)
{
var ex = Record.Exception(() =>
{
var store = new PositionStore(folder!);
var testKey = "test-key-" + Guid.NewGuid().ToString("N");
Assert.Equal(0, store.Get(testKey));
store.Set(testKey, 15.0);
Assert.Equal(15.0, store.Get(testKey));
});
Assert.Null(ex);
}
// ==========================================
// 2. Thumbnailer
// ==========================================
[Theory]
[InlineData(null, "C:\\media\\test.mp4")]
[InlineData("ffmpeg.exe", null)]
[InlineData("", "C:\\media\\test.mp4")]
[InlineData("ffmpeg.exe", " ")]
public async Task Thumbnailer_GetThumbnailAsync_NullOrEmptyInputs_ReturnsNull(string? ffmpeg, string? media)
{
var thumb = new Thumbnailer(DesktopProcessRunner.Default);
var res = await thumb.GetThumbnailAsync(ffmpeg!, media!);
Assert.Null(res);
}
[Theory]
[InlineData(null, "C:\\media\\test.mp4")]
[InlineData("ffmpeg.exe", null)]
[InlineData("", "C:\\media\\test.mp4")]
[InlineData("ffmpeg.exe", " ")]
public async Task Thumbnailer_GetResolutionAsync_NullOrEmptyInputs_ReturnsNull(string? ffmpeg, string? media)
{
var thumb = new Thumbnailer(DesktopProcessRunner.Default);
var res = await thumb.GetResolutionAsync(ffmpeg!, media!);
Assert.Null(res);
}
// ==========================================
// 3. MediaLibrary
// ==========================================
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void MediaLibrary_List_NullOrEmptyFolder_ReturnsEmpty(string? folder)
{
var list = MediaLibrary.List(folder!);
Assert.NotNull(list);
Assert.Empty(list);
}
[Theory]
[InlineData(null, "some-id")]
[InlineData("C:\\Media", null)]
[InlineData("", "some-id")]
[InlineData("C:\\Media", " ")]
public void MediaLibrary_Find_NullOrEmptyParameters_ReturnsNull(string? folder, string? id)
{
var item = MediaLibrary.Find(folder!, id!);
Assert.Null(item);
}
// ==========================================
// 4. LanBeaconResponder
// ==========================================
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("GARBAGE_PAYLOAD")]
[InlineData("{ \"invalid\": true }")]
public void LanBeaconResponder_ProcessIncomingPayload_NullOrGarbagePayload_ReturnsNull(string? payload)
{
var responder = new LanBeaconResponder("PACA-TEST", 52100);
var response = responder.ProcessIncomingPayload(payload!, "192.168.1.50");
Assert.Null(response);
}
[Fact]
public void LanBeaconResponder_BuildPongMessage_NullIp_UsesDefaultIp()
{
var responder = new LanBeaconResponder("PACA-TEST", 52100);
var pong = responder.BuildPongMessage(null);
Assert.NotNull(pong);
Assert.Contains("0.0.0.0", pong.Endpoint);
}
}