Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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 @@ -57,6 +57,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");
}
}
3 changes: 2 additions & 1 deletion desktop/src-tauri/src/commands/media_download.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ use tauri::State;
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 Down
29 changes: 29 additions & 0 deletions desktop/src-tauri/src/commands/media_filename.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/// Sanitize a filename for use as a display label in the imeta `filename` field.
///
/// Strips directory components, removes control characters, and bounds length
/// to 255 so the resulting name always passes relay ingest validation.
pub(crate) fn sanitize_filename(name: &str) -> String {
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
}
}

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

#[test]
fn strips_paths_controls_and_empty_names() {
assert_eq!(sanitize_filename("report.pdf"), "report.pdf");
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");
assert_eq!(sanitize_filename(""), "file");
assert_eq!(sanitize_filename("/"), "file");
assert_eq!(sanitize_filename("a\nb\tc.txt"), "abc.txt");
}
}
146 changes: 146 additions & 0 deletions desktop/src-tauri/src/commands/media_transcode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,91 @@ fn transcode_to_mp4_with_cancellation(
Ok(output)
}

/// Package a voice-note audio file in the relay's existing canonical video
/// envelope. The tiny H.264 track satisfies the deployed video validator while
/// the AAC track remains the only user-facing content in the voice-note player.
///
/// Returns the path to a temp MP4. Caller must clean up.
pub(super) fn transcode_voice_note_to_mp4_with_cancellation(
source: &std::path::Path,
cancellation: Option<&CancellationToken>,
) -> Result<std::path::PathBuf, String> {
let ffmpeg = find_ffmpeg()?;
let output = std::env::temp_dir().join(format!("buzz-voice-note-{}.mp4", uuid::Uuid::new_v4()));

let result = run_ffmpeg_with_cancellation(
ffmpeg_command(&ffmpeg)
.args([
"-y",
"-nostdin",
"-loglevel",
"error",
"-f",
"lavfi",
"-i",
"color=c=black:s=16x16:r=1",
])
.arg("-i")
.arg(source)
.args([
"-map",
"0:v:0",
"-map",
"1:a:0",
"-shortest",
"-map_metadata",
"-1",
"-map_chapters",
"-1",
"-sn",
"-dn",
"-fflags",
"+bitexact",
"-flags:v",
"+bitexact",
"-flags:a",
"+bitexact",
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-tune",
"stillimage",
"-pix_fmt",
"yuv420p",
"-c:a",
"aac",
"-b:a",
"96k",
"-movflags",
"+faststart",
"-metadata",
"encoder=",
])
.arg(&output)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped()),
FFMPEG_TIMEOUT,
cancellation,
)
.inspect_err(|_| {
let _ = std::fs::remove_file(&output);
})?;

if !result.status.success() {
let _ = std::fs::remove_file(&output);
let stderr = String::from_utf8_lossy(&result.stderr);
let detail = stderr
.lines()
.rev()
.find(|line| !line.is_empty() && !line.starts_with(" "))
.unwrap_or("unknown error");
return Err(format!("Voice note conversion failed: {detail}"));
}

Ok(output)
}

/// Transcode a HEIC/HEIF still image to JPEG via ffmpeg.
///
/// The Tauri webview / Chromium cannot decode HEIC, so iPhone photos uploaded
Expand Down Expand Up @@ -655,6 +740,67 @@ mod tests {
}
}

