embala_setup_overlay/manifest.rs
1//! Overlay manifest (spec R8): product identity + resolved install mode.
2//!
3//! Like the [`crate::Trailer`], the manifest is overlay-format shared by both
4//! sides: the package-time writer (`embala-setup`) serializes it into the
5//! manifest section, and the install-time runtime (`embala-setup-runtime`)
6//! deserializes it to build the `embala.package` table and resolve the install
7//! mode. Serialization is `serde_json` over structs with a fixed field order, so
8//! the bytes are deterministic (no map reordering, no timestamps) — the
9//! reproducibility invariant that makes signing/caching viable later.
10
11use serde::{Deserialize, Serialize};
12
13/// The manifest embedded in the overlay's manifest section.
14///
15/// `arch` and `install_mode` are plain strings (`"x86_64"`/`"aarch64"`,
16/// `"per-user"`/`"per-machine"`/`"user-choice"`) rather than enums so this
17/// crate stays dependency-light and the writer/runtime each keep their own
18/// domain enums without a shared vocabulary type.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct Manifest {
21 /// Echoes [`crate::FORMAT_VERSION`]; a cross-check against the trailer.
22 pub format_version: u32,
23 /// `"x86_64"` | `"aarch64"` — the stub arch this overlay was built for.
24 pub arch: String,
25 /// Resolved default install mode: `"per-user"` | `"per-machine"` |
26 /// `"user-choice"`. A `/mode=` CLI flag overrides it at install time.
27 pub install_mode: String,
28 /// Product identity mirrored from `[package]`.
29 pub package: Package,
30 /// Declarative uninstall options (spec R2), shown as checkboxes by the
31 /// uninstaller's confirm dialog. `#[serde(default)]`: an older sidecar
32 /// without the field parses to an empty list (no `FORMAT_VERSION` bump).
33 #[serde(default)]
34 pub uninstall_options: Vec<UninstallOption>,
35}
36
37/// One declarative uninstall option (spec R2). `default` pre-ticks its checkbox.
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct UninstallOption {
40 pub id: String,
41 pub label: String,
42 #[serde(default)]
43 pub default: bool,
44}
45
46/// Product identity, mirrored from the embala `[package]` section.
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct Package {
49 pub name: String,
50 pub display_name: String,
51 pub version: String,
52 pub identifier: String,
53 pub publisher: String,
54 pub description: String,
55 pub homepage: Option<String>,
56 pub license: Option<String>,
57}
58
59impl Manifest {
60 /// Serialize to deterministic JSON bytes (the writer appends these).
61 pub fn to_bytes(&self) -> Vec<u8> {
62 // serde_json over a struct emits fields in declaration order with no
63 // trailing whitespace — deterministic by construction.
64 serde_json::to_vec(self).expect("manifest serializes")
65 }
66
67 /// Parse the manifest section bytes (the runtime reads these).
68 pub fn parse(bytes: &[u8]) -> Result<Manifest, serde_json::Error> {
69 serde_json::from_slice(bytes)
70 }
71}
72
73#[cfg(test)]
74mod tests {
75 use super::*;
76
77 fn sample() -> Manifest {
78 Manifest {
79 format_version: crate::FORMAT_VERSION,
80 arch: "x86_64".to_string(),
81 install_mode: "per-user".to_string(),
82 package: Package {
83 name: "hello".to_string(),
84 display_name: "Embala Hello".to_string(),
85 version: "0.1.0".to_string(),
86 identifier: "ca.hrndz.embala.hello".to_string(),
87 publisher: "Carlos Hernandez".to_string(),
88 description: "test".to_string(),
89 homepage: Some("https://example.com".to_string()),
90 license: None,
91 },
92 uninstall_options: vec![UninstallOption {
93 id: "purge-data".to_string(),
94 label: "Delete all data".to_string(),
95 default: false,
96 }],
97 }
98 }
99
100 #[test]
101 fn round_trips_through_bytes() {
102 // Why: the writer serializes and the runtime deserializes *one* type; a
103 // field that does not survive the round trip would leave the installer
104 // with a wrong DisplayName/version/identifier in the ARP entry.
105 let m = sample();
106 let parsed = Manifest::parse(&m.to_bytes()).expect("valid manifest");
107 assert_eq!(parsed, m);
108 }
109
110 #[test]
111 fn serialization_is_deterministic() {
112 // Why: byte-reproducibility of the whole setup.exe depends on the
113 // manifest bytes being a pure function of the struct — no map ordering.
114 assert_eq!(sample().to_bytes(), sample().to_bytes());
115 }
116
117 #[test]
118 fn json_without_uninstall_options_parses() {
119 // Why: a sidecar written by a pre-Phase-2 installer has no
120 // `uninstall_options` field; `#[serde(default)]` must let it parse to an
121 // empty list so old uninstall.exe sidecars keep working (spec R2 compat).
122 let json = br#"{"format_version":2,"arch":"x86_64","install_mode":"per-user","package":{"name":"hello","display_name":"Embala Hello","version":"0.1.0","identifier":"ca.hrndz.embala.hello","publisher":"Carlos Hernandez","description":"test","homepage":null,"license":null}}"#;
123 let parsed = Manifest::parse(json).expect("old sidecar must still parse");
124 assert!(parsed.uninstall_options.is_empty());
125 }
126}