field_ios

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

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(&regular, b"regular").expect("regular file");
    351         assert!(reject_unsafe_target(&regular).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 }