From 2964c2535314b802ece6a973df8c71864bb1060d Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Tue, 25 Aug 2026 12:47:00 +0200 Subject: [PATCH] feat(qs): add native Stripe-compatible shim (#8751) --- Cargo.lock | 9 + Cargo.toml | 2 + changelog.d/8751-native-qs.md | 1 + crates/perry-api-manifest/src/entries.rs | 1 + .../perry-api-manifest/src/entries/part_4.rs | 19 + crates/perry-codegen/src/ext_registry.rs | 3 + .../src/lower_call/native_table/mod.rs | 2 + .../src/lower_call/native_table/qs.rs | 22 + crates/perry-ext-qs/Cargo.toml | 20 + crates/perry-ext-qs/src/codec.rs | 128 +++++ crates/perry-ext-qs/src/lib.rs | 46 ++ crates/perry-ext-qs/src/options.rs | 360 ++++++++++++++ crates/perry-ext-qs/src/parse.rs | 449 ++++++++++++++++++ crates/perry-ext-qs/src/runtime.rs | 134 ++++++ crates/perry-ext-qs/src/stringify.rs | 330 +++++++++++++ crates/perry-ext-qs/src/test_async_shims.rs | 113 +++++ .../perry/src/commands/compile/well_known.rs | 2 +- .../perry/tests/issue_8751_qs_native_shim.rs | 154 ++++++ crates/perry/well_known_bindings.toml | 17 + docs/src/native-libraries/governance.md | 1 + workspace-architecture.json | 9 +- 21 files changed, 1819 insertions(+), 3 deletions(-) create mode 100644 changelog.d/8751-native-qs.md create mode 100644 crates/perry-codegen/src/lower_call/native_table/qs.rs create mode 100644 crates/perry-ext-qs/Cargo.toml create mode 100644 crates/perry-ext-qs/src/codec.rs create mode 100644 crates/perry-ext-qs/src/lib.rs create mode 100644 crates/perry-ext-qs/src/options.rs create mode 100644 crates/perry-ext-qs/src/parse.rs create mode 100644 crates/perry-ext-qs/src/runtime.rs create mode 100644 crates/perry-ext-qs/src/stringify.rs create mode 100644 crates/perry-ext-qs/src/test_async_shims.rs create mode 100644 crates/perry/tests/issue_8751_qs_native_shim.rs diff --git a/Cargo.lock b/Cargo.lock index 6389c5f135..3a322d080e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6164,6 +6164,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "perry-ext-qs" +version = "0.5.1519" +dependencies = [ + "perry-ffi", + "perry-runtime", + "serde_json", +] + [[package]] name = "perry-ext-ratelimit" version = "0.5.1519" diff --git a/Cargo.toml b/Cargo.toml index e890c61a5a..08061d929f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ members = [ "crates/perry-ext-events", "crates/perry-ext-decimal", "crates/perry-ext-dayjs", + "crates/perry-ext-qs", "crates/perry-ext-moment", "crates/perry-ext-cheerio", "crates/perry-ext-sharp", @@ -491,6 +492,7 @@ perry-ext-axios = { path = "crates/perry-ext-axios" } perry-ext-events = { path = "crates/perry-ext-events" } perry-ext-decimal = { path = "crates/perry-ext-decimal" } perry-ext-dayjs = { path = "crates/perry-ext-dayjs" } +perry-ext-qs = { path = "crates/perry-ext-qs" } perry-ext-moment = { path = "crates/perry-ext-moment" } perry-ext-cheerio = { path = "crates/perry-ext-cheerio" } perry-ext-sharp = { path = "crates/perry-ext-sharp" } diff --git a/changelog.d/8751-native-qs.md b/changelog.d/8751-native-qs.md new file mode 100644 index 0000000000..bce298a0a7 --- /dev/null +++ b/changelog.d/8751-native-qs.md @@ -0,0 +1 @@ +**Native `qs` compatibility:** bundle nested query-string parsing and serialization so Stripe request encoding no longer compiles the AOT-hostile `get-intrinsic` dependency chain. diff --git a/crates/perry-api-manifest/src/entries.rs b/crates/perry-api-manifest/src/entries.rs index c099a601eb..370e208959 100644 --- a/crates/perry-api-manifest/src/entries.rs +++ b/crates/perry-api-manifest/src/entries.rs @@ -33,6 +33,7 @@ pub const NATIVE_MODULES: &[&str] = &[ "mysql2/promise", // mysql2's promise-API subpath "pg", // PostgreSQL client "uuid", // RFC-4122 UUID generation + "qs", // nested query-string parser/stringifier (Stripe dependency) "bcrypt", // bcrypt password hashing (replaces the N-API addon) "argon2", // Argon2 password hashing (replaces the N-API addon) "ioredis", // Redis/Valkey client diff --git a/crates/perry-api-manifest/src/entries/part_4.rs b/crates/perry-api-manifest/src/entries/part_4.rs index 3894bbfa36..01f196dc7c 100644 --- a/crates/perry-api-manifest/src/entries/part_4.rs +++ b/crates/perry-api-manifest/src/entries/part_4.rs @@ -1114,4 +1114,23 @@ pub(crate) const API_MANIFEST_PART_4: &[ApiEntry] = &[ property("bun", "stdin"), property("bun", "stdout"), property("bun", "stderr"), + // --- qs (issue #8751) --- + // Native nested query-string codec. This keeps Stripe's request encoder + // off qs' legacy get-intrinsic/ES-shims dependency chain. + method_sig( + "qs", + "stringify", + false, + None, + &[p_any("value"), p_any("options")], + TypeSpec::String, + ), + method_sig( + "qs", + "parse", + false, + None, + &[p_str("input"), p_any("options")], + TypeSpec::Any, + ), ]; diff --git a/crates/perry-codegen/src/ext_registry.rs b/crates/perry-codegen/src/ext_registry.rs index cd1a27c466..f9b141831a 100644 --- a/crates/perry-codegen/src/ext_registry.rs +++ b/crates/perry-codegen/src/ext_registry.rs @@ -639,6 +639,7 @@ const EXT_PREFIX_REGISTRY: &[(&str, &str)] = &[ ("js_node_forge_", "node-forge"), // Native runtime TypeScript transpilation subset (#8511). ("js_typescript_", "typescript"), + ("js_qs_", "qs"), ]; /// Process-wide collector of provider keys observed during codegen. @@ -1172,6 +1173,8 @@ mod tests { ("js_node_forge_create_certificate", "node-forge"), ("js_parcel_watcher_subscribe", "@parcel/watcher"), ("js_parcel_watcher_get_events_since", "@parcel/watcher"), + ("js_qs_stringify", "qs"), + ("js_qs_parse", "qs"), ] { assert_symbol_routes_to(symbol, OwnerKind::WellKnown(binding)); } diff --git a/crates/perry-codegen/src/lower_call/native_table/mod.rs b/crates/perry-codegen/src/lower_call/native_table/mod.rs index 3115da4e5a..65ea9392ce 100644 --- a/crates/perry-codegen/src/lower_call/native_table/mod.rs +++ b/crates/perry-codegen/src/lower_call/native_table/mod.rs @@ -33,6 +33,7 @@ mod node_dns; mod node_domain; mod node_misc; mod parcel_watcher; +mod qs; mod thread_lodash; mod tls_events; mod tui; @@ -178,6 +179,7 @@ pub(super) static NATIVE_MODULE_TABLE: LazyLock> = LazyLock::n v.extend_from_slice(media::MEDIA_ROWS); v.extend_from_slice(native_profile::NATIVE_PROFILE_ROWS); v.extend_from_slice(parcel_watcher::PARCEL_WATCHER_ROWS); + v.extend_from_slice(qs::QS_ROWS); v.extend_from_slice(tui::TUI_ROWS); v.extend_from_slice(typescript::TYPESCRIPT_ROWS); v.extend_from_slice(yoga::YOGA_ROWS); diff --git a/crates/perry-codegen/src/lower_call/native_table/qs.rs b/crates/perry-codegen/src/lower_call/native_table/qs.rs new file mode 100644 index 0000000000..a6fefc9562 --- /dev/null +++ b/crates/perry-codegen/src/lower_call/native_table/qs.rs @@ -0,0 +1,22 @@ +use super::*; + +pub(super) const QS_ROWS: &[NativeModSig] = &[ + NativeModSig { + module: "qs", + has_receiver: false, + method: "stringify", + class_filter: None, + runtime: "js_qs_stringify", + args: &[NA_F64, NA_F64], + ret: NR_STR, + }, + NativeModSig { + module: "qs", + has_receiver: false, + method: "parse", + class_filter: None, + runtime: "js_qs_parse", + args: &[NA_STR, NA_F64], + ret: NR_OBJ_FROM_JSON_STR, + }, +]; diff --git a/crates/perry-ext-qs/Cargo.toml b/crates/perry-ext-qs/Cargo.toml new file mode 100644 index 0000000000..96211bb51e --- /dev/null +++ b/crates/perry-ext-qs/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "perry-ext-qs" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Native qs compatibility binding for nested query-string parsing and serialization" + +[lints] +workspace = true + +[lib] +crate-type = ["staticlib", "rlib"] + +[dependencies] +perry-ffi.workspace = true +serde_json.workspace = true + +[dev-dependencies] +perry-ffi = { workspace = true, features = ["runtime-link"] } +perry-runtime = { workspace = true, features = ["default", "stdlib"] } diff --git a/crates/perry-ext-qs/src/codec.rs b/crates/perry-ext-qs/src/codec.rs new file mode 100644 index 0000000000..d2458cfd98 --- /dev/null +++ b/crates/perry-ext-qs/src/codec.rs @@ -0,0 +1,128 @@ +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Charset { + Utf8, + Latin1, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Format { + Rfc1738, + Rfc3986, +} + +pub(crate) fn encode(input: &str, charset: Charset, format: Format) -> String { + let mut out = String::with_capacity(input.len()); + match charset { + Charset::Utf8 => { + for &byte in input.as_bytes() { + if is_safe(byte, format) { + out.push(byte as char); + } else { + push_escape(&mut out, byte); + } + } + } + Charset::Latin1 => { + for unit in input.encode_utf16() { + if unit <= 0xFF { + let byte = unit as u8; + if is_safe(byte, format) { + out.push(byte as char); + } else { + push_escape(&mut out, byte); + } + } else { + out.push_str("%26%23"); + out.push_str(&unit.to_string()); + out.push_str("%3B"); + } + } + } + } + if format == Format::Rfc1738 { + out = out.replace("%20", "+"); + } + out +} + +pub(crate) fn format_encoded(input: String, format: Format) -> String { + if format == Format::Rfc1738 { + input.replace("%20", "+") + } else { + input + } +} + +pub(crate) fn decode(input: &str, charset: Charset) -> String { + let plus_replaced = input.replace('+', " "); + let mut bytes = Vec::with_capacity(plus_replaced.len()); + let raw = plus_replaced.as_bytes(); + let mut index = 0; + let mut invalid_escape = false; + while index < raw.len() { + if raw[index] == b'%' { + if index + 2 < raw.len() { + if let (Some(high), Some(low)) = (hex(raw[index + 1]), hex(raw[index + 2])) { + bytes.push((high << 4) | low); + index += 3; + continue; + } + } + invalid_escape = true; + } + bytes.push(raw[index]); + index += 1; + } + + match charset { + Charset::Utf8 if invalid_escape => plus_replaced, + Charset::Utf8 => String::from_utf8(bytes).unwrap_or(plus_replaced), + Charset::Latin1 => bytes.into_iter().map(char::from).collect(), + } +} + +fn is_safe(byte: u8, format: Format) -> bool { + byte.is_ascii_alphanumeric() + || matches!(byte, b'-' | b'.' | b'_' | b'~') + || (format == Format::Rfc1738 && matches!(byte, b'(' | b')')) +} + +fn push_escape(out: &mut String, byte: u8) { + const HEX: &[u8; 16] = b"0123456789ABCDEF"; + out.push('%'); + out.push(HEX[(byte >> 4) as usize] as char); + out.push(HEX[(byte & 0xF) as usize] as char); +} + +fn hex(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rfc3986_encoding_matches_qs_defaults() { + assert_eq!( + encode("a b[c]/✓", Charset::Utf8, Format::Rfc3986), + "a%20b%5Bc%5D%2F%E2%9C%93" + ); + } + + #[test] + fn rfc1738_uses_plus_and_preserves_parentheses() { + assert_eq!(encode("a b(c)", Charset::Utf8, Format::Rfc1738), "a+b(c)"); + } + + #[test] + fn decoder_is_lenient_like_decode_uri_component_wrapper() { + assert_eq!(decode("a+b%5Bc%5D", Charset::Utf8), "a b[c]"); + assert_eq!(decode("bad%ZZ", Charset::Utf8), "bad%ZZ"); + } +} diff --git a/crates/perry-ext-qs/src/lib.rs b/crates/perry-ext-qs/src/lib.rs new file mode 100644 index 0000000000..7e360fb168 --- /dev/null +++ b/crates/perry-ext-qs/src/lib.rs @@ -0,0 +1,46 @@ +//! Native compatibility binding for [`qs`](https://www.npmjs.com/package/qs). +//! +//! The binding exists primarily so packages such as Stripe can retain qs' +//! nested request encoding without asking Perry's AOT compiler to compile the +//! legacy `get-intrinsic` / ES-shims dependency chain. The implementation is +//! intentionally dependency-light and crosses the runtime only through the +//! stable `perry-ffi` surface plus existing C ABI symbols. + +mod codec; +mod options; +mod parse; +mod runtime; +mod stringify; + +#[cfg(test)] +mod test_async_shims; + +use perry_ffi::{alloc_string, read_string, JsString, StringHeader, TransientRootScope}; + +/// `qs.stringify(value, options?)`. +#[no_mangle] +pub extern "C" fn js_qs_stringify(value: f64, options: f64) -> *mut StringHeader { + alloc_string(&stringify::stringify(value, options)).as_raw() +} + +/// `qs.parse(input, options?)`. +/// +/// # Safety +/// `input` must be null or a live Perry `StringHeader` pointer. +#[no_mangle] +pub unsafe extern "C" fn js_qs_parse( + input: *const StringHeader, + options: f64, +) -> *mut StringHeader { + let input = if input.is_null() { + String::new() + } else { + let input = JsString::from_raw(input as *mut StringHeader); + read_string(input).unwrap_or_default().to_owned() + }; + let scope = TransientRootScope::enter(); + let mut options = options::ParseOptions::from_js(&scope, options); + let value = parse::parse(&input, &mut options); + let json = serde_json::to_string(&value).expect("qs parse tree is JSON serializable"); + alloc_string(&json).as_raw() +} diff --git a/crates/perry-ext-qs/src/options.rs b/crates/perry-ext-qs/src/options.rs new file mode 100644 index 0000000000..648d9b5e42 --- /dev/null +++ b/crates/perry-ext-qs/src/options.rs @@ -0,0 +1,360 @@ +use crate::codec::{Charset, Format}; +use crate::runtime; +use perry_ffi::{ + js_array_get, js_array_length, throw_with_code, ErrorKind, JsValue, TransientRootScope, + TransientRootedNanbox, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ArrayFormat { + Brackets, + Comma, + Indices, + Repeat, +} + +pub(crate) struct StringifyOptions { + pub(crate) add_query_prefix: bool, + pub(crate) allow_dots: bool, + pub(crate) allow_empty_arrays: bool, + pub(crate) array_format: ArrayFormat, + pub(crate) charset: Charset, + pub(crate) charset_sentinel: bool, + pub(crate) comma_round_trip: bool, + pub(crate) delimiter: String, + pub(crate) encode: bool, + pub(crate) encode_dot_in_keys: bool, + pub(crate) encode_values_only: bool, + pub(crate) format: Format, + pub(crate) skip_nulls: bool, + pub(crate) strict_null_handling: bool, + pub(crate) encoder: Option, + pub(crate) filter: Option, + pub(crate) filter_keys: Option>, + pub(crate) serialize_date: Option, + pub(crate) sort: Option, +} + +impl Default for StringifyOptions { + fn default() -> Self { + Self { + add_query_prefix: false, + allow_dots: false, + allow_empty_arrays: false, + array_format: ArrayFormat::Indices, + charset: Charset::Utf8, + charset_sentinel: false, + comma_round_trip: false, + delimiter: "&".to_owned(), + encode: true, + encode_dot_in_keys: false, + encode_values_only: false, + format: Format::Rfc3986, + skip_nulls: false, + strict_null_handling: false, + encoder: None, + filter: None, + filter_keys: None, + serialize_date: None, + sort: None, + } + } +} + +impl StringifyOptions { + pub(crate) fn from_js(scope: &TransientRootScope, raw: f64) -> Self { + let mut result = Self::default(); + let value = runtime::from_f64(raw); + if !value.is_pointer() || runtime::is_closure(value) { + return result; + } + let options = scope.root_nanbox(raw); + + validate_bool(scope, &options, "allowEmptyArrays"); + validate_bool(scope, &options, "encodeDotInKeys"); + validate_bool(scope, &options, "commaRoundTrip"); + + result.add_query_prefix = bool_option(scope, &options, "addQueryPrefix", false); + result.allow_empty_arrays = bool_option(scope, &options, "allowEmptyArrays", false); + result.charset_sentinel = bool_option(scope, &options, "charsetSentinel", false); + result.comma_round_trip = bool_option(scope, &options, "commaRoundTrip", false); + result.encode = bool_option(scope, &options, "encode", true); + result.encode_dot_in_keys = bool_option(scope, &options, "encodeDotInKeys", false); + result.encode_values_only = bool_option(scope, &options, "encodeValuesOnly", false); + result.skip_nulls = bool_option(scope, &options, "skipNulls", false); + result.strict_null_handling = bool_option(scope, &options, "strictNullHandling", false); + + let allow_dots = field(scope, &options, "allowDots"); + result.allow_dots = if allow_dots.is_undefined() { + result.encode_dot_in_keys + } else { + truthy(allow_dots) + }; + + let delimiter = field(scope, &options, "delimiter"); + if !delimiter.is_undefined() { + result.delimiter = runtime::owned_string(scope, runtime::as_f64(delimiter)); + } + + let charset = field(scope, &options, "charset"); + if !charset.is_undefined() { + match runtime::string_value(scope, runtime::as_f64(charset)).as_deref() { + Some("utf-8") => result.charset = Charset::Utf8, + Some("iso-8859-1") => result.charset = Charset::Latin1, + _ => { + throw_type("The charset option must be either utf-8, iso-8859-1, or undefined") + } + } + } + + let format = field(scope, &options, "format"); + if !format.is_undefined() { + match runtime::string_value(scope, runtime::as_f64(format)).as_deref() { + Some("RFC1738") => result.format = Format::Rfc1738, + Some("RFC3986") => result.format = Format::Rfc3986, + _ => throw_type("Unknown format option provided."), + } + } + + let array_format = field(scope, &options, "arrayFormat"); + result.array_format = + match runtime::string_value(scope, runtime::as_f64(array_format)).as_deref() { + Some("brackets") => ArrayFormat::Brackets, + Some("comma") => ArrayFormat::Comma, + Some("repeat") => ArrayFormat::Repeat, + Some("indices") => ArrayFormat::Indices, + _ => { + let indices = field(scope, &options, "indices"); + if indices.is_undefined() || truthy(indices) { + ArrayFormat::Indices + } else { + ArrayFormat::Repeat + } + } + }; + + let encoder = field(scope, &options, "encoder"); + if !encoder.is_undefined() && !encoder.is_null() { + if !runtime::is_closure(encoder) { + throw_type("Encoder has to be a function."); + } + result.encoder = Some(scope.root_nanbox(runtime::as_f64(encoder))); + } + + let serialize_date = field(scope, &options, "serializeDate"); + if runtime::is_closure(serialize_date) { + result.serialize_date = Some(scope.root_nanbox(runtime::as_f64(serialize_date))); + } + + let sort = field(scope, &options, "sort"); + if runtime::is_closure(sort) { + result.sort = Some(scope.root_nanbox(runtime::as_f64(sort))); + } + + let filter = field(scope, &options, "filter"); + if runtime::is_closure(filter) { + result.filter = Some(scope.root_nanbox(runtime::as_f64(filter))); + } else if runtime::is_array(runtime::as_f64(filter)) { + let filter = scope.root_nanbox(runtime::as_f64(filter)); + let array = runtime::from_f64(filter.get()).as_pointer(); + let length = unsafe { js_array_length(array) }; + let mut keys = Vec::with_capacity(length as usize); + for index in 0..length { + let array = runtime::from_f64(filter.get()).as_pointer(); + let value = unsafe { js_array_get(array, index) }; + if !value.is_undefined() && !value.is_null() { + keys.push(runtime::owned_string(scope, runtime::as_f64(value))); + } + } + result.filter_keys = Some(keys); + } + + result + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum DuplicateMode { + Combine, + First, + Last, +} + +pub(crate) struct ParseOptions { + pub(crate) allow_dots: bool, + pub(crate) allow_empty_arrays: bool, + pub(crate) allow_prototypes: bool, + pub(crate) allow_sparse: bool, + pub(crate) array_limit: usize, + pub(crate) charset: Charset, + pub(crate) charset_sentinel: bool, + pub(crate) comma: bool, + pub(crate) decode_dot_in_keys: bool, + pub(crate) delimiter: String, + pub(crate) depth: usize, + pub(crate) duplicates: DuplicateMode, + pub(crate) ignore_query_prefix: bool, + pub(crate) interpret_numeric_entities: bool, + pub(crate) parameter_limit: usize, + pub(crate) parse_arrays: bool, + pub(crate) strict_depth: bool, + pub(crate) strict_null_handling: bool, + pub(crate) throw_on_limit_exceeded: bool, +} + +impl Default for ParseOptions { + fn default() -> Self { + Self { + allow_dots: false, + allow_empty_arrays: false, + allow_prototypes: false, + allow_sparse: false, + array_limit: 20, + charset: Charset::Utf8, + charset_sentinel: false, + comma: false, + decode_dot_in_keys: false, + delimiter: "&".to_owned(), + depth: 5, + duplicates: DuplicateMode::Combine, + ignore_query_prefix: false, + interpret_numeric_entities: false, + parameter_limit: 1000, + parse_arrays: true, + strict_depth: false, + strict_null_handling: false, + throw_on_limit_exceeded: false, + } + } +} + +impl ParseOptions { + pub(crate) fn from_js(scope: &TransientRootScope, raw: f64) -> Self { + let mut result = Self::default(); + let value = runtime::from_f64(raw); + if !value.is_pointer() || runtime::is_closure(value) { + return result; + } + let options = scope.root_nanbox(raw); + + result.allow_dots = bool_option(scope, &options, "allowDots", false); + result.allow_empty_arrays = bool_option(scope, &options, "allowEmptyArrays", false); + result.allow_prototypes = bool_option(scope, &options, "allowPrototypes", false); + result.allow_sparse = bool_option(scope, &options, "allowSparse", false); + result.charset_sentinel = bool_option(scope, &options, "charsetSentinel", false); + result.comma = bool_option(scope, &options, "comma", false); + result.decode_dot_in_keys = bool_option(scope, &options, "decodeDotInKeys", false); + result.ignore_query_prefix = bool_option(scope, &options, "ignoreQueryPrefix", false); + result.interpret_numeric_entities = + bool_option(scope, &options, "interpretNumericEntities", false); + result.parse_arrays = bool_option(scope, &options, "parseArrays", true); + result.strict_depth = bool_option(scope, &options, "strictDepth", false); + result.strict_null_handling = bool_option(scope, &options, "strictNullHandling", false); + result.throw_on_limit_exceeded = + bool_option(scope, &options, "throwOnLimitExceeded", false); + + result.array_limit = number_option(scope, &options, "arrayLimit", 20); + result.depth = number_option(scope, &options, "depth", 5); + result.parameter_limit = number_option(scope, &options, "parameterLimit", 1000); + + let delimiter = field(scope, &options, "delimiter"); + if !delimiter.is_undefined() { + if delimiter.is_pointer() && !delimiter.is_any_string() { + throw_type("Regular-expression delimiters are not supported by the native qs shim"); + } + result.delimiter = runtime::owned_string(scope, runtime::as_f64(delimiter)); + } + + let charset = field(scope, &options, "charset"); + if !charset.is_undefined() { + match runtime::string_value(scope, runtime::as_f64(charset)).as_deref() { + Some("utf-8") => result.charset = Charset::Utf8, + Some("iso-8859-1") => result.charset = Charset::Latin1, + _ => { + throw_type("The charset option must be either utf-8, iso-8859-1, or undefined") + } + } + } + + let duplicates = field(scope, &options, "duplicates"); + if !duplicates.is_undefined() { + result.duplicates = + match runtime::string_value(scope, runtime::as_f64(duplicates)).as_deref() { + Some("combine") => DuplicateMode::Combine, + Some("first") => DuplicateMode::First, + Some("last") => DuplicateMode::Last, + _ => throw_type("The duplicates option must be either combine, first, or last"), + }; + } + + let decoder = field(scope, &options, "decoder"); + if !decoder.is_undefined() && !decoder.is_null() { + if !runtime::is_closure(decoder) { + throw_type("Decoder has to be a function."); + } + throw_type("Custom decoders are not supported by the native qs shim"); + } + + result + } +} + +fn field(scope: &TransientRootScope, options: &TransientRootedNanbox, name: &str) -> JsValue { + runtime::field_by_name(scope, options, name) +} + +fn bool_option( + scope: &TransientRootScope, + options: &TransientRootedNanbox, + name: &str, + default: bool, +) -> bool { + let value = field(scope, options, name); + if value.is_bool() { + value.to_bool() + } else { + default + } +} + +fn validate_bool(scope: &TransientRootScope, options: &TransientRootedNanbox, name: &str) { + let value = field(scope, options, name); + if !value.is_undefined() && !value.is_bool() { + throw_type(&format!( + "`{name}` option can only be `true` or `false`, when provided" + )); + } +} + +fn number_option( + scope: &TransientRootScope, + options: &TransientRootedNanbox, + name: &str, + default: usize, +) -> usize { + let value = field(scope, options, name); + if value.is_number() { + let value = value.to_number(); + if value.is_finite() && value >= 0.0 { + return value.floor() as usize; + } + } + default +} + +fn truthy(value: JsValue) -> bool { + if value.is_undefined() || value.is_null() { + false + } else if value.is_bool() { + value.to_bool() + } else if value.is_number() { + let number = value.to_number(); + number != 0.0 && !number.is_nan() + } else { + true + } +} + +fn throw_type(message: &str) -> ! { + throw_with_code(message, "", ErrorKind::TypeError) +} diff --git a/crates/perry-ext-qs/src/parse.rs b/crates/perry-ext-qs/src/parse.rs new file mode 100644 index 0000000000..c22dcee8ee --- /dev/null +++ b/crates/perry-ext-qs/src/parse.rs @@ -0,0 +1,449 @@ +use crate::codec::{self, Charset}; +use crate::options::{DuplicateMode, ParseOptions}; +use perry_ffi::{throw_with_code, ErrorKind}; +use serde_json::{Map, Value}; + +#[derive(Clone, Debug, PartialEq, Eq)] +enum Segment { + Key(String), + Index(usize), + Append, +} + +pub(crate) fn parse(input: &str, options: &mut ParseOptions) -> Value { + let input = if options.ignore_query_prefix { + input.strip_prefix('?').unwrap_or(input) + } else { + input + }; + if input.is_empty() { + return Value::Object(Map::new()); + } + + let mut pairs: Vec<&str> = if options.delimiter.is_empty() { + vec![input] + } else { + input.split(&options.delimiter).collect() + }; + if pairs.len() > options.parameter_limit { + if options.throw_on_limit_exceeded { + throw_with_code( + &format!( + "Parameter limit exceeded. Only {} parameter{} allowed.", + options.parameter_limit, + if options.parameter_limit == 1 { + " is" + } else { + "s are" + } + ), + "", + ErrorKind::RangeError, + ); + } + pairs.truncate(options.parameter_limit); + } + + if options.charset_sentinel { + if let Some((index, charset)) = pairs.iter().enumerate().find_map(|(index, pair)| { + if *pair == "utf8=%E2%9C%93" { + Some((index, Charset::Utf8)) + } else if *pair == "utf8=%26%2310003%3B" { + Some((index, Charset::Latin1)) + } else { + None + } + }) { + options.charset = charset; + pairs.remove(index); + } + } + + let mut root = Value::Object(Map::new()); + for pair in pairs { + let (raw_key, raw_value, had_equals) = match pair.find('=') { + Some(index) => (&pair[..index], &pair[index + 1..], true), + None => (pair, "", false), + }; + let mut key = codec::decode(raw_key, options.charset); + if options.decode_dot_in_keys { + key = key.replace("%2E", ".").replace("%2e", "."); + } + let mut value = codec::decode(raw_value, options.charset); + if options.charset == Charset::Latin1 && options.interpret_numeric_entities { + value = decode_numeric_entities(&value); + } + + let mut segments = parse_segments(&key, options); + if segments.is_empty() || forbidden_path(&segments, options.allow_prototypes) { + continue; + } + + let empty_array = !had_equals + && options.allow_empty_arrays + && matches!(segments.last(), Some(Segment::Append)); + let parsed_value = if empty_array { + segments.pop(); + Value::Array(Vec::new()) + } else if !had_equals && options.strict_null_handling { + Value::Null + } else if options.comma && value.contains(',') { + Value::Array( + value + .split(',') + .map(|part| Value::String(part.to_owned())) + .collect(), + ) + } else { + Value::String(value) + }; + insert(&mut root, &segments, parsed_value, options); + } + root +} + +fn parse_segments(key: &str, options: &ParseOptions) -> Vec { + let use_dots = options.allow_dots || options.decode_dot_in_keys; + let mut raw_segments = Vec::new(); + let mut index = key + .char_indices() + .find_map(|(index, ch)| (ch == '[' || (use_dots && ch == '.')).then_some(index)) + .unwrap_or(key.len()); + raw_segments.push(key[..index].to_owned()); + let mut nested = 0usize; + + while index < key.len() { + match key.as_bytes()[index] { + b'.' if use_dots => { + let start = index + 1; + let end = key[start..] + .char_indices() + .find_map(|(offset, ch)| { + (ch == '[' || (use_dots && ch == '.')).then_some(start + offset) + }) + .unwrap_or(key.len()); + raw_segments.push(key[start..end].to_owned()); + index = end; + } + b'[' => { + let bracket_start = index; + let Some(close_offset) = key[index + 1..].find(']') else { + raw_segments.push(key[index..].to_owned()); + break; + }; + let close = index + 1 + close_offset; + nested += 1; + if nested > options.depth { + if options.strict_depth { + throw_with_code( + &format!( + "Input depth exceeded depth option of {} and strictDepth is true", + options.depth + ), + "", + ErrorKind::RangeError, + ); + } + raw_segments.push(key[bracket_start..].to_owned()); + index = key.len(); + continue; + } + raw_segments.push(key[index + 1..close].to_owned()); + index = close + 1; + } + _ => { + raw_segments.push(key[index..].to_owned()); + break; + } + } + } + + raw_segments + .into_iter() + .enumerate() + .map(|(position, segment)| { + if position > 0 && segment.is_empty() && options.parse_arrays { + Segment::Append + } else if position > 0 && options.parse_arrays { + match segment.parse::() { + Ok(index) if index <= options.array_limit => Segment::Index(index), + _ => Segment::Key(segment), + } + } else { + Segment::Key(segment) + } + }) + .collect() +} + +fn forbidden_path(segments: &[Segment], allow_prototypes: bool) -> bool { + const OBJECT_PROTOTYPE_KEYS: &[&str] = &[ + "__defineGetter__", + "__defineSetter__", + "__lookupGetter__", + "__lookupSetter__", + "constructor", + "hasOwnProperty", + "isPrototypeOf", + "propertyIsEnumerable", + "toLocaleString", + "toString", + "valueOf", + ]; + segments.iter().any(|segment| match segment { + Segment::Key(key) if key == "__proto__" => true, + Segment::Key(key) if !allow_prototypes => OBJECT_PROTOTYPE_KEYS.contains(&key.as_str()), + _ => false, + }) +} + +fn insert(node: &mut Value, segments: &[Segment], value: Value, options: &ParseOptions) { + let Some((segment, rest)) = segments.split_first() else { + merge_leaf(node, value, options.duplicates); + return; + }; + + match segment { + Segment::Key(key) => { + if !node.is_object() { + *node = Value::Object(Map::new()); + } + let object = node.as_object_mut().expect("object initialized"); + if rest.is_empty() { + match object.get_mut(key) { + Some(existing) => merge_leaf(existing, value, options.duplicates), + None => { + object.insert(key.clone(), value); + } + } + return; + } + let child = object + .entry(key.clone()) + .or_insert_with(|| empty_container(&rest[0], options)); + insert(child, rest, value, options); + } + Segment::Index(requested) => { + if !node.is_array() { + *node = Value::Array(Vec::new()); + } + let array = node.as_array_mut().expect("array initialized"); + let position = if options.allow_sparse { + while array.len() <= *requested { + array.push(Value::Null); + } + *requested + } else if *requested < array.len() { + *requested + } else { + if rest.is_empty() { + array.push(value); + return; + } + array.push(empty_container(&rest[0], options)); + let position = array.len() - 1; + insert(&mut array[position], rest, value, options); + return; + }; + if rest.is_empty() { + if options.allow_sparse && array[position].is_null() { + array[position] = value; + } else { + merge_leaf(&mut array[position], value, options.duplicates); + } + } else { + if array[position].is_null() { + array[position] = empty_container(&rest[0], options); + } + insert(&mut array[position], rest, value, options); + } + } + Segment::Append => { + if !node.is_array() { + *node = Value::Array(Vec::new()); + } + let array = node.as_array_mut().expect("array initialized"); + if rest.is_empty() { + array.push(value); + } else { + let mut child = empty_container(&rest[0], options); + insert(&mut child, rest, value, options); + array.push(child); + } + } + } +} + +fn empty_container(next: &Segment, options: &ParseOptions) -> Value { + if options.parse_arrays && matches!(next, Segment::Index(_) | Segment::Append) { + Value::Array(Vec::new()) + } else { + Value::Object(Map::new()) + } +} + +fn merge_leaf(existing: &mut Value, value: Value, mode: DuplicateMode) { + match mode { + DuplicateMode::First => {} + DuplicateMode::Last => *existing = value, + DuplicateMode::Combine => match existing { + Value::Array(values) => values.push(value), + _ => { + let previous = std::mem::replace(existing, Value::Null); + *existing = Value::Array(vec![previous, value]); + } + }, + } +} + +fn decode_numeric_entities(input: &str) -> String { + let mut output = String::with_capacity(input.len()); + let mut rest = input; + while let Some(start) = rest.find("&#") { + output.push_str(&rest[..start]); + let entity = &rest[start + 2..]; + let Some(end) = entity.find(';') else { + output.push_str(&rest[start..]); + return output; + }; + let digits = &entity[..end]; + if let Ok(codepoint) = digits.parse::() { + if let Some(ch) = char::from_u32(codepoint) { + output.push(ch); + } else { + output.push_str(&rest[start..start + end + 3]); + } + } else { + output.push_str(&rest[start..start + end + 3]); + } + rest = &entity[end + 1..]; + } + output.push_str(rest); + output +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parsed(input: &str) -> Value { + parse(input, &mut ParseOptions::default()) + } + + #[test] + fn parses_nested_objects_arrays_and_duplicates() { + assert_eq!( + parsed("customer[name]=Ada&items[0][id]=price_1&items[1][id]=price_2&tag=a&tag=b"), + serde_json::json!({ + "customer": { "name": "Ada" }, + "items": [{ "id": "price_1" }, { "id": "price_2" }], + "tag": ["a", "b"] + }) + ); + } + + #[test] + fn blocks_prototype_pollution_segments() { + assert_eq!( + parsed("safe=yes&__proto__[polluted]=yes&constructor[prototype][bad]=yes"), + serde_json::json!({ "safe": "yes" }) + ); + } + + #[test] + fn array_limit_falls_back_to_object_key() { + assert_eq!(parsed("a[21]=x"), serde_json::json!({ "a": { "21": "x" } })); + } + + #[test] + fn supports_dot_and_sparse_modes() { + let mut options = ParseOptions { + allow_dots: true, + allow_sparse: true, + ..ParseOptions::default() + }; + assert_eq!( + parse("a.b[2]=x", &mut options), + serde_json::json!({ "a": { "b": [null, null, "x"] } }) + ); + } + + #[test] + fn allow_empty_arrays_matches_qs_without_strict_null_mode() { + let mut options = ParseOptions { + allow_empty_arrays: true, + ..ParseOptions::default() + }; + assert_eq!( + parse("foo[]", &mut options), + serde_json::json!({ "foo": [] }) + ); + } + + #[test] + fn duplicates_modes_match_qs() { + let mut first = ParseOptions { + duplicates: DuplicateMode::First, + ..ParseOptions::default() + }; + let mut last = ParseOptions { + duplicates: DuplicateMode::Last, + ..ParseOptions::default() + }; + assert_eq!( + parse("a=b&a=c", &mut first), + serde_json::json!({ "a": "b" }) + ); + assert_eq!(parse("a=b&a=c", &mut last), serde_json::json!({ "a": "c" })); + } + + #[test] + fn query_prefix_comma_and_strict_null_options_match_qs() { + let mut comma = ParseOptions { + ignore_query_prefix: true, + comma: true, + ..ParseOptions::default() + }; + assert_eq!( + parse("?a=b,c", &mut comma), + serde_json::json!({ "a": ["b", "c"] }) + ); + + let mut strict = ParseOptions { + strict_null_handling: true, + ..ParseOptions::default() + }; + assert_eq!( + parse("a&b=", &mut strict), + serde_json::json!({ "a": null, "b": "" }) + ); + } + + #[test] + fn charset_sentinel_depth_and_encoded_dots_match_qs() { + let mut charset = ParseOptions { + charset_sentinel: true, + ..ParseOptions::default() + }; + assert_eq!( + parse("utf8=%26%2310003%3B&a=%F8", &mut charset), + serde_json::json!({ "a": "ø" }) + ); + + assert_eq!( + parsed("a[b][c][d][e][f][g]=h"), + serde_json::json!({ + "a": { "b": { "c": { "d": { "e": { "f": { "[g]": "h" } } } } } } + }) + ); + + let mut dots = ParseOptions { + decode_dot_in_keys: true, + ..ParseOptions::default() + }; + assert_eq!( + parse("a%2Eb=c", &mut dots), + serde_json::json!({ "a": { "b": "c" } }) + ); + } +} diff --git a/crates/perry-ext-qs/src/runtime.rs b/crates/perry-ext-qs/src/runtime.rs new file mode 100644 index 0000000000..8e46266fc7 --- /dev/null +++ b/crates/perry-ext-qs/src/runtime.rs @@ -0,0 +1,134 @@ +use perry_ffi::{ + alloc_string, read_string, ArrayHeader, ClosureHeader, JsClosure, JsString, JsValue, + ObjectHeader, StringHeader, TransientRootScope, TransientRootedNanbox, +}; + +extern "C" { + fn js_array_is_array(value: f64) -> f64; + fn js_date_to_iso_string_or_throw(value: f64) -> *mut StringHeader; + fn js_get_string_pointer_unified(value: f64) -> i64; + fn js_jsvalue_to_string(value: f64) -> *mut StringHeader; + fn js_object_get_field_by_name( + object: *const ObjectHeader, + key: *const StringHeader, + ) -> JsValue; + fn js_object_keys_value(value: f64) -> *mut ArrayHeader; + fn js_util_types_is_date(value: f64) -> f64; + fn js_value_is_closure(value_bits: i64) -> i32; +} + +#[inline] +pub(crate) fn as_f64(value: JsValue) -> f64 { + f64::from_bits(value.bits()) +} + +#[inline] +pub(crate) fn from_f64(value: f64) -> JsValue { + JsValue::from_bits(value.to_bits()) +} + +pub(crate) fn is_array(value: f64) -> bool { + from_f64(unsafe { js_array_is_array(value) }).to_bool() +} + +pub(crate) fn is_date(value: f64) -> bool { + from_f64(unsafe { js_util_types_is_date(value) }).to_bool() +} + +pub(crate) fn is_closure(value: JsValue) -> bool { + unsafe { js_value_is_closure(value.bits() as i64) != 0 } +} + +pub(crate) fn owned_string(scope: &TransientRootScope, value: f64) -> String { + let rooted = scope.root_nanbox(value); + let ptr = unsafe { js_jsvalue_to_string(rooted.get()) }; + read_owned_header(ptr) +} + +pub(crate) fn string_value(scope: &TransientRootScope, value: f64) -> Option { + let rooted = scope.root_nanbox(value); + let js = from_f64(rooted.get()); + if !js.is_any_string() { + return None; + } + let ptr = unsafe { js_get_string_pointer_unified(rooted.get()) } as *mut StringHeader; + Some(read_owned_header(ptr)) +} + +pub(crate) fn date_iso(scope: &TransientRootScope, value: f64) -> String { + let rooted = scope.root_nanbox(value); + let ptr = unsafe { js_date_to_iso_string_or_throw(rooted.get()) }; + read_owned_header(ptr) +} + +pub(crate) fn object_keys( + scope: &TransientRootScope, + value: &TransientRootedNanbox, +) -> TransientRootedNanbox { + let keys = unsafe { js_object_keys_value(value.get()) }; + let boxed = JsValue::from_object_ptr(keys); + scope.root_nanbox(as_f64(boxed)) +} + +pub(crate) fn field_by_name( + scope: &TransientRootScope, + object: &TransientRootedNanbox, + name: &str, +) -> JsValue { + let key = JsValue::from_string_ptr(alloc_string(name).as_raw()); + let key = scope.root_nanbox(as_f64(key)); + field_by_key(object, &key) +} + +pub(crate) fn field_by_key(object: &TransientRootedNanbox, key: &TransientRootedNanbox) -> JsValue { + let key_value = from_f64(key.get()); + let key = if key_value.is_string() { + key_value.as_string_ptr() + } else { + (unsafe { js_get_string_pointer_unified(key.get()) }) as *mut StringHeader + }; + // Materializing an SSO key may allocate and move the object. Reload the + // rooted object only after the key is a stable heap string. + let object = from_f64(object.get()).as_pointer::(); + if object.is_null() || key.is_null() { + JsValue::UNDEFINED + } else { + unsafe { js_object_get_field_by_name(object, key) } + } +} + +pub(crate) fn call1(scope: &TransientRootScope, callback: &TransientRootedNanbox, arg: f64) -> f64 { + let arg = scope.root_nanbox(arg); + let callback_value = from_f64(callback.get()); + let closure = unsafe { + JsClosure::from_raw(callback_value.as_pointer::() as *const ClosureHeader) + }; + unsafe { closure.call1(arg.get()) } +} + +pub(crate) fn call2( + scope: &TransientRootScope, + callback: &TransientRootedNanbox, + arg0: f64, + arg1: f64, +) -> f64 { + let arg0 = scope.root_nanbox(arg0); + let arg1 = scope.root_nanbox(arg1); + let callback_value = from_f64(callback.get()); + let closure = unsafe { + JsClosure::from_raw(callback_value.as_pointer::() as *const ClosureHeader) + }; + unsafe { closure.call2(arg0.get(), arg1.get()) } +} + +pub(crate) fn alloc_string_value(value: &str) -> f64 { + as_f64(JsValue::from_string_ptr(alloc_string(value).as_raw())) +} + +fn read_owned_header(ptr: *mut StringHeader) -> String { + if ptr.is_null() { + return String::new(); + } + let string = unsafe { JsString::from_raw(ptr) }; + read_string(string).unwrap_or_default().to_owned() +} diff --git a/crates/perry-ext-qs/src/stringify.rs b/crates/perry-ext-qs/src/stringify.rs new file mode 100644 index 0000000000..4e62fcf65d --- /dev/null +++ b/crates/perry-ext-qs/src/stringify.rs @@ -0,0 +1,330 @@ +use crate::codec; +use crate::options::{ArrayFormat, StringifyOptions}; +use crate::runtime; +use perry_ffi::{ + js_array_get, js_array_length, throw_with_code, value_byte_slice, ErrorKind, + TransientRootScope, TransientRootedNanbox, +}; +use std::cmp::Ordering; + +pub(crate) fn stringify(value: f64, options: f64) -> String { + let scope = TransientRootScope::enter(); + let options = StringifyOptions::from_js(&scope, options); + let mut root = scope.root_nanbox(value); + + if let Some(filter) = &options.filter { + root = apply_filter(&scope, filter, "", root.get()); + } + + let root_value = runtime::from_f64(root.get()); + if !root_value.is_pointer() || root_value.is_null() || runtime::is_closure(root_value) { + return String::new(); + } + + let mut keys = options + .filter_keys + .clone() + .unwrap_or_else(|| own_keys(&scope, &root)); + sort_keys(&scope, &options, &mut keys); + + let mut values = Vec::new(); + let mut ancestors = vec![root]; + for key in keys { + let value = runtime::field_by_name(&scope, &root, &key); + if options.skip_nulls && value.is_null() { + continue; + } + values.extend(stringify_value( + &scope, + &options, + runtime::as_f64(value), + key, + &mut ancestors, + )); + } + + let joined = values.join(&options.delimiter); + if joined.is_empty() { + return joined; + } + + let mut prefix = String::new(); + if options.add_query_prefix { + prefix.push('?'); + } + if options.charset_sentinel { + match options.charset { + codec::Charset::Utf8 => prefix.push_str("utf8=%E2%9C%93"), + codec::Charset::Latin1 => prefix.push_str("utf8=%26%2310003%3B"), + } + prefix.push_str(&options.delimiter); + } + prefix + joined.as_str() +} + +fn stringify_value( + scope: &TransientRootScope, + options: &StringifyOptions, + raw: f64, + mut prefix: String, + ancestors: &mut Vec, +) -> Vec { + let mut value = scope.root_nanbox(raw); + + if let Some(filter) = &options.filter { + value = apply_filter(scope, filter, &prefix, value.get()); + } else if runtime::is_date(value.get()) { + value = if let Some(callback) = &options.serialize_date { + scope.root_nanbox(runtime::call1(scope, callback, value.get())) + } else { + let iso = runtime::date_iso(scope, value.get()); + scope.root_nanbox(runtime::alloc_string_value(&iso)) + }; + } + + let js = runtime::from_f64(value.get()); + if js.is_null() { + if options.strict_null_handling { + return vec![encode_key(scope, options, &prefix)]; + } + value = scope.root_nanbox(runtime::alloc_string_value("")); + } + + let js = runtime::from_f64(value.get()); + if js.is_undefined() || runtime::is_closure(js) { + return Vec::new(); + } + + if let Some(bytes) = value_byte_slice(js) { + let text = String::from_utf8_lossy(bytes).into_owned(); + return vec![format!( + "{}={}", + encode_key(scope, options, &prefix), + encode_text(scope, options, &text, false) + )]; + } + + if !js.is_pointer() { + return vec![format!( + "{}={}", + encode_key(scope, options, &prefix), + encode_value(scope, options, value.get()) + )]; + } + + let is_array = runtime::is_array(value.get()); + if is_array && options.array_format == ArrayFormat::Comma { + return stringify_comma_array(scope, options, &value, prefix); + } + + if ancestors + .iter() + .any(|ancestor| same_heap_value(ancestor.get(), value.get())) + { + throw_with_code("Cyclic object value", "", ErrorKind::RangeError); + } + ancestors.push(value); + + let mut keys = options + .filter_keys + .clone() + .unwrap_or_else(|| own_keys(scope, &value)); + sort_keys(scope, options, &mut keys); + + if options.encode_dot_in_keys { + prefix = prefix.replace('.', "%2E"); + } + let adjusted_prefix = if is_array + && options.array_format == ArrayFormat::Comma + && options.comma_round_trip + && keys.len() == 1 + { + format!("{prefix}[]") + } else { + prefix + }; + + if options.allow_empty_arrays && is_array && keys.is_empty() { + ancestors.pop(); + return vec![format!("{adjusted_prefix}[]")]; + } + + let mut values = Vec::new(); + for key in keys { + let child = runtime::field_by_name(scope, &value, &key); + if options.skip_nulls && child.is_null() { + continue; + } + let key = if options.allow_dots && options.encode_dot_in_keys { + key.replace('.', "%2E") + } else { + key + }; + let child_prefix = if is_array { + match options.array_format { + ArrayFormat::Brackets => format!("{adjusted_prefix}[]"), + ArrayFormat::Indices => format!("{adjusted_prefix}[{key}]"), + ArrayFormat::Repeat => adjusted_prefix.clone(), + ArrayFormat::Comma => unreachable!(), + } + } else if options.allow_dots { + format!("{adjusted_prefix}.{key}") + } else { + format!("{adjusted_prefix}[{key}]") + }; + values.extend(stringify_value( + scope, + options, + runtime::as_f64(child), + child_prefix, + ancestors, + )); + } + ancestors.pop(); + values +} + +fn stringify_comma_array( + scope: &TransientRootScope, + options: &StringifyOptions, + value: &TransientRootedNanbox, + mut prefix: String, +) -> Vec { + let array = runtime::from_f64(value.get()).as_pointer(); + let length = unsafe { js_array_length(array) }; + if length == 0 { + return if options.allow_empty_arrays { + vec![format!("{prefix}[]")] + } else { + Vec::new() + }; + } + + if options.comma_round_trip && length == 1 { + prefix.push_str("[]"); + } + let mut parts = Vec::with_capacity(length as usize); + for index in 0..length { + let array = runtime::from_f64(value.get()).as_pointer(); + let mut item = unsafe { js_array_get(array, index) }; + if runtime::is_date(runtime::as_f64(item)) { + item = if let Some(callback) = &options.serialize_date { + runtime::from_f64(runtime::call1(scope, callback, runtime::as_f64(item))) + } else { + let iso = runtime::date_iso(scope, runtime::as_f64(item)); + runtime::from_f64(runtime::alloc_string_value(&iso)) + }; + } + if item.is_null() || item.is_undefined() { + parts.push(String::new()); + } else { + let text = runtime::owned_string(scope, runtime::as_f64(item)); + parts.push(if options.encode_values_only && options.encode { + encode_text(scope, options, &text, false) + } else { + text + }); + } + } + let joined = parts.join(","); + if joined.is_empty() && options.strict_null_handling { + vec![encode_key(scope, options, &prefix)] + } else { + let encoded_value = if options.encode_values_only && options.encode { + codec::format_encoded(joined, options.format) + } else { + encode_text(scope, options, &joined, false) + }; + vec![format!( + "{}={}", + encode_key(scope, options, &prefix), + encoded_value + )] + } +} + +fn own_keys(scope: &TransientRootScope, value: &TransientRootedNanbox) -> Vec { + let keys = runtime::object_keys(scope, value); + let array = runtime::from_f64(keys.get()).as_pointer(); + let length = unsafe { js_array_length(array) }; + let mut result = Vec::with_capacity(length as usize); + for index in 0..length { + let array = runtime::from_f64(keys.get()).as_pointer(); + let key = unsafe { js_array_get(array, index) }; + result.push(runtime::owned_string(scope, runtime::as_f64(key))); + } + result +} + +fn sort_keys(scope: &TransientRootScope, options: &StringifyOptions, keys: &mut [String]) { + let Some(callback) = &options.sort else { + return; + }; + keys.sort_by(|left, right| { + let left = scope.root_nanbox(runtime::alloc_string_value(left)); + let right = scope.root_nanbox(runtime::alloc_string_value(right)); + let result = runtime::from_f64(runtime::call2(scope, callback, left.get(), right.get())); + let number = result.to_number(); + if number < 0.0 { + Ordering::Less + } else if number > 0.0 { + Ordering::Greater + } else { + Ordering::Equal + } + }); +} + +fn apply_filter( + scope: &TransientRootScope, + callback: &TransientRootedNanbox, + prefix: &str, + value: f64, +) -> TransientRootedNanbox { + let value = scope.root_nanbox(value); + let prefix = scope.root_nanbox(runtime::alloc_string_value(prefix)); + scope.root_nanbox(runtime::call2(scope, callback, prefix.get(), value.get())) +} + +fn encode_key(scope: &TransientRootScope, options: &StringifyOptions, key: &str) -> String { + if options.encode_values_only { + codec::format_encoded(key.to_owned(), options.format) + } else { + encode_text(scope, options, key, true) + } +} + +fn encode_value(scope: &TransientRootScope, options: &StringifyOptions, value: f64) -> String { + if !options.encode { + return codec::format_encoded(runtime::owned_string(scope, value), options.format); + } + if let Some(callback) = &options.encoder { + let encoded = runtime::call1(scope, callback, value); + return codec::format_encoded(runtime::owned_string(scope, encoded), options.format); + } + let text = runtime::owned_string(scope, value); + codec::encode(&text, options.charset, options.format) +} + +fn encode_text( + scope: &TransientRootScope, + options: &StringifyOptions, + text: &str, + _is_key: bool, +) -> String { + if !options.encode { + return codec::format_encoded(text.to_owned(), options.format); + } + if let Some(callback) = &options.encoder { + let value = runtime::alloc_string_value(text); + let encoded = runtime::call1(scope, callback, value); + return codec::format_encoded(runtime::owned_string(scope, encoded), options.format); + } + codec::encode(text, options.charset, options.format) +} + +fn same_heap_value(left: f64, right: f64) -> bool { + let left = runtime::from_f64(left); + let right = runtime::from_f64(right); + left.is_pointer() && right.is_pointer() && left.as_pointer::() == right.as_pointer::() +} diff --git a/crates/perry-ext-qs/src/test_async_shims.rs b/crates/perry-ext-qs/src/test_async_shims.rs new file mode 100644 index 0000000000..30431722a4 --- /dev/null +++ b/crates/perry-ext-qs/src/test_async_shims.rs @@ -0,0 +1,113 @@ +//! Test-only host shims for the standalone extension test binary. + +use perry_ffi::{NativeAsyncCompletion, Promise}; +use std::ffi::c_void; + +#[no_mangle] +pub extern "C" fn perry_ffi_promise_new() -> *mut Promise { + perry_runtime::promise::js_promise_new() as *mut Promise +} + +#[no_mangle] +pub extern "C" fn perry_ffi_promise_resolve_bits(promise: *mut Promise, bits: u64) { + perry_runtime::promise::js_promise_resolve( + promise as *mut perry_runtime::Promise, + f64::from_bits(bits), + ); +} + +#[no_mangle] +pub extern "C" fn perry_ffi_promise_reject_bits(promise: *mut Promise, bits: u64) { + perry_runtime::promise::js_promise_reject( + promise as *mut perry_runtime::Promise, + f64::from_bits(bits), + ); +} + +#[no_mangle] +pub extern "C" fn perry_ffi_promise_resolve_deferred( + promise: *mut Promise, + context: *mut c_void, + invoke: extern "C" fn(*mut c_void) -> u64, +) { + perry_ffi_promise_resolve_bits(promise, invoke(context)); +} + +#[no_mangle] +pub extern "C" fn perry_ffi_promise_reject_deferred( + promise: *mut Promise, + context: *mut c_void, + invoke: extern "C" fn(*mut c_void) -> u64, +) { + perry_ffi_promise_reject_bits(promise, invoke(context)); +} + +#[no_mangle] +pub extern "C" fn perry_ffi_spawn_blocking( + context: *mut c_void, + invoke: extern "C" fn(*mut c_void), +) { + invoke(context); +} + +#[no_mangle] +pub extern "C" fn perry_ffi_spawn_blocking_with_reactor( + context: *mut c_void, + invoke: extern "C" fn(*mut c_void), +) { + invoke(context); +} + +#[no_mangle] +pub extern "C" fn perry_ffi_native_async_new(_flags: u32) -> *mut NativeAsyncCompletion { + std::ptr::null_mut() +} + +#[no_mangle] +pub extern "C" fn perry_ffi_native_async_promise( + _token: *mut NativeAsyncCompletion, +) -> *mut Promise { + std::ptr::null_mut() +} + +#[no_mangle] +pub extern "C" fn perry_ffi_native_async_resolve_bits( + _token: *mut NativeAsyncCompletion, + _bits: u64, +) -> i32 { + 0 +} + +#[no_mangle] +pub extern "C" fn perry_ffi_native_async_reject_bits( + _token: *mut NativeAsyncCompletion, + _bits: u64, +) -> i32 { + 0 +} + +#[no_mangle] +pub extern "C" fn perry_ffi_native_async_reject_string( + _token: *mut NativeAsyncCompletion, + _data: *const u8, + _len: usize, +) -> i32 { + 0 +} + +#[no_mangle] +pub extern "C" fn perry_ffi_native_async_cancel(_token: *mut NativeAsyncCompletion) -> i32 { + 0 +} + +#[no_mangle] +pub extern "C" fn perry_ffi_native_async_attach_handle( + _token: *mut NativeAsyncCompletion, + _handle_bits: u64, + _cleanup_flags: u32, +) -> i32 { + 0 +} + +#[no_mangle] +pub extern "C" fn perry_ffi_run_pending(_budget_ms: u64) {} diff --git a/crates/perry/src/commands/compile/well_known.rs b/crates/perry/src/commands/compile/well_known.rs index 95ced11120..573723d2e9 100644 --- a/crates/perry/src/commands/compile/well_known.rs +++ b/crates/perry/src/commands/compile/well_known.rs @@ -473,7 +473,7 @@ mod tests { /// stay `Partial` and are never silently treated as complete drop-ins. #[test] fn shipped_subset_bindings_are_partial() { - for name in ["undici", "node-forge", "lru-cache"] { + for name in ["undici", "node-forge", "lru-cache", "qs"] { let b = lookup_well_known(name).unwrap_or_else(|| panic!("{name} registered")); assert_eq!( b.compat, diff --git a/crates/perry/tests/issue_8751_qs_native_shim.rs b/crates/perry/tests/issue_8751_qs_native_shim.rs new file mode 100644 index 0000000000..7e3cf96599 --- /dev/null +++ b/crates/perry/tests/issue_8751_qs_native_shim.rs @@ -0,0 +1,154 @@ +//! Regression coverage for #8751: Stripe's CommonJS request helper requires +//! `qs` and calls `qs.stringify` with indexed arrays and a Date serializer. +//! Compiling upstream qs pulls in get-intrinsic's legacy ES-shims chain, which +//! is hostile to Perry's AOT path. The bundled binding must win even when a +//! deliberately broken on-disk qs package is present transitively. + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +#[test] +fn stripe_style_dependency_uses_native_qs_without_compiling_installed_source() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + + std::fs::write( + root.join("package.json"), + r#"{ + "name": "issue-8751", + "type": "module", + "perry": { + "compilePackages": ["stripe-fixture"], + "allow": { "compilePackages": ["stripe-fixture"] } + } +}"#, + ) + .expect("write app package.json"); + + let stripe = root.join("node_modules").join("stripe-fixture"); + std::fs::create_dir_all(&stripe).expect("mkdir stripe fixture"); + std::fs::write( + stripe.join("package.json"), + r#"{ "name": "stripe-fixture", "version": "1.0.0", "main": "index.js" }"#, + ) + .expect("write stripe fixture package.json"); + std::fs::write( + stripe.join("index.js"), + r#"'use strict'; +const qs = require('qs'); + +exports.encodeStripePayload = function encodeStripePayload(data) { + return qs.stringify(data, { + serializeDate: function serializeDate(date) { + return Math.floor(date.getTime() / 1000).toString(); + }, + arrayFormat: 'indices' + }).replace(/%5B/g, '[').replace(/%5D/g, ']'); +}; +"#, + ) + .expect("write stripe fixture source"); + + // If module resolution ever falls back to compiling the installed qs, + // compilation or startup fails with this sentinel. A get-intrinsic stub is + // included to retain the transitive shape reported in #8751. + let qs = root.join("node_modules").join("qs"); + std::fs::create_dir_all(&qs).expect("mkdir hostile qs"); + std::fs::write( + qs.join("package.json"), + r#"{ "name": "qs", "version": "6.15.3", "main": "index.js" }"#, + ) + .expect("write hostile qs package.json"); + std::fs::write( + qs.join("index.js"), + "throw new Error('AOT-HOSTILE-QS-SOURCE-WAS-COMPILED');\n", + ) + .expect("write hostile qs source"); + let intrinsic = root.join("node_modules").join("get-intrinsic"); + std::fs::create_dir_all(&intrinsic).expect("mkdir get-intrinsic"); + std::fs::write( + intrinsic.join("package.json"), + r#"{ "name": "get-intrinsic", "version": "1.3.0", "main": "index.js" }"#, + ) + .expect("write get-intrinsic package.json"); + std::fs::write( + intrinsic.join("index.js"), + "throw new SyntaxError('intrinsic %% does not exist!');\n", + ) + .expect("write get-intrinsic source"); + + let entry = root.join("main.ts"); + std::fs::write( + &entry, + r#" +import qsDefault from "qs"; +import * as qsNamespace from "qs"; +import { parse, stringify } from "qs"; +import { encodeStripePayload } from "stripe-fixture"; + +const payload = { + customer: { name: "Ada Lovelace" }, + items: [ + { price: "p_1", quantity: 2 }, + { price: "p_2", quantity: 1 } + ], + metadata: { empty: null }, + created: new Date("2024-01-02T03:04:05Z") +}; + +console.log(encodeStripePayload(payload)); +console.log(JSON.stringify(parse("customer[name]=Ada%20Lovelace&items[0][price]=p_1&items[1][price]=p_2&tag=a&tag=b"))); +console.log(stringify({ a: ["x", "y"], empty: [], nil: null }, { + arrayFormat: "brackets", + allowEmptyArrays: true, + strictNullHandling: true, + addQueryPrefix: true +})); +console.log(stringify({ z: "last", a: "first" }, { + sort: (left: string, right: string) => left < right ? -1 : left > right ? 1 : 0, + encoder: (value: any) => "X" + String(value) +})); +console.log(qsNamespace.stringify({ a: 1 }), qsDefault.stringify({ a: 1 }), stringify({ a: 1 })); +"#, + ) + .expect("write entry"); + + let output = root.join("main_bin"); + let compile = Command::new(perry_bin()) + .current_dir(root) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .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).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) + ); + assert_eq!( + String::from_utf8_lossy(&run.stdout), + concat!( + "customer[name]=Ada%20Lovelace&items[0][price]=p_1&items[0][quantity]=2&items[1][price]=p_2&items[1][quantity]=1&metadata[empty]=&created=1704164645\n", + "{\"customer\":{\"name\":\"Ada Lovelace\"},\"items\":[{\"price\":\"p_1\"},{\"price\":\"p_2\"}],\"tag\":[\"a\",\"b\"]}\n", + "?a%5B%5D=x&a%5B%5D=y&empty[]&nil\n", + "Xa=Xfirst&Xz=Xlast\n", + "a=1 a=1 a=1\n" + ) + ); +} diff --git a/crates/perry/well_known_bindings.toml b/crates/perry/well_known_bindings.toml index b32b4ffa3c..b72de46d51 100644 --- a/crates/perry/well_known_bindings.toml +++ b/crates/perry/well_known_bindings.toml @@ -91,6 +91,23 @@ repo = "https://github.com/uuidjs/uuid" ref = "70177807e9229dfacde2038dc1e722f1828f358a" ported-at = "14.0.1" date = "2026-07-30" +[bindings.qs] +crate = "perry-ext-qs" +lib = "perry_ext_qs" +tracking = "#8751" +# Partial: stringify covers qs' ordinary scalar/object/array surface and the +# callback/options used by Stripe. parse covers the safe nested-query subset. +# Regex delimiters and the full decoder/filter extension-hook contracts remain +# intentionally outside this shim. +compat = "partial" + +[bindings.qs.upstream] +version = "6.15.3" +sha256 = "c0278b636e7a016d6e835cd8f194a63c276dff430620e4a04344a4ba8892c0f9" +repo = "https://github.com/ljharb/qs.git" +ref = "18d085e919dae70c8f1b200ab99323058edab2c2" +ported-at = "6.15.3" +date = "2026-08-25" [bindings.bcrypt] crate = "perry-ext-bcrypt" lib = "perry_ext_bcrypt" diff --git a/docs/src/native-libraries/governance.md b/docs/src/native-libraries/governance.md index c047c79845..dd9a302695 100644 --- a/docs/src/native-libraries/governance.md +++ b/docs/src/native-libraries/governance.md @@ -114,6 +114,7 @@ from `well_known_bindings.toml`. Regenerate this table with | `perry-ext-parcel-watcher` | `@parcel/watcher`
`@parcel/watcher-darwin-arm64`
`@parcel/watcher-darwin-x64`
`@parcel/watcher-linux-arm64-glibc`
`@parcel/watcher-linux-arm64-musl`
`@parcel/watcher-linux-x64-glibc`
`@parcel/watcher-linux-x64-musl`
`@parcel/watcher-win32-arm64`
`@parcel/watcher-win32-x64` | External integration | Move to an external native package | Bundled; migration pending | | `perry-ext-pdf` | `@perryts/pdf` | External integration | Move to an external native package | Bundled; migration pending | | `perry-ext-pg` | `pg` | Source package | Compile the upstream package source | Bundled; migration pending | +| `perry-ext-qs` | `qs` | Source package | Compile the upstream package source | Bundled; migration pending | | `perry-ext-ratelimit` | `rate-limiter-flexible` | Source package | Compile the upstream package source | Bundled; migration pending | | `perry-ext-sharp` | `sharp` | External integration | Move to an external native package | Bundled; migration pending | | `perry-ext-streams` | `streams` | Runtime API | Keep near core; consolidate when practical | Bundled; retained | diff --git a/workspace-architecture.json b/workspace-architecture.json index eddd42c1b0..9f5fc0ee49 100644 --- a/workspace-architecture.json +++ b/workspace-architecture.json @@ -25,7 +25,7 @@ ] }, "baseline": { - "workspace_members": 79, + "workspace_members": 80, "default_dependency_closure": [ "perry", "perry-api-manifest", @@ -66,7 +66,7 @@ "perry-updater" ], "decision_counts": { - "externalize": 32, + "externalize": 33, "keep": 42, "merge": 1, "remove": 1, @@ -293,6 +293,11 @@ "decision": "externalize", "migration": "compile-source" }, + "perry-ext-qs": { + "category": "binding", + "decision": "externalize", + "migration": "compile-source" + }, "perry-ext-ratelimit": { "category": "binding", "decision": "externalize",