From 7393c56699cd6ff2ff6e261377b558dc7a21d99c Mon Sep 17 00:00:00 2001 From: InauguralPhysicist Date: Fri, 10 Jul 2026 20:34:14 -0500 Subject: [PATCH] =?UTF-8?q?feat(cli):=20--bundle=20=E2=80=94=20single-file?= =?UTF-8?q?=20distribution=20with=20optional=20attached=20tape=20(#413)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #413 `eigenscript --bundle app.eigs out [--with-tape tape]` copies the runtime binary and appends an archive: the script (always the first entry), the adjacent eigs_modules/ tree (whole tree — dependency analysis can't see dynamic imports and the superset is cheap), the stdlib lib/ modules (so `import json` works on a bare machine), and optionally a trace tape. One executable, runs anywhere. Startup (eigs_bundle_selfexec, called before any flag handling) detects the 24-byte trailer magic on the binary's own tail, extracts entries to a mkdtemp dir (removed at exit), and rewrites argv to run the extracted script — the script's dir becomes the resolve base, so every existing resolution rule just works: no VFS, no resolver changes. In bundle mode all args belong to the bundled script; only `--replay` (first arg) is ours — it points EIGS_REPLAY at the extracted tape, making a tape-carrying bundle an executable bug report that replays byte-identically with no environment. The #411 version contract is pinned by construction: the tape names its recording version and the bundle carries that exact binary. Format (fmt 1, little-endian): [u32 plen][path][u64 size][bytes]* entries + [u64 off][u32 count][u32 fmt][EIGSBNDL] trailer. Torn archives (misparsed entry headers, traversal paths) refuse with exit 3. Re-bundling from a bundle copies only the runtime image, so archives don't accrete. src/bundle.c is CLI_ONLY — excluded from embed/LSP/freestanding/fuzz builds like main.c/repl.c/step.c. tests/test_bundle.sh (suite [42g], 8 checks): script+modules+stdlib from bare cwd, arg passthrough, byte-identical --replay serving the recorded nondet value, no-tape refusal, torn-archive refusal, re-bundle size stability. docs/BUNDLE.md documents the format and positions ouroboros AOT as the native-speed tier of the same story. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GdDAVfZHgoQ2UNP63FYUXm --- CHANGELOG.md | 22 +++ Makefile | 4 +- docs/BUNDLE.md | 71 ++++++++ src/bundle.c | 388 +++++++++++++++++++++++++++++++++++++++++ src/main.c | 29 +++ tests/run_all_tests.sh | 19 +- tests/test_bundle.sh | 120 +++++++++++++ 7 files changed, 650 insertions(+), 3 deletions(-) create mode 100644 docs/BUNDLE.md create mode 100644 src/bundle.c create mode 100644 tests/test_bundle.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 406e5cf..46cf3e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,28 @@ All notable changes to EigenScript are documented here. ## [Unreleased] +### Added +- **`--bundle`: single-file distribution (#413).** `eigenscript + --bundle app.eigs out [--with-tape tape]` copies the runtime binary + and appends an archive — the script, the adjacent `eigs_modules/` + tree, the stdlib `lib/` modules, and optionally a trace tape — into + one executable that runs on a machine with no EigenScript checkout. + Startup detects the trailer magic on the binary's own tail, extracts + to a tempdir (removed at exit), and rewrites argv to run the + extracted script, so every existing resolution rule just works with + no VFS or resolver changes. With a tape attached, `out --replay` is + an **executable bug report**: it replays byte-identically, serving + every nondet input from the tape — and the #411 version contract is + pinned by construction, since the bundle carries the exact binary + that recorded the tape. In bundle mode all arguments belong to the + script (only `--replay` is intercepted); torn archives refuse with + exit 3; re-bundling from a bundle copies only the runtime image. + `tests/test_bundle.sh` (suite [42g], 8 checks) covers script+modules+ + stdlib from a bare cwd, arg passthrough, byte-identical replay of the + recorded nondet, no-tape/torn refusals, and re-bundle size stability. + docs/BUNDLE.md documents the format and the AOT (ouroboros) tier as + the native-speed variant of the same single-file story. + ### Fixed - **Builtin calls with a single list argument were O(len(list)) (#546).** The direct-borrow heuristic that runs after every builtin call scanned diff --git a/Makefile b/Makefile index acf0ca3..15ae861 100644 --- a/Makefile +++ b/Makefile @@ -19,14 +19,14 @@ LDFLAGS := -pie -Wl,-z,relro,-z,now -lm -lpthread endif SRC_DIR := src -SOURCES := $(SRC_DIR)/eigenscript.c $(SRC_DIR)/lexer.c $(SRC_DIR)/parser.c $(SRC_DIR)/builtins.c $(SRC_DIR)/builtins_tensor.c $(SRC_DIR)/hash.c $(SRC_DIR)/arena.c $(SRC_DIR)/state.c $(SRC_DIR)/strbuf.c $(SRC_DIR)/ext_store.c $(SRC_DIR)/fmt.c $(SRC_DIR)/lint.c $(SRC_DIR)/chunk.c $(SRC_DIR)/compiler.c $(SRC_DIR)/vm.c $(SRC_DIR)/jit.c $(SRC_DIR)/trace.c $(SRC_DIR)/eigs_embed.c $(SRC_DIR)/repl.c $(SRC_DIR)/step.c $(SRC_DIR)/main.c +SOURCES := $(SRC_DIR)/eigenscript.c $(SRC_DIR)/lexer.c $(SRC_DIR)/parser.c $(SRC_DIR)/builtins.c $(SRC_DIR)/builtins_tensor.c $(SRC_DIR)/hash.c $(SRC_DIR)/arena.c $(SRC_DIR)/state.c $(SRC_DIR)/strbuf.c $(SRC_DIR)/ext_store.c $(SRC_DIR)/fmt.c $(SRC_DIR)/lint.c $(SRC_DIR)/chunk.c $(SRC_DIR)/compiler.c $(SRC_DIR)/vm.c $(SRC_DIR)/jit.c $(SRC_DIR)/trace.c $(SRC_DIR)/eigs_embed.c $(SRC_DIR)/repl.c $(SRC_DIR)/step.c $(SRC_DIR)/bundle.c $(SRC_DIR)/main.c BINARY := $(SRC_DIR)/eigenscript # CLI-only translation units: linked into the binary, never into the # runtime library (repl.c pulls termios/isatty — banned in the # freestanding/embed profile, same footing as main.c; step.c is the # --step tape-stepper, stdio+isatty, same footing). -CLI_ONLY := $(SRC_DIR)/main.c $(SRC_DIR)/repl.c $(SRC_DIR)/step.c +CLI_ONLY := $(SRC_DIR)/main.c $(SRC_DIR)/repl.c $(SRC_DIR)/step.c $(SRC_DIR)/bundle.c FULL_SOURCES := $(SOURCES) $(SRC_DIR)/ext_http.c $(SRC_DIR)/ext_db.c \ $(SRC_DIR)/model_io.c $(SRC_DIR)/model_infer.c $(SRC_DIR)/model_train.c diff --git a/docs/BUNDLE.md b/docs/BUNDLE.md new file mode 100644 index 0000000..48a38dc --- /dev/null +++ b/docs/BUNDLE.md @@ -0,0 +1,71 @@ +# `--bundle` — single-file distribution (#413) + +``` +eigenscript --bundle app.eigs out [--with-tape tape] +./out [args...] # runs anywhere — no EigenScript checkout needed +./out --replay [args...] # with a tape attached: re-run it EXACTLY +``` + +`--bundle` copies the running runtime binary and appends an archive: +the script, the adjacent `eigs_modules/` tree (the whole tree — +dependency analysis can't see dynamic imports, and the superset is +cheap), the stdlib `lib/` modules (so `import json` works on a bare +machine), and optionally a trace tape. The result is one executable. + +Demo: + +``` +eigenscript --bundle examples/data_pipeline.eigs pipeline +./pipeline +``` + +## The tape-carrying variant: an executable bug report + +Record a failing run, then ship binary + failure as one file: + +``` +EIGS_TRACE=fail.tape eigenscript app.eigs # reproduce once, recorded +eigenscript --bundle app.eigs repro --with-tape fail.tape +./repro --replay # replays byte-identically, + # no network, no env, no luck +``` + +Every nondeterministic input (random, time, env, file reads, HTTP — +see docs/TRACE.md) is served from the tape. The #411 version contract +is pinned *by construction*: the tape names the version that recorded +it, and the bundle carries that exact binary — the pair can't drift. + +## How it runs + +Startup checks the binary's own tail for the archive trailer. A bundle +extracts to a `mkdtemp` directory and rewrites `argv` to run the +extracted script; the script's directory is the resolve base, so every +existing resolution rule just works (`import name` → +`eigs_modules/name/name.eigs`, `import json` → `lib/json.eigs`). The +tempdir is removed at exit. In bundle mode all arguments belong to the +bundled script — only `--replay` (first argument) is intercepted. + +## Format + +Appended after the runtime image (little-endian, x86-64 — the same +assumption the JIT makes): + +``` +entry*: [u32 path_len][path][u64 size][bytes] (first entry = the script) +trailer: [u64 archive_off][u32 count][u32 fmt=1]["EIGSBNDL"] +``` + +A torn archive (misparsing entry headers) refuses with exit 3 rather +than running garbage. There are no per-entry checksums — a flipped bit +inside one file's *data* is out of scope for fmt 1; the attached tape +still self-identifies via its own `V` header. Re-bundling from a bundle +copies only the runtime image, so archives don't accrete. + +## The native-speed tier of the same story + +`--bundle` ships at VM speed today. The **AOT compiler in the sibling +`ouroboros` repo** (transpile-to-C, the VM as its byte-exact oracle) is +the native-speed tier of the same deliverable: compile the script to a +real native binary instead of carrying the interpreter. Same +single-file story, ~10–60× faster on numeric code — use it when the +bundle's job is performance rather than reproduction. diff --git a/src/bundle.c b/src/bundle.c new file mode 100644 index 0000000..4622624 --- /dev/null +++ b/src/bundle.c @@ -0,0 +1,388 @@ +/* ================================================================ + * EigenScript --bundle — single-file distribution (#413) + * ================================================================ + * `eigenscript --bundle app.eigs out [--with-tape tape]` copies the + * running runtime binary and appends an archive: the script, the + * adjacent eigs_modules/ tree (if any), the stdlib lib/ modules, and + * optionally a trace tape. The result is one executable that runs on + * a machine with no EigenScript checkout — and with a tape attached, + * `out --replay` is a self-replaying reproducer (an executable bug + * report that replays deterministically). + * + * Startup self-exec detection: main() calls eigs_bundle_selfexec() + * first; a trailer magic at EOF marks a bundle. Entries extract to a + * mkdtemp directory and argv is rewritten to run the extracted script + * — every existing resolution rule then just works: the script's dir + * is the resolve base, so `import name` finds tmpdir/eigs_modules/... + * and `import json` finds tmpdir/lib/json.eigs. No VFS, no resolver + * changes. The tempdir is removed at exit (best-effort). + * + * Archive format (fmt 1), appended after the runtime image: + * entry*: [u32 path_len][path bytes, no NUL][u64 size][bytes] + * trailer (24 bytes at EOF): + * [u64 archive_off][u32 entry_count][u32 fmt=1][8-byte magic] + * The FIRST entry is always the script. Integers are little-endian + * (x86-64 only today, same assumption the JIT already makes). + * + * The tape entry (".eigs_bundle.tape") pins the #411 contract by + * construction: the tape names its recording version, and the bundle + * carries the exact binary that recorded it — the pair can't drift. + * + * CLI-only (Makefile CLI_ONLY set): excluded from embed/LSP/ + * freestanding builds like main.c/repl.c/step.c. + * ================================================================ */ + +#include "eigenscript.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define BUNDLE_MAGIC "EIGSBNDL" +#define BUNDLE_FMT 1u +#define BUNDLE_TAPE ".eigs_bundle.tape" +#define TRAILER_SIZE 24 + +/* ---- own-executable path (Linux readlink; argv0 fallback for macOS, + * which is how the suite invokes bundles anyway: an explicit path) ---- */ +static int own_exe_path(const char *argv0, char *out, size_t cap) { + ssize_t n = readlink("/proc/self/exe", out, cap - 1); + if (n > 0 && n < (ssize_t)cap) { out[n] = '\0'; return 1; } + if (argv0 && strchr(argv0, '/') && realpath(argv0, out)) return 1; + return 0; +} + +/* ---- trailer probe: returns 1 and fills off/count if `f` is a bundle */ +static int read_trailer(FILE *f, uint64_t *off, uint32_t *count) { + unsigned char t[TRAILER_SIZE]; + if (fseek(f, -(long)TRAILER_SIZE, SEEK_END) != 0) return 0; + if (fread(t, 1, TRAILER_SIZE, f) != TRAILER_SIZE) return 0; + if (memcmp(t + 16, BUNDLE_MAGIC, 8) != 0) return 0; + uint32_t fmt; + memcpy(off, t, 8); + memcpy(count, t + 8, 4); + memcpy(&fmt, t + 12, 4); + if (fmt != BUNDLE_FMT) { + fprintf(stderr, "bundle: archive format v%u, this binary reads v%u — " + "re-bundle on this version\n", fmt, BUNDLE_FMT); + return 0; + } + return 1; +} + +/* ---- writer ----------------------------------------------------- */ + +static int copy_stream(FILE *src, FILE *dst, uint64_t nbytes) { + char buf[65536]; + while (nbytes > 0) { + size_t want = nbytes < sizeof(buf) ? (size_t)nbytes : sizeof(buf); + size_t got = fread(buf, 1, want, src); + if (got == 0) return 0; + if (fwrite(buf, 1, got, dst) != got) return 0; + nbytes -= got; + } + return 1; +} + +static int emit_entry_header(FILE *out, const char *path, uint64_t size) { + uint32_t plen = (uint32_t)strlen(path); + if (fwrite(&plen, 4, 1, out) != 1) return 0; + if (fwrite(path, 1, plen, out) != plen) return 0; + if (fwrite(&size, 8, 1, out) != 1) return 0; + return 1; +} + +/* Append one file under archive path `apath`. Returns 1 on success. */ +static int emit_file(FILE *out, const char *fspath, const char *apath, + uint32_t *count) { + FILE *f = fopen(fspath, "rb"); + if (!f) return 0; + fseek(f, 0, SEEK_END); + long sz = ftell(f); + fseek(f, 0, SEEK_SET); + if (sz < 0) { fclose(f); return 0; } + int ok = emit_entry_header(out, apath, (uint64_t)sz) && + copy_stream(f, out, (uint64_t)sz); + fclose(f); + if (ok) (*count)++; + return ok; +} + +/* Recursively append every regular file under `fsdir` as `adir/...`. + * Missing dir is not an error (a script may have no eigs_modules). */ +static int emit_tree(FILE *out, const char *fsdir, const char *adir, + uint32_t *count, int depth) { + if (depth > 16) return 1; + DIR *d = opendir(fsdir); + if (!d) return 1; + struct dirent *e; + int ok = 1; + while (ok && (e = readdir(d)) != NULL) { + if (e->d_name[0] == '.') continue; + char fs[4096], ar[4096]; + snprintf(fs, sizeof(fs), "%s/%s", fsdir, e->d_name); + snprintf(ar, sizeof(ar), "%s/%s", adir, e->d_name); + struct stat st; + if (stat(fs, &st) != 0) continue; + if (S_ISDIR(st.st_mode)) + ok = emit_tree(out, fs, ar, count, depth + 1); + else if (S_ISREG(st.st_mode)) + ok = emit_file(out, fs, ar, count); + } + closedir(d); + return ok; +} + +/* Stdlib source dir: repo layout (/../lib) or install layout + * (/../lib/eigenscript). Archive paths are lib/.eigs either + * way — exactly what `import` requests against the script dir. Derived + * from the exe path directly: --bundle runs before any EigsState is + * attached, so the g_exe_dir state macro is off-limits here. */ +static const char *stdlib_dir(const char *self, char *buf, size_t cap) { + char dir[4096]; + snprintf(dir, sizeof(dir), "%s", self); + char *slash = strrchr(dir, '/'); + if (!slash) return NULL; + *slash = '\0'; + char probe[4600]; + snprintf(buf, cap, "%.4000s/../lib/eigenscript", dir); + snprintf(probe, sizeof(probe), "%.4500s/string.eigs", buf); + if (access(probe, F_OK) == 0) return buf; + snprintf(buf, cap, "%.4000s/../lib", dir); + snprintf(probe, sizeof(probe), "%.4500s/string.eigs", buf); + if (access(probe, F_OK) == 0) return buf; + return NULL; +} + +int eigs_bundle_create(const char *argv0, const char *script, + const char *outpath, const char *tape) { + char self[4096]; + if (!own_exe_path(argv0, self, sizeof(self))) { + fprintf(stderr, "bundle: cannot locate own executable\n"); + return 1; + } + FILE *in = fopen(self, "rb"); + if (!in) { fprintf(stderr, "bundle: cannot open '%s'\n", self); return 1; } + + /* Re-bundling from a bundle: copy only the runtime image, not the + * old archive. */ + uint64_t img_end; uint32_t old_count; + if (read_trailer(in, &img_end, &old_count)) { + /* img_end = old archive start = end of the runtime image */ + } else { + fseek(in, 0, SEEK_END); + img_end = (uint64_t)ftell(in); + } + fseek(in, 0, SEEK_SET); + + /* explicit mode — never world-writable regardless of umask; the + * final chmod below still stamps the executable bit set. */ + int outfd = open(outpath, O_WRONLY | O_CREAT | O_TRUNC, 0755); + FILE *out = outfd >= 0 ? fdopen(outfd, "wb") : NULL; + if (!out) { + if (outfd >= 0) close(outfd); + fprintf(stderr, "bundle: cannot write '%s'\n", outpath); + fclose(in); + return 1; + } + + int ok = copy_stream(in, out, img_end); + fclose(in); + uint64_t archive_off = img_end; + uint32_t count = 0; + + /* 1. the script — ALWAYS the first entry, archived by basename */ + const char *base = strrchr(script, '/'); + base = base ? base + 1 : script; + ok = ok && emit_file(out, script, base, &count); + if (!ok) fprintf(stderr, "bundle: cannot read script '%s'\n", script); + + /* 2. the adjacent eigs_modules/ tree (whole tree: dependency + * analysis can't see dynamic imports, and the superset is cheap) */ + if (ok) { + char sdir[4096], mods[4600]; + snprintf(sdir, sizeof(sdir), "%s", script); + char *slash = strrchr(sdir, '/'); + if (slash) *slash = '\0'; else snprintf(sdir, sizeof(sdir), "."); + snprintf(mods, sizeof(mods), "%.4000s/eigs_modules", sdir); + ok = emit_tree(out, mods, "eigs_modules", &count, 0); + } + + /* 3. the stdlib, so `import json` works on a bare machine */ + if (ok) { + char libbuf[4096]; + const char *lib = stdlib_dir(self, libbuf, sizeof(libbuf)); + if (lib) ok = emit_tree(out, lib, "lib", &count, 0); + else fprintf(stderr, "bundle: warning: stdlib dir not found — " + "bundle will lack lib/ imports\n"); + } + + /* 4. optional tape */ + if (ok && tape) { + ok = emit_file(out, tape, BUNDLE_TAPE, &count); + if (!ok) fprintf(stderr, "bundle: cannot read tape '%s'\n", tape); + } + + /* trailer */ + if (ok) { + uint32_t fmt = BUNDLE_FMT; + ok = fwrite(&archive_off, 8, 1, out) == 1 && + fwrite(&count, 4, 1, out) == 1 && + fwrite(&fmt, 4, 1, out) == 1 && + fwrite(BUNDLE_MAGIC, 1, 8, out) == 8; + } + /* fchmod on the still-open fd: the exec bit lands on the file we + * wrote, not whatever a path race could swap in (cpp/toctou). */ + if (ok && fchmod(fileno(out), 0755) != 0) ok = 0; + if (fclose(out) != 0) ok = 0; + if (!ok) { + fprintf(stderr, "bundle: write failed\n"); + unlink(outpath); + return 1; + } + printf("bundled %s + %u file(s)%s -> %s\n", base, count - 1, + tape ? " + tape" : "", outpath); + return 0; +} + +/* ---- self-exec reader ------------------------------------------- */ + +static char g_bundle_tmpdir[4096]; + +/* fd-anchored recursive delete (openat/fstatat/unlinkat): each step is + * relative to an open directory fd, so a mid-walk path swap can't + * redirect the traversal (cpp/toctou). O_NOFOLLOW on the descent — we + * created every entry; a symlink here is someone else's. */ +static void rm_tree_fd(int dirfd, int depth) { + if (depth > 16) { close(dirfd); return; } + DIR *d = fdopendir(dirfd); + if (!d) { close(dirfd); return; } + struct dirent *e; + while ((e = readdir(d)) != NULL) { + if (strcmp(e->d_name, ".") == 0 || strcmp(e->d_name, "..") == 0) + continue; + struct stat st; + if (fstatat(dirfd, e->d_name, &st, AT_SYMLINK_NOFOLLOW) != 0) + continue; + if (S_ISDIR(st.st_mode)) { + int sub = openat(dirfd, e->d_name, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW); + if (sub >= 0) rm_tree_fd(sub, depth + 1); /* closes sub */ + unlinkat(dirfd, e->d_name, AT_REMOVEDIR); + } else { + unlinkat(dirfd, e->d_name, 0); + } + } + closedir(d); /* also closes dirfd */ +} + +static void bundle_cleanup(void) { + if (g_bundle_tmpdir[0]) { + int fd = open(g_bundle_tmpdir, O_RDONLY | O_DIRECTORY | O_NOFOLLOW); + if (fd >= 0) rm_tree_fd(fd, 0); + rmdir(g_bundle_tmpdir); + } +} + +/* mkdir -p for the dirname of `path` (relative to tmpdir extraction). */ +static void mkdirs_for(char *path) { + for (char *p = path + 1; *p; p++) { + if (*p == '/') { + *p = '\0'; + mkdir(path, 0755); + *p = '/'; + } + } +} + +/* Detect an appended archive on our own binary; extract and rewrite + * argv so the ordinary script path runs the extracted entry script. + * Returns 1 in bundle mode (argc/argv rewritten in place), 0 otherwise. + * In bundle mode ONLY `--replay` (as the first arg) is intercepted — + * every other argument belongs to the bundled script. */ +int eigs_bundle_selfexec(int *argc, char ***argv) { + char self[4096]; + if (!own_exe_path((*argv)[0], self, sizeof(self))) return 0; + FILE *f = fopen(self, "rb"); + if (!f) return 0; + + uint64_t off; uint32_t count; + if (!read_trailer(f, &off, &count)) { fclose(f); return 0; } + + const char *tmp = getenv("TMPDIR"); + snprintf(g_bundle_tmpdir, sizeof(g_bundle_tmpdir), + "%s/eigsbndl.XXXXXX", (tmp && tmp[0]) ? tmp : "/tmp"); + if (!mkdtemp(g_bundle_tmpdir)) { + fprintf(stderr, "bundle: mkdtemp failed\n"); + fclose(f); + exit(1); + } + atexit(bundle_cleanup); + + static char script_path[4600]; + char tape_path[4600]; + tape_path[0] = '\0'; + + fseek(f, (long)off, SEEK_SET); + for (uint32_t i = 0; i < count; i++) { + uint32_t plen; + uint64_t size; + char rel[2048]; + if (fread(&plen, 4, 1, f) != 1 || plen == 0 || plen >= sizeof(rel) || + fread(rel, 1, plen, f) != plen) goto torn; + rel[plen] = '\0'; + if (fread(&size, 8, 1, f) != 1) goto torn; + /* Refuse traversal: entries are relative, no '..' segments. */ + if (rel[0] == '/' || strstr(rel, "..")) goto torn; + + char dst[4600]; + snprintf(dst, sizeof(dst), "%.2500s/%.2000s", g_bundle_tmpdir, rel); + mkdirs_for(dst); + int ofd = open(dst, O_WRONLY | O_CREAT | O_TRUNC, 0644); + FILE *o = ofd >= 0 ? fdopen(ofd, "wb") : NULL; + if (ofd >= 0 && !o) close(ofd); + if (!o || !copy_stream(f, o, size)) { + if (o) fclose(o); + goto torn; + } + fclose(o); + if (i == 0) snprintf(script_path, sizeof(script_path), "%s", dst); + if (strcmp(rel, BUNDLE_TAPE) == 0) + snprintf(tape_path, sizeof(tape_path), "%s", dst); + } + fclose(f); + + /* `out --replay ...`: serve the attached tape */ + int shift = 0; + if (*argc >= 2 && strcmp((*argv)[1], "--replay") == 0) { + if (!tape_path[0]) { + fprintf(stderr, "bundle: --replay but no tape attached\n"); + exit(3); + } + setenv("EIGS_REPLAY", tape_path, 1); + shift = 1; + } + + /* argv becomes: [argv0, extracted-script, script args...] */ + static char *new_argv[256]; + int n = 0; + new_argv[n++] = (*argv)[0]; + new_argv[n++] = script_path; + for (int i = 1 + shift; i < *argc && n < 255; i++) + new_argv[n++] = (*argv)[i]; + new_argv[n] = NULL; + *argc = n; + *argv = new_argv; + return 1; + +torn: + fprintf(stderr, "bundle: torn or malformed archive — refusing to run\n"); + fclose(f); + exit(3); +} diff --git a/src/main.c b/src/main.c index 5f03c4a..df222f4 100644 --- a/src/main.c +++ b/src/main.c @@ -15,6 +15,12 @@ void register_gfx_builtins(Env *env); #define EIGENSCRIPT_VERSION "dev" #endif +/* #413 bundle entry points (src/bundle.c, CLI-only) — declared at file + * scope; block-scope function declarations trip cpp/function-in-block. */ +extern int eigs_bundle_selfexec(int *argc, char ***argv); +extern int eigs_bundle_create(const char *argv0, const char *script, + const char *outpath, const char *tape); + /* ---- Main ---- */ /* The REPL (piped loop + the #392 interactive line editor) lives in repl.c. */ @@ -47,6 +53,12 @@ static void set_exe_dir(const char *argv0) { } int main(int argc, char **argv) { + /* #413: a bundled binary (appended archive, trailer magic) extracts + * itself and rewrites argv to run the extracted script — before ANY + * flag handling, because in bundle mode the arguments belong to the + * bundled script (only `--replay` is ours). */ + eigs_bundle_selfexec(&argc, &argv); + /* `--trace `: record this run's tape to , the CLI * twin of EIGS_TRACE (which trace_init reads below). Set before trace_init * and strip the two tokens so the rest of arg handling sees the script at @@ -87,6 +99,9 @@ int main(int argc, char **argv) { " eigenscript --step [file] interactive tape-stepper: step forward/back, bindings +\n" " trajectories, breakpoints (docs/DEBUGGING.md)\n" " eigenscript --pkg [args...] package manager (add/install/update/verify; see docs)\n" + " eigenscript --bundle [--with-tape ]\n" + " single-file executable: runtime + script + eigs_modules +\n" + " stdlib (+ optional tape: `out --replay` re-runs it exactly)\n" " eigenscript --version, -v print the version and exit\n" " eigenscript --help, -h print this help and exit\n" "\n" @@ -95,6 +110,20 @@ int main(int argc, char **argv) { return 0; } + /* #413 --bundle: pure file I/O — copies our own binary and appends + * the archive; nothing executes. Needs g_exe_dir for the stdlib dir. */ + if (argc >= 2 && strcmp(argv[1], "--bundle") == 0) { + if (argc < 4) { + fprintf(stderr, "Usage: eigenscript --bundle " + "[--with-tape ]\n"); + return 1; + } + const char *bundle_tape = NULL; + if (argc >= 6 && strcmp(argv[4], "--with-tape") == 0) + bundle_tape = argv[5]; + return eigs_bundle_create(argv[0], argv[2], argv[3], bundle_tape); + } + /* --step: the #418 tape-stepper — a pure tape READER (nothing executes). * Handled before trace_init for the same reason as --version: a stale * EIGS_REPLAY in the environment is a fatal header check (#411) and must diff --git a/tests/run_all_tests.sh b/tests/run_all_tests.sh index 590ea10..5a1a782 100755 --- a/tests/run_all_tests.sh +++ b/tests/run_all_tests.sh @@ -1374,7 +1374,7 @@ echo "" # [42f] Tape stepper (#418 eigsdap v1: --step forward/back, bindings + # trajectory labels, breakpoints, jumps, #411 version refusals) -echo "[42f] Tape Stepper (16 checks)" +echo "[42f] Tape Stepper (22 checks)" ST_OUTPUT=$(bash "$TESTS_DIR/test_step.sh" 2>&1) ST_PASS=$(echo "$ST_OUTPUT" | grep -c "PASS:" || true) ST_FAIL=$(echo "$ST_OUTPUT" | grep -c "FAIL:" || true) @@ -1389,6 +1389,23 @@ else fi echo "" +# [42g] --bundle (#413): single-file distribution — script + eigs_modules + +# stdlib in one executable; tape-attached bundles replay byte-identically. +echo "[42g] Bundle (8 checks)" +BN_OUTPUT=$(bash "$TESTS_DIR/test_bundle.sh" 2>&1) +BN_PASS=$(echo "$BN_OUTPUT" | grep -c "PASS:" || true) +BN_FAIL=$(echo "$BN_OUTPUT" | grep -c "FAIL:" || true) +TOTAL=$((TOTAL + BN_PASS + BN_FAIL)) +PASS=$((PASS + BN_PASS)) +FAIL=$((FAIL + BN_FAIL)) +if [ "$BN_FAIL" -gt 0 ]; then + echo " FAIL: $BN_FAIL bundle check(s) failed" + echo "$BN_OUTPUT" | grep "FAIL:" | head -5 +else + echo " PASS: all $BN_PASS bundle checks" +fi +echo "" + # [42c] REPL (#392): piped transcript byte-exact + pty-driven line editor echo "[42c] REPL editor & piped transcript (16 checks)" RE_OUTPUT=$(bash "$TESTS_DIR/test_repl.sh" 2>&1) diff --git a/tests/test_bundle.sh b/tests/test_bundle.sh new file mode 100644 index 0000000..ce6770f --- /dev/null +++ b/tests/test_bundle.sh @@ -0,0 +1,120 @@ +#!/bin/bash +# --bundle tests (#413): single-file distribution — runtime + script + +# eigs_modules + stdlib in one executable, with an optional attached +# trace tape (`out --replay` = a self-replaying reproducer). +# +# Run directly or from run_all_tests.sh. Prints PASS:/FAIL: lines and a +# summary. Exit code: 0 if all pass, 1 if any fail. + +set -u +TESTS_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$TESTS_DIR/.." && pwd)" +EIGS="$ROOT/src/eigenscript" + +PASS=0 +FAIL=0 +TMPDIR=$(mktemp -d -t eigs_bundle.XXXXXX) +trap 'rm -rf "$TMPDIR"' EXIT + +ok() { echo " PASS: $1"; PASS=$((PASS+1)); } +fail() { echo " FAIL: $1${2:+ ($2)}"; FAIL=$((FAIL+1)); } + +if [ ! -x "$EIGS" ]; then + echo " FAIL: eigenscript binary not found at $EIGS" + echo "BUNDLE: 0 passed, 1 failed" + exit 1 +fi + +# ---- fixture: script + an eigs_modules package + a stdlib import + one +# nondet call (so the tape variant proves replay determinism). +APP="$TMPDIR/app" +mkdir -p "$APP/eigs_modules/greeter" +cat > "$APP/eigs_modules/greeter/greeter.eigs" <<'EOF' +define greet(n) as: + return f"hello {n}" +EOF +cat > "$APP/app.eigs" <<'EOF' +import greeter +import json +r is random of null +print of greeter.greet of "bundle" +print of json.json_from_pairs of ([["k", 1]]) +print of r +EOF + +# ---- 1. bundle builds +"$EIGS" --bundle "$APP/app.eigs" "$APP/out" > /dev/null 2>&1 \ + && [ -x "$APP/out" ] \ + && ok "bundle builds an executable" \ + || fail "bundle builds an executable" + +# ---- 2. runs away from any checkout: cwd=/ so no eigs_modules, no lib/, +# no eigs.json is reachable except through the bundle's own extraction +OUT=$(cd / && "$APP/out" 2>&1); RC=$? +echo "$OUT" | grep -q "hello bundle" \ + && echo "$OUT" | grep -q '{"k": 1}' \ + && [ "$RC" -eq 0 ] \ + && ok "bundled script + eigs_modules + stdlib run from bare cwd" \ + || fail "bundled script + eigs_modules + stdlib run from bare cwd" "rc=$RC: $(echo "$OUT" | head -2)" + +# ---- 3. script args pass through to `args of null` +cat > "$APP/argy.eigs" <<'EOF' +print of (args of null) +EOF +"$EIGS" --bundle "$APP/argy.eigs" "$APP/outargs" > /dev/null 2>&1 +OUT=$(cd / && "$APP/outargs" alpha beta 2>&1) +echo "$OUT" | grep -q "alpha" && echo "$OUT" | grep -q "beta" \ + && ok "bundle passes script args through" \ + || fail "bundle passes script args through" "$OUT" + +# ---- 4. tape-attached bundle replays byte-identically, without the +# original environment (nondet served from the tape, not live). The +# oracle is the RECORDED run's own stdout: replay must reproduce it +# exactly (comparing against the tape's %.17g serialization would trip +# on print's shorter float formatting). +REC_OUT=$(EIGS_TRACE="$APP/app.tape" "$APP/out" 2>/dev/null) +"$EIGS" --bundle "$APP/app.eigs" "$APP/out2" --with-tape "$APP/app.tape" > /dev/null 2>&1 +A=$(cd / && "$APP/out2" --replay 2>&1); RA=$? +B=$(cd / && "$APP/out2" --replay 2>&1); RB=$? +[ "$RA" -eq 0 ] && [ "$RB" -eq 0 ] && [ "$A" = "$B" ] \ + && ok "bundle-with-tape replays byte-identically" \ + || fail "bundle-with-tape replays byte-identically" "rc=$RA/$RB" +[ "$A" = "$REC_OUT" ] \ + && ok "replay reproduces the recorded run's exact output" \ + || fail "replay reproduces the recorded run's exact output" "recorded: $REC_OUT / replayed: $A" + +# ---- 5. --replay without a tape refuses loudly (exit 3, replay policy) +(cd / && "$APP/out" --replay > /dev/null 2>&1); RC=$? +[ "$RC" -eq 3 ] \ + && ok "--replay without an attached tape refuses with exit 3" \ + || fail "--replay without an attached tape refuses with exit 3" "rc=$RC" + +# ---- 6. a torn archive refuses instead of running garbage: drop a byte +# EARLY in the archive (inside the first entry), so every later entry +# header misparses — the structural tear the reader must catch. (A byte +# lost in the LAST entry's data is undetectable without checksums; the +# format trades that for simplicity — the tape inside carries its own +# version header.) +OFF=$(tail -c 24 "$APP/out" | od -A n -t u8 -N 8 | tr -d ' ') +head -c $((OFF + 20)) "$APP/out" > "$APP/torn" +tail -c +$((OFF + 22)) "$APP/out" >> "$APP/torn" +chmod +x "$APP/torn" +(cd / && "$APP/torn" > /dev/null 2>&1); RC=$? +[ "$RC" -eq 3 ] \ + && ok "torn archive refuses with exit 3" \ + || fail "torn archive refuses with exit 3" "rc=$RC" + +# ---- 7. re-bundling FROM a bundle copies only the runtime image (the +# new bundle must not grow by the old archive's size) +"$EIGS" --bundle "$APP/app.eigs" "$APP/out3" > /dev/null 2>&1 +# portable sizes (GNU stat -c vs BSD stat -f): wc -c works on both. +# out3 must stay in the same ballpark as the runtime image + ~77 small +# files, far below 2x the image — i.e. the old archive did not accrete. +S3=$(wc -c < "$APP/out3" | tr -d ' ') +BASE=$(wc -c < "$EIGS" | tr -d ' ') +[ "$S3" -lt $((BASE * 2)) ] \ + && ok "re-bundle stays runtime-sized (no archive accretion)" \ + || fail "re-bundle stays runtime-sized (no archive accretion)" "base=$BASE out3=$S3" + +echo "BUNDLE: $PASS passed, $FAIL failed" +[ "$FAIL" -eq 0 ]