diff --git a/changelog.d/8005-ext-zlib-raw-sync.md b/changelog.d/8005-ext-zlib-raw-sync.md new file mode 100644 index 0000000000..58291f2017 --- /dev/null +++ b/changelog.d/8005-ext-zlib-raw-sync.md @@ -0,0 +1,44 @@ +### Fixed + +- **`perry-ext-zlib` now implements `deflateRawSync` / `inflateRawSync`**, so + `test_gap_zlib_4917_level` links on the auto-optimize path instead of failing + with two undefined symbols. + + When the well-known flip routes `node:zlib` to `perry-ext-zlib`, + `optimized_libs/driver.rs` strips the per-codec features from the stdlib + rebuild on a stated premise: *"The ext crate carries all codecs, so nothing is + lost by dropping them here."* That is true of the codecs and false of the RAW + one-shot entry points — `js_zlib_deflate_raw_sync` and + `js_zlib_inflate_raw_sync` existed only in `perry-stdlib`, so the flip removed + them from the link. + + **Note the ABI.** Codegen declares this pair as `(DOUBLE, DOUBLE)` and + `(DOUBLE)`, unlike the zlib-format one-shots beside them which take their data + as `I64`. The parameter types match the declaration rather than this crate's + local convention: the bits are the same NaN-boxed value either way, so a + mismatch would link cleanly and misread the argument. + +- **The flip's premise is now checked, not just asserted in a comment.** + `ext_zlib_covers_every_stdlib_symbol_the_flip_strips` scans both crates for + exported `js_zlib_*` symbols and requires the ext surface to be a superset, + minus an explicit shrink-only `KNOWN_EXT_GAPS` list. A symbol that leaves + stdlib, or gains an ext implementation, must be deleted from that list in the + same commit — an entry matching nothing fails. + + Writing the check found the gap is wider than the reported pair: **19 further + `js_zlib_*` symbols** exist only in stdlib. Most are stream constructors the + ext crate serves through its own dispatch, and pump plumbing supplied by the + `external-zlib-pump` feature the flip *adds*; each is listed with its reason. + Five are genuine one-shot gaps of exactly the #8005 shape — `js_zlib_crc32`, + `js_zlib_deflate_raw`, `js_zlib_inflate_raw`, `js_zlib_unzip`, + `js_zlib_unzip_sync` — which have not broken a link only because no gap test + links them on this path yet. + + Affected files: `crates/perry-ext-zlib/src/lib.rs`, + `crates/perry/src/commands/compile/optimized_libs/tests.rs`. + + Validation: sabotage-verified in both directions. Removing the #8005 pair from + the ext crate fails the check by name; making an unlisted stdlib symbol + disappear reports it as missing. The scan also asserts its own subject is live + (>20 stdlib and >10 ext symbols found), so a broken matcher cannot make the + superset check vacuously true. diff --git a/crates/perry-ext-zlib/src/lib.rs b/crates/perry-ext-zlib/src/lib.rs index a2cf20a061..8a322f6bc3 100644 --- a/crates/perry-ext-zlib/src/lib.rs +++ b/crates/perry-ext-zlib/src/lib.rs @@ -6,7 +6,9 @@ //! aren't valid UTF-8, so the wrapper can't go through the standard //! `read_string` / `alloc_string` path. -use flate2::read::{GzEncoder, MultiGzDecoder, ZlibDecoder, ZlibEncoder}; +use flate2::read::{ + DeflateDecoder, DeflateEncoder, GzEncoder, MultiGzDecoder, ZlibDecoder, ZlibEncoder, +}; use flate2::Compression; use perry_ffi::{alloc_buffer, BufferHeader, ErrorKind}; use std::io::{Error as IoError, ErrorKind as IoErrorKind, Read}; @@ -69,6 +71,24 @@ fn inflate_bytes(data: &[u8]) -> std::io::Result> { Ok(decompressed) } +// Raw deflate (RFC 1951 — no zlib header, no adler32 trailer), which is what +// `deflateRawSync`/`inflateRawSync` speak. Distinct from the zlib-format pair +// above; the comment there already drew the line, but the entry points were +// never added on this side (#8005). +fn deflate_raw_bytes_with(data: &[u8], level: Compression) -> std::io::Result> { + let mut encoder = DeflateEncoder::new(data, level); + let mut compressed = Vec::new(); + encoder.read_to_end(&mut compressed)?; + Ok(compressed) +} + +fn inflate_raw_bytes(data: &[u8]) -> std::io::Result> { + let mut decoder = DeflateDecoder::new(data); + let mut decompressed = Vec::new(); + decoder.read_to_end(&mut decompressed)?; + Ok(decompressed) +} + // ── sync variants ───────────────────────────────────────────── /// `zlib.gzipSync(data, options?)`. @@ -138,6 +158,46 @@ pub unsafe extern "C" fn js_zlib_inflate_sync(data_bits: i64) -> *mut BufferHead } } +/// `zlib.deflateRawSync(data, opts)` — raw deflate, no zlib wrapper. +/// +/// # Safety +/// +/// NOTE THE ABI: codegen declares this pair as `(DOUBLE, DOUBLE)` and +/// `(DOUBLE)` (`runtime_decls/stdlib_ffi/third_party.rs`), unlike the +/// zlib-format one-shots beside it which take the data as `I64`. The parameter +/// types must match the DECLARATION, not this crate's local convention — the +/// bits are the same NaN-boxed value either way, so a mismatch would link +/// cleanly and misread the argument. +/// +/// #4917: honor `options.level`. +#[no_mangle] +pub unsafe extern "C" fn js_zlib_deflate_raw_sync(data_value: f64, opts: f64) -> *mut BufferHeader { + stream::js_zlib_validate_options(opts, 8); + let data_bits = data_value.to_bits() as i64; + stream::js_zlib_validate_buffer_arg(data_bits); + let level = stream::compression_from_opts(opts); + match stream::read_input_from_bits(data_bits).map(|d| deflate_raw_bytes_with(&d, level)) { + Some(Ok(out)) => alloc_buffer(&out), + _ => std::ptr::null_mut(), + } +} + +/// `zlib.inflateRawSync(data)` — raw inflate, no zlib wrapper. +/// +/// # Safety +/// +/// See `js_zlib_deflate_raw_sync` on the `DOUBLE` ABI. +#[no_mangle] +pub unsafe extern "C" fn js_zlib_inflate_raw_sync(data_value: f64) -> *mut BufferHeader { + let data_bits = data_value.to_bits() as i64; + stream::js_zlib_validate_buffer_arg(data_bits); + match stream::read_input_from_bits(data_bits).map(|d| inflate_raw_bytes(&d)) { + Some(Ok(out)) => alloc_buffer(&out), + Some(Err(err)) => throw_deflate_decode_error(err), + None => std::ptr::null_mut(), + } +} + // `zlib.createBrotliDecompress` and the other `create*` Transform-stream // factories now live in `stream.rs` (returning real stream handles). diff --git a/crates/perry/src/commands/compile/optimized_libs/tests.rs b/crates/perry/src/commands/compile/optimized_libs/tests.rs index 7914df1a16..b24c810174 100644 --- a/crates/perry/src/commands/compile/optimized_libs/tests.rs +++ b/crates/perry/src/commands/compile/optimized_libs/tests.rs @@ -1052,3 +1052,133 @@ fn retain_workspace_declared_features_keeps_all_without_manifests() { assert!(dropped.is_empty()); assert_eq!(cross_features.len(), 2); } + +/// The well-known flip drops `compression-brotli`/`compression-zstd` from the +/// stdlib rebuild on a stated premise: "The ext crate carries all codecs, so +/// nothing is lost by dropping them here." +/// +/// #8005: that premise was a comment and nothing checked it. It is false for +/// the RAW one-shots — `js_zlib_deflate_raw_sync` and `js_zlib_inflate_raw_sync` +/// exist only in perry-stdlib — so the flip removed them from the link and +/// `test_gap_zlib_4917_level` failed with two undefined symbols, two stages +/// downstream of the decision that caused it. +/// +/// This scans both crates for exported `js_zlib_*` symbols and requires the ext +/// surface to be a superset, minus an explicit shrink-only list. A name that +/// leaves stdlib, or gains an ext implementation, must be deleted from +/// `KNOWN_EXT_GAPS` in the same commit — an entry matching nothing FAILS, so +/// the list cannot rot into an alibi. +#[test] +fn ext_zlib_covers_every_stdlib_symbol_the_flip_strips() { + /// Symbols perry-stdlib exports that perry-ext-zlib does not implement yet. + /// SHRINKS ONLY. Every entry is reachable today only because the flip does + /// not strip the feature that defines it; adding one is how #8005 happened. + const KNOWN_EXT_GAPS: &[&str] = &[ + // Stream constructors — perry-ext-zlib owns streams through its own + // dispatch (`js_ext_zlib_dispatch_method`) rather than these entry + // points, so these are a naming difference, not a hole. Listed so the + // superset check stays honest instead of being weakened to ignore them. + "js_zlib_create_brotli_compress", + "js_zlib_create_brotli_decompress", + "js_zlib_create_deflate", + "js_zlib_create_deflate_raw", + "js_zlib_create_gunzip", + "js_zlib_create_gzip", + "js_zlib_create_inflate", + "js_zlib_create_inflate_raw", + "js_zlib_create_unzip", + "js_zlib_create_zstd_compress", + "js_zlib_create_zstd_decompress", + // Pump/dispatch plumbing, supplied by the `external-zlib-pump` feature + // the flip ADDS rather than strips. + "js_zlib_has_active_handles", + "js_zlib_native_dispatch", + "js_zlib_process_pending", + // Genuine one-shot gaps. Same shape as the #8005 pair; they have not + // broken a link only because no gap test links them on this path yet. + "js_zlib_crc32", + "js_zlib_deflate_raw", + "js_zlib_inflate_raw", + "js_zlib_unzip", + "js_zlib_unzip_sync", + ]; + + fn exported_zlib_symbols(dir: &Path) -> std::collections::BTreeSet { + let mut found = std::collections::BTreeSet::new(); + let mut stack = vec![dir.to_path_buf()]; + while let Some(next) = stack.pop() { + let Ok(entries) = std::fs::read_dir(&next) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + continue; + } + if path.extension().is_none_or(|e| e != "rs") { + continue; + } + let Ok(text) = std::fs::read_to_string(&path) else { + continue; + }; + for line in text.lines() { + if let Some(rest) = line.split("fn js_zlib_").nth(1) { + let name: String = rest + .chars() + .take_while(|c| c.is_ascii_alphanumeric() || *c == '_') + .collect(); + if !name.is_empty() { + found.insert(format!("js_zlib_{name}")); + } + } + } + } + } + found + } + + let root = find_perry_workspace_root().expect("workspace root"); + let stdlib = exported_zlib_symbols(&root.join("crates/perry-stdlib/src")); + let ext = exported_zlib_symbols(&root.join("crates/perry-ext-zlib/src")); + + // Live-subject check: a scan that found nothing would make every assertion + // below vacuously true, which is precisely the failure mode this test is + // about. + assert!( + stdlib.len() > 20 && ext.len() > 10, + "symbol scan looks broken — stdlib {} / ext {}; the superset check \ + below would pass without proving anything", + stdlib.len(), + ext.len() + ); + assert!( + ext.contains("js_zlib_deflate_raw_sync") && ext.contains("js_zlib_inflate_raw_sync"), + "#8005's pair must stay implemented in perry-ext-zlib; the flip strips \ + the stdlib feature that would otherwise supply them" + ); + + let missing: Vec<&String> = stdlib + .iter() + .filter(|name| !ext.contains(*name) && !KNOWN_EXT_GAPS.contains(&name.as_str())) + .collect(); + assert!( + missing.is_empty(), + "perry-stdlib exports these `js_zlib_*` symbols and perry-ext-zlib does \ + not: {missing:?}. The well-known flip routes `node:zlib` to the ext \ + crate on the premise that it carries everything, so a symbol only \ + stdlib defines disappears from the link. Implement it in \ + perry-ext-zlib, or add it to KNOWN_EXT_GAPS with the reason." + ); + + let stale: Vec<&&str> = KNOWN_EXT_GAPS + .iter() + .filter(|name| !stdlib.contains(**name) || ext.contains(**name)) + .collect(); + assert!( + stale.is_empty(), + "these KNOWN_EXT_GAPS entries no longer describe reality — the symbol \ + left perry-stdlib or gained an ext implementation: {stale:?}. Delete \ + them; a list that outlives its entries stops being a ratchet." + ); +}