use crate::error::{EngineError, Result};
use crate::events::{Event, EventKind};
use crate::paths::MissionPaths;
use crate::scrub::SecretFinding;
use cap_std::fs::Dir;
use chrono::Utc;
#[cfg(unix)]
use std::ffi::OsString;
use std::fs::File;
use std::io::{ErrorKind, Write};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use subtle::ConstantTimeEq as _;
const CHAIN_FIELD: &str = "h";
const MAC_FIELD: &str = "m";
const VERSION_FIELD: &str = "v";
const EXACT_FLOAT_VERSION: u64 = 2;
fn canonical_event_bytes(event: &Event, version: u64) -> Result<String> {
if version == EXACT_FLOAT_VERSION {
let mut value = serde_json::to_value(event)?;
value.sort_all_objects();
Ok(serde_json::to_string(&value)?)
} else {
Ok(serde_json::to_string(event)?)
}
}
fn chain_hash(prev: &str, body: &str, version: u64) -> String {
let prefix = if version == EXACT_FLOAT_VERSION {
"kranz.event-log.v2\n"
} else {
""
};
let mut input = String::with_capacity(prefix.len() + prev.len() + body.len());
input.push_str(prefix);
input.push_str(prev);
input.push_str(body);
crate::standards_waiver::sha256_hex(input.as_bytes())
}
fn seal_line(event: &Event, prev_hash: &str, key: Option<&[u8]>) -> Result<(String, String)> {
let body = canonical_event_bytes(event, EXACT_FLOAT_VERSION)?;
let hash = chain_hash(prev_hash, &body, EXACT_FLOAT_VERSION);
let mut value = serde_json::to_value(event)?;
let object = value.as_object_mut().ok_or_else(|| {
EngineError::InvalidState("event did not serialize as a JSON object".to_string())
})?;
object.insert(VERSION_FIELD.to_string(), EXACT_FLOAT_VERSION.into());
object.insert(
CHAIN_FIELD.to_string(),
serde_json::Value::String(hash.clone()),
);
if let Some(key) = key {
object.insert(
MAC_FIELD.to_string(),
serde_json::Value::String(crate::hooks::hmac_sha256_hex(key, hash.as_bytes())),
);
}
Ok((serde_json::to_string(&value)?, hash))
}
fn restore_legacy_float_parsing(value: &mut serde_json::Value) -> Result<()> {
match value {
serde_json::Value::Number(number) if number.is_f64() => {
*number = legacy_float_number(number).ok_or_else(|| {
EngineError::LogCorruption("legacy event has an out-of-range float".into())
})?;
}
serde_json::Value::Array(values) => {
for value in values {
restore_legacy_float_parsing(value)?;
}
}
serde_json::Value::Object(values) => {
for value in values.values_mut() {
restore_legacy_float_parsing(value)?;
}
}
_ => {}
}
Ok(())
}
fn legacy_float_number(number: &serde_json::Number) -> Option<serde_json::Number> {
let decimal = number.to_string();
let negative = decimal.starts_with('-');
let unsigned = decimal.strip_prefix('-').unwrap_or(&decimal);
let (mantissa, exponent) = unsigned.split_once('e').unwrap_or((unsigned, "0"));
let fraction_digits = mantissa
.split_once('.')
.map_or(0, |(_, fraction)| fraction.len());
let mut exponent = exponent.parse::<i32>().ok()? - i32::try_from(fraction_digits).ok()?;
let coefficient = mantissa.replace('.', "").parse::<u64>().ok()?;
let mut parsed = coefficient as f64;
if exponent < -308 {
parsed /= 1e308;
exponent += 308;
}
if exponent.unsigned_abs() > 308 {
return None;
}
let power = format!("1e{}", exponent.unsigned_abs())
.parse::<f64>()
.ok()?;
parsed = if exponent < 0 {
parsed / power
} else {
parsed * power
};
serde_json::Number::from_f64(if negative { -parsed } else { parsed })
}
pub fn seal_events(events: &[Event], key: Option<&[u8]>) -> Result<String> {
let mut out = String::new();
let mut prev = String::new();
for event in events {
let (line, hash) = seal_line(event, &prev, key)?;
out.push_str(&line);
out.push('\n');
prev = hash;
}
Ok(out)
}
pub fn check_no_rollback(paths: &MissionPaths, events: &[Event]) -> Result<()> {
let snapshot_seq = crate::reducer::read_snapshot(&paths.state_file())
.map(|snapshot| snapshot.last_seq)
.unwrap_or(0);
let mark_seq = crate::paths::read_high_water(&paths.repo_root, &paths.mission_id).unwrap_or(0);
let (witness_seq, witness) = if mark_seq >= snapshot_seq {
(mark_seq, "the out-of-repo high-water mark")
} else {
(snapshot_seq, "the last snapshot")
};
let log_last_seq = events.last().map(|e| e.seq).unwrap_or(0);
if witness_seq > log_last_seq {
return Err(EngineError::LogCorruption(format!(
"refusing to resume mission '{}': {} ends at seq {log_last_seq} but {witness} \
recorded seq {witness_seq}. The log has lost {} event(s) since it was written; \
resuming would overwrite the snapshot with the rolled-back state and erase the \
evidence. Restore the log from the mission branch or abandon the mission.",
paths.mission_id,
paths.events_file().display(),
witness_seq - log_last_seq
)));
}
Ok(())
}
fn log_identity(path: &Path) -> Option<(&Path, &str)> {
let mission_dir = path.parent()?;
let mission_id = mission_dir.file_name()?.to_str()?;
let missions_dir = mission_dir.parent()?;
if missions_dir.file_name()? != "missions" {
return None;
}
let kranz_dir = missions_dir.parent()?;
if kranz_dir.file_name()? != ".kranz" {
return None;
}
Some((kranz_dir.parent()?, mission_id))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LockForce {
No,
IfNotLive,
EvenIfLive,
}
#[derive(Debug)]
struct BufferedLine {
buffered_at: Instant,
line: String,
}
#[derive(Debug)]
struct ParsedLog {
events: Vec<Event>,
valid_len: u64,
terminated: bool,
last_hash: Option<String>,
saw_mac: bool,
}
#[derive(Debug)]
pub struct EventLog {
mission_id: String,
events_path: PathBuf,
lock_path: PathBuf,
lock_generation: u64,
lock_token: Option<String>,
mission_dir: Dir,
file: File,
next_seq: u64,
throttle: Duration,
buffer: Vec<BufferedLine>,
repo_root: PathBuf,
prev_hash: String,
authority_key: Option<Vec<u8>>,
}
fn drain_lines<W: std::io::Write>(
sink: &mut W,
buffer: &mut Vec<BufferedLine>,
) -> std::io::Result<()> {
while !buffer.is_empty() {
sink.write_all(buffer[0].line.as_bytes())?;
buffer.remove(0);
}
Ok(())
}
fn ensure_absent_or_regular_at(dir: &Dir, name: &str, display: &Path) -> Result<()> {
match dir.symlink_metadata(name) {
Ok(metadata) if metadata.file_type().is_file() => Ok(()),
Ok(_) => Err(EngineError::InvalidState(format!(
"refusing non-regular mission runtime path {}",
display.display()
))),
Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
Err(error) => Err(error.into()),
}
}
fn open_create_new_at(dir: &Dir, name: &str) -> std::io::Result<File> {
use cap_fs_ext::OpenOptionsFollowExt as _;
use cap_primitives::fs::FollowSymlinks;
let mut options = cap_std::fs::OpenOptions::new();
options
.write(true)
.create_new(true)
.follow(FollowSymlinks::No);
dir.open_with(name, &options).map(|file| file.into_std())
}
fn open_write_at(dir: &Dir, name: &str, create: bool) -> std::io::Result<File> {
use cap_fs_ext::OpenOptionsFollowExt as _;
use cap_primitives::fs::FollowSymlinks;
let mut options = cap_std::fs::OpenOptions::new();
options
.write(true)
.create(create)
.follow(FollowSymlinks::No);
dir.open_with(name, &options).map(|file| file.into_std())
}
fn open_append_at(dir: &Dir, name: &str, create: bool) -> std::io::Result<File> {
use cap_fs_ext::OpenOptionsFollowExt as _;
use cap_primitives::fs::FollowSymlinks;
let mut options = cap_std::fs::OpenOptions::new();
options
.append(true)
.create(create)
.follow(FollowSymlinks::No);
dir.open_with(name, &options).map(|file| file.into_std())
}
fn open_read_at(dir: &Dir, name: &str) -> std::io::Result<File> {
use cap_fs_ext::OpenOptionsFollowExt as _;
use cap_primitives::fs::FollowSymlinks;
let mut options = cap_std::fs::OpenOptions::new();
options.read(true).follow(FollowSymlinks::No);
#[cfg(unix)]
{
use cap_fs_ext::OpenOptionsExt as _;
options.custom_flags(libc::O_NONBLOCK);
}
dir.open_with(name, &options).map(|file| file.into_std())
}
impl EventLog {
pub fn acquire(
paths: &MissionPaths,
mission_id: &str,
throttle: Duration,
force: LockForce,
) -> Result<EventLog> {
let mission_dir = paths.open_mission_dir_nofollow(true)?;
crate::paths::create_real_subdir(&mission_dir, "runs", &paths.runs_dir())?;
crate::paths::create_real_subdir(&mission_dir, "control", &paths.control_dir())?;
ensure_absent_or_regular_at(&mission_dir, "events.jsonl.lock", &paths.lock_file())?;
ensure_absent_or_regular_at(&mission_dir, "events.jsonl", &paths.events_file())?;
let lock_path = paths.lock_file();
let (mut lock_file, lock_generation) =
match open_create_new_at(&mission_dir, "events.jsonl.lock") {
Ok(f) => (f, 0u64),
Err(e) if e.kind() == ErrorKind::AlreadyExists => {
let (f, prev_gen) =
steal_lock(&mission_dir, "events.jsonl.lock", &lock_path, force)?;
(f, prev_gen.saturating_add(1))
}
Err(e) => return Err(e.into()),
};
let mut open = || -> Result<EventLog> {
lock_file.write_all(
current_lock_holder_record_with_generation(lock_generation).as_bytes(),
)?;
lock_file.flush()?;
let events_path = paths.events_file();
let mut prev_hash = String::new();
let mut tail_had_mac = false;
let last_seq = if mission_dir
.symlink_metadata("events.jsonl")
.is_ok_and(|metadata| metadata.file_type().is_file())
{
let parsed = Self::parse_log_file(
open_read_at(&mission_dir, "events.jsonl")?,
&events_path,
)?;
if let Some(first) = parsed.events.first() {
if first.mission_id != mission_id {
return Err(EngineError::InvalidState(format!(
"event log {} belongs to mission '{}', not '{}'",
events_path.display(),
first.mission_id,
mission_id
)));
}
}
let file_len = mission_dir.metadata("events.jsonl")?.len();
if parsed.valid_len < file_len {
let repair = open_write_at(&mission_dir, "events.jsonl", false)?;
repair.set_len(parsed.valid_len)?;
repair.sync_data()?;
} else if !parsed.terminated {
let mut repair = open_append_at(&mission_dir, "events.jsonl", false)?;
repair.write_all(b"\n")?;
repair.sync_data()?;
}
prev_hash = parsed.last_hash.clone().unwrap_or_default();
tail_had_mac = parsed.saw_mac;
parsed.events.last().map(|e| e.seq).unwrap_or(0)
} else {
0
};
let file = open_append_at(&mission_dir, "events.jsonl", true)?;
let authority_key = Some(
crate::paths::load_or_create_authority_key(&paths.repo_root).map_err(|error| {
EngineError::InvalidState(format!(
"cannot mint or read the repository authority key for mission '{mission_id}' \
({}): {error}. The event log is not written unsealed; restore the key \
directory before running this mission",
crate::paths::authority_key_path(&paths.repo_root)
.map(|p| p.display().to_string())
.unwrap_or_else(|| "<global kranz dir>/keys/<repo>.key".to_string())
))
})?,
);
if authority_key.is_none() && tail_had_mac {
return Err(EngineError::InvalidState(format!(
"event log {} is MAC-protected but the repository authority key is unreadable; \
restore {} before running this mission",
events_path.display(),
crate::paths::authority_key_path(&paths.repo_root)
.map(|p| p.display().to_string())
.unwrap_or_else(|| "~/.kranz/keys/<repo>.key".to_string())
)));
}
if authority_key.is_some() {
crate::paths::record_seal_floor(
&paths.repo_root,
mission_id,
last_seq.saturating_add(1),
)?;
}
Ok(EventLog {
mission_id: mission_id.to_string(),
events_path,
lock_path: lock_path.clone(),
lock_generation,
lock_token: process_identity_token(std::process::id() as i32),
mission_dir: mission_dir.try_clone()?,
file,
next_seq: last_seq + 1,
throttle,
buffer: Vec::new(),
repo_root: paths.repo_root.clone(),
prev_hash,
authority_key,
})
};
match open() {
Ok(log) => Ok(log),
Err(e) => {
let _ = mission_dir.remove_file("events.jsonl.lock");
Err(e)
}
}
}
pub fn mission_id(&self) -> &str {
&self.mission_id
}
pub fn last_seq(&self) -> u64 {
self.next_seq - 1
}
pub fn events_path(&self) -> &Path {
&self.events_path
}
pub fn append(&mut self, kind: EventKind) -> Result<Event> {
Ok(self.append_with_redaction_audits(kind)?.0)
}
pub fn append_with_redaction_audits(&mut self, kind: EventKind) -> Result<(Event, Vec<Event>)> {
let (event, redactions) = self.append_redacting(kind)?;
let mut audits = Vec::new();
for finding in redactions {
let (audit, _) = self.append_redacting(EventKind::SecretRedacted {
rule_id: finding.rule_id,
fingerprint: finding.fingerprint,
location: finding.location,
})?;
audits.push(audit);
}
Ok((event, audits))
}
pub fn append_redacting(&mut self, kind: EventKind) -> Result<(Event, Vec<SecretFinding>)> {
let current_gen = read_lock_info_at(&self.mission_dir, "events.jsonl.lock")
.generation
.unwrap_or(0);
if current_gen != self.lock_generation {
return Err(EngineError::LockHeld(format!(
"event log lock for '{}' was stolen (generation {} → {}); refusing append",
self.mission_id, self.lock_generation, current_gen
)));
}
let event = Event {
seq: self.next_seq,
ts: Utc::now(),
mission_id: self.mission_id.clone(),
kind,
};
let mut value = serde_json::to_value(&event)?;
let findings = crate::scrub::scrub_json_value(&mut value, "event");
let event: Event = serde_json::from_value(value)?;
let (mut line, hash) = seal_line(&event, &self.prev_hash, self.authority_key.as_deref())?;
self.prev_hash = hash;
line.push('\n');
if event.kind.is_stream_delta() {
self.buffer.push(BufferedLine {
buffered_at: Instant::now(),
line,
});
let oldest = self.buffer.first().expect("just pushed").buffered_at;
if oldest.elapsed() >= self.throttle {
self.drain_buffer()?;
}
} else {
self.drain_buffer()?;
self.file.write_all(line.as_bytes())?;
self.file.flush()?;
self.file.sync_data()?;
if self.authority_key.is_some() {
crate::paths::record_high_water(&self.repo_root, &self.mission_id, event.seq)?;
}
}
self.next_seq += 1;
Ok((event, findings))
}
pub fn flush(&mut self) -> Result<()> {
self.drain_buffer()?;
self.file.flush()?;
Ok(())
}
pub fn buffer_age(&self) -> Option<Duration> {
self.buffer.first().map(|b| b.buffered_at.elapsed())
}
pub fn flush_if_due(&mut self) -> Result<bool> {
match self.buffer_age() {
Some(age) if age >= self.throttle => {
self.drain_buffer()?;
self.file.flush()?;
Ok(true)
}
_ => Ok(false),
}
}
fn drain_buffer(&mut self) -> Result<()> {
drain_lines(&mut self.file, &mut self.buffer)?;
Ok(())
}
pub fn read_events(path: &Path) -> Result<Vec<Event>> {
Ok(Self::parse_log(path)?.events)
}
pub fn read_events_and_log_bytes(path: &Path) -> Result<(Vec<Event>, Vec<u8>)> {
use std::io::Read;
let mut bytes = Vec::new();
crate::paths::open_read_nofollow(path)?.read_to_end(&mut bytes)?;
let parsed = Self::parse_log_bytes(&bytes, path)?;
bytes.truncate(parsed.valid_len as usize);
Ok((parsed.events, bytes))
}
fn parse_log(path: &Path) -> Result<ParsedLog> {
Self::parse_log_file(crate::paths::open_read_nofollow(path)?, path)
}
fn parse_log_file(mut file: File, path: &Path) -> Result<ParsedLog> {
use std::io::Read;
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)?;
Self::parse_log_bytes(&bytes, path)
}
fn parse_log_bytes(bytes: &[u8], path: &Path) -> Result<ParsedLog> {
let identity = log_identity(path);
if identity.is_none() {
tracing::warn!(
path = %path.display(),
"event log read from a non-mission path: integrity verified by chain only"
);
}
let key = identity.and_then(|(root, _)| crate::paths::load_authority_key(root));
let seal_floor = identity
.and_then(|(root, mission)| crate::paths::read_seal_floor(root, mission))
.unwrap_or(u64::MAX);
let mut events = Vec::new();
let mut valid_len: usize = 0;
let mut terminated = true;
let mut offset: usize = 0;
let mut line_no: usize = 0;
let mut prev_hash = String::new();
let mut last_hash: Option<String> = None;
let mut saw_mac = false;
while offset < bytes.len() {
line_no += 1;
let rest = &bytes[offset..];
let (line_end, step) = match rest.iter().position(|&b| b == b'\n') {
Some(nl) => (nl, nl + 1),
None => (rest.len(), rest.len()),
};
let is_final = offset + step == bytes.len();
let line = String::from_utf8_lossy(&rest[..line_end]);
let mut value: serde_json::Value = match serde_json::from_str(&line) {
Ok(v) => v,
Err(err) => {
if is_final {
tracing::warn!(
path = %path.display(),
line = line_no,
error = %err,
"dropping unparseable final event line (torn write)"
);
break;
}
return Err(EngineError::LogCorruption(format!(
"unparseable event at {}:{}: {err}",
path.display(),
line_no
)));
}
};
let (presented_hash, presented_mac, version) = match value.as_object_mut() {
Some(object) => (
object
.remove(CHAIN_FIELD)
.and_then(|v| v.as_str().map(str::to_string)),
object
.remove(MAC_FIELD)
.and_then(|v| v.as_str().map(str::to_string)),
object.remove(VERSION_FIELD),
),
None => (None, None, None),
};
let version = match version {
None => 1,
Some(value) if value.as_u64() == Some(EXACT_FLOAT_VERSION) => EXACT_FLOAT_VERSION,
Some(_) => {
return Err(EngineError::LogCorruption(format!(
"unsupported integrity version at {}:{}",
path.display(),
line_no
)));
}
};
if version == 1 {
restore_legacy_float_parsing(&mut value)?;
}
let event: Event = match serde_json::from_value(value) {
Ok(e) => e,
Err(err) => {
if is_final {
tracing::warn!(
path = %path.display(),
line = line_no,
error = %err,
"dropping unparseable final event line (torn write)"
);
break;
}
return Err(EngineError::LogCorruption(format!(
"unparseable event at {}:{}: {err}",
path.display(),
line_no
)));
}
};
let expected = events.len() as u64 + 1;
if event.seq != expected {
return Err(EngineError::LogCorruption(format!(
"seq discontinuity at {}:{}: expected {expected}, found {}",
path.display(),
line_no,
event.seq
)));
}
if let Some(first_mission_id) = events.first().map(|e: &Event| &e.mission_id) {
if event.mission_id != *first_mission_id {
return Err(EngineError::LogCorruption(format!(
"mission_id mismatch at {}:{}: expected '{}' (from first event), found '{}'",
path.display(),
line_no,
first_mission_id,
event.mission_id
)));
}
}
match &presented_hash {
Some(hash) => {
let body = canonical_event_bytes(&event, version)?;
let expected_hash = chain_hash(&prev_hash, &body, version);
if expected_hash != *hash {
return Err(EngineError::LogCorruption(format!(
"integrity chain broken at {}:{}: the line does not hash to its recorded `h`",
path.display(),
line_no
)));
}
match (&key, &presented_mac) {
(Some(key), Some(mac)) => {
let expected_mac = crate::hooks::hmac_sha256_hex(key, hash.as_bytes());
if !bool::from(expected_mac.as_bytes().ct_eq(mac.as_bytes())) {
return Err(EngineError::LogCorruption(format!(
"integrity mac does not verify at {}:{}",
path.display(),
line_no
)));
}
}
(None, Some(_)) => {}
(_, None) if saw_mac || event.seq >= seal_floor => {
return Err(EngineError::LogCorruption(format!(
"integrity mac missing at {}:{}: this mission is sealed from seq {}, and earlier lines carry `m`",
path.display(),
line_no,
seal_floor
)));
}
(_, None) => {}
}
saw_mac |= presented_mac.is_some();
prev_hash = hash.clone();
last_hash = Some(hash.clone());
}
None if last_hash.is_some() => {
return Err(EngineError::LogCorruption(format!(
"integrity chain dropped at {}:{}: earlier lines carry `h`, so a line without one is a downgrade",
path.display(),
line_no
)));
}
None if event.seq >= seal_floor => {
return Err(EngineError::LogCorruption(format!(
"integrity chain missing at {}:{}: this mission is sealed from seq {seal_floor} on",
path.display(),
line_no
)));
}
None if version == EXACT_FLOAT_VERSION => {
return Err(EngineError::LogCorruption(format!(
"integrity chain missing at {}:{}: versioned lines must be sealed",
path.display(),
line_no
)));
}
None => {}
}
events.push(event);
offset += step;
valid_len = offset;
terminated = step > line_end;
}
Ok(ParsedLog {
events,
valid_len: valid_len as u64,
terminated,
last_hash,
saw_mac,
})
}
pub fn read_events_after(path: &Path, after_seq: u64) -> Result<Vec<Event>> {
let mut events = Self::read_events(path)?;
events.retain(|e| e.seq > after_seq);
Ok(events)
}
pub fn read_tail_events(path: &Path, max_bytes: u64) -> Result<Vec<Event>> {
use std::io::{Read, Seek, SeekFrom};
let mut file = crate::paths::open_read_nofollow(path)?;
let len = file.metadata()?.len();
let window_start = len.saturating_sub(max_bytes);
let start = window_start.saturating_sub(1);
file.seek(SeekFrom::Start(start))?;
let mut bytes = Vec::with_capacity((len - start) as usize);
file.read_to_end(&mut bytes)?;
let mut slice = bytes.as_slice();
if window_start > 0 {
match slice.iter().position(|&b| b == b'\n') {
Some(nl) => slice = &slice[nl + 1..],
None => return Ok(Vec::new()),
}
}
Ok(slice
.split(|&b| b == b'\n')
.filter(|line| !line.is_empty())
.filter_map(|line| {
let mut value: serde_json::Value =
serde_json::from_str(&String::from_utf8_lossy(line)).ok()?;
let object = value.as_object_mut()?;
let version = object.remove(VERSION_FIELD);
object.remove(CHAIN_FIELD);
object.remove(MAC_FIELD);
match version {
None => restore_legacy_float_parsing(&mut value).ok()?,
Some(version) if version.as_u64() == Some(EXACT_FLOAT_VERSION) => {}
Some(_) => return None,
}
serde_json::from_value(value).ok()
})
.collect())
}
}
impl Drop for EventLog {
fn drop(&mut self) {
if let Err(e) = self.flush() {
tracing::warn!(
error = %e,
retained = self.buffer.len(),
"failed to flush event buffer on drop; buffered deltas retained for a future drain"
);
}
let info = read_lock_info_at(&self.mission_dir, "events.jsonl.lock");
let generation_matches = info.generation.unwrap_or(0) == self.lock_generation;
let token_matches = match (&self.lock_token, &info.token) {
(Some(ours), Some(theirs)) => ours == theirs,
_ => true,
};
if generation_matches && token_matches {
if let Err(e) = self.mission_dir.remove_file("events.jsonl.lock") {
if e.kind() != ErrorKind::NotFound {
tracing::warn!(
path = %self.lock_path.display(),
error = %e,
"failed to remove lock file on drop"
);
}
}
}
}
}
fn steal_lock(
mission_dir: &Dir,
lock_name: &str,
lock_path: &Path,
force: LockForce,
) -> Result<(File, u64)> {
#[cfg(unix)]
let _guard = StealGuard::acquire(mission_dir, lock_name)?;
loop {
match open_create_new_at(mission_dir, lock_name) {
Ok(f) => return Ok((f, 0)),
Err(e) if e.kind() == ErrorKind::AlreadyExists => {}
Err(e) => return Err(e.into()),
}
let info = read_lock_info_at(mission_dir, lock_name);
authorize_steal(lock_path, &info, force)?;
let prev_gen = info.generation.unwrap_or(0);
match mission_dir.remove_file(lock_name) {
Ok(()) => {}
Err(e) if e.kind() == ErrorKind::NotFound => continue,
Err(e) => return Err(e.into()),
}
match open_create_new_at(mission_dir, lock_name) {
Ok(f) => return Ok((f, prev_gen)),
Err(e) if e.kind() == ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e.into()),
}
}
}
fn authorize_steal(lock_path: &Path, info: &LockInfo, force: LockForce) -> Result<()> {
match (probe_liveness(info), force) {
(LockLiveness::Dead, _) => {
tracing::warn!(
lock = %lock_path.display(),
holder = %info.holder,
"stale engine lock (holder is dead); taking over"
);
Ok(())
}
(LockLiveness::Unknown | LockLiveness::Alive, LockForce::No) => {
Err(EngineError::LockHeld(format!(
"lock file {} exists (held by pid {}); if that process \
is truly gone, re-run with --force-lock",
lock_path.display(),
info.holder
)))
}
(LockLiveness::Unknown, LockForce::IfNotLive | LockForce::EvenIfLive) => {
tracing::warn!(
lock = %lock_path.display(),
holder = %info.holder,
"forced takeover of a lock whose holder's liveness \
cannot be determined"
);
Ok(())
}
(LockLiveness::Alive, LockForce::IfNotLive) => Err(EngineError::LockHeld(format!(
"lock file {} is held by pid {}, and that process is \
ALIVE — refusing --force-lock. Identify it with \
`ps -p {}`; pass --dangerously-steal-live-lock ONLY \
if you are certain it is a zombie or foreign process \
and not a running kranz engine (two engines on one \
mission corrupt its event log)",
lock_path.display(),
info.holder,
info.holder
))),
(LockLiveness::Alive, LockForce::EvenIfLive) => {
tracing::warn!(
lock = %lock_path.display(),
holder = %info.holder,
"DANGEROUS: stealing the mission lock from a LIVE \
process at operator request \
(--dangerously-steal-live-lock); if that process is \
a kranz engine, two engines now write one event log"
);
Ok(())
}
}
}
#[cfg(unix)]
struct StealGuard {
_file: File,
dir: Dir,
name: OsString,
}
#[cfg(unix)]
impl StealGuard {
fn acquire(dir: &Dir, lock_name: &str) -> Result<StealGuard> {
use std::os::unix::io::AsRawFd;
let mut name = OsString::from(lock_name);
name.push(".steal");
loop {
use cap_fs_ext::OpenOptionsExt as _;
use cap_fs_ext::OpenOptionsFollowExt as _;
use cap_primitives::fs::FollowSymlinks;
let mut options = cap_std::fs::OpenOptions::new();
options
.write(true)
.truncate(false)
.follow(FollowSymlinks::No);
options.custom_flags(libc::O_NONBLOCK);
let file = match dir.open_with(&name, &options) {
Ok(file) => file.into_std(),
Err(error) if error.kind() == ErrorKind::NotFound => {
let mut create = cap_std::fs::OpenOptions::new();
create
.write(true)
.create_new(true)
.follow(FollowSymlinks::No);
create.custom_flags(libc::O_NONBLOCK);
match dir.open_with(&name, &create) {
Ok(file) => file.into_std(),
Err(error) if error.kind() == ErrorKind::AlreadyExists => continue,
Err(error) => return Err(error.into()),
}
}
Err(error) => return Err(error.into()),
};
if !file.metadata()?.is_file() {
return Err(EngineError::InvalidState(format!(
"event-log steal guard {} is not a regular file",
name.to_string_lossy()
)));
}
loop {
if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } == 0 {
break;
}
let err = std::io::Error::last_os_error();
if err.raw_os_error() != Some(libc::EINTR) {
return Err(err.into());
}
}
let held = file.metadata()?;
match dir.symlink_metadata(&name) {
Ok(m)
if cap_fs_ext::MetadataExt::dev(&m)
== std::os::unix::fs::MetadataExt::dev(&held)
&& cap_fs_ext::MetadataExt::ino(&m)
== std::os::unix::fs::MetadataExt::ino(&held) =>
{
return Ok(StealGuard {
_file: file,
dir: dir.try_clone()?,
name,
});
}
_ => continue,
}
}
}
}
#[cfg(unix)]
impl Drop for StealGuard {
fn drop(&mut self) {
let _ = self.dir.remove_file(&self.name);
}
}
pub fn lock_holder_is_alive(lock_path: &Path) -> bool {
if !std::fs::symlink_metadata(lock_path).is_ok_and(|m| m.file_type().is_file()) {
return false;
}
let info = read_lock_info(lock_path);
match probe_liveness(&info) {
LockLiveness::Dead => false,
LockLiveness::Alive | LockLiveness::Unknown => true,
}
}
pub fn current_lock_holder_record() -> String {
current_lock_holder_record_with_generation(0)
}
fn current_lock_holder_record_with_generation(generation: u64) -> String {
let acquired_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let mut contents = format!("{}\n{}\n", std::process::id(), acquired_secs);
if let Some(token) = process_identity_token(std::process::id() as i32) {
contents.push_str(&token);
contents.push('\n');
} else {
contents.push('\n');
}
contents.push_str(&format!("{generation}\n"));
contents
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LockLiveness {
Dead,
Alive,
Unknown,
}
#[derive(Debug)]
struct LockInfo {
holder: String,
pid: Option<i32>,
acquired_secs: Option<u64>,
token: Option<String>,
generation: Option<u64>,
}
fn read_lock_info(lock_path: &Path) -> LockInfo {
use std::io::Read;
let mut contents = String::new();
if let Ok(file) = crate::paths::open_read_nofollow(lock_path) {
let _ = file.take(8 * 1024).read_to_string(&mut contents);
}
parse_lock_info(&contents)
}
fn read_lock_info_at(dir: &Dir, name: &str) -> LockInfo {
use std::io::Read;
let mut contents = String::new();
if let Ok(file) = open_read_at(dir, name) {
let _ = file.take(8 * 1024).read_to_string(&mut contents);
}
parse_lock_info(&contents)
}
fn parse_lock_info(contents: &str) -> LockInfo {
let mut lines = contents.lines();
let first = lines.next().unwrap_or("").trim();
let holder = if first.is_empty() {
"unknown".to_string()
} else {
first.to_string()
};
let pid = first.parse::<i32>().ok().filter(|p| *p > 0);
let acquired_secs = lines.next().and_then(|l| l.trim().parse::<u64>().ok());
let token = lines
.next()
.map(str::trim)
.filter(|t| !t.is_empty())
.map(String::from);
let generation = lines.next().and_then(|l| l.trim().parse::<u64>().ok());
LockInfo {
holder,
pid,
acquired_secs,
token,
generation,
}
}
fn probe_liveness(info: &LockInfo) -> LockLiveness {
let Some(pid) = info.pid else {
return LockLiveness::Unknown;
};
if pid as u32 == std::process::id() {
return alive_or_reused(pid, info);
}
#[cfg(unix)]
{
if unsafe { libc::kill(pid, 0) } == 0 {
return alive_or_reused(pid, info);
}
match std::io::Error::last_os_error().raw_os_error() {
Some(libc::EPERM) => alive_or_reused(pid, info),
Some(libc::ESRCH) => LockLiveness::Dead,
_ => LockLiveness::Unknown,
}
}
#[cfg(not(unix))]
{
tracing::debug!(
pid,
"liveness cannot be proven for a foreign pid on this platform; \
reporting Unknown (Dead is unreachable here)"
);
non_unix_liveness_fallback()
}
}
#[cfg_attr(unix, allow(dead_code))]
fn non_unix_liveness_fallback() -> LockLiveness {
LockLiveness::Unknown
}
fn alive_or_reused(pid: i32, info: &LockInfo) -> LockLiveness {
let Some(recorded) = info.token.as_deref() else {
return LockLiveness::Alive;
};
let Some(current) = process_identity_token(pid) else {
return LockLiveness::Alive;
};
if current == recorded {
LockLiveness::Alive
} else {
tracing::warn!(
pid,
recorded_token = recorded,
current_token = %current,
lock_acquired_epoch_secs = ?info.acquired_secs,
"lock holder pid was REUSED: the process now at this pid is not \
the one that recorded the lock, so the engine that wrote the \
lock is dead"
);
LockLiveness::Dead
}
}
#[cfg(target_os = "linux")]
pub(crate) fn process_identity_token(pid: i32) -> Option<String> {
let boot_id = std::fs::read_to_string("/proc/sys/kernel/random/boot_id").ok()?;
let boot_id = boot_id.trim();
if boot_id.is_empty() {
return None;
}
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
let rest = stat.get(stat.rfind(')')? + 1..)?;
let start_ticks = rest.split_whitespace().nth(19)?;
start_ticks.parse::<u64>().ok()?; Some(format!("{boot_id}:{start_ticks}"))
}
#[cfg(target_os = "macos")]
fn ps_identity_token(pid: i32) -> Option<String> {
#[cfg(test)]
{
*PS_SPAWN_COUNTS
.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
.lock()
.unwrap()
.entry(pid)
.or_insert(0) += 1;
}
let out = std::process::Command::new("ps")
.env("LC_ALL", "C")
.env("TZ", "UTC")
.args(["-p", &pid.to_string(), "-o", "lstart="])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let token = String::from_utf8_lossy(&out.stdout).trim().to_string();
if token.is_empty() {
None
} else {
Some(token)
}
}
#[cfg(all(test, target_os = "macos"))]
static PS_SPAWN_COUNTS: std::sync::OnceLock<
std::sync::Mutex<std::collections::HashMap<i32, usize>>,
> = std::sync::OnceLock::new();
#[cfg(target_os = "macos")]
const IDENTITY_TOKEN_CACHE_TTL: Duration = Duration::from_millis(50);
#[cfg(target_os = "macos")]
type IdentityTokenCache =
std::sync::Mutex<std::collections::HashMap<i32, (Option<String>, Instant)>>;
#[cfg(target_os = "macos")]
static IDENTITY_TOKEN_CACHE: std::sync::OnceLock<IdentityTokenCache> = std::sync::OnceLock::new();
#[cfg(target_os = "macos")]
fn proc_pidinfo_identity_token(pid: i32) -> Option<String> {
let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() };
let rc = unsafe {
libc::proc_pidinfo(
pid,
libc::PROC_PIDTBSDINFO,
0,
&mut info as *mut libc::proc_bsdinfo as *mut libc::c_void,
std::mem::size_of::<libc::proc_bsdinfo>() as i32,
)
};
if rc <= 0 {
return None;
}
let secs = i64::try_from(info.pbi_start_tvsec).ok()?;
let rendered = chrono::DateTime::from_timestamp(secs, 0)?
.format("%a %b %e %H:%M:%S %Y")
.to_string();
if rendered.is_empty() {
None
} else {
Some(rendered)
}
}
#[cfg(target_os = "macos")]
fn uncached_identity_token(pid: i32) -> Option<String> {
if let Some(token) = proc_pidinfo_identity_token(pid) {
return Some(token);
}
ps_identity_token(pid)
}
#[cfg(target_os = "macos")]
pub(crate) fn process_identity_token(pid: i32) -> Option<String> {
let cache = IDENTITY_TOKEN_CACHE
.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()));
let now = Instant::now();
if let Some((token, captured)) = cache.lock().unwrap().get(&pid) {
if now.duration_since(*captured) < IDENTITY_TOKEN_CACHE_TTL {
return token.clone();
}
}
let identity = uncached_identity_token(pid);
cache.lock().unwrap().insert(pid, (identity.clone(), now));
identity
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
pub(crate) fn process_identity_token(_pid: i32) -> Option<String> {
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn legacy_float_parser_matches_independent_reference() {
let reference: serde_json::Value =
serde_json::from_str(include_str!("../tests/fixtures/legacy-json-floats.json"))
.unwrap();
let cases = reference["cases"].as_array().unwrap();
assert_eq!(cases.len(), 96);
for case in cases {
let number: serde_json::Number =
serde_json::from_str(case["json"].as_str().unwrap()).unwrap();
let actual = legacy_float_number(&number)
.and_then(|n| n.as_f64())
.map(f64::to_bits);
let expected = case["expected_bits"]
.as_str()
.map(|bits| bits.parse::<u64>().unwrap());
assert_eq!(actual, expected, "reference: {case}");
}
}
#[test]
fn versioned_canonical_bytes_sort_nested_objects() {
let event = Event {
seq: 1,
ts: "2026-09-05T00:00:00Z".parse().unwrap(),
mission_id: "m-canonical".into(),
kind: EventKind::ConfigChanged {
patch: serde_json::json!({"z": 1, "a": [{"d": 2, "b": 3}]}),
},
};
assert_eq!(
canonical_event_bytes(&event, EXACT_FLOAT_VERSION).unwrap(),
r#"{"missionId":"m-canonical","payload":{"patch":{"a":[{"b":3,"d":2}],"z":1}},"seq":1,"ts":"2026-09-05T00:00:00Z","type":"config.changed"}"#
);
}
#[test]
fn non_unix_liveness_fallback_is_never_dead() {
assert_eq!(non_unix_liveness_fallback(), LockLiveness::Unknown);
assert_ne!(non_unix_liveness_fallback(), LockLiveness::Dead);
}
#[cfg_attr(not(unix), allow(dead_code))]
fn one_event_line() -> String {
let event = Event {
seq: 1,
ts: Utc::now(),
mission_id: "m-1".to_string(),
kind: EventKind::MissionCreated {
goal: "goal".into(),
base_branch: "main".into(),
mission_branch: "kranz/mission-m-1".into(),
config: crate::types::MissionConfig::default(),
},
};
let mut line = serde_json::to_string(&event).unwrap();
line.push('\n');
line
}
#[cfg(unix)]
#[test]
fn read_events_refuses_a_symlinked_log() {
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("target.jsonl");
std::fs::write(&target, one_event_line()).unwrap();
let link = dir.path().join("events.jsonl");
symlink(&target, &link).unwrap();
let err = EventLog::read_events(&link).unwrap_err();
assert!(err.to_string().contains("refusing"), "{err}");
}
#[cfg(unix)]
#[test]
fn acquire_refuses_symlinked_runtime_files_without_writing_through() {
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir().unwrap();
let paths = MissionPaths::new(dir.path(), "m-1");
std::fs::create_dir_all(paths.mission_dir()).unwrap();
let elsewhere = tempfile::tempdir().unwrap();
let target = elsewhere.path().join("elsewhere.jsonl");
std::fs::write(&target, b"").unwrap();
symlink(&target, paths.events_file()).unwrap();
let err = EventLog::acquire(&paths, "m-1", Duration::ZERO, LockForce::No).unwrap_err();
assert!(err.to_string().contains("refusing"), "{err}");
assert_eq!(std::fs::read(&target).unwrap(), b"");
assert!(!paths.lock_file().exists(), "no lock taken on refusal");
std::fs::remove_file(paths.events_file()).unwrap();
symlink(&target, paths.lock_file()).unwrap();
let err = EventLog::acquire(&paths, "m-1", Duration::ZERO, LockForce::No).unwrap_err();
assert!(err.to_string().contains("refusing"), "{err}");
assert_eq!(std::fs::read(&target).unwrap(), b"");
}
#[cfg(unix)]
#[test]
fn acquired_log_retains_mission_capability_across_parent_swap() {
use std::os::unix::fs::symlink;
let repo = tempfile::tempdir().unwrap();
let paths = MissionPaths::new(repo.path(), "m-1");
let mut log = EventLog::acquire(&paths, "m-1", Duration::ZERO, LockForce::No).unwrap();
let original = paths.missions_dir().join("m-original");
std::fs::rename(paths.mission_dir(), &original).unwrap();
let outside = tempfile::tempdir().unwrap();
std::fs::write(outside.path().join("events.jsonl.lock"), "outside-lock").unwrap();
std::fs::write(outside.path().join("events.jsonl"), "outside-events").unwrap();
symlink(outside.path(), paths.mission_dir()).unwrap();
log.append(EventKind::MissionPaused {}).unwrap();
drop(log);
assert!(
std::fs::read_to_string(original.join("events.jsonl"))
.unwrap()
.contains("mission.paused"),
"the retained append handle must stay on the originally pinned mission"
);
assert!(
!original.join("events.jsonl.lock").exists(),
"drop must remove the lock relative to the retained capability"
);
assert_eq!(
std::fs::read_to_string(outside.path().join("events.jsonl")).unwrap(),
"outside-events"
);
assert_eq!(
std::fs::read_to_string(outside.path().join("events.jsonl.lock")).unwrap(),
"outside-lock"
);
}
#[test]
fn lock_info_parses_all_formats() {
let dir = tempfile::tempdir().unwrap();
let lock = dir.path().join("l");
std::fs::write(&lock, "1234\n1700000000\nabcd-boot-id:5678\n").unwrap();
let info = read_lock_info(&lock);
assert_eq!(info.holder, "1234");
assert_eq!(info.pid, Some(1234));
assert_eq!(info.acquired_secs, Some(1_700_000_000));
assert_eq!(info.token.as_deref(), Some("abcd-boot-id:5678"));
std::fs::write(&lock, "1234\n1700000000\n").unwrap();
let info = read_lock_info(&lock);
assert_eq!(info.pid, Some(1234));
assert_eq!(info.acquired_secs, Some(1_700_000_000));
assert_eq!(info.token, None);
std::fs::write(&lock, "1234\n1700000000\n\n").unwrap();
assert_eq!(read_lock_info(&lock).token, None);
std::fs::write(&lock, "1234").unwrap();
let info = read_lock_info(&lock);
assert_eq!(info.pid, Some(1234));
assert_eq!(info.acquired_secs, None);
assert_eq!(info.token, None);
std::fs::write(&lock, "not-a-pid\nnot-a-time").unwrap();
let info = read_lock_info(&lock);
assert_eq!(info.holder, "not-a-pid");
assert_eq!(info.pid, None);
assert_eq!(info.acquired_secs, None);
assert_eq!(info.token, None);
std::fs::write(&lock, "-4\n1700000000").unwrap();
assert_eq!(read_lock_info(&lock).pid, None);
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn identity_token_is_stable_for_a_live_process() {
let pid = std::process::id() as i32;
let a = process_identity_token(pid).expect("own token must be obtainable");
let b = process_identity_token(pid).expect("own token must be obtainable");
assert_eq!(a, b, "token readings of the same live process must match");
assert!(
!a.is_empty() && !a.contains('\n'),
"token must be a single non-empty line: {a:?}"
);
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn identity_token_of_a_dead_pid_is_none() {
assert_eq!(process_identity_token(i32::MAX), None);
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn own_pid_reuse_is_decided_by_token_equality() {
let pid = std::process::id() as i32;
let own = process_identity_token(pid).expect("own token must be obtainable");
let info = LockInfo {
holder: pid.to_string(),
pid: Some(pid),
acquired_secs: Some(0),
token: Some(own.clone()),
generation: None,
};
assert_eq!(probe_liveness(&info), LockLiveness::Alive);
let info = LockInfo {
token: Some(format!("{own}-not")),
..info
};
assert_eq!(probe_liveness(&info), LockLiveness::Dead);
}
#[cfg(target_os = "macos")]
#[test]
fn macos_identity_token_caches_one_ps_per_pid() {
let pid = 1;
if ps_identity_token(pid).is_none() {
eprintln!(
"SKIP-UNDER-WRAP (gate-sandbox-supervision-dogfood): \
macos_identity_token_caches_one_ps_per_pid — the setuid /bin/ps cannot \
execute inside the gate sandbox wrap, so pid 1's token is unreadable here; \
skipping"
);
return;
}
let count_for_pid = |p: i32| {
*PS_SPAWN_COUNTS
.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
.lock()
.unwrap()
.get(&p)
.unwrap_or(&0)
};
let before = count_for_pid(pid);
let a = process_identity_token(pid).expect("own token must be obtainable");
let b = process_identity_token(pid).expect("own token must be obtainable");
let after = count_for_pid(pid);
assert_eq!(
after - before,
1,
"second call within the cache window must not spawn ps again"
);
assert_eq!(a, b, "cached token must match the freshly spawned one");
}
#[cfg(target_os = "macos")]
#[test]
fn macos_cache_never_masks_pid_reuse() {
let pid = std::process::id() as i32;
let own = process_identity_token(pid).expect("own token must be obtainable");
let info = LockInfo {
holder: pid.to_string(),
pid: Some(pid),
acquired_secs: Some(0),
token: Some(format!("{own}-not")),
generation: None,
};
assert_eq!(probe_liveness(&info), LockLiveness::Dead);
}
struct FlakyWriter {
fail_at: usize,
writes: Vec<String>,
}
impl std::io::Write for FlakyWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
if self.writes.len() >= self.fail_at {
return Err(std::io::Error::other("simulated write failure"));
}
self.writes.push(String::from_utf8_lossy(buf).into_owned());
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
fn buffered(n: usize) -> Vec<BufferedLine> {
(0..n)
.map(|i| BufferedLine {
buffered_at: Instant::now(),
line: format!("line-{i}\n"),
})
.collect()
}
#[test]
fn drain_retains_unwritten_deltas_on_write_failure() {
let k = 3;
let n = 7;
let mut writer = FlakyWriter {
fail_at: k,
writes: Vec::new(),
};
let mut buffer = buffered(n);
let result = drain_lines(&mut writer, &mut buffer);
assert!(result.is_err(), "drain must surface the write error");
assert_eq!(
writer.writes,
(0..k).map(|i| format!("line-{i}\n")).collect::<Vec<_>>(),
"exactly the first k lines must have been written, in order"
);
assert_eq!(
buffer.iter().map(|b| b.line.clone()).collect::<Vec<_>>(),
(k..n).map(|i| format!("line-{i}\n")).collect::<Vec<_>>(),
"the remaining lines, including the one that failed, must stay buffered in order"
);
let mut retry_writer = FlakyWriter {
fail_at: usize::MAX,
writes: Vec::new(),
};
let retry_result = drain_lines(&mut retry_writer, &mut buffer);
assert!(retry_result.is_ok());
assert!(buffer.is_empty());
assert_eq!(
retry_writer.writes,
(k..n).map(|i| format!("line-{i}\n")).collect::<Vec<_>>()
);
}
fn event_line(seq: u64, mission_id: &str) -> String {
let event = Event {
seq,
ts: Utc::now(),
mission_id: mission_id.to_string(),
kind: EventKind::MissionPaused {},
};
let mut line = serde_json::to_string(&event).unwrap();
line.push('\n');
line
}
#[test]
fn acquire_rejects_foreign_mission_id_in_later_event() {
let dir = tempfile::tempdir().unwrap();
let paths = MissionPaths::new(dir.path(), "m-a");
std::fs::create_dir_all(paths.mission_dir()).unwrap();
let events_path = paths.events_file();
std::fs::write(
&events_path,
format!("{}{}", event_line(1, "m-a"), event_line(2, "m-b")),
)
.unwrap();
let err = EventLog::acquire(&paths, "m-a", Duration::from_secs(1), LockForce::No)
.expect_err("mixed mission_id log must be rejected");
assert!(
matches!(err, EngineError::LogCorruption(_)),
"expected LogCorruption, got {err:?}"
);
let dir2 = tempfile::tempdir().unwrap();
let paths2 = MissionPaths::new(dir2.path(), "m-a");
std::fs::create_dir_all(paths2.mission_dir()).unwrap();
std::fs::write(
paths2.events_file(),
format!("{}{}", event_line(1, "m-a"), event_line(2, "m-a")),
)
.unwrap();
let log = EventLog::acquire(&paths2, "m-a", Duration::from_secs(1), LockForce::No)
.expect("consistent-mission log must acquire cleanly");
assert_eq!(log.last_seq(), 2);
}
#[test]
fn parse_log_rejects_mixed_mission_ids() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("events.jsonl");
std::fs::write(
&path,
format!("{}{}", event_line(1, "m-a"), event_line(2, "m-b")),
)
.unwrap();
let err = EventLog::read_events(&path).expect_err("mixed mission_id must be rejected");
assert!(
matches!(err, EngineError::LogCorruption(_)),
"expected LogCorruption, got {err:?}"
);
}
#[test]
fn acquire_adopts_empty_preexisting_log_as_fresh() {
let dir = tempfile::tempdir().unwrap();
let paths = MissionPaths::new(dir.path(), "m-a");
std::fs::create_dir_all(paths.mission_dir()).unwrap();
std::fs::write(paths.events_file(), "").unwrap();
let log = EventLog::acquire(&paths, "m-a", Duration::from_secs(1), LockForce::No)
.expect("empty pre-existing log must be adopted as fresh");
assert_eq!(log.last_seq(), 0);
let appended = {
let mut log = log;
log.append(EventKind::MissionPaused {}).unwrap()
};
assert_eq!(appended.seq, 1);
}
}