diff --git a/desktop/src-tauri/src/commands/media_download.rs b/desktop/src-tauri/src/commands/media_download.rs index 7bc94da25d..1683c41a38 100644 --- a/desktop/src-tauri/src/commands/media_download.rs +++ b/desktop/src-tauri/src/commands/media_download.rs @@ -20,6 +20,15 @@ use crate::relay::{classify_request_error, relay_api_base_url_with_override, rel /// Maximum download size: 50 MiB. Prevents OOM from oversized responses. const MAX_DOWNLOAD_BYTES: u64 = 50 * 1024 * 1024; +/// Maximum markdown-document size for the in-app viewer: 2 MiB. +/// +/// This is the *enforcement* boundary for the viewer — the frontend's +/// matching `MAX_MARKDOWN_DOC_BYTES` pre-gate reads the untrusted imeta +/// `size` field and is UX-only (a forged or absent size must not buy a +/// larger fetch). Keep in sync with +/// `desktop/src/shared/ui/markdown/markdownDocFile.ts`. +const MAX_MARKDOWN_DOC_BYTES: u64 = 2 * 1024 * 1024; + /// Download request timeout. const DOWNLOAD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); @@ -165,6 +174,35 @@ pub async fn fetch_media_bytes( Ok(tauri::ipc::Response::new(bytes)) } +/// Fetch a markdown document attachment for the in-app viewer, enforcing the +/// viewer's 2 MiB ceiling natively during the fetch. +/// +/// The generic `fetch_media_bytes` command streams up to the 50 MiB download +/// cap; routing the viewer through it would let an attachment with a forged +/// or absent imeta `size` occupy 50 MiB of memory and IPC before the +/// frontend's decoder rejected it. This command makes the 2 MiB limit a +/// native boundary: Content-Length over the cap is refused before the body +/// is read, and the streamed byte count aborts mid-transfer when the header +/// is missing or dishonest. +/// +/// Deliberately skips `detect_and_validate_mime`: the bytes are only ever +/// strictly UTF-8-decoded and rendered through the escaping markdown +/// pipeline (never handed to the webview as a document), and a legitimate +/// `.md` file may open with bytes that sniff as a blocked type — e.g. an +/// SVG snippet pasted at the top of the file. Binary payloads fail the +/// frontend's fatal UTF-8 decode and fall back to download. +#[tauri::command] +pub async fn fetch_markdown_doc_bytes( + url: String, + state: State<'_, AppState>, +) -> Result { + let relay_base = relay_api_base_url_with_override(&state); + validate_download_url(&url, &relay_base)?; + + let bytes = fetch_blob_bytes_with_cap(&url, &state, MAX_MARKDOWN_DOC_BYTES).await?; + Ok(tauri::ipc::Response::new(bytes)) +} + /// Copy an image from a relay media URL directly to the system clipboard. /// /// Fetches the image, decodes it to RGBA8, and writes it to the clipboard via @@ -307,14 +345,8 @@ async fn fetch_blob_bytes_with_cap( } // Check Content-Length header upfront if present. - if let Some(content_length) = resp.content_length() { - if content_length > cap { - return Err(format!( - "file too large ({} MiB, max {} MiB)", - content_length / (1024 * 1024), - cap / (1024 * 1024) - )); - } + if let Some(err) = declared_length_refusal_error(resp.content_length(), cap) { + return Err(err); } // Stream the response with a running byte count to enforce the size cap @@ -323,15 +355,43 @@ async fn fetch_blob_bytes_with_cap( let mut stream = resp.bytes_stream(); while let Some(chunk) = stream.next().await { let chunk = chunk.map_err(|e| classify_request_error(&e))?; - if bytes.len() as u64 + chunk.len() as u64 > cap { - return Err(format!("file too large (max {} MiB)", cap / (1024 * 1024))); - } - bytes.extend_from_slice(&chunk); + append_chunk_within_cap(&mut bytes, &chunk, cap)?; } Ok(bytes) } +/// The refusal for a declared Content-Length that exceeds the byte cap, or +/// `None` when the header is absent or within bounds. An absent header is +/// allowed through — `append_chunk_within_cap` still enforces the cap on the +/// streamed bytes, so a missing or dishonest length never buys a larger +/// download. Pulled out of `fetch_blob_bytes_with_cap` so the pre-body +/// refusal is unit-testable without a Tauri `State`. +fn declared_length_refusal_error(content_length: Option, cap: u64) -> Option { + let content_length = content_length?; + (content_length > cap).then(|| { + format!( + "file too large ({} MiB, max {} MiB)", + content_length / (1024 * 1024), + cap / (1024 * 1024) + ) + }) +} + +/// Append a response chunk to the accumulator, refusing once the running +/// total would exceed `cap`. This is the enforcement point that holds even +/// when Content-Length is missing or dishonest: the transfer aborts +/// mid-stream instead of buffering past the cap. Pulled out of +/// `fetch_blob_bytes_with_cap` so the cutoff is unit-testable without a +/// Tauri `State`. +fn append_chunk_within_cap(bytes: &mut Vec, chunk: &[u8], cap: u64) -> Result<(), String> { + if bytes.len() as u64 + chunk.len() as u64 > cap { + return Err(format!("file too large (max {} MiB)", cap / (1024 * 1024))); + } + bytes.extend_from_slice(chunk); + Ok(()) +} + /// The snapshot file format inferred from the sanitized filename suffix. /// Carries the format-specific byte cap used during bounded fetch. #[derive(Clone, Copy, PartialEq, Eq, Debug)] @@ -524,469 +584,5 @@ pub async fn fetch_snapshot_bytes( } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn snapshot_kind_json_returns_json_kind_and_correct_cap() { - let kind = snapshot_kind_for_filename("analyst.agent.json").unwrap(); - assert_eq!(kind, SnapshotFileKind::AgentJson); - assert_eq!(kind.cap(), MAX_SNAPSHOT_JSON_BYTES as u64); - } - - #[test] - fn snapshot_kind_png_returns_png_kind_and_correct_cap() { - let kind = snapshot_kind_for_filename("analyst.agent.png").unwrap(); - assert_eq!(kind, SnapshotFileKind::AgentPng); - assert_eq!(kind.cap(), MAX_SNAPSHOT_PNG_BYTES as u64); - } - - #[test] - fn snapshot_kind_plain_json_rejected() { - assert!(snapshot_kind_for_filename("data.json").is_err()); - } - - #[test] - fn snapshot_kind_deceptive_name_rejected() { - // foo.agent.json.exe must not match .agent.json - assert!(snapshot_kind_for_filename("foo.agent.json.exe").is_err()); - } - - #[test] - fn snapshot_kind_plain_png_rejected() { - assert!(snapshot_kind_for_filename("photo.png").is_err()); - } - - #[test] - fn snapshot_kind_agent_json_only_rejected() { - // "agent.json" without the leading dot — plain filename, not the suffix - assert!(snapshot_kind_for_filename("agentjson").is_err()); - } - - #[test] - fn snapshot_kind_team_extensions_are_case_insensitive_and_scale_caps() { - let json = snapshot_kind_for_filename("review.TEAM.JSON").unwrap(); - let png = snapshot_kind_for_filename("review.TEAM.PNG").unwrap(); - assert_eq!(json, SnapshotFileKind::TeamJson); - assert_eq!(png, SnapshotFileKind::TeamPng); - assert_eq!(json.cap(), 25 * 1024 * 1024); - assert_eq!(png.cap(), 50 * 1024 * 1024); - } - - #[test] - fn fetch_boundary_team_png_filename_with_json_bytes_rejected() { - let bytes = br#"{"format":"buzz-team-snapshot","version":1}"#; - let kind = snapshot_kind_for_filename("review.team.png").unwrap(); - let error = ensure_bytes_match_kind(bytes, kind).unwrap_err(); - assert!(error.contains(".team.png") && error.contains("not a PNG")); - } - - #[test] - fn fetch_boundary_team_declared_size_over_cap_rejected() { - let kind = snapshot_kind_for_filename("review.team.json").unwrap(); - assert!(ensure_declared_size_within_cap(MAX_TEAM_SNAPSHOT_JSON_BYTES, kind).is_ok()); - let error = - ensure_declared_size_within_cap(MAX_TEAM_SNAPSHOT_JSON_BYTES + 1, kind).unwrap_err(); - assert!(error.contains("25 MiB")); - } - - // ── Focused boundary tests: format mismatch and consistency ────────────── - // - // These tests exercise the guard logic that fetch_snapshot_bytes applies - // after the bounded fetch + hash check. The validation has two layers: - // - // 1. Magic-byte kind check: filename kind (from snapshot_kind_for_filename) - // must match the actual byte format (PNG magic or absence of it). - // 2. decode_snapshot_from_bytes: rejects malformed manifests including - // JSON with level:none + non-empty entries. - // - // We verify each rejection path directly — no live HTTP required. - - #[test] - fn fetch_boundary_png_filename_with_json_bytes_rejected() { - use crate::managed_agents::agent_snapshot::{ - encode_snapshot_json, AgentSnapshot, AgentSnapshotDefinition, AgentSnapshotMemory, - AgentSnapshotProfile, FORMAT_DISCRIMINATOR, FORMAT_VERSION, - }; - let snapshot = AgentSnapshot { - format: FORMAT_DISCRIMINATOR.to_string(), - version: FORMAT_VERSION, - definition: AgentSnapshotDefinition { - name: "test".to_string(), - source_is_builtin: false, - system_prompt: None, - runtime: None, - model: None, - provider: None, - parallelism: None, - respond_to: None, - respond_to_allowlist: vec![], - name_pool: vec![], - idle_timeout_seconds: None, - max_turn_duration_seconds: None, - }, - profile: AgentSnapshotProfile { - display_name: "Test".to_string(), - about: None, - avatar_data_url: None, - avatar_url: None, - }, - memory: AgentSnapshotMemory { - level: crate::managed_agents::agent_snapshot::MemoryLevel::None, - entries: vec![], - }, - }; - let json_bytes = encode_snapshot_json(&snapshot).unwrap(); - // .agent.png filename → Png kind; JSON bytes must be rejected. - let kind = snapshot_kind_for_filename("analyst.agent.png").unwrap(); - let result = ensure_bytes_match_kind(&json_bytes, kind); - assert!( - result.is_err(), - ".agent.png filename with JSON bytes must be rejected by the magic-byte guard" - ); - assert!( - result.unwrap_err().contains("not a PNG"), - "error must describe the mismatch" - ); - } - - #[test] - fn fetch_boundary_png_filename_with_memory_bearing_json_bytes_rejected() { - use crate::managed_agents::agent_snapshot::{ - encode_snapshot_json, AgentSnapshot, AgentSnapshotDefinition, AgentSnapshotMemory, - AgentSnapshotMemoryEntry, AgentSnapshotProfile, FORMAT_DISCRIMINATOR, FORMAT_VERSION, - }; - // This is the trust-hole case: memory-bearing JSON delivered under a - // .agent.png label to bypass the PNG no-memory policy. - let snapshot = AgentSnapshot { - format: FORMAT_DISCRIMINATOR.to_string(), - version: FORMAT_VERSION, - definition: AgentSnapshotDefinition { - name: "test".to_string(), - source_is_builtin: false, - system_prompt: None, - runtime: None, - model: None, - provider: None, - parallelism: None, - respond_to: None, - respond_to_allowlist: vec![], - name_pool: vec![], - idle_timeout_seconds: None, - max_turn_duration_seconds: None, - }, - profile: AgentSnapshotProfile { - display_name: "Test".to_string(), - about: None, - avatar_data_url: None, - avatar_url: None, - }, - memory: AgentSnapshotMemory { - level: crate::managed_agents::agent_snapshot::MemoryLevel::Everything, - entries: vec![AgentSnapshotMemoryEntry { - slug: "core".to_string(), - body: "Secret memory.".to_string(), - }], - }, - }; - let json_bytes = encode_snapshot_json(&snapshot).unwrap(); - let kind = snapshot_kind_for_filename("analyst.agent.png").unwrap(); - let result = ensure_bytes_match_kind(&json_bytes, kind); - assert!( - result.is_err(), - ".agent.png filename with memory-bearing JSON bytes must be rejected" - ); - } - - #[test] - fn fetch_boundary_json_filename_with_png_bytes_rejected() { - use crate::managed_agents::agent_snapshot::{ - encode_snapshot_png, AgentSnapshot, AgentSnapshotDefinition, AgentSnapshotMemory, - AgentSnapshotProfile, FORMAT_DISCRIMINATOR, FORMAT_VERSION, - }; - let snapshot = AgentSnapshot { - format: FORMAT_DISCRIMINATOR.to_string(), - version: FORMAT_VERSION, - definition: AgentSnapshotDefinition { - name: "test".to_string(), - source_is_builtin: false, - system_prompt: None, - runtime: None, - model: None, - provider: None, - parallelism: None, - respond_to: None, - respond_to_allowlist: vec![], - name_pool: vec![], - idle_timeout_seconds: None, - max_turn_duration_seconds: None, - }, - profile: AgentSnapshotProfile { - display_name: "Test".to_string(), - about: None, - avatar_data_url: None, - avatar_url: None, - }, - memory: AgentSnapshotMemory { - level: crate::managed_agents::agent_snapshot::MemoryLevel::None, - entries: vec![], - }, - }; - let png_bytes = encode_snapshot_png(&snapshot, None).unwrap(); - // .agent.json filename → Json kind; PNG bytes must be rejected. - let kind = snapshot_kind_for_filename("analyst.agent.json").unwrap(); - let result = ensure_bytes_match_kind(&png_bytes, kind); - assert!( - result.is_err(), - ".agent.json filename with PNG bytes must be rejected by the magic-byte guard" - ); - assert!( - result.unwrap_err().contains("bytes are a PNG"), - "error must describe the mismatch" - ); - } - - #[test] - fn decode_boundary_json_none_level_with_entries_rejected() { - use crate::commands::personas::decode_snapshot_from_bytes; - // Construct JSON bytes directly: level=none but entries non-empty. - // encode_snapshot_json does not guard against this, so we can produce it. - let raw = serde_json::json!({ - "format": "buzz-agent-snapshot", - "version": 1, - "definition": { "name": "test" }, - "profile": { "displayName": "Test" }, - "memory": { - "level": "none", - "entries": [{"slug": "core", "body": "leaked"}] - } - }); - let bytes = serde_json::to_vec(&raw).unwrap(); - let result = decode_snapshot_from_bytes(&bytes); - assert!( - result.is_err(), - "JSON with level:none + non-empty entries must be rejected by decode_snapshot_from_bytes" - ); - assert!( - result - .unwrap_err() - .contains("'none' but entries are present"), - "error must describe the consistency violation" - ); - } - - const RELAY_BASE: &str = "https://relay.example.com"; - - #[test] - fn test_validate_download_url_valid_relay_url() { - assert!(validate_download_url( - "https://relay.example.com/media/abcdef1234567890.jpg", - RELAY_BASE, - ) - .is_ok()); - } - - #[test] - fn test_validate_download_url_valid_relay_url_png() { - assert!( - validate_download_url("https://relay.example.com/media/abc123.png", RELAY_BASE,) - .is_ok() - ); - } - - #[test] - fn test_validate_download_url_non_relay_origin_rejected() { - let result = validate_download_url("https://evil.example.com/media/abc123.jpg", RELAY_BASE); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("relay origin")); - } - - #[test] - fn test_validate_download_url_private_ip_rejected() { - let result = validate_download_url("http://169.254.169.254/latest/meta-data/", RELAY_BASE); - assert!(result.is_err()); - } - - #[test] - fn test_validate_download_url_loopback_rejected() { - let result = validate_download_url("http://127.0.0.1/media/abc.jpg", RELAY_BASE); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("relay origin")); - } - - #[test] - fn test_validate_download_url_localhost_allowed_for_localhost_relay() { - assert!(validate_download_url( - "http://localhost:3000/media/abc.jpg", - "http://localhost:3000", - ) - .is_ok()); - } - - #[test] - fn test_validate_download_url_missing_media_path_rejected() { - let result = validate_download_url("https://relay.example.com/other/abc.jpg", RELAY_BASE); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("/media/")); - } - - #[test] - fn test_validate_download_url_non_https_scheme_rejected() { - let result = validate_download_url("ftp://relay.example.com/media/abc.jpg", RELAY_BASE); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("HTTPS")); - } - - #[test] - fn test_validate_download_url_http_non_localhost_rejected() { - let result = validate_download_url("http://relay.example.com/media/abc.jpg", RELAY_BASE); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("HTTPS")); - } - - #[test] - fn test_validate_download_url_root_path_rejected() { - let result = validate_download_url("https://relay.example.com/", RELAY_BASE); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("/media/")); - } - - // Video Download reuses `download_file`, which runs the same - // `validate_download_url` gate as image download. `validate_download_url` - // is extension-agnostic (it only checks scheme, origin, and the `/media/` - // path prefix), so a relay-hosted mp4/webm passes exactly like an image, - // and an off-relay or private-host video is rejected exactly like an - // off-relay image. These cases pin that parity so a future change can't - // silently narrow the video download path's SSRF protection. - #[test] - fn test_validate_download_url_valid_relay_video_mp4() { - assert!(validate_download_url( - "https://relay.example.com/media/abcdef1234567890.mp4", - RELAY_BASE, - ) - .is_ok()); - } - - #[test] - fn test_validate_download_url_valid_relay_video_webm() { - assert!( - validate_download_url("https://relay.example.com/media/abc123.webm", RELAY_BASE) - .is_ok() - ); - } - - #[test] - fn test_validate_download_url_non_relay_video_rejected() { - let result = validate_download_url("https://evil.example.com/media/clip.mp4", RELAY_BASE); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("relay origin")); - } - - #[test] - fn test_validate_download_url_private_host_video_rejected() { - // Off-relay private host serving a video must be rejected before any - // fetch — same SSRF gate as image download. - let result = validate_download_url("http://127.0.0.1/media/clip.mp4", RELAY_BASE); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("relay origin")); - } - - /// Redirect-hop SSRF guard: the media fetch client must NOT follow a 3xx, - /// and the command-facing error must identify the refused redirect. - /// - /// `validate_download_url` only vets the *initial* URL, so a relay that - /// returned a redirect to an off-origin or private host would, under a - /// redirect-following client, forward the minted media Authorization - /// header across origins. The client `build_media_fetch_client()` produces - /// (the same one `fetch_blob_bytes_with_cap` uses via `AppState`) is built - /// with `redirect::Policy::none()`, so the 302 comes back verbatim and - /// `redirect_refusal_error` — the same mapping the command applies — turns - /// it into an actionable redirect error, not a silent cross-origin fetch. - /// - /// A loopback `std::net::TcpListener` (no extra tokio feature) serves one - /// raw `302` pointing at an off-origin target and records how many - /// connections it accepts. - #[tokio::test] - async fn media_fetch_client_does_not_follow_redirects() { - use std::io::{Read, Write}; - use std::sync::atomic::{AtomicUsize, Ordering}; - use std::sync::Arc; - - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - let addr = listener.local_addr().unwrap(); - let connections = Arc::new(AtomicUsize::new(0)); - - let server_connections = Arc::clone(&connections); - let server = std::thread::spawn(move || { - // Accept exactly one connection; if the client followed the - // redirect it would open a second one to the (unrelated) target, - // but that target is never this server, so a second accept here - // would only happen on an unexpected retry. We serve one 302 and - // return, so the count stays at 1 for a compliant no-redirect client. - if let Ok((mut stream, _)) = listener.accept() { - server_connections.fetch_add(1, Ordering::SeqCst); - let mut buf = [0u8; 1024]; - let _ = stream.read(&mut buf); - let response = "HTTP/1.1 302 Found\r\n\ - Location: http://169.254.169.254/latest/meta-data/\r\n\ - Content-Length: 0\r\n\ - Connection: close\r\n\r\n"; - let _ = stream.write_all(response.as_bytes()); - let _ = stream.flush(); - } - }); - - // Drive the exact client the command path uses, not an ad-hoc one. - let client = crate::app_state::build_media_fetch_client() - .expect("media fetch client must build with no-redirect policy"); - let resp = client - .get(format!("http://{addr}/media/clip.mp4")) - .timeout(std::time::Duration::from_secs(5)) - .send() - .await - .expect("request should complete without following the redirect"); - - // The 302 is returned verbatim — not the 169.254.x target's response. - assert_eq!(resp.status().as_u16(), 302); - assert!(!resp.status().is_success()); - - // The command maps that status through `redirect_refusal_error`; the - // user-facing error must name the redirect, not read as a generic - // relay failure. - let err = redirect_refusal_error(resp.status()) - .expect("a 3xx must map to a redirect-refusal error"); - assert!( - err.contains("redirect") && err.contains("302"), - "error must identify the refused 302 redirect, got: {err}", - ); - - server.join().unwrap(); - assert_eq!( - connections.load(Ordering::SeqCst), - 1, - "exactly one request must be issued — the redirect must not be followed", - ); - } - - #[test] - fn build_media_fetch_client_succeeds_with_no_redirect_policy() { - // The fail-closed invariant: construction must not silently degrade to - // a redirect-following client. If this ever starts failing, startup - // panics loudly (see `build_app_state`) rather than substituting an - // insecure client. - assert!( - crate::app_state::build_media_fetch_client().is_ok(), - "media fetch client must build; a redirect-following fallback is forbidden", - ); - } - - #[test] - fn redirect_refusal_error_only_fires_for_3xx() { - // 3xx → redirect-identifying error; success/non-3xx → None (fall - // through to the normal success or relay-error handling). - assert!(redirect_refusal_error(reqwest::StatusCode::FOUND).is_some()); - assert!(redirect_refusal_error(reqwest::StatusCode::TEMPORARY_REDIRECT).is_some()); - assert!(redirect_refusal_error(reqwest::StatusCode::OK).is_none()); - assert!(redirect_refusal_error(reqwest::StatusCode::NOT_FOUND).is_none()); - } -} +#[path = "media_download_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/commands/media_download_tests.rs b/desktop/src-tauri/src/commands/media_download_tests.rs new file mode 100644 index 0000000000..be9960a101 --- /dev/null +++ b/desktop/src-tauri/src/commands/media_download_tests.rs @@ -0,0 +1,516 @@ +// Tests for commands/media_download.rs — split into a sibling file to keep +// media_download.rs under the per-file line cap. + +use super::*; + +#[test] +fn snapshot_kind_json_returns_json_kind_and_correct_cap() { + let kind = snapshot_kind_for_filename("analyst.agent.json").unwrap(); + assert_eq!(kind, SnapshotFileKind::AgentJson); + assert_eq!(kind.cap(), MAX_SNAPSHOT_JSON_BYTES as u64); +} + +#[test] +fn snapshot_kind_png_returns_png_kind_and_correct_cap() { + let kind = snapshot_kind_for_filename("analyst.agent.png").unwrap(); + assert_eq!(kind, SnapshotFileKind::AgentPng); + assert_eq!(kind.cap(), MAX_SNAPSHOT_PNG_BYTES as u64); +} + +#[test] +fn snapshot_kind_plain_json_rejected() { + assert!(snapshot_kind_for_filename("data.json").is_err()); +} + +#[test] +fn snapshot_kind_deceptive_name_rejected() { + // foo.agent.json.exe must not match .agent.json + assert!(snapshot_kind_for_filename("foo.agent.json.exe").is_err()); +} + +#[test] +fn snapshot_kind_plain_png_rejected() { + assert!(snapshot_kind_for_filename("photo.png").is_err()); +} + +#[test] +fn snapshot_kind_agent_json_only_rejected() { + // "agent.json" without the leading dot — plain filename, not the suffix + assert!(snapshot_kind_for_filename("agentjson").is_err()); +} + +#[test] +fn snapshot_kind_team_extensions_are_case_insensitive_and_scale_caps() { + let json = snapshot_kind_for_filename("review.TEAM.JSON").unwrap(); + let png = snapshot_kind_for_filename("review.TEAM.PNG").unwrap(); + assert_eq!(json, SnapshotFileKind::TeamJson); + assert_eq!(png, SnapshotFileKind::TeamPng); + assert_eq!(json.cap(), 25 * 1024 * 1024); + assert_eq!(png.cap(), 50 * 1024 * 1024); +} + +#[test] +fn fetch_boundary_team_png_filename_with_json_bytes_rejected() { + let bytes = br#"{"format":"buzz-team-snapshot","version":1}"#; + let kind = snapshot_kind_for_filename("review.team.png").unwrap(); + let error = ensure_bytes_match_kind(bytes, kind).unwrap_err(); + assert!(error.contains(".team.png") && error.contains("not a PNG")); +} + +#[test] +fn fetch_boundary_team_declared_size_over_cap_rejected() { + let kind = snapshot_kind_for_filename("review.team.json").unwrap(); + assert!(ensure_declared_size_within_cap(MAX_TEAM_SNAPSHOT_JSON_BYTES, kind).is_ok()); + let error = + ensure_declared_size_within_cap(MAX_TEAM_SNAPSHOT_JSON_BYTES + 1, kind).unwrap_err(); + assert!(error.contains("25 MiB")); +} + +// ── Focused boundary tests: format mismatch and consistency ────────────── +// +// These tests exercise the guard logic that fetch_snapshot_bytes applies +// after the bounded fetch + hash check. The validation has two layers: +// +// 1. Magic-byte kind check: filename kind (from snapshot_kind_for_filename) +// must match the actual byte format (PNG magic or absence of it). +// 2. decode_snapshot_from_bytes: rejects malformed manifests including +// JSON with level:none + non-empty entries. +// +// We verify each rejection path directly — no live HTTP required. + +#[test] +fn fetch_boundary_png_filename_with_json_bytes_rejected() { + use crate::managed_agents::agent_snapshot::{ + encode_snapshot_json, AgentSnapshot, AgentSnapshotDefinition, AgentSnapshotMemory, + AgentSnapshotProfile, FORMAT_DISCRIMINATOR, FORMAT_VERSION, + }; + let snapshot = AgentSnapshot { + format: FORMAT_DISCRIMINATOR.to_string(), + version: FORMAT_VERSION, + definition: AgentSnapshotDefinition { + name: "test".to_string(), + source_is_builtin: false, + system_prompt: None, + runtime: None, + model: None, + provider: None, + parallelism: None, + respond_to: None, + respond_to_allowlist: vec![], + name_pool: vec![], + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + }, + profile: AgentSnapshotProfile { + display_name: "Test".to_string(), + about: None, + avatar_data_url: None, + avatar_url: None, + }, + memory: AgentSnapshotMemory { + level: crate::managed_agents::agent_snapshot::MemoryLevel::None, + entries: vec![], + }, + }; + let json_bytes = encode_snapshot_json(&snapshot).unwrap(); + // .agent.png filename → Png kind; JSON bytes must be rejected. + let kind = snapshot_kind_for_filename("analyst.agent.png").unwrap(); + let result = ensure_bytes_match_kind(&json_bytes, kind); + assert!( + result.is_err(), + ".agent.png filename with JSON bytes must be rejected by the magic-byte guard" + ); + assert!( + result.unwrap_err().contains("not a PNG"), + "error must describe the mismatch" + ); +} + +#[test] +fn fetch_boundary_png_filename_with_memory_bearing_json_bytes_rejected() { + use crate::managed_agents::agent_snapshot::{ + encode_snapshot_json, AgentSnapshot, AgentSnapshotDefinition, AgentSnapshotMemory, + AgentSnapshotMemoryEntry, AgentSnapshotProfile, FORMAT_DISCRIMINATOR, FORMAT_VERSION, + }; + // This is the trust-hole case: memory-bearing JSON delivered under a + // .agent.png label to bypass the PNG no-memory policy. + let snapshot = AgentSnapshot { + format: FORMAT_DISCRIMINATOR.to_string(), + version: FORMAT_VERSION, + definition: AgentSnapshotDefinition { + name: "test".to_string(), + source_is_builtin: false, + system_prompt: None, + runtime: None, + model: None, + provider: None, + parallelism: None, + respond_to: None, + respond_to_allowlist: vec![], + name_pool: vec![], + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + }, + profile: AgentSnapshotProfile { + display_name: "Test".to_string(), + about: None, + avatar_data_url: None, + avatar_url: None, + }, + memory: AgentSnapshotMemory { + level: crate::managed_agents::agent_snapshot::MemoryLevel::Everything, + entries: vec![AgentSnapshotMemoryEntry { + slug: "core".to_string(), + body: "Secret memory.".to_string(), + }], + }, + }; + let json_bytes = encode_snapshot_json(&snapshot).unwrap(); + let kind = snapshot_kind_for_filename("analyst.agent.png").unwrap(); + let result = ensure_bytes_match_kind(&json_bytes, kind); + assert!( + result.is_err(), + ".agent.png filename with memory-bearing JSON bytes must be rejected" + ); +} + +#[test] +fn fetch_boundary_json_filename_with_png_bytes_rejected() { + use crate::managed_agents::agent_snapshot::{ + encode_snapshot_png, AgentSnapshot, AgentSnapshotDefinition, AgentSnapshotMemory, + AgentSnapshotProfile, FORMAT_DISCRIMINATOR, FORMAT_VERSION, + }; + let snapshot = AgentSnapshot { + format: FORMAT_DISCRIMINATOR.to_string(), + version: FORMAT_VERSION, + definition: AgentSnapshotDefinition { + name: "test".to_string(), + source_is_builtin: false, + system_prompt: None, + runtime: None, + model: None, + provider: None, + parallelism: None, + respond_to: None, + respond_to_allowlist: vec![], + name_pool: vec![], + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + }, + profile: AgentSnapshotProfile { + display_name: "Test".to_string(), + about: None, + avatar_data_url: None, + avatar_url: None, + }, + memory: AgentSnapshotMemory { + level: crate::managed_agents::agent_snapshot::MemoryLevel::None, + entries: vec![], + }, + }; + let png_bytes = encode_snapshot_png(&snapshot, None).unwrap(); + // .agent.json filename → Json kind; PNG bytes must be rejected. + let kind = snapshot_kind_for_filename("analyst.agent.json").unwrap(); + let result = ensure_bytes_match_kind(&png_bytes, kind); + assert!( + result.is_err(), + ".agent.json filename with PNG bytes must be rejected by the magic-byte guard" + ); + assert!( + result.unwrap_err().contains("bytes are a PNG"), + "error must describe the mismatch" + ); +} + +#[test] +fn decode_boundary_json_none_level_with_entries_rejected() { + use crate::commands::personas::decode_snapshot_from_bytes; + // Construct JSON bytes directly: level=none but entries non-empty. + // encode_snapshot_json does not guard against this, so we can produce it. + let raw = serde_json::json!({ + "format": "buzz-agent-snapshot", + "version": 1, + "definition": { "name": "test" }, + "profile": { "displayName": "Test" }, + "memory": { + "level": "none", + "entries": [{"slug": "core", "body": "leaked"}] + } + }); + let bytes = serde_json::to_vec(&raw).unwrap(); + let result = decode_snapshot_from_bytes(&bytes); + assert!( + result.is_err(), + "JSON with level:none + non-empty entries must be rejected by decode_snapshot_from_bytes" + ); + assert!( + result + .unwrap_err() + .contains("'none' but entries are present"), + "error must describe the consistency violation" + ); +} + +const RELAY_BASE: &str = "https://relay.example.com"; + +#[test] +fn test_validate_download_url_valid_relay_url() { + assert!(validate_download_url( + "https://relay.example.com/media/abcdef1234567890.jpg", + RELAY_BASE, + ) + .is_ok()); +} + +#[test] +fn test_validate_download_url_valid_relay_url_png() { + assert!( + validate_download_url("https://relay.example.com/media/abc123.png", RELAY_BASE,).is_ok() + ); +} + +#[test] +fn test_validate_download_url_non_relay_origin_rejected() { + let result = validate_download_url("https://evil.example.com/media/abc123.jpg", RELAY_BASE); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("relay origin")); +} + +#[test] +fn test_validate_download_url_private_ip_rejected() { + let result = validate_download_url("http://169.254.169.254/latest/meta-data/", RELAY_BASE); + assert!(result.is_err()); +} + +#[test] +fn test_validate_download_url_loopback_rejected() { + let result = validate_download_url("http://127.0.0.1/media/abc.jpg", RELAY_BASE); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("relay origin")); +} + +#[test] +fn test_validate_download_url_localhost_allowed_for_localhost_relay() { + assert!(validate_download_url( + "http://localhost:3000/media/abc.jpg", + "http://localhost:3000", + ) + .is_ok()); +} + +#[test] +fn test_validate_download_url_missing_media_path_rejected() { + let result = validate_download_url("https://relay.example.com/other/abc.jpg", RELAY_BASE); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("/media/")); +} + +#[test] +fn test_validate_download_url_non_https_scheme_rejected() { + let result = validate_download_url("ftp://relay.example.com/media/abc.jpg", RELAY_BASE); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("HTTPS")); +} + +#[test] +fn test_validate_download_url_http_non_localhost_rejected() { + let result = validate_download_url("http://relay.example.com/media/abc.jpg", RELAY_BASE); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("HTTPS")); +} + +#[test] +fn test_validate_download_url_root_path_rejected() { + let result = validate_download_url("https://relay.example.com/", RELAY_BASE); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("/media/")); +} + +// Video Download reuses `download_file`, which runs the same +// `validate_download_url` gate as image download. `validate_download_url` +// is extension-agnostic (it only checks scheme, origin, and the `/media/` +// path prefix), so a relay-hosted mp4/webm passes exactly like an image, +// and an off-relay or private-host video is rejected exactly like an +// off-relay image. These cases pin that parity so a future change can't +// silently narrow the video download path's SSRF protection. +#[test] +fn test_validate_download_url_valid_relay_video_mp4() { + assert!(validate_download_url( + "https://relay.example.com/media/abcdef1234567890.mp4", + RELAY_BASE, + ) + .is_ok()); +} + +#[test] +fn test_validate_download_url_valid_relay_video_webm() { + assert!( + validate_download_url("https://relay.example.com/media/abc123.webm", RELAY_BASE).is_ok() + ); +} + +#[test] +fn test_validate_download_url_non_relay_video_rejected() { + let result = validate_download_url("https://evil.example.com/media/clip.mp4", RELAY_BASE); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("relay origin")); +} + +#[test] +fn test_validate_download_url_private_host_video_rejected() { + // Off-relay private host serving a video must be rejected before any + // fetch — same SSRF gate as image download. + let result = validate_download_url("http://127.0.0.1/media/clip.mp4", RELAY_BASE); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("relay origin")); +} + +/// Redirect-hop SSRF guard: the media fetch client must NOT follow a 3xx, +/// and the command-facing error must identify the refused redirect. +/// +/// `validate_download_url` only vets the *initial* URL, so a relay that +/// returned a redirect to an off-origin or private host would, under a +/// redirect-following client, forward the minted media Authorization +/// header across origins. The client `build_media_fetch_client()` produces +/// (the same one `fetch_blob_bytes_with_cap` uses via `AppState`) is built +/// with `redirect::Policy::none()`, so the 302 comes back verbatim and +/// `redirect_refusal_error` — the same mapping the command applies — turns +/// it into an actionable redirect error, not a silent cross-origin fetch. +/// +/// A loopback `std::net::TcpListener` (no extra tokio feature) serves one +/// raw `302` pointing at an off-origin target and records how many +/// connections it accepts. +#[tokio::test] +async fn media_fetch_client_does_not_follow_redirects() { + use std::io::{Read, Write}; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let connections = Arc::new(AtomicUsize::new(0)); + + let server_connections = Arc::clone(&connections); + let server = std::thread::spawn(move || { + // Accept exactly one connection; if the client followed the + // redirect it would open a second one to the (unrelated) target, + // but that target is never this server, so a second accept here + // would only happen on an unexpected retry. We serve one 302 and + // return, so the count stays at 1 for a compliant no-redirect client. + if let Ok((mut stream, _)) = listener.accept() { + server_connections.fetch_add(1, Ordering::SeqCst); + let mut buf = [0u8; 1024]; + let _ = stream.read(&mut buf); + let response = "HTTP/1.1 302 Found\r\n\ + Location: http://169.254.169.254/latest/meta-data/\r\n\ + Content-Length: 0\r\n\ + Connection: close\r\n\r\n"; + let _ = stream.write_all(response.as_bytes()); + let _ = stream.flush(); + } + }); + + // Drive the exact client the command path uses, not an ad-hoc one. + let client = crate::app_state::build_media_fetch_client() + .expect("media fetch client must build with no-redirect policy"); + let resp = client + .get(format!("http://{addr}/media/clip.mp4")) + .timeout(std::time::Duration::from_secs(5)) + .send() + .await + .expect("request should complete without following the redirect"); + + // The 302 is returned verbatim — not the 169.254.x target's response. + assert_eq!(resp.status().as_u16(), 302); + assert!(!resp.status().is_success()); + + // The command maps that status through `redirect_refusal_error`; the + // user-facing error must name the redirect, not read as a generic + // relay failure. + let err = + redirect_refusal_error(resp.status()).expect("a 3xx must map to a redirect-refusal error"); + assert!( + err.contains("redirect") && err.contains("302"), + "error must identify the refused 302 redirect, got: {err}", + ); + + server.join().unwrap(); + assert_eq!( + connections.load(Ordering::SeqCst), + 1, + "exactly one request must be issued — the redirect must not be followed", + ); +} + +#[test] +fn build_media_fetch_client_succeeds_with_no_redirect_policy() { + // The fail-closed invariant: construction must not silently degrade to + // a redirect-following client. If this ever starts failing, startup + // panics loudly (see `build_app_state`) rather than substituting an + // insecure client. + assert!( + crate::app_state::build_media_fetch_client().is_ok(), + "media fetch client must build; a redirect-following fallback is forbidden", + ); +} + +#[test] +fn redirect_refusal_error_only_fires_for_3xx() { + // 3xx → redirect-identifying error; success/non-3xx → None (fall + // through to the normal success or relay-error handling). + assert!(redirect_refusal_error(reqwest::StatusCode::FOUND).is_some()); + assert!(redirect_refusal_error(reqwest::StatusCode::TEMPORARY_REDIRECT).is_some()); + assert!(redirect_refusal_error(reqwest::StatusCode::OK).is_none()); + assert!(redirect_refusal_error(reqwest::StatusCode::NOT_FOUND).is_none()); +} + +#[test] +fn markdown_doc_cap_is_two_mib() { + assert_eq!(MAX_MARKDOWN_DOC_BYTES, 2 * 1024 * 1024); +} + +#[test] +fn declared_length_over_markdown_cap_refused_before_body() { + let err = + declared_length_refusal_error(Some(MAX_MARKDOWN_DOC_BYTES + 1), MAX_MARKDOWN_DOC_BYTES) + .expect("length just over the cap must be refused"); + assert!(err.contains("too large"), "{err}"); + // A 50 MiB advertisement passes the generic download cap but must be + // refused by the markdown viewer's cap before any body bytes are read. + assert!( + declared_length_refusal_error(Some(MAX_DOWNLOAD_BYTES), MAX_MARKDOWN_DOC_BYTES).is_some() + ); +} + +#[test] +fn declared_length_at_cap_or_absent_is_admitted() { + assert!( + declared_length_refusal_error(Some(MAX_MARKDOWN_DOC_BYTES), MAX_MARKDOWN_DOC_BYTES) + .is_none() + ); + // Absent Content-Length is admitted here by design — the streaming + // accumulator below still owns the cap, so a stripped or dishonest + // header cannot buy a larger download. + assert!(declared_length_refusal_error(None, MAX_MARKDOWN_DOC_BYTES).is_none()); +} + +#[test] +fn streamed_bytes_hit_markdown_cap_without_content_length() { + // Simulate a chunked transfer with no (or dishonest) Content-Length: the + // running total must abort the moment a chunk would cross the cap, and + // nothing past the cap may be buffered. + let cap = MAX_MARKDOWN_DOC_BYTES; + let mut bytes = Vec::new(); + let chunk = vec![b'a'; 1024 * 1024]; // 1 MiB chunks + append_chunk_within_cap(&mut bytes, &chunk, cap).expect("first MiB fits"); + append_chunk_within_cap(&mut bytes, &chunk, cap).expect("exactly at the cap is allowed"); + assert_eq!(bytes.len() as u64, cap); + let err = append_chunk_within_cap(&mut bytes, b"x", cap) + .expect_err("one byte past the cap must abort"); + assert!(err.contains("too large"), "{err}"); + assert_eq!( + bytes.len() as u64, + cap, + "no bytes past the cap were buffered" + ); +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 613040b809..876188ef01 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -671,6 +671,7 @@ pub fn run() { save_png_data_url, download_file, fetch_media_bytes, + fetch_markdown_doc_bytes, copy_image_to_clipboard, copy_text_to_clipboard, read_clipboard_text, diff --git a/desktop/src/features/channels/ui/MarkdownDocPanel.tsx b/desktop/src/features/channels/ui/MarkdownDocPanel.tsx index 7873a8bc6a..411d30bf87 100644 --- a/desktop/src/features/channels/ui/MarkdownDocPanel.tsx +++ b/desktop/src/features/channels/ui/MarkdownDocPanel.tsx @@ -4,7 +4,10 @@ import { Download, FileText, Loader2 } from "lucide-react"; import { toast } from "sonner"; import { invokeTauri } from "@/shared/api/tauri"; -import { fetchMediaBytes } from "@/shared/api/tauriMedia"; +import { + fetchMarkdownDocBytes, + isMediaTooLargeError, +} from "@/shared/api/tauriMedia"; import { useEscapeKey } from "@/shared/hooks/useEscapeKey"; import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile"; import { @@ -52,8 +55,9 @@ function decodeErrorMessage(kind: "too-large" | "binary"): string { * Right auxiliary panel rendering a shared markdown attachment in-app. * * Relay media URLs require relay auth (plain browser requests 401), so the - * content is fetched through the authenticated `fetch_media_bytes` Tauri - * command and rendered with the same markdown pipeline chat messages use. + * content is fetched through the authenticated `fetch_markdown_doc_bytes` + * Tauri command — which enforces the viewer's 2 MiB cap natively during the + * fetch — and rendered with the same markdown pipeline chat messages use. * The Preview/Code toggle switches between rendered markdown and the * syntax-highlighted source. */ @@ -74,7 +78,17 @@ export function MarkdownDocPanel({ // document never changes under its URL — cache it for the session. const docQuery = useQuery({ queryKey: ["markdown-doc", url], - queryFn: async () => decodeMarkdownDocBytes(await fetchMediaBytes(url)), + queryFn: async () => { + try { + return decodeMarkdownDocBytes(await fetchMarkdownDocBytes(url)); + } catch (err) { + // The native 2 MiB cap refuses oversized documents during the fetch + // (the in-frontend decode check never sees their bytes). Surface it + // as the too-large fallback rather than a generic fetch failure. + if (isMediaTooLargeError(err)) return { kind: "too-large" }; + throw err; + } + }, staleTime: Number.POSITIVE_INFINITY, retry: 1, }); diff --git a/desktop/src/shared/api/tauriMedia.ts b/desktop/src/shared/api/tauriMedia.ts index 2f0498769f..71d5f1194f 100644 --- a/desktop/src/shared/api/tauriMedia.ts +++ b/desktop/src/shared/api/tauriMedia.ts @@ -72,6 +72,34 @@ export async function fetchMediaBytes( return new Uint8Array(bytes); } +/** + * Fetch a markdown document attachment for the in-app viewer. + * + * Unlike the generic `fetchMediaBytes` (50 MiB cap), the Rust command + * enforces the viewer's 2 MiB ceiling natively — refusing an oversized + * Content-Length before the body is read and aborting mid-stream when the + * header is missing or dishonest — so a forged imeta `size` can never buy + * a 50 MiB download and IPC copy. + */ +export async function fetchMarkdownDocBytes( + url: string, +): Promise> { + const bytes = await invokeTauri("fetch_markdown_doc_bytes", { + url, + }); + return new Uint8Array(bytes); +} + +/** + * Whether a markdown-doc fetch failure is the native size-cap refusal, as + * opposed to a network or relay error. Matches the stable "file too large" + * prefix produced by the Rust cap checks. + */ +export function isMediaTooLargeError(err: unknown): boolean { + const message = err instanceof Error ? err.message : String(err); + return message.includes("file too large"); +} + /** Read plain text without depending on embedded-webview clipboard grants. */ export async function readTextFromSystemClipboard(): Promise { // E2E installs Tauri's mocked IPC surface in a browser page, where the SDK's diff --git a/desktop/src/shared/ui/markdown/FileCard.tsx b/desktop/src/shared/ui/markdown/FileCard.tsx index 637bf5b3d6..48318ada17 100644 --- a/desktop/src/shared/ui/markdown/FileCard.tsx +++ b/desktop/src/shared/ui/markdown/FileCard.tsx @@ -40,7 +40,8 @@ function formatFileSize(bytes: number): string { * Markdown attachments (`.md`/`.markdown`/`.mdx` by imeta filename) open the * in-app markdown viewer panel instead when a hosting surface provides one — * relay-hosted only, because the viewer fetches through the authenticated - * `fetch_media_bytes` command, which accepts relay `/media/` origins alone. + * `fetch_markdown_doc_bytes` command (native 2 MiB cap), which accepts + * relay `/media/` origins alone. */ export function FileCard({ href, diff --git a/desktop/src/shared/ui/markdown/markdownDocFile.ts b/desktop/src/shared/ui/markdown/markdownDocFile.ts index 7e9dbf06aa..ba7b2df153 100644 --- a/desktop/src/shared/ui/markdown/markdownDocFile.ts +++ b/desktop/src/shared/ui/markdown/markdownDocFile.ts @@ -17,8 +17,13 @@ const MARKDOWN_DOC_EXTENSIONS = [".md", ".markdown", ".mdx"] as const; /** * Maximum attachment size the viewer will render. Larger files fall back - * to the download card path. Matches the ceiling proven workable by other - * in-app text viewers; well under the 50 MiB `fetch_media_bytes` cap. + * to the download card path. + * + * This constant powers the untrusted-imeta pre-gate (UX only) and the + * defense-in-depth decode check. The *enforcement* boundary is the native + * `fetch_markdown_doc_bytes` command's matching `MAX_MARKDOWN_DOC_BYTES` + * cap in `media_download.rs`, which refuses oversized documents during the + * streamed fetch — keep the two in sync. */ export const MAX_MARKDOWN_DOC_BYTES = 2 * 1024 * 1024; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 0bd7bc6eec..1f52b8d6d0 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -13543,6 +13543,18 @@ export function maybeInstallE2eTauriMocks() { if (!response.ok) throw new Error(`fetch failed: ${response.status}`); return await response.arrayBuffer(); } + case "fetch_markdown_doc_bytes": { + // Mirrors the real command's native 2 MiB viewer cap (enforced in + // Rust during the streamed fetch) so specs can prove the oversized + // fallback: the refusal message must match the Rust cap error shape. + const response = await fetch((payload as { url: string }).url); + if (!response.ok) throw new Error(`fetch failed: ${response.status}`); + const buffer = await response.arrayBuffer(); + if (buffer.byteLength > 2 * 1024 * 1024) { + throw new Error("file too large (max 2 MiB)"); + } + return buffer; + } case "fetch_snapshot_bytes": { // The real command fetches + validates a snapshot attachment in memory // (size cap, SHA-256, decode). In E2E the bridge returns a minimal diff --git a/desktop/tests/e2e/markdown-doc-viewer.spec.ts b/desktop/tests/e2e/markdown-doc-viewer.spec.ts index ecc1ec6191..c362a617bd 100644 --- a/desktop/tests/e2e/markdown-doc-viewer.spec.ts +++ b/desktop/tests/e2e/markdown-doc-viewer.spec.ts @@ -126,6 +126,63 @@ test("markdown attachment opens the in-app viewer with Preview/Code toggle", asy await expect(page.getByTestId("markdown-doc-panel")).toHaveCount(0); }); +test("open document survives reload and back/forward navigation", async ({ + page, +}) => { + await sendMarkdownAttachment(page); + await page.getByTestId("file-card").last().click(); + + const panel = () => page.getByTestId("markdown-doc-panel"); + await expect( + panel().getByRole("heading", { name: "Release Notes" }), + ).toBeVisible(); + + // The document lives in the URL (`doc`/`docName` params), so a reload + // must restore the open panel with its content. + await page.reload(); + await expect( + panel().getByRole("heading", { name: "Release Notes" }), + ).toBeVisible(); + + // Opening the panel pushed a history entry: back closes it, forward + // restores it — the advertised back/forward contract. + await page.goBack(); + await expect(page.getByTestId("markdown-doc-panel")).toHaveCount(0); + await page.goForward(); + await expect( + panel().getByRole("heading", { name: "Release Notes" }), + ).toBeVisible(); +}); + +test("a document over the native 2 MiB cap falls back to download", async ({ + page, +}) => { + // The imeta `size` is untrusted and here it lies small — the card offers + // the viewer. The served body is over the cap, so the (mocked) native + // fetch refuses it mid-transfer and the panel must show the too-large + // fallback instead of rendering, proving enforcement does not rest on + // the advertised size. + await page.unroute(`**/media/${DOC_SHA}.bin`); + await page.route(`**/media/${DOC_SHA}.bin`, (route) => + route.fulfill({ + body: Buffer.alloc(2 * 1024 * 1024 + 1, 0x61), + contentType: "application/octet-stream", + }), + ); + await sendMarkdownAttachment(page); + + const card = page.getByTestId("file-card").last(); + await expect(card).toHaveAttribute("aria-label", "Open release-notes.md"); + await card.click(); + + const panel = page.getByTestId("markdown-doc-panel"); + await expect(panel).toBeVisible(); + await expect(panel).toContainText("This file is too large to preview."); + await expect( + panel.getByRole("button", { name: "Download file" }), + ).toBeVisible(); +}); + test("non-markdown attachments keep the download-card behavior", async ({ page, }) => {