Skip to main content

stryke/
aot.rs

1//! Ahead-of-time build: bake a Perl script into a copy of the running `stryke` binary as
2//! a compressed trailer, producing a self-contained executable.
3//!
4//! Layout (little-endian, appended to the end of a copy of the `stryke` binary):
5//!
6//! ```text
7//!   [elf/mach-o bytes of stryke ...]   (unchanged, still runs as `stryke`)
8//!   [zstd-compressed payload ...]
9//!   [u64 compressed_len]
10//!   [u64 uncompressed_len]
11//!   [u32 version]
12//!   [u32 reserved (0)]
13//!   [8 bytes magic  b"STRYKEAOT"]
14//! ```
15//!
16//! Payload (before zstd compression):
17//!
18//! ```text
19//!   [u32 script_name_len]
20//!   [script_name utf8]
21//!   [source bytes utf8]
22//! ```
23//!
24//! Why source, not bytecode? [`crate::bytecode::Chunk`] holds `Arc<HeapObject>` runtime
25//! values (regex objects, strings, closures, …) that are not serde-ready. Re-parsing a
26//! typical script adds ~1-2 ms to startup which is negligible for a deployment binary.
27//! The trailer format is versioned so a future pre-compiled-bytecode payload can live
28//! alongside v1 without breaking already-shipped binaries.
29//!
30//! ELF (Linux) and Mach-O (macOS) loaders ignore bytes past the program-header-listed
31//! segments, so appending data leaves the original `stryke` fully runnable. On macOS the
32//! resulting binary is unsigned — users distributing signed builds must re-`codesign`.
33
34use std::collections::HashMap;
35use std::fs::{self, File, OpenOptions};
36use std::io::{self, Read, Seek, SeekFrom, Write};
37use std::path::{Path, PathBuf};
38
39/// 8-byte trailer magic (`b"STRK_AOT"`).
40pub const AOT_MAGIC: &[u8; 8] = b"STRK_AOT";
41/// Trailer format version 1: single script.
42pub const AOT_VERSION_V1: u32 = 1;
43/// Trailer format version 2: project bundle with multiple files.
44pub const AOT_VERSION_V2: u32 = 2;
45/// Fixed trailer length in bytes: `8 (cl) + 8 (ul) + 4 (ver) + 4 (rsv) + 8 (magic)`.
46pub const TRAILER_LEN: u64 = 32;
47/// `EmbeddedScript` — see fields for layout.
48#[derive(Debug, Clone)]
49pub struct EmbeddedScript {
50    /// `__FILE__` / error-reporting name (e.g. `hello.pl`).
51    pub name: String,
52    /// UTF-8 Perl source.
53    pub source: String,
54}
55
56/// A bundled project: main entry point + library files.
57#[derive(Debug, Clone)]
58pub struct EmbeddedBundle {
59    /// Entry point script name (e.g. `main.stk`).
60    pub entry: String,
61    /// All files: path -> source (includes entry + lib files).
62    pub files: HashMap<String, String>,
63}
64
65/// Serialize `(name, source)` into the v1 pre-compression payload format.
66fn encode_payload_v1(name: &str, source: &str) -> Vec<u8> {
67    let mut out = Vec::with_capacity(4 + name.len() + source.len());
68    let name_len = u32::try_from(name.len()).expect("script name length fits in u32");
69    out.extend_from_slice(&name_len.to_le_bytes());
70    out.extend_from_slice(name.as_bytes());
71    out.extend_from_slice(source.as_bytes());
72    out
73}
74
75/// Serialize a project bundle into the v2 pre-compression payload format.
76fn encode_payload_v2(entry: &str, files: &HashMap<String, String>) -> Vec<u8> {
77    let mut out = Vec::new();
78    let file_count = u32::try_from(files.len()).expect("file count fits in u32");
79    out.extend_from_slice(&file_count.to_le_bytes());
80    let entry_len = u32::try_from(entry.len()).expect("entry name length fits in u32");
81    out.extend_from_slice(&entry_len.to_le_bytes());
82    out.extend_from_slice(entry.as_bytes());
83    for (path, source) in files {
84        let path_len = u32::try_from(path.len()).expect("path length fits in u32");
85        out.extend_from_slice(&path_len.to_le_bytes());
86        out.extend_from_slice(path.as_bytes());
87        let source_len = u32::try_from(source.len()).expect("source length fits in u32");
88        out.extend_from_slice(&source_len.to_le_bytes());
89        out.extend_from_slice(source.as_bytes());
90    }
91    out
92}
93
94/// Inverse of [`encode_payload_v1`].
95fn decode_payload_v1(bytes: &[u8]) -> Option<EmbeddedScript> {
96    if bytes.len() < 4 {
97        return None;
98    }
99    let name_len = u32::from_le_bytes(bytes[0..4].try_into().ok()?) as usize;
100    if 4 + name_len > bytes.len() {
101        return None;
102    }
103    let name = std::str::from_utf8(&bytes[4..4 + name_len])
104        .ok()?
105        .to_string();
106    let source = std::str::from_utf8(&bytes[4 + name_len..])
107        .ok()?
108        .to_string();
109    Some(EmbeddedScript { name, source })
110}
111
112/// Inverse of [`encode_payload_v2`].
113fn decode_payload_v2(bytes: &[u8]) -> Option<EmbeddedBundle> {
114    let mut pos = 0usize;
115    if bytes.len() < 8 {
116        return None;
117    }
118    let file_count = u32::from_le_bytes(bytes[pos..pos + 4].try_into().ok()?) as usize;
119    pos += 4;
120    let entry_len = u32::from_le_bytes(bytes[pos..pos + 4].try_into().ok()?) as usize;
121    pos += 4;
122    if pos + entry_len > bytes.len() {
123        return None;
124    }
125    let entry = std::str::from_utf8(&bytes[pos..pos + entry_len])
126        .ok()?
127        .to_string();
128    pos += entry_len;
129    let mut files = HashMap::with_capacity(file_count);
130    for _ in 0..file_count {
131        if pos + 4 > bytes.len() {
132            return None;
133        }
134        let path_len = u32::from_le_bytes(bytes[pos..pos + 4].try_into().ok()?) as usize;
135        pos += 4;
136        if pos + path_len > bytes.len() {
137            return None;
138        }
139        let path = std::str::from_utf8(&bytes[pos..pos + path_len])
140            .ok()?
141            .to_string();
142        pos += path_len;
143        if pos + 4 > bytes.len() {
144            return None;
145        }
146        let source_len = u32::from_le_bytes(bytes[pos..pos + 4].try_into().ok()?) as usize;
147        pos += 4;
148        if pos + source_len > bytes.len() {
149            return None;
150        }
151        let source = std::str::from_utf8(&bytes[pos..pos + source_len])
152            .ok()?
153            .to_string();
154        pos += source_len;
155        files.insert(path, source);
156    }
157    Some(EmbeddedBundle { entry, files })
158}
159
160/// Build a 32-byte trailer referring to `compressed_len` / `uncompressed_len`.
161fn build_trailer(compressed_len: u64, uncompressed_len: u64, version: u32) -> [u8; 32] {
162    let mut trailer = [0u8; 32];
163    trailer[0..8].copy_from_slice(&compressed_len.to_le_bytes());
164    trailer[8..16].copy_from_slice(&uncompressed_len.to_le_bytes());
165    trailer[16..20].copy_from_slice(&version.to_le_bytes());
166    // 20..24 reserved (zeros).
167    trailer[24..32].copy_from_slice(AOT_MAGIC);
168    trailer
169}
170
171/// Append a compressed v1 script payload to an existing file.
172pub fn append_embedded_script(out_path: &Path, name: &str, source: &str) -> io::Result<()> {
173    let payload = encode_payload_v1(name, source);
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_V1,
181    );
182    f.write_all(&trailer)?;
183    f.sync_all()?;
184    Ok(())
185}
186
187/// Append a compressed v2 bundle payload to an existing file.
188pub fn append_embedded_bundle(
189    out_path: &Path,
190    entry: &str,
191    files: &HashMap<String, String>,
192) -> io::Result<()> {
193    let payload = encode_payload_v2(entry, files);
194    let compressed = zstd::stream::encode_all(&payload[..], 3)?;
195    let mut f = OpenOptions::new().append(true).open(out_path)?;
196    f.write_all(&compressed)?;
197    let trailer = build_trailer(
198        compressed.len() as u64,
199        payload.len() as u64,
200        AOT_VERSION_V2,
201    );
202    f.write_all(&trailer)?;
203    f.sync_all()?;
204    Ok(())
205}
206
207/// Result of loading an embedded payload — either a single script (v1) or a bundle (v2).
208#[derive(Debug, Clone)]
209pub enum EmbeddedPayload {
210    /// `Script` variant.
211    Script(EmbeddedScript),
212    /// `Bundle` variant.
213    Bundle(EmbeddedBundle),
214}
215
216/// Fast probe: read the last 32 bytes of `exe` and return the embedded payload if present.
217/// Supports both v1 (single script) and v2 (project bundle) formats.
218pub fn try_load_embedded(exe: &Path) -> Option<EmbeddedPayload> {
219    let mut f = File::open(exe).ok()?;
220    let size = f.metadata().ok()?.len();
221    if size < TRAILER_LEN {
222        return None;
223    }
224    f.seek(SeekFrom::End(-(TRAILER_LEN as i64))).ok()?;
225    let mut trailer = [0u8; TRAILER_LEN as usize];
226    f.read_exact(&mut trailer).ok()?;
227    if &trailer[24..32] != AOT_MAGIC {
228        return None;
229    }
230    let compressed_len = u64::from_le_bytes(trailer[0..8].try_into().ok()?);
231    let uncompressed_len = u64::from_le_bytes(trailer[8..16].try_into().ok()?);
232    let version = u32::from_le_bytes(trailer[16..20].try_into().ok()?);
233    if compressed_len == 0 || compressed_len > size - TRAILER_LEN {
234        return None;
235    }
236    let payload_start = size - TRAILER_LEN - compressed_len;
237    f.seek(SeekFrom::Start(payload_start)).ok()?;
238    let mut compressed = vec![0u8; compressed_len as usize];
239    f.read_exact(&mut compressed).ok()?;
240    let payload = zstd::stream::decode_all(&compressed[..]).ok()?;
241    if payload.len() != uncompressed_len as usize {
242        return None;
243    }
244    match version {
245        AOT_VERSION_V1 => decode_payload_v1(&payload).map(EmbeddedPayload::Script),
246        AOT_VERSION_V2 => decode_payload_v2(&payload).map(EmbeddedPayload::Bundle),
247        _ => None,
248    }
249}
250
251/// Legacy: load v1 single script only (for backward compat).
252pub fn try_load_embedded_script(exe: &Path) -> Option<EmbeddedScript> {
253    match try_load_embedded(exe)? {
254        EmbeddedPayload::Script(s) => Some(s),
255        EmbeddedPayload::Bundle(b) => {
256            let source = b.files.get(&b.entry)?.clone();
257            Some(EmbeddedScript {
258                name: b.entry,
259                source,
260            })
261        }
262    }
263}
264
265/// `stryke build SCRIPT -o OUT`:
266/// 1. Read and parse-validate SCRIPT (surfacing syntax errors at build time, not at user run time).
267/// 2. Copy the currently-running `stryke` binary to OUT.
268/// 3. Append a compressed-source trailer.
269/// 4. `chmod +x` the result on unix.
270///
271/// Errors are returned as human-readable strings; the caller prints and sets an exit code.
272pub fn build(script_path: &Path, out_path: &Path) -> Result<PathBuf, String> {
273    let source = fs::read_to_string(script_path)
274        .map_err(|e| format!("stryke build: cannot read {}: {}", script_path.display(), e))?;
275    let script_name = script_path
276        .file_name()
277        .and_then(|s| s.to_str())
278        .unwrap_or("script.pl")
279        .to_string();
280
281    crate::parse_with_file(&source, &script_name).map_err(|e| format!("{}", e))?;
282
283    let exe = std::env::current_exe()
284        .map_err(|e| format!("stryke build: locating current executable: {}", e))?;
285
286    copy_exe_without_trailer(&exe, out_path).map_err(|e| {
287        format!(
288            "stryke build: copy {} -> {}: {}",
289            exe.display(),
290            out_path.display(),
291            e
292        )
293    })?;
294
295    append_embedded_script(out_path, &script_name, &source)
296        .map_err(|e| format!("stryke build: write trailer: {}", e))?;
297
298    set_executable(out_path);
299    Ok(out_path.to_path_buf())
300}
301
302/// Collect all `.stk` and `.pl` files from a directory, excluding `t/` (tests).
303fn collect_project_files(project_dir: &Path) -> io::Result<HashMap<String, String>> {
304    let mut files = HashMap::new();
305    fn visit(dir: &Path, base: &Path, files: &mut HashMap<String, String>) -> io::Result<()> {
306        for entry in fs::read_dir(dir)? {
307            let entry = entry?;
308            let path = entry.path();
309            let rel = path.strip_prefix(base).unwrap_or(&path);
310            let rel_str = rel.to_string_lossy();
311            if rel_str.starts_with("t/") || rel_str.starts_with("t\\") || rel_str == "t" {
312                continue;
313            }
314            if path.is_dir() {
315                visit(&path, base, files)?;
316            } else if let Some(ext) = path.extension() {
317                if ext == "stk" || ext == "pl" {
318                    let source = fs::read_to_string(&path)?;
319                    files.insert(rel.to_string_lossy().replace('\\', "/"), source);
320                }
321            }
322        }
323        Ok(())
324    }
325    visit(project_dir, project_dir, &mut files)?;
326    Ok(files)
327}
328
329/// `stryke build --project DIR -o OUT`:
330/// Bundle main.stk + lib/*.stk (excluding t/) into a single executable.
331pub fn build_project(project_dir: &Path, out_path: &Path) -> Result<PathBuf, String> {
332    let entry_path = project_dir.join("main.stk");
333    if !entry_path.exists() {
334        return Err(format!(
335            "stryke build: project directory {} has no main.stk",
336            project_dir.display()
337        ));
338    }
339
340    let files = collect_project_files(project_dir)
341        .map_err(|e| format!("stryke build: scanning project: {}", e))?;
342
343    eprintln!(
344        "stryke build: bundling {} files from {}",
345        files.len(),
346        project_dir.display()
347    );
348    for path in files.keys() {
349        eprintln!("  {}", path);
350    }
351
352    for (path, source) in &files {
353        crate::parse_with_file(source, path).map_err(|e| format!("{}", e))?;
354    }
355
356    let exe = std::env::current_exe()
357        .map_err(|e| format!("stryke build: locating current executable: {}", e))?;
358
359    copy_exe_without_trailer(&exe, out_path).map_err(|e| {
360        format!(
361            "stryke build: copy {} -> {}: {}",
362            exe.display(),
363            out_path.display(),
364            e
365        )
366    })?;
367
368    append_embedded_bundle(out_path, "main.stk", &files)
369        .map_err(|e| format!("stryke build: write trailer: {}", e))?;
370
371    set_executable(out_path);
372    Ok(out_path.to_path_buf())
373}
374
375#[cfg(unix)]
376fn set_executable(path: &Path) {
377    use std::os::unix::fs::PermissionsExt;
378    if let Ok(meta) = fs::metadata(path) {
379        let mut p = meta.permissions();
380        p.set_mode(p.mode() | 0o111);
381        let _ = fs::set_permissions(path, p);
382    }
383}
384
385#[cfg(not(unix))]
386fn set_executable(_path: &Path) {}
387
388/// Copy `src` to `dst`, skipping any existing AOT trailer on `src`. Prevents nested builds
389/// from stacking trailers: `stryke build a.pl -o a && stryke --exe a build b.pl -o b` would otherwise
390/// embed both scripts, one on top of the other.
391fn copy_exe_without_trailer(src: &Path, dst: &Path) -> io::Result<()> {
392    let mut sf = File::open(src)?;
393    let size = sf.metadata()?.len();
394    let keep = if size >= TRAILER_LEN {
395        sf.seek(SeekFrom::End(-(TRAILER_LEN as i64)))?;
396        let mut trailer = [0u8; TRAILER_LEN as usize];
397        if sf.read_exact(&mut trailer).is_ok() && &trailer[24..32] == AOT_MAGIC {
398            let compressed_len = u64::from_le_bytes(trailer[0..8].try_into().unwrap());
399            if compressed_len > 0 && compressed_len <= size - TRAILER_LEN {
400                size - TRAILER_LEN - compressed_len
401            } else {
402                size
403            }
404        } else {
405            size
406        }
407    } else {
408        size
409    };
410    sf.seek(SeekFrom::Start(0))?;
411    // Remove any existing destination first so `fs::copy`-like behaviour is atomic from the
412    // caller's point of view and we never open the running destination for truncation.
413    let _ = fs::remove_file(dst);
414    let mut df = File::create(dst)?;
415    let mut remaining = keep;
416    let mut buf = vec![0u8; 64 * 1024];
417    while remaining > 0 {
418        let n = std::cmp::min(remaining as usize, buf.len());
419        sf.read_exact(&mut buf[..n])?;
420        df.write_all(&buf[..n])?;
421        remaining -= n as u64;
422    }
423    df.sync_all()?;
424    Ok(())
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430
431    fn tmp_path(tag: &str) -> PathBuf {
432        let dir = std::env::temp_dir();
433        dir.join(format!(
434            "stryke-aot-test-{}-{}-{}",
435            std::process::id(),
436            tag,
437            rand::random::<u32>()
438        ))
439    }
440
441    #[test]
442    fn payload_roundtrips_name_and_source() {
443        let payload = encode_payload_v1("hello.pl", "print \"hi\\n\";\n");
444        let decoded = decode_payload_v1(&payload).expect("decode");
445        assert_eq!(decoded.name, "hello.pl");
446        assert_eq!(decoded.source, "print \"hi\\n\";\n");
447    }
448
449    #[test]
450    fn append_and_load_trailer_roundtrips_on_plain_file() {
451        let path = tmp_path("roundtrip");
452        // Pretend this is a `stryke` binary: write a non-empty prefix so trailer math is exercised.
453        fs::write(
454            &path,
455            b"not really an ELF, but good enough for trailer tests",
456        )
457        .unwrap();
458        append_embedded_script(&path, "script.pl", "my $x = 1 + 2;").unwrap();
459        let loaded = try_load_embedded(&path).expect("load");
460        match loaded {
461            EmbeddedPayload::Script(s) => {
462                assert_eq!(s.name, "script.pl");
463                assert_eq!(s.source, "my $x = 1 + 2;");
464            }
465            EmbeddedPayload::Bundle(_) => panic!("expected Script, got Bundle"),
466        }
467        fs::remove_file(&path).ok();
468    }
469
470    #[test]
471    fn load_returns_none_for_file_without_trailer() {
472        let path = tmp_path("no-trailer");
473        fs::write(&path, b"plain binary, no magic").unwrap();
474        assert!(try_load_embedded(&path).is_none());
475        fs::remove_file(&path).ok();
476    }
477
478    #[test]
479    fn load_returns_none_for_short_file() {
480        let path = tmp_path("short");
481        fs::write(&path, b"abc").unwrap();
482        assert!(try_load_embedded(&path).is_none());
483        fs::remove_file(&path).ok();
484    }
485
486    #[test]
487    fn copy_without_trailer_strips_embedded_script() {
488        let src = tmp_path("src");
489        let mid = tmp_path("mid");
490        let dst = tmp_path("dst");
491        fs::write(&src, b"pretend stryke binary bytes").unwrap();
492        // Layer 1: embed script_a.
493        fs::copy(&src, &mid).unwrap();
494        append_embedded_script(&mid, "a.pl", "p 1;").unwrap();
495        // Layer 2: strip + embed script_b — should yield only script_b.
496        copy_exe_without_trailer(&mid, &dst).unwrap();
497        append_embedded_script(&dst, "b.pl", "p 2;").unwrap();
498        let loaded = try_load_embedded(&dst).expect("load layer 2");
499        match loaded {
500            EmbeddedPayload::Script(s) => {
501                assert_eq!(s.name, "b.pl");
502                assert_eq!(s.source, "p 2;");
503            }
504            EmbeddedPayload::Bundle(_) => panic!("expected Script, got Bundle"),
505        }
506        // Compare stripped prefix to original: they must match byte-for-byte.
507        let original = fs::read(&src).unwrap();
508        let mut stripped_dst = fs::read(&dst).unwrap();
509        stripped_dst.truncate(original.len());
510        assert_eq!(stripped_dst, original);
511        fs::remove_file(&src).ok();
512        fs::remove_file(&mid).ok();
513        fs::remove_file(&dst).ok();
514    }
515
516    #[test]
517    fn bad_magic_is_ignored() {
518        let path = tmp_path("bad-magic");
519        let mut bytes = vec![0u8; 200];
520        // Write 32 bytes that look like a trailer but with wrong magic at the end.
521        let tail = &mut bytes[200 - 32..];
522        tail[0..8].copy_from_slice(&10u64.to_le_bytes()); // compressed_len claims 10
523        tail[8..16].copy_from_slice(&20u64.to_le_bytes());
524        tail[16..20].copy_from_slice(&1u32.to_le_bytes());
525        tail[24..32].copy_from_slice(b"NOTPERLZ");
526        fs::write(&path, &bytes).unwrap();
527        assert!(try_load_embedded(&path).is_none());
528        fs::remove_file(&path).ok();
529    }
530}