diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 3f7189deea..d75402d950 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -58,6 +58,7 @@ objc2-foundation = { version = "0.3.2", default-features = false, features = ["N objc2-user-notifications = { version = "0.3.2", default-features = false, features = ["block2", "UNNotification", "UNNotificationContent", "UNNotificationRequest", "UNNotificationResponse", "UNNotificationSettings", "UNNotificationTrigger", "UNUserNotificationCenter"] } keyring = { version = "3.6.3", default-features = false, features = ["apple-native", "vendored"], optional = true } security-framework = { version = "3.7.0", features = ["OSX_10_15"] } +buzz_media_pkg = { package = "buzz-media", path = "../../crates/buzz-media" } window-vibrancy = "0.6" user-idle = { version = "0.6", default-features = false } plist = "1" @@ -153,6 +154,3 @@ 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. -buzz_media_pkg = { package = "buzz-media", path = "../../crates/buzz-media" } diff --git a/desktop/src-tauri/src/commands/media_transcode.rs b/desktop/src-tauri/src/commands/media_transcode.rs index 7e878ad5dd..69b3f1b078 100644 --- a/desktop/src-tauri/src/commands/media_transcode.rs +++ b/desktop/src-tauri/src/commands/media_transcode.rs @@ -486,6 +486,35 @@ fn prepare_avconvert_source( Ok((staged, true)) } +/// Reject output that the relay cannot ingest before spending bandwidth. +/// +/// `avconvert` can preserve metadata tracks from some source containers, while +/// the relay deliberately accepts exactly one H.264 video track and at most +/// one AAC audio track. Validate locally so those inputs fail with a useful +/// conversion error instead of a later generic upload rejection. +#[cfg(target_os = "macos")] +fn validate_avconvert_output(path: &std::path::Path) -> Result<(), String> { + let 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: 500 * 1024 * 1024, + max_file_bytes: 100 * 1024 * 1024, + public_base_url: String::new(), + upload_records_enabled: false, + upload_ip_header: None, + upload_port_header: None, + }; + buzz_media_pkg::validation::validate_video_file(path, &config) + .map(|_| ()) + .map_err(|error| format!("Video conversion produced an unsupported file: {error}")) +} + /// Transcode a video with macOS's built-in AVFoundation converter. /// /// Release builds do not bundle ffmpeg, and a stock macOS installation does @@ -536,6 +565,7 @@ fn transcode_with_avconvert( return Err("upload cancelled".to_string()); } + validate_avconvert_output(&output)?; std::fs::read(&output).map_err(|error| format!("failed to read converted video: {error}")) })(); @@ -626,43 +656,22 @@ mod tests { #[cfg(target_os = "macos")] #[test] - fn test_avconvert_normalizes_configured_quicktime_fixture() { - let Some(source) = std::env::var_os("BUZZ_TEST_QUICKTIME_PATH") else { - eprintln!("skipping avconvert round-trip: BUZZ_TEST_QUICKTIME_PATH is unset"); - return; - }; - let staged_source = std::env::temp_dir().join(format!( - "buzz-avconvert-configured-test-{}", - uuid::Uuid::new_v4() - )); - std::fs::copy(&source, &staged_source).expect("stage extensionless QuickTime fixture"); + fn test_avconvert_normalizes_extensionless_quicktime_fixture() { + let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/quicktime-screen-recording.mov"); + let staged_source = + std::env::temp_dir().join(format!("buzz-avconvert-fixture-{}", uuid::Uuid::new_v4())); + std::fs::copy(fixture, &staged_source).expect("stage extensionless QuickTime fixture"); let video = transcode_with_avconvert(&staged_source, None).expect("convert QuickTime fixture"); let _ = std::fs::remove_file(staged_source); let converted = std::env::temp_dir().join(format!( - "buzz-avconvert-configured-output-{}.mp4", + "buzz-avconvert-fixture-output-{}.mp4", uuid::Uuid::new_v4() )); std::fs::write(&converted, &video).expect("write converted fixture"); - 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: 500 * 1024 * 1024, - max_file_bytes: 100 * 1024 * 1024, - public_base_url: String::new(), - upload_records_enabled: false, - upload_ip_header: None, - upload_port_header: None, - }; - buzz_media_pkg::validation::validate_video_file(&converted, &relay_config) - .expect("relay rejected avconvert output"); + validate_avconvert_output(&converted).expect("relay rejected avconvert output"); let _ = std::fs::remove_file(converted); } diff --git a/desktop/src-tauri/tests/fixtures/generate-quicktime-screen-recording.swift b/desktop/src-tauri/tests/fixtures/generate-quicktime-screen-recording.swift new file mode 100644 index 0000000000..e2a5eebe70 --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/generate-quicktime-screen-recording.swift @@ -0,0 +1,54 @@ +// Generates the tiny H.264 QuickTime fixture beside this file. +// Run: swift tests/fixtures/generate-quicktime-screen-recording.swift \ +// tests/fixtures/quicktime-screen-recording.mov +import AVFoundation +import CoreVideo +import Foundation + +let output = URL(fileURLWithPath: CommandLine.arguments[1]) +try? FileManager.default.removeItem(at: output) +let writer = try AVAssetWriter(outputURL: output, fileType: .mov) +let input = AVAssetWriterInput( + mediaType: .video, + outputSettings: [ + AVVideoCodecKey: AVVideoCodecType.h264, + AVVideoWidthKey: 64, + AVVideoHeightKey: 64, + ] +) +let adaptor = AVAssetWriterInputPixelBufferAdaptor( + assetWriterInput: input, + sourcePixelBufferAttributes: [ + kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA, + kCVPixelBufferWidthKey as String: 64, + kCVPixelBufferHeightKey as String: 64, + ] +) +writer.add(input) +guard writer.startWriting() else { fatalError("start failed: \(writer.error!)") } +writer.startSession(atSourceTime: .zero) +input.requestMediaDataWhenReady(on: DispatchQueue(label: "fixture")) { + for frame in 0..<3 { + while !input.isReadyForMoreMediaData { usleep(1_000) } + var buffer: CVPixelBuffer? + CVPixelBufferPoolCreatePixelBuffer(nil, adaptor.pixelBufferPool!, &buffer) + guard let pixel = buffer else { fatalError("pixel buffer allocation failed") } + CVPixelBufferLockBaseAddress(pixel, []) + memset( + CVPixelBufferGetBaseAddress(pixel), + Int32(frame * 32), + CVPixelBufferGetDataSize(pixel) + ) + CVPixelBufferUnlockBaseAddress(pixel, []) + guard adaptor.append( + pixel, + withPresentationTime: CMTime(value: CMTimeValue(frame), timescale: 30) + ) else { fatalError("append failed: \(writer.error!)") } + } + input.markAsFinished() + writer.finishWriting { + guard writer.status == .completed else { fatalError("finish failed: \(writer.error!)") } + exit(0) + } +} +dispatchMain() diff --git a/desktop/src-tauri/tests/fixtures/quicktime-screen-recording.mov b/desktop/src-tauri/tests/fixtures/quicktime-screen-recording.mov new file mode 100644 index 0000000000..015fdb5b3c Binary files /dev/null and b/desktop/src-tauri/tests/fixtures/quicktime-screen-recording.mov differ