From 04939f0dcbda326b5ced4ea883e02a7733020d2b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 05:06:01 +0900 Subject: [PATCH 01/18] test: prove private evidence parent replacement race --- src-tauri/src/private_evidence.rs | 51 +++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/src-tauri/src/private_evidence.rs b/src-tauri/src/private_evidence.rs index b35e6e8a8..211dd6fb0 100644 --- a/src-tauri/src/private_evidence.rs +++ b/src-tauri/src/private_evidence.rs @@ -28,6 +28,19 @@ pub fn write_private_json_create_new( path: &Path, value: &impl Serialize, ) -> Result { + write_private_json_create_new_unix_with_hook(source_root, path, value, || {}) +} + +#[cfg(unix)] +fn write_private_json_create_new_unix_with_hook( + source_root: &Path, + path: &Path, + value: &impl Serialize, + before_create: F, +) -> Result +where + F: FnOnce(), +{ use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; let parent = path @@ -60,6 +73,7 @@ pub fn write_private_json_create_new( return Err("private-evidence-too-large".into()); } + before_create(); let mut file = std::fs::OpenOptions::new() .write(true) .create_new(true) @@ -164,4 +178,41 @@ mod tests { assert_eq!(error, "private-evidence-parent-writable-by-others"); assert!(!path.exists()); } + + #[cfg(unix)] + #[test] + fn fails_closed_if_private_parent_is_replaced_after_authorization() { + use std::os::unix::fs::PermissionsExt; + + let source = tempfile::tempdir().unwrap(); + let fixture = tempfile::tempdir().unwrap(); + let parent = fixture.path().join("records"); + let moved_parent = fixture.path().join("authorized-records-moved"); + std::fs::create_dir(&parent).unwrap(); + std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700)).unwrap(); + let path = parent.join("audit.json"); + let replacement_parent = parent.clone(); + let parent_for_hook = parent.clone(); + let moved_for_hook = moved_parent.clone(); + + let error = write_private_json_create_new_unix_with_hook( + source.path(), + &path, + &serde_json::json!({"private": true}), + move || { + std::fs::rename(&parent_for_hook, &moved_for_hook).unwrap(); + std::fs::create_dir(&parent_for_hook).unwrap(); + std::fs::set_permissions( + &parent_for_hook, + std::fs::Permissions::from_mode(0o700), + ) + .unwrap(); + }, + ) + .unwrap_err(); + + assert_eq!(error, "private-evidence-parent-identity-drift"); + assert!(!replacement_parent.join("audit.json").exists()); + assert!(!moved_parent.join("audit.json").exists()); + } } From 4a5bec73bccce9635e6cb92bf62591a0927fb507 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 05:09:43 +0900 Subject: [PATCH 02/18] fix: bind private evidence writes to opened parent --- src-tauri/src/private_evidence.rs | 126 +++++++++++++++++++++++++----- 1 file changed, 107 insertions(+), 19 deletions(-) diff --git a/src-tauri/src/private_evidence.rs b/src-tauri/src/private_evidence.rs index 211dd6fb0..8d7d71c03 100644 --- a/src-tauri/src/private_evidence.rs +++ b/src-tauri/src/private_evidence.rs @@ -20,8 +20,10 @@ pub struct PrivateEvidenceReceipt { /// Persist exact local evidence outside the audited source tree. /// /// The destination parent must already exist, must not be a symlink, and must not be writable by -/// group or other principals. The file is created once with mode 0600, synced, and never -/// overwritten. A failed write is removed before returning. +/// group or other principals. On Unix, publication is bound to the exact opened parent-directory +/// object so a same-user pathname replacement cannot redirect the write after authorization. The +/// file is created once with mode 0600, synced, and never overwritten. A failed publication is +/// cleaned through the authorized directory descriptor rather than through a mutable pathname. #[cfg(unix)] pub fn write_private_json_create_new( source_root: &Path, @@ -41,7 +43,10 @@ fn write_private_json_create_new_unix_with_hook( where F: FnOnce(), { - use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + use std::ffi::CString; + use std::os::fd::{AsRawFd, FromRawFd}; + use std::os::unix::ffi::OsStrExt; + use std::os::unix::fs::{MetadataExt, PermissionsExt}; let parent = path .parent() @@ -73,35 +78,118 @@ where return Err("private-evidence-too-large".into()); } + let parent_c = CString::new(canonical_parent.as_os_str().as_bytes()) + .map_err(|_| "private-evidence-parent-unavailable".to_string())?; + let file_name_c = CString::new(file_name.as_bytes()) + .map_err(|_| "private-evidence-name-invalid".to_string())?; + let directory_fd = unsafe { + libc::open( + parent_c.as_ptr(), + libc::O_RDONLY | libc::O_CLOEXEC | libc::O_DIRECTORY | libc::O_NOFOLLOW, + ) + }; + if directory_fd < 0 { + return Err("private-evidence-parent-unavailable".into()); + } + let directory = unsafe { std::fs::File::from_raw_fd(directory_fd) }; + let opened_parent_metadata = directory + .metadata() + .map_err(|_| "private-evidence-parent-unavailable".to_string())?; + if !opened_parent_metadata.is_dir() || opened_parent_metadata.file_type().is_symlink() { + return Err("private-evidence-parent-unsafe".into()); + } + if opened_parent_metadata.permissions().mode() & 0o022 != 0 { + return Err("private-evidence-parent-writable-by-others".into()); + } + let current_parent_metadata = std::fs::symlink_metadata(&canonical_parent) + .map_err(|_| "private-evidence-parent-identity-drift".to_string())?; + if current_parent_metadata.file_type().is_symlink() + || !current_parent_metadata.is_dir() + || current_parent_metadata.dev() != opened_parent_metadata.dev() + || current_parent_metadata.ino() != opened_parent_metadata.ino() + { + return Err("private-evidence-parent-identity-drift".into()); + } + before_create(); - let mut file = std::fs::OpenOptions::new() - .write(true) - .create_new(true) - .mode(0o600) - .open(&final_path) - .map_err(|_| "private-evidence-create-failed".to_string())?; - let result = (|| -> Result<(), String> { + + // Re-check the pathname immediately after the deterministic race seam. Publication itself is + // descriptor-relative, so even a later rename cannot redirect record creation to a new parent. + let parent_before_create = std::fs::symlink_metadata(&canonical_parent) + .map_err(|_| "private-evidence-parent-identity-drift".to_string())?; + if parent_before_create.file_type().is_symlink() + || !parent_before_create.is_dir() + || parent_before_create.dev() != opened_parent_metadata.dev() + || parent_before_create.ino() != opened_parent_metadata.ino() + { + return Err("private-evidence-parent-identity-drift".into()); + } + + let file_fd = unsafe { + libc::openat( + directory.as_raw_fd(), + file_name_c.as_ptr(), + libc::O_WRONLY + | libc::O_CREAT + | libc::O_EXCL + | libc::O_CLOEXEC + | libc::O_NOFOLLOW, + 0o600, + ) + }; + if file_fd < 0 { + return Err("private-evidence-create-failed".into()); + } + let mut file = unsafe { std::fs::File::from_raw_fd(file_fd) }; + + let publication = (|| -> Result<(), String> { file.write_all(&encoded) .and_then(|_| file.sync_all()) .map_err(|_| "private-evidence-write-failed".to_string())?; - let metadata = file + let opened_file_metadata = file .metadata() .map_err(|_| "private-evidence-metadata-failed".to_string())?; - if !metadata.is_file() - || metadata.file_type().is_symlink() - || metadata.permissions().mode() & 0o777 != 0o600 + if !opened_file_metadata.is_file() + || opened_file_metadata.file_type().is_symlink() + || opened_file_metadata.permissions().mode() & 0o777 != 0o600 { return Err("private-evidence-mode-invalid".into()); } - std::fs::File::open(&canonical_parent) - .and_then(|directory| directory.sync_all()) - .map_err(|_| "private-evidence-parent-sync-failed".to_string()) + directory + .sync_all() + .map_err(|_| "private-evidence-parent-sync-failed".to_string())?; + + let final_parent_metadata = std::fs::symlink_metadata(&canonical_parent) + .map_err(|_| "private-evidence-parent-identity-drift".to_string())?; + if final_parent_metadata.file_type().is_symlink() + || !final_parent_metadata.is_dir() + || final_parent_metadata.dev() != opened_parent_metadata.dev() + || final_parent_metadata.ino() != opened_parent_metadata.ino() + { + return Err("private-evidence-parent-identity-drift".into()); + } + + let final_file_metadata = std::fs::symlink_metadata(&final_path) + .map_err(|_| "private-evidence-record-identity-drift".to_string())?; + if final_file_metadata.file_type().is_symlink() + || !final_file_metadata.is_file() + || final_file_metadata.dev() != opened_file_metadata.dev() + || final_file_metadata.ino() != opened_file_metadata.ino() + { + return Err("private-evidence-record-identity-drift".into()); + } + Ok(()) })(); - if let Err(error) = result { + + if let Err(error) = publication { drop(file); - let _ = std::fs::remove_file(&final_path); + unsafe { + libc::unlinkat(directory.as_raw_fd(), file_name_c.as_ptr(), 0); + } + let _ = directory.sync_all(); return Err(error); } + let sha256 = Sha256::digest(&encoded) .iter() .map(|byte| format!("{byte:02x}")) From b22780d280f3f68f7ccb6febe43a9b9fdd6d547c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 05:18:43 +0900 Subject: [PATCH 03/18] test: prove private evidence parent permission drift --- src-tauri/src/private_evidence.rs | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src-tauri/src/private_evidence.rs b/src-tauri/src/private_evidence.rs index 8d7d71c03..b6528d7f7 100644 --- a/src-tauri/src/private_evidence.rs +++ b/src-tauri/src/private_evidence.rs @@ -267,6 +267,35 @@ mod tests { assert!(!path.exists()); } + #[cfg(unix)] + #[test] + fn fails_closed_if_parent_becomes_shared_writable_after_authorization() { + use std::os::unix::fs::PermissionsExt; + + let source = tempfile::tempdir().unwrap(); + let private = tempfile::tempdir().unwrap(); + std::fs::set_permissions(private.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + let path = private.path().join("audit.json"); + let parent_for_hook = private.path().to_path_buf(); + + let error = write_private_json_create_new_unix_with_hook( + source.path(), + &path, + &serde_json::json!({"private": true}), + move || { + std::fs::set_permissions( + &parent_for_hook, + std::fs::Permissions::from_mode(0o770), + ) + .unwrap(); + }, + ) + .unwrap_err(); + + assert_eq!(error, "private-evidence-parent-writable-by-others"); + assert!(!path.exists()); + } + #[cfg(unix)] #[test] fn fails_closed_if_private_parent_is_replaced_after_authorization() { From a17f460eeda8fab05932ec8c6b0fc713eb374a0c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 05:24:06 +0900 Subject: [PATCH 04/18] fix: revalidate private evidence parent permissions --- src-tauri/src/private_evidence.rs | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/private_evidence.rs b/src-tauri/src/private_evidence.rs index b6528d7f7..df9a316f5 100644 --- a/src-tauri/src/private_evidence.rs +++ b/src-tauri/src/private_evidence.rs @@ -113,8 +113,15 @@ where before_create(); - // Re-check the pathname immediately after the deterministic race seam. Publication itself is - // descriptor-relative, so even a later rename cannot redirect record creation to a new parent. + // Re-check both the opened object and its pathname immediately after the deterministic race + // seam. Publication itself is descriptor-relative, so a later rename cannot redirect record + // creation to a different parent. + let opened_parent_before_create = directory + .metadata() + .map_err(|_| "private-evidence-parent-unavailable".to_string())?; + if opened_parent_before_create.permissions().mode() & 0o022 != 0 { + return Err("private-evidence-parent-writable-by-others".into()); + } let parent_before_create = std::fs::symlink_metadata(&canonical_parent) .map_err(|_| "private-evidence-parent-identity-drift".to_string())?; if parent_before_create.file_type().is_symlink() @@ -124,6 +131,9 @@ where { return Err("private-evidence-parent-identity-drift".into()); } + if parent_before_create.permissions().mode() & 0o022 != 0 { + return Err("private-evidence-parent-writable-by-others".into()); + } let file_fd = unsafe { libc::openat( @@ -159,6 +169,12 @@ where .sync_all() .map_err(|_| "private-evidence-parent-sync-failed".to_string())?; + let opened_parent_after_sync = directory + .metadata() + .map_err(|_| "private-evidence-parent-unavailable".to_string())?; + if opened_parent_after_sync.permissions().mode() & 0o022 != 0 { + return Err("private-evidence-parent-writable-by-others".into()); + } let final_parent_metadata = std::fs::symlink_metadata(&canonical_parent) .map_err(|_| "private-evidence-parent-identity-drift".to_string())?; if final_parent_metadata.file_type().is_symlink() @@ -168,6 +184,9 @@ where { return Err("private-evidence-parent-identity-drift".into()); } + if final_parent_metadata.permissions().mode() & 0o022 != 0 { + return Err("private-evidence-parent-writable-by-others".into()); + } let final_file_metadata = std::fs::symlink_metadata(&final_path) .map_err(|_| "private-evidence-record-identity-drift".to_string())?; From fbac0eda5c07419c9ad834ef6b0cc8e3c8cf47e2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 06:22:30 +0900 Subject: [PATCH 05/18] test: preserve replaced private evidence on failed cleanup --- src-tauri/src/private_evidence.rs | 43 ++++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/private_evidence.rs b/src-tauri/src/private_evidence.rs index df9a316f5..d618bf6fb 100644 --- a/src-tauri/src/private_evidence.rs +++ b/src-tauri/src/private_evidence.rs @@ -30,18 +30,20 @@ pub fn write_private_json_create_new( path: &Path, value: &impl Serialize, ) -> Result { - write_private_json_create_new_unix_with_hook(source_root, path, value, || {}) + write_private_json_create_new_unix_with_hooks(source_root, path, value, || {}, || {}) } #[cfg(unix)] -fn write_private_json_create_new_unix_with_hook( +fn write_private_json_create_new_unix_with_hooks( source_root: &Path, path: &Path, value: &impl Serialize, before_create: F, + before_finalize: G, ) -> Result where F: FnOnce(), + G: FnOnce(), { use std::ffi::CString; use std::os::fd::{AsRawFd, FromRawFd}; @@ -169,6 +171,8 @@ where .sync_all() .map_err(|_| "private-evidence-parent-sync-failed".to_string())?; + before_finalize(); + let opened_parent_after_sync = directory .metadata() .map_err(|_| "private-evidence-parent-unavailable".to_string())?; @@ -297,7 +301,7 @@ mod tests { let path = private.path().join("audit.json"); let parent_for_hook = private.path().to_path_buf(); - let error = write_private_json_create_new_unix_with_hook( + let error = write_private_json_create_new_unix_with_hooks( source.path(), &path, &serde_json::json!({"private": true}), @@ -308,6 +312,7 @@ mod tests { ) .unwrap(); }, + || {}, ) .unwrap_err(); @@ -331,7 +336,7 @@ mod tests { let parent_for_hook = parent.clone(); let moved_for_hook = moved_parent.clone(); - let error = write_private_json_create_new_unix_with_hook( + let error = write_private_json_create_new_unix_with_hooks( source.path(), &path, &serde_json::json!({"private": true}), @@ -344,6 +349,7 @@ mod tests { ) .unwrap(); }, + || {}, ) .unwrap_err(); @@ -351,4 +357,33 @@ mod tests { assert!(!replacement_parent.join("audit.json").exists()); assert!(!moved_parent.join("audit.json").exists()); } + + #[cfg(unix)] + #[test] + fn failed_final_identity_check_preserves_unrelated_replacement_record() { + use std::os::unix::fs::PermissionsExt; + + let source = tempfile::tempdir().unwrap(); + let private = tempfile::tempdir().unwrap(); + std::fs::set_permissions(private.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + let path = private.path().join("audit.json"); + let path_for_hook = path.clone(); + let replacement = b"attacker-replacement".to_vec(); + let replacement_for_hook = replacement.clone(); + + let error = write_private_json_create_new_unix_with_hooks( + source.path(), + &path, + &serde_json::json!({"private": true}), + || {}, + move || { + std::fs::remove_file(&path_for_hook).unwrap(); + std::fs::write(&path_for_hook, &replacement_for_hook).unwrap(); + }, + ) + .unwrap_err(); + + assert_eq!(error, "private-evidence-record-identity-drift"); + assert_eq!(std::fs::read(&path).unwrap(), replacement); + } } From 7bb72ed3e387fc5b9a31093fae7e9f7367b9ddb7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 06:23:36 +0900 Subject: [PATCH 06/18] security: preserve identity-mismatched evidence replacements --- src-tauri/src/private_evidence.rs | 53 ++++++++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/private_evidence.rs b/src-tauri/src/private_evidence.rs index d618bf6fb..5775cdc03 100644 --- a/src-tauri/src/private_evidence.rs +++ b/src-tauri/src/private_evidence.rs @@ -17,13 +17,56 @@ pub struct PrivateEvidenceReceipt { pub is_approval: bool, } +/// Remove a failed publication only when the current directory entry still names the exact file +/// object created by this writer. +/// +/// A same-user actor can unlink the created record and install another object at the same name +/// before final validation. Descriptor-relative cleanup must not delete that replacement. If the +/// current entry cannot be inspected or its device/inode identity differs from the opened file, +/// cleanup fails closed by leaving the current entry untouched. +#[cfg(unix)] +fn unlink_failed_record_if_same_identity( + directory: &std::fs::File, + file_name: &std::ffi::CString, + opened_file_metadata: &std::fs::Metadata, +) { + use std::mem::MaybeUninit; + use std::os::fd::AsRawFd; + use std::os::unix::fs::MetadataExt; + + let mut current_stat = MaybeUninit::::uninit(); + let stat_result = unsafe { + libc::fstatat( + directory.as_raw_fd(), + file_name.as_ptr(), + current_stat.as_mut_ptr(), + libc::AT_SYMLINK_NOFOLLOW, + ) + }; + if stat_result != 0 { + return; + } + let current_stat = unsafe { current_stat.assume_init() }; + if current_stat.st_dev as u64 != opened_file_metadata.dev() + || current_stat.st_ino as u64 != opened_file_metadata.ino() + { + return; + } + + let unlink_result = unsafe { libc::unlinkat(directory.as_raw_fd(), file_name.as_ptr(), 0) }; + if unlink_result == 0 { + let _ = directory.sync_all(); + } +} + /// Persist exact local evidence outside the audited source tree. /// /// The destination parent must already exist, must not be a symlink, and must not be writable by /// group or other principals. On Unix, publication is bound to the exact opened parent-directory /// object so a same-user pathname replacement cannot redirect the write after authorization. The -/// file is created once with mode 0600, synced, and never overwritten. A failed publication is -/// cleaned through the authorized directory descriptor rather than through a mutable pathname. +/// file is created once with mode 0600, synced, and never overwritten. A failed publication removes +/// only the exact record object created by this writer; an identity-mismatched replacement is left +/// untouched. #[cfg(unix)] pub fn write_private_json_create_new( source_root: &Path, @@ -205,11 +248,11 @@ where })(); if let Err(error) = publication { + let opened_file_metadata = file.metadata().ok(); drop(file); - unsafe { - libc::unlinkat(directory.as_raw_fd(), file_name_c.as_ptr(), 0); + if let Some(opened_file_metadata) = opened_file_metadata.as_ref() { + unlink_failed_record_if_same_identity(&directory, &file_name_c, opened_file_metadata); } - let _ = directory.sync_all(); return Err(error); } From d00128fb08697a44228f8f566d84fdfc0a5d33d4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 06:28:21 +0900 Subject: [PATCH 07/18] security: invalidate failed evidence through opened descriptor --- src-tauri/src/private_evidence.rs | 94 +++++++++++++++---------------- 1 file changed, 45 insertions(+), 49 deletions(-) diff --git a/src-tauri/src/private_evidence.rs b/src-tauri/src/private_evidence.rs index 5775cdc03..4f3ff8a07 100644 --- a/src-tauri/src/private_evidence.rs +++ b/src-tauri/src/private_evidence.rs @@ -17,56 +17,15 @@ pub struct PrivateEvidenceReceipt { pub is_approval: bool, } -/// Remove a failed publication only when the current directory entry still names the exact file -/// object created by this writer. -/// -/// A same-user actor can unlink the created record and install another object at the same name -/// before final validation. Descriptor-relative cleanup must not delete that replacement. If the -/// current entry cannot be inspected or its device/inode identity differs from the opened file, -/// cleanup fails closed by leaving the current entry untouched. -#[cfg(unix)] -fn unlink_failed_record_if_same_identity( - directory: &std::fs::File, - file_name: &std::ffi::CString, - opened_file_metadata: &std::fs::Metadata, -) { - use std::mem::MaybeUninit; - use std::os::fd::AsRawFd; - use std::os::unix::fs::MetadataExt; - - let mut current_stat = MaybeUninit::::uninit(); - let stat_result = unsafe { - libc::fstatat( - directory.as_raw_fd(), - file_name.as_ptr(), - current_stat.as_mut_ptr(), - libc::AT_SYMLINK_NOFOLLOW, - ) - }; - if stat_result != 0 { - return; - } - let current_stat = unsafe { current_stat.assume_init() }; - if current_stat.st_dev as u64 != opened_file_metadata.dev() - || current_stat.st_ino as u64 != opened_file_metadata.ino() - { - return; - } - - let unlink_result = unsafe { libc::unlinkat(directory.as_raw_fd(), file_name.as_ptr(), 0) }; - if unlink_result == 0 { - let _ = directory.sync_all(); - } -} - /// Persist exact local evidence outside the audited source tree. /// /// The destination parent must already exist, must not be a symlink, and must not be writable by /// group or other principals. On Unix, publication is bound to the exact opened parent-directory /// object so a same-user pathname replacement cannot redirect the write after authorization. The -/// file is created once with mode 0600, synced, and never overwritten. A failed publication removes -/// only the exact record object created by this writer; an identity-mismatched replacement is left -/// untouched. +/// file is created once with mode 0600, synced, and never overwritten. After a post-create failure, +/// the still-open record is truncated and synced through its descriptor. The pathname is +/// deliberately not unlinked because a same-user process may already have replaced that name; this +/// can leave a zero-length mode-0600 create-new tombstone that requires explicit operator cleanup. #[cfg(unix)] pub fn write_private_json_create_new( source_root: &Path, @@ -248,10 +207,15 @@ where })(); if let Err(error) = publication { - let opened_file_metadata = file.metadata().ok(); - drop(file); - if let Some(opened_file_metadata) = opened_file_metadata.as_ref() { - unlink_failed_record_if_same_identity(&directory, &file_name_c, opened_file_metadata); + // Do not unlink by name here. No portable Unix primitive atomically proves that the name + // still identifies this open file at unlink time, so a stat-then-unlink sequence would + // retain a replacement race. Invalidate only the exact object held by this descriptor. + let invalidation = file + .set_len(0) + .and_then(|_| file.sync_all()) + .and_then(|_| directory.sync_all()); + if invalidation.is_err() { + return Err("private-evidence-invalidation-failed".into()); } return Err(error); } @@ -363,6 +327,38 @@ mod tests { assert!(!path.exists()); } + #[cfg(unix)] + #[test] + fn failed_post_create_validation_leaves_private_zero_length_tombstone() { + use std::os::unix::fs::PermissionsExt; + + let source = tempfile::tempdir().unwrap(); + let private = tempfile::tempdir().unwrap(); + std::fs::set_permissions(private.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + let path = private.path().join("audit.json"); + let parent_for_hook = private.path().to_path_buf(); + + let error = write_private_json_create_new_unix_with_hooks( + source.path(), + &path, + &serde_json::json!({"private": true}), + || {}, + move || { + std::fs::set_permissions( + &parent_for_hook, + std::fs::Permissions::from_mode(0o770), + ) + .unwrap(); + }, + ) + .unwrap_err(); + + assert_eq!(error, "private-evidence-parent-writable-by-others"); + let metadata = std::fs::metadata(&path).unwrap(); + assert_eq!(metadata.len(), 0); + assert_eq!(metadata.permissions().mode() & 0o777, 0o600); + } + #[cfg(unix)] #[test] fn fails_closed_if_private_parent_is_replaced_after_authorization() { From f760b74d70ce8eed456180532fd0dbebabf670d5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 15:14:21 +0900 Subject: [PATCH 08/18] test: prove private evidence mode survives restrictive umask --- src-tauri/tests/private_evidence_umask.rs | 59 +++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 src-tauri/tests/private_evidence_umask.rs diff --git a/src-tauri/tests/private_evidence_umask.rs b/src-tauri/tests/private_evidence_umask.rs new file mode 100644 index 000000000..4d995ec09 --- /dev/null +++ b/src-tauri/tests/private_evidence_umask.rs @@ -0,0 +1,59 @@ +//! Regression coverage for private-evidence creation under a restrictive process umask. +//! +//! The Unix `openat(O_CREAT, 0o600)` mode argument is filtered by the process umask. DiskSage +//! promises a durable private evidence object with exact mode `0600`, so publication must explicitly +//! harden the already-opened descriptor rather than relying on the creation request alone. + +#![cfg(unix)] + +use disksage_lib::private_evidence::write_private_json_create_new; +use std::os::unix::fs::PermissionsExt; + +const CHILD_ENV: &str = "DISKSAGE_PRIVATE_EVIDENCE_RESTRICTIVE_UMASK_CHILD"; + +#[test] +fn restrictive_umask_still_publishes_mode_0600() { + if std::env::var_os(CHILD_ENV).is_none() { + let status = std::process::Command::new(std::env::current_exe().expect("test executable")) + .arg("--exact") + .arg("restrictive_umask_still_publishes_mode_0600") + .arg("--nocapture") + .env(CHILD_ENV, "1") + .status() + .expect("spawn isolated restrictive-umask test process"); + assert!( + status.success(), + "private evidence publication must succeed under a restrictive umask" + ); + return; + } + + // Isolate the process-global umask in this child test process so concurrently executing tests + // cannot observe the temporary mask. Removing owner-write makes a raw openat(..., 0o600) + // create mode 0400 and reproduces the production failure boundary. + unsafe { + libc::umask(0o200); + } + + let source = tempfile::tempdir().expect("source tempdir"); + let private = tempfile::tempdir().expect("private tempdir"); + let path = private.path().join("audit.json"); + let receipt = write_private_json_create_new( + source.path(), + &path, + &serde_json::json!({"private": true}), + ) + .expect("publication must normalize the opened file to mode 0600"); + + assert!(receipt.written); + assert_eq!(receipt.unix_mode, "0600"); + assert_eq!( + std::fs::metadata(&path) + .expect("published evidence metadata") + .permissions() + .mode() + & 0o777, + 0o600 + ); + assert!(std::fs::metadata(&path).expect("published evidence").len() > 0); +} From 706da5b7170cab460062bd2b7a0ba3c5a96edfd3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 15:22:16 +0900 Subject: [PATCH 09/18] fix: normalize private evidence mode after open --- src-tauri/src/private_evidence.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src-tauri/src/private_evidence.rs b/src-tauri/src/private_evidence.rs index 4f3ff8a07..03caf4a0d 100644 --- a/src-tauri/src/private_evidence.rs +++ b/src-tauri/src/private_evidence.rs @@ -157,6 +157,11 @@ where let mut file = unsafe { std::fs::File::from_raw_fd(file_fd) }; let publication = (|| -> Result<(), String> { + // `openat` applies the process umask to its requested creation mode. Normalize the exact + // already-opened record descriptor before writing so restrictive masks cannot create an + // unreadable tombstone and permanently block this create-new evidence path. + file.set_permissions(std::fs::Permissions::from_mode(0o600)) + .map_err(|_| "private-evidence-mode-invalid".to_string())?; file.write_all(&encoded) .and_then(|_| file.sync_all()) .map_err(|_| "private-evidence-write-failed".to_string())?; From 2d1f018376f87bc69f1236e648c7510fd08c3eea Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 17:08:06 +0900 Subject: [PATCH 10/18] test: isolate restrictive umask to record creation --- src-tauri/tests/private_evidence_umask.rs | 24 +++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src-tauri/tests/private_evidence_umask.rs b/src-tauri/tests/private_evidence_umask.rs index 4d995ec09..b8b871181 100644 --- a/src-tauri/tests/private_evidence_umask.rs +++ b/src-tauri/tests/private_evidence_umask.rs @@ -28,22 +28,26 @@ fn restrictive_umask_still_publishes_mode_0600() { return; } - // Isolate the process-global umask in this child test process so concurrently executing tests - // cannot observe the temporary mask. Removing owner-write makes a raw openat(..., 0o600) - // create mode 0400 and reproduces the production failure boundary. - unsafe { - libc::umask(0o200); - } - + // Build writable fixtures before changing the process-global umask. Applying 0o200 while + // tempfile creates its private directories can remove owner-write permission from the parent + // itself, which tests directory writability rather than the record-creation boundary. let source = tempfile::tempdir().expect("source tempdir"); let private = tempfile::tempdir().expect("private tempdir"); let path = private.path().join("audit.json"); - let receipt = write_private_json_create_new( + + // Isolate the process-global umask in this child test process so concurrently executing tests + // cannot observe the temporary mask. Removing owner-write makes a raw openat(..., 0o600) + // create mode 0400 and reproduces the production file-mode boundary. + let previous_umask = unsafe { libc::umask(0o200) }; + let publication = write_private_json_create_new( source.path(), &path, &serde_json::json!({"private": true}), - ) - .expect("publication must normalize the opened file to mode 0600"); + ); + unsafe { + libc::umask(previous_umask); + } + let receipt = publication.expect("publication must normalize the opened file to mode 0600"); assert!(receipt.written); assert_eq!(receipt.unix_mode, "0600"); From 91d2a44cedd550ad1851328aae0a9beb08f1415a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 19:10:16 +0900 Subject: [PATCH 11/18] test: cover post-create parent replacement --- src-tauri/src/private_evidence.rs | 44 +++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src-tauri/src/private_evidence.rs b/src-tauri/src/private_evidence.rs index 03caf4a0d..9917d005d 100644 --- a/src-tauri/src/private_evidence.rs +++ b/src-tauri/src/private_evidence.rs @@ -402,6 +402,50 @@ mod tests { assert!(!moved_parent.join("audit.json").exists()); } + #[cfg(unix)] + #[test] + fn post_create_parent_replacement_invalidates_only_authorized_record() { + use std::os::unix::fs::PermissionsExt; + + let source = tempfile::tempdir().unwrap(); + let fixture = tempfile::tempdir().unwrap(); + let parent = fixture.path().join("records"); + let moved_parent = fixture.path().join("authorized-records-moved"); + std::fs::create_dir(&parent).unwrap(); + std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700)).unwrap(); + let path = parent.join("audit.json"); + let replacement_parent = parent.clone(); + let parent_for_hook = parent.clone(); + let moved_for_hook = moved_parent.clone(); + + let error = write_private_json_create_new_unix_with_hooks( + source.path(), + &path, + &serde_json::json!({"private": true}), + || {}, + move || { + std::fs::rename(&parent_for_hook, &moved_for_hook).unwrap(); + std::fs::create_dir(&parent_for_hook).unwrap(); + std::fs::set_permissions( + &parent_for_hook, + std::fs::Permissions::from_mode(0o700), + ) + .unwrap(); + }, + ) + .unwrap_err(); + + assert_eq!(error, "private-evidence-parent-identity-drift"); + assert!( + !replacement_parent.join("audit.json").exists(), + "replacement directory must never receive the authorized record" + ); + let tombstone = moved_parent.join("audit.json"); + let metadata = std::fs::metadata(&tombstone).unwrap(); + assert_eq!(metadata.len(), 0, "authorized record must be invalidated"); + assert_eq!(metadata.permissions().mode() & 0o777, 0o600); + } + #[cfg(unix)] #[test] fn failed_final_identity_check_preserves_unrelated_replacement_record() { From ce17c7adeecc8a9e9e6c1ebf54a94b468048d443 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 19:12:36 +0900 Subject: [PATCH 12/18] refactor: centralize private parent validation --- src-tauri/src/private_evidence.rs | 103 +++++++++++++++--------------- 1 file changed, 52 insertions(+), 51 deletions(-) diff --git a/src-tauri/src/private_evidence.rs b/src-tauri/src/private_evidence.rs index 9917d005d..b3b2d5bb1 100644 --- a/src-tauri/src/private_evidence.rs +++ b/src-tauri/src/private_evidence.rs @@ -17,6 +17,40 @@ pub struct PrivateEvidenceReceipt { pub is_approval: bool, } +#[cfg(unix)] +fn revalidate_private_parent( + directory: &std::fs::File, + canonical_parent: &Path, + expected_dev: u64, + expected_ino: u64, +) -> Result<(), String> { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + let opened = directory + .metadata() + .map_err(|_| "private-evidence-parent-unavailable".to_string())?; + if !opened.is_dir() || opened.file_type().is_symlink() { + return Err("private-evidence-parent-unsafe".into()); + } + if opened.permissions().mode() & 0o022 != 0 { + return Err("private-evidence-parent-writable-by-others".into()); + } + + let named = std::fs::symlink_metadata(canonical_parent) + .map_err(|_| "private-evidence-parent-identity-drift".to_string())?; + if named.file_type().is_symlink() + || !named.is_dir() + || named.dev() != expected_dev + || named.ino() != expected_ino + { + return Err("private-evidence-parent-identity-drift".into()); + } + if named.permissions().mode() & 0o022 != 0 { + return Err("private-evidence-parent-writable-by-others".into()); + } + Ok(()) +} + /// Persist exact local evidence outside the audited source tree. /// /// The destination parent must already exist, must not be a symlink, and must not be writable by @@ -99,45 +133,24 @@ where let opened_parent_metadata = directory .metadata() .map_err(|_| "private-evidence-parent-unavailable".to_string())?; - if !opened_parent_metadata.is_dir() || opened_parent_metadata.file_type().is_symlink() { - return Err("private-evidence-parent-unsafe".into()); - } - if opened_parent_metadata.permissions().mode() & 0o022 != 0 { - return Err("private-evidence-parent-writable-by-others".into()); - } - let current_parent_metadata = std::fs::symlink_metadata(&canonical_parent) - .map_err(|_| "private-evidence-parent-identity-drift".to_string())?; - if current_parent_metadata.file_type().is_symlink() - || !current_parent_metadata.is_dir() - || current_parent_metadata.dev() != opened_parent_metadata.dev() - || current_parent_metadata.ino() != opened_parent_metadata.ino() - { - return Err("private-evidence-parent-identity-drift".into()); - } + revalidate_private_parent( + &directory, + &canonical_parent, + opened_parent_metadata.dev(), + opened_parent_metadata.ino(), + )?; before_create(); // Re-check both the opened object and its pathname immediately after the deterministic race // seam. Publication itself is descriptor-relative, so a later rename cannot redirect record // creation to a different parent. - let opened_parent_before_create = directory - .metadata() - .map_err(|_| "private-evidence-parent-unavailable".to_string())?; - if opened_parent_before_create.permissions().mode() & 0o022 != 0 { - return Err("private-evidence-parent-writable-by-others".into()); - } - let parent_before_create = std::fs::symlink_metadata(&canonical_parent) - .map_err(|_| "private-evidence-parent-identity-drift".to_string())?; - if parent_before_create.file_type().is_symlink() - || !parent_before_create.is_dir() - || parent_before_create.dev() != opened_parent_metadata.dev() - || parent_before_create.ino() != opened_parent_metadata.ino() - { - return Err("private-evidence-parent-identity-drift".into()); - } - if parent_before_create.permissions().mode() & 0o022 != 0 { - return Err("private-evidence-parent-writable-by-others".into()); - } + revalidate_private_parent( + &directory, + &canonical_parent, + opened_parent_metadata.dev(), + opened_parent_metadata.ino(), + )?; let file_fd = unsafe { libc::openat( @@ -180,24 +193,12 @@ where before_finalize(); - let opened_parent_after_sync = directory - .metadata() - .map_err(|_| "private-evidence-parent-unavailable".to_string())?; - if opened_parent_after_sync.permissions().mode() & 0o022 != 0 { - return Err("private-evidence-parent-writable-by-others".into()); - } - let final_parent_metadata = std::fs::symlink_metadata(&canonical_parent) - .map_err(|_| "private-evidence-parent-identity-drift".to_string())?; - if final_parent_metadata.file_type().is_symlink() - || !final_parent_metadata.is_dir() - || final_parent_metadata.dev() != opened_parent_metadata.dev() - || final_parent_metadata.ino() != opened_parent_metadata.ino() - { - return Err("private-evidence-parent-identity-drift".into()); - } - if final_parent_metadata.permissions().mode() & 0o022 != 0 { - return Err("private-evidence-parent-writable-by-others".into()); - } + revalidate_private_parent( + &directory, + &canonical_parent, + opened_parent_metadata.dev(), + opened_parent_metadata.ino(), + )?; let final_file_metadata = std::fs::symlink_metadata(&final_path) .map_err(|_| "private-evidence-record-identity-drift".to_string())?; From 173f68d328a7f1c5ac3bd94da6d0ec7eba2f3e7b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 03:46:35 -0700 Subject: [PATCH 13/18] fix(stack): preserve current audit root in private evidence owner --- src-tauri/src/brew_cleanup.rs | 19 +++++++++++++++++ .../src/brew_cleanup_audit_authority_tests.rs | 21 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src-tauri/src/brew_cleanup.rs b/src-tauri/src/brew_cleanup.rs index 1f67b6220..e7a52a912 100644 --- a/src-tauri/src/brew_cleanup.rs +++ b/src-tauri/src/brew_cleanup.rs @@ -430,6 +430,23 @@ pub fn execute( const MAX_AUDIT_BYTES: usize = 128 * 1024; +fn validate_audit_parent_ancestors(app_data_dir: &Path, allow_missing: bool) -> Result<(), String> { + for ancestor in app_data_dir + .ancestors() + .filter(|ancestor| !ancestor.as_os_str().is_empty()) + { + match std::fs::symlink_metadata(ancestor) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => { + return Err("brew-cleanup-audit-parent-unsafe".into()); + } + Ok(_) => {} + Err(error) if allow_missing && error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return Err("brew-cleanup-audit-parent-unavailable".into()), + } + } + Ok(()) +} + fn audit_directory(app_data_dir: &Path) -> Result { if !app_data_dir.is_absolute() || app_data_dir @@ -438,8 +455,10 @@ fn audit_directory(app_data_dir: &Path) -> Result { { return Err("brew-cleanup-audit-directory-invalid".into()); } + validate_audit_parent_ancestors(app_data_dir, true)?; std::fs::create_dir_all(app_data_dir) .map_err(|_| "brew-cleanup-audit-parent-create-failed".to_string())?; + validate_audit_parent_ancestors(app_data_dir, false)?; let parent = std::fs::symlink_metadata(app_data_dir) .map_err(|_| "brew-cleanup-audit-parent-unavailable".to_string())?; if parent.file_type().is_symlink() || !parent.is_dir() { diff --git a/src-tauri/src/brew_cleanup_audit_authority_tests.rs b/src-tauri/src/brew_cleanup_audit_authority_tests.rs index a2b6f1ffc..e617b74b7 100644 --- a/src-tauri/src/brew_cleanup_audit_authority_tests.rs +++ b/src-tauri/src/brew_cleanup_audit_authority_tests.rs @@ -71,6 +71,27 @@ fn shared_writable_app_data_parent_fails_closed_without_creating_audit_storage() } } +#[test] +fn symlinked_app_data_ancestor_fails_closed_before_creating_external_storage() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().expect("temporary authority root"); + let outside = temp.path().join("outside"); + std::fs::create_dir(&outside).expect("create external target directory"); + let alias = temp.path().join("app-data-alias"); + symlink(&outside, &alias).expect("create symlinked app-data ancestor"); + let app_data = alias.join("nested-app-data"); + + let error = write_audit_record(&app_data, &valid_record()) + .expect_err("symlinked app-data ancestor must fail closed"); + + assert_eq!(error, "brew-cleanup-audit-parent-unsafe"); + assert!( + !outside.join("nested-app-data").exists(), + "authority admission must reject the symlink before creating storage outside app-data" + ); +} + #[test] fn shared_writable_audit_directory_fails_closed_without_creating_a_record() { for unsafe_write_bit in [0o020, 0o002] { From 017d61bfcb5c02afd9176612f506bff44c426400 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 12:12:22 -0700 Subject: [PATCH 14/18] test: expose pre-open private evidence parent race --- src-tauri/src/private_evidence.rs | 56 ++++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/private_evidence.rs b/src-tauri/src/private_evidence.rs index b3b2d5bb1..105dd7a9d 100644 --- a/src-tauri/src/private_evidence.rs +++ b/src-tauri/src/private_evidence.rs @@ -66,20 +66,22 @@ pub fn write_private_json_create_new( path: &Path, value: &impl Serialize, ) -> Result { - write_private_json_create_new_unix_with_hooks(source_root, path, value, || {}, || {}) + write_private_json_create_new_unix_with_hooks(source_root, path, value, || {}, || {}, || {}) } #[cfg(unix)] -fn write_private_json_create_new_unix_with_hooks( +fn write_private_json_create_new_unix_with_hooks( source_root: &Path, path: &Path, value: &impl Serialize, - before_create: F, - before_finalize: G, + before_parent_open: F, + before_create: G, + before_finalize: H, ) -> Result where F: FnOnce(), G: FnOnce(), + H: FnOnce(), { use std::ffi::CString; use std::os::fd::{AsRawFd, FromRawFd}; @@ -98,6 +100,9 @@ where if parent_metadata.permissions().mode() & 0o022 != 0 { return Err("private-evidence-parent-writable-by-others".into()); } + + before_parent_open(); + let canonical_parent = std::fs::canonicalize(parent) .map_err(|_| "private-evidence-parent-unavailable".to_string())?; let canonical_source = std::fs::canonicalize(source_root) @@ -303,6 +308,44 @@ mod tests { assert!(!path.exists()); } + #[cfg(unix)] + #[test] + fn fails_closed_if_parent_is_replaced_before_open() { + use std::os::unix::fs::PermissionsExt; + + let source = tempfile::tempdir().unwrap(); + let fixture = tempfile::tempdir().unwrap(); + let parent = fixture.path().join("records"); + let moved_parent = fixture.path().join("authorized-records-moved"); + std::fs::create_dir(&parent).unwrap(); + std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700)).unwrap(); + let path = parent.join("audit.json"); + let parent_for_hook = parent.clone(); + let moved_for_hook = moved_parent.clone(); + + let error = write_private_json_create_new_unix_with_hooks( + source.path(), + &path, + &serde_json::json!({"private": true}), + move || { + std::fs::rename(&parent_for_hook, &moved_for_hook).unwrap(); + std::fs::create_dir(&parent_for_hook).unwrap(); + std::fs::set_permissions( + &parent_for_hook, + std::fs::Permissions::from_mode(0o700), + ) + .unwrap(); + }, + || {}, + || {}, + ) + .unwrap_err(); + + assert_eq!(error, "private-evidence-parent-identity-drift"); + assert!(!parent.join("audit.json").exists()); + assert!(!moved_parent.join("audit.json").exists()); + } + #[cfg(unix)] #[test] fn fails_closed_if_parent_becomes_shared_writable_after_authorization() { @@ -318,6 +361,7 @@ mod tests { source.path(), &path, &serde_json::json!({"private": true}), + || {}, move || { std::fs::set_permissions( &parent_for_hook, @@ -349,6 +393,7 @@ mod tests { &path, &serde_json::json!({"private": true}), || {}, + || {}, move || { std::fs::set_permissions( &parent_for_hook, @@ -385,6 +430,7 @@ mod tests { source.path(), &path, &serde_json::json!({"private": true}), + || {}, move || { std::fs::rename(&parent_for_hook, &moved_for_hook).unwrap(); std::fs::create_dir(&parent_for_hook).unwrap(); @@ -424,6 +470,7 @@ mod tests { &path, &serde_json::json!({"private": true}), || {}, + || {}, move || { std::fs::rename(&parent_for_hook, &moved_for_hook).unwrap(); std::fs::create_dir(&parent_for_hook).unwrap(); @@ -465,6 +512,7 @@ mod tests { &path, &serde_json::json!({"private": true}), || {}, + || {}, move || { std::fs::remove_file(&path_for_hook).unwrap(); std::fs::write(&path_for_hook, &replacement_for_hook).unwrap(); From cf9b8d0f30dde23bcd81ba8c554f277bd852f39b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 12:13:28 -0700 Subject: [PATCH 15/18] fix: bind private evidence parent before canonicalization --- src-tauri/src/private_evidence.rs | 77 ++++++++++++++++++------------- 1 file changed, 45 insertions(+), 32 deletions(-) diff --git a/src-tauri/src/private_evidence.rs b/src-tauri/src/private_evidence.rs index 105dd7a9d..57cb8a5a2 100644 --- a/src-tauri/src/private_evidence.rs +++ b/src-tauri/src/private_evidence.rs @@ -54,12 +54,13 @@ fn revalidate_private_parent( /// Persist exact local evidence outside the audited source tree. /// /// The destination parent must already exist, must not be a symlink, and must not be writable by -/// group or other principals. On Unix, publication is bound to the exact opened parent-directory -/// object so a same-user pathname replacement cannot redirect the write after authorization. The -/// file is created once with mode 0600, synced, and never overwritten. After a post-create failure, -/// the still-open record is truncated and synced through its descriptor. The pathname is -/// deliberately not unlinked because a same-user process may already have replaced that name; this -/// can leave a zero-length mode-0600 create-new tombstone that requires explicit operator cleanup. +/// group or other principals. On Unix, publication is bound to the exact caller-supplied parent +/// directory object admitted before canonicalization, so a same-user pathname replacement cannot +/// redirect either canonicalization or the later write. The file is created once with mode 0600, +/// synced, and never overwritten. After a post-create failure, the still-open record is truncated +/// and synced through its descriptor. The pathname is deliberately not unlinked because a same-user +/// process may already have replaced that name; this can leave a zero-length mode-0600 create-new +/// tombstone that requires explicit operator cleanup. #[cfg(unix)] pub fn write_private_json_create_new( source_root: &Path, @@ -100,11 +101,44 @@ where if parent_metadata.permissions().mode() & 0o022 != 0 { return Err("private-evidence-parent-writable-by-others".into()); } + let expected_parent_dev = parent_metadata.dev(); + let expected_parent_ino = parent_metadata.ino(); before_parent_open(); - let canonical_parent = std::fs::canonicalize(parent) + // Open the caller-supplied pathname before canonicalizing it and bind all later authority to + // the object admitted above. If the pathname was replaced in this window, O_NOFOLLOW rejects a + // symlink replacement and the device/inode comparison rejects a different directory object. + let parent_c = CString::new(parent.as_os_str().as_bytes()) .map_err(|_| "private-evidence-parent-unavailable".to_string())?; + let directory_fd = unsafe { + libc::open( + parent_c.as_ptr(), + libc::O_RDONLY | libc::O_CLOEXEC | libc::O_DIRECTORY | libc::O_NOFOLLOW, + ) + }; + if directory_fd < 0 { + return Err("private-evidence-parent-identity-drift".into()); + } + let directory = unsafe { std::fs::File::from_raw_fd(directory_fd) }; + let opened_parent_metadata = directory + .metadata() + .map_err(|_| "private-evidence-parent-unavailable".to_string())?; + if opened_parent_metadata.dev() != expected_parent_dev + || opened_parent_metadata.ino() != expected_parent_ino + { + return Err("private-evidence-parent-identity-drift".into()); + } + + let canonical_parent = std::fs::canonicalize(parent) + .map_err(|_| "private-evidence-parent-identity-drift".to_string())?; + revalidate_private_parent( + &directory, + &canonical_parent, + expected_parent_dev, + expected_parent_ino, + )?; + let canonical_source = std::fs::canonicalize(source_root) .map_err(|_| "private-evidence-source-root-unavailable".to_string())?; if canonical_parent.starts_with(&canonical_source) { @@ -121,29 +155,8 @@ where return Err("private-evidence-too-large".into()); } - let parent_c = CString::new(canonical_parent.as_os_str().as_bytes()) - .map_err(|_| "private-evidence-parent-unavailable".to_string())?; let file_name_c = CString::new(file_name.as_bytes()) .map_err(|_| "private-evidence-name-invalid".to_string())?; - let directory_fd = unsafe { - libc::open( - parent_c.as_ptr(), - libc::O_RDONLY | libc::O_CLOEXEC | libc::O_DIRECTORY | libc::O_NOFOLLOW, - ) - }; - if directory_fd < 0 { - return Err("private-evidence-parent-unavailable".into()); - } - let directory = unsafe { std::fs::File::from_raw_fd(directory_fd) }; - let opened_parent_metadata = directory - .metadata() - .map_err(|_| "private-evidence-parent-unavailable".to_string())?; - revalidate_private_parent( - &directory, - &canonical_parent, - opened_parent_metadata.dev(), - opened_parent_metadata.ino(), - )?; before_create(); @@ -153,8 +166,8 @@ where revalidate_private_parent( &directory, &canonical_parent, - opened_parent_metadata.dev(), - opened_parent_metadata.ino(), + expected_parent_dev, + expected_parent_ino, )?; let file_fd = unsafe { @@ -201,8 +214,8 @@ where revalidate_private_parent( &directory, &canonical_parent, - opened_parent_metadata.dev(), - opened_parent_metadata.ino(), + expected_parent_dev, + expected_parent_ino, )?; let final_file_metadata = std::fs::symlink_metadata(&final_path) From 33a93830f5f38cbf40c89eac7b68b688017ea457 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 12:16:22 -0700 Subject: [PATCH 16/18] refactor: expose object-bound evidence publication primitive --- src-tauri/src/private_evidence.rs | 242 +++++++++++++++++++++--------- 1 file changed, 169 insertions(+), 73 deletions(-) diff --git a/src-tauri/src/private_evidence.rs b/src-tauri/src/private_evidence.rs index 57cb8a5a2..5e1b66dc6 100644 --- a/src-tauri/src/private_evidence.rs +++ b/src-tauri/src/private_evidence.rs @@ -17,68 +17,128 @@ pub struct PrivateEvidenceReceipt { pub is_approval: bool, } +#[cfg(unix)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ObjectBoundPublicationError { + ParentMissing, + ParentUnavailable, + ParentUnsafe, + ParentWritableByOthers, + ParentIdentityDrift, + ForbiddenRootUnavailable, + InsideForbiddenRoot, + NameInvalid, + CreateFailed, + ModeInvalid, + WriteFailed, + MetadataFailed, + ParentSyncFailed, + RecordIdentityDrift, + InvalidationFailed, +} + #[cfg(unix)] fn revalidate_private_parent( directory: &std::fs::File, canonical_parent: &Path, expected_dev: u64, expected_ino: u64, -) -> Result<(), String> { +) -> Result<(), ObjectBoundPublicationError> { use std::os::unix::fs::{MetadataExt, PermissionsExt}; let opened = directory .metadata() - .map_err(|_| "private-evidence-parent-unavailable".to_string())?; + .map_err(|_| ObjectBoundPublicationError::ParentUnavailable)?; if !opened.is_dir() || opened.file_type().is_symlink() { - return Err("private-evidence-parent-unsafe".into()); + return Err(ObjectBoundPublicationError::ParentUnsafe); } if opened.permissions().mode() & 0o022 != 0 { - return Err("private-evidence-parent-writable-by-others".into()); + return Err(ObjectBoundPublicationError::ParentWritableByOthers); } let named = std::fs::symlink_metadata(canonical_parent) - .map_err(|_| "private-evidence-parent-identity-drift".to_string())?; + .map_err(|_| ObjectBoundPublicationError::ParentIdentityDrift)?; if named.file_type().is_symlink() || !named.is_dir() || named.dev() != expected_dev || named.ino() != expected_ino { - return Err("private-evidence-parent-identity-drift".into()); + return Err(ObjectBoundPublicationError::ParentIdentityDrift); } if named.permissions().mode() & 0o022 != 0 { - return Err("private-evidence-parent-writable-by-others".into()); + return Err(ObjectBoundPublicationError::ParentWritableByOthers); } Ok(()) } -/// Persist exact local evidence outside the audited source tree. +#[cfg(unix)] +fn publication_error_string(error: ObjectBoundPublicationError) -> String { + match error { + ObjectBoundPublicationError::ParentMissing => "private-evidence-parent-missing", + ObjectBoundPublicationError::ParentUnavailable => "private-evidence-parent-unavailable", + ObjectBoundPublicationError::ParentUnsafe => "private-evidence-parent-unsafe", + ObjectBoundPublicationError::ParentWritableByOthers => { + "private-evidence-parent-writable-by-others" + } + ObjectBoundPublicationError::ParentIdentityDrift => { + "private-evidence-parent-identity-drift" + } + ObjectBoundPublicationError::ForbiddenRootUnavailable => { + "private-evidence-source-root-unavailable" + } + ObjectBoundPublicationError::InsideForbiddenRoot => "private-evidence-inside-source-root", + ObjectBoundPublicationError::NameInvalid => "private-evidence-name-invalid", + ObjectBoundPublicationError::CreateFailed => "private-evidence-create-failed", + ObjectBoundPublicationError::ModeInvalid => "private-evidence-mode-invalid", + ObjectBoundPublicationError::WriteFailed => "private-evidence-write-failed", + ObjectBoundPublicationError::MetadataFailed => "private-evidence-metadata-failed", + ObjectBoundPublicationError::ParentSyncFailed => "private-evidence-parent-sync-failed", + ObjectBoundPublicationError::RecordIdentityDrift => { + "private-evidence-record-identity-drift" + } + ObjectBoundPublicationError::InvalidationFailed => { + "private-evidence-invalidation-failed" + } + } + .to_string() +} + +/// Create and durably publish one immutable byte record relative to the exact private parent +/// directory object admitted by the caller-supplied pathname. /// -/// The destination parent must already exist, must not be a symlink, and must not be writable by -/// group or other principals. On Unix, publication is bound to the exact caller-supplied parent -/// directory object admitted before canonicalization, so a same-user pathname replacement cannot -/// redirect either canonicalization or the later write. The file is created once with mode 0600, -/// synced, and never overwritten. After a post-create failure, the still-open record is truncated -/// and synced through its descriptor. The pathname is deliberately not unlinked because a same-user -/// process may already have replaced that name; this can leave a zero-length mode-0600 create-new -/// tombstone that requires explicit operator cleanup. +/// The parent must already exist and must not be writable by group or other principals. The +/// pathname is opened with `O_NOFOLLOW` before canonicalization and the opened directory is bound to +/// the device/inode observed during initial admission. Record creation is descriptor-relative and +/// create-new. `forbidden_root`, when present, is checked only after the opened parent has been bound +/// and revalidated, so a pathname replacement cannot redirect publication into that root. #[cfg(unix)] -pub fn write_private_json_create_new( - source_root: &Path, +pub(crate) fn write_object_bound_bytes_create_new( path: &Path, - value: &impl Serialize, -) -> Result { - write_private_json_create_new_unix_with_hooks(source_root, path, value, || {}, || {}, || {}) + encoded: &[u8], + unix_mode: u32, + forbidden_root: Option<&Path>, +) -> Result<(), ObjectBoundPublicationError> { + write_object_bound_bytes_create_new_with_hooks( + path, + encoded, + unix_mode, + forbidden_root, + || {}, + || {}, + || {}, + ) } #[cfg(unix)] -fn write_private_json_create_new_unix_with_hooks( - source_root: &Path, +fn write_object_bound_bytes_create_new_with_hooks( path: &Path, - value: &impl Serialize, + encoded: &[u8], + unix_mode: u32, + forbidden_root: Option<&Path>, before_parent_open: F, before_create: G, before_finalize: H, -) -> Result +) -> Result<(), ObjectBoundPublicationError> where F: FnOnce(), G: FnOnce(), @@ -92,25 +152,22 @@ where let parent = path .parent() .filter(|parent| !parent.as_os_str().is_empty()) - .ok_or_else(|| "private-evidence-parent-missing".to_string())?; + .ok_or(ObjectBoundPublicationError::ParentMissing)?; let parent_metadata = std::fs::symlink_metadata(parent) - .map_err(|_| "private-evidence-parent-unavailable".to_string())?; + .map_err(|_| ObjectBoundPublicationError::ParentUnavailable)?; if !parent_metadata.is_dir() || parent_metadata.file_type().is_symlink() { - return Err("private-evidence-parent-unsafe".into()); + return Err(ObjectBoundPublicationError::ParentUnsafe); } if parent_metadata.permissions().mode() & 0o022 != 0 { - return Err("private-evidence-parent-writable-by-others".into()); + return Err(ObjectBoundPublicationError::ParentWritableByOthers); } let expected_parent_dev = parent_metadata.dev(); let expected_parent_ino = parent_metadata.ino(); before_parent_open(); - // Open the caller-supplied pathname before canonicalizing it and bind all later authority to - // the object admitted above. If the pathname was replaced in this window, O_NOFOLLOW rejects a - // symlink replacement and the device/inode comparison rejects a different directory object. let parent_c = CString::new(parent.as_os_str().as_bytes()) - .map_err(|_| "private-evidence-parent-unavailable".to_string())?; + .map_err(|_| ObjectBoundPublicationError::ParentUnavailable)?; let directory_fd = unsafe { libc::open( parent_c.as_ptr(), @@ -118,20 +175,20 @@ where ) }; if directory_fd < 0 { - return Err("private-evidence-parent-identity-drift".into()); + return Err(ObjectBoundPublicationError::ParentIdentityDrift); } let directory = unsafe { std::fs::File::from_raw_fd(directory_fd) }; let opened_parent_metadata = directory .metadata() - .map_err(|_| "private-evidence-parent-unavailable".to_string())?; + .map_err(|_| ObjectBoundPublicationError::ParentUnavailable)?; if opened_parent_metadata.dev() != expected_parent_dev || opened_parent_metadata.ino() != expected_parent_ino { - return Err("private-evidence-parent-identity-drift".into()); + return Err(ObjectBoundPublicationError::ParentIdentityDrift); } let canonical_parent = std::fs::canonicalize(parent) - .map_err(|_| "private-evidence-parent-identity-drift".to_string())?; + .map_err(|_| ObjectBoundPublicationError::ParentIdentityDrift)?; revalidate_private_parent( &directory, &canonical_parent, @@ -139,30 +196,23 @@ where expected_parent_ino, )?; - let canonical_source = std::fs::canonicalize(source_root) - .map_err(|_| "private-evidence-source-root-unavailable".to_string())?; - if canonical_parent.starts_with(&canonical_source) { - return Err("private-evidence-inside-source-root".into()); + if let Some(forbidden_root) = forbidden_root { + let canonical_forbidden = std::fs::canonicalize(forbidden_root) + .map_err(|_| ObjectBoundPublicationError::ForbiddenRootUnavailable)?; + if canonical_parent.starts_with(canonical_forbidden) { + return Err(ObjectBoundPublicationError::InsideForbiddenRoot); + } } + let file_name = path .file_name() .filter(|name| !name.is_empty()) - .ok_or_else(|| "private-evidence-name-invalid".to_string())?; + .ok_or(ObjectBoundPublicationError::NameInvalid)?; let final_path = canonical_parent.join(file_name); - let encoded = serde_json::to_vec_pretty(value) - .map_err(|_| "private-evidence-json-invalid".to_string())?; - if encoded.len() > MAX_PRIVATE_EVIDENCE_BYTES { - return Err("private-evidence-too-large".into()); - } - let file_name_c = CString::new(file_name.as_bytes()) - .map_err(|_| "private-evidence-name-invalid".to_string())?; + .map_err(|_| ObjectBoundPublicationError::NameInvalid)?; before_create(); - - // Re-check both the opened object and its pathname immediately after the deterministic race - // seam. Publication itself is descriptor-relative, so a later rename cannot redirect record - // creation to a different parent. revalidate_private_parent( &directory, &canonical_parent, @@ -179,35 +229,32 @@ where | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW, - 0o600, + unix_mode as libc::mode_t, ) }; if file_fd < 0 { - return Err("private-evidence-create-failed".into()); + return Err(ObjectBoundPublicationError::CreateFailed); } let mut file = unsafe { std::fs::File::from_raw_fd(file_fd) }; - let publication = (|| -> Result<(), String> { - // `openat` applies the process umask to its requested creation mode. Normalize the exact - // already-opened record descriptor before writing so restrictive masks cannot create an - // unreadable tombstone and permanently block this create-new evidence path. - file.set_permissions(std::fs::Permissions::from_mode(0o600)) - .map_err(|_| "private-evidence-mode-invalid".to_string())?; - file.write_all(&encoded) + let publication = (|| -> Result<(), ObjectBoundPublicationError> { + file.set_permissions(std::fs::Permissions::from_mode(unix_mode)) + .map_err(|_| ObjectBoundPublicationError::ModeInvalid)?; + file.write_all(encoded) .and_then(|_| file.sync_all()) - .map_err(|_| "private-evidence-write-failed".to_string())?; + .map_err(|_| ObjectBoundPublicationError::WriteFailed)?; let opened_file_metadata = file .metadata() - .map_err(|_| "private-evidence-metadata-failed".to_string())?; + .map_err(|_| ObjectBoundPublicationError::MetadataFailed)?; if !opened_file_metadata.is_file() || opened_file_metadata.file_type().is_symlink() - || opened_file_metadata.permissions().mode() & 0o777 != 0o600 + || opened_file_metadata.permissions().mode() & 0o777 != unix_mode { - return Err("private-evidence-mode-invalid".into()); + return Err(ObjectBoundPublicationError::ModeInvalid); } directory .sync_all() - .map_err(|_| "private-evidence-parent-sync-failed".to_string())?; + .map_err(|_| ObjectBoundPublicationError::ParentSyncFailed)?; before_finalize(); @@ -219,30 +266,79 @@ where )?; let final_file_metadata = std::fs::symlink_metadata(&final_path) - .map_err(|_| "private-evidence-record-identity-drift".to_string())?; + .map_err(|_| ObjectBoundPublicationError::RecordIdentityDrift)?; if final_file_metadata.file_type().is_symlink() || !final_file_metadata.is_file() || final_file_metadata.dev() != opened_file_metadata.dev() || final_file_metadata.ino() != opened_file_metadata.ino() { - return Err("private-evidence-record-identity-drift".into()); + return Err(ObjectBoundPublicationError::RecordIdentityDrift); } Ok(()) })(); if let Err(error) = publication { - // Do not unlink by name here. No portable Unix primitive atomically proves that the name - // still identifies this open file at unlink time, so a stat-then-unlink sequence would - // retain a replacement race. Invalidate only the exact object held by this descriptor. let invalidation = file .set_len(0) .and_then(|_| file.sync_all()) .and_then(|_| directory.sync_all()); if invalidation.is_err() { - return Err("private-evidence-invalidation-failed".into()); + return Err(ObjectBoundPublicationError::InvalidationFailed); } return Err(error); } + Ok(()) +} + +/// Persist exact local evidence outside the audited source tree. +/// +/// The destination parent must already exist, must not be a symlink, and must not be writable by +/// group or other principals. On Unix, publication is bound to the exact caller-supplied parent +/// directory object admitted before canonicalization, so a same-user pathname replacement cannot +/// redirect either canonicalization or the later write. The file is created once with mode 0600, +/// synced, and never overwritten. After a post-create failure, the still-open record is truncated +/// and synced through its descriptor. The pathname is deliberately not unlinked because a same-user +/// process may already have replaced that name; this can leave a zero-length mode-0600 create-new +/// tombstone that requires explicit operator cleanup. +#[cfg(unix)] +pub fn write_private_json_create_new( + source_root: &Path, + path: &Path, + value: &impl Serialize, +) -> Result { + write_private_json_create_new_unix_with_hooks(source_root, path, value, || {}, || {}, || {}) +} + +#[cfg(unix)] +fn write_private_json_create_new_unix_with_hooks( + source_root: &Path, + path: &Path, + value: &impl Serialize, + before_parent_open: F, + before_create: G, + before_finalize: H, +) -> Result +where + F: FnOnce(), + G: FnOnce(), + H: FnOnce(), +{ + let encoded = serde_json::to_vec_pretty(value) + .map_err(|_| "private-evidence-json-invalid".to_string())?; + if encoded.len() > MAX_PRIVATE_EVIDENCE_BYTES { + return Err("private-evidence-too-large".into()); + } + + write_object_bound_bytes_create_new_with_hooks( + path, + &encoded, + 0o600, + Some(source_root), + before_parent_open, + before_create, + before_finalize, + ) + .map_err(publication_error_string)?; let sha256 = Sha256::digest(&encoded) .iter() From 51b58483fd3077dc6cb095e23cb0cb1a5539f4f9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 12:21:29 -0700 Subject: [PATCH 17/18] test: expose shared object-bound publication seam --- src-tauri/src/private_evidence.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/private_evidence.rs b/src-tauri/src/private_evidence.rs index 5e1b66dc6..de40b22c2 100644 --- a/src-tauri/src/private_evidence.rs +++ b/src-tauri/src/private_evidence.rs @@ -129,8 +129,10 @@ pub(crate) fn write_object_bound_bytes_create_new( ) } +/// Internal deterministic seam for dependent authority writers to prove directory-replacement +/// handling while exercising the same descriptor-bound publication implementation used in production. #[cfg(unix)] -fn write_object_bound_bytes_create_new_with_hooks( +pub(crate) fn write_object_bound_bytes_create_new_with_hooks( path: &Path, encoded: &[u8], unix_mode: u32, From 1eb947ec9d4e591638230a8cb24af4d5b14ae35b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 04:46:55 +0900 Subject: [PATCH 18/18] fix: use C-compatible private evidence mode argument --- src-tauri/src/private_evidence.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/src/private_evidence.rs b/src-tauri/src/private_evidence.rs index de40b22c2..b0e3d769f 100644 --- a/src-tauri/src/private_evidence.rs +++ b/src-tauri/src/private_evidence.rs @@ -231,7 +231,7 @@ where | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW, - unix_mode as libc::mode_t, + unix_mode as libc::c_uint, ) }; if file_fd < 0 {