lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 97e1604479301f4e255c601f39d5d875eb61aaf3
parent 2c17ee2df7f24c3616356b211c3848cff1124dd0
Author: triesap <tyson@radroots.org>
Date:   Thu,  6 Aug 2026 07:11:57 +0000

mobile: move global logging policy into mobileffi

- relocate host logging initialization and exported entry points to mobile_ffi
- relocate bounded rotation and filesystem policy with its attack tests
- remove tracing and logging dependencies from ordinary mobile_core builds
- preserve native error behavior and qualify the new boundary with strict checks

Diffstat:
MCargo.lock | 8++++----
Mcrates/mobile_core/Cargo.toml | 6------
Mcrates/mobile_core/src/lib.rs | 2--
Dcrates/mobile_core/src/logging.rs | 248-------------------------------------------------------------------------------
Dcrates/mobile_core/tests/logging_error.rs | 9---------
Mcrates/mobile_ffi/Cargo.toml | 8+++++++-
Mcrates/mobile_ffi/src/lib.rs | 7+++++++
Acrates/mobile_ffi/src/logging.rs | 249+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rcrates/mobile_core/src/logging/writer.rs -> crates/mobile_ffi/src/logging/writer.rs | 0
Acrates/mobile_ffi/tests/logging_error.rs | 9+++++++++
10 files changed, 276 insertions(+), 270 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -3044,12 +3044,8 @@ dependencies = [ "radroots_sdk", "serde", "serde_json", - "tempfile", "thiserror 1.0.69", "tokio", - "tracing", - "tracing-appender", - "tracing-subscriber", "uniffi", ] @@ -3058,6 +3054,10 @@ name = "radroots_mobile_ffi" version = "0.1.0-alpha" dependencies = [ "radroots_mobile_core", + "tempfile", + "tracing", + "tracing-appender", + "tracing-subscriber", "uniffi", ] diff --git a/crates/mobile_core/Cargo.toml b/crates/mobile_core/Cargo.toml @@ -34,11 +34,5 @@ serde_json = { workspace = true } thiserror = { workspace = true } uniffi = { workspace = true } -[target.'cfg(not(target_arch = "wasm32"))'.dependencies] -tracing = { workspace = true } -tracing-appender = { workspace = true } -tracing-subscriber = { workspace = true } - [dev-dependencies] -tempfile = { workspace = true } tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/crates/mobile_core/src/lib.rs b/crates/mobile_core/src/lib.rs @@ -7,8 +7,6 @@ uniffi::setup_scaffolding!("radroots_mobile_core"); pub mod error; -#[cfg(not(target_arch = "wasm32"))] -pub mod logging; pub mod runtime; pub use error::{RadrootsAppError, SdkErrorRecord}; diff --git a/crates/mobile_core/src/logging.rs b/crates/mobile_core/src/logging.rs @@ -1,248 +0,0 @@ -mod writer; - -use std::fs; -use std::path::{Component, Path, PathBuf}; -use std::sync::Mutex; - -use tracing_appender::non_blocking::WorkerGuard; -use tracing_subscriber::prelude::*; - -use self::writer::{LogRotation, SizeRotatingWriter}; - -#[derive(Debug, Clone, PartialEq, Eq)] -struct LoggingOptions { - dir: Option<PathBuf>, - file_name: String, - stdout: bool, - rotation: LogRotation, -} - -impl Default for LoggingOptions { - fn default() -> Self { - Self { - dir: None, - file_name: "radroots.log".to_owned(), - stdout: true, - rotation: LogRotation::default(), - } - } -} - -struct ActiveLogging { - options: LoggingOptions, - _file_guard: Option<WorkerGuard>, -} - -static LOGGING: Mutex<Option<ActiveLogging>> = Mutex::new(None); - -#[cfg_attr(not(coverage_nightly), uniffi::export)] -pub fn init_logging( - dir: Option<String>, - file_name: Option<String>, - is_stdout: Option<bool>, -) -> Result<(), crate::RadrootsAppError> { - let opts = logging_options(dir, file_name, is_stdout); - initialize(opts).map_err(crate::RadrootsAppError::initialization) -} - -fn logging_options( - dir: Option<String>, - file_name: Option<String>, - is_stdout: Option<bool>, -) -> LoggingOptions { - LoggingOptions { - dir: dir.map(PathBuf::from), - file_name: file_name.unwrap_or_else(|| "radroots.log".to_string()), - stdout: is_stdout.unwrap_or(true), - ..LoggingOptions::default() - } -} - -fn initialize(options: LoggingOptions) -> Result<(), String> { - validate_options(&options)?; - let mut active = LOGGING - .lock() - .map_err(|_| "logging initialization state is poisoned".to_owned())?; - if let Some(existing) = active.as_ref() { - return if existing.options == options { - Ok(()) - } else { - Err("logging is already initialized with a different configuration".to_owned()) - }; - } - - let (file_writer, file_guard) = build_file_writer(&options)?; - let file_layer = file_writer.as_ref().map(|writer| { - tracing_subscriber::fmt::layer() - .with_writer(writer.clone()) - .with_ansi(false) - .with_target(false) - }); - let stdout_layer = options.stdout.then(|| { - tracing_subscriber::fmt::layer() - .with_writer(std::io::stdout) - .with_target(false) - }); - tracing_subscriber::registry() - .with(file_layer) - .with(stdout_layer) - .try_init() - .map_err(|error| error.to_string())?; - *active = Some(ActiveLogging { - options, - _file_guard: file_guard, - }); - Ok(()) -} - -fn validate_options(options: &LoggingOptions) -> Result<(), String> { - if options.dir.is_none() && !options.stdout { - return Err("logging requires at least one configured output".to_owned()); - } - if options.file_name.is_empty() - || Path::new(&options.file_name).components().count() != 1 - || !matches!( - Path::new(&options.file_name).components().next(), - Some(Component::Normal(_)) - ) - { - return Err("log file_name must be a safe basename".to_owned()); - } - Ok(()) -} - -type FileWriter = tracing_appender::non_blocking::NonBlocking; - -fn build_file_writer( - options: &LoggingOptions, -) -> Result<(Option<FileWriter>, Option<WorkerGuard>), String> { - let Some(dir) = options.dir.as_ref() else { - return Ok((None, None)); - }; - fs::create_dir_all(dir).map_err(|error| error.to_string())?; - let metadata = fs::symlink_metadata(dir).map_err(|error| error.to_string())?; - if metadata.file_type().is_symlink() || !metadata.is_dir() { - return Err("log directory is not a safe directory".to_owned()); - } - let writer = SizeRotatingWriter::new(dir.join(&options.file_name), options.rotation) - .map_err(|error| error.to_string())?; - let (writer, guard) = tracing_appender::non_blocking::NonBlockingBuilder::default() - .buffered_lines_limit(8192) - .lossy(false) - .thread_name("radroots-app-log-writer") - .finish(writer); - Ok((Some(writer), Some(guard))) -} - -#[cfg_attr(not(coverage_nightly), uniffi::export)] -pub fn init_logging_stdout() -> Result<(), crate::RadrootsAppError> { - initialize(LoggingOptions::default()).map_err(crate::RadrootsAppError::initialization) -} - -#[cfg_attr(not(coverage_nightly), uniffi::export)] -pub fn log_info(msg: String) -> Result<(), crate::RadrootsAppError> { - tracing::info!("{msg}"); - Ok(()) -} - -#[cfg_attr(not(coverage_nightly), uniffi::export)] -pub fn log_error(msg: String) -> Result<(), crate::RadrootsAppError> { - tracing::error!("{msg}"); - Ok(()) -} - -#[cfg_attr(not(coverage_nightly), uniffi::export)] -pub fn log_debug(msg: String) -> Result<(), crate::RadrootsAppError> { - tracing::debug!("{msg}"); - Ok(()) -} - -#[cfg(test)] -#[cfg_attr(coverage_nightly, coverage(off))] -mod tests { - use super::{ - LogRotation, LoggingOptions, build_file_writer, initialize, log_debug, log_error, log_info, - logging_options, validate_options, - }; - use std::path::PathBuf; - - #[test] - fn logging_options_adopt_bounded_library_defaults() { - let options = logging_options( - Some("logs".to_owned()), - Some("mobile.log".to_owned()), - Some(false), - ); - - assert_eq!(options.dir, Some(PathBuf::from("logs"))); - assert_eq!(options.file_name, "mobile.log"); - assert!(!options.stdout); - assert_eq!(options.rotation, LogRotation::default()); - } - - #[test] - fn logging_options_preserve_public_api_defaults() { - let options = logging_options(None, None, None); - - assert_eq!(options.file_name, "radroots.log"); - assert!(options.stdout); - assert_eq!(options, LoggingOptions::default()); - } - - #[test] - fn validation_rejects_missing_outputs_and_unsafe_names() { - let mut options = LoggingOptions { - stdout: false, - ..LoggingOptions::default() - }; - assert!(validate_options(&options).is_err()); - - options.stdout = true; - for name in ["", "../radroots.log", "nested/radroots.log", "."] { - options.file_name = name.to_owned(); - assert!(validate_options(&options).is_err(), "accepted {name:?}"); - } - - options.dir = Some(PathBuf::from("logs")); - options.file_name = "radroots.log".to_owned(); - options.stdout = false; - validate_options(&options).expect("file output is sufficient"); - } - - #[test] - fn file_writer_is_optional_and_rejects_non_directories() { - let options = LoggingOptions::default(); - let (writer, guard) = build_file_writer(&options).expect("stdout only"); - assert!(writer.is_none()); - assert!(guard.is_none()); - - let directory = tempfile::tempdir().expect("temporary directory"); - let file = directory.path().join("not-a-directory"); - std::fs::write(&file, b"not a directory").expect("fixture"); - let options = LoggingOptions { - dir: Some(file), - ..LoggingOptions::default() - }; - assert!(build_file_writer(&options).is_err()); - } - - #[test] - fn logging_entry_points_accept_secret_safe_messages() { - log_info("info".to_owned()).expect("info"); - log_error("error".to_owned()).expect("error"); - log_debug("debug".to_owned()).expect("debug"); - } - - #[test] - fn initialization_is_idempotent_but_rejects_reconfiguration() { - let directory = tempfile::tempdir().expect("temporary directory"); - let options = LoggingOptions { - dir: Some(directory.path().to_owned()), - stdout: false, - ..LoggingOptions::default() - }; - initialize(options.clone()).expect("first initialization"); - initialize(options).expect("idempotent initialization"); - assert!(initialize(LoggingOptions::default()).is_err()); - } -} diff --git a/crates/mobile_core/tests/logging_error.rs b/crates/mobile_core/tests/logging_error.rs @@ -1,9 +0,0 @@ -use radroots_mobile_core::RadrootsAppError; -use radroots_mobile_core::logging; - -#[test] -fn init_logging_stdout_maps_global_subscriber_error() { - let _ = tracing_subscriber::fmt().try_init(); - let err = logging::init_logging_stdout(); - assert!(matches!(err, Err(RadrootsAppError::Initialization(_)))); -} diff --git a/crates/mobile_ffi/Cargo.toml b/crates/mobile_ffi/Cargo.toml @@ -13,11 +13,17 @@ publish = false include = ["src/**", "uniffi.toml", "Cargo.toml"] [lib] -crate-type = ["staticlib", "cdylib"] +crate-type = ["rlib", "staticlib", "cdylib"] [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } [dependencies] radroots_mobile_core = { workspace = true, features = ["mobile-social"] } +tracing = { workspace = true } +tracing-appender = { workspace = true } +tracing-subscriber = { workspace = true } uniffi = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/crates/mobile_ffi/src/lib.rs b/crates/mobile_ffi/src/lib.rs @@ -1,5 +1,12 @@ +// UniFFI serializes the complete versioned error record across the language +// boundary; keeping it by value preserves the generated wire contract. +#![allow(clippy::result_large_err)] + +uniffi::setup_scaffolding!("radroots_mobile_ffi"); radroots_mobile_core::uniffi_reexport_scaffolding!(); +pub mod logging; + #[allow( clippy::if_same_then_else, reason = "coverage probe intentionally exercises both paths with a stable value" diff --git a/crates/mobile_ffi/src/logging.rs b/crates/mobile_ffi/src/logging.rs @@ -0,0 +1,249 @@ +mod writer; + +use std::fs; +use std::path::{Component, Path, PathBuf}; +use std::sync::Mutex; + +use tracing_appender::non_blocking::WorkerGuard; +use tracing_subscriber::prelude::*; + +use self::writer::{LogRotation, SizeRotatingWriter}; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct LoggingOptions { + dir: Option<PathBuf>, + file_name: String, + stdout: bool, + rotation: LogRotation, +} + +impl Default for LoggingOptions { + fn default() -> Self { + Self { + dir: None, + file_name: "radroots.log".to_owned(), + stdout: true, + rotation: LogRotation::default(), + } + } +} + +struct ActiveLogging { + options: LoggingOptions, + _file_guard: Option<WorkerGuard>, +} + +static LOGGING: Mutex<Option<ActiveLogging>> = Mutex::new(None); + +#[cfg_attr(not(coverage_nightly), uniffi::export)] +pub fn init_logging( + dir: Option<String>, + file_name: Option<String>, + is_stdout: Option<bool>, +) -> Result<(), radroots_mobile_core::RadrootsAppError> { + let opts = logging_options(dir, file_name, is_stdout); + initialize(opts).map_err(radroots_mobile_core::RadrootsAppError::initialization) +} + +fn logging_options( + dir: Option<String>, + file_name: Option<String>, + is_stdout: Option<bool>, +) -> LoggingOptions { + LoggingOptions { + dir: dir.map(PathBuf::from), + file_name: file_name.unwrap_or_else(|| "radroots.log".to_string()), + stdout: is_stdout.unwrap_or(true), + ..LoggingOptions::default() + } +} + +fn initialize(options: LoggingOptions) -> Result<(), String> { + validate_options(&options)?; + let mut active = LOGGING + .lock() + .map_err(|_| "logging initialization state is poisoned".to_owned())?; + if let Some(existing) = active.as_ref() { + return if existing.options == options { + Ok(()) + } else { + Err("logging is already initialized with a different configuration".to_owned()) + }; + } + + let (file_writer, file_guard) = build_file_writer(&options)?; + let file_layer = file_writer.as_ref().map(|writer| { + tracing_subscriber::fmt::layer() + .with_writer(writer.clone()) + .with_ansi(false) + .with_target(false) + }); + let stdout_layer = options.stdout.then(|| { + tracing_subscriber::fmt::layer() + .with_writer(std::io::stdout) + .with_target(false) + }); + tracing_subscriber::registry() + .with(file_layer) + .with(stdout_layer) + .try_init() + .map_err(|error| error.to_string())?; + *active = Some(ActiveLogging { + options, + _file_guard: file_guard, + }); + Ok(()) +} + +fn validate_options(options: &LoggingOptions) -> Result<(), String> { + if options.dir.is_none() && !options.stdout { + return Err("logging requires at least one configured output".to_owned()); + } + if options.file_name.is_empty() + || Path::new(&options.file_name).components().count() != 1 + || !matches!( + Path::new(&options.file_name).components().next(), + Some(Component::Normal(_)) + ) + { + return Err("log file_name must be a safe basename".to_owned()); + } + Ok(()) +} + +type FileWriter = tracing_appender::non_blocking::NonBlocking; + +fn build_file_writer( + options: &LoggingOptions, +) -> Result<(Option<FileWriter>, Option<WorkerGuard>), String> { + let Some(dir) = options.dir.as_ref() else { + return Ok((None, None)); + }; + fs::create_dir_all(dir).map_err(|error| error.to_string())?; + let metadata = fs::symlink_metadata(dir).map_err(|error| error.to_string())?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err("log directory is not a safe directory".to_owned()); + } + let writer = SizeRotatingWriter::new(dir.join(&options.file_name), options.rotation) + .map_err(|error| error.to_string())?; + let (writer, guard) = tracing_appender::non_blocking::NonBlockingBuilder::default() + .buffered_lines_limit(8192) + .lossy(false) + .thread_name("radroots-app-log-writer") + .finish(writer); + Ok((Some(writer), Some(guard))) +} + +#[cfg_attr(not(coverage_nightly), uniffi::export)] +pub fn init_logging_stdout() -> Result<(), radroots_mobile_core::RadrootsAppError> { + initialize(LoggingOptions::default()) + .map_err(radroots_mobile_core::RadrootsAppError::initialization) +} + +#[cfg_attr(not(coverage_nightly), uniffi::export)] +pub fn log_info(msg: String) -> Result<(), radroots_mobile_core::RadrootsAppError> { + tracing::info!("{msg}"); + Ok(()) +} + +#[cfg_attr(not(coverage_nightly), uniffi::export)] +pub fn log_error(msg: String) -> Result<(), radroots_mobile_core::RadrootsAppError> { + tracing::error!("{msg}"); + Ok(()) +} + +#[cfg_attr(not(coverage_nightly), uniffi::export)] +pub fn log_debug(msg: String) -> Result<(), radroots_mobile_core::RadrootsAppError> { + tracing::debug!("{msg}"); + Ok(()) +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use super::{ + LogRotation, LoggingOptions, build_file_writer, initialize, log_debug, log_error, log_info, + logging_options, validate_options, + }; + use std::path::PathBuf; + + #[test] + fn logging_options_adopt_bounded_library_defaults() { + let options = logging_options( + Some("logs".to_owned()), + Some("mobile.log".to_owned()), + Some(false), + ); + + assert_eq!(options.dir, Some(PathBuf::from("logs"))); + assert_eq!(options.file_name, "mobile.log"); + assert!(!options.stdout); + assert_eq!(options.rotation, LogRotation::default()); + } + + #[test] + fn logging_options_preserve_public_api_defaults() { + let options = logging_options(None, None, None); + + assert_eq!(options.file_name, "radroots.log"); + assert!(options.stdout); + assert_eq!(options, LoggingOptions::default()); + } + + #[test] + fn validation_rejects_missing_outputs_and_unsafe_names() { + let mut options = LoggingOptions { + stdout: false, + ..LoggingOptions::default() + }; + assert!(validate_options(&options).is_err()); + + options.stdout = true; + for name in ["", "../radroots.log", "nested/radroots.log", "."] { + options.file_name = name.to_owned(); + assert!(validate_options(&options).is_err(), "accepted {name:?}"); + } + + options.dir = Some(PathBuf::from("logs")); + options.file_name = "radroots.log".to_owned(); + options.stdout = false; + validate_options(&options).expect("file output is sufficient"); + } + + #[test] + fn file_writer_is_optional_and_rejects_non_directories() { + let options = LoggingOptions::default(); + let (writer, guard) = build_file_writer(&options).expect("stdout only"); + assert!(writer.is_none()); + assert!(guard.is_none()); + + let directory = tempfile::tempdir().expect("temporary directory"); + let file = directory.path().join("not-a-directory"); + std::fs::write(&file, b"not a directory").expect("fixture"); + let options = LoggingOptions { + dir: Some(file), + ..LoggingOptions::default() + }; + assert!(build_file_writer(&options).is_err()); + } + + #[test] + fn logging_entry_points_accept_secret_safe_messages() { + log_info("info".to_owned()).expect("info"); + log_error("error".to_owned()).expect("error"); + log_debug("debug".to_owned()).expect("debug"); + } + + #[test] + fn initialization_is_idempotent_but_rejects_reconfiguration() { + let directory = tempfile::tempdir().expect("temporary directory"); + let options = LoggingOptions { + dir: Some(directory.path().to_owned()), + stdout: false, + ..LoggingOptions::default() + }; + initialize(options.clone()).expect("first initialization"); + initialize(options).expect("idempotent initialization"); + assert!(initialize(LoggingOptions::default()).is_err()); + } +} diff --git a/crates/mobile_core/src/logging/writer.rs b/crates/mobile_ffi/src/logging/writer.rs diff --git a/crates/mobile_ffi/tests/logging_error.rs b/crates/mobile_ffi/tests/logging_error.rs @@ -0,0 +1,9 @@ +use radroots_mobile_core::RadrootsAppError; +use radroots_mobile_ffi::logging; + +#[test] +fn init_logging_stdout_maps_global_subscriber_error() { + let _ = tracing_subscriber::fmt().try_init(); + let err = logging::init_logging_stdout(); + assert!(matches!(err, Err(RadrootsAppError::Initialization(_)))); +}