diff --git a/Cargo.lock b/Cargo.lock index 51d1a3d..9907baf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -368,6 +368,18 @@ dependencies = [ "once_cell", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.5.0" @@ -560,6 +572,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "libsqlite3-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -577,6 +600,7 @@ dependencies = [ "libloading", "ort", "parakeet-rs", + "rusqlite", ] [[package]] @@ -1111,6 +1135,19 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rusqlite" +version = "0.40.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustfft" version = "6.4.1" diff --git a/Cargo.toml b/Cargo.toml index 081437b..e3d71de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ libc = "0.2" libloading = "0.8" ort = { version = "=2.0.0-rc.13", default-features = false, features = ["api-28", "copy-dylibs", "cuda", "download-binaries", "ndarray", "std"] } parakeet-rs = { version = "=0.3.7", default-features = false, features = ["api-28", "cuda", "ort-defaults"] } +rusqlite = { version = "0.40.2", default-features = false, features = ["bundled"] } [profile.release] lto = "thin" diff --git a/README.md b/README.md index 4fb5b08..4c9b8a2 100644 --- a/README.md +++ b/README.md @@ -74,3 +74,15 @@ The Sway wrapper sources this file and passes `LW_POST_PROCESS_MODEL` directly to OpenAI. Leave it empty to disable remote cleanup. Cleanup uses the Responses API with reasoning effort fixed at `none`; run `lw --help` for the equivalent command-line options. + +## Transcript history + +Local Wisper saves each successful transcription in an on-device SQLite database. +Each row contains the speech-to-text output, the final processed text prepared +for delivery, the configured post-processing model (if any), and the processing +path that produced the final text. Audio is not retained. + +The database is stored at `$XDG_DATA_HOME/local-wisper/transcripts.sqlite3`, or +`~/.local/share/local-wisper/transcripts.sqlite3` when `XDG_DATA_HOME` is unset. +Local Wisper restricts the directory to the current user, but the database +contents are not encrypted. diff --git a/baml_src/app.baml b/baml_src/app.baml index ae81e9e..d35f434 100644 --- a/baml_src/app.baml +++ b/baml_src/app.baml @@ -26,6 +26,13 @@ class AppOptions { post_process_glossary_file: string?, } +class TranscriptRecord { + raw_text: string, + final_text: string, + processing: TranscriptProcessing, + post_process_model: string?, +} + function invalid_argument(message: string) -> never { throw baml.errors.InvalidArgument { message: message } } @@ -152,17 +159,60 @@ function parse_options(args: string[]) -> AppOptions { options } -function clean_if_present(transcript: string, options: AppOptions) -> string { - if (transcript.trim() == "") { +function prepare_transcript(raw_text: string, options: AppOptions) -> TranscriptRecord? { + if (raw_text.trim() == "") { baml.io.eprintln("No speech detected."); - "" + null } else { - process_transcript( - transcript, + let processed = process_transcript( + raw_text, options.post_process_model, options.post_process_timeout, options.post_process_glossary_file, - ) + ); + TranscriptRecord { + raw_text: raw_text, + final_text: processed.text, + processing: processed.processing, + post_process_model: options.post_process_model, + } + } +} + +function store_transcript( + transcript: TranscriptRecord, + native_store_transcript: (transcript: TranscriptRecord) -> null throws baml.errors.HostCallable, +) -> null { + native_store_transcript(transcript) catch_all (error) { + _ => { + baml.io.eprintln(`Warning: Could not save transcript history: ${error.to_string()}`); + null + }, + } +} + +function delivery_failure_message(mode: DeliveryMode) -> string { + match (mode) { + DeliveryMode.Copy => "Could not copy transcript to the clipboard.", + DeliveryMode.Type => "Could not type transcript into the focused application.", + } +} + +function complete_transcription( + raw_text: string, + options: AppOptions, + mode: DeliveryMode, + native_store_transcript: (transcript: TranscriptRecord) -> null throws baml.errors.HostCallable, +) -> null { + if let transcript: TranscriptRecord = prepare_transcript(raw_text, options) { + baml.io.println(transcript.final_text); + if (!deliver_text(transcript.final_text, mode)) { + baml.io.eprintln(`Warning: ${delivery_failure_message(mode)}`) + } + //# Persistence is best effort and must not delay delivery. + store_transcript(transcript, native_store_transcript) + } else { + null } } @@ -183,6 +233,7 @@ function run_workflow( process: NativeRecorder, backend: RecorderBackend, ) -> null throws baml.errors.HostCallable, + native_store_transcript: (transcript: TranscriptRecord) -> null throws baml.errors.HostCallable, ) -> null { match (options.command) { AppCommand.Record => { @@ -191,16 +242,12 @@ function run_workflow( ensure_model_daemon(runtime_dir, options.device, native_spawn_daemon); let audio = record_interactively(runtime_dir, native_spawn_recorder, native_recorder_exists, native_stop_recorder); defer { cleanup_audio(audio) } - let transcript = clean_if_present( + complete_transcription( transcribe_with_daemon(runtime_dir, audio.path, options.device, native_spawn_daemon), options, + DeliveryMode.Copy, + native_store_transcript, ); - if (transcript != "") { - baml.io.println(transcript); - if (!deliver_text(transcript, DeliveryMode.Copy)) { - baml.io.eprintln("Warning: Could not copy transcript to the clipboard.") - } - } null }, AppCommand.Preload => { @@ -215,23 +262,17 @@ function run_workflow( let runtime_dir = native_runtime_dir(); let audio = sway_stop_recording(runtime_dir, native_recorder_exists, native_stop_recorder); defer { cleanup_audio(audio) } - let transcript = clean_if_present( + let mode = if (options.type_output) { + DeliveryMode.Type + } else { + DeliveryMode.Copy + }; + complete_transcription( transcribe_with_daemon(runtime_dir, audio.path, options.device, native_spawn_daemon), options, + mode, + native_store_transcript, ); - if (transcript != "") { - baml.io.println(transcript); - let mode = if (options.type_output) { - DeliveryMode.Type - } else { - DeliveryMode.Copy - }; - if (!deliver_text(transcript, mode)) { - baml.io.eprintln( - "Warning: Could not deliver transcript to the focused application.", - ) - } - } null }, AppCommand.SwayCancel => { @@ -257,6 +298,7 @@ function run_app( process: NativeRecorder, backend: RecorderBackend, ) -> null throws baml.errors.HostCallable, + native_store_transcript: (transcript: TranscriptRecord) -> null throws baml.errors.HostCallable, ) -> int { run_workflow( parse_options(args), @@ -265,6 +307,7 @@ function run_app( native_spawn_recorder, native_recorder_exists, native_stop_recorder, + native_store_transcript, ) catch_all (error) { _ => { baml.io.eprintln(error.to_string()); @@ -303,3 +346,29 @@ test "post-processing model is user configurable" { let options = parse_options(["--post-process-model", "gpt-5.6-luna-next"]); assert.equal(options.post_process_model, "gpt-5.6-luna-next") } + +test "history receives the raw and final transcript" { + let saved: string[] = []; + store_transcript(TranscriptRecord { raw_text: "raw words", final_text: "Final words.", processing: TranscriptProcessing.Model, post_process_model: "gpt-5.6-luna" }, ( + transcript, + ) -> { + saved.push( + `${transcript.raw_text}|${transcript.final_text}|${transcript.processing}|${transcript.post_process_model ?? "none"}`, + ); + null + }); + assert.equal(saved, ["raw words|Final words.|Model|gpt-5.6-luna"]) +} + +test "local processing records the raw and final transcript" { + let transcript = prepare_transcript("version zero point one.", parse_options([])); + assert.equal( + transcript, + TranscriptRecord { + raw_text: "version zero point one.", + final_text: "version 0.1", + processing: TranscriptProcessing.Local, + post_process_model: null, + }, + ) +} diff --git a/baml_src/cleanup.baml b/baml_src/cleanup.baml index b47c0c8..dc3a826 100644 --- a/baml_src/cleanup.baml +++ b/baml_src/cleanup.baml @@ -34,6 +34,19 @@ class ModelCleanAttempt { timed_out: bool, } +enum TranscriptProcessing { + Local, + Model, + ModelTimeoutFallback, + ModelErrorFallback, + ModelRejectedFallback, +} + +class ProcessedTranscript { + text: string, + processing: TranscriptProcessing, +} + function empty_glossary() -> Glossary { Glossary { always: [], likely: [], contextual: [], terms: [], legacy: null } } @@ -626,7 +639,7 @@ function process_transcript( model: string?, timeout_seconds: float, glossary_file: string?, -) -> string { +) -> ProcessedTranscript { let raw_word_count = word_count(text); let glossary = load_glossary(glossary_file) catch_all (error) { _ => { @@ -636,18 +649,14 @@ function process_transcript( }; let prepared = apply_guaranteed_corrections(normalize_spoken_numerics(text), glossary.always); let local = normalize_short_statement_style(prepared); - if (!should_clean_with_model(raw_word_count, model)) { - return local; - } - - let model_name = model - ?? return local; + let model_name = model_for_cleanup(raw_word_count, model) + ?? return ProcessedTranscript { text: local, processing: TranscriptProcessing.Local }; let attempt = clean_with_timeout(prepared, glossary_prompt(glossary), model_name, timeout_seconds); if (attempt.timed_out) { baml.io.eprintln( `Warning: transcript post-processing timed out after ${timeout_seconds}s; using local cleanup.`, ); - return local; + return ProcessedTranscript { text: local, processing: TranscriptProcessing.ModelTimeoutFallback }; } let result = attempt.result ?? CleanResult { text: null, error: "missing model result" }; let cleaned = result.text ?? ""; @@ -655,13 +664,16 @@ function process_transcript( baml.io.eprintln( `Warning: transcript post-processing failed: ${result.error ?? "empty model output"}; using local cleanup.`, ); - return local; + return ProcessedTranscript { text: local, processing: TranscriptProcessing.ModelErrorFallback }; } if (looks_like_unwanted_non_latin_translation(prepared, cleaned)) { baml.io.eprintln("Warning: transcript cleanup changed the language; using local cleanup."); - return local; + return ProcessedTranscript { text: local, processing: TranscriptProcessing.ModelRejectedFallback }; + } + ProcessedTranscript { + text: normalize_final_transcript(apply_guaranteed_corrections(cleaned, glossary.always)), + processing: TranscriptProcessing.Model, } - normalize_final_transcript(apply_guaranteed_corrections(cleaned, glossary.always)) } test "BAML normalizes spoken numbers" { diff --git a/baml_src/main.baml b/baml_src/main.baml index 6233cb7..4d1fe21 100644 --- a/baml_src/main.baml +++ b/baml_src/main.baml @@ -5,8 +5,12 @@ class CleanResult { // Short utterances stay local. They rarely benefit from a network round trip, // and this matches the established six-word threshold. -function should_clean_with_model(word_count: int, model: string?) -> bool { - model != null && word_count >= 6 +function model_for_cleanup(word_count: int, model: string?) -> string? { + if (word_count >= 6) { + model + } else { + null + } } function transcript_cleaner(model: string) -> openai.ResponsesClient { @@ -63,9 +67,9 @@ function clean_transcript(transcript: string, glossary: string, model: string) - } test "model cleanup threshold" { - assert.equal(should_clean_with_model(5, "gpt-5.6-luna"), false); - assert.equal(should_clean_with_model(6, "gpt-5.6-luna"), true); - assert.equal(should_clean_with_model(12, null), false) + assert.equal(model_for_cleanup(5, "gpt-5.6-luna"), null); + assert.equal(model_for_cleanup(6, "gpt-5.6-luna"), "gpt-5.6-luna"); + assert.equal(model_for_cleanup(12, null), null) } test "transcript cleaner uses the selected model without reasoning" { diff --git a/src/history.rs b/src/history.rs new file mode 100644 index 0000000..0be2176 --- /dev/null +++ b/src/history.rs @@ -0,0 +1,312 @@ +use std::fs::{self, OpenOptions}; +use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt}; +use std::path::Path; +use std::time::Duration; + +use anyhow::{Context, Result, bail}; +use rusqlite::{Connection, OpenFlags, TransactionBehavior, params}; + +use crate::paths; + +const DATABASE_NAME: &str = "transcripts.sqlite3"; +const SCHEMA_VERSION: i64 = 1; +const CREATE_SCHEMA: &str = " + CREATE TABLE IF NOT EXISTS transcripts ( + id INTEGER PRIMARY KEY, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + raw_text TEXT NOT NULL, + final_text TEXT NOT NULL, + post_process_model TEXT, + processing TEXT NOT NULL + CHECK (processing IN ('local', 'model', 'model_timeout_fallback', 'model_error_fallback', 'model_rejected_fallback')) + ); +"; + +pub fn save(transcript: baml_sdk::TranscriptRecord) -> Result<()> { + let database_path = paths::data_dir()?.join(DATABASE_NAME); + save_to(&database_path, &transcript) +} + +fn save_to(database_path: &Path, transcript: &baml_sdk::TranscriptRecord) -> Result<()> { + create_secure_database_file(database_path)?; + let mut connection = Connection::open_with_flags( + database_path, + OpenFlags::default() | OpenFlags::SQLITE_OPEN_NOFOLLOW, + ) + .with_context(|| { + format!( + "failed to open transcript database {}", + database_path.display() + ) + })?; + configure_connection(&connection)?; + initialize_schema(&mut connection)?; + connection + .execute( + "INSERT INTO transcripts (raw_text, final_text, post_process_model, processing) VALUES (?1, ?2, ?3, ?4)", + params![ + &transcript.raw_text, + &transcript.final_text, + &transcript.post_process_model, + processing_name(transcript.processing), + ], + ) + .context("failed to save transcript")?; + Ok(()) +} + +fn configure_connection(connection: &Connection) -> Result<()> { + connection + .busy_timeout(Duration::from_secs(1)) + .context("failed to configure transcript database timeout")?; + let journal_mode = connection + .query_row("PRAGMA journal_mode = WAL", [], |row| { + row.get::<_, String>(0) + }) + .context("failed to enable transcript database WAL mode")?; + if !journal_mode.eq_ignore_ascii_case("wal") { + bail!("transcript database refused WAL mode and selected {journal_mode}") + } + Ok(()) +} + +fn initialize_schema(connection: &mut Connection) -> Result<()> { + if schema_version(connection)? == SCHEMA_VERSION { + return Ok(()); + } + let transaction = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .context("failed to lock transcript database for schema initialization")?; + // Another process may have migrated while this one waited for the lock. + let version = schema_version(&transaction)?; + match version { + 0 if database_is_empty(&transaction)? => transaction + .execute_batch(CREATE_SCHEMA) + .context("failed to initialize transcript database")?, + 0 => bail!("refusing to initialize a non-empty unversioned transcript database"), + SCHEMA_VERSION => {} + other if other > SCHEMA_VERSION => bail!( + "transcript database schema version {version} is newer than supported version {SCHEMA_VERSION}" + ), + _ => bail!("unsupported transcript database schema version {version}"), + } + if version != SCHEMA_VERSION { + transaction + .pragma_update(None, "user_version", SCHEMA_VERSION) + .context("failed to update transcript database schema version")?; + } + transaction + .commit() + .context("failed to commit transcript database schema initialization") +} + +fn schema_version(connection: &Connection) -> Result { + connection + .pragma_query_value(None, "user_version", |row| row.get::<_, i64>(0)) + .context("failed to read transcript database schema version") +} + +fn database_is_empty(connection: &Connection) -> Result { + connection + .query_row( + "SELECT NOT EXISTS (SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%')", + [], + |row| row.get(0), + ) + .context("failed to inspect unversioned transcript database") +} + +fn processing_name(processing: baml_sdk::TranscriptProcessing) -> &'static str { + match processing { + baml_sdk::TranscriptProcessing::Local => "local", + baml_sdk::TranscriptProcessing::Model => "model", + baml_sdk::TranscriptProcessing::ModelTimeoutFallback => "model_timeout_fallback", + baml_sdk::TranscriptProcessing::ModelErrorFallback => "model_error_fallback", + baml_sdk::TranscriptProcessing::ModelRejectedFallback => "model_rejected_fallback", + } +} + +fn create_secure_database_file(path: &Path) -> Result<()> { + let file = OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW) + .open(path) + .with_context(|| format!("failed to create transcript database {}", path.display()))?; + let metadata = file + .metadata() + .with_context(|| format!("failed to inspect transcript database {}", path.display()))?; + let uid = unsafe { libc::geteuid() }; + if !metadata.is_file() || metadata.uid() != uid { + bail!( + "transcript database {} is not a regular file owned by user {uid}", + path.display() + ) + } + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) + .with_context(|| format!("failed to secure transcript database {}", path.display()))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::os::unix::fs::symlink; + use std::time::{SystemTime, UNIX_EPOCH}; + + use super::*; + + fn transcript( + raw_text: &str, + final_text: &str, + processing: baml_sdk::TranscriptProcessing, + post_process_model: Option<&str>, + ) -> baml_sdk::TranscriptRecord { + baml_sdk::TranscriptRecord { + raw_text: raw_text.to_owned(), + final_text: final_text.to_owned(), + processing, + post_process_model: post_process_model.map(str::to_owned), + } + } + + fn all_processing_outcomes() -> [baml_sdk::TranscriptProcessing; 5] { + [ + baml_sdk::TranscriptProcessing::Local, + baml_sdk::TranscriptProcessing::Model, + baml_sdk::TranscriptProcessing::ModelTimeoutFallback, + baml_sdk::TranscriptProcessing::ModelErrorFallback, + baml_sdk::TranscriptProcessing::ModelRejectedFallback, + ] + } + + #[test] + fn saves_raw_and_final_transcripts_in_a_private_database() { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let directory = std::env::temp_dir().join(format!( + "local-wisper-history-test-{}-{suffix}", + std::process::id() + )); + fs::create_dir(&directory).unwrap(); + let database_path = directory.join(DATABASE_NAME); + + for processing in all_processing_outcomes() { + save_to( + &database_path, + &transcript( + "raw words", + "Final words.", + processing, + Some("gpt-5.6-luna"), + ), + ) + .unwrap(); + } + + let connection = Connection::open(&database_path).unwrap(); + let saved = connection + .query_row( + "SELECT raw_text, final_text, post_process_model, processing FROM transcripts WHERE id = 1", + [], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, String>(3)?, + )) + }, + ) + .unwrap(); + assert_eq!( + saved, + ( + "raw words".to_owned(), + "Final words.".to_owned(), + Some("gpt-5.6-luna".to_owned()), + "local".to_owned(), + ) + ); + let outcomes = { + let mut statement = connection + .prepare("SELECT processing FROM transcripts ORDER BY id") + .unwrap(); + statement + .query_map([], |row| row.get::<_, String>(0)) + .unwrap() + .collect::>>() + .unwrap() + }; + assert_eq!( + outcomes, + [ + "local", + "model", + "model_timeout_fallback", + "model_error_fallback", + "model_rejected_fallback", + ] + ); + assert_eq!( + connection + .pragma_query_value(None, "user_version", |row| row.get::<_, i64>(0)) + .unwrap(), + SCHEMA_VERSION + ); + assert_eq!( + connection + .query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0)) + .unwrap(), + "wal" + ); + assert_eq!( + fs::metadata(&database_path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + + drop(connection); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn refuses_a_symlinked_database() { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let directory = std::env::temp_dir().join(format!( + "local-wisper-history-symlink-test-{}-{suffix}", + std::process::id() + )); + fs::create_dir(&directory).unwrap(); + let target = directory.join("target"); + fs::write(&target, "untouched").unwrap(); + let database_path = directory.join(DATABASE_NAME); + symlink(&target, &database_path).unwrap(); + + let error = save_to( + &database_path, + &transcript( + "raw words", + "Final words.", + baml_sdk::TranscriptProcessing::Local, + None, + ), + ) + .unwrap_err(); + + assert!( + error + .to_string() + .contains("failed to create transcript database") + ); + assert_eq!(fs::read_to_string(target).unwrap(), "untouched"); + + fs::remove_dir_all(directory).unwrap(); + } +} diff --git a/src/main.rs b/src/main.rs index 48eabd5..feec2cb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use anyhow::{Context, Result}; mod daemon; +mod history; mod model; mod paths; mod recording; @@ -73,6 +74,7 @@ fn main() -> Result<()> { }, move |process| native(observed_recorders.exists(process)), move |process, backend| native(stopped_recorders.stop(process, backend)), + move |transcript: baml_sdk::TranscriptRecord| native(history::save(transcript)), ) .context("BAML application failed")?; diff --git a/src/paths.rs b/src/paths.rs index 970e087..d5aa0ae 100644 --- a/src/paths.rs +++ b/src/paths.rs @@ -1,7 +1,7 @@ use std::fs; use std::os::unix::fs::MetadataExt; use std::os::unix::fs::PermissionsExt; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; @@ -13,27 +13,97 @@ pub fn runtime_dir() -> Result { } else { PathBuf::from(format!("/tmp/local-wisper-{uid}")) }; - match fs::create_dir(&path) { - Ok(()) => {} - Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} - Err(error) => { - return Err(error) - .with_context(|| format!("failed to create runtime directory {}", path.display())); - } + secure_user_dir(&path, uid)?; + Ok(path) +} + +pub fn data_dir() -> Result { + let root = data_root( + std::env::var_os("XDG_DATA_HOME") + .filter(|value| !value.is_empty()) + .map(PathBuf::from), + std::env::var_os("HOME").map(PathBuf::from), + )?; + let path = root.join("local-wisper"); + let uid = unsafe { libc::geteuid() }; + secure_user_dir(&path, uid)?; + Ok(path) +} + +fn data_root(xdg_data_home: Option, home: Option) -> Result { + let root = xdg_data_home + .or_else(|| home.map(|path| path.join(".local/share"))) + .context("HOME or XDG_DATA_HOME is required")?; + if !root.is_absolute() { + bail!("XDG_DATA_HOME or HOME must be an absolute path") } - let metadata = fs::symlink_metadata(&path) - .with_context(|| format!("failed to inspect runtime directory {}", path.display()))?; + Ok(root) +} + +fn secure_user_dir(path: &Path, uid: u32) -> Result<()> { + fs::create_dir_all(path) + .with_context(|| format!("failed to create directory {}", path.display()))?; + let metadata = fs::symlink_metadata(path) + .with_context(|| format!("failed to inspect directory {}", path.display()))?; if !metadata.is_dir() || metadata.uid() != uid { bail!( - "runtime path {} is not a directory owned by user {uid}", + "path {} is not a directory owned by user {uid}", path.display() ) } - fs::set_permissions(&path, fs::Permissions::from_mode(0o700)) - .with_context(|| format!("failed to secure runtime directory {}", path.display()))?; - Ok(path) + fs::set_permissions(path, fs::Permissions::from_mode(0o700)) + .with_context(|| format!("failed to secure directory {}", path.display()))?; + Ok(()) } pub fn daemon_lock_path() -> Result { Ok(runtime_dir()?.join("daemon.lock")) } + +#[cfg(test)] +mod tests { + use std::time::{SystemTime, UNIX_EPOCH}; + + use super::*; + + #[test] + fn secure_user_dir_creates_private_parent_directories() { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "local-wisper-path-test-{}-{suffix}", + std::process::id() + )); + let path = root.join("nested/local-wisper"); + + secure_user_dir(&path, unsafe { libc::geteuid() }).unwrap(); + + assert!(path.is_dir()); + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o700 + ); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn data_root_prefers_xdg_and_falls_back_to_home() { + assert_eq!( + data_root( + Some(PathBuf::from("/xdg")), + Some(PathBuf::from("/home/user")) + ) + .unwrap(), + PathBuf::from("/xdg") + ); + assert_eq!( + data_root(None, Some(PathBuf::from("/home/user"))).unwrap(), + PathBuf::from("/home/user/.local/share") + ); + assert!(data_root(Some(PathBuf::from("relative")), None).is_err()); + assert!(data_root(None, None).is_err()); + } +}