1use chrono::{DateTime, TimeZone, Utc};
4use std::fmt;
5use thiserror::Error;
6
7#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
9pub struct JournalField {
10 pub key: String,
11 pub value: JournalFieldValue,
12}
13
14#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
16pub enum JournalFieldValue {
17 Text(String),
18 Binary(Vec<u8>),
19}
20
21#[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 pub fn realtime_as_datetime(&self) -> DateTime<Utc> {
34 #[allow(clippy::cast_possible_wrap)]
36 let secs = (self.realtime_us / 1_000_000) as i64;
37 #[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 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 pub fn message(&self) -> Option<&str> {
59 self.field("MESSAGE")
60 }
61
62 pub fn priority(&self) -> Option<u8> {
64 self.field("PRIORITY")?.parse().ok()
65 }
66
67 pub fn syslog_identifier(&self) -> Option<&str> {
69 self.field("SYSLOG_IDENTIFIER")
70 }
71
72 pub fn pid(&self) -> Option<u32> {
74 self.field("_PID")?.parse().ok()
75 }
76}
77
78#[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 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 #[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
146fn 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#[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 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}