field_ios

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

logging.rs (9488B)


      1 mod privacy_format;
      2 mod writer;
      3 
      4 use std::fs;
      5 use std::path::{Component, Path, PathBuf};
      6 use std::sync::Mutex;
      7 
      8 use tracing_appender::non_blocking::WorkerGuard;
      9 use tracing_subscriber::prelude::*;
     10 
     11 use self::writer::{LogRotation, SizeRotatingWriter};
     12 
     13 #[derive(Debug, Clone, PartialEq, Eq)]
     14 struct LoggingOptions {
     15     dir: Option<PathBuf>,
     16     file_name: String,
     17     stdout: bool,
     18     rotation: LogRotation,
     19 }
     20 
     21 impl Default for LoggingOptions {
     22     fn default() -> Self {
     23         Self {
     24             dir: None,
     25             file_name: "radroots.log".to_owned(),
     26             stdout: true,
     27             rotation: LogRotation::default(),
     28         }
     29     }
     30 }
     31 
     32 struct ActiveLogging {
     33     options: LoggingOptions,
     34     _file_guard: Option<WorkerGuard>,
     35 }
     36 
     37 static LOGGING: Mutex<Option<ActiveLogging>> = Mutex::new(None);
     38 
     39 #[cfg_attr(not(coverage_nightly), uniffi::export)]
     40 pub fn init_logging(
     41     dir: Option<String>,
     42     file_name: Option<String>,
     43     is_stdout: Option<bool>,
     44 ) -> Result<(), crate::TeraAppError> {
     45     let opts = logging_options(dir, file_name, is_stdout);
     46     initialize(opts).map_err(crate::TeraAppError::initialization)
     47 }
     48 
     49 fn logging_options(
     50     dir: Option<String>,
     51     file_name: Option<String>,
     52     is_stdout: Option<bool>,
     53 ) -> LoggingOptions {
     54     LoggingOptions {
     55         dir: dir.map(PathBuf::from),
     56         file_name: file_name.unwrap_or_else(|| "radroots.log".to_string()),
     57         stdout: is_stdout.unwrap_or(true),
     58         ..LoggingOptions::default()
     59     }
     60 }
     61 
     62 fn initialize(options: LoggingOptions) -> Result<(), String> {
     63     validate_options(&options)?;
     64     let mut active = LOGGING
     65         .lock()
     66         .map_err(|_| "logging initialization state is poisoned".to_owned())?;
     67     if let Some(existing) = active.as_ref() {
     68         return if existing.options == options {
     69             Ok(())
     70         } else {
     71             Err("logging is already initialized with a different configuration".to_owned())
     72         };
     73     }
     74 
     75     let (file_writer, file_guard) = build_file_writer(&options)?;
     76     let file_layer = file_writer.as_ref().map(|writer| {
     77         tracing_subscriber::fmt::layer()
     78             .with_writer(writer.clone())
     79             .with_ansi(false)
     80             .event_format(privacy_format::DiagnosticFormat)
     81     });
     82     let stdout_layer = options.stdout.then(|| {
     83         tracing_subscriber::fmt::layer()
     84             .with_writer(std::io::stdout)
     85             .event_format(privacy_format::DiagnosticFormat)
     86     });
     87     tracing_subscriber::registry()
     88         .with(file_layer)
     89         .with(stdout_layer)
     90         .try_init()
     91         .map_err(|error| error.to_string())?;
     92     *active = Some(ActiveLogging {
     93         options,
     94         _file_guard: file_guard,
     95     });
     96     Ok(())
     97 }
     98 
     99 fn validate_options(options: &LoggingOptions) -> Result<(), String> {
    100     if options.dir.is_none() && !options.stdout {
    101         return Err("logging requires at least one configured output".to_owned());
    102     }
    103     if options.file_name.is_empty()
    104         || Path::new(&options.file_name).components().count() != 1
    105         || !matches!(
    106             Path::new(&options.file_name).components().next(),
    107             Some(Component::Normal(_))
    108         )
    109     {
    110         return Err("log file_name must be a safe basename".to_owned());
    111     }
    112     Ok(())
    113 }
    114 
    115 type FileWriter = tracing_appender::non_blocking::NonBlocking;
    116 
    117 fn build_file_writer(
    118     options: &LoggingOptions,
    119 ) -> Result<(Option<FileWriter>, Option<WorkerGuard>), String> {
    120     #[cfg(windows)]
    121     if options.dir.is_some() {
    122         return Err(
    123             "secure file logging is unavailable until Windows ACL and reparse-point enforcement is active"
    124                 .to_owned(),
    125         );
    126     }
    127     let Some(dir) = options.dir.as_ref() else {
    128         return Ok((None, None));
    129     };
    130     fs::create_dir_all(dir).map_err(|error| error.to_string())?;
    131     let metadata = fs::symlink_metadata(dir).map_err(|error| error.to_string())?;
    132     if metadata.file_type().is_symlink() || !metadata.is_dir() {
    133         return Err("log directory is not a safe directory".to_owned());
    134     }
    135     let writer = SizeRotatingWriter::new(dir.join(&options.file_name), options.rotation)
    136         .map_err(|error| error.to_string())?;
    137     let (writer, guard) = tracing_appender::non_blocking::NonBlockingBuilder::default()
    138         .buffered_lines_limit(8192)
    139         .lossy(false)
    140         .thread_name("tera-app-log-writer")
    141         .finish(writer);
    142     Ok((Some(writer), Some(guard)))
    143 }
    144 
    145 #[cfg_attr(not(coverage_nightly), uniffi::export)]
    146 pub fn init_logging_stdout() -> Result<(), crate::TeraAppError> {
    147     initialize(LoggingOptions::default()).map_err(crate::TeraAppError::initialization)
    148 }
    149 
    150 #[cfg_attr(not(coverage_nightly), uniffi::export)]
    151 pub fn log_info(msg: String) -> Result<(), crate::TeraAppError> {
    152     drop(msg);
    153     tracing::info!(code = "message_redacted");
    154     Ok(())
    155 }
    156 
    157 #[cfg_attr(not(coverage_nightly), uniffi::export)]
    158 pub fn log_error(msg: String) -> Result<(), crate::TeraAppError> {
    159     drop(msg);
    160     tracing::error!(code = "message_redacted");
    161     Ok(())
    162 }
    163 
    164 #[cfg_attr(not(coverage_nightly), uniffi::export)]
    165 pub fn log_debug(msg: String) -> Result<(), crate::TeraAppError> {
    166     drop(msg);
    167     tracing::debug!(code = "message_redacted");
    168     Ok(())
    169 }
    170 
    171 #[cfg(test)]
    172 #[cfg_attr(coverage_nightly, coverage(off))]
    173 mod tests {
    174     use super::{
    175         LogRotation, LoggingOptions, build_file_writer, initialize, log_debug, log_error, log_info,
    176         logging_options, validate_options,
    177     };
    178     use std::path::PathBuf;
    179 
    180     #[test]
    181     fn logging_options_adopt_bounded_library_defaults() {
    182         let options = logging_options(
    183             Some("logs".to_owned()),
    184             Some("mobile.log".to_owned()),
    185             Some(false),
    186         );
    187 
    188         assert_eq!(options.dir, Some(PathBuf::from("logs")));
    189         assert_eq!(options.file_name, "mobile.log");
    190         assert!(!options.stdout);
    191         assert_eq!(options.rotation, LogRotation::default());
    192     }
    193 
    194     #[test]
    195     fn logging_options_preserve_public_api_defaults() {
    196         let options = logging_options(None, None, None);
    197 
    198         assert_eq!(options.file_name, "radroots.log");
    199         assert!(options.stdout);
    200         assert_eq!(options, LoggingOptions::default());
    201     }
    202 
    203     #[test]
    204     fn validation_rejects_missing_outputs_and_unsafe_names() {
    205         let mut options = LoggingOptions {
    206             stdout: false,
    207             ..LoggingOptions::default()
    208         };
    209         assert!(validate_options(&options).is_err());
    210 
    211         options.stdout = true;
    212         for name in ["", "../radroots.log", "nested/radroots.log", "."] {
    213             options.file_name = name.to_owned();
    214             assert!(validate_options(&options).is_err(), "accepted {name:?}");
    215         }
    216 
    217         options.dir = Some(PathBuf::from("logs"));
    218         options.file_name = "radroots.log".to_owned();
    219         options.stdout = false;
    220         validate_options(&options).expect("file output is sufficient");
    221     }
    222 
    223     #[test]
    224     fn file_writer_is_optional_and_rejects_non_directories() {
    225         let options = LoggingOptions::default();
    226         let (writer, guard) = build_file_writer(&options).expect("stdout only");
    227         assert!(writer.is_none());
    228         assert!(guard.is_none());
    229 
    230         let directory = tempfile::tempdir().expect("temporary directory");
    231         let file = directory.path().join("not-a-directory");
    232         std::fs::write(&file, b"not a directory").expect("fixture");
    233         let options = LoggingOptions {
    234             dir: Some(file),
    235             ..LoggingOptions::default()
    236         };
    237         assert!(build_file_writer(&options).is_err());
    238 
    239         #[cfg(unix)]
    240         {
    241             let target = directory.path().join("real-directory");
    242             std::fs::create_dir(&target).expect("real directory");
    243             let link = directory.path().join("linked-directory");
    244             std::os::unix::fs::symlink(&target, &link).expect("directory symlink");
    245             let options = LoggingOptions {
    246                 dir: Some(link),
    247                 ..LoggingOptions::default()
    248             };
    249             assert!(build_file_writer(&options).is_err());
    250         }
    251     }
    252 
    253     #[test]
    254     fn logging_entry_points_accept_secret_safe_messages() {
    255         log_info("info".to_owned()).expect("info");
    256         log_error("error".to_owned()).expect("error");
    257         log_debug("debug".to_owned()).expect("debug");
    258     }
    259 
    260     #[cfg(windows)]
    261     #[test]
    262     fn windows_file_logging_fails_before_filesystem_mutation() {
    263         let directory = tempfile::tempdir().expect("temporary directory");
    264         let requested = directory.path().join("must-not-exist");
    265         let options = LoggingOptions {
    266             dir: Some(requested.clone()),
    267             stdout: false,
    268             ..LoggingOptions::default()
    269         };
    270         let error = build_file_writer(&options).expect_err("ACL capability must fail closed");
    271         assert!(error.contains("ACL"));
    272         assert!(error.contains("reparse-point"));
    273         assert!(!requested.exists());
    274     }
    275 
    276     #[test]
    277     fn initialization_is_idempotent_but_rejects_reconfiguration() {
    278         let directory = tempfile::tempdir().expect("temporary directory");
    279         let options = LoggingOptions {
    280             dir: Some(directory.path().to_owned()),
    281             stdout: false,
    282             ..LoggingOptions::default()
    283         };
    284         initialize(options.clone()).expect("first initialization");
    285         initialize(options).expect("idempotent initialization");
    286         assert!(initialize(LoggingOptions::default()).is_err());
    287     }
    288 }