Skip to main content

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}