1use std::collections::HashMap;
35use std::fs::{self, File, OpenOptions};
36use std::io::{self, Read, Seek, SeekFrom, Write};
37use std::path::{Path, PathBuf};
38
39pub const AOT_MAGIC: &[u8; 8] = b"STRK_AOT";
41pub const AOT_VERSION_V1: u32 = 1;
43pub const AOT_VERSION_V2: u32 = 2;
45pub const TRAILER_LEN: u64 = 32;
47#[derive(Debug, Clone)]
49pub struct EmbeddedScript {
50 pub name: String,
52 pub source: String,
54}
55
56#[derive(Debug, Clone)]
58pub struct EmbeddedBundle {
59 pub entry: String,
61 pub files: HashMap<String, String>,
63}
64
65fn 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
75fn 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
94fn 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
112fn 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
160fn 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 trailer[24..32].copy_from_slice(AOT_MAGIC);
168 trailer
169}
170
171pub 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
187pub 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#[derive(Debug, Clone)]
209pub enum EmbeddedPayload {
210 Script(EmbeddedScript),
212 Bundle(EmbeddedBundle),
214}
215
216pub 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
251pub 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
265pub 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
302fn 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
329pub 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
388fn 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 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 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 fs::copy(&src, &mid).unwrap();
494 append_embedded_script(&mid, "a.pl", "p 1;").unwrap();
495 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 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 let tail = &mut bytes[200 - 32..];
522 tail[0..8].copy_from_slice(&10u64.to_le_bytes()); 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}