diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b66a948..d6cdaee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,7 @@ jobs: desktop: name: Desktop checks (${{ matrix.target }}) + timeout-minutes: 30 strategy: fail-fast: false matrix: @@ -52,8 +53,18 @@ jobs: - name: Install Linux dependencies if: runner.os == 'Linux' run: | - sudo apt-get update - sudo apt-get install -y \ + sudo env DEBIAN_FRONTEND=noninteractive timeout --foreground --signal=TERM --kill-after=30s 5m apt-get \ + -o Acquire::Retries=3 \ + -o Acquire::http::Timeout=30 \ + -o Acquire::https::Timeout=30 \ + -o DPkg::Lock::Timeout=60 \ + update + sudo env DEBIAN_FRONTEND=noninteractive timeout --foreground --signal=TERM --kill-after=30s 10m apt-get \ + -o Acquire::Retries=3 \ + -o Acquire::http::Timeout=30 \ + -o Acquire::https::Timeout=30 \ + -o DPkg::Lock::Timeout=60 \ + install -y --no-install-recommends \ libwebkit2gtk-4.1-dev \ libappindicator3-dev \ librsvg2-dev \ @@ -69,7 +80,8 @@ jobs: if: runner.os == 'Windows' working-directory: src-tauri run: | - cargo test --tests --target ${{ matrix.target }} + cargo test --test queue_manager --target ${{ matrix.target }} -- --nocapture + cargo test --test torrent_rpc --target ${{ matrix.target }} -- --nocapture cargo test --lib --no-run --target ${{ matrix.target }} - name: Verify Windows atomic Torrent storage if: runner.os == 'Windows' @@ -78,11 +90,11 @@ jobs: - name: Verify Windows Torrent cache safety if: runner.os == 'Windows' working-directory: src-tauri - run: cargo test --lib --target ${{ matrix.target }} canonical_cache_round_trip_rejects_invalid_bytes_and_source_metadata -- --nocapture - - name: Verify Windows Torrent web-seed lifecycle + run: cargo test --test torrent_cache --target ${{ matrix.target }} -- --nocapture + - name: Verify Windows Torrent web-seed normalization if: runner.os == 'Windows' working-directory: src-tauri - run: cargo test --lib --target ${{ matrix.target }} web_seed -- --nocapture + run: cargo test --test torrent_web_seed --target ${{ matrix.target }} -- --nocapture - name: Provision locked engines if: runner.os != 'macOS' run: node scripts/provision-engines.js --target ${{ matrix.target }} diff --git a/src-tauri/src/torrent.rs b/src-tauri/src/torrent.rs index 96c76b3..cea8d13 100644 --- a/src-tauri/src/torrent.rs +++ b/src-tauri/src/torrent.rs @@ -1496,68 +1496,6 @@ mod tests { )); } - #[tokio::test] - async fn canonical_cache_round_trip_rejects_invalid_bytes_and_source_metadata() { - let app = tauri::test::mock_builder() - .build(tauri::test::mock_context(tauri::test::noop_assets())) - .expect("mock app"); - let bytes = b"d4:infod6:lengthi5e4:name4:testee"; - let parsed = parse_torrent_bytes(bytes).expect("test torrent should parse"); - let path = managed_torrent_info_hash_path(app.handle(), &parsed.info_hash) - .expect("canonical cache path should resolve"); - let _ = tokio::fs::remove_file(&path).await; - - assert!( - cache_torrent_info_hash(app.handle(), bytes) - .await - .expect("canonical cache write should succeed") - .is_some() - ); - assert_eq!( - read_cached_torrent_by_info_hash(app.handle(), &parsed.info_hash) - .await - .expect("canonical cache read should succeed"), - Some(bytes.to_vec()) - ); - - tokio::fs::write(&path, b"not a torrent") - .await - .expect("invalid cache fixture should be writable"); - assert!( - read_cached_torrent_by_info_hash(app.handle(), &parsed.info_hash) - .await - .expect("invalid cache should be handled") - .is_none() - ); - assert!(!path.exists()); - - let tracker_bytes = b"d8:announce32:https://tracker.example/announce4:infod6:lengthi5e4:name4:testee"; - let tracker_hash = parse_torrent_bytes(tracker_bytes) - .expect("tracker-bearing torrent should parse") - .info_hash; - assert!( - cache_torrent_info_hash(app.handle(), tracker_bytes) - .await - .expect("tracker metadata should be reusable") - .is_some() - ); - assert_eq!( - read_cached_torrent_by_info_hash(app.handle(), &tracker_hash) - .await - .expect("tracker cache should be readable"), - Some(tracker_bytes.to_vec()) - ); - assert!( - cache_torrent_info_hash( - app.handle(), - b"d4:infod6:lengthi5e4:name4:teste8:url-list22:https://example.test/ae" - ) - .await - .expect("web-seed metadata should be handled") - .is_none() - ); - } - #[test] fn canonicalizes_base32_magnet_hashes_to_hex() { let parsed = inspect_source( diff --git a/src-tauri/tests/README.md b/src-tauri/tests/README.md index 106e179..4b41271 100644 --- a/src-tauri/tests/README.md +++ b/src-tauri/tests/README.md @@ -51,7 +51,8 @@ npm run smoke:torrent:failure-paths ``` Native CI runs this failure-path smoke after staging the target-specific -bundled engines on macOS, Windows, and Linux. Windows runs this RPC -integration test through its target-qualified `cargo test --tests` step; -the general Rust job compiles the library tests without executing the -known-broken Tauri library harness. +bundled engines on macOS, Windows, and Linux. Windows runs the queue-manager +and Torrent RPC integration targets explicitly, plus atomic-storage, +canonical-cache, and web-seed normalization targets; the general Rust job +compiles the library tests without executing the known-broken Tauri library +harness. diff --git a/src-tauri/tests/torrent_cache.rs b/src-tauri/tests/torrent_cache.rs new file mode 100644 index 0000000..dd92efd --- /dev/null +++ b/src-tauri/tests/torrent_cache.rs @@ -0,0 +1,67 @@ +use firelink_lib::torrent::{ + cache_torrent_info_hash, managed_torrent_info_hash_path, parse_torrent_bytes, + read_cached_torrent_by_info_hash, +}; +use tauri::test::{mock_builder, mock_context, noop_assets}; + +#[tokio::test] +async fn canonical_cache_round_trip_rejects_invalid_bytes_and_source_metadata() { + let app = mock_builder() + .build(mock_context(noop_assets())) + .expect("mock app"); + let bytes = b"d4:infod6:lengthi5e4:name4:testee"; + let parsed = parse_torrent_bytes(bytes).expect("test torrent should parse"); + let path = managed_torrent_info_hash_path(app.handle(), &parsed.info_hash) + .expect("canonical cache path should resolve"); + let _ = tokio::fs::remove_file(&path).await; + + assert!(cache_torrent_info_hash(app.handle(), bytes) + .await + .expect("canonical cache write should succeed") + .is_some()); + assert_eq!( + read_cached_torrent_by_info_hash(app.handle(), &parsed.info_hash) + .await + .expect("canonical cache read should succeed"), + Some(bytes.to_vec()) + ); + + tokio::fs::write(&path, b"not a torrent") + .await + .expect("invalid cache fixture should be writable"); + assert!( + read_cached_torrent_by_info_hash(app.handle(), &parsed.info_hash) + .await + .expect("invalid cache should be handled") + .is_none() + ); + assert!(!path.exists()); + + let tracker_bytes = + b"d8:announce32:https://tracker.example/announce4:infod6:lengthi5e4:name4:testee"; + let tracker_hash = parse_torrent_bytes(tracker_bytes) + .expect("tracker-bearing torrent should parse") + .info_hash; + let tracker_path = managed_torrent_info_hash_path(app.handle(), &tracker_hash) + .expect("tracker cache path should resolve"); + let _ = tokio::fs::remove_file(&tracker_path).await; + assert!(cache_torrent_info_hash(app.handle(), tracker_bytes) + .await + .expect("tracker metadata should be reusable") + .is_some()); + assert_eq!( + read_cached_torrent_by_info_hash(app.handle(), &tracker_hash) + .await + .expect("tracker cache should be readable"), + Some(tracker_bytes.to_vec()) + ); + assert!(cache_torrent_info_hash( + app.handle(), + b"d4:infod6:lengthi5e4:name4:teste8:url-list22:https://example.test/ae" + ) + .await + .expect("web-seed metadata should be handled") + .is_none()); + + let _ = tokio::fs::remove_file(&tracker_path).await; +} diff --git a/src-tauri/tests/torrent_web_seed.rs b/src-tauri/tests/torrent_web_seed.rs new file mode 100644 index 0000000..659d782 --- /dev/null +++ b/src-tauri/tests/torrent_web_seed.rs @@ -0,0 +1,68 @@ +use firelink_lib::ipc::{TorrentFile, TorrentWebSeed}; +use firelink_lib::queue::{expand_torrent_web_seeds, normalize_torrent_web_seeds}; + +#[test] +fn web_seed_normalization_preserves_one_based_file_ownership() { + let files = vec![ + TorrentFile { + index: 1, + path: "one.bin".to_string(), + length: 1, + }, + TorrentFile { + index: 2, + path: "nested/two.bin".to_string(), + length: 2, + }, + ]; + let seeds = vec![ + TorrentWebSeed { + file_index: 2, + uri: " https://cdn.example/assets/ ".to_string(), + }, + TorrentWebSeed { + file_index: 1, + uri: "https://cdn.example/one".to_string(), + }, + TorrentWebSeed { + file_index: 2, + uri: "https://cdn.example/assets/".to_string(), + }, + ]; + + let normalized = normalize_torrent_web_seeds(Some(&seeds), &files) + .expect("valid web seeds should normalize"); + assert_eq!(normalized.len(), 2); + assert_eq!(normalized[0].file_index, 2); + assert_eq!(normalized[1].file_index, 1); + assert_eq!( + expand_torrent_web_seeds(&normalized, &files).expect("web seeds should expand"), + vec![ + (2, "https://cdn.example/assets/nested/two.bin".to_string()), + (1, "https://cdn.example/one/one.bin".to_string()), + ] + ); + + for uri in [ + "ftp://cdn.example/file", + "https://user:pass@cdn.example/file", + "https://cdn.example/file#fragment", + ] { + assert!(normalize_torrent_web_seeds( + Some(&[TorrentWebSeed { + file_index: 1, + uri: uri.to_string() + }]), + &files, + ) + .is_err()); + } + assert!(normalize_torrent_web_seeds( + Some(&[TorrentWebSeed { + file_index: 0, + uri: "https://cdn.example/file".to_string() + }]), + &files, + ) + .is_err()); +}