diff --git a/CHANGELOG.md b/CHANGELOG.md index 01e7ed0c77..3a1b5779c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## v0.5.1227 — spread sources validated; full handle band + no type-pun on the dynamic set path (#5943) + +First slice of the 2026-07-02 audit's write-path family (the GET side was hardened by the #5429 fix; the write/spread side was not). `{...expr}` (js_object_copy_own_fields) deref'd ANY value ≥0x10000 as an ObjectHeader via a `top16 >= 0x7FF8` catch-all — spreading a fetch-band handle or SSO string hit non-heap memory (Linux SIGSEGV), spreading a Map walked MapHeader bytes as object fields; sources are now POINTER-tagged, outside the handle band, and try_read_gc_header-verified GC_TYPE_OBJECT (documented safe-now gap: string spread should yield index props). js_object_set_field_by_name routed handles with a `< 0x10000` guard — one zero short of HANDLE_BAND_MAX — so POINTER-tagged fetch/zlib ids skipped handle dispatch and hit the raw GcHeader deref (`response.myProp = v` deref'd the id as memory); both routing sites now use addr_class::is_small_handle. The object_type pun is closed: a one-entry Map has MapHeader.size aliasing OBJECT_TYPE_REGULAR, so `m.customProp = 5` corrupted the Map's bytes — recognized non-object gc_types now return early (Map/Set expando writes are safely dropped; ExoticKind::Map/Set stores are the tracked follow-up, with the ~30-file 0x10000-idiom sweep). e2e: spread_and_setpath_band_safety.rs. + ## v0.5.1226 — per-invocation object staging; per-run parity scratch dir (#5940) The two concurrent-compile contamination P0s from the 2026-07-02 audit (the documented events.once-era trap, critical now that multiple dev agents compile in sibling worktrees daily). Per-module objects landed at CWD-relative name-only paths (object_output_dir = current_dir()), so two concurrent compiles sharing a working directory overwrote each other's .o mid-link and each deleted the other's intermediates — deterministically wrong binaries on any object-cache bypass — and the fixed-name _perry_stubs.o/_perry_failed_stubs.o collided even with the cache ON. Objects now stage under temp_dir()/perry-objs-- (the linker.rs #509 discipline; embedded-asset objects inherit the fix; dir removed with the intermediates). run_parity_tests.sh compiled every test to a fixed /tmp/perry_parity_ path — two concurrent suite runs executed each other's compiler output; binaries now live in a per-run mktemp dir (trap-cleaned). Follow-ups: shared cache-key env allowlist; port-parameterize the TLS-upgrade fixture's 17892. diff --git a/CLAUDE.md b/CLAUDE.md index 21d39b6f37..45a06c8680 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.1226 +**Current Version:** 0.5.1227 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 2faa4d0e21..31d7be1840 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5338,7 +5338,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "anyhow", "base64", @@ -5395,14 +5395,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "cc", "libc", @@ -5410,7 +5410,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "anyhow", "log", @@ -5425,7 +5425,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "anyhow", "perry-hir", @@ -5434,7 +5434,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "anyhow", "perry-hir", @@ -5442,7 +5442,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "anyhow", "perry-dispatch", @@ -5452,7 +5452,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "anyhow", "perry-hir", @@ -5461,7 +5461,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "anyhow", "base64", @@ -5474,7 +5474,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "anyhow", "perry-hir", @@ -5482,7 +5482,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "anyhow", "async-trait", @@ -5511,14 +5511,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "serde", "serde_json", @@ -5526,7 +5526,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1226" +version = "0.5.1227" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5537,7 +5537,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "anyhow", "clap", @@ -5552,7 +5552,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "block2", "objc2", @@ -5562,7 +5562,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "argon2", "perry-ffi", @@ -5570,7 +5570,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "perry-ffi", "reqwest", @@ -5579,7 +5579,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "bcrypt", "perry-ffi", @@ -5587,7 +5587,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "perry-ffi", "rusqlite", @@ -5595,7 +5595,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "perry-ffi", "scraper", @@ -5603,7 +5603,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "perry-ffi", "perry-runtime", @@ -5611,7 +5611,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "chrono", "cron", @@ -5621,7 +5621,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "chrono", "perry-ffi", @@ -5629,7 +5629,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "perry-ffi", "rust_decimal", @@ -5637,7 +5637,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "perry-ffi", "serde_json", @@ -5645,7 +5645,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "perry-ffi", "rand 0.8.6", @@ -5653,7 +5653,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "perry-ffi", "perry-runtime", @@ -5661,14 +5661,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "bytes", "http-body-util", @@ -5686,7 +5686,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "bytes", "lazy_static", @@ -5699,7 +5699,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "bytes", "lazy_static", @@ -5713,7 +5713,7 @@ dependencies = [ [[package]] name = "perry-ext-http-server" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "bytes", "h2", @@ -5736,7 +5736,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "lazy_static", "perry-ffi", @@ -5746,7 +5746,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "base64", "jsonwebtoken", @@ -5757,7 +5757,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "lru", "perry-ffi", @@ -5765,7 +5765,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "chrono", "perry-ffi", @@ -5773,7 +5773,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "bson", "futures-util", @@ -5785,7 +5785,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "chrono", "perry-ffi", @@ -5795,7 +5795,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "nanoid", "perry-ffi", @@ -5804,7 +5804,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "bytes", "perry-ffi", @@ -5817,7 +5817,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "lettre", "perry-ffi", @@ -5827,7 +5827,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "perry-ffi", "printpdf", @@ -5835,7 +5835,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "perry-ffi", "sqlx", @@ -5844,7 +5844,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "governor", "perry-ffi", @@ -5852,7 +5852,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "fast_image_resize", "image", @@ -5862,14 +5862,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "lazy_static", "perry-ffi", @@ -5878,7 +5878,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "perry-ffi", "uuid", @@ -5886,7 +5886,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "perry-ffi", "regex", @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "futures-util", "lazy_static", @@ -5908,7 +5908,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "brotli", "flate2", @@ -5917,7 +5917,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "dashmap", "once_cell", @@ -5926,7 +5926,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "anyhow", "perry-api-manifest", @@ -5944,7 +5944,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "anyhow", "perry-diagnostics", @@ -5956,7 +5956,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "anyhow", "base64", @@ -5989,14 +5989,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "aes 0.8.4", "aes-gcm", @@ -6089,14 +6089,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "anyhow", "perry-hir", @@ -6106,7 +6106,7 @@ dependencies = [ [[package]] name = "perry-types" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "anyhow", "thiserror 1.0.69", @@ -6114,14 +6114,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "base64", "itoa", @@ -6138,7 +6138,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "rand 0.8.6", "serde", @@ -6148,7 +6148,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6171,7 +6171,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "base64", "block2", @@ -6187,7 +6187,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "base64", "block2", @@ -6202,7 +6202,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1226" +version = "0.5.1227" [[package]] name = "perry-ui-test" @@ -6210,11 +6210,11 @@ version = "0.1.0" [[package]] name = "perry-ui-testkit" -version = "0.5.1226" +version = "0.5.1227" [[package]] name = "perry-ui-tvos" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "base64", "block2", @@ -6230,7 +6230,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "base64", "block2", @@ -6246,7 +6246,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "block2", "libc", @@ -6259,7 +6259,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "base64", "libc", @@ -6276,14 +6276,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "base64", "ed25519-dalek", @@ -6297,7 +6297,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1226" +version = "0.5.1227" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 0a75bc2ea1..9de5bce23b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -301,7 +301,7 @@ strip = false codegen-units = 16 [workspace.package] -version = "0.5.1226" +version = "0.5.1227" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/crates/perry-runtime/src/object/alloc.rs b/crates/perry-runtime/src/object/alloc.rs index 58eaa798b5..38a6153340 100644 --- a/crates/perry-runtime/src/object/alloc.rs +++ b/crates/perry-runtime/src/object/alloc.rs @@ -757,17 +757,34 @@ pub unsafe extern "C" fn js_object_copy_own_fields(dst_i64: i64, src_f64: f64) { } let dst = dst_raw as *mut ObjectHeader; - // Extract src pointer (NaN-boxed f64) + // Extract + VALIDATE the src pointer (2026-07-02 audit P0). The old + // `top16 >= 0x7FF8` catch-all admitted SSO strings (0x7FF9), registry + // handles, INT32s, and negative doubles, and the only guard was + // `< 0x10000` — so `{...response}` (a POINTER-tagged fetch-band id) or + // `{..."ab"}` deref'd a non-heap address as an ObjectHeader (Linux + // SIGSEGV), and `{...map}` walked a MapHeader's bytes as object fields. + // Spec (CopyDataProperties): non-objects with no own enumerable string + // props contribute nothing — so anything that is not a genuine heap + // OBJECT is skipped. (Known remaining gap, safe now instead of UB: + // spreading a STRING should yield its index properties; it currently + // yields none.) let src_bits = src_f64.to_bits(); let src_top16 = src_bits >> 48; - let src_raw = if src_top16 >= 0x7FF8 { - (src_bits & 0x0000_FFFF_FFFF_FFFF) as usize - } else { - src_bits as usize - }; - if src_raw < 0x10000 { + // Only a POINTER-tagged value can be a spreadable heap object. + if src_top16 != 0x7FFD { + return; + } + let src_raw = (src_bits & 0x0000_FFFF_FFFF_FFFF) as usize; + if crate::value::addr_class::is_handle_band(src_raw) || src_raw < 0x10000 { return; } + // Probe the GcHeader without deref-faulting and require a real object + // (Maps/Sets/Promises/etc. have their own layouts — reading their bytes + // as ObjectHeader fields is type confusion). + match crate::value::addr_class::try_read_gc_header(src_raw) { + Some(h) if h.obj_type == crate::gc::GC_TYPE_OBJECT => {} + _ => return, + } let src = src_raw as *const ObjectHeader; // Iterate src's keys and copy each value via set_field_by_name. diff --git a/crates/perry-runtime/src/object/field_set_by_name.rs b/crates/perry-runtime/src/object/field_set_by_name.rs index f3fd4b76a5..42cd6aa524 100644 --- a/crates/perry-runtime/src/object/field_set_by_name.rs +++ b/crates/perry-runtime/src/object/field_set_by_name.rs @@ -495,8 +495,13 @@ pub extern "C" fn js_object_set_field_by_name( if raw.is_null() || top16 == 0x7FFC { return; } - if (raw as usize) < 0x10000 { - // Small handle — dispatch to handle property set if registered + if crate::value::addr_class::is_small_handle(raw as usize) { + // Handle-band id (2026-07-02 audit P1: the old `< 0x10000` + // guard was one zero short of HANDLE_BAND_MAX, so a + // POINTER-tagged fetch (0x40000+) or zlib id skipped handle + // dispatch and reached the raw GcHeader deref below — + // `response.myProp = v` deref'd the id as memory). Dispatch + // to the registered handle property setter. if let Some(dispatch) = handle_property_set_dispatch() { if !key.is_null() { unsafe { @@ -514,8 +519,9 @@ pub extern "C" fn js_object_set_field_by_name( obj } }; - if obj.is_null() || (obj as usize) < 0x10000 { - // Small non-null value — could be a stripped handle (after ensure_i64 stripped NaN-box tag) + if obj.is_null() || crate::value::addr_class::is_small_handle(obj as usize) { + // Handle-band value (full band, not the old `< 0x10000` — see above) + // or a stripped handle after ensure_i64 removed the NaN-box tag. if !obj.is_null() && (obj as usize) > 0 { if let Some(dispatch) = handle_property_set_dispatch() { if !key.is_null() { @@ -662,10 +668,20 @@ pub extern "C" fn js_object_set_field_by_name( return; } if gc_type != crate::gc::GC_TYPE_OBJECT && gc_type != crate::gc::GC_TYPE_CLOSURE { + // A RECOGNIZED non-object heap type (Map/Set/Buffer/TypedArray/…) + // must never fall through to the plain-object write below: their + // layouts alias ObjectHeader fields. A Map with EXACTLY one entry + // had MapHeader.size aliasing object_type == OBJECT_TYPE_REGULAR, + // so `m.customProp = 5` walked the Map's bytes as object fields — + // deterministic heap corruption (2026-07-02 audit P1). The + // object_type fallback exists ONLY for static/const objects whose + // preceding bytes decode to no known GC type. + if crate::gc::gc_type_info(gc_type).is_some() { + return; + } if !is_valid_obj_ptr(obj as *const u8) { return; } - // Not a heap object/closure — only accept object_type == 1 (OBJECT_TYPE_REGULAR) let object_type = (*obj).object_type; if object_type != crate::error::OBJECT_TYPE_REGULAR { return; diff --git a/crates/perry/tests/spread_and_setpath_band_safety.rs b/crates/perry/tests/spread_and_setpath_band_safety.rs new file mode 100644 index 0000000000..309efa2dfb --- /dev/null +++ b/crates/perry/tests/spread_and_setpath_band_safety.rs @@ -0,0 +1,98 @@ +//! Regression tests for the 2026-07-02 audit's handle-band/type-confusion +//! write-path P0s: +//! +//! - `{...expr}` (js_object_copy_own_fields) deref'd ANY value ≥ 0x10000 as +//! an ObjectHeader — spreading a Map walked MapHeader bytes as object +//! fields, spreading a number/SSO string deref'd non-heap bits (Linux +//! SIGSEGV). Spec: non-objects and keyless exotics contribute nothing. +//! - The dynamic set path's handle guard was `< 0x10000` (one zero short of +//! the handle band) and a recognized non-object heap type could fall +//! through to the plain-object write via the object_type pun: a Map with +//! EXACTLY one entry has MapHeader.size aliasing object_type == +//! OBJECT_TYPE_REGULAR, so `m.customProp = 5` corrupted the Map's bytes. +//! +//! The one-entry Map/Set cases assert SURVIVAL (contents intact, exit 0) — +//! node additionally allows expando reads on Maps (`m.custom` → 5), which +//! Perry does not yet support (ExoticKind::Map is a tracked follow-up); the +//! expando read is therefore deliberately not asserted here. + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn compile_and_run(dir: &std::path::Path, source: &str) -> String { + let entry = dir.join("main.ts"); + let output = dir.join("main_bin"); + std::fs::write(&entry, source).expect("write entry"); + + let compile = Command::new(perry_bin()) + .current_dir(dir) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .arg("--no-cache") + .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 (exit {:?})\nstdout:\n{}\nstderr:\n{}", + run.status.code(), + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + String::from_utf8_lossy(&run.stdout).into_owned() +} + +/// Spreading a Map/number yields `{}` (not UB); spreading a plain object +/// still copies its fields. +#[test] +fn spread_of_non_objects_is_empty_and_safe() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run( + dir.path(), + r#" +const m = new Map([["a", 1]]); +console.log("spread-map:", JSON.stringify({ ...m })); +console.log("spread-num:", JSON.stringify({ ...(5 as any) })); +console.log("spread-obj:", JSON.stringify({ ...{ x: 1, y: 2 } })); +"#, + ); + assert_eq!( + stdout, + "spread-map: {}\nspread-num: {}\nspread-obj: {\"x\":1,\"y\":2}\n" + ); +} + +/// A dynamic property write on a ONE-entry Map/Set (the object_type-pun +/// shape) must leave the collection intact instead of corrupting its bytes. +#[test] +fn expando_write_on_one_entry_map_does_not_corrupt() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run( + dir.path(), + r#" +const one = new Map([["k", "v"]]); +(one as any).custom = 5; +console.log("map-alive:", one.get("k"), one.size); +const s = new Set([1, 2]); +(s as any).tag = "t"; +console.log("set-alive:", s.has(2), s.size); +"#, + ); + assert_eq!(stdout, "map-alive: v 1\nset-alive: true 2\n"); +}