From f9733dfc739af8fc2be6a13fa8d2981c69676366 Mon Sep 17 00:00:00 2001 From: harald Date: Sat, 19 Sep 2026 09:05:12 +0200 Subject: [PATCH] =?UTF-8?q?fix(storage):=20S-06=20=E2=80=94=20container=20?= =?UTF-8?q?advisory=20lock=20prevents=20parallel=20mount=20and=20concurren?= =?UTF-8?q?t=20writes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main.rs | 8 ++ src/mount.rs | 3 + src/storage.rs | 106 ++++++++++++++++++++++- src/sync.rs | 19 +++++ src/windows.rs | 29 ++++++- tests/sync_carrier_protection_test.rs | 1 + tests/sync_test.rs | 117 ++++++++++++++++++++++++++ 7 files changed, 280 insertions(+), 3 deletions(-) diff --git a/src/main.rs b/src/main.rs index 8d3c171..19c689b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -253,6 +253,10 @@ enum Commands { #[arg(short, long, default_value_t = false)] quiet: bool, + /// Erzwingt die Synchronisation auch bei erkanntem Advisory Lock eines anderen Prozesses (S-06) + #[arg(short = 'f', long, default_value_t = false)] + force: bool, + /// Optionaler 24-Wort Notfallschlüssel (umgeht Passwortabfrage) #[arg(long, num_args = 0..=1, default_missing_value = "")] recovery_key: Option, @@ -1362,6 +1366,7 @@ fn handle_sync( checksum: bool, exclude: Vec, quiet: bool, + force: bool, recovery_key: Option<&str>, ) -> Result<()> { if !container_path.exists() { @@ -1418,6 +1423,7 @@ fn handle_sync( checksum, exclude_patterns: exclude, quiet, + force, }; if !quiet { @@ -1686,6 +1692,7 @@ async fn run() -> Result<()> { checksum, exclude, quiet, + force, recovery_key, } => { handle_sync( @@ -1699,6 +1706,7 @@ async fn run() -> Result<()> { checksum, exclude, quiet, + force, recovery_key.as_deref(), )?; } diff --git a/src/mount.rs b/src/mount.rs index 77e3a6a..a74b88a 100644 --- a/src/mount.rs +++ b/src/mount.rs @@ -182,6 +182,9 @@ pub async fn mount_container( } }; + // S-06: Advisory Lock setzen, um parallele Mounts und schreibende Sync-Läufe abzuwehren + let _advisory_lock = db.acquire_advisory_lock_guard(false)?; + // 128-Bit Session-Token für Loopback-Schutz (CWE-306) & Anti-CSRF generieren let mut token_bytes = [0u8; 16]; rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut token_bytes); diff --git a/src/storage.rs b/src/storage.rs index 9d4583b..0e3639b 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -61,6 +61,17 @@ pub struct UnlockedKeys( pub Option, // 4: Carrier Node ID (Inode der Alibi-Datei in nodes) ); +/// RAII-Guard für den Advisory-Lock eines gemounteten Containers (S-06). +pub struct AdvisoryLockGuard { + db: Database, +} + +impl Drop for AdvisoryLockGuard { + fn drop(&mut self) { + let _ = self.db.release_advisory_lock(); + } +} + impl UnlockedKeys { pub fn dek(&self) -> &Zeroizing<[u8; 32]> { &self.0 @@ -411,6 +422,11 @@ impl Database { [], ); + // Spalten für Advisory-Lock (S-06) + let _ = conn.execute("ALTER TABLE meta ADD COLUMN lock_pid INTEGER", []); + let _ = conn.execute("ALTER TABLE meta ADD COLUMN lock_host TEXT", []); + let _ = conn.execute("ALTER TABLE meta ADD COLUMN lock_time INTEGER", []); + Ok(()) } @@ -576,7 +592,10 @@ impl Database { header_nonce BLOB NOT NULL, header_tag BLOB NOT NULL, metadata_mac BLOB, - metadata_gen INTEGER NOT NULL DEFAULT 0 + metadata_gen INTEGER NOT NULL DEFAULT 0, + lock_pid INTEGER, + lock_host TEXT, + lock_time INTEGER ); CREATE TABLE IF NOT EXISTS nodes ( @@ -783,7 +802,10 @@ impl Database { header_nonce BLOB NOT NULL, header_tag BLOB NOT NULL, metadata_mac BLOB, - metadata_gen INTEGER NOT NULL DEFAULT 0 + metadata_gen INTEGER NOT NULL DEFAULT 0, + lock_pid INTEGER, + lock_host TEXT, + lock_time INTEGER ); CREATE TABLE IF NOT EXISTS nodes ( @@ -1820,6 +1842,86 @@ impl Database { Ok(()) } + /// Prüft den aktuellen Advisory-Lock-Status (S-06). + /// Gibt `Some((pid, host, timestamp))` zurück, falls ein Lock aktiv und der Prozess noch am Leben ist. + pub fn check_advisory_lock(&self) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn + .prepare("SELECT lock_pid, lock_host, lock_time FROM meta WHERE slot_id = 0 LIMIT 1")?; + let lock_info = stmt + .query_row([], |row| { + let pid: Option = row.get(0)?; + let host: Option = row.get(1)?; + let time: Option = row.get(2)?; + Ok((pid, host, time)) + }) + .optional()?; + + if let Some((Some(pid), Some(host), Some(time))) = lock_info { + let current_host = std::env::var("COMPUTERNAME") + .or_else(|_| std::env::var("HOSTNAME")) + .unwrap_or_else(|_| "localhost".to_string()); + + // Falls gleicher Host, prüfe ob PID noch läuft + if host == current_host { + if crate::platform::is_process_alive(pid as u32) { + return Ok(Some((pid as u32, host, time as u64))); + } else { + // Verwaister Lock von abgestürztem Prozess -> ignorieren/löschen + drop(stmt); + drop(conn); + let _ = self.release_advisory_lock(); + return Ok(None); + } + } else { + return Ok(Some((pid as u32, host, time as u64))); + } + } + + Ok(None) + } + + /// Setzt einen Advisory Lock auf den Container (S-06). + pub fn acquire_advisory_lock(&self, force: bool) -> Result<()> { + if !force { + if let Some((pid, host, time)) = self.check_advisory_lock()? { + bail!( + "Container ist gesperrt: Wird aktuell von Prozess {} auf Host '{}' verwendet (seit UNIX-Zeit {}). Verwenden Sie --force zum Überschreiben.", + pid, host, time + ); + } + } + + let pid = std::process::id(); + let host = std::env::var("COMPUTERNAME") + .or_else(|_| std::env::var("HOSTNAME")) + .unwrap_or_else(|_| "localhost".to_string()); + let now = current_timestamp(); + + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE meta SET lock_pid = ?1, lock_host = ?2, lock_time = ?3 WHERE slot_id = 0", + params![pid as i64, host, now], + )?; + Ok(()) + } + + /// Entfernt den Advisory Lock (S-06). + pub fn release_advisory_lock(&self) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE meta SET lock_pid = NULL, lock_host = NULL, lock_time = NULL WHERE slot_id = 0", + [], + )?; + Ok(()) + } + + /// Setzt einen Advisory Lock und gibt einen RAII Guard zurück (S-06). + pub fn acquire_advisory_lock_guard(&self, force: bool) -> Result { + self.acquire_advisory_lock(force)?; + Ok(AdvisoryLockGuard { db: self.clone() }) + } + /// Erzeugt die deterministische kanonische Byterepräsentation aller Knoten für den Metadaten-MAC (K-01). pub fn canonical_nodes_bytes(&self) -> Result> { self.canonical_nodes_bytes_for_vault(0) diff --git a/src/sync.rs b/src/sync.rs index 124068f..3366c32 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -83,6 +83,7 @@ pub struct SyncOptions { pub checksum: bool, pub exclude_patterns: Vec, pub quiet: bool, + pub force: bool, } impl Default for SyncOptions { @@ -95,6 +96,7 @@ impl Default for SyncOptions { checksum: false, exclude_patterns: Vec::new(), quiet: false, + force: false, } } } @@ -453,6 +455,23 @@ pub fn run_sync( let start_time = Instant::now(); let mut stats = SyncStats::default(); + // S-06: Advisory-Lock gegen parallelen Mount / gleichzeitigen Zugriff + if let Some((pid, host, time)) = db.check_advisory_lock()? { + if !options.force { + bail!( + "Container ist gesperrt: Wird aktuell von Prozess {} auf Host '{}' verwendet (seit UNIX-Zeit {}). Verwenden Sie --force zum Überschreiben.", + pid, host, time + ); + } else if !options.quiet { + println!( + " {} Warnung: Aktiver Advisory-Lock (Prozess {} auf '{}') wird durch --force überschrieben.", + ui::yellow("[!]"), + pid, + host + ); + } + } + db.set_active_dek(zeroize::Zeroizing::new(*dek)); match options.direction { diff --git a/src/windows.rs b/src/windows.rs index 38c07a8..403df65 100644 --- a/src/windows.rs +++ b/src/windows.rs @@ -819,6 +819,33 @@ pub fn unmount_drive_wnet(_drive_letter: char) -> Result<()> { Ok(()) } +/// Prüft, ob ein Prozess mit der angegebenen PID auf dem lokalen System aktiv ist (S-06). +pub fn is_process_alive(pid: u32) -> bool { + #[cfg(windows)] + { + extern "system" { + fn OpenProcess(desired_access: u32, inherit_handle: i32, process_id: u32) -> isize; + fn CloseHandle(handle: isize) -> i32; + fn GetExitCodeProcess(handle: isize, exit_code: *mut u32) -> i32; + } + const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000; + const STILL_ACTIVE: u32 = 259; + + let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) }; + if handle == 0 { + return false; + } + let mut exit_code: u32 = 0; + let success = unsafe { GetExitCodeProcess(handle, &mut exit_code) }; + unsafe { CloseHandle(handle) }; + success != 0 && exit_code == STILL_ACTIVE + } + #[cfg(not(windows))] + { + std::path::Path::new(&format!("/proc/{}", pid)).exists() + } +} + #[cfg(test)] mod tests { use super::*; @@ -835,7 +862,7 @@ mod tests { fn test_find_next_available_drive() { let drive = find_next_available_drive().expect("Find next drive"); assert!(drive.is_ascii_alphabetic()); - assert!(drive >= 'D' && drive <= 'Z'); + assert!(('D'..='Z').contains(&drive)); } #[test] diff --git a/tests/sync_carrier_protection_test.rs b/tests/sync_carrier_protection_test.rs index 1ef8809..fd571da 100644 --- a/tests/sync_carrier_protection_test.rs +++ b/tests/sync_carrier_protection_test.rs @@ -170,6 +170,7 @@ fn test_carrier_protection_in_storage_and_sync() { checksum: false, exclude_patterns: Vec::new(), quiet: true, + force: false, }; let pull_res = sanctum::sync::run_sync( &db, diff --git a/tests/sync_test.rs b/tests/sync_test.rs index 652ad11..3daee07 100644 --- a/tests/sync_test.rs +++ b/tests/sync_test.rs @@ -786,3 +786,120 @@ fn test_s05_zero_byte_file_truncate_removes_all_chunks() { let _ = fs::remove_dir_all(&temp_root); } + +#[test] +fn test_s06_advisory_lock_blocks_sync_and_force_overrides() { + let temp_root = + std::env::temp_dir().join(format!("sanctum_s06_test_{}", rand::random::())); + let container_path = temp_root.join("test.sanctum"); + let source_dir = temp_root.join("source"); + + fs::create_dir_all(&source_dir).unwrap(); + fs::write(source_dir.join("test_lock.txt"), b"Locked content").unwrap(); + + let (db, dek) = create_test_container(&container_path); + + // 1. Initial: Kein Lock aktiv + assert!(db.check_advisory_lock().unwrap().is_none()); + + // 2. Lock setzen (simuliert laufenden Mount) + db.acquire_advisory_lock(false) + .expect("Acquire advisory lock"); + let active_lock = db.check_advisory_lock().unwrap(); + assert!(active_lock.is_some()); + let (pid, _host, _time) = active_lock.unwrap(); + assert_eq!(pid, std::process::id()); + + let mut opts = SyncOptions::default(); + opts.direction = SyncDirection::Push; + opts.quiet = true; + opts.force = false; + + // 3. Sync ohne --force muss mit Lock-Fehler abbrechen + let sync_res = run_sync( + &db, + 0, + &dek, + FORMAT_VERSION, + source_dir.to_str().unwrap(), + "/", + &opts, + ); + assert!( + sync_res.is_err(), + "Sync ohne --force muss bei aktivem Advisory Lock abbrechen" + ); + let err_msg = sync_res.unwrap_err().to_string(); + assert!( + err_msg.contains("Container ist gesperrt"), + "Fehlermeldung muss Lock-Hinweis enthalten: {}", + err_msg + ); + + // 4. Sync mit --force muss trotz Lock erfolgreich durchlaufen + opts.force = true; + let sync_force_res = run_sync( + &db, + 0, + &dek, + FORMAT_VERSION, + source_dir.to_str().unwrap(), + "/", + &opts, + ); + assert!( + sync_force_res.is_ok(), + "Sync mit --force muss trotz aktivem Advisory Lock gelingen" + ); + + // 5. Lock freigeben + db.release_advisory_lock().expect("Release advisory lock"); + assert!(db.check_advisory_lock().unwrap().is_none()); + + // 6. Sync ohne --force gelingt jetzt wieder + opts.force = false; + let sync_unlocked = run_sync( + &db, + 0, + &dek, + FORMAT_VERSION, + source_dir.to_str().unwrap(), + "/", + &opts, + ); + assert!(sync_unlocked.is_ok()); + + // 7. Stale Lock Test: Lock mit nicht mehr existierender PID wird automatisch bereinigt + { + let conn = rusqlite::Connection::open(&container_path).unwrap(); + let current_host = std::env::var("COMPUTERNAME") + .or_else(|_| std::env::var("HOSTNAME")) + .unwrap_or_else(|_| "localhost".to_string()); + conn.execute( + "UPDATE meta SET lock_pid = 99999999, lock_host = ?1, lock_time = 1000 WHERE slot_id = 0", + rusqlite::params![current_host], + ) + .unwrap(); + } + + // check_advisory_lock erkennt den toten Prozess und räumt den Lock auf + assert!( + db.check_advisory_lock().unwrap().is_none(), + "Stale Lock eines toten Prozesses muss automatisch als None bewertet werden" + ); + let sync_stale = run_sync( + &db, + 0, + &dek, + FORMAT_VERSION, + source_dir.to_str().unwrap(), + "/", + &opts, + ); + assert!( + sync_stale.is_ok(), + "Sync muss bei verwaistem Lock eines toten Prozesses ohne --force gelingen" + ); + + let _ = fs::remove_dir_all(&temp_root); +}