Skip to main content

zsh/extensions/
aot.rs

1//! Ahead-of-time build: bake one or more shell scripts into a copy of the
2//! running `zshrs` binary as a compressed trailer, producing a self-contained
3//! executable. At startup, zshrs detects the trailer and runs every embedded
4//! script IN INPUT ORDER as a single concatenated zsh program.
5//!
6//! **zshrs-original infrastructure — no C source counterpart.** C zsh
7//! has the `zcompile` builtin (Src/parse.c → `bin_zcompile()`) which
8//! writes a parsed-AST `.zwc` file alongside a script for faster
9//! re-parse. That's a separate file, not a self-contained binary.
10//! AOT-ing scripts into the shell executable itself is a zshrs
11//! addition — it lets you ship a single binary that bundles its own
12//! script (think `zsh -c '...'` but compiled in).
13//!
14//! Layout (little-endian, appended to the end of a copy of the `zshrs` binary):
15//!
16//! ```text
17//!   [elf/mach-o bytes of zshrs ...]   (unchanged, still runs as `zshrs`)
18//!   [zstd-compressed payload ...]
19//!   [u64 compressed_len]
20//!   [u64 uncompressed_len]
21//!   [u32 version]
22//!   [u32 reserved (0)]
23//!   [8 bytes magic  b"ZSHRSAOT"]
24//! ```
25//!
26//! Payload v1 (single script, BACKWARD-COMPAT decoder only — new builds
27//! always emit v2 even for one input):
28//!
29//! ```text
30//!   [u32 script_name_len]
31//!   [script_name utf8]
32//!   [source bytes utf8]
33//! ```
34//!
35//! Payload v2 (ordered file list — current `zbuild` output):
36//!
37//! ```text
38//!   [u32 file_count]
39//!   for each file (file_count times, in input order):
40//!     [u32 name_len][name utf8]
41//!     [u32 source_len][source utf8]
42//! ```
43//!
44//! Files run sequentially in iteration order. zsh has no "project" concept;
45//! ordering matches `--in` argv order. Globals/functions defined by file N
46//! are visible to file N+1 (single ShellExecutor across all files).
47//!
48//! ELF (Linux) and Mach-O (macOS) loaders ignore bytes past the program-
49//! header-listed segments, so appending leaves the original `zshrs` fully
50//! runnable. macOS resulting binary is unsigned; signed-build distributors
51//! must re-codesign.
52
53use std::fs::{self, File, OpenOptions};
54use std::io::{self, Read, Seek, SeekFrom, Write};
55use std::os::unix::fs::PermissionsExt;
56use std::path::{Path, PathBuf};
57
58/// 8-byte trailer magic.
59pub const AOT_MAGIC: &[u8; 8] = b"ZSHRSAOT";
60/// Trailer format version 1: single script (legacy decode-only).
61pub const AOT_VERSION_V1: u32 = 1;
62/// Trailer format version 2: ordered file list (current build output).
63pub const AOT_VERSION_V2: u32 = 2;
64/// Fixed trailer length: `8 (cl) + 8 (ul) + 4 (ver) + 4 (rsv) + 8 (magic)`.
65pub const TRAILER_LEN: u64 = 32;
66/// `EmbeddedFile` — see fields for layout.
67#[derive(Debug, Clone)]
68pub struct EmbeddedFile {
69    /// `__FILE__` / error-reporting name (e.g. `hello.zsh`).
70    pub name: String,
71    /// UTF-8 zsh source.
72    pub source: String,
73}
74
75/// One or more embedded files, in build-order. v1 binaries decode to a
76/// 1-element vec; v2 to N elements preserving input order.
77#[derive(Debug, Clone)]
78pub struct EmbeddedFiles(pub Vec<EmbeddedFile>);
79
80fn encode_payload_v2(files: &[EmbeddedFile]) -> Vec<u8> {
81    let mut out = Vec::with_capacity(
82        64 + files
83            .iter()
84            .map(|f| f.name.len() + f.source.len() + 8)
85            .sum::<usize>(),
86    );
87    let count = u32::try_from(files.len()).expect("file count fits in u32");
88    out.extend_from_slice(&count.to_le_bytes());
89    for f in files {
90        let name_len = u32::try_from(f.name.len()).expect("name length fits in u32");
91        let src_len = u32::try_from(f.source.len()).expect("source length fits in u32");
92        out.extend_from_slice(&name_len.to_le_bytes());
93        out.extend_from_slice(f.name.as_bytes());
94        out.extend_from_slice(&src_len.to_le_bytes());
95        out.extend_from_slice(f.source.as_bytes());
96    }
97    out
98}
99
100fn decode_payload_v2(bytes: &[u8]) -> Option<EmbeddedFiles> {
101    let mut pos = 0usize;
102    if bytes.len() < 4 {
103        return None;
104    }
105    let count = u32::from_le_bytes(bytes[pos..pos + 4].try_into().ok()?) as usize;
106    pos += 4;
107    let mut out = Vec::with_capacity(count);
108    for _ in 0..count {
109        if pos + 4 > bytes.len() {
110            return None;
111        }
112        let name_len = u32::from_le_bytes(bytes[pos..pos + 4].try_into().ok()?) as usize;
113        pos += 4;
114        if pos + name_len > bytes.len() {
115            return None;
116        }
117        let name = std::str::from_utf8(&bytes[pos..pos + name_len])
118            .ok()?
119            .to_string();
120        pos += name_len;
121        if pos + 4 > bytes.len() {
122            return None;
123        }
124        let src_len = u32::from_le_bytes(bytes[pos..pos + 4].try_into().ok()?) as usize;
125        pos += 4;
126        if pos + src_len > bytes.len() {
127            return None;
128        }
129        let source = std::str::from_utf8(&bytes[pos..pos + src_len])
130            .ok()?
131            .to_string();
132        pos += src_len;
133        out.push(EmbeddedFile { name, source });
134    }
135    Some(EmbeddedFiles(out))
136}
137
138/// v1 decoder kept for backward compat: one-script payload promoted into a
139/// single-element EmbeddedFiles. Old binaries built with the previous zbuild
140/// still load.
141fn decode_payload_v1(bytes: &[u8]) -> Option<EmbeddedFiles> {
142    if bytes.len() < 4 {
143        return None;
144    }
145    let name_len = u32::from_le_bytes(bytes[0..4].try_into().ok()?) as usize;
146    if 4 + name_len > bytes.len() {
147        return None;
148    }
149    let name = std::str::from_utf8(&bytes[4..4 + name_len])
150        .ok()?
151        .to_string();
152    let source = std::str::from_utf8(&bytes[4 + name_len..])
153        .ok()?
154        .to_string();
155    Some(EmbeddedFiles(vec![EmbeddedFile { name, source }]))
156}
157
158fn build_trailer(compressed_len: u64, uncompressed_len: u64, version: u32) -> [u8; 32] {
159    let mut trailer = [0u8; 32];
160    trailer[0..8].copy_from_slice(&compressed_len.to_le_bytes());
161    trailer[8..16].copy_from_slice(&uncompressed_len.to_le_bytes());
162    trailer[16..20].copy_from_slice(&version.to_le_bytes());
163    // 20..24 reserved (zeros).
164    trailer[24..32].copy_from_slice(AOT_MAGIC);
165    trailer
166}
167
168/// Append a compressed v2 ordered-file payload to an existing file.
169/// zshrs-original — no C counterpart. C zsh's closest analog is the
170/// `zcompile` builtin in Src/parse.c which writes parsed-AST data
171/// to a separate `.zwc` file rather than appending to the binary.
172pub fn append_embedded_files(out_path: &Path, files: &[EmbeddedFile]) -> io::Result<()> {
173    let payload = encode_payload_v2(files);
174    let compressed = zstd::stream::encode_all(&payload[..], 3)?;
175    let mut f = OpenOptions::new().append(true).open(out_path)?;
176    f.write_all(&compressed)?;
177    let trailer = build_trailer(
178        compressed.len() as u64,
179        payload.len() as u64,
180        AOT_VERSION_V2,
181    );
182    f.write_all(&trailer)?;
183    f.sync_all()?;
184    Ok(())
185}
186
187/// Fast probe: read the last 32 bytes of `exe` and return embedded files
188/// in build-order if present. Decodes both v1 (legacy single-script) and
189/// v2 (current ordered list). Called at zshrs startup before arg parsing.
190/// zshrs-original — no C counterpart.
191pub fn try_load_embedded(exe: &Path) -> Option<EmbeddedFiles> {
192    let mut f = File::open(exe).ok()?;
193    let size = f.metadata().ok()?.len();
194    if size < TRAILER_LEN {
195        return None;
196    }
197    f.seek(SeekFrom::End(-(TRAILER_LEN as i64))).ok()?;
198    let mut trailer = [0u8; TRAILER_LEN as usize];
199    f.read_exact(&mut trailer).ok()?;
200    if &trailer[24..32] != AOT_MAGIC {
201        return None;
202    }
203    let compressed_len = u64::from_le_bytes(trailer[0..8].try_into().ok()?);
204    let uncompressed_len = u64::from_le_bytes(trailer[8..16].try_into().ok()?);
205    let version = u32::from_le_bytes(trailer[16..20].try_into().ok()?);
206    if compressed_len == 0 || compressed_len > size - TRAILER_LEN {
207        return None;
208    }
209    let payload_start = size - TRAILER_LEN - compressed_len;
210    f.seek(SeekFrom::Start(payload_start)).ok()?;
211    let mut compressed = vec![0u8; compressed_len as usize];
212    f.read_exact(&mut compressed).ok()?;
213    let payload = zstd::stream::decode_all(&compressed[..]).ok()?;
214    if payload.len() != uncompressed_len as usize {
215        return None;
216    }
217    match version {
218        AOT_VERSION_V1 => decode_payload_v1(&payload),
219        AOT_VERSION_V2 => decode_payload_v2(&payload),
220        _ => None,
221    }
222}
223
224#[cfg(unix)]
225fn set_executable(path: &Path) {
226    if let Ok(meta) = fs::metadata(path) {
227        let mut p = meta.permissions();
228        p.set_mode(p.mode() | 0o111);
229        let _ = fs::set_permissions(path, p);
230    }
231}
232
233#[cfg(not(unix))]
234fn set_executable(_path: &Path) {}
235
236/// Copy `src` to `dst`, skipping any existing AOT trailer on `src`. Prevents
237/// nested builds from stacking trailers: building once with trailer-A then
238/// building again with trailer-B would otherwise embed both, A then B.
239fn copy_exe_without_trailer(src: &Path, dst: &Path) -> io::Result<()> {
240    let mut sf = File::open(src)?;
241    let size = sf.metadata()?.len();
242    let keep = if size >= TRAILER_LEN {
243        sf.seek(SeekFrom::End(-(TRAILER_LEN as i64)))?;
244        let mut trailer = [0u8; TRAILER_LEN as usize];
245        if sf.read_exact(&mut trailer).is_ok() && &trailer[24..32] == AOT_MAGIC {
246            let compressed_len = u64::from_le_bytes(trailer[0..8].try_into().unwrap());
247            if compressed_len > 0 && compressed_len <= size - TRAILER_LEN {
248                size - TRAILER_LEN - compressed_len
249            } else {
250                size
251            }
252        } else {
253            size
254        }
255    } else {
256        size
257    };
258    sf.seek(SeekFrom::Start(0))?;
259    let _ = fs::remove_file(dst);
260    let mut df = File::create(dst)?;
261    let mut remaining = keep;
262    let mut buf = vec![0u8; 64 * 1024];
263    while remaining > 0 {
264        let n = std::cmp::min(remaining as usize, buf.len());
265        sf.read_exact(&mut buf[..n])?;
266        df.write_all(&buf[..n])?;
267        remaining -= n as u64;
268    }
269    df.sync_all()?;
270    Ok(())
271}
272
273/// `zbuild --in A --in B --out OUT`: bake A and B into a copy of the
274/// running zshrs binary in input order, producing a self-contained AOT
275/// executable. At runtime, all embedded files run sequentially under one
276/// ShellExecutor — globals + functions from earlier files are visible
277/// to later ones.
278/// zshrs-original — no C counterpart. C zsh's `bin_zcompile()`
279/// (Src/parse.c) writes a `.zwc` cache file but doesn't bundle into
280/// the shell binary itself.
281pub fn build(script_paths: &[PathBuf], out_path: &Path) -> Result<PathBuf, String> {
282    if script_paths.is_empty() {
283        return Err("zbuild: at least one --in PATH required".to_string());
284    }
285    let mut files: Vec<EmbeddedFile> = Vec::with_capacity(script_paths.len());
286    for p in script_paths {
287        let source = fs::read_to_string(p)
288            .map_err(|e| format!("zbuild: cannot read {}: {}", p.display(), e))?;
289        let name = p
290            .file_name()
291            .and_then(|s| s.to_str())
292            .unwrap_or("script.zsh")
293            .to_string();
294        files.push(EmbeddedFile { name, source });
295    }
296    let exe = std::env::current_exe()
297        .map_err(|e| format!("zbuild: locating current executable: {}", e))?;
298    copy_exe_without_trailer(&exe, out_path).map_err(|e| {
299        format!(
300            "zbuild: copy {} -> {}: {}",
301            exe.display(),
302            out_path.display(),
303            e
304        )
305    })?;
306    append_embedded_files(out_path, &files).map_err(|e| format!("zbuild: write trailer: {}", e))?;
307    set_executable(out_path);
308    Ok(out_path.to_path_buf())
309}
310
311// ───────────────────────── Native AOT (`zbuild --native`) ──────────────────
312//
313// Unlike the source-trailer build above (which embeds the script text and
314// re-runs it interpreted at startup), the native path compiles the script to a
315// fusevm chunk, lowers that to native machine code via `fusevm::aot`
316// (Cranelift `ObjectModule` → relocatable `.o`), and links the object against
317// the zsh runtime staticlib (`libzsh.a`) into a standalone executable. The
318// script's bytecode runs as native code with no interpreter dispatch loop;
319// command execution, expansion, and cmdsubst still go through the embedded
320// `ShellExecutor` (the shell runtime is linked in).
321
322/// Frontend runtime hook invoked by `fusevm::aot::fusevm_aot_run_embedded` at
323/// startup of a native AOT binary. Stands up a leaked-`'static` `ShellExecutor`,
324/// seeds positional parameters from `argv`, installs it as the thread's
325/// `CURRENT_EXECUTOR`, and registers the zsh builtins on the run VM — so host
326/// calls from the native chunk reach a live shell exactly as the interpreter's
327/// `run_chunk` arranges. The executor lives for the process (an AOT binary runs
328/// one program and exits), so the install is intentionally permanent.
329///
330/// # Safety
331/// `vm` is the live run VM passed by the fusevm runtime; borrowed only here.
332#[no_mangle]
333pub extern "C" fn fusevm_aot_register_builtins(vm: *mut fusevm::VM) {
334    // SAFETY: the fusevm runtime hands us the live run VM for this call.
335    let vm = unsafe { &mut *vm };
336    let exec: &'static mut crate::vm_helper::ShellExecutor =
337        Box::leak(Box::new(crate::vm_helper::ShellExecutor::new()));
338    // Positional params $1.. come from argv after the program name.
339    let args: Vec<String> = std::env::args().skip(1).collect();
340    exec.set_pparams(args);
341    let last = exec.last_status();
342    crate::fusevm_bridge::register_builtins(vm);
343    vm.last_status = last;
344    // Install permanently: forget the RAII guard so CURRENT_EXECUTOR stays set
345    // for the whole native run (the process is one-shot).
346    std::mem::forget(crate::fusevm_bridge::ExecutorContext::enter(exec));
347}
348
349/// Compile zsh `source` to a fusevm chunk via the normal parse + compile path.
350fn compile_source_to_chunk(source: &str) -> Result<fusevm::Chunk, String> {
351    let program = crate::vm_helper::parse_isolated(source);
352    let chunk = crate::compile_zsh::ZshCompiler::new().compile(&program);
353    if chunk.ops.is_empty() {
354        return Err("zbuild --native: script compiled to an empty chunk".to_string());
355    }
356    Ok(chunk)
357}
358
359/// Locate the zsh runtime staticlib to link against. `ZSHRS_AOT_RUNTIME_LIB`
360/// overrides; otherwise look for `libzsh.a` beside the running executable
361/// (the dev `target/<profile>/` layout).
362fn runtime_staticlib() -> Result<PathBuf, String> {
363    if let Ok(p) = std::env::var("ZSHRS_AOT_RUNTIME_LIB") {
364        return Ok(PathBuf::from(p));
365    }
366    let exe = std::env::current_exe().map_err(|e| e.to_string())?;
367    if let Some(dir) = exe.parent() {
368        let cand = dir.join("libzsh.a");
369        if cand.exists() {
370            return Ok(cand);
371        }
372    }
373    Err("could not locate libzsh.a (set ZSHRS_AOT_RUNTIME_LIB)".to_string())
374}
375
376/// `zbuild --native --in A.zsh [--in B.zsh] --out OUT`: AOT-compile the inputs
377/// to native machine code and link a standalone executable. Multiple inputs are
378/// concatenated in order into one program, matching the source-trailer build.
379pub fn build_native(script_paths: &[PathBuf], out_path: &Path) -> Result<PathBuf, String> {
380    if script_paths.is_empty() {
381        return Err("zbuild --native: at least one --in PATH required".to_string());
382    }
383    let mut source = String::new();
384    for p in script_paths {
385        let s = fs::read_to_string(p)
386            .map_err(|e| format!("zbuild --native: cannot read {}: {}", p.display(), e))?;
387        source.push_str(&s);
388        if !source.ends_with('\n') {
389            source.push('\n');
390        }
391    }
392    let chunk = compile_source_to_chunk(&source)?;
393
394    let runtime_lib = runtime_staticlib()?;
395    if !runtime_lib.exists() {
396        return Err(format!(
397            "zbuild --native: runtime staticlib not found at {}",
398            runtime_lib.display()
399        ));
400    }
401
402    let obj = out_path.with_extension("o");
403    fusevm::aot::compile_object(&chunk, &obj).map_err(|e| format!("zbuild --native: {}", e))?;
404
405    let stub = out_path.with_extension("aot_main.c");
406    fs::write(
407        &stub,
408        b"extern long fusevm_aot_run_embedded(void);\nint main(void){return (int)fusevm_aot_run_embedded();}\n" as &[u8],
409    )
410    .map_err(|e| format!("zbuild --native: write entry stub: {}", e))?;
411
412    let mut cmd = std::process::Command::new("cc");
413    cmd.arg(&stub).arg(&obj).arg(&runtime_lib);
414    // zsh's terminfo/termcap modules need the system terminal library.
415    cmd.arg("-lncurses");
416    if cfg!(target_os = "macos") {
417        // Frameworks pulled by zsh's transitive deps: chrono/iana-time-zone
418        // (CoreFoundation), notify/FSEvents (CoreServices), and TLS/keychain
419        // and network-config crates (Security, SystemConfiguration).
420        for fw in [
421            "CoreFoundation",
422            "CoreServices",
423            "Security",
424            "SystemConfiguration",
425        ] {
426            cmd.arg("-framework").arg(fw);
427        }
428    }
429    cmd.arg("-o").arg(out_path);
430    let status = cmd
431        .status()
432        .map_err(|e| format!("zbuild --native: invoking cc: {}", e))?;
433    let _ = fs::remove_file(&stub);
434    let _ = fs::remove_file(&obj);
435    if !status.success() {
436        return Err(format!(
437            "zbuild --native: link failed (cc exit {:?})",
438            status.code()
439        ));
440    }
441    set_executable(out_path);
442    Ok(out_path.to_path_buf())
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448
449    fn mkfile(name: &str, src: &str) -> EmbeddedFile {
450        EmbeddedFile {
451            name: name.into(),
452            source: src.into(),
453        }
454    }
455
456    #[test]
457    fn build_trailer_layout_matches_spec() {
458        let t = build_trailer(0x11_22_33_44, 0xAA_BB_CC_DD, AOT_VERSION_V2);
459        assert_eq!(t.len(), TRAILER_LEN as usize);
460        // little-endian u64 compressed_len
461        assert_eq!(&t[0..8], &0x11_22_33_44u64.to_le_bytes());
462        // little-endian u64 uncompressed_len
463        assert_eq!(&t[8..16], &0xAA_BB_CC_DDu64.to_le_bytes());
464        // little-endian u32 version
465        assert_eq!(&t[16..20], &AOT_VERSION_V2.to_le_bytes());
466        // 20..24 reserved zeros
467        assert_eq!(&t[20..24], &[0u8; 4]);
468        // magic
469        assert_eq!(&t[24..32], AOT_MAGIC);
470    }
471
472    #[test]
473    fn payload_v2_roundtrip_single_file() {
474        let files = vec![mkfile("hello.zsh", "echo hi\n")];
475        let encoded = encode_payload_v2(&files);
476        let decoded = decode_payload_v2(&encoded).expect("decode v2");
477        assert_eq!(decoded.0.len(), 1);
478        assert_eq!(decoded.0[0].name, "hello.zsh");
479        assert_eq!(decoded.0[0].source, "echo hi\n");
480    }
481
482    #[test]
483    fn payload_v2_roundtrip_multiple_files_preserves_order() {
484        let files = vec![
485            mkfile("a.zsh", "echo a\n"),
486            mkfile("b.zsh", "echo b\n"),
487            mkfile("c.zsh", "echo c\n"),
488        ];
489        let encoded = encode_payload_v2(&files);
490        let decoded = decode_payload_v2(&encoded).expect("decode v2");
491        assert_eq!(decoded.0.len(), 3);
492        assert_eq!(decoded.0[0].name, "a.zsh");
493        assert_eq!(decoded.0[1].name, "b.zsh");
494        assert_eq!(decoded.0[2].name, "c.zsh");
495        assert_eq!(decoded.0[0].source, "echo a\n");
496        assert_eq!(decoded.0[1].source, "echo b\n");
497        assert_eq!(decoded.0[2].source, "echo c\n");
498    }
499
500    #[test]
501    fn payload_v2_roundtrip_zero_files() {
502        let files: Vec<EmbeddedFile> = vec![];
503        let encoded = encode_payload_v2(&files);
504        // u32 count=0 → 4 bytes of zero.
505        assert_eq!(encoded.as_slice(), &0u32.to_le_bytes());
506        let decoded = decode_payload_v2(&encoded).expect("decode zero-file v2");
507        assert!(decoded.0.is_empty());
508    }
509
510    #[test]
511    fn payload_v2_handles_empty_source() {
512        let files = vec![mkfile("empty.zsh", "")];
513        let encoded = encode_payload_v2(&files);
514        let decoded = decode_payload_v2(&encoded).expect("decode empty source");
515        assert_eq!(decoded.0[0].source, "");
516        assert_eq!(decoded.0[0].name, "empty.zsh");
517    }
518
519    #[test]
520    fn payload_v2_preserves_utf8_in_names_and_sources() {
521        let files = vec![
522            mkfile("名前.zsh", "echo こんにちは\n"),
523            mkfile("emoji-🚀.zsh", "echo $'\\xf0\\x9f\\x9a\\x80'\n"),
524        ];
525        let encoded = encode_payload_v2(&files);
526        let decoded = decode_payload_v2(&encoded).expect("decode utf8");
527        assert_eq!(decoded.0[0].name, "名前.zsh");
528        assert_eq!(decoded.0[0].source, "echo こんにちは\n");
529        assert_eq!(decoded.0[1].name, "emoji-🚀.zsh");
530    }
531
532    #[test]
533    fn payload_v2_rejects_truncated_input() {
534        let files = vec![mkfile("x.zsh", "echo x\n")];
535        let mut encoded = encode_payload_v2(&files);
536        // Drop the last byte — source ends prematurely.
537        encoded.pop();
538        assert!(decode_payload_v2(&encoded).is_none());
539    }
540
541    #[test]
542    fn payload_v2_rejects_empty_buffer() {
543        assert!(decode_payload_v2(&[]).is_none());
544    }
545
546    #[test]
547    fn payload_v2_rejects_lying_count_header() {
548        // Header claims 5 files but no body follows.
549        let mut buf = Vec::new();
550        buf.extend_from_slice(&5u32.to_le_bytes());
551        assert!(decode_payload_v2(&buf).is_none());
552    }
553
554    #[test]
555    fn payload_v1_decodes_legacy_single_script() {
556        let name = "old.zsh";
557        let source = "echo legacy\n";
558        let mut buf = Vec::new();
559        buf.extend_from_slice(&(name.len() as u32).to_le_bytes());
560        buf.extend_from_slice(name.as_bytes());
561        buf.extend_from_slice(source.as_bytes());
562        let decoded = decode_payload_v1(&buf).expect("decode v1");
563        assert_eq!(decoded.0.len(), 1);
564        assert_eq!(decoded.0[0].name, name);
565        assert_eq!(decoded.0[0].source, source);
566    }
567
568    #[test]
569    fn payload_v1_rejects_short_buffer() {
570        assert!(decode_payload_v1(&[]).is_none());
571        assert!(decode_payload_v1(&[1, 2, 3]).is_none());
572    }
573
574    #[test]
575    fn payload_v1_rejects_name_len_larger_than_buffer() {
576        let mut buf = Vec::new();
577        buf.extend_from_slice(&999u32.to_le_bytes()); // name_len = 999
578        buf.extend_from_slice(b"abc"); // only 3 bytes follow
579        assert!(decode_payload_v1(&buf).is_none());
580    }
581
582    #[test]
583    fn payload_v1_handles_empty_source_after_name() {
584        let mut buf = Vec::new();
585        let name = "x";
586        buf.extend_from_slice(&(name.len() as u32).to_le_bytes());
587        buf.extend_from_slice(name.as_bytes());
588        // No source bytes after — should still decode with empty source.
589        let decoded = decode_payload_v1(&buf).expect("decode v1 empty source");
590        assert_eq!(decoded.0[0].source, "");
591    }
592
593    #[test]
594    fn append_and_load_roundtrip_single_file() {
595        let tmp = tempfile::NamedTempFile::new().expect("temp file");
596        // Pre-populate with non-magic data simulating an existing binary.
597        std::fs::write(tmp.path(), b"FAKE-ELF-PREFIX").expect("write prefix");
598        let files = vec![mkfile("greet.zsh", "echo hello\n")];
599        append_embedded_files(tmp.path(), &files).expect("append");
600        let loaded = try_load_embedded(tmp.path()).expect("load back");
601        assert_eq!(loaded.0.len(), 1);
602        assert_eq!(loaded.0[0].name, "greet.zsh");
603        assert_eq!(loaded.0[0].source, "echo hello\n");
604    }
605
606    #[test]
607    fn append_and_load_roundtrip_multiple_files() {
608        let tmp = tempfile::NamedTempFile::new().expect("temp file");
609        std::fs::write(tmp.path(), b"PREFIX").expect("write prefix");
610        let files = vec![
611            mkfile("first.zsh", "first()  { :; }\n"),
612            mkfile("second.zsh", "first\nsecond()  { :; }\n"),
613            mkfile("third.zsh", "echo done\n"),
614        ];
615        append_embedded_files(tmp.path(), &files).expect("append");
616        let loaded = try_load_embedded(tmp.path()).expect("load back");
617        assert_eq!(loaded.0.len(), 3);
618        assert_eq!(loaded.0[0].name, "first.zsh");
619        assert_eq!(loaded.0[1].name, "second.zsh");
620        assert_eq!(loaded.0[2].name, "third.zsh");
621    }
622
623    #[test]
624    fn try_load_embedded_returns_none_without_magic() {
625        let tmp = tempfile::NamedTempFile::new().expect("temp file");
626        // Write at least TRAILER_LEN bytes but no magic in the trailing slot.
627        std::fs::write(tmp.path(), vec![0u8; 64]).expect("write");
628        assert!(try_load_embedded(tmp.path()).is_none());
629    }
630
631    #[test]
632    fn try_load_embedded_returns_none_for_small_file() {
633        let tmp = tempfile::NamedTempFile::new().expect("temp file");
634        // Smaller than TRAILER_LEN — fast bail.
635        std::fs::write(tmp.path(), b"tiny").expect("write");
636        assert!(try_load_embedded(tmp.path()).is_none());
637    }
638
639    #[test]
640    fn try_load_embedded_returns_none_for_missing_path() {
641        let path = std::path::PathBuf::from("/this/path/does/not/exist/zshrs-aot");
642        assert!(try_load_embedded(&path).is_none());
643    }
644
645    #[test]
646    fn try_load_embedded_returns_none_for_zero_compressed_len() {
647        // Forge a trailer with valid magic but compressed_len=0.
648        let tmp = tempfile::NamedTempFile::new().expect("temp file");
649        let mut data = vec![0u8; 100];
650        let trailer = build_trailer(0, 0, AOT_VERSION_V2);
651        data.extend_from_slice(&trailer);
652        std::fs::write(tmp.path(), &data).expect("write");
653        assert!(try_load_embedded(tmp.path()).is_none());
654    }
655
656    #[test]
657    fn try_load_embedded_rejects_unknown_version() {
658        // Build a valid magic + size frame, but version=99 — unsupported.
659        let tmp = tempfile::NamedTempFile::new().expect("temp file");
660        let payload = encode_payload_v2(&[mkfile("x.zsh", "y\n")]);
661        let compressed = zstd::stream::encode_all(&payload[..], 3).expect("zstd");
662        let mut data = vec![0u8; 32]; // prefix
663        data.extend_from_slice(&compressed);
664        let trailer = build_trailer(compressed.len() as u64, payload.len() as u64, 99);
665        data.extend_from_slice(&trailer);
666        std::fs::write(tmp.path(), &data).expect("write");
667        assert!(try_load_embedded(tmp.path()).is_none());
668    }
669
670    #[test]
671    fn try_load_embedded_rejects_corrupt_uncompressed_len() {
672        // Magic + version OK, but uncompressed_len lies → decoder returns None.
673        let tmp = tempfile::NamedTempFile::new().expect("temp file");
674        let payload = encode_payload_v2(&[mkfile("x.zsh", "y\n")]);
675        let compressed = zstd::stream::encode_all(&payload[..], 3).expect("zstd");
676        let mut data = vec![0u8; 32];
677        data.extend_from_slice(&compressed);
678        // Lie: claim uncompressed is one byte larger than reality.
679        let trailer = build_trailer(
680            compressed.len() as u64,
681            payload.len() as u64 + 1,
682            AOT_VERSION_V2,
683        );
684        data.extend_from_slice(&trailer);
685        std::fs::write(tmp.path(), &data).expect("write");
686        assert!(try_load_embedded(tmp.path()).is_none());
687    }
688
689    #[test]
690    fn build_rejects_empty_input_list() {
691        let out = std::path::PathBuf::from("/tmp/zshrs-aot-empty-out");
692        let res = build(&[], &out);
693        assert!(res.is_err());
694        let err = res.unwrap_err();
695        assert!(err.contains("at least one"), "got: {err}");
696    }
697}