fix(storage): S-06 — container advisory lock prevents parallel mount and concurrent writes
This commit is contained in:
@@ -253,6 +253,10 @@ enum Commands {
|
|||||||
#[arg(short, long, default_value_t = false)]
|
#[arg(short, long, default_value_t = false)]
|
||||||
quiet: bool,
|
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)
|
/// Optionaler 24-Wort Notfallschlüssel (umgeht Passwortabfrage)
|
||||||
#[arg(long, num_args = 0..=1, default_missing_value = "")]
|
#[arg(long, num_args = 0..=1, default_missing_value = "")]
|
||||||
recovery_key: Option<String>,
|
recovery_key: Option<String>,
|
||||||
@@ -1362,6 +1366,7 @@ fn handle_sync(
|
|||||||
checksum: bool,
|
checksum: bool,
|
||||||
exclude: Vec<String>,
|
exclude: Vec<String>,
|
||||||
quiet: bool,
|
quiet: bool,
|
||||||
|
force: bool,
|
||||||
recovery_key: Option<&str>,
|
recovery_key: Option<&str>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
if !container_path.exists() {
|
if !container_path.exists() {
|
||||||
@@ -1418,6 +1423,7 @@ fn handle_sync(
|
|||||||
checksum,
|
checksum,
|
||||||
exclude_patterns: exclude,
|
exclude_patterns: exclude,
|
||||||
quiet,
|
quiet,
|
||||||
|
force,
|
||||||
};
|
};
|
||||||
|
|
||||||
if !quiet {
|
if !quiet {
|
||||||
@@ -1686,6 +1692,7 @@ async fn run() -> Result<()> {
|
|||||||
checksum,
|
checksum,
|
||||||
exclude,
|
exclude,
|
||||||
quiet,
|
quiet,
|
||||||
|
force,
|
||||||
recovery_key,
|
recovery_key,
|
||||||
} => {
|
} => {
|
||||||
handle_sync(
|
handle_sync(
|
||||||
@@ -1699,6 +1706,7 @@ async fn run() -> Result<()> {
|
|||||||
checksum,
|
checksum,
|
||||||
exclude,
|
exclude,
|
||||||
quiet,
|
quiet,
|
||||||
|
force,
|
||||||
recovery_key.as_deref(),
|
recovery_key.as_deref(),
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// 128-Bit Session-Token für Loopback-Schutz (CWE-306) & Anti-CSRF generieren
|
||||||
let mut token_bytes = [0u8; 16];
|
let mut token_bytes = [0u8; 16];
|
||||||
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut token_bytes);
|
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut token_bytes);
|
||||||
|
|||||||
+104
-2
@@ -61,6 +61,17 @@ pub struct UnlockedKeys(
|
|||||||
pub Option<i64>, // 4: Carrier Node ID (Inode der Alibi-Datei in nodes)
|
pub Option<i64>, // 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 {
|
impl UnlockedKeys {
|
||||||
pub fn dek(&self) -> &Zeroizing<[u8; 32]> {
|
pub fn dek(&self) -> &Zeroizing<[u8; 32]> {
|
||||||
&self.0
|
&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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -576,7 +592,10 @@ impl Database {
|
|||||||
header_nonce BLOB NOT NULL,
|
header_nonce BLOB NOT NULL,
|
||||||
header_tag BLOB NOT NULL,
|
header_tag BLOB NOT NULL,
|
||||||
metadata_mac BLOB,
|
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 (
|
CREATE TABLE IF NOT EXISTS nodes (
|
||||||
@@ -783,7 +802,10 @@ impl Database {
|
|||||||
header_nonce BLOB NOT NULL,
|
header_nonce BLOB NOT NULL,
|
||||||
header_tag BLOB NOT NULL,
|
header_tag BLOB NOT NULL,
|
||||||
metadata_mac BLOB,
|
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 (
|
CREATE TABLE IF NOT EXISTS nodes (
|
||||||
@@ -1820,6 +1842,86 @@ impl Database {
|
|||||||
Ok(())
|
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<Option<(u32, String, u64)>> {
|
||||||
|
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<i64> = row.get(0)?;
|
||||||
|
let host: Option<String> = row.get(1)?;
|
||||||
|
let time: Option<i64> = 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<AdvisoryLockGuard> {
|
||||||
|
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).
|
/// Erzeugt die deterministische kanonische Byterepräsentation aller Knoten für den Metadaten-MAC (K-01).
|
||||||
pub fn canonical_nodes_bytes(&self) -> Result<Vec<u8>> {
|
pub fn canonical_nodes_bytes(&self) -> Result<Vec<u8>> {
|
||||||
self.canonical_nodes_bytes_for_vault(0)
|
self.canonical_nodes_bytes_for_vault(0)
|
||||||
|
|||||||
+19
@@ -83,6 +83,7 @@ pub struct SyncOptions {
|
|||||||
pub checksum: bool,
|
pub checksum: bool,
|
||||||
pub exclude_patterns: Vec<String>,
|
pub exclude_patterns: Vec<String>,
|
||||||
pub quiet: bool,
|
pub quiet: bool,
|
||||||
|
pub force: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for SyncOptions {
|
impl Default for SyncOptions {
|
||||||
@@ -95,6 +96,7 @@ impl Default for SyncOptions {
|
|||||||
checksum: false,
|
checksum: false,
|
||||||
exclude_patterns: Vec::new(),
|
exclude_patterns: Vec::new(),
|
||||||
quiet: false,
|
quiet: false,
|
||||||
|
force: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -453,6 +455,23 @@ pub fn run_sync(
|
|||||||
let start_time = Instant::now();
|
let start_time = Instant::now();
|
||||||
let mut stats = SyncStats::default();
|
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));
|
db.set_active_dek(zeroize::Zeroizing::new(*dek));
|
||||||
|
|
||||||
match options.direction {
|
match options.direction {
|
||||||
|
|||||||
+28
-1
@@ -819,6 +819,33 @@ pub fn unmount_drive_wnet(_drive_letter: char) -> Result<()> {
|
|||||||
Ok(())
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -835,7 +862,7 @@ mod tests {
|
|||||||
fn test_find_next_available_drive() {
|
fn test_find_next_available_drive() {
|
||||||
let drive = find_next_available_drive().expect("Find next drive");
|
let drive = find_next_available_drive().expect("Find next drive");
|
||||||
assert!(drive.is_ascii_alphabetic());
|
assert!(drive.is_ascii_alphabetic());
|
||||||
assert!(drive >= 'D' && drive <= 'Z');
|
assert!(('D'..='Z').contains(&drive));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ fn test_carrier_protection_in_storage_and_sync() {
|
|||||||
checksum: false,
|
checksum: false,
|
||||||
exclude_patterns: Vec::new(),
|
exclude_patterns: Vec::new(),
|
||||||
quiet: true,
|
quiet: true,
|
||||||
|
force: false,
|
||||||
};
|
};
|
||||||
let pull_res = sanctum::sync::run_sync(
|
let pull_res = sanctum::sync::run_sync(
|
||||||
&db,
|
&db,
|
||||||
|
|||||||
@@ -786,3 +786,120 @@ fn test_s05_zero_byte_file_truncate_removes_all_chunks() {
|
|||||||
|
|
||||||
let _ = fs::remove_dir_all(&temp_root);
|
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::<u64>()));
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user