#[test]
fn test_voice_note_envelope_passes_relay_video_validation() {
if find_ffmpeg().is_err() {
eprintln!("skipping voice-note round-trip: ffmpeg not found");
return;
}

let source =
std::env::temp_dir().join(format!("buzz-voice-test-{}.wav", uuid::Uuid::new_v4()));
let sample_rate = 24_000u32;
let sample_bytes = sample_rate as usize * 2;
let mut wav = Vec::with_capacity(44 + sample_bytes);
wav.extend_from_slice(b"RIFF");
wav.extend_from_slice(&(36 + sample_bytes as u32).to_le_bytes());
wav.extend_from_slice(b"WAVEfmt ");
wav.extend_from_slice(&16u32.to_le_bytes());
wav.extend_from_slice(&1u16.to_le_bytes());
wav.extend_from_slice(&1u16.to_le_bytes());
wav.extend_from_slice(&sample_rate.to_le_bytes());
wav.extend_from_slice(&(sample_rate * 2).to_le_bytes());
wav.extend_from_slice(&2u16.to_le_bytes());
wav.extend_from_slice(&16u16.to_le_bytes());
wav.extend_from_slice(b"data");
wav.extend_from_slice(&(sample_bytes as u32).to_le_bytes());
wav.resize(44 + sample_bytes, 0);
std::fs::write(&source, wav).expect("write voice-note fixture");

let output = match transcode_voice_note_to_mp4_with_cancellation(&source, None) {
Ok(output) => output,
Err(error) => {
eprintln!("skipping voice-note round-trip: {error}");
let _ = std::fs::remove_file(&source);
return;
}
};
let relay_config = buzz_media_pkg::MediaConfig {
s3_endpoint: String::new(),
s3_access_key: String::new(),
s3_secret_key: String::new(),
s3_bucket: String::new(),
s3_region: "us-east-1".to_string(),
s3_addressing_style: buzz_media_pkg::S3AddressingStyle::Path,
max_image_bytes: 50 * 1024 * 1024,
max_gif_bytes: 10 * 1024 * 1024,
max_video_bytes: 524_288_000,
max_file_bytes: 104_857_600,
public_base_url: String::new(),
upload_records_enabled: false,
upload_ip_header: None,
upload_port_header: None,
};
let metadata = buzz_media_pkg::validation::validate_video_file(&output, &relay_config)
.expect("relay rejected the canonical voice-note envelope");
let _ = std::fs::remove_file(&source);
let _ = std::fs::remove_file(&output);

assert!(metadata.has_audio);
assert_eq!((metadata.width, metadata.height), (16, 16));
assert!(metadata.duration_secs > 0.0);
}

/// Round-trip transcode test, gated on ffmpeg being present so CI without
/// ffmpeg doesn't fail. Generates a HEIC via ffmpeg, then transcodes it
/// back to JPEG and asserts the output is a valid JPEG.
Expand Down
64 changes: 64 additions & 0 deletions desktop/src-tauri/src/commands/media_voice_note.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
use tokio_util::sync::CancellationToken;

use super::media_transcode::transcode_voice_note_to_mp4_with_cancellation;

pub(super) fn is_voice_note_filename(filename: Option<&str>) -> bool {
filename.is_some_and(|name| {
let lower = name.to_ascii_lowercase();
lower.starts_with("voice-note-") && lower.ends_with(".wav")
})
}

pub(super) fn voice_note_mp4_filename(filename: &str) -> String {
filename
.strip_suffix(".wav")
.or_else(|| filename.strip_suffix(".WAV"))
.map_or_else(|| format!("{filename}.mp4"), |stem| format!("{stem}.mp4"))
}

pub(super) async fn prepare_voice_note_for_upload(
data: Vec<u8>,
cancellation: Option<&CancellationToken>,
) -> Result<(Vec<u8>, Option<Vec<u8>>), String> {
let cancellation = cancellation.cloned();
tokio::task::spawn_blocking(move || {
let detected = infer::get(&data)
.ok_or_else(|| "Voice note has an unrecognized audio format.".to_string())?;
if !detected.mime_type().starts_with("audio/") {
return Err("Voice note upload did not contain audio.".to_string());
}

let tmp_input =
std::env::temp_dir().join(format!("buzz-voice-input-{}", uuid::Uuid::new_v4()));
let result = (|| {
std::fs::write(&tmp_input, &data)
.map_err(|error| format!("failed to prepare voice note: {error}"))?;
let output =
transcode_voice_note_to_mp4_with_cancellation(&tmp_input, cancellation.as_ref())?;
let bytes = std::fs::read(&output)
.map_err(|error| format!("failed to read prepared voice note: {error}"));
let _ = std::fs::remove_file(&output);
bytes.map(|bytes| (bytes, None))
})();
let _ = std::fs::remove_file(&tmp_input);
result
})
.await
.map_err(|error| format!("voice note task failed: {error}"))?
}

#[cfg(test)]
mod tests {
use super::{is_voice_note_filename, voice_note_mp4_filename};

#[test]
fn voice_note_filenames_are_scoped_and_rewritten_for_video_upload() {
assert!(is_voice_note_filename(Some("voice-note-123.wav")));
assert!(!is_voice_note_filename(Some("meeting.wav")));
assert!(!is_voice_note_filename(Some("voice-note-123.mp4")));
assert_eq!(
voice_note_mp4_filename("voice-note-123.wav"),
"voice-note-123.mp4"
);
}
}
2 changes: 2 additions & 0 deletions desktop/src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,13 @@ mod managed_agent_definition;
pub(crate) mod media;
mod media_animated;
mod media_download;
mod media_filename;
mod media_gif;
mod media_raw;
mod media_snapshot_png;
mod media_transcode;
mod media_upload_progress;
mod media_voice_note;
#[cfg(feature = "mesh-llm")]
pub(crate) mod mesh_llm;
#[cfg(feature = "mesh-llm")]
Expand Down
Loading
Loading