lib

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

commit b48181fc930a6e11d5bd77e4086dd11de3de959c
parent b580bd27deec4665c91bee63e5287e2c76d48c67
Author: triesap <tyson@radroots.org>
Date:   Tue,  4 Aug 2026 13:21:16 +0000

logging: own mobile host initialization

- remove the unpublished radroots_log dependency
- preserve the existing mobile logging entry points
- keep file output private, bounded, and rotation-aware
- verify the complete workspace against registry SDK artifacts

Diffstat:
Mcrates/mobile_core/Cargo.toml | 5+++--
Mcrates/mobile_core/src/logging.rs | 152+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Acrates/mobile_core/src/logging/writer.rs | 183+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 308 insertions(+), 32 deletions(-)

diff --git a/crates/mobile_core/Cargo.toml b/crates/mobile_core/Cargo.toml @@ -27,15 +27,16 @@ mobile-social = [ ] [dependencies] -radroots_log = { workspace = true } radroots_sdk = { workspace = true } chrono = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } +tracing-appender = { workspace = true } +tracing-subscriber = { workspace = true } uniffi = { workspace = true } [dev-dependencies] +tempfile = "3" tokio = { version = "1", features = ["macros", "rt"] } -tracing-subscriber = { workspace = true } diff --git a/crates/mobile_core/src/logging.rs b/crates/mobile_core/src/logging.rs @@ -1,4 +1,39 @@ -use std::path::PathBuf; +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( @@ -7,56 +42,124 @@ pub fn init_logging( is_stdout: Option<bool>, ) -> Result<(), crate::RadrootsAppError> { let opts = logging_options(dir, file_name, is_stdout); - match radroots_log::init_logging(opts) { - Ok(()) => Ok(()), - Err(err) => Err(crate::RadrootsAppError::initialization(format!("{err}"))), - } + initialize(opts).map_err(crate::RadrootsAppError::initialization) } fn logging_options( dir: Option<String>, file_name: Option<String>, is_stdout: Option<bool>, -) -> radroots_log::LoggingOptions { - radroots_log::LoggingOptions { +) -> LoggingOptions { + LoggingOptions { dir: dir.map(PathBuf::from), file_name: file_name.unwrap_or_else(|| "radroots.log".to_string()), - file_layout: radroots_log::LogFileLayout::StableFileName, stdout: is_stdout.unwrap_or(true), - ..radroots_log::LoggingOptions::default() + ..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> { - match radroots_log::init_stdout() { - Ok(()) => Ok(()), - Err(err) => Err(crate::RadrootsAppError::initialization(format!("{err}"))), - } + initialize(LoggingOptions::default()).map_err(crate::RadrootsAppError::initialization) } #[cfg_attr(not(coverage_nightly), uniffi::export)] pub fn log_info(msg: String) -> Result<(), crate::RadrootsAppError> { - radroots_log::log_info(msg); + tracing::info!("{msg}"); Ok(()) } #[cfg_attr(not(coverage_nightly), uniffi::export)] pub fn log_error(msg: String) -> Result<(), crate::RadrootsAppError> { - radroots_log::log_error(msg); + tracing::error!("{msg}"); Ok(()) } #[cfg_attr(not(coverage_nightly), uniffi::export)] pub fn log_debug(msg: String) -> Result<(), crate::RadrootsAppError> { - radroots_log::log_debug(msg); + tracing::debug!("{msg}"); Ok(()) } #[cfg(test)] mod tests { - use super::logging_options; - use radroots_log::{LogFileLayout, LogFormat, LogRotation, LoggingOptions}; + use super::{LogRotation, LoggingOptions, logging_options}; use std::path::PathBuf; #[test] @@ -69,13 +172,8 @@ mod tests { assert_eq!(options.dir, Some(PathBuf::from("logs"))); assert_eq!(options.file_name, "mobile.log"); - assert_eq!(options.file_layout, LogFileLayout::StableFileName); assert!(!options.stdout); - assert!(!options.stderr); - assert_eq!(options.format, LogFormat::Compact); - assert_eq!(options.identity, None); assert_eq!(options.rotation, LogRotation::default()); - assert_eq!(options.default_level, None); } #[test] @@ -84,12 +182,6 @@ mod tests { assert_eq!(options.file_name, "radroots.log"); assert!(options.stdout); - assert_eq!( - options, - LoggingOptions { - file_layout: LogFileLayout::StableFileName, - ..LoggingOptions::default() - } - ); + assert_eq!(options, LoggingOptions::default()); } } diff --git a/crates/mobile_core/src/logging/writer.rs b/crates/mobile_core/src/logging/writer.rs @@ -0,0 +1,183 @@ +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)] +mod tests { + use super::{LogRotation, SizeRotatingWriter, 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()); + } +}