fix(desktop): enforce the markdown viewer's 2 MiB cap natively during fetch

Routing the viewer through the generic fetch_media_bytes command meant an
attachment with a forged or absent imeta size could occupy up to the 50 MiB
download cap in memory and IPC before the frontend decoder rejected it. Add
a markdown-specific fetch_markdown_doc_bytes command whose 2 MiB cap is
owned by Rust: an oversized Content-Length is refused before the body is
read, and the streamed byte count aborts mid-transfer when the header is
missing or dishonest. The untrusted imeta pre-gate stays UX-only.

The cap checks are extracted into pure helpers (declared_length_refusal_error,
append_chunk_within_cap) so both enforcement points are unit-tested without
a Tauri State; media_download.rs tests move to a sibling file per the repo's
_tests.rs convention to stay under the per-file line cap. The panel maps the
native refusal onto the existing too-large download fallback, and new e2e
coverage proves the forged-size fallback plus the reload/back-forward
contract. The viewer command skips MIME sniffing deliberately: bytes are
strictly UTF-8-decoded and rendered escaped, and a legitimate .md may begin
with bytes that sniff as a blocked type.

Signed-off-by: Riley Crane <rileycrane@squareup.com>
Co-authored-by: Fizz <d4ec90109b43092b0ec94cf136850915a3732a29ab3089a1dec43d2ed43dd6eb@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Riley Crane
2026-08-24 08:27:25 -07:00
parent 520dff6bfc
commit 34c9f56c0b
9 changed files with 715 additions and 485 deletions

View File

@@ -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<tauri::ipc::Response, String> {
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<u64>, cap: u64) -> Option<String> {
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<u8>, 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;

View File

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

View File

@@ -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,

View File

@@ -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<MarkdownDocDecodeResult>({
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,
});

View File

@@ -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<Uint8Array<ArrayBuffer>> {
const bytes = await invokeTauri<ArrayBuffer>("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<string> {
// E2E installs Tauri's mocked IPC surface in a browser page, where the SDK's

View File

@@ -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,

View File

@@ -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;

View File

@@ -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

View File

@@ -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,
}) => {