Harden event log validation

This commit is contained in:
2026-05-25 13:13:14 -04:00
parent 52d5c65290
commit 03eb1f82b3
+129 -25
View File
@@ -21,6 +21,7 @@ pub enum EventType {
} }
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EventRecord { pub struct EventRecord {
pub schema_version: u16, pub schema_version: u16,
pub sequence: u64, pub sequence: u64,
@@ -49,31 +50,7 @@ impl EventLog {
})?; })?;
} }
let mut next_sequence = 1; let (next_sequence, previous_hash) = load_tail(&path)?;
let mut previous_hash = None;
if path.exists() {
let file =
File::open(&path).with_context(|| format!("open event log {}", path.display()))?;
for (line_index, line) in BufReader::new(file).lines().enumerate() {
let line_number = line_index + 1;
let line = line.with_context(|| {
format!("read event log line {line_number} from {}", path.display())
})?;
if line.trim().is_empty() {
continue;
}
let record: EventRecord = serde_json::from_str(&line).with_context(|| {
format!("parse event log line {line_number} from {}", path.display())
})?;
validate_record(&record, next_sequence, previous_hash.as_deref())
.map_err(|err| anyhow!("validate event log line {line_number}: {err}"))?;
next_sequence += 1;
previous_hash = Some(record.hash);
}
}
Ok(Self { Ok(Self {
path, path,
@@ -98,6 +75,10 @@ impl EventLog {
})?; })?;
} }
let (next_sequence, previous_hash) = load_tail(&self.path)?;
self.next_sequence = next_sequence;
self.previous_hash = previous_hash;
let mut record = EventRecord { let mut record = EventRecord {
schema_version: POLICY_SCHEMA_VERSION, schema_version: POLICY_SCHEMA_VERSION,
sequence: self.next_sequence, sequence: self.next_sequence,
@@ -130,11 +111,49 @@ impl EventLog {
} }
} }
fn load_tail(path: &Path) -> Result<(u64, Option<String>)> {
let mut next_sequence = 1;
let mut previous_hash = None;
if path.exists() {
let file =
File::open(path).with_context(|| format!("open event log {}", path.display()))?;
for (line_index, line) in BufReader::new(file).lines().enumerate() {
let line_number = line_index + 1;
let line = line.with_context(|| {
format!("read event log line {line_number} from {}", path.display())
})?;
if line.trim().is_empty() {
continue;
}
let record: EventRecord = serde_json::from_str(&line).with_context(|| {
format!("parse event log line {line_number} from {}", path.display())
})?;
validate_record(&record, next_sequence, previous_hash.as_deref())
.map_err(|err| anyhow!("validate event log line {line_number}: {err}"))?;
next_sequence += 1;
previous_hash = Some(record.hash);
}
}
Ok((next_sequence, previous_hash))
}
fn validate_record( fn validate_record(
record: &EventRecord, record: &EventRecord,
expected_sequence: u64, expected_sequence: u64,
expected_previous_hash: Option<&str>, expected_previous_hash: Option<&str>,
) -> Result<()> { ) -> Result<()> {
if record.schema_version != POLICY_SCHEMA_VERSION {
return Err(anyhow!(
"unsupported schema version at sequence {}: expected {}, found {}",
record.sequence,
POLICY_SCHEMA_VERSION,
record.schema_version
));
}
if record.sequence != expected_sequence { if record.sequence != expected_sequence {
return Err(anyhow!( return Err(anyhow!(
"sequence mismatch: expected {}, found {}", "sequence mismatch: expected {}, found {}",
@@ -285,4 +304,89 @@ mod tests {
assert!(err.to_string().contains("sequence mismatch")); assert!(err.to_string().contains("sequence mismatch"));
fs::remove_file(path).unwrap(); fs::remove_file(path).unwrap();
} }
#[test]
fn opening_log_with_unknown_record_field_fails() {
let path = temp_log_path("unknown-field");
{
let mut log = EventLog::open(&path).unwrap();
log.append(
EventType::RuntimeTransition,
RuntimeState::Transition,
None,
json!({"from": "active"}),
)
.unwrap();
}
let contents = fs::read_to_string(&path).unwrap();
let mut record_json: Value =
serde_json::from_str(contents.lines().next().unwrap()).unwrap();
record_json["unexpected_field"] = json!("ignored-before-hashing");
fs::write(
&path,
format!("{}\n", serde_json::to_string(&record_json).unwrap()),
)
.unwrap();
let err = EventLog::open(&path).expect_err("unknown record fields must fail");
assert!(err.to_string().contains("parse event log"));
fs::remove_file(path).unwrap();
}
#[test]
fn opening_log_with_unsupported_schema_version_fails() {
let path = temp_log_path("unsupported-schema");
let mut record = EventRecord {
schema_version: 999,
sequence: 1,
timestamp_unix_secs: unix_secs_now(),
event_type: EventType::RuntimeTransition,
commitment_id: None,
runtime_state: RuntimeState::Transition,
payload_json: json!({"from": "active"}),
previous_hash: None,
hash: String::new(),
};
record.hash = record_hash(&record).unwrap();
fs::write(
&path,
format!("{}\n", serde_json::to_string(&record).unwrap()),
)
.unwrap();
let err = EventLog::open(&path).expect_err("unsupported schema version must fail");
assert!(err.to_string().contains("unsupported schema version"));
fs::remove_file(path).unwrap();
}
#[test]
fn stale_handle_reloads_tail_before_append() {
let path = temp_log_path("stale-handle");
let mut log1 = EventLog::open(&path).unwrap();
let mut log2 = EventLog::open(&path).unwrap();
let first = log1
.append(
EventType::RuntimeTransition,
RuntimeState::Transition,
None,
json!({"from": "active"}),
)
.unwrap();
let second = log2
.append(
EventType::PolicyApplied,
RuntimeState::Active,
None,
json!({"policy_id": "policy-1"}),
)
.unwrap();
assert_eq!(second.sequence, 2);
assert_eq!(second.previous_hash.as_deref(), Some(first.hash.as_str()));
EventLog::open(&path).unwrap();
fs::remove_file(path).unwrap();
}
} }