.daedalus Format
.daedalus Format
A .daedalus file is a valid ELF executable with a compressed payload,
JSON metadata, and a footer at the very end of the file. The launcher
reads itself via /proc/self/exe, seeks to the end, reads the footer, and
finds everything else from the offsets stored there.
offset 0 ┌─────────────────────────┐
│ ELF launcher (musl) │ ← kernel executes this
├─────────────────────────┤
payload_off │ payload = zstd(tar) │ ← compressed rootfs
├─────────────────────────┤
meta_off │ metadata (JSON utf8) │ ← entrypoint, env, runtime...
├─────────────────────────┤
EOF - 92 │ FOOTER (92 bytes) │ ← read backwards by launcher
└─────────────────────────┘
The format is versioned (format_version in the footer). v1 uses a single
monolithic payload; v2 splits the payload into layers; v3 adds Ed25519
signatures; v4 adds AES-256-GCM encryption; v5 adds SquashFS layer support.
The launcher reads all versions.
Footer layout (v3/v4/v5, 92 bytes)
All integers are little-endian. The v3 footer is a 92-byte block: an 8-byte
prefix (sig_offset) followed by the 84-byte v2-compatible core.
| Field | Offset | Type | Size | Description |
|---|---|---|---|---|
sig_offset |
0 | u64 | 8 | absolute offset of signature block (0 if unsigned) |
magic |
8 | bytes | 5 | "ERE\x01" |
format_version |
13 | u8 | 1 | format version (3, 4, or 5) |
arch |
14 | u8 | 1 | 0x01=x86_64, 0x02=aarch64 |
flags |
15 | u8 | 1 | bit0=signed, bit1=encrypted |
payload_offset |
16 | u64 | 8 | absolute offset of payload |
payload_csize |
24 | u64 | 8 | compressed size of all layers |
payload_usize |
32 | u64 | 8 | v2/v3: unused; v4/v5: crypto_suite (0=none, 1=AES-256-GCM) |
payload_sha256 |
40 | bytes | 32 | SHA-256(payload ‖ metadata) |
meta_offset |
72 | u64 | 8 | absolute offset of metadata |
meta_size |
80 | u64 | 8 | metadata size in bytes |
footer_magic |
88 | u32 | 4 | 0xBEEFCAFE end sentinel |
The spec is implemented in daedalus-core/src/format.rs — the single source of
truth, shared by the launcher (stub) and the CLI. There is no separate
stub/src/format.rs.
Design decision: footer at end, not header at start
Constraint: The Linux kernel requires ELF magic (\x7fELF) at offset 0
to execute the file. We cannot put our own bytes at the start without
breaking chmod +x && ./my_app.daedalus.
Options considered:
- Custom header before ELF — rejected. The kernel would refuse to execute
the file.
binfmt_misccould work but requires root on every target machine. - Embed metadata inside ELF sections — rejected. The kernel loads ELF sections into memory; our metadata would consume address space and confuse debuggers.
- Footer at end of file — chosen. Used by
makeself, AppImage, and self-extracting.exe. The launcher opens itself via/proc/self/exe, seeks from the end, reads the fixed-size footer, and finds everything from the stored offsets.
Design decision: v3 8-byte prefix for signatures
Constraint: Ed25519 signatures need to be stored between metadata and the footer. But the footer is at a fixed position (EOF-84 for v2), and v2 launchers read exactly 84 bytes from the end. We cannot change the footer size without breaking backward compatibility.
Options considered:
- Grow the footer to 92 bytes (add
sig_offsetfield) — rejected. A v2 launcher reading 84 bytes would see truncated data and likely crash or silently misparse. - Append the signature block after the footer — rejected. The launcher reads backwards from EOF; data after the footer is invisible to it.
- 8-byte prefix before the 84-byte core — chosen. The last 92 bytes are:
[8-byte sig_offset][84-byte v2 core]. A v2 launcher reads the last 84 bytes and sees valid v2 data (the prefix is invisible). A v3 launcher reads 92 bytes and picks upsig_offsetfrom the prefix. No breaking change.
v2 launcher reads: v3 launcher reads:
┌──────────────┐ ┌──────────────────────┐
│ 84-byte core │ │ 8B prefix │ 84B core │
└──────────────┘ └──────────────────────┘
EOF-84 → EOF EOF-92 → EOF
Integrity hash
The payload_sha256 field stores:
SHA-256(compressed_payload_bytes ‖ metadata_json_bytes)
The launcher recomputes this hash on every cold start and compares it to the
stored value before extracting anything. On mismatch: exit(1), nothing
written to disk.
For signed files (v3+), the digest the signature covers also includes the
footer's full on-disk form (the 8-byte sig_offset prefix + the 84-byte core):
Ed25519_sign(SHA-256(payload ‖ metadata ‖ footer_bytes), private_key)
The footer must be covered because it decides whether the signature is ever
consulted: a signature over payload ‖ metadata alone would let an attacker
downgrade the file to v2, clear FLAG_SIGNED, and recompute the SHA-256 —
the signature would be silently skipped. The launcher also rejects
inconsistent states (sig block without the flag, or the flag without a block)
and v2 files carrying a leftover signature block.
See Security for why SHA-256 alone is insufficient.
Signature block (v3, 68 bytes)
Inserted between metadata and footer when flags & FLAG_SIGNED:
| Field | Type | Size | Description |
|---|---|---|---|
sig_size |
u32le | 4 | always 64 (Ed25519 sig) |
signature |
bytes | 64 | Ed25519 signature |
The footer's sig_offset field points to the start of this block.
JSON metadata
{
"name": "hello-web",
"daedalus_version": "0.1.0",
"created": "2026-06-23T12:00:00Z",
"runtime": "python",
"isolation": 0,
"entrypoint": ["/usr/bin/python3.12", "/app/app.py"],
"env": { "PYTHONUNBUFFERED": "1" },
"layers": [...]
}
entrypoint: argv executed by the launcher. Paths are relative to the rootfs — the launcher resolves them to real cache paths at exec time.env: additional variables. The launcher injectsLD_LIBRARY_PATHseparately and resolves${ROOTFS}tokens to the real cache path.isolation: 0 =LD_LIBRARY_PATH, 1 = chroot (skipped), 2 = user namespaces +pivot_root.layers: array of compressed layer objects (v2+ only).
Layers (v2+)
The payload is a sequence of layers, each an independent compressed blob. Layers stack at extraction (later layers overwrite earlier ones):
[ stub ][ runtime layer ][ app layer ][ metadata ][ sig? ][ footer ]
^ ^
python+stdlib+.so app code + site-packages
stable volatile
v2–v4 uses zstd(tar) blobs. v5 uses SquashFS images (better
compression ratio). The layer format is indicated by the "payload_format"
field in metadata: "zstd-tar" (default) or "squashfs".
The layer table lives in the JSON metadata:
"layers": [
{"kind": "runtime", "offset": 614960, "csize": 6710886, "usize": 26953728,
"sha256": "168a7279b815..."},
{"kind": "app", "offset": 7325846, "csize": 12044, "usize": 204800,
"sha256": "9e61cd65ed9e..."}
]
offset is the absolute byte offset in the file. Each layer's SHA-256 (of
the compressed blob) is a stable cache key — if the content doesn't
change, its extraction is reusable.
Why layers: incremental rebuild
The runtime layer (interpreter + stdlib + .so) is independent of app code.
Editing app.py doesn't change it. On rebuild, the builder reuses it from
the build cache (~/.cache/daedalus/build/) — no recompression. Only the app
layer is rebuilt.
# First build: ~25s (compressing runtime layer, ~54 MB)
$ daedalus build ./my_app
[daedalus] wrote my_app.daedalus (7.1MB) in 25.1s
# Rebuild after code change: ~1s (runtime layer reused)
$ daedalus build ./my_app
[daedalus] wrote my_app.daedalus (7.1MB) in 1.2s
Two apps sharing the same runtime share the same runtime layer in the build cache — the second app also builds in ~1 s.
Version evolution
The footer is versioned (format_version). A launcher rejects a file with
a version higher than it understands:
$ ./old-daedalus new-format.daedalus
[daedalus] error: unsupported .daedalus format version (binary newer than launcher)
| Version | Changes |
|---|---|
| v1 | Monolithic zstd(tar) payload |
| v2 | Layered payload (runtime + app), incremental rebuild |
| v3 | Ed25519 signatures (92-byte footer with sig_offset) |
| v4 | AES-256-GCM payload encryption (crypto_suite in footer) |
| v5 | SquashFS layer support (payload_format in metadata) |
Reserved fields (flags, sig_offset) allow extension without breaking
compatibility. Ed25519 signatures are inserted between metadata and footer
with a flags bit and a dedicated offset — v2 files remain readable.
Universal Polyglot Binary Format
A universal .daedalus file bundles multiple architecture-specific
.daedalus slices behind a polyglot shell-script launcher. At runtime, the
launcher detects the host OS/architecture via uname, extracts the
matching slice to a temp file, and execs it. Each slice is a complete,
independent .daedalus binary.
Motivation
A single self-contained binary that runs unmodified on:
- Linux: x86_64, aarch64, riscv64
- macOS: x86_64 (Intel), arm64 (Apple Silicon)
is achieved by concatenating self-extracting .daedalus slices and prepending
a POSIX-compliant shell script that knows where each slice lives.
Layout
offset 0 ┌──────────────────────────┐
│ shell-script launcher │ 64 KiB (fixed), null-padded
│ (polyglot with ELF header│ )
├──────────────────────────┤
slice_1_off │ linux-x86_64 .daedalus slice │
├──────────────────────────┤
slice_2_off │ linux-aarch64 .daedalus slice│
├──────────────────────────┤
... │ ... │
├──────────────────────────┤
manifest_off │ JSON manifest (4 KiB) │ fixed-size, null-padded
├──────────────────────────┤
EOF - 26 │ universal footer (26 B) │
└──────────────────────────┘
Polyglot trick
The first 64 KiB is simultaneously:
- A valid shell script — the kernel's
#!handler treats#!/bin/shat offset 0 as a directive to invoke/bin/sh. The shell reads and executes the script, which extracts the matching slice andexecs it. - A valid ELF/PE/Mach-O file (when the slice is extracted alone) —
each slice is a standalone
.daedalusbinary that the kernel can load directly.
The shell script occupies exactly 64 * 1024 bytes. Bytes after the
script's exec line are unused padding (zeros), so the shell never
parses them. When a slice is extracted to a temp file, the file begins
with the slice's own binary header (ELF magic, PE, or Mach-O magic).
Universal footer (26 bytes)
Offset Type Field Description
0 u32le magic 0xBEEFCABE (little-endian)
4 u32le num_slices Number of ArchSlices
8 u64le manifest_offset Absolute offset of JSON manifest
16 u32le manifest_size Size of the JSON manifest (before padding)
20 u32le reserved Must be 0
JSON manifest
Immediately before the footer, padded to 4 KiB:
{
"slices": [
{
"target": "x86_64-unknown-linux-musl",
"uname_machine": "x86_64",
"uname_sys": "Linux",
"offset": 65536,
"size": 2956757,
"sha256": "a1b2c3..."
},
{
"target": "aarch64-apple-darwin",
"uname_machine": "arm64",
"uname_sys": "Darwin",
"offset": 3022293,
"size": 2476949,
"sha256": "d4e5f6..."
}
]
}
The manifest is human-readable and allows tools to inspect a universal binary without executing it. Tools reading the footer can locate the manifest, parse the slice table, and verify SHA-256 checksums.
Launcher script
The shell script (first 64 KiB) looks like:
#!/bin/sh
# daedalus universal binary — auto-generated launcher
_arch=$(uname -m)
_os=$(uname -s 2>/dev/null || echo Linux)
_self=$0
_off=_sz=
case "$_arch $_os" in
"x86_64 Linux") _off=65536; _sz=2956757 ;;
esac
case "$_arch $_os" in
"aarch64 Linux") _off=3022293; _sz=2476949 ;;
esac
case "$_arch $_os" in
"riscv64 Linux") _off=5499242; _sz=2362661 ;;
esac
case "$_arch $_os" in
"x86_64 Darwin") _off=7861903; _sz=1524141 ;;
esac
case "$_arch $_os" in
"arm64 Darwin") _off=9386044; _sz=1287101 ;;
esac
if [ -z "$_off" ]; then
echo 'daedalus: unsupported architecture: '"$_arch"' on '"_os" >&2
exit 1
fi
_tmpf=$(mktemp /tmp/daedalus.XXXXXX)
tail -c +$((_off + 1)) "$_self" 2>/dev/null | head -c $_sz > "$_tmpf" || \
dd if="$_self" of="$_tmpf" bs=1 skip=$_off count=$_sz 2>/dev/null
chmod +x "$_tmpf"
exec "$_tmpf" "$@"
Extraction strategy
The launcher uses tail -c +N piped to head -c M for fast extraction.
This is much faster than dd bs=1 (which reads byte-by-byte) and
correctly handles non-block-aligned offsets. The dd bs=1 fallback is
used if tail/head are unavailable or fail.
Supported targets
| uname -m | uname -s | target triple | notes |
|---|---|---|---|
| x86_64 | Linux | x86_64-unknown-linux-musl | static ELF |
| aarch64 | Linux | aarch64-unknown-linux-musl | static ELF |
| riscv64 | Linux | riscv64gc-unknown-linux-musl | static ELF |
| x86_64 | Darwin | x86_64-apple-darwin | Mach-O |
| arm64 | Darwin | aarch64-apple-darwin | Mach-O |
Linux slices use musl for static linking (no glibc dependency). macOS
slices use the system dynamic linker but the stub itself is
freestanding (minimal libc with -undefined dynamic_lookup).
Building
# Build a universal binary for all 5 supported targets
daedalus build ./app --universal --isolation 0 -o app.daedalus
# The CLI builds each slice individually via cargo zigbuild/musl/cargo
# cross-compiler, then assembles them with the polyglot launcher.
Implementation lives in daedalus-core/src/universal.rs (shared by
the CLI builder and any inspection tools).