aegis_wire_formats/lib.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3// Phase 4a of #286. README becomes the rustdoc landing page
4// (same pattern as the library trio).
5#![allow(clippy::doc_markdown)]
6#![doc = include_str!("../README.md")]
7//!
8//! ---
9//!
10//! # Rust API
11//!
12//! The core type is [`Manifest`] — the on-disk JSON body that gets
13//! written to `::/aegis-boot-manifest.json` + signed into
14//! `::/aegis-boot-manifest.json.minisig`. Every field is part of a
15//! pinned wire contract; see the comments on each struct for the
16//! invariants a verifier must enforce.
17//!
18//! # Schema versioning
19//!
20//! [`SCHEMA_VERSION`] is the canonical value for `schema_version`.
21//! Bump it only alongside a shape-breaking change (removing a field,
22//! changing a field's type). Adding a new optional field is
23//! backwards-compatible and does not require a version bump — the
24//! verifier ignores fields it doesn't know about.
25//!
26//! # JSON Schema
27//!
28//! With the `schema` feature enabled, every public type derives
29//! [`schemars::JsonSchema`]. The `aegis-wire-formats-schema-docgen`
30//! binary emits a validated JSON Schema document to
31//! `docs/reference/schemas/aegis-boot-manifest.schema.json` in the
32//! parent workspace; third-party verifiers can pin to this schema.
33//!
34//! # Two wire formats under one crate
35//!
36//! This crate hosts two logically distinct JSON wire formats that
37//! both carry aegis-boot provenance:
38//!
39//! * **On-ESP manifest** ([`Manifest`], [`SCHEMA_VERSION`]) — signed
40//! record written into `::/aegis-boot-manifest.json` at flash time,
41//! read by runtime verifiers. Phase 4a of [#286].
42//! * **Host attestation receipt** ([`Attestation`],
43//! [`ATTESTATION_SCHEMA_VERSION`]) — per-flash audit record
44//! written to `$XDG_DATA_HOME/aegis-boot/attestations/` for
45//! chain-of-custody + fleet inventory. Phase 4c-1 of [#286].
46//! * **CLI envelopes** ([`Version`], [`ListReport`],
47//! [`AttestListReport`], [`VerifyReport`], [`UpdateReport`],
48//! [`RecommendReport`], [`CompatReport`], [`CompatSubmitReport`],
49//! [`DoctorReport`], with their `*_SCHEMA_VERSION` constants) —
50//! the `--json` envelopes emitted by `aegis-boot --version --json`,
51//! `... list --json`, `... attest list --json`, `... verify --json`,
52//! `... update --json`, `... recommend --json`, `... compat --json`,
53//! `... compat --submit --json`, and siblings. Phase 4b of [#286].
54//!
55//! Each contract is independently versioned — a change to one
56//! schema does not require bumping the others. They are co-located
57//! in the same crate because they are all "aegis-boot wire-format
58//! structs for third-party consumers" and sharing the optional
59//! `schema` feature + docgen infrastructure is cheaper than forking
60//! it across N crates. A future crate rename (`aegis-wire-formats`
61//! or similar) may follow once the full CLI envelope set lands.
62//!
63//! [#286]: https://github.com/aegis-boot/aegis-boot/issues/286
64
65use serde::{Deserialize, Serialize};
66
67/// Locked schema version for the on-ESP signed [`Manifest`]. Bump
68/// alongside a breaking shape change (removing a field, changing a
69/// field's type). Adding a new optional field is backwards-compatible
70/// and does not require a version bump — the verifier ignores fields
71/// it doesn't know about.
72pub const SCHEMA_VERSION: u32 = 1;
73
74/// Locked schema version for the host-side [`Attestation`] record.
75/// Independent of [`SCHEMA_VERSION`] — either contract can advance
76/// without the other.
77pub const ATTESTATION_SCHEMA_VERSION: u32 = 1;
78
79/// Locked schema version for the [`Version`] envelope emitted by
80/// `aegis-boot --version --json`. Independent of the manifest and
81/// attestation contract versions.
82pub const VERSION_SCHEMA_VERSION: u32 = 1;
83
84/// Locked schema version for the [`ListReport`] envelope emitted
85/// by `aegis-boot list --json`. Independent of the other envelope
86/// contracts.
87pub const LIST_SCHEMA_VERSION: u32 = 1;
88
89/// Locked schema version for the [`AttestListReport`] envelope
90/// emitted by `aegis-boot attest list --json`. Independent of the
91/// other envelope contracts.
92pub const ATTEST_LIST_SCHEMA_VERSION: u32 = 1;
93
94/// Locked schema version for the [`VerifyReport`] envelope emitted
95/// by `aegis-boot verify --json`. Independent of the other envelope
96/// contracts.
97pub const VERIFY_SCHEMA_VERSION: u32 = 1;
98
99/// Locked schema version for the [`UpdateReport`] envelope emitted
100/// by `aegis-boot update --json`. Independent of the other envelope
101/// contracts.
102///
103/// Bumped to `2` in Phase 1 of #181 when
104/// [`UpdateEligibility::Eligible`] gained the `esp_diff` field.
105/// The field carries `serde(default)` + `skip_serializing_if =
106/// "Vec::is_empty"`, so a v1 payload still parses as a v2
107/// `UpdateReport` (the diff just comes through empty) — i.e. the
108/// bump is additive and backward-compatible for downstream parsers.
109pub const UPDATE_SCHEMA_VERSION: u32 = 2;
110
111/// Locked schema version for the [`RecommendReport`] envelope
112/// emitted by `aegis-boot recommend --json`. Independent of the
113/// other envelope contracts.
114pub const RECOMMEND_SCHEMA_VERSION: u32 = 1;
115
116/// Locked schema version for the [`CompatReport`] envelope emitted
117/// by `aegis-boot compat --json`. Shared by the 4 mutually-exclusive
118/// shapes (catalog / single / miss / my-machine-miss). Independent
119/// of the other envelope contracts.
120pub const COMPAT_SCHEMA_VERSION: u32 = 1;
121
122/// Locked schema version for the [`CompatSubmitReport`] envelope
123/// emitted by `aegis-boot compat --submit --json` — the
124/// draft-a-hardware-report flow. Deliberately a separate schema
125/// from [`CompatReport`] because the two surfaces have different
126/// consumer contracts (operators draft-submit vs. scripted
127/// lookup).
128pub const COMPAT_SUBMIT_SCHEMA_VERSION: u32 = 1;
129
130/// Locked schema version for the [`DoctorReport`] envelope emitted
131/// by `aegis-boot doctor --json`. Independent of the other envelope
132/// contracts.
133pub const DOCTOR_SCHEMA_VERSION: u32 = 1;
134
135/// Locked schema version for the [`CliError`] envelope — the
136/// generic `{schema_version, error}` shape emitted by any
137/// subcommand that fails *before* it can produce its
138/// subcommand-specific `--json` envelope. Shared by multiple
139/// subcommands because the pre-dispatch error path is identical
140/// across them.
141pub const CLI_ERROR_SCHEMA_VERSION: u32 = 1;
142
143/// Locked schema version for the [`FailureMicroreport`] envelope —
144/// the Tier-A anonymous on-stick failure log written by `rescue-tui`
145/// / initramfs when a classifiable boot failure occurs. Per #342
146/// Phase 2. Anonymous-by-construction: no hostname, no DMI serial,
147/// no free-form error text; just vendor_family + bios_year +
148/// classified error code + an opaque hash of the full error text.
149pub const FAILURE_MICROREPORT_SCHEMA_VERSION: u32 = 1;
150
151/// Top-level manifest body. Serialized field order matches the
152/// declaration order below — relied on for canonical JSON stability
153/// (the signature is computed over `serde_json::to_vec(&Manifest)`).
154///
155/// The Rust field is `sequence` (clippy prefers not to prefix the
156/// struct name); the JSON wire field stays `manifest_sequence` per
157/// the [#277] schema lock via `#[serde(rename)]`.
158///
159/// [#277]: https://github.com/aegis-boot/aegis-boot/issues/277
160#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
161#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
162pub struct Manifest {
163 /// Wire-format version. See [`SCHEMA_VERSION`].
164 pub schema_version: u32,
165 /// `aegis-boot` binary version that produced the manifest
166 /// (e.g. `"aegis-boot 0.14.1"`). Informational — not a trust
167 /// anchor.
168 pub tool_version: String,
169 /// Monotonic-per-flash sequence number. Defends against
170 /// rollback to an older validly-signed manifest without
171 /// relying on a secure RTC. Verifiers track the highest
172 /// sequence they've ever seen for a given device fingerprint
173 /// and reject manifests whose sequence is strictly less.
174 #[serde(rename = "manifest_sequence")]
175 pub sequence: u64,
176 /// Device identity captured at flash time.
177 pub device: Device,
178 /// Closed set of files on the ESP. Verifier rejects the stick
179 /// if any ESP file is not listed here or is missing / has a
180 /// different sha256 than recorded. Six entries today, one per
181 /// line in the signed-chain layout established by Phase 2b of
182 /// [#274](https://github.com/aegis-boot/aegis-boot/issues/274).
183 pub esp_files: Vec<EspFileEntry>,
184 /// When `true`, the verifier treats [`Self::esp_files`] as
185 /// exhaustive — the presence of any additional file on the ESP
186 /// is itself a violation. Always `true` in PR3; left as a
187 /// field so future phases can ship an evolutionary "extended"
188 /// manifest without breaking consumers.
189 pub allowed_files_closed_set: bool,
190 /// Reserved for E6 / Phase 3 TPM attestation. Left empty by
191 /// PR3; once E6 locks the PCR selection this vector grows
192 /// populated rows.
193 pub expected_pcrs: Vec<PcrEntry>,
194}
195
196/// Device identity captured at flash time. All values come from the
197/// freshly-written GPT (`blkid` + `sgdisk -p`); the verifier
198/// re-reads them at runtime and asserts equality.
199#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
200#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
201pub struct Device {
202 /// GPT disk GUID.
203 pub disk_guid: String,
204 /// Total partition count observed at flash time. aegis-boot
205 /// lays down exactly two partitions (ESP + AEGIS_ISOS); a
206 /// verifier reading three or more partitions on the same disk
207 /// rejects the stick.
208 pub partition_count: u32,
209 /// ESP partition identity — first partition by design.
210 pub esp: EspPartition,
211 /// Data partition identity — `AEGIS_ISOS` label, exfat by
212 /// default, fat32 or ext4 opt-in.
213 pub data: DataPartition,
214}
215
216/// Identity of the ESP partition (partition 1).
217#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
218#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
219pub struct EspPartition {
220 /// GPT PARTUUID (per-partition unique identifier).
221 pub partuuid: String,
222 /// GPT partition-type GUID. For the ESP this is
223 /// `C12A7328-F81F-11D2-BA4B-00A0C93EC93B`.
224 pub type_guid: String,
225 /// FAT32 volume serial number (`blkid -o value -s UUID`).
226 pub fs_uuid: String,
227 /// First absolute LBA of the partition's extent on disk.
228 pub first_lba: u64,
229 /// Last absolute LBA of the partition's extent on disk.
230 pub last_lba: u64,
231}
232
233/// Identity of the AEGIS_ISOS data partition (partition 2).
234#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
235#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
236pub struct DataPartition {
237 /// GPT PARTUUID.
238 pub partuuid: String,
239 /// GPT partition-type GUID — Microsoft Basic Data for
240 /// exfat/fat32, Linux Filesystem for ext4.
241 pub type_guid: String,
242 /// Filesystem UUID.
243 pub fs_uuid: String,
244 /// Filesystem label — always `AEGIS_ISOS` for aegis-boot
245 /// sticks.
246 pub label: String,
247}
248
249/// A single file on the ESP with its content hash and size.
250#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
251#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
252pub struct EspFileEntry {
253 /// Mtools-style `::/`-rooted path on the ESP (e.g.
254 /// `::/EFI/BOOT/BOOTX64.EFI`). The verifier lowercases both
255 /// sides before comparison because FAT32 is case-insensitive.
256 pub path: String,
257 /// Lowercase hex sha256 of the file body.
258 pub sha256: String,
259 /// File size in bytes. Redundant with sha256 but cheap to
260 /// check first — a size mismatch lets the verifier reject
261 /// without reading the full body.
262 pub size_bytes: u64,
263}
264
265/// Expected TPM PCR value at an aegis-boot stick's first post-boot
266/// measurement. Empty in PR3; populated once E6 locks the PCR
267/// selection.
268#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
269#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
270pub struct PcrEntry {
271 /// PCR index (0..23 for most banks).
272 pub pcr_index: u32,
273 /// Hash bank — `sha256`, `sha384`, etc.
274 pub bank: String,
275 /// Lowercase hex expected digest.
276 pub digest_hex: String,
277}
278
279// -----------------------------------------------------------------
280// Host-side attestation record (Phase 4c-1 of #286).
281//
282// The [`Attestation`] document is written to
283// `$XDG_DATA_HOME/aegis-boot/attestations/<guid>-<ts>.json` at flash
284// time, and amended with [`IsoRecord`] entries each time
285// `aegis-boot add` lands an ISO on the stick. Independent schema
286// from the on-ESP [`Manifest`] above — the attestation is audit
287// trail + fleet-inventory data, not a boot contract.
288// -----------------------------------------------------------------
289
290/// One flash + zero-or-more ISO additions, captured as a single
291/// JSON document on the host. Stored under
292/// `$XDG_DATA_HOME/aegis-boot/attestations/` (or
293/// `~/.local/share/aegis-boot/attestations/` if `XDG_DATA_HOME` is
294/// unset). The `aegis-boot attest list` / `attest show` commands
295/// read these back for chain-of-custody queries.
296///
297/// v0 ships unsigned — the trust anchor is "the operator ran this
298/// command on this host, the timestamps + hashes are the evidence."
299/// TPM PCR attestation + signing lands under epic
300/// [#139](https://github.com/aegis-boot/aegis-boot/issues/139)
301/// as additive fields; the current schema is forward-compatible
302/// (consumers ignore unknown fields).
303#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
304#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
305pub struct Attestation {
306 /// Wire-format version. See [`ATTESTATION_SCHEMA_VERSION`].
307 pub schema_version: u32,
308 /// `aegis-boot` binary version that produced this record
309 /// (e.g. `"aegis-boot 0.14.1"`).
310 pub tool_version: String,
311 /// RFC 3339 / ISO 8601 timestamp of the flash operation.
312 /// Generated via the host's `date -u +%FT%TZ` so the crate
313 /// does not pull a chrono dep.
314 pub flashed_at: String,
315 /// The user that ran `aegis-boot flash` — captured from
316 /// `$SUDO_USER` if set, else `$USER`.
317 pub operator: String,
318 /// Host environment captured at flash time.
319 pub host: HostInfo,
320 /// Target stick captured at flash time.
321 pub target: TargetInfo,
322 /// ISO records appended on each successful `aegis-boot add`.
323 /// Empty immediately after flash; grows over the stick's
324 /// lifetime.
325 pub isos: Vec<IsoRecord>,
326}
327
328/// Host environment fingerprint at flash time.
329#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
330#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
331pub struct HostInfo {
332 /// `uname -r` output.
333 pub kernel: String,
334 /// Secure Boot state: one of `"enforcing"`, `"disabled"`, or
335 /// `"unknown"`.
336 pub secure_boot: String,
337}
338
339/// Target stick fingerprint at flash time.
340#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
341#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
342pub struct TargetInfo {
343 /// Device node path (e.g. `/dev/sda`).
344 pub device: String,
345 /// Vendor + model string from `/sys/block/sdX/device/{vendor,model}`.
346 pub model: String,
347 /// Raw device size in bytes, rounded to the nearest 512B sector.
348 pub size_bytes: u64,
349 /// Lowercase hex sha256 of the dd'd image body.
350 pub image_sha256: String,
351 /// Size in bytes of the image body (for sanity-checking the
352 /// sha256 over the correct length).
353 pub image_size_bytes: u64,
354 /// GPT disk GUID, captured from `sgdisk -p` after `partprobe`.
355 /// May be empty if sgdisk fails or the drive isn't partitioned.
356 pub disk_guid: String,
357}
358
359/// One `aegis-boot add` operation appended to the stick's
360/// attestation record.
361#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
362#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
363pub struct IsoRecord {
364 /// ISO filename as it lives on the AEGIS_ISOS data partition.
365 pub filename: String,
366 /// Lowercase hex sha256 of the ISO body.
367 pub sha256: String,
368 /// ISO size in bytes.
369 pub size_bytes: u64,
370 /// Sidecar filenames recorded alongside the ISO (e.g. a
371 /// `.aegis.toml` operator-label file).
372 pub sidecars: Vec<String>,
373 /// RFC 3339 / ISO 8601 timestamp of when the ISO was added.
374 pub added_at: String,
375}
376
377// -----------------------------------------------------------------
378// CLI envelopes (Phase 4b-1 onward of #286).
379//
380// These are the `--json` output shapes emitted by `aegis-boot`
381// subcommands. The envelopes are tiny, stable, and
382// independently-versioned wire contracts scripted consumers
383// (monitoring, install-one-liner assertions, Homebrew formula
384// tests) depend on.
385// -----------------------------------------------------------------
386
387/// Envelope emitted by `aegis-boot --version --json`. Lets scripted
388/// consumers (install-one-liner assertions, Homebrew formula tests,
389/// ansible-verified installs) parse the version without regex on
390/// the human-readable string.
391#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
392#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
393pub struct Version {
394 /// Wire-format version. See [`VERSION_SCHEMA_VERSION`].
395 pub schema_version: u32,
396 /// Always `"aegis-boot"` for this CLI — field exists to make a
397 /// future migration to a multi-tool repo (where the same
398 /// envelope shape might be emitted by a sibling binary) a
399 /// zero-schema-bump operation.
400 pub tool: String,
401 /// Semver string matching the workspace version (`Cargo.toml`
402 /// `[workspace.package].version`). Does NOT include a `v`
403 /// prefix — `"0.14.1"`, not `"v0.14.1"`.
404 pub version: String,
405}
406
407/// Envelope emitted by `aegis-boot list --json`. Reports the ISOs
408/// discovered on a stick's AEGIS_ISOS data partition, plus the
409/// attestation summary if one was recorded at flash time.
410#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
411#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
412pub struct ListReport {
413 /// Wire-format version. See [`LIST_SCHEMA_VERSION`].
414 pub schema_version: u32,
415 /// `aegis-boot` binary version that produced this envelope.
416 pub tool_version: String,
417 /// Filesystem mount path the stick was resolved to (e.g.
418 /// `/run/media/alice/AEGIS_ISOS`).
419 pub mount_path: String,
420 /// Chain-of-custody summary from the matching attestation
421 /// record, or null if no attestation was found (operator
422 /// flashed on a different host, or pre-v0.13.0 stick).
423 pub attestation: Option<ListAttestationSummary>,
424 /// Number of ISOs observed. Redundant with `isos.len()` but
425 /// useful for consumers that only read the header.
426 pub count: u32,
427 /// Per-ISO details. See [`ListIsoSummary`].
428 pub isos: Vec<ListIsoSummary>,
429}
430
431/// Compact attestation summary embedded in [`ListReport`]. Derived
432/// from the stored [`Attestation`] record; smaller than the full
433/// attestation (no device fingerprint, no host kernel string) so
434/// the list envelope stays lightweight.
435#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
436#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
437pub struct ListAttestationSummary {
438 /// RFC 3339 timestamp of the flash operation.
439 pub flashed_at: String,
440 /// Operator that ran `aegis-boot flash`.
441 pub operator: String,
442 /// Count of ISOs recorded in the attestation. Note this can
443 /// differ from [`ListReport::count`] — the attestation tracks
444 /// ISOs that were added via `aegis-boot add`, while the list
445 /// count scans the actual partition. A mismatch is a signal
446 /// that someone hand-copied an ISO rather than using the CLI.
447 pub isos_recorded: u32,
448 /// Filesystem path of the host-side attestation manifest.
449 pub manifest_path: String,
450}
451
452/// Per-ISO detail in [`ListReport`]. The `display_name` +
453/// `description` fields come from an optional `<iso>.aegis.toml`
454/// operator-label sidecar (#246); they are always present in the
455/// wire format (as `null` when absent) so consumers see a stable
456/// shape.
457#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
458#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
459pub struct ListIsoSummary {
460 /// ISO basename (no directory component). Consumers that join
461 /// `mount_path + name` will get the root-of-stick path, which is
462 /// only valid when `folder` is null. For sticks with subfolder
463 /// layouts (#274 Phase 6), join `mount_path + folder + name`.
464 /// Kept as basename — separate from `folder` — so downstream
465 /// automation that shelled out to `basename(1)` on the old flat
466 /// layout keeps working.
467 pub name: String,
468 /// Subfolder path relative to the data-partition mount, or `null`
469 /// when the ISO sits at the root. `"ubuntu-24.04"` for a single
470 /// level, `"ubuntu/24.04"` for nested (forward-slash separator
471 /// regardless of host OS — the stick filesystem is exFAT, which
472 /// normalizes to `/`). Added in v0.16.0 (#274 Phase 6a) as an
473 /// additive optional field; v0.15.x consumers that ignore
474 /// unknown keys see no behavior change, and the `name` field
475 /// remains a basename for scripts that joined it directly.
476 pub folder: Option<String>,
477 /// ISO size in bytes.
478 pub size_bytes: u64,
479 /// Whether a matching `<iso>.sha256` sidecar file exists.
480 pub has_sha256: bool,
481 /// Whether a matching `<iso>.minisig` sidecar file exists.
482 pub has_minisig: bool,
483 /// Operator-curated display name from `<iso>.aegis.toml`, or
484 /// null when the sidecar is absent. Intentionally NOT omitted
485 /// when null — consumers depend on a stable field set.
486 pub display_name: Option<String>,
487 /// Operator-curated description from `<iso>.aegis.toml`, or
488 /// null when the sidecar is absent.
489 pub description: Option<String>,
490}
491
492/// Envelope emitted by `aegis-boot attest list --json`. Scans the
493/// host's attestation directory, attempts to parse each file, and
494/// reports either a parsed summary or a parse-error placeholder per
495/// entry. Enables monitoring / fleet tools to audit chain-of-custody
496/// across all flashed sticks on a host.
497#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
498#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
499pub struct AttestListReport {
500 /// Wire-format version. See [`ATTEST_LIST_SCHEMA_VERSION`].
501 pub schema_version: u32,
502 /// `aegis-boot` binary version that produced this envelope.
503 pub tool_version: String,
504 /// Host filesystem path of the attestations directory scanned
505 /// (typically `$XDG_DATA_HOME/aegis-boot/attestations/`).
506 pub attestations_dir: String,
507 /// Number of files scanned (including parse-failure entries).
508 pub count: u32,
509 /// One entry per file found. See [`AttestListEntry`] for the
510 /// success/error shape selection.
511 pub attestations: Vec<AttestListEntry>,
512}
513
514/// One entry in an [`AttestListReport`]. Two mutually-exclusive
515/// wire shapes via serde's `untagged` tagging:
516///
517/// * **Success** — a successful parse, reporting the manifest's
518/// headline fields. The `error` field is absent.
519/// * **Error** — the file existed but could not be parsed. Only
520/// `manifest_path` + `error` are emitted; the summary fields
521/// are absent.
522///
523/// Schemars emits this as a JSON Schema `oneOf` so consumers know
524/// to branch on the presence of the `error` field.
525#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
526#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
527#[serde(untagged)]
528pub enum AttestListEntry {
529 /// Parsed manifest summary.
530 Success(AttestListSuccess),
531 /// Placeholder for a file that existed but failed to parse.
532 Error(AttestListError),
533}
534
535/// Successfully-parsed attestation summary inside an
536/// [`AttestListReport`]. Deliberately a strict subset of the full
537/// [`Attestation`] — enough to drive a dashboard without requiring
538/// consumers to re-parse each file. Full detail is one
539/// `aegis-boot attest show <path>` away.
540#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
541#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
542pub struct AttestListSuccess {
543 /// Host filesystem path of the attestation manifest file.
544 pub manifest_path: String,
545 /// Schema version declared inside the attestation manifest
546 /// (see [`ATTESTATION_SCHEMA_VERSION`]).
547 pub schema_version: u32,
548 /// `tool_version` field from the attestation manifest.
549 pub tool_version: String,
550 /// `flashed_at` timestamp from the attestation manifest.
551 pub flashed_at: String,
552 /// Operator that ran the flash (from the attestation).
553 pub operator: String,
554 /// `target.device` from the attestation (e.g. `/dev/sda`).
555 pub target_device: String,
556 /// `target.model` from the attestation.
557 pub target_model: String,
558 /// GPT disk GUID from the attestation's target info.
559 pub disk_guid: String,
560 /// Number of [`IsoRecord`] entries inside the attestation.
561 pub iso_count: u32,
562}
563
564/// Parse-failure entry inside an [`AttestListReport`]. Consumer
565/// decision: show the operator which file failed + why, so they
566/// can audit / repair / delete.
567#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
568#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
569pub struct AttestListError {
570 /// Host filesystem path of the unparseable file.
571 pub manifest_path: String,
572 /// Human-readable error message from the parser.
573 pub error: String,
574}
575
576/// Envelope emitted by `aegis-boot verify --json`. Re-verifies
577/// every ISO on a stick against its sidecar checksum and reports
578/// a per-ISO verdict plus a summary tally. Used by CI / monitoring
579/// to audit that a stick's ISOs haven't bit-rotted, been replaced,
580/// or lost their sha256 sidecars.
581#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
582#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
583pub struct VerifyReport {
584 /// Wire-format version. See [`VERIFY_SCHEMA_VERSION`].
585 pub schema_version: u32,
586 /// `aegis-boot` binary version that produced this envelope.
587 pub tool_version: String,
588 /// Filesystem mount path of the AEGIS_ISOS partition scanned.
589 pub mount_path: String,
590 /// Aggregate tally + overall pass/fail.
591 pub summary: VerifySummary,
592 /// Per-ISO verdict. Always present (even as `[]` for an empty
593 /// stick) so consumers see a stable field set.
594 pub isos: Vec<VerifyEntry>,
595}
596
597/// Tally of per-ISO verdicts in a [`VerifyReport`]. `any_failure`
598/// is the summary bit downstream tooling (CI, dashboards)
599/// branches on.
600#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
601#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
602pub struct VerifySummary {
603 /// Total ISOs scanned. Equals the length of [`VerifyReport::isos`].
604 pub total: u32,
605 /// Count with `verdict: "Verified"`.
606 pub verified: u32,
607 /// Count with `verdict: "Mismatch"` — sha256 differed from
608 /// sidecar. A serious trust-chain signal; consumer should
609 /// surface aggressively.
610 pub mismatch: u32,
611 /// Count with `verdict: "Unreadable"` — file exists but
612 /// couldn't be opened / read (permission, bad media).
613 pub unreadable: u32,
614 /// Count with `verdict: "NotPresent"` — referenced in an
615 /// attestation manifest but missing from the partition.
616 pub not_present: u32,
617 /// True iff at least one of `mismatch`, `unreadable`, or
618 /// `not_present` is non-zero. The overall stick-health bit.
619 pub any_failure: bool,
620}
621
622/// One ISO's verdict inside a [`VerifyReport`]. The `verdict`
623/// field is the discriminator; variant-specific fields follow via
624/// `#[serde(flatten)]`. Consumer contract: branch on `verdict`,
625/// expect the fields documented for that variant.
626///
627/// Wire shape examples:
628///
629/// ```text
630/// {"name": "ubuntu.iso", "verdict": "Verified", "digest": "…", "source": "sidecar"}
631/// {"name": "debian.iso", "verdict": "Mismatch", "actual": "…", "expected": "…", "source": "sidecar"}
632/// {"name": "alpine.iso", "verdict": "Unreadable", "source": "sidecar", "reason": "permission denied"}
633/// {"name": "fedora.iso", "verdict": "NotPresent"}
634/// ```
635#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
636#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
637pub struct VerifyEntry {
638 /// ISO filename.
639 pub name: String,
640 /// Verdict tag + variant-specific fields.
641 #[serde(flatten)]
642 pub verdict: VerifyVerdict,
643}
644
645/// Per-ISO verdict variants. Internally-tagged under `verdict`; a
646/// consumer that doesn't recognize a future variant can fall back
647/// on the tag string.
648#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
649#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
650#[serde(tag = "verdict")]
651pub enum VerifyVerdict {
652 /// sha256 of the ISO matches the sidecar's recorded digest.
653 Verified {
654 /// Computed sha256 (lowercase hex).
655 digest: String,
656 /// Where the expected digest came from (e.g. `"sidecar"`
657 /// for the on-partition `.sha256` file).
658 source: String,
659 },
660 /// sha256 of the ISO does NOT match the sidecar — either
661 /// media corruption or a replaced/tampered file. Trust-chain
662 /// breaking.
663 Mismatch {
664 /// Computed sha256 of the ISO on disk.
665 actual: String,
666 /// Digest the sidecar asserts.
667 expected: String,
668 /// Where the expected digest came from.
669 source: String,
670 },
671 /// The ISO file exists but couldn't be opened / hashed.
672 Unreadable {
673 /// Where the expected digest came from (so the operator
674 /// knows which sidecar to reconcile against after
675 /// restoring access to the file).
676 source: String,
677 /// Human-readable explanation (permission, I/O error).
678 reason: String,
679 },
680 /// An ISO referenced elsewhere (e.g. in the attestation
681 /// manifest's `isos` list) is not on the partition.
682 NotPresent,
683}
684
685/// Envelope emitted by `aegis-boot update --json`. Phase 1 of #181
686/// is eligibility-check-only: answers "would a non-destructive
687/// signed-chain rotation apply cleanly to this stick?" — the actual
688/// rotation is Phase 2. The envelope's outer fields
689/// (`schema_version`, `tool_version`, `device`) are common; the
690/// [`eligibility`] flattened enum carries the variant-specific
691/// body.
692#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
693#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
694pub struct UpdateReport {
695 /// Wire-format version. See [`UPDATE_SCHEMA_VERSION`].
696 pub schema_version: u32,
697 /// `aegis-boot` binary version that produced this envelope.
698 pub tool_version: String,
699 /// Device node path operated on (e.g. `/dev/sda`).
700 pub device: String,
701 /// Eligibility verdict + variant-specific fields.
702 #[serde(flatten)]
703 pub eligibility: UpdateEligibility,
704}
705
706/// Outcome of the eligibility check. Internally-tagged under
707/// `eligibility` with the tag values `"ELIGIBLE"` and
708/// `"INELIGIBLE"` (upper-case to match the existing wire format).
709/// `flatten`-combined with [`UpdateReport`]'s outer fields so the
710/// emitted JSON preserves the `schema_version, tool_version,
711/// device, eligibility, …` ordering consumers parse against.
712#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
713#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
714#[serde(tag = "eligibility")]
715pub enum UpdateEligibility {
716 /// The stick could accept an in-place signed-chain rotation.
717 /// Reports the disk GUID (matches the ESP partition table's
718 /// `.disk_guid`), the host-side attestation that would be
719 /// updated, and the new signed chain the host would write.
720 #[serde(rename = "ELIGIBLE")]
721 Eligible {
722 /// GPT disk GUID of the stick (matches the attestation
723 /// manifest's `device.disk_guid`).
724 disk_guid: String,
725 /// Host filesystem path of the attestation manifest that
726 /// the rotation will update.
727 attestation_path: String,
728 /// Ordered signed-chain files the host would install if
729 /// the operator re-ran flash today (shim / grub /
730 /// kernel / initrd). Each carries either `sha256` (success)
731 /// or `error` (could not be hashed / located).
732 host_chain: Vec<UpdateChainEntry>,
733 /// Per-file diff between the current ESP contents and what
734 /// a fresh flash would produce. Phase 1 of #181:
735 /// informational only — eligibility does not depend on the
736 /// diff. Empty when the ESP could not be read (e.g.
737 /// missing `mtype`, partition unreadable); in that case the
738 /// operator still gets the [`host_chain`] preview.
739 ///
740 /// Added in `schema_version = 2`. `serde(default)` +
741 /// `skip_serializing_if = "Vec::is_empty"` make the bump
742 /// additive: v1 payloads parse as v2 with an empty vec.
743 #[serde(default, skip_serializing_if = "Vec::is_empty")]
744 esp_diff: Vec<UpdateFileDiff>,
745 },
746 /// The stick cannot accept a rotation. Carries the operator-
747 /// readable reason (device not removable, no attestation on
748 /// this host, signed-chain source missing, …).
749 #[serde(rename = "INELIGIBLE")]
750 Ineligible {
751 /// Explanation of why the rotation was refused.
752 reason: String,
753 },
754}
755
756/// One signed-chain entry in an [`UpdateEligibility::Eligible`]
757/// response. Two mutually-exclusive wire shapes via untagged-enum
758/// dispatch on the presence of `sha256` vs `error`.
759#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
760#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
761pub struct UpdateChainEntry {
762 /// Role in the signed chain: `shim`, `grub`, `kernel`, or
763 /// `initrd`.
764 pub role: String,
765 /// Host filesystem path of the source file.
766 pub path: String,
767 /// Success (sha256) or failure (error) — flattened so consumers
768 /// see `{role, path, sha256}` or `{role, path, error}`.
769 #[serde(flatten)]
770 pub result: UpdateChainResult,
771}
772
773/// Per-chain-entry result. Untagged to match the current wire
774/// format's mutually-exclusive shape (no discriminator tag — the
775/// consumer branches on the presence of `sha256` vs `error`).
776#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
777#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
778#[serde(untagged)]
779pub enum UpdateChainResult {
780 /// File was hashed successfully.
781 Ok {
782 /// Lowercase hex sha256 of the file.
783 sha256: String,
784 },
785 /// File could not be hashed (missing, permission denied,
786 /// read error). `reason` is operator-facing.
787 Error {
788 /// Human-readable error.
789 error: String,
790 },
791}
792
793/// Per-file diff row between what's currently on the stick's ESP
794/// and what a fresh `mkusb` / direct-install flash would write.
795/// One row per canonical ESP destination (see
796/// `direct_install::ESP_DEST_*`).
797///
798/// Semantics of the hash/error pairs:
799///
800/// * `current_sha256` is `None` when the ESP file couldn't be read
801/// (file absent, `mtype` missing, permission denied); in that
802/// case `current_error` carries the operator-facing reason.
803/// * `fresh_sha256` is `None` when the host-side source couldn't
804/// be hashed (package not installed, kernel glob missed, etc);
805/// `fresh_error` carries the reason.
806/// * `would_change` is the Phase-1 verdict the operator cares
807/// about: `true` only when both hashes are present AND they
808/// differ. When either hash is absent the comparison is
809/// inconclusive and `would_change` is `false` — the operator
810/// sees the error field and knows the answer isn't "yes it
811/// would change", it's "we couldn't tell".
812///
813/// Added in `UPDATE_SCHEMA_VERSION = 2` (Phase 1 of #181).
814#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
815#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
816pub struct UpdateFileDiff {
817 /// Role in the signed chain: `shim`, `grub`, `grub_cfg_boot`,
818 /// `grub_cfg_ubuntu`, `kernel`, `initrd`. One entry per
819 /// canonical destination (the two `grub.cfg` targets get
820 /// separate rows because `mkusb.sh` writes both).
821 pub role: String,
822 /// Destination path on the ESP, e.g. `/EFI/BOOT/BOOTX64.EFI`
823 /// (no `::` mtools prefix — stripped for consumers that want
824 /// an operator-readable path).
825 pub esp_path: String,
826 /// Lowercase hex sha256 of the file currently on the stick's
827 /// ESP. `None` when unreadable.
828 #[serde(skip_serializing_if = "Option::is_none")]
829 pub current_sha256: Option<String>,
830 /// Operator-readable error explaining why `current_sha256`
831 /// is absent. `None` when the read succeeded.
832 #[serde(skip_serializing_if = "Option::is_none")]
833 pub current_error: Option<String>,
834 /// Lowercase hex sha256 of what a fresh flash would install.
835 /// `None` when the host-side source couldn't be hashed.
836 #[serde(skip_serializing_if = "Option::is_none")]
837 pub fresh_sha256: Option<String>,
838 /// Operator-readable error explaining why `fresh_sha256` is
839 /// absent. `None` when the hash succeeded.
840 #[serde(skip_serializing_if = "Option::is_none")]
841 pub fresh_error: Option<String>,
842 /// `true` when both hashes are present and differ. `false`
843 /// when they match, OR when either hash is absent (see struct
844 /// docs for the rationale).
845 pub would_change: bool,
846}
847
848/// Envelope emitted by `aegis-boot recommend --json`. Untagged
849/// wrapper around three mutually-exclusive shapes: a full catalog
850/// listing, a single-entry response, or a miss. Consumers branch
851/// on the presence of `entries` / `entry` / `error`.
852///
853/// The miss shape intentionally omits `tool_version` — matches
854/// the existing wire format. Future schema bumps can unify the
855/// three shapes; Phase 4b-6 preserves the current contract.
856#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
857#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
858#[serde(untagged)]
859pub enum RecommendReport {
860 /// Full catalog listing. Emitted when `aegis-boot recommend
861 /// --json` is called with no slug.
862 Catalog(RecommendCatalogReport),
863 /// Single-entry response. Emitted when the slug matched one
864 /// catalog entry exactly.
865 Single(RecommendSingleReport),
866 /// Miss — the slug didn't match any entry.
867 Miss(RecommendMissReport),
868}
869
870/// Full-catalog variant of [`RecommendReport`].
871#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
872#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
873pub struct RecommendCatalogReport {
874 /// Wire-format version. See [`RECOMMEND_SCHEMA_VERSION`].
875 pub schema_version: u32,
876 /// `aegis-boot` binary version that produced this envelope.
877 pub tool_version: String,
878 /// Total entries in the catalog. Equals `entries.len()`.
879 pub count: u32,
880 /// All catalog entries in the order `CATALOG` defines them
881 /// (typically alphabetical by slug).
882 pub entries: Vec<RecommendEntry>,
883}
884
885/// Single-entry variant of [`RecommendReport`].
886#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
887#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
888pub struct RecommendSingleReport {
889 /// Wire-format version. See [`RECOMMEND_SCHEMA_VERSION`].
890 pub schema_version: u32,
891 /// `aegis-boot` binary version that produced this envelope.
892 pub tool_version: String,
893 /// The matched catalog entry.
894 pub entry: RecommendEntry,
895}
896
897/// Miss variant of [`RecommendReport`] — no catalog entry matched
898/// the given slug. The envelope is deliberately asymmetric from
899/// the success variants (no `tool_version`) to match the existing
900/// wire format; tightening to always emit `tool_version` would be
901/// an additive (non-breaking) future change.
902#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
903#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
904pub struct RecommendMissReport {
905 /// Wire-format version. See [`RECOMMEND_SCHEMA_VERSION`].
906 pub schema_version: u32,
907 /// Human-readable error ("no catalog entry matching '<slug>'").
908 pub error: String,
909}
910
911/// Envelope emitted by `aegis-boot compat --json`. Untagged
912/// wrapper around 4 mutually-exclusive shapes: full catalog,
913/// single match, miss (query matched no DB entry), or
914/// my-machine-miss (DMI lookup couldn't resolve an identity).
915///
916/// Dispatch by field presence:
917/// * `entries` → [`CompatReport::Catalog`]
918/// * `entry` → [`CompatReport::Single`]
919/// * `report_url` + `error` (no entries/entry) → [`CompatReport::Miss`]
920/// * `error` without `report_url` → [`CompatReport::MyMachineMiss`]
921///
922/// The separate `CompatSubmitReport` carries the `--submit` flow's
923/// own shape and schema version.
924#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
925#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
926#[serde(untagged)]
927pub enum CompatReport {
928 /// Full catalog listing. Emitted with no query argument.
929 Catalog(CompatCatalogReport),
930 /// Single match. Emitted when the query resolved exactly one
931 /// DB entry.
932 Single(CompatSingleReport),
933 /// Miss. Emitted when the query didn't match any DB entry
934 /// (but the query was well-formed).
935 Miss(CompatMissReport),
936 /// My-machine miss. Emitted when `--my-machine` or
937 /// `--submit` couldn't resolve DMI identity (non-Linux host,
938 /// placeholder values). Exit code on the CLI side is 2
939 /// (host-environment issue) vs the Miss case's 1 (DB coverage
940 /// gap).
941 MyMachineMiss(CompatMyMachineMissReport),
942}
943
944/// Full-catalog variant of [`CompatReport`].
945#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
946#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
947pub struct CompatCatalogReport {
948 /// Wire-format version. See [`COMPAT_SCHEMA_VERSION`].
949 pub schema_version: u32,
950 /// `aegis-boot` binary version that produced this envelope.
951 pub tool_version: String,
952 /// URL operators visit to file a new hardware report.
953 pub report_url: String,
954 /// Number of entries in the DB. Equals `entries.len()`.
955 pub count: u32,
956 /// All entries in DB declaration order.
957 pub entries: Vec<CompatEntry>,
958}
959
960/// Single-entry variant of [`CompatReport`].
961#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
962#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
963pub struct CompatSingleReport {
964 /// Wire-format version. See [`COMPAT_SCHEMA_VERSION`].
965 pub schema_version: u32,
966 /// `aegis-boot` binary version that produced this envelope.
967 pub tool_version: String,
968 /// URL operators visit to file a new hardware report.
969 pub report_url: String,
970 /// The matched DB entry.
971 pub entry: CompatEntry,
972}
973
974/// Miss variant of [`CompatReport`] — the query was well-formed but
975/// didn't match any DB entry. Carries `report_url` so the operator
976/// can file a new entry; deliberately omits `tool_version` to match
977/// the existing wire format.
978#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
979#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
980pub struct CompatMissReport {
981 /// Wire-format version. See [`COMPAT_SCHEMA_VERSION`].
982 pub schema_version: u32,
983 /// URL operators visit to file a new hardware report.
984 pub report_url: String,
985 /// Human-readable error (`"no platform matching '<query>'"`).
986 pub error: String,
987}
988
989/// My-machine-miss variant of [`CompatReport`] — `--my-machine` or
990/// `--submit` couldn't auto-fill the query from DMI. Minimal
991/// envelope (just `schema_version` + `error`) to match the existing
992/// wire format.
993#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
994#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
995pub struct CompatMyMachineMissReport {
996 /// Wire-format version. See [`COMPAT_SCHEMA_VERSION`].
997 pub schema_version: u32,
998 /// Human-readable error
999 /// (`"--my-machine: DMI fields unavailable (…)"`).
1000 pub error: String,
1001}
1002
1003/// One hardware-compatibility DB row. Mirrors
1004/// `docs/HARDWARE_COMPAT.md`; every entry corresponds to a real
1005/// operator report (or the QEMU reference environment).
1006#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1007#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1008pub struct CompatEntry {
1009 /// Vendor (e.g. `"Lenovo"`, `"Framework"`, `"QEMU"`).
1010 pub vendor: String,
1011 /// Model (e.g. `"ThinkPad X1 Carbon Gen 11"`).
1012 pub model: String,
1013 /// Firmware vendor + version, free-form from BIOS.
1014 pub firmware: String,
1015 /// Secure Boot state at the time of the report
1016 /// (typically `"enforcing"` or `"disabled"`).
1017 pub sb_state: String,
1018 /// Boot-menu key for this firmware (`"F12"`, `"Esc"`, etc.).
1019 /// `"n/a"` for reference / virtualized environments.
1020 pub boot_key: String,
1021 /// Confidence level: `"verified"`, `"partial"`, or
1022 /// `"reference"`.
1023 pub level: String,
1024 /// GitHub handle or `"aegis-team"` that filed the report.
1025 pub reported_by: String,
1026 /// ISO-8601 date string (`"2026-04-18"`).
1027 pub date: String,
1028 /// Free-text operator notes (quirks, BIOS tweaks,
1029 /// fast-boot caveats). May be empty for a clean boot.
1030 pub notes: Vec<String>,
1031}
1032
1033/// Envelope emitted by `aegis-boot compat --submit --json` — the
1034/// draft-a-hardware-report flow. Collects DMI identity + builds a
1035/// pre-filled GitHub issue URL the operator can open to file a
1036/// report. Independent schema from [`CompatReport`] because the
1037/// consumer contracts diverge: lookup drives scripted decisions,
1038/// submit drives an operator workflow.
1039#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1040#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1041pub struct CompatSubmitReport {
1042 /// Wire-format version. See [`COMPAT_SUBMIT_SCHEMA_VERSION`].
1043 pub schema_version: u32,
1044 /// Always `"aegis-boot"`. Deliberately named `tool` (not
1045 /// `tool_version`) to match the existing wire format; this
1046 /// envelope carries the draft template, not a version pin.
1047 pub tool: String,
1048 /// Pre-filled GitHub issue URL with `vendor`, `model`,
1049 /// `firmware`, and `aegis-version` query-string parameters
1050 /// set from DMI.
1051 pub submit_url: String,
1052 /// DMI `sys_vendor`. Empty string if unavailable.
1053 pub vendor: String,
1054 /// DMI product label (name + version). Empty if unavailable.
1055 pub model: String,
1056 /// DMI BIOS label (vendor + version + date). Empty if
1057 /// unavailable.
1058 pub firmware: String,
1059}
1060
1061/// Envelope emitted by `aegis-boot doctor --json`. Reports host +
1062/// stick health as a rollup score + per-check rows. The monitoring /
1063/// CI consumer target — a non-zero `has_any_fail` is the signal to
1064/// surface to an operator.
1065#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1066#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1067pub struct DoctorReport {
1068 /// Wire-format version. See [`DOCTOR_SCHEMA_VERSION`].
1069 pub schema_version: u32,
1070 /// `aegis-boot` binary version that produced this envelope.
1071 pub tool_version: String,
1072 /// Aggregate score (0–100). PASS = 1.0, WARN = 0.7, FAIL =
1073 /// 0.0; skipped rows are excluded from the denominator.
1074 pub score: u32,
1075 /// Human-readable score band: typically `"EXCELLENT"`,
1076 /// `"GOOD"`, `"FAIR"`, or `"POOR"`. Exact thresholds are an
1077 /// implementation detail of the CLI; consumers should treat
1078 /// these as opaque labels paired with the numeric `score`.
1079 pub band: String,
1080 /// True iff any row's `verdict` is `"FAIL"`. The minimal
1081 /// rollup bit for monitoring: operator attention required.
1082 pub has_any_fail: bool,
1083 /// Operator-facing remediation hint pulled from the first
1084 /// `FAIL` row's `next_action` text. `None` when no row failed
1085 /// or none carried a remedy.
1086 pub next_action: Option<String>,
1087 /// One entry per check run. Order is check-declaration order
1088 /// inside `doctor.rs` — stable across invocations on the same
1089 /// host.
1090 pub rows: Vec<DoctorRow>,
1091}
1092
1093/// One check result in a [`DoctorReport`].
1094#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1095#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1096pub struct DoctorRow {
1097 /// Verdict label — `"PASS"`, `"WARN"`, `"FAIL"`, or `"SKIP"`.
1098 /// String (not enum) because the CLI's verdict vocabulary is
1099 /// intentionally loose: new verdicts can be added without a
1100 /// `schema_version` bump so long as consumers treat unknown
1101 /// values as "don't block on this."
1102 pub verdict: String,
1103 /// Short check name (e.g. `"command: mcopy"`).
1104 pub name: String,
1105 /// Single-line detail (filepath, value, or error message).
1106 pub detail: String,
1107}
1108
1109/// Generic error envelope emitted when a subcommand fails before
1110/// it can produce its subcommand-specific `--json` envelope.
1111/// Examples: `aegis-boot list --json` before mount-resolution
1112/// succeeds; `aegis-boot verify --json` before a stick is found.
1113///
1114/// Kept deliberately minimal (just `schema_version` + `error`) so
1115/// scripted consumers can parse it without knowing which
1116/// subcommand was called. Shared across subcommands because the
1117/// pre-dispatch failure path is semantically identical.
1118#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1119#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1120pub struct CliError {
1121 /// Wire-format version. See [`CLI_ERROR_SCHEMA_VERSION`].
1122 pub schema_version: u32,
1123 /// Human-readable error message.
1124 pub error: String,
1125}
1126
1127/// Tier-A anonymous failure microreport — written by `rescue-tui`
1128/// / initramfs to `AEGIS_ISOS/aegis-boot-logs/<ts>-<hash>.json`
1129/// when a classifiable boot failure occurs, so the operator can
1130/// later include the log in an `aegis-boot bug-report` bundle
1131/// (#342 Phase 2).
1132///
1133/// **Anonymous by construction.** Every field is either an
1134/// aegis-boot version, a loosely-bucketed machine-family hint, or
1135/// an opaque content hash. No hostname, no DMI serial, no full
1136/// error text. Matches [ABRT's uReport]
1137/// (<https://fedoraproject.org/wiki/Features/SimplifiedCrashReporting>)
1138/// pattern: safe to ship without operator review, useful for
1139/// failure-class correlation across a fleet.
1140///
1141/// Tier B (full structured log, consent-gated) lands in a later
1142/// phase with a distinct `schema_version` track.
1143#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1144#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1145pub struct FailureMicroreport {
1146 /// Wire-format version. See [`FAILURE_MICROREPORT_SCHEMA_VERSION`].
1147 pub schema_version: u32,
1148 /// Tier marker. Always `"A"` in this envelope; reserved for
1149 /// `"B"` when the consent-gated full-log tier ships.
1150 pub tier: String,
1151 /// RFC-3339 UTC timestamp of when the microreport was written.
1152 pub collected_at: String,
1153 /// `aegis-boot` version string that produced this log.
1154 pub aegis_boot_version: String,
1155 /// Lowercased first token of the DMI `sys_vendor` field (e.g.
1156 /// `"framework"`, `"lenovo"`, `"dell"`). Vendor-granularity
1157 /// only — enough to correlate per-vendor bugs without
1158 /// identifying the operator.
1159 pub vendor_family: String,
1160 /// Four-digit year extracted from the DMI `bios_date` (e.g.
1161 /// `"2024"`). Year-granularity is coarse enough to preserve
1162 /// anonymity on any laptop model older than a few months.
1163 pub bios_year: String,
1164 /// Classified boot stage the failure occurred at. One of:
1165 /// `"pre_kernel"`, `"kernel_init"`, `"initramfs"`,
1166 /// `"rescue_tui"`, `"kexec_handoff"`.
1167 pub boot_step_reached: String,
1168 /// Classified failure code. String (not enum) so new
1169 /// classifications can be added without a `schema_version`
1170 /// bump. Consumer convention: treat unknown codes as
1171 /// `"unclassified"`.
1172 pub failure_class: String,
1173 /// Opaque hash (`sha256:<64-hex>`) of the full raw error text.
1174 /// Lets a maintainer match two field reports as "same failure"
1175 /// without either operator sharing the raw text.
1176 pub failure_hash: String,
1177}
1178
1179/// One curated catalog entry. Used in both
1180/// [`RecommendCatalogReport::entries`] and
1181/// [`RecommendSingleReport::entry`].
1182#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1183#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1184pub struct RecommendEntry {
1185 /// Short stable identifier (e.g. `"ubuntu-24.04-live-server"`).
1186 /// Used by `aegis-boot fetch <slug>` to resolve a URL set.
1187 pub slug: String,
1188 /// Human-readable name (e.g. `"Ubuntu 24.04 LTS Live Server"`).
1189 pub name: String,
1190 /// CPU architecture (`"amd64"`, `"arm64"`, …).
1191 pub arch: String,
1192 /// ISO size in mebibytes (rounded to nearest; informational
1193 /// for download-time estimates, not a strict guarantee).
1194 /// `u32` accommodates up to ~4 PiB — plenty of headroom for
1195 /// any realistic ISO.
1196 pub size_mib: u32,
1197 /// HTTPS URL of the ISO body.
1198 pub iso_url: String,
1199 /// HTTPS URL of the upstream SHA256SUMS file.
1200 pub sha256_url: String,
1201 /// HTTPS URL of the detached signature over the SHA256SUMS file
1202 /// (typically a GPG `.gpg`).
1203 pub sig_url: String,
1204 /// Secure Boot status string — one of `"signed:<vendor>"` (e.g.
1205 /// `"signed:canonical"`), `"unsigned-needs-mok"`, or
1206 /// `"unknown"`.
1207 pub sb: String,
1208 /// One-line operator-facing purpose (e.g. `"Standard server
1209 /// install media"`).
1210 pub purpose: String,
1211}
1212
1213#[cfg(test)]
1214#[allow(clippy::unwrap_used, clippy::expect_used)]
1215mod tests {
1216 use super::*;
1217
1218 fn sample_manifest() -> Manifest {
1219 Manifest {
1220 schema_version: SCHEMA_VERSION,
1221 tool_version: "aegis-boot 0.14.1".to_string(),
1222 sequence: 42,
1223 device: Device {
1224 disk_guid: "00000000-0000-0000-0000-000000000001".to_string(),
1225 partition_count: 2,
1226 esp: EspPartition {
1227 partuuid: "aaa".to_string(),
1228 type_guid: "C12A7328-F81F-11D2-BA4B-00A0C93EC93B".to_string(),
1229 fs_uuid: "1234-ABCD".to_string(),
1230 first_lba: 2048,
1231 last_lba: 821_247,
1232 },
1233 data: DataPartition {
1234 partuuid: "bbb".to_string(),
1235 type_guid: "EBD0A0A2-B9E5-4433-87C0-68B6B72699C7".to_string(),
1236 fs_uuid: "ABCD-1234".to_string(),
1237 label: "AEGIS_ISOS".to_string(),
1238 },
1239 },
1240 esp_files: vec![EspFileEntry {
1241 path: "::/EFI/BOOT/BOOTX64.EFI".to_string(),
1242 sha256: "0".repeat(64),
1243 size_bytes: 947_200,
1244 }],
1245 allowed_files_closed_set: true,
1246 expected_pcrs: vec![],
1247 }
1248 }
1249
1250 #[test]
1251 fn schema_version_is_one() {
1252 // Bumping this is intentional and downstream-visible.
1253 assert_eq!(SCHEMA_VERSION, 1);
1254 }
1255
1256 #[test]
1257 fn round_trip_preserves_all_fields() {
1258 let m = sample_manifest();
1259 let body = serde_json::to_string(&m).expect("serialize");
1260 let parsed: Manifest = serde_json::from_str(&body).expect("parse");
1261 assert_eq!(m, parsed);
1262 }
1263
1264 #[test]
1265 fn serialized_uses_manifest_sequence_wire_name() {
1266 let m = sample_manifest();
1267 let body = serde_json::to_string(&m).expect("serialize");
1268 assert!(
1269 body.contains("\"manifest_sequence\":42"),
1270 "wire field should be manifest_sequence, got: {body}"
1271 );
1272 assert!(
1273 !body.contains("\"sequence\":"),
1274 "bare `sequence` leak would break verifiers: {body}"
1275 );
1276 }
1277
1278 fn sample_attestation() -> Attestation {
1279 Attestation {
1280 schema_version: ATTESTATION_SCHEMA_VERSION,
1281 tool_version: "aegis-boot 0.14.1".to_string(),
1282 flashed_at: "2026-04-19T14:30:00Z".to_string(),
1283 operator: "alice".to_string(),
1284 host: HostInfo {
1285 kernel: "6.17.0-20-generic".to_string(),
1286 secure_boot: "enforcing".to_string(),
1287 },
1288 target: TargetInfo {
1289 device: "/dev/sda".to_string(),
1290 model: "SanDisk Cruzer 32 GB".to_string(),
1291 size_bytes: 32_000_000_000,
1292 image_sha256: "f".repeat(64),
1293 image_size_bytes: 536_870_912,
1294 disk_guid: "00000000-0000-0000-0000-000000000001".to_string(),
1295 },
1296 isos: vec![IsoRecord {
1297 filename: "ubuntu-24.04.iso".to_string(),
1298 sha256: "a".repeat(64),
1299 size_bytes: 5_368_709_120,
1300 sidecars: vec!["ubuntu-24.04.iso.aegis.toml".to_string()],
1301 added_at: "2026-04-19T14:35:00Z".to_string(),
1302 }],
1303 }
1304 }
1305
1306 #[test]
1307 fn attestation_schema_version_is_one() {
1308 // Independent contract from the on-ESP manifest; bumping is
1309 // intentional and consumer-visible.
1310 assert_eq!(ATTESTATION_SCHEMA_VERSION, 1);
1311 }
1312
1313 #[test]
1314 fn attestation_round_trip_preserves_all_fields() {
1315 let a = sample_attestation();
1316 let body = serde_json::to_string(&a).expect("serialize");
1317 let parsed: Attestation = serde_json::from_str(&body).expect("parse");
1318 assert_eq!(a, parsed);
1319 }
1320
1321 #[test]
1322 fn empty_isos_list_serializes_as_empty_array() {
1323 // A freshly-flashed stick has `isos: []` — the consumer
1324 // contract is that the array field is always present,
1325 // never omitted. Guards against accidentally adding
1326 // `#[serde(skip_serializing_if = "Vec::is_empty")]`.
1327 let mut a = sample_attestation();
1328 a.isos.clear();
1329 let body = serde_json::to_string(&a).expect("serialize");
1330 assert!(body.contains("\"isos\":[]"), "isos must be present: {body}");
1331 }
1332
1333 fn sample_version() -> Version {
1334 Version {
1335 schema_version: VERSION_SCHEMA_VERSION,
1336 tool: "aegis-boot".to_string(),
1337 version: "0.14.1".to_string(),
1338 }
1339 }
1340
1341 #[test]
1342 fn version_schema_version_is_one() {
1343 assert_eq!(VERSION_SCHEMA_VERSION, 1);
1344 }
1345
1346 #[test]
1347 fn version_round_trip_preserves_all_fields() {
1348 let v = sample_version();
1349 let body = serde_json::to_string(&v).expect("serialize");
1350 let parsed: Version = serde_json::from_str(&body).expect("parse");
1351 assert_eq!(v, parsed);
1352 }
1353
1354 #[test]
1355 fn version_wire_field_order_matches_documented_shape() {
1356 // docs/CLI.md pins the shape as `{ schema_version, tool,
1357 // version }` — serde's default field order is declaration
1358 // order; this test is the guard against accidental reorder.
1359 let v = sample_version();
1360 let body = serde_json::to_string(&v).expect("serialize");
1361 let sv_pos = body.find("\"schema_version\"").expect("sv");
1362 let tool_pos = body.find("\"tool\"").expect("tool");
1363 let ver_pos = body.find("\"version\"").expect("version");
1364 assert!(sv_pos < tool_pos, "schema_version before tool: {body}");
1365 assert!(tool_pos < ver_pos, "tool before version: {body}");
1366 }
1367
1368 fn sample_list_report() -> ListReport {
1369 ListReport {
1370 schema_version: LIST_SCHEMA_VERSION,
1371 tool_version: "0.14.1".to_string(),
1372 mount_path: "/run/media/alice/AEGIS_ISOS".to_string(),
1373 attestation: Some(ListAttestationSummary {
1374 flashed_at: "2026-04-19T14:30:00Z".to_string(),
1375 operator: "alice".to_string(),
1376 isos_recorded: 3,
1377 manifest_path: "/home/alice/.local/share/aegis-boot/attestations/abc.json"
1378 .to_string(),
1379 }),
1380 count: 2,
1381 isos: vec![
1382 ListIsoSummary {
1383 name: "ubuntu-24.04.iso".to_string(),
1384 folder: Some("ubuntu-24.04".to_string()),
1385 size_bytes: 5_368_709_120,
1386 has_sha256: true,
1387 has_minisig: false,
1388 display_name: Some("Ubuntu 24.04 Desktop".to_string()),
1389 description: None,
1390 },
1391 ListIsoSummary {
1392 name: "debian-12.iso".to_string(),
1393 folder: None,
1394 size_bytes: 3_221_225_472,
1395 has_sha256: false,
1396 has_minisig: false,
1397 display_name: None,
1398 description: None,
1399 },
1400 ],
1401 }
1402 }
1403
1404 #[test]
1405 fn list_schema_version_is_one() {
1406 assert_eq!(LIST_SCHEMA_VERSION, 1);
1407 }
1408
1409 #[test]
1410 fn list_round_trip_preserves_all_fields() {
1411 let r = sample_list_report();
1412 let body = serde_json::to_string(&r).expect("serialize");
1413 let parsed: ListReport = serde_json::from_str(&body).expect("parse");
1414 assert_eq!(r, parsed);
1415 }
1416
1417 #[test]
1418 fn list_attestation_serializes_as_null_when_absent() {
1419 // A stick flashed on a different host has no matching
1420 // attestation record — the field is `null`, NOT omitted.
1421 // Scripted consumers depend on a stable field set.
1422 let mut r = sample_list_report();
1423 r.attestation = None;
1424 let body = serde_json::to_string(&r).expect("serialize");
1425 assert!(
1426 body.contains("\"attestation\":null"),
1427 "attestation must be explicit null: {body}"
1428 );
1429 }
1430
1431 #[test]
1432 fn list_iso_summary_preserves_null_sidecar_fields() {
1433 // display_name + description are `null` when the
1434 // `.aegis.toml` sidecar is absent. This stable-shape
1435 // property was called out explicitly in inventory.rs's
1436 // original emitter (see the comment around #246). Guards
1437 // against an accidental `skip_serializing_if`.
1438 let mut r = sample_list_report();
1439 r.isos[1].display_name = None;
1440 r.isos[1].description = None;
1441 let body = serde_json::to_string(&r).expect("serialize");
1442 // The second ISO entry should contain both fields as null.
1443 assert!(
1444 body.contains("\"display_name\":null"),
1445 "display_name missing or omitted: {body}"
1446 );
1447 assert!(
1448 body.contains("\"description\":null"),
1449 "description missing or omitted: {body}"
1450 );
1451 }
1452
1453 fn sample_attest_list_success() -> AttestListSuccess {
1454 AttestListSuccess {
1455 manifest_path: "/home/alice/.local/share/aegis-boot/attestations/abc.json".to_string(),
1456 schema_version: ATTESTATION_SCHEMA_VERSION,
1457 tool_version: "aegis-boot 0.14.1".to_string(),
1458 flashed_at: "2026-04-19T14:30:00Z".to_string(),
1459 operator: "alice".to_string(),
1460 target_device: "/dev/sda".to_string(),
1461 target_model: "SanDisk Cruzer".to_string(),
1462 disk_guid: "00000000-0000-0000-0000-000000000001".to_string(),
1463 iso_count: 3,
1464 }
1465 }
1466
1467 #[test]
1468 fn attest_list_schema_version_is_one() {
1469 assert_eq!(ATTEST_LIST_SCHEMA_VERSION, 1);
1470 }
1471
1472 #[test]
1473 fn attest_list_success_serializes_without_error_field() {
1474 // The untagged enum's Success variant must NOT emit an
1475 // `error` key — that's how consumers branch between the
1476 // two shapes.
1477 let entry = AttestListEntry::Success(sample_attest_list_success());
1478 let body = serde_json::to_string(&entry).expect("serialize");
1479 assert!(!body.contains("\"error\""), "must not have error: {body}");
1480 assert!(body.contains("\"operator\":\"alice\""));
1481 }
1482
1483 #[test]
1484 fn attest_list_error_serializes_without_summary_fields() {
1485 // The Error variant must NOT emit any of the success
1486 // fields (schema_version, tool_version, flashed_at,
1487 // operator, target_device, target_model, disk_guid,
1488 // iso_count). This is the mutually-exclusive shape
1489 // contract that Phase 4b-3's untagged enum preserves.
1490 let entry = AttestListEntry::Error(AttestListError {
1491 manifest_path: "/tmp/broken.json".to_string(),
1492 error: "parse failed: missing field".to_string(),
1493 });
1494 let body = serde_json::to_string(&entry).expect("serialize");
1495 assert!(body.contains("\"error\":"));
1496 for success_field in &[
1497 "schema_version",
1498 "tool_version",
1499 "flashed_at",
1500 "operator",
1501 "target_device",
1502 "target_model",
1503 "disk_guid",
1504 "iso_count",
1505 ] {
1506 let pattern = format!("\"{success_field}\"");
1507 assert!(
1508 !body.contains(&pattern),
1509 "Error variant must not emit {success_field}: {body}"
1510 );
1511 }
1512 }
1513
1514 #[test]
1515 fn attest_list_entry_round_trips() {
1516 // An untagged-enum round-trip through serde must pick the
1517 // right variant based on field shape. Success → Success,
1518 // Error → Error.
1519 let success = AttestListEntry::Success(sample_attest_list_success());
1520 let body = serde_json::to_string(&success).expect("serialize");
1521 let parsed: AttestListEntry = serde_json::from_str(&body).expect("parse");
1522 assert_eq!(success, parsed);
1523
1524 let err = AttestListEntry::Error(AttestListError {
1525 manifest_path: "/tmp/x.json".to_string(),
1526 error: "nope".to_string(),
1527 });
1528 let body = serde_json::to_string(&err).expect("serialize");
1529 let parsed: AttestListEntry = serde_json::from_str(&body).expect("parse");
1530 assert_eq!(err, parsed);
1531 }
1532
1533 fn sample_verify_report() -> VerifyReport {
1534 VerifyReport {
1535 schema_version: VERIFY_SCHEMA_VERSION,
1536 tool_version: "0.14.1".to_string(),
1537 mount_path: "/run/media/alice/AEGIS_ISOS".to_string(),
1538 summary: VerifySummary {
1539 total: 4,
1540 verified: 1,
1541 mismatch: 1,
1542 unreadable: 1,
1543 not_present: 1,
1544 any_failure: true,
1545 },
1546 isos: vec![
1547 VerifyEntry {
1548 name: "ubuntu.iso".to_string(),
1549 verdict: VerifyVerdict::Verified {
1550 digest: "a".repeat(64),
1551 source: "sidecar".to_string(),
1552 },
1553 },
1554 VerifyEntry {
1555 name: "debian.iso".to_string(),
1556 verdict: VerifyVerdict::Mismatch {
1557 actual: "b".repeat(64),
1558 expected: "c".repeat(64),
1559 source: "sidecar".to_string(),
1560 },
1561 },
1562 VerifyEntry {
1563 name: "alpine.iso".to_string(),
1564 verdict: VerifyVerdict::Unreadable {
1565 source: "sidecar".to_string(),
1566 reason: "permission denied".to_string(),
1567 },
1568 },
1569 VerifyEntry {
1570 name: "fedora.iso".to_string(),
1571 verdict: VerifyVerdict::NotPresent,
1572 },
1573 ],
1574 }
1575 }
1576
1577 #[test]
1578 fn verify_schema_version_is_one() {
1579 assert_eq!(VERIFY_SCHEMA_VERSION, 1);
1580 }
1581
1582 #[test]
1583 fn verify_round_trip_preserves_all_variants() {
1584 let r = sample_verify_report();
1585 let body = serde_json::to_string(&r).expect("serialize");
1586 let parsed: VerifyReport = serde_json::from_str(&body).expect("parse");
1587 assert_eq!(r, parsed);
1588 }
1589
1590 #[test]
1591 fn verify_entry_emits_name_before_verdict() {
1592 // Consumer contract: `name` is the first key so a
1593 // streaming JSON parser can key off it before seeing the
1594 // variant-specific fields. `#[serde(flatten)]` on the
1595 // `verdict` field + internally-tagged enum gives us this
1596 // ordering for free; this test is the guard.
1597 let entry = VerifyEntry {
1598 name: "x".to_string(),
1599 verdict: VerifyVerdict::NotPresent,
1600 };
1601 let body = serde_json::to_string(&entry).expect("serialize");
1602 let name_pos = body.find("\"name\"").expect("has name");
1603 let verdict_pos = body.find("\"verdict\"").expect("has verdict");
1604 assert!(
1605 name_pos < verdict_pos,
1606 "name must come before verdict: {body}"
1607 );
1608 }
1609
1610 #[test]
1611 fn verify_notpresent_emits_no_variant_fields() {
1612 // The unit variant NotPresent must NOT emit `digest`,
1613 // `actual`, `expected`, `source`, or `reason` — those are
1614 // variant-specific and would confuse a consumer that
1615 // dispatched on the `verdict` tag.
1616 let entry = VerifyEntry {
1617 name: "x".to_string(),
1618 verdict: VerifyVerdict::NotPresent,
1619 };
1620 let body = serde_json::to_string(&entry).expect("serialize");
1621 for field in &["digest", "actual", "expected", "source", "reason"] {
1622 let pattern = format!("\"{field}\"");
1623 assert!(
1624 !body.contains(&pattern),
1625 "NotPresent must not emit {field}: {body}"
1626 );
1627 }
1628 }
1629
1630 #[test]
1631 fn verify_verdict_tags_match_strings() {
1632 // The four tag strings are part of the wire contract.
1633 // Consumers branch on these literals; this test pins the
1634 // spelling.
1635 let v = VerifyEntry {
1636 name: "x".to_string(),
1637 verdict: VerifyVerdict::Verified {
1638 digest: "d".to_string(),
1639 source: "s".to_string(),
1640 },
1641 };
1642 let body = serde_json::to_string(&v).expect("serialize");
1643 assert!(body.contains("\"verdict\":\"Verified\""));
1644
1645 let m = VerifyEntry {
1646 name: "x".to_string(),
1647 verdict: VerifyVerdict::Mismatch {
1648 actual: "a".to_string(),
1649 expected: "e".to_string(),
1650 source: "s".to_string(),
1651 },
1652 };
1653 assert!(
1654 serde_json::to_string(&m)
1655 .expect("ok")
1656 .contains("\"verdict\":\"Mismatch\"")
1657 );
1658
1659 let u = VerifyEntry {
1660 name: "x".to_string(),
1661 verdict: VerifyVerdict::Unreadable {
1662 source: "s".to_string(),
1663 reason: "r".to_string(),
1664 },
1665 };
1666 assert!(
1667 serde_json::to_string(&u)
1668 .expect("ok")
1669 .contains("\"verdict\":\"Unreadable\"")
1670 );
1671
1672 let n = VerifyEntry {
1673 name: "x".to_string(),
1674 verdict: VerifyVerdict::NotPresent,
1675 };
1676 assert!(
1677 serde_json::to_string(&n)
1678 .expect("ok")
1679 .contains("\"verdict\":\"NotPresent\"")
1680 );
1681 }
1682
1683 fn sample_update_eligible() -> UpdateReport {
1684 UpdateReport {
1685 schema_version: UPDATE_SCHEMA_VERSION,
1686 tool_version: "0.14.1".to_string(),
1687 device: "/dev/sda".to_string(),
1688 eligibility: UpdateEligibility::Eligible {
1689 disk_guid: "00000000-0000-0000-0000-000000000001".to_string(),
1690 attestation_path: "/home/alice/.local/share/aegis-boot/attestations/abc.json"
1691 .to_string(),
1692 host_chain: vec![
1693 UpdateChainEntry {
1694 role: "shim".to_string(),
1695 path: "/usr/lib/shim/shimx64.efi.signed".to_string(),
1696 result: UpdateChainResult::Ok {
1697 sha256: "a".repeat(64),
1698 },
1699 },
1700 UpdateChainEntry {
1701 role: "grub".to_string(),
1702 path: "/usr/lib/grub/x86_64-efi/grubx64.efi".to_string(),
1703 result: UpdateChainResult::Error {
1704 error: "file not found".to_string(),
1705 },
1706 },
1707 ],
1708 esp_diff: vec![UpdateFileDiff {
1709 role: "shim".to_string(),
1710 esp_path: "/EFI/BOOT/BOOTX64.EFI".to_string(),
1711 current_sha256: Some("b".repeat(64)),
1712 current_error: None,
1713 fresh_sha256: Some("a".repeat(64)),
1714 fresh_error: None,
1715 would_change: true,
1716 }],
1717 },
1718 }
1719 }
1720
1721 fn sample_update_ineligible() -> UpdateReport {
1722 UpdateReport {
1723 schema_version: UPDATE_SCHEMA_VERSION,
1724 tool_version: "0.14.1".to_string(),
1725 device: "/dev/sdb".to_string(),
1726 eligibility: UpdateEligibility::Ineligible {
1727 reason: "device is not removable (looks like an internal disk)".to_string(),
1728 },
1729 }
1730 }
1731
1732 #[test]
1733 fn update_schema_version_is_two() {
1734 // Bumped 1 -> 2 in #181 Phase 1 (added `esp_diff`).
1735 // `serde(default)` + skip_serializing_if empty vec makes the
1736 // bump backward-compatible — see
1737 // `update_report_parses_v1_payload_without_esp_diff`.
1738 assert_eq!(UPDATE_SCHEMA_VERSION, 2);
1739 }
1740
1741 #[test]
1742 fn update_report_parses_v1_payload_without_esp_diff() {
1743 // Forward-compatibility: old producers pre-#181 Phase 1 emit
1744 // UpdateReport without `esp_diff`. A current parser MUST
1745 // accept that — the field defaults to an empty Vec.
1746 let v1_body = r#"{
1747 "schema_version": 1,
1748 "tool_version": "0.14.1",
1749 "device": "/dev/sda",
1750 "eligibility": "ELIGIBLE",
1751 "disk_guid": "00000000-0000-0000-0000-000000000001",
1752 "attestation_path": "/tmp/att.json",
1753 "host_chain": []
1754 }"#;
1755 let parsed: UpdateReport = serde_json::from_str(v1_body).expect("v1 parse");
1756 match parsed.eligibility {
1757 UpdateEligibility::Eligible { esp_diff, .. } => assert!(esp_diff.is_empty()),
1758 UpdateEligibility::Ineligible { .. } => panic!("expected ELIGIBLE"),
1759 }
1760 }
1761
1762 #[test]
1763 fn update_file_diff_omits_none_fields_on_wire() {
1764 let d = UpdateFileDiff {
1765 role: "shim".to_string(),
1766 esp_path: "/EFI/BOOT/BOOTX64.EFI".to_string(),
1767 current_sha256: Some("a".repeat(64)),
1768 current_error: None,
1769 fresh_sha256: Some("a".repeat(64)),
1770 fresh_error: None,
1771 would_change: false,
1772 };
1773 let body = serde_json::to_string(&d).expect("serialize");
1774 assert!(body.contains("\"current_sha256\""));
1775 assert!(body.contains("\"fresh_sha256\""));
1776 // None-valued error fields must not leak onto the wire.
1777 assert!(!body.contains("current_error"), "{body}");
1778 assert!(!body.contains("fresh_error"), "{body}");
1779 assert!(body.contains("\"would_change\":false"));
1780 }
1781
1782 #[test]
1783 fn update_file_diff_round_trips_with_errors() {
1784 let d = UpdateFileDiff {
1785 role: "kernel".to_string(),
1786 esp_path: "/vmlinuz".to_string(),
1787 current_sha256: None,
1788 current_error: Some("mtype exited non-zero".to_string()),
1789 fresh_sha256: Some("f".repeat(64)),
1790 fresh_error: None,
1791 would_change: false,
1792 };
1793 let body = serde_json::to_string(&d).expect("serialize");
1794 let back: UpdateFileDiff = serde_json::from_str(&body).expect("parse");
1795 assert_eq!(d, back);
1796 }
1797
1798 #[test]
1799 fn update_round_trip_preserves_all_variants() {
1800 let eligible = sample_update_eligible();
1801 let body = serde_json::to_string(&eligible).expect("serialize");
1802 let parsed: UpdateReport = serde_json::from_str(&body).expect("parse");
1803 assert_eq!(eligible, parsed);
1804
1805 let ineligible = sample_update_ineligible();
1806 let body = serde_json::to_string(&ineligible).expect("serialize");
1807 let parsed: UpdateReport = serde_json::from_str(&body).expect("parse");
1808 assert_eq!(ineligible, parsed);
1809 }
1810
1811 #[test]
1812 fn update_emits_header_fields_before_eligibility() {
1813 // Consumer contract: `schema_version, tool_version, device`
1814 // appear before the `eligibility` tag. Pre-flatten, the
1815 // field order is pinned by struct declaration.
1816 let r = sample_update_ineligible();
1817 let body = serde_json::to_string(&r).expect("serialize");
1818 let sv_pos = body.find("\"schema_version\"").expect("sv");
1819 let tv_pos = body.find("\"tool_version\"").expect("tv");
1820 let dev_pos = body.find("\"device\"").expect("dev");
1821 let elig_pos = body.find("\"eligibility\"").expect("eligibility");
1822 assert!(sv_pos < tv_pos, "{body}");
1823 assert!(tv_pos < dev_pos, "{body}");
1824 assert!(dev_pos < elig_pos, "{body}");
1825 }
1826
1827 #[test]
1828 fn update_eligibility_tags_match_upper_case_wire_strings() {
1829 let e = sample_update_eligible();
1830 let body = serde_json::to_string(&e).expect("serialize");
1831 assert!(body.contains("\"eligibility\":\"ELIGIBLE\""), "{body}");
1832 let i = sample_update_ineligible();
1833 let body = serde_json::to_string(&i).expect("serialize");
1834 assert!(body.contains("\"eligibility\":\"INELIGIBLE\""), "{body}");
1835 }
1836
1837 #[test]
1838 fn update_chain_entry_variants_are_mutually_exclusive() {
1839 // Success variant emits sha256, no error field.
1840 let ok = UpdateChainEntry {
1841 role: "shim".to_string(),
1842 path: "/path/to/shim".to_string(),
1843 result: UpdateChainResult::Ok {
1844 sha256: "a".repeat(64),
1845 },
1846 };
1847 let body = serde_json::to_string(&ok).expect("serialize");
1848 assert!(body.contains("\"sha256\""));
1849 assert!(!body.contains("\"error\""), "{body}");
1850 // Error variant emits error, no sha256 field.
1851 let err = UpdateChainEntry {
1852 role: "grub".to_string(),
1853 path: "/path/to/grub".to_string(),
1854 result: UpdateChainResult::Error {
1855 error: "missing".to_string(),
1856 },
1857 };
1858 let body = serde_json::to_string(&err).expect("serialize");
1859 assert!(body.contains("\"error\""));
1860 assert!(!body.contains("\"sha256\""), "{body}");
1861 }
1862
1863 fn sample_recommend_entry() -> RecommendEntry {
1864 RecommendEntry {
1865 slug: "ubuntu-24.04-live-server".to_string(),
1866 name: "Ubuntu 24.04 LTS Live Server".to_string(),
1867 arch: "amd64".to_string(),
1868 size_mib: 2_400_u32,
1869 iso_url: "https://releases.ubuntu.com/24.04/ubuntu-24.04-live-server-amd64.iso"
1870 .to_string(),
1871 sha256_url: "https://releases.ubuntu.com/24.04/SHA256SUMS".to_string(),
1872 sig_url: "https://releases.ubuntu.com/24.04/SHA256SUMS.gpg".to_string(),
1873 sb: "signed:canonical".to_string(),
1874 purpose: "Standard server install media".to_string(),
1875 }
1876 }
1877
1878 #[test]
1879 fn recommend_schema_version_is_one() {
1880 assert_eq!(RECOMMEND_SCHEMA_VERSION, 1);
1881 }
1882
1883 #[test]
1884 fn recommend_catalog_round_trips() {
1885 let report = RecommendReport::Catalog(RecommendCatalogReport {
1886 schema_version: RECOMMEND_SCHEMA_VERSION,
1887 tool_version: "0.14.1".to_string(),
1888 count: 1,
1889 entries: vec![sample_recommend_entry()],
1890 });
1891 let body = serde_json::to_string(&report).expect("serialize");
1892 let parsed: RecommendReport = serde_json::from_str(&body).expect("parse");
1893 assert_eq!(report, parsed);
1894 assert!(body.contains("\"entries\""));
1895 assert!(!body.contains("\"entry\""));
1896 assert!(!body.contains("\"error\""));
1897 }
1898
1899 #[test]
1900 fn recommend_single_round_trips() {
1901 let report = RecommendReport::Single(RecommendSingleReport {
1902 schema_version: RECOMMEND_SCHEMA_VERSION,
1903 tool_version: "0.14.1".to_string(),
1904 entry: sample_recommend_entry(),
1905 });
1906 let body = serde_json::to_string(&report).expect("serialize");
1907 let parsed: RecommendReport = serde_json::from_str(&body).expect("parse");
1908 assert_eq!(report, parsed);
1909 assert!(body.contains("\"entry\""));
1910 assert!(!body.contains("\"entries\""));
1911 assert!(!body.contains("\"error\""));
1912 }
1913
1914 #[test]
1915 fn recommend_miss_round_trips_and_omits_tool_version() {
1916 // The miss envelope intentionally does NOT carry
1917 // tool_version — that's the existing wire-format asymmetry
1918 // we're preserving. Phase 4b-6 keeps this.
1919 let report = RecommendReport::Miss(RecommendMissReport {
1920 schema_version: RECOMMEND_SCHEMA_VERSION,
1921 error: "no catalog entry matching 'x'".to_string(),
1922 });
1923 let body = serde_json::to_string(&report).expect("serialize");
1924 let parsed: RecommendReport = serde_json::from_str(&body).expect("parse");
1925 assert_eq!(report, parsed);
1926 assert!(body.contains("\"error\""));
1927 assert!(
1928 !body.contains("\"tool_version\""),
1929 "miss omits tool_version: {body}"
1930 );
1931 }
1932
1933 #[test]
1934 fn recommend_untagged_dispatch_by_field_presence() {
1935 // Serde-untagged distinguishes the three variants by the
1936 // presence of their signature fields (entries / entry /
1937 // error). This test pins that an out-of-band parser that
1938 // dispatches on field presence can recover the right
1939 // variant from bytes alone.
1940 let catalog_body = r#"{"schema_version":1,"tool_version":"0.1.0","count":0,"entries":[]}"#;
1941 let parsed: RecommendReport = serde_json::from_str(catalog_body).expect("catalog parse");
1942 assert!(matches!(parsed, RecommendReport::Catalog(_)));
1943
1944 let miss_body = r#"{"schema_version":1,"error":"not found"}"#;
1945 let parsed: RecommendReport = serde_json::from_str(miss_body).expect("miss parse");
1946 assert!(matches!(parsed, RecommendReport::Miss(_)));
1947 }
1948
1949 fn sample_compat_entry() -> CompatEntry {
1950 CompatEntry {
1951 vendor: "Framework".to_string(),
1952 model: "Laptop (12th Gen Intel Core) / A6".to_string(),
1953 firmware: "INSYDE Corp. 03.19".to_string(),
1954 sb_state: "enforcing".to_string(),
1955 boot_key: "F12".to_string(),
1956 level: "verified".to_string(),
1957 reported_by: "@williamzujkowski".to_string(),
1958 date: "2026-04-18".to_string(),
1959 notes: vec!["Full chain validated".to_string()],
1960 }
1961 }
1962
1963 #[test]
1964 fn compat_schema_versions_are_one() {
1965 assert_eq!(COMPAT_SCHEMA_VERSION, 1);
1966 assert_eq!(COMPAT_SUBMIT_SCHEMA_VERSION, 1);
1967 }
1968
1969 #[test]
1970 fn compat_report_catalog_round_trips() {
1971 let report = CompatReport::Catalog(CompatCatalogReport {
1972 schema_version: COMPAT_SCHEMA_VERSION,
1973 tool_version: "0.14.1".to_string(),
1974 report_url: "https://example.com/report".to_string(),
1975 count: 1,
1976 entries: vec![sample_compat_entry()],
1977 });
1978 let body = serde_json::to_string(&report).expect("serialize");
1979 let parsed: CompatReport = serde_json::from_str(&body).expect("parse");
1980 assert_eq!(report, parsed);
1981 assert!(body.contains("\"entries\""));
1982 }
1983
1984 #[test]
1985 fn compat_report_miss_omits_tool_version() {
1986 let report = CompatReport::Miss(CompatMissReport {
1987 schema_version: COMPAT_SCHEMA_VERSION,
1988 report_url: "https://example.com/report".to_string(),
1989 error: "no platform matching 'foo'".to_string(),
1990 });
1991 let body = serde_json::to_string(&report).expect("serialize");
1992 assert!(body.contains("\"report_url\""));
1993 assert!(body.contains("\"error\""));
1994 assert!(
1995 !body.contains("\"tool_version\""),
1996 "miss omits tool_version: {body}"
1997 );
1998 }
1999
2000 #[test]
2001 fn compat_report_my_machine_miss_has_minimal_shape() {
2002 let report = CompatReport::MyMachineMiss(CompatMyMachineMissReport {
2003 schema_version: COMPAT_SCHEMA_VERSION,
2004 error: "--my-machine: DMI fields unavailable".to_string(),
2005 });
2006 let body = serde_json::to_string(&report).expect("serialize");
2007 assert!(body.contains("\"error\""));
2008 assert!(!body.contains("\"report_url\""));
2009 assert!(!body.contains("\"tool_version\""));
2010 }
2011
2012 #[test]
2013 fn compat_untagged_dispatch_by_field_presence() {
2014 let body =
2015 r#"{"schema_version":1,"tool_version":"0.1","report_url":"x","count":0,"entries":[]}"#;
2016 assert!(matches!(
2017 serde_json::from_str::<CompatReport>(body).expect("catalog"),
2018 CompatReport::Catalog(_)
2019 ));
2020
2021 let body = r#"{"schema_version":1,"tool_version":"0.1","report_url":"x","entry":{"vendor":"","model":"","firmware":"","sb_state":"","boot_key":"","level":"","reported_by":"","date":"","notes":[]}}"#;
2022 assert!(matches!(
2023 serde_json::from_str::<CompatReport>(body).expect("single"),
2024 CompatReport::Single(_)
2025 ));
2026
2027 let body = r#"{"schema_version":1,"report_url":"x","error":"nope"}"#;
2028 assert!(matches!(
2029 serde_json::from_str::<CompatReport>(body).expect("miss"),
2030 CompatReport::Miss(_)
2031 ));
2032
2033 let body = r#"{"schema_version":1,"error":"dmi"}"#;
2034 assert!(matches!(
2035 serde_json::from_str::<CompatReport>(body).expect("mymachine"),
2036 CompatReport::MyMachineMiss(_)
2037 ));
2038 }
2039
2040 #[test]
2041 fn compat_submit_uses_tool_not_tool_version() {
2042 let r = CompatSubmitReport {
2043 schema_version: COMPAT_SUBMIT_SCHEMA_VERSION,
2044 tool: "aegis-boot".to_string(),
2045 submit_url: "https://example.com/new?vendor=x".to_string(),
2046 vendor: "x".to_string(),
2047 model: "y".to_string(),
2048 firmware: "z".to_string(),
2049 };
2050 let body = serde_json::to_string(&r).expect("serialize");
2051 let parsed: CompatSubmitReport = serde_json::from_str(&body).expect("parse");
2052 assert_eq!(r, parsed);
2053 assert!(body.contains("\"tool\":\"aegis-boot\""));
2054 assert!(!body.contains("\"tool_version\""), "{body}");
2055 assert!(body.contains("\"submit_url\""));
2056 }
2057
2058 fn sample_doctor_report() -> DoctorReport {
2059 DoctorReport {
2060 schema_version: DOCTOR_SCHEMA_VERSION,
2061 tool_version: "0.14.1".to_string(),
2062 score: 85,
2063 band: "GOOD".to_string(),
2064 has_any_fail: false,
2065 next_action: None,
2066 rows: vec![
2067 DoctorRow {
2068 verdict: "PASS".to_string(),
2069 name: "OS".to_string(),
2070 detail: "Linux 6.17.0".to_string(),
2071 },
2072 DoctorRow {
2073 verdict: "WARN".to_string(),
2074 name: "Secure Boot (host)".to_string(),
2075 detail: "disabled".to_string(),
2076 },
2077 ],
2078 }
2079 }
2080
2081 #[test]
2082 fn doctor_schema_version_is_one() {
2083 assert_eq!(DOCTOR_SCHEMA_VERSION, 1);
2084 }
2085
2086 #[test]
2087 fn doctor_round_trips_and_preserves_all_fields() {
2088 let r = sample_doctor_report();
2089 let body = serde_json::to_string(&r).expect("serialize");
2090 let parsed: DoctorReport = serde_json::from_str(&body).expect("parse");
2091 assert_eq!(r, parsed);
2092 }
2093
2094 #[test]
2095 fn doctor_next_action_null_when_absent() {
2096 // next_action = null when no FAIL row carried a remedy.
2097 // Must serialize as `null` (not omitted) to keep the field
2098 // set stable for consumers.
2099 let r = sample_doctor_report();
2100 let body = serde_json::to_string(&r).expect("serialize");
2101 assert!(
2102 body.contains("\"next_action\":null"),
2103 "next_action must be explicit null: {body}"
2104 );
2105 }
2106
2107 #[test]
2108 fn doctor_next_action_populated_on_fail() {
2109 let mut r = sample_doctor_report();
2110 r.has_any_fail = true;
2111 r.next_action = Some("install mcopy".to_string());
2112 r.rows.push(DoctorRow {
2113 verdict: "FAIL".to_string(),
2114 name: "command: mcopy".to_string(),
2115 detail: "not found in PATH".to_string(),
2116 });
2117 let body = serde_json::to_string(&r).expect("serialize");
2118 assert!(body.contains("\"has_any_fail\":true"));
2119 assert!(body.contains("\"next_action\":\"install mcopy\""));
2120 }
2121
2122 #[test]
2123 fn doctor_row_verdict_is_free_string_not_enum() {
2124 // The verdict field accepts any string — the CLI's
2125 // vocabulary can grow with new verdict labels without
2126 // bumping schema_version. Consumer contract: treat
2127 // unknown verdicts as "informational / don't block."
2128 let r = DoctorRow {
2129 verdict: "FUTURE-VERDICT-LABEL".to_string(),
2130 name: "some new check".to_string(),
2131 detail: "novel condition".to_string(),
2132 };
2133 let body = serde_json::to_string(&r).expect("serialize");
2134 let parsed: DoctorRow = serde_json::from_str(&body).expect("parse");
2135 assert_eq!(r, parsed);
2136 }
2137
2138 #[test]
2139 fn cli_error_schema_version_is_one() {
2140 assert_eq!(CLI_ERROR_SCHEMA_VERSION, 1);
2141 }
2142
2143 #[test]
2144 fn cli_error_round_trips_and_escapes_properly() {
2145 // serde_json handles the escaping — no more hand-rolled
2146 // json_escape needed.
2147 let e = CliError {
2148 schema_version: CLI_ERROR_SCHEMA_VERSION,
2149 error: "mount failed: \"/dev/sdX\" is not removable".to_string(),
2150 };
2151 let body = serde_json::to_string(&e).expect("serialize");
2152 let parsed: CliError = serde_json::from_str(&body).expect("parse");
2153 assert_eq!(e, parsed);
2154 // The embedded quotes must be properly escaped on the wire.
2155 assert!(
2156 body.contains(r#"\"/dev/sdX\""#),
2157 "embedded quotes must be escaped: {body}"
2158 );
2159 }
2160}