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()?; diff --git a/crates/state/src/fs.rs b/crates/state/src/fs.rs index 47fa7a1f..f8daa5c6 100644 --- a/crates/state/src/fs.rs +++ b/crates/state/src/fs.rs @@ -160,17 +160,25 @@ pub(crate) fn database_exists(paths: &PvPaths) -> bool { } pub(crate) fn secure_database_files(paths: &PvPaths) -> Result<(), StateError> { - for (_, path) in database_files(paths) { - if !path_exists(&path) { - continue; - } + secure_sensitive_file(paths.db())?; - secure_sensitive_file(&path)?; + for path in database_auxiliary_files(paths) { + 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)?; @@ -193,6 +201,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 +619,73 @@ fn require_owner_only_filesystem() -> Result<(), StateError> { #[cfg(test)] mod tests { use camino::Utf8Path; - #[cfg(windows)] use camino_tempfile::tempdir; 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, 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(()) + } + + #[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() {