writer.rs (15077B)
1 use std::ffi::{OsStr, OsString}; 2 #[cfg(any(test, not(unix)))] 3 use std::fs; 4 use std::fs::File; 5 use std::io::{self, Write}; 6 use std::path::{Path, PathBuf}; 7 8 const DEFAULT_MAX_FILE_BYTES: u64 = 10 * 1024 * 1024; 9 const DEFAULT_RETAINED_FILES: usize = 5; 10 11 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 12 pub(super) struct LogRotation { 13 max_file_bytes: u64, 14 retained_files: usize, 15 } 16 17 impl Default for LogRotation { 18 fn default() -> Self { 19 Self { 20 max_file_bytes: DEFAULT_MAX_FILE_BYTES, 21 retained_files: DEFAULT_RETAINED_FILES, 22 } 23 } 24 } 25 26 pub(super) struct SizeRotatingWriter { 27 #[cfg(not(unix))] 28 path: PathBuf, 29 #[cfg(unix)] 30 directory: File, 31 file_name: OsString, 32 file: Option<File>, 33 bytes_written: u64, 34 policy: LogRotation, 35 } 36 37 impl SizeRotatingWriter { 38 pub(super) fn new(path: PathBuf, policy: LogRotation) -> io::Result<Self> { 39 let file_name = path 40 .file_name() 41 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "log target has no name"))? 42 .to_owned(); 43 #[cfg(unix)] 44 let directory = open_secure_directory(path.parent().ok_or_else(|| { 45 io::Error::new(io::ErrorKind::InvalidInput, "log target has no parent") 46 })?)?; 47 #[cfg(unix)] 48 let file = open_append_at(&directory, &file_name)?; 49 #[cfg(not(unix))] 50 let file = open_append(&path)?; 51 let bytes_written = file.metadata()?.len(); 52 let mut writer = Self { 53 #[cfg(not(unix))] 54 path, 55 #[cfg(unix)] 56 directory, 57 file_name, 58 file: Some(file), 59 bytes_written, 60 policy, 61 }; 62 if writer.bytes_written >= writer.policy.max_file_bytes { 63 writer.rotate()?; 64 } 65 Ok(writer) 66 } 67 68 fn rotate(&mut self) -> io::Result<()> { 69 if let Some(mut file) = self.file.take() { 70 file.flush()?; 71 file.sync_data()?; 72 } 73 if self.policy.retained_files == 1 { 74 self.remove_if_present(&self.file_name)?; 75 } else { 76 self.remove_if_present(&rotated_name( 77 &self.file_name, 78 self.policy.retained_files - 1, 79 ))?; 80 for index in (2..self.policy.retained_files).rev() { 81 self.rename_if_present( 82 &rotated_name(&self.file_name, index - 1), 83 &rotated_name(&self.file_name, index), 84 )?; 85 } 86 self.rename_if_present(&self.file_name, &rotated_name(&self.file_name, 1))?; 87 } 88 #[cfg(unix)] 89 let file = open_append_at(&self.directory, &self.file_name)?; 90 #[cfg(not(unix))] 91 let file = open_append(&self.path)?; 92 self.file = Some(file); 93 self.bytes_written = 0; 94 Ok(()) 95 } 96 97 fn remove_if_present(&self, name: &OsStr) -> io::Result<()> { 98 #[cfg(unix)] 99 { 100 use rustix::fs::{AtFlags, unlinkat}; 101 match unlinkat(&self.directory, name, AtFlags::empty()) { 102 Ok(()) => Ok(()), 103 Err(error) if error == rustix::io::Errno::NOENT => Ok(()), 104 Err(error) => Err(io::Error::from_raw_os_error(error.raw_os_error())), 105 } 106 } 107 #[cfg(not(unix))] 108 remove_if_present(&self.path.with_file_name(name)) 109 } 110 111 fn rename_if_present(&self, source: &OsStr, target: &OsStr) -> io::Result<()> { 112 #[cfg(unix)] 113 { 114 use rustix::fs::renameat; 115 match renameat(&self.directory, source, &self.directory, target) { 116 Ok(()) => Ok(()), 117 Err(error) if error == rustix::io::Errno::NOENT => Ok(()), 118 Err(error) => Err(io::Error::from_raw_os_error(error.raw_os_error())), 119 } 120 } 121 #[cfg(not(unix))] 122 rename_if_present( 123 &self.path.with_file_name(source), 124 &self.path.with_file_name(target), 125 ) 126 } 127 128 fn file_mut(&mut self) -> io::Result<&mut File> { 129 self.file 130 .as_mut() 131 .ok_or_else(|| io::Error::other("log file is unavailable")) 132 } 133 } 134 135 impl Write for SizeRotatingWriter { 136 fn write(&mut self, buffer: &[u8]) -> io::Result<usize> { 137 let incoming = u64::try_from(buffer.len()) 138 .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "log event is too large"))?; 139 if incoming > self.policy.max_file_bytes { 140 return Err(io::Error::new( 141 io::ErrorKind::InvalidData, 142 "log event exceeds the configured file limit", 143 )); 144 } 145 if self.bytes_written > 0 146 && self.bytes_written.saturating_add(incoming) > self.policy.max_file_bytes 147 { 148 self.rotate()?; 149 } 150 let written = self.file_mut()?.write(buffer)?; 151 self.bytes_written = self 152 .bytes_written 153 .saturating_add(u64::try_from(written).unwrap_or(u64::MAX)); 154 Ok(written) 155 } 156 157 fn flush(&mut self) -> io::Result<()> { 158 self.file_mut()?.flush() 159 } 160 } 161 162 #[cfg(test)] 163 fn reject_unsafe_target(path: &Path) -> io::Result<()> { 164 match fs::symlink_metadata(path) { 165 Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => Err( 166 io::Error::new(io::ErrorKind::InvalidInput, "log target is not a safe file"), 167 ), 168 Ok(_) => Ok(()), 169 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), 170 Err(error) => Err(error), 171 } 172 } 173 174 #[cfg(unix)] 175 fn open_secure_directory(path: &Path) -> io::Result<File> { 176 use rustix::fs::{FileType, Mode, OFlags, fstat, open}; 177 use rustix::process::geteuid; 178 179 let directory = open( 180 path, 181 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, 182 Mode::empty(), 183 ) 184 .map_err(errno_to_io)?; 185 let status = fstat(&directory).map_err(errno_to_io)?; 186 if FileType::from_raw_mode(status.st_mode) != FileType::Directory 187 || status.st_uid != geteuid().as_raw() 188 || status.st_mode & 0o022 != 0 189 { 190 return Err(io::Error::new( 191 io::ErrorKind::PermissionDenied, 192 "log parent must be an owner-controlled non-writable directory", 193 )); 194 } 195 Ok(File::from(directory)) 196 } 197 198 #[cfg(unix)] 199 fn open_append_at(directory: &File, name: &OsStr) -> io::Result<File> { 200 use rustix::fs::{FileType, Mode, OFlags, fchmod, fstat, openat}; 201 202 let descriptor = openat( 203 directory, 204 name, 205 OFlags::WRONLY | OFlags::APPEND | OFlags::CREATE | OFlags::NOFOLLOW | OFlags::CLOEXEC, 206 Mode::RUSR | Mode::WUSR, 207 ) 208 .map_err(errno_to_io)?; 209 let status = fstat(&descriptor).map_err(errno_to_io)?; 210 if FileType::from_raw_mode(status.st_mode) != FileType::RegularFile || status.st_nlink != 1 { 211 return Err(io::Error::new( 212 io::ErrorKind::InvalidInput, 213 "log target must be one regular file link", 214 )); 215 } 216 fchmod(&descriptor, Mode::RUSR | Mode::WUSR).map_err(errno_to_io)?; 217 Ok(File::from(descriptor)) 218 } 219 220 #[cfg(unix)] 221 fn errno_to_io(error: rustix::io::Errno) -> io::Error { 222 io::Error::from_raw_os_error(error.raw_os_error()) 223 } 224 225 #[cfg(not(unix))] 226 fn open_append(_path: &Path) -> io::Result<File> { 227 Err(io::Error::new( 228 io::ErrorKind::Unsupported, 229 "secure file logging is unavailable without a no-follow ACL implementation", 230 )) 231 } 232 233 #[cfg(test)] 234 fn rotated_path(path: &Path, index: usize) -> PathBuf { 235 let mut value = path.as_os_str().to_owned(); 236 value.push(format!(".{index}")); 237 PathBuf::from(value) 238 } 239 240 fn rotated_name(name: &OsStr, index: usize) -> OsString { 241 let mut value = name.to_owned(); 242 value.push(format!(".{index}")); 243 value 244 } 245 246 #[cfg(any(test, not(unix)))] 247 fn remove_if_present(path: &Path) -> io::Result<()> { 248 match fs::remove_file(path) { 249 Ok(()) => Ok(()), 250 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), 251 Err(error) => Err(error), 252 } 253 } 254 255 #[cfg(any(test, not(unix)))] 256 fn rename_if_present(source: &Path, target: &Path) -> io::Result<()> { 257 match fs::rename(source, target) { 258 Ok(()) => Ok(()), 259 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), 260 Err(error) => Err(error), 261 } 262 } 263 264 #[cfg(test)] 265 #[cfg_attr(coverage_nightly, coverage(off))] 266 mod tests { 267 use super::{ 268 LogRotation, SizeRotatingWriter, reject_unsafe_target, remove_if_present, 269 rename_if_present, rotated_path, 270 }; 271 use std::ffi::OsStr; 272 use std::io::Write; 273 274 #[test] 275 fn rotation_is_size_bounded_and_retention_is_finite() { 276 let directory = tempfile::tempdir().expect("temporary log directory"); 277 let path = directory.path().join("radroots.log"); 278 let mut writer = SizeRotatingWriter::new( 279 path.clone(), 280 LogRotation { 281 max_file_bytes: 5, 282 retained_files: 3, 283 }, 284 ) 285 .expect("writer"); 286 for value in [b"1111", b"2222", b"3333", b"4444"] { 287 writer.write_all(value).expect("log entry"); 288 } 289 writer.flush().expect("flush"); 290 assert_eq!(std::fs::read(&path).expect("current"), b"4444"); 291 assert_eq!( 292 std::fs::read(rotated_path(&path, 1)).expect("first retained"), 293 b"3333" 294 ); 295 assert_eq!( 296 std::fs::read(rotated_path(&path, 2)).expect("second retained"), 297 b"2222" 298 ); 299 assert!(!rotated_path(&path, 3).exists()); 300 } 301 302 #[test] 303 fn single_file_rotation_removes_the_previous_file() { 304 let directory = tempfile::tempdir().expect("temporary log directory"); 305 let path = directory.path().join("radroots.log"); 306 std::fs::write(&path, b"full!").expect("fixture"); 307 let mut writer = SizeRotatingWriter::new( 308 path.clone(), 309 LogRotation { 310 max_file_bytes: 5, 311 retained_files: 1, 312 }, 313 ) 314 .expect("writer"); 315 writer.write_all(b"next").expect("write"); 316 writer.flush().expect("flush"); 317 assert_eq!(std::fs::read(path).expect("current"), b"next"); 318 } 319 320 #[test] 321 fn oversized_events_and_unavailable_files_fail_closed() { 322 let directory = tempfile::tempdir().expect("temporary log directory"); 323 let path = directory.path().join("radroots.log"); 324 let mut writer = SizeRotatingWriter::new( 325 path, 326 LogRotation { 327 max_file_bytes: 3, 328 retained_files: 2, 329 }, 330 ) 331 .expect("writer"); 332 assert_eq!( 333 writer.write(b"four").expect_err("oversized").kind(), 334 std::io::ErrorKind::InvalidData 335 ); 336 writer.write_all(b"a").expect("first short write"); 337 writer.write_all(b"b").expect("second short write"); 338 writer.file = None; 339 assert_eq!( 340 writer.flush().expect_err("missing file").kind(), 341 std::io::ErrorKind::Other 342 ); 343 } 344 345 #[test] 346 fn unsafe_targets_and_absent_rotation_files_are_handled() { 347 let directory = tempfile::tempdir().expect("temporary directory"); 348 assert!(reject_unsafe_target(directory.path()).is_err()); 349 let regular = directory.path().join("regular"); 350 std::fs::write(®ular, b"regular").expect("regular file"); 351 assert!(reject_unsafe_target(®ular).is_ok()); 352 let missing = directory.path().join("missing"); 353 assert!(reject_unsafe_target(&missing).is_ok()); 354 assert!(remove_if_present(&missing).is_ok()); 355 assert!(rename_if_present(&missing, &directory.path().join("target")).is_ok()); 356 assert!(remove_if_present(directory.path()).is_err()); 357 358 let source = directory.path().join("source"); 359 std::fs::write(&source, b"source").expect("source"); 360 assert!(rename_if_present(&source, directory.path()).is_err()); 361 362 #[cfg(unix)] 363 { 364 std::os::unix::fs::symlink(directory.path(), &missing).expect("symlink"); 365 assert!(reject_unsafe_target(&missing).is_err()); 366 } 367 } 368 369 #[cfg(unix)] 370 #[test] 371 fn descriptor_relative_open_rejects_symlinks_and_multiple_links() { 372 let directory = tempfile::tempdir().expect("temporary directory"); 373 let outside = tempfile::NamedTempFile::new().expect("outside file"); 374 let symlink = directory.path().join("radroots.log"); 375 std::os::unix::fs::symlink(outside.path(), &symlink).expect("symlink"); 376 assert!(SizeRotatingWriter::new(symlink.clone(), LogRotation::default()).is_err()); 377 378 std::fs::remove_file(&symlink).expect("remove symlink"); 379 std::fs::hard_link(outside.path(), &symlink).expect("hard link"); 380 assert!(SizeRotatingWriter::new(symlink, LogRotation::default()).is_err()); 381 382 let link_holder = tempfile::tempdir().expect("link holder"); 383 let parent_link = link_holder.path().join("parent-link"); 384 std::os::unix::fs::symlink(directory.path(), &parent_link).expect("parent symlink"); 385 assert!( 386 SizeRotatingWriter::new(parent_link.join("other.log"), LogRotation::default()).is_err() 387 ); 388 389 use std::os::unix::fs::PermissionsExt; 390 let insecure = tempfile::tempdir().expect("insecure parent"); 391 std::fs::set_permissions(insecure.path(), std::fs::Permissions::from_mode(0o777)) 392 .expect("insecure permissions"); 393 assert!( 394 SizeRotatingWriter::new(insecure.path().join("radroots.log"), LogRotation::default()) 395 .is_err() 396 ); 397 } 398 399 #[cfg(unix)] 400 #[test] 401 fn descriptor_relative_rotation_propagates_non_missing_errors() { 402 let directory = tempfile::tempdir().expect("temporary directory"); 403 let mut writer = SizeRotatingWriter::new( 404 directory.path().join("radroots.log"), 405 LogRotation::default(), 406 ) 407 .expect("writer"); 408 writer.flush().expect("flush"); 409 410 std::fs::create_dir(directory.path().join("blocked")).expect("blocked directory"); 411 assert!(writer.remove_if_present(OsStr::new("blocked")).is_err()); 412 std::fs::write(directory.path().join("source"), b"source").expect("source"); 413 assert!( 414 writer 415 .rename_if_present(OsStr::new("source"), OsStr::new("blocked")) 416 .is_err() 417 ); 418 } 419 420 #[test] 421 fn rotation_without_an_open_file_recreates_the_target() { 422 let directory = tempfile::tempdir().expect("temporary directory"); 423 let path = directory.path().join("radroots.log"); 424 let mut writer = SizeRotatingWriter::new( 425 path, 426 LogRotation { 427 max_file_bytes: 3, 428 retained_files: 2, 429 }, 430 ) 431 .expect("writer"); 432 writer.file = None; 433 writer.rotate().expect("rotation"); 434 writer.write_all(b"ok").expect("write after rotation"); 435 } 436 }