app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 74119e9babb88443347ac052f727b41251bc7925
parent 36ac0acccc2ad5c840b52146209479e6a49a761f
Author: triesap <tyson@radroots.org>
Date:   Mon,  3 Aug 2026 22:38:00 +0000

storage: enforce single writable runtime ownership

- acquire an exclusive database-adjacent process lock before migration
- create the application data directory with owner-only permissions
- retain lock ownership for the complete database lifetime
- cover concurrent-open rejection and ownership release on drop

Diffstat:
Mcore/Cargo.lock | 33+++++++++++++++++++++++++++++++++
Mcore/Cargo.toml | 1+
Mcore/crates/storage/Cargo.toml | 1+
Mcore/crates/storage/src/db.rs | 75++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
4 files changed, 109 insertions(+), 1 deletion(-)

diff --git a/core/Cargo.lock b/core/Cargo.lock @@ -810,6 +810,16 @@ dependencies = [ ] [[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] name = "futures" version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1757,6 +1767,7 @@ dependencies = [ name = "radroots-studio-storage" version = "0.1.0-alpha" dependencies = [ + "fs2", "keyring", "nostr", "nostr-relay-builder", @@ -2975,6 +2986,22 @@ dependencies = [ ] [[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] name = "winapi-util" version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2984,6 +3011,12 @@ dependencies = [ ] [[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/core/Cargo.toml b/core/Cargo.toml @@ -27,6 +27,7 @@ pedantic = "deny" bech32 = "=0.11.1" keyring = "=4.1.6" directories = "=6.0.0" +fs2 = "=0.4.3" nostr = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr" } nostr-sdk = "=0.44.1" nostr-relay-builder = "=0.44.1" diff --git a/core/crates/storage/Cargo.toml b/core/crates/storage/Cargo.toml @@ -9,6 +9,7 @@ repository.workspace = true [dependencies] radroots-studio-application = { path = "../application" } radroots-studio-domain = { path = "../domain" } +fs2.workspace = true keyring.workspace = true refinery.workspace = true rusqlite.workspace = true diff --git a/core/crates/storage/src/db.rs b/core/crates/storage/src/db.rs @@ -1,7 +1,9 @@ +use std::fs::{self, File, OpenOptions}; use std::path::Path; use std::sync::{Mutex, MutexGuard}; use std::time::Duration; +use fs2::FileExt; use radroots_studio_domain::{SafeError, SafeErrorCode, SafeMessage}; use refinery::embed_migrations; use rusqlite::{Connection, OpenFlags}; @@ -16,6 +18,11 @@ mod migrations { pub struct Database { connection: Mutex<Connection>, + _ownership: Option<WritableOwnership>, +} + +struct WritableOwnership { + _file: File, } impl Database { @@ -26,6 +33,9 @@ impl Database { /// Returns a safe storage error when the file, connection configuration, /// permission update, or migration cannot complete. pub fn open(path: &Path) -> Result<Self, SafeError> { + let parent = path.parent().ok_or_else(storage_error)?; + create_secure_directory(parent)?; + let ownership = WritableOwnership::acquire(path)?; let flags = OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE | OpenFlags::SQLITE_OPEN_NO_MUTEX; @@ -38,6 +48,7 @@ impl Database { restrict_file_permissions(path)?; Ok(Self { connection: Mutex::new(connection), + _ownership: Some(ownership), }) } @@ -54,6 +65,7 @@ impl Database { .map_err(|_| corrupt_storage_error())?; Ok(Self { connection: Mutex::new(connection), + _ownership: None, }) } @@ -79,6 +91,27 @@ impl Database { } } +impl WritableOwnership { + fn acquire(database_path: &Path) -> Result<Self, SafeError> { + let lock_path = database_path.with_extension("sqlite3.lock"); + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&lock_path) + .map_err(|_| storage_error())?; + restrict_file_permissions(&lock_path)?; + file.try_lock_exclusive().map_err(|_| ownership_error())?; + Ok(Self { _file: file }) + } +} + +fn create_secure_directory(path: &Path) -> Result<(), SafeError> { + fs::create_dir_all(path).map_err(|_| storage_error())?; + restrict_directory_permissions(path) +} + fn configure(connection: &Connection) -> Result<(), SafeError> { connection .pragma_update(None, "foreign_keys", "ON") @@ -89,17 +122,28 @@ fn configure(connection: &Connection) -> Result<(), SafeError> { #[cfg(unix)] fn restrict_file_permissions(path: &Path) -> Result<(), SafeError> { - use std::fs; use std::os::unix::fs::PermissionsExt; fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(|_| storage_error()) } +#[cfg(unix)] +fn restrict_directory_permissions(path: &Path) -> Result<(), SafeError> { + use std::os::unix::fs::PermissionsExt; + + fs::set_permissions(path, fs::Permissions::from_mode(0o700)).map_err(|_| storage_error()) +} + #[cfg(not(unix))] fn restrict_file_permissions(_path: &Path) -> Result<(), SafeError> { Ok(()) } +#[cfg(not(unix))] +fn restrict_directory_permissions(_path: &Path) -> Result<(), SafeError> { + Ok(()) +} + const fn storage_error() -> SafeError { SafeError::new( SafeErrorCode::StorageUnavailable, @@ -114,6 +158,13 @@ const fn corrupt_storage_error() -> SafeError { ) } +const fn ownership_error() -> SafeError { + SafeError::new( + SafeErrorCode::StorageUnavailable, + SafeMessage::new("The application database is already in use."), + ) +} + #[cfg(test)] mod tests { use std::fs; @@ -156,6 +207,22 @@ mod tests { assert!(fs::metadata(path).expect("database metadata").len() > 0); } + #[test] + fn writable_ownership_rejects_a_second_runtime_and_releases_on_drop() { + let directory = tempdir().expect("temporary directory"); + let path = directory.path().join("studio.sqlite3"); + let first = Database::open(&path).expect("first owner"); + let Err(error) = Database::open(&path) else { + panic!("second owner must fail"); + }; + assert_eq!( + error.message().as_str(), + "The application database is already in use." + ); + drop(first); + Database::open(&path).expect("ownership released"); + } + #[cfg(unix)] #[test] fn migration_attempts_owner_only_database_permissions() { @@ -171,5 +238,11 @@ mod tests { & 0o777; assert_eq!(mode, 0o600); + let directory_mode = fs::metadata(directory.path()) + .expect("directory metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(directory_mode, 0o700); } }