use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use tauri::Manager; use crate::commands::security::ensure_main_window; use lumotia_audio::{DeviceInfo, MicrophoneCapture}; /// Enumerate every input device available to cpal, with metadata for the /// Settings device-picker UI. Includes a flag for likely PulseAudio / /// PipeWire monitor sources so the UI can warn the user. #[tauri::command] pub async fn list_audio_devices(window: tauri::WebviewWindow) -> Result, String> { ensure_main_window(&window)?; tokio::task::spawn_blocking(MicrophoneCapture::list_devices) .await .map_err(|e| format!("join error: {e}"))? .map_err(|e| e.to_string()) } /// Resolve the destination path for a new live-capture recording, /// ensuring the parent directory exists. Used by /// `start_live_transcription_session` to hand the path to the progressive /// WAV writer before any samples arrive (brief item #19). /// /// Trust boundary (Trust-2): `output_folder` arrives from the webview /// as a free-form string and is otherwise joined verbatim into a /// PathBuf that `create_dir_all` + WavWriter then operate on. Without /// validation the webview can write recordings anywhere the Lumotia /// process can write — `/etc`, `/var`, somebody else's home dir. /// /// The fix: /// 1. None or empty → fall back to the default `recordings` subtree /// under the app-local data dir. Always safe. /// 2. Otherwise → canonicalise the supplied path (resolves `..` and /// symlinks) and ensure it sits inside the trusted base. Reject if /// it escapes. /// /// A user who wants recordings somewhere else must configure it via /// Settings (a different trust boundary owned by the app's persisted /// preferences); the live-session command surface is constrained. pub fn resolve_recording_path( app: &tauri::AppHandle, output_folder: Option<&str>, ) -> Result { let default_base = app .path() .app_local_data_dir() .map_err(|e: tauri::Error| e.to_string())? .join("recordings"); let recordings_dir = match output_folder.map(str::trim).filter(|s| !s.is_empty()) { Some(folder) => validate_output_folder(folder, &default_base)?, None => default_base, }; std::fs::create_dir_all(&recordings_dir) .map_err(|e| format!("Failed to create recordings dir: {e}"))?; Ok(recordings_dir.join(recording_filename())) } /// Validate that a webview-supplied `output_folder` does not escape the /// trusted recordings base. Canonicalises both sides so `..`-walks and /// symlinks resolve before the containment check. /// /// Allows the supplied path to be either the base itself or a /// descendant of it; everything else is rejected. The base must /// already exist on disk (we `create_dir_all` it on the default /// branch); we ensure it here too so canonicalisation can succeed even /// on a clean install where neither side has been created yet. fn validate_output_folder(folder: &str, default_base: &Path) -> Result { // Make sure the trusted base exists before canonicalisation — // `canonicalize` errors on missing paths and we want a clean // failure mode on first launch. std::fs::create_dir_all(default_base) .map_err(|e| format!("Failed to ensure recordings base dir: {e}"))?; // Create the requested path too if it doesn't exist yet, so // canonicalisation can resolve it. If the user is pointing at a // path outside the trusted base the containment check rejects // them right after — a stray empty directory is the only side // effect, which is acceptable for the trust gain. let requested = PathBuf::from(folder); if !requested.exists() { std::fs::create_dir_all(&requested).map_err(|e| { format!( "Failed to create requested output folder '{}': {e}", requested.display() ) })?; } let canonical_base = default_base.canonicalize().map_err(|e| { format!( "Failed to canonicalise recordings base '{}': {e}", default_base.display() ) })?; let canonical_requested = requested.canonicalize().map_err(|e| { format!( "Failed to canonicalise output folder '{}': {e}", requested.display() ) })?; if !canonical_requested.starts_with(&canonical_base) { return Err(format!( "outputFolder '{}' is outside the allowed recordings base '{}'", canonical_requested.display(), canonical_base.display() )); } Ok(canonical_requested) } /// Deterministic recording filename generator. Combines three fields /// for absolute uniqueness across rapid calls: /// /// - wall-clock seconds since the epoch — human-readable and /// sortable; /// - the sub-second nanosecond component — defeats same-second /// collisions; /// - a process-lifetime atomic counter — defeats even same-nanosecond /// collisions, which `SystemTime::now()` alone cannot guarantee /// (two calls in the same clock tick can return identical nanos). /// /// Format: `lumotia---.wav`, e.g. /// `lumotia-1776828000-123456789-0000.wav`. fn recording_filename() -> String { let duration = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default(); let secs = duration.as_secs(); let nanos = duration.subsec_nanos(); let counter = RECORDING_COUNTER.fetch_add(1, Ordering::SeqCst); format!("lumotia-{secs}-{nanos:09}-{counter:04}.wav") } /// Process-lifetime monotonic counter for `recording_filename`. Starts /// at 0 on each Lumotia launch; wall-clock secs/nanos still advance across /// restarts, so cross-launch collisions are already impossible — the /// counter is the last-mile guarantee against within-launch same-tick /// collisions. static RECORDING_COUNTER: AtomicU64 = AtomicU64::new(0); #[cfg(test)] mod tests { use super::{recording_filename, validate_output_folder}; use std::path::PathBuf; #[test] fn recording_filenames_are_unique_across_rapid_calls() { // Regression for the 2026-04-22 review AND the review-of- // review MINOR: SystemTime::now() alone cannot guarantee // uniqueness under tight loops on every OS clock resolution, // so the filename now includes a process-lifetime atomic // counter. With the counter, uniqueness is absolute across // any number of in-process calls. let mut names = std::collections::HashSet::new(); for _ in 0..1024 { names.insert(recording_filename()); } assert_eq!( names.len(), 1024, "every filename must be unique (counter-backed guarantee)" ); } fn unique_tmp(label: &str) -> PathBuf { let pid = std::process::id(); let nanos = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_nanos()) .unwrap_or(0); std::env::temp_dir().join(format!("lumotia-audio-test-{label}-{pid}-{nanos}")) } #[test] fn validate_output_folder_accepts_base_itself() { // Trust-2 happy path: the recordings base is always allowed. let root = unique_tmp("base-itself"); let base = root.join("recordings"); std::fs::create_dir_all(&base).unwrap(); let canonical = validate_output_folder(base.to_str().unwrap(), &base) .expect("base path must validate as itself"); assert_eq!(canonical, base.canonicalize().unwrap()); std::fs::remove_dir_all(&root).ok(); } #[test] fn validate_output_folder_accepts_descendant() { // A user-configured subdirectory of the trusted base is fine. let root = unique_tmp("descendant"); let base = root.join("recordings"); let nested = base.join("2026-05"); std::fs::create_dir_all(&nested).unwrap(); let canonical = validate_output_folder(nested.to_str().unwrap(), &base) .expect("descendant must validate"); assert!(canonical.starts_with(base.canonicalize().unwrap())); std::fs::remove_dir_all(&root).ok(); } #[test] fn validate_output_folder_rejects_etc() { // Trust-2 attack shape: webview supplies `/etc`. Must be // rejected even though /etc exists (and `create_dir_all` on it // would no-op). let root = unique_tmp("etc-reject"); let base = root.join("recordings"); std::fs::create_dir_all(&base).unwrap(); let err = validate_output_folder("/etc", &base) .expect_err("`/etc` must NEVER be accepted as a recordings dir"); assert!( err.contains("outside the allowed recordings base"), "rejection message must name the trust boundary: {err}" ); std::fs::remove_dir_all(&root).ok(); } #[test] fn validate_output_folder_rejects_parent_escape() { // `..`-walk attack shape: a path that resolves OUT of the // trusted base via parent traversal. Canonicalisation must // resolve the `..` before the containment check runs. let root = unique_tmp("parent-escape"); let base = root.join("recordings"); let escape_target = root.join("escape"); std::fs::create_dir_all(&base).unwrap(); std::fs::create_dir_all(&escape_target).unwrap(); // recordings/../escape — exists on disk, but resolves to root/escape. let attack = base.join("..").join("escape"); let err = validate_output_folder(attack.to_str().unwrap(), &base) .expect_err("..-walk out of base must be rejected"); assert!( err.contains("outside the allowed recordings base"), "rejection message must name the trust boundary: {err}" ); std::fs::remove_dir_all(&root).ok(); } #[test] fn validate_output_folder_rejects_sibling_dir() { // Plain sibling-of-base: equal path depth, prefix-matching the // base's parent but not the base itself. `starts_with` on // canonical paths must reject this (a bare-string prefix match // would have let `recordings-backdoor` through). let root = unique_tmp("sibling"); let base = root.join("recordings"); let sibling = root.join("recordings-backdoor"); std::fs::create_dir_all(&base).unwrap(); std::fs::create_dir_all(&sibling).unwrap(); let err = validate_output_folder(sibling.to_str().unwrap(), &base) .expect_err("sibling of base must be rejected even with prefix overlap"); assert!(err.contains("outside the allowed recordings base")); std::fs::remove_dir_all(&root).ok(); } #[cfg(unix)] #[test] fn validate_output_folder_rejects_symlink_pointing_out() { // Symlink-escape attack shape: a path inside the base which // resolves through a symlink to a directory outside the base. // Canonicalisation must follow the link before the containment // check. let root = unique_tmp("symlink-escape"); let base = root.join("recordings"); let outside = root.join("outside"); std::fs::create_dir_all(&base).unwrap(); std::fs::create_dir_all(&outside).unwrap(); let link = base.join("trapdoor"); std::os::unix::fs::symlink(&outside, &link).unwrap(); let err = validate_output_folder(link.to_str().unwrap(), &base) .expect_err("symlink to outside path must be rejected"); assert!(err.contains("outside the allowed recordings base")); std::fs::remove_dir_all(&root).ok(); } #[test] fn recording_filename_has_expected_shape() { let name = recording_filename(); assert!(name.starts_with("lumotia-")); assert!(name.ends_with(".wav")); // Shape: lumotia--<9 digits>-<>=4 digits>.wav let rest = name .strip_prefix("lumotia-") .and_then(|s| s.strip_suffix(".wav")) .expect("shape prefix/suffix"); let parts: Vec<&str> = rest.split('-').collect(); assert_eq!( parts.len(), 3, "expected three '-' separated parts, got {parts:?}" ); assert!( parts[0].chars().all(|c| c.is_ascii_digit()), "secs is digits" ); assert_eq!( parts[1].len(), 9, "nanos component is zero-padded to 9 digits" ); assert!(parts[1].chars().all(|c| c.is_ascii_digit())); assert!( parts[2].len() >= 4, "counter component is zero-padded to >=4 digits" ); assert!(parts[2].chars().all(|c| c.is_ascii_digit())); } }