Skip to main content

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}