Skip to main content

iicp_client/
node_log.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Persistent node log writer — `~/.iicp/logs/<node-id>.log` + `events.jsonl`.
3//! Used for registration, heartbeat, and deregistration events.
4//!
5//! Both files are append-only. Rotation triggers when either file exceeds
6//! `MAX_LOG_BYTES` (10 MiB); up to `MAX_ROTATIONS` (3) generations are kept.
7//! This module contains no credentials — callers MUST NOT pass token/key values.
8
9use std::fs::{self, OpenOptions};
10use std::io::Write;
11use std::path::{Path, PathBuf};
12use std::sync::Mutex;
13use std::time::{SystemTime, UNIX_EPOCH};
14
15const MAX_LOG_BYTES: u64 = 10 * 1024 * 1024; // 10 MiB
16const MAX_ROTATIONS: u32 = 3;
17
18/// Thread-safe file logger for a single IICP node.
19pub struct NodeLog {
20    text_path: PathBuf,
21    jsonl_path: PathBuf,
22    /// Serialises rotation + append so two concurrent heartbeats don't race.
23    lock: Mutex<()>,
24}
25
26impl NodeLog {
27    /// Open (or create) the log directory and return a `NodeLog` for `node_id`.
28    /// `log_dir` is created recursively if absent.
29    pub fn open(log_dir: &Path, node_id: &str) -> std::io::Result<Self> {
30        fs::create_dir_all(log_dir)?;
31        Ok(Self {
32            text_path: log_dir.join(format!("{node_id}.log")),
33            jsonl_path: log_dir.join("events.jsonl"),
34            lock: Mutex::new(()),
35        })
36    }
37
38    /// Write one event to both the text log and `events.jsonl`.
39    ///
40    /// `event` is a snake_case key (e.g. `"register_ok"`, `"heartbeat_fail"`).
41    /// `details` is a flat string appended verbatim to the human-readable line;
42    /// it MUST NOT contain secret material.
43    pub fn write(&self, event: &str, node_id: &str, details: &str) {
44        let _g = self.lock.lock().unwrap_or_else(|e| e.into_inner());
45        let ts = iso_now();
46        let text = format!("{ts} [{event}] node={node_id} {details}\n");
47        let jsonl = format!(
48            "{{\"ts\":\"{ts}\",\"event\":\"{event}\",\"node_id\":\"{node_id}\",\"details\":\"{}\"}}\n",
49            details.replace('"', "'")
50        );
51        let _ = self.append_rotating(&self.text_path, text.as_bytes());
52        let _ = self.append_rotating(&self.jsonl_path, jsonl.as_bytes());
53    }
54
55    fn append_rotating(&self, path: &Path, data: &[u8]) -> std::io::Result<()> {
56        if let Ok(m) = fs::metadata(path) {
57            if m.len() >= MAX_LOG_BYTES {
58                rotate(path);
59            }
60        }
61        OpenOptions::new()
62            .create(true)
63            .append(true)
64            .open(path)?
65            .write_all(data)
66    }
67}
68
69fn rotate(path: &Path) {
70    for i in (1..MAX_ROTATIONS).rev() {
71        let from = suffixed(path, i);
72        let to = suffixed(path, i + 1);
73        let _ = fs::rename(from, to);
74    }
75    let _ = fs::rename(path, suffixed(path, 1));
76}
77
78fn suffixed(path: &Path, n: u32) -> PathBuf {
79    let mut s = path.to_path_buf().into_os_string();
80    s.push(format!(".{n}"));
81    PathBuf::from(s)
82}
83
84fn iso_now() -> String {
85    let d = SystemTime::now()
86        .duration_since(UNIX_EPOCH)
87        .unwrap_or_default();
88    let secs = d.as_secs();
89    let (y, mon, day, h, min, sec) = epoch_to_datetime(secs);
90    format!("{y:04}-{mon:02}-{day:02}T{h:02}:{min:02}:{sec:02}Z")
91}
92
93fn epoch_to_datetime(secs: u64) -> (u32, u32, u32, u32, u32, u32) {
94    // Minimal Gregorian calendar — avoids adding chrono formatting feature.
95    let sec = (secs % 60) as u32;
96    let min = ((secs / 60) % 60) as u32;
97    let h = ((secs / 3600) % 24) as u32;
98    let days = (secs / 86400) as u32;
99    // Days since 1970-01-01
100    let mut year = 1970u32;
101    let mut rem = days;
102    loop {
103        let dy = if is_leap(year) { 366 } else { 365 };
104        if rem < dy {
105            break;
106        }
107        rem -= dy;
108        year += 1;
109    }
110    let leap = is_leap(year);
111    let months = [
112        31u32,
113        if leap { 29 } else { 28 },
114        31,
115        30,
116        31,
117        30,
118        31,
119        31,
120        30,
121        31,
122        30,
123        31,
124    ];
125    let mut mon = 1u32;
126    for &m in &months {
127        if rem < m {
128            break;
129        }
130        rem -= m;
131        mon += 1;
132    }
133    (year, mon, rem + 1, h, min, sec)
134}
135
136fn is_leap(y: u32) -> bool {
137    (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0)
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use std::fs;
144
145    static TEST_CTR: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
146
147    fn tmp_dir() -> PathBuf {
148        // Use a monotonic counter to guarantee uniqueness across parallel tests.
149        let id = TEST_CTR.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
150        let d = std::env::temp_dir().join(format!("iicp_log_test_{id}_{}", std::process::id()));
151        let _ = fs::remove_dir_all(&d);
152        d
153    }
154
155    #[test]
156    fn creates_log_files() {
157        let dir = tmp_dir();
158        let log = NodeLog::open(&dir, "test-node").unwrap();
159        log.write("register_ok", "test-node", "endpoint=http://localhost:9484");
160        assert!(dir.join("test-node.log").exists());
161        assert!(dir.join("events.jsonl").exists());
162        let _ = fs::remove_dir_all(&dir);
163    }
164
165    #[test]
166    fn text_log_contains_event() {
167        let dir = tmp_dir();
168        let log = NodeLog::open(&dir, "abc").unwrap();
169        log.write("heartbeat_ok", "abc", "seq=1");
170        let content = fs::read_to_string(dir.join("abc.log")).unwrap();
171        assert!(content.contains("heartbeat_ok"));
172        assert!(content.contains("abc"));
173        let _ = fs::remove_dir_all(&dir);
174    }
175
176    #[test]
177    fn jsonl_is_valid_json() {
178        let dir = tmp_dir();
179        let log = NodeLog::open(&dir, "n1").unwrap();
180        log.write("register_fail", "n1", "error=timeout");
181        let line = fs::read_to_string(dir.join("events.jsonl")).unwrap();
182        let v: serde_json::Value = serde_json::from_str(line.trim()).unwrap();
183        assert_eq!(v["event"], "register_fail");
184        assert_eq!(v["node_id"], "n1");
185        let _ = fs::remove_dir_all(&dir);
186    }
187
188    #[test]
189    fn rotation_on_size_limit() {
190        let dir = tmp_dir();
191        let log = NodeLog::open(&dir, "r").unwrap();
192        // Write MAX_LOG_BYTES + 1 so the size check ">= MAX_LOG_BYTES" is unambiguously true.
193        let padding: Vec<u8> = vec![b'X'; MAX_LOG_BYTES as usize + 1];
194        fs::write(dir.join("r.log"), &padding).unwrap();
195        let pre_size = fs::metadata(dir.join("r.log")).unwrap().len();
196        assert!(
197            pre_size > MAX_LOG_BYTES,
198            "padding not written correctly: {pre_size}"
199        );
200        log.write("serve_start", "r", "port=9484");
201        // Original file was renamed to .1; new file exists with the event.
202        assert!(
203            dir.join("r.log.1").exists(),
204            "rotation did not create r.log.1; r.log size was {pre_size}"
205        );
206        let new = fs::read_to_string(dir.join("r.log")).unwrap();
207        assert!(new.contains("serve_start"));
208        let _ = fs::remove_dir_all(&dir);
209    }
210
211    #[test]
212    fn iso_now_format() {
213        let ts = iso_now();
214        // Expect 20 chars: YYYY-MM-DDTHH:MM:SSZ
215        assert_eq!(ts.len(), 20);
216        assert!(ts.ends_with('Z'));
217    }
218}