1use 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
58pub const AOT_MAGIC: &[u8; 8] = b"ZSHRSAOT";
60pub const AOT_VERSION_V1: u32 = 1;
62pub const AOT_VERSION_V2: u32 = 2;
64pub const TRAILER_LEN: u64 = 32;
66#[derive(Debug, Clone)]
68pub struct EmbeddedFile {
69 pub name: String,
71 pub source: String,
73}
74
75#[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
138fn 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 trailer[24..32].copy_from_slice(AOT_MAGIC);
165 trailer
166}
167
168pub 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
187pub 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
236fn 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
273pub 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#[no_mangle]
333pub extern "C" fn fusevm_aot_register_builtins(vm: *mut fusevm::VM) {
334 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 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 std::mem::forget(crate::fusevm_bridge::ExecutorContext::enter(exec));
347}
348
349fn 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
359fn 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
376pub 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 cmd.arg("-lncurses");
416 if cfg!(target_os = "macos") {
417 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 assert_eq!(&t[0..8], &0x11_22_33_44u64.to_le_bytes());
462 assert_eq!(&t[8..16], &0xAA_BB_CC_DDu64.to_le_bytes());
464 assert_eq!(&t[16..20], &AOT_VERSION_V2.to_le_bytes());
466 assert_eq!(&t[20..24], &[0u8; 4]);
468 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 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 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 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()); buf.extend_from_slice(b"abc"); 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 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 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 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 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 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 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]; 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 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 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}