From 8677853822d10bdd7f2d44ca6826c7dfc9f3874b Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Fri, 18 Sep 2026 23:32:32 +0900 Subject: [PATCH] test(core/browser/platform): 26 extreme RED/GREEN TDD cycles and full regression hardening (3,235 PASS) --- docs/BACKLOG.md | 12 + docs/IMPLEMENTATION_AUDIT.md | 25 +- src/Paca.Browser/ChromeInternalUrlRouter.cs | 8 +- .../Content/MhtmlWebArchivePackager.cs | 32 ++- .../Content/SiteAutoMutePolicy.cs | 26 +- src/Paca.Browser/DualSubtitleEngine.cs | 6 +- .../Extensions/ExtensionCspValidator.cs | 14 +- src/Paca.Browser/LiveCaptionEngine.cs | 3 +- src/Paca.Core/AudioAlbumTrackSplitter.cs | 26 +- src/Paca.Core/AudioChannelDownmixer.cs | 10 +- src/Paca.Core/AudioDynamicRangeCompressor.cs | 25 +- src/Paca.Core/AudioPitchShiftEngine.cs | 13 + src/Paca.Core/AudioVocalStemExtractor.cs | 14 +- src/Paca.Core/AudioVolumeNormalizer.cs | 15 ++ .../BandwidthScheduleProfileEngine.cs | 5 +- .../Config/WorkspaceConfigSanitizer.cs | 8 +- src/Paca.Core/Dash/DashParser.cs | 43 ++- src/Paca.Core/Detection/AiHighlightClipper.cs | 36 ++- src/Paca.Core/Detection/MediaDetector.cs | 17 +- .../Detection/NeuralShaderUpscaler.cs | 19 +- src/Paca.Core/Detection/SocialSiteDetector.cs | 26 ++ src/Paca.Core/Hls/HlsDownloader.cs | 38 ++- src/Paca.Core/Hls/M3u8Parser.cs | 59 +++- .../Hls/PartialChunkIntegrityCache.cs | 50 ++-- src/Paca.Core/HlsManifestValidator.cs | 3 +- src/Paca.Core/Media/FftAudioWaveformEngine.cs | 6 +- .../Media/HardwareTranscoderStudio.cs | 18 +- src/Paca.Core/Media/WebRtcCastingEngine.cs | 31 ++- src/Paca.Core/MediaBitrateEstimator.cs | 23 +- src/Paca.Core/MediaMetadataTagWriter.cs | 19 +- .../MediaPlaybackSpeedPitchEngine.cs | 17 +- src/Paca.Core/Models/PlaylistModels.cs | 12 +- src/Paca.Core/MultiTrackMediaMuxer.cs | 45 +++- src/Paca.Core/Net/HostRateLimiter.cs | 29 +- src/Paca.Core/Net/MultiCdnMeshDownloader.cs | 36 ++- src/Paca.Core/Net/P2pSwarmEngine.cs | 16 +- src/Paca.Core/Paca.Core.csproj | 1 + src/Paca.Core/Platform/AudioNormalizer.cs | 5 + src/Paca.Core/Platform/ChapterSplitter.cs | 39 ++- .../Platform/HardwareEncoderDetector.cs | 35 ++- src/Paca.Core/Platform/HdrToneMapper.cs | 32 ++- .../Platform/HighlightShortsClipper.cs | 19 +- .../Platform/LosslessSmartTrimmer.cs | 17 +- .../Platform/MultiSourceSegmentAccelerator.cs | 72 +++-- .../Platform/PredictiveChunkPrefetcher.cs | 20 +- src/Paca.Core/Platform/ProcessModels.cs | 41 ++- src/Paca.Core/Platform/VideoDeduplicator.cs | 32 ++- .../Platform/VideoDenoiseUpscaler.cs | 16 +- .../Platform/VideoWatermarkBurner.cs | 17 +- .../Platform/VoiceActivitySubtitleAdapter.cs | 9 +- .../Platform/VrSpatialAudioEngine.cs | 20 +- .../QuickActionClipboardDetector.cs | 57 +++- .../QuickActions/QuickActionFuzzyMatcher.cs | 25 +- .../QuickActionInlineEvaluator.cs | 12 +- .../QuickActionParameterRouter.cs | 14 +- src/Paca.Core/Security/CrdtVaultSyncEngine.cs | 21 +- .../Security/CredentialDataExchange.cs | 57 ++-- .../Security/CredentialSecurityAuditor.cs | 29 +- .../Security/E2eeRemoteBackupProvider.cs | 74 ++++- .../Security/PasswordSecurityEngine.cs | 8 +- .../Streaming/AdaptiveStreamingEngines.cs | 22 +- src/Paca.Core/SubtitleBurnInEngine.cs | 37 ++- src/Paca.Core/Util/FileNameUtil.cs | 44 ++- src/Paca.Core/Utils/DiskSpaceGuardian.cs | 45 +++- src/Paca.Core/VideoAspectAutoCropper.cs | 14 +- src/Paca.Core/VideoDenoiseEnhancer.cs | 11 +- src/Paca.Core/VideoFrameRateConverter.cs | 16 +- src/Paca.Core/VideoIntroOutroTrimmer.cs | 14 +- src/Paca.Core/VideoPosterExtractor.cs | 11 +- src/Paca.Core/VideoSpriteSheetGenerator.cs | 12 +- src/Paca.Core/VideoTonemapEnhancer.cs | 15 +- src/Paca.Server/MediaLibrary.cs | 10 +- src/Paca.Server/PositionStore.cs | 12 +- src/Paca.Server/Thumbnailer.cs | 4 +- ...BrowserContentAndRoutingExtremeRedTests.cs | 106 ++++++++ .../AiMediaAndShaderEnginesExtremeRedTests.cs | 204 ++++++++++++++ .../Core/AudioEnginesExtremeRedTests.cs | 171 ++++++++++++ ...pboardAndEncoderSecurityExtremeRedTests.cs | 104 ++++++++ ...urrencyAndCacheIntegrityExtremeRedTests.cs | 155 +++++++++++ .../FileNameUtilAndCleanupExtremeRedTests.cs | 93 +++++++ .../HlsAndDashResilienceExtremeRedTests.cs | 100 +++++++ ...u8ParserByteRangeAndfMp4ExtremeRedTests.cs | 131 +++++++++ .../MediaMuxerAndMetadataExtremeRedTests.cs | 164 ++++++++++++ .../Core/MediaProcessingExtremeRedTests.cs | 92 +++++++ .../Core/P2pAndE2eeVaultExtremeRedTests.cs | 252 ++++++++++++++++++ .../Core/ProcessExecutionExtremeRedTests.cs | 63 +++++ .../Core/QuickActionsExtremeRedTests.cs | 124 +++++++++ .../Core/SecurityAndConfigExtremeRedTests.cs | 119 +++++++++ .../SecurityAndRateLimitingExtremeRedTests.cs | 96 +++++++ .../Core/SocialSiteDetectorExtremeRedTests.cs | 71 +++++ .../SpeedAndBitrateScheduleExtremeRedTests.cs | 144 ++++++++++ ...amingAndResourceGuardianExtremeRedTests.cs | 159 +++++++++++ ...itleAndNetworkResilienceExtremeRedTests.cs | 111 ++++++++ .../Core/VideoEnginesExtremeRedTests.cs | 167 ++++++++++++ .../UrlAndMediaDetectionExtremeRedTests.cs | 119 +++++++++ ...wareTranscoderAndCastingExtremeRedTests.cs | 172 ++++++++++++ ...aEnhanceAndDeduplicationExtremeRedTests.cs | 125 +++++++++ .../PlatformMediaToolsExtremeRedTests.cs | 110 ++++++++ .../VrAndAudioAdaptiveExtremeRedTests.cs | 119 +++++++++ .../Server/ServerResilienceExtremeRedTests.cs | 152 +++++++++++ 100 files changed, 4814 insertions(+), 333 deletions(-) create mode 100644 tests/Paca.Tests/Browser/BrowserContentAndRoutingExtremeRedTests.cs create mode 100644 tests/Paca.Tests/Core/AiMediaAndShaderEnginesExtremeRedTests.cs create mode 100644 tests/Paca.Tests/Core/AudioEnginesExtremeRedTests.cs create mode 100644 tests/Paca.Tests/Core/ClipboardAndEncoderSecurityExtremeRedTests.cs create mode 100644 tests/Paca.Tests/Core/ConcurrencyAndCacheIntegrityExtremeRedTests.cs create mode 100644 tests/Paca.Tests/Core/FileNameUtilAndCleanupExtremeRedTests.cs create mode 100644 tests/Paca.Tests/Core/HlsAndDashResilienceExtremeRedTests.cs create mode 100644 tests/Paca.Tests/Core/M3u8ParserByteRangeAndfMp4ExtremeRedTests.cs create mode 100644 tests/Paca.Tests/Core/MediaMuxerAndMetadataExtremeRedTests.cs create mode 100644 tests/Paca.Tests/Core/MediaProcessingExtremeRedTests.cs create mode 100644 tests/Paca.Tests/Core/P2pAndE2eeVaultExtremeRedTests.cs create mode 100644 tests/Paca.Tests/Core/ProcessExecutionExtremeRedTests.cs create mode 100644 tests/Paca.Tests/Core/QuickActionsExtremeRedTests.cs create mode 100644 tests/Paca.Tests/Core/SecurityAndConfigExtremeRedTests.cs create mode 100644 tests/Paca.Tests/Core/SecurityAndRateLimitingExtremeRedTests.cs create mode 100644 tests/Paca.Tests/Core/SocialSiteDetectorExtremeRedTests.cs create mode 100644 tests/Paca.Tests/Core/SpeedAndBitrateScheduleExtremeRedTests.cs create mode 100644 tests/Paca.Tests/Core/StreamingAndResourceGuardianExtremeRedTests.cs create mode 100644 tests/Paca.Tests/Core/SubtitleAndNetworkResilienceExtremeRedTests.cs create mode 100644 tests/Paca.Tests/Core/VideoEnginesExtremeRedTests.cs create mode 100644 tests/Paca.Tests/Detection/UrlAndMediaDetectionExtremeRedTests.cs create mode 100644 tests/Paca.Tests/Media/HardwareTranscoderAndCastingExtremeRedTests.cs create mode 100644 tests/Paca.Tests/Platform/MediaEnhanceAndDeduplicationExtremeRedTests.cs create mode 100644 tests/Paca.Tests/Platform/PlatformMediaToolsExtremeRedTests.cs create mode 100644 tests/Paca.Tests/Platform/VrAndAudioAdaptiveExtremeRedTests.cs create mode 100644 tests/Paca.Tests/Server/ServerResilienceExtremeRedTests.cs diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 2189904..ae26771 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -3,6 +3,18 @@ > **출처**: 2026-07-26 다차원 코드 감사(9개 차원, 원시 63개 → 상위 15 선정). 모두 실제 소스 기반(file:line 검증). > **사용법**: 완료한 항목은 `- [ ]` → `- [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) - [x] **리서치 완료**: 2026 기준 검색(GEO/AEO 실측), 런치 시퀀스, 패키지 매니저·포털·대안 사이트, 오픈소스 바이럴, 리퍼럴 루프 조사 및 PACA 전용 계획서 작성. - [x] **헤드풀 브라우징 조사 도구**: `scratch/headful_fetch.mjs` (Playwright, 실제 Chrome 창 + 영속 프로필 + `CDP` 접속 모드) — 봇 차단/JS 전용 페이지 조사용. diff --git a/docs/IMPLEMENTATION_AUDIT.md b/docs/IMPLEMENTATION_AUDIT.md index 8e7e580..e1fab02 100644 --- a/docs/IMPLEMENTATION_AUDIT.md +++ b/docs/IMPLEMENTATION_AUDIT.md @@ -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)** > **감사일**: 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)**: - **Milestone 32 (검색엔진 3종 실등록 + 롱테일 확장 + 공유 루프 + 배포 값 확정)**: diff --git a/src/Paca.Browser/ChromeInternalUrlRouter.cs b/src/Paca.Browser/ChromeInternalUrlRouter.cs index bbfaa2f..cf5f735 100644 --- a/src/Paca.Browser/ChromeInternalUrlRouter.cs +++ b/src/Paca.Browser/ChromeInternalUrlRouter.cs @@ -32,7 +32,13 @@ public static class ChromeInternalUrlRouter return ChromeInternalAction.None; 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 { diff --git a/src/Paca.Browser/Content/MhtmlWebArchivePackager.cs b/src/Paca.Browser/Content/MhtmlWebArchivePackager.cs index 370543e..87c3265 100644 --- a/src/Paca.Browser/Content/MhtmlWebArchivePackager.cs +++ b/src/Paca.Browser/Content/MhtmlWebArchivePackager.cs @@ -9,12 +9,15 @@ public sealed class MhtmlWebArchivePackager { public string Package(WebArchivePackageRequest request) { + if (request == null) + throw new ArgumentNullException(nameof(request)); + var boundary = "----=_NextPart_" + Guid.NewGuid().ToString("N"); var sb = new StringBuilder(); sb.AppendLine("From: "); - sb.AppendLine($"Snapshot-Content-Location: {request.Url}"); - sb.AppendLine($"Subject: {request.Title}"); + sb.AppendLine($"Snapshot-Content-Location: {request.Url ?? string.Empty}"); + sb.AppendLine($"Subject: {request.Title ?? "Untitled"}"); sb.AppendLine($"Date: {DateTime.UtcNow:R}"); sb.AppendLine("MIME-Version: 1.0"); sb.AppendLine($"Content-Type: multipart/related; boundary=\"{boundary}\"; type=\"text/html\""); @@ -24,21 +27,26 @@ public sealed class MhtmlWebArchivePackager sb.AppendLine($"--{boundary}"); sb.AppendLine("Content-Type: text/html; charset=\"utf-8\""); sb.AppendLine("Content-Transfer-Encoding: 8bit"); - sb.AppendLine($"Content-Location: {request.Url}"); + sb.AppendLine($"Content-Location: {request.Url ?? string.Empty}"); sb.AppendLine(); - sb.AppendLine(request.HtmlContent); + sb.AppendLine(request.HtmlContent ?? string.Empty); sb.AppendLine(); // Resources - foreach (var res in request.Resources) + if (request.Resources != null) { - sb.AppendLine($"--{boundary}"); - sb.AppendLine($"Content-Type: {res.MimeType}"); - sb.AppendLine("Content-Transfer-Encoding: base64"); - sb.AppendLine($"Content-Location: {res.Url}"); - sb.AppendLine(); - sb.AppendLine(Convert.ToBase64String(res.Data)); - sb.AppendLine(); + foreach (var res in request.Resources) + { + if (res == null) continue; + var data = res.Data ?? Array.Empty(); + sb.AppendLine($"--{boundary}"); + sb.AppendLine($"Content-Type: {res.MimeType ?? "application/octet-stream"}"); + sb.AppendLine("Content-Transfer-Encoding: base64"); + sb.AppendLine($"Content-Location: {res.Url ?? string.Empty}"); + sb.AppendLine(); + sb.AppendLine(Convert.ToBase64String(data)); + sb.AppendLine(); + } } sb.AppendLine($"--{boundary}--"); diff --git a/src/Paca.Browser/Content/SiteAutoMutePolicy.cs b/src/Paca.Browser/Content/SiteAutoMutePolicy.cs index ee3549a..b218f64 100644 --- a/src/Paca.Browser/Content/SiteAutoMutePolicy.cs +++ b/src/Paca.Browser/Content/SiteAutoMutePolicy.cs @@ -41,6 +41,11 @@ public sealed class SiteAutoMutePolicy var host = uri.Host.ToLowerInvariant(); foreach (var pattern in _patterns) { + if (pattern == "*" || pattern == "*.*") + { + return true; + } + if (pattern.StartsWith("*.", StringComparison.Ordinal)) { var suffix = pattern[2..]; @@ -67,14 +72,23 @@ public sealed class SiteAutoMutePolicy { if (string.IsNullOrWhiteSpace(_configPath)) return; - var dir = Path.GetDirectoryName(_configPath); - if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + try { - Directory.CreateDirectory(dir); - } + var dir = Path.GetDirectoryName(_configPath); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + { + Directory.CreateDirectory(dir); + } - var json = JsonSerializer.Serialize(_patterns.ToList(), new JsonSerializerOptions { WriteIndented = true }); - File.WriteAllText(_configPath, json); + var json = JsonSerializer.Serialize(_patterns.ToList(), new JsonSerializerOptions { WriteIndented = true }); + var tmp = _configPath + ".tmp"; + File.WriteAllText(tmp, json); + File.Move(tmp, _configPath, overwrite: true); + } + catch + { + // 디스크 오류 또는 권한 문제 시 예외를 전파하지 않고 메모리 정책으로 격리 + } } private void Load() diff --git a/src/Paca.Browser/DualSubtitleEngine.cs b/src/Paca.Browser/DualSubtitleEngine.cs index 95d495c..8f3c4ab 100644 --- a/src/Paca.Browser/DualSubtitleEngine.cs +++ b/src/Paca.Browser/DualSubtitleEngine.cs @@ -1,4 +1,4 @@ -namespace Paca.Browser; +namespace Paca.Browser; public sealed record DualCuePair(string? PrimaryText, string? SecondaryText); @@ -28,6 +28,8 @@ public sealed class DualSubtitleEngine public DualCuePair? GetDualCues(double currentSec) { + if (double.IsNaN(currentSec) || double.IsInfinity(currentSec) || currentSec < 0) return null; + var pCue = _primaryEngine.GetActiveCue(currentSec); var sCue = _secondaryEngine.GetActiveCue(currentSec); @@ -38,7 +40,7 @@ public sealed class DualSubtitleEngine public void RegisterAudioTrack(AudioTrackInfo track) { - if (track == null) return; + if (track == null || _audioTracks.Any(t => t.Id == track.Id)) return; _audioTracks.Add(track); if (track.IsDefault || _audioTracks.Count == 1) { diff --git a/src/Paca.Browser/Extensions/ExtensionCspValidator.cs b/src/Paca.Browser/Extensions/ExtensionCspValidator.cs index d8e7594..8e84810 100644 --- a/src/Paca.Browser/Extensions/ExtensionCspValidator.cs +++ b/src/Paca.Browser/Extensions/ExtensionCspValidator.cs @@ -39,9 +39,16 @@ public static class ExtensionCspValidator 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) || 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."); } @@ -50,6 +57,11 @@ public static class ExtensionCspValidator normalizedList.Add(directive); } + if (normalizedList.Count == 0) + { + return new CspValidationResult(true, Array.Empty(), DefaultManifestV3Csp); + } + var normalizedCsp = string.Join("; ", normalizedList); if (!normalizedCsp.EndsWith(';')) normalizedCsp += ";"; diff --git a/src/Paca.Browser/LiveCaptionEngine.cs b/src/Paca.Browser/LiveCaptionEngine.cs index fe16286..8ffc518 100644 --- a/src/Paca.Browser/LiveCaptionEngine.cs +++ b/src/Paca.Browser/LiveCaptionEngine.cs @@ -1,4 +1,4 @@ -using System.Globalization; +using System.Globalization; using System.Text; namespace Paca.Browser; @@ -60,6 +60,7 @@ public sealed class LiveCaptionEngine 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); } diff --git a/src/Paca.Core/AudioAlbumTrackSplitter.cs b/src/Paca.Core/AudioAlbumTrackSplitter.cs index 064a10e..e7d3e7e 100644 --- a/src/Paca.Core/AudioAlbumTrackSplitter.cs +++ b/src/Paca.Core/AudioAlbumTrackSplitter.cs @@ -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 -"; } + /// /// /// Parses ffmpeg stderr silencedetect output and returns calculated track intervals. /// public static List ParseSilenceToTracks(string silenceLog, double totalDurationSec, double minTrackDurationSec = 30.0) { var result = new List(); - if (string.IsNullOrWhiteSpace(silenceLog) || totalDurationSec <= 0) + if (double.IsNaN(totalDurationSec) || double.IsInfinity(totalDurationSec) || totalDurationSec <= 0) { - if (totalDurationSec > 0) - { - result.Add(new AlbumTrackSegment(1, 0.0, totalDurationSec, "Track 01")); - } + return result; + } + + if (string.IsNullOrWhiteSpace(silenceLog)) + { + result.Add(new AlbumTrackSegment(1, 0.0, totalDurationSec, "Track 01")); return result; } @@ -146,6 +149,15 @@ public static class AudioAlbumTrackSplitter /// 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; 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 /// public static string GenerateM3uPlaylist(string albumTitle, IReadOnlyList tracks, string filenamePattern = "track_{0:D2}.mp3") { + var safeAlbum = (albumTitle ?? "Album").Replace("\r", "").Replace("\n", " ").Trim(); var sb = new StringBuilder(); sb.AppendLine("#EXTM3U"); @@ -162,8 +175,9 @@ public static class AudioAlbumTrackSplitter { foreach (var t in tracks) { + var safeTitle = (t.Title ?? "Track").Replace("\r", "").Replace("\n", " ").Trim(); 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)); } } diff --git a/src/Paca.Core/AudioChannelDownmixer.cs b/src/Paca.Core/AudioChannelDownmixer.cs index e80d7a7..eddab24 100644 --- a/src/Paca.Core/AudioChannelDownmixer.cs +++ b/src/Paca.Core/AudioChannelDownmixer.cs @@ -41,6 +41,13 @@ public static class AudioChannelDownmixer DownmixMode mode, 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(); sb.Append($"-i \"{inputPath}\""); @@ -48,7 +55,8 @@ public static class AudioChannelDownmixer sb.Append(" -c:v copy"); var filter = BuildDownmixFilterArgs(mode); - sb.Append($" -filter:a \"{filter}\""); + if (!string.IsNullOrWhiteSpace(filter)) + sb.Append($" -filter:a \"{filter}\""); sb.Append(" -c:a aac -b:a 192k"); sb.Append($" \"{outputPath}\" -y"); diff --git a/src/Paca.Core/AudioDynamicRangeCompressor.cs b/src/Paca.Core/AudioDynamicRangeCompressor.cs index b278b5e..8fee5f7 100644 --- a/src/Paca.Core/AudioDynamicRangeCompressor.cs +++ b/src/Paca.Core/AudioDynamicRangeCompressor.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Globalization; namespace Paca.Core; @@ -39,6 +39,19 @@ public static class AudioDynamicRangeCompressor /// 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; 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 /// 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"; var filter = BuildFilter(preset); return $"-hide_banner -y -i \"{inputPath}\" {vCodec} -af \"{filter}\" -c:a aac -b:a {audioBitrateKbps}k \"{outputPath}\""; diff --git a/src/Paca.Core/AudioPitchShiftEngine.cs b/src/Paca.Core/AudioPitchShiftEngine.cs index 313fc78..ca59c8f 100644 --- a/src/Paca.Core/AudioPitchShiftEngine.cs +++ b/src/Paca.Core/AudioPitchShiftEngine.cs @@ -17,6 +17,9 @@ public static class AudioPitchShiftEngine /// public static double ClampSemitones(double semitones) { + if (double.IsNaN(semitones) || double.IsInfinity(semitones)) + return 0.0; + return Math.Clamp(semitones, MinSemitones, MaxSemitones); } @@ -34,6 +37,9 @@ public static class AudioPitchShiftEngine /// 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 newRate = (int)Math.Round(sampleRate * scale); var tempoFactor = 1.0 / scale; @@ -51,6 +57,13 @@ public static class AudioPitchShiftEngine bool isAudioOnly = false, 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 sb = new StringBuilder(); sb.Append($"-i \"{inputPath}\""); diff --git a/src/Paca.Core/AudioVocalStemExtractor.cs b/src/Paca.Core/AudioVocalStemExtractor.cs index 5c14116..1fdcfa5 100644 --- a/src/Paca.Core/AudioVocalStemExtractor.cs +++ b/src/Paca.Core/AudioVocalStemExtractor.cs @@ -46,9 +46,17 @@ public static class AudioVocalStemExtractor AudioStemMode mode, string audioCodec = "libmp3lame") { - var filter = BuildFfmpegStemFilter(mode); - var bitrate = audioCodec.Contains("mp3", StringComparison.OrdinalIgnoreCase) ? "320k" : "192k"; + 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)); - 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"; } } diff --git a/src/Paca.Core/AudioVolumeNormalizer.cs b/src/Paca.Core/AudioVolumeNormalizer.cs index 9938128..48ab3b0 100644 --- a/src/Paca.Core/AudioVolumeNormalizer.cs +++ b/src/Paca.Core/AudioVolumeNormalizer.cs @@ -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 TargetOffsetRegex = new(@"""target_offset""\s*:\s*""?([^"",\s]+)""?", RegexOptions.Compiled); + /// + /// Builds single-pass loudnorm audio filter string. + /// /// /// Builds single-pass loudnorm audio filter string. /// public static string BuildLoudnormFilterArgs(AudioNormalizerPreset preset) { + if (preset == null) + throw new ArgumentNullException(nameof(preset)); + var i = preset.TargetI.ToString("F1", CultureInfo.InvariantCulture); var tp = preset.TargetTp.ToString("F1", CultureInfo.InvariantCulture); var lra = preset.TargetLra.ToString("F1", CultureInfo.InvariantCulture); @@ -46,6 +52,15 @@ public static class AudioVolumeNormalizer /// 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(); sb.Append($"-i \"{inputPath}\""); diff --git a/src/Paca.Core/BandwidthScheduleProfileEngine.cs b/src/Paca.Core/BandwidthScheduleProfileEngine.cs index d44e26e..dcd9cdf 100644 --- a/src/Paca.Core/BandwidthScheduleProfileEngine.cs +++ b/src/Paca.Core/BandwidthScheduleProfileEngine.cs @@ -46,6 +46,9 @@ public static class BandwidthScheduleProfileEngine foreach (var rule in config.Rules) { + if (rule == null || rule.Profile == null) + continue; + bool inWindow; if (rule.StartTime <= rule.EndTime) { @@ -61,6 +64,6 @@ public static class BandwidthScheduleProfileEngine return rule.Profile; } - return config.DefaultProfile; + return config.DefaultProfile ?? new BandwidthProfile("Default", 0, 4); } } diff --git a/src/Paca.Core/Config/WorkspaceConfigSanitizer.cs b/src/Paca.Core/Config/WorkspaceConfigSanitizer.cs index 79fa704..6b2b0e4 100644 --- a/src/Paca.Core/Config/WorkspaceConfigSanitizer.cs +++ b/src/Paca.Core/Config/WorkspaceConfigSanitizer.cs @@ -43,10 +43,16 @@ public static class WorkspaceConfigSanitizer { foreach (var name in names) { + if (result.Count >= 20) + break; + if (!IsCorrupted(name)) { 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); } } diff --git a/src/Paca.Core/Dash/DashParser.cs b/src/Paca.Core/Dash/DashParser.cs index 0f75bb0..6736ef6 100644 --- a/src/Paca.Core/Dash/DashParser.cs +++ b/src/Paca.Core/Dash/DashParser.cs @@ -98,6 +98,7 @@ public static class DashParser // t 속성은 타임라인을 해당 절대시각으로 재설정. // #9 r=-1 은 "Period 끝까지 반복" — count=r+1=0 으로 0개를 내던 버그 수정. var timescale = (long?)tmpl.Attribute("timescale") ?? 1; + if (timescale <= 0) timescale = 1; long endTime = periodSec > 0 ? (long)Math.Ceiling(periodSec * timescale) : long.MaxValue; long number = startNumber; int idx = 0; long time = 0; const long MaxSegs = 200000; // 폭주 방지 상한 @@ -130,6 +131,7 @@ public static class DashParser else { var timescale = (long?)tmpl.Attribute("timescale") ?? 1; + if (timescale <= 0) timescale = 1; var duration = (long?)tmpl.Attribute("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); } - private static double ParseDuration(string? d) + internal static double ParseDuration(string? d) { if (string.IsNullOrWhiteSpace(d)) return 0; try @@ -205,17 +207,40 @@ public static class DashParser } catch { - // 폴백: 수동 파싱 - var s = d.Trim(); - if (!s.StartsWith("PT", StringComparison.OrdinalIgnoreCase)) return 0; - s = s.Substring(2); - double total = 0; var num = ""; + // 폴백: ISO 8601 Duration (P[n]Y[n]M[n]DT[n]H[n]M[n]S) 수동 파싱 + var s = d.Trim().ToUpperInvariant(); + if (!s.StartsWith("P")) return 0; + s = s.Substring(1); // 'P' 제거 + + bool inTime = false; + double total = 0; + var num = ""; foreach (var ch in s) { - if (char.IsDigit(ch) || ch == '.') num += ch; - else if (ch is 'H' or 'M' or 'S' && double.TryParse(num, NumberStyles.Any, CultureInfo.InvariantCulture, out var v)) + if (ch == 'T') { - 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 = ""; } } diff --git a/src/Paca.Core/Detection/AiHighlightClipper.cs b/src/Paca.Core/Detection/AiHighlightClipper.cs index b20f7a6..d84d78f 100644 --- a/src/Paca.Core/Detection/AiHighlightClipper.cs +++ b/src/Paca.Core/Detection/AiHighlightClipper.cs @@ -32,6 +32,16 @@ public static class AiHighlightClipper Func? sceneChangeProvider = null, double windowSeconds = 30.0) { + if (double.IsNaN(totalDurationSeconds) || totalDurationSeconds <= 0) + { + return new List(); + } + + if (double.IsNaN(windowSeconds) || windowSeconds <= 0) + { + windowSeconds = 30.0; + } + var windows = new List(); int count = (int)Math.Ceiling(totalDurationSeconds / windowSeconds); @@ -61,11 +71,29 @@ public static class AiHighlightClipper public static List 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 { - "-ss", spec.StartSeconds.ToString("F2", System.Globalization.CultureInfo.InvariantCulture), - "-t", spec.DurationSeconds.ToString("F2", System.Globalization.CultureInfo.InvariantCulture), - "-i", inputPath + "-ss", startSec.ToString("F2", System.Globalization.CultureInfo.InvariantCulture), + "-t", durSec.ToString("F2", System.Globalization.CultureInfo.InvariantCulture), + "-i", cleanInput }; if (spec.VerticalCrop9x16) @@ -78,7 +106,7 @@ public static class AiHighlightClipper args.AddRange(new[] { "-c", "copy" }); } - args.AddRange(new[] { "-y", outputPath }); + args.AddRange(new[] { "-y", cleanOutput }); return args; } } diff --git a/src/Paca.Core/Detection/MediaDetector.cs b/src/Paca.Core/Detection/MediaDetector.cs index f5c20dc..6310738 100644 --- a/src/Paca.Core/Detection/MediaDetector.cs +++ b/src/Paca.Core/Detection/MediaDetector.cs @@ -6,9 +6,22 @@ namespace Paca.Core.Detection; public static class MediaDetector { /// HLS 또는 Direct, 감지 대상이 아니면 null - 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(); // 세그먼트 청크(.ts, .m4s, chunk, init.mp4 등)는 독립 미디어가 아니므로 목록 등록 차단 diff --git a/src/Paca.Core/Detection/NeuralShaderUpscaler.cs b/src/Paca.Core/Detection/NeuralShaderUpscaler.cs index 019a30a..c4cedd3 100644 --- a/src/Paca.Core/Detection/NeuralShaderUpscaler.cs +++ b/src/Paca.Core/Detection/NeuralShaderUpscaler.cs @@ -26,9 +26,16 @@ public static class NeuralShaderUpscaler { 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 $@" // 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); SamplerState LinearSampler : register(s0); @@ -41,13 +48,13 @@ float4 PS_NeuralUpscale(PS_INPUT input) : SV_Target {{ float2 uv = input.TexCoord; float4 center = InputTexture.Sample(LinearSampler, uv); // Neural weight accumulation pass - float4 n = InputTexture.Sample(LinearSampler, uv + float2(0, -1.0 / {config.TargetHeight})); - float4 s = InputTexture.Sample(LinearSampler, uv + float2(0, 1.0 / {config.TargetHeight})); - float4 e = InputTexture.Sample(LinearSampler, uv + float2(1.0 / {config.TargetWidth}, 0)); - float4 w = InputTexture.Sample(LinearSampler, uv + float2(-1.0 / {config.TargetWidth}, 0)); + float4 n = InputTexture.Sample(LinearSampler, uv + float2(0, -1.0 / {targetHeight})); + float4 s = InputTexture.Sample(LinearSampler, uv + float2(0, 1.0 / {targetHeight})); + float4 e = InputTexture.Sample(LinearSampler, uv + float2(1.0 / {targetWidth}, 0)); + float4 w = InputTexture.Sample(LinearSampler, uv + float2(-1.0 / {targetWidth}, 0)); float4 edge = abs(n - s) + abs(e - w); - return center + (edge * {config.Sharpness:F2} * 0.15); + return center + (edge * {sharpnessStr} * 0.15); }} "; } diff --git a/src/Paca.Core/Detection/SocialSiteDetector.cs b/src/Paca.Core/Detection/SocialSiteDetector.cs index 182fb30..fa75334 100644 --- a/src/Paca.Core/Detection/SocialSiteDetector.cs +++ b/src/Paca.Core/Detection/SocialSiteDetector.cs @@ -17,11 +17,37 @@ public static class SocialSiteDetector private static readonly Regex TikTokPathRe = new(@"/@[^/]+/video/([0-9]+)", RegexOptions.Compiled); private static readonly Regex FbPathRe = new(@"/(?:reel|videos)/([^/?#]+)", RegexOptions.Compiled); + /// 문자열 URL이 소셜 영상 URL이면 정규화된 URL과 사이트 라벨을 반환. + 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); + } + /// 소셜 영상 URL 이면 정규화된 URL 과 사이트 라벨을 반환. public static bool TryMatch(Uri url, out string normalized, out string label) { normalized = ""; label = ""; + if (url == null || !url.IsAbsoluteUri) return false; + if (url.Scheme != "http" && url.Scheme != "https") return false; + var host = (url.Host ?? "").ToLowerInvariant(); var path = url.AbsolutePath ?? ""; diff --git a/src/Paca.Core/Hls/HlsDownloader.cs b/src/Paca.Core/Hls/HlsDownloader.cs index 8437852..4a7ef70 100644 --- a/src/Paca.Core/Hls/HlsDownloader.cs +++ b/src/Paca.Core/Hls/HlsDownloader.cs @@ -262,10 +262,18 @@ public class HlsDownloader _keys[keyUri] = key; } - private byte[] DecryptSegment(byte[] data, Segment seg) + internal byte[] DecryptSegment(byte[] data, Segment seg) { 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); using var aes = Aes.Create(); aes.Key = keyBytes; @@ -276,13 +284,33 @@ public class HlsDownloader 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; if (!string.IsNullOrEmpty(iv)) { - var hex = iv.Replace("0x", "").Replace(" ", ""); - return Convert.FromHexString(hex.Length % 2 == 1 ? "0" + hex : hex); + var hex = iv.Replace("0x", "").Replace("0X", "").Replace(" ", ""); + 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 으로 var b = new byte[16]; diff --git a/src/Paca.Core/Hls/M3u8Parser.cs b/src/Paca.Core/Hls/M3u8Parser.cs index 0f0451f..55122fa 100644 --- a/src/Paca.Core/Hls/M3u8Parser.cs +++ b/src/Paca.Core/Hls/M3u8Parser.cs @@ -31,9 +31,11 @@ public static class M3u8Parser var variants = new List(); var segments = new List(); Uri? init = null; + long? initByteRangeLen = null; + long? initByteRangeOff = null; 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 { @@ -41,6 +43,8 @@ public static class M3u8Parser Variants = variants, Segments = segments, InitSegment = init, + InitByteRangeLength = initByteRangeLen, + InitByteRangeOffset = initByteRangeOff, }; } @@ -49,6 +53,11 @@ public static class M3u8Parser long seq = 0; int idx = 0; + long? pendingByteRangeLength = null; + long? pendingByteRangeOffset = null; + long prevByteRangeOffset = 0; + long prevByteRangeLength = 0; + using var reader = new StringReader(text); string? raw; while ((raw = reader.ReadLine()) != null) @@ -64,7 +73,7 @@ public static class M3u8Parser if (line.StartsWith("#EXT-X-MEDIA-SEQUENCE:", StringComparison.OrdinalIgnoreCase)) { 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; } if (line.StartsWith("#EXT-X-KEY:", StringComparison.OrdinalIgnoreCase)) @@ -78,11 +87,46 @@ public static class M3u8Parser : null; 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)) { 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)) 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; } if (line.StartsWith("#")) continue; @@ -93,15 +137,18 @@ public static class M3u8Parser if (pendingVariant != null) { 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 = null; isMaster = true; } else { - segments.Add(new Segment(abs, idx, seq, curKey)); - idx++; seq++; + segments.Add(new Segment(abs, idx, seq, curKey, pendingByteRangeLength, pendingByteRangeOffset)); + pendingByteRangeLength = null; + pendingByteRangeOffset = null; + idx++; + if (seq < long.MaxValue) seq++; } } @@ -111,6 +158,8 @@ public static class M3u8Parser Variants = variants, Segments = segments, InitSegment = init, + InitByteRangeLength = initByteRangeLen, + InitByteRangeOffset = initByteRangeOff, }; } } diff --git a/src/Paca.Core/Hls/PartialChunkIntegrityCache.cs b/src/Paca.Core/Hls/PartialChunkIntegrityCache.cs index ecbdbed..f88e4ca 100644 --- a/src/Paca.Core/Hls/PartialChunkIntegrityCache.cs +++ b/src/Paca.Core/Hls/PartialChunkIntegrityCache.cs @@ -85,24 +85,31 @@ public class PartialChunkIntegrityCache /// public ChunkIntegrityState VerifyChunkFile(string filePath, long? expectedBytes = null, string? expectedSha256 = null) { - if (!File.Exists(filePath)) - return ChunkIntegrityState.Missing; - - var fi = new FileInfo(filePath); - if (fi.Length == 0) - return ChunkIntegrityState.Corrupt; - - if (expectedBytes.HasValue && expectedBytes.Value >= 0 && fi.Length != expectedBytes.Value) - return ChunkIntegrityState.Corrupt; - - if (!string.IsNullOrWhiteSpace(expectedSha256)) + try { - var computed = ComputeFileSha256(filePath); - if (!string.Equals(computed, expectedSha256.Trim(), StringComparison.OrdinalIgnoreCase)) - return ChunkIntegrityState.Corrupt; - } + if (!File.Exists(filePath)) + return ChunkIntegrityState.Missing; - return ChunkIntegrityState.Verified; + var fi = new FileInfo(filePath); + if (fi.Length == 0) + return ChunkIntegrityState.Corrupt; + + if (expectedBytes.HasValue && expectedBytes.Value >= 0 && fi.Length != expectedBytes.Value) + return ChunkIntegrityState.Corrupt; + + if (!string.IsNullOrWhiteSpace(expectedSha256)) + { + var computed = ComputeFileSha256(filePath); + if (!string.Equals(computed, expectedSha256.Trim(), StringComparison.OrdinalIgnoreCase)) + return ChunkIntegrityState.Corrupt; + } + + return ChunkIntegrityState.Verified; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return ChunkIntegrityState.Corrupt; + } } /// @@ -206,10 +213,21 @@ public class PartialChunkIntegrityCache long verifiedBytes = 0; var toDownload = new List(); + var fullPartsDir = Path.GetFullPath(partsDir); for (int i = 0; i < totalSegments; i++) { var chunkName = getChunkName(i); 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"; // 잔존 임시 파일(.tmp)은 비정상 중단 흔적이므로 안전 제거 diff --git a/src/Paca.Core/HlsManifestValidator.cs b/src/Paca.Core/HlsManifestValidator.cs index c2b4842..4f68cb5 100644 --- a/src/Paca.Core/HlsManifestValidator.cs +++ b/src/Paca.Core/HlsManifestValidator.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Globalization; using System.IO; @@ -123,6 +123,7 @@ public static class HlsManifestValidator /// public static string StripAdBlocks(string manifestContent, double maxAdDurationSec = 30.0) { + if (manifestContent == null) return string.Empty; if (string.IsNullOrWhiteSpace(manifestContent)) return manifestContent; var lines = manifestContent.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None); diff --git a/src/Paca.Core/Media/FftAudioWaveformEngine.cs b/src/Paca.Core/Media/FftAudioWaveformEngine.cs index 115d1ea..055f46c 100644 --- a/src/Paca.Core/Media/FftAudioWaveformEngine.cs +++ b/src/Paca.Core/Media/FftAudioWaveformEngine.cs @@ -50,7 +50,11 @@ public static class FftAudioWaveformEngine public static string BuildFfmpegEqualizerFilter(List 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); } } diff --git a/src/Paca.Core/Media/HardwareTranscoderStudio.cs b/src/Paca.Core/Media/HardwareTranscoderStudio.cs index bffe150..ea69be7 100644 --- a/src/Paca.Core/Media/HardwareTranscoderStudio.cs +++ b/src/Paca.Core/Media/HardwareTranscoderStudio.cs @@ -40,7 +40,19 @@ public static class HardwareTranscoderStudio TranscodePresetSpec spec, Platform.GpuCapabilities? capabilities = null) { - var args = new List { "-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 { "-i", cleanInput }; // Select Video Encoder string vcodec; @@ -78,7 +90,9 @@ public static class HardwareTranscoderStudio 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; } } diff --git a/src/Paca.Core/Media/WebRtcCastingEngine.cs b/src/Paca.Core/Media/WebRtcCastingEngine.cs index f0d710d..ec3aa36 100644 --- a/src/Paca.Core/Media/WebRtcCastingEngine.cs +++ b/src/Paca.Core/Media/WebRtcCastingEngine.cs @@ -42,13 +42,25 @@ public sealed class WebRtcCastingEngine _iceServers.Add(new IceServerConfig()); } + public int ActiveSessionsCount + { + get + { + lock (_lock) + { + return _sessions.Count; + } + } + } + public WebRtcCastSession CreateCastSession(string targetDeviceName) { + var cleanDevice = string.IsNullOrWhiteSpace(targetDeviceName) ? "Default Display" : targetDeviceName.Trim(); lock (_lock) { var session = new WebRtcCastSession { - TargetDeviceName = targetDeviceName, + TargetDeviceName = cleanDevice, IsConnected = true }; _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) { + 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 { 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) { + if (string.IsNullOrWhiteSpace(sessionId)) return false; lock (_lock) { return _sessions.Remove(sessionId); diff --git a/src/Paca.Core/MediaBitrateEstimator.cs b/src/Paca.Core/MediaBitrateEstimator.cs index 68c1c53..2ddc7a4 100644 --- a/src/Paca.Core/MediaBitrateEstimator.cs +++ b/src/Paca.Core/MediaBitrateEstimator.cs @@ -1,4 +1,4 @@ -using System; +using System; 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) { 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 totalBits = usableBytes * 8.0; @@ -74,9 +78,22 @@ public static class MediaBitrateEstimator /// 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 pass1 = $"-hide_banner -y -i \"{inputPath}\" -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 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 \"{cleanInput}\" -c:v libx264 -b:v {plan.VideoBitrateKbps}k -pass 2 -c:a aac -b:a {plan.AudioBitrateKbps}k \"{cleanOutput}\""; return (pass1, pass2); } } \ No newline at end of file diff --git a/src/Paca.Core/MediaMetadataTagWriter.cs b/src/Paca.Core/MediaMetadataTagWriter.cs index efca0aa..92bfb4d 100644 --- a/src/Paca.Core/MediaMetadataTagWriter.cs +++ b/src/Paca.Core/MediaMetadataTagWriter.cs @@ -81,12 +81,23 @@ public static class MediaMetadataTagWriter /// 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(); - sb.Append($"-i \"{inputPath}\""); + sb.Append($"-i \"{cleanIn}\""); 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)) @@ -115,10 +126,10 @@ public static class MediaMetadataTagWriter if (!string.IsNullOrEmpty(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($" \"{outputPath}\" -y"); + sb.Append($" \"{cleanOut}\" -y"); return sb.ToString(); } } diff --git a/src/Paca.Core/MediaPlaybackSpeedPitchEngine.cs b/src/Paca.Core/MediaPlaybackSpeedPitchEngine.cs index 4488d94..d764cc6 100644 --- a/src/Paca.Core/MediaPlaybackSpeedPitchEngine.cs +++ b/src/Paca.Core/MediaPlaybackSpeedPitchEngine.cs @@ -64,10 +64,23 @@ public static class MediaPlaybackSpeedPitchEngine double speed, 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); var atempo = BuildAtempoFilterChain(speed); var sb = new StringBuilder(); - sb.Append($"-i \"{inputPath}\""); + sb.Append($"-i \"{cleanInput}\""); if (!isAudioOnly) { @@ -82,7 +95,7 @@ public static class MediaPlaybackSpeedPitchEngine sb.Append(" -c:a libmp3lame -b:a 320k"); } - sb.Append($" \"{outputPath}\" -y"); + sb.Append($" \"{cleanOutput}\" -y"); return sb.ToString(); } } diff --git a/src/Paca.Core/Models/PlaylistModels.cs b/src/Paca.Core/Models/PlaylistModels.cs index f848f04..d603db2 100644 --- a/src/Paca.Core/Models/PlaylistModels.cs +++ b/src/Paca.Core/Models/PlaylistModels.cs @@ -10,8 +10,8 @@ public record Variant(Uri Url, int Bandwidth, string Resolution, string Codecs) get { if (string.IsNullOrEmpty(Resolution)) return 0; - var parts = Resolution.Split('x', 'X'); - return parts.Length == 2 && int.TryParse(parts[1], out int h) ? h : 0; + var parts = Resolution.Split(new[] { 'x', 'X', '@' }, StringSplitOptions.RemoveEmptyEntries); + 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 { if (string.IsNullOrEmpty(Resolution)) return 0; - var parts = Resolution.Split('x', 'X'); - return parts.Length == 2 && int.TryParse(parts[0], out int w) ? w : 0; + var parts = Resolution.Split(new[] { 'x', 'X', '@' }, StringSplitOptions.RemoveEmptyEntries); + 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 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); /// m3u8 파싱 결과 public record ParsedPlaylist @@ -39,4 +39,6 @@ public record ParsedPlaylist public IReadOnlyList Variants { get; init; } = Array.Empty(); public IReadOnlyList Segments { get; init; } = Array.Empty(); public Uri? InitSegment { get; init; } // #EXT-X-MAP (fMP4 초기화 세그먼트) + public long? InitByteRangeLength { get; init; } + public long? InitByteRangeOffset { get; init; } } diff --git a/src/Paca.Core/MultiTrackMediaMuxer.cs b/src/Paca.Core/MultiTrackMediaMuxer.cs index 8cebc61..2fba2cd 100644 --- a/src/Paca.Core/MultiTrackMediaMuxer.cs +++ b/src/Paca.Core/MultiTrackMediaMuxer.cs @@ -36,8 +36,16 @@ public static class MultiTrackMediaMuxer /// public static string BuildMuxCommandLine(MuxJobSpecification job) { - if (job == null) - throw new ArgumentNullException(nameof(job)); + ArgumentNullException.ThrowIfNull(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(); var maps = new List(); @@ -45,9 +53,20 @@ public static class MultiTrackMediaMuxer var disps = new List(); 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) { - 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"); fileIndex++; } @@ -55,14 +74,19 @@ public static class MultiTrackMediaMuxer for (var i = 0; i < job.AudioTracks.Count; 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"); if (!string.IsNullOrEmpty(track.Language)) metas.Add($"-metadata:s:a:{i} language={track.Language}"); 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) disps.Add($"-disposition:a:{i} default"); @@ -73,14 +97,19 @@ public static class MultiTrackMediaMuxer for (var i = 0; i < job.SubtitleTracks.Count; 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"); if (!string.IsNullOrEmpty(track.Language)) metas.Add($"-metadata:s:s:{i} language={track.Language}"); 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) disps.Add($"-disposition:s:{i} default"); @@ -106,7 +135,7 @@ public static class MultiTrackMediaMuxer } sb.Append(" -c copy"); - sb.Append($" \"{job.OutputPath}\" -y"); + sb.Append($" \"{cleanOut}\" -y"); return sb.ToString(); } diff --git a/src/Paca.Core/Net/HostRateLimiter.cs b/src/Paca.Core/Net/HostRateLimiter.cs index bf89ed9..8b363e3 100644 --- a/src/Paca.Core/Net/HostRateLimiter.cs +++ b/src/Paca.Core/Net/HostRateLimiter.cs @@ -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 GetDefaultIntervalMs(string host) { - if (string.IsNullOrWhiteSpace(host)) return 200; - var h = host.ToLowerInvariant(); + var h = NormalizeHost(host); + if (string.IsNullOrWhiteSpace(h)) return 200; if (IsDomainOrSubdomain(h, "4cdn.org") || IsDomainOrSubdomain(h, "4chan.org") || IsDomainOrSubdomain(h, "4channel.org")) return 1200; // 4chan 공식 API 룰: 초당 1회 이하 diff --git a/src/Paca.Core/Net/MultiCdnMeshDownloader.cs b/src/Paca.Core/Net/MultiCdnMeshDownloader.cs index 20dcf51..51ac7a7 100644 --- a/src/Paca.Core/Net/MultiCdnMeshDownloader.cs +++ b/src/Paca.Core/Net/MultiCdnMeshDownloader.cs @@ -42,10 +42,17 @@ public sealed class MultiCdnMeshDownloader _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) { var key = GetMirrorKey(mirrorUri); - _mirrorStats.TryAdd(key, new CdnMirrorInfo(new Uri(key))); + _mirrorStats.TryAdd(key, CreateMirrorInfo(key)); } public List GetRankedMirrors() @@ -53,7 +60,11 @@ public sealed class MultiCdnMeshDownloader 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(); + } /// /// 복수 CDN 미러 URL로 세그먼트 레이싱 다운로드를 수행합니다. @@ -63,12 +74,20 @@ public sealed class MultiCdnMeshDownloader int staggerDelayMs = 40, 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) { return new MultiCdnSegmentRaceResult { IsSuccess = false }; } + if (staggerDelayMs < 0) + staggerDelayMs = 0; + if (uris.Count == 1) { return await DownloadSingleSegmentAsync(uris[0], ct).ConfigureAwait(false); @@ -91,7 +110,14 @@ public sealed class MultiCdnMeshDownloader if (i > 0 && staggerDelayMs > 0) { - await Task.Delay(staggerDelayMs, linkedCts.Token).ConfigureAwait(false); + try + { + await Task.Delay(staggerDelayMs, linkedCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } } if (linkedCts.IsCancellationRequested) break; @@ -137,7 +163,7 @@ public sealed class MultiCdnMeshDownloader { var sw = Stopwatch.StartNew(); var key = GetMirrorKey(uri); - var stat = _mirrorStats.GetOrAdd(key, _ => new CdnMirrorInfo(new Uri(key))); + var stat = _mirrorStats.GetOrAdd(key, k => CreateMirrorInfo(k)); try { diff --git a/src/Paca.Core/Net/P2pSwarmEngine.cs b/src/Paca.Core/Net/P2pSwarmEngine.cs index 9bedc7c..4a83eee 100644 --- a/src/Paca.Core/Net/P2pSwarmEngine.cs +++ b/src/Paca.Core/Net/P2pSwarmEngine.cs @@ -86,6 +86,10 @@ public sealed class P2pSwarmEngine 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 { Name = name, @@ -118,6 +122,7 @@ public sealed class P2pSwarmEngine public void InitializeSession(SwarmTorrentManifest manifest) { + ArgumentNullException.ThrowIfNull(manifest); lock (_lock) { _manifest = manifest; @@ -142,15 +147,20 @@ public sealed class P2pSwarmEngine public void AddPeer(SwarmPeer peer) { + if (peer == null) return; lock (_lock) { + peer.Bitfield ??= Array.Empty(); if (peer.Bitfield.Length != _pieces.Count) { var bf = new bool[_pieces.Count]; Array.Copy(peer.Bitfield, bf, Math.Min(peer.Bitfield.Length, bf.Length)); peer.Bitfield = bf; } - _peers[peer.PeerId] = peer; + if (!string.IsNullOrEmpty(peer.PeerId)) + { + _peers[peer.PeerId] = peer; + } } } @@ -164,11 +174,15 @@ public sealed class P2pSwarmEngine public bool IngestPieceData(int pieceIndex, byte[] data) { + if (data == null) return false; lock (_lock) { if (!_pieces.TryGetValue(pieceIndex, out var piece)) return false; + if (data.Length != piece.Length) + return false; + using var sha256 = SHA256.Create(); byte[] computedHash = sha256.ComputeHash(data); string computedHex = Convert.ToHexString(computedHash).ToLowerInvariant(); diff --git a/src/Paca.Core/Paca.Core.csproj b/src/Paca.Core/Paca.Core.csproj index ced30ec..70fe9df 100644 --- a/src/Paca.Core/Paca.Core.csproj +++ b/src/Paca.Core/Paca.Core.csproj @@ -8,5 +8,6 @@ + diff --git a/src/Paca.Core/Platform/AudioNormalizer.cs b/src/Paca.Core/Platform/AudioNormalizer.cs index 9de133a..929111b 100644 --- a/src/Paca.Core/Platform/AudioNormalizer.cs +++ b/src/Paca.Core/Platform/AudioNormalizer.cs @@ -25,6 +25,11 @@ public sealed class AudioNormalizer public async Task 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 var iStr = spec.TargetLufs.ToString("F1", CultureInfo.InvariantCulture); var tpStr = spec.TruePeak.ToString("F1", CultureInfo.InvariantCulture); diff --git a/src/Paca.Core/Platform/ChapterSplitter.cs b/src/Paca.Core/Platform/ChapterSplitter.cs index 8c7eb4c..00cd988 100644 --- a/src/Paca.Core/Platform/ChapterSplitter.cs +++ b/src/Paca.Core/Platform/ChapterSplitter.cs @@ -1,4 +1,4 @@ -using System.Globalization; +using System.Globalization; using System.IO; using System.Text; using Paca.Core.Util; @@ -68,25 +68,44 @@ public sealed class ChapterSplitter public static string GetChapterFileName(int id, string title, string ext) { - var cleanTitle = FileNameUtil.Sanitize(title); - var cleanExt = ext.TrimStart('.'); + var cleanTitle = string.IsNullOrWhiteSpace(title) ? "Chapter" : FileNameUtil.Sanitize(title); + var cleanExt = string.IsNullOrWhiteSpace(ext) ? "mp4" : ext.TrimStart('.'); 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 chapters, string? mainTitle = null) { var sb = new StringBuilder(); sb.AppendLine(";FFMETADATA1"); if (!string.IsNullOrWhiteSpace(mainTitle)) - sb.AppendLine($"title={mainTitle}"); + sb.AppendLine($"title={EscapeFfmetadata(mainTitle)}"); - foreach (var ch in chapters) + if (chapters != null) { - sb.AppendLine("[CHAPTER]"); - sb.AppendLine("TIMEBASE=1/1000"); - sb.AppendLine($"START={(long)(ch.StartSec * 1000)}"); - sb.AppendLine($"END={(long)(ch.EndSec * 1000)}"); - sb.AppendLine($"title={ch.Title}"); + 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("TIMEBASE=1/1000"); + sb.AppendLine($"START={startMs}"); + sb.AppendLine($"END={endMs}"); + sb.AppendLine($"title={EscapeFfmetadata(ch.Title ?? "Chapter")}"); + } } return sb.ToString(); diff --git a/src/Paca.Core/Platform/HardwareEncoderDetector.cs b/src/Paca.Core/Platform/HardwareEncoderDetector.cs index 96ee774..0e2f1c7 100644 --- a/src/Paca.Core/Platform/HardwareEncoderDetector.cs +++ b/src/Paca.Core/Platform/HardwareEncoderDetector.cs @@ -61,8 +61,9 @@ public sealed class HardwareEncoderDetector 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.HasAppleVideotoolbox) return "h264_videotoolbox"; if (caps.HasIntelQsv) return "h264_qsv"; @@ -70,8 +71,9 @@ public sealed class HardwareEncoderDetector 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.HasAppleVideotoolbox) return "hevc_videotoolbox"; if (caps.HasQsvHevc || caps.HasIntelQsv) return "hevc_qsv"; @@ -79,40 +81,45 @@ public sealed class HardwareEncoderDetector 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.HasQsvAv1) return "av1_qsv"; if (caps.HasAmfAv1) return "av1_amf"; 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[] { - "-c:v", encoder, - "-preset", preset, - "-cq", crfOrCqp.ToString() + "-c:v", cleanEncoder, + "-preset", cleanPreset, + "-cq", safeCrf.ToString() }; } - if (encoder.EndsWith("_qsv", StringComparison.OrdinalIgnoreCase)) + if (cleanEncoder.EndsWith("_qsv", StringComparison.OrdinalIgnoreCase)) { return new[] { - "-c:v", encoder, - "-global_quality", crfOrCqp.ToString() + "-c:v", cleanEncoder, + "-global_quality", safeCrf.ToString() }; } return new[] { - "-c:v", encoder, - "-crf", crfOrCqp.ToString(), - "-preset", preset + "-c:v", cleanEncoder, + "-crf", safeCrf.ToString(), + "-preset", cleanPreset }; } } diff --git a/src/Paca.Core/Platform/HdrToneMapper.cs b/src/Paca.Core/Platform/HdrToneMapper.cs index ca11c3d..a5f0883 100644 --- a/src/Paca.Core/Platform/HdrToneMapper.cs +++ b/src/Paca.Core/Platform/HdrToneMapper.cs @@ -1,4 +1,4 @@ -namespace Paca.Core.Platform; +namespace Paca.Core.Platform; public enum ToneMapAlgorithm { @@ -33,6 +33,8 @@ public sealed class HdrToneMapper public static string BuildToneMapFilter(ToneMapAlgorithm algo, int targetNits = 100) { + if (targetNits <= 0) targetNits = 100; + var algoStr = algo switch { ToneMapAlgorithm.Mobius => "mobius", @@ -46,33 +48,51 @@ public sealed class HdrToneMapper 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[] { "-y", "-hide_banner", "-loglevel", "error", - "-i", inputFile, + "-i", cleanInput, "-c:v", "copy", "-c:a", "copy", - outputFile + cleanOutput }; } public async Task 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 args = new[] { "-y", "-hide_banner", "-loglevel", "error", - "-i", inputFile, + "-i", cleanInput, "-vf", filter, "-c:v", "libx264", "-crf", "18", "-preset", "fast", "-c:a", "copy", - outputFile + cleanOutput }; var res = await _runner.RunAsync(new ProcessSpec(ffmpeg, args), null, ct); if (res.ExitCode != 0) throw new InvalidOperationException($"SDR 톤매핑 변환 실패: {res.Stderr}"); - return outputFile; + return cleanOutput; } } diff --git a/src/Paca.Core/Platform/HighlightShortsClipper.cs b/src/Paca.Core/Platform/HighlightShortsClipper.cs index a1fcd9f..572aa3d 100644 --- a/src/Paca.Core/Platform/HighlightShortsClipper.cs +++ b/src/Paca.Core/Platform/HighlightShortsClipper.cs @@ -1,4 +1,4 @@ -using System.Globalization; +using System.Globalization; namespace Paca.Core.Platform; @@ -29,9 +29,11 @@ public sealed class HighlightShortsClipper public static IReadOnlyList DetectHighlightWindows(IEnumerable samples, double windowDurationSec = 15.0, int topCount = 3) { - if (samples == null) return Array.Empty(); + if (samples == null || topCount <= 0) return Array.Empty(); + 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(); foreach (var s in sortedByVolume) @@ -51,7 +53,16 @@ public sealed class HighlightShortsClipper public async Task 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 (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 durStr = clip.DurationSec.ToString("F2", CultureInfo.InvariantCulture); @@ -61,7 +72,7 @@ public sealed class HighlightShortsClipper "-y", "-hide_banner", "-loglevel", "error", "-ss", startStr, "-t", durStr, - "-i", inputFile + "-i", cleanInput }; if (isVerticalShorts) diff --git a/src/Paca.Core/Platform/LosslessSmartTrimmer.cs b/src/Paca.Core/Platform/LosslessSmartTrimmer.cs index f854d64..c1ca464 100644 --- a/src/Paca.Core/Platform/LosslessSmartTrimmer.cs +++ b/src/Paca.Core/Platform/LosslessSmartTrimmer.cs @@ -1,4 +1,4 @@ -using System.Globalization; +using System.Globalization; namespace Paca.Core.Platform; @@ -16,14 +16,23 @@ public sealed class LosslessSmartTrimmer public static double FindNearestKeyframe(IEnumerable 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 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("시작 시간과 종료 시간이 유효하지 않습니다."); var startStr = startSec.ToString("F3", CultureInfo.InvariantCulture); diff --git a/src/Paca.Core/Platform/MultiSourceSegmentAccelerator.cs b/src/Paca.Core/Platform/MultiSourceSegmentAccelerator.cs index 0299f9c..22c77dc 100644 --- a/src/Paca.Core/Platform/MultiSourceSegmentAccelerator.cs +++ b/src/Paca.Core/Platform/MultiSourceSegmentAccelerator.cs @@ -1,4 +1,4 @@ -namespace Paca.Core.Platform; +namespace Paca.Core.Platform; public sealed class MirrorHealthInfo { @@ -16,65 +16,87 @@ public sealed class MultiSourceSegmentAccelerator { private readonly int _maxFailuresBeforeDrop; private readonly List _mirrors = new(); + private readonly object _syncLock = new(); public MultiSourceSegmentAccelerator(int maxFailuresBeforeDrop = 3) { _maxFailuresBeforeDrop = maxFailuresBeforeDrop; } - public int MirrorCount => _mirrors.Count; + public int MirrorCount + { + get + { + lock (_syncLock) return _mirrors.Count; + } + } public void AddMirror(string baseUrl) { if (string.IsNullOrWhiteSpace(baseUrl)) return; - if (!_mirrors.Any(m => m.BaseUrl.Equals(baseUrl, StringComparison.OrdinalIgnoreCase))) + lock (_syncLock) { - _mirrors.Add(new MirrorHealthInfo + if (!_mirrors.Any(m => m.BaseUrl.Equals(baseUrl, StringComparison.OrdinalIgnoreCase))) { - BaseUrl = baseUrl, - MaxFailures = _maxFailuresBeforeDrop - }); + _mirrors.Add(new MirrorHealthInfo + { + BaseUrl = baseUrl, + MaxFailures = _maxFailuresBeforeDrop + }); + } } } public void RecordFailure(string baseUrl) { - var mirror = _mirrors.FirstOrDefault(m => m.BaseUrl.Equals(baseUrl, StringComparison.OrdinalIgnoreCase)); - if (mirror != null) + lock (_syncLock) { - mirror.FailureCount++; + var mirror = _mirrors.FirstOrDefault(m => m.BaseUrl.Equals(baseUrl, StringComparison.OrdinalIgnoreCase)); + if (mirror != null) + { + mirror.FailureCount++; + } } } public void RecordLatency(string baseUrl, TimeSpan latency) { - var mirror = _mirrors.FirstOrDefault(m => m.BaseUrl.Equals(baseUrl, StringComparison.OrdinalIgnoreCase)); - if (mirror != null) + lock (_syncLock) { - mirror.AverageLatencyMs = latency.TotalMilliseconds; + var mirror = _mirrors.FirstOrDefault(m => m.BaseUrl.Equals(baseUrl, StringComparison.OrdinalIgnoreCase)); + if (mirror != null) + { + mirror.AverageLatencyMs = latency.TotalMilliseconds; + } } } public string SelectBestMirror(int chunkIndex) { - if (_mirrors.Count == 0) return ""; - - var active = _mirrors.Where(m => m.IsActive).ToList(); - if (active.Count == 0) + lock (_syncLock) { - // 모든 미러가 실패 상태이면 첫 번째 미러로 폴백 - return _mirrors[0].BaseUrl; - } + if (_mirrors.Count == 0) return ""; - int idx = (int)((uint)chunkIndex % active.Count); - return active[idx].BaseUrl; + var active = _mirrors.Where(m => m.IsActive).ToList(); + if (active.Count == 0) + { + // 모든 미러가 실패 상태이면 첫 번째 미러로 폴백 + return _mirrors[0].BaseUrl; + } + + int idx = (int)((uint)chunkIndex % active.Count); + return active[idx].BaseUrl; + } } public string? GetFastestMirror() { - var active = _mirrors.Where(m => m.IsActive).ToList(); - if (active.Count == 0) return _mirrors.FirstOrDefault()?.BaseUrl; + lock (_syncLock) + { + var active = _mirrors.Where(m => m.IsActive).ToList(); + if (active.Count == 0) return _mirrors.FirstOrDefault()?.BaseUrl; - return active.OrderBy(m => m.AverageLatencyMs).First().BaseUrl; + return active.OrderBy(m => m.AverageLatencyMs).First().BaseUrl; + } } } diff --git a/src/Paca.Core/Platform/PredictiveChunkPrefetcher.cs b/src/Paca.Core/Platform/PredictiveChunkPrefetcher.cs index ec7be1b..eb36a88 100644 --- a/src/Paca.Core/Platform/PredictiveChunkPrefetcher.cs +++ b/src/Paca.Core/Platform/PredictiveChunkPrefetcher.cs @@ -1,4 +1,4 @@ -namespace Paca.Core.Platform; +namespace Paca.Core.Platform; /// /// 46차: 지능형 대역폭 예측 및 동적 청크 프리패칭 윈도우 엔진. @@ -9,6 +9,7 @@ public sealed class PredictiveChunkPrefetcher private readonly int _maxWindowSize; private readonly Queue _speedSamples = new(); private readonly int _maxSamples; + private readonly object _syncLock = new(); public PredictiveChunkPrefetcher(int initialWindowSize = 3, int minWindowSize = 1, int maxWindowSize = 10, int maxSamples = 5) { @@ -22,18 +23,23 @@ public sealed class PredictiveChunkPrefetcher if (duration.TotalSeconds <= 0 || chunkSizeBytes <= 0) return; double speed = chunkSizeBytes / duration.TotalSeconds; - _speedSamples.Enqueue(speed); - while (_speedSamples.Count > _maxSamples) + lock (_syncLock) { - _speedSamples.Dequeue(); + _speedSamples.Enqueue(speed); + while (_speedSamples.Count > _maxSamples) + { + _speedSamples.Dequeue(); + } } } public double GetEstimatedSpeedBytesPerSec() { - if (_speedSamples.Count == 0) return 1_000_000.0; // 기본값 1MB/s - - return _speedSamples.Average(); + lock (_syncLock) + { + if (_speedSamples.Count == 0) return 1_000_000.0; // 기본값 1MB/s + return _speedSamples.Average(); + } } public int CalculateOptimalPrefetchWindow() diff --git a/src/Paca.Core/Platform/ProcessModels.cs b/src/Paca.Core/Platform/ProcessModels.cs index d9f70b7..70d00ba 100644 --- a/src/Paca.Core/Platform/ProcessModels.cs +++ b/src/Paca.Core/Platform/ProcessModels.cs @@ -32,14 +32,21 @@ public sealed class DesktopProcessRunner : IProcessRunner System.Diagnostics.Process? proc = null; try { - var psi = new System.Diagnostics.ProcessStartInfo(spec.ExePath) + var exePath = (spec.ExePath ?? "").Trim().Trim('"'); + var psi = new System.Diagnostics.ProcessStartInfo(exePath) { UseShellExecute = false, CreateNoWindow = true, RedirectStandardError = 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) { foreach (var (key, prefix) in spec.EnvPrefix) @@ -50,7 +57,7 @@ public sealed class DesktopProcessRunner : IProcessRunner } proc = System.Diagnostics.Process.Start(psi) - ?? throw new InvalidOperationException($"{spec.ExePath} 시작 실패"); + ?? throw new InvalidOperationException($"{exePath} 시작 실패"); await using var _ = ct.Register(() => { 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 outTask = TapAsync(proc.StandardOutput, "out", stdout, onLine, ct); var errTask = TapAsync(proc.StandardError, "err", stderr, onLine, ct); - await proc.WaitForExitAsync(ct); - await Task.WhenAll(outTask, errTask); + try + { + await proc.WaitForExitAsync(ct); + 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()); } finally @@ -74,11 +91,17 @@ public sealed class DesktopProcessRunner : IProcessRunner private static async Task TapAsync(StreamReader reader, string stream, StringBuilder acc, Action? onLine, CancellationToken ct) { - string? line; - while ((line = await reader.ReadLineAsync(ct)) != null) + try { - acc.AppendLine(line); - if (onLine != null && !string.IsNullOrWhiteSpace(line)) onLine(stream, line); + string? line; + while ((line = await reader.ReadLineAsync(ct)) != null) + { + acc.AppendLine(line); + if (onLine != null && !string.IsNullOrWhiteSpace(line)) onLine(stream, line); + } } + catch (OperationCanceledException) { } + catch (ObjectDisposedException) { } + catch (IOException) { } } } diff --git a/src/Paca.Core/Platform/VideoDeduplicator.cs b/src/Paca.Core/Platform/VideoDeduplicator.cs index a28827a..af9eb06 100644 --- a/src/Paca.Core/Platform/VideoDeduplicator.cs +++ b/src/Paca.Core/Platform/VideoDeduplicator.cs @@ -1,4 +1,4 @@ -using System.Numerics; +using System.Numerics; namespace Paca.Core.Platform; @@ -11,26 +11,43 @@ public sealed class VideoDeduplicator { private readonly int _maxHammingThreshold; private readonly Dictionary _registry = new(StringComparer.OrdinalIgnoreCase); + private readonly object _syncLock = new(); 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) { if (string.IsNullOrWhiteSpace(filePath)) return; - _registry[filePath] = pHash; + var cleanPath = filePath.Replace("\"", "").Trim(); + lock (_syncLock) + { + _registry[cleanPath] = pHash; + } } public DuplicateMatch? FindDuplicate(ulong targetHash) { + KeyValuePair[] snapshot; + lock (_syncLock) + { + snapshot = _registry.ToArray(); + } + DuplicateMatch? bestMatch = null; int minDistance = int.MaxValue; - foreach (var (path, hash) in _registry) + foreach (var (path, hash) in snapshot) { var distance = ComputeHammingDistance(targetHash, hash); if (distance <= _maxHammingThreshold && distance < minDistance) @@ -43,7 +60,10 @@ public sealed class VideoDeduplicator return bestMatch; } - public void Clear() => _registry.Clear(); + public void Clear() + { + lock (_syncLock) _registry.Clear(); + } public static int ComputeHammingDistance(ulong a, ulong b) { diff --git a/src/Paca.Core/Platform/VideoDenoiseUpscaler.cs b/src/Paca.Core/Platform/VideoDenoiseUpscaler.cs index e45e312..ccbf94d 100644 --- a/src/Paca.Core/Platform/VideoDenoiseUpscaler.cs +++ b/src/Paca.Core/Platform/VideoDenoiseUpscaler.cs @@ -1,4 +1,4 @@ -namespace Paca.Core.Platform; +namespace Paca.Core.Platform; public enum DenoiseStrength { @@ -72,25 +72,33 @@ public sealed class VideoDenoiseUpscaler public async Task EnhanceVideoAsync(string ffmpeg, string inputFile, string outputFile, DenoiseUpscaleSpec spec, CancellationToken ct) { 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 args = new[] { "-y", "-hide_banner", "-loglevel", "error", - "-i", inputFile, + "-i", cleanInput, "-vf", filter, "-c:v", "libx264", "-preset", "medium", "-crf", "18", "-c:a", "copy", - outputFile + cleanOutput }; var res = await _runner.RunAsync(new ProcessSpec(ffmpeg, args), null, ct); if (res.ExitCode != 0) throw new InvalidOperationException($"비디오 화질 개선 실패: {res.Stderr}"); - return outputFile; + return cleanOutput; } } diff --git a/src/Paca.Core/Platform/VideoWatermarkBurner.cs b/src/Paca.Core/Platform/VideoWatermarkBurner.cs index c5b516d..615ce1f 100644 --- a/src/Paca.Core/Platform/VideoWatermarkBurner.cs +++ b/src/Paca.Core/Platform/VideoWatermarkBurner.cs @@ -1,4 +1,4 @@ -using System.Globalization; +using System.Globalization; 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) { - 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 사이여야 합니다."); 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) { - 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 ApplyWatermarkAsync(string ffmpeg, string inputFile, string outputFile, WatermarkSpec spec, CancellationToken ct) { 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); diff --git a/src/Paca.Core/Platform/VoiceActivitySubtitleAdapter.cs b/src/Paca.Core/Platform/VoiceActivitySubtitleAdapter.cs index 79acfdb..6010d63 100644 --- a/src/Paca.Core/Platform/VoiceActivitySubtitleAdapter.cs +++ b/src/Paca.Core/Platform/VoiceActivitySubtitleAdapter.cs @@ -1,4 +1,4 @@ -using System.Globalization; +using System.Globalization; using System.Text; namespace Paca.Core.Platform; @@ -67,6 +67,7 @@ public static class VoiceActivitySubtitleAdapter int cueIndex = 1; foreach (var seg in segments) { + if (seg == null) continue; sb.AppendLine(cueIndex.ToString()); sb.AppendLine($"{FormatTimecode(seg.StartSec)} --> {FormatTimecode(seg.EndSec)}"); sb.AppendLine($"[AI Speech Segment {cueIndex}]"); @@ -79,6 +80,12 @@ public static class VoiceActivitySubtitleAdapter 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); return string.Format(CultureInfo.InvariantCulture, "{0:D2}:{1:D2}:{2:D2}.{3:D3}", (int)ts.TotalHours, diff --git a/src/Paca.Core/Platform/VrSpatialAudioEngine.cs b/src/Paca.Core/Platform/VrSpatialAudioEngine.cs index 2356572..ccec3f6 100644 --- a/src/Paca.Core/Platform/VrSpatialAudioEngine.cs +++ b/src/Paca.Core/Platform/VrSpatialAudioEngine.cs @@ -1,4 +1,4 @@ -namespace Paca.Core.Platform; +namespace Paca.Core.Platform; public sealed record VrSpatialMetadata( string Projection, @@ -40,21 +40,29 @@ public sealed class VrSpatialAudioEngine public async Task InjectVrMetadataAsync(string ffmpeg, string inputFile, string outputFile, VrSpatialMetadata meta, CancellationToken ct) { 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[] { "-y", "-hide_banner", "-loglevel", "error", - "-i", inputFile, + "-i", cleanInput, "-c", "copy", - "-metadata", $"spherical-video=projection:{meta.Projection}", - "-metadata", $"stereo-mode={meta.StereoMode}", - outputFile + "-metadata", $"spherical-video=projection:{meta.Projection ?? "equirectangular"}", + "-metadata", $"stereo-mode={meta.StereoMode ?? "mono"}", + cleanOutput }; var res = await _runner.RunAsync(new ProcessSpec(ffmpeg, args), null, ct); if (res.ExitCode != 0) throw new InvalidOperationException($"VR 메타데이터 주입 실패: {res.Stderr}"); - return outputFile; + return cleanOutput; } } diff --git a/src/Paca.Core/QuickActions/QuickActionClipboardDetector.cs b/src/Paca.Core/QuickActions/QuickActionClipboardDetector.cs index e16e826..f3d24b1 100644 --- a/src/Paca.Core/QuickActions/QuickActionClipboardDetector.cs +++ b/src/Paca.Core/QuickActions/QuickActionClipboardDetector.cs @@ -20,10 +20,32 @@ public sealed record ClipboardIntentResult( public sealed class QuickActionClipboardDetector { - private static readonly Regex MediaUrlRegex = new( - @"(?: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 - ); + private static readonly HashSet KnownMediaHosts = new(StringComparer.OrdinalIgnoreCase) + { + "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( @"^[\s\d\.\+\-\*\/\(\)\^\%\=]+$", @@ -32,16 +54,34 @@ public sealed class QuickActionClipboardDetector public ClipboardIntentResult DetectIntent(string? text) { - if (string.IsNullOrWhiteSpace(text)) + if (string.IsNullOrWhiteSpace(text) || text.Length > 8192) return new ClipboardIntentResult(ClipboardIntentType.None, null, null, null); 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 감지 - 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)) { - if (MediaUrlRegex.IsMatch(trimmed)) + if (IsMediaHost(uri.Host) || HasMediaExtension(uri.AbsolutePath)) { return new ClipboardIntentResult( ClipboardIntentType.MediaDownload, @@ -60,8 +100,9 @@ public sealed class QuickActionClipboardDetector ); } - // 3. 인라인 수식 감지 (연산자가 포함되어 있고 3글자 이상인 경우) + // 3. 인라인 수식 감지 (숫자가 포함되어 있고 연산자가 포함되어 있는 경우) if (trimmed.Length >= 3 && + trimmed.Any(char.IsDigit) && (trimmed.Contains('+') || trimmed.Contains('-') || trimmed.Contains('*') || trimmed.Contains('/') || trimmed.Contains('^')) && MathExpressionRegex.IsMatch(trimmed)) { diff --git a/src/Paca.Core/QuickActions/QuickActionFuzzyMatcher.cs b/src/Paca.Core/QuickActions/QuickActionFuzzyMatcher.cs index be8be6c..bd1a671 100644 --- a/src/Paca.Core/QuickActions/QuickActionFuzzyMatcher.cs +++ b/src/Paca.Core/QuickActions/QuickActionFuzzyMatcher.cs @@ -34,28 +34,37 @@ public static class QuickActionFuzzyMatcher if (string.IsNullOrEmpty(s)) return t?.Length ?? 0; 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 tLower = t.ToLowerInvariant(); int n = sLower.Length; int m = tLower.Length; - int[,] d = new int[n + 1, m + 1]; - for (int i = 0; i <= n; i++) d[i, 0] = i; - for (int j = 0; j <= m; j++) d[0, j] = j; + int[] prev = new int[m + 1]; + int[] curr = new int[m + 1]; + + for (int j = 0; j <= m; j++) prev[j] = j; for (int i = 1; i <= n; i++) { + curr[0] = i; + char sChar = sLower[i - 1]; + for (int j = 1; j <= m; j++) { - int cost = (sLower[i - 1] == tLower[j - 1]) ? 0 : 1; - d[i, j] = Math.Min( - Math.Min(d[i - 1, j] + 1, d[i, j - 1] + 1), - d[i - 1, j - 1] + cost); + int cost = (sChar == tLower[j - 1]) ? 0 : 1; + curr[j] = Math.Min( + Math.Min(curr[j - 1] + 1, prev[j] + 1), + prev[j - 1] + cost); } + + Array.Copy(curr, prev, m + 1); } - return d[n, m]; + return prev[m]; } public static int CalculateFuzzyScore(string? pattern, string? target) diff --git a/src/Paca.Core/QuickActions/QuickActionInlineEvaluator.cs b/src/Paca.Core/QuickActions/QuickActionInlineEvaluator.cs index 6642a46..4f77039 100644 --- a/src/Paca.Core/QuickActions/QuickActionInlineEvaluator.cs +++ b/src/Paca.Core/QuickActions/QuickActionInlineEvaluator.cs @@ -41,8 +41,8 @@ public sealed class QuickActionInlineEvaluator return true; } - // Basic arithmetic: e.g. "12 + 34", "100 * 5", "2 ^ 10", "1000 - 350", "10 / 4" - var opMatch = Regex.Match(expr, @"^([0-9\.]+)\s*([\+\-\*\/\^])\s*([0-9\.]+)$"); + // 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\.]+)$"); if (opMatch.Success && 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)) @@ -89,6 +89,10 @@ public sealed class QuickActionInlineEvaluator { byte[] bytes = Convert.FromBase64String(raw); string text = Encoding.UTF8.GetString(bytes); + if (text.Contains('\0')) + { + text = $"[바이너리 데이터 ({bytes.Length} 바이트)] " + Convert.ToHexString(bytes); + } result = new EvaluatorResult(text, "Base64 디코딩 결과", "dev.base64_decode"); return true; } @@ -109,6 +113,10 @@ public sealed class QuickActionInlineEvaluator { string hex = query.StartsWith("hex ", StringComparison.OrdinalIgnoreCase) ? query[4..].Trim() : query.Trim(); hex = hex.TrimStart('#'); + if (hex.Length == 3) + { + hex = $"{hex[0]}{hex[0]}{hex[1]}{hex[1]}{hex[2]}{hex[2]}"; + } if (hex.Length == 6 && byte.TryParse(hex[0..2], NumberStyles.HexNumber, null, out byte r) && byte.TryParse(hex[2..4], NumberStyles.HexNumber, null, out byte g) && diff --git a/src/Paca.Core/QuickActions/QuickActionParameterRouter.cs b/src/Paca.Core/QuickActions/QuickActionParameterRouter.cs index a05f261..41f7d47 100644 --- a/src/Paca.Core/QuickActions/QuickActionParameterRouter.cs +++ b/src/Paca.Core/QuickActions/QuickActionParameterRouter.cs @@ -73,8 +73,8 @@ public sealed class QuickActionParameterRouter IconGlyph: "\uE8AC", Handler: ctx => { - ctx.ClipboardSetter?.Invoke(encoded); - ctx.ToastNotifier?.Invoke("Base64 복사 완료"); + ctx?.ClipboardSetter?.Invoke(encoded); + ctx?.ToastNotifier?.Invoke("Base64 복사 완료"); return Task.FromResult(new QuickActionResult(true, OutputValue: encoded)); }, Description: $"'{text}' ➔ Base64로 인코딩하여 클립보드에 복사합니다." @@ -109,8 +109,8 @@ public sealed class QuickActionParameterRouter IconGlyph: "\uE8AC", Handler: ctx => { - ctx.ClipboardSetter?.Invoke(decoded); - ctx.ToastNotifier?.Invoke("디코딩 복사 완료"); + ctx?.ClipboardSetter?.Invoke(decoded); + ctx?.ToastNotifier?.Invoke("디코딩 복사 완료"); return Task.FromResult(new QuickActionResult(true, OutputValue: decoded)); }, Description: $"Base64 문자열을 디코딩하여 클립보드에 복사합니다." @@ -142,8 +142,8 @@ public sealed class QuickActionParameterRouter IconGlyph: "\uE943", Handler: ctx => { - ctx.ClipboardSetter?.Invoke(hex); - ctx.ToastNotifier?.Invoke("SHA-256 해시 복사 완료"); + ctx?.ClipboardSetter?.Invoke(hex); + ctx?.ToastNotifier?.Invoke("SHA-256 해시 복사 완료"); return Task.FromResult(new QuickActionResult(true, OutputValue: hex)); }, Description: $"'{text}'에 대한 전체 SHA-256 해시값을 클립보드에 복사합니다." @@ -167,7 +167,7 @@ public sealed class QuickActionParameterRouter IconGlyph: "\uE70B", Handler: ctx => { - ctx.ToastNotifier?.Invoke($"메모 저장됨: {text}"); + ctx?.ToastNotifier?.Invoke($"메모 저장됨: {text}"); return Task.FromResult(new QuickActionResult(true, OutputValue: text)); }, Description: $"'{text}' 텍스트를 PACA 빠른 메모에 즉시 기록합니다." diff --git a/src/Paca.Core/Security/CrdtVaultSyncEngine.cs b/src/Paca.Core/Security/CrdtVaultSyncEngine.cs index c479fc0..c74c461 100644 --- a/src/Paca.Core/Security/CrdtVaultSyncEngine.cs +++ b/src/Paca.Core/Security/CrdtVaultSyncEngine.cs @@ -36,38 +36,44 @@ public sealed class CrdtVaultSyncEngine public CrdtItemRecord Set(string id, T value) { + if (string.IsNullOrWhiteSpace(id)) + throw new ArgumentException("Item ID cannot be null or whitespace.", nameof(id)); + lock (_lock) { _currentLamportClock++; var record = new CrdtItemRecord { - Id = id, + Id = id.Trim(), Value = value, LamportTimestamp = _currentLamportClock, ClientId = _clientId, IsDeleted = false, UtcTimestamp = DateTime.UtcNow }; - _items[id] = record; + _items[record.Id] = record; return record; } } public CrdtItemRecord Delete(string id) { + if (string.IsNullOrWhiteSpace(id)) + throw new ArgumentException("Item ID cannot be null or whitespace.", nameof(id)); + lock (_lock) { _currentLamportClock++; var record = new CrdtItemRecord { - Id = id, + Id = id.Trim(), Value = default, LamportTimestamp = _currentLamportClock, ClientId = _clientId, IsDeleted = true, UtcTimestamp = DateTime.UtcNow }; - _items[id] = record; + _items[record.Id] = record; return record; } } @@ -93,11 +99,18 @@ public sealed class CrdtVaultSyncEngine public int Merge(IEnumerable> remoteRecords) { + if (remoteRecords == null) return 0; int appliedCount = 0; lock (_lock) { 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)) { _items[remote.Id] = remote; diff --git a/src/Paca.Core/Security/CredentialDataExchange.cs b/src/Paca.Core/Security/CredentialDataExchange.cs index 7dc64c0..7ace2d6 100644 --- a/src/Paca.Core/Security/CredentialDataExchange.cs +++ b/src/Paca.Core/Security/CredentialDataExchange.cs @@ -25,7 +25,8 @@ public static class CredentialDataExchange /// public static byte[] ExportToEncryptedVault(IReadOnlyList records, string masterPassword) { - if (string.IsNullOrEmpty(masterPassword)) + ArgumentNullException.ThrowIfNull(records); + if (string.IsNullOrWhiteSpace(masterPassword)) throw new ArgumentException("마스터 비밀번호를 입력해야 합니다.", nameof(masterPassword)); var exportItems = records.Select(r => new @@ -110,7 +111,7 @@ public static class CredentialDataExchange byte[] tag = br.ReadBytes(TagSizeBytes); 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("암호문 크기가 유효 범위를 벗어났습니다."); byte[] ciphertext = br.ReadBytes(cipherLen); @@ -179,15 +180,18 @@ public static class CredentialDataExchange var sb = new StringBuilder(); sb.AppendLine("name,url,username,password,note,totp,category"); + if (records == null) return sb.ToString(); + foreach (var r in records) { - string name = EscapeCsv(r.SiteName.Length > 0 ? r.SiteName : r.Domain); - string url = EscapeCsv(r.Url.Length > 0 ? r.Url : "https://" + r.Domain); - string user = EscapeCsv(r.Username); - string pass = EscapeCsv(!string.IsNullOrEmpty(r.DecryptedPassword) ? r.DecryptedPassword : r.EncryptedPassword); - string note = EscapeCsv(r.Notes); - string totp = EscapeCsv(r.TotpSecret ?? ""); - string cat = EscapeCsv(r.Category); + if (r == null) continue; + string name = EscapeCsv(SanitizeCsvFormula(r.SiteName.Length > 0 ? r.SiteName : r.Domain)); + string url = EscapeCsv(SanitizeCsvFormula(r.Url.Length > 0 ? r.Url : "https://" + r.Domain)); + string user = EscapeCsv(SanitizeCsvFormula(r.Username)); + string pass = EscapeCsv(SanitizeCsvFormula(!string.IsNullOrEmpty(r.DecryptedPassword) ? r.DecryptedPassword : r.EncryptedPassword)); + string note = EscapeCsv(SanitizeCsvFormula(r.Notes)); + string totp = EscapeCsv(SanitizeCsvFormula(r.TotpSecret ?? "")); + string cat = EscapeCsv(SanitizeCsvFormula(r.Category)); sb.AppendLine($"{name},{url},{user},{pass},{note},{totp},{cat}"); } @@ -216,13 +220,13 @@ public static class CredentialDataExchange var cols = ParseCsvLine(line); if (cols.Count < 3) continue; - string name = cols[0]; - string url = cols[1]; - string user = cols[2]; - string pass = cols.Count > 3 ? cols[3] : ""; - string note = cols.Count > 4 ? cols[4] : ""; - string totp = cols.Count > 5 ? cols[5] : ""; - string cat = cols.Count > 6 ? cols[6] : "General"; + string name = UnsanitizeCsvFormula(cols[0]); + string url = UnsanitizeCsvFormula(cols[1]); + string user = UnsanitizeCsvFormula(cols[2]); + string pass = cols.Count > 3 ? UnsanitizeCsvFormula(cols[3]) : ""; + string note = cols.Count > 4 ? UnsanitizeCsvFormula(cols[4]) : ""; + string totp = cols.Count > 5 ? UnsanitizeCsvFormula(cols[5]) : ""; + string cat = cols.Count > 6 ? UnsanitizeCsvFormula(cols[6]) : "General"; var rec = new SiteCredentialRecord { @@ -241,6 +245,27 @@ public static class CredentialDataExchange 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) { if (string.IsNullOrEmpty(text)) return ""; diff --git a/src/Paca.Core/Security/CredentialSecurityAuditor.cs b/src/Paca.Core/Security/CredentialSecurityAuditor.cs index ddd0391..7a8dbd5 100644 --- a/src/Paca.Core/Security/CredentialSecurityAuditor.cs +++ b/src/Paca.Core/Security/CredentialSecurityAuditor.cs @@ -31,18 +31,25 @@ public sealed record VaultSecurityAuditReport( /// public static class CredentialSecurityAuditor { - public static VaultSecurityAuditReport PerformAudit(IReadOnlyList credentials, int oldPasswordDays = 90) + public static VaultSecurityAuditReport PerformAudit(IReadOnlyList? credentials, int oldPasswordDays = 90) { var issues = new List(); - int total = credentials.Count; - - if (total == 0) + if (credentials == null || credentials.Count == 0) { return new VaultSecurityAuditReport(100, 0, 0, 0, 0, 0, issues, DateTime.UtcNow); } + var cleanList = credentials.Where(c => c != null).Cast().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. 패스워드 재사용 검사 - var passwordGroups = credentials + var passwordGroups = cleanList .Where(c => !string.IsNullOrEmpty(c.DecryptedPassword) || !string.IsNullOrEmpty(c.EncryptedPassword)) .GroupBy(c => !string.IsNullOrEmpty(c.DecryptedPassword) ? c.DecryptedPassword : c.EncryptedPassword) .Where(g => g.Count() > 1) @@ -66,7 +73,7 @@ public static class CredentialSecurityAuditor // 2. 취약한 패스워드 검사 int weakCount = 0; - foreach (var item in credentials) + foreach (var item in cleanList) { if (!string.IsNullOrEmpty(item.DecryptedPassword)) { @@ -88,11 +95,11 @@ public static class CredentialSecurityAuditor // 3. 오래된 비밀번호 검사 (기본 90일) int oldCount = 0; 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++; issues.Add(new VaultAuditIssue( item.Id, item.Domain, item.Username, @@ -104,8 +111,8 @@ public static class CredentialSecurityAuditor // 4. 2FA 미설정 검사 (금융/쇼핑/이메일 등 주요 사이트 또는 전체 미설정) int missing2FaCount = 0; - bool hasSpecificHighSecurityCategory = credentials.Any(c => (c.Category is "Banking" or "Shopping" or "Email") || c.IsFavorite); - foreach (var item in credentials) + bool hasSpecificHighSecurityCategory = cleanList.Any(c => (c.Category is "Banking" or "Shopping" or "Email") || c.IsFavorite); + foreach (var item in cleanList) { if (string.IsNullOrEmpty(item.TotpSecret)) { diff --git a/src/Paca.Core/Security/E2eeRemoteBackupProvider.cs b/src/Paca.Core/Security/E2eeRemoteBackupProvider.cs index a4c9573..bd14e4b 100644 --- a/src/Paca.Core/Security/E2eeRemoteBackupProvider.cs +++ b/src/Paca.Core/Security/E2eeRemoteBackupProvider.cs @@ -1,3 +1,4 @@ +using System.IO; using System.Security.Cryptography; using System.Text; using System.Text.Json; @@ -29,6 +30,10 @@ public static class E2eeRemoteBackupProvider public static string EncryptPayload(string plainText, string masterPassword) { + if (string.IsNullOrWhiteSpace(masterPassword)) + throw new ArgumentException("마스터 비밀번호를 입력해야 합니다.", nameof(masterPassword)); + + plainText ??= ""; byte[] salt = RandomNumberGenerator.GetBytes(SaltSizeBytes); byte[] nonce = RandomNumberGenerator.GetBytes(NonceSizeBytes); byte[] key = Rfc2898DeriveBytes.Pbkdf2( @@ -42,9 +47,17 @@ public static class E2eeRemoteBackupProvider byte[] cipherBytes = new byte[plainBytes.Length]; byte[] tag = new byte[TagSizeBytes]; - using (var aesGcm = new AesGcm(key, TagSizeBytes)) + try { - aesGcm.Encrypt(nonce, plainBytes, cipherBytes, tag); + using (var aesGcm = new AesGcm(key, TagSizeBytes)) + { + aesGcm.Encrypt(nonce, plainBytes, cipherBytes, tag); + } + } + finally + { + CryptographicOperations.ZeroMemory(key); + CryptographicOperations.ZeroMemory(plainBytes); } var header = new VaultBackupHeader @@ -60,13 +73,43 @@ public static class E2eeRemoteBackupProvider public static string DecryptPayload(string backupJson, string masterPassword) { - var header = JsonSerializer.Deserialize(backupJson) - ?? throw new InvalidOperationException("손상된 백업 파일 헤더입니다."); + if (string.IsNullOrWhiteSpace(backupJson)) + throw new InvalidDataException("손상되었거나 비어있는 백업 페이로드입니다."); - byte[] salt = Convert.FromBase64String(header.SaltBase64); - byte[] nonce = Convert.FromBase64String(header.NonceBase64); - byte[] tag = Convert.FromBase64String(header.TagBase64); - byte[] cipherBytes = Convert.FromBase64String(header.CiphertextBase64); + if (string.IsNullOrWhiteSpace(masterPassword)) + throw new ArgumentException("마스터 비밀번호를 입력해야 합니다.", nameof(masterPassword)); + + VaultBackupHeader header; + try + { + header = JsonSerializer.Deserialize(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( Encoding.UTF8.GetBytes(masterPassword), @@ -76,11 +119,18 @@ public static class E2eeRemoteBackupProvider KeySizeBytes); byte[] plainBytes = new byte[cipherBytes.Length]; - using (var aesGcm = new AesGcm(key, TagSizeBytes)) + try { - aesGcm.Decrypt(nonce, cipherBytes, tag, plainBytes); + using (var aesGcm = new AesGcm(key, TagSizeBytes)) + { + aesGcm.Decrypt(nonce, cipherBytes, tag, plainBytes); + } + return Encoding.UTF8.GetString(plainBytes); + } + finally + { + CryptographicOperations.ZeroMemory(key); + CryptographicOperations.ZeroMemory(plainBytes); } - - return Encoding.UTF8.GetString(plainBytes); } } diff --git a/src/Paca.Core/Security/PasswordSecurityEngine.cs b/src/Paca.Core/Security/PasswordSecurityEngine.cs index f8979a7..34543f7 100644 --- a/src/Paca.Core/Security/PasswordSecurityEngine.cs +++ b/src/Paca.Core/Security/PasswordSecurityEngine.cs @@ -145,7 +145,7 @@ public static class PasswordSecurityEngine public static PasswordStrengthResult EvaluateStrength(string? password) { - if (string.IsNullOrEmpty(password)) + if (string.IsNullOrWhiteSpace(password)) { return new PasswordStrengthResult( 0, PasswordGrade.VeryWeak, 0, @@ -224,6 +224,9 @@ public static class TotpEngine DateTime? customTime = null, TotpHashAlgorithm algorithm = TotpHashAlgorithm.Sha1) { + if (timeStepSeconds <= 0) timeStepSeconds = 30; + digits = Math.Clamp(digits, 6, 8); + byte[] key = DecodeBase32(base32Secret); 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) { + if (timeStepSeconds <= 0) timeStepSeconds = 30; DateTime time = customTime ?? DateTime.UtcNow; long seconds = (long)(time - UnixEpoch).TotalSeconds; int remaining = timeStepSeconds - (int)(seconds % timeStepSeconds); - return remaining == 0 ? timeStepSeconds : remaining; + return remaining <= 0 ? timeStepSeconds : remaining; } public static bool ValidateCode( diff --git a/src/Paca.Core/Streaming/AdaptiveStreamingEngines.cs b/src/Paca.Core/Streaming/AdaptiveStreamingEngines.cs index 5a313ec..97de4d3 100644 --- a/src/Paca.Core/Streaming/AdaptiveStreamingEngines.cs +++ b/src/Paca.Core/Streaming/AdaptiveStreamingEngines.cs @@ -51,15 +51,18 @@ public class ThermalThrottlingGuard if (IsThrottled(currentTemperatureCelsius)) return 1; // 과열 시 단일 세그먼트로 스로틀링 - return requestedSegments; + return Math.Max(1, requestedSegments); } } /// FFmpeg 트랜스코딩 프로파일 관리자 public class TranscodeProfileManager { - public string GetFfmpegProfileArgs(string profileName) + public string GetFfmpegProfileArgs(string? profileName) { + if (string.IsNullOrWhiteSpace(profileName)) + return "-c copy"; + return profileName.ToLowerInvariant() switch { "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; } - public void Write(byte[] data) + public void Write(byte[]? data) { + if (data == null || data.Length == 0) return; lock (_lock) { _stream.Seek(0, SeekOrigin.End); _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); + } } } diff --git a/src/Paca.Core/SubtitleBurnInEngine.cs b/src/Paca.Core/SubtitleBurnInEngine.cs index ad41d2d..5ffe3bc 100644 --- a/src/Paca.Core/SubtitleBurnInEngine.cs +++ b/src/Paca.Core/SubtitleBurnInEngine.cs @@ -28,12 +28,32 @@ public static class SubtitleBurnInEngine /// 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}"; } /// /// 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 '\[' / '\]'. /// public static string EscapeSubtitlePathForFfmpeg(string path) { @@ -43,7 +63,9 @@ public static class SubtitleBurnInEngine return path .Replace("\\", "/") .Replace(":", "\\:") - .Replace("'", "\\'"); + .Replace("'", "\\'") + .Replace("[", "\\[") + .Replace("]", "\\]"); } /// @@ -55,6 +77,15 @@ public static class SubtitleBurnInEngine string outputVideoPath, 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; var escapedPath = EscapeSubtitlePathForFfmpeg(subtitlePath); var forceStyle = BuildForceStyleString(style); diff --git a/src/Paca.Core/Util/FileNameUtil.cs b/src/Paca.Core/Util/FileNameUtil.cs index e6e8df3..e175b2c 100644 --- a/src/Paca.Core/Util/FileNameUtil.cs +++ b/src/Paca.Core/Util/FileNameUtil.cs @@ -25,38 +25,58 @@ public static class FileNameUtil "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; + } + /// 파일명으로 사용 불가능한 문자를 '_'로 치환하고 길이를 제한한다. public static string Sanitize(string? name, int maxLen = 120) { + if (maxLen <= 0) return "video"; if (string.IsNullOrWhiteSpace(name)) return "video"; + if (string.IsNullOrWhiteSpace(name.Trim().Trim('.'))) return "video"; + var extra = new[] { '"', '<', '>', '|', '*', '?', ':', '/', '\\' }; var sb = new StringBuilder(); foreach (var c in name.Trim()) { - if (Array.IndexOf(Invalid, c) >= 0 || Array.IndexOf(extra, c) >= 0 || char.IsControl(c)) sb.Append('_'); - else sb.Append(c); + if (Array.IndexOf(Invalid, c) >= 0 || Array.IndexOf(extra, c) >= 0 || char.IsControl(c) || IsBidiOrInvisible(c)) + sb.Append('_'); + else + sb.Append(c); } var result = sb.ToString().Trim().Trim('.'); 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) { var cut = result[..maxLen]; // UTF-16 상위 서러게이트로 끝나면 1글자 줄여서 이모지/특수문자 파편화 방지 - if (char.IsHighSurrogate(cut[^1])) + if (cut.Length > 0 && char.IsHighSurrogate(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; } diff --git a/src/Paca.Core/Utils/DiskSpaceGuardian.cs b/src/Paca.Core/Utils/DiskSpaceGuardian.cs index 501cd21..3491347 100644 --- a/src/Paca.Core/Utils/DiskSpaceGuardian.cs +++ b/src/Paca.Core/Utils/DiskSpaceGuardian.cs @@ -1,4 +1,4 @@ -using System.IO; +using System.IO; namespace Paca.Core.Utils; @@ -9,6 +9,7 @@ public static class DiskSpaceGuardian { public static bool HasSufficientSpace(string targetPath, long requiredBytes) { + if (requiredBytes <= 0 || string.IsNullOrWhiteSpace(targetPath)) return true; try { 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; - var msg = ioEx.Message.ToLowerInvariant(); - return msg.Contains("not enough space") || - msg.Contains("disk full") || - msg.Contains("디스크 공간이 부족") || - msg.Contains("공간이 부족"); + 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(); + if (msg.Contains("not enough space") || + msg.Contains("disk full") || + msg.Contains("디스크 공간이 부족") || + msg.Contains("공간이 부족") || + msg.Contains("espace disque insuffisant")) + { + return true; + } + } + + if (ex.InnerException != null && IsDiskFullException(ex.InnerException)) + return true; + + return false; } } diff --git a/src/Paca.Core/VideoAspectAutoCropper.cs b/src/Paca.Core/VideoAspectAutoCropper.cs index ecd0086..daf45cd 100644 --- a/src/Paca.Core/VideoAspectAutoCropper.cs +++ b/src/Paca.Core/VideoAspectAutoCropper.cs @@ -47,9 +47,21 @@ public static class VideoAspectAutoCropper int targetWidth = 1080, 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 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"; } } diff --git a/src/Paca.Core/VideoDenoiseEnhancer.cs b/src/Paca.Core/VideoDenoiseEnhancer.cs index fc7df6e..f10f578 100644 --- a/src/Paca.Core/VideoDenoiseEnhancer.cs +++ b/src/Paca.Core/VideoDenoiseEnhancer.cs @@ -56,9 +56,18 @@ public static class VideoDenoiseEnhancer bool sharpen, 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 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"; } } diff --git a/src/Paca.Core/VideoFrameRateConverter.cs b/src/Paca.Core/VideoFrameRateConverter.cs index c45d219..1f1bbb0 100644 --- a/src/Paca.Core/VideoFrameRateConverter.cs +++ b/src/Paca.Core/VideoFrameRateConverter.cs @@ -1,4 +1,4 @@ -using System; +using System; namespace Paca.Core; @@ -23,7 +23,7 @@ public static class VideoFrameRateConverter /// public static string BuildFpsFilter(int targetFps, FrameRateConversionMode mode) { - if (targetFps <= 0) targetFps = 60; + targetFps = Math.Clamp(targetFps, 1, 240); return mode switch { @@ -38,7 +38,17 @@ public static class VideoFrameRateConverter /// 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); - 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}\""; } } \ No newline at end of file diff --git a/src/Paca.Core/VideoIntroOutroTrimmer.cs b/src/Paca.Core/VideoIntroOutroTrimmer.cs index 1675148..2119c76 100644 --- a/src/Paca.Core/VideoIntroOutroTrimmer.cs +++ b/src/Paca.Core/VideoIntroOutroTrimmer.cs @@ -79,9 +79,21 @@ public static class VideoIntroOutroTrimmer double startSec, 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 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"; } } diff --git a/src/Paca.Core/VideoPosterExtractor.cs b/src/Paca.Core/VideoPosterExtractor.cs index 7bbff69..7662eb5 100644 --- a/src/Paca.Core/VideoPosterExtractor.cs +++ b/src/Paca.Core/VideoPosterExtractor.cs @@ -65,6 +65,15 @@ public static class VideoPosterExtractor string outputImagePath, 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 scaleFilter = BuildScalingFilter(spec); @@ -75,6 +84,6 @@ public static class VideoPosterExtractor _ => "-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"; } } diff --git a/src/Paca.Core/VideoSpriteSheetGenerator.cs b/src/Paca.Core/VideoSpriteSheetGenerator.cs index 2d52b54..d143953 100644 --- a/src/Paca.Core/VideoSpriteSheetGenerator.cs +++ b/src/Paca.Core/VideoSpriteSheetGenerator.cs @@ -31,9 +31,19 @@ public static class VideoSpriteSheetGenerator string outputJpgPath, 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.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"; } /// diff --git a/src/Paca.Core/VideoTonemapEnhancer.cs b/src/Paca.Core/VideoTonemapEnhancer.cs index ba156d5..91c9a21 100644 --- a/src/Paca.Core/VideoTonemapEnhancer.cs +++ b/src/Paca.Core/VideoTonemapEnhancer.cs @@ -87,6 +87,19 @@ public static class VideoTonemapEnhancer string videoEncoder = "libx264", 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); string encoderParams; @@ -107,6 +120,6 @@ public static class VideoTonemapEnhancer 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}\""; } } \ No newline at end of file diff --git a/src/Paca.Server/MediaLibrary.cs b/src/Paca.Server/MediaLibrary.cs index 20b2d5f..649f240 100644 --- a/src/Paca.Server/MediaLibrary.cs +++ b/src/Paca.Server/MediaLibrary.cs @@ -1,4 +1,4 @@ -using System.Security.Cryptography; +using System.Security.Cryptography; namespace Paca.Server; @@ -12,7 +12,7 @@ public static class MediaLibrary var result = new List(); 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))) { var ext = Path.GetExtension(f).ToLowerInvariant(); @@ -29,9 +29,10 @@ public static class MediaLibrary { 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)) - if (IdOf(f) == id) return (f, Path.GetFileName(f)); + if (IdOf(f) == cleanId) return (f, Path.GetFileName(f)); } catch { } return null; @@ -39,6 +40,7 @@ public static class MediaLibrary 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))); return Convert.ToHexString(hash)[..12].ToLowerInvariant(); } diff --git a/src/Paca.Server/PositionStore.cs b/src/Paca.Server/PositionStore.cs index 2a6c1fb..8c1a234 100644 --- a/src/Paca.Server/PositionStore.cs +++ b/src/Paca.Server/PositionStore.cs @@ -16,7 +16,10 @@ public sealed class PositionStore 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 { if (File.Exists(_path)) @@ -28,16 +31,17 @@ public sealed class PositionStore 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) { - 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; lock (_gate) { - _positions[id] = positionSec; + _positions[id.Trim()] = positionSec; try { var dir = Path.GetDirectoryName(_path); diff --git a/src/Paca.Server/Thumbnailer.cs b/src/Paca.Server/Thumbnailer.cs index a63651a..e685528 100644 --- a/src/Paca.Server/Thumbnailer.cs +++ b/src/Paca.Server/Thumbnailer.cs @@ -1,4 +1,4 @@ -using System.Text.RegularExpressions; +using System.Text.RegularExpressions; using Paca.Core.Config; using Paca.Core.Platform; @@ -16,6 +16,7 @@ public sealed partial class Thumbnailer /// 1초 지점 프레임 썸네일(320px 폭 JPEG). 실패 시 null. 캐시 히트 시 프로세스 없음. public async Task GetThumbnailAsync(string ffmpeg, string mediaPath, CancellationToken ct = default) { + if (string.IsNullOrWhiteSpace(ffmpeg) || string.IsNullOrWhiteSpace(mediaPath)) return null; var cache = mediaPath + ".thumb.jpg"; try { @@ -34,6 +35,7 @@ public sealed partial class Thumbnailer /// 해상도("1920x1080"). ffmpeg -i stderr 의 Stream 라인에서 추출, .meta.json 캐시. public async Task GetResolutionAsync(string ffmpeg, string mediaPath, CancellationToken ct = default) { + if (string.IsNullOrWhiteSpace(ffmpeg) || string.IsNullOrWhiteSpace(mediaPath)) return null; var cache = mediaPath + ".meta.json"; try { diff --git a/tests/Paca.Tests/Browser/BrowserContentAndRoutingExtremeRedTests.cs b/tests/Paca.Tests/Browser/BrowserContentAndRoutingExtremeRedTests.cs new file mode 100644 index 0000000..0c66669 --- /dev/null +++ b/tests/Paca.Tests/Browser/BrowserContentAndRoutingExtremeRedTests.cs @@ -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(() => 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: "Hello", + Resources: new List + { + new("https://example.com/null.png", "image/png", null!), + null! + } + ); + + var mhtml = packager.Package(req); + Assert.NotNull(mhtml); + Assert.Contains("Hello", mhtml); + } +} diff --git a/tests/Paca.Tests/Core/AiMediaAndShaderEnginesExtremeRedTests.cs b/tests/Paca.Tests/Core/AiMediaAndShaderEnginesExtremeRedTests.cs new file mode 100644 index 0000000..59e7ca0 --- /dev/null +++ b/tests/Paca.Tests/Core/AiMediaAndShaderEnginesExtremeRedTests.cs @@ -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(() => + 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(() => + AiHighlightClipper.BuildFfmpegShortsCommand(inPath!, outPath!, spec)); + } + + [Fact] + public void AiHighlightClipper_BuildFfmpegShortsCommand_NullSpec_ThrowsArgumentNullException() + { + Assert.Throws(() => + 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())); + } + + [Fact] + public void FftAudioWaveformEngine_BuildFfmpegEqualizerFilter_ListContainingNullBands_SkipsSafely() + { + var bands = new List + { + 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 + { + 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."); + } + } +} diff --git a/tests/Paca.Tests/Core/AudioEnginesExtremeRedTests.cs b/tests/Paca.Tests/Core/AudioEnginesExtremeRedTests.cs new file mode 100644 index 0000000..13905e6 --- /dev/null +++ b/tests/Paca.Tests/Core/AudioEnginesExtremeRedTests.cs @@ -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(() => + 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(() => + 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(() => + AudioPitchShiftEngine.BuildPitchShiftFilter(2.0, sampleRate)); + } + + [Fact] + public void AudioDynamicRangeCompressor_InPlaceOverwrite_ThrowsArgumentException() + { + var ex = Assert.Throws(() => + 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(() => + 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(() => + 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(() => + AudioAlbumTrackSplitter.BuildSplitCommand("album.flac", seg, "album.flac")); + Assert.Contains("identical", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void AudioAlbumTrackSplitter_NullTrack_ThrowsArgumentNullException() + { + Assert.Throws(() => + 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 + { + 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(() => + 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(() => + 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(() => + AudioVolumeNormalizer.BuildNormalizeCommandLine("stream.aac", "stream.aac", AudioVolumeNormalizer.PodcastStreamingPreset)); + Assert.Contains("identical", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void AudioVolumeNormalizer_NullPreset_ThrowsArgumentNullException() + { + Assert.Throws(() => + AudioVolumeNormalizer.BuildNormalizeCommandLine("in.aac", "out.aac", null!)); + Assert.Throws(() => + AudioVolumeNormalizer.BuildLoudnormFilterArgs(null!)); + } +} diff --git a/tests/Paca.Tests/Core/ClipboardAndEncoderSecurityExtremeRedTests.cs b/tests/Paca.Tests/Core/ClipboardAndEncoderSecurityExtremeRedTests.cs new file mode 100644 index 0000000..6d669bc --- /dev/null +++ b/tests/Paca.Tests/Core/ClipboardAndEncoderSecurityExtremeRedTests.cs @@ -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 RunAsync(ProcessSpec spec, Action? 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]); + } +} diff --git a/tests/Paca.Tests/Core/ConcurrencyAndCacheIntegrityExtremeRedTests.cs b/tests/Paca.Tests/Core/ConcurrencyAndCacheIntegrityExtremeRedTests.cs new file mode 100644 index 0000000..e5d0bb5 --- /dev/null +++ b/tests/Paca.Tests/Core/ConcurrencyAndCacheIntegrityExtremeRedTests.cs @@ -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; + +/// +/// Cycle 4: 극한 RED 시나리오 - 카오스 멀티스레딩 동시성 무결성 및 경로 순회(Path Traversal) / 파일 락 방어선 검증 +/// +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 { } + } + } +} diff --git a/tests/Paca.Tests/Core/FileNameUtilAndCleanupExtremeRedTests.cs b/tests/Paca.Tests/Core/FileNameUtilAndCleanupExtremeRedTests.cs new file mode 100644 index 0000000..39c324d --- /dev/null +++ b/tests/Paca.Tests/Core/FileNameUtilAndCleanupExtremeRedTests.cs @@ -0,0 +1,93 @@ +using System; +using System.IO; +using Paca.Core.Platform; +using Paca.Core.Util; +using Xunit; + +namespace Paca.Tests.Core; + +/// +/// Cycle 1: 극한 RED 시나리오 - 파일명 정제 및 윈도우 장치 예약어, 트로이목마 Bidi 스푸핑, 경로 누수 방어 검증 +/// +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 { } + } + } +} diff --git a/tests/Paca.Tests/Core/HlsAndDashResilienceExtremeRedTests.cs b/tests/Paca.Tests/Core/HlsAndDashResilienceExtremeRedTests.cs new file mode 100644 index 0000000..296c185 --- /dev/null +++ b/tests/Paca.Tests/Core/HlsAndDashResilienceExtremeRedTests.cs @@ -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; + +/// +/// Cycle 2: 극한 RED 시나리오 - HLS AES-128 암호화 IV/키 경계값 패딩, 블록 크기 손상 예외 정밀화, DASH 매니페스트 퍼징 검증 +/// +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(() => 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(() => downloader.DecryptSegmentForTesting(new byte[15], seg)); + Assert.Contains("블록 크기", exData.Message); + } + + [Fact] + public void Cycle2_Gate3_DashParser_TimescaleZeroOrNegative_DefaultsToOne() + { + var mpdXml = """ + + + + + + + + + + """; + + 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); + } +} diff --git a/tests/Paca.Tests/Core/M3u8ParserByteRangeAndfMp4ExtremeRedTests.cs b/tests/Paca.Tests/Core/M3u8ParserByteRangeAndfMp4ExtremeRedTests.cs new file mode 100644 index 0000000..5814a21 --- /dev/null +++ b/tests/Paca.Tests/Core/M3u8ParserByteRangeAndfMp4ExtremeRedTests.cs @@ -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); + } +} diff --git a/tests/Paca.Tests/Core/MediaMuxerAndMetadataExtremeRedTests.cs b/tests/Paca.Tests/Core/MediaMuxerAndMetadataExtremeRedTests.cs new file mode 100644 index 0000000..eff0869 --- /dev/null +++ b/tests/Paca.Tests/Core/MediaMuxerAndMetadataExtremeRedTests.cs @@ -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(() => + 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(() => + MediaMetadataTagWriter.BuildFfmpegArgs(tag, inPath!, outPath!)); + } + + [Fact] + public void MediaMetadataTagWriter_BuildFfmpegArgs_SameInputAndOutput_ThrowsInvalidOperationException() + { + var tag = new MediaMetadataTag { Title = "Song" }; + Assert.Throws(() => + 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(() => + 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(() => + MultiTrackMediaMuxer.BuildMuxCommandLine(job)); + } + + [Fact] + public void MultiTrackMediaMuxer_BuildMuxCommandLine_NoTracks_ThrowsInvalidOperationException() + { + var job = new MuxJobSpecification + { + OutputPath = SampleOut + }; + Assert.Throws(() => + MultiTrackMediaMuxer.BuildMuxCommandLine(job)); + } + + [Fact] + public void MultiTrackMediaMuxer_BuildMuxCommandLine_OutputPathMatchesAnyInput_ThrowsInvalidOperationException() + { + var job = new MuxJobSpecification + { + VideoTrack = new TrackSpec(SampleIn1), + AudioTracks = new List { new(SampleAudio) }, + OutputPath = SampleAudio // Overwriting audio source! + }; + Assert.Throws(() => + MultiTrackMediaMuxer.BuildMuxCommandLine(job)); + } + + [Fact] + public void MultiTrackMediaMuxer_BuildMuxCommandLine_TrackTitleWithQuotes_EscapedSafely() + { + var job = new MuxJobSpecification + { + VideoTrack = new TrackSpec(SampleIn1), + AudioTracks = new List { 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(() => + VideoTonemapEnhancer.BuildFfmpegArgs(inPath!, outPath!, TonemapAlgorithm.Hable, ColorGradePreset.None)); + } + + [Fact] + public void VideoTonemapEnhancer_BuildFfmpegArgs_SameInputAndOutput_ThrowsInvalidOperationException() + { + Assert.Throws(() => + 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); + } +} diff --git a/tests/Paca.Tests/Core/MediaProcessingExtremeRedTests.cs b/tests/Paca.Tests/Core/MediaProcessingExtremeRedTests.cs new file mode 100644 index 0000000..b52eb05 --- /dev/null +++ b/tests/Paca.Tests/Core/MediaProcessingExtremeRedTests.cs @@ -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; + +/// +/// Cycle 3: 극한 RED 시나리오 - 인플레이스 덮어쓰기 원본 파괴 방어, FFmpeg 필터 인젝션 이스케이프, 메타데이터 문법 오염 차단 +/// +public class MediaProcessingExtremeRedTests +{ + private sealed class DummyProcessRunner : IProcessRunner + { + public Task RunAsync(ProcessSpec spec, Action? 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(() => + trimmer.TrimLosslessAsync("ffmpeg.exe", path, path, 0, 10, default)); + Assert.Contains("동일", exTrimmer.Message); + + // 2. 오디오 정규화기가 동일 경로에 덮어쓰기 시 ArgumentException 발생 + var exNormalizer = await Assert.ThrowsAsync(() => + normalizer.NormalizeAudioAsync("ffmpeg.exe", path, path, LoudnessSpec.WebStandard, default)); + Assert.Contains("동일", exNormalizer.Message); + + // 3. 워터마크 버너가 동일 경로에 덮어쓰기 시 ArgumentException 발생 + var exBurner = await Assert.ThrowsAsync(() => + 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 + { + 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); + } +} diff --git a/tests/Paca.Tests/Core/P2pAndE2eeVaultExtremeRedTests.cs b/tests/Paca.Tests/Core/P2pAndE2eeVaultExtremeRedTests.cs new file mode 100644 index 0000000..c7fccd8 --- /dev/null +++ b/tests/Paca.Tests/Core/P2pAndE2eeVaultExtremeRedTests.cs @@ -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(() => + P2pSwarmEngine.CreateManifestFromBytes("test.mp4", null!)); + } + + [Fact] + public void P2pSwarmEngine_InitializeSession_NullManifest_ThrowsArgumentNullException() + { + var engine = new P2pSwarmEngine(); + Assert.Throws(() => 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(() => + CredentialDataExchange.ExportEncryptedVault(null!, "MasterPass123!")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void CredentialDataExchange_ExportEncryptedVault_NullOrWhitespacePassword_ThrowsArgumentException(string? badPass) + { + var records = new List + { + new() { Id = "1", Domain = "example.com", Username = "user", DecryptedPassword = "pw" } + }; + Assert.Throws(() => + 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(() => + 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(); + int applied = engine.Merge(null!); + Assert.Equal(0, applied); + } + + [Fact] + public void CrdtVaultSyncEngine_Merge_NullOrEmptyIds_SkippedSafely() + { + var engine = new CrdtVaultSyncEngine(); + var items = new List> + { + 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(); + Assert.Throws(() => engine.Set(invalidId!, "value")); + } + + // ========================================== + // 4. E2eeRemoteBackupProvider Extreme Scenarios + // ========================================== + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void E2eeRemoteBackupProvider_EncryptPayload_NullOrEmptyPassword_ThrowsArgumentException(string? badPass) + { + Assert.Throws(() => + 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(() => + E2eeRemoteBackupProvider.DecryptPayload(json, "MasterPassword123!")); + } + + [Theory] + [InlineData("not-json")] + [InlineData("{\"Magic\":\"PACAVAULT\",\"SaltBase64\":\"%%%not-base64%%%\"}")] + public void E2eeRemoteBackupProvider_DecryptPayload_CorruptedBase64_ThrowsInvalidDataException(string corruptJson) + { + Assert.Throws(() => + E2eeRemoteBackupProvider.DecryptPayload(corruptJson, "MasterPassword123!")); + } +} diff --git a/tests/Paca.Tests/Core/ProcessExecutionExtremeRedTests.cs b/tests/Paca.Tests/Core/ProcessExecutionExtremeRedTests.cs new file mode 100644 index 0000000..3181f89 --- /dev/null +++ b/tests/Paca.Tests/Core/ProcessExecutionExtremeRedTests.cs @@ -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; + +/// +/// Cycle 5: 극한 RED 시나리오 - 프로세스 실행 시 따옴표 감싸인 ExePath, Null 인자 방어 및 고속 취소 시 스트림 무결성 검증 +/// +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(async () => + { + await runner.RunAsync(spec, null, cts.Token); + }); + } +} diff --git a/tests/Paca.Tests/Core/QuickActionsExtremeRedTests.cs b/tests/Paca.Tests/Core/QuickActionsExtremeRedTests.cs new file mode 100644 index 0000000..1cb89cd --- /dev/null +++ b/tests/Paca.Tests/Core/QuickActionsExtremeRedTests.cs @@ -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); + } +} + diff --git a/tests/Paca.Tests/Core/SecurityAndConfigExtremeRedTests.cs b/tests/Paca.Tests/Core/SecurityAndConfigExtremeRedTests.cs new file mode 100644 index 0000000..79e4be7 --- /dev/null +++ b/tests/Paca.Tests/Core/SecurityAndConfigExtremeRedTests.cs @@ -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); + } +} diff --git a/tests/Paca.Tests/Core/SecurityAndRateLimitingExtremeRedTests.cs b/tests/Paca.Tests/Core/SecurityAndRateLimitingExtremeRedTests.cs new file mode 100644 index 0000000..4f8ab51 --- /dev/null +++ b/tests/Paca.Tests/Core/SecurityAndRateLimitingExtremeRedTests.cs @@ -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); + } +} diff --git a/tests/Paca.Tests/Core/SocialSiteDetectorExtremeRedTests.cs b/tests/Paca.Tests/Core/SocialSiteDetectorExtremeRedTests.cs new file mode 100644 index 0000000..beffbc5 --- /dev/null +++ b/tests/Paca.Tests/Core/SocialSiteDetectorExtremeRedTests.cs @@ -0,0 +1,71 @@ +using System; +using Paca.Core.Detection; +using Xunit; + +namespace Paca.Tests.Core; + +/// +/// Cycle 6: 극한 RED 시나리오 - 소셜 사이트 감지기의 상대 URI 크래시, 비-HTTP 스킴, 변칙/불완전 URL 방어선 검증 +/// +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,test")] + [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); + } +} diff --git a/tests/Paca.Tests/Core/SpeedAndBitrateScheduleExtremeRedTests.cs b/tests/Paca.Tests/Core/SpeedAndBitrateScheduleExtremeRedTests.cs new file mode 100644 index 0000000..242d061 --- /dev/null +++ b/tests/Paca.Tests/Core/SpeedAndBitrateScheduleExtremeRedTests.cs @@ -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(() => + 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(() => + 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(() => + 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(() => + 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 + { + 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); + } +} diff --git a/tests/Paca.Tests/Core/StreamingAndResourceGuardianExtremeRedTests.cs b/tests/Paca.Tests/Core/StreamingAndResourceGuardianExtremeRedTests.cs new file mode 100644 index 0000000..85ffaec --- /dev/null +++ b/tests/Paca.Tests/Core/StreamingAndResourceGuardianExtremeRedTests.cs @@ -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(() => 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); + } +} diff --git a/tests/Paca.Tests/Core/SubtitleAndNetworkResilienceExtremeRedTests.cs b/tests/Paca.Tests/Core/SubtitleAndNetworkResilienceExtremeRedTests.cs new file mode 100644 index 0000000..9fdef69 --- /dev/null +++ b/tests/Paca.Tests/Core/SubtitleAndNetworkResilienceExtremeRedTests.cs @@ -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(() => + 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(() => + 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(() => + 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 { 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 + { + 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); + } +} diff --git a/tests/Paca.Tests/Core/VideoEnginesExtremeRedTests.cs b/tests/Paca.Tests/Core/VideoEnginesExtremeRedTests.cs new file mode 100644 index 0000000..c420d03 --- /dev/null +++ b/tests/Paca.Tests/Core/VideoEnginesExtremeRedTests.cs @@ -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(() => + 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(() => + VideoAspectAutoCropper.BuildReframingCommandLine(inPath!, outPath!, ReframingMode.CenterCrop)); + } + + // ========================================== + // 2. VideoIntroOutroTrimmer + // ========================================== + + [Fact] + public void VideoIntroOutroTrimmer_BuildCommandLine_SameInputAndOutput_ThrowsInvalidOperationException() + { + Assert.Throws(() => + 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(() => + 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(() => + 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(() => + 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(() => + 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(() => + VideoSpriteSheetGenerator.BuildFfmpegSpriteCommandLine(inPath!, outPath!, config)); + } + + // ========================================== + // 5. VideoFrameRateConverter + // ========================================== + + [Fact] + public void VideoFrameRateConverter_BuildFfmpegArgs_SameInputAndOutput_ThrowsInvalidOperationException() + { + Assert.Throws(() => + 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(() => + 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(() => + 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(() => + 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); + } +} diff --git a/tests/Paca.Tests/Detection/UrlAndMediaDetectionExtremeRedTests.cs b/tests/Paca.Tests/Detection/UrlAndMediaDetectionExtremeRedTests.cs new file mode 100644 index 0000000..2f43c61 --- /dev/null +++ b/tests/Paca.Tests/Detection/UrlAndMediaDetectionExtremeRedTests.cs @@ -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,")] + [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 + { + 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 + { + 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); + } +} diff --git a/tests/Paca.Tests/Media/HardwareTranscoderAndCastingExtremeRedTests.cs b/tests/Paca.Tests/Media/HardwareTranscoderAndCastingExtremeRedTests.cs new file mode 100644 index 0000000..baae516 --- /dev/null +++ b/tests/Paca.Tests/Media/HardwareTranscoderAndCastingExtremeRedTests.cs @@ -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(() => + 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(() => + HardwareTranscoderStudio.BuildFfmpegTranscodeCommand(invalidInput!, "output.mp4", spec)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void BuildFfmpegTranscodeCommand_NullOrWhitespaceOutput_ThrowsArgumentException(string? invalidOutput) + { + var spec = new TranscodePresetSpec(); + Assert.Throws(() => + HardwareTranscoderStudio.BuildFfmpegTranscodeCommand("input.mp4", invalidOutput!, spec)); + } + + [Fact] + public void BuildFfmpegTranscodeCommand_SameInputAndOutput_ThrowsInvalidOperationException() + { + var tempFile = Path.GetTempFileName(); + try + { + var spec = new TranscodePresetSpec(); + Assert.Throws(() => + 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); + } +} diff --git a/tests/Paca.Tests/Platform/MediaEnhanceAndDeduplicationExtremeRedTests.cs b/tests/Paca.Tests/Platform/MediaEnhanceAndDeduplicationExtremeRedTests.cs new file mode 100644 index 0000000..868e4d0 --- /dev/null +++ b/tests/Paca.Tests/Platform/MediaEnhanceAndDeduplicationExtremeRedTests.cs @@ -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(() => + 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(() => + 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(() => + 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(() => + 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(() => + 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(() => + 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); + } +} diff --git a/tests/Paca.Tests/Platform/PlatformMediaToolsExtremeRedTests.cs b/tests/Paca.Tests/Platform/PlatformMediaToolsExtremeRedTests.cs new file mode 100644 index 0000000..0e96754 --- /dev/null +++ b/tests/Paca.Tests/Platform/PlatformMediaToolsExtremeRedTests.cs @@ -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 + { + 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(() => + 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(() => + VideoWatermarkBurner.BuildOverlayFilter(WatermarkPosition.Center, opacity: opacity)); + } +} diff --git a/tests/Paca.Tests/Platform/VrAndAudioAdaptiveExtremeRedTests.cs b/tests/Paca.Tests/Platform/VrAndAudioAdaptiveExtremeRedTests.cs new file mode 100644 index 0000000..866af81 --- /dev/null +++ b/tests/Paca.Tests/Platform/VrAndAudioAdaptiveExtremeRedTests.cs @@ -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(() => + 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(() => + engine.InjectVrMetadataAsync("ffmpeg.exe", inPath!, outPath!, meta, CancellationToken.None)); + } + + // ========================================== + // 2. VoiceActivitySubtitleAdapter + // ========================================== + + [Fact] + public void VoiceActivitySubtitleAdapter_GenerateVttSkeleton_ListWithNullSegment_SkipsSafely() + { + var list = new List + { + 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(() => + 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(() => + 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(() => + clipper.ExportShortsClipAsync("ffmpeg.exe", inPath!, outPath!, clip, false, CancellationToken.None)); + } + + [Fact] + public void HighlightShortsClipper_DetectHighlightWindows_ListWithNullSamples_SkipsSafely() + { + var samples = new List + { + 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); + } +} diff --git a/tests/Paca.Tests/Server/ServerResilienceExtremeRedTests.cs b/tests/Paca.Tests/Server/ServerResilienceExtremeRedTests.cs new file mode 100644 index 0000000..28a4816 --- /dev/null +++ b/tests/Paca.Tests/Server/ServerResilienceExtremeRedTests.cs @@ -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); + } +}