Skip to main content

embala_setup/
lib.rs

1//! Windows `setup.exe` builder (prebuilt stub + PE overlay).
2//!
3//! Honors embala's byte-writer invariant: `embala build` never compiles. The
4//! Win32 runtime stub (`embala-setup-runtime`) is cross-compiled once by
5//! `just stubs` and committed under `stubs/<target>/setup-stub.exe`; this crate
6//! `include_bytes!`-embeds it and appends `payload.zip + install.lua +
7//! uninstall.lua + manifest + signed_stub + pad + trailer` as a PE overlay — pure byte-writing,
8//! no compile, no network. The `pad` is zero-to-seven bytes so the unsigned file
9//! length is 8-byte aligned: an appended Authenticode cert table then starts at
10//! the security-directory offset the runtime reads as the overlay end. Output is
11//! byte-reproducible: the payload zip pins entry
12//! mtimes and sorts entries, so the whole `setup.exe` is a pure function of
13//! (stub, scripts, manifest, payload).
14//!
15//! Scope grows per phase. Phase 3 carries the real manifest (product identity +
16//! resolved install mode, spec R8) and ships the caller's `install.lua` /
17//! `uninstall.lua` bytes verbatim (spec R7) — the runtime's mlua host runs them.
18//! When no `install.lua` is supplied the Phase-2 extraction-only placeholder
19//! still ships. The wizard arrives with Phase 5.
20
21use std::io::{Cursor, Write as _};
22use std::path::{Path, PathBuf};
23
24mod brand;
25pub use brand::Branding;
26
27use embala_setup_overlay::{
28    FORMAT_VERSION, Manifest, Package, Section, TRAILER_LEN, Trailer, UninstallOption,
29};
30// Re-export so `embala build` can assemble the spec without depending on the
31// overlay crate directly (spec R2).
32pub use embala_setup_overlay::UninstallOption as UninstallOptionSpec;
33use zip::write::SimpleFileOptions;
34
35/// The committed runtime stubs, embedded at this crate's compile time (spec R2).
36/// The aarch64 GNU-family triple is `-gnullvm` (there is no aarch64 mingw).
37const STUB_X86_64: &[u8] = include_bytes!("../stubs/x86_64-pc-windows-gnu/setup-stub.exe");
38const STUB_AARCH64: &[u8] = include_bytes!("../stubs/aarch64-pc-windows-gnullvm/setup-stub.exe");
39
40/// Fixed zip entry mtime (2020-01-01T00:00:00Z) for reproducible output — the
41/// same instant embala-nupkg/embala-msi pin.
42const FIXED_DOS_TIME: (u16, u8, u8, u8, u8, u8) = (2020, 1, 1, 0, 0, 0);
43
44/// Phase-2 `install.lua`: the runtime extracts natively and does not yet run
45/// Lua, so this is a plaintext placeholder (spec R7: scripts ship verbatim).
46const INSTALL_LUA_PLACEHOLDER: &[u8] = b"-- embala phase-2 placeholder: extraction is native\n";
47
48/// Windows arch selecting the embedded stub.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum SetupArch {
51    X86_64,
52    Aarch64,
53}
54
55impl SetupArch {
56    /// Token used in artifact file names (`hello-0.1.0-x86_64-setup.exe`).
57    pub fn as_str(self) -> &'static str {
58        match self {
59            SetupArch::X86_64 => "x86_64",
60            SetupArch::Aarch64 => "aarch64",
61        }
62    }
63
64    /// The committed stub bytes for this arch.
65    fn stub(self) -> &'static [u8] {
66        match self {
67            SetupArch::X86_64 => STUB_X86_64,
68            SetupArch::Aarch64 => STUB_AARCH64,
69        }
70    }
71}
72
73/// The committed bare setup stub for `arch` — the exact bytes external signing
74/// flows sign out-of-band (see `embala dump-stub`) and feed back as
75/// [`SetupSpec::signed_stub`].
76pub fn stub_bytes(arch: SetupArch) -> &'static [u8] {
77    arch.stub()
78}
79
80/// The `arch` stub with `branding` patched into its PE resources (spec R7/R9) —
81/// the exact bytes `build()` overlays and the in-build signing path signs.
82pub fn patched_stub_bytes(arch: SetupArch, branding: &Branding) -> Result<Vec<u8>> {
83    brand::patch_stub(arch.stub(), branding)
84}
85
86/// Default install mode baked into the manifest (spec R13). Config-independent,
87/// mirroring the config's own enum; a `/mode=` CLI flag overrides it at install
88/// time.
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum InstallMode {
91    PerUser,
92    PerMachine,
93    UserChoice,
94}
95
96impl InstallMode {
97    /// The manifest token the runtime parses (matches the spec's CLI vocabulary).
98    fn as_str(self) -> &'static str {
99        match self {
100            InstallMode::PerUser => "per-user",
101            InstallMode::PerMachine => "per-machine",
102            InstallMode::UserChoice => "user-choice",
103        }
104    }
105}
106
107/// Product identity carried into the manifest and, at install time, the
108/// `embala.package` Lua table + the ARP entry. Mirrors `[package]`.
109#[derive(Debug, Clone)]
110pub struct ProductInfo {
111    pub name: String,
112    pub display_name: String,
113    pub version: String,
114    pub identifier: String,
115    pub publisher: String,
116    pub description: String,
117    pub homepage: Option<String>,
118    pub license: Option<String>,
119}
120
121/// A payload file: `src` on disk (already resolved to a real path), `dest` the
122/// `/`-separated install path relative to the install directory.
123#[derive(Debug, Clone)]
124pub struct FileSpec {
125    pub src: PathBuf,
126    pub dest: String,
127}
128
129/// Everything needed to author one `setup.exe`. Deliberately independent of the
130/// embala config types so the crate is usable on its own.
131#[derive(Debug, Clone)]
132pub struct SetupSpec {
133    pub arch: SetupArch,
134    pub install_mode: InstallMode,
135    pub product: ProductInfo,
136    /// Icon / banner / product identity patched into the stub before overlaying
137    /// (spec R7). Always sets VERSIONINFO; icon and banner are optional.
138    pub branding: Branding,
139    /// Declarative uninstall options (spec R2), carried into the overlay manifest
140    /// and shown by the uninstaller's confirm dialog.
141    pub uninstall_options: Vec<UninstallOption>,
142    pub files: Vec<FileSpec>,
143    /// Raw `install.lua` bytes shipped verbatim (spec R7). `None` ships the
144    /// extraction-only Phase-2 placeholder.
145    pub install_lua: Option<Vec<u8>>,
146    /// Raw `uninstall.lua` bytes shipped verbatim. `None` ships an empty
147    /// section; the runtime then falls back to the log-driven uninstaller.
148    pub uninstall_lua: Option<Vec<u8>>,
149    /// A pre-signed bare uninstall stub. `Some` embeds it so the installer writes
150    /// a SIGNED `uninstall.exe` instead of prefix-copying its own stub; `None`
151    /// ships a zero-length section and the runtime falls back to the prefix copy.
152    pub signed_stub: Option<Vec<u8>>,
153}
154
155#[derive(Debug, thiserror::Error)]
156pub enum Error {
157    #[error("setup: no files to package")]
158    NoFiles,
159    #[error(
160        "setup dest {0:?} must be a relative /-separated path without empty, '..', or drive segments"
161    )]
162    InvalidDest(String),
163    #[error("setup: two files share the dest {0:?}")]
164    DuplicateDest(String),
165    #[error(transparent)]
166    Io(#[from] std::io::Error),
167    #[error(transparent)]
168    Zip(#[from] zip::result::ZipError),
169    #[error("setup: branding the stub failed: {0}")]
170    Brand(String),
171}
172
173pub type Result<T> = std::result::Result<T, Error>;
174
175/// Build the `setup.exe` described by `spec` at `out` (parent dirs are created).
176pub fn build(spec: &SetupSpec, out: &Path) -> Result<()> {
177    if spec.files.is_empty() {
178        return Err(Error::NoFiles);
179    }
180
181    // dest -> file bytes, normalized to forward slashes and traversal-checked,
182    // then sorted so the zip entry order (and thus the whole exe) is stable.
183    let mut entries: Vec<(String, Vec<u8>)> = Vec::with_capacity(spec.files.len());
184    let mut seen = std::collections::BTreeSet::new();
185    for file in &spec.files {
186        let dest = validate_dest(&file.dest)?;
187        if !seen.insert(dest.clone()) {
188            return Err(Error::DuplicateDest(dest));
189        }
190        entries.push((dest, std::fs::read(&file.src)?));
191    }
192    entries.sort_by(|a, b| a.0.cmp(&b.0));
193
194    let payload = build_payload_zip(&entries)?;
195    // Brand the stub before overlaying so both the prefix section and every
196    // offset are computed over the patched bytes (spec R7/R8).
197    let stub = brand::patch_stub(spec.arch.stub(), &spec.branding)?;
198    let stub: &[u8] = &stub;
199    // Ship the caller's script bytes verbatim (spec R7); fall back to the
200    // Phase-2 extraction-only placeholder when no install.lua was supplied.
201    let install: &[u8] = spec
202        .install_lua
203        .as_deref()
204        .unwrap_or(INSTALL_LUA_PLACEHOLDER);
205    let uninstall: &[u8] = spec.uninstall_lua.as_deref().unwrap_or(b"");
206    let manifest = manifest_bytes(spec);
207    // A pre-signed uninstall stub, if supplied; else a zero-length section.
208    let signed_stub: &[u8] = spec.signed_stub.as_deref().unwrap_or(b"");
209
210    // Section offsets are cumulative; order matches the trailer's field
211    // semantics: [stub][payload.zip][install.lua][uninstall.lua][manifest][signed_stub].
212    let stub_sec = Section {
213        offset: 0,
214        len: stub.len() as u64,
215    };
216    let payload_sec = after(&stub_sec, payload.len());
217    let install_sec = after(&payload_sec, install.len());
218    let uninstall_sec = after(&install_sec, uninstall.len());
219    let manifest_sec = after(&uninstall_sec, manifest.len());
220    let signed_stub_sec = after(&manifest_sec, signed_stub.len());
221    let trailer = Trailer {
222        version: FORMAT_VERSION,
223        stub: stub_sec,
224        payload_zip: payload_sec,
225        install_lua: install_sec,
226        uninstall_lua: uninstall_sec,
227        manifest: manifest_sec,
228        signed_stub: signed_stub_sec,
229    };
230
231    // Pad before the trailer so the unsigned file length is 8-byte aligned. An
232    // Authenticode cert table is then appended at an 8-aligned offset, which is
233    // exactly the security-directory offset the runtime reads as the overlay end.
234    let sections_end = signed_stub_sec.offset + signed_stub_sec.len;
235    let pad = (8 - (sections_end + TRAILER_LEN as u64) % 8) % 8;
236
237    let mut bytes =
238        Vec::with_capacity(sections_end as usize + pad as usize + trailer.to_bytes().len());
239    bytes.extend_from_slice(stub);
240    bytes.extend_from_slice(&payload);
241    bytes.extend_from_slice(install);
242    bytes.extend_from_slice(uninstall);
243    bytes.extend_from_slice(&manifest);
244    bytes.extend_from_slice(signed_stub);
245    bytes.resize(bytes.len() + pad as usize, 0);
246    bytes.extend_from_slice(&trailer.to_bytes());
247
248    if let Some(parent) = out.parent() {
249        if !parent.as_os_str().is_empty() {
250            std::fs::create_dir_all(parent)?;
251        }
252    }
253    std::fs::write(out, bytes)?;
254    Ok(())
255}
256
257/// A section starting immediately after `prev`.
258fn after(prev: &Section, len: usize) -> Section {
259    Section {
260        offset: prev.offset + prev.len,
261        len: len as u64,
262    }
263}
264
265/// Normalize `\` to `/` and reject anything that could write outside the target
266/// dir at extract time (absolute, drive-relative, `..`, or empty segments).
267fn validate_dest(dest: &str) -> Result<String> {
268    let normalized = dest.replace('\\', "/");
269    // A drive segment (`C:`) or a bare drive-relative path escapes the target.
270    if normalized.contains(':') {
271        return Err(Error::InvalidDest(dest.to_string()));
272    }
273    if normalized.split('/').any(|s| s.is_empty() || s == "..") {
274        return Err(Error::InvalidDest(dest.to_string()));
275    }
276    Ok(normalized)
277}
278
279/// Build the deterministic payload zip (mirrors embala-nupkg's option style).
280fn build_payload_zip(entries: &[(String, Vec<u8>)]) -> Result<Vec<u8>> {
281    let (year, month, day, hour, minute, second) = FIXED_DOS_TIME;
282    let mtime = zip::DateTime::from_date_and_time(year, month, day, hour, minute, second)
283        .expect("fixed timestamp is a valid DOS datetime");
284    let options = SimpleFileOptions::default()
285        .compression_method(zip::CompressionMethod::Deflated)
286        .last_modified_time(mtime);
287
288    let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
289    for (name, data) in entries {
290        writer.start_file(name, options)?;
291        writer.write_all(data)?;
292    }
293    Ok(writer.finish()?.into_inner())
294}
295
296/// Build the deterministic manifest (spec R8) via the shared overlay type: the
297/// runtime deserializes the very same struct. Stable field order, no timestamps.
298fn manifest_bytes(spec: &SetupSpec) -> Vec<u8> {
299    let p = &spec.product;
300    Manifest {
301        format_version: FORMAT_VERSION,
302        arch: spec.arch.as_str().to_string(),
303        install_mode: spec.install_mode.as_str().to_string(),
304        package: Package {
305            name: p.name.clone(),
306            display_name: p.display_name.clone(),
307            version: p.version.clone(),
308            identifier: p.identifier.clone(),
309            publisher: p.publisher.clone(),
310            description: p.description.clone(),
311            homepage: p.homepage.clone(),
312            license: p.license.clone(),
313        },
314        uninstall_options: spec.uninstall_options.clone(),
315    }
316    .to_bytes()
317}