From 4461307162f8a309266444184731712d71cc509a Mon Sep 17 00:00:00 2001 From: Avi Date: Fri, 21 Aug 2026 15:18:03 -0500 Subject: [PATCH] Create vault files owner-only from the first byte --- src/vault.rs | 50 ++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/src/vault.rs b/src/vault.rs index eb78e3f..6d9fbac 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -1,7 +1,7 @@ use std::env; use std::fs; use std::io::Write; -use std::os::unix::fs::PermissionsExt; +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -250,10 +250,15 @@ fn write_restricted(path: &Path, content: &str) -> Result<(), AppError> { let tmp_path = path.with_extension("json.tmp"); { + // `mode` only applies when the file is freshly created, so the tmp + // file is owner-only from its very first byte. The explicit + // `set_permissions` below covers a tmp file left over by a crashed + // earlier run, which would be opened without applying `mode`. let mut file = fs::OpenOptions::new() .write(true) .create(true) .truncate(true) + .mode(0o600) .open(&tmp_path) .map_err(|e| AppError::io("Could not write the data file", e))?; file.set_permissions(fs::Permissions::from_mode(0o600)) @@ -285,8 +290,24 @@ pub fn backup_file(path: &Path) -> Result { return Ok(backup); } - fs::copy(path, &backup) + // Copy by hand instead of `fs::copy`, which gives the new file the + // source's permission bits: a group-readable legacy vault would sit there + // fully readable until the later chmod landed. Created with `mode(0o600)`, + // the backup is owner-only from its first byte. + let mut input = + fs::File::open(path).map_err(|e| AppError::io("Could not read your profile data", e))?; + let mut output = fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(&backup) .map_err(|e| AppError::io("Could not create a backup of your profile data", e))?; + std::io::copy(&mut input, &mut output) + .map_err(|e| AppError::io("Could not create a backup of your profile data", e))?; + output + .sync_all() + .map_err(|e| AppError::io("Could not save your profile data backup", e))?; let _ = fs::set_permissions(&backup, fs::Permissions::from_mode(0o600)); Ok(backup) } @@ -394,6 +415,14 @@ mod tests { } } + /// The legacy flat-array form of [`sample_profile`], as on disk. + fn sample_profile_json() -> String { + r#"[ + { "label": "Alice", "public_key": "npub1test", "secret_key": "00ff", "created_at": 1700000000 } + ]"# + .to_string() + } + #[test] fn parse_vault_accepts_legacy_array_format() { let json = r#"[ @@ -478,6 +507,23 @@ mod tests { assert_eq!(mode & 0o777, 0o600, "vault must be readable only by owner"); } + #[test] + fn backup_of_group_readable_source_is_0600() { + let path = temp_vault_path(); + fs::write(&path, sample_profile_json()).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o664)).unwrap(); + + let backup = backup_file(&path).expect("backup should succeed"); + + let mode = fs::metadata(&backup).unwrap().permissions().mode(); + assert_eq!( + mode & 0o777, + 0o600, + "backup must be owner-only even when the source is group-readable" + ); + assert_eq!(fs::read_to_string(backup).unwrap(), sample_profile_json()); + } + #[test] fn recognised_legacy_vault_is_tightened_to_0600() { let path = temp_vault_path();