// Phase 9. Thin filesystem write command for the save-dialog path. // // Path safety (Trust-1, code-atomiser-fix 2026-05-12): an OS save dialog // is the intended entry point, but the Tauri IPC surface does not enforce // that — a compromised webview can invoke `write_text_file_cmd` directly // with any path the running process can write to. We therefore: // // 1. require the call to originate from the main window; // 2. canonicalise the supplied path (resolves symlinks, rejects bad // parents); // 3. assert the canonical path is inside a known-good base directory // (app data, downloads, documents, or desktop). Anything outside is // rejected with a clear error. // // The base-dir allowlist mirrors where the OS save dialog is expected to // land; it is deliberately broader than `recordings_dir()` because users // routinely save transcripts to Documents or Desktop. Adding new bases is // a one-line change in `allowed_export_bases`. use std::path::{Path, PathBuf}; use tauri::Manager; use crate::commands::security::ensure_main_window; /// Write UTF-8 text to a user-chosen path. The path must (a) canonicalise /// to a real, accessible path, and (b) live inside one of the allowlisted /// base directories (app data dir, downloads, documents, desktop). Errors /// propagate the OS message verbatim wrapped with the path so the toast /// on the frontend is actionable. #[tauri::command] pub async fn write_text_file_cmd( app: tauri::AppHandle, window: tauri::WebviewWindow, path: String, contents: String, ) -> Result<(), String> { ensure_main_window(&window)?; let bases = allowed_export_bases(&app); let resolved = resolve_export_path(Path::new(&path), &bases)?; tokio::fs::write(&resolved, contents) .await .map_err(|e| format!("Failed to write {}: {e}", resolved.display())) } /// Collect the set of base directories under which `write_text_file_cmd` /// is allowed to land. Returns the canonicalised form of each so the /// later containment check can be a pure prefix comparison. fn allowed_export_bases(app: &tauri::AppHandle) -> Vec { let path = app.path(); let mut bases: Vec = Vec::new(); for candidate in [ path.app_data_dir().ok(), path.app_local_data_dir().ok(), path.download_dir().ok(), path.document_dir().ok(), path.desktop_dir().ok(), ] .into_iter() .flatten() { // Allow the base directory even if it does not yet exist on disk — // canonicalise the parent walk-up when possible. if let Ok(canon) = std::fs::canonicalize(&candidate) { bases.push(canon); } else { bases.push(candidate); } } bases } /// Resolve the requested write target. The canonicalised path must sit /// inside one of `bases`. /// /// Two-mode canonicalisation: /// /// - **If the target already exists**, canonicalise the WHOLE path. This /// follows any symlink at the target itself, so a symlink that sits /// inside an allowlisted base but points outside it gets rejected /// here. `tokio::fs::write` follows symlinks on open(2), so without /// this branch a symlink at `~/Downloads/notes.md -> ~/.bashrc` would /// pass the containment check (the path string is inside Downloads) /// and the subsequent write would silently land in `~/.bashrc`. /// Trust-1 audit residual closed in Phase B.5 (2026-05-14); parallels /// `validate_output_folder`'s full-path-canonicalize in /// `commands/audio.rs` (Trust-2). /// /// - **If the target does not yet exist** (the typical save-dialog /// case), canonicalise the parent and re-join the filename. The /// parent must exist for `tokio::fs::write` to succeed anyway, so /// checking it is no extra cost. pub(crate) fn resolve_export_path(path: &Path, bases: &[PathBuf]) -> Result { let canon_path = match std::fs::canonicalize(path) { Ok(canon) => canon, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { let parent = path.parent().ok_or_else(|| { format!( "Refusing to write {}: path has no parent directory.", path.display() ) })?; let file_name = path.file_name().ok_or_else(|| { format!( "Refusing to write {}: path has no filename.", path.display() ) })?; let canon_parent = std::fs::canonicalize(parent).map_err(|e| { format!( "Refusing to write {}: cannot resolve parent dir ({e}).", path.display() ) })?; canon_parent.join(file_name) } Err(e) => { return Err(format!( "Refusing to write {}: cannot resolve path ({e}).", path.display() )); } }; if !is_inside_any_base(&canon_path, bases) { return Err(format!( "Refusing to write {}: path is outside the allowed export directories \ (app data, Downloads, Documents, Desktop). \ Pick a destination inside one of those folders.", canon_path.display() )); } Ok(canon_path) } /// Pure containment check: does `candidate` sit at or under any path in /// `bases`? Pure on purpose so the unit tests below can exercise the /// rule without a Tauri app handle. pub(crate) fn is_inside_any_base(candidate: &Path, bases: &[PathBuf]) -> bool { bases.iter().any(|base| candidate.starts_with(base)) } #[cfg(test)] mod tests { use super::*; use std::path::PathBuf; fn tempdir_canon(name: &str) -> (tempfile::TempDir, PathBuf) { let dir = tempfile::tempdir().expect("tempdir"); let canon = std::fs::canonicalize(dir.path()).expect("canonicalise tempdir"); let _ = name; (dir, canon) } #[test] fn rejects_path_outside_allowlist() { let (_keep, base) = tempdir_canon("base"); let bases = vec![base]; // /etc/passwd is outside the temp base. let result = resolve_export_path(Path::new("/etc/passwd"), &bases); assert!( result.is_err(), "outside-base path must be rejected, got {result:?}" ); } #[test] fn rejects_path_traversal_attempt() { // A "../../etc/passwd" relative to a sub-dir of the base must // resolve to /etc/passwd (or similar) which is OUTSIDE the // base — and we must reject it. We use the canonicalised // tempdir as the base, then construct a sibling escape path. let (_keep, base) = tempdir_canon("base"); let sub = base.join("sub"); std::fs::create_dir(&sub).expect("mkdir sub"); let bases = vec![base.clone()]; let traversal = sub.join("../../etc/passwd"); let result = resolve_export_path(&traversal, &bases); assert!( result.is_err(), "../../etc/passwd traversal must be rejected, got {result:?}" ); } #[test] fn accepts_path_inside_allowlist() { let (_keep, base) = tempdir_canon("base"); let bases = vec![base.clone()]; let target = base.join("out.md"); let resolved = resolve_export_path(&target, &bases).expect("inside-base must be allowed"); assert!( resolved.starts_with(&base), "resolved path {resolved:?} must sit under base {base:?}" ); } #[test] fn accepts_nested_path_inside_allowlist() { let (_keep, base) = tempdir_canon("base"); let nested = base.join("nested"); std::fs::create_dir(&nested).expect("mkdir nested"); let bases = vec![base.clone()]; let target = nested.join("out.md"); let resolved = resolve_export_path(&target, &bases).expect("nested inside-base must be allowed"); assert!(resolved.starts_with(&base)); } #[test] fn rejects_when_parent_does_not_exist() { let bases = vec![PathBuf::from("/")]; let result = resolve_export_path(Path::new("/definitely-not-real-lumotia-fix/out.md"), &bases); assert!( result.is_err(), "nonexistent parent must be rejected, got {result:?}" ); } #[test] fn is_inside_any_base_is_a_prefix_check() { let a = PathBuf::from("/tmp/a"); let b = PathBuf::from("/tmp/b"); let bases = vec![a.clone(), b]; assert!(is_inside_any_base(&a.join("x.md"), &bases)); assert!(is_inside_any_base(&a.join("nested/x.md"), &bases)); assert!(!is_inside_any_base(Path::new("/tmp/other/x.md"), &bases)); } /// Phase B.5 audit regression (2026-05-14). The Trust-1 fix /// canonicalised only the parent of the target path because the file /// typically does not exist yet. But if a symlink ALREADY exists at /// the target path pointing outside the allowlisted base, /// `tokio::fs::write` follows it on `File::create -> open(2)` and /// silently writes through to the outside target. The path-string /// containment check passed because the SYMLINK lives inside the /// base, even though the symlink's target does not. /// /// The fix canonicalises the full path when it exists (so the /// symlink resolves before the containment check) and only falls /// back to parent-canonicalize on NotFound. This regression test /// pins that contract. #[cfg(unix)] #[test] fn rejects_symlink_target_outside_allowlist() { let (_keep_base, base) = tempdir_canon("base"); let (_keep_out, outside) = tempdir_canon("outside"); // Real, writable file outside the base — the symlink target. let outside_target = outside.join("victim.txt"); std::fs::write(&outside_target, b"original").expect("seed outside target"); // Inside-base symlink pointing to the outside file. let symlink_path = base.join("notes.md"); std::os::unix::fs::symlink(&outside_target, &symlink_path) .expect("create symlink inside base pointing outside"); let bases = vec![base.clone()]; let result = resolve_export_path(&symlink_path, &bases); assert!( result.is_err(), "symlink target outside allowlist must be rejected, got {result:?}" ); let err = result.err().unwrap(); assert!( err.contains("outside the allowed export directories"), "unexpected error shape: {err}" ); } /// Symmetric to the rejection above: a symlink whose target stays /// inside the same base must still be accepted, so legitimate users /// of in-base symlinks (e.g. a tilde-expansion alias inside the base) /// are not regressed. #[cfg(unix)] #[test] fn accepts_symlink_target_inside_allowlist() { let (_keep, base) = tempdir_canon("base"); let real_path = base.join("real.md"); std::fs::write(&real_path, b"original").expect("seed real target"); let symlink_path = base.join("alias.md"); std::os::unix::fs::symlink(&real_path, &symlink_path) .expect("create in-base alias symlink"); let bases = vec![base.clone()]; let resolved = resolve_export_path(&symlink_path, &bases) .expect("in-base symlink must resolve and pass"); assert!(resolved.starts_with(&base)); } }