Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 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
Loading
Loading