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 363a846643b5ea875da48fee148451afe0b2489d
parent 7e17c4896b906e7aa36198354615fb555c70884b
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_ffi/Cargo.toml | 8+++++++-
Mcore/crates/tera_ffi/src/lib.rs | 7+++++++
Acore/crates/tera_ffi/src/logging.rs | 249+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_ffi/src/logging/writer.rs | 268+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_ffi/tests/logging_error.rs | 9+++++++++
5 files changed, 540 insertions(+), 1 deletion(-)

diff --git a/core/crates/tera_ffi/Cargo.toml b/core/crates/tera_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/core/crates/tera_ffi/src/lib.rs b/core/crates/tera_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/core/crates/tera_ffi/src/logging.rs b/core/crates/tera_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/core/crates/tera_ffi/src/logging/writer.rs b/core/crates/tera_ffi/src/logging/writer.rs @@ -0,0 +1,268 @@ +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_ffi/tests/logging_error.rs b/core/crates/tera_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(_)))); +}