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