Skip to main content

cortexkit_log/
sink.rs

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
9/// A rotating file sink for bytes the caller has already framed as complete
10/// lines — captured child output, forwarded worker rings, anything that is
11/// already a log line and must not be re-formatted.
12///
13/// The tracing layer is process-global by construction: one `init` per process,
14/// one destination. A supervisor needs one rotating file PER CHILD, and those
15/// bytes must reach the child's own file unchanged rather than becoming events
16/// in the supervisor's log. Without this type the only options are duplicating
17/// the rotation code (which then drifts from the crate that owns the policy) or
18/// shipping unrotated files (which is how a log reaches 936 MB unnoticed).
19///
20/// The caller owns framing: `write_line` appends a single trailing newline if
21/// the bytes do not already end with one, and writes exactly once so two pipes
22/// feeding one sink cannot interleave a partial line. Rotation and pruning are
23/// the same policy the layer uses, because it is the same code.
24pub struct LineSink {
25    destination: Destination,
26}
27
28impl LineSink {
29    /// Opens (or creates) `path` with `retention`, pruning aged generations the
30    /// way the layer's own sink does on open.
31    pub fn open(path: &Path, retention: Retention) -> io::Result<Self> {
32        Self::open_at(path, retention, SystemTime::now())
33    }
34
35    /// `open` with an explicit clock, so a caller can test rotation and age
36    /// pruning without sleeping.
37    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    /// Writes one complete line, rotating first if it would exceed the cap.
44    pub fn write_line(&mut self, line: &[u8]) -> io::Result<()> {
45        self.write_line_at(line, SystemTime::now())
46    }
47
48    /// `write_line` with an explicit clock.
49    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        // One write, not two: a second write for the newline would let a
54        // concurrent writer on another pipe land between them.
55        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    // Windows has no mode bits to enforce; the per-user data directory's ACL is
150    // inherited. The inputs exist only for the Unix arm above.
151    #[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}