From a9419d12b333ee3976505f02686cc4d990c93a0f Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Mon, 3 Aug 2026 00:27:51 -0400 Subject: [PATCH 1/3] fix(state): require persistent database hardening --- crates/state/src/fs.rs | 36 +++++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/crates/state/src/fs.rs b/crates/state/src/fs.rs index 47fa7a1f..dc27ae47 100644 --- a/crates/state/src/fs.rs +++ b/crates/state/src/fs.rs @@ -160,7 +160,9 @@ pub(crate) fn database_exists(paths: &PvPaths) -> bool { } pub(crate) fn secure_database_files(paths: &PvPaths) -> Result<(), StateError> { - for (_, path) in database_files(paths) { + secure_sensitive_file(paths.db())?; + + for path in database_auxiliary_files(paths) { if !path_exists(&path) { continue; } @@ -193,6 +195,13 @@ fn database_files(paths: &PvPaths) -> [(&'static str, Utf8PathBuf); 3] { ] } +fn database_auxiliary_files(paths: &PvPaths) -> [Utf8PathBuf; 2] { + [ + paths.root().join("pv.db-wal"), + paths.root().join("pv.db-shm"), + ] +} + pub fn ensure_user_dir(path: &Utf8Path) -> Result<(), StateError> { require_owner_only_filesystem()?; create_dir_all(path)?; @@ -604,14 +613,35 @@ fn require_owner_only_filesystem() -> Result<(), StateError> { #[cfg(test)] mod tests { use camino::Utf8Path; - #[cfg(windows)] use camino_tempfile::tempdir; + #[cfg(unix)] + use super::secure_database_files; use super::temporary_path_for; #[cfg(windows)] use super::{ensure_user_dir, path_exists}; + #[cfg(unix)] + use crate::PvPaths; #[cfg(windows)] - use crate::{StateCapability, StateError}; + use crate::StateCapability; + use crate::StateError; + + #[cfg(unix)] + #[test] + fn missing_persistent_database_file_is_not_ignored() -> anyhow::Result<()> { + let tempdir = tempdir()?; + let paths = PvPaths::for_home(tempdir.path().join("home")); + + let result = secure_database_files(&paths); + + assert!(matches!( + result, + Err(StateError::Filesystem { path, source }) + if path == paths.db() && source.kind() == std::io::ErrorKind::NotFound + )); + + Ok(()) + } #[test] fn temporary_paths_keep_the_target_extension_in_the_derived_name() { From 18e2f1e96bad9a4b8f20cd9cc092e76451d2f2cb Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Mon, 3 Aug 2026 00:31:47 -0400 Subject: [PATCH 2/3] fix(state): tolerate disappearing SQLite side files --- crates/state/src/fs.rs | 58 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 51 insertions(+), 7 deletions(-) diff --git a/crates/state/src/fs.rs b/crates/state/src/fs.rs index dc27ae47..f8daa5c6 100644 --- a/crates/state/src/fs.rs +++ b/crates/state/src/fs.rs @@ -163,16 +163,22 @@ pub(crate) fn secure_database_files(paths: &PvPaths) -> Result<(), StateError> { secure_sensitive_file(paths.db())?; for path in database_auxiliary_files(paths) { - if !path_exists(&path) { - continue; - } - - secure_sensitive_file(&path)?; + secure_database_auxiliary_file(&path)?; } Ok(()) } +fn secure_database_auxiliary_file(path: &Utf8Path) -> Result<(), StateError> { + match secure_sensitive_file(path) { + Ok(()) => Ok(()), + Err(StateError::Filesystem { source, .. }) if source.kind() == io::ErrorKind::NotFound => { + Ok(()) + } + Err(error) => Err(error), + } +} + pub(crate) fn secure_sensitive_file(path: &Utf8Path) -> Result<(), StateError> { require_owner_only_filesystem()?; set_file_mode(path, SENSITIVE_FILE_MODE)?; @@ -615,12 +621,15 @@ mod tests { use camino::Utf8Path; use camino_tempfile::tempdir; - #[cfg(unix)] - use super::secure_database_files; use super::temporary_path_for; #[cfg(windows)] use super::{ensure_user_dir, path_exists}; #[cfg(unix)] + use super::{ + path_exists, remove_file, secure_database_auxiliary_file, secure_database_files, + write_sensitive_file, + }; + #[cfg(unix)] use crate::PvPaths; #[cfg(windows)] use crate::StateCapability; @@ -643,6 +652,41 @@ mod tests { Ok(()) } + #[cfg(unix)] + #[test] + fn disappearing_database_auxiliary_file_is_ignored() -> anyhow::Result<()> { + let tempdir = tempdir()?; + let paths = PvPaths::for_home(tempdir.path().join("home")); + let auxiliary_path = paths.root().join("pv.db-wal"); + write_sensitive_file(&auxiliary_path, "")?; + assert!(path_exists(&auxiliary_path)); + remove_file(&auxiliary_path)?; + + secure_database_auxiliary_file(&auxiliary_path)?; + + Ok(()) + } + + #[cfg(unix)] + #[test] + fn database_auxiliary_file_security_errors_are_preserved() -> anyhow::Result<()> { + let tempdir = tempdir()?; + let paths = PvPaths::for_home(tempdir.path().join("home")); + let blocking_file = paths.root().join("blocking-file"); + write_sensitive_file(&blocking_file, "")?; + let auxiliary_path = blocking_file.join("pv.db-wal"); + + let result = secure_database_auxiliary_file(&auxiliary_path); + + assert!(matches!( + result, + Err(StateError::Filesystem { path, source }) + if path == auxiliary_path && source.kind() == std::io::ErrorKind::NotADirectory + )); + + Ok(()) + } + #[test] fn temporary_paths_keep_the_target_extension_in_the_derived_name() { let pid_temporary_path = temporary_path_for(Utf8Path::new("/tmp/pv/runtime.pid")); From 05230cd63b3124915a3a54cba8fb0498f24530ed Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Mon, 3 Aug 2026 00:35:03 -0400 Subject: [PATCH 3/3] test(daemon): cover disappearing SQLite side files --- crates/daemon/tests/daemon_foundation.rs | 34 ++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/crates/daemon/tests/daemon_foundation.rs b/crates/daemon/tests/daemon_foundation.rs index 48fbb1cf..43d0eb67 100644 --- a/crates/daemon/tests/daemon_foundation.rs +++ b/crates/daemon/tests/daemon_foundation.rs @@ -864,6 +864,40 @@ async fn project_config_watcher_enqueues_project_reconciliation() -> Result<()> Ok(()) } +#[tokio::test] +async fn project_config_watcher_survives_disappearing_database_side_files() -> Result<()> { + let tempdir = tempdir()?; + let paths = PvPaths::for_home(tempdir.path().join("home")); + drop(Database::open(&paths)?); + + for name in ["pv.db-wal", "pv.db-shm"] { + let auxiliary_path = paths.root().join(name); + state::fs::write_sensitive_file(&auxiliary_path, "")?; + assert!(state::fs::path_exists(&auxiliary_path)); + state::fs::remove_file(&auxiliary_path)?; + } + + let daemon = daemon::RunningDaemon::start(paths.clone()).await?; + sleep(Duration::from_millis(250)).await; + + let health_lines = request_lines( + &paths, + json!({ + "protocol_version": daemon::PROTOCOL_VERSION, + "command": "health", + }), + ) + .await?; + + daemon.shutdown().await?; + + assert_eq!(health_lines.len(), 1); + assert_eq!(health_lines[0]["status"], json!("ok")); + assert!(Database::open(&paths)?.recent_jobs()?.is_empty()); + + Ok(()) +} + #[tokio::test] async fn dns_resolver_answers_udp_a_and_aaaa_for_test_hostnames() -> Result<()> { let tempdir = tempdir()?;