diff --git a/CLAUDE.md b/CLAUDE.md index dd0386da48..ec3ed18294 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1597 +**Current Version:** 0.5.1598 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 7693bb21b9..1f0159a2c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5623,7 +5623,7 @@ checksum = "1473d470930ed48574515a25df34900f3af89c6fa422d903e019121312a9f13e" [[package]] name = "perry" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "anyhow", "base64 0.22.1", @@ -5687,7 +5687,7 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "perry-dispatch", "serde", @@ -5695,7 +5695,7 @@ dependencies = [ [[package]] name = "perry-audio-miniaudio" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "cc", "libc", @@ -5704,7 +5704,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "aho-corasick", "anyhow", @@ -5721,7 +5721,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "anyhow", "perry-hir", @@ -5729,7 +5729,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "anyhow", "perry-hir", @@ -5737,7 +5737,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "anyhow", "perry-dispatch", @@ -5746,7 +5746,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "anyhow", "perry-hir", @@ -5754,7 +5754,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "anyhow", "base64 0.22.1", @@ -5766,7 +5766,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "anyhow", "perry-hir", @@ -5774,7 +5774,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "async-trait", "clap", @@ -5798,14 +5798,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "serde", "serde_json", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1597" +version = "0.5.1598" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5824,7 +5824,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "anyhow", "clap", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "block2", "objc2", @@ -5849,7 +5849,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "argon2", "perry-ffi", @@ -5858,7 +5858,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "perry-ffi", "reqwest", @@ -5867,7 +5867,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "bcrypt", "perry-ffi", @@ -5875,7 +5875,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "perry-ffi", "rusqlite", @@ -5883,7 +5883,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "perry-ffi", "scraper", @@ -5891,7 +5891,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "perry-ffi", "perry-runtime", @@ -5899,7 +5899,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "chrono", "cron", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "chrono", "perry-ffi", @@ -5917,7 +5917,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "perry-ffi", "rust_decimal", @@ -5925,7 +5925,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "perry-ffi", "serde_json", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "perry-ffi", "rand 0.10.2", @@ -5941,7 +5941,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "perry-ffi", "perry-runtime", @@ -5949,14 +5949,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "bytes", "http-body-util", @@ -5973,7 +5973,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "bytes", "lazy_static", @@ -5986,7 +5986,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "base64 0.22.1", "bytes", @@ -6018,7 +6018,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "lazy_static", "perry-ffi", @@ -6026,20 +6026,9 @@ dependencies = [ "tokio", ] -[[package]] -name = "perry-ext-jsonwebtoken" -version = "0.5.1597" -dependencies = [ - "base64 0.22.1", - "jsonwebtoken", - "perry-ffi", - "serde", - "serde_json", -] - [[package]] name = "perry-ext-lru-cache" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "lru", "perry-ffi", @@ -6048,7 +6037,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "chrono", "perry-ffi", @@ -6056,7 +6045,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "bson", "futures-util", @@ -6068,7 +6057,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "chrono", "perry-ffi", @@ -6080,7 +6069,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "nanoid", "perry-ffi", @@ -6089,7 +6078,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "bytes", "perry-ffi", @@ -6104,7 +6093,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "const-oid 0.10.2", "der 0.8.2", @@ -6123,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "lettre", "perry-ffi", @@ -6133,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-parcel-watcher" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "notify", "perry-ffi", @@ -6145,7 +6134,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "perry-ffi", "printpdf", @@ -6153,7 +6142,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "perry-ffi", "sqlx", @@ -6162,7 +6151,7 @@ dependencies = [ [[package]] name = "perry-ext-qs" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "perry-ffi", "perry-runtime", @@ -6171,7 +6160,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "governor", "perry-ffi", @@ -6179,7 +6168,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "fast_image_resize", "image", @@ -6190,7 +6179,7 @@ dependencies = [ [[package]] name = "perry-ext-streams" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "lazy_static", "perry-ffi", @@ -6199,7 +6188,7 @@ dependencies = [ [[package]] name = "perry-ext-typescript" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "anyhow", "perry-ffi", @@ -6219,7 +6208,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "perry-ffi", "perry-runtime", @@ -6228,7 +6217,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "perry-ffi", "uuid", @@ -6236,7 +6225,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "perry-ffi", "perry-validation", @@ -6245,7 +6234,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "futures-util", "lazy_static", @@ -6258,7 +6247,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "brotli", "flate2", @@ -6268,7 +6257,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "dashmap 6.2.1", "once_cell", @@ -6278,7 +6267,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "anyhow", "perry-api-manifest", @@ -6298,11 +6287,11 @@ dependencies = [ [[package]] name = "perry-native-registration" -version = "0.5.1597" +version = "0.5.1598" [[package]] name = "perry-parser" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "anyhow", "perry-diagnostics", @@ -6315,7 +6304,7 @@ dependencies = [ [[package]] name = "perry-perex" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "perex", "regex", @@ -6323,7 +6312,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "ahash", "base64 0.22.1", @@ -6381,14 +6370,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6420,7 +6409,6 @@ dependencies = [ "hyper", "hyper-util", "image", - "jsonwebtoken", "lazy_static", "lettre", "libc", @@ -6477,21 +6465,21 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "perry-hir", ] [[package]] name = "perry-ui" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "dirs", "perry-ffi", @@ -6501,7 +6489,7 @@ dependencies = [ [[package]] name = "perry-ui-android" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "base64 0.22.1", "jni", @@ -6516,7 +6504,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "rand 0.10.2", "serde", @@ -6526,7 +6514,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "base64 0.22.1", "cairo-rs 0.22.9", @@ -6549,7 +6537,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "base64 0.22.1", "block2", @@ -6566,7 +6554,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "base64 0.22.1", "block2", @@ -6583,7 +6571,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1597" +version = "0.5.1598" [[package]] name = "perry-ui-test" @@ -6594,11 +6582,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1597" +version = "0.5.1598" [[package]] name = "perry-ui-tvos" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "base64 0.22.1", "block2", @@ -6615,7 +6603,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "base64 0.22.1", "block2", @@ -6632,7 +6620,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "block2", "libc", @@ -6646,7 +6634,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "base64 0.22.1", "libc", @@ -6665,7 +6653,7 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "base64 0.22.1", "libc", @@ -6678,7 +6666,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "anyhow", "base64 0.22.1", @@ -6693,7 +6681,7 @@ dependencies = [ [[package]] name = "perry-validation" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "idna", "regex", @@ -6703,7 +6691,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index baed25af0c..aaee929951 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,6 @@ members = [ "crates/perry-ext-uuid", "crates/perry-ext-bcrypt", "crates/perry-ext-argon2", - "crates/perry-ext-jsonwebtoken", "crates/perry-ext-validator", "crates/perry-validation", "crates/perry-perex", @@ -338,7 +337,7 @@ codegen-units = 1 codegen-units = 1 [workspace.package] -version = "0.5.1597" +version = "0.5.1598" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" @@ -482,7 +481,6 @@ perry-ext-nanoid = { path = "crates/perry-ext-nanoid" } perry-ext-uuid = { path = "crates/perry-ext-uuid" } perry-ext-bcrypt = { path = "crates/perry-ext-bcrypt" } perry-ext-argon2 = { path = "crates/perry-ext-argon2" } -perry-ext-jsonwebtoken = { path = "crates/perry-ext-jsonwebtoken" } perry-ext-validator = { path = "crates/perry-ext-validator" } perry-validation = { path = "crates/perry-validation" } perry-perex = { path = "crates/perry-perex" } diff --git a/changelog.d/10667-no-auto-http-client-pump.md b/changelog.d/10667-no-auto-http-client-pump.md new file mode 100644 index 0000000000..efc7fcffa2 --- /dev/null +++ b/changelog.d/10667-no-auto-http-client-pump.md @@ -0,0 +1,9 @@ +Fixed `node:http`/`node:https` client dynamic dispatch (`res.pipe()`, `req.setHeader()`, `req.setTimeout()`, and +the rest of the client `IncomingMessage`/`ClientRequest` fallback surface) being silently absent under +`PERRY_NO_AUTO_OPTIMIZE=1`: the prebuilt stdlib archive is built with the default `full` feature set, which +deliberately excludes `external-http-client-pump` (folding it into `full` would force every no-auto program to +carry `libperry_ext_http.a`). When the program imports `http`/`https`, the no-auto path now rebuilds +`perry-stdlib-static` with that feature on top of `full`, in the same cargo invocation as `perry-ext-http` itself +(two archives built in separate invocations can carry different tokio compilations even off an identical +`Cargo.lock`, which the existing link-time guard in `runtime_compat.rs` refuses to link). Mirrors the on-demand +`wasm-host` rebuild `build_optional_runtime` already does for `WebAssembly.*` support (#10466). diff --git a/changelog.d/10669-zero-slot-skip.md b/changelog.d/10669-zero-slot-skip.md new file mode 100644 index 0000000000..c9757c2763 --- /dev/null +++ b/changelog.d/10669-zero-slot-skip.md @@ -0,0 +1 @@ +Skip child-slot enumeration for objects that cannot have child slots. The copying minor, the full mark and the remembered-set rebuild each paid ~206 instructions per zero-slot object — iterator construction, the descriptor body, a worklist push and a drain entry — only to discover there was nothing to visit. The full mark and the remembered-set rebuild now walk half as many objects; gc3 spends 7.1% fewer instructions and 4.4% less peak RSS. diff --git a/changelog.d/10672-string-concat-memo-ascii-header.md b/changelog.d/10672-string-concat-memo-ascii-header.md new file mode 100644 index 0000000000..c99f6ac1ef --- /dev/null +++ b/changelog.d/10672-string-concat-memo-ascii-header.md @@ -0,0 +1,104 @@ +**String concat: stop re-scanning bytes for ASCII-ness twice, and stop +building a scratch buffer just to hash it** — 640-distinct short-string +concat down 26.5% (548 → 403 instructions/concat), a memo-ineligible 73-byte +concat down 26.8% (644 → 471), a 100%-memo-hit workload unchanged (319 → +318, within noise), measured with a differential instruction-count probe +(median-of-7, base vs arm built in the same session; bare-loop control flat +at ~3 in both). + +`concat_byte_parts` (the `s + t` fast path for two statically-typed string +operands, `perry-runtime/src/string/concat.rs`) had three defects, all in the +same neighborhood: + +1. It scanned both operands for ASCII-ness via `bytes_all_ascii` up front, + then scanned them *again* on the heap path via `l_slice.is_ascii() && + r_slice.is_ascii()` — with the first scan's answer (`both_ascii`) sitting + in scope, unused. Fixed by computing the bit once, in the caller (the new + `str_bytes_ascii_from_jsvalue`, a sibling of `str_bytes_from_jsvalue`), + and threading it through. +2. `bytes_all_ascii` scanned byte-at-a-time (`.iter().all(|&b| b < 0x80)`). + `<[u8]>::is_ascii()` inspects the same bytes word-at-a-time and is total + over arbitrary byte strings, valid or not (see the soundness note below — + that "total over arbitrary bytes" property is why it's the only sound + choice here, not just the faster one). Switching to it — `bytes_all_ascii`'s + body, and `str_bytes_ascii_from_jsvalue`'s scan — is most of this change's + win on `long73`: that workload's improvement is mostly the scan itself + getting faster over ~140 bytes/concat, not any trick that avoids it. +3. The short-concat memo's probe assembled both operands into a 12-byte stack + buffer just to hand the hash/lookup helpers one contiguous slice. FNV-1a is + a streaming hash (`fnv(a ++ b)` needs no buffer, just fold `a` then `b`), + and the byte compare on a hit/miss can run in the same two parts against + the cached entry. `concat_memo_hash_parts` / `concat_memo_slot_and_tag_parts` + / `concat_memo_lookup_parts` replace the buffer with direct two-slice + hashing and lookup; the single-slice `js_string_concat_value` ("prefix" + + i) memo probe is now implemented in terms of the same two-slice + primitives. + +**An earlier version of this change also tried to skip the scan entirely**, +by reading a heap string's ASCII-ness straight off its header (`utf16_len == +byte_len`, already computed at construction, so free). That is unsound, and +was caught in review before landing: Perry heap-string payloads are not +guaranteed valid UTF-8 (WTF-8 lone surrogates, `Buffer.toString` of +arbitrary bytes, FFI blobs — #6085), and a payload ending in a truncated +multi-byte lead byte can coincide on `utf16_len == byte_len` without being +ASCII — `compute_utf16_len_wtf8` charges a truncated lead its full nominal +unit count while the payload holds fewer bytes than that sequence declares +(`[0xC3]`, a lone 2-byte lead, records `utf16_len == 1 == byte_len`; +`string/compare.rs`'s `utf16_cmp_bytes` doc names the identical hazard and +pins the identical corpus for its own ASCII fast path — this change's +`str_bytes_ascii_from_jsvalue` doc now cross-references it). The header +check survives only as a NEGATIVE filter: `utf16_len != byte_len` soundly +proves non-ASCII with no scan needed, because `compute_utf16_len_wtf8` +counts exactly one unit per byte for any run of bytes `< 0x80` — the +contrapositive holds unconditionally, not just for well-formed input — but +`utf16_len == byte_len` is ambiguous and always falls back to a real +`is_ascii()` scan. `js_string_concat_value`'s memo-admission gate had the +identical exposure independently: `prefix_u16 == prefix_blen` was treated as +sufficient on its own and the `bytes_all_ascii` check right after it deleted +as redundant with it; restored, with the same negative-filter-then-scan +reasoning documented at the call site. + +No TypeScript-reachable path that constructs such a payload was found: +`Buffer.toString` (all seven encodings, `buffer/encode.rs`), +`TextDecoder.decode` (`text.rs`), and every `bun:ffi` string-returning path +(`read_cstring_value`, `dlopen.rs`'s `CString`/`cstring` conversions) all +validate via `str::from_utf8`/`from_utf8_lossy` (or are fed a Rust `&str`, +valid by construction) before ever calling `js_string_from_bytes` — `#609` +closed these same construction sites for a related UB hazard, and the fix +happens to guarantee well-formed output too. The fix stands regardless: +relying on an invariant this tree already documents as unsound is the wrong +foundation, and `js_string_from_bytes` is a `pub extern "C"` entry point +whose own contract must hold for any bytes, whether or not today's call +graph happens to always validate first. Regression tests are therefore +Rust-level, against hand-built malformed `StringHeader`s — the same +technique `string/compare.rs`'s own corpus and `tests_guard_page.rs` already +use — rather than a gap test: +`ascii_probe_falls_back_to_a_scan_when_the_header_lies`, +`concat_memo_declines_a_prefix_whose_header_lies_about_being_ascii`, +`concat_box_reports_not_well_formed_for_a_malformed_operand_either_side` +(`perry-runtime/src/string/tests.rs`) — all three fail against the reverted +(unsound) code, confirmed by temporarily reintroducing it and reverting +back. + +The memo probe's own break-even hit rate (governed by defect 3's mechanics — +hash/lookup/admit cost — not by the ASCII determination defects 1/2 changed) +barely moved between the unsound and sound paths: ~54% with the unsound +header shortcut, ~50.6% with the sound negative-filter-then-scan, both +measured by forcing the governor on/off via a temporary env knob (since +removed). `MEMO_MIN_HIT_SHIFT` stays `1` (50%, still the closest +power-of-two floor to either number) — it moved from the original `2` (25%, +chosen as a plausible fraction, never measured against the probe's own cost) +in this same change, which is what made the floor worth re-deriving at all. + +Covered by `test-files/test_gap_string_concat_memo_ascii_header.ts` +(byte-for-byte against node): ASCII boundary lengths crossing the SSO (5) and +memo (12) ceilings, 2/3/4-byte non-ASCII operands, a surrogate pair formed +*across* the join boundary and one that deliberately isn't (reverse order), +empty operands, and repeated-identical concats (both split two different +ways) to exercise the memo's "seen twice" admission and its `===` identity. +GC stress (`PERRY_GC_SCHEDULE_SEED=1` and `=42`, `RATE=1`, +`PROTECT_FROMSPACE=1`, `VERIFY_EVACUATION=1`, `FROMSPACE_SCAN_ABORT=1`) on a +concat-heavy fixture ran 117,923 copying minors and quarantined 14,739 +retired from-space sets with no fault and output still matching node, +confirming the memo's GC roots (`scan_concat_memo_roots_mut`) survive +evacuation under the new two-slice storage. diff --git a/changelog.d/10674-cjs-conditional-require-deferred.md b/changelog.d/10674-cjs-conditional-require-deferred.md new file mode 100644 index 0000000000..7b1c487441 --- /dev/null +++ b/changelog.d/10674-cjs-conditional-require-deferred.md @@ -0,0 +1,36 @@ +### Fixed + +- **CommonJS `require()` outside a function is no longer hoisted past the + control flow that guards it.** Perry's CJS→ESM wrap turned every + literal `require('S')` in a wrapped file into a static `import` at the + top of the module and eager-initialized the target — even when the call + sat inside `if (false)`, a false env check, `&&`/`??`, a ternary arm, a + `switch` case, or a loop that never iterates. A module reached only + through such a branch loaded (and could throw) at program start, + regardless of whether the branch ever ran; a module reached through a + taken branch loaded before the statements preceding it. This was the + sole remaining blocker compiling `pg` from source: `lib/index.js` guards + its optional native binding behind `if (forceNative) { require('./native') }`, + and `./native` requires the often-uninstalled `pg-native` — every + program using `pg` crashed at init with `Cannot find module 'pg-native'` + even though `forceNative` was false. + `cjs_wrap::extract_requires::function_local_specs` now classifies a + `require()` call site as deferred (Node's actual "loads only when + control flow reaches it" semantics) whenever it sits inside a + control-flow block (`if`/`for`/`while`/`switch`/`catch`/`try`/`else`/ + `do`/`finally`) or a braceless/operator equivalent (`cond && + require(...)`, `cond ? require(...) : x`, `for (...) require(...)` with + no block) — not only inside a function body as before. An ordinary + object literal or class body still does not count, so the common + `module.exports = { fs: require('fs'), path: require('path') }` barrel + shape stays eager. A `process.platform === ''` guard (the + node-pty Windows/Unix terminal split) is exempted from the broader + reclassification and keeps its existing eager treatment — the platform + is a compile-time-known build target, not a runtime unknown, and + `wrap_commonjs_for_target`'s dead-branch pruning already resolves it. + +Verified end-to-end: `pg` now compiles, links, and runs from real source +under `perry.compilePackages`, reaching a real TCP connect attempt with no +`pg-native` crash. + +Fixes #10437. diff --git a/changelog.d/10675-dyn-eval-class-expr.md b/changelog.d/10675-dyn-eval-class-expr.md new file mode 100644 index 0000000000..d759df2289 --- /dev/null +++ b/changelog.d/10675-dyn-eval-class-expr.md @@ -0,0 +1,11 @@ +Fixed `new Function`-string interpreter (#6559) to support **class expressions** in a +deliberately narrow subset: a constructor plus regular instance/`static` methods +(identifier/string/numeric keys), no `extends`/decorators/getters-setters/fields/private +members/computed keys/static blocks. This was the sole runtime blocker for `mysql2`, whose +`generate-function`-built row parsers (`text_parser.js`/`binary_parser.js`) return a class +expression from a `Function.apply(...).apply(...)` call; `generate-function` is used well beyond +mysql2, so this likely unblocks other packages too. Desugars onto machinery the interpreter +already had (closure expando writes + the generic `new ` path that already reads a +`"prototype"` dynamic prop), so no new runtime mechanism was added. mysql2 now runs an +end-to-end `CREATE`/`INSERT`/`SELECT`/`DROP` round trip against a real server; the hand-written +native mysql2 binding now looks deletable as a follow-up. See #10661. diff --git a/changelog.d/10687-jsonwebtoken-native-binding-removal.md b/changelog.d/10687-jsonwebtoken-native-binding-removal.md new file mode 100644 index 0000000000..d0c6817091 --- /dev/null +++ b/changelog.d/10687-jsonwebtoken-native-binding-removal.md @@ -0,0 +1,17 @@ +Removed the native `jsonwebtoken` binding (#10683): `verify()` returned +`null` instead of throwing on every forgery case (tampered payload, wrong +secret, `alg:none`, garbage token, tampered signature, expired token), and +`sign(..., { expiresIn: "1h" })` silently dropped the expiry. `import jwt +from "jsonwebtoken"` (no `perry.compilePackages` entry) now compiles the +real npm package from source, matching Node exactly including all six +thrown error names/messages. + +Deleted both duplicate hand-written implementations (`crates/perry-ext-jsonwebtoken` +and `crates/perry-stdlib/src/jsonwebtoken.rs`, which independently exported +the same `js_jwt_*` symbols — #10678) plus the dedicated codegen lowering +path in `crates/perry-codegen/src/lower_call/native/jsonwebtoken.rs` that +bypassed the well-known-binding registry entirely. Re-wired `dep:rsa`/ +`dep:spki` directly onto perry-stdlib's `crypto` feature, since WebCrypto's +`key_object.rs`/`keys.rs` need them unconditionally and were only reachable +through the now-deleted `bundled-jsonwebtoken` feature by historical +accident. diff --git a/changelog.d/10689-inherited-read-escape.md b/changelog.d/10689-inherited-read-escape.md new file mode 100644 index 0000000000..fd7580b402 --- /dev/null +++ b/changelog.d/10689-inherited-read-escape.md @@ -0,0 +1,33 @@ +### Fixed + +- **`typeof o.toString` on a non-escaping object literal answered `undefined` (#10689).** + Reading an *inherited* member as a VALUE — `o.constructor`, `o.toString`, + `o.hasOwnProperty`, a user-added `Object.prototype` property, or a class's own + prototype METHOD read as a value — answered `undefined` whenever the receiver + was a scalar-replacement candidate, while *calling* the same member + (`o.toString()`, `"" + o`, `` `${o}` ``) was correct. No error: the program + took the other branch and continued. + + The mechanism is escape analysis, not the lazy `globalThis` realm the report + guessed at. `collectors/escape_check.rs`'s `PropertyGet` arm classified every + read on a candidate local as a "plain field read — safe", without checking + that the class chain actually declares the key. The local stayed + scalar-replaced (no heap object exists at all) and `expr/property_get.rs`'s + scalar arm folded the slot-less read to the constant `undefined`. The three + WRITE arms of the same analysis already carried exactly this rule — #9024 for + `PropertySet`/`PutValueSet`, #9460 for `PropertyUpdate` — and the sibling + object-literal analysis in `collectors/escape_objects.rs` has always had it; + only the read arm was missing it. + + That also explains the reported order-dependence. `JSON.stringify(o)` earlier + in the function "repaired" the read because passing `o` to a call makes it + escape — not because it forced the realm. `JSON.stringify` of an *unrelated* + object forces the realm just the same and did **not** repair it; that case is + pinned as a test. + + `Expr::Call`'s arm no longer routes a fused method-call callee + (`o.m()`) back through the `PropertyGet` arm, so the receivers that + `simple_scalar_method_summary` deliberately keeps scalar-replaced still are. + Measured instruction-neutral on the r0–r9 ladder (max |Δ| 0.02%, noise). + + Regression test: `crates/perry/tests/object_prototype_value_read_10689.rs`. diff --git a/crates/perry-api-manifest/src/entries.rs b/crates/perry-api-manifest/src/entries.rs index 0e1415b629..de7f6a8bec 100644 --- a/crates/perry-api-manifest/src/entries.rs +++ b/crates/perry-api-manifest/src/entries.rs @@ -47,7 +47,6 @@ pub const NATIVE_MODULES: &[&str] = &[ "crypto", // (Node builtin) hashing, HMAC, cipher, sign/verify, WebCrypto "dotenv", // .env file loader "dotenv/config", // dotenv's auto-load-on-import subpath - "jsonwebtoken", // JWT sign/verify "nanoid", // compact URL-safe ID generation "validator", // string validators/sanitizers "ethers", // Ethereum library (utils/wallet/ABI) diff --git a/crates/perry-api-manifest/src/entries/part_1.rs b/crates/perry-api-manifest/src/entries/part_1.rs index aa51556739..5af3539565 100644 --- a/crates/perry-api-manifest/src/entries/part_1.rs +++ b/crates/perry-api-manifest/src/entries/part_1.rs @@ -1166,71 +1166,6 @@ pub(crate) const API_MANIFEST_PART_1: &[ApiEntry] = &[ }], TypeSpec::Number, ), - method_sig( - "jsonwebtoken", - "sign", - false, - None, - &[ - ParamSpec::Named { - name: "payload", - ty: TypeSpec::Any, - optional: false, - }, - ParamSpec::Named { - name: "secret", - ty: TypeSpec::String, - optional: false, - }, - ParamSpec::Named { - name: "options", - ty: TypeSpec::Any, - optional: true, - }, - // #915: FFI's 4th arg is `kid_ptr: *const StringHeader` — the - // dispatch table padding zeroes it when the user doesn't pass - // it. Surfacing the slot in the manifest keeps the - // #512 arity-drift assertion happy without forcing every - // caller to write a 4th positional arg. - ParamSpec::Named { - name: "kid", - ty: TypeSpec::String, - optional: true, - }, - ], - TypeSpec::String, - ), - method_sig( - "jsonwebtoken", - "verify", - false, - None, - &[ - ParamSpec::Named { - name: "token", - ty: TypeSpec::String, - optional: false, - }, - ParamSpec::Named { - name: "secret", - ty: TypeSpec::String, - optional: false, - }, - ], - TypeSpec::Any, - ), - method_sig( - "jsonwebtoken", - "decode", - false, - None, - &[ParamSpec::Named { - name: "token", - ty: TypeSpec::String, - optional: false, - }], - TypeSpec::Any, - ), method_sig( "nodemailer", "createTransport", diff --git a/crates/perry-codegen/src/collectors/escape_check.rs b/crates/perry-codegen/src/collectors/escape_check.rs index ef20f86861..2470f65f19 100644 --- a/crates/perry-codegen/src/collectors/escape_check.rs +++ b/crates/perry-codegen/src/collectors/escape_check.rs @@ -222,7 +222,36 @@ pub fn check_escapes_in_expr( escaped.insert(*id); return; } - // Plain field read — safe, don't recurse into object. + // #10689: a read of a key the class chain does not + // DECLARE as a field is an INHERITED read — an + // `Object.prototype` member (`toString`, `constructor`, + // `hasOwnProperty`), a prototype method read as a value, + // or a user-added `Object.prototype` property. Scalar + // replacement allocates a slot per declared field only, so + // `expr/property_get.rs`'s scalar arm finds none and folds + // the read to the constant `undefined` — silently, and + // only while the receiver happens not to escape, which is + // why `JSON.stringify(o)` earlier in the function + // "repaired" it. Escape the receiver so the read takes the + // ordinary heap path, which resolves the prototype chain. + // + // This is the READ half of the rule the three WRITE arms + // below already apply (#9024 `PropertySet`/`PutValueSet`, + // #9460 `PropertyUpdate`), and the per-property form of + // #6343's whole-class unmodeled-base escape. + // + // A fused method CALL (`o.m()`) is NOT this: its callee is + // handled in the `Expr::Call` arm, which does not route the + // callee through here, so `simple_scalar_method_summary` + // receivers stay scalar-replaced. + if !crate::collectors::class_accessors::class_chain_has_field( + classes, class_name, property, + ) { + escaped.insert(*id); + return; + } + // Plain declared-field read — safe, don't recurse into + // object. return; } } @@ -465,31 +494,37 @@ pub fn check_escapes_in_expr( // and fixed numeric params. That summary lets codegen inline the // body against scalar field slots instead of dispatching with a // heap receiver. - if let Expr::PropertyGet { object, .. } = callee.as_ref() { + // #10689: set when the callee IS the fused method-call form on a + // candidate receiver. That callee is a CALL target, not a value + // read of `property`, so it must not be sent through the + // `PropertyGet` arm — whose inherited-read rule would escape every + // receiver whose method the summary below deliberately keeps + // scalar-replaced. The receiver is `LocalGet(id)` itself, so + // skipping the recursion hides no nested candidate. + let mut callee_is_candidate_method_call = false; + if let Expr::PropertyGet { + object, property, .. + } = callee.as_ref() + { if let Expr::LocalGet(id) = object.as_ref() { - if candidates.contains_key(id) { - let is_summarized = if let Expr::PropertyGet { property, .. } = - callee.as_ref() - { - candidates.get(id).is_some_and(|class_name| { - crate::collectors::simple_scalar_method_summary( - classes, - class_name, - property, - args.len(), - ) - .is_some() - }) - } else { - false - }; + if let Some(class_name) = candidates.get(id) { + let is_summarized = crate::collectors::simple_scalar_method_summary( + classes, + class_name, + property, + args.len(), + ) + .is_some(); if !is_summarized { escaped.insert(*id); } + callee_is_candidate_method_call = true; } } } - check_escapes_in_expr(callee, candidates, classes, escaped); + if !callee_is_candidate_method_call { + check_escapes_in_expr(callee, candidates, classes, escaped); + } for a in args { check_escapes_in_expr(a, candidates, classes, escaped); } diff --git a/crates/perry-codegen/src/lower_call/native/jsonwebtoken.rs b/crates/perry-codegen/src/lower_call/native/jsonwebtoken.rs deleted file mode 100644 index dbdec4be7c..0000000000 --- a/crates/perry-codegen/src/lower_call/native/jsonwebtoken.rs +++ /dev/null @@ -1,290 +0,0 @@ -//! `lower_jsonwebtoken_sign` / `lower_jsonwebtoken_verify` and the -//! payload-pointer helper. Split out of `lower_call/native.rs` -//! (~272 LOC) so the parent module stays under the 2000-line cap. -//! -//! Both entry points are option-aware (algorithm: HS256 / ES256 / -//! RS256) and route to typed runtime helpers when possible, falling -//! back to the `_dyn` / `_dyn_opts` paths when the algorithm / -//! options object isn't an inline literal (#1074). - -use anyhow::{bail, Result}; -use perry_hir::Expr; - -use crate::expr::{lower_expr, FnCtx}; -use crate::nanbox::double_literal; -use crate::type_analysis::is_string_expr; -use crate::types::{DOUBLE, I32, I64}; - -use super::*; - -fn lower_jsonwebtoken_payload_ptr(ctx: &mut FnCtx<'_>, payload: &Expr) -> Result { - if is_string_expr(ctx, payload) { - return get_raw_string_ptr(ctx, payload); - } - - let boxed_payload = lower_expr(ctx, payload)?; - Ok(ctx.block().call( - I64, - "js_json_stringify", - &[(DOUBLE, &boxed_payload), (I32, "0")], - )) -} - -pub(super) fn lower_jsonwebtoken_sign(ctx: &mut FnCtx<'_>, args: &[Expr]) -> Result { - if args.len() < 2 { - bail!( - "jsonwebtoken.sign(payload, secret, options?) expects at least 2 args, got {}", - args.len() - ); - } - - let payload_ptr = lower_jsonwebtoken_payload_ptr(ctx, &args[0])?; - let secret_ptr = get_raw_string_ptr(ctx, &args[1])?; - let mut runtime = "js_jwt_sign"; - // #1074: when the user writes `{ algorithm: ALG }` (i.e. `algorithm` - // is a non-literal expression), the inline-literal fast path can't - // pick a typed runtime helper. We track that as a fallback to - // `js_jwt_sign_dyn`, which takes the alg string as a runtime argument - // and dispatches there. Pre-#1074 this fell through to the HS256 - // path silently — a real cryptographic downgrade. - let mut alg_ptr_dyn: Option = None; - let mut expires_in = double_literal(0.0); - let mut kid_ptr = "0".to_string(); - - if let Some(options) = args.get(2) { - if let Some(props) = extract_options_fields(ctx, options) { - for (key, val) in &props { - match key.as_str() { - "algorithm" => { - if let Expr::String(algorithm) = val { - runtime = match algorithm.as_str() { - "ES256" => "js_jwt_sign_es256", - "RS256" => "js_jwt_sign_rs256", - _ => "js_jwt_sign", - }; - } else { - // Non-literal alg (#1074): lower to a string - // pointer and let `js_jwt_sign_dyn` pick the - // right backend at runtime. - alg_ptr_dyn = Some(get_raw_string_ptr(ctx, val)?); - runtime = "js_jwt_sign_dyn"; - } - } - "expiresIn" => { - expires_in = lower_expr(ctx, val)?; - } - "keyid" | "kid" => { - kid_ptr = get_raw_string_ptr(ctx, val)?; - } - _ => { - let _ = lower_expr(ctx, val)?; - } - } - } - } else { - // #1074 case C: the options expression is not an inline - // object literal (e.g. `const opts = { algorithm: "ES256" }; - // jwt.sign(p, k, opts)`). Lower options as a NaN-boxed - // JSValue and route to `js_jwt_sign_dyn_opts`, which - // extracts algorithm/expiresIn/keyid at runtime. - let opts_val = lower_expr(ctx, options)?; - for extra in args.iter().skip(3) { - let _ = lower_expr(ctx, extra)?; - } - ctx.pending_declares.push(( - "js_jwt_sign_dyn_opts".to_string(), - I64, - vec![I64, I64, DOUBLE], - )); - let raw = ctx.block().call( - I64, - "js_jwt_sign_dyn_opts", - &[(I64, &payload_ptr), (I64, &secret_ptr), (DOUBLE, &opts_val)], - ); - return Ok(ctx.block().bitcast_i64_to_double(&raw)); - } - } - - for extra in args.iter().skip(3) { - let _ = lower_expr(ctx, extra)?; - } - - // Build the call. The five-arg dyn path takes the alg string first; - // the four-arg typed-helper path doesn't (the algorithm is implied - // by the symbol name). - let raw = if let Some(alg_ptr) = alg_ptr_dyn { - ctx.pending_declares.push(( - "js_jwt_sign_dyn".to_string(), - I64, - vec![I64, I64, I64, DOUBLE, I64], - )); - ctx.block().call( - I64, - "js_jwt_sign_dyn", - &[ - (I64, &alg_ptr), - (I64, &payload_ptr), - (I64, &secret_ptr), - (DOUBLE, &expires_in), - (I64, &kid_ptr), - ], - ) - } else { - ctx.pending_declares - .push((runtime.to_string(), I64, vec![I64, I64, DOUBLE, I64])); - ctx.block().call( - I64, - runtime, - &[ - (I64, &payload_ptr), - (I64, &secret_ptr), - (DOUBLE, &expires_in), - (I64, &kid_ptr), - ], - ) - }; - Ok(ctx.block().bitcast_i64_to_double(&raw)) -} - -/// Dispatch `jsonwebtoken.verify(token, secret_or_pem, options?)` to -/// the right runtime (HS256 / ES256 / RS256) based on the -/// `algorithms: ['…']` (or singular `algorithm: '…'`) option. -/// Mirrors `lower_jsonwebtoken_sign`. -/// -/// perry#927 follow-up: the generic NativeModSig table picked -/// `js_jwt_verify` (HS256-only) for every algorithm, so ES256 / RS256 -/// tokens silently failed verification (returning `null` to user -/// code, breaking the shop-admin auth middleware after a successful -/// signup). Verify needs the same option-aware routing that `sign` -/// already has. -/// -/// Return shape matches the old `NR_OBJ_FROM_JSON_STR`: the runtime -/// hands back a JSON-text `*mut StringHeader` (or null), which we -/// pipe through `js_json_parse_or_null` so user code sees a real -/// object on success and `null` on failure (no throw). -pub(super) fn lower_jsonwebtoken_verify(ctx: &mut FnCtx<'_>, args: &[Expr]) -> Result { - if args.len() < 2 { - bail!( - "jsonwebtoken.verify(token, secret, options?) expects at least 2 args, got {}", - args.len() - ); - } - - let token_ptr = get_raw_string_ptr(ctx, &args[0])?; - let secret_ptr = get_raw_string_ptr(ctx, &args[1])?; - let mut runtime = "js_jwt_verify"; - // #1074: when `algorithm` (or the first entry of `algorithms`) is a - // non-literal expression, lower it as a string and route through - // `js_jwt_verify_dyn` instead of silently picking HS256. - let mut alg_ptr_dyn: Option = None; - - if let Some(options) = args.get(2) { - if let Some(props) = extract_options_fields(ctx, options) { - for (key, val) in &props { - match key.as_str() { - // `algorithm: 'ES256'` (singular) — accepted for - // symmetry with `sign`'s option name. - "algorithm" => { - if let Expr::String(algorithm) = val { - runtime = match algorithm.as_str() { - "ES256" => "js_jwt_verify_es256", - "RS256" => "js_jwt_verify_rs256", - _ => "js_jwt_verify", - }; - } else { - alg_ptr_dyn = Some(get_raw_string_ptr(ctx, val)?); - runtime = "js_jwt_verify_dyn"; - } - } - // `algorithms: ['ES256']` (plural array) — the - // canonical Node `jsonwebtoken.verify` shape. - // First entry decides routing; the underlying Rust - // jsonwebtoken crate's verify is single-algorithm, - // so multi-algorithm fallback isn't honored. - "algorithms" => { - if let Expr::Array(elems) = val { - match elems.first() { - Some(Expr::String(algorithm)) => { - runtime = match algorithm.as_str() { - "ES256" => "js_jwt_verify_es256", - "RS256" => "js_jwt_verify_rs256", - _ => "js_jwt_verify", - }; - } - // #1074: first element is a non-literal - // (e.g. `algorithms: [ALG]` where ALG is - // a const-bound name). Lower it as a - // string and route through the dyn path. - Some(other) => { - alg_ptr_dyn = Some(get_raw_string_ptr(ctx, other)?); - runtime = "js_jwt_verify_dyn"; - } - None => {} - } - } else { - // `algorithms` is a non-array expression - // (e.g. a const-bound array reference). We - // could try harder, but the runtime opts - // path below already handles this when the - // whole options object is non-extractable. - // Lower the side effect and let the - // following HS256 fallback fire — same as - // pre-#1074 (rare in practice). - let _ = lower_expr(ctx, val)?; - } - } - _ => { - let _ = lower_expr(ctx, val)?; - } - } - } - } else { - // #1074 case C: options is not an inline object literal — - // defer extraction to `js_jwt_verify_dyn_opts`, which reads - // `algorithm` / `algorithms[0]` at runtime. - let opts_val = lower_expr(ctx, options)?; - for extra in args.iter().skip(3) { - let _ = lower_expr(ctx, extra)?; - } - ctx.pending_declares.push(( - "js_jwt_verify_dyn_opts".to_string(), - I64, - vec![I64, I64, DOUBLE], - )); - ctx.pending_declares - .push(("js_json_parse_or_null".to_string(), I64, vec![I64])); - let blk = ctx.block(); - let raw = blk.call( - I64, - "js_jwt_verify_dyn_opts", - &[(I64, &token_ptr), (I64, &secret_ptr), (DOUBLE, &opts_val)], - ); - let parsed_bits = blk.call(I64, "js_json_parse_or_null", &[(I64, &raw)]); - return Ok(blk.bitcast_i64_to_double(&parsed_bits)); - } - } - - for extra in args.iter().skip(3) { - let _ = lower_expr(ctx, extra)?; - } - - let raw = if let Some(alg_ptr) = alg_ptr_dyn { - ctx.pending_declares - .push(("js_jwt_verify_dyn".to_string(), I64, vec![I64, I64, I64])); - ctx.block().call( - I64, - "js_jwt_verify_dyn", - &[(I64, &alg_ptr), (I64, &token_ptr), (I64, &secret_ptr)], - ) - } else { - ctx.pending_declares - .push((runtime.to_string(), I64, vec![I64, I64])); - ctx.block() - .call(I64, runtime, &[(I64, &token_ptr), (I64, &secret_ptr)]) - }; - ctx.pending_declares - .push(("js_json_parse_or_null".to_string(), I64, vec![I64])); - let blk = ctx.block(); - let parsed_bits = blk.call(I64, "js_json_parse_or_null", &[(I64, &raw)]); - Ok(blk.bitcast_i64_to_double(&parsed_bits)) -} diff --git a/crates/perry-codegen/src/lower_call/native/mod.rs b/crates/perry-codegen/src/lower_call/native/mod.rs index 8c255a1bd9..6f5e681b11 100644 --- a/crates/perry-codegen/src/lower_call/native/mod.rs +++ b/crates/perry-codegen/src/lower_call/native/mod.rs @@ -17,8 +17,6 @@ //! Split into siblings: //! - `box_style.rs` — `apply_box_style` + `emit_dim_setter` (perry/tui //! `Box(...)` inline-style destructure helpers). -//! - `jsonwebtoken.rs` — `lower_jsonwebtoken_sign` / `_verify` (#1074 -//! algorithm-aware routing). //! The giant `lower_native_method_call` dispatcher itself stays here. use anyhow::{bail, Result}; @@ -49,11 +47,9 @@ pub(super) use super::{ }; mod box_style; -mod jsonwebtoken; mod perf_hooks; use box_style::apply_box_style; -use jsonwebtoken::{lower_jsonwebtoken_sign, lower_jsonwebtoken_verify}; fn util_types_arg_is_async_function_static(ctx: &FnCtx<'_>, expr: &Expr) -> Option { match expr { diff --git a/crates/perry-codegen/src/lower_call/native/native_runtime_branch.rs b/crates/perry-codegen/src/lower_call/native/native_runtime_branch.rs index c1e5a9a059..5fa5d09881 100644 --- a/crates/perry-codegen/src/lower_call/native/native_runtime_branch.rs +++ b/crates/perry-codegen/src/lower_call/native/native_runtime_branch.rs @@ -312,13 +312,6 @@ } } - if module == "jsonwebtoken" && method == "sign" && object.is_none() { - return lower_jsonwebtoken_sign(ctx, args); - } - if module == "jsonwebtoken" && method == "verify" && object.is_none() { - return lower_jsonwebtoken_verify(ctx, args); - } - // node:perf_hooks → native/perf_hooks.rs (performance.* + PerformanceObserver). if let Some(v) = perf_hooks::lower_perf_hooks_method(ctx, module, method, object, args)? { return Ok(v); diff --git a/crates/perry-codegen/src/lower_call/native_table/utils_crypto.rs b/crates/perry-codegen/src/lower_call/native_table/utils_crypto.rs index 880a733ee1..f94c677f70 100644 --- a/crates/perry-codegen/src/lower_call/native_table/utils_crypto.rs +++ b/crates/perry-codegen/src/lower_call/native_table/utils_crypto.rs @@ -75,24 +75,6 @@ pub(super) const UTILS_CRYPTO_ROWS: &[NativeModSig] = &[ args: &[NA_STR], ret: NR_F64, }, - // ========== jsonwebtoken ========== - // `sign` and `verify` are intentionally handled in - // lower_call/native.rs — both need option-dependent runtime - // selection (HS256 / ES256 / RS256) that the generic table can't - // express. `decode` stays here because it has no algorithm options. - NativeModSig { - module: "jsonwebtoken", - has_receiver: false, - method: "decode", - class_filter: None, - runtime: "js_jwt_decode", - // js_jwt_decode(token_ptr) -> *mut StringHeader (JSON of payload). - // NR_OBJ_FROM_JSON_STR pipes the returned JSON through - // js_json_parse_or_null so user code sees an object (mirrors - // `verify`'s post-#927 contract). Issue #927. - args: &[NA_STR], - ret: NR_OBJ_FROM_JSON_STR, - }, // ========== nodemailer ========== NativeModSig { module: "nodemailer", diff --git a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/third_party.rs b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/third_party.rs index 471f8c5726..ff029c6f90 100644 --- a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/third_party.rs +++ b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/third_party.rs @@ -63,27 +63,6 @@ pub(crate) fn declare_third_party(module: &mut LlModule) { module.declare_function("js_perry_native_f32", DOUBLE, &[DOUBLE]); module.declare_function("js_perry_native_f64", DOUBLE, &[DOUBLE]); - // ========== jsonwebtoken / JWT ========== - module.declare_function("js_jwt_decode", I64, &[I64]); - module.declare_function("js_jwt_sign", I64, &[I64, I64, DOUBLE, I64]); - module.declare_function("js_jwt_sign_es256", I64, &[I64, I64, DOUBLE, I64]); - module.declare_function("js_jwt_sign_rs256", I64, &[I64, I64, DOUBLE, I64]); - module.declare_function("js_jwt_verify", I64, &[I64, I64]); - module.declare_function("js_jwt_verify_es256", I64, &[I64, I64]); - module.declare_function("js_jwt_verify_rs256", I64, &[I64, I64]); - // #1074: runtime-algorithm dispatchers. The codegen `lower_jsonwebtoken_*` - // fast paths still hard-route literal `algorithm: "ES256"` to the typed - // helpers above; non-literal shapes (const-bound ident, spread, ternary) - // are routed here with the alg name lowered as a string at runtime. - module.declare_function("js_jwt_sign_dyn", I64, &[I64, I64, I64, DOUBLE, I64]); - module.declare_function("js_jwt_verify_dyn", I64, &[I64, I64, I64]); - // #1074 case C: options is a whole non-extractable expression - // (`const opts = { algorithm: "ES256" }; jwt.sign(p, k, opts)`). We - // pass `opts` as a NaN-boxed JSValue and the runtime helper extracts - // `algorithm` / `expiresIn` / `keyid` via `js_object_get_field_by_name`. - module.declare_function("js_jwt_sign_dyn_opts", I64, &[I64, I64, DOUBLE]); - module.declare_function("js_jwt_verify_dyn_opts", I64, &[I64, I64, DOUBLE]); - // ========== axios / node-fetch ========== module.declare_function("js_axios_create", DOUBLE, &[I64]); module.declare_function("js_axios_delete", I64, &[I64]); diff --git a/crates/perry-ext-jsonwebtoken/Cargo.toml b/crates/perry-ext-jsonwebtoken/Cargo.toml deleted file mode 100644 index 3450cc9270..0000000000 --- a/crates/perry-ext-jsonwebtoken/Cargo.toml +++ /dev/null @@ -1,22 +0,0 @@ -[package] -name = "perry-ext-jsonwebtoken" -version.workspace = true -edition.workspace = true -license.workspace = true -description = "Native bindings for the npm `jsonwebtoken` package — uses only `perry-ffi`. Sync, string-only port (Phase 5 step 7)." - -[lints] -workspace = true - -[lib] -crate-type = ["staticlib", "rlib"] - -[dependencies] -perry-ffi.workspace = true -jsonwebtoken.workspace = true -serde = { workspace = true } -serde_json = { workspace = true } -base64.workspace = true - -[dev-dependencies] -perry-ffi = { workspace = true, features = ["runtime-link"] } diff --git a/crates/perry-ext-jsonwebtoken/src/lib.rs b/crates/perry-ext-jsonwebtoken/src/lib.rs deleted file mode 100644 index 42faadda03..0000000000 --- a/crates/perry-ext-jsonwebtoken/src/lib.rs +++ /dev/null @@ -1,399 +0,0 @@ -//! Native bindings for the npm `jsonwebtoken` package. -//! -//! Sync wrapper — no async/await, no Promise. Uses only the -//! perry-ffi v0.5 string surface. Functionally identical to -//! `crates/perry-stdlib/src/jsonwebtoken.rs`. Seventh wrapper port -//! under #466 Phase 5. - -use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation}; -use perry_ffi::{alloc_string, nanbox_string_bits, read_string, JsString, StringHeader}; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; - -/// Generic claims structure that can hold any JSON. Mirrors the -/// shape `perry-stdlib::jsonwebtoken` uses so encoded / decoded -/// tokens are byte-compatible. -#[derive(Debug, Serialize, Deserialize)] -struct Claims { - #[serde(flatten)] - data: HashMap, - #[serde(skip_serializing_if = "Option::is_none")] - exp: Option, - #[serde(skip_serializing_if = "Option::is_none")] - iat: Option, - #[serde(skip_serializing_if = "Option::is_none")] - nbf: Option, - #[serde(skip_serializing_if = "Option::is_none")] - sub: Option, - #[serde(skip_serializing_if = "Option::is_none")] - iss: Option, - #[serde(skip_serializing_if = "Option::is_none")] - aud: Option, -} - -unsafe fn read_str(ptr: *const StringHeader) -> Option { - let handle = JsString::from_raw(ptr as *mut StringHeader); - read_string(handle).map(String::from) -} - -/// Shared signing logic — parse payload, apply expiry, encode with -/// the given algorithm/key. `kid_ptr` is optional (null = no `kid` -/// header field). Returns a NaN-boxed string i64, or 0 on error. -unsafe fn sign_common( - payload_ptr: *const StringHeader, - expires_in_secs: f64, - algorithm: Algorithm, - key: &EncodingKey, - kid_ptr: *const StringHeader, -) -> i64 { - let Some(payload_json) = read_str(payload_ptr) else { - return 0; - }; - - let mut claims: Claims = serde_json::from_str(&payload_json).unwrap_or_else(|_| Claims { - data: HashMap::new(), - exp: None, - iat: None, - nbf: None, - sub: None, - iss: None, - aud: None, - }); - - if expires_in_secs > 0.0 { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - claims.exp = Some(now + expires_in_secs as u64); - if claims.iat.is_none() { - claims.iat = Some(now); - } - } - - let mut header = Header::new(algorithm); - if !kid_ptr.is_null() { - if let Some(kid) = read_str(kid_ptr) { - if !kid.is_empty() { - header.kid = Some(kid); - } - } - } - - match encode(&header, &claims, key) { - Ok(token) => { - let s = alloc_string(&token); - nanbox_string_bits(s.as_raw()) as i64 - } - Err(_) => 0, - } -} - -/// `jwt.sign(payload, secret)` — HS256. -/// -/// # Safety -/// -/// All pointers must be null or Perry-runtime `StringHeader`s. -#[no_mangle] -pub unsafe extern "C" fn js_jwt_sign( - payload_ptr: *const StringHeader, - secret_ptr: *const StringHeader, - expires_in_secs: f64, - kid_ptr: *const StringHeader, -) -> i64 { - let Some(secret) = read_str(secret_ptr) else { - return 0; - }; - sign_common( - payload_ptr, - expires_in_secs, - Algorithm::HS256, - &EncodingKey::from_secret(secret.as_bytes()), - kid_ptr, - ) -} - -/// `jwt.sign(payload, ecPrivateKeyPem, { algorithm: 'ES256' })` — -/// PKCS#8 PEM-encoded EC P-256 private key. Used by APNs. -/// -/// # Safety -/// -/// All pointers must be null or Perry-runtime `StringHeader`s. -#[no_mangle] -pub unsafe extern "C" fn js_jwt_sign_es256( - payload_ptr: *const StringHeader, - pem_ptr: *const StringHeader, - expires_in_secs: f64, - kid_ptr: *const StringHeader, -) -> i64 { - let Some(pem) = read_str(pem_ptr) else { - return 0; - }; - let Ok(key) = EncodingKey::from_ec_pem(pem.as_bytes()) else { - return 0; - }; - sign_common( - payload_ptr, - expires_in_secs, - Algorithm::ES256, - &key, - kid_ptr, - ) -} - -/// `jwt.sign(payload, rsaPrivateKeyPem, { algorithm: 'RS256' })` — -/// PKCS#8 PEM-encoded RSA private key. Used by FCM. -/// -/// # Safety -/// -/// All pointers must be null or Perry-runtime `StringHeader`s. -#[no_mangle] -pub unsafe extern "C" fn js_jwt_sign_rs256( - payload_ptr: *const StringHeader, - pem_ptr: *const StringHeader, - expires_in_secs: f64, - kid_ptr: *const StringHeader, -) -> i64 { - let Some(pem) = read_str(pem_ptr) else { - return 0; - }; - let Ok(key) = EncodingKey::from_rsa_pem(pem.as_bytes()) else { - return 0; - }; - sign_common( - payload_ptr, - expires_in_secs, - Algorithm::RS256, - &key, - kid_ptr, - ) -} - -/// `jwt.verify(token, secret)` — HS256. Returns the claims as a -/// JSON string. -/// -/// # Safety -/// -/// `token_ptr` and `secret_ptr` must be null or Perry-runtime -/// `StringHeader`s. -#[no_mangle] -pub unsafe extern "C" fn js_jwt_verify( - token_ptr: *const StringHeader, - secret_ptr: *const StringHeader, -) -> *mut StringHeader { - let Some(token) = read_str(token_ptr) else { - return std::ptr::null_mut(); - }; - let Some(secret) = read_str(secret_ptr) else { - return std::ptr::null_mut(); - }; - - let key = DecodingKey::from_secret(secret.as_bytes()); - let mut validation = Validation::new(Algorithm::HS256); - // Match Node's `jsonwebtoken`: validate the `exp` claim whenever it is - // present (so expired tokens are rejected), but do not *require* exp — a - // token that legitimately omits expiry still verifies. `required_spec_claims` - // stays empty for the latter; `validate_exp = true` enforces the former. - // - // This previously read `validate_exp = false`, which accepted expired - // tokens indefinitely (GHSA-5324-c68v-8w62 / CVE-2026-53777) — the same - // bug already fixed in crates/perry-stdlib/src/jsonwebtoken.rs. - validation.required_spec_claims = std::collections::HashSet::new(); - validation.validate_exp = true; - - match decode::(&token, &key, &validation) { - Ok(token_data) => { - let json = serde_json::to_string(&token_data.claims).unwrap_or_else(|_| "{}".into()); - alloc_string(&json).as_raw() - } - Err(_) => std::ptr::null_mut(), - } -} - -/// `jwt.decode(token)` — split-and-base64-decode the payload, no -/// signature verification. -/// -/// # Safety -/// -/// `token_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_jwt_decode(token_ptr: *const StringHeader) -> *mut StringHeader { - let Some(token) = read_str(token_ptr) else { - return std::ptr::null_mut(); - }; - - let parts: Vec<&str> = token.split('.').collect(); - if parts.len() != 3 { - return std::ptr::null_mut(); - } - - use base64::Engine; - let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD; - let Ok(payload_bytes) = engine.decode(parts[1]) else { - return std::ptr::null_mut(); - }; - let Ok(payload_json) = String::from_utf8(payload_bytes) else { - return std::ptr::null_mut(); - }; - if serde_json::from_str::(&payload_json).is_err() { - return std::ptr::null_mut(); - } - alloc_string(&payload_json).as_raw() -} - -#[cfg(test)] -mod tests { - use super::*; - - fn s(handle: i64) -> String { - const POINTER_MASK: u64 = 0x0000_FFFF_FFFF_FFFF; - let raw = (handle as u64 & POINTER_MASK) as *mut StringHeader; - read_string(unsafe { JsString::from_raw(raw) }) - .map(String::from) - .unwrap_or_default() - } - - fn ps(p: *mut StringHeader) -> Option { - if p.is_null() { - return None; - } - read_string(unsafe { JsString::from_raw(p) }).map(String::from) - } - - #[test] - fn sign_then_verify_round_trip() { - let payload = alloc_string(r#"{"sub":"1234","name":"Alice"}"#); - let secret = alloc_string("supersecret"); - let token_bits = unsafe { - js_jwt_sign( - payload.as_raw() as *const _, - secret.as_raw() as *const _, - 3600.0, - std::ptr::null(), - ) - }; - assert_ne!(token_bits, 0, "sign returned zero"); - let token = s(token_bits); - assert!( - token.starts_with("eyJ"), - "JWT should start with eyJ: {}", - token - ); - - let token_handle = alloc_string(&token); - let claims_ptr = unsafe { - js_jwt_verify( - token_handle.as_raw() as *const _, - alloc_string("supersecret").as_raw() as *const _, - ) - }; - let claims = ps(claims_ptr).expect("verify returned non-null"); - assert!(claims.contains("\"name\":\"Alice\""), "got: {}", claims); - assert!(claims.contains("\"sub\":\"1234\""), "got: {}", claims); - } - - #[test] - fn verify_with_wrong_secret_returns_null() { - let payload = alloc_string(r#"{"sub":"x"}"#); - let token_bits = unsafe { - js_jwt_sign( - payload.as_raw() as *const _, - alloc_string("right").as_raw() as *const _, - 0.0, - std::ptr::null(), - ) - }; - let token = s(token_bits); - let token_handle = alloc_string(&token); - let result = unsafe { - js_jwt_verify( - token_handle.as_raw() as *const _, - alloc_string("wrong").as_raw() as *const _, - ) - }; - assert!(result.is_null(), "wrong secret should fail verify"); - } - - #[test] - fn decode_skips_signature_check() { - // Decode unverified — even with a wrong secret, decode - // returns the payload. Used by clients that just need to - // peek at the claims (e.g. `exp`) before deciding whether - // to refresh. - let payload = alloc_string(r#"{"role":"admin"}"#); - let token_bits = unsafe { - js_jwt_sign( - payload.as_raw() as *const _, - alloc_string("k").as_raw() as *const _, - 0.0, - std::ptr::null(), - ) - }; - let token = s(token_bits); - let result_ptr = unsafe { js_jwt_decode(alloc_string(&token).as_raw() as *const _) }; - let claims = ps(result_ptr).expect("decode non-null"); - assert!(claims.contains("\"role\":\"admin\""), "got: {}", claims); - } - - #[test] - fn verify_rejects_expired_token() { - // Regression for #5066 / GHSA-5324-c68v-8w62: expired token must be rejected. - let secret = "supersecret"; - let expired_claims = Claims { - data: std::collections::HashMap::new(), - exp: Some(1), - iat: None, - nbf: None, - sub: Some("1234".into()), - iss: None, - aud: None, - }; - let token = encode( - &Header::new(Algorithm::HS256), - &expired_claims, - &EncodingKey::from_secret(secret.as_bytes()), - ) - .expect("encode expired token"); - let token_handle = alloc_string(&token); - let result = unsafe { - js_jwt_verify( - token_handle.as_raw() as *const _, - alloc_string(secret).as_raw() as *const _, - ) - }; - assert!( - result.is_null(), - "expired token must be rejected, got claims: {:?}", - ps(result) - ); - } - - #[test] - fn verify_accepts_token_without_exp() { - // Node parity: token omitting exp must still verify (required_spec_claims empty). - let secret = "supersecret"; - let claims = Claims { - data: std::collections::HashMap::new(), - exp: None, - iat: None, - nbf: None, - sub: Some("1234".into()), - iss: None, - aud: None, - }; - let token = encode( - &Header::new(Algorithm::HS256), - &claims, - &EncodingKey::from_secret(secret.as_bytes()), - ) - .expect("encode no-exp token"); - let token_handle = alloc_string(&token); - let result = unsafe { - js_jwt_verify( - token_handle.as_raw() as *const _, - alloc_string(secret).as_raw() as *const _, - ) - }; - assert!(!result.is_null(), "token without exp must still verify"); - } -} diff --git a/crates/perry-runtime/src/dyn_eval/expr.rs b/crates/perry-runtime/src/dyn_eval/expr.rs index 0a582d9f3b..c2b77405b5 100644 --- a/crates/perry-runtime/src/dyn_eval/expr.rs +++ b/crates/perry-runtime/src/dyn_eval/expr.rs @@ -7,8 +7,12 @@ //! sequence operators, `typeof`/`instanceof`/`in`/`delete`, assignments //! (plain, compound, logical, destructuring), member + computed access, //! optional chaining, calls (host functions, host methods, interpreted -//! closures — with `this` bound like the runtime binds it), and `new` on -//! host constructors / builtin error types / RegExp. +//! closures — with `this` bound like the runtime binds it), `new` on +//! host constructors / builtin error types / RegExp, and #10661 class +//! expressions restricted to: a constructor plus regular (non-getter/setter, +//! non-generator/async) instance/static methods with identifier, string, or +//! numeric keys — see `interp::eval_class_expr` for exactly what is and +//! isn't covered. //! //! Everything else throws the #6559 diagnostic naming the construct. @@ -94,7 +98,7 @@ pub(crate) fn eval_expr(ctx: &Ctx, expr: &ast::Expr, env_idx: usize) -> f64 { OptChain(o) => eval_opt_chain(ctx, o, env_idx), Await(_) => throw_unsupported("await (async interpreted code)"), Yield(_) => throw_unsupported("yield (generator interpreted code)"), - Class(_) => throw_unsupported("class expression"), + Class(c) => super::interp::eval_class_expr(ctx, c, env_idx), TaggedTpl(_) => throw_unsupported("tagged template literal"), SuperProp(_) => throw_unsupported("super property access"), MetaProp(_) => throw_unsupported("new.target / import.meta"), diff --git a/crates/perry-runtime/src/dyn_eval/interp.rs b/crates/perry-runtime/src/dyn_eval/interp.rs index b3fc1819d2..e4f4788485 100644 --- a/crates/perry-runtime/src/dyn_eval/interp.rs +++ b/crates/perry-runtime/src/dyn_eval/interp.rs @@ -1089,3 +1089,200 @@ fn exec_try_catch(ctx: &Ctx, t: &ast::TryStmt, env_idx: usize) -> Flow { fn protected_block(ctx: &Ctx, t: &ast::TryStmt, env_idx: usize) -> Flow { exec_block_scope(ctx, &t.block, env_idx) } + +// ── class expressions (#10661) ────────────────────────────────────────────── + +/// `class [Name] { constructor(...) { ... } method(...) { ... } ... }` as a +/// standalone expression — the shape `generate-function` emits (mysql2's row +/// parsers: `return class TextRow { constructor(fields) {...} next(...) {...} }`). +/// +/// **Supported subset, deliberately narrow** (matches what the schema/codegen +/// corpus behind #6559 actually emits, not general ES2022 class syntax): +/// * an optional `constructor`; missing one synthesizes an empty no-op +/// constructor (there is no `extends`, so there is nothing to forward to +/// a super constructor); +/// * regular (non-getter/setter, non-generator/async) methods, instance or +/// `static`, keyed by identifier / string / numeric literal; +/// * a named class expression sees its own name inside its body, exactly +/// like a named function expression. +/// +/// **Explicitly unsupported** (throws the #6559 diagnostic naming the +/// construct, same as every other out-of-subset form in this interpreter): +/// `extends` (no superclass chain — no `super()`/`super.foo` machinery +/// exists here), decorators, getters/setters, generator/async methods, +/// class fields (public or private), private methods, static blocks, +/// auto-accessors, TS index signatures, TS parameter properties, and +/// computed member keys. +/// +/// **Why this is sugar, not a new mechanism.** The interpreter already +/// supports the ES5 pattern this desugars to — `function Foo(){}` plus +/// `Foo.prototype.bar = function(){}` plus `new Foo()` — because ordinary +/// property writes on an interpreted closure already land in its dynamic +/// expando table (ajv's `validate.errors = ...` already exercises that path), +/// and `new` on ANY closure (host or interpreted) already goes through +/// `js_new_function_construct`'s generic path, which specifically looks for a +/// `"prototype"` dynamic prop to link the new instance's `[[Prototype]]` +/// (`crates/perry-runtime/src/object/class_registry/construct.rs`). So this +/// function does nothing runtime-side that wasn't already reachable from +/// interpreted code — it just builds a constructor closure, a plain prototype +/// object, and wires them together the same way hand-written ES5 would. +/// Nothing new is added to `js_new_function_construct`, method dispatch, or +/// `instanceof` — an instance built this way is an ordinary object whose +/// `[[Prototype]]` happens to be the class's prototype object, found by the +/// same prototype-chain walk any plain object uses. +pub(crate) fn eval_class_expr(ctx: &Ctx, class_expr: &ast::ClassExpr, env_idx: usize) -> f64 { + let class = class_expr.class.as_ref(); + if class.super_class.is_some() { + throw_unsupported("class expression with `extends`"); + } + if !class.decorators.is_empty() { + throw_unsupported("class decorator"); + } + + let base = roots_len(); + + // Named class expressions see their own name inside constructor AND + // method bodies — same pattern `make_function_value` uses for named + // function expressions: chain a one-binding scope, alloc the closure + // over it, then backfill the binding once the closure value exists. + let name = class_expr.ident.as_ref().map(|i| i.sym.to_string()); + let body_env_idx = if name.is_some() { + let name_env = env::env_new(root_get(env_idx)); + root_push(name_env) + } else { + env_idx + }; + + let ctor_member = class.body.iter().find_map(|m| match m { + ast::ClassMember::Constructor(c) => Some(c), + _ => None, + }); + let ctor_fn_id = match ctor_member { + Some(c) => { + let mut params = Vec::with_capacity(c.params.len()); + for p in &c.params { + match p { + ast::ParamOrTsParamProp::Param(p) => params.push(p.pat.clone()), + ast::ParamOrTsParamProp::TsParamProp(_) => { + throw_unsupported("TypeScript parameter property in class constructor") + } + } + } + let body = + InterpBody::Block(c.body.as_ref().map(|b| b.stmts.clone()).unwrap_or_default()); + fn_id_for_node(c as *const ast::Constructor as usize, || { + build_interp_fn(params, body, ctx.strict) + }) + } + None => { + // No constructor written: synthesize an empty one. Keyed on the + // `Class` node itself (there is no dedicated AST node for a + // synthesized constructor) — only used as a cache key, stable + // for the same reason every other node-address key here is: + // `FN_REGISTRY` keeps the owning `InterpFn` (and therefore this + // address) alive for the program's lifetime. + fn_id_for_node(class as *const ast::Class as usize, || { + build_interp_fn(Vec::new(), InterpBody::Block(Vec::new()), ctx.strict) + }) + } + }; + + let ctor_closure = alloc_interp_closure( + ctor_fn_id, + root_get(body_env_idx), + None, + root_get(ctx.global_idx), + root_get(ctx.intrinsics_idx), + ctx.strings_allowed, + ctx.wasm_allowed, + ); + let ctor_idx = root_push(ctor_closure); + + if let Some(name) = &name { + env::define(root_get(body_env_idx), name, root_get(ctor_idx)); + } + + // Plain object, `Object.prototype`-rooted — same as any object literal. + let prototype = bridge::attach_intrinsic_prototype( + bridge::object_new(), + root_get(ctx.intrinsics_idx), + "Object", + ); + let proto_idx = root_push(prototype); + bridge::set_member(root_get(proto_idx), "constructor", root_get(ctor_idx)); + + for member in &class.body { + match member { + ast::ClassMember::Constructor(_) => {} + ast::ClassMember::Method(m) => { + if m.kind != ast::MethodKind::Method { + throw_unsupported("getter/setter in class body"); + } + if m.function.is_generator || m.function.is_async { + throw_unsupported("generator/async method in class body"); + } + let value = make_function_value( + ctx, + m.function.params.iter().map(|p| p.pat.clone()).collect(), + InterpBody::Block( + m.function + .body + .as_ref() + .map(|b| b.stmts.clone()) + .unwrap_or_default(), + ), + false, + None, + m.function.as_ref() as *const ast::Function as usize, + body_env_idx, + ); + let target_idx = if m.is_static { ctor_idx } else { proto_idx }; + set_class_member(target_idx, &m.key, value); + } + ast::ClassMember::PrivateMethod(_) => throw_unsupported("private method (#field)"), + ast::ClassMember::ClassProp(_) => throw_unsupported("class field"), + ast::ClassMember::PrivateProp(_) => throw_unsupported("private class field (#field)"), + ast::ClassMember::TsIndexSignature(_) => { + throw_unsupported("TypeScript index signature in class body") + } + ast::ClassMember::Empty(_) => {} + ast::ClassMember::StaticBlock(_) => throw_unsupported("static initialization block"), + ast::ClassMember::AutoAccessor(_) => throw_unsupported("auto-accessor class member"), + } + } + + // Wire the two together last: `Ctor.prototype = proto` is the dynamic + // expando write `js_new_function_construct` specifically looks for + // (`closure_get_dynamic_prop(fp, "prototype")`) to link a `new`-built + // instance's `[[Prototype]]` to `proto` instead of the closure's default + // (empty, per-function) prototype object. + bridge::set_member(root_get(ctor_idx), "prototype", root_get(proto_idx)); + + let result = root_get(ctor_idx); + roots_truncate(base); + result +} + +/// Set a class member (method) by its `PropName` onto the target (prototype +/// or constructor, for instance vs. `static`). Rejects computed and bigint +/// keys — see `eval_class_expr`'s documented subset. +fn set_class_member(target_idx: usize, key: &ast::PropName, value: f64) { + let value_idx = root_push(value); + match key { + ast::PropName::Ident(i) => { + bridge::set_member(root_get(target_idx), &i.sym, root_get(value_idx)) + } + ast::PropName::Str(s) => bridge::set_member( + root_get(target_idx), + &String::from_utf8_lossy(s.value.as_bytes()), + root_get(value_idx), + ), + ast::PropName::Num(n) => { + let k = bridge::make_number(n.value); + bridge::set_index(root_get(target_idx), k, root_get(value_idx), false); + } + ast::PropName::Computed(_) => throw_unsupported("computed method name in class body"), + ast::PropName::BigInt(_) => throw_unsupported("bigint method name in class body"), + } + roots_truncate(value_idx); +} diff --git a/crates/perry-runtime/src/dyn_eval/mod.rs b/crates/perry-runtime/src/dyn_eval/mod.rs index 6edcca6d22..7515dae07f 100644 --- a/crates/perry-runtime/src/dyn_eval/mod.rs +++ b/crates/perry-runtime/src/dyn_eval/mod.rs @@ -15,7 +15,12 @@ //! covers the pragmatic subset those code generators emit (see `interp.rs` / //! `expr.rs`); anything outside the subset throws a diagnostic TypeError //! naming the unsupported construct, so real-world gaps surface as clear -//! errors instead of silent miscomputation. +//! errors instead of silent miscomputation. #10661: `generate-function` +//! (mysql2's row parsers, and others beyond mysql2) emits a **class +//! expression** as the returned value — `interp::eval_class_expr` supports a +//! deliberately narrow subset of that (constructor + plain methods, no +//! `extends`/decorators/getters/setters/fields/private members/computed +//! keys); see its doc comment for the exact boundary. //! //! Bridging is the crux and it is bidirectional: //! * interpreted code calls REAL runtime values (schema refs, format diff --git a/crates/perry-runtime/src/dyn_eval/tests.rs b/crates/perry-runtime/src/dyn_eval/tests.rs index 7696d0dd12..0fe12e05da 100644 --- a/crates/perry-runtime/src/dyn_eval/tests.rs +++ b/crates/perry-runtime/src/dyn_eval/tests.rs @@ -397,11 +397,16 @@ fn parse_error_throws_syntax_error() { #[test] fn unsupported_construct_diagnostic_names_the_construct() { + // #10661 narrowed what counts as "unsupported" here: a plain class + // expression is now interpreted (see the `class_expression_*` tests + // above). `extends` stays out of the supported subset (no superclass + // chain / `super()` machinery exists in this interpreter), so it is + // still the representative "diagnostic names the construct" case. let result = catch_throw(|| { - let f = dyn_fn(&["return class {}"]); + let f = dyn_fn(&["return class extends Array {}"]); call(f, &[]) }); - let exc = result.expect_err("class expression must be rejected"); + let exc = result.expect_err("class expression with extends must be rejected"); let msg = error_message(exc); assert!( msg.contains("unsupported construct") && msg.contains("class"), @@ -1076,3 +1081,179 @@ fn promise_static_result_retains_intrinsic_prototype() { buys nothing and costs a process-wide fast-path invalidation" ); } + +// ── class expressions (#10661) ────────────────────────────────────────────── +// +// mysql2's row parsers (`lib/parsers/text_parser.js` / +// `binary_parser.js`, via `generate-function`) build EXACTLY this shape at +// runtime — captured verbatim from a live `mysql2` `SELECT` against a real +// server (`Function.apply(null, keys.concat(src)).apply(null, vals)`, +// `generate-function/index.js:172`): +// +// (function anonymous() { +// return ((function () { +// return class TextRow { +// constructor(fields) {} +// next(packet, fields, options) { +// this.packet = packet; +// const result = {}; +// result["val"] = packet.readLengthCodedString(fields[0].encoding); +// return result; +// } +// }; +// })()) +// }) +// +// The tests below exercise that shape (minus the host `packet` receiver, +// which is out of unit-test scope the same way +// `interpreted_code_constructs_host_class_parameter` above notes) plus the +// rest of the documented subset, and confirm the documented boundary +// (`extends`, getters/setters, private members, computed keys, class fields, +// static blocks) still throws the #6559 diagnostic. + +#[test] +fn class_expression_mysql2_row_parser_shape() { + let f = dyn_fn(&[r#" + return (function () { + return class TextRow { + constructor(fields) { + this.fields = fields; + } + next(extra) { + return this.fields + extra; + } + }; + })(); + "#]); + let ctor_idx = root_push(call(f, &[])); + let inst = super::bridge::construct(root_get(ctor_idx), &[num(3.0)]); + let inst_idx = root_push(inst); + let result = super::bridge::call_method(root_get(inst_idx), "next", &[num(4.0)]); + roots_truncate(ctor_idx); + assert_eq!(as_num(result), 7.0); +} + +#[test] +fn class_expression_default_constructor_and_instance_state() { + // No explicit constructor: synthesized empty one, matching a class with + // no `constructor(...)` member. + let f = dyn_fn(&[r#" + return class Empty { + set(v) { this.v = v; return this; } + get() { return this.v; } + }; + "#]); + let ctor_idx = root_push(call(f, &[])); + let inst = super::bridge::construct(root_get(ctor_idx), &[]); + let inst_idx = root_push(inst); + super::bridge::call_method(root_get(inst_idx), "set", &[num(9.0)]); + let result = super::bridge::call_method(root_get(inst_idx), "get", &[]); + roots_truncate(ctor_idx); + assert_eq!(as_num(result), 9.0); +} + +#[test] +fn class_expression_static_method_and_string_numeric_keys() { + let f = dyn_fn(&[r#" + return class Keyed { + static make() { return new Keyed(); } + "str-key"() { return "s"; } + 0() { return "n"; } + }; + "#]); + let ctor_idx = root_push(call(f, &[])); + let made = super::bridge::call_method(root_get(ctor_idx), "make", &[]); + let made_idx = root_push(made); + assert_eq!( + as_str(super::bridge::call_method( + root_get(made_idx), + "str-key", + &[] + )), + "s" + ); + assert_eq!( + as_str(super::bridge::call_method(root_get(made_idx), "0", &[])), + "n" + ); + roots_truncate(ctor_idx); +} + +#[test] +fn class_expression_two_instances_do_not_share_state() { + let f = dyn_fn(&[r#" + return class Counter { + constructor() { this.n = 0; } + inc() { this.n = this.n + 1; return this.n; } + }; + "#]); + let ctor_idx = root_push(call(f, &[])); + let a = super::bridge::construct(root_get(ctor_idx), &[]); + let a_idx = root_push(a); + let b = super::bridge::construct(root_get(ctor_idx), &[]); + let b_idx = root_push(b); + super::bridge::call_method(root_get(a_idx), "inc", &[]); + super::bridge::call_method(root_get(a_idx), "inc", &[]); + let a_result = super::bridge::call_method(root_get(a_idx), "inc", &[]); + let b_result = super::bridge::call_method(root_get(b_idx), "inc", &[]); + roots_truncate(ctor_idx); + assert_eq!(as_num(a_result), 3.0); + assert_eq!(as_num(b_result), 1.0); +} + +#[test] +fn class_expression_named_self_reference() { + // A named class expression sees its own name inside its body, same as a + // named function expression. + let f = dyn_fn(&[r#" + return (class Self { + static describe() { return typeof Self; } + }).describe(); + "#]); + let r = call(f, &[]); + assert_eq!(as_str(r), "function"); +} + +#[test] +fn class_expression_with_extends_is_unsupported() { + let f = dyn_fn(&["return class Sub extends Array {};"]); + let err = catch_throw(|| call(f, &[])).expect_err("extends must throw"); + assert!( + error_message(err).contains("class expression with `extends`"), + "unexpected message: {}", + error_message(err) + ); +} + +#[test] +fn class_expression_getter_is_unsupported() { + let f = dyn_fn(&["return class G { get x() { return 1; } };"]); + let err = catch_throw(|| call(f, &[])).expect_err("getter must throw"); + assert!(error_message(err).contains("getter/setter in class body")); +} + +#[test] +fn class_expression_field_is_unsupported() { + let f = dyn_fn(&["return class F { x = 1; };"]); + let err = catch_throw(|| call(f, &[])).expect_err("class field must throw"); + assert!(error_message(err).contains("class field")); +} + +#[test] +fn class_expression_computed_key_is_unsupported() { + let f = dyn_fn(&[r#" + const k = "m"; + return class C { [k]() { return 1; } }; + "#]); + let err = catch_throw(|| call(f, &[])).expect_err("computed key must throw"); + assert!(error_message(err).contains("computed method name in class body")); +} + +#[test] +fn class_declaration_statement_remains_unsupported() { + // Only the class EXPRESSION form is in scope for #10661; a class + // declaration statement is untouched. + let f = dyn_fn(&["class D {} return D;"]); + let err = catch_throw(|| call(f, &[])).expect_err("class declaration must throw"); + assert!(error_message(err).contains("class declaration")); +} diff --git a/crates/perry-runtime/src/gc/copying.rs b/crates/perry-runtime/src/gc/copying.rs index 7a510d1d64..54751c82cc 100644 --- a/crates/perry-runtime/src/gc/copying.rs +++ b/crates/perry-runtime/src/gc/copying.rs @@ -608,7 +608,19 @@ impl CopyingNurseryCollector { (*header).gc_flags &= !GC_FLAG_MARKED; gc_type_after_payload_move((*header).obj_type, old_user as usize, new_user as usize); - self.worklist.push(new_header); + // #10362: an object that provably yields no child slot is marked and + // moved, but not QUEUED — the drain would build an iterator and find + // nothing. See `gc_object_yields_no_child_slots` for what "no child + // slot" has to mean for this to be sound; the copying minor needs no + // proxy term because it ignores `PointerFreeRange`. + // + // `moved_headers` below is NOT part of this and must keep EVERY + // survivor: `clear_marks` walks it, so a header missing from it carries + // GC_FLAG_MARKED past the end of the cycle and reads as live to the + // next full sweep. Only the worklist push is skipped. + if !gc_object_yields_no_child_slots(new_header) { + self.worklist.push(new_header); + } self.survival_push(); if let Some(d) = self.survival.as_mut() { d.record((*new_header).obj_type, total, promote); diff --git a/crates/perry-runtime/src/gc/layout.rs b/crates/perry-runtime/src/gc/layout.rs index cb5e79282a..5726984e28 100644 --- a/crates/perry-runtime/src/gc/layout.rs +++ b/crates/perry-runtime/src/gc/layout.rs @@ -481,6 +481,76 @@ pub(super) unsafe fn layout_header_for_user(user_ptr: usize) -> Option<*mut GcHe } } +/// True when a traced object provably yields NO child slot to any collector +/// walk, so the walk can be SKIPPED rather than performed and found empty. On a +/// chain-node heap half the traced objects are of this shape. +/// +/// This is a claim about four independent edge sources, and every one of them +/// needs its own term. `GC_LAYOUT_POINTER_FREE` alone is NOT enough, because it +/// describes the PAYLOAD and nothing else: +/// +/// * **the payload** — `GC_LAYOUT_POINTER_FREE`, which +/// `heap_payload_slot_selection` already trusts to skip the whole payload +/// without consulting a mask; +/// * **the kind's prefix and meta edges** — the reason for the kind term, and +/// the reason it comes first. `gc_child_slots` builds `ArrayElements` as +/// `new(header, None, range)`: no prefix, no meta, no meta2. Every other +/// layout kind carries at least one. `ObjectFields` carries the meta record, +/// which #6812 records as "fatal for the spill buffer, reachable through meta +/// alone"; `RegExpFields` and `ObjectMeta` carry a prefix and two meta edges +/// each. And POINTER_FREE is emphatically not an array-only bit: a closure is +/// ALLOCATED pointer-free (`symbol/properties.rs`, #7154) and only leaves that +/// state when a capture store records a pointer, and a typed object whose +/// shape has an empty pointer mask acquires it (`gc/layout/typed_shape.rs`). +/// Skipping either would drop edges the payload bit says nothing about — the +/// closure's dynamic property values and static `.prototype`, the object's +/// meta record, shape `keys` edge and overflow fields; +/// * **the array's named-property reserve slots** — `GC_ARRAY_NAMED_PROPS`, +/// which live in front of element 0, outside every layout range; +/// * **a residual `Object.setPrototypeOf` entry** — the per-owner header bit +/// from #10611, which is what makes this affordable to ask per object. +/// +/// A FORWARDED header is never skippable, whatever its layout: array growth +/// installs PERMANENT forwarding stubs, and walking the stub is what propagates +/// liveness across the hop (#6228). The same guard on the sibling leaf skip in +/// `gc/trace.rs` is there for this reason. +/// +/// NOT SUFFICIENT ON ITS OWN FOR THE FULL MARK. `gc/trace.rs` reads every word +/// of a pointer-free payload through `proxy::gc_observe_traced_value` when a +/// proxy trace is active, because a proxy id is a `POINTER_TAG` value in the +/// proxy-id band rather than a heap pointer — which is precisely why the layout +/// mask is entitled to call a payload holding one pointer free. The full mark's +/// call site therefore ANDs in `!proxy_trace_active`; the copying minor and the +/// remembered-set rebuild both ignore `PointerFreeRange` and need no such term. +#[inline] +pub(crate) unsafe fn gc_object_yields_no_child_slots(header: *const GcHeader) -> bool { + // ORDER IS LOAD-BEARING, and it is a measurement, not a preference. Every + // object the copying minor moves asks this, and most say no; a first + // version that asked the type table first cost +0.07% to +0.12% on the + // three fixtures where almost nothing qualifies. The three header-word + // terms fold into ONE mask compare on a word `move_young` has already + // loaded, so a non-candidate is rejected in two instructions. + let reserved = (*header)._reserved; + if reserved + & (GC_LAYOUT_STATE_MASK + | crate::gc::GC_ARRAY_NAMED_PROPS + | crate::gc::GC_RESIDUAL_PROTO_OWNER) + != GC_LAYOUT_POINTER_FREE + { + return false; + } + if (*header).gc_flags & GC_FLAG_FORWARDED != 0 { + return false; + } + // Keyed on the TYPE rather than the rewrite kind, so the surviving path is + // a byte compare instead of a table load. This is conservative in the safe + // direction: a future type that also had no prefix/meta edge and no + // uncovered sibling would simply not be admitted here, costing a walk it + // could have skipped. `the_array_type_still_pairs_with_the_prefix_free_ + // layout_kind` pins the two table facts this leans on. + (*header).obj_type == crate::gc::GC_TYPE_ARRAY +} + #[inline] pub(crate) unsafe fn layout_init_pointer_free(user_ptr: *mut u8) { let Some(header) = layout_header_for_user(user_ptr as usize) else { diff --git a/crates/perry-runtime/src/gc/tests/layout_trace.rs b/crates/perry-runtime/src/gc/tests/layout_trace.rs index ff187248aa..0bbf10e94e 100644 --- a/crates/perry-runtime/src/gc/tests/layout_trace.rs +++ b/crates/perry-runtime/src/gc/tests/layout_trace.rs @@ -229,11 +229,21 @@ fn test_raw_numeric_array_layout_transfers_on_copying_minor_and_skips_payload() } assert_eq!(test_layout_pointer_slot_count(after, 4), Some(0)); assert_eq!(test_heap_child_slot_count(after as *mut u8), 0); - assert!( - trace.layout_scans.raw_numeric_array_slots_skipped >= 4, - "copied raw numeric array payload should be skipped by layout scan: {:?}", - trace.layout_scans - ); + // #10362 changed WHERE this payload stops being scanned, and therefore what + // the evidence for it is. The layout-scan counters are charged BY the walk; + // a copied raw-numeric array is now not walked at all, so + // `raw_numeric_array_slots_skipped` no longer counts it. The subject of this + // test is unchanged and in fact stronger — the payload is not scanned — so + // it is asserted against the mechanism that now decides it, which is + // falsifiable in a way `>= 0` would not be. + unsafe { + assert!( + crate::gc::gc_object_yields_no_child_slots(header), + "a copied raw numeric array must be admitted by the zero-slot skip, \ + which is what now keeps its payload off the scan: reserved={:#x}", + (*header)._reserved + ); + } } #[test] diff --git a/crates/perry-runtime/src/gc/tests/mod.rs b/crates/perry-runtime/src/gc/tests/mod.rs index 7a7e8a70e3..63d50b4fa6 100644 --- a/crates/perry-runtime/src/gc/tests/mod.rs +++ b/crates/perry-runtime/src/gc/tests/mod.rs @@ -97,3 +97,4 @@ mod u8_inline_cache; mod weak_read_barrier; mod young_leaf_route; mod young_log_tests; +mod zero_slot_skip; diff --git a/crates/perry-runtime/src/gc/tests/zero_slot_skip.rs b/crates/perry-runtime/src/gc/tests/zero_slot_skip.rs new file mode 100644 index 0000000000..792dbb85d8 --- /dev/null +++ b/crates/perry-runtime/src/gc/tests/zero_slot_skip.rs @@ -0,0 +1,230 @@ +//! The zero-slot skip (#10362): an object that provably yields no child slot is +//! marked and moved but never queued for a walk that would find nothing. +//! +//! Skipping a walk is skipping every edge that walk would have produced, so the +//! witnesses here are organised by EDGE SOURCE, not by fixture. Each term of +//! `gc_object_yields_no_child_slots` gets a case that fails without it, and the +//! full mark's extra `!proxy_trace_active` term — the one no ordinary GC +//! fixture can see — gets a real collection and a sabotaged twin. + +use super::super::trace::zero_slot_skip_sabotage; +use super::super::*; +use super::support::*; + +fn full_collect() { + let trigger = GcTriggerSnapshot { + kind: GcTriggerKind::Manual, + steps_before: Some(GcStepSnapshot::current()), + }; + let _ = GcCycleState::new_full(trigger).run_to_completion(); +} + +fn alloc_proxy_endpoint() -> (*mut u8, f64) { + let ptr = gc_malloc( + std::mem::size_of::(), + GC_TYPE_CLOSURE, + ); + unsafe { + init_test_closure(ptr); + } + (ptr, f64::from_bits(ptr_bits(ptr as usize))) +} + +/// A plain pointer-free array: the population the skip exists for. +unsafe fn pointer_free_array(length: u32) -> (*mut crate::array::ArrayHeader, *mut u64) { + let (arr, elements) = alloc_old_test_array(length); + layout_init_pointer_free(arr as *mut u8); + (arr, elements) +} + +unsafe fn header_of(user: usize) -> *mut GcHeader { + header_from_user_ptr(user as *const u8) as *mut GcHeader +} + +// ------------------------------------------------------------ the predicate -- + +/// The predicate keys on `GC_TYPE_ARRAY` for speed, which is only sound while +/// that type is the one whose rewrite arm has no uncovered sibling and whose +/// layout kind has no prefix or meta edge. Both are table facts, so both are +/// pinned here rather than argued in a comment. +#[test] +fn the_array_type_still_pairs_with_the_prefix_free_layout_kind() { + assert_eq!( + gc_type_rewrite_descriptor_kind(GC_TYPE_ARRAY), + GcRewriteDescriptorKind::Array, + "the skip assumes GC_TYPE_ARRAY takes the Array rewrite arm, whose only \ + siblings are named props and the residual prototype" + ); + assert_eq!( + gc_type_layout_slot_kind(GC_TYPE_ARRAY), + GcLayoutSlotKind::ArrayElements, + "the skip assumes GC_TYPE_ARRAY's layout kind yields no prefix or meta \ + child edge, which is what gc_child_slots builds for ArrayElements" + ); +} + +#[test] +fn a_plain_pointer_free_array_is_admitted() { + let _guard = GcTestIsolationGuard::new(); + unsafe { + let (arr, _) = pointer_free_array(4); + assert!( + gc_object_yields_no_child_slots(header_of(arr as usize)), + "a pointer-free array with no named props and no residual prototype \ + is exactly the population this skip is for" + ); + } +} + +#[test] +fn an_array_that_still_holds_pointers_is_refused() { + let _guard = GcTestIsolationGuard::new(); + unsafe { + let (arr, _) = alloc_old_test_array(4); + assert!( + !gc_object_yields_no_child_slots(header_of(arr as usize)), + "without GC_LAYOUT_POINTER_FREE the payload may hold anything" + ); + } +} + +#[test] +fn named_properties_refuse_the_skip() { + let _guard = GcTestIsolationGuard::new(); + unsafe { + let (arr, _) = pointer_free_array(4); + let header = header_of(arr as usize); + assert!(gc_object_yields_no_child_slots(header), "premise"); + (*header)._reserved |= crate::gc::GC_ARRAY_NAMED_PROPS; + assert!( + !gc_object_yields_no_child_slots(header), + "named-property reserve slots sit in front of element 0, outside \ + every layout range, so POINTER_FREE says nothing about them" + ); + } +} + +#[test] +fn a_residual_prototype_owner_refuses_the_skip() { + let _guard = GcTestIsolationGuard::new(); + unsafe { + let (arr, _) = pointer_free_array(4); + let header = header_of(arr as usize); + assert!(gc_object_yields_no_child_slots(header), "premise"); + (*header)._reserved |= crate::gc::GC_RESIDUAL_PROTO_OWNER; + assert!( + !gc_object_yields_no_child_slots(header), + "an explicit Object.setPrototypeOf value is a child edge of its \ + owner whatever the payload holds (#10493)" + ); + } +} + +#[test] +fn a_forwarded_array_refuses_the_skip() { + let _guard = GcTestIsolationGuard::new(); + unsafe { + let (arr, _) = pointer_free_array(4); + let header = header_of(arr as usize); + assert!(gc_object_yields_no_child_slots(header), "premise"); + (*header).gc_flags |= GC_FLAG_FORWARDED; + assert!( + !gc_object_yields_no_child_slots(header), + "array growth installs PERMANENT forwarding stubs and walking the \ + stub is what propagates liveness across the hop (#6228)" + ); + } +} + +/// The kind term, and the reason it is a term at all: `GC_LAYOUT_POINTER_FREE` +/// is NOT an array-only bit. A closure is allocated pointer-free +/// (`symbol/properties.rs`, #7154) and a typed object with an empty pointer mask +/// acquires it. Both carry child edges outside the payload, so admitting them +/// on the payload bit alone would drop those edges silently. +#[test] +fn a_pointer_free_non_array_is_refused_whatever_its_payload_says() { + let _guard = GcTestIsolationGuard::new(); + unsafe { + let (obj, _) = alloc_old_test_object(1); + layout_init_pointer_free(obj as *mut u8); + let obj_header = header_of(obj as usize); + assert_eq!( + (*obj_header)._reserved & GC_LAYOUT_STATE_MASK, + GC_LAYOUT_POINTER_FREE, + "premise: the object really is marked pointer-free" + ); + assert!( + !gc_object_yields_no_child_slots(obj_header), + "an object carries the meta record edge, the shape keys edge and \ + its overflow fields, none of which the payload bit describes" + ); + + let (closure_ptr, _) = alloc_proxy_endpoint(); + layout_init_pointer_free(closure_ptr); + assert!( + !gc_object_yields_no_child_slots(header_of(closure_ptr as usize)), + "a closure is ALLOCATED pointer-free and still has dynamic property \ + values and a static prototype edge" + ); + } +} + +// --------------------------------------------- the full mark's proxy term --- + +/// A live proxy reachable ONLY through a pointer-free array, which is itself +/// reached as a FIELD (so the mark takes `mark_field_into_worklist`, the skip +/// site, rather than the root path). Returns whether the proxy survived. +fn proxy_behind_a_pointer_free_array(sabotaged: bool) -> bool { + let _guard = CopyingNurseryTestGuard::new(1); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let (_target_ptr, target) = alloc_proxy_endpoint(); + let (_handler_ptr, handler) = alloc_proxy_endpoint(); + let proxy = crate::proxy::js_proxy_new(target, handler); + + let (arr, elements) = unsafe { pointer_free_array(1) }; + unsafe { + *elements = proxy.to_bits(); + assert!( + gc_object_yields_no_child_slots(header_of(arr as usize)), + "premise: the carrier must be a skip candidate, or this proves nothing" + ); + } + // Reached as a FIELD, not as a root: the skip lives in + // `mark_field_into_worklist`, and the root path does not go through it. + let (holder, fields) = unsafe { alloc_old_test_array(1) }; + unsafe { + *fields = ptr_bits(arr as usize); + layout_init_all_pointer_slots(holder as *mut u8); + } + js_shadow_slot_set(0, ptr_bits(holder as usize)); + + { + let _sabotage = sabotaged.then(zero_slot_skip_sabotage::Guard::arm); + full_collect(); + } + let live = crate::proxy::test_proxy_slot_is_live(proxy); + js_shadow_slot_set(0, crate::value::TAG_UNDEFINED); + live +} + +#[test] +fn a_proxy_behind_a_pointer_free_array_survives_a_full_trace() { + assert!( + proxy_behind_a_pointer_free_array(false), + "the full mark must still read every word of a pointer-free payload \ + while a proxy trace is active: a proxy id is a POINTER_TAG value in \ + the proxy-id band, not a heap pointer, which is why the layout mask \ + calls that payload pointer-free in the first place" + ); +} + +#[test] +fn sabotaging_the_proxy_gate_strands_that_proxys_target() { + assert!( + !proxy_behind_a_pointer_free_array(true), + "with the !proxy_trace_active term removed the array is skipped, the \ + registry entry is never observed, gc_finish_full_trace prunes it and \ + a LIVE proxy loses its target and handler. If this twin ever passes, \ + the term is unwitnessed." + ); +} diff --git a/crates/perry-runtime/src/gc/trace.rs b/crates/perry-runtime/src/gc/trace.rs index 669dde6f88..18894b7c90 100644 --- a/crates/perry-runtime/src/gc/trace.rs +++ b/crates/perry-runtime/src/gc/trace.rs @@ -1359,8 +1359,27 @@ pub(super) unsafe fn mark_field_into_worklist( let forwarded = flags & GC_FLAG_FORWARDED != 0; #[cfg(test)] let forwarded = flags & GC_FLAG_FORWARDED != 0 && !leaf_mark_sabotage::ignoring_forwarding(); + // #10362: a pointer-free array yields no slot either, and on a chain-node + // heap it is half the traced objects — the obj_type-keyed leaf test above + // cannot see them, because they are arrays and not the strings it was + // written for. + // + // ONLY WHEN NO PROXY TRACE IS ACTIVE. `trace_heap_rewrite_slots` reads + // every word of a POINTER-FREE payload through `gc_observe_traced_value` + // when `proxy_trace_active`, because a proxy id is a POINTER_TAG value in + // the proxy-id band and not a heap pointer — which is exactly why the + // layout mask calls that payload pointer free. Skipping the object would + // leave the entry unobserved, `gc_finish_full_trace` would prune it, and a + // LIVE proxy's target and handler would be collected. The other two + // consumers of this predicate ignore `PointerFreeRange` and carry no such + // term; the asymmetry is deliberate. + #[cfg(not(test))] + let proxy_gate = proxy_trace_active; + #[cfg(test)] + let proxy_gate = proxy_trace_active && !zero_slot_skip_sabotage::respecting_proxy_gate(); if !forwarded - && gc_type_rewrite_descriptor_kind((*header).obj_type) == GcRewriteDescriptorKind::Leaf + && (gc_type_rewrite_descriptor_kind((*header).obj_type) == GcRewriteDescriptorKind::Leaf + || (!proxy_gate && gc_object_yields_no_child_slots(header))) { return true; } @@ -1373,6 +1392,41 @@ pub(super) unsafe fn mark_field_into_worklist( true } +/// Sabotage switches for the zero-slot skip (#10362). Test builds only. +/// +/// `respecting_proxy_gate` DISARMS the `!proxy_trace_active` term, i.e. makes +/// the full mark skip a pointer-free array even while a proxy trace is running. +/// That is the defect the gate exists to prevent, and +/// `gc::tests::zero_slot_skip` requires it to strand a live proxy's target. +#[cfg(test)] +pub(crate) mod zero_slot_skip_sabotage { + use std::cell::Cell; + + thread_local! { + static IGNORE_PROXY_GATE: Cell = const { Cell::new(false) }; + } + + #[inline] + pub(crate) fn respecting_proxy_gate() -> bool { + IGNORE_PROXY_GATE.with(Cell::get) + } + + pub(crate) struct Guard(bool); + + impl Guard { + pub(crate) fn arm() -> Self { + Self(IGNORE_PROXY_GATE.with(|s| s.replace(true))) + } + } + + impl Drop for Guard { + fn drop(&mut self) { + let prior = self.0; + IGNORE_PROXY_GATE.with(|s| s.set(prior)); + } + } +} + /// Sabotage switch for the leaf-mark test: a forwarded pointer-free object is /// not queued either, so its forwarding hop is never followed. Test builds /// only. diff --git a/crates/perry-runtime/src/gc/verify.rs b/crates/perry-runtime/src/gc/verify.rs index 48a642a65d..12e94ba7d0 100644 --- a/crates/perry-runtime/src/gc/verify.rs +++ b/crates/perry-runtime/src/gc/verify.rs @@ -303,6 +303,12 @@ pub(super) unsafe fn remember_evacuated_old_copy_young_slots( if !crate::arena::pointer_in_old_gen(user_ptr as usize) { return; } + // #10362: no child slot means no old->young edge to remember. This pass + // ignores `PointerFreeRange`, so unlike the full mark it needs no proxy + // term. + if crate::gc::gc_object_yields_no_child_slots(header) { + return; + } visit_gc_rewrite_slots(header, |slot| unsafe { if crate::weakref::is_weak_target_trace_slot(header, slot.slot) { return; diff --git a/crates/perry-runtime/src/string/concat.rs b/crates/perry-runtime/src/string/concat.rs index 3f4c1ef80f..ce30ed6839 100644 --- a/crates/perry-runtime/src/string/concat.rs +++ b/crates/perry-runtime/src/string/concat.rs @@ -104,15 +104,18 @@ pub(crate) fn canonicalize_surrogate_pairs(ptr: *mut StringHeader) -> *mut Strin /// True when the `len` bytes at `data` are all ASCII (`< 0x80`), or the slice /// is empty/null. Used to decide whether a concat result may be stored inline -/// through the concat helpers' ASCII SSO fast path. +/// through the concat helpers' ASCII SSO fast path. `<[u8]>::is_ascii` +/// inspects the bytes word-at-a-time and is total over arbitrary byte +/// strings — the only sound way to answer this for a Perry heap-string +/// payload, which is not guaranteed valid UTF-8 (see +/// [`str_bytes_ascii_from_jsvalue`](super::str_bytes_ascii_from_jsvalue)'s +/// doc for why the header's `utf16_len == byte_len` cannot stand in for it). #[inline] fn bytes_all_ascii(data: *const u8, len: u32) -> bool { if data.is_null() || len == 0 { return true; } - unsafe { std::slice::from_raw_parts(data, len as usize) } - .iter() - .all(|&b| b < 0x80) + unsafe { std::slice::from_raw_parts(data, len as usize) }.is_ascii() } /// `ptr::copy_nonoverlapping` with a byte loop for short payloads: the libc @@ -194,21 +197,24 @@ pub extern "C" fn js_string_concat_box(l_value: f64, r_value: f64) -> f64 { // NaN-boxed — keeps the dynamic arm. One side must still be a REAL // string, so the annotation-lie semantics of the dynamic arm are // unchanged for number+number. + // Digits from `fast_itoa_u32` are always ASCII (`'0'..='9'`, no sign — the + // admission range below is non-negative), so this arm's third tuple + // element is a constant `true`, never a scan. #[inline] - fn itoa_operand(bits_value: f64, buf: &mut [u8; 32]) -> Option<(*const u8, u32)> { + fn itoa_operand(bits_value: f64, buf: &mut [u8; 32]) -> Option<(*const u8, u32, bool)> { let bits = bits_value.to_bits(); let tag = bits >> 48; let is_plain_f64 = tag < 0x7FF8 || (tag == 0x7FF8 && (bits & 0x000F_FFFF_FFFF_FFFF) == 0); if is_plain_f64 && bits_value.fract() == 0.0 && (0.0..=999_999_999.0).contains(&bits_value) { let len = fast_itoa_u32(bits_value as u32, buf); - Some((buf.as_ptr(), len as u32)) + Some((buf.as_ptr(), len as u32, true)) } else { None } } - let l_str = str_bytes_from_jsvalue(l_value, &mut scratch_l); - let r_str = str_bytes_from_jsvalue(r_value, &mut scratch_r); + let l_str = str_bytes_ascii_from_jsvalue(l_value, &mut scratch_l); + let r_str = str_bytes_ascii_from_jsvalue(r_value, &mut scratch_r); if let (Some(l), Some(r)) = (l_str, r_str) { // Two real strings: straight to assembly, no number buffer touched // (the itoa scratch below would cost this path a 32-byte memset). @@ -231,9 +237,9 @@ pub extern "C" fn js_string_concat_box(l_value: f64, r_value: f64) -> f64 { } _ => {} } - // `str_bytes_from_jsvalue` returns `None` for exactly the non-string - // values, so every remaining pair — number+number included — is the - // annotation-lie arm and nothing else. + // `str_bytes_ascii_from_jsvalue` returns `None` for exactly the + // non-string values, so every remaining pair — number+number included — + // is the annotation-lie arm and nothing else. unsafe { crate::value::js_dynamic_string_or_number_add(l_value, r_value) } } @@ -266,8 +272,34 @@ const CONCAT_MEMO_MAX_BYTES: u32 = 12; // Candidates per governor window. const MEMO_WINDOW: u32 = 4096; -// Earn the probe: at least a quarter of a window's candidates must hit. -const MEMO_MIN_HIT_SHIFT: u32 = 2; +// Earn the probe: at least half a window's candidates must hit. +// +// This was `2` (a 25% floor) — chosen as a plausible fraction, never measured +// against the probe's own cost. A differential instruction-count probe +// (`"abcdefgN" + "xJJ"`, N ∈ 8, JJ ∈ 0..79 — 640 distinct 11-byte results +// against the memo's 512 slots, vs an 8-distinct 10-byte set that hits +// ~100%) against the SAME binary with the governor's decision forced ON/OFF +// via a temporary env knob (since removed) put the break-even — the hit rate +// at which the memo's probe cost equals its allocation savings — at: +// +// before F0/F1/F2 (double ASCII scan + stack-buffer memo probe): ~69-72% +// after F0/F1 + F2's UNSOUND positive header-ASCII path (since reverted, +// see `str_bytes_ascii_from_jsvalue`'s doc): ~54% +// after F0/F1 + F2 corrected to a sound header-negative-filter +// -then-scan (current code): ~50.6% +// +// (`cost / (cost + save)`, reading `cost` off the low-hit-rate workload and +// `save` off the ~100%-hit one — both relative to the same memo-off +// baseline, which the `long73` memo-ineligible control confirmed was flat +// across the forced on/off runs, so the two workloads' allocation-path costs +// are comparable). The break-even barely moved between the unsound and +// sound versions of F2, because this governor times the MEMO PROBE itself +// (hash/lookup/admit, F1's concern) — not the ASCII determination that +// gates whether `concat_byte_parts` reaches the probe at all, which F2 +// changed. `1` (a 50% floor) was already the closest power-of-two to the +// unsound path's ~54%, and it is still the closest power-of-two to the +// sound path's ~50.6% — no change from the F2 fix. +const MEMO_MIN_HIT_SHIFT: u32 = 1; // A hostile workload ends up probing one window in 2^8 rather than one in two. const MEMO_MAX_BACKOFF: u32 = 8; @@ -393,11 +425,33 @@ crate::perry_thread_local! { const { std::cell::UnsafeCell::new([std::ptr::null_mut(); CONCAT_MEMO_SIZE]) }; } -/// Slot and admission tag from one hash walk. The tag is a different slice of -/// the same digest, so two keys sharing a slot rarely share a tag. +/// FNV-1a over concatenated content `a ++ b`, without materialising the +/// concatenation. FNV-1a is a streaming hash — folding in `a`'s bytes then +/// `b`'s bytes is bit-identical to folding in `(a ++ b)`'s bytes — so a +/// two-operand walk needs no scratch buffer at all. This is the memo's +/// analogue of the intern table's `fnv1a_concat`, over raw byte slices +/// instead of `StringHeader` pointers (the memo's operands may be an SSO +/// scratch view, not a heap header). #[inline] -fn concat_memo_slot_and_tag(bytes: &[u8]) -> (usize, u8) { - let h = concat_memo_hash(bytes); +fn concat_memo_hash_parts(a: &[u8], b: &[u8]) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for &byte in a { + h ^= byte as u64; + h = h.wrapping_mul(0x100_0000_01b3); + } + for &byte in b { + h ^= byte as u64; + h = h.wrapping_mul(0x100_0000_01b3); + } + h +} + +/// Slot and admission tag from one hash walk over `a ++ b`. The tag is a +/// different slice of the same digest, so two keys sharing a slot rarely +/// share a tag. +#[inline] +fn concat_memo_slot_and_tag_parts(a: &[u8], b: &[u8]) -> (usize, u8) { + let h = concat_memo_hash_parts(a, b); // FNV-1a avalanches poorly in its high bits, so slicing a tag straight out // of `h >> 32` gave two distinct keys the same tag about half the time — // measured 256,516 admissions in 501,000 probes where ~1/128 was intended, @@ -413,38 +467,45 @@ fn concat_memo_slot_and_tag(bytes: &[u8]) -> (usize, u8) { ) } +/// A cached string whose content is exactly `a ++ b`, or null. The compare is +/// done in the same two parts, against the cached entry's payload — no +/// scratch buffer, and a hash collision is a miss, never a wrong answer. #[inline] -fn concat_memo_hash(bytes: &[u8]) -> u64 { - // FNV-1a over the result bytes. Content-addressed, so two different - // operand splits that produce the same string share one entry. - let mut h: u64 = 0xcbf2_9ce4_8422_2325; - for &b in bytes { - h ^= b as u64; - h = h.wrapping_mul(0x100_0000_01b3); - } - h -} - -/// A cached string with exactly these bytes, or null. The byte compare makes -/// a hash collision a miss, never a wrong answer. -#[inline] -fn concat_memo_lookup(slot: usize, bytes: &[u8]) -> *mut StringHeader { +fn concat_memo_lookup_parts(slot: usize, a: &[u8], b: &[u8]) -> *mut StringHeader { let cached = CONCAT_MEMO.with(|c| unsafe { (*c.get())[slot] }); if cached.is_null() { return std::ptr::null_mut(); } unsafe { - if (*cached).byte_len as usize != bytes.len() { + if (*cached).byte_len as usize != a.len() + b.len() { return std::ptr::null_mut(); } let data = crate::string::string_data(cached); - if std::slice::from_raw_parts(data, bytes.len()) != bytes { + if !a.is_empty() && std::slice::from_raw_parts(data, a.len()) != a { + return std::ptr::null_mut(); + } + if !b.is_empty() && std::slice::from_raw_parts(data.add(a.len()), b.len()) != b { return std::ptr::null_mut(); } } cached } +/// Single-slice callers (the `"prefix" + i` arm in +/// [`js_string_concat_value`], which already has its two pieces contiguous +/// in a scratch buffer by the time it probes) go through the two-slice +/// primitives with an empty second operand — one hash/lookup definition, +/// not two. +#[inline] +fn concat_memo_slot_and_tag(bytes: &[u8]) -> (usize, u8) { + concat_memo_slot_and_tag_parts(bytes, &[]) +} + +#[inline] +fn concat_memo_lookup(slot: usize, bytes: &[u8]) -> *mut StringHeader { + concat_memo_lookup_parts(slot, bytes, &[]) +} + #[inline] fn concat_memo_insert(slot: usize, ptr: *mut StringHeader) { CONCAT_MEMO.with(|c| unsafe { @@ -482,16 +543,39 @@ pub(crate) fn test_clear_concat_memo() { }); } +/// Byte view over a `(ptr, len)` operand, empty for a null/zero-length one. +/// `slice::from_raw_parts` requires a non-null, aligned pointer even at +/// length 0, so the null check must come first. +/// +/// # Safety +/// `ptr` must be valid for `len` bytes when non-null. +#[inline(always)] +unsafe fn operand_byte_slice<'a>(ptr: *const u8, len: u32) -> &'a [u8] { + if ptr.is_null() || len == 0 { + &[] + } else { + std::slice::from_raw_parts(ptr, len as usize) + } +} + /// Shared tail of [`js_string_concat_box`]: assemble two raw byte slices /// (each a real string's payload or an itoa'd integer) into an SSO immediate /// when the total fits five ASCII bytes, a heap `StringHeader` otherwise. +/// +/// The third tuple element is whether that operand is pure ASCII, computed +/// once by the caller — see +/// [`str_bytes_ascii_from_jsvalue`](super::str_bytes_ascii_from_jsvalue) for +/// how (a sound header-filter-then-scan for a heap string, a plain scan for +/// an SSO one, or a constant `true` for an itoa'd operand). Taking it as a +/// precomputed bit here, instead of re-deriving it with a fresh byte scan, is +/// F0: this function used to scan both operands for ASCII-ness twice (once +/// via `bytes_all_ascii` up front, again via `l_slice.is_ascii() && +/// r_slice.is_ascii()` on the heap path below with `both_ascii` sitting +/// unused in scope) — one real scan per operand now, not two. #[inline(always)] -fn concat_byte_parts(l: (*const u8, u32), r: (*const u8, u32)) -> f64 { +fn concat_byte_parts(l: (*const u8, u32, bool), r: (*const u8, u32, bool)) -> f64 { let total_blen = l.1 + r.1; - - // Keep the existing ASCII-only concat fast path. Non-ASCII results use - // the heap path, which also handles WTF-8 surrogate-pair boundaries. - let both_ascii = bytes_all_ascii(l.0, l.1) && bytes_all_ascii(r.0, r.1); + let both_ascii = l.2 && r.2; // SSO fast path — assemble the result inline when it fits (≤ 5 // bytes). Pure bit arithmetic, no heap touch. @@ -509,33 +593,28 @@ fn concat_byte_parts(l: (*const u8, u32), r: (*const u8, u32)) -> f64 { } } - // Memo probe, ahead of the allocation: assemble the result into a stack - // buffer and look it up by content. Restricted to short ASCII results, so - // `flags`/`utf16_len` are trivially `0`/`total_blen` and the surrogate - // canonicalization below is a no-op — the cached string is bit-identical - // to what the heap path would have built. + // Byte views over both operands — used by the memo probe below and by + // the heap path's copy (and, on the non-ASCII arm only, its UTF-16/flags + // walk). Built once and shared, rather than re-derived per use. + let l_slice: &[u8] = unsafe { operand_byte_slice(l.0, l.1) }; + let r_slice: &[u8] = unsafe { operand_byte_slice(r.0, r.1) }; + + // Memo probe, ahead of the allocation: hash and look up `l_slice ++ + // r_slice` directly (F1 — no stack buffer to materialise the + // concatenation just to ask about it; FNV-1a is a streaming hash and the + // compare runs in the same two parts against the cached entry). Restricted + // to short ASCII results, so `flags`/`utf16_len` are trivially + // `0`/`total_blen` and the surrogate canonicalization below is a no-op — + // the cached string is bit-identical to what the heap path would have + // built. let memoizable = both_ascii && total_blen <= CONCAT_MEMO_MAX_BYTES && concat_memo_should_probe(); - let mut memo_buf = [0u8; CONCAT_MEMO_MAX_BYTES as usize]; let mut memo_slot = 0usize; let mut memo_admitted = false; if memoizable { - unsafe { - if l.1 > 0 { - std::ptr::copy_nonoverlapping(l.0, memo_buf.as_mut_ptr(), l.1 as usize); - } - if r.1 > 0 { - std::ptr::copy_nonoverlapping( - r.0, - memo_buf.as_mut_ptr().add(l.1 as usize), - r.1 as usize, - ); - } - } - let bytes = &memo_buf[..total_blen as usize]; - let (slot, tag) = concat_memo_slot_and_tag(bytes); + let (slot, tag) = concat_memo_slot_and_tag_parts(l_slice, r_slice); memo_slot = slot; - let hit = concat_memo_lookup(memo_slot, bytes); + let hit = concat_memo_lookup_parts(memo_slot, l_slice, r_slice); if !hit.is_null() { concat_memo_note_hit(); return f64::from_bits(crate::value::JSValue::string_ptr(hit).bits()); @@ -546,26 +625,11 @@ fn concat_byte_parts(l: (*const u8, u32), r: (*const u8, u32)) -> f64 { } // Heap path — allocate a StringHeader and memcpy. Decode both - // operands' byte slices via `str_bytes_from_jsvalue` (already done + // operands' byte slices via `str_bytes_ascii_from_jsvalue` (already done // above) and write directly into the new header's payload region. let (ptr, data_ptr) = string_storage_alloc(total_blen); unsafe { - // ASCII-fast utf16 length: count bytes < 0x80 in both slices in - // one pass. Most concat results are pure ASCII (number formatting, - // ID building, slug construction, etc.); falling back to the - // full Grisu-style codepoint walk for non-ASCII keeps spec - // compliance for the edge case. - let l_slice = if !l.0.is_null() { - std::slice::from_raw_parts(l.0, l.1 as usize) - } else { - &[] - }; - let r_slice = if !r.0.is_null() { - std::slice::from_raw_parts(r.0, r.1 as usize) - } else { - &[] - }; - let (utf16_len, flags) = if l_slice.is_ascii() && r_slice.is_ascii() { + let (utf16_len, flags) = if both_ascii { (total_blen, 0) } else { // Sum each operand's UTF-16 length independently (concatenating two @@ -794,6 +858,17 @@ pub extern "C" fn js_string_concat_value( // and heap-allocates. Restricted to a plain ASCII prefix so the cached // string is bit-identical to what the block below would build // (flags == 0, utf16_len == byte_len). + // + // `prefix_u16 == prefix_blen` is NOT the runtime's ASCII predicate — + // it is necessary but not sufficient: a truncated multi-byte lead + // byte can make a non-ASCII `prefix` coincide on `utf16_len == + // byte_len` too (see `str_bytes_ascii_from_jsvalue`'s doc in + // `string/mod.rs`, and `string/compare.rs`'s `utf16_cmp_bytes` doc, + // for the exact mechanism and a concrete payload). It DOES soundly + // rule out non-ASCII when the lengths differ, so it stays first in + // the chain as a free short-circuit — but when it's true, the + // `bytes_all_ascii` scan below is still required, not redundant + // with it. let memoizable = total_blen <= CONCAT_MEMO_MAX_BYTES as usize && is_valid_string_ptr(prefix) && prefix_u16 == prefix_blen diff --git a/crates/perry-runtime/src/string/mod.rs b/crates/perry-runtime/src/string/mod.rs index 670455727d..b82f892d51 100644 --- a/crates/perry-runtime/src/string/mod.rs +++ b/crates/perry-runtime/src/string/mod.rs @@ -926,6 +926,70 @@ pub fn str_bytes_from_jsvalue( None } +/// Sibling of [`str_bytes_from_jsvalue`] that additionally reports whether the +/// operand is pure ASCII. +/// +/// - Heap `STRING_TAG`: Perry heap-string payloads are **not guaranteed valid +/// UTF-8** (WTF-8 lone surrogates, `Buffer.toString` of arbitrary bytes, FFI +/// blobs — #6085), so the header's `utf16_len == byte_len` can only be used +/// as a one-directional filter, never as the answer: +/// - `utf16_len != byte_len` ⟹ **definitely not ASCII**, no scan needed. +/// This direction is unconditional, not a well-formedness assumption: +/// [`compute_utf16_len_wtf8`] advances exactly one byte and adds exactly +/// one unit per iteration whenever it sees a byte `< 0x80`, so a payload +/// of nothing but such bytes always produces `utf16_len == byte_len` +/// exactly — the contrapositive holds for *any* byte content, valid or +/// not. +/// - `utf16_len == byte_len` does **not** imply ASCII: a truncated +/// multi-byte lead byte is charged its full nominal unit count by +/// [`compute_utf16_len_wtf8`] while the payload holds fewer bytes than +/// that sequence would need, so a short malformed payload can coincide — +/// `[0xC3]` (a lone 2-byte lead) records `utf16_len == 1 == byte_len`, +/// and `[0xF0, 0x41]` (a truncated 4-byte lead followed by an unrelated +/// byte) records `utf16_len == 2 == byte_len` — both non-ASCII. See +/// `string/compare.rs`'s `utf16_cmp_bytes` doc, which documents the same +/// hazard for the same reason. When the header is this ambiguous, fall +/// back to an actual byte scan (`<[u8]>::is_ascii`, word-at-a-time, total +/// over arbitrary bytes — no validity precondition at all). +/// - Inline `SHORT_STRING_TAG`: [`JSValue::try_short_string`] stores whatever +/// bytes it's given verbatim, with no ASCII requirement, and there is no +/// header standing in for the scan — always run `is_ascii()` on the +/// already-materialised ≤5-byte scratch, which is trivial at that size. +/// +/// Left as a separate function (not a shared implementation with +/// `str_bytes_from_jsvalue`) so the latter's ~50 other call sites pay no new +/// cost for a bit they don't use. +#[inline] +pub fn str_bytes_ascii_from_jsvalue( + value: f64, + scratch: &mut [u8; crate::value::SHORT_STRING_MAX_LEN], +) -> Option<(*const u8, u32, bool)> { + let bits = value.to_bits(); + let jsval = crate::value::JSValue::from_bits(bits); + unsafe { + if jsval.is_short_string() { + let n = jsval.short_string_to_buf(scratch); + let ascii = scratch[..n].is_ascii(); + return Some((scratch.as_ptr(), n as u32, ascii)); + } + if jsval.is_string() { + let hdr = jsval.as_string_ptr(); + if hdr.is_null() { + return Some((std::ptr::null(), 0, true)); + } + let data = string_data(hdr); + let byte_len = (*hdr).byte_len; + // `!=` proves non-ASCII outright (see doc above); `==` is + // ambiguous — a truncated/malformed lead byte can coincidentally + // match — so only THAT arm pays for the real scan. + let ascii = (*hdr).utf16_len == byte_len + && std::slice::from_raw_parts(data, byte_len as usize).is_ascii(); + return Some((data, byte_len, ascii)); + } + } + None +} + /// Fast path: create a string from bytes known to be pure ASCII. /// Skips the `compute_utf16_len` byte scan — sets utf16_len = byte_len directly. #[inline] diff --git a/crates/perry-runtime/src/string/tests.rs b/crates/perry-runtime/src/string/tests.rs index f581570e00..362925c21b 100644 --- a/crates/perry-runtime/src/string/tests.rs +++ b/crates/perry-runtime/src/string/tests.rs @@ -1197,6 +1197,137 @@ fn concat_memo_declines_non_ascii_prefixes() { } } +// ── #6085-class regression: a header that LIES about being ASCII ────────── +// +// Perry heap-string payloads are not guaranteed valid UTF-8 (WTF-8 lone +// surrogates, `Buffer.toString` of arbitrary bytes, FFI blobs — #6085). The +// header's `utf16_len == byte_len` predicate is sound as a NEGATIVE filter +// (unequal ⟹ definitely not ASCII) but not as a positive one: a payload +// ending in a truncated multi-byte lead byte can coincide on equal lengths +// without being ASCII — `compute_utf16_len_wtf8` charges a truncated lead +// its full nominal unit count while the payload holds fewer bytes than that +// sequence declares. `[0xC3]` (a lone 2-byte lead) and `[0xF0, 0x41]` (a +// truncated 4-byte lead followed by an unrelated byte) both report +// `utf16_len == byte_len` while being non-ASCII — the exact pair +// `string/compare.rs`'s `cached_utf16_len_predicate_would_misclassify_these` +// pins for the same reason. (`[0x80]`, a bare continuation byte, is NOT in +// this class: `compute_utf16_len_wtf8` skips it as "continuation byte in +// lead position" without counting a unit, so it reports `utf16_len == 0 != +// byte_len == 1` — the negative filter already catches it correctly, no +// scan needed.) +// +// I could not find a TypeScript-reachable path that constructs such a +// payload today: every raw-bytes-to-string channel that could plausibly +// carry attacker/arbitrary bytes — `Buffer.toString` (all seven encodings, +// `buffer/encode.rs`), `TextDecoder.decode` (`text.rs::decode_bytes`), and +// every `bun:ffi` string-returning path (`read_cstring_value`, +// `dlopen.rs`'s `CString`/`cstring` conversions) — validates via +// `str::from_utf8`/`from_utf8_lossy` (or is fed a Rust `&str`, valid by +// construction) before ever calling `js_string_from_bytes`; `#609` closed +// the same construction sites for a related UB hazard and the fix happens +// to guarantee well-formed output too. So these are Rust-level regression +// tests against `js_string_from_bytes` directly (the same technique +// `string/compare.rs`'s own corpus and `tests_guard_page.rs` use) rather +// than a gap test: `js_string_from_bytes` is a `pub extern "C"` entry point +// whose own contract must hold for any bytes, whether or not today's call +// graph happens to always validate first. + +/// [`str_bytes_ascii_from_jsvalue`] must not trust the header's equal-lengths +/// coincidence — it must fall back to a real (word-at-a-time, always sound) +/// byte scan whenever the header is this ambiguous. +#[test] +fn ascii_probe_falls_back_to_a_scan_when_the_header_lies() { + for bytes in [&[0xC3u8][..], &[0xF0u8, 0x41][..]] { + let hdr = js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32); + unsafe { + assert_eq!( + (*hdr).utf16_len, + (*hdr).byte_len, + "{bytes:?}: header must (wrongly) report equal lengths, \ + or this test is not exercising the hazard" + ); + } + let value = f64::from_bits(crate::value::JSValue::string_ptr(hdr).bits()); + let mut scratch = [0u8; crate::value::SHORT_STRING_MAX_LEN]; + let (_ptr, len, ascii) = str_bytes_ascii_from_jsvalue(value, &mut scratch) + .expect("a real string operand must decode"); + assert_eq!(len, bytes.len() as u32); + assert!(!ascii, "{bytes:?} is not ASCII"); + } +} + +/// The `"prefix" + i` memo probe ([`js_string_concat_value`]'s memoizable +/// gate) must not memoize off a header-lying prefix either: `prefix_u16 == +/// prefix_blen` is a necessary pre-filter, not the ASCII predicate — the +/// `bytes_all_ascii` scan after it is what actually decides. +#[test] +fn concat_memo_declines_a_prefix_whose_header_lies_about_being_ascii() { + let _lock = crate::gc::global_side_table_test_lock(); + crate::string::concat::test_clear_concat_memo(); + crate::string::concat::test_reset_memo_governor(); + + let malformed: &[u8] = &[0xC3]; + let prefix = js_string_from_bytes(malformed.as_ptr(), malformed.len() as u32); + unsafe { + assert_eq!( + (*prefix).utf16_len, + (*prefix).byte_len, + "premise: header must (wrongly) report equal lengths" + ); + } + // Same 3-call shape as `concat_memo_returns_one_object_for_equal_results`: + // the doorkeeper admits a result only on its SECOND sighting, so a + // 2-call probe cannot distinguish "declined outright" from "memoizable, + // just not admitted yet" — the second and third calls are the pair that + // would share identity if this prefix were (wrongly) memoized. + let _first = crate::string::js_string_concat_value(prefix, 1.0); + let second = crate::string::js_string_concat_value(prefix, 1.0); + let third = crate::string::js_string_concat_value(prefix, 1.0); + assert_ne!( + second as usize, third as usize, + "a header-lying malformed prefix must not be memoized" + ); +} + +/// The observable divergence the (now-fixed) header-trick bug produced: +/// concatenating a malformed operand with an ordinary ASCII string, on +/// either side, must come out `isWellFormed() === false` — the same answer +/// [`js_string_concat`] (the general, always-scanning path) gives — instead +/// of silently taking the ASCII fast path and reporting well-formed. +#[test] +fn concat_box_reports_not_well_formed_for_a_malformed_operand_either_side() { + let heap_bytes = |b: &[u8]| { + let p = js_string_from_bytes(b.as_ptr(), b.len() as u32); + f64::from_bits(crate::value::JSValue::string_ptr(p).bits()) + }; + let heap_str = |s: &str| heap_bytes(s.as_bytes()); + + for malformed in [&[0xC3u8][..], &[0xF0u8, 0x41][..]] { + for (l, r, order) in [ + (heap_bytes(malformed), heap_str("hello"), "malformed+ascii"), + (heap_str("hello"), heap_bytes(malformed), "ascii+malformed"), + ] { + let result = js_string_concat_box(l, r); + let jsval = crate::value::JSValue::from_bits(result.to_bits()); + assert!( + jsval.is_string(), + "{malformed:?} {order}: both operands are real strings, \ + concat must not fall through to the dynamic-add arm" + ); + let ptr = jsval.as_string_ptr(); + assert!( + !ptr.is_null(), + "{malformed:?} {order}: empty-string sentinel unexpected here" + ); + let well_formed = crate::value::js_is_truthy(js_string_is_well_formed(ptr)); + assert_eq!( + well_formed, 0, + "{malformed:?} {order}: concat result must report isWellFormed() === false" + ); + } + } +} + /// #9391: the memo must stop PROBING when it stops paying. /// /// `bench_gc_pressure` builds half a million distinct `"item_" + i` strings. diff --git a/crates/perry-stdlib/Cargo.toml b/crates/perry-stdlib/Cargo.toml index 2881d64681..3dd3510220 100644 --- a/crates/perry-stdlib/Cargo.toml +++ b/crates/perry-stdlib/Cargo.toml @@ -229,10 +229,9 @@ bundled-mongodb = ["dep:mongodb", "dep:bson", "dep:futures-util", "async-runtime # bindings so the well-known flip (#466 Phase 4 step 2) can route them # to perry-ext-bcrypt / perry-ext-argon2 without taking the rest of # the crypto surface offline. -crypto = ["dep:sha2", "dep:sha1", "dep:sha3", "dep:shake", "dep:sha3_010", "dep:sha3-utils", "dep:rsa-sha1", "dep:md-5", "dep:hex", "dep:hmac", "dep:aes", "dep:aes_09", "dep:cbc", "dep:ecb", "dep:ctr", "dep:scrypt", "dep:pbkdf2", "dep:base64", "dep:x25519-dalek", "dep:x448", "dep:ed25519-dalek", "dep:ed448-goldilocks", "dep:aes-gcm", "dep:chacha20poly1305", "dep:ghash", "dep:aes-kw", "dep:hkdf", "dep:p256", "dep:p384", "dep:p521", "dep:x509-cert", "dep:ml-kem", "async-runtime", "ids", "bundled-bcrypt", "bundled-argon2", "bundled-jsonwebtoken", "bundled-ethers"] +crypto = ["dep:sha2", "dep:sha1", "dep:sha3", "dep:shake", "dep:sha3_010", "dep:sha3-utils", "dep:rsa-sha1", "dep:md-5", "dep:hex", "dep:hmac", "dep:aes", "dep:aes_09", "dep:cbc", "dep:ecb", "dep:ctr", "dep:scrypt", "dep:pbkdf2", "dep:base64", "dep:x25519-dalek", "dep:x448", "dep:ed25519-dalek", "dep:ed448-goldilocks", "dep:aes-gcm", "dep:chacha20poly1305", "dep:ghash", "dep:aes-kw", "dep:hkdf", "dep:p256", "dep:p384", "dep:p521", "dep:rsa", "dep:spki", "dep:x509-cert", "dep:ml-kem", "async-runtime", "ids", "bundled-bcrypt", "bundled-argon2", "bundled-ethers"] bundled-bcrypt = ["dep:bcrypt", "async-runtime"] bundled-argon2 = ["dep:argon2", "async-runtime"] -bundled-jsonwebtoken = ["dep:jsonwebtoken", "dep:p256", "dep:rsa", "dep:spki"] # ethers blockchain utilities — pure Rust, no extra deps. Default-on # through `crypto` umbrella; the well-known flip strips this and # routes to perry-ext-ethers when `import 'ethers'` is detected. @@ -388,7 +387,6 @@ md-5 = { version = "0.11", optional = true } hex = { workspace = true, optional = true } hmac = { version = "0.13", optional = true } bcrypt = { version = "0.19", optional = true } -jsonwebtoken = { workspace = true, optional = true } p256 = { version = "0.13", optional = true, default-features = false, features = ["pkcs8", "pem", "ecdsa", "ecdh"] } p384 = { version = "0.13", optional = true, default-features = false, features = ["pkcs8", "pem", "ecdsa", "ecdh"] } p521 = { version = "0.13", optional = true, default-features = false, features = ["pkcs8", "pem", "ecdsa", "ecdh"] } diff --git a/crates/perry-stdlib/src/jsonwebtoken.rs b/crates/perry-stdlib/src/jsonwebtoken.rs deleted file mode 100644 index 0bfb6dd0bd..0000000000 --- a/crates/perry-stdlib/src/jsonwebtoken.rs +++ /dev/null @@ -1,904 +0,0 @@ -//! JSON Web Token module (jsonwebtoken compatible) -//! -//! Native implementation of the 'jsonwebtoken' npm package. -//! Provides JWT sign, verify, and decode functionality. - -use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation}; -use perry_runtime::{ - js_object_get_field_by_name, js_string_from_bytes, ObjectHeader, StringHeader, -}; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; - -use crate::common::string_from_header; - -/// Generic claims structure that can hold any JSON -#[derive(Debug, Serialize, Deserialize)] -struct Claims { - #[serde(flatten)] - data: HashMap, - #[serde(skip_serializing_if = "Option::is_none")] - exp: Option, - #[serde(skip_serializing_if = "Option::is_none")] - iat: Option, - #[serde(skip_serializing_if = "Option::is_none")] - nbf: Option, - #[serde(skip_serializing_if = "Option::is_none")] - sub: Option, - #[serde(skip_serializing_if = "Option::is_none")] - iss: Option, - #[serde(skip_serializing_if = "Option::is_none")] - aud: Option, -} - -const STRING_TAG: u64 = 0x7FFF_0000_0000_0000; -const POINTER_MASK: u64 = 0x0000_FFFF_FFFF_FFFF; - -/// Shared signing logic — parse payload, apply expiry, encode with given algorithm/key. -/// `kid_ptr` is optional (null = no `kid` header field). Returns a NaN-boxed string i64, -/// or 0 on error. -unsafe fn sign_common( - payload_ptr: *const StringHeader, - expires_in_secs: f64, - algorithm: Algorithm, - key: &EncodingKey, - kid_ptr: *const StringHeader, -) -> i64 { - let payload_json = match string_from_header(payload_ptr) { - Some(p) => p, - None => return 0, - }; - - let mut claims: Claims = match serde_json::from_str(&payload_json) { - Ok(c) => c, - Err(_) => Claims { - data: HashMap::new(), - exp: None, - iat: None, - nbf: None, - sub: None, - iss: None, - aud: None, - }, - }; - - if expires_in_secs > 0.0 { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - claims.exp = Some(now + expires_in_secs as u64); - if claims.iat.is_none() { - claims.iat = Some(now); - } - } - - let mut header = Header::new(algorithm); - if !kid_ptr.is_null() { - if let Some(kid) = string_from_header(kid_ptr) { - if !kid.is_empty() { - header.kid = Some(kid); - } - } - } - - match encode(&header, &claims, key) { - Ok(token) => { - let ptr = js_string_from_bytes(token.as_ptr(), token.len() as u32); - (STRING_TAG | (ptr as u64 & POINTER_MASK)) as i64 - } - Err(_) => 0, - } -} - -/// Sign a payload to create a JWT (HS256) -/// jwt.sign(payload, secret) -> string -/// jwt.sign(payload, secret, options) -> string -/// -/// `kid_ptr` may be null when no `keyid` is provided in options. -#[no_mangle] -pub unsafe extern "C" fn js_jwt_sign( - payload_ptr: *const StringHeader, - secret_ptr: *const StringHeader, - expires_in_secs: f64, - kid_ptr: *const StringHeader, -) -> i64 { - let secret = match string_from_header(secret_ptr) { - Some(s) => s, - None => return 0, - }; - let key = EncodingKey::from_secret(secret.as_bytes()); - sign_common( - payload_ptr, - expires_in_secs, - Algorithm::HS256, - &key, - kid_ptr, - ) -} - -/// Sign a payload to create a JWT (ES256) -/// `pem_ptr` must contain a PKCS#8 PEM-encoded EC private key (P-256 curve). -/// jwt.sign(payload, ecPrivateKeyPem, { algorithm: 'ES256', keyid: '...' }) -> string -/// -/// Used by APNs (Apple Push Notification service) provider tokens — APNs requires -/// `kid` in the JWT header to identify which `.p8` key was used to sign. -#[no_mangle] -pub unsafe extern "C" fn js_jwt_sign_es256( - payload_ptr: *const StringHeader, - pem_ptr: *const StringHeader, - expires_in_secs: f64, - kid_ptr: *const StringHeader, -) -> i64 { - let pem = match string_from_header(pem_ptr) { - Some(p) => p, - None => return 0, - }; - // jsonwebtoken's `EncodingKey::from_ec_pem` only accepts PKCS#8 - // (`-----BEGIN PRIVATE KEY-----`). openssl's default - // `ecparam -genkey -name prime256v1` emits SEC1 - // (`-----BEGIN EC PRIVATE KEY-----`), which is the form most users - // start with. Convert SEC1 → PKCS#8 transparently so both PEM - // forms work. Same ergonomic story as the verify side's - // `ec_pem_to_public_pem` helper. - let pkcs8_pem = if pem.contains("EC PRIVATE KEY") { - use p256::pkcs8::EncodePrivateKey; - match p256::SecretKey::from_sec1_pem(&pem) - .ok() - .and_then(|k| k.to_pkcs8_pem(Default::default()).ok()) - { - Some(p) => p.to_string(), - None => { - eprintln!("[jwt-sign-es256] could not convert SEC1 EC PEM to PKCS#8"); - return 0; - } - } - } else { - pem - }; - let key = match EncodingKey::from_ec_pem(pkcs8_pem.as_bytes()) { - Ok(k) => k, - Err(e) => { - eprintln!("[jwt-sign-es256] invalid EC PEM key: {}", e); - return 0; - } - }; - sign_common( - payload_ptr, - expires_in_secs, - Algorithm::ES256, - &key, - kid_ptr, - ) -} - -/// Sign a payload to create a JWT (RS256) -/// `pem_ptr` must contain a PKCS#8 PEM-encoded RSA private key. -/// jwt.sign(payload, rsaPrivateKeyPem, { algorithm: 'RS256', keyid: '...' }) -> string -/// -/// Used by FCM (Firebase Cloud Messaging) OAuth assertions. -#[no_mangle] -pub unsafe extern "C" fn js_jwt_sign_rs256( - payload_ptr: *const StringHeader, - pem_ptr: *const StringHeader, - expires_in_secs: f64, - kid_ptr: *const StringHeader, -) -> i64 { - let pem = match string_from_header(pem_ptr) { - Some(p) => p, - None => return 0, - }; - let key = match EncodingKey::from_rsa_pem(pem.as_bytes()) { - Ok(k) => k, - Err(e) => { - eprintln!("[jwt-sign-rs256] invalid RSA PEM key: {}", e); - return 0; - } - }; - sign_common( - payload_ptr, - expires_in_secs, - Algorithm::RS256, - &key, - kid_ptr, - ) -} - -/// Dynamic-algorithm `jwt.sign` dispatcher (#1074). -/// -/// The codegen fast path in `lower_jsonwebtoken_sign` routes inline-literal -/// `{ algorithm: "ES256" }` to `js_jwt_sign_es256` / `…_rs256` at compile -/// time. When `algorithm` is anything else — a const-bound identifier -/// (`const ALG = "ES256"; jwt.sign(p, k, { algorithm: ALG })`), a property -/// spread, a ternary, etc. — the fast path falls through and previously -/// silently signed with HS256 keyed by the user's PEM (cryptographic -/// downgrade: the token is HMAC-signed with the PEM bytes, on-wire -/// `header.alg` reads `"HS256"`, so any verifier that accepts either -/// HMAC OR EC/RSA quietly accepted the downgrade). -/// -/// This entry point reads the algorithm name from `alg_ptr` at runtime and -/// dispatches to the same `sign_common` paths the typed helpers use. The -/// inline-literal fast path remains for the common case; everything else -/// goes through here. -#[no_mangle] -pub unsafe extern "C" fn js_jwt_sign_dyn( - alg_ptr: *const StringHeader, - payload_ptr: *const StringHeader, - secret_ptr: *const StringHeader, - expires_in_secs: f64, - kid_ptr: *const StringHeader, -) -> i64 { - let alg_name = string_from_header(alg_ptr).unwrap_or_else(|| "HS256".to_string()); - match alg_name.as_str() { - "ES256" => js_jwt_sign_es256(payload_ptr, secret_ptr, expires_in_secs, kid_ptr), - "RS256" => js_jwt_sign_rs256(payload_ptr, secret_ptr, expires_in_secs, kid_ptr), - "HS256" | "" => js_jwt_sign(payload_ptr, secret_ptr, expires_in_secs, kid_ptr), - other => { - // Unknown alg — treat as HS256 fallback (matches the legacy - // non-literal behavior) but log under PERRY_DEBUG so callers - // can diagnose. The header.alg will still say HS256, so the - // user's verifier rejects it properly — this is a safer - // failure mode than the pre-#1074 silent downgrade. - if std::env::var_os("PERRY_DEBUG").is_some() { - eprintln!( - "[jwt-sign-dyn] unknown algorithm `{}`; falling back to HS256", - other - ); - } - js_jwt_sign(payload_ptr, secret_ptr, expires_in_secs, kid_ptr) - } - } -} - -/// Coerce a NaN-boxed JSValue (`f64`) into a raw `*const ObjectHeader` -/// pointer. Mirrors the upper-bits sniff used by the native HTTP bindings. -/// Returns null when the value isn't pointer-shaped. -unsafe fn jsvalue_to_object_ptr(obj_f64: f64) -> *const ObjectHeader { - let obj_bits = obj_f64.to_bits(); - let upper = obj_bits >> 48; - if upper >= 0x7FF8 { - (obj_bits & 0x0000_FFFF_FFFF_FFFF) as *const ObjectHeader - } else if upper == 0 && obj_bits >= 0x10000 { - obj_bits as *const ObjectHeader - } else { - std::ptr::null() - } -} - -/// Read a named property off a NaN-boxed object value, returning its -/// string-typed result as a `*const StringHeader` (or null when missing/ -/// not-a-string). The field name is materialized as a transient -/// `*const StringHeader` because that's `js_object_get_field_by_name`'s -/// signature. -unsafe fn opts_get_string_field(obj_f64: f64, field: &str) -> *const StringHeader { - let obj_ptr = jsvalue_to_object_ptr(obj_f64); - if obj_ptr.is_null() { - return std::ptr::null(); - } - let key = js_string_from_bytes(field.as_ptr(), field.len() as u32); - let val = js_object_get_field_by_name(obj_ptr, key); - if val.is_undefined() || val.is_null() { - return std::ptr::null(); - } - if val.is_string() { - return val.as_string_ptr(); - } - std::ptr::null() -} - -/// Read a named property as f64. Returns 0.0 when missing/non-numeric -/// (matches `lower_jsonwebtoken_sign`'s `expires_in = double_literal(0.0)` -/// default). -unsafe fn opts_get_number_field(obj_f64: f64, field: &str) -> f64 { - let obj_ptr = jsvalue_to_object_ptr(obj_f64); - if obj_ptr.is_null() { - return 0.0; - } - let key = js_string_from_bytes(field.as_ptr(), field.len() as u32); - let val = js_object_get_field_by_name(obj_ptr, key); - if val.is_undefined() || val.is_null() { - return 0.0; - } - if val.is_number() { - return val.as_number(); - } - 0.0 -} - -/// `jwt.sign(payload, secret, options)` where `options` is a non-extractable -/// expression (e.g. `const opts = { algorithm: "ES256", ... }; jwt.sign(p, k, opts)`) -/// — #1074 case C. The codegen lowers `opts` as a NaN-boxed JSValue and we -/// extract `algorithm` / `expiresIn` / `keyid` at runtime, then defer to -/// `js_jwt_sign_dyn`. Reads each option via `js_object_get_field_by_name` -/// (which works for ordinary `Expr::Object` literals → `__AnonShape_*` class -/// instances). -#[no_mangle] -pub unsafe extern "C" fn js_jwt_sign_dyn_opts( - payload_ptr: *const StringHeader, - secret_ptr: *const StringHeader, - options_value: f64, -) -> i64 { - let alg_ptr = opts_get_string_field(options_value, "algorithm"); - // `keyid` is the spec-correct field name; `kid` is accepted as an alias - // (matches the inline-literal codegen path which special-cases both). - let kid_ptr = { - let p = opts_get_string_field(options_value, "keyid"); - if p.is_null() { - opts_get_string_field(options_value, "kid") - } else { - p - } - }; - let expires_in = opts_get_number_field(options_value, "expiresIn"); - js_jwt_sign_dyn(alg_ptr, payload_ptr, secret_ptr, expires_in, kid_ptr) -} - -/// Shared verify path — runs the decode + returns claims as JSON, or -/// a null pointer on any failure. `debug` mirrors the gating in -/// `js_jwt_verify` (perry#924) so all three verify entry points emit -/// the same `[jwt-verify]` log lines under `PERRY_DEBUG=1`. -unsafe fn verify_decode( - token: &str, - key: &DecodingKey, - algorithm: Algorithm, - debug: bool, -) -> *mut StringHeader { - let mut validation = Validation::new(algorithm); - // Match Node's `jsonwebtoken`: validate the `exp` claim whenever it is - // present (so expired tokens are rejected), but do not *require* exp — a - // token that legitimately omits expiry still verifies. `required_spec_claims` - // stays empty for the latter; `validate_exp = true` enforces the former. - // - // This previously read `validate_exp = false`, which disabled expiry - // enforcement for every JWT verification path in the stdlib — expired - // tokens were accepted indefinitely (GHSA-5324-c68v-8w62 / CVE-2026-53777). - validation.required_spec_claims = std::collections::HashSet::new(); - validation.validate_exp = true; - - match decode::(token, key, &validation) { - Ok(token_data) => { - let json = - serde_json::to_string(&token_data.claims).unwrap_or_else(|_| "{}".to_string()); - if debug { - eprintln!( - "[jwt-verify] success, claims={}", - &json[..json.len().min(80)] - ); - } - js_string_from_bytes(json.as_ptr(), json.len() as u32) - } - Err(e) => { - if debug { - eprintln!("[jwt-verify] error: {}", e); - } - std::ptr::null_mut() - } - } -} - -/// Verify and decode an HS256 JWT -/// jwt.verify(token, secret) -> object (payload) -#[no_mangle] -pub unsafe extern "C" fn js_jwt_verify( - token_ptr: *const StringHeader, - secret_ptr: *const StringHeader, -) -> *mut StringHeader { - // perry#924: all `[jwt-verify]` eprintln!s are gated behind - // `PERRY_DEBUG=1`. Authenticated production services call - // `jwt.verify` per request, so the previous unconditional logging - // (token length + secret length + claims/error) flooded stderr and - // also leaked the secret length, narrowing the cracking surface - // when paired with a known JWT structure. The application layer - // already logs 401s at a useful granularity. - let debug = std::env::var_os("PERRY_DEBUG").is_some(); - - let token = match string_from_header(token_ptr) { - Some(t) => t, - None => { - if debug { - eprintln!("[jwt-verify] token_ptr is null or invalid"); - } - return std::ptr::null_mut(); - } - }; - - let secret = match string_from_header(secret_ptr) { - Some(s) => s, - None => { - if debug { - eprintln!("[jwt-verify] secret_ptr is null or invalid"); - } - return std::ptr::null_mut(); - } - }; - - let key = DecodingKey::from_secret(secret.as_bytes()); - verify_decode(&token, &key, Algorithm::HS256, debug) -} - -/// Coerce an EC PEM (public *or* private, SEC1 or PKCS#8) into a -/// PKCS#8 PUBLIC KEY PEM that `DecodingKey::from_ec_pem` accepts. -/// Mirrors Node's `jsonwebtoken` ergonomics: the user can pass the -/// same PEM to `sign` and `verify` without having to extract the -/// public key separately. perry#927 follow-up — without this, ES256 -/// `verify` rejected the very PEM the matching `sign` accepted, -/// breaking the shop-admin auth path even after the JSON-parse -/// return-shape fix. -fn ec_pem_to_public_pem(pem: &str) -> Option { - use p256::pkcs8::{DecodePrivateKey, EncodePublicKey}; - - if pem.contains("PUBLIC KEY") { - return Some(pem.to_string()); - } - - // Try PKCS#8 private (`-----BEGIN PRIVATE KEY-----`) first, - // then SEC1 (`-----BEGIN EC PRIVATE KEY-----`). - let secret = p256::SecretKey::from_pkcs8_pem(pem) - .or_else(|_| p256::SecretKey::from_sec1_pem(pem)) - .ok()?; - secret - .public_key() - .to_public_key_pem(Default::default()) - .ok() -} - -/// Verify and decode an ES256 JWT. -/// `pem_ptr` may contain either a PUBLIC key PEM (SPKI) or the -/// matching PRIVATE key PEM (PKCS#8 or SEC1) — the latter is -/// auto-converted via `ec_pem_to_public_pem` so callers can reuse -/// their signing key. -/// jwt.verify(token, pem, { algorithms: ['ES256'] }) -> object -#[no_mangle] -pub unsafe extern "C" fn js_jwt_verify_es256( - token_ptr: *const StringHeader, - pem_ptr: *const StringHeader, -) -> *mut StringHeader { - let debug = std::env::var_os("PERRY_DEBUG").is_some(); - - let token = match string_from_header(token_ptr) { - Some(t) => t, - None => { - if debug { - eprintln!("[jwt-verify-es256] token_ptr is null or invalid"); - } - return std::ptr::null_mut(); - } - }; - - let pem = match string_from_header(pem_ptr) { - Some(p) => p, - None => { - if debug { - eprintln!("[jwt-verify-es256] pem_ptr is null or invalid"); - } - return std::ptr::null_mut(); - } - }; - - let public_pem = match ec_pem_to_public_pem(&pem) { - Some(p) => p, - None => { - if debug { - eprintln!("[jwt-verify-es256] could not derive EC public key from PEM"); - } - return std::ptr::null_mut(); - } - }; - - let key = match DecodingKey::from_ec_pem(public_pem.as_bytes()) { - Ok(k) => k, - Err(e) => { - if debug { - eprintln!("[jwt-verify-es256] invalid EC PEM key: {}", e); - } - return std::ptr::null_mut(); - } - }; - - verify_decode(&token, &key, Algorithm::ES256, debug) -} - -/// Coerce an RSA PEM (public *or* private, PKCS#1 or PKCS#8) into a -/// PEM that `DecodingKey::from_rsa_pem` accepts. Matches Node's -/// `jsonwebtoken` behavior of accepting either side of the keypair -/// on verify. -fn rsa_pem_to_public_pem(pem: &str) -> Option { - use rsa::pkcs1::EncodeRsaPublicKey; - use rsa::pkcs8::{DecodePrivateKey, EncodePublicKey}; - - if pem.contains("PUBLIC KEY") { - // Either PKCS#1 `RSA PUBLIC KEY` or PKCS#8 `PUBLIC KEY` — - // both consumed directly by `DecodingKey::from_rsa_pem`. - return Some(pem.to_string()); - } - - // Try PKCS#8 (`-----BEGIN PRIVATE KEY-----`) then PKCS#1 - // (`-----BEGIN RSA PRIVATE KEY-----`). - let priv_key = rsa::RsaPrivateKey::from_pkcs8_pem(pem) - .or_else(|_| { - use rsa::pkcs1::DecodeRsaPrivateKey; - rsa::RsaPrivateKey::from_pkcs1_pem(pem) - }) - .ok()?; - let pub_key = priv_key.to_public_key(); - pub_key - .to_public_key_pem(Default::default()) - .ok() - .or_else(|| pub_key.to_pkcs1_pem(Default::default()).ok()) -} - -/// Verify and decode an RS256 JWT. -/// `pem_ptr` may contain either a PUBLIC key PEM (PKCS#1 or PKCS#8) -/// or the matching PRIVATE key PEM (auto-converted via -/// `rsa_pem_to_public_pem`). -/// jwt.verify(token, pem, { algorithms: ['RS256'] }) -> object -#[no_mangle] -pub unsafe extern "C" fn js_jwt_verify_rs256( - token_ptr: *const StringHeader, - pem_ptr: *const StringHeader, -) -> *mut StringHeader { - let debug = std::env::var_os("PERRY_DEBUG").is_some(); - - let token = match string_from_header(token_ptr) { - Some(t) => t, - None => { - if debug { - eprintln!("[jwt-verify-rs256] token_ptr is null or invalid"); - } - return std::ptr::null_mut(); - } - }; - - let pem = match string_from_header(pem_ptr) { - Some(p) => p, - None => { - if debug { - eprintln!("[jwt-verify-rs256] pem_ptr is null or invalid"); - } - return std::ptr::null_mut(); - } - }; - - let public_pem = match rsa_pem_to_public_pem(&pem) { - Some(p) => p, - None => { - if debug { - eprintln!("[jwt-verify-rs256] could not derive RSA public key from PEM"); - } - return std::ptr::null_mut(); - } - }; - - let key = match DecodingKey::from_rsa_pem(public_pem.as_bytes()) { - Ok(k) => k, - Err(e) => { - if debug { - eprintln!("[jwt-verify-rs256] invalid RSA PEM key: {}", e); - } - return std::ptr::null_mut(); - } - }; - - verify_decode(&token, &key, Algorithm::RS256, debug) -} - -/// Dynamic-algorithm `jwt.verify` dispatcher (#1074). -/// -/// Mirrors `js_jwt_sign_dyn`. The codegen fast path resolves -/// `algorithms: ["ES256"]` to `js_jwt_verify_es256` at compile time; -/// const-ref or computed shapes fell through to `js_jwt_verify` (HS256) -/// and silently rejected ES/RS tokens. This entry point reads the -/// algorithm name from `alg_ptr` at runtime and dispatches. -#[no_mangle] -pub unsafe extern "C" fn js_jwt_verify_dyn( - alg_ptr: *const StringHeader, - token_ptr: *const StringHeader, - secret_ptr: *const StringHeader, -) -> *mut StringHeader { - let alg_name = string_from_header(alg_ptr).unwrap_or_else(|| "HS256".to_string()); - match alg_name.as_str() { - "ES256" => js_jwt_verify_es256(token_ptr, secret_ptr), - "RS256" => js_jwt_verify_rs256(token_ptr, secret_ptr), - "HS256" | "" => js_jwt_verify(token_ptr, secret_ptr), - other => { - if std::env::var_os("PERRY_DEBUG").is_some() { - eprintln!( - "[jwt-verify-dyn] unknown algorithm `{}`; falling back to HS256", - other - ); - } - js_jwt_verify(token_ptr, secret_ptr) - } - } -} - -/// `jwt.verify(token, secret, options)` where `options` is a non-extractable -/// expression (case C, #1074). Extract `algorithm` (singular) or the first -/// entry of `algorithms` (plural array) at runtime and defer to -/// `js_jwt_verify_dyn`. The plural-array first-entry rule mirrors the -/// compile-time fast path in `lower_jsonwebtoken_verify` — the underlying -/// `jsonwebtoken` crate verifies against one algorithm at a time, so we -/// pick the first. -#[no_mangle] -pub unsafe extern "C" fn js_jwt_verify_dyn_opts( - token_ptr: *const StringHeader, - secret_ptr: *const StringHeader, - options_value: f64, -) -> *mut StringHeader { - // Try singular `algorithm: "..."` first. - let mut alg_ptr = opts_get_string_field(options_value, "algorithm"); - // Then plural `algorithms: ["..."]`. Read the field, then index [0] - // through `js_array_get_f64` to mirror the compile-time fast path. - if alg_ptr.is_null() { - let obj_ptr = jsvalue_to_object_ptr(options_value); - if !obj_ptr.is_null() { - let key = js_string_from_bytes("algorithms".as_ptr(), "algorithms".len() as u32); - let arr_val = js_object_get_field_by_name(obj_ptr, key); - // Array is pointer-tagged in NaN-boxing; extract pointer if - // present. We reuse the existing array_get_f64 entry point - // because it's the most-tested path for array.[i] reads. - if !arr_val.is_undefined() && !arr_val.is_null() { - // The array NaN-box is POINTER_TAG-shaped just like an - // object — strip the upper bits to recover the raw - // ArrayHeader*. `js_array_get_f64` does its own tag - // strip too, but we already have an authoritative - // pointer here so just pass it through. - let arr_bits = arr_val.bits(); - let arr_ptr = - (arr_bits & 0x0000_FFFF_FFFF_FFFF) as *const perry_runtime::ArrayHeader; - if !arr_ptr.is_null() { - let first_jsval = perry_runtime::js_array_get(arr_ptr, 0); - if first_jsval.is_string() { - alg_ptr = first_jsval.as_string_ptr(); - } - } - } - } - } - js_jwt_verify_dyn(alg_ptr, token_ptr, secret_ptr) -} - -/// Decode a JWT without verification (just parse the payload) -/// jwt.decode(token) -> object (payload) -#[no_mangle] -pub unsafe extern "C" fn js_jwt_decode(token_ptr: *const StringHeader) -> *mut StringHeader { - let token = match string_from_header(token_ptr) { - Some(t) => t, - None => return std::ptr::null_mut(), - }; - - // Split the token into parts - let parts: Vec<&str> = token.split('.').collect(); - if parts.len() != 3 { - return std::ptr::null_mut(); - } - - // Decode the payload (second part) - use base64::Engine; - let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD; - - match engine.decode(parts[1]) { - Ok(payload_bytes) => { - match String::from_utf8(payload_bytes) { - Ok(payload_json) => { - // Validate it's valid JSON and return it - if serde_json::from_str::(&payload_json).is_ok() { - js_string_from_bytes(payload_json.as_ptr(), payload_json.len() as u32) - } else { - std::ptr::null_mut() - } - } - Err(_) => std::ptr::null_mut(), - } - } - Err(_) => std::ptr::null_mut(), - } -} - -#[cfg(all(test, unix))] -mod tests { - //! perry#924 regression tests — `jwt.verify` MUST be silent on the - //! happy path. We exercise the real `js_jwt_verify` FFI in a - //! subprocess (spawning the current test binary with a sentinel - //! env var) because cargo-test's harness installs a Rust-level - //! stderr capture that intercepts `eprintln!` before fd 2, making - //! in-process `dup2`-style capture vacuously pass. Subprocess - //! stderr is unaffected and gives us a real byte stream to count - //! lines against. - //! - //! Before the fix: - //! • valid token: 3 stderr lines (`token_len=…` + `success, claims=…`) - //! • invalid token: 2 stderr lines (`token_len=…` + `error: …`) - //! After the fix (no `PERRY_DEBUG`): - //! • valid token: 0 stderr lines - //! • invalid token: 0 stderr lines - //! With `PERRY_DEBUG=1`: original verbose output is restored. - use super::*; - use perry_runtime::js_string_from_bytes; - use std::process::{Command, Stdio}; - - /// Sentinel env var: when set, the targeted helper test runs the - /// FFI in this process (which is a subprocess of the real test) - /// and exits so the subprocess produces a clean, uncaptured - /// stderr stream for the parent test to inspect. Spawning is done - /// via `--exact …::__perry_924_helper --nocapture --quiet` so - /// only the helper test runs and harness stderr capture is off. - const HELPER_ENV: &str = "PERRY_924_HELPER"; - - /// Hidden helper test — invoked by the real tests via subprocess. - /// When `PERRY_924_HELPER` is set, exec the requested FFI scenario - /// and exit. Otherwise no-op (so a normal `cargo test` run just - /// records this as a trivially-passing test). - #[test] - fn __perry_924_helper() { - let Ok(mode) = std::env::var(HELPER_ENV) else { - return; - }; - unsafe { run_helper(&mode) }; - std::process::exit(0); - } - - unsafe fn run_helper(mode: &str) { - unsafe fn mk(s: &str) -> *mut StringHeader { - js_string_from_bytes(s.as_ptr(), s.len() as u32) - } - - match mode { - "valid" => { - // Mint a real HS256 token, then verify it. Success - // path → must not eprintln (unless PERRY_DEBUG set - // by parent). - let payload = mk(r#"{"sub":"1234","name":"Alice"}"#); - let secret = mk("supersecret"); - let token_bits = js_jwt_sign( - payload as *const _, - secret as *const _, - 0.0, - std::ptr::null(), - ); - assert_ne!(token_bits, 0); - let raw = (token_bits as u64 & POINTER_MASK) as *mut StringHeader; - let len = (*raw).byte_len as usize; - let data_ptr = (raw as *const u8).add(std::mem::size_of::()); - let token_bytes = std::slice::from_raw_parts(data_ptr, len); - let token_str = std::str::from_utf8(token_bytes).unwrap().to_string(); - - let token = mk(&token_str); - let secret2 = mk("supersecret"); - let result = js_jwt_verify(token as *const _, secret2 as *const _); - assert!(!result.is_null(), "verify must succeed on a valid token"); - } - "invalid" => { - // Garbage input → verify must fail silently (no log - // unless PERRY_DEBUG set). - let token = mk("not-a-jwt"); - let secret = mk("supersecret"); - let result = js_jwt_verify(token as *const _, secret as *const _); - assert!(result.is_null(), "verify must fail on garbage"); - } - other => panic!("unknown helper mode: {}", other), - } - } - - fn spawn_helper(mode: &str, debug: bool) -> std::process::Output { - let exe = std::env::current_exe().expect("current_exe"); - let mut cmd = Command::new(exe); - cmd.arg("--exact") - .arg("jsonwebtoken::tests::__perry_924_helper") - .arg("--nocapture") - .arg("--quiet") - .env(HELPER_ENV, mode) - .env_remove("PERRY_DEBUG") - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - if debug { - cmd.env("PERRY_DEBUG", "1"); - } - cmd.output().expect("spawn helper") - } - - #[test] - fn verify_valid_token_is_silent() { - let out = spawn_helper("valid", false); - assert!(out.status.success(), "helper exited non-zero: {:?}", out); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.is_empty(), - "jwt.verify on a valid token must not log to stderr (perry#924); got: {:?}", - stderr - ); - } - - #[test] - fn verify_invalid_token_is_silent() { - let out = spawn_helper("invalid", false); - assert!(out.status.success(), "helper exited non-zero: {:?}", out); - let stderr = String::from_utf8_lossy(&out.stderr); - // Application code (e.g. authMiddleware) already logs the - // 401 — stdlib must not duplicate. One line maximum if we - // ever decide a single error-class summary is worth it. - let lines = stderr.lines().count(); - assert!( - lines == 0, - "jwt.verify on invalid input must be silent (perry#924), got {} lines: {:?}", - lines, - stderr - ); - assert!( - !stderr.contains("[jwt-verify]"), - "no `[jwt-verify]` line may appear without PERRY_DEBUG; got: {:?}", - stderr - ); - } - - #[test] - fn verify_logs_under_perry_debug() { - let out = spawn_helper("valid", true); - assert!(out.status.success(), "helper exited non-zero: {:?}", out); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains("[jwt-verify] success"), - "PERRY_DEBUG=1 must restore verbose logging; got: {:?}", - stderr - ); - } - - // --- GHSA-5324-c68v-8w62 / CVE-2026-53777: exp must be enforced --- - - fn now_secs() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() - } - - fn hs256_token(claims: serde_json::Value, secret: &[u8]) -> String { - encode( - &Header::new(Algorithm::HS256), - &claims, - &EncodingKey::from_secret(secret), - ) - .unwrap() - } - - #[test] - fn expired_token_is_rejected() { - let secret = b"supersecret"; - let token = hs256_token( - serde_json::json!({ "sub": "user123", "exp": now_secs() - 3600 }), - secret, - ); - let key = DecodingKey::from_secret(secret); - unsafe { - let r = verify_decode(&token, &key, Algorithm::HS256, false); - assert!(r.is_null(), "expired token must be rejected by jwt.verify"); - } - } - - #[test] - fn unexpired_token_is_accepted() { - let secret = b"supersecret"; - let token = hs256_token( - serde_json::json!({ "sub": "user123", "exp": now_secs() + 3600 }), - secret, - ); - let key = DecodingKey::from_secret(secret); - unsafe { - let r = verify_decode(&token, &key, Algorithm::HS256, false); - assert!(!r.is_null(), "valid, unexpired token must be accepted"); - } - } - - #[test] - fn token_without_exp_is_still_accepted() { - // Node's jsonwebtoken does not *require* exp; a token that omits it - // verifies. We must not regress that while enforcing exp-if-present. - let secret = b"supersecret"; - let token = hs256_token(serde_json::json!({ "sub": "user123" }), secret); - let key = DecodingKey::from_secret(secret); - unsafe { - let r = verify_decode(&token, &key, Algorithm::HS256, false); - assert!(!r.is_null(), "token without exp claim must still verify"); - } - } -} diff --git a/crates/perry-stdlib/src/lib.rs b/crates/perry-stdlib/src/lib.rs index 1c9346d525..1a2970af77 100644 --- a/crates/perry-stdlib/src/lib.rs +++ b/crates/perry-stdlib/src/lib.rs @@ -340,14 +340,9 @@ pub mod argon2; #[cfg(feature = "bundled-argon2")] pub use argon2::*; -// jsonwebtoken split out into `bundled-jsonwebtoken` (v0.5.538) // for the same reason as bcrypt/argon2 — well-known flip // independence. The `crypto` umbrella still pulls it in for // backwards compat. -#[cfg(feature = "bundled-jsonwebtoken")] -pub mod jsonwebtoken; -#[cfg(feature = "bundled-jsonwebtoken")] -pub use jsonwebtoken::*; #[cfg(feature = "crypto")] pub mod crypto_e2e; diff --git a/crates/perry-ui-android/src/stdlib_stubs.rs b/crates/perry-ui-android/src/stdlib_stubs.rs index 6e061442a9..4351918c1f 100644 --- a/crates/perry-ui-android/src/stdlib_stubs.rs +++ b/crates/perry-ui-android/src/stdlib_stubs.rs @@ -917,26 +917,6 @@ pub extern "C" fn js_ioredis_setex() -> i64 { } // js_json_* — real implementations in json.rs #[no_mangle] -pub extern "C" fn js_jwt_decode() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_jwt_sign() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_jwt_sign_es256() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_jwt_sign_rs256() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_jwt_verify() -> i64 { - 0 -} -#[no_mangle] pub extern "C" fn js_lodash_camel_case() -> i64 { 0 } diff --git a/crates/perry/src/commands/compile/cjs_wrap/extract_requires.rs b/crates/perry/src/commands/compile/cjs_wrap/extract_requires.rs index cdcb336a1b..5e7b061c15 100644 --- a/crates/perry/src/commands/compile/cjs_wrap/extract_requires.rs +++ b/crates/perry/src/commands/compile/cjs_wrap/extract_requires.rs @@ -293,19 +293,40 @@ pub fn identifier_is_declared_binding(source: &str, name: &str) -> bool { false } -/// Next.js lazy-require classification (single forward pass). Returns the set -/// of specifiers whose EVERY `require('')` call site is lexically inside -/// a FUNCTION body — never at module top level, and never inside a top-level -/// control-flow block that runs at module load. Node loads such a module -/// lazily (only when the enclosing function runs), so Perry must not eager-init -/// it. +/// Deferred-require classification (single forward pass). Returns the set of +/// specifiers whose EVERY `require('')` call site is NOT guaranteed to +/// run the moment the module loads — a function body (never called, or called +/// later: #Next.js lazy-require), a control-flow block that may not run every +/// time its enclosing scope runs (`if`/`for`/`while`/`switch`/`catch`/`with`/ +/// `else`/`try`/`do`/`finally`), or a braceless/operator-guarded equivalent of +/// the same thing (`cond && require(...)`, `cond ? require(...) : x`, `for +/// (...) require(...)` with no block). Node only ever loads such a module when +/// control flow actually reaches the call, so Perry must not eager-init it +/// either (issue #10437: `pg` guards its optional `pg-native` binding exactly +/// this way, behind `if (forceNative) { require('./native') }`). /// -/// Conservative by construction: a spec with any top-level call site (including -/// top-level `if`/`for`/`try` blocks, which execute during module evaluation) -/// is excluded and keeps the default eager behavior. A misclassification is -/// self-correcting at runtime — the require shim triggers the target's init -/// when `require()` is actually called — so this only governs eager-init-loop -/// membership. +/// An ordinary object literal (`{ key: require(...) }`), a class body, or a +/// bare grouping block do NOT count — their contents run unconditionally +/// whenever the enclosing statement/expression is reached, same as top level, +/// so nesting inside one of those must not flip a spec to lazy (that would be +/// the common `module.exports = { fs: require('fs'), path: require('path') }` +/// barrel-export shape, which really is eager). +/// +/// The ternary ALTERNATE arm (`cond ? x : require(...)`) is deliberately NOT +/// matched — a bare `:` immediately before `require(` is indistinguishable +/// from an object-literal property value or a `switch` case label without a +/// real parse, and guessing wrong there risks the same barrel-export +/// misclassification the object-literal exclusion above avoids. That shape +/// keeps the conservative eager default (a known, narrow gap — not in scope +/// for #10437's reproduction). +/// +/// A false POSITIVE here (treating a genuinely-unconditional require as +/// conditional) is harmless: the require shim still triggers the target's +/// init at the exact point the call is lexically reached, which for an +/// unconditional call is essentially the same moment eager pre-init would +/// have run it. A false NEGATIVE (missing a genuinely-conditional call) is +/// the actual bug class — the target loads (and can throw) before its +/// guarding condition was ever evaluated. /// /// Brace/paren scanning runs on a comment/string/regex-masked copy (same /// length, code structure preserved) so literal braces never corrupt the scope @@ -337,30 +358,65 @@ pub fn function_local_specs(source: &str) -> std::collections::HashSet { return HashSet::new(); } + // #10437 followup: a spec whose ONLY conditionality is a + // `process.platform === /!== ''` if/else guard (either branch — + // e.g. node-pty's `./windowsTerminal` / `./unixTerminal` split) must NOT + // be downgraded to lazy by the broader control-flow classification below. + // The platform is a build TARGET resolved at compile time, not a runtime + // unknown — `wrap_commonjs_for_target`'s `inactive_platform_guarded_requires` + // already prunes the dead branch's spec outright for a known target, and + // the live branch's spec keeps the eager `_req_N` classification it had + // before this fix. Treating a compile-time-resolved platform check as + // conditional the way a genuinely runtime-unknown check (env var, + // arbitrary function result) is would only add needless deferral, not + // fix a bug — #10437 is about conditions Perry cannot resolve at compile + // time. + let platform_guarded_specs = process_platform_guarded_specs(source); + let mbytes = masked.as_bytes(); let is_ident = |c: u8| c == b'_' || c == b'$' || c.is_ascii_alphanumeric(); let control_keywords = ["if", "for", "while", "switch", "catch", "with", "else"]; + // Bare-keyword control blocks with no parens (`try {`, `else {`, `do {`, + // `} finally {`) — as opposed to an object literal / class body / plain + // grouping block, whose opening `{` is also not preceded by `)`/`=>` but + // whose contents are NOT conditional (see doc comment above). + let bare_control_keywords = ["try", "else", "do", "finally"]; #[derive(PartialEq)] enum Scope { + /// Function/method/arrow/IIFE body: reachability depends on whether, + /// and when, the function is ever called. Function, + /// A control-flow block that may not run every time its enclosing + /// scope runs. Block, + /// Anything else brace-delimited whose contents run unconditionally + /// when reached (object literal, class body, bare grouping block). + /// Nesting here does not itself make an enclosed `require()` + /// conditional. + Other, } let mut scopes: Vec = Vec::new(); - // spec → (seen any site, all sites so far in-function). + // spec → (seen any site, all sites so far conditionally-reached). let mut state: HashMap<&str, (bool, bool)> = HashMap::new(); let mut next_site = 0usize; - let in_function = |scopes: &[Scope]| scopes.contains(&Scope::Function); + let gates_reachability = |scopes: &[Scope]| { + scopes + .iter() + .any(|s| matches!(s, Scope::Function | Scope::Block)) + }; let mut i = 0usize; while i < mbytes.len() { // Record any require site at this offset before processing the char. while next_site < sites.len() && sites[next_site].0 == i { let (_, spec) = sites[next_site]; - let here = in_function(&scopes); + let conditional = !platform_guarded_specs.contains(spec) + && (gates_reachability(&scopes) + || site_is_conditionally_guarded(&masked, mbytes, i, &is_ident)); let e = state.entry(spec).or_insert((false, true)); e.0 = true; - e.1 = e.1 && here; + e.1 = e.1 && conditional; next_site += 1; } match mbytes[i] { @@ -381,7 +437,18 @@ pub fn function_local_specs(source: &str) -> std::collections::HashSet { Scope::Function } } else { - Scope::Block + // Not preceded by `)` or `=>`: a bare control keyword + // (`try`/`else`/`do`/`finally`) is conditional; an object + // literal, class body, or plain grouping block is not. + let mut w = p; + while w > 0 && is_ident(mbytes[w - 1]) { + w -= 1; + } + if bare_control_keywords.iter().any(|k| *k == &masked[w..p]) { + Scope::Block + } else { + Scope::Other + } }; scopes.push(kind); } @@ -395,16 +462,19 @@ pub fn function_local_specs(source: &str) -> std::collections::HashSet { // Any sites at EOF offset (defensive). while next_site < sites.len() { let (_, spec) = sites[next_site]; + let conditional = !platform_guarded_specs.contains(spec) + && (gates_reachability(&scopes) + || site_is_conditionally_guarded(&masked, mbytes, mbytes.len(), &is_ident)); let e = state.entry(spec).or_insert((false, true)); e.0 = true; - e.1 = e.1 && in_function(&scopes); + e.1 = e.1 && conditional; next_site += 1; } state .into_iter() - .filter_map(|(spec, (seen, all_in_fn))| { - if seen && all_in_fn { + .filter_map(|(spec, (seen, all_conditional))| { + if seen && all_conditional { Some(spec.to_string()) } else { None @@ -413,6 +483,77 @@ pub fn function_local_specs(source: &str) -> std::collections::HashSet { .collect() } +/// Is the `require(` call whose match starts at masked-source offset +/// `call_start` reached only conditionally by a nearby operator or a +/// braceless control-flow header, even though it has no enclosing `{ }` +/// scope of its own? Brace-scope tracking (above) can't see these shapes: +/// `cond && require(...)` / `cond || require(...)` / `cond ?? require(...)`, +/// the ternary CONSEQUENT arm `cond ? require(...) : x`, a braceless arrow +/// `() => require(...)`, and a braceless control-flow body — `if (...) +/// require(...)`, `for (...) require(...)`, `while (...) require(...)`, +/// `else require(...)`, `do require(...)`. +fn site_is_conditionally_guarded( + masked: &str, + mbytes: &[u8], + call_start: usize, + is_ident: &impl Fn(u8) -> bool, +) -> bool { + let mut p = call_start; + while p > 0 && (mbytes[p - 1] as char).is_whitespace() { + p -= 1; + } + if p == 0 { + return false; + } + if p >= 2 { + let two = &masked[p - 2..p]; + if two == "&&" || two == "||" || two == "??" || two == "=>" { + return true; + } + } + // Ternary consequent (`cond ? require(...) : x`) — a lone `?`, not the + // second char of `??` (already handled above). + if mbytes[p - 1] == b'?' && !(p >= 2 && mbytes[p - 2] == b'?') { + return true; + } + // Braceless control-flow header: `if (...)`, `for (...)`, `while (...)` + // immediately followed by the require call (no block). + if mbytes[p - 1] == b')' { + let head = matched_open_head(masked, mbytes, p - 1, is_ident); + return matches!(head.as_str(), "if" | "for" | "while"); + } + // Bare `else`/`do` immediately before, with no parens and no block. + let mut w = p; + while w > 0 && is_ident(mbytes[w - 1]) { + w -= 1; + } + matches!(&masked[w..p], "else" | "do") +} + +/// Every `require('')` specifier textually inside EITHER branch of a +/// `if (process.platform === /!== '') { … } else { … }` guard. +/// Mirrors the pattern `wrap.rs`'s `inactive_platform_guarded_requires` +/// matches to prune the DEAD branch's spec for a known build target — this +/// helper is target-independent and returns BOTH branches' specs, so the +/// LIVE branch's spec (which `inactive_platform_guarded_requires` keeps) can +/// be exempted from the general conditional-require classification above. +fn process_platform_guarded_specs(source: &str) -> std::collections::HashSet { + let re = perry_perex::tooling::Regex::new( + r#"(?s)if\s*\(\s*process\.platform\s*(?:===|!==)\s*['"][^'"]+['"]\s*\)\s*\{(?P.*?)\}\s*else\s*\{(?P.*?)\}"#, + ) + .unwrap(); + let mut specs = std::collections::HashSet::new(); + for cap in re.captures_iter(source) { + if let Some(then) = cap.name("then") { + specs.extend(extract_require_specifiers(then.as_str())); + } + if let Some(els) = cap.name("else") { + specs.extend(extract_require_specifiers(els.as_str())); + } + } + specs +} + /// Given the index of a `)` in the masked source, walk back to its matching /// `(` and return the identifier/keyword immediately before that `(`. fn matched_open_head( diff --git a/crates/perry/src/commands/compile/collect_modules.rs b/crates/perry/src/commands/compile/collect_modules.rs index 320eb65545..a179e14cc5 100644 --- a/crates/perry/src/commands/compile/collect_modules.rs +++ b/crates/perry/src/commands/compile/collect_modules.rs @@ -1777,15 +1777,19 @@ fn collect_module_one( } } - // Next.js lazy-require: the CJS→ESM wrap names a binding `_lazyreq_N` when - // every `require('S')` call site is inside a function body (lazy in Node). - // Tag the import so `classify_eager_modules` leaves the target Deferred — - // matching Node, which only loads such a module when the enclosing function - // runs (e.g. jsonwebtoken, required only inside Next.js's request handlers). - // The require shim triggers the target's `__init` on first `require()`, so - // an over-eager classification is self-correcting at runtime. Limited to - // Perry-compiled (`NativeCompiled`) targets — native stdlib / V8 modules - // have their own init paths. + // Deferred require (#10437, originally the Next.js lazy-require case): the + // CJS→ESM wrap names a binding `_lazyreq_N` when every `require('S')` call + // site is NOT guaranteed to run the moment the module loads — inside a + // function body (lazy in Node: jsonwebtoken, required only inside Next.js's + // request handlers), or inside a top-level control-flow block / braceless + // equivalent that may never run (`if (forceNative) { require('./native') }`, + // pg's optional native binding). Tag the import so `classify_eager_modules` + // leaves the target Deferred — matching Node, which only loads such a + // module when control flow actually reaches the call. The require shim + // triggers the target's `__init` at that same call site, so an over-eager + // classification is self-correcting at runtime (it just runs a bit early). + // Limited to Perry-compiled (`NativeCompiled`) targets — native stdlib / + // V8 modules have their own init paths. { for import in &mut hir_module.imports { if import.type_only diff --git a/crates/perry/src/commands/compile/optimized_libs/no_auto.rs b/crates/perry/src/commands/compile/optimized_libs/no_auto.rs index 5ce63789ac..8bbce7b833 100644 --- a/crates/perry/src/commands/compile/optimized_libs/no_auto.rs +++ b/crates/perry/src/commands/compile/optimized_libs/no_auto.rs @@ -42,8 +42,9 @@ pub(crate) fn resolve_no_auto_optimized_libs( if matches!(format, OutputFormat::Text) && verbose > 0 { eprintln!(" auto-optimize: skipped; using prebuilt target/release/libperry_*.a"); } - let well_known_libs = if std::env::var_os("PERRY_DISABLE_WELL_KNOWN").is_none() { - resolve_prebuilt_ext_libs(&well_known_iteration_set(ctx), target, format, verbose) + let iteration_set = well_known_iteration_set(ctx); + let mut well_known_libs = if std::env::var_os("PERRY_DISABLE_WELL_KNOWN").is_none() { + resolve_prebuilt_ext_libs(&iteration_set, target, format, verbose) } else { Vec::new() }; @@ -61,6 +62,46 @@ pub(crate) fn resolve_no_auto_optimized_libs( } else { (None, None) }; + // #10466 — the prebuilt `libperry_stdlib.a` is built with the default + // `full` feature set, which deliberately excludes + // `external-http-client-pump` (adding it to `full` would force every + // no-auto program, HTTP client or not, to link `libperry_ext_http.a` — + // see the Cargo.toml comment on `full`, #5983/#8587). Without that + // feature, perry-stdlib's dynamic-dispatch fallbacks for the + // `node:http`/`node:https` CLIENT surface (`res.pipe()`, `req.setHeader()`, + // `req.setTimeout()`, …) don't exist in the linked archive at all — they + // read `undefined` with no compile-time warning. When the program + // imports `http`/`https`, rebuild perry-stdlib-static with that feature + // added on top of `full`, the same on-demand-rebuild shape + // `build_optional_runtime` uses for `wasm-host` — AND, in the SAME cargo + // invocation, `perry-ext-http` itself: two archives built in separate + // cargo invocations can bundle different tokio compilations even from an + // identical Cargo.lock (`runtime_compat.rs`'s link-time guard exists + // exactly for this), so a stdlib-only rebuild would leave the fresh + // stdlib archive unlinkable against whatever `libperry_ext_http.a` + // `resolve_prebuilt_ext_libs` found on disk. A prior wasm/native-addon + // rebuild above already producing a stdlib archive (Windows) takes + // precedence; this only fills the common case where `stdlib` is `None`. + let stdlib = stdlib.or_else(|| { + let imports_http_client = iteration_set.iter().any(|m| { + matches!( + m.strip_prefix("node:").unwrap_or(m.as_str()), + "http" | "https" + ) + }); + if !imports_http_client { + return None; + } + let (stdlib_path, ext_http_path) = build_http_client_pump_stdlib(target, format, verbose)?; + // Replace whatever `libperry_ext_http.a` `resolve_prebuilt_ext_libs` + // found (built in a different cargo invocation, so a different + // tokio compilation) with the one just built alongside this stdlib, + // in the same invocation — the pair the link-time guard requires. + let ext_http_name = ext_http_path.file_name().map(|n| n.to_owned()); + well_known_libs.retain(|p| p.file_name() != ext_http_name.as_deref()); + well_known_libs.push(ext_http_path); + Some(stdlib_path) + }); OptimizedLibs { runtime, stdlib, @@ -70,6 +111,141 @@ pub(crate) fn resolve_no_auto_optimized_libs( } } +/// #10466 — on-demand rebuild of `perry-stdlib-static` (default `full` +/// features plus `external-http-client-pump`) into a dedicated target dir, +/// so the no-auto path's client-side `node:http`/`node:https` dynamic +/// dispatch (`res.pipe()`/`req.setHeader()`/`req.setTimeout()`/…) has +/// somewhere to link against without forcing every other no-auto program to +/// carry `libperry_ext_http.a`. `perry-ext-http` is rebuilt **in the same +/// cargo invocation** — two archives from separate invocations can bundle +/// different tokio compilations even off an identical `Cargo.lock` +/// (`runtime_compat.rs`'s link-time guard exists exactly for this pair), so +/// a stdlib-only rebuild would leave the fresh stdlib unlinkable against +/// whatever `libperry_ext_http.a` `resolve_prebuilt_ext_libs` found on disk. +/// Mirrors `build_optional_runtime`'s `wasm-host` rebuild; returns `None` on +/// any failure (no source on disk, no cargo, build error) so the caller +/// falls back to the prebuilt full stdlib (same #10466 gap, not a new +/// failure mode). Returns `(stdlib_archive, ext_http_archive)`. +fn build_http_client_pump_stdlib( + target: Option<&str>, + format: OutputFormat, + verbose: u8, +) -> Option<(PathBuf, PathBuf)> { + let workspace_root = cargo_target_dir_path(find_perry_workspace_root()?); + let stdlib_crate_dir = workspace_root.join("crates").join("perry-stdlib-static"); + let ext_http_crate_dir = workspace_root.join("crates").join("perry-ext-http"); + if !stdlib_crate_dir.is_dir() || !ext_http_crate_dir.is_dir() { + if matches!(format, OutputFormat::Text) && verbose > 0 { + eprintln!( + " http-client-pump (no-auto): skipping rebuild — crate source not found at {} or {}", + stdlib_crate_dir.display(), + ext_http_crate_dir.display() + ); + } + return None; + } + + if matches!(format, OutputFormat::Text) { + println!( + " http-client-pump (no-auto): rebuilding stdlib (external-http-client-pump) + perry-ext-http together" + ); + } + + // Dedicated target dir so the prebuilt libperry_stdlib.a in + // target/release is not overwritten. Cargo's incremental cache makes + // repeat builds a no-op. + let relative_target_dir = PathBuf::from("target").join("perry-no-auto-http-pump"); + let pump_target_dir = cargo_target_dir_path(workspace_root.join(&relative_target_dir)); + let cargo_target_dir = if cfg!(windows) { + relative_target_dir + } else { + pump_target_dir.clone() + }; + + let mut cargo_cmd = Command::new("cargo"); + cargo_cmd + .current_dir(&workspace_root) + .env("CARGO_TARGET_DIR", &cargo_target_dir) + .arg("build") + .arg("--release") + .arg("-p") + .arg("perry-stdlib-static") + .arg("-p") + .arg("perry-ext-http") + .arg("--features") + .arg("perry-stdlib/external-http-client-pump"); + if let Some(triple) = rust_target_triple(target) { + cargo_cmd.arg("--target").arg(triple); + } + if is_android_target(target) { + if let Some(ndk) = std::env::var_os("ANDROID_NDK_HOME") { + for (k, v) in android_cross_env(std::path::Path::new(&ndk), target) { + cargo_cmd.env(k, v); + } + } + } + if matches!(target, Some("harmonyos") | Some("harmonyos-simulator")) { + match find_harmonyos_sdk() { + Some(sdk) => { + for (k, v) in harmonyos_cross_env(&sdk, target) { + cargo_cmd.env(k, v); + } + } + None => { + if matches!(format, OutputFormat::Text) && verbose > 0 { + eprintln!( + " http-client-pump (no-auto): skipping rebuild — OHOS SDK not found (set OHOS_SDK_HOME)" + ); + } + return None; + } + } + } + + match super::super::tool_output::run_internal_tool(&mut cargo_cmd, verbose) { + Ok(status) if status.success() => {} + Ok(status) => { + if matches!(format, OutputFormat::Text) { + eprintln!( + " http-client-pump (no-auto): cargo build for http-client-pump stdlib+ext-http failed ({status})" + ); + } + return None; + } + Err(err) => { + if matches!(format, OutputFormat::Text) { + eprintln!(" http-client-pump (no-auto): failed to spawn cargo ({err})"); + } + return None; + } + } + + let (stdlib_name, ext_http_name) = if is_windows_target(target) { + ("perry_stdlib.lib", "perry_ext_http.lib") + } else { + ("libperry_stdlib.a", "libperry_ext_http.a") + }; + let mut release_dir = pump_target_dir; + if let Some(triple) = rust_target_triple(target) { + release_dir = release_dir.join(triple); + } + let release_dir = release_dir.join("release"); + let stdlib = release_dir.join(stdlib_name); + let ext_http = release_dir.join(ext_http_name); + for path in [&stdlib, &ext_http] { + if !path.exists() { + if matches!(format, OutputFormat::Text) && verbose > 0 { + eprintln!( + " http-client-pump (no-auto): cargo finished but {} was not produced", + path.display() + ); + } + return None; + } + } + Some((stdlib, ext_http)) +} + /// Build `perry-runtime-static` with default features + `perry-runtime/wasm-host` /// into a dedicated target dir so the prebuilt `libperry_runtime.a` is not /// clobbered. Windows also builds `perry-stdlib-static` in the same graph and diff --git a/crates/perry/src/commands/compile/optimized_libs/tests.rs b/crates/perry/src/commands/compile/optimized_libs/tests.rs index ebb015fd59..b4e86c6c4f 100644 --- a/crates/perry/src/commands/compile/optimized_libs/tests.rs +++ b/crates/perry/src/commands/compile/optimized_libs/tests.rs @@ -788,16 +788,18 @@ fn no_auto_still_resolves_prebuilt_well_known_archives() { let old_disable_well_known = std::env::var("PERRY_DISABLE_WELL_KNOWN").ok(); let dir = tempfile::tempdir().expect("tempdir"); - let http = - super::super::well_known::lookup_well_known("http").expect("http well-known binding"); + // #10466 — deliberately NOT "http"/"https" here: importing either now + // triggers `build_http_client_pump_stdlib`'s on-demand rebuild (a real + // cargo invocation), which this test's fake `PERRY_LIB_DIR` archives + // (raw `!\n` placeholders, not real cargo output) can't stand in + // for, and which would turn this fast unit test into a slow, real build. + // That new behavior has its own coverage below + // (`no_auto_http_client_import_rebuilds_pump_stdlib_with_ext_http`). + // This test's job is unrelated: confirm `resolve_prebuilt_ext_libs` still + // finds multiple well-known archives via `PERRY_LIB_DIR` when no rebuild + // trigger is present. let net = super::super::well_known::lookup_well_known("net").expect("net well-known binding"); let ws = super::super::well_known::lookup_well_known("ws").expect("ws well-known binding"); - let http_lib = dir - .path() - .join(super::super::well_known::ext_staticlib_filename( - &http.lib, - rust_target_triple(None), - )); let net_lib = dir .path() .join(super::super::well_known::ext_staticlib_filename( @@ -810,7 +812,6 @@ fn no_auto_still_resolves_prebuilt_well_known_archives() { &ws.lib, rust_target_triple(None), )); - std::fs::write(&http_lib, b"!\n").expect("write fake http archive"); std::fs::write(&net_lib, b"!\n").expect("write fake net archive"); std::fs::write(&ws_lib, b"!\n").expect("write fake ws archive"); @@ -822,7 +823,6 @@ fn no_auto_still_resolves_prebuilt_well_known_archives() { set_env_var("PERRY_DISABLE_WELL_KNOWN", None); let mut ctx = CompilationContext::new(dir.path().to_path_buf()); - ctx.native_module_imports.insert("http".to_string()); ctx.native_module_imports.insert("net".to_string()); ctx.native_module_imports.insert("ws".to_string()); let libs = resolve_no_auto_optimized_libs(&ctx, None, OutputFormat::Json, 0); @@ -836,11 +836,6 @@ fn no_auto_still_resolves_prebuilt_well_known_archives() { assert_eq!(libs.runtime, None); assert_eq!(libs.stdlib, None); - assert!( - libs.well_known_libs.contains(&http_lib), - "expected no-auto well-known libs to include {http_lib:?}, got {:?}", - libs.well_known_libs - ); assert!( libs.well_known_libs.contains(&net_lib), "expected no-auto well-known libs to include {net_lib:?}, got {:?}", @@ -853,6 +848,43 @@ fn no_auto_still_resolves_prebuilt_well_known_archives() { ); } +/// #10466 — the flip side of the test above: when the program DOES import +/// `http`, no-auto now rebuilds `perry-stdlib-static` (with +/// `external-http-client-pump`) and `perry-ext-http` together, and the +/// rebuilt `perry-ext-http` archive takes the place of whatever +/// `resolve_prebuilt_ext_libs` would otherwise have found on disk for it. +/// This does a real (if small) cargo build, so it's slower than the rest of +/// this file — that's the trade-off for exercising the actual rebuild path +/// rather than re-asserting the pass-through plumbing against a mock. +#[test] +fn no_auto_http_client_import_rebuilds_pump_stdlib_with_ext_http() { + let _guard = env_lock(); + let mut ctx = CompilationContext::new( + find_perry_workspace_root().expect("workspace root for this checkout"), + ); + ctx.native_module_imports.insert("http".to_string()); + let libs = resolve_no_auto_optimized_libs(&ctx, None, OutputFormat::Json, 0); + + let stdlib = libs + .stdlib + .as_ref() + .expect("http import should trigger the http-client-pump stdlib rebuild"); + assert!( + stdlib.ends_with("libperry_stdlib.a") || stdlib.ends_with("perry_stdlib.lib"), + "unexpected stdlib archive name: {stdlib:?}" + ); + let ext_http_in_well_known = libs.well_known_libs.iter().any(|p| { + p.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.contains("perry_ext_http")) + }); + assert!( + ext_http_in_well_known, + "expected the freshly-rebuilt perry-ext-http archive in well_known_libs, got {:?}", + libs.well_known_libs + ); +} + #[cfg(windows)] #[test] fn cargo_target_dir_strips_windows_verbatim_prefixes() { diff --git a/crates/perry/src/commands/stdlib_features.rs b/crates/perry/src/commands/stdlib_features.rs index a7f4b0561b..e45b6d39b7 100644 --- a/crates/perry/src/commands/stdlib_features.rs +++ b/crates/perry/src/commands/stdlib_features.rs @@ -101,7 +101,6 @@ pub fn module_to_features(module: &str) -> &'static [&'static str] { // bcrypt also typically use sha256/jwt/etc., which keeps the // umbrella worthwhile. "bcrypt" => &["bundled-bcrypt"], - "jsonwebtoken" => &["bundled-jsonwebtoken"], "crypto" => &["crypto"], // ethers ships utility functions (formatUnits, parseUnits, // getAddress, keccak256, …). The keccak256 implementation is diff --git a/crates/perry/tests/object_prototype_value_read_10689.rs b/crates/perry/tests/object_prototype_value_read_10689.rs new file mode 100644 index 0000000000..338944a805 --- /dev/null +++ b/crates/perry/tests/object_prototype_value_read_10689.rs @@ -0,0 +1,280 @@ +//! Regression: reading an INHERITED member of a non-escaping object as a +//! VALUE must resolve through the prototype chain, not fold to `undefined`. +//! +//! Issue #10689. `const o = { a: 1 }; typeof o.toString` answered `undefined` +//! while `o.toString()` answered correctly, and the divergence was +//! order-dependent: adding `JSON.stringify(o)` earlier in the function +//! "repaired" it. +//! +//! The mechanism is escape analysis, not the lazy `globalThis` realm the +//! report guessed at. `collectors/escape_check.rs`'s `PropertyGet` arm treated +//! EVERY read on a scalar-replacement candidate as a "plain field read — safe", +//! including reads of keys the class chain does not declare. The local then +//! stayed scalar-replaced (no heap object at all) and +//! `expr/property_get.rs`'s scalar arm folded the slot-less read to the +//! constant `undefined`. `JSON.stringify(o)` only appeared to fix it because +//! passing `o` to a call makes it escape; `JSON.stringify` of an UNRELATED +//! object — which forces the realm just the same — does not, and that case is +//! pinned below. +//! +//! The three WRITE arms of the same analysis already carried this rule +//! (#9024 `PropertySet`/`PutValueSet`, #9460 `PropertyUpdate`); only the read +//! arm was missing it. +//! +//! Fixtures are `.js`, not `.ts`, so `Object.prototype.zz = 7` and +//! `o.nope` are valid source without `as any` casts — a cast would route the +//! read through the dynamic path and miss the statically-shaped lowering the +//! bug lived in. + +use std::path::Path; +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +/// Write `entry` into `dir`, compile it with `--no-cache` +/// `PERRY_NO_AUTO_OPTIMIZE=1` (links the prebuilt runtime archive), run it, and +/// return stdout. Mirrors the helper in `builtin_namespace_unknown_member.rs`. +fn compile_and_run_js(dir: &Path, entry: &str, source: &str) -> String { + let entry_path = dir.join(entry); + std::fs::write(&entry_path, source).expect("write fixture"); + let output = dir.join(format!("{entry}.bin")); + + let compile = Command::new(perry_bin()) + .current_dir(dir) + .arg("compile") + .arg(&entry_path) + .arg("--no-cache") + .arg("-o") + .arg(&output) + .env("PERRY_NO_AUTO_OPTIMIZE", "1") + .output() + .expect("run perry compile"); + assert!( + compile.status.success(), + "perry compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + + let run = Command::new(&output) + .current_dir(dir) + .output() + .expect("run compiled binary"); + assert!( + run.status.success(), + "compiled binary failed\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}", + run.status, + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + String::from_utf8_lossy(&run.stdout).into_owned() +} + +/// The core case. `o` is read but never passed anywhere, so nothing makes it +/// escape and nothing forces the realm — the exact program shape #10689 +/// reported. Every line was `undefined` / `false` before the fix. +#[test] +fn inherited_object_prototype_members_read_as_values() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run_js( + dir.path(), + "main.js", + r#" +const o = { a: 1 }; +console.log("typeof-constructor:", typeof o.constructor); +console.log("typeof-toString:", typeof o.toString); +console.log("typeof-hasOwnProperty:", typeof o.hasOwnProperty); +console.log("typeof-valueOf:", typeof o.valueOf); +console.log("typeof-isPrototypeOf:", typeof o.isPrototypeOf); +console.log("typeof-propertyIsEnumerable:", typeof o.propertyIsEnumerable); +console.log("ctor-is-Object:", o.constructor === Object); +console.log("toString-is-proto-toString:", o.toString === Object.prototype.toString); +"#, + ); + for expected in [ + "typeof-constructor: function", + "typeof-toString: function", + "typeof-hasOwnProperty: function", + "typeof-valueOf: function", + "typeof-isPrototypeOf: function", + "typeof-propertyIsEnumerable: function", + "ctor-is-Object: true", + "toString-is-proto-toString: true", + ] { + assert!( + stdout.contains(expected), + "missing {expected:?}\nstdout:\n{stdout}" + ); + } +} + +/// The ordering half of #10689, and the case that names the real mechanism. +/// +/// `JSON.stringify` is what the report found "repaired" the read — but only +/// when it was handed `o` ITSELF, which makes `o` escape. Stringifying an +/// UNRELATED object forces exactly the same lazy realm and must not change the +/// answer, and the read before it must agree with the read after it. Before the +/// fix all four reads here were `undefined`; a fix that merely forced the realm +/// from the read path would leave this test passing for the wrong reason, so it +/// asserts the invariant (before == after == "function"), not the repair. +#[test] +fn inherited_value_read_does_not_depend_on_evaluation_order() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run_js( + dir.path(), + "main.js", + r#" +const o = { a: 1 }; +console.log("before-toString:", typeof o.toString); +console.log("before-constructor:", typeof o.constructor); +// Forces `populate_global_this_builtins` without `o` escaping. +JSON.stringify({ unrelated: 2 }); +console.log("after-toString:", typeof o.toString); +console.log("after-constructor:", typeof o.constructor); +"#, + ); + for expected in [ + "before-toString: function", + "before-constructor: function", + "after-toString: function", + "after-constructor: function", + ] { + assert!( + stdout.contains(expected), + "missing {expected:?}\nstdout:\n{stdout}" + ); + } +} + +/// The other direction of the same pair: calls through the inherited chain were +/// always correct and must STAY correct, so a future change cannot "fix" reads +/// by routing them through something that breaks the call path. +#[test] +fn inherited_object_prototype_members_are_still_callable() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run_js( + dir.path(), + "main.js", + r#" +const o = { a: 1 }; +console.log("call-toString:", o.toString()); +console.log("call-hasOwnProperty-present:", o.hasOwnProperty("a")); +console.log("call-hasOwnProperty-absent:", o.hasOwnProperty("b")); +console.log("concat:", "" + o); +console.log("template:", `${o}`); +console.log("in-operator:", "constructor" in o); +console.log("proto-identity:", Object.getPrototypeOf(o) === Object.prototype); +// Read-then-call through a local, the form that needs a real function value. +const f = o.toString; +console.log("read-then-call:", f.call(o)); +"#, + ); + for expected in [ + "call-toString: [object Object]", + "call-hasOwnProperty-present: true", + "call-hasOwnProperty-absent: false", + "concat: [object Object]", + "template: [object Object]", + "in-operator: true", + "proto-identity: true", + "read-then-call: [object Object]", + ] { + assert!( + stdout.contains(expected), + "missing {expected:?}\nstdout:\n{stdout}" + ); + } +} + +/// The same hole on a declared class: a PROTOTYPE METHOD read as a value off a +/// non-escaping `new` answered `undefined` while the fused call answered +/// correctly. Same arm, same fold — `m` is not a declared FIELD, so scalar +/// replacement had no slot for it. +#[test] +fn prototype_method_read_as_value_on_non_escaping_instance() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run_js( + dir.path(), + "main.js", + r#" +// `m` is body-summarizable, which is what keeps `c` scalar-replaced across +// the fused call below. A method whose body the summary rejects escapes its +// receiver for that reason alone and would not exercise the fold. +class C { + m() { return 1; } +} +const c = new C(); +console.log("typeof-method:", typeof c.m); +console.log("call-method:", c.m()); +"#, + ); + for expected in ["typeof-method: function", "call-method: 1"] { + assert!( + stdout.contains(expected), + "missing {expected:?}\nstdout:\n{stdout}" + ); + } +} + +/// A user-added `Object.prototype` member is inherited by a plain object. The +/// folded read could not see it at all, which is the form the report warned +/// about: a library feature-detects and silently takes the other branch. +#[test] +fn user_added_object_prototype_member_is_inherited() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run_js( + dir.path(), + "main.js", + r#" +Object.prototype.perryInherited = 7; +const o = { a: 1 }; +console.log("inherited-value:", o.perryInherited); +console.log("own-value:", o.a); +"#, + ); + for expected in ["inherited-value: 7", "own-value: 1"] { + assert!( + stdout.contains(expected), + "missing {expected:?}\nstdout:\n{stdout}" + ); + } +} + +/// The opposite failure the fix must not cause: a key that is on neither the +/// object nor its prototype chain still reads `undefined`, and own fields still +/// come from their scalar slots. Without this, "make every miss escape" would +/// pass the tests above while answering some non-`undefined` value here. +#[test] +fn genuinely_absent_key_is_still_undefined_and_own_fields_are_unchanged() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run_js( + dir.path(), + "main.js", + r#" +const o = { a: 1, b: "x" }; +console.log("absent-typeof:", typeof o.definitelyNotThere); +console.log("absent-is-undefined:", o.definitelyNotThere === undefined); +console.log("own-a:", o.a); +console.log("own-b:", o.b); +const n = { count: 0 }; +n.count = n.count + 41; +n.count++; +console.log("own-updated:", n.count); +"#, + ); + for expected in [ + "absent-typeof: undefined", + "absent-is-undefined: true", + "own-a: 1", + "own-b: x", + "own-updated: 42", + ] { + assert!( + stdout.contains(expected), + "missing {expected:?}\nstdout:\n{stdout}" + ); + } +} diff --git a/crates/perry/well_known_bindings.toml b/crates/perry/well_known_bindings.toml index 59e13d4598..70b7e3b59c 100644 --- a/crates/perry/well_known_bindings.toml +++ b/crates/perry/well_known_bindings.toml @@ -131,18 +131,6 @@ repo = "https://github.com/ranisalt/node-argon2" ref = "786de7152f95881b0683aea1d2ca60ed0d6d9e2f" ported-at = "0.45.1" date = "2026-07-30" -[bindings.jsonwebtoken] -crate = "perry-ext-jsonwebtoken" -lib = "perry_ext_jsonwebtoken" -tracking = "#466" - -[bindings.jsonwebtoken.upstream] -version = "9.0.3" -sha256 = "d9af2628a7a4dda25acf1e19c7ecc2468e1e9e8d4619fe2cae829e89d96f6b82" -repo = "https://github.com/auth0/node-jsonwebtoken" -ref = "ed59e76ea37a80f54b833668c02a5271984dcba3" -ported-at = "9.0.3" -date = "2026-07-30" [bindings.validator] crate = "perry-ext-validator" lib = "perry_ext_validator" diff --git a/docs/api/perry.d.ts b/docs/api/perry.d.ts index 87dc9693bb..81c488c26b 100644 --- a/docs/api/perry.d.ts +++ b/docs/api/perry.d.ts @@ -1,6 +1,6 @@ // Auto-generated from Perry's API manifest (#465). Do not edit by hand. // Source: perry-api-manifest::API_MANIFEST -// Coverage: 2093 entries across 136 modules +// Coverage: 2090 entries across 135 modules type PerryI8 = number & { readonly __perryI8?: never }; type PerryI16 = number & { readonly __perryI16?: never }; @@ -2096,15 +2096,6 @@ declare module "iovalkey" { export function createClient(...args: any[]): any; } -declare module "jsonwebtoken" { - /** stdlib */ - export function decode(token: string): any; - /** stdlib */ - export function sign(payload: any, secret: string, options?: any, kid?: string): string; - /** stdlib */ - export function verify(token: string, secret: string): any; -} - declare module "lodash" { /** stdlib */ export function camelCase(p0: string): string; diff --git a/docs/src/api/reference.md b/docs/src/api/reference.md index afcbe5c54a..affdd14dcb 100644 --- a/docs/src/api/reference.md +++ b/docs/src/api/reference.md @@ -2,7 +2,7 @@ This page is auto-generated from Perry's compile-time API manifest (`perry-api-manifest::API_MANIFEST`). It is the source of truth for what `perry compile` accepts; references to symbols not listed here produce `R005 UnimplementedApi` (issue #463). Stubs (#464) are flagged ⚠ — they link cleanly but no-op at runtime on the chosen target. -Total: 3035 entries across 138 modules. +Total: 3032 entries across 137 modules. ## Modules @@ -64,7 +64,6 @@ Total: 3035 entries across 138 modules. - [`inspector/promises`](#inspectorpromises) - [`ioredis`](#ioredis) - [`iovalkey`](#iovalkey) -- [`jsonwebtoken`](#jsonwebtoken) - [`lodash`](#lodash) - [`lru-cache`](#lru-cache) - [`module`](#module) @@ -2012,14 +2011,6 @@ Total: 3035 entries across 138 modules. - `createClient` — module -## `jsonwebtoken` - -### Methods - -- `decode` — module -- `sign` — module -- `verify` — module - ## `lodash` ### Methods diff --git a/docs/src/native-libraries/governance.md b/docs/src/native-libraries/governance.md index 1591f27649..9813d11331 100644 --- a/docs/src/native-libraries/governance.md +++ b/docs/src/native-libraries/governance.md @@ -102,7 +102,6 @@ from `well_known_bindings.toml`. Regenerate this table with | `perry-ext-fetch` | `node-fetch` | Source package | Compile the upstream package source | Bundled; migration pending | | `perry-ext-http` | `http`
`http2`
`https` | Runtime API | Keep near core; consolidate when practical | Bundled; retained | | `perry-ext-ioredis` | `ioredis`
`iovalkey`
`redis` | Source package | Compile the upstream package source | Bundled; migration pending | -| `perry-ext-jsonwebtoken` | `jsonwebtoken` | Source package | Compile the upstream package source | Bundled; migration pending | | `perry-ext-lru-cache` | `lru-cache` | Source package | Compile the upstream package source | Bundled; migration pending | | `perry-ext-moment` | `moment` | Source package | Compile the upstream package source | Bundled; migration pending | | `perry-ext-mongodb` | `mongodb` | Source package | Compile the upstream package source | Bundled; migration pending | diff --git a/scripts/string_payload_access_baseline.txt b/scripts/string_payload_access_baseline.txt index 52cc4699a8..bc1ba4ef70 100644 --- a/scripts/string_payload_access_baseline.txt +++ b/scripts/string_payload_access_baseline.txt @@ -13,7 +13,7 @@ inline-offset | perry-ext-pg | 2 inline-offset | perry-ext-zlib | 3 inline-offset | perry-ffi | 3 inline-offset | perry-runtime | 350 -inline-offset | perry-stdlib | 40 +inline-offset | perry-stdlib | 39 inline-offset | perry-updater | 5 reader-helper | perry-ext-ethers | 1 reader-helper | perry-runtime | 13 diff --git a/scripts/unrooted_local_shape_baseline.json b/scripts/unrooted_local_shape_baseline.json index 40f260534c..c1786de33f 100644 --- a/scripts/unrooted_local_shape_baseline.json +++ b/scripts/unrooted_local_shape_baseline.json @@ -19,7 +19,6 @@ "crates/perry-ext-http/src/server/response.rs": 1, "crates/perry-ext-http/src/server/types.rs": 1, "crates/perry-ext-ioredis/src/lib.rs": 1, - "crates/perry-ext-jsonwebtoken/src/lib.rs": 1, "crates/perry-ext-mongodb/src/lib.rs": 2, "crates/perry-ext-mysql2/src/lib.rs": 9, "crates/perry-ext-net/src/classes.rs": 2, diff --git a/test-files/_helpers/gap10437_cjs_lazy_require.cjs b/test-files/_helpers/gap10437_cjs_lazy_require.cjs new file mode 100644 index 0000000000..62ecf6644f --- /dev/null +++ b/test-files/_helpers/gap10437_cjs_lazy_require.cjs @@ -0,0 +1,88 @@ +'use strict' +// #10437: CommonJS `require()` outside a function is hoisted and run +// unconditionally at module init, including inside `if (false)` and other +// branches that never run. Every require below except H is inside a branch +// that never executes; only H's side-effect module should ever load, and it +// should load exactly at the point control flow reaches it (between the +// "before taken branch" and "after taken branch" log lines) — not before +// the first statement, and not before H's guarding condition was evaluated. +// +// This is the shape pg 8.22.0 hits verbatim: `lib/index.js` guards an +// optional native binding behind `if (forceNative) { require('./native') }`, +// and `./native` transitively requires the optional, often-uninstalled +// `pg-native`. The crash-form section below reproduces that two-hop shape +// with a target that genuinely does not resolve on disk. + +console.log('start') + +// A: literal false +if (false) { + require('./gap10437_side_a.cjs') +} +// B: short-circuit +false && require('./gap10437_side_b.cjs') +// C: runtime-false env check (pg's `if (forceNative)` shape) +if (process.env.PERRY_GAP10437_UNSET_C) { + require('./gap10437_side_c.cjs') +} +// D: ternary arm not taken +const d = process.env.PERRY_GAP10437_UNSET_D ? require('./gap10437_side_d.cjs') : 'd-skipped' +// E: switch case not taken +switch (1) { + case 2: + require('./gap10437_side_e.cjs') +} +// F: loop body never runs +for (let i = 0; i < 0; i++) require('./gap10437_side_f.cjs') +// G: function never called (already correctly deferred pre-#10437) +function never() { + return require('./gap10437_side_g.cjs') +} + +console.log('before taken branch') + +// H: the taken branch — must load exactly here, not earlier. +if (true) { + require('./gap10437_side_h.cjs') +} + +console.log('after taken branch, d=' + d) + +// Caching: two conditional requires of the SAME module must run the side +// effect once and return the SAME exports object both times. +let capA = null +let capB = null +if (true) { + capA = require('./gap10437_counter.cjs') +} +if (true) { + capB = require('./gap10437_counter.cjs') +} +console.log('cache same=' + (capA === capB) + ' n=' + capA.n) + +// Crash-form (pg-native shape): an optional native binding behind an unset +// env check, whose target itself unconditionally (but inside a +// non-swallowing try/catch) requires a module that does not exist on disk. +// Pre-fix this crashed the whole program with "Cannot find module" even +// though the guarding env var was never set. +let impl = 'js' +if (process.env.PERRY_GAP10437_USE_NATIVE) { + impl = require('./gap10437_native_rethrow.cjs') +} +console.log('impl=' + impl) + +// A genuinely missing module behind a try/catch that SWALLOWS the error, +// itself nested inside a condition that never runs. +let fallback = 'default' +if (process.env.PERRY_GAP10437_UNSET_FALLBACK) { + try { + fallback = require('./gap10437_does_not_exist.cjs') + } catch (e) { + fallback = 'caught' + } +} +console.log('fallback=' + fallback) + +console.log('end') + +module.exports = { never: never } diff --git a/test-files/_helpers/gap10437_counter.cjs b/test-files/_helpers/gap10437_counter.cjs new file mode 100644 index 0000000000..35643ed461 --- /dev/null +++ b/test-files/_helpers/gap10437_counter.cjs @@ -0,0 +1,2 @@ +console.log('counter evaluated') +module.exports = { n: 1 } diff --git a/test-files/_helpers/gap10437_native_rethrow.cjs b/test-files/_helpers/gap10437_native_rethrow.cjs new file mode 100644 index 0000000000..76e34c552c --- /dev/null +++ b/test-files/_helpers/gap10437_native_rethrow.cjs @@ -0,0 +1,11 @@ +'use strict' +// pg 8.22.0 lib/native/client.js:3-10 shape: an optional native addon, +// required unconditionally once this file's own init runs, wrapped in a +// try/catch that RE-THROWS rather than swallowing. +var Native +try { + Native = require('./gap10437_missing_optional_dep.cjs') +} catch (e) { + throw e +} +module.exports = Native diff --git a/test-files/_helpers/gap10437_side_a.cjs b/test-files/_helpers/gap10437_side_a.cjs new file mode 100644 index 0000000000..5efe46f1b2 --- /dev/null +++ b/test-files/_helpers/gap10437_side_a.cjs @@ -0,0 +1,2 @@ +console.log('side_a evaluated') +module.exports = 'a' diff --git a/test-files/_helpers/gap10437_side_b.cjs b/test-files/_helpers/gap10437_side_b.cjs new file mode 100644 index 0000000000..3d552351d4 --- /dev/null +++ b/test-files/_helpers/gap10437_side_b.cjs @@ -0,0 +1,2 @@ +console.log('side_b evaluated') +module.exports = 'b' diff --git a/test-files/_helpers/gap10437_side_c.cjs b/test-files/_helpers/gap10437_side_c.cjs new file mode 100644 index 0000000000..e72b064d6c --- /dev/null +++ b/test-files/_helpers/gap10437_side_c.cjs @@ -0,0 +1,2 @@ +console.log('side_c evaluated') +module.exports = 'c' diff --git a/test-files/_helpers/gap10437_side_d.cjs b/test-files/_helpers/gap10437_side_d.cjs new file mode 100644 index 0000000000..aa22fe700e --- /dev/null +++ b/test-files/_helpers/gap10437_side_d.cjs @@ -0,0 +1,2 @@ +console.log('side_d evaluated') +module.exports = 'd' diff --git a/test-files/_helpers/gap10437_side_e.cjs b/test-files/_helpers/gap10437_side_e.cjs new file mode 100644 index 0000000000..4a43a95ec8 --- /dev/null +++ b/test-files/_helpers/gap10437_side_e.cjs @@ -0,0 +1,2 @@ +console.log('side_e evaluated') +module.exports = 'e' diff --git a/test-files/_helpers/gap10437_side_f.cjs b/test-files/_helpers/gap10437_side_f.cjs new file mode 100644 index 0000000000..c886b3edd4 --- /dev/null +++ b/test-files/_helpers/gap10437_side_f.cjs @@ -0,0 +1,2 @@ +console.log('side_f evaluated') +module.exports = 'f' diff --git a/test-files/_helpers/gap10437_side_g.cjs b/test-files/_helpers/gap10437_side_g.cjs new file mode 100644 index 0000000000..2c5c7003eb --- /dev/null +++ b/test-files/_helpers/gap10437_side_g.cjs @@ -0,0 +1,2 @@ +console.log('side_g evaluated') +module.exports = 'g' diff --git a/test-files/_helpers/gap10437_side_h.cjs b/test-files/_helpers/gap10437_side_h.cjs new file mode 100644 index 0000000000..3a0b41452a --- /dev/null +++ b/test-files/_helpers/gap10437_side_h.cjs @@ -0,0 +1,2 @@ +console.log('side_h evaluated') +module.exports = 'h' diff --git a/test-files/test_gap_10661_dyn_eval_class_expr.ts b/test-files/test_gap_10661_dyn_eval_class_expr.ts new file mode 100644 index 0000000000..6a46d76af0 --- /dev/null +++ b/test-files/test_gap_10661_dyn_eval_class_expr.ts @@ -0,0 +1,108 @@ +// #10661: Perry's `new Function` runtime interpreter (#6559) did not support +// class expressions, so `mysql2` compiled from source but crashed at runtime +// with "unsupported construct: class expression" — `mysql2`'s row parsers +// are built at runtime by `generate-function` +// (`Function.apply(null, keys.concat(src)).apply(null, vals)`, +// generate-function/index.js:172) and the generated source is a class +// expression. +// +// This mirrors the EXACT shape captured from a live `mysql2` `SELECT` +// against a real server (`lib/parsers/text_parser.js`'s `compile()`): +// +// (function anonymous(wrap, LocalDate) { +// return ((function () { +// return class TextRow { +// constructor(fields) {} +// next(packet, fields, options) { ... } +// }; +// })()) +// }) +// +// plus the rest of the #10661 supported subset (constructor + regular +// instance/static methods, string/numeric keys, named self-reference) — +// everything the class expression form does NOT need (`extends`, +// getters/setters, private members, computed keys, class fields) is +// out of scope and stays untouched by this test. +// +// `genfun()` below is a minimal stand-in for `generate-function`'s own +// `genfun()`: `toFunction` assembles `"return (" + + ")"` and +// runs it through `Function.apply(null, keys.concat(src)).apply(null, vals)` +// — verbatim generate-function/index.js:154-172. Every `gen(...)` chain below +// therefore supplies the BODY of that one implicit `return (...)`, so a chain +// only writes its own `return` when it is inside a nested function scope +// (block 1 and 4's inner IIFE) — never at the outer level, which is exactly +// how mysql2's real `text_parser.js`/`binary_parser.js` codegen is shaped. + +function genfun() { + const lines: string[] = []; + const gen: any = function (line: string) { + lines.push(line); + return gen; + }; + gen.toFunction = function (scope: any) { + const src = "return (" + lines.join("\n") + ")"; + const keys = Object.keys(scope || {}); + const vals = keys.map((key) => scope[key]); + return Function.apply(null, keys.concat(src)).apply(null, vals); + }; + return gen; +} + +// 1. mysql2's row-parser shape verbatim: a nested IIFE returning a class +// expression with a constructor and one instance method. +{ + const gen = genfun(); + gen("(function () {")("return class TextRow {")("constructor(fields) {")( + "this.fields = fields;" + )("}")("next(extra) {")("return this.fields + extra;")("}")("};")("})()"); + const TextRow = gen.toFunction({}); + const row = new TextRow(3); + console.log("mysql2-row-parser", typeof TextRow, row.next(4)); +} + +// 2. A named class expression with constructor + multiple instance methods, +// built through the same `Function.apply` machinery, static method +// referencing the class by its own name, and string/numeric method keys. +{ + const gen = genfun(); + gen("class Counter {")("constructor(start) {")("this.n = start;")("}")( + "inc() {" + )("this.n = this.n + 1;")("return this.n;")("}")("static make(start) {")( + "return new Counter(start);" + )("}")('"label"() {')('return "counter";')("}")("0() {")( + 'return "zero-key";' + )("}")("}"); + const Counter = gen.toFunction({}); + const a = new Counter(10); + const b = Counter.make(100); + console.log("counter-a", a.inc(), a.inc(), a.inc()); + console.log("counter-b", b.inc(), b.inc()); + console.log("counter-a-again", a.inc()); + console.log("counter-label", a["label"]()); + console.log("counter-zero-key", a[0]()); +} + +// 3. No explicit constructor — the default (empty) constructor. +{ + const gen = genfun(); + gen("class Empty {")("set(v) { this.v = v; return this; }")( + "get() { return this.v; }" + )("}"); + const Empty = gen.toFunction({}); + const e = new Empty(); + console.log("empty-ctor", e.set(42).get()); +} + +// 4. A row-parser-shaped class over multiple synthetic fields, matching the +// per-field member assignment `text_parser.js` actually generates. +{ + const gen = genfun(); + gen("(function () {")("return class Row {")("constructor(fields) {")("}")( + "next(values) {" + )("var result = {};")('result["id"] = values[0];')( + 'result["name"] = values[1];' + )("return result;")("}")("};")("})()"); + const Row = gen.toFunction({}); + const row = new Row([1, 2]); + console.log("row-parser-fields", JSON.stringify(row.next([7, "ann"]))); +} diff --git a/test-files/test_gap_cjs_conditional_require_deferred.ts b/test-files/test_gap_cjs_conditional_require_deferred.ts new file mode 100644 index 0000000000..01d2549078 --- /dev/null +++ b/test-files/test_gap_cjs_conditional_require_deferred.ts @@ -0,0 +1,20 @@ +// #10437: CommonJS `require()` outside a function is hoisted and run +// unconditionally at module init, whatever the surrounding control flow. +// Perry loaded every `require('')` in a CJS file before the +// file's first statement ran, so a branch that never runs (`if (false)`, a +// false env check, `&&`, `?:`, `switch`, a loop that never iterates) still +// loaded its module, and a module reached via a taken branch loaded before +// the statements preceding it. +// +// The crash form is `pg` 8.22.0: `lib/index.js` guards its optional native +// binding behind `if (forceNative) { require('./native') }`, and `./native` +// requires the optional, often-uninstalled `pg-native`. Every program using +// `pg` crashed at init with `Cannot find module 'pg-native'` even though +// `forceNative` was false. `./_helpers/gap10437_cjs_lazy_require.cjs` +// reproduces the full variant matrix (A-H from the issue, plus require +// caching and a swallowed try/catch around a genuinely missing module) in +// one file so the expected interleaving with its own `console.log` calls is +// unambiguous. +import mod from "./_helpers/gap10437_cjs_lazy_require.cjs"; + +console.log("typeof never=" + typeof mod.never); diff --git a/test-files/test_gap_string_concat_memo_ascii_header.ts b/test-files/test_gap_string_concat_memo_ascii_header.ts new file mode 100644 index 0000000000..92d35f057f --- /dev/null +++ b/test-files/test_gap_string_concat_memo_ascii_header.ts @@ -0,0 +1,186 @@ +// Coverage for the string-concat perf fix (perry-runtime/src/string/concat.rs +// + string/mod.rs): the ASCII-ness of a concat operand is now read off the +// StringHeader (`utf16_len == byte_len`) instead of re-scanning bytes, the +// short-concat memo probe hashes/looks-up two operand slices directly +// instead of assembling them into a scratch buffer first, and the memo +// governor's minimum-hit-rate floor changed. None of that may change any +// observable result: every case here is compared byte-for-byte against +// `node --experimental-strip-types`. +// +// Values are built from runtime state (array/loop indices), never folded to +// a compile-time constant, so codegen must actually reach the runtime concat +// paths under test. + +function codes(s: string): string { + let out = ""; + for (let i = 0; i < s.length; i++) out += (i ? "+" : "") + s.charCodeAt(i).toString(16); + return out; +} + +// --------------------------------------------------------------------------- +// 1. ASCII string+string concat across the SSO (5) and memo (12) byte +// ceilings — exercises concat_byte_parts's SSO fast path, memo path, and +// heap path in one sweep. +// --------------------------------------------------------------------------- +const lensA = [0, 0, 2, 3, 6, 6, 10, 36]; +const lensB = [0, 1, 3, 3, 6, 7, 10, 37]; +for (let i = 0; i < lensA.length; i++) { + const a = "x".repeat(lensA[i]); + const b = "y".repeat(lensB[i]); + const r = a + b; + console.log("ss", lensA[i], lensB[i], r.length, r); +} + +// Same boundary set, but string+number (js_string_concat_value / +// js_value_concat_string) and number+string, so the "prefix" + i arm's +// memoizable gate (also touched by this fix) is covered too. +const prefixLens = [0, 1, 4, 5, 6, 11, 12, 13, 20]; +for (let i = 0; i < prefixLens.length; i++) { + const prefix = "p".repeat(prefixLens[i]); + const withNum = prefix + i; + const numWith = i + prefix; + console.log("sn", prefixLens[i], withNum.length, withNum, numWith.length, numWith); +} + +// --------------------------------------------------------------------------- +// 2. Non-ASCII, valid (well-formed) UTF-16 — 2-byte, 3-byte and 4-byte +// (astral, via a literal, not a joined surrogate pair) UTF-8 operands. +// These print directly: valid Unicode encodes identically to UTF-8 in +// both engines, so a byte-for-byte diff is a meaningful check on its own. +// --------------------------------------------------------------------------- +const twoByte = "é"; // U+00E9, 2 UTF-8 bytes, 1 UTF-16 unit +const threeByte = "€"; // U+20AC, 3 UTF-8 bytes, 1 UTF-16 unit +const fourByte = "😀"; // U+1F600, 4 UTF-8 bytes, 2 UTF-16 units (already a pair) +const nonAsciiCases: [string, string][] = [ + ["2b+ascii", twoByte + "ab"], + ["ascii+2b", "ab" + twoByte], + ["2b+2b", twoByte + twoByte], + ["3b+ascii", threeByte + "ab"], + ["ascii+3b", "ab" + threeByte], + ["3b+3b", threeByte + threeByte], + ["4b+ascii", fourByte + "ab"], + ["ascii+4b", "ab" + fourByte], + ["4b+4b", fourByte + fourByte], + ["mixed", "a" + twoByte + "b" + threeByte + "c" + fourByte + "d"], +]; +for (const [name, s] of nonAsciiCases) { + console.log("na", name, s.length, s, s.isWellFormed()); +} + +// string+number and number+string with a non-ASCII prefix/suffix, to hit +// js_string_concat_value / js_value_concat_string's non-ASCII path. +for (let i = 0; i < 3; i++) { + const withNum = threeByte + i; + const numWith = i + fourByte; + console.log("nan", i, withNum.length, withNum, numWith.length, numWith); +} + +// --------------------------------------------------------------------------- +// 3. Lone surrogates and a surrogate pair formed ACROSS the join boundary. +// Raw lone-surrogate content is reported via charCodeAt (codes()) or +// JSON.stringify — both are byte-safe (JSON.stringify escapes an +// unpaired surrogate as \uXXXX rather than emitting it raw), matching +// the pattern used elsewhere in this suite (#9431). A properly merged +// astral pair is well-formed Unicode and is printed directly. +// --------------------------------------------------------------------------- +const hi = "\uD83D"; // lone high surrogate +const lo = "\uDE00"; // lone low surrogate — hi+lo is exactly 😀 (U+1F600) + +// Pair formed directly across the join boundary: canonicalize_surrogate_pairs +// must merge it, so this is well-formed and safe to print raw. +const paired = hi + lo; +console.log("pair-direct", paired.length, paired, paired.isWellFormed(), paired.codePointAt(0)); + +// Reverse order never forms a pair (low-before-high is not a valid pair) — +// must remain two lone surrogates. +const reversed = lo + hi; +console.log( + "pair-reversed", + reversed.length, + codes(reversed), + reversed.isWellFormed(), + JSON.stringify(reversed), +); + +// A pair split across TWO separate concatenations, then joined by a THIRD: +// "a" + hi built first, lo + "b" built second, then those two results +// concatenated — the pair only becomes adjacent at the last join. +const left = "a" + hi; +const right = lo + "b"; +const rejoined = left + right; +console.log( + "pair-split-rejoin", + left.length, + right.length, + rejoined.length, + rejoined, + rejoined.isWellFormed(), + rejoined.codePointAt(1), +); + +// A lone surrogate with ASCII on both sides never forms a pair — stays lone, +// flag preserved through the concat. +const loneMid = "x" + hi + "y"; +console.log("lone-mid", loneMid.length, codes(loneMid), loneMid.isWellFormed(), JSON.stringify(loneMid)); + +// Two highs in a row: no valid pair (high+high is not low-after-high). +const twoHighs = hi + hi; +console.log("two-highs", twoHighs.length, codes(twoHighs), twoHighs.isWellFormed()); + +// --------------------------------------------------------------------------- +// 4. Empty operands on both sides of both concat forms. +// --------------------------------------------------------------------------- +console.log("empty-both", ("" + "").length, JSON.stringify("" + "")); +console.log("empty-left", ("" + "z").length, "" + "z"); +console.log("empty-right", ("z" + "").length, "z" + ""); +console.log("empty-num", ("" + 0).length, "" + 0, (0 + "").length, 0 + ""); + +// --------------------------------------------------------------------------- +// 5. Repeated identical concat results — forces the memo doorkeeper's +// "seen twice" admission and then real hits, and checks `===` identity +// across independently-built equal results (the memo must never change +// observable semantics: value equality is unaffected either way, but a +// hash-collision or admission bug would surface as a wrong `.length` or +// a `false` here). +// --------------------------------------------------------------------------- +let memoFailures = 0; +const memoResults: string[] = []; +for (let i = 0; i < 40; i++) { + // Same content, two different operand splits — "ab" + "cdef" and + // "abc" + "def" both yield "abcdef". + const viaSplitA = "ab" + "cdef".slice(0); + const viaSplitB = "abc".slice(0) + "def"; + if (viaSplitA !== viaSplitB) memoFailures++; + if (viaSplitA.length !== 6) memoFailures++; + memoResults.push(viaSplitA); +} +for (let i = 1; i < memoResults.length; i++) { + if (memoResults[i] !== memoResults[0]) memoFailures++; +} +console.log("memo-repeat-failures", memoFailures, memoResults.length, memoResults[0]); + +// A heap-forced (>SSO, <=memo-ceiling) equal pair built two different ways, +// repeated enough to admit, then compared for identity and content. +let memoHeapFailures = 0; +for (let i = 0; i < 40; i++) { + const a = "field_" + "ab".slice(0); // "field_ab", 8 bytes + const b = "field" + "_ab".slice(0); + if (a !== b || a.length !== 8 || a !== "field_ab") memoHeapFailures++; +} +console.log("memo-heap-repeat-failures", memoHeapFailures); + +// The "prefix" + i shape repeated with a REPEATED i, so the SAME result +// recurs (as opposed to section 1's sweep, which never repeats a value). +let memoNumFailures = 0; +const memoNumResults: string[] = []; +for (let rep = 0; rep < 30; rep++) { + const k = "row_" + 7; + if (k.length !== 5 || k !== "row_7") memoNumFailures++; + memoNumResults.push(k); +} +for (let i = 1; i < memoNumResults.length; i++) { + if (memoNumResults[i] !== memoNumResults[0]) memoNumFailures++; +} +console.log("memo-num-repeat-failures", memoNumFailures, memoNumResults[0]); + +console.log("done"); diff --git a/workspace-architecture.json b/workspace-architecture.json index 623d2d2711..8f9365ffc4 100644 --- a/workspace-architecture.json +++ b/workspace-architecture.json @@ -25,7 +25,7 @@ ] }, "baseline": { - "workspace_members": 83, + "workspace_members": 82, "default_dependency_closure": [ "perry", "perry-api-manifest", @@ -68,7 +68,7 @@ "perry-updater" ], "decision_counts": { - "externalize": 33, + "externalize": 32, "keep": 45, "merge": 1, "remove": 1, @@ -245,11 +245,6 @@ "decision": "externalize", "migration": "compile-source" }, - "perry-ext-jsonwebtoken": { - "category": "binding", - "decision": "externalize", - "migration": "compile-source" - }, "perry-ext-lru-cache": { "category": "binding", "decision": "externalize",