Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 85 additions & 21 deletions crates/consolebook-server/src/packet_verify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -536,12 +536,27 @@ fn check_signoffs(path: &str, signoffs: &[SignoffDoc]) -> Vec<Finding> {
/// The enrollment's pin history, from the manifest's current pin and the
/// lifecycle events' version changes: every version the enrollment ever
/// pinned, labelled as the packet labels it; the original pin; and the
/// version each version change reached, its epoch.
/// version each version change reached, with its epoch's time boundaries.
struct PinHistory {
labels: BTreeMap<i64, String>,
original: i64,
/// A version change's `event_id` → the version it reached.
epochs: BTreeMap<i64, i64>,
original: PinEpoch,
/// A version change's `event_id` → the epoch it opened.
epochs: BTreeMap<i64, PinEpoch>,
}

struct PinEpoch {
version: i64,
opened_at: Option<i64>,
closed_at: Option<i64>,
}

impl PinEpoch {
/// Unix seconds cannot order a change and an act within that second.
/// Both endpoints therefore belong to the epoch, even when equal.
fn includes(&self, instant: i64) -> bool {
self.opened_at.is_none_or(|opened| instant >= opened)
&& self.closed_at.is_none_or(|closed| instant <= closed)
}
}

impl PinHistory {
Expand Down Expand Up @@ -575,7 +590,7 @@ impl PinHistory {
let mut epochs = BTreeMap::new();
let mut findings = Vec::new();
let mut pinned = original;
for (event, from, to) in &changes {
for (index, (event, from, to)) in changes.iter().enumerate() {
let id = event.event_id;
if from.version_number != pinned {
findings.push(off_history(
Expand All @@ -601,7 +616,23 @@ impl PinHistory {
}
}
}
epochs.insert(id, to.version_number);
let closed_at = changes.get(index + 1).map(|(next, _, _)| next.occurred_at);
if closed_at.is_some_and(|closed| closed < event.occurred_at) {
findings.push(off_history(
path,
format!(
"version change {id} occurs after the next version change in recorded order"
),
));
}
epochs.insert(
id,
PinEpoch {
version: to.version_number,
opened_at: Some(event.occurred_at),
closed_at,
},
);
pinned = to.version_number;
}
if pinned != current.version_number {
Expand All @@ -616,7 +647,11 @@ impl PinHistory {
(
Self {
labels,
original,
original: PinEpoch {
version: original,
opened_at: None,
closed_at: changes.first().map(|(event, _, _)| event.occurred_at),
},
epochs,
},
findings,
Expand Down Expand Up @@ -649,18 +684,26 @@ impl PinHistory {
signoffs
.iter()
.filter_map(|signoff| {
self.check_version(
path,
&format!("signoff {}", signoff.signoff_id),
&signoff.program_version,
)
let who = format!("signoff {}", signoff.signoff_id);
if let Some(finding) = self.check_version(path, &who, &signoff.program_version) {
return Some(finding);
}
let named = signoff.program_version.version_number;
let pinned = std::iter::once(&self.original)
.chain(self.epochs.values())
.any(|epoch| epoch.version == named && epoch.includes(signoff.signed_at));
(!pinned).then(|| off_history(path, format!(
"{who} names program version {named}, which was not pinned at signed_at {}",
signoff.signed_at,
)))
})
.collect()
}

/// Every phase event names a pinned version, and the version its
/// epoch reached: the original pin under `null`, otherwise the
/// version the named version change reached.
/// version the named version change reached. Effective and recorded times
/// cannot predate the opening; recording cannot postdate the closing.
fn check_phase_events(&self, path: &str, enrollment: &EnrollmentDocument) -> Vec<Finding> {
enrollment
.phase_events
Expand All @@ -671,31 +714,52 @@ impl PinHistory {
if let Some(finding) = self.check_version(path, &who, &event.program_version) {
return Some(finding);
}
match event.version_change_event_id {
None if named != self.original => Some(off_history(
let epoch = match event.version_change_event_id {
None if named != self.original.version => return Some(off_history(
path,
format!(
"{who} is recorded under the original pin, but names version {named} rather than version {}",
self.original
self.original.version
),
)),
None => None,
None => &self.original,
Some(epoch) => match self.epochs.get(&epoch) {
None => Some(off_history(
None => return Some(off_history(
path,
format!(
"{who} names version change {epoch} as its epoch, which the history does not record"
),
)),
Some(reached) if *reached != named => Some(off_history(
Some(reached) if reached.version != named => return Some(off_history(
path,
format!(
"{who} names version {named} under the epoch that reached version {reached}"
"{who} names version {named} under the epoch that reached version {}",
reached.version,
),
)),
Some(_) => None,
Some(epoch) => epoch,
},
};
if let Some(opened) = epoch.opened_at {
if event.effective_at < opened {
return Some(off_history(path, format!(
"{who} takes effect at {}, before its epoch opened at {opened}",
event.effective_at,
)));
}
if event.recorded_at < opened {
return Some(off_history(path, format!(
"{who} was recorded at {}, before its epoch opened at {opened}",
event.recorded_at,
)));
}
}
epoch.closed_at.filter(|&closed| event.recorded_at > closed).map(|closed| {
off_history(path, format!(
"{who} was recorded at {}, after its epoch closed at {closed}",
event.recorded_at,
))
})
})
.collect()
}
Expand Down
165 changes: 3 additions & 162 deletions crates/consolebook-server/tests/trainee_packet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions};
use tower::ServiceExt;
use zip::write::{SimpleFileOptions, ZipWriter};

#[path = "trainee_packet/pin_history.rs"]
mod pin_history;

const PASSWORD: &str = "invented-passphrase-1";

/// 2026-09-01T19:00:00Z.
Expand Down Expand Up @@ -1257,27 +1260,6 @@ fn phase_event(
})
}

/// One version-change event as a forger would write it.
fn version_change(event_id: i64, from: (i64, &str), to: (i64, &str)) -> serde_json::Value {
serde_json::json!({
"actor": null,
"event_id": event_id,
"from_version": {"label": from.1, "version_number": from.0},
"kind": "version_change",
"occurred_at": 1_780_000_000,
"reason": "Invented version change.",
"to_version": {"label": to.1, "version_number": to.0},
})
}

/// A phase event under a named epoch, naming a version.
fn epoch_phase_event(event_id: i64, epoch: Option<i64>, version: (i64, &str)) -> serde_json::Value {
let mut event = phase_event("advance", None, Some("Phase One"), 10, 10, event_id);
event["program_version"] = serde_json::json!({"label": version.1, "version_number": version.0});
event["version_change_event_id"] = serde_json::json!(epoch);
event
}

/// The verifier holds every document to the order and the cross-member
/// rules the format mandates — the stored tables' own constraints — not
/// only to member types: a forger who keeps every member well-typed and
Expand Down Expand Up @@ -1749,144 +1731,3 @@ async fn a_packet_never_holds_a_connection_while_waiting_for_one() {
.expect("permitted");
assert_eq!(packet.bytes, reference.bytes);
}

/// The lifecycle events define the enrollment's pin history, and every
/// program version the packet names belongs to it: the verifier refuses
/// a version the enrollment never pinned, a label that disagrees, a
/// version change that leaves a version other than the one pinned, a
/// history ending elsewhere than the manifest's pin, and a phase event
/// naming a version its epoch did not reach.
#[tokio::test]
#[allow(clippy::too_many_lines)]
async fn packets_agree_with_their_pin_history() {
let fx = Fixture::new().await;
let s = seed(&fx, "pins").await;
let original = pack(&fx, s.casey_id, s.enrollment_id).await;
let listed = entries(&original);
let enrollment = DocumentKind::Enrollment;
let signoffs = DocumentKind::Signoffs;
let next_event_id = |doc: &serde_json::Value| -> i64 {
doc["events"]
.as_array()
.expect("events")
.iter()
.map(|event| event["event_id"].as_i64().expect("id"))
.max()
.unwrap_or(0)
+ 1
};
let history = |findings: &[Finding], expected: &str| {
assert!(
findings.len() == 1
&& matches!(
&findings[0],
Finding::DocumentPinHistory { detail, .. } if detail.contains(expected)
),
"expected one pin-history finding containing {expected:?}: {findings:?}"
);
};

// Signoffs naming a version the enrollment never pinned, and ones
// labelling the pinned version differently from the packet.
let report = forged(&listed, signoffs, |doc| {
for row in doc.as_array_mut().expect("rows") {
row["program_version"]["version_number"] = serde_json::json!(7);
}
});
assert_eq!(report.documents[3].findings.len(), 2, "{report:?}");
assert!(
report.documents[3].findings.iter().all(|finding| matches!(
finding,
Finding::DocumentPinHistory { detail, .. } if detail.contains("never pinned")
)),
"{report:?}"
);
let report = forged(&listed, signoffs, |doc| {
for row in doc.as_array_mut().expect("rows") {
row["program_version"]["label"] = serde_json::json!("2026 rev B");
}
});
assert!(
report.documents[3].findings.iter().all(|finding| matches!(
finding,
Finding::DocumentPinHistory { detail, .. } if detail.contains("labels version 1")
)),
"{report:?}"
);

// A version change ending elsewhere than the manifest's pin; a
// second change leaving a version other than the one pinned; an
// event labelling the manifest's version another way.
let report = forged(&listed, enrollment, |doc| {
let id = next_event_id(doc);
doc["events"]
.as_array_mut()
.expect("events")
.push(version_change(id, (1, "2026 rev A"), (2, "2026 rev B")));
});
history(
&report.documents[2].findings,
"end at version 2, but the manifest pins version 1",
);
let report = forged(&listed, enrollment, |doc| {
let id = next_event_id(doc);
let events = doc["events"].as_array_mut().expect("events");
events.push(version_change(id, (1, "2026 rev A"), (2, "2026 rev B")));
events.push(version_change(id + 1, (3, "2026 rev C"), (1, "2026 rev A")));
});
history(
&report.documents[2].findings,
"leaves version 3, but the enrollment was pinned to version 2",
);
let report = forged(&listed, enrollment, |doc| {
let id = next_event_id(doc);
doc["events"]
.as_array_mut()
.expect("events")
.push(version_change(id, (2, "2026 rev B"), (1, "Renamed")));
doc["phase_events"] = serde_json::json!([]);
});
history(
&report.documents[2].findings,
"labels version 1 \"Renamed\", but the packet labels it \"2026 rev A\"",
);

// Phase events: an epoch the history does not record; a version the
// named epoch did not reach; the original pin naming another version.
let report = forged(&listed, enrollment, |doc| {
doc["phase_events"] =
serde_json::json!([epoch_phase_event(1, Some(999), (1, "2026 rev A"))]);
});
history(
&report.documents[2].findings,
"names version change 999 as its epoch, which the history does not record",
);
let report = forged(&listed, enrollment, |doc| {
let id = next_event_id(doc);
doc["events"]
.as_array_mut()
.expect("events")
.push(version_change(id, (2, "2026 rev B"), (1, "2026 rev A")));
doc["phase_events"] =
serde_json::json!([epoch_phase_event(1, Some(id), (2, "2026 rev B"))]);
});
history(
&report.documents[2].findings,
"names version 2 under the epoch that reached version 1",
);
let report = forged(&listed, enrollment, |doc| {
let id = next_event_id(doc);
doc["events"]
.as_array_mut()
.expect("events")
.push(version_change(id, (2, "2026 rev B"), (1, "2026 rev A")));
doc["phase_events"] = serde_json::json!([epoch_phase_event(1, None, (1, "2026 rev A"))]);
});
history(
&report.documents[2].findings,
"recorded under the original pin, but names version 1 rather than version 2",
);

// The genuine packet's history is coherent.
assert!(export_verify::verify_archive(&original).verified());
}
Loading