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:
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(_))));
+}