1use common::Error;
17use serde::{Deserialize, Serialize};
18use std::fs::{self, File, OpenOptions};
19use std::io::{BufRead, BufReader, Read, Write};
20use std::os::unix::fs::OpenOptionsExt;
21use std::path::{Path, PathBuf};
22use std::sync::{atomic::AtomicU64, atomic::Ordering, Mutex};
23
24const JOURNAL_FILE_MODE: u32 = 0o600;
30
31static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36pub enum JournalAction {
37 Freeze,
38 Cap,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub struct JournalEntry {
44 pub cgroup: String,
46 pub inode: u64,
48 pub unit: Option<String>,
50 pub action: JournalAction,
52 pub prev_high: Option<String>,
54 pub our_high: Option<String>,
56}
57
58pub struct Journal {
60 path: PathBuf,
61 boot_id: String,
62 mutation_lock: Mutex<()>,
64}
65
66impl Journal {
67 pub fn read_entries(path: &Path, boot_id: &str) -> Vec<JournalEntry> {
87 let Ok(contents) = fs::read_to_string(path) else {
88 return vec![];
89 };
90 let mut lines = contents.lines();
91
92 let Some(header_line) = lines.next() else {
93 return vec![];
94 };
95 let Ok(header) = serde_json::from_str::<serde_json::Value>(header_line) else {
96 return vec![];
97 };
98 let Some(stored_boot_id) = header.get("boot_id").and_then(|v| v.as_str()) else {
99 return vec![];
100 };
101 if stored_boot_id != boot_id {
102 return vec![];
103 }
104
105 lines
106 .filter_map(|line| serde_json::from_str::<JournalEntry>(line).ok())
107 .collect()
108 }
109
110 pub fn open(path: PathBuf, boot_id: String) -> common::Result<Self> {
119 if let Some(parent) = path.parent() {
121 fs::create_dir_all(parent)?;
122 }
123
124 let journal = Journal {
125 path: path.clone(),
126 boot_id: boot_id.clone(),
127 mutation_lock: Mutex::new(()),
128 };
129
130 if path.exists() {
132 let file = File::open(&path)?;
133 let reader = BufReader::new(file);
134 let mut lines = reader.lines();
135
136 if let Some(Ok(header_line)) = lines.next() {
138 if let Ok(header) = serde_json::from_str::<serde_json::Value>(&header_line) {
139 if let Some(stored_boot_id) = header.get("boot_id").and_then(|v| v.as_str()) {
140 if stored_boot_id != boot_id {
141 journal.write_header()?;
143 return Ok(journal);
144 }
145 journal.recover_tail()?;
147 return Ok(journal);
148 }
149 }
150 }
151
152 journal.write_header()?;
154 } else {
155 journal.write_header()?;
157 }
158
159 Ok(journal)
160 }
161
162 fn recover_tail(&self) -> common::Result<()> {
165 let mut file = File::open(&self.path)?;
166 let mut contents = String::new();
167 file.read_to_string(&mut contents)?;
168
169 let mut byte_offset = 0;
170 let mut found_corruption = false;
171
172 for (idx, line) in contents.lines().enumerate() {
173 if idx == 0 {
174 byte_offset += line.len() + 1; continue;
177 }
178
179 let is_valid = serde_json::from_str::<JournalEntry>(line).is_ok();
181
182 let line_start = byte_offset;
185 let line_with_newline_len = line.len() + 1;
186 byte_offset += line_with_newline_len;
187
188 if !is_valid {
191 found_corruption = true;
192 if line_start > 0 {
194 self.truncate_at(line_start)?;
195 }
196 break;
197 }
198 }
199
200 if !found_corruption && !contents.is_empty() && !contents.ends_with('\n') {
202 let last_line_start = contents.rfind('\n').map(|i| i + 1).unwrap_or(0);
204 self.truncate_at(last_line_start)?;
206 }
207
208 Ok(())
209 }
210
211 fn truncate_at(&self, byte_offset: usize) -> common::Result<()> {
213 let file = OpenOptions::new().write(true).open(&self.path)?;
214 file.set_len(byte_offset as u64)?;
215 file.sync_data()?;
216 Ok(())
217 }
218
219 fn write_header(&self) -> common::Result<()> {
221 let mut file = OpenOptions::new()
222 .write(true)
223 .create(true)
224 .truncate(true)
225 .mode(JOURNAL_FILE_MODE)
226 .open(&self.path)?;
227
228 let header = serde_json::json!({ "boot_id": self.boot_id });
229 writeln!(file, "{}", header)?;
230 file.sync_data()?;
231
232 Ok(())
233 }
234
235 pub fn append(&self, e: &JournalEntry) -> common::Result<()> {
237 let _guard = self.mutation_lock.lock().unwrap();
238
239 let mut file = OpenOptions::new().append(true).open(&self.path)?;
240
241 let json_line = serde_json::to_string(e)
242 .map_err(|err| Error::Cgroup(format!("journal serialization error: {}", err)))?;
243 writeln!(file, "{}", json_line)?;
244 file.sync_data()?;
245
246 Ok(())
247 }
248
249 pub fn entries(&self) -> Vec<JournalEntry> {
251 let Ok(file) = File::open(&self.path) else {
252 return vec![];
253 };
254
255 let reader = BufReader::new(file);
256 let mut entries = vec![];
257
258 for (idx, line) in reader.lines().enumerate() {
259 if idx == 0 {
260 continue;
262 }
263
264 if let Ok(line) = line {
265 if let Ok(entry) = serde_json::from_str::<JournalEntry>(&line) {
266 entries.push(entry);
267 } else {
268 tracing::warn!("Journal: skipping corrupt line {}", idx);
270 }
271 }
272 }
273
274 entries
275 }
276
277 pub fn remove(&self, cgroup: &str) -> common::Result<()> {
279 let _guard = self.mutation_lock.lock().unwrap();
280
281 let entries = self
282 .entries()
283 .into_iter()
284 .filter(|e| e.cgroup != cgroup)
285 .collect::<Vec<_>>();
286
287 self.write_entries(&entries)?;
288 Ok(())
289 }
290
291 pub fn clear(&self) -> common::Result<()> {
293 let _guard = self.mutation_lock.lock().unwrap();
294 self.write_entries(&[])?;
295 Ok(())
296 }
297
298 pub fn replace(&self, cgroup: &str, entries: &[JournalEntry]) -> common::Result<()> {
307 let _guard = self.mutation_lock.lock().unwrap();
308
309 let mut all: Vec<JournalEntry> = self
310 .entries()
311 .into_iter()
312 .filter(|e| e.cgroup != cgroup)
313 .collect();
314 all.extend(entries.iter().cloned());
315
316 self.write_entries(&all)?;
317 Ok(())
318 }
319
320 fn write_entries(&self, entries: &[JournalEntry]) -> common::Result<()> {
322 let parent = self
323 .path
324 .parent()
325 .ok_or_else(|| common::Error::Cgroup("Journal path has no parent".to_string()))?;
326
327 let counter = TEMP_COUNTER.fetch_add(1, Ordering::SeqCst);
329 let temp_path = parent.join(format!(".journal-tmp-{}-{}", std::process::id(), counter));
330
331 let mut file = OpenOptions::new()
332 .write(true)
333 .create(true)
334 .truncate(true)
335 .mode(JOURNAL_FILE_MODE)
336 .open(&temp_path)?;
337
338 let header = serde_json::json!({ "boot_id": self.boot_id });
340 writeln!(file, "{}", header)?;
341
342 for entry in entries {
344 let json_line = serde_json::to_string(entry)
345 .map_err(|err| Error::Cgroup(format!("journal serialization error: {}", err)))?;
346 writeln!(file, "{}", json_line)?;
347 }
348
349 file.sync_data()?;
351 drop(file);
352
353 fs::rename(&temp_path, &self.path)?;
355
356 if let Ok(dir) = fs::File::open(parent) {
358 let _ = dir.sync_all();
359 }
360
361 Ok(())
362 }
363}
364
365pub fn should_restore(
372 e: &JournalEntry,
373 current_inode: Option<u64>,
374 current_high: Option<&str>,
375) -> bool {
376 if current_inode != Some(e.inode) {
377 return false;
378 }
379
380 match e.action {
381 JournalAction::Freeze => true,
382 JournalAction::Cap => e.our_high.as_deref() == current_high,
383 }
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389
390 fn entry(cg: &str) -> JournalEntry {
391 JournalEntry {
392 cgroup: cg.into(),
393 inode: 42,
394 unit: None,
395 action: JournalAction::Cap,
396 prev_high: Some("max".into()),
397 our_high: Some("1000000".into()),
398 }
399 }
400
401 #[test]
402 fn append_then_entries_roundtrip() {
403 let dir = tempfile::tempdir().unwrap();
404 let j = Journal::open(dir.path().join("j.jsonl"), "boot-a".into()).unwrap();
405 j.append(&entry("/x/a")).unwrap();
406 j.append(&entry("/x/b")).unwrap();
407 assert_eq!(j.entries().len(), 2);
408 assert_eq!(j.entries()[0].cgroup, "/x/a");
409 }
410
411 #[test]
417 fn journal_file_is_owner_only_after_create_and_after_rewrite() {
418 use std::os::unix::fs::PermissionsExt;
419
420 let dir = tempfile::tempdir().unwrap();
421 let p = dir.path().join("j.jsonl");
422 let j = Journal::open(p.clone(), "boot-a".into()).unwrap();
423 let mode_of = |path: &Path| fs::metadata(path).unwrap().permissions().mode() & 0o777;
424 assert_eq!(mode_of(&p), 0o600, "journal file must be created 0600");
425
426 j.append(&entry("/x/a")).unwrap();
427 j.append(&entry("/x/b")).unwrap();
428 assert_eq!(
429 mode_of(&p),
430 0o600,
431 "journal file must stay 0600 after append"
432 );
433
434 j.remove("/x/a").unwrap();
437 assert_eq!(mode_of(&p), 0o600, "rewritten journal file must stay 0600");
438 }
439
440 #[test]
441 fn stale_boot_id_truncates() {
442 let dir = tempfile::tempdir().unwrap();
443 let p = dir.path().join("j.jsonl");
444 let j = Journal::open(p.clone(), "boot-a".into()).unwrap();
445 j.append(&entry("/x/a")).unwrap();
446 drop(j);
447 let j2 = Journal::open(p, "boot-b".into()).unwrap();
448 assert!(
449 j2.entries().is_empty(),
450 "prior-boot entries must be discarded"
451 );
452 }
453
454 #[test]
455 fn remove_deletes_only_matching_cgroup() {
456 let dir = tempfile::tempdir().unwrap();
457 let j = Journal::open(dir.path().join("j.jsonl"), "b".into()).unwrap();
458 j.append(&entry("/x/a")).unwrap();
459 j.append(&entry("/x/b")).unwrap();
460 j.remove("/x/a").unwrap();
461 let e = j.entries();
462 assert_eq!(e.len(), 1);
463 assert_eq!(e[0].cgroup, "/x/b");
464 }
465
466 #[test]
472 fn replace_swaps_only_target_cgroup_atomically() {
473 let dir = tempfile::tempdir().unwrap();
474 let p = dir.path().join("j.jsonl");
475 let j = Journal::open(p.clone(), "boot-a".into()).unwrap();
476 j.append(&entry("/x")).unwrap();
477 j.append(&entry("/y")).unwrap();
478
479 let mut corrected = entry("/x");
480 corrected.our_high = Some("corrected".into());
481 j.replace("/x", &[corrected]).unwrap();
482
483 let entries = j.entries();
484 assert_eq!(entries.len(), 2, "one entry per cgroup, as before");
485 assert!(
486 entries
487 .iter()
488 .any(|e| e.cgroup == "/y" && e == &entry("/y")),
489 "y's entry must be byte-for-byte untouched: {entries:?}"
490 );
491 assert!(
492 entries
493 .iter()
494 .any(|e| e.cgroup == "/x" && e.our_high.as_deref() == Some("corrected")),
495 "x's entry must be replaced with the corrected value: {entries:?}"
496 );
497
498 drop(j);
499 let j2 = Journal::open(p, "boot-a".into()).unwrap();
500 assert_eq!(
501 j2.entries().len(),
502 2,
503 "both entries still readable after re-open with the same boot_id"
504 );
505 }
506
507 #[test]
508 fn replace_with_empty_slice_removes_the_cgroup() {
509 let dir = tempfile::tempdir().unwrap();
510 let j = Journal::open(dir.path().join("j.jsonl"), "b".into()).unwrap();
511 j.append(&entry("/x")).unwrap();
512 j.append(&entry("/y")).unwrap();
513 j.replace("/x", &[]).unwrap();
514 let e = j.entries();
515 assert_eq!(e.len(), 1);
516 assert_eq!(e[0].cgroup, "/y");
517 }
518
519 #[test]
520 fn clear_leaves_header_only() {
521 let dir = tempfile::tempdir().unwrap();
522 let j = Journal::open(dir.path().join("j.jsonl"), "b".into()).unwrap();
523 j.append(&entry("/x/a")).unwrap();
524 j.clear().unwrap();
525 assert!(j.entries().is_empty());
526 }
527
528 #[test]
529 fn corrupt_lines_are_skipped_not_fatal() {
530 let dir = tempfile::tempdir().unwrap();
531 let p = dir.path().join("j.jsonl");
532 let j = Journal::open(p.clone(), "b".into()).unwrap();
533 j.append(&entry("/x/a")).unwrap();
534 use std::io::Write;
535 let mut f = std::fs::OpenOptions::new().append(true).open(&p).unwrap();
536 writeln!(f, "{{garbage").unwrap();
537 assert_eq!(j.entries().len(), 1);
538 }
539
540 #[test]
541 fn should_restore_guards() {
542 let e = entry("/x/a"); assert!(should_restore(&e, Some(42), Some("1000000")));
544 assert!(
545 !should_restore(&e, Some(43), Some("1000000")),
546 "inode mismatch must skip"
547 );
548 assert!(
549 !should_restore(&e, None, Some("1000000")),
550 "cgroup gone must skip"
551 );
552 assert!(
553 !should_restore(&e, Some(42), Some("999")),
554 "someone changed high must skip"
555 );
556 let f = JournalEntry {
557 action: JournalAction::Freeze,
558 prev_high: None,
559 our_high: None,
560 ..e
561 };
562 assert!(
563 should_restore(&f, Some(42), None),
564 "freeze entries only need inode"
565 );
566 }
567
568 #[test]
569 fn wal_tail_recovery_on_reopen() {
570 let dir = tempfile::tempdir().unwrap();
573 let p = dir.path().join("j.jsonl");
574
575 let j = Journal::open(p.clone(), "boot-a".into()).unwrap();
577 j.append(&entry("/x/a")).unwrap();
578 drop(j);
579
580 use std::io::Write;
582 let mut f = std::fs::OpenOptions::new().append(true).open(&p).unwrap();
583 write!(f, "{{partial").unwrap(); drop(f);
585
586 let j2 = Journal::open(p.clone(), "boot-a".into()).unwrap();
588
589 j2.append(&entry("/x/b")).unwrap();
591
592 let entries = j2.entries();
594 assert_eq!(entries.len(), 2, "both old and new entry must survive");
595 assert_eq!(entries[0].cgroup, "/x/a", "old entry first");
596 assert_eq!(entries[1].cgroup, "/x/b", "new entry second");
597 }
598
599 #[test]
606 fn read_entries_is_read_only_and_skips_torn_trailing_line() {
607 let dir = tempfile::tempdir().unwrap();
608 let p = dir.path().join("j.jsonl");
609
610 let j = Journal::open(p.clone(), "boot-a".into()).unwrap();
611 j.append(&entry("/x/a")).unwrap();
612 j.append(&entry("/x/b")).unwrap();
613 drop(j);
614
615 use std::io::Write;
617 let mut f = std::fs::OpenOptions::new().append(true).open(&p).unwrap();
618 write!(f, "{{partial").unwrap();
619 drop(f);
620
621 let before = fs::read(&p).unwrap();
622 let entries = Journal::read_entries(&p, "boot-a");
623 let after = fs::read(&p).unwrap();
624
625 assert_eq!(
626 entries.len(),
627 2,
628 "torn trailing line must be skipped, not recovered: {entries:?}"
629 );
630 assert_eq!(entries[0].cgroup, "/x/a");
631 assert_eq!(entries[1].cgroup, "/x/b");
632 assert_eq!(
633 before, after,
634 "read_entries must never mutate the journal file on disk"
635 );
636 }
637
638 #[test]
642 fn read_entries_returns_empty_on_boot_mismatch_without_mutating() {
643 let dir = tempfile::tempdir().unwrap();
644 let p = dir.path().join("j.jsonl");
645
646 let j = Journal::open(p.clone(), "boot-a".into()).unwrap();
647 j.append(&entry("/x/a")).unwrap();
648 drop(j);
649
650 let before = fs::read(&p).unwrap();
651 let entries = Journal::read_entries(&p, "boot-b");
652 let after = fs::read(&p).unwrap();
653
654 assert!(
655 entries.is_empty(),
656 "stale-boot entries must not be returned"
657 );
658 assert_eq!(
659 before, after,
660 "read_entries must not truncate on boot_id mismatch"
661 );
662 }
663}