Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1425,6 +1425,22 @@ jobs:
} >> "$GITHUB_OUTPUT"
fi

- name: Setup pinned Node.js for scoped native oracles
if: steps.scope.outputs.rust_work == 'true'
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version-file: .node-version

- name: Verify scoped Node oracle before native builds
if: steps.scope.outputs.rust_work == 'true'
run: |
node --input-type=module -e '
import assert from "node:assert/strict";
import fs from "node:fs";
const expected = fs.readFileSync(".node-version", "utf8").trim().replace(/^v/, "");
assert.equal(process.versions.node, expected, "Scoped native tests require the pinned Node oracle");
'

- name: Install Rust toolchain
if: steps.scope.outputs.rust_work == 'true'
run: rustup toolchain install nightly-2026-08-20 --profile minimal
Expand Down Expand Up @@ -1480,7 +1496,7 @@ jobs:
# RFC-2945 abort guards that a JS throw trips — the opposite of the
# shipped semantics. See the longer note in `cargo-test`.
if printf '%s\n' "$SUITES" | grep -qE '^(perry|perry-stdlib) '; then
if printf '%s\n' "$SUITES" | grep -qE '^perry (bun_text_modules|import_meta_require_value) '; then
if printf '%s\n' "$SUITES" | grep -qE '^perry (bun_text_modules|bun_embedded_compression|import_meta_require_value) '; then
# Prepare all require providers in one graph, outside the fixture's
# timeout. A second perry-dev graph inside cargo test exceeded its
# ten-minute bound on fresh runners (#9989/#9990).
Expand Down
12 changes: 12 additions & 0 deletions changelog.d/10053-bun-embedded-asset-compression.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
Compress worthwhile embedded payloads in Bun-platform executables with
checksummed zstd,
using the existing Bun CLI runtime feature. Keep small/incompressible payloads
raw and require at least 256 KiB of aggregate payload savings before enabling
compressed registration. Generated constructors validate and decode one exact
frame into immortal native storage before registration, preserving paths,
lengths, empty/binary bytes and explicit text-loader metadata. Corrupt data or
length/loader mismatches fail before publishing an asset. Existing raw APIs and
non-Bun embedding remain unchanged. Independent decoder, packaging, and native
filesystem/Bun/text-loader tests cover the compressed path and raw controls.
Install and verify the pinned Node oracle in scoped native CI before any
expensive build, instead of inheriting the runner's ambient Node version.
17 changes: 11 additions & 6 deletions crates/perry-runtime/src/embedded.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
//! `[compile] embed` in perry.toml) bakes the matched files' bytes into the
//! binary. The compiler emits a generated C object whose `constructor` calls
//! [`js_register_embedded_asset`] once per file before `main` runs, populating
//! a process-global registry. The bytes themselves live in the binary's
//! read-only data (static C literals), so the registry only stores
//! `&'static [u8]` slices into them — no copy, no per-asset heap allocation.
//! a process-global registry. Raw payloads live in the binary's read-only data
//! without copying. Compressed Bun payloads are decoded at startup into immortal
//! native storage. Both are exposed as the same byte-exact `&'static [u8]`.
//!
//! Three consumers read the registry at runtime:
//! * `fs.readFileSync` / `fs.readFile` — a `$perryfs/...` virtual path (or a
Expand All @@ -20,6 +20,11 @@
//! The global never frees (matching Perry's "embedded data lives for the life of
//! the process" model), mirroring the `crate::shared_sab` registry pattern.

#[cfg(feature = "bun-cli-utils")]
mod compressed;
#[cfg(feature = "bun-cli-utils")]
pub use compressed::js_register_embedded_zstd_asset;

use std::collections::BTreeMap;
use std::sync::{Mutex, OnceLock};

Expand All @@ -36,8 +41,8 @@ pub const VIRTUAL_PREFIX: &str = "$perryfs/";
/// code can keep passing the original string to `node:fs` and `Bun.file()`.
pub const BUNFS_ROOT_PREFIX: &str = "/$bunfs/root/";

