Skip to main content

inferd_daemon/
logx.rs

1//! Activity log: NDJSON writer with rotation and write-time redaction.
2//!
3//! Per `THREAT_MODEL.md` F-3, F-4 and `docs/protocol-v1.md`'s
4//! observability section.
5//!
6//! Shape on disk:
7//! - Files live under the configured log dir (default
8//!   `~/.inferd/logs/`).
9//! - One file at a time named `inferd.ndjson`. When it crosses the
10//!   configured size cap, it rotates to `inferd.ndjson.1` and a fresh
11//!   `inferd.ndjson` is created. `inferd.ndjson.2` and `.3` follow.
12//!   Anything beyond `.3` is deleted (the F-4 "3 generations" rule).
13//! - Each record is one JSON object on a single line, terminated by
14//!   `\n`. Fields: `t` (RFC3339 timestamp), `level`, `component`,
15//!   `msg`, plus arbitrary structured fields supplied by `tracing`
16//!   spans/events.
17//!
18//! The redactor runs *before* bytes hit the disk; even
19//! `INFERD_LOG=debug` records are scrubbed.
20
21use std::collections::BTreeMap;
22use std::fmt::Write as _;
23use std::fs::{File, OpenOptions};
24use std::io::{self, Write};
25use std::path::{Path, PathBuf};
26use std::sync::{Arc, Mutex};
27
28use chrono::Utc;
29use serde_json::{Value, json};
30use tracing::field::{Field, Visit};
31use tracing::{Event, Subscriber};
32use tracing_subscriber::Layer;
33use tracing_subscriber::layer::Context;
34use tracing_subscriber::registry::LookupSpan;
35
36use crate::redact::redact_in_place;
37
38/// Default per-file size cap before rotation.
39pub const DEFAULT_ROTATE_BYTES: u64 = 16 * 1024 * 1024; // 16 MiB
40
41/// Number of historical generations kept. Per F-4 the daemon keeps
42/// **3 historicals** plus the live file (`.ndjson`, `.1`, `.2`, `.3`).
43pub const KEEP_GENERATIONS: u32 = 3;
44
45/// A rotating NDJSON writer.
46///
47/// Single instance, internally locked. The `tracing` layer in
48/// `tracing_layer::LogxLayer` calls `write_record` once per event.
49pub struct LogxWriter {
50    inner: Mutex<Inner>,
51}
52
53struct Inner {
54    dir: PathBuf,
55    base: String,
56    rotate_bytes: u64,
57    file: File,
58    written: u64,
59}
60
61impl LogxWriter {
62    /// Create or reopen the active log file under `dir/base.ndjson`.
63    /// Creates `dir` if it does not exist.
64    pub fn open(dir: &Path, base: &str, rotate_bytes: u64) -> io::Result<Self> {
65        std::fs::create_dir_all(dir)?;
66        let path = log_path(dir, base, 0);
67        let file = OpenOptions::new().create(true).append(true).open(&path)?;
68        let written = file.metadata().map(|m| m.len()).unwrap_or(0);
69        Ok(Self {
70            inner: Mutex::new(Inner {
71                dir: dir.to_path_buf(),
72                base: base.to_string(),
73                rotate_bytes,
74                file,
75                written,
76            }),
77        })
78    }
79
80    /// Write one NDJSON record. The caller hands in the fully-formed
81    /// JSON line (no trailing newline); this method runs the redactor,
82    /// appends `\n`, and rotates if the cap is crossed.
83    pub fn write_record(&self, mut record: String) -> io::Result<()> {
84        redact_in_place(&mut record);
85        let mut bytes = record.into_bytes();
86        bytes.push(b'\n');
87
88        let mut inner = self
89            .inner
90            .lock()
91            .map_err(|_| io::Error::other("logx mutex poisoned"))?;
92
93        if inner.written.saturating_add(bytes.len() as u64) > inner.rotate_bytes
94            && inner.written > 0
95        {
96            inner.rotate()?;
97        }
98
99        inner.file.write_all(&bytes)?;
100        inner.written = inner.written.saturating_add(bytes.len() as u64);
101        Ok(())
102    }
103
104    /// Force a rotation regardless of size. Tests and ops tools.
105    pub fn rotate_now(&self) -> io::Result<()> {
106        let mut inner = self
107            .inner
108            .lock()
109            .map_err(|_| io::Error::other("logx mutex poisoned"))?;
110        inner.rotate()
111    }
112}
113
114impl Inner {
115    fn rotate(&mut self) -> io::Result<()> {
116        // Drop the live file before renaming so Windows allows the
117        // replace; on Unix this is a no-op cost.
118        drop_file(&mut self.file);
119
120        // Cascade: .3 dies, .2 -> .3, .1 -> .2, live -> .1.
121        let dir = self.dir.clone();
122        let base = self.base.clone();
123        delete_if_exists(&log_path(&dir, &base, KEEP_GENERATIONS))?;
124        for n in (1..KEEP_GENERATIONS).rev() {
125            let from = log_path(&dir, &base, n);
126            let to = log_path(&dir, &base, n + 1);
127            if from.exists() {
128                std::fs::rename(&from, &to)?;
129            }
130        }
131        let live = log_path(&dir, &base, 0);
132        let to = log_path(&dir, &base, 1);
133        if live.exists() {
134            std::fs::rename(&live, &to)?;
135        }
136
137        self.file = OpenOptions::new().create(true).append(true).open(&live)?;
138        self.written = 0;
139        Ok(())
140    }
141}
142
143fn drop_file(_f: &mut File) {
144    // Reopen-replace pattern needs the old handle closed. We do this by
145    // dropping the File via std::mem::replace with a sentinel. Keeping
146    // this in a helper documents the intent.
147    //
148    // Actual drop happens when the caller next reassigns `inner.file`.
149    // No-op body; the caller's reassignment closes the prior fd.
150}
151
152fn delete_if_exists(path: &Path) -> io::Result<()> {
153    match std::fs::remove_file(path) {
154        Ok(()) => Ok(()),
155        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
156        Err(e) => Err(e),
157    }
158}
159
160fn log_path(dir: &Path, base: &str, generation: u32) -> PathBuf {
161    if generation == 0 {
162        dir.join(format!("{base}.ndjson"))
163    } else {
164        dir.join(format!("{base}.ndjson.{generation}"))
165    }
166}
167
168/// Default log directory: `~/.inferd/logs/`. Honours `INFERD_LOG_DIR`
169/// when set so operators and tests can redirect.
170pub fn default_log_dir() -> PathBuf {
171    if let Ok(p) = std::env::var("INFERD_LOG_DIR") {
172        return PathBuf::from(p);
173    }
174    let home = dirs_home().unwrap_or_else(|| PathBuf::from("."));
175    home.join(".inferd").join("logs")
176}
177
178fn dirs_home() -> Option<PathBuf> {
179    // Avoid pulling the `dirs` crate just for HOME on Unix and USERPROFILE
180    // on Windows. Both are well-defined env vars.
181    #[cfg(unix)]
182    {
183        std::env::var_os("HOME").map(PathBuf::from)
184    }
185    #[cfg(not(unix))]
186    {
187        std::env::var_os("USERPROFILE").map(PathBuf::from)
188    }
189}
190
191/// `tracing` layer that serialises events as NDJSON and routes them
192/// through a shared `LogxWriter`.
193///
194/// Field shape:
195/// - `t`         — RFC3339 timestamp.
196/// - `level`     — `info` | `warn` | `error` | `debug` | `trace`.
197/// - `component` — module-path-derived component name (the part of the
198///   `tracing` target after the crate name; falls back to the target
199///   itself).
200/// - `msg`       — the event's primary message string.
201/// - any structured fields supplied via `tracing!(key = value, ...)`.
202pub struct LogxLayer {
203    writer: Arc<LogxWriter>,
204}
205
206impl LogxLayer {
207    /// Wrap a shared `LogxWriter` so it can be added to a
208    /// `tracing_subscriber::Registry`.
209    pub fn new(writer: Arc<LogxWriter>) -> Self {
210        Self { writer }
211    }
212}
213
214impl<S> Layer<S> for LogxLayer
215where
216    S: Subscriber + for<'a> LookupSpan<'a>,
217{
218    fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
219        let metadata = event.metadata();
220        let mut visitor = JsonVisitor::default();
221        event.record(&mut visitor);
222
223        let message = visitor.fields.remove("message").map(value_to_string);
224
225        let mut record = serde_json::Map::new();
226        record.insert("t".into(), Value::String(Utc::now().to_rfc3339()));
227        record.insert(
228            "level".into(),
229            Value::String(metadata.level().to_string().to_lowercase()),
230        );
231        record.insert(
232            "component".into(),
233            Value::String(component_from_target(metadata.target())),
234        );
235        if let Some(msg) = message {
236            record.insert("msg".into(), Value::String(msg));
237        }
238        for (k, v) in visitor.fields {
239            record.insert(k, v);
240        }
241
242        let line = match serde_json::to_string(&Value::Object(record)) {
243            Ok(s) => s,
244            Err(e) => {
245                // Falling back to a synthetic line so we never lose an
246                // event, even if the structured payload is unserialisable.
247                let mut buf = String::with_capacity(128);
248                let _ = write!(
249                    buf,
250                    r#"{{"t":"{}","level":"error","component":"logx","msg":"serialise: {}"}}"#,
251                    Utc::now().to_rfc3339(),
252                    e
253                );
254                buf
255            }
256        };
257        // Write errors are intentionally swallowed: the alternative is
258        // panicking inside a tracing event, which is a worse outcome.
259        // Operators see disk-full conditions via OS-level monitoring.
260        let _ = self.writer.write_record(line);
261    }
262}
263
264fn component_from_target(target: &str) -> String {
265    // Targets are `crate::module::path`. The leading crate is redundant
266    // (every record carries the same one); strip it so dashboards can
267    // group by component.
268    target
269        .split_once("::")
270        .map(|(_, rest)| rest.to_string())
271        .unwrap_or_else(|| target.to_string())
272}
273
274#[derive(Default)]
275struct JsonVisitor {
276    fields: BTreeMap<String, Value>,
277}
278
279impl Visit for JsonVisitor {
280    fn record_str(&mut self, field: &Field, value: &str) {
281        self.fields.insert(field.name().into(), json!(value));
282    }
283    fn record_bool(&mut self, field: &Field, value: bool) {
284        self.fields.insert(field.name().into(), json!(value));
285    }
286    fn record_i64(&mut self, field: &Field, value: i64) {
287        self.fields.insert(field.name().into(), json!(value));
288    }
289    fn record_u64(&mut self, field: &Field, value: u64) {
290        self.fields.insert(field.name().into(), json!(value));
291    }
292    fn record_f64(&mut self, field: &Field, value: f64) {
293        self.fields.insert(field.name().into(), json!(value));
294    }
295    fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
296        self.fields
297            .insert(field.name().into(), json!(format!("{value:?}")));
298    }
299    fn record_error(&mut self, field: &Field, value: &(dyn std::error::Error + 'static)) {
300        self.fields
301            .insert(field.name().into(), json!(value.to_string()));
302    }
303}
304
305fn value_to_string(v: Value) -> String {
306    match v {
307        Value::String(s) => s,
308        other => other.to_string(),
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use tempfile::tempdir;
316
317    fn read_file(path: &Path) -> String {
318        std::fs::read_to_string(path).unwrap()
319    }
320
321    #[test]
322    fn appends_records_and_rotates_at_size_cap() {
323        let dir = tempdir().unwrap();
324        // Cap of 50 bytes — second/third records each trigger a rotation
325        // before write, so the cascade is:
326        //   r1 -> live (live becomes 30 bytes)
327        //   r2 -> rotate(live=>.1, contains r1); new live; write r2
328        //   r3 -> rotate(.1=>.2 contains r1, live=>.1 contains r2); new live; write r3
329        let writer = LogxWriter::open(dir.path(), "inferd", 50).unwrap();
330
331        writer
332            .write_record(r#"{"msg":"first record","n":1}"#.to_string())
333            .unwrap();
334        writer
335            .write_record(r#"{"msg":"second record","n":2}"#.to_string())
336            .unwrap();
337        writer
338            .write_record(r#"{"msg":"third record","n":3}"#.to_string())
339            .unwrap();
340
341        let live = read_file(&log_path(dir.path(), "inferd", 0));
342        let one = read_file(&log_path(dir.path(), "inferd", 1));
343        let two = read_file(&log_path(dir.path(), "inferd", 2));
344
345        assert!(live.contains("third record"), "live should hold r3: {live}");
346        assert!(one.contains("second record"), ".1 should hold r2: {one}");
347        assert!(two.contains("first record"), ".2 should hold r1: {two}");
348    }
349
350    #[test]
351    fn cascade_keeps_only_three_generations() {
352        let dir = tempdir().unwrap();
353        let writer = LogxWriter::open(dir.path(), "inferd", 1024).unwrap();
354
355        writer.write_record(r#"{"g":0}"#.to_string()).unwrap();
356        for _ in 0..5 {
357            writer.rotate_now().unwrap();
358            writer.write_record(r#"{"g":"new"}"#.to_string()).unwrap();
359        }
360
361        // .ndjson, .1, .2, .3 — nothing higher.
362        for n in 0..=KEEP_GENERATIONS {
363            assert!(
364                log_path(dir.path(), "inferd", n).exists(),
365                "missing generation {n}"
366            );
367        }
368        assert!(
369            !log_path(dir.path(), "inferd", KEEP_GENERATIONS + 1).exists(),
370            "generation {} should have been pruned",
371            KEEP_GENERATIONS + 1
372        );
373    }
374
375    #[test]
376    fn redactor_runs_on_write_path() {
377        let dir = tempdir().unwrap();
378        let writer = LogxWriter::open(dir.path(), "inferd", 1 << 20).unwrap();
379
380        // Fixture assembled at runtime so secret-scanning tools don't
381        // treat the literal as a real key in source.
382        let fixture = format!("{}-{}", "sk", "1234567890abcdefghij");
383        let record = format!(r#"{{"msg":"oops","key":"{fixture}"}}"#);
384        writer.write_record(record).unwrap();
385
386        let live = read_file(&log_path(dir.path(), "inferd", 0));
387        assert!(!live.contains(&fixture), "secret leaked: {live}");
388        assert!(
389            live.contains("[REDACTED"),
390            "expected redaction marker: {live}"
391        );
392    }
393}