Skip to main content

journald_core/
lib.rs

1//! Core domain types for systemd journal forensic analysis.
2
3use chrono::{DateTime, TimeZone, Utc};
4use std::fmt;
5use thiserror::Error;
6
7/// A single key=value pair from a journal entry.
8#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
9pub struct JournalField {
10    pub key: String,
11    pub value: JournalFieldValue,
12}
13
14/// The value of a journal field — either valid UTF-8 text or raw binary bytes.
15#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
16pub enum JournalFieldValue {
17    Text(String),
18    Binary(Vec<u8>),
19}
20
21/// A single parsed journal entry.
22#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
23pub struct JournalEntry {
24    pub seqnum: u64,
25    pub realtime_us: u64,
26    pub monotonic_us: u64,
27    pub boot_id: [u8; 16],
28    pub fields: Vec<JournalField>,
29}
30
31impl JournalEntry {
32    /// Convert `realtime_us` (microseconds since Unix epoch) to a UTC `DateTime`.
33    pub fn realtime_as_datetime(&self) -> DateTime<Utc> {
34        // realtime_us is microseconds since epoch; max sane value fits i64
35        #[allow(clippy::cast_possible_wrap)]
36        let secs = (self.realtime_us / 1_000_000) as i64;
37        // remainder is 0..999_999 µs → 0..999_999_000 ns, always fits u32
38        #[allow(clippy::cast_possible_truncation)]
39        let nanos = ((self.realtime_us % 1_000_000) * 1_000) as u32;
40        Utc.timestamp_opt(secs, nanos)
41            .single()
42            .unwrap_or_else(|| Utc.timestamp_opt(0, 0).unwrap())
43    }
44
45    /// Look up a text field by key name. Returns `None` if the key is absent
46    /// or if the value is binary.
47    pub fn field(&self, key: &str) -> Option<&str> {
48        self.fields.iter().find(|f| f.key == key).and_then(|f| {
49            if let JournalFieldValue::Text(ref s) = f.value {
50                Some(s.as_str())
51            } else {
52                None
53            }
54        })
55    }
56
57    /// Convenience: return the `MESSAGE` field.
58    pub fn message(&self) -> Option<&str> {
59        self.field("MESSAGE")
60    }
61
62    /// Parse the `PRIORITY` field as a `u8`. Returns `None` if absent or unparseable.
63    pub fn priority(&self) -> Option<u8> {
64        self.field("PRIORITY")?.parse().ok()
65    }
66
67    /// Return the `SYSLOG_IDENTIFIER` field.
68    pub fn syslog_identifier(&self) -> Option<&str> {
69        self.field("SYSLOG_IDENTIFIER")
70    }
71
72    /// Parse the `_PID` field as a `u32`. Returns `None` if absent or unparseable.
73    pub fn pid(&self) -> Option<u32> {
74        self.field("_PID")?.parse().ok()
75    }
76}
77
78/// A parsed journal cursor string.
79///
80/// Format: `s=<seqnum_id_hex>;i=<seqnum_hex>;b=<boot_id_hex>;m=<monotonic_hex>;t=<realtime_hex>;x=<xor_hash_hex>`
81#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
82pub struct JournalCursor {
83    pub seqnum_id: [u8; 16],
84    pub seqnum: u64,
85    pub boot_id: [u8; 16],
86    pub monotonic_us: u64,
87    pub realtime_us: u64,
88    pub xor_hash: u64,
89}
90
91impl JournalCursor {
92    /// Parse a journal cursor string into a `JournalCursor`.
93    ///
94    /// Expected format:
95    /// `s=<uuid_hex32>;i=<hex>;b=<uuid_hex32>;m=<hex>;t=<hex>;x=<hex>`
96    pub fn parse(cursor_str: &str) -> Result<Self, JournalError> {
97        let make_err = || JournalError::InvalidCursor {
98            cursor: cursor_str.to_string(),
99        };
100
101        let mut seqnum_id: Option<[u8; 16]> = None;
102        let mut seqnum: Option<u64> = None;
103        let mut boot_id: Option<[u8; 16]> = None;
104        let mut monotonic_us: Option<u64> = None;
105        let mut realtime_us: Option<u64> = None;
106        let mut xor_hash: Option<u64> = None;
107
108        for part in cursor_str.split(';') {
109            let (key, val) = part.split_once('=').ok_or_else(make_err)?;
110            match key {
111                "s" => seqnum_id = Some(parse_uuid_hex(val).ok_or_else(make_err)?),
112                "i" => seqnum = Some(u64::from_str_radix(val, 16).map_err(|_| make_err())?),
113                "b" => boot_id = Some(parse_uuid_hex(val).ok_or_else(make_err)?),
114                "m" => monotonic_us = Some(u64::from_str_radix(val, 16).map_err(|_| make_err())?),
115                "t" => realtime_us = Some(u64::from_str_radix(val, 16).map_err(|_| make_err())?),
116                "x" => xor_hash = Some(u64::from_str_radix(val, 16).map_err(|_| make_err())?),
117                _ => return Err(make_err()),
118            }
119        }
120
121        Ok(JournalCursor {
122            seqnum_id: seqnum_id.ok_or_else(make_err)?,
123            seqnum: seqnum.ok_or_else(make_err)?,
124            boot_id: boot_id.ok_or_else(make_err)?,
125            monotonic_us: monotonic_us.ok_or_else(make_err)?,
126            realtime_us: realtime_us.ok_or_else(make_err)?,
127            xor_hash: xor_hash.ok_or_else(make_err)?,
128        })
129    }
130
131    /// Serialize back to the canonical cursor string format.
132    #[allow(clippy::inherent_to_string)]
133    pub fn to_string(&self) -> String {
134        format!(
135            "s={};i={:x};b={};m={:x};t={:x};x={:x}",
136            uuid_to_hex(&self.seqnum_id),
137            self.seqnum,
138            uuid_to_hex(&self.boot_id),
139            self.monotonic_us,
140            self.realtime_us,
141            self.xor_hash,
142        )
143    }
144}
145
146/// Parse a 32-char hex string (no dashes) into a 16-byte UUID array.
147fn parse_uuid_hex(s: &str) -> Option<[u8; 16]> {
148    if s.len() != 32 {
149        return None;
150    }
151    let mut out = [0u8; 16];
152    for (i, chunk) in s.as_bytes().chunks(2).enumerate() {
153        let hi = hex_nibble(chunk[0])?;
154        let lo = hex_nibble(chunk[1])?;
155        out[i] = (hi << 4) | lo;
156    }
157    Some(out)
158}
159
160fn hex_nibble(b: u8) -> Option<u8> {
161    match b {
162        b'0'..=b'9' => Some(b - b'0'),
163        b'a'..=b'f' => Some(b - b'a' + 10),
164        b'A'..=b'F' => Some(b - b'A' + 10),
165        _ => None,
166    }
167}
168
169fn uuid_to_hex(bytes: &[u8; 16]) -> String {
170    use fmt::Write as _;
171    let mut s = String::with_capacity(32);
172    for b in bytes {
173        write!(s, "{b:02x}").unwrap();
174    }
175    s
176}
177
178/// Errors produced by the journald-forensic library.
179#[derive(Debug, Error)]
180pub enum JournalError {
181    #[error("invalid magic: expected LPKSHHRH, found {found:02x?}")]
182    InvalidMagic { found: [u8; 8] },
183
184    #[error("buffer too short: needed {needed} bytes, got {got}")]
185    BufferTooShort { needed: usize, got: usize },
186
187    #[error("invalid object type byte: {type_byte}")]
188    InvalidObjectType { type_byte: u8 },
189
190    #[error("unknown compression flags: {flags}")]
191    UnknownCompression { flags: u8 },
192
193    #[error("invalid cursor string: '{cursor}'")]
194    InvalidCursor { cursor: String },
195
196    #[error("I/O error: {0}")]
197    Io(#[from] std::io::Error),
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    fn make_entry_with_fields(fields: Vec<(&str, &str)>) -> JournalEntry {
205        JournalEntry {
206            seqnum: 1,
207            realtime_us: 0,
208            monotonic_us: 0,
209            boot_id: [0u8; 16],
210            fields: fields
211                .into_iter()
212                .map(|(k, v)| JournalField {
213                    key: k.to_string(),
214                    value: JournalFieldValue::Text(v.to_string()),
215                })
216                .collect(),
217        }
218    }
219
220    #[test]
221    fn entry_field_lookup_by_name() {
222        let entry = make_entry_with_fields(vec![("MESSAGE", "hello world"), ("_PID", "42")]);
223        assert_eq!(entry.field("MESSAGE"), Some("hello world"));
224        assert_eq!(entry.field("_PID"), Some("42"));
225        assert_eq!(entry.field("MISSING"), None);
226    }
227
228    #[test]
229    fn entry_realtime_converts_to_datetime() {
230        let entry = JournalEntry {
231            seqnum: 1,
232            realtime_us: 1_000_000,
233            monotonic_us: 0,
234            boot_id: [0u8; 16],
235            fields: vec![],
236        };
237        let dt = entry.realtime_as_datetime();
238        assert_eq!(dt, Utc.timestamp_opt(1, 0).unwrap());
239    }
240
241    #[test]
242    fn entry_message_convenience() {
243        let entry = make_entry_with_fields(vec![("MESSAGE", "test message")]);
244        assert_eq!(entry.message(), Some("test message"));
245    }
246
247    #[test]
248    fn entry_priority_parses_u8() {
249        let entry = make_entry_with_fields(vec![("PRIORITY", "6")]);
250        assert_eq!(entry.priority(), Some(6u8));
251    }
252
253    #[test]
254    fn entry_priority_returns_none_when_absent() {
255        let entry = make_entry_with_fields(vec![]);
256        assert_eq!(entry.priority(), None);
257    }
258
259    #[test]
260    fn entry_pid_parses_u32() {
261        let entry = make_entry_with_fields(vec![("_PID", "1234")]);
262        assert_eq!(entry.pid(), Some(1234u32));
263    }
264
265    #[test]
266    fn entry_pid_returns_none_when_absent() {
267        let entry = make_entry_with_fields(vec![]);
268        assert_eq!(entry.pid(), None);
269    }
270
271    #[test]
272    fn cursor_parse_roundtrip() {
273        // All-zero UUIDs, seqnum=1, all others 0
274        let cursor_str = "s=00000000000000000000000000000000;i=1;b=00000000000000000000000000000000;m=0;t=0;x=0";
275        let cursor = JournalCursor::parse(cursor_str).expect("parse should succeed");
276        assert_eq!(cursor.seqnum, 1);
277        assert_eq!(cursor.seqnum_id, [0u8; 16]);
278        assert_eq!(cursor.boot_id, [0u8; 16]);
279        assert_eq!(cursor.monotonic_us, 0);
280        assert_eq!(cursor.realtime_us, 0);
281        assert_eq!(cursor.xor_hash, 0);
282        let roundtrip = cursor.to_string();
283        assert_eq!(roundtrip, cursor_str);
284    }
285
286    #[test]
287    fn cursor_parse_invalid_returns_err() {
288        let result = JournalCursor::parse("not-a-cursor");
289        assert!(result.is_err());
290    }
291
292    #[test]
293    fn journal_field_value_text_accessible() {
294        let val = JournalFieldValue::Text("hello".to_string());
295        match val {
296            JournalFieldValue::Text(s) => assert_eq!(s, "hello"),
297            JournalFieldValue::Binary(_) => panic!("expected Text"),
298        }
299    }
300
301    #[test]
302    fn journal_error_invalid_magic_display() {
303        let err = JournalError::InvalidMagic { found: *b"BADMAGIC" };
304        let display = format!("{err}");
305        assert!(display.contains("LPKSHHRH") || display.contains("invalid magic"));
306    }
307}