1use std::ffi::OsString;
2use std::fs::{self, File, OpenOptions};
3use std::io::{self, Write};
4use std::path::{Path, PathBuf};
5use std::time::{Duration, SystemTime};
6
7use crate::Retention;
8
9pub struct LineSink {
25 destination: Destination,
26}
27
28impl LineSink {
29 pub fn open(path: &Path, retention: Retention) -> io::Result<Self> {
32 Self::open_at(path, retention, SystemTime::now())
33 }
34
35 pub fn open_at(path: &Path, retention: Retention, now: SystemTime) -> io::Result<Self> {
38 Ok(Self {
39 destination: Destination::open(path, retention, now, false)?,
40 })
41 }
42
43 pub fn write_line(&mut self, line: &[u8]) -> io::Result<()> {
45 self.write_line_at(line, SystemTime::now())
46 }
47
48 pub fn write_line_at(&mut self, line: &[u8], now: SystemTime) -> io::Result<()> {
50 if line.ends_with(b"\n") {
51 return self.destination.write(line, now);
52 }
53 let mut framed = Vec::with_capacity(line.len() + 1);
56 framed.extend_from_slice(line);
57 framed.push(b'\n');
58 self.destination.write(&framed, now)
59 }
60}
61
62pub(crate) enum Destination {
63 File(FileDestination),
64}
65
66impl Destination {
67 pub(crate) fn open(
68 path: &Path,
69 retention: Retention,
70 now: SystemTime,
71 enforce_directory_mode: bool,
72 ) -> io::Result<Self> {
73 FileDestination::open(path, retention, now, enforce_directory_mode).map(Self::File)
74 }
75
76 pub(crate) fn write(&mut self, bytes: &[u8], now: SystemTime) -> io::Result<()> {
77 match self {
78 Self::File(file) => file.write(bytes, now),
79 }
80 }
81}
82
83pub(crate) struct FileDestination {
84 path: PathBuf,
85 file: Option<File>,
86 retention: Retention,
87}
88
89impl FileDestination {
90 fn open(
91 path: &Path,
92 retention: Retention,
93 now: SystemTime,
94 enforce_directory_mode: bool,
95 ) -> io::Result<Self> {
96 prepare_parent(path, enforce_directory_mode)?;
97 prune_generations(path, retention, now)?;
98 let file = open_active(path)?;
99 Ok(Self {
100 path: path.to_owned(),
101 file: Some(file),
102 retention,
103 })
104 }
105
106 fn write(&mut self, bytes: &[u8], now: SystemTime) -> io::Result<()> {
107 let current_len = self
108 .file
109 .as_ref()
110 .ok_or_else(|| io::Error::other("log file is not open"))?
111 .metadata()?
112 .len();
113 let cap = self.retention.max_bytes();
114 if current_len > 0 && current_len.saturating_add(bytes.len() as u64) > cap {
115 self.rotate(now)?;
116 }
117 self.file
118 .as_mut()
119 .ok_or_else(|| io::Error::other("log file is not open"))?
120 .write_all(bytes)
121 }
122
123 fn rotate(&mut self, now: SystemTime) -> io::Result<()> {
124 drop(self.file.take());
125 let rotation_result = rotate_paths(&self.path, self.retention, now);
126 let reopen_result = open_active(&self.path);
127 self.file = reopen_result.ok();
128
129 rotation_result?;
130 if self.file.is_none() {
131 return Err(io::Error::other("rotated log file could not be reopened"));
132 }
133 Ok(())
134 }
135}
136
137fn prepare_parent(path: &Path, enforce_directory_mode: bool) -> io::Result<()> {
138 let parent = path
139 .parent()
140 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "log path has no parent"))?;
141 let existed = parent.exists();
142 fs::create_dir_all(parent)?;
143
144 #[cfg(unix)]
145 if enforce_directory_mode || !existed {
146 use std::os::unix::fs::PermissionsExt;
147 fs::set_permissions(parent, fs::Permissions::from_mode(0o700))?;
148 }
149 #[cfg(not(unix))]
152 let _ = (enforce_directory_mode, existed);
153
154 Ok(())
155}
156
157fn open_active(path: &Path) -> io::Result<File> {
158 let mut options = OpenOptions::new();
159 options.create(true).append(true);
160 #[cfg(unix)]
161 {
162 use std::os::unix::fs::OpenOptionsExt;
163 options.mode(0o600);
164 }
165 let file = options.open(path)?;
166
167 #[cfg(unix)]
168 if file.metadata()?.file_type().is_file() {
169 use std::os::unix::fs::PermissionsExt;
170 file.set_permissions(fs::Permissions::from_mode(0o600))?;
171 }
172
173 Ok(file)
174}
175
176fn rotate_paths(path: &Path, retention: Retention, now: SystemTime) -> io::Result<()> {
177 prune_generations(path, retention, now)?;
178
179 if retention.keep == 0 {
180 remove_if_present(path)?;
181 return Ok(());
182 }
183
184 remove_if_present(&generation_path(path, u32::from(retention.keep)))?;
185 for generation in (1..u32::from(retention.keep)).rev() {
186 let source = generation_path(path, generation);
187 if source.exists() {
188 fs::rename(source, generation_path(path, generation + 1))?;
189 }
190 }
191 if path.exists() {
192 fs::rename(path, generation_path(path, 1))?;
193 }
194
195 prune_generations(path, retention, now)
196}
197
198fn prune_generations(path: &Path, retention: Retention, now: SystemTime) -> io::Result<()> {
199 let parent = path
200 .parent()
201 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "log path has no parent"))?;
202 let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
203 return Ok(());
204 };
205 if !parent.exists() {
206 return Ok(());
207 }
208
209 let prefix = format!("{file_name}.");
210 let max_age = Duration::from_secs(u64::from(retention.max_age_days) * 24 * 60 * 60);
211 for entry in fs::read_dir(parent)? {
212 let entry = entry?;
213 let name = entry.file_name();
214 let Some(name) = name.to_str() else {
215 continue;
216 };
217 let Some(suffix) = name.strip_prefix(&prefix) else {
218 continue;
219 };
220 let Ok(generation) = suffix.parse::<u32>() else {
221 continue;
222 };
223
224 let metadata = entry.metadata()?;
225 let too_many = generation > u32::from(retention.keep);
226 let too_old = metadata
227 .modified()
228 .ok()
229 .and_then(|modified| now.duration_since(modified).ok())
230 .is_some_and(|age| age > max_age);
231 if too_many || too_old {
232 remove_if_present(&entry.path())?;
233 }
234 }
235 Ok(())
236}
237
238fn generation_path(path: &Path, generation: u32) -> PathBuf {
239 let mut name: OsString = path.as_os_str().to_owned();
240 name.push(format!(".{generation}"));
241 PathBuf::from(name)
242}
243
244fn remove_if_present(path: &Path) -> io::Result<()> {
245 match fs::remove_file(path) {
246 Ok(()) => Ok(()),
247 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
248 Err(error) => Err(error),
249 }
250}
251
252#[cfg(test)]
253pub(crate) fn rotated_path(path: &Path, generation: u32) -> PathBuf {
254 generation_path(path, generation)
255}