field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

commit 6373d4698488b161c2e87930e2ea0439be4e36b2
parent 34837875a75779076a945be3dde90966b6db94a3
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:
Mcore/crates/tera_core/Cargo.toml | 6------
Mcore/crates/tera_core/src/lib.rs | 2--
Dcore/crates/tera_core/src/logging.rs | 248-------------------------------------------------------------------------------
Dcore/crates/tera_core/src/logging/writer.rs | 268-------------------------------------------------------------------------------
Dcore/crates/tera_core/tests/logging_error.rs | 9---------
5 files changed, 0 insertions(+), 533 deletions(-)

diff --git a/core/crates/tera_core/Cargo.toml b/core/crates/tera_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/core/crates/tera_core/src/lib.rs b/core/crates/tera_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/core/crates/tera_core/src/logging.rs b/core/crates/tera_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/core/crates/tera_core/src/logging/writer.rs b/core/crates/tera_core/src/logging/writer.rs @@ -1,268 +0,0 @@ -use std::fs::{self, File, OpenOptions}; -use std::io::{self, Write}; -use std::path::{Path, PathBuf}; - -const DEFAULT_MAX_FILE_BYTES: u64 = 10 * 1024 * 1024; -const DEFAULT_RETAINED_FILES: usize = 5; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) struct LogRotation { - max_file_bytes: u64, - retained_files: usize, -} - -impl Default for LogRotation { - fn default() -> Self { - Self { - max_file_bytes: DEFAULT_MAX_FILE_BYTES, - retained_files: DEFAULT_RETAINED_FILES, - } - } -} - -pub(super) struct SizeRotatingWriter { - path: PathBuf, - file: Option<File>, - bytes_written: u64, - policy: LogRotation, -} - -impl SizeRotatingWriter { - pub(super) fn new(path: PathBuf, policy: LogRotation) -> io::Result<Self> { - reject_unsafe_target(&path)?; - let file = open_append(&path)?; - let bytes_written = file.metadata()?.len(); - let mut writer = Self { - path, - file: Some(file), - bytes_written, - policy, - }; - if writer.bytes_written >= writer.policy.max_file_bytes { - writer.rotate()?; - } - Ok(writer) - } - - fn rotate(&mut self) -> io::Result<()> { - if let Some(mut file) = self.file.take() { - file.flush()?; - file.sync_data()?; - } - if self.policy.retained_files == 1 { - remove_if_present(&self.path)?; - } else { - remove_if_present(&rotated_path(&self.path, self.policy.retained_files - 1))?; - for index in (2..self.policy.retained_files).rev() { - rename_if_present( - &rotated_path(&self.path, index - 1), - &rotated_path(&self.path, index), - )?; - } - rename_if_present(&self.path, &rotated_path(&self.path, 1))?; - } - self.file = Some(open_append(&self.path)?); - self.bytes_written = 0; - Ok(()) - } - - fn file_mut(&mut self) -> io::Result<&mut File> { - self.file - .as_mut() - .ok_or_else(|| io::Error::other("log file is unavailable")) - } -} - -impl Write for SizeRotatingWriter { - fn write(&mut self, buffer: &[u8]) -> io::Result<usize> { - let incoming = u64::try_from(buffer.len()) - .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "log event is too large"))?; - if incoming > self.policy.max_file_bytes { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "log event exceeds the configured file limit", - )); - } - if self.bytes_written > 0 - && self.bytes_written.saturating_add(incoming) > self.policy.max_file_bytes - { - self.rotate()?; - } - let written = self.file_mut()?.write(buffer)?; - self.bytes_written = self - .bytes_written - .saturating_add(u64::try_from(written).unwrap_or(u64::MAX)); - Ok(written) - } - - fn flush(&mut self) -> io::Result<()> { - self.file_mut()?.flush() - } -} - -fn reject_unsafe_target(path: &Path) -> io::Result<()> { - match fs::symlink_metadata(path) { - Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => Err( - io::Error::new(io::ErrorKind::InvalidInput, "log target is not a safe file"), - ), - Ok(_) => Ok(()), - Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(error), - } -} - -fn open_append(path: &Path) -> io::Result<File> { - let mut options = OpenOptions::new(); - options.create(true).append(true); - #[cfg(unix)] - { - use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; - options.mode(0o600); - let file = options.open(path)?; - file.set_permissions(fs::Permissions::from_mode(0o600))?; - Ok(file) - } - #[cfg(not(unix))] - options.open(path) -} - -fn rotated_path(path: &Path, index: usize) -> PathBuf { - let mut value = path.as_os_str().to_owned(); - value.push(format!(".{index}")); - PathBuf::from(value) -} - -fn remove_if_present(path: &Path) -> io::Result<()> { - match fs::remove_file(path) { - Ok(()) => Ok(()), - Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(error), - } -} - -fn rename_if_present(source: &Path, target: &Path) -> io::Result<()> { - match fs::rename(source, target) { - Ok(()) => Ok(()), - Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(error), - } -} - -#[cfg(test)] -#[cfg_attr(coverage_nightly, coverage(off))] -mod tests { - use super::{ - LogRotation, SizeRotatingWriter, reject_unsafe_target, remove_if_present, - rename_if_present, rotated_path, - }; - use std::io::Write; - - #[test] - fn rotation_is_size_bounded_and_retention_is_finite() { - let directory = tempfile::tempdir().expect("temporary log directory"); - let path = directory.path().join("radroots.log"); - let mut writer = SizeRotatingWriter::new( - path.clone(), - LogRotation { - max_file_bytes: 5, - retained_files: 3, - }, - ) - .expect("writer"); - for value in [b"1111", b"2222", b"3333", b"4444"] { - writer.write_all(value).expect("log entry"); - } - writer.flush().expect("flush"); - assert_eq!(std::fs::read(&path).expect("current"), b"4444"); - assert_eq!( - std::fs::read(rotated_path(&path, 1)).expect("first retained"), - b"3333" - ); - assert_eq!( - std::fs::read(rotated_path(&path, 2)).expect("second retained"), - b"2222" - ); - assert!(!rotated_path(&path, 3).exists()); - } - - #[test] - fn single_file_rotation_removes_the_previous_file() { - let directory = tempfile::tempdir().expect("temporary log directory"); - let path = directory.path().join("radroots.log"); - std::fs::write(&path, b"full!").expect("fixture"); - let mut writer = SizeRotatingWriter::new( - path.clone(), - LogRotation { - max_file_bytes: 5, - retained_files: 1, - }, - ) - .expect("writer"); - writer.write_all(b"next").expect("write"); - writer.flush().expect("flush"); - assert_eq!(std::fs::read(path).expect("current"), b"next"); - } - - #[test] - fn oversized_events_and_unavailable_files_fail_closed() { - let directory = tempfile::tempdir().expect("temporary log directory"); - let path = directory.path().join("radroots.log"); - let mut writer = SizeRotatingWriter::new( - path, - LogRotation { - max_file_bytes: 3, - retained_files: 2, - }, - ) - .expect("writer"); - assert_eq!( - writer.write(b"four").expect_err("oversized").kind(), - std::io::ErrorKind::InvalidData - ); - writer.write_all(b"a").expect("first short write"); - writer.write_all(b"b").expect("second short write"); - writer.file = None; - assert_eq!( - writer.flush().expect_err("missing file").kind(), - std::io::ErrorKind::Other - ); - } - - #[test] - fn unsafe_targets_and_absent_rotation_files_are_handled() { - let directory = tempfile::tempdir().expect("temporary directory"); - assert!(reject_unsafe_target(directory.path()).is_err()); - let missing = directory.path().join("missing"); - assert!(reject_unsafe_target(&missing).is_ok()); - assert!(remove_if_present(&missing).is_ok()); - assert!(rename_if_present(&missing, &directory.path().join("target")).is_ok()); - assert!(remove_if_present(directory.path()).is_err()); - - let source = directory.path().join("source"); - std::fs::write(&source, b"source").expect("source"); - assert!(rename_if_present(&source, directory.path()).is_err()); - - #[cfg(unix)] - { - std::os::unix::fs::symlink(directory.path(), &missing).expect("symlink"); - assert!(reject_unsafe_target(&missing).is_err()); - } - } - - #[test] - fn rotation_without_an_open_file_recreates_the_target() { - let directory = tempfile::tempdir().expect("temporary directory"); - let path = directory.path().join("radroots.log"); - let mut writer = SizeRotatingWriter::new( - path, - LogRotation { - max_file_bytes: 3, - retained_files: 2, - }, - ) - .expect("writer"); - writer.file = None; - writer.rotate().expect("rotation"); - writer.write_all(b"ok").expect("write after rotation"); - } -} diff --git a/core/crates/tera_core/tests/logging_error.rs b/core/crates/tera_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(_)))); -}