commit e1f4d689803b32d9a48363344cbf9c18c7dbe69f parent debab9e01f7edc2cad57258bbd4414c3e7a23c72 Author: triesap <tyson@radroots.org> Date: Wed, 8 Apr 2026 00:45:55 +0000 app: adopt canonical runtime paths - route desktop app storage through radroots-runtime-paths - align iOS and Android storage to mobile-native logical roots - update Android security bridge callers to resolve the RadRoots base root - refresh app path contract tests and workspace dependencies Diffstat:
16 files changed, 364 insertions(+), 118 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock @@ -815,27 +815,6 @@ dependencies = [ ] [[package]] -name = "directories" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.61.2", -] - -[[package]] name = "dispatch" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2721,12 +2700,6 @@ dependencies = [ ] [[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - -[[package]] name = "orbclient" version = "0.3.51" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3087,6 +3060,7 @@ dependencies = [ "radroots-geocoder", "radroots-identity", "radroots-nostr-accounts", + "radroots-runtime-paths", "radroots-secret-vault", "wgpu", "winit", @@ -3116,7 +3090,6 @@ dependencies = [ name = "radroots-app-desktop" version = "0.1.0" dependencies = [ - "directories", "eframe", "egui", "image", @@ -3129,6 +3102,7 @@ dependencies = [ "radroots-geocoder", "radroots-identity", "radroots-nostr-accounts", + "radroots-runtime-paths", "wgpu", "zeroize", ] @@ -3146,6 +3120,7 @@ dependencies = [ "radroots-geocoder", "radroots-identity", "radroots-nostr-accounts", + "radroots-runtime-paths", "wgpu", "zeroize", ] @@ -3223,6 +3198,7 @@ dependencies = [ "nostr", "radroots-events", "radroots-runtime", + "radroots-runtime-paths", "serde", "serde_json", "thiserror 1.0.69", @@ -3316,6 +3292,7 @@ dependencies = [ "getrandom 0.2.17", "radroots-log", "radroots-protected-store", + "radroots-runtime-paths", "radroots-secret-vault", "serde", "serde_json", @@ -3328,6 +3305,13 @@ dependencies = [ ] [[package]] +name = "radroots-runtime-paths" +version = "0.1.0-alpha.1" +dependencies = [ + "thiserror 1.0.69", +] + +[[package]] name = "radroots-secret-vault" version = "0.1.0-alpha.1" dependencies = [ @@ -3433,17 +3417,6 @@ dependencies = [ ] [[package]] -name = "redox_users" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 2.0.18", -] - -[[package]] name = "regex" version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/Cargo.toml b/Cargo.toml @@ -23,7 +23,6 @@ readme = "README.md" [workspace.dependencies] android_logger = "0.15.1" -directories = "6" eframe = { version = "0.33.3", default-features = false, features = ["default_fonts"] } egui = { version = "0.33.3", features = ["serde"] } image = { version = "0.25.10", default-features = false, features = ["ico", "png"] } @@ -39,6 +38,7 @@ radroots-identity = { path = "../lib/crates/identity", default-features = false, radroots-nostr = { path = "../lib/crates/nostr", default-features = false, features = ["std", "client"] } radroots-nostr-accounts = { path = "../lib/crates/nostr-accounts", default-features = false, features = ["std", "file-store", "os-keyring"] } radroots-nostr-connect = { path = "../lib/crates/nostr-connect" } +radroots-runtime-paths = { path = "../lib/crates/runtime-paths" } radroots-secret-vault = { path = "../lib/crates/secret-vault", default-features = false, features = ["std"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.145" diff --git a/crates/android/Cargo.toml b/crates/android/Cargo.toml @@ -22,6 +22,7 @@ radroots-app-remote-signer = { path = "../remote-signer" } radroots-geocoder.workspace = true radroots-identity.workspace = true radroots-nostr-accounts = { workspace = true, features = ["memory-vault"] } +radroots-runtime-paths.workspace = true radroots-secret-vault.workspace = true zeroize.workspace = true diff --git a/crates/android/src/remote_signer.rs b/crates/android/src/remote_signer.rs @@ -1,4 +1,5 @@ -use crate::security::{ANDROID_NOSTR_SERVICE, resolve_nostr_storage_root}; +use crate::security::ANDROID_NOSTR_SERVICE; +use crate::storage; use crate::vault::RadrootsAndroidKeystoreVault; use radroots_app_core::{ IdentityGateState, RadrootsAccountCustody, RadrootsPendingRemoteSignerConnection, @@ -407,8 +408,9 @@ fn save_sessions( } fn sessions_path() -> Result<PathBuf, String> { - let root = resolve_nostr_storage_root().map_err(|source| source.to_string())?; - Ok(root.join("remote-signer-sessions.json")) + Ok(storage::app_data_root()? + .join("nostr") + .join("remote-signer-sessions.json")) } fn client_secret_vault() -> RadrootsAndroidKeystoreVault { diff --git a/crates/android/src/security.rs b/crates/android/src/security.rs @@ -302,14 +302,14 @@ pub(crate) fn remove_secret_namespace( } #[cfg(target_os = "android")] -pub(crate) fn resolve_nostr_storage_root() -> Result<PathBuf, RadrootsNostrAccountsError> { +pub(crate) fn resolve_radroots_base_root() -> Result<PathBuf, RadrootsNostrAccountsError> { let java_vm = android_java_vm()?; let mut env = java_vm.attach_current_thread().map_err(jni_error)?; let bridge_class = bridge_class(&mut env)?; let value = env .call_static_method( &bridge_class, - "resolveNostrStorageRoot", + "resolveRadrootsBaseRoot", "()Ljava/lang/String;", &[], ) @@ -442,9 +442,9 @@ pub(crate) fn take_user_presence_verification_result() #[cfg(not(target_os = "android"))] #[allow(dead_code)] -pub(crate) fn resolve_nostr_storage_root() -> Result<PathBuf, RadrootsNostrAccountsError> { +pub(crate) fn resolve_radroots_base_root() -> Result<PathBuf, RadrootsNostrAccountsError> { Err(RadrootsNostrAccountsError::Store( - "android no-backup storage is only available on android".to_owned(), + "android mobile base storage root is only available on android".to_owned(), )) } diff --git a/crates/android/src/storage.rs b/crates/android/src/storage.rs @@ -1,20 +1,51 @@ #[cfg(target_os = "android")] -use crate::security::{ANDROID_NOSTR_SERVICE, resolve_nostr_storage_root}; +use crate::security::{ANDROID_NOSTR_SERVICE, resolve_radroots_base_root}; #[cfg(target_os = "android")] use crate::vault::RadrootsAndroidKeystoreVault; #[cfg(target_os = "android")] use radroots_nostr_accounts::prelude::{ RadrootsNostrAccountsManager, RadrootsNostrFileAccountStore, }; -use std::path::Path; -use std::path::PathBuf; +use radroots_runtime_paths::{ + RadrootsHostEnvironment, RadrootsPathOverrides, RadrootsPathProfile, RadrootsPathResolver, + RadrootsPaths, RadrootsPlatform, RadrootsRuntimeNamespace, +}; +use std::path::{Path, PathBuf}; #[cfg(target_os = "android")] use std::sync::Arc; +fn app_namespace() -> Result<RadrootsRuntimeNamespace, String> { + RadrootsRuntimeNamespace::app("app") + .map_err(|source| format!("failed to resolve android app namespace: {source}")) +} + +fn app_paths_from_base_root(base_root: &Path) -> Result<RadrootsPaths, String> { + let resolver = RadrootsPathResolver::new( + RadrootsPlatform::Android, + RadrootsHostEnvironment::default(), + ); + let namespace = app_namespace()?; + resolver + .resolve( + RadrootsPathProfile::MobileNative, + &RadrootsPathOverrides::mobile(RadrootsPaths::from_base_root(base_root)), + ) + .map(|roots| roots.namespaced(&namespace)) + .map_err(|source| format!("failed to resolve android mobile-native roots: {source}")) +} + +#[cfg(target_os = "android")] +pub(crate) fn app_data_root() -> Result<PathBuf, String> { + let base_root = resolve_radroots_base_root().map_err(|source| source.to_string())?; + let root = app_data_root_from_base_root(base_root.as_path())?; + ensure_directory_tree(root.as_path())?; + Ok(root) +} + #[cfg(target_os = "android")] pub(crate) fn accounts_path() -> Result<PathBuf, String> { - let root = resolve_nostr_storage_root().map_err(|source| source.to_string())?; - let accounts_path = accounts_path_from_root(root.as_path()); + let base_root = resolve_radroots_base_root().map_err(|source| source.to_string())?; + let accounts_path = accounts_path_from_base_root(base_root.as_path())?; if let Some(parent) = accounts_path.parent() { ensure_directory_tree(parent)?; } @@ -28,14 +59,20 @@ pub(crate) fn accounts_manager() -> Result<RadrootsNostrAccountsManager, String> RadrootsNostrAccountsManager::new(store, vault).map_err(|source| source.to_string()) } -pub(crate) fn accounts_path_from_root(root: &Path) -> PathBuf { - root.join("accounts.json") +pub(crate) fn app_data_root_from_base_root(base_root: &Path) -> Result<PathBuf, String> { + Ok(app_paths_from_base_root(base_root)?.data) +} + +pub(crate) fn accounts_path_from_base_root(base_root: &Path) -> Result<PathBuf, String> { + Ok(app_data_root_from_base_root(base_root)? + .join("nostr") + .join("accounts.json")) } #[cfg(target_os = "android")] fn ensure_directory_tree(path: &Path) -> Result<(), String> { std::fs::create_dir_all(path) - .map_err(|source| format!("failed to create android accounts directory: {source}"))?; + .map_err(|source| format!("failed to create android app data directory: {source}"))?; Ok(()) } @@ -44,15 +81,46 @@ mod tests { use super::*; #[test] - fn accounts_path_uses_android_no_backup_layout() { - let root = PathBuf::from( - "/data/user/0/org.radroots.app.android/no_backup/RadRoots/app/android/nostr", + fn accounts_path_uses_android_mobile_native_layout() { + let base_root = PathBuf::from("/data/user/0/org.radroots.app.android/no_backup/RadRoots"); + + assert_eq!( + accounts_path_from_base_root(base_root.as_path()).expect("accounts path"), + PathBuf::from( + "/data/user/0/org.radroots.app.android/no_backup/RadRoots/data/apps/app/nostr/accounts.json" + ) ); + } + #[test] + fn app_data_root_uses_android_mobile_native_layout() { + let base_root = PathBuf::from("/data/user/0/org.radroots.app.android/no_backup/RadRoots"); + + assert_eq!( + app_data_root_from_base_root(base_root.as_path()).expect("app data root"), + PathBuf::from("/data/user/0/org.radroots.app.android/no_backup/RadRoots/data/apps/app") + ); + } + + #[test] + fn mobile_paths_follow_shared_logical_root_model() { + let base_root = PathBuf::from("/data/user/0/org.radroots.app.android/no_backup/RadRoots"); + let paths = app_paths_from_base_root(base_root.as_path()).expect("mobile paths"); + + assert_eq!( + paths.config, + PathBuf::from( + "/data/user/0/org.radroots.app.android/no_backup/RadRoots/config/apps/app" + ) + ); + assert_eq!( + paths.data, + PathBuf::from("/data/user/0/org.radroots.app.android/no_backup/RadRoots/data/apps/app") + ); assert_eq!( - accounts_path_from_root(root.as_path()), + paths.secrets, PathBuf::from( - "/data/user/0/org.radroots.app.android/no_backup/RadRoots/app/android/nostr/accounts.json" + "/data/user/0/org.radroots.app.android/no_backup/RadRoots/secrets/apps/app" ) ); } diff --git a/crates/desktop/Cargo.toml b/crates/desktop/Cargo.toml @@ -15,7 +15,6 @@ build = "build.rs" workspace = true [dependencies] -directories.workspace = true eframe = { workspace = true, features = ["wgpu", "wayland", "x11"] } egui.workspace = true image.workspace = true @@ -24,6 +23,7 @@ radroots-app-core = { path = "../core" } radroots-app-remote-signer = { path = "../remote-signer" } radroots-geocoder.workspace = true radroots-nostr-accounts = { workspace = true, features = ["memory-vault"] } +radroots-runtime-paths.workspace = true zeroize.workspace = true [target.'cfg(any(target_os = "macos", target_os = "ios"))'.dependencies] diff --git a/crates/desktop/src/main.rs b/crates/desktop/src/main.rs @@ -1,7 +1,6 @@ #![forbid(unsafe_code)] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -use directories::BaseDirs; use eframe::egui; use image::ImageFormat; #[cfg(all(target_os = "macos", not(test)))] @@ -24,7 +23,10 @@ use radroots_identity::RadrootsIdentity; use radroots_nostr_accounts::prelude::{ RadrootsNostrAccountsManager, RadrootsNostrFileAccountStore, RadrootsNostrSelectedAccountStatus, }; -#[cfg(target_os = "macos")] +use radroots_runtime_paths::{ + RadrootsPathOverrides, RadrootsPathProfile, RadrootsPathResolver, RadrootsPaths, + RadrootsRuntimeNamespace, +}; use std::path::{Path, PathBuf}; use std::sync::Arc; #[cfg(target_os = "macos")] @@ -77,6 +79,27 @@ struct DesktopBackend { } impl DesktopBackend { + fn app_namespace() -> Result<RadrootsRuntimeNamespace, String> { + RadrootsRuntimeNamespace::app("app") + .map_err(|source| format!("failed to resolve desktop app namespace: {source}")) + } + + fn interactive_user_roots_with_resolver( + resolver: &RadrootsPathResolver, + ) -> Result<RadrootsPaths, String> { + resolver + .resolve( + RadrootsPathProfile::InteractiveUser, + &RadrootsPathOverrides::default(), + ) + .map_err(|source| format!("failed to resolve desktop interactive-user roots: {source}")) + } + + fn app_paths_with_resolver(resolver: &RadrootsPathResolver) -> Result<RadrootsPaths, String> { + let namespace = Self::app_namespace()?; + Ok(Self::interactive_user_roots_with_resolver(resolver)?.namespaced(&namespace)) + } + fn new() -> Self { #[cfg(target_os = "macos")] let offline_geocoder = match Self::app_data_root() { @@ -107,19 +130,19 @@ impl DesktopBackend { } } - #[cfg(target_os = "macos")] fn radroots_root() -> Result<PathBuf, String> { - let base_dirs = - BaseDirs::new().ok_or_else(|| "failed to resolve home directory".to_owned())?; - Ok(base_dirs.home_dir().join(".radroots")) + let roots = Self::interactive_user_roots_with_resolver(&RadrootsPathResolver::current())?; + roots + .config + .parent() + .map(Path::to_path_buf) + .ok_or_else(|| "desktop interactive-user config root had no parent".to_owned()) } - #[cfg(target_os = "macos")] fn app_data_root() -> Result<PathBuf, String> { - Ok(Self::radroots_root()?.join("app").join("desktop")) + Ok(Self::app_paths_with_resolver(&RadrootsPathResolver::current())?.data) } - #[cfg(target_os = "macos")] fn private_directory_chain(root: &Path, leaf: &Path) -> Result<Vec<PathBuf>, String> { let relative = leaf .strip_prefix(root) @@ -149,7 +172,6 @@ impl DesktopBackend { Ok(()) } - #[cfg(target_os = "macos")] fn accounts_path() -> Result<PathBuf, String> { Ok(Self::app_data_root()?.join("nostr").join("accounts.json")) } @@ -952,6 +974,112 @@ fn main() -> eframe::Result<()> { ) } +#[cfg(test)] +mod path_contract_tests { + use super::DesktopBackend; + use radroots_runtime_paths::{RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform}; + use std::path::PathBuf; + + #[test] + fn desktop_app_paths_follow_linux_interactive_user_contract() { + let resolver = RadrootsPathResolver::new( + RadrootsPlatform::Linux, + RadrootsHostEnvironment { + home_dir: Some(PathBuf::from("/home/treesap")), + ..RadrootsHostEnvironment::default() + }, + ); + + let paths = DesktopBackend::app_paths_with_resolver(&resolver).expect("desktop app paths"); + + assert_eq!( + paths.data, + PathBuf::from("/home/treesap/.radroots/data/apps/app") + ); + assert_eq!( + paths.logs, + PathBuf::from("/home/treesap/.radroots/logs/apps/app") + ); + assert_eq!( + paths.secrets, + PathBuf::from("/home/treesap/.radroots/secrets/apps/app") + ); + } + + #[test] + fn desktop_app_paths_follow_macos_interactive_user_contract() { + let resolver = RadrootsPathResolver::new( + RadrootsPlatform::Macos, + RadrootsHostEnvironment { + home_dir: Some(PathBuf::from("/Users/treesap")), + ..RadrootsHostEnvironment::default() + }, + ); + + let paths = DesktopBackend::app_paths_with_resolver(&resolver).expect("desktop app paths"); + + assert_eq!( + paths.data, + PathBuf::from("/Users/treesap/.radroots/data/apps/app") + ); + assert_eq!( + paths.logs, + PathBuf::from("/Users/treesap/.radroots/logs/apps/app") + ); + assert_eq!( + paths.secrets, + PathBuf::from("/Users/treesap/.radroots/secrets/apps/app") + ); + } + + #[test] + fn desktop_app_paths_follow_windows_interactive_user_contract() { + let resolver = RadrootsPathResolver::new( + RadrootsPlatform::Windows, + RadrootsHostEnvironment { + appdata_dir: Some(PathBuf::from(r"C:\Users\treesap\AppData\Roaming")), + localappdata_dir: Some(PathBuf::from(r"C:\Users\treesap\AppData\Local")), + ..RadrootsHostEnvironment::default() + }, + ); + + let paths = DesktopBackend::app_paths_with_resolver(&resolver).expect("desktop app paths"); + + assert_eq!( + paths.config, + PathBuf::from(r"C:\Users\treesap\AppData\Roaming") + .join("Radroots") + .join("config") + .join("apps") + .join("app") + ); + assert_eq!( + paths.data, + PathBuf::from(r"C:\Users\treesap\AppData\Local") + .join("Radroots") + .join("data") + .join("apps") + .join("app") + ); + assert_eq!( + paths.logs, + PathBuf::from(r"C:\Users\treesap\AppData\Local") + .join("Radroots") + .join("logs") + .join("apps") + .join("app") + ); + assert_eq!( + paths.secrets, + PathBuf::from(r"C:\Users\treesap\AppData\Roaming") + .join("Radroots") + .join("secrets") + .join("apps") + .join("app") + ); + } +} + #[cfg(all(test, target_os = "macos"))] mod tests { use super::DesktopBackend; @@ -969,7 +1097,7 @@ mod tests { #[test] fn private_directory_chain_covers_only_radroots_subtree() { let root = PathBuf::from("/tmp/example/.radroots"); - let leaf = root.join("app").join("desktop").join("nostr"); + let leaf = root.join("data").join("apps").join("app").join("nostr"); let chain = DesktopBackend::private_directory_chain(&root, &leaf).unwrap(); @@ -977,9 +1105,10 @@ mod tests { chain, vec![ PathBuf::from("/tmp/example/.radroots"), - PathBuf::from("/tmp/example/.radroots/app"), - PathBuf::from("/tmp/example/.radroots/app/desktop"), - PathBuf::from("/tmp/example/.radroots/app/desktop/nostr"), + PathBuf::from("/tmp/example/.radroots/data"), + PathBuf::from("/tmp/example/.radroots/data/apps"), + PathBuf::from("/tmp/example/.radroots/data/apps/app"), + PathBuf::from("/tmp/example/.radroots/data/apps/app/nostr"), ] ); } diff --git a/crates/desktop/src/offline_geocoder.rs b/crates/desktop/src/offline_geocoder.rs @@ -355,11 +355,11 @@ mod tests { #[test] fn staged_db_path_uses_app_geocoder_directory() { - let app_data_root = PathBuf::from("/Users/example/.radroots/app/desktop"); + let app_data_root = PathBuf::from("/Users/example/.radroots/data/apps/app"); assert_eq!( staged_db_path(app_data_root.as_path(), "abcd"), - PathBuf::from("/Users/example/.radroots/app/desktop/geocoder/abcd/geonames.db") + PathBuf::from("/Users/example/.radroots/data/apps/app/geocoder/abcd/geonames.db") ); } diff --git a/crates/ios/Cargo.toml b/crates/ios/Cargo.toml @@ -23,6 +23,7 @@ radroots-app-remote-signer = { path = "../remote-signer" } radroots-geocoder.workspace = true radroots-identity.workspace = true radroots-nostr-accounts = { workspace = true, features = ["memory-vault"] } +radroots-runtime-paths.workspace = true zeroize.workspace = true [target.'cfg(target_os = "ios")'.dependencies] diff --git a/crates/ios/src/offline_geocoder.rs b/crates/ios/src/offline_geocoder.rs @@ -355,13 +355,13 @@ mod tests { #[test] fn staged_db_path_uses_ios_geocoder_directory() { let app_data_root = PathBuf::from( - "/var/mobile/Containers/Data/Application/example/Library/Application Support/RadRoots/app/ios", + "/var/mobile/Containers/Data/Application/example/Library/Application Support/RadRoots/data/apps/app", ); assert_eq!( staged_db_path(app_data_root.as_path(), "abcd"), PathBuf::from( - "/var/mobile/Containers/Data/Application/example/Library/Application Support/RadRoots/app/ios/geocoder/abcd/geonames.db" + "/var/mobile/Containers/Data/Application/example/Library/Application Support/RadRoots/data/apps/app/geocoder/abcd/geonames.db" ) ); } diff --git a/crates/ios/src/storage.rs b/crates/ios/src/storage.rs @@ -4,17 +4,46 @@ use radroots_app_apple_security::{APPLE_NOSTR_SERVICE, RadrootsAppleKeychainVaul use radroots_nostr_accounts::prelude::{ RadrootsNostrAccountsManager, RadrootsNostrFileAccountStore, }; -use std::path::Path; -use std::path::PathBuf; +use radroots_runtime_paths::{ + RadrootsHostEnvironment, RadrootsPathOverrides, RadrootsPathProfile, RadrootsPathResolver, + RadrootsPaths, RadrootsPlatform, RadrootsRuntimeNamespace, +}; +use std::path::{Path, PathBuf}; #[cfg(target_os = "ios")] use std::sync::Arc; +fn app_namespace() -> Result<RadrootsRuntimeNamespace, String> { + RadrootsRuntimeNamespace::app("app") + .map_err(|source| format!("failed to resolve ios app namespace: {source}")) +} + +fn mobile_base_root_from_home(home: &Path) -> PathBuf { + home.join("Library") + .join("Application Support") + .join("RadRoots") +} + +fn app_paths_from_home(home: &Path) -> Result<RadrootsPaths, String> { + let resolver = + RadrootsPathResolver::new(RadrootsPlatform::Ios, RadrootsHostEnvironment::default()); + let namespace = app_namespace()?; + resolver + .resolve( + RadrootsPathProfile::MobileNative, + &RadrootsPathOverrides::mobile(RadrootsPaths::from_base_root( + mobile_base_root_from_home(home), + )), + ) + .map(|roots| roots.namespaced(&namespace)) + .map_err(|source| format!("failed to resolve ios mobile-native roots: {source}")) +} + #[cfg(target_os = "ios")] pub(crate) fn accounts_path() -> Result<PathBuf, String> { let home = std::env::var_os("HOME") .map(PathBuf::from) .ok_or_else(|| "failed to resolve ios app container home directory".to_owned())?; - let accounts_path = accounts_path_from_home(home.as_path()); + let accounts_path = accounts_path_from_home(home.as_path())?; if let Some(parent) = accounts_path.parent() { ensure_private_directory_tree(parent)?; } @@ -26,7 +55,7 @@ pub(crate) fn app_data_root() -> Result<PathBuf, String> { let home = std::env::var_os("HOME") .map(PathBuf::from) .ok_or_else(|| "failed to resolve ios app container home directory".to_owned())?; - let root = app_data_root_from_home(home.as_path()); + let root = app_data_root_from_home(home.as_path())?; ensure_private_directory_tree(root.as_path())?; Ok(root) } @@ -38,18 +67,14 @@ pub(crate) fn accounts_manager() -> Result<RadrootsNostrAccountsManager, String> RadrootsNostrAccountsManager::new(store, vault).map_err(|source| source.to_string()) } -fn accounts_path_from_home(home: &Path) -> PathBuf { - app_data_root_from_home(home) +fn accounts_path_from_home(home: &Path) -> Result<PathBuf, String> { + Ok(app_data_root_from_home(home)? .join("nostr") - .join("accounts.json") + .join("accounts.json")) } -fn app_data_root_from_home(home: &Path) -> PathBuf { - home.join("Library") - .join("Application Support") - .join("RadRoots") - .join("app") - .join("ios") +fn app_data_root_from_home(home: &Path) -> Result<PathBuf, String> { + Ok(app_paths_from_home(home)?.data) } #[cfg(target_os = "ios")] @@ -57,9 +82,9 @@ fn ensure_private_directory_tree(leaf: &Path) -> Result<(), String> { use std::os::unix::fs::PermissionsExt; std::fs::create_dir_all(leaf) - .map_err(|source| format!("failed to create ios accounts directory: {source}"))?; + .map_err(|source| format!("failed to create ios app data directory: {source}"))?; std::fs::set_permissions(leaf, std::fs::Permissions::from_mode(0o700)) - .map_err(|source| format!("failed to set ios accounts directory permissions: {source}"))?; + .map_err(|source| format!("failed to set ios app data permissions: {source}"))?; Ok(()) } @@ -68,25 +93,50 @@ mod tests { use super::*; #[test] - fn accounts_path_uses_ios_application_support_layout() { + fn accounts_path_uses_ios_mobile_native_layout() { + let home = PathBuf::from("/var/mobile/Containers/Data/Application/example"); + + assert_eq!( + accounts_path_from_home(home.as_path()).expect("accounts path"), + PathBuf::from( + "/var/mobile/Containers/Data/Application/example/Library/Application Support/RadRoots/data/apps/app/nostr/accounts.json" + ) + ); + } + + #[test] + fn app_data_root_uses_ios_mobile_native_layout() { let home = PathBuf::from("/var/mobile/Containers/Data/Application/example"); assert_eq!( - accounts_path_from_home(home.as_path()), + app_data_root_from_home(home.as_path()).expect("app data root"), PathBuf::from( - "/var/mobile/Containers/Data/Application/example/Library/Application Support/RadRoots/app/ios/nostr/accounts.json" + "/var/mobile/Containers/Data/Application/example/Library/Application Support/RadRoots/data/apps/app" ) ); } #[test] - fn app_data_root_uses_ios_application_support_layout() { + fn mobile_paths_follow_shared_logical_root_model() { let home = PathBuf::from("/var/mobile/Containers/Data/Application/example"); + let paths = app_paths_from_home(home.as_path()).expect("mobile paths"); assert_eq!( - app_data_root_from_home(home.as_path()), + paths.config, + PathBuf::from( + "/var/mobile/Containers/Data/Application/example/Library/Application Support/RadRoots/config/apps/app" + ) + ); + assert_eq!( + paths.data, + PathBuf::from( + "/var/mobile/Containers/Data/Application/example/Library/Application Support/RadRoots/data/apps/app" + ) + ); + assert_eq!( + paths.secrets, PathBuf::from( - "/var/mobile/Containers/Data/Application/example/Library/Application Support/RadRoots/app/ios" + "/var/mobile/Containers/Data/Application/example/Library/Application Support/RadRoots/secrets/apps/app" ) ); } diff --git a/native/android/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidKeystoreSecretStore.kt b/native/android/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidKeystoreSecretStore.kt @@ -120,7 +120,7 @@ class RadRootsAndroidKeystoreSecretStore( deleteKey(masterKeyAlias(servicePrefix, namespace)) } - fun resolveNostrStorageRoot(): File = RadRootsAndroidStoragePaths.nostrRoot(context) + fun resolveRadrootsBaseRoot(): File = RadRootsAndroidStoragePaths.baseRoot(context) private fun validateIdentifiers(servicePrefix: String, namespace: String, name: String) { if (servicePrefix.isBlank()) { diff --git a/native/android/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidSecurityBridge.kt b/native/android/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidSecurityBridge.kt @@ -110,9 +110,9 @@ object RadRootsAndroidSecurityBridge { } @JvmStatic - fun resolveNostrStorageRoot(): String? { + fun resolveRadrootsBaseRoot(): String? { return try { - val path = secretStore().resolveNostrStorageRoot().absolutePath + val path = secretStore().resolveRadrootsBaseRoot().absolutePath clearError() path } catch (cause: Throwable) { diff --git a/native/android/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidStoragePaths.kt b/native/android/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidStoragePaths.kt @@ -6,29 +6,43 @@ import java.security.MessageDigest object RadRootsAndroidStoragePaths { private const val rootDirName = "RadRoots" - private const val productDirName = "app" - private const val platformDirName = "android" + private const val configDirName = "config" + private const val dataDirName = "data" + private const val secretsRootDirName = "secrets" + private const val appsDirName = "apps" + private const val appRuntimeDirName = "app" private const val nostrDirName = "nostr" - private const val secretsDirName = "secrets" private const val accountsFileName = "accounts.json" - fun nostrRoot(context: Context): File = nostrRoot(context.noBackupFilesDir) + fun baseRoot(context: Context): File = baseRoot(context.noBackupFilesDir) + + fun baseRoot(baseDir: File): File = File(baseDir, rootDirName) + + fun appDataRoot(context: Context): File = appDataRoot(context.noBackupFilesDir) - fun nostrRoot(baseDir: File): File = + fun appDataRoot(baseDir: File): File = File( File( - File( - File(baseDir, rootDirName), - productDirName, - ), - platformDirName, + File(baseRoot(baseDir), dataDirName), + appsDirName, ), - nostrDirName, + appRuntimeDirName, ) + fun nostrRoot(context: Context): File = nostrRoot(context.noBackupFilesDir) + + fun nostrRoot(baseDir: File): File = File(appDataRoot(baseDir), nostrDirName) + fun secretsDir(context: Context): File = secretsDir(context.noBackupFilesDir) - fun secretsDir(baseDir: File): File = File(nostrRoot(baseDir), secretsDirName) + fun secretsDir(baseDir: File): File = + File( + File( + File(baseRoot(baseDir), secretsRootDirName), + appsDirName, + ), + appRuntimeDirName, + ) fun accountsFile(context: Context): File = accountsFile(context.noBackupFilesDir) diff --git a/native/android/kotlin/RadRootsAndroidSecurity/src/test/kotlin/org/radroots/app/android/security/RadRootsAndroidSecurityTests.kt b/native/android/kotlin/RadRootsAndroidSecurity/src/test/kotlin/org/radroots/app/android/security/RadRootsAndroidSecurityTests.kt @@ -19,15 +19,23 @@ class RadRootsAndroidSecurityTests { } @Test - fun nostrRootUsesNoBackupLayout() { + fun mobileNativeRootsUseNoBackupLayout() { val baseDir = File("/data/user/0/org.radroots.app.android/no_backup") assertEquals( - File("/data/user/0/org.radroots.app.android/no_backup/RadRoots/app/android/nostr"), + File("/data/user/0/org.radroots.app.android/no_backup/RadRoots"), + RadRootsAndroidStoragePaths.baseRoot(baseDir), + ) + assertEquals( + File("/data/user/0/org.radroots.app.android/no_backup/RadRoots/data/apps/app"), + RadRootsAndroidStoragePaths.appDataRoot(baseDir), + ) + assertEquals( + File("/data/user/0/org.radroots.app.android/no_backup/RadRoots/data/apps/app/nostr"), RadRootsAndroidStoragePaths.nostrRoot(baseDir), ) assertEquals( - File("/data/user/0/org.radroots.app.android/no_backup/RadRoots/app/android/nostr/accounts.json"), + File("/data/user/0/org.radroots.app.android/no_backup/RadRoots/data/apps/app/nostr/accounts.json"), RadRootsAndroidStoragePaths.accountsFile(baseDir), ) }