use common::Error;
use serde::{Deserialize, Serialize};
use std::fs::{self, File, OpenOptions};
use std::io::{BufRead, BufReader, Read, Write};
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};
use std::sync::{atomic::AtomicU64, atomic::Ordering, Mutex};
const JOURNAL_FILE_MODE: u32 = 0o600;
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum JournalAction {
Freeze,
Cap,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JournalEntry {
pub cgroup: String,
pub inode: u64,
pub unit: Option<String>,
pub action: JournalAction,
pub prev_high: Option<String>,
pub our_high: Option<String>,
}
pub struct Journal {
path: PathBuf,
boot_id: String,
mutation_lock: Mutex<()>,
}
impl Journal {
pub fn read_entries(path: &Path, boot_id: &str) -> Vec<JournalEntry> {
let Ok(contents) = fs::read_to_string(path) else {
return vec![];
};
let mut lines = contents.lines();
let Some(header_line) = lines.next() else {
return vec![];
};
let Ok(header) = serde_json::from_str::<serde_json::Value>(header_line) else {
return vec![];
};
let Some(stored_boot_id) = header.get("boot_id").and_then(|v| v.as_str()) else {
return vec![];
};
if stored_boot_id != boot_id {
return vec![];
}
lines
.filter_map(|line| serde_json::from_str::<JournalEntry>(line).ok())
.collect()
}
pub fn open(path: PathBuf, boot_id: String) -> common::Result<Self> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let journal = Journal {
path: path.clone(),
boot_id: boot_id.clone(),
mutation_lock: Mutex::new(()),
};
if path.exists() {
let file = File::open(&path)?;
let reader = BufReader::new(file);
let mut lines = reader.lines();
if let Some(Ok(header_line)) = lines.next() {
if let Ok(header) = serde_json::from_str::<serde_json::Value>(&header_line) {
if let Some(stored_boot_id) = header.get("boot_id").and_then(|v| v.as_str()) {
if stored_boot_id != boot_id {
journal.write_header()?;
return Ok(journal);
}
journal.recover_tail()?;
return Ok(journal);
}
}
}
journal.write_header()?;
} else {
journal.write_header()?;
}
Ok(journal)
}
fn recover_tail(&self) -> common::Result<()> {
let mut file = File::open(&self.path)?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
let mut byte_offset = 0;
let mut found_corruption = false;
for (idx, line) in contents.lines().enumerate() {
if idx == 0 {
byte_offset += line.len() + 1; continue;
}
let is_valid = serde_json::from_str::<JournalEntry>(line).is_ok();
let line_start = byte_offset;
let line_with_newline_len = line.len() + 1;
byte_offset += line_with_newline_len;
if !is_valid {
found_corruption = true;
if line_start > 0 {
self.truncate_at(line_start)?;
}
break;
}
}
if !found_corruption && !contents.is_empty() && !contents.ends_with('\n') {
let last_line_start = contents.rfind('\n').map(|i| i + 1).unwrap_or(0);
self.truncate_at(last_line_start)?;
}
Ok(())
}
fn truncate_at(&self, byte_offset: usize) -> common::Result<()> {
let file = OpenOptions::new().write(true).open(&self.path)?;
file.set_len(byte_offset as u64)?;
file.sync_data()?;
Ok(())
}
fn write_header(&self) -> common::Result<()> {
let mut file = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(JOURNAL_FILE_MODE)
.open(&self.path)?;
let header = serde_json::json!({ "boot_id": self.boot_id });
writeln!(file, "{}", header)?;
file.sync_data()?;
Ok(())
}
pub fn append(&self, e: &JournalEntry) -> common::Result<()> {
let _guard = self.mutation_lock.lock().unwrap();
let mut file = OpenOptions::new().append(true).open(&self.path)?;
let json_line = serde_json::to_string(e)
.map_err(|err| Error::Cgroup(format!("journal serialization error: {}", err)))?;
writeln!(file, "{}", json_line)?;
file.sync_data()?;
Ok(())
}
pub fn entries(&self) -> Vec<JournalEntry> {
let Ok(file) = File::open(&self.path) else {
return vec![];
};
let reader = BufReader::new(file);
let mut entries = vec![];
for (idx, line) in reader.lines().enumerate() {
if idx == 0 {
continue;
}
if let Ok(line) = line {
if let Ok(entry) = serde_json::from_str::<JournalEntry>(&line) {
entries.push(entry);
} else {
tracing::warn!("Journal: skipping corrupt line {}", idx);
}
}
}
entries
}
pub fn remove(&self, cgroup: &str) -> common::Result<()> {
let _guard = self.mutation_lock.lock().unwrap();
let entries = self
.entries()
.into_iter()
.filter(|e| e.cgroup != cgroup)
.collect::<Vec<_>>();
self.write_entries(&entries)?;
Ok(())
}
pub fn clear(&self) -> common::Result<()> {
let _guard = self.mutation_lock.lock().unwrap();
self.write_entries(&[])?;
Ok(())
}
pub fn replace(&self, cgroup: &str, entries: &[JournalEntry]) -> common::Result<()> {
let _guard = self.mutation_lock.lock().unwrap();
let mut all: Vec<JournalEntry> = self
.entries()
.into_iter()
.filter(|e| e.cgroup != cgroup)
.collect();
all.extend(entries.iter().cloned());
self.write_entries(&all)?;
Ok(())
}
fn write_entries(&self, entries: &[JournalEntry]) -> common::Result<()> {
let parent = self
.path
.parent()
.ok_or_else(|| common::Error::Cgroup("Journal path has no parent".to_string()))?;
let counter = TEMP_COUNTER.fetch_add(1, Ordering::SeqCst);
let temp_path = parent.join(format!(".journal-tmp-{}-{}", std::process::id(), counter));
let mut file = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(JOURNAL_FILE_MODE)
.open(&temp_path)?;
let header = serde_json::json!({ "boot_id": self.boot_id });
writeln!(file, "{}", header)?;
for entry in entries {
let json_line = serde_json::to_string(entry)
.map_err(|err| Error::Cgroup(format!("journal serialization error: {}", err)))?;
writeln!(file, "{}", json_line)?;
}
file.sync_data()?;
drop(file);
fs::rename(&temp_path, &self.path)?;
if let Ok(dir) = fs::File::open(parent) {
let _ = dir.sync_all();
}
Ok(())
}
}
pub fn should_restore(
e: &JournalEntry,
current_inode: Option<u64>,
current_high: Option<&str>,
) -> bool {
if current_inode != Some(e.inode) {
return false;
}
match e.action {
JournalAction::Freeze => true,
JournalAction::Cap => e.our_high.as_deref() == current_high,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(cg: &str) -> JournalEntry {
JournalEntry {
cgroup: cg.into(),
inode: 42,
unit: None,
action: JournalAction::Cap,
prev_high: Some("max".into()),
our_high: Some("1000000".into()),
}
}
#[test]
fn append_then_entries_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let j = Journal::open(dir.path().join("j.jsonl"), "boot-a".into()).unwrap();
j.append(&entry("/x/a")).unwrap();
j.append(&entry("/x/b")).unwrap();
assert_eq!(j.entries().len(), 2);
assert_eq!(j.entries()[0].cgroup, "/x/a");
}
#[test]
fn journal_file_is_owner_only_after_create_and_after_rewrite() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("j.jsonl");
let j = Journal::open(p.clone(), "boot-a".into()).unwrap();
let mode_of = |path: &Path| fs::metadata(path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode_of(&p), 0o600, "journal file must be created 0600");
j.append(&entry("/x/a")).unwrap();
j.append(&entry("/x/b")).unwrap();
assert_eq!(
mode_of(&p),
0o600,
"journal file must stay 0600 after append"
);
j.remove("/x/a").unwrap();
assert_eq!(mode_of(&p), 0o600, "rewritten journal file must stay 0600");
}
#[test]
fn stale_boot_id_truncates() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("j.jsonl");
let j = Journal::open(p.clone(), "boot-a".into()).unwrap();
j.append(&entry("/x/a")).unwrap();
drop(j);
let j2 = Journal::open(p, "boot-b".into()).unwrap();
assert!(
j2.entries().is_empty(),
"prior-boot entries must be discarded"
);
}
#[test]
fn remove_deletes_only_matching_cgroup() {
let dir = tempfile::tempdir().unwrap();
let j = Journal::open(dir.path().join("j.jsonl"), "b".into()).unwrap();
j.append(&entry("/x/a")).unwrap();
j.append(&entry("/x/b")).unwrap();
j.remove("/x/a").unwrap();
let e = j.entries();
assert_eq!(e.len(), 1);
assert_eq!(e[0].cgroup, "/x/b");
}
#[test]
fn replace_swaps_only_target_cgroup_atomically() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("j.jsonl");
let j = Journal::open(p.clone(), "boot-a".into()).unwrap();
j.append(&entry("/x")).unwrap();
j.append(&entry("/y")).unwrap();
let mut corrected = entry("/x");
corrected.our_high = Some("corrected".into());
j.replace("/x", &[corrected]).unwrap();
let entries = j.entries();
assert_eq!(entries.len(), 2, "one entry per cgroup, as before");
assert!(
entries
.iter()
.any(|e| e.cgroup == "/y" && e == &entry("/y")),
"y's entry must be byte-for-byte untouched: {entries:?}"
);
assert!(
entries
.iter()
.any(|e| e.cgroup == "/x" && e.our_high.as_deref() == Some("corrected")),
"x's entry must be replaced with the corrected value: {entries:?}"
);
drop(j);
let j2 = Journal::open(p, "boot-a".into()).unwrap();
assert_eq!(
j2.entries().len(),
2,
"both entries still readable after re-open with the same boot_id"
);
}
#[test]
fn replace_with_empty_slice_removes_the_cgroup() {
let dir = tempfile::tempdir().unwrap();
let j = Journal::open(dir.path().join("j.jsonl"), "b".into()).unwrap();
j.append(&entry("/x")).unwrap();
j.append(&entry("/y")).unwrap();
j.replace("/x", &[]).unwrap();
let e = j.entries();
assert_eq!(e.len(), 1);
assert_eq!(e[0].cgroup, "/y");
}
#[test]
fn clear_leaves_header_only() {
let dir = tempfile::tempdir().unwrap();
let j = Journal::open(dir.path().join("j.jsonl"), "b".into()).unwrap();
j.append(&entry("/x/a")).unwrap();
j.clear().unwrap();
assert!(j.entries().is_empty());
}
#[test]
fn corrupt_lines_are_skipped_not_fatal() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("j.jsonl");
let j = Journal::open(p.clone(), "b".into()).unwrap();
j.append(&entry("/x/a")).unwrap();
use std::io::Write;
let mut f = std::fs::OpenOptions::new().append(true).open(&p).unwrap();
writeln!(f, "{{garbage").unwrap();
assert_eq!(j.entries().len(), 1);
}
#[test]
fn should_restore_guards() {
let e = entry("/x/a"); assert!(should_restore(&e, Some(42), Some("1000000")));
assert!(
!should_restore(&e, Some(43), Some("1000000")),
"inode mismatch must skip"
);
assert!(
!should_restore(&e, None, Some("1000000")),
"cgroup gone must skip"
);
assert!(
!should_restore(&e, Some(42), Some("999")),
"someone changed high must skip"
);
let f = JournalEntry {
action: JournalAction::Freeze,
prev_high: None,
our_high: None,
..e
};
assert!(
should_restore(&f, Some(42), None),
"freeze entries only need inode"
);
}
#[test]
fn wal_tail_recovery_on_reopen() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("j.jsonl");
let j = Journal::open(p.clone(), "boot-a".into()).unwrap();
j.append(&entry("/x/a")).unwrap();
drop(j);
use std::io::Write;
let mut f = std::fs::OpenOptions::new().append(true).open(&p).unwrap();
write!(f, "{{partial").unwrap(); drop(f);
let j2 = Journal::open(p.clone(), "boot-a".into()).unwrap();
j2.append(&entry("/x/b")).unwrap();
let entries = j2.entries();
assert_eq!(entries.len(), 2, "both old and new entry must survive");
assert_eq!(entries[0].cgroup, "/x/a", "old entry first");
assert_eq!(entries[1].cgroup, "/x/b", "new entry second");
}
#[test]
fn read_entries_is_read_only_and_skips_torn_trailing_line() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("j.jsonl");
let j = Journal::open(p.clone(), "boot-a".into()).unwrap();
j.append(&entry("/x/a")).unwrap();
j.append(&entry("/x/b")).unwrap();
drop(j);
use std::io::Write;
let mut f = std::fs::OpenOptions::new().append(true).open(&p).unwrap();
write!(f, "{{partial").unwrap();
drop(f);
let before = fs::read(&p).unwrap();
let entries = Journal::read_entries(&p, "boot-a");
let after = fs::read(&p).unwrap();
assert_eq!(
entries.len(),
2,
"torn trailing line must be skipped, not recovered: {entries:?}"
);
assert_eq!(entries[0].cgroup, "/x/a");
assert_eq!(entries[1].cgroup, "/x/b");
assert_eq!(
before, after,
"read_entries must never mutate the journal file on disk"
);
}
#[test]
fn read_entries_returns_empty_on_boot_mismatch_without_mutating() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("j.jsonl");
let j = Journal::open(p.clone(), "boot-a".into()).unwrap();
j.append(&entry("/x/a")).unwrap();
drop(j);
let before = fs::read(&p).unwrap();
let entries = Journal::read_entries(&p, "boot-b");
let after = fs::read(&p).unwrap();
assert!(
entries.is_empty(),
"stale-boot entries must not be returned"
);
assert_eq!(
before, after,
"read_entries must not truncate on boot_id mismatch"
);
}
}