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 }