/// One embedded file. `bytes` points into the binary's read-only data and is
/// valid for the life of the process.
/// One embedded file. `bytes` points into read-only data or decoded native
/// storage and is valid for the life of the process.
struct EmbeddedAsset {
/// Registry key — the embed-relative path, e.g. `dist/index.html`.
name: String,
Expand Down Expand Up @@ -148,7 +153,7 @@ unsafe fn register_asset(
}

/// Look up an embedded asset's bytes by virtual path (`$perryfs/...`) or by its
/// embed-relative key. Returns the `'static` slice into the binary. This is the
/// embed-relative key. Returns the immortal decoded/raw byte slice. This is the
/// authoritative presence test — a path is "embedded" iff this returns `Some`.
pub fn lookup(path: &str) -> Option<&'static [u8]> {
let key = normalize_key(path);
Expand Down
214 changes: 214 additions & 0 deletions crates/perry-runtime/src/embedded/compressed.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
//! Byte-exact compressed payload registration before JavaScript/GC startup.
//! Only the Bun utility pack links zstd; ordinary embedding remains unchanged.

use super::register_asset;

fn decode(packed: &[u8], original_len: usize) -> Option<Box<[u8]>> {
if original_len > isize::MAX as usize
|| zstd::zstd_safe::get_frame_content_size(packed).ok()?? != original_len as u64
|| zstd::zstd_safe::find_frame_compressed_size(packed).ok()? != packed.len()
{
return None;
}
// Validate the frame's own length before reserving. Never let an unchecked
// caller length drive an allocation, or accept trailing/concatenated data.
let mut bytes = Vec::new();
bytes.try_reserve_exact(original_len).ok()?;
let written = zstd::bulk::Decompressor::new()
.ok()?
.decompress_to_buffer(packed, &mut bytes)
.ok()?;
if written != original_len || bytes.len() != original_len {
return None;
}
Some(bytes.into_boxed_slice())
}

/// Register one compiler-emitted zstd frame, returning 1 on success and 0 on
/// invalid metadata, corrupt/truncated input, or a failed allocation/decode.
/// No entry is published on failure. The generated constructor treats 0 as
/// fatal rather than exposing compressed bytes as a file or silently omitting it.
///
/// Names are copied by the existing registry; decoded native storage is kept
/// for the process lifetime, exactly like the raw API's immortal byte range.
/// This does not allocate JavaScript values or access an initialized GC.
///
/// # Safety
/// Non-null pointers must name readable ranges of the supplied lengths for
/// this call. Compressed input need not outlive the call. `text_module` must be
/// 0 (ordinary file) or 1 (explicit text loader); other values are rejected.
#[no_mangle]
pub unsafe extern "C" fn js_register_embedded_zstd_asset(
name_ptr: *const u8,
name_len: usize,
packed_ptr: *const u8,
packed_len: usize,
original_len: usize,
text_module: u32,
) -> i32 {
if name_ptr.is_null()
|| packed_ptr.is_null()
|| name_len > isize::MAX as usize
|| packed_len > isize::MAX as usize
|| text_module > 1
{
return 0;
}
let packed = std::slice::from_raw_parts(packed_ptr, packed_len);
let Some(decoded) = decode(packed, original_len) else {
return 0;
};
let bytes = Box::leak(decoded);
register_asset(
name_ptr,
name_len,
bytes.as_ptr(),
bytes.len(),
text_module == 1,
);
1
}

#[cfg(feature = "keepalive-anchors")]
#[used]
static KEEP_REGISTER_ZSTD: unsafe extern "C" fn(
*const u8,
usize,
*const u8,
usize,
usize,
u32,
) -> i32 = js_register_embedded_zstd_asset;

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn exact_bytes_and_lengths_including_empty_and_non_utf8() {
for bytes in [
&b""[..],
&b"plain\0text\xff\xc3\xa9"[..],
&vec![b'x'; 32768][..],
] {
let packed = zstd::bulk::compress(bytes, 3).unwrap();
assert_eq!(decode(&packed, bytes.len()).unwrap().as_ref(), bytes);
assert!(decode(&packed, bytes.len() + 1).is_none());
if !bytes.is_empty() {
assert!(decode(&packed, bytes.len() - 1).is_none());
}
}
}

#[test]
fn malformed_truncated_trailing_and_concatenated_frames_are_rejected() {
let packed = zstd::bulk::compress(&vec![b'x'; 32768], 3).unwrap();
for end in 0..packed.len() {
assert!(decode(&packed[..end], 32768).is_none(), "prefix {end}");
}
let mut invalid = packed.clone();
invalid[0] ^= 0xff;
assert!(decode(&invalid, 32768).is_none());
let mut trailing = packed.clone();
trailing.push(0);
assert!(decode(&trailing, 32768).is_none());
let mut concatenated = packed.clone();
concatenated.extend(zstd::bulk::compress(b"", 3).unwrap());
assert!(decode(&concatenated, 32768).is_none());
assert!(decode(&packed, usize::MAX).is_none());
}

#[test]
fn payload_checksum_damage_is_rejected() {
let mut compressor = zstd::bulk::Compressor::new(3).unwrap();
compressor.include_checksum(true).unwrap();
let bytes = vec![b'x'; 32768];
let mut packed = compressor.compress(&bytes).unwrap();
assert_eq!(decode(&packed, bytes.len()).unwrap().as_ref(), bytes);
*packed.last_mut().unwrap() ^= 1;
assert!(decode(&packed, bytes.len()).is_none());
}

#[test]
fn registry_preserves_loader_path_size_and_ownership() {
let bytes = b"retained\0bytes\xff";
let mut packed = zstd::bulk::compress(bytes, 3).unwrap();
unsafe {
for (name, text) in [
("compressed-test/file.bin", 0),
("compressed-test/text.md", 1),
] {
assert_eq!(
js_register_embedded_zstd_asset(
name.as_ptr(),
name.len(),
packed.as_ptr(),
packed.len(),
bytes.len(),
text
),
1
);
}
}
packed.fill(0); // Registry must not retain any borrow of the input.
drop(packed);
assert_eq!(
super::super::lookup("$perryfs/compressed-test/file.bin"),
Some(bytes.as_slice())
);
assert!(super::super::lookup_text_module("compressed-test/file.bin").is_none());
assert_eq!(
super::super::lookup_text_module("compressed-test/text.md"),
Some(bytes.as_slice())
);
assert_eq!(
super::super::metadata("compressed-test/file.bin")
.unwrap()
.size,
bytes.len()
);
}

#[test]
fn invalid_registration_does_not_publish_a_partial_entry() {
let name = b"compressed-test/invalid";
let packed = zstd::bulk::compress(b"value", 3).unwrap();
unsafe {
assert_eq!(
js_register_embedded_zstd_asset(
name.as_ptr(),
name.len(),
packed.as_ptr(),
packed.len(),
5,
2
),
0
);
assert_eq!(
js_register_embedded_zstd_asset(
name.as_ptr(),
name.len(),
packed.as_ptr(),
packed.len() - 1,
5,
0
),
0
);
assert_eq!(
js_register_embedded_zstd_asset(
name.as_ptr(),
name.len(),
std::ptr::null(),
0,
5,
0
),
0
);
}
assert!(super::super::lookup("compressed-test/invalid").is_none());
}
}
33 changes: 28 additions & 5 deletions crates/perry/src/commands/compile/embed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::OnceLock;

mod compression;

/// Read loader metadata preserved by unbun. Extensions are not authoritative:
/// Bun can embed `notes.md` using its text loader or its file/Markdown loaders.
/// Validate before graph compilation, so a malformed sidecar fails promptly.
Expand Down Expand Up @@ -363,6 +365,7 @@ pub(super) fn generate_embedded_asset_object(
assets: &[(String, PathBuf)],
output_dir: &Path,
text_modules: &std::collections::HashSet<String>,
bun_platform: bool,
) -> Result<Option<PathBuf>> {
if assets.is_empty() {
return Ok(None);
Expand All @@ -376,6 +379,10 @@ pub(super) fn generate_embedded_asset_object(
} else {
"__perry_embedded_assets.o"
});
let payloads = compression::prepare(assets, output_dir, bun_platform)?;
let compressed = payloads
.iter()
.any(|payload| payload.original_len.is_some());

// Mach-O prefixes C symbols with `_` and names its read-only-const section
// `__TEXT,__const`; ELF uses the bare symbol and `.rodata`. Perry runs on
Expand All @@ -401,6 +408,10 @@ pub(super) fn generate_embedded_asset_object(
if !text_modules.is_empty() {
c.push_str("extern void js_register_embedded_text_asset(const char *name, size_t name_len, const char *bytes, size_t bytes_len);\n\n");
}
if compressed {
c.push_str("#include <stdint.h>\n#include <stdio.h>\n#include <stdlib.h>\n");
c.push_str("extern int32_t js_register_embedded_zstd_asset(const char *, size_t, const char *, size_t, size_t, uint32_t);\n");
}

for (idx, (name, path)) in assets.iter().enumerate() {
// Names are tiny — keep them as ASCII-clean C string literals.
Expand All @@ -421,11 +432,18 @@ pub(super) fn generate_embedded_asset_object(
// and end label so the C side recovers the length as a link-time
// constant (end − start). `.incbin` needs an unambiguous path, so feed
// it the canonical absolute path.
let abs = path
let abs = payloads[idx]
.path
.canonicalize()
.map_err(|e| anyhow!("failed to resolve embed asset {}: {}", path.display(), e))?;
let start = format!("{sym_prefix}PERRY_ASSET_DATA_{idx}");
let end = format!("{sym_prefix}PERRY_ASSET_END_{idx}");
// Keep compressed bytes out of the old raw-data symbol namespace.
let kind = if payloads[idx].original_len.is_some() {
"ZSTD_"
} else {
""
};
let start = format!("{sym_prefix}PERRY_ASSET_{kind}DATA_{idx}");
let end = format!("{sym_prefix}PERRY_ASSET_{kind}END_{idx}");
// Assembler-level escape for the path inside `.incbin "..."`; `asm_line`
// adds the C-string-literal escaping on top.
let asm_path = abs
Expand All @@ -440,8 +458,8 @@ pub(super) fn generate_embedded_asset_object(
c.push_str(&asm_line(&format!(".globl {end}")));
c.push_str(&asm_line(&format!("{end}:")));
c.push_str(");\n");
writeln!(c, "extern const char PERRY_ASSET_DATA_{idx}[];").ok();
writeln!(c, "extern const char PERRY_ASSET_END_{idx}[];").ok();
writeln!(c, "extern const char PERRY_ASSET_{kind}DATA_{idx}[];").ok();
writeln!(c, "extern const char PERRY_ASSET_{kind}END_{idx}[];").ok();
}

// Register before `main`'s `js_runtime_init`. Unix hosts use a priority
Expand All @@ -458,6 +476,11 @@ pub(super) fn generate_embedded_asset_object(
c.push_str("static void perry_register_embedded_assets(void) {\n");
}
for idx in 0..assets.len() {
if let Some(original_len) = payloads[idx].original_len {
let text = u32::from(text_modules.contains(&assets[idx].0));
writeln!(c, " if (!js_register_embedded_zstd_asset(PERRY_ASSET_NAME_{idx}, PERRY_ASSET_NAME_LEN_{idx}, PERRY_ASSET_ZSTD_DATA_{idx}, (size_t)(PERRY_ASSET_ZSTD_END_{idx} - PERRY_ASSET_ZSTD_DATA_{idx}), {original_len}, {text})) {{ fputs(\"Corrupt compressed embedded asset\\n\", stderr); _Exit(74); }}").ok();
continue;
}
let register = if text_modules.contains(&assets[idx].0) {
"js_register_embedded_text_asset"
} else {
Expand Down
Loading
Loading