Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export default defineConfig({
"**/welcome-agent-modal-screenshots.spec.ts",
"**/local-archive-screenshots.spec.ts",
"**/voice-settings.spec.ts",
"**/voice-note.spec.ts",
"**/agent-readiness-screenshots.spec.ts",
"**/agent-error-state-screenshots.spec.ts",
"**/edit-agent.spec.ts",
Expand Down
4 changes: 2 additions & 2 deletions desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,6 @@ tauri-utils = "2"
# `test-util` enables tokio's paused-clock (`start_paused`) so the relay
# admission gate tests can assert exact wait durations without real sleeps.
tokio = { version = "1", features = ["test-util"] }
# The relay's media validation, so the snapshot-sharing tests can prove the
# full export → sanitize → relay-accept → import contract end to end.
# The relay's media validation, so desktop-produced snapshots and voice notes
# can prove their full client-sanitize → relay-accept contract end to end.
buzz_media_pkg = { package = "buzz-media", path = "../../crates/buzz-media" }
2 changes: 1 addition & 1 deletion desktop/src-tauri/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<key>CFBundleName</key>
<string>Buzz</string>
<key>NSMicrophoneUsageDescription</key>
<string>Buzz needs microphone access for voice huddles.</string>
<string>Buzz needs microphone access for voice huddles and voice notes.</string>
<key>NSCameraUsageDescription</key>
<string>Buzz needs camera access to record animated avatars.</string>
<key>NSLocalNetworkUsageDescription</key>
Expand Down
51 changes: 17 additions & 34 deletions desktop/src-tauri/src/commands/media.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,16 @@ use tokio_util::sync::CancellationToken;
use crate::app_state::AppState;
use crate::relay::{parse_json_response, relay_api_base_url_with_override, relay_error_message};

use super::media_filename::sanitize_filename;
use super::media_transcode::{
has_heic_extension, is_heic_file, is_video_file, transcode_and_extract_poster,
transcode_and_extract_poster_with_cancellation, transcode_heic_path_to_jpeg_bytes,
transcode_heic_path_to_jpeg_bytes_with_cancellation,
};
use super::media_upload_progress::{emit_media_upload_phase, send_upload_attempt, UploadAttempt};
use super::media_voice_note::{
is_voice_note_filename, prepare_voice_note_for_upload, voice_note_mp4_filename,
};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlobDescriptor {
Expand Down Expand Up @@ -134,24 +138,6 @@ const BLOCKED_MIME: &[&str] = &[
"application/x-apple-diskimage",
];

/// Sanitize a filename for use as a display label in the imeta `filename` field.
///
/// Strips any directory components (keeps only the final path segment), removes
/// control characters, and bounds length to 255. Mirrors the relay's filename
/// validation so a sanitized name always passes ingest. Returns a fallback when
/// the result would be empty.
pub(crate) fn sanitize_filename(name: &str) -> String {
// Keep only the final path segment — defend against `../` and absolute paths
// regardless of separator style.
let base = name.rsplit(['/', '\\']).next().unwrap_or(name).trim();
let cleaned: String = base.chars().filter(|c| !c.is_control()).take(255).collect();
if cleaned.is_empty() {
"file".to_string()
} else {
cleaned
}
}

/// Return true when a PNG/WebP payload declares animation.
///
/// Animated payloads use structural sanitizers so frame timing, looping, and
Expand Down Expand Up @@ -724,8 +710,12 @@ pub(super) async fn upload_media_bytes_inner(
let heic_by_extension = filename
.as_deref()
.is_some_and(|name| has_heic_extension(std::path::Path::new(name)));
let is_voice_note = is_voice_note_filename(filename.as_deref());

let (body, poster_bytes) = if is_video_file(&data) {
let (body, poster_bytes) = if is_voice_note {
emit_media_upload_phase(&app, progress_id.as_deref(), "processing-audio");
prepare_voice_note_for_upload(data, cancellation).await?
} else if is_video_file(&data) {
emit_media_upload_phase(&app, progress_id.as_deref(), "processing-video");
// Video: write to temp → transcode + extract poster → read results.
// All blocking I/O runs off the async runtime via spawn_blocking.
Expand Down Expand Up @@ -790,7 +780,14 @@ pub(super) async fn upload_media_bytes_inner(
}
}

descriptor.filename = filename.as_deref().map(sanitize_filename);
descriptor.filename = filename.as_deref().map(|name| {
let upload_name = if is_voice_note {
voice_note_mp4_filename(name)
} else {
name.to_string()
};
sanitize_filename(&upload_name)
});

Ok(descriptor)
}
Expand Down Expand Up @@ -981,18 +978,4 @@ mod tests {
reqwest::StatusCode::UNSUPPORTED_MEDIA_TYPE
));
}

#[test]
fn test_sanitize_filename() {
assert_eq!(sanitize_filename("report.pdf"), "report.pdf");
// Strips directory components and traversal.
assert_eq!(sanitize_filename("../../etc/passwd"), "passwd");
assert_eq!(sanitize_filename("/abs/path/notes.txt"), "notes.txt");
assert_eq!(sanitize_filename(r"C:\Users\me\doc.docx"), "doc.docx");
// Empty / separator-only falls back.
assert_eq!(sanitize_filename(""), "file");
assert_eq!(sanitize_filename("/"), "file");
// Control chars removed.
assert_eq!(sanitize_filename("a\nb\tc.txt"), "abc.txt");
}
}
65 changes: 31 additions & 34 deletions desktop/src-tauri/src/commands/media_download.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
use futures_util::StreamExt;
use sha2::{Digest, Sha256};
use tauri::State;
use tokio_util::sync::CancellationToken;

use crate::app_state::AppState;
use crate::commands::clipboard::with_clipboard;
use crate::commands::export_util::save_bytes_with_dialog;
use crate::commands::media::{detect_and_validate_mime, mint_media_get_auth, sanitize_filename};
use crate::commands::media::{detect_and_validate_mime, mint_media_get_auth};
use crate::commands::media_filename::sanitize_filename;
use crate::commands::{
personas::{
parse_snapshot_payload_from_bytes, MAX_SNAPSHOT_JSON_BYTES, MAX_SNAPSHOT_PNG_BYTES,
Expand All @@ -18,7 +20,7 @@ use crate::commands::{
use crate::relay::{classify_request_error, relay_api_base_url_with_override, relay_error_message};

/// Maximum download size: 50 MiB. Prevents OOM from oversized responses.
const MAX_DOWNLOAD_BYTES: u64 = 50 * 1024 * 1024;
pub(super) const MAX_DOWNLOAD_BYTES: u64 = 50 * 1024 * 1024;

/// Download request timeout.
const DOWNLOAD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
Expand All @@ -29,7 +31,7 @@ const DOWNLOAD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60)
/// - URL scheme is `https` (or `http` for localhost dev)
/// - URL origin matches the relay base URL
/// - URL path matches `/media/{hash}.{ext}`
fn validate_download_url(url: &str, relay_base: &str) -> Result<(), String> {
pub(super) fn validate_download_url(url: &str, relay_base: &str) -> Result<(), String> {
let parsed = url::Url::parse(url).map_err(|_| "invalid URL".to_string())?;
let base = url::Url::parse(relay_base).map_err(|_| "invalid relay base URL".to_string())?;

Expand Down Expand Up @@ -139,32 +141,6 @@ pub async fn download_file(
save_bytes_with_dialog(&app, &filename, "All Files", &extensions, &bytes).await
}

/// Fetch relay media bytes for the composer image editor.
///
/// The editor composites the image onto a canvas and needs pixel access.
/// Handing the webview raw bytes over IPC (which it wraps in a same-origin
/// `blob:` URL) keeps the canvas un-tainted without involving CORS — and
/// therefore without any media-proxy header or origin-gate changes.
///
/// Same SSRF validation, size cap, and content policy as the download
/// commands above.
///
/// Returns `tauri::ipc::Response` so the bytes cross IPC as a raw buffer
/// instead of a JSON number array (which would be ~3x the size to
/// serialize and deserialize at the 50 MiB cap).
#[tauri::command]
pub async fn fetch_media_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(&url, &state).await?;
detect_and_validate_mime(&bytes)?;
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
Expand Down Expand Up @@ -255,7 +231,7 @@ pub async fn copy_text_to_clipboard(
/// HTTP client, enforcing the download size cap. The caller is responsible for
/// validating the URL origin and for any content-type checks on the result.
async fn fetch_blob_bytes(url: &str, state: &State<'_, AppState>) -> Result<Vec<u8>, String> {
fetch_blob_bytes_with_cap(url, state, MAX_DOWNLOAD_BYTES).await
fetch_blob_bytes_with_cap(url, state, MAX_DOWNLOAD_BYTES, None).await
}

/// The command-facing error for a media-fetch response status, or `None` if
Expand All @@ -277,10 +253,11 @@ fn redirect_refusal_error(status: reqwest::StatusCode) -> Option<String> {
}

/// Core streaming fetcher with a caller-supplied byte cap.
async fn fetch_blob_bytes_with_cap(
pub(super) async fn fetch_blob_bytes_with_cap(
url: &str,
state: &State<'_, AppState>,
cap: u64,
cancellation: Option<&CancellationToken>,
) -> Result<Vec<u8>, String> {
// Fetch bytes via the no-redirect media client (goes through the VPN tunnel).
// A no-redirect client keeps the minted media auth token from being
Expand All @@ -296,7 +273,16 @@ async fn fetch_blob_bytes_with_cap(
req = req.header("authorization", auth);
}

let resp = req.send().await.map_err(|e| classify_request_error(&e))?;
let request = req.send();
let resp = if let Some(cancellation) = cancellation {
tokio::select! {
_ = cancellation.cancelled() => return Err("media fetch cancelled".to_string()),
result = request => result,
}
} else {
request.await
}
.map_err(|e| classify_request_error(&e))?;

if let Some(err) = redirect_refusal_error(resp.status()) {
return Err(err);
Expand All @@ -321,7 +307,18 @@ async fn fetch_blob_bytes_with_cap(
// even when Content-Length is missing or dishonest.
let mut bytes = Vec::new();
let mut stream = resp.bytes_stream();
while let Some(chunk) = stream.next().await {
loop {
let next = if let Some(cancellation) = cancellation {
tokio::select! {
_ = cancellation.cancelled() => return Err("media fetch cancelled".to_string()),
next = stream.next() => next,
}
} else {
stream.next().await
};
let Some(chunk) = next else {
break;
};
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)));
Expand Down Expand Up @@ -482,7 +479,7 @@ pub async fn fetch_snapshot_bytes(
ensure_declared_size_within_cap(expected_size, kind)?;

// ── Bounded fetch ─────────────────────────────────────────────────────
let bytes = fetch_blob_bytes_with_cap(&url, &state, cap).await?;
let bytes = fetch_blob_bytes_with_cap(&url, &state, cap, None).await?;

// ── Post-fetch validation ─────────────────────────────────────────────
// 1. Byte length must equal the declared imeta size.
Expand Down
125 changes: 125 additions & 0 deletions desktop/src-tauri/src/commands/media_fetch_cancellation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
use std::{
collections::HashMap,
sync::{LazyLock, Mutex},
};

use tokio_util::sync::CancellationToken;

use crate::app_state::AppState;
use crate::commands::media::detect_and_validate_mime;
use crate::commands::media_download::{
fetch_blob_bytes_with_cap, validate_download_url, MAX_DOWNLOAD_BYTES,
};
use crate::relay::relay_api_base_url_with_override;

#[derive(Default)]
struct MediaFetchCancellations {
tokens: HashMap<String, CancellationToken>,
}

impl MediaFetchCancellations {
fn begin(&mut self, request_id: &str) -> CancellationToken {
if let Some(cancel) = self.tokens.get(request_id).cloned() {
return cancel;
}
let cancel = CancellationToken::new();
self.tokens.insert(request_id.to_string(), cancel.clone());
cancel
}

fn cancel(&mut self, request_id: &str) {
self.tokens
.entry(request_id.to_string())
.or_default()
.cancel();
}

fn finish(&mut self, request_id: &str) {
self.tokens.remove(request_id);
}
}

static MEDIA_FETCH_CANCELLATIONS: LazyLock<Mutex<MediaFetchCancellations>> =
LazyLock::new(|| Mutex::new(MediaFetchCancellations::default()));

pub(super) fn begin_media_fetch(request_id: Option<&str>) -> Option<CancellationToken> {
let request_id = request_id?;
MEDIA_FETCH_CANCELLATIONS
.lock()
.ok()
.map(|mut fetches| fetches.begin(request_id))
}

pub(super) fn finish_media_fetch(request_id: Option<&str>) {
let Some(request_id) = request_id else {
return;
};
if let Ok(mut fetches) = MEDIA_FETCH_CANCELLATIONS.lock() {
fetches.finish(request_id);
}
}

/// Cancel a renderer-owned relay media fetch, including an in-flight body.
#[tauri::command]
pub fn cancel_media_fetch(request_id: String) {
if let Ok(mut fetches) = MEDIA_FETCH_CANCELLATIONS.lock() {
fetches.cancel(&request_id);
}
}

/// Release renderer ownership after the fetch promise settles.
#[tauri::command]
pub fn release_media_fetch(request_id: String) {
finish_media_fetch(Some(&request_id));
}

/// Fetch relay media bytes with renderer-owned cancellation.
#[tauri::command]
pub async fn fetch_media_bytes(
url: String,
request_id: Option<String>,
state: tauri::State<'_, AppState>,
) -> Result<tauri::ipc::Response, String> {
let cancellation = begin_media_fetch(request_id.as_deref());
let result = async {
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_DOWNLOAD_BYTES, cancellation.as_ref())
.await?;
detect_and_validate_mime(&bytes)?;
Ok(tauri::ipc::Response::new(bytes))
}
.await;
finish_media_fetch(request_id.as_deref());
result
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn cancellation_before_begin_is_retained() {
let mut fetches = MediaFetchCancellations::default();
fetches.cancel("cancel-before-begin");

let cancellation = fetches.begin("cancel-before-begin");

assert!(cancellation.is_cancelled());
fetches.finish("cancel-before-begin");
assert!(fetches.tokens.is_empty());
}

#[test]
fn cancellation_reaches_active_owner() {
let mut fetches = MediaFetchCancellations::default();
let cancellation = fetches.begin("active-fetch");

fetches.cancel("active-fetch");

assert!(cancellation.is_cancelled());
fetches.finish("active-fetch");
assert!(fetches.tokens.is_empty());
}
}
Loading
Loading