diff --git a/CODE_ARCHITECTURE.md b/CODE_ARCHITECTURE.md index 170ee8e..8ccadf5 100644 --- a/CODE_ARCHITECTURE.md +++ b/CODE_ARCHITECTURE.md @@ -38,8 +38,8 @@ The compatibility split is intentional: - Go and rust-native already use core structural decoding. - Python and Node use native graph adapters that preserve host object identity and cycles. -- Java, C#/.NET, Ruby, and PHP retain full structural copies until the - remaining C-ABI binding migration. +- Java, C#/.NET, Ruby, and PHP use the opaque C graph API. Their shims only + allocate host values, connect graph edges, and map leaf values. The rule, applied to every change: @@ -116,15 +116,15 @@ The core graph parser establishes the evaluate-wire and launch-parser ownership | Python | PyO3 native graph adapter for evaluate results; Playwright-shaped validation and ergonomics remain in Python. | | Go | C ABI binding that already uses the core's flattened structural decoder and maps leaf values. | | rust-native | In-process facade that already uses core structural decoding and maps leaf values. | -| C ABI | Legacy flattened JSON decoder and core launch parser; it does not preserve host graph identity. | -| Java / C#/.NET / Ruby / PHP | C-ABI bindings with full structural copies pending migration to the core decoder. | +| C ABI | Legacy flattened JSON decoder plus an opaque identity-preserving graph API; both use the core parser. | +| Java / C#/.NET / Ruby / PHP | Two-pass host materializers over the opaque C graph API; leaf conversion stays language-native. | | Node | napi native graph adapter; the typed camelCase coercion veneer remains in Node. | The remaining cross-shim concerns have these owners: | Concern | Current owner | Remaining work | | --- | --- | --- | -| Evaluate wire | Core parser; C ABI uses its flattened compatibility decoder, while Python and Node use native graph adapters. | Migrate Java, C#/.NET, Ruby, and PHP to the core decoder. | +| Evaluate wire | Core parser; C ABI exposes flattened compatibility and opaque graph paths. Every identity-preserving binding materializes the core graph. | Keep only language-native leaf conversion in binding shims. | | Launch options | Core parser accepts canonical snake_case and current camelCase aliases. | Keep C callers on canonical snake_case; retain language-specific validation and coercion only where required for compatibility. | | Screenshot options | Core parser. | Keep language veneers limited to marshalling and native coercion. | | Data URL / JSON equality / manifest validation | Binding harnesses. | Move behind C-ABI helpers when harness work next opens. | @@ -138,9 +138,9 @@ Ordered tracks; each is independently landable and keeps parity tests green. legacy C ABI decoder flattened for compatibility. Use native graph adapters in Python and Node, and retain core structural decoding in Go and rust-native. -2. **Migrate the remaining C-ABI bindings.** Move Java, C#/.NET, Ruby, and PHP - from full structural copies to the core graph contract. This remains - pending; do not treat those migrations as complete. +2. **Expose the graph through the C ABI (this change).** Java, C#/.NET, Ruby, + and PHP materialize the opaque core graph instead of parsing wire wrappers, + tags, and references in each language. 3. **Maintain the generated async Python facade.** Keep mechanical delegation methods generated from `sync_api.py` signatures, keep hand-written code limited to methods with real async semantics, and keep the checked-in @@ -281,7 +281,8 @@ Chrome-for-Testing installer is linux x86_64-only. 1. Preserve and extend the core evaluate-wire and launch-parser contract without changing the legacy C ABI behavior. -2. Migrate the remaining C-ABI bindings; this is not complete. +2. Keep every language binding limited to graph materialization, native leaf + conversion, ownership, and public validation. 3. Maintain the generated async Python facade and its ownership checks. 4. Extract Python event waiters/context managers into `python/rustwright/events.py` (pairs with roadmap track 7). diff --git a/bindings/CONTRACT.md b/bindings/CONTRACT.md index 4998558..d4ac0ff 100644 --- a/bindings/CONTRACT.md +++ b/bindings/CONTRACT.md @@ -39,6 +39,28 @@ Opaque handles have no public layout: ```c typedef struct RwBrowser RwBrowser; typedef struct RwPage RwPage; +typedef struct RwWireGraph RwWireGraph; +typedef size_t RwWireNodeId; +typedef int32_t RwWireNodeKind; +#define RW_WIRE_NODE_NULL 0 +#define RW_WIRE_NODE_BOOL 1 +#define RW_WIRE_NODE_SIGNED 2 +#define RW_WIRE_NODE_UNSIGNED 3 +#define RW_WIRE_NODE_FLOAT 4 +#define RW_WIRE_NODE_STRING 5 +#define RW_WIRE_NODE_ARRAY 6 +#define RW_WIRE_NODE_OBJECT 7 +#define RW_WIRE_NODE_LEAF 8 +typedef int32_t RwWireLeafKind; +#define RW_WIRE_LEAF_UNSERIALIZABLE 0 +#define RW_WIRE_LEAF_BIGINT 1 +#define RW_WIRE_LEAF_DATE 2 +#define RW_WIRE_LEAF_REGEXP 3 +#define RW_WIRE_LEAF_URL 4 +#define RW_WIRE_LEAF_ERROR 5 +#define RW_WIRE_LEAF_UNDEFINED 6 +#define RW_WIRE_LEAF_SYMBOL 7 +#define RW_WIRE_LEAF_FUNCTION 8 ``` The complete exported ABI is: @@ -51,6 +73,63 @@ void rw_bytes_free(uint8_t *buf, size_t len); /* evaluate wire */ int32_t rw_decode_wire(const char *wire_json, char **out_json); +int32_t rw_wire_graph_parse(const char *wire_json, + RwWireGraph **out_graph); +void rw_wire_graph_free(RwWireGraph *graph); +int32_t rw_wire_graph_node_count(const RwWireGraph *graph, + size_t *out_count); +int32_t rw_wire_graph_root(const RwWireGraph *graph, + RwWireNodeId *out_root); +int32_t rw_wire_graph_node_kind(const RwWireGraph *graph, + RwWireNodeId node, + RwWireNodeKind *out_kind); +int32_t rw_wire_graph_get_bool(const RwWireGraph *graph, + RwWireNodeId node, + int32_t *out_value); +int32_t rw_wire_graph_get_signed(const RwWireGraph *graph, + RwWireNodeId node, + int64_t *out_value); +int32_t rw_wire_graph_get_unsigned(const RwWireGraph *graph, + RwWireNodeId node, + uint64_t *out_value); +int32_t rw_wire_graph_get_float(const RwWireGraph *graph, + RwWireNodeId node, + double *out_value); +int32_t rw_wire_graph_get_string(const RwWireGraph *graph, + RwWireNodeId node, + const uint8_t **out_data, + size_t *out_len); +int32_t rw_wire_graph_array_length(const RwWireGraph *graph, + RwWireNodeId node, + size_t *out_len); +int32_t rw_wire_graph_array_child(const RwWireGraph *graph, + RwWireNodeId node, + size_t index, + RwWireNodeId *out_child); +int32_t rw_wire_graph_object_length(const RwWireGraph *graph, + RwWireNodeId node, + size_t *out_len); +int32_t rw_wire_graph_object_key(const RwWireGraph *graph, + RwWireNodeId node, + size_t index, + const uint8_t **out_data, + size_t *out_len); +int32_t rw_wire_graph_object_child(const RwWireGraph *graph, + RwWireNodeId node, + size_t index, + RwWireNodeId *out_child); +int32_t rw_wire_graph_leaf_kind(const RwWireGraph *graph, + RwWireNodeId node, + RwWireLeafKind *out_kind); +int32_t rw_wire_graph_leaf_field_count(const RwWireGraph *graph, + RwWireNodeId node, + size_t *out_count); +int32_t rw_wire_graph_leaf_field(const RwWireGraph *graph, + RwWireNodeId node, + size_t index, + const uint8_t **out_data, + size_t *out_len); + /* Chromium */ int32_t rw_chromium_executable_path(char **out_path); @@ -173,10 +252,11 @@ non-cyclic references are duplicated, and references that form cycles become `{"__rustwright_cdp_cycle__": true}`. Leaf scalar tags remain in the output for binding-specific native mapping. This behavior is compatibility-stable. -The PyO3 and napi graph adapters use the core graph representation instead. -They allocate host arrays and objects with stable identity, preserve repeated -references and cycles, and keep leaf conversion policy in the adapter. These -graph adapters must not be replaced with the legacy flattened C ABI decoder. +Identity-preserving bindings use `rw_wire_graph_parse`. They allocate all +host containers by dense node id before they fill array and object edges. +This preserves repeated references, cycles, and object entry order. PyO3 and +napi use the same core graph directly. None of these adapters may substitute +the flattened `rw_decode_wire` compatibility path. The core serializer is the single source of truth for this vocabulary; a binding maps the core's tags to its closest native representation and must not @@ -185,6 +265,17 @@ values are JSON-compatible and never require cycles; a runner must at least recursively decode array/object wrappers before capture or `assertEval` comparison. +`RwWireGraph` is immutable and caller-owned. Borrowed string, object-key, and +leaf-field byte views remain valid only until `rw_wire_graph_free`. Bindings +must copy each view by its explicit length before graph release. The length can +include embedded NUL bytes. Empty views use NULL plus zero. + +Node and leaf tag values are fixed in `capi/include/rustwright.h`, where both +public kind aliases are `int32_t` rather than implementation-defined C enums. +Leaf fields are positional: unserializable, bigint, date, and URL have one +field; regexp has pattern then flags; error has name, message, then stack; +undefined, symbol, and function have no fields. + ### Thin-shim rule (single source of logic) Any behavior expressible as a pure function of JSON-in/JSON-out — launch and diff --git a/capi/examples/smoke.c b/capi/examples/smoke.c index fa329a3..cb0062f 100644 --- a/capi/examples/smoke.c +++ b/capi/examples/smoke.c @@ -5,6 +5,11 @@ #include #include +_Static_assert(sizeof(RwWireNodeKind) == 4, + "RwWireNodeKind must remain a 32-bit ABI type"); +_Static_assert(sizeof(RwWireLeafKind) == 4, + "RwWireLeafKind must remain a 32-bit ABI type"); + static void fail(const char *operation) { const char *error = rw_last_error(); fprintf(stderr, "%s failed: %s\n", operation, error ? error : "unknown error"); diff --git a/capi/include/rustwright.h b/capi/include/rustwright.h index 25c1504..28d793c 100644 --- a/capi/include/rustwright.h +++ b/capi/include/rustwright.h @@ -14,6 +14,38 @@ typedef struct RwBrowser RwBrowser; /** Opaque page handle owned by the caller. */ typedef struct RwPage RwPage; +/** Opaque immutable parsed wire graph owned by the caller. */ +typedef struct RwWireGraph RwWireGraph; + +/** Dense node ids are indices into the immutable graph. */ +typedef size_t RwWireNodeId; + +/* + * These tags use a fixed-width integer type. C enum storage is implementation + * defined and may not match the Rust ABI when passed through a pointer. + */ +typedef int32_t RwWireNodeKind; +#define RW_WIRE_NODE_NULL 0 +#define RW_WIRE_NODE_BOOL 1 +#define RW_WIRE_NODE_SIGNED 2 +#define RW_WIRE_NODE_UNSIGNED 3 +#define RW_WIRE_NODE_FLOAT 4 +#define RW_WIRE_NODE_STRING 5 +#define RW_WIRE_NODE_ARRAY 6 +#define RW_WIRE_NODE_OBJECT 7 +#define RW_WIRE_NODE_LEAF 8 + +typedef int32_t RwWireLeafKind; +#define RW_WIRE_LEAF_UNSERIALIZABLE 0 +#define RW_WIRE_LEAF_BIGINT 1 +#define RW_WIRE_LEAF_DATE 2 +#define RW_WIRE_LEAF_REGEXP 3 +#define RW_WIRE_LEAF_URL 4 +#define RW_WIRE_LEAF_ERROR 5 +#define RW_WIRE_LEAF_UNDEFINED 6 +#define RW_WIRE_LEAF_SYMBOL 7 +#define RW_WIRE_LEAF_FUNCTION 8 + /** * Return the current thread's last error as borrowed UTF-8. * @@ -44,6 +76,110 @@ void rw_bytes_free(uint8_t *buf, size_t len); */ int32_t rw_decode_wire(const char *wire_json, char **out_json); +/** + * Parse the evaluate wire format into an immutable graph. + * + * The graph owns all nodes and strings. On success, free it with + * rw_wire_graph_free. On failure, `*out_graph` is NULL. + */ +int32_t rw_wire_graph_parse(const char *wire_json, RwWireGraph **out_graph); + +/** Release a graph and all borrowed views into it. NULL is accepted. */ +void rw_wire_graph_free(RwWireGraph *graph); + +/** Return the dense number of nodes in a graph. */ +int32_t rw_wire_graph_node_count(const RwWireGraph *graph, size_t *out_count); + +/** Return the graph root's dense node id. */ +int32_t rw_wire_graph_root(const RwWireGraph *graph, RwWireNodeId *out_root); + +/** Return a node's kind. */ +int32_t rw_wire_graph_node_kind(const RwWireGraph *graph, + RwWireNodeId node, + RwWireNodeKind *out_kind); + +/** Read a boolean node as 0 or 1. */ +int32_t rw_wire_graph_get_bool(const RwWireGraph *graph, + RwWireNodeId node, + int32_t *out_value); + +/** Read a signed integer node. */ +int32_t rw_wire_graph_get_signed(const RwWireGraph *graph, + RwWireNodeId node, + int64_t *out_value); + +/** Read an unsigned integer node. */ +int32_t rw_wire_graph_get_unsigned(const RwWireGraph *graph, + RwWireNodeId node, + uint64_t *out_value); + +/** Read a floating-point node. */ +int32_t rw_wire_graph_get_float(const RwWireGraph *graph, + RwWireNodeId node, + double *out_value); + +/** + * Read a string node as a borrowed byte view. + * + * The view includes embedded NUL bytes and remains valid until graph free. + * Empty strings return NULL and zero. + */ +int32_t rw_wire_graph_get_string(const RwWireGraph *graph, + RwWireNodeId node, + const uint8_t **out_data, + size_t *out_len); + +/** Return an array node's length. */ +int32_t rw_wire_graph_array_length(const RwWireGraph *graph, + RwWireNodeId node, + size_t *out_len); + +/** Return an array child node id in native order. */ +int32_t rw_wire_graph_array_child(const RwWireGraph *graph, + RwWireNodeId node, + size_t index, + RwWireNodeId *out_child); + +/** Return an object node's entry count. */ +int32_t rw_wire_graph_object_length(const RwWireGraph *graph, + RwWireNodeId node, + size_t *out_len); + +/** Return an object key as a borrowed byte view in native order. */ +int32_t rw_wire_graph_object_key(const RwWireGraph *graph, + RwWireNodeId node, + size_t index, + const uint8_t **out_data, + size_t *out_len); + +/** Return an object child node id in native order. */ +int32_t rw_wire_graph_object_child(const RwWireGraph *graph, + RwWireNodeId node, + size_t index, + RwWireNodeId *out_child); + +/** Return a leaf's canonical kind. */ +int32_t rw_wire_graph_leaf_kind(const RwWireGraph *graph, + RwWireNodeId node, + RwWireLeafKind *out_kind); + +/** + * Return the positional field count for a leaf. + * + * Unserializable, bigint, date, and URL have one field; regexp has two; + * error has three; undefined, symbol, and function have zero. + */ +int32_t rw_wire_graph_leaf_field_count(const RwWireGraph *graph, + RwWireNodeId node, + size_t *out_count); + +/** Return one positional leaf field as a borrowed byte view. */ +int32_t rw_wire_graph_leaf_field(const RwWireGraph *graph, + RwWireNodeId node, + size_t index, + const uint8_t **out_data, + size_t *out_len); + /** * Discover Chromium and return its executable path. * diff --git a/capi/src/lib.rs b/capi/src/lib.rs index d9c588e..cb96bc9 100644 --- a/capi/src/lib.rs +++ b/capi/src/lib.rs @@ -20,6 +20,85 @@ pub struct RwBrowser { pub struct RwPage { inner: rw::RustwrightPage, } +/// Opaque immutable parsed wire graph. Its layout is intentionally not part of the ABI. +pub struct RwWireGraph { + inner: rw::WireGraph, +} + +/// Dense node ids are indices into an immutable wire graph. +pub type RwWireNodeId = usize; + +#[repr(i32)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RwWireNodeKind { + Null = 0, + Bool = 1, + Signed = 2, + Unsigned = 3, + Float = 4, + String = 5, + Array = 6, + Object = 7, + Leaf = 8, +} + +impl From for i32 { + fn from(kind: RwWireNodeKind) -> Self { + match kind { + RwWireNodeKind::Null => 0, + RwWireNodeKind::Bool => 1, + RwWireNodeKind::Signed => 2, + RwWireNodeKind::Unsigned => 3, + RwWireNodeKind::Float => 4, + RwWireNodeKind::String => 5, + RwWireNodeKind::Array => 6, + RwWireNodeKind::Object => 7, + RwWireNodeKind::Leaf => 8, + } + } +} + +impl Default for RwWireNodeKind { + fn default() -> Self { + Self::Null + } +} + +#[repr(i32)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RwWireLeafKind { + Unserializable = 0, + BigInt = 1, + Date = 2, + RegExp = 3, + Url = 4, + Error = 5, + Undefined = 6, + Symbol = 7, + Function = 8, +} + +impl From for i32 { + fn from(kind: RwWireLeafKind) -> Self { + match kind { + RwWireLeafKind::Unserializable => 0, + RwWireLeafKind::BigInt => 1, + RwWireLeafKind::Date => 2, + RwWireLeafKind::RegExp => 3, + RwWireLeafKind::Url => 4, + RwWireLeafKind::Error => 5, + RwWireLeafKind::Undefined => 6, + RwWireLeafKind::Symbol => 7, + RwWireLeafKind::Function => 8, + } + } +} + +impl Default for RwWireLeafKind { + fn default() -> Self { + Self::Unserializable + } +} thread_local! { static LAST_ERROR: RefCell> = const { RefCell::new(None) }; @@ -143,6 +222,85 @@ unsafe fn page_ref<'a>(page: *mut RwPage) -> Result<&'a RwPage, String> { unsafe { page.as_ref() }.ok_or_else(|| "page handle must not be NULL".to_string()) } +fn init_output(output: *mut T, value: T, name: &str) -> Result<(), String> { + if !output.is_null() { + // SAFETY: A non-NULL output slot is caller-provided writable storage. + unsafe { *output = value }; + } else { + return Err(format!("{name} must not be NULL")); + } + Ok(()) +} + +fn init_bytes_outputs(out_data: *mut *const u8, out_len: *mut usize) -> Result<(), String> { + if !out_data.is_null() { + // SAFETY: A non-NULL output slot is caller-provided writable storage. + unsafe { *out_data = ptr::null() }; + } + if !out_len.is_null() { + // SAFETY: A non-NULL output slot is caller-provided writable storage. + unsafe { *out_len = 0 }; + } + if out_data.is_null() { + return Err("out_data must not be NULL".to_string()); + } + if out_len.is_null() { + return Err("out_len must not be NULL".to_string()); + } + Ok(()) +} + +unsafe fn wire_graph_ref<'a>(graph: *const RwWireGraph) -> Result<&'a RwWireGraph, String> { + // SAFETY: The caller owns a live handle created by rw_wire_graph_parse. + unsafe { graph.as_ref() }.ok_or_else(|| "graph handle must not be NULL".to_string()) +} + +fn wire_node<'a>( + graph: &'a RwWireGraph, + node: RwWireNodeId, +) -> Result<&'a rw::WireNodeKind, String> { + graph + .inner + .node(rw::WireNodeId::from_index(node)) + .ok_or_else(|| format!("wire graph node id {node} is out of range")) +} + +fn wire_node_kind(kind: &rw::WireNodeKind) -> RwWireNodeKind { + match kind { + rw::WireNodeKind::Null => RwWireNodeKind::Null, + rw::WireNodeKind::Bool(_) => RwWireNodeKind::Bool, + rw::WireNodeKind::Number(rw::WireNumber::Signed(_)) => RwWireNodeKind::Signed, + rw::WireNodeKind::Number(rw::WireNumber::Unsigned(_)) => RwWireNodeKind::Unsigned, + rw::WireNodeKind::Number(rw::WireNumber::Float(_)) => RwWireNodeKind::Float, + rw::WireNodeKind::String(_) => RwWireNodeKind::String, + rw::WireNodeKind::Array(_) => RwWireNodeKind::Array, + rw::WireNodeKind::Object(_) => RwWireNodeKind::Object, + rw::WireNodeKind::Leaf(_) => RwWireNodeKind::Leaf, + } +} + +fn wire_leaf_kind(leaf: &rw::WireLeaf) -> RwWireLeafKind { + match leaf { + rw::WireLeaf::Unserializable(_) => RwWireLeafKind::Unserializable, + rw::WireLeaf::BigInt(_) => RwWireLeafKind::BigInt, + rw::WireLeaf::Date(_) => RwWireLeafKind::Date, + rw::WireLeaf::RegExp { .. } => RwWireLeafKind::RegExp, + rw::WireLeaf::Url(_) => RwWireLeafKind::Url, + rw::WireLeaf::Error { .. } => RwWireLeafKind::Error, + rw::WireLeaf::Undefined => RwWireLeafKind::Undefined, + rw::WireLeaf::Symbol => RwWireLeafKind::Symbol, + rw::WireLeaf::Function => RwWireLeafKind::Function, + } +} + +fn borrowed_bytes(value: &str) -> (*const u8, usize) { + if value.is_empty() { + (ptr::null(), 0) + } else { + (value.as_ptr(), value.len()) + } +} + /// Returns the current thread's borrowed last-error message, or NULL. #[no_mangle] pub extern "C" fn rw_last_error() -> *const c_char { @@ -230,6 +388,410 @@ pub unsafe extern "C" fn rw_decode_wire( }) } +/// Parse the evaluate wire format into an immutable graph owned by the caller. +#[no_mangle] +pub unsafe extern "C" fn rw_wire_graph_parse( + wire_json: *const c_char, + out_graph: *mut *mut RwWireGraph, +) -> c_int { + ffi_status(|| { + if !out_graph.is_null() { + // SAFETY: A non-NULL output slot is caller-provided writable storage. + unsafe { *out_graph = ptr::null_mut() }; + } + if out_graph.is_null() { + return Err("out_graph must not be NULL".to_string()); + } + let wire_json = unsafe { required_str(wire_json, "wire_json")? }; + let graph = rw::parse_wire_graph(wire_json).map_err(|error| error.to_string())?; + // SAFETY: `out_graph` was validated above. + unsafe { + *out_graph = Box::into_raw(Box::new(RwWireGraph { inner: graph })); + } + Ok(()) + }) +} + +/// Release an immutable wire graph. NULL is accepted. +#[no_mangle] +pub unsafe extern "C" fn rw_wire_graph_free(graph: *mut RwWireGraph) { + let result = catch_unwind(AssertUnwindSafe(|| { + clear_error(); + if !graph.is_null() { + // SAFETY: The handle came from Box::into_raw in this library and + // has not previously been freed. + drop(unsafe { Box::from_raw(graph) }); + } + })); + if let Err(payload) = result { + record_error(format!( + "panic at Rustwright C ABI boundary: {}", + panic_message(payload) + )); + } +} + +#[no_mangle] +pub unsafe extern "C" fn rw_wire_graph_node_count( + graph: *const RwWireGraph, + out_count: *mut usize, +) -> c_int { + ffi_status(|| { + init_output(out_count, 0, "out_count")?; + let graph = unsafe { wire_graph_ref(graph)? }; + // SAFETY: `out_count` was validated above. + unsafe { *out_count = graph.inner.nodes().len() }; + Ok(()) + }) +} + +#[no_mangle] +pub unsafe extern "C" fn rw_wire_graph_root( + graph: *const RwWireGraph, + out_root: *mut RwWireNodeId, +) -> c_int { + ffi_status(|| { + init_output(out_root, 0, "out_root")?; + let graph = unsafe { wire_graph_ref(graph)? }; + // SAFETY: `out_root` was validated above. + unsafe { *out_root = graph.inner.root().index() }; + Ok(()) + }) +} + +#[no_mangle] +pub unsafe extern "C" fn rw_wire_graph_node_kind( + graph: *const RwWireGraph, + node: RwWireNodeId, + out_kind: *mut i32, +) -> c_int { + ffi_status(|| { + init_output(out_kind, i32::from(RwWireNodeKind::Null), "out_kind")?; + let graph = unsafe { wire_graph_ref(graph)? }; + let kind = wire_node(graph, node)?; + // SAFETY: `out_kind` was validated above. + unsafe { *out_kind = i32::from(wire_node_kind(kind)) }; + Ok(()) + }) +} + +#[no_mangle] +pub unsafe extern "C" fn rw_wire_graph_get_bool( + graph: *const RwWireGraph, + node: RwWireNodeId, + out_value: *mut c_int, +) -> c_int { + ffi_status(|| { + init_output(out_value, 0, "out_value")?; + let graph = unsafe { wire_graph_ref(graph)? }; + match wire_node(graph, node)? { + rw::WireNodeKind::Bool(value) => { + // SAFETY: `out_value` was validated above. + unsafe { *out_value = i32::from(*value) }; + Ok(()) + } + _ => Err(format!("wire node {node} is not a boolean")), + } + }) +} + +#[no_mangle] +pub unsafe extern "C" fn rw_wire_graph_get_signed( + graph: *const RwWireGraph, + node: RwWireNodeId, + out_value: *mut i64, +) -> c_int { + ffi_status(|| { + init_output(out_value, 0, "out_value")?; + let graph = unsafe { wire_graph_ref(graph)? }; + match wire_node(graph, node)? { + rw::WireNodeKind::Number(rw::WireNumber::Signed(value)) => { + // SAFETY: `out_value` was validated above. + unsafe { *out_value = *value }; + Ok(()) + } + _ => Err(format!("wire node {node} is not a signed number")), + } + }) +} + +#[no_mangle] +pub unsafe extern "C" fn rw_wire_graph_get_unsigned( + graph: *const RwWireGraph, + node: RwWireNodeId, + out_value: *mut u64, +) -> c_int { + ffi_status(|| { + init_output(out_value, 0, "out_value")?; + let graph = unsafe { wire_graph_ref(graph)? }; + match wire_node(graph, node)? { + rw::WireNodeKind::Number(rw::WireNumber::Unsigned(value)) => { + // SAFETY: `out_value` was validated above. + unsafe { *out_value = *value }; + Ok(()) + } + _ => Err(format!("wire node {node} is not an unsigned number")), + } + }) +} + +#[no_mangle] +pub unsafe extern "C" fn rw_wire_graph_get_float( + graph: *const RwWireGraph, + node: RwWireNodeId, + out_value: *mut c_double, +) -> c_int { + ffi_status(|| { + init_output(out_value, 0.0, "out_value")?; + let graph = unsafe { wire_graph_ref(graph)? }; + match wire_node(graph, node)? { + rw::WireNodeKind::Number(rw::WireNumber::Float(value)) => { + // SAFETY: `out_value` was validated above. + unsafe { *out_value = *value }; + Ok(()) + } + _ => Err(format!("wire node {node} is not a floating-point number")), + } + }) +} + +#[no_mangle] +pub unsafe extern "C" fn rw_wire_graph_get_string( + graph: *const RwWireGraph, + node: RwWireNodeId, + out_data: *mut *const u8, + out_len: *mut usize, +) -> c_int { + ffi_status(|| { + init_bytes_outputs(out_data, out_len)?; + let graph = unsafe { wire_graph_ref(graph)? }; + let value = match wire_node(graph, node)? { + rw::WireNodeKind::String(value) => value, + _ => return Err(format!("wire node {node} is not a string")), + }; + let (data, len) = borrowed_bytes(value); + // SAFETY: Both output slots were validated above. + unsafe { + *out_data = data; + *out_len = len; + } + Ok(()) + }) +} + +#[no_mangle] +pub unsafe extern "C" fn rw_wire_graph_array_length( + graph: *const RwWireGraph, + node: RwWireNodeId, + out_len: *mut usize, +) -> c_int { + ffi_status(|| { + init_output(out_len, 0, "out_len")?; + let graph = unsafe { wire_graph_ref(graph)? }; + match wire_node(graph, node)? { + rw::WireNodeKind::Array(children) => { + // SAFETY: `out_len` was validated above. + unsafe { *out_len = children.len() }; + Ok(()) + } + _ => Err(format!("wire node {node} is not an array")), + } + }) +} + +#[no_mangle] +pub unsafe extern "C" fn rw_wire_graph_array_child( + graph: *const RwWireGraph, + node: RwWireNodeId, + index: usize, + out_child: *mut RwWireNodeId, +) -> c_int { + ffi_status(|| { + init_output(out_child, 0, "out_child")?; + let graph = unsafe { wire_graph_ref(graph)? }; + let children = match wire_node(graph, node)? { + rw::WireNodeKind::Array(children) => children, + _ => return Err(format!("wire node {node} is not an array")), + }; + let child = children + .get(index) + .ok_or_else(|| format!("wire array index {index} is out of range"))?; + // SAFETY: `out_child` was validated above. + unsafe { *out_child = child.index() }; + Ok(()) + }) +} + +#[no_mangle] +pub unsafe extern "C" fn rw_wire_graph_object_length( + graph: *const RwWireGraph, + node: RwWireNodeId, + out_len: *mut usize, +) -> c_int { + ffi_status(|| { + init_output(out_len, 0, "out_len")?; + let graph = unsafe { wire_graph_ref(graph)? }; + match wire_node(graph, node)? { + rw::WireNodeKind::Object(entries) => { + // SAFETY: `out_len` was validated above. + unsafe { *out_len = entries.len() }; + Ok(()) + } + _ => Err(format!("wire node {node} is not an object")), + } + }) +} + +#[no_mangle] +pub unsafe extern "C" fn rw_wire_graph_object_key( + graph: *const RwWireGraph, + node: RwWireNodeId, + index: usize, + out_data: *mut *const u8, + out_len: *mut usize, +) -> c_int { + ffi_status(|| { + init_bytes_outputs(out_data, out_len)?; + let graph = unsafe { wire_graph_ref(graph)? }; + let entries = match wire_node(graph, node)? { + rw::WireNodeKind::Object(entries) => entries, + _ => return Err(format!("wire node {node} is not an object")), + }; + let (key, _) = entries + .get(index) + .ok_or_else(|| format!("wire object index {index} is out of range"))?; + let (data, len) = borrowed_bytes(key); + // SAFETY: Both output slots were validated above. + unsafe { + *out_data = data; + *out_len = len; + } + Ok(()) + }) +} + +#[no_mangle] +pub unsafe extern "C" fn rw_wire_graph_object_child( + graph: *const RwWireGraph, + node: RwWireNodeId, + index: usize, + out_child: *mut RwWireNodeId, +) -> c_int { + ffi_status(|| { + init_output(out_child, 0, "out_child")?; + let graph = unsafe { wire_graph_ref(graph)? }; + let entries = match wire_node(graph, node)? { + rw::WireNodeKind::Object(entries) => entries, + _ => return Err(format!("wire node {node} is not an object")), + }; + let (_, child) = entries + .get(index) + .ok_or_else(|| format!("wire object index {index} is out of range"))?; + // SAFETY: `out_child` was validated above. + unsafe { *out_child = child.index() }; + Ok(()) + }) +} + +#[no_mangle] +pub unsafe extern "C" fn rw_wire_graph_leaf_kind( + graph: *const RwWireGraph, + node: RwWireNodeId, + out_kind: *mut i32, +) -> c_int { + ffi_status(|| { + init_output( + out_kind, + i32::from(RwWireLeafKind::Unserializable), + "out_kind", + )?; + let graph = unsafe { wire_graph_ref(graph)? }; + let leaf = match wire_node(graph, node)? { + rw::WireNodeKind::Leaf(leaf) => leaf, + _ => return Err(format!("wire node {node} is not a leaf")), + }; + // SAFETY: `out_kind` was validated above. + unsafe { *out_kind = i32::from(wire_leaf_kind(leaf)) }; + Ok(()) + }) +} + +#[no_mangle] +pub unsafe extern "C" fn rw_wire_graph_leaf_field_count( + graph: *const RwWireGraph, + node: RwWireNodeId, + out_count: *mut usize, +) -> c_int { + ffi_status(|| { + init_output(out_count, 0, "out_count")?; + let graph = unsafe { wire_graph_ref(graph)? }; + let leaf = match wire_node(graph, node)? { + rw::WireNodeKind::Leaf(leaf) => leaf, + _ => return Err(format!("wire node {node} is not a leaf")), + }; + let count = match leaf { + rw::WireLeaf::Unserializable(_) + | rw::WireLeaf::BigInt(_) + | rw::WireLeaf::Date(_) + | rw::WireLeaf::Url(_) => 1, + rw::WireLeaf::RegExp { .. } => 2, + rw::WireLeaf::Error { .. } => 3, + rw::WireLeaf::Undefined | rw::WireLeaf::Symbol | rw::WireLeaf::Function => 0, + }; + // SAFETY: `out_count` was validated above. + unsafe { *out_count = count }; + Ok(()) + }) +} + +#[no_mangle] +pub unsafe extern "C" fn rw_wire_graph_leaf_field( + graph: *const RwWireGraph, + node: RwWireNodeId, + index: usize, + out_data: *mut *const u8, + out_len: *mut usize, +) -> c_int { + ffi_status(|| { + init_bytes_outputs(out_data, out_len)?; + let graph = unsafe { wire_graph_ref(graph)? }; + let leaf = match wire_node(graph, node)? { + rw::WireNodeKind::Leaf(leaf) => leaf, + _ => return Err(format!("wire node {node} is not a leaf")), + }; + let value = match leaf { + rw::WireLeaf::Unserializable(value) + | rw::WireLeaf::BigInt(value) + | rw::WireLeaf::Date(value) + | rw::WireLeaf::Url(value) => (index == 0).then_some(value), + rw::WireLeaf::RegExp { pattern, flags } => match index { + 0 => Some(pattern), + 1 => Some(flags), + _ => None, + }, + rw::WireLeaf::Error { + name, + message, + stack, + } => match index { + 0 => Some(name), + 1 => Some(message), + 2 => Some(stack), + _ => None, + }, + rw::WireLeaf::Undefined | rw::WireLeaf::Symbol | rw::WireLeaf::Function => None, + } + .ok_or_else(|| format!("wire leaf field index {index} is out of range"))?; + let (data, len) = borrowed_bytes(value); + // SAFETY: Both output slots were validated above. + unsafe { + *out_data = data; + *out_len = len; + } + Ok(()) + }) +} + #[no_mangle] pub unsafe extern "C" fn rw_chromium_executable_path(out_path: *mut *mut c_char) -> c_int { ffi_status(|| { @@ -609,6 +1171,314 @@ pub unsafe extern "C" fn rw_page_free(page: *mut RwPage) { #[cfg(test)] mod tests { use super::*; + use std::slice; + + unsafe fn parse_graph(wire: &str) -> *mut RwWireGraph { + let wire = CString::new(wire).unwrap(); + let mut graph = ptr::null_mut(); + assert_eq!(unsafe { rw_wire_graph_parse(wire.as_ptr(), &mut graph) }, 0); + assert!(!graph.is_null()); + graph + } + + unsafe fn borrowed_bytes(data: *const u8, len: usize) -> Vec { + if len == 0 { + assert!(data.is_null()); + Vec::new() + } else { + assert!(!data.is_null()); + unsafe { slice::from_raw_parts(data, len) }.to_vec() + } + } + + unsafe fn object_entries( + graph: *const RwWireGraph, + node: RwWireNodeId, + ) -> Vec<(Vec, RwWireNodeId)> { + let mut len = 0; + assert_eq!( + unsafe { rw_wire_graph_object_length(graph, node, &mut len) }, + 0 + ); + (0..len) + .map(|index| { + let mut data = ptr::null(); + let mut key_len = 0; + let mut child = 0; + assert_eq!( + unsafe { + rw_wire_graph_object_key(graph, node, index, &mut data, &mut key_len) + }, + 0 + ); + assert_eq!( + unsafe { rw_wire_graph_object_child(graph, node, index, &mut child) }, + 0 + ); + (unsafe { borrowed_bytes(data, key_len) }, child) + }) + .collect() + } + + unsafe fn leaf_field(graph: *const RwWireGraph, node: RwWireNodeId, index: usize) -> Vec { + let mut data = ptr::null(); + let mut len = 0; + assert_eq!( + unsafe { rw_wire_graph_leaf_field(graph, node, index, &mut data, &mut len) }, + 0 + ); + unsafe { borrowed_bytes(data, len) } + } + + #[test] + fn graph_accessors_preserve_types_order_identity_and_leaf_fields() { + let graph = unsafe { + parse_graph( + r#"{ + "__rustwright_cdp_object__": 1, + "entries": { + "signed": -7, + "unsigned": 18446744073709551615, + "nul\u0000key": "a\u0000b", + "values": { + "__rustwright_cdp_array__": 2, + "items": [ + true, + 1.25, + {"__rustwright_cdp_unserializable_value__": "NaN"}, + {"__rustwright_cdp_unserializable_value__": "42n"}, + {"__rustwright_cdp_date__": "2026-07-21T12:34:56.789Z"}, + {"__rustwright_cdp_regexp__": {"p": "a+b", "f": "gi"}}, + {"__rustwright_cdp_url__": "https://example.com/path"}, + {"__rustwright_cdp_error__": { + "name": "TypeError", + "message": "broken", + "stack": "TypeError: broken" + }}, + {"__rustwright_cdp_undefined__": true}, + {"__rustwright_cdp_symbol__": true}, + {"__rustwright_cdp_function__": true}, + {"__rustwright_cdp_ref__": 1} + ] + }, + "forward": {"__rustwright_cdp_ref__": 3}, + "later": { + "__rustwright_cdp_object__": 3, + "entries": {"ok": true} + }, + "self": {"__rustwright_cdp_ref__": 1} + } + }"#, + ) + }; + + let mut count = 0; + let mut root = 0; + let mut root_kind = i32::from(RwWireNodeKind::Null); + assert_eq!(unsafe { rw_wire_graph_node_count(graph, &mut count) }, 0); + assert!(count > 10); + assert_eq!(unsafe { rw_wire_graph_root(graph, &mut root) }, 0); + assert_eq!( + unsafe { rw_wire_graph_node_kind(graph, root, &mut root_kind) }, + 0 + ); + assert_eq!(root_kind, i32::from(RwWireNodeKind::Object)); + + let entries = unsafe { object_entries(graph, root) }; + let keys = entries + .iter() + .map(|(key, _)| key.clone()) + .collect::>(); + assert_eq!( + keys, + vec![ + b"signed".to_vec(), + b"unsigned".to_vec(), + b"nul\0key".to_vec(), + b"values".to_vec(), + b"forward".to_vec(), + b"later".to_vec(), + b"self".to_vec(), + ] + ); + + let signed_node = entries[0].1; + let unsigned_node = entries[1].1; + let string_node = entries[2].1; + let array_node = entries[3].1; + let forward_node = entries[4].1; + let later_node = entries[5].1; + assert_eq!(entries[6].1, root); + assert_eq!(forward_node, later_node); + + let mut signed = 0; + let mut unsigned = 0; + assert_eq!( + unsafe { rw_wire_graph_get_signed(graph, signed_node, &mut signed) }, + 0 + ); + assert_eq!(signed, -7); + assert_eq!( + unsafe { rw_wire_graph_get_unsigned(graph, unsigned_node, &mut unsigned) }, + 0 + ); + assert_eq!(unsigned, u64::MAX); + + let mut string_data = ptr::null(); + let mut string_len = 0; + assert_eq!( + unsafe { + rw_wire_graph_get_string(graph, string_node, &mut string_data, &mut string_len) + }, + 0 + ); + assert_eq!( + unsafe { borrowed_bytes(string_data, string_len) }, + b"a\0b".to_vec() + ); + + let mut array_len = 0; + assert_eq!( + unsafe { rw_wire_graph_array_length(graph, array_node, &mut array_len) }, + 0 + ); + assert_eq!(array_len, 12); + let mut bool_node = 0; + let mut float_node = 0; + assert_eq!( + unsafe { rw_wire_graph_array_child(graph, array_node, 0, &mut bool_node) }, + 0 + ); + assert_eq!( + unsafe { rw_wire_graph_array_child(graph, array_node, 1, &mut float_node) }, + 0 + ); + let mut bool_value = 0; + let mut float_value = 0.0; + assert_eq!( + unsafe { rw_wire_graph_get_bool(graph, bool_node, &mut bool_value) }, + 0 + ); + assert_eq!(bool_value, 1); + assert_eq!( + unsafe { rw_wire_graph_get_float(graph, float_node, &mut float_value) }, + 0 + ); + assert_eq!(float_value, 1.25); + + let field_counts = [1, 1, 1, 2, 1, 3, 0, 0, 0]; + for (offset, expected_kind) in [ + RwWireLeafKind::Unserializable, + RwWireLeafKind::BigInt, + RwWireLeafKind::Date, + RwWireLeafKind::RegExp, + RwWireLeafKind::Url, + RwWireLeafKind::Error, + RwWireLeafKind::Undefined, + RwWireLeafKind::Symbol, + RwWireLeafKind::Function, + ] + .into_iter() + .enumerate() + { + let mut leaf_node = 0; + let mut leaf_kind = i32::from(RwWireLeafKind::Unserializable); + let mut field_count = usize::MAX; + assert_eq!( + unsafe { rw_wire_graph_array_child(graph, array_node, offset + 2, &mut leaf_node) }, + 0 + ); + assert_eq!( + unsafe { rw_wire_graph_leaf_kind(graph, leaf_node, &mut leaf_kind) }, + 0 + ); + assert_eq!(leaf_kind, i32::from(expected_kind)); + assert_eq!( + unsafe { rw_wire_graph_leaf_field_count(graph, leaf_node, &mut field_count,) }, + 0 + ); + assert_eq!(field_count, field_counts[offset]); + } + + let mut bigint_node = 0; + assert_eq!( + unsafe { rw_wire_graph_array_child(graph, array_node, 3, &mut bigint_node) }, + 0 + ); + assert_eq!(unsafe { leaf_field(graph, bigint_node, 0) }, b"42"); + let mut regexp_node = 0; + assert_eq!( + unsafe { rw_wire_graph_array_child(graph, array_node, 5, &mut regexp_node) }, + 0 + ); + assert_eq!(unsafe { leaf_field(graph, regexp_node, 0) }, b"a+b"); + assert_eq!(unsafe { leaf_field(graph, regexp_node, 1) }, b"gi"); + let mut error_node = 0; + assert_eq!( + unsafe { rw_wire_graph_array_child(graph, array_node, 7, &mut error_node) }, + 0 + ); + assert_eq!(unsafe { leaf_field(graph, error_node, 0) }, b"TypeError"); + assert_eq!(unsafe { leaf_field(graph, error_node, 1) }, b"broken"); + assert_eq!( + unsafe { leaf_field(graph, error_node, 2) }, + b"TypeError: broken" + ); + let mut back_edge = 0; + assert_eq!( + unsafe { rw_wire_graph_array_child(graph, array_node, 11, &mut back_edge) }, + 0 + ); + assert_eq!(back_edge, root); + + unsafe { rw_wire_graph_free(graph) }; + } + + #[test] + fn graph_accessors_initialize_outputs_before_reporting_errors() { + let malformed = CString::new(r#"{"__rustwright_cdp_ref__":99}"#).unwrap(); + let mut graph = 1usize as *mut RwWireGraph; + assert_ne!( + unsafe { rw_wire_graph_parse(malformed.as_ptr(), &mut graph) }, + 0 + ); + assert!(graph.is_null()); + assert!(!rw_last_error().is_null()); + + let graph = unsafe { parse_graph(r#"{"__rustwright_cdp_array__":1,"items":[true]}"#) }; + let mut root = 0; + assert_eq!(unsafe { rw_wire_graph_root(graph, &mut root) }, 0); + let mut bool_value = 99; + assert_ne!( + unsafe { rw_wire_graph_get_bool(graph, root, &mut bool_value) }, + 0 + ); + assert_eq!(bool_value, 0); + let mut data = 1usize as *const u8; + let mut len = 99; + assert_ne!( + unsafe { rw_wire_graph_get_string(graph, root, &mut data, &mut len) }, + 0 + ); + assert!(data.is_null()); + assert_eq!(len, 0); + assert_ne!( + unsafe { rw_wire_graph_node_count(ptr::null(), &mut len) }, + 0 + ); + assert_eq!(len, 0); + unsafe { + rw_wire_graph_free(graph); + rw_wire_graph_free(ptr::null_mut()); + } + } + + #[test] + fn wire_kind_types_have_fixed_width_abi() { + assert_eq!(std::mem::size_of::(), 4); + assert_eq!(std::mem::size_of::(), 4); + assert_eq!(std::mem::size_of::(), 4); + } #[test] fn decode_wire_round_trip_uses_c_string_ownership() { diff --git a/csharp/Runner/Program.cs b/csharp/Runner/Program.cs index fc6e624..572579f 100644 --- a/csharp/Runner/Program.cs +++ b/csharp/Runner/Program.cs @@ -46,6 +46,10 @@ internal static int Run(string[] arguments) } WriteResults(options.OutputPath, results); + foreach (var failed in results.Where(result => !result.Ok)) + { + Console.Error.WriteLine($"{failed.Id}: {failed.Error}"); + } if (browserCloseError is not null) { Console.Error.WriteLine($"browser close failed: {browserCloseError.Message}"); diff --git a/csharp/Rustwright/Chromium.cs b/csharp/Rustwright/Chromium.cs index 4687f8d..9b6452b 100644 --- a/csharp/Rustwright/Chromium.cs +++ b/csharp/Rustwright/Chromium.cs @@ -368,7 +368,14 @@ public void Close(double? timeoutMs = null, bool runBeforeUnload = false) throw NativeError.FromNullResult("rw_page_evaluate"); } - return JsonWire.Decode(NativeError.ReadOwnedString(pointer)); + try + { + return JsonWire.DecodeWire(pointer); + } + finally + { + NativeMethods.StringFree(pointer); + } } } diff --git a/csharp/Rustwright/JsonWire.cs b/csharp/Rustwright/JsonWire.cs index 570b511..5c750ac 100644 --- a/csharp/Rustwright/JsonWire.cs +++ b/csharp/Rustwright/JsonWire.cs @@ -1,7 +1,8 @@ using System.Globalization; using System.Numerics; +using System.Runtime.InteropServices; +using System.Text; using System.Text.Json; -using System.Text.Json.Nodes; using System.Text.Json.Serialization; namespace Rustwright; @@ -20,180 +21,366 @@ internal static class JsonWire internal static object? Decode(string json) { - var root = JsonNode.Parse(json); - var references = new Dictionary(); - return DecodeNode(root, references); + NativeLibraryLoader.EnsureConfigured(); + var status = NativeMethods.WireGraphParse(json, out var graph); + NativeError.ThrowIfFailed(status); + return DecodeGraph(graph); } - private static object? DecodeNode(JsonNode? node, Dictionary references) + internal static object? DecodeWire(IntPtr wireJson) { - if (node is null) + NativeLibraryLoader.EnsureConfigured(); + var status = NativeMethods.WireGraphParse(wireJson, out var graph); + NativeError.ThrowIfFailed(status); + return DecodeGraph(graph); + } + + private static object? DecodeGraph(IntPtr graph) + { + if (graph == IntPtr.Zero) { - return null; + throw new RustwrightException("rw_wire_graph_parse succeeded but returned NULL."); } - if (node is JsonValue value) + try { - return DecodeValue(value); + return new Materializer(graph).Materialize(); } + finally + { + NativeMethods.WireGraphFree(graph); + } + } - if (node is JsonArray array) + private sealed class Materializer + { + private const int NodeNull = 0; + private const int NodeBool = 1; + private const int NodeSigned = 2; + private const int NodeUnsigned = 3; + private const int NodeFloat = 4; + private const int NodeString = 5; + private const int NodeArray = 6; + private const int NodeObject = 7; + private const int NodeLeaf = 8; + + private const int LeafUnserializable = 0; + private const int LeafBigInt = 1; + private const int LeafDate = 2; + private const int LeafRegexp = 3; + private const int LeafUrl = 4; + private const int LeafError = 5; + private const int LeafUndefined = 6; + private const int LeafSymbol = 7; + private const int LeafFunction = 8; + + private readonly IntPtr graph; + private object?[] values = []; + private int[] kinds = []; + + internal Materializer(IntPtr graph) { - return array.Select(item => DecodeNode(item, references)).ToList(); + this.graph = graph; } - var obj = node.AsObject(); - if (TryGetRefId(obj, "__rustwright_cdp_ref__", out var existingId)) + internal object? Materialize() { - if (!references.TryGetValue(existingId, out var existing)) + var count = GetNodeCount(); + var root = GetRoot(); + values = new object?[count]; + kinds = new int[count]; + + // First pass allocates every identity-bearing placeholder. + for (var node = 0; node < count; node++) + { + var nodeId = (nuint)node; + var kind = GetNodeKind(nodeId); + kinds[node] = kind; + values[node] = kind switch + { + NodeNull => null, + NodeBool => GetBool(nodeId), + NodeSigned => GetSigned(nodeId), + NodeUnsigned => DecodeUnsigned(GetUnsigned(nodeId)), + NodeFloat => GetFloat(nodeId), + NodeString => GetString(nodeId), + NodeArray => EmptyArray(GetArrayLength(nodeId)), + NodeObject => new Dictionary(StringComparer.Ordinal), + NodeLeaf => DecodeLeaf(nodeId), + _ => throw new RustwrightException($"Native wire graph returned unknown node kind {kind}."), + }; + } + + // Second pass fills edges using the already allocated values. + for (var node = 0; node < count; node++) { - throw new RustwrightException($"Evaluate wire referenced unknown object id {existingId}."); + var nodeId = (nuint)node; + if (kinds[node] == NodeArray) + { + var array = (List)values[node]!; + var length = GetArrayLength(nodeId); + for (nuint index = 0; index < length; index++) + { + array[CheckedIndex(index, array.Count, "array child")] = + values[NodeIndex(GetArrayChild(nodeId, index))]; + } + } + else if (kinds[node] == NodeObject) + { + var dictionary = (Dictionary)values[node]!; + var length = GetObjectLength(nodeId); + for (nuint index = 0; index < length; index++) + { + dictionary[GetObjectKey(nodeId, index)] = + values[NodeIndex(GetObjectChild(nodeId, index))]; + } + } } - return existing; + return values[NodeIndex(root)]; } - if (TryGetRefId(obj, "__rustwright_cdp_array__", out var arrayId) && - obj["items"] is JsonArray wrappedItems) + private object? DecodeLeaf(nuint node) { - var decoded = new List(wrappedItems.Count); - references[arrayId] = decoded; - foreach (var item in wrappedItems) + var leafKind = GetLeafKind(node); + var fields = GetLeafFields(node); + return leafKind switch { - decoded.Add(DecodeNode(item, references)); - } + LeafUnserializable => RequireFields(fields, 1, "unserializable", + () => DecodeUnserializable(fields[0])), + LeafBigInt => RequireFields(fields, 1, "bigint", + () => ParseBigIntegerOrString(fields[0])), + LeafDate => RequireFields(fields, 1, "date", + () => DecodeDate(fields[0])), + LeafRegexp => RequireFields(fields, 2, "regexp", + () => new RustwrightRegularExpression(fields[0], fields[1])), + LeafUrl => RequireFields(fields, 1, "url", + () => DecodeUrl(fields[0])), + LeafError => RequireFields(fields, 3, "error", + () => new RustwrightJavaScriptError(fields[0], fields[1], fields[2])), + LeafUndefined or LeafSymbol or LeafFunction => fields.Count == 0 + ? null + : throw new RustwrightException("Native wire graph leaf has unexpected fields."), + _ => throw new RustwrightException($"Native wire graph returned unknown leaf kind {leafKind}."), + }; + } - return decoded; + private int GetNodeCount() + { + var status = NativeMethods.WireGraphNodeCount(graph, out var count); + NativeError.ThrowIfFailed(status); + return CheckedLength(count, "node count"); } - if (TryGetRefId(obj, "__rustwright_cdp_object__", out var objectId) && - obj["entries"] is JsonObject wrappedEntries) + private int NodeIndex(nuint node) => CheckedIndex(node, values.Length, "node id"); + + private nuint GetRoot() { - var decoded = new Dictionary(StringComparer.Ordinal); - references[objectId] = decoded; - foreach (var entry in wrappedEntries) - { - decoded[entry.Key] = DecodeNode(entry.Value, references); - } + var status = NativeMethods.WireGraphRoot(graph, out var root); + NativeError.ThrowIfFailed(status); + return root; + } - return decoded; + private int GetNodeKind(nuint node) + { + var status = NativeMethods.WireGraphNodeKind(graph, node, out var kind); + NativeError.ThrowIfFailed(status); + return kind; } - if (obj.TryGetPropertyValue("__rustwright_cdp_unserializable_value__", out var specialNode) && - specialNode is JsonValue specialValue && - specialValue.TryGetValue(out var special)) + private bool GetBool(nuint node) { - return DecodeUnserializable(special); + var status = NativeMethods.WireGraphGetBool(graph, node, out var value); + NativeError.ThrowIfFailed(status); + return value != 0; } - // JavaScript has no direct C# equivalents for these values. The binding's - // documented fallback follows the contract and maps them to null. - if (obj.ContainsKey("__rustwright_cdp_undefined__") || - obj.ContainsKey("__rustwright_cdp_symbol__") || - obj.ContainsKey("__rustwright_cdp_function__")) + private long GetSigned(nuint node) { - return null; + var status = NativeMethods.WireGraphGetSigned(graph, node, out var value); + NativeError.ThrowIfFailed(status); + return value; } - if (TryGetString(obj, "__rustwright_cdp_date__", out var date)) + private ulong GetUnsigned(nuint node) { - return DateTimeOffset.TryParse( - date, - CultureInfo.InvariantCulture, - DateTimeStyles.RoundtripKind, - out var parsedDate) - ? parsedDate - : date; + var status = NativeMethods.WireGraphGetUnsigned(graph, node, out var value); + NativeError.ThrowIfFailed(status); + return value; } - if (TryGetString(obj, "__rustwright_cdp_url__", out var url)) + private double GetFloat(nuint node) { - return Uri.TryCreate(url, UriKind.RelativeOrAbsolute, out var parsedUrl) ? parsedUrl : url; + var status = NativeMethods.WireGraphGetFloat(graph, node, out var value); + NativeError.ThrowIfFailed(status); + return value; } - if (obj["__rustwright_cdp_regexp__"] is JsonObject regularExpression) + private string GetString(nuint node) { - return new RustwrightRegularExpression( - regularExpression["p"]?.GetValue() ?? string.Empty, - regularExpression["f"]?.GetValue() ?? string.Empty); + var status = NativeMethods.WireGraphGetString(graph, node, out var data, out var length); + NativeError.ThrowIfFailed(status); + return CopyUtf8(data, length); } - if (obj["__rustwright_cdp_error__"] is JsonObject error) + private nuint GetArrayLength(nuint node) { - return new RustwrightJavaScriptError( - error["name"]?.GetValue() ?? "Error", - error["message"]?.GetValue() ?? string.Empty, - error["stack"]?.GetValue() ?? string.Empty); + var status = NativeMethods.WireGraphArrayLength(graph, node, out var length); + NativeError.ThrowIfFailed(status); + return length; } - var ordinary = new Dictionary(StringComparer.Ordinal); - foreach (var property in obj) + private nuint GetArrayChild(nuint node, nuint index) { - ordinary[property.Key] = DecodeNode(property.Value, references); + var status = NativeMethods.WireGraphArrayChild(graph, node, index, out var child); + NativeError.ThrowIfFailed(status); + return child; } - return ordinary; - } + private nuint GetObjectLength(nuint node) + { + var status = NativeMethods.WireGraphObjectLength(graph, node, out var length); + NativeError.ThrowIfFailed(status); + return length; + } - private static object DecodeValue(JsonValue value) - { - if (value.TryGetValue(out var boolean)) + private string GetObjectKey(nuint node, nuint index) { - return boolean; + var status = NativeMethods.WireGraphObjectKey( + graph, node, index, out var data, out var length); + NativeError.ThrowIfFailed(status); + return CopyUtf8(data, length); } - if (value.TryGetValue(out var text)) + private nuint GetObjectChild(nuint node, nuint index) { - return text; + var status = NativeMethods.WireGraphObjectChild(graph, node, index, out var child); + NativeError.ThrowIfFailed(status); + return child; } - if (value.TryGetValue(out var integer)) + private int GetLeafKind(nuint node) { - return integer; + var status = NativeMethods.WireGraphLeafKind(graph, node, out var kind); + NativeError.ThrowIfFailed(status); + return kind; } - if (value.TryGetValue(out var decimalValue)) + private List GetLeafFields(nuint node) { - return decimalValue; + var status = NativeMethods.WireGraphLeafFieldCount(graph, node, out var count); + NativeError.ThrowIfFailed(status); + var fields = new List(CheckedLength(count, "leaf field count")); + for (nuint index = 0; index < count; index++) + { + status = NativeMethods.WireGraphLeafField( + graph, node, index, out var data, out var length); + NativeError.ThrowIfFailed(status); + fields.Add(CopyUtf8(data, length)); + } + + return fields; } + } - if (value.TryGetValue(out var doubleValue)) + private static List EmptyArray(nuint length) + { + var count = CheckedLength(length, "array length"); + var array = new List(count); + for (var index = 0; index < count; index++) { - return doubleValue; + array.Add(null); } - return value.ToJsonString(); + return array; } + private static object DecodeUnsigned(ulong value) => + value <= long.MaxValue ? (object)(long)value : new BigInteger(value); + private static object DecodeUnserializable(string value) => value switch { "NaN" => double.NaN, "Infinity" => double.PositiveInfinity, "-Infinity" => double.NegativeInfinity, "-0" => BitConverter.Int64BitsToDouble(long.MinValue), - _ when value.EndsWith('n') && - BigInteger.TryParse(value[..^1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var integer) - => integer, + _ when value.EndsWith('n') => ParseBigIntegerOrString(value[..^1], value), _ => value, }; - private static bool TryGetRefId(JsonObject obj, string propertyName, out int id) + private static object ParseBigIntegerOrString(string digits, string? fallback = null) + { + return BigInteger.TryParse( + digits, + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out var integer) + ? integer + : fallback ?? digits; + } + + private static object DecodeDate(string date) => + DateTimeOffset.TryParse( + date, + CultureInfo.InvariantCulture, + DateTimeStyles.RoundtripKind, + out var parsedDate) + ? parsedDate + : date; + + private static object DecodeUrl(string url) => + Uri.TryCreate(url, UriKind.RelativeOrAbsolute, out var parsedUrl) ? parsedUrl : url; + + private static T RequireFields( + IReadOnlyList fields, + int expected, + string leaf, + Func decoder) + { + if (fields.Count != expected) + { + throw new RustwrightException( + $"Native wire graph {leaf} leaf has {fields.Count} fields; expected {expected}."); + } + + return decoder(); + } + + private static string CopyUtf8(IntPtr data, nuint length) { - id = default; - return obj[propertyName] is JsonValue value && value.TryGetValue(out id); + var count = CheckedLength(length, "UTF-8 byte length"); + if (count == 0) + { + return string.Empty; + } + + if (data == IntPtr.Zero) + { + throw new RustwrightException($"Native returned NULL UTF-8 data with nonzero length {count}."); + } + + var bytes = new byte[count]; + Marshal.Copy(data, bytes, 0, count); + return Encoding.UTF8.GetString(bytes); + } + + private static int CheckedLength(nuint value, string what) + { + if (value > (nuint)int.MaxValue) + { + throw new RustwrightException($"Native wire graph returned invalid {what}: {value}."); + } + + return (int)value; } - private static bool TryGetString(JsonObject obj, string propertyName, out string value) + private static int CheckedIndex(nuint value, int size, string what) { - if (obj[propertyName] is JsonValue node && - node.TryGetValue(out var decoded) && - decoded is not null) + if (value >= (nuint)size) { - value = decoded; - return true; + throw new RustwrightException($"Native wire graph returned invalid {what}: {value}."); } - value = string.Empty; - return false; + return (int)value; } } diff --git a/csharp/Rustwright/NativeMethods.cs b/csharp/Rustwright/NativeMethods.cs index 7867004..ec4d085 100644 --- a/csharp/Rustwright/NativeMethods.cs +++ b/csharp/Rustwright/NativeMethods.cs @@ -82,6 +82,87 @@ internal static extern int PageEvaluate( [MarshalAs(UnmanagedType.LPUTF8Str)] string? argumentJson, double timeoutMsOrNan, out IntPtr resultJson); + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "rw_wire_graph_parse")] + internal static extern int WireGraphParse( + [MarshalAs(UnmanagedType.LPUTF8Str)] string wireJson, + out IntPtr graph); + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "rw_wire_graph_parse")] + internal static extern int WireGraphParse(IntPtr wireJson, out IntPtr graph); + + + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "rw_wire_graph_free")] + internal static extern void WireGraphFree(IntPtr graph); + + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "rw_wire_graph_node_count")] + internal static extern int WireGraphNodeCount(IntPtr graph, out nuint count); + + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "rw_wire_graph_root")] + internal static extern int WireGraphRoot(IntPtr graph, out nuint root); + + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "rw_wire_graph_node_kind")] + internal static extern int WireGraphNodeKind(IntPtr graph, nuint node, out int kind); + + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "rw_wire_graph_get_bool")] + internal static extern int WireGraphGetBool(IntPtr graph, nuint node, out int value); + + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "rw_wire_graph_get_signed")] + internal static extern int WireGraphGetSigned(IntPtr graph, nuint node, out long value); + + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "rw_wire_graph_get_unsigned")] + internal static extern int WireGraphGetUnsigned(IntPtr graph, nuint node, out ulong value); + + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "rw_wire_graph_get_float")] + internal static extern int WireGraphGetFloat(IntPtr graph, nuint node, out double value); + + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "rw_wire_graph_get_string")] + internal static extern int WireGraphGetString( + IntPtr graph, + nuint node, + out IntPtr data, + out nuint length); + + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "rw_wire_graph_array_length")] + internal static extern int WireGraphArrayLength(IntPtr graph, nuint node, out nuint length); + + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "rw_wire_graph_array_child")] + internal static extern int WireGraphArrayChild( + IntPtr graph, + nuint node, + nuint index, + out nuint child); + + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "rw_wire_graph_object_length")] + internal static extern int WireGraphObjectLength(IntPtr graph, nuint node, out nuint length); + + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "rw_wire_graph_object_key")] + internal static extern int WireGraphObjectKey( + IntPtr graph, + nuint node, + nuint index, + out IntPtr data, + out nuint length); + + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "rw_wire_graph_object_child")] + internal static extern int WireGraphObjectChild( + IntPtr graph, + nuint node, + nuint index, + out nuint child); + + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "rw_wire_graph_leaf_kind")] + internal static extern int WireGraphLeafKind(IntPtr graph, nuint node, out int kind); + + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "rw_wire_graph_leaf_field_count")] + internal static extern int WireGraphLeafFieldCount(IntPtr graph, nuint node, out nuint count); + + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "rw_wire_graph_leaf_field")] + internal static extern int WireGraphLeafField( + IntPtr graph, + nuint node, + nuint index, + out IntPtr data, + out nuint length); + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "rw_page_screenshot")] internal static extern int PageScreenshot( diff --git a/java/src/main/java/com/skyvern/rustwright/NativeBindings.java b/java/src/main/java/com/skyvern/rustwright/NativeBindings.java index e9aa962..2a64534 100644 --- a/java/src/main/java/com/skyvern/rustwright/NativeBindings.java +++ b/java/src/main/java/com/skyvern/rustwright/NativeBindings.java @@ -7,10 +7,11 @@ import java.lang.foreign.SymbolLookup; import java.lang.foreign.ValueLayout; import java.lang.invoke.MethodHandle; +import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.util.Objects; -/** Exact Java FFM declarations and ownership helpers for all 19 rw_* symbols. */ +/** Exact Java FFM declarations and ownership helpers for Rustwright's native ABI. */ final class NativeBindings { private static final ValueLayout.OfLong SIZE_T = ValueLayout.JAVA_LONG; private static final long MAX_C_STRING_BYTES = Integer.MAX_VALUE; @@ -39,6 +40,25 @@ final class NativeBindings { private final MethodHandle rwPageScreenshot; private final MethodHandle rwPageClose; private final MethodHandle rwPageFree; + private final MethodHandle rwWireGraphParse; + private final MethodHandle rwWireGraphFree; + private final MethodHandle rwWireGraphNodeCount; + private final MethodHandle rwWireGraphRoot; + private final MethodHandle rwWireGraphNodeKind; + private final MethodHandle rwWireGraphGetBool; + private final MethodHandle rwWireGraphGetSigned; + private final MethodHandle rwWireGraphGetUnsigned; + private final MethodHandle rwWireGraphGetFloat; + private final MethodHandle rwWireGraphGetString; + private final MethodHandle rwWireGraphArrayLength; + private final MethodHandle rwWireGraphArrayChild; + private final MethodHandle rwWireGraphObjectLength; + private final MethodHandle rwWireGraphObjectKey; + private final MethodHandle rwWireGraphObjectChild; + private final MethodHandle rwWireGraphLeafKind; + private final MethodHandle rwWireGraphLeafFieldCount; + private final MethodHandle rwWireGraphLeafField; + NativeBindings(Path path) { Objects.requireNonNull(path, "path"); @@ -100,6 +120,57 @@ final class NativeBindings { ValueLayout.JAVA_INT)); rwPageFree = bind(linker, lookup, "rw_page_free", FunctionDescriptor.ofVoid(ValueLayout.ADDRESS)); + rwWireGraphParse = bind(linker, lookup, "rw_wire_graph_parse", + FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.ADDRESS, ValueLayout.ADDRESS)); + rwWireGraphFree = bind(linker, lookup, "rw_wire_graph_free", + FunctionDescriptor.ofVoid(ValueLayout.ADDRESS)); + rwWireGraphNodeCount = bind(linker, lookup, "rw_wire_graph_node_count", + FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.ADDRESS, ValueLayout.ADDRESS)); + rwWireGraphRoot = bind(linker, lookup, "rw_wire_graph_root", + FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.ADDRESS, ValueLayout.ADDRESS)); + rwWireGraphNodeKind = bind(linker, lookup, "rw_wire_graph_node_kind", + FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.ADDRESS, SIZE_T, + ValueLayout.ADDRESS)); + rwWireGraphGetBool = bind(linker, lookup, "rw_wire_graph_get_bool", + FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.ADDRESS, SIZE_T, + ValueLayout.ADDRESS)); + rwWireGraphGetSigned = bind(linker, lookup, "rw_wire_graph_get_signed", + FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.ADDRESS, SIZE_T, + ValueLayout.ADDRESS)); + rwWireGraphGetUnsigned = bind(linker, lookup, "rw_wire_graph_get_unsigned", + FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.ADDRESS, SIZE_T, + ValueLayout.ADDRESS)); + rwWireGraphGetFloat = bind(linker, lookup, "rw_wire_graph_get_float", + FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.ADDRESS, SIZE_T, + ValueLayout.ADDRESS)); + rwWireGraphGetString = bind(linker, lookup, "rw_wire_graph_get_string", + FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.ADDRESS, SIZE_T, + ValueLayout.ADDRESS, ValueLayout.ADDRESS)); + rwWireGraphArrayLength = bind(linker, lookup, "rw_wire_graph_array_length", + FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.ADDRESS, SIZE_T, + ValueLayout.ADDRESS)); + rwWireGraphArrayChild = bind(linker, lookup, "rw_wire_graph_array_child", + FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.ADDRESS, SIZE_T, SIZE_T, + ValueLayout.ADDRESS)); + rwWireGraphObjectLength = bind(linker, lookup, "rw_wire_graph_object_length", + FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.ADDRESS, SIZE_T, + ValueLayout.ADDRESS)); + rwWireGraphObjectKey = bind(linker, lookup, "rw_wire_graph_object_key", + FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.ADDRESS, SIZE_T, SIZE_T, + ValueLayout.ADDRESS, ValueLayout.ADDRESS)); + rwWireGraphObjectChild = bind(linker, lookup, "rw_wire_graph_object_child", + FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.ADDRESS, SIZE_T, SIZE_T, + ValueLayout.ADDRESS)); + rwWireGraphLeafKind = bind(linker, lookup, "rw_wire_graph_leaf_kind", + FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.ADDRESS, SIZE_T, + ValueLayout.ADDRESS)); + rwWireGraphLeafFieldCount = bind(linker, lookup, "rw_wire_graph_leaf_field_count", + FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.ADDRESS, SIZE_T, + ValueLayout.ADDRESS)); + rwWireGraphLeafField = bind(linker, lookup, "rw_wire_graph_leaf_field", + FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.ADDRESS, SIZE_T, SIZE_T, + ValueLayout.ADDRESS, ValueLayout.ADDRESS)); + } Path libraryPath() { @@ -207,17 +278,26 @@ String pageTextContent(MemorySegment page, String selector, double timeout) { } } - String pageEvaluate(MemorySegment page, String expression, String argumentJson, double timeout) { + MemorySegment pageEvaluateGraph( + MemorySegment page, + String expression, + String argumentJson, + double timeout) { try (Arena arena = Arena.ofConfined()) { MemorySegment out = pointerOut(arena); int status = invokeInt("rw_page_evaluate", rwPageEvaluate, page, string(arena, expression), nullableString(arena, argumentJson), timeout, out); checkStatus(status, "rw_page_evaluate"); - MemorySegment value = requireOutPointer(out, "rw_page_evaluate"); - return takeNullableString(value); + MemorySegment wire = requireOutPointer(out, "rw_page_evaluate"); + try { + return wireGraphParse(wire); + } finally { + invokeVoid("rw_string_free", rwStringFree, wire); + } } } + byte[] pageScreenshot(MemorySegment page, String optionsJson) { try (Arena arena = Arena.ofConfined()) { MemorySegment outBuffer = pointerOut(arena); @@ -246,6 +326,182 @@ byte[] pageScreenshot(MemorySegment page, String optionsJson) { } } } + MemorySegment wireGraphParse(String wireJson) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment out = pointerOut(arena); + int status = invokeInt("rw_wire_graph_parse", rwWireGraphParse, + string(arena, wireJson), out); + checkStatus(status, "rw_wire_graph_parse"); + return requireOutPointer(out, "rw_wire_graph_parse"); + } + } + MemorySegment wireGraphParse(MemorySegment wireJson) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment out = pointerOut(arena); + int status = invokeInt("rw_wire_graph_parse", rwWireGraphParse, wireJson, out); + checkStatus(status, "rw_wire_graph_parse"); + return requireOutPointer(out, "rw_wire_graph_parse"); + } + } + + + void wireGraphFree(MemorySegment graph) { + invokeVoid("rw_wire_graph_free", rwWireGraphFree, graph); + } + + long wireGraphNodeCount(MemorySegment graph) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment out = sizeOut(arena); + int status = invokeInt("rw_wire_graph_node_count", rwWireGraphNodeCount, graph, out); + checkStatus(status, "rw_wire_graph_node_count"); + return out.get(SIZE_T, 0); + } + } + + long wireGraphRoot(MemorySegment graph) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment out = sizeOut(arena); + int status = invokeInt("rw_wire_graph_root", rwWireGraphRoot, graph, out); + checkStatus(status, "rw_wire_graph_root"); + return out.get(SIZE_T, 0); + } + } + + int wireGraphNodeKind(MemorySegment graph, long node) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment out = intOut(arena); + int status = invokeInt("rw_wire_graph_node_kind", rwWireGraphNodeKind, graph, node, out); + checkStatus(status, "rw_wire_graph_node_kind"); + return out.get(ValueLayout.JAVA_INT, 0); + } + } + + boolean wireGraphGetBool(MemorySegment graph, long node) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment out = intOut(arena); + int status = invokeInt("rw_wire_graph_get_bool", rwWireGraphGetBool, graph, node, out); + checkStatus(status, "rw_wire_graph_get_bool"); + return out.get(ValueLayout.JAVA_INT, 0) != 0; + } + } + + long wireGraphGetSigned(MemorySegment graph, long node) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment out = longOut(arena); + int status = invokeInt("rw_wire_graph_get_signed", rwWireGraphGetSigned, graph, node, out); + checkStatus(status, "rw_wire_graph_get_signed"); + return out.get(ValueLayout.JAVA_LONG, 0); + } + } + + long wireGraphGetUnsigned(MemorySegment graph, long node) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment out = longOut(arena); + int status = invokeInt("rw_wire_graph_get_unsigned", rwWireGraphGetUnsigned, graph, node, out); + checkStatus(status, "rw_wire_graph_get_unsigned"); + return out.get(ValueLayout.JAVA_LONG, 0); + } + } + + double wireGraphGetFloat(MemorySegment graph, long node) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment out = doubleOut(arena); + int status = invokeInt("rw_wire_graph_get_float", rwWireGraphGetFloat, graph, node, out); + checkStatus(status, "rw_wire_graph_get_float"); + return out.get(ValueLayout.JAVA_DOUBLE, 0); + } + } + + String wireGraphGetString(MemorySegment graph, long node) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment outData = pointerOut(arena); + MemorySegment outLength = sizeOut(arena); + int status = invokeInt("rw_wire_graph_get_string", rwWireGraphGetString, graph, node, + outData, outLength); + checkStatus(status, "rw_wire_graph_get_string"); + return copyBorrowedUtf8(outData.get(ValueLayout.ADDRESS, 0), outLength.get(SIZE_T, 0)); + } + } + + long wireGraphArrayLength(MemorySegment graph, long node) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment out = sizeOut(arena); + int status = invokeInt("rw_wire_graph_array_length", rwWireGraphArrayLength, graph, node, out); + checkStatus(status, "rw_wire_graph_array_length"); + return out.get(SIZE_T, 0); + } + } + + long wireGraphArrayChild(MemorySegment graph, long node, long index) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment out = sizeOut(arena); + int status = invokeInt("rw_wire_graph_array_child", rwWireGraphArrayChild, + graph, node, index, out); + checkStatus(status, "rw_wire_graph_array_child"); + return out.get(SIZE_T, 0); + } + } + + long wireGraphObjectLength(MemorySegment graph, long node) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment out = sizeOut(arena); + int status = invokeInt("rw_wire_graph_object_length", rwWireGraphObjectLength, graph, node, out); + checkStatus(status, "rw_wire_graph_object_length"); + return out.get(SIZE_T, 0); + } + } + + String wireGraphObjectKey(MemorySegment graph, long node, long index) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment outData = pointerOut(arena); + MemorySegment outLength = sizeOut(arena); + int status = invokeInt("rw_wire_graph_object_key", rwWireGraphObjectKey, + graph, node, index, outData, outLength); + checkStatus(status, "rw_wire_graph_object_key"); + return copyBorrowedUtf8(outData.get(ValueLayout.ADDRESS, 0), outLength.get(SIZE_T, 0)); + } + } + + long wireGraphObjectChild(MemorySegment graph, long node, long index) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment out = sizeOut(arena); + int status = invokeInt("rw_wire_graph_object_child", rwWireGraphObjectChild, + graph, node, index, out); + checkStatus(status, "rw_wire_graph_object_child"); + return out.get(SIZE_T, 0); + } + } + + int wireGraphLeafKind(MemorySegment graph, long node) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment out = intOut(arena); + int status = invokeInt("rw_wire_graph_leaf_kind", rwWireGraphLeafKind, graph, node, out); + checkStatus(status, "rw_wire_graph_leaf_kind"); + return out.get(ValueLayout.JAVA_INT, 0); + } + } + + long wireGraphLeafFieldCount(MemorySegment graph, long node) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment out = sizeOut(arena); + int status = invokeInt("rw_wire_graph_leaf_field_count", rwWireGraphLeafFieldCount, + graph, node, out); + checkStatus(status, "rw_wire_graph_leaf_field_count"); + return out.get(SIZE_T, 0); + } + } + + String wireGraphLeafField(MemorySegment graph, long node, long index) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment outData = pointerOut(arena); + MemorySegment outLength = sizeOut(arena); + int status = invokeInt("rw_wire_graph_leaf_field", rwWireGraphLeafField, + graph, node, index, outData, outLength); + checkStatus(status, "rw_wire_graph_leaf_field"); + return copyBorrowedUtf8(outData.get(ValueLayout.ADDRESS, 0), outLength.get(SIZE_T, 0)); + } + } + void pageClose(MemorySegment page, double timeout, boolean runBeforeUnload) { int status = invokeInt("rw_page_close", rwPageClose, page, timeout, runBeforeUnload ? 1 : 0); @@ -268,6 +524,44 @@ private static MemorySegment pointerOut(Arena arena) { out.set(ValueLayout.ADDRESS, 0, MemorySegment.NULL); return out; } + private static MemorySegment sizeOut(Arena arena) { + MemorySegment out = arena.allocate(SIZE_T); + out.set(SIZE_T, 0, 0L); + return out; + } + + private static MemorySegment intOut(Arena arena) { + MemorySegment out = arena.allocate(ValueLayout.JAVA_INT); + out.set(ValueLayout.JAVA_INT, 0, 0); + return out; + } + + private static MemorySegment longOut(Arena arena) { + MemorySegment out = arena.allocate(ValueLayout.JAVA_LONG); + out.set(ValueLayout.JAVA_LONG, 0, 0L); + return out; + } + + private static MemorySegment doubleOut(Arena arena) { + MemorySegment out = arena.allocate(ValueLayout.JAVA_DOUBLE); + out.set(ValueLayout.JAVA_DOUBLE, 0, 0.0d); + return out; + } + + private static String copyBorrowedUtf8(MemorySegment pointer, long length) { + if (length < 0 || length > Integer.MAX_VALUE) { + throw new RustwrightException("native returned invalid UTF-8 byte length: " + length); + } + if (length == 0) { + return ""; + } + if (isNull(pointer)) { + throw new RustwrightException("native returned NULL UTF-8 data with nonzero length " + length); + } + byte[] bytes = pointer.reinterpret(length).toArray(ValueLayout.JAVA_BYTE); + return new String(bytes, StandardCharsets.UTF_8); + } + private static MemorySegment nullableString(Arena arena, String value) { return value == null ? MemorySegment.NULL : string(arena, value); diff --git a/java/src/main/java/com/skyvern/rustwright/Page.java b/java/src/main/java/com/skyvern/rustwright/Page.java index d522070..1ff605e 100644 --- a/java/src/main/java/com/skyvern/rustwright/Page.java +++ b/java/src/main/java/com/skyvern/rustwright/Page.java @@ -107,8 +107,9 @@ private Object evaluateInternal(String expression, String argumentJson, Double t Objects.requireNonNull(expression, "expression"); synchronized (lock) { requireOpen(); - String wireJson = bindings.pageEvaluate(handle, expression, argumentJson, timeout(timeoutMs)); - return WireValueDecoder.decodeJson(wireJson); + MemorySegment graph = bindings.pageEvaluateGraph( + handle, expression, argumentJson, timeout(timeoutMs)); + return WireValueDecoder.decodeGraph(graph, bindings); } } diff --git a/java/src/main/java/com/skyvern/rustwright/WireValueDecoder.java b/java/src/main/java/com/skyvern/rustwright/WireValueDecoder.java index 9c56c75..b302092 100644 --- a/java/src/main/java/com/skyvern/rustwright/WireValueDecoder.java +++ b/java/src/main/java/com/skyvern/rustwright/WireValueDecoder.java @@ -4,112 +4,173 @@ import java.net.URI; import java.time.Instant; import java.time.format.DateTimeParseException; +import java.lang.foreign.MemorySegment; import java.util.ArrayList; -import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.regex.Pattern; -/** Decodes the tagged JSON values produced by the CDP evaluate serializer. */ +/** Materializes the canonical native wire graph without exposing native views. */ final class WireValueDecoder { - private final Map references = new HashMap<>(); + private static final int NODE_NULL = 0; + private static final int NODE_BOOL = 1; + private static final int NODE_SIGNED = 2; + private static final int NODE_UNSIGNED = 3; + private static final int NODE_FLOAT = 4; + private static final int NODE_STRING = 5; + private static final int NODE_ARRAY = 6; + private static final int NODE_OBJECT = 7; + private static final int NODE_LEAF = 8; - static Object decodeJson(String json) { - return new WireValueDecoder().decode(Json.parse(json)); + private static final int LEAF_UNSERIALIZABLE = 0; + private static final int LEAF_BIGINT = 1; + private static final int LEAF_DATE = 2; + private static final int LEAF_REGEXP = 3; + private static final int LEAF_URL = 4; + private static final int LEAF_ERROR = 5; + private static final int LEAF_UNDEFINED = 6; + private static final int LEAF_SYMBOL = 7; + private static final int LEAF_FUNCTION = 8; + + private WireValueDecoder() {} + + static Object decode(String json, NativeBindings bindings) { + return decodeGraph(bindings.wireGraphParse(json), bindings); } - private Object decode(Object value) { - if (value instanceof List list) { - List decoded = new ArrayList<>(list.size()); - for (Object item : list) { - decoded.add(decode(item)); - } - return decoded; - } - if (!(value instanceof Map rawMap)) { - return value; + static Object decodeGraph(MemorySegment graph, NativeBindings bindings) { + try { + return new Materializer(bindings, graph).materialize(); + } finally { + bindings.wireGraphFree(graph); } + } - Map map = stringMap(rawMap); - if (map.containsKey("__rustwright_cdp_ref__")) { - Object id = map.get("__rustwright_cdp_ref__"); - if (!references.containsKey(id)) { - throw new RustwrightException("evaluate wire contains an unknown reference: " + id); - } - return references.get(id); + + private static final class Materializer { + private final NativeBindings bindings; + private final MemorySegment graph; + private Object[] values; + private int[] kinds; + + private Materializer(NativeBindings bindings, MemorySegment graph) { + this.bindings = bindings; + this.graph = graph; } - if (map.containsKey("__rustwright_cdp_array__")) { - Object id = map.get("__rustwright_cdp_array__"); - Object itemsValue = map.get("items"); - if (!(itemsValue instanceof List items)) { - throw new RustwrightException("evaluate array wrapper has no items array"); + + private Object materialize() { + long countValue = bindings.wireGraphNodeCount(graph); + int count = checkedLength(countValue, "node count"); + long root = bindings.wireGraphRoot(graph); + values = new Object[count]; + kinds = new int[count]; + + // First pass: every node gets its final scalar or an empty identity-bearing container. + for (int node = 0; node < count; node++) { + long nodeId = node; + int kind = bindings.wireGraphNodeKind(graph, nodeId); + kinds[node] = kind; + values[node] = switch (kind) { + case NODE_NULL -> null; + case NODE_BOOL -> bindings.wireGraphGetBool(graph, nodeId); + case NODE_SIGNED -> bindings.wireGraphGetSigned(graph, nodeId); + case NODE_UNSIGNED -> decodeUnsigned(bindings.wireGraphGetUnsigned(graph, nodeId)); + case NODE_FLOAT -> bindings.wireGraphGetFloat(graph, nodeId); + case NODE_STRING -> bindings.wireGraphGetString(graph, nodeId); + case NODE_ARRAY -> emptyArray(bindings.wireGraphArrayLength(graph, nodeId)); + case NODE_OBJECT -> new LinkedHashMap(); + case NODE_LEAF -> decodeLeaf(nodeId); + default -> throw new RustwrightException("native wire graph returned unknown node kind " + kind); + }; } - List decoded = new ArrayList<>(items.size()); - register(id, decoded); - for (Object item : items) { - decoded.add(decode(item)); + + // Second pass: all child ids now point at allocated placeholders or immutable values. + for (int node = 0; node < count; node++) { + long nodeId = node; + if (kinds[node] == NODE_ARRAY) { + @SuppressWarnings("unchecked") + List array = (List) values[node]; + long length = bindings.wireGraphArrayLength(graph, nodeId); + for (long index = 0; index < length; index++) { + array.set(checkedIndex(index, array.size(), "array child"), + values[nodeIndex(bindings.wireGraphArrayChild(graph, nodeId, index))]); + } + } else if (kinds[node] == NODE_OBJECT) { + @SuppressWarnings("unchecked") + Map object = (Map) values[node]; + long length = bindings.wireGraphObjectLength(graph, nodeId); + for (long index = 0; index < length; index++) { + String key = bindings.wireGraphObjectKey(graph, nodeId, index); + object.put(key, values[nodeIndex( + bindings.wireGraphObjectChild(graph, nodeId, index))]); + } + } } - return decoded; + return values[nodeIndex(root)]; } - if (map.containsKey("__rustwright_cdp_object__")) { - Object id = map.get("__rustwright_cdp_object__"); - Object entriesValue = map.get("entries"); - if (!(entriesValue instanceof Map entries)) { - throw new RustwrightException("evaluate object wrapper has no entries object"); + + private Object decodeLeaf(long node) { + int leafKind = bindings.wireGraphLeafKind(graph, node); + long fieldCountValue = bindings.wireGraphLeafFieldCount(graph, node); + int fieldCount = checkedLength(fieldCountValue, "leaf field count"); + List fields = new ArrayList<>(fieldCount); + for (long index = 0; index < fieldCount; index++) { + fields.add(bindings.wireGraphLeafField(graph, node, index)); } - Map decoded = new LinkedHashMap<>(); - register(id, decoded); - for (Map.Entry entry : entries.entrySet()) { - if (!(entry.getKey() instanceof String key)) { - throw new RustwrightException("evaluate object wrapper has a non-string key"); + + return switch (leafKind) { + case LEAF_UNSERIALIZABLE -> requireFields(fields, 1, "unserializable", + () -> decodeUnserializable(fields.get(0))); + case LEAF_BIGINT -> requireFields(fields, 1, "bigint", + () -> parseBigIntegerOrString(fields.get(0), fields.get(0))); + case LEAF_DATE -> requireFields(fields, 1, "date", + () -> decodeDate(fields.get(0))); + case LEAF_REGEXP -> requireFields(fields, 2, "regexp", + () -> decodePattern(fields.get(0), fields.get(1))); + case LEAF_URL -> requireFields(fields, 1, "url", + () -> decodeUrl(fields.get(0))); + case LEAF_ERROR -> requireFields(fields, 3, "error", + () -> new JavaScriptErrorValue(fields.get(0), fields.get(1), fields.get(2))); + case LEAF_UNDEFINED, LEAF_SYMBOL, LEAF_FUNCTION -> { + if (!fields.isEmpty()) { + throw new RustwrightException("native wire graph leaf has unexpected fields"); + } + yield null; } - decoded.put(key, decode(entry.getValue())); - } - return decoded; - } - if (map.containsKey("__rustwright_cdp_undefined__") - || map.containsKey("__rustwright_cdp_symbol__") - || map.containsKey("__rustwright_cdp_function__")) { - return null; - } - if (map.containsKey("__rustwright_cdp_unserializable_value__")) { - return decodeUnserializable(String.valueOf(map.get("__rustwright_cdp_unserializable_value__"))); - } - if (map.containsKey("__rustwright_cdp_date__")) { - String date = String.valueOf(map.get("__rustwright_cdp_date__")); - try { - return Instant.parse(date); - } catch (DateTimeParseException ignored) { - return date; - } + default -> throw new RustwrightException("native wire graph returned unknown leaf kind " + leafKind); + }; } - if (map.containsKey("__rustwright_cdp_regexp__")) { - return decodePattern(map.get("__rustwright_cdp_regexp__")); + + private int nodeIndex(long node) { + return checkedIndex(node, values.length, "node id"); } - if (map.containsKey("__rustwright_cdp_url__")) { - String url = String.valueOf(map.get("__rustwright_cdp_url__")); - try { - return URI.create(url); - } catch (IllegalArgumentException ignored) { - return url; + + private static List emptyArray(long lengthValue) { + int length = checkedLength(lengthValue, "array length"); + List array = new ArrayList<>(length); + for (int index = 0; index < length; index++) { + array.add(null); } - } - if (map.containsKey("__rustwright_cdp_error__")) { - return decodeError(map.get("__rustwright_cdp_error__")); + return array; } - Map decoded = new LinkedHashMap<>(); - for (Map.Entry entry : map.entrySet()) { - decoded.put(entry.getKey(), decode(entry.getValue())); + private static T requireFields( + List fields, + int expected, + String leaf, + java.util.function.Supplier decoder) { + if (fields.size() != expected) { + throw new RustwrightException( + "native wire graph " + leaf + " leaf has " + fields.size() + + " fields; expected " + expected); + } + return decoder.get(); } - return decoded; } - private void register(Object id, Object value) { - if (references.putIfAbsent(id, value) != null) { - throw new RustwrightException("evaluate wire reuses reference id: " + id); - } + private static Object decodeUnsigned(long value) { + return value >= 0 ? value : new BigInteger(Long.toUnsignedString(value)); } private static Object decodeUnserializable(String value) { @@ -132,13 +193,15 @@ private static Object parseBigIntegerOrString(String digits, String fallback) { } } - private static Object decodePattern(Object value) { - if (!(value instanceof Map raw)) { - return value; + private static Object decodeDate(String date) { + try { + return Instant.parse(date); + } catch (DateTimeParseException ignored) { + return date; } - Map regexp = stringMap(raw); - String source = String.valueOf(regexp.getOrDefault("p", "")); - String flags = String.valueOf(regexp.getOrDefault("f", "")); + } + + private static Object decodePattern(String source, String flags) { int javaFlags = 0; if (flags.indexOf('i') >= 0) { javaFlags |= Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE; @@ -155,33 +218,32 @@ private static Object decodePattern(Object value) { try { return Pattern.compile(source, javaFlags); } catch (RuntimeException ignored) { - return value; + Map fallback = new LinkedHashMap<>(); + fallback.put("p", source); + fallback.put("f", flags); + return fallback; } } - private static Object decodeError(Object value) { - if (!(value instanceof Map raw)) { - return new JavaScriptErrorValue("Error", String.valueOf(value), null); + private static Object decodeUrl(String url) { + try { + return URI.create(url); + } catch (IllegalArgumentException ignored) { + return url; } - Map error = stringMap(raw); - return new JavaScriptErrorValue( - nullableText(error.get("name"), "Error"), - nullableText(error.get("message"), ""), - nullableText(error.get("stack"), null)); } - private static String nullableText(Object value, String fallback) { - return value == null ? fallback : String.valueOf(value); + private static int checkedLength(long value, String what) { + if (value < 0 || value > Integer.MAX_VALUE) { + throw new RustwrightException("native wire graph returned invalid " + what + ": " + value); + } + return (int) value; } - private static Map stringMap(Map raw) { - Map result = new LinkedHashMap<>(); - for (Map.Entry entry : raw.entrySet()) { - if (!(entry.getKey() instanceof String key)) { - throw new RustwrightException("evaluate wire object has a non-string key"); - } - result.put(key, entry.getValue()); + private static int checkedIndex(long value, int size, String what) { + if (value < 0 || value >= size) { + throw new RustwrightException("native wire graph returned invalid " + what + ": " + value); } - return result; + return (int) value; } } diff --git a/java/src/test/java/com/skyvern/rustwright/ContractSelfTest.java b/java/src/test/java/com/skyvern/rustwright/ContractSelfTest.java index 2208c47..b20135e 100644 --- a/java/src/test/java/com/skyvern/rustwright/ContractSelfTest.java +++ b/java/src/test/java/com/skyvern/rustwright/ContractSelfTest.java @@ -1,5 +1,7 @@ package com.skyvern.rustwright; +import java.math.BigInteger; +import java.nio.file.Path; import java.util.List; import java.util.Map; @@ -10,16 +12,10 @@ public static void main(String[] arguments) { check(Runner.caseHtmlUrl("A z/~é").equals( "data:text/html;charset=utf-8,A%20z%2F~%C3%A9"), "canonical data URL"); - Object decoded = WireValueDecoder.decodeJson(""" - {"__rustwright_cdp_object__":1,"entries":{ - "items":{"__rustwright_cdp_array__":2,"items":[1,{"__rustwright_cdp_ref__":1}]}, - "missing":{"__rustwright_cdp_undefined__":true} - }} - """); - Map object = (Map) decoded; - List items = (List) object.get("items"); - check(items.get(1) == object, "wire reference identity"); - check(object.containsKey("missing") && object.get("missing") == null, "undefined fallback"); + String nativeLibrary = System.getenv("RUSTWRIGHT_CAPI_LIB"); + if (nativeLibrary != null && !nativeLibrary.isBlank()) { + verifyNativeGraph(Path.of(nativeLibrary)); + } expectInvalid("unknown version", Map.of("version", 2, "cases", validCases())); expectInvalid("duplicate ids", Map.of("version", 1, "cases", List.of( @@ -39,6 +35,31 @@ public static void main(String[] arguments) { System.out.println("ContractSelfTest: ok"); } + @SuppressWarnings("unchecked") + private static void verifyNativeGraph(Path libraryPath) { + NativeBindings bindings = new NativeBindings(libraryPath); + Object decoded = WireValueDecoder.decode(""" + { + "__rustwright_cdp_object__": 1, + "entries": { + "self": {"__rustwright_cdp_ref__": 1}, + "shared": { + "__rustwright_cdp_object__": 2, + "entries": {"value": "a\\u0000b"} + }, + "again": {"__rustwright_cdp_ref__": 2}, + "bigint": {"__rustwright_cdp_unserializable_value__": "123n"} + } + } + """, bindings); + Map root = (Map) decoded; + check(root.get("self") == root, "wire graph self-cycle"); + check(root.get("shared") == root.get("again"), "wire graph repeated identity"); + check(((Map) root.get("shared")).get("value").equals("a\0b"), + "wire graph embedded NUL"); + check(root.get("bigint").equals(BigInteger.valueOf(123)), "wire graph bigint"); + } + private static List> validCases() { return List.of(validCase("valid", List.of(Map.of("op", "title", "capture", "title")))); } diff --git a/php/contract_test.php b/php/contract_test.php new file mode 100755 index 0000000..7f9fb35 --- /dev/null +++ b/php/contract_test.php @@ -0,0 +1,42 @@ +#!/usr/bin/env php +self !== $decoded) { + throw new RuntimeException('wire graph did not preserve its self-cycle'); +} +if ($decoded->shared !== $decoded->again) { + throw new RuntimeException('wire graph did not preserve repeated object identity'); +} +if ($decoded->shared->value !== "a\0b") { + throw new RuntimeException('wire graph did not preserve an embedded NUL'); +} +if ($decoded->bigint !== null) { + throw new RuntimeException('BigInt did not use the PHP null fallback'); +} + +echo "graph contract ok\n"; diff --git a/php/src/NativeLibrary.php b/php/src/NativeLibrary.php index f65e083..88fd6ea 100644 --- a/php/src/NativeLibrary.php +++ b/php/src/NativeLibrary.php @@ -8,14 +8,38 @@ final class NativeLibrary { private const C_DECLARATIONS = <<<'CDEF' typedef signed int int32_t; +typedef signed long long int64_t; +typedef unsigned long long uint64_t; typedef unsigned char uint8_t; typedef struct RwBrowser RwBrowser; typedef struct RwPage RwPage; +typedef struct RwWireGraph RwWireGraph; +typedef size_t RwWireNodeId; +typedef int32_t RwWireNodeKind; +typedef int32_t RwWireLeafKind; const char *rw_last_error(void); void rw_string_free(char *s); void rw_bytes_free(uint8_t *buf, size_t len); +int32_t rw_wire_graph_parse(const char *wire_json, RwWireGraph **out_graph); +void rw_wire_graph_free(RwWireGraph *graph); +int32_t rw_wire_graph_node_count(const RwWireGraph *graph, size_t *out_count); +int32_t rw_wire_graph_root(const RwWireGraph *graph, RwWireNodeId *out_root); +int32_t rw_wire_graph_node_kind(const RwWireGraph *graph, RwWireNodeId node, RwWireNodeKind *out_kind); +int32_t rw_wire_graph_get_bool(const RwWireGraph *graph, RwWireNodeId node, int32_t *out_value); +int32_t rw_wire_graph_get_signed(const RwWireGraph *graph, RwWireNodeId node, int64_t *out_value); +int32_t rw_wire_graph_get_unsigned(const RwWireGraph *graph, RwWireNodeId node, uint64_t *out_value); +int32_t rw_wire_graph_get_float(const RwWireGraph *graph, RwWireNodeId node, double *out_value); +int32_t rw_wire_graph_get_string(const RwWireGraph *graph, RwWireNodeId node, const uint8_t **out_ptr, size_t *out_len); +int32_t rw_wire_graph_array_length(const RwWireGraph *graph, RwWireNodeId node, size_t *out_len); +int32_t rw_wire_graph_array_child(const RwWireGraph *graph, RwWireNodeId node, size_t index, RwWireNodeId *out_child); +int32_t rw_wire_graph_object_length(const RwWireGraph *graph, RwWireNodeId node, size_t *out_len); +int32_t rw_wire_graph_object_key(const RwWireGraph *graph, RwWireNodeId node, size_t index, const uint8_t **out_ptr, size_t *out_len); +int32_t rw_wire_graph_object_child(const RwWireGraph *graph, RwWireNodeId node, size_t index, RwWireNodeId *out_child); +int32_t rw_wire_graph_leaf_kind(const RwWireGraph *graph, RwWireNodeId node, RwWireLeafKind *out_kind); +int32_t rw_wire_graph_leaf_field_count(const RwWireGraph *graph, RwWireNodeId node, size_t *out_count); +int32_t rw_wire_graph_leaf_field(const RwWireGraph *graph, RwWireNodeId node, size_t index, const uint8_t **out_ptr, size_t *out_len); int32_t rw_chromium_executable_path(char **out_path); int32_t rw_chromium_launch(const char *options_json, RwBrowser **out_browser); int32_t rw_browser_new_page(RwBrowser *b, RwPage **out_page); @@ -191,7 +215,7 @@ public function pageEvaluate( string $expression, ?string $argumentJson, ?float $timeout, - ): mixed { + ): string { $out = $this->ffi->new('char *'); $status = $this->ffi->rw_page_evaluate( $page, @@ -205,7 +229,170 @@ public function pageEvaluate( if ($json === null) { throw new RustwrightException('rw_page_evaluate succeeded without returning JSON'); } - return WireDecoder::decode($json); + return $json; + } + public function wireGraphParse(string $wireJson): \FFI\CData + { + $out = $this->ffi->new('RwWireGraph *'); + $status = $this->ffi->rw_wire_graph_parse(self::cAbiString($wireJson), \FFI::addr($out)); + $this->checkStatus($status, 'rw_wire_graph_parse'); + if ($this->isNull($out)) { + throw new RustwrightException('rw_wire_graph_parse succeeded without returning a graph'); + } + return $out; + } + + public function wireGraphFree(\FFI\CData $graph): void + { + $this->ffi->rw_wire_graph_free($graph); + } + + public function wireGraphNodeCount(\FFI\CData $graph): int + { + $out = $this->ffi->new('size_t'); + $status = $this->ffi->rw_wire_graph_node_count($graph, \FFI::addr($out)); + $this->checkStatus($status, 'rw_wire_graph_node_count'); + return (int) $out->cdata; + } + + public function wireGraphRoot(\FFI\CData $graph): int + { + $out = $this->ffi->new('RwWireNodeId'); + $status = $this->ffi->rw_wire_graph_root($graph, \FFI::addr($out)); + $this->checkStatus($status, 'rw_wire_graph_root'); + return (int) $out->cdata; + } + + public function wireGraphNodeKind(\FFI\CData $graph, int $node): int + { + $out = $this->ffi->new('RwWireNodeKind'); + $status = $this->ffi->rw_wire_graph_node_kind($graph, $node, \FFI::addr($out)); + $this->checkStatus($status, 'rw_wire_graph_node_kind'); + return (int) $out->cdata; + } + + public function wireGraphGetBool(\FFI\CData $graph, int $node): bool + { + $out = $this->ffi->new('int32_t'); + $status = $this->ffi->rw_wire_graph_get_bool($graph, $node, \FFI::addr($out)); + $this->checkStatus($status, 'rw_wire_graph_get_bool'); + return (int) $out->cdata !== 0; + } + + public function wireGraphGetSigned(\FFI\CData $graph, int $node): int + { + $out = $this->ffi->new('int64_t'); + $status = $this->ffi->rw_wire_graph_get_signed($graph, $node, \FFI::addr($out)); + $this->checkStatus($status, 'rw_wire_graph_get_signed'); + return (int) $out->cdata; + } + + public function wireGraphGetUnsigned(\FFI\CData $graph, int $node): int|float + { + $out = $this->ffi->new('uint64_t'); + $status = $this->ffi->rw_wire_graph_get_unsigned($graph, $node, \FFI::addr($out)); + $this->checkStatus($status, 'rw_wire_graph_get_unsigned'); + return is_int($out->cdata) ? $out->cdata : (float) $out->cdata; + } + + public function wireGraphGetFloat(\FFI\CData $graph, int $node): float + { + $out = $this->ffi->new('double'); + $status = $this->ffi->rw_wire_graph_get_float($graph, $node, \FFI::addr($out)); + $this->checkStatus($status, 'rw_wire_graph_get_float'); + return (float) $out->cdata; + } + + public function wireGraphGetString(\FFI\CData $graph, int $node): string + { + $pointer = $this->ffi->new('uint8_t *'); + $length = $this->ffi->new('size_t'); + $status = $this->ffi->rw_wire_graph_get_string( + $graph, + $node, + \FFI::addr($pointer), + \FFI::addr($length), + ); + $this->checkStatus($status, 'rw_wire_graph_get_string'); + return $this->copyBorrowedBytes($pointer, (int) $length->cdata); + } + + public function wireGraphArrayLength(\FFI\CData $graph, int $node): int + { + $out = $this->ffi->new('size_t'); + $status = $this->ffi->rw_wire_graph_array_length($graph, $node, \FFI::addr($out)); + $this->checkStatus($status, 'rw_wire_graph_array_length'); + return (int) $out->cdata; + } + + public function wireGraphArrayChild(\FFI\CData $graph, int $node, int $index): int + { + $out = $this->ffi->new('RwWireNodeId'); + $status = $this->ffi->rw_wire_graph_array_child($graph, $node, $index, \FFI::addr($out)); + $this->checkStatus($status, 'rw_wire_graph_array_child'); + return (int) $out->cdata; + } + + public function wireGraphObjectLength(\FFI\CData $graph, int $node): int + { + $out = $this->ffi->new('size_t'); + $status = $this->ffi->rw_wire_graph_object_length($graph, $node, \FFI::addr($out)); + $this->checkStatus($status, 'rw_wire_graph_object_length'); + return (int) $out->cdata; + } + + public function wireGraphObjectKey(\FFI\CData $graph, int $node, int $index): string + { + $pointer = $this->ffi->new('uint8_t *'); + $length = $this->ffi->new('size_t'); + $status = $this->ffi->rw_wire_graph_object_key( + $graph, + $node, + $index, + \FFI::addr($pointer), + \FFI::addr($length), + ); + $this->checkStatus($status, 'rw_wire_graph_object_key'); + return $this->copyBorrowedBytes($pointer, (int) $length->cdata); + } + + public function wireGraphObjectChild(\FFI\CData $graph, int $node, int $index): int + { + $out = $this->ffi->new('RwWireNodeId'); + $status = $this->ffi->rw_wire_graph_object_child($graph, $node, $index, \FFI::addr($out)); + $this->checkStatus($status, 'rw_wire_graph_object_child'); + return (int) $out->cdata; + } + + public function wireGraphLeafKind(\FFI\CData $graph, int $node): int + { + $out = $this->ffi->new('RwWireLeafKind'); + $status = $this->ffi->rw_wire_graph_leaf_kind($graph, $node, \FFI::addr($out)); + $this->checkStatus($status, 'rw_wire_graph_leaf_kind'); + return (int) $out->cdata; + } + + public function wireGraphLeafFieldCount(\FFI\CData $graph, int $node): int + { + $out = $this->ffi->new('size_t'); + $status = $this->ffi->rw_wire_graph_leaf_field_count($graph, $node, \FFI::addr($out)); + $this->checkStatus($status, 'rw_wire_graph_leaf_field_count'); + return (int) $out->cdata; + } + + public function wireGraphLeafField(\FFI\CData $graph, int $node, int $index): string + { + $pointer = $this->ffi->new('uint8_t *'); + $length = $this->ffi->new('size_t'); + $status = $this->ffi->rw_wire_graph_leaf_field( + $graph, + $node, + $index, + \FFI::addr($pointer), + \FFI::addr($length), + ); + $this->checkStatus($status, 'rw_wire_graph_leaf_field'); + return $this->copyBorrowedBytes($pointer, (int) $length->cdata); } public function pageScreenshot(\FFI\CData $page, ?string $optionsJson): string @@ -274,6 +461,16 @@ private function copyStringAndFree(\FFI\CData $value): string $this->ffi->rw_string_free($value); } } + private function copyBorrowedBytes(mixed $pointer, int $length): string + { + if ($length === 0) { + return ''; + } + if ($this->isNull($pointer)) { + throw new RustwrightException('Rustwright returned NULL bytes with a nonzero length'); + } + return \FFI::string($pointer, $length); + } private function isNull(mixed $value): bool { diff --git a/php/src/Page.php b/php/src/Page.php index adbc599..88de81a 100644 --- a/php/src/Page.php +++ b/php/src/Page.php @@ -69,7 +69,8 @@ public function evaluate(string $expression, mixed $argument = null, array $opti $this->ensureOpen(); self::assertOptionKeys($options, ['timeout'], 'evaluate'); $argumentJson = $argumentWasProvided ? Json::encodeValue($argument) : null; - return $this->native->pageEvaluate($this->handle, $expression, $argumentJson, self::timeout($options)); + $wireJson = $this->native->pageEvaluate($this->handle, $expression, $argumentJson, self::timeout($options)); + return WireDecoder::decode($wireJson, $this->native); } /** @param array $options */ diff --git a/php/src/WireDecoder.php b/php/src/WireDecoder.php index 1a9481b..5a9cf43 100644 --- a/php/src/WireDecoder.php +++ b/php/src/WireDecoder.php @@ -6,101 +6,138 @@ final class WireDecoder { - public static function decode(string $json): mixed + private const NODE_NULL = 0; + private const NODE_BOOL = 1; + private const NODE_SIGNED = 2; + private const NODE_UNSIGNED = 3; + private const NODE_FLOAT = 4; + private const NODE_STRING = 5; + private const NODE_ARRAY = 6; + private const NODE_OBJECT = 7; + private const NODE_LEAF = 8; + + private const LEAF_UNSERIALIZABLE = 0; + private const LEAF_BIGINT = 1; + private const LEAF_DATE = 2; + private const LEAF_REGEXP = 3; + private const LEAF_URL = 4; + private const LEAF_ERROR = 5; + private const LEAF_UNDEFINED = 6; + private const LEAF_SYMBOL = 7; + private const LEAF_FUNCTION = 8; + + public static function decode(string $json, NativeLibrary $native): mixed { - $value = Json::decode($json); - $references = []; - return self::decodeValue($value, $references); + $graph = $native->wireGraphParse($json); + try { + return self::materialize($graph, $native); + } finally { + $native->wireGraphFree($graph); + } } - /** @param array $references */ - private static function decodeValue(mixed $value, array &$references): mixed + private static function materialize(\FFI\CData $graph, NativeLibrary $native): mixed { - if (is_array($value)) { - $decoded = []; - foreach ($value as $item) { - $decoded[] = self::decodeValue($item, $references); - } - return $decoded; + $count = $native->wireGraphNodeCount($graph); + $values = array_fill(0, $count, null); + $kinds = array_fill(0, $count, null); + + for ($node = 0; $node < $count; $node++) { + $kind = $native->wireGraphNodeKind($graph, $node); + $kinds[$node] = $kind; + $values[$node] = self::allocateNode($graph, $native, $node, $kind); } - if (!$value instanceof \stdClass) { - return $value; - } - - $fields = get_object_vars($value); - - if (array_key_exists('__rustwright_cdp_ref__', $fields)) { - $id = (string) $fields['__rustwright_cdp_ref__']; - return $references[$id] ?? null; - } - - if (array_key_exists('__rustwright_cdp_array__', $fields) && isset($fields['items']) && is_array($fields['items'])) { - $id = (string) $fields['__rustwright_cdp_array__']; - $decoded = []; - $references[$id] =& $decoded; - foreach ($fields['items'] as $item) { - $decoded[] = self::decodeValue($item, $references); + for ($node = 0; $node < $count; $node++) { + if ($kinds[$node] === self::NODE_ARRAY) { + $length = $native->wireGraphArrayLength($graph, $node); + for ($index = 0; $index < $length; $index++) { + $child = $native->wireGraphArrayChild($graph, $node, $index); + $values[$node][] =& $values[$child]; + } + continue; } - return $decoded; - } - if (array_key_exists('__rustwright_cdp_object__', $fields) && isset($fields['entries']) && $fields['entries'] instanceof \stdClass) { - $id = (string) $fields['__rustwright_cdp_object__']; - $decoded = new \stdClass(); - $references[$id] = $decoded; - foreach (get_object_vars($fields['entries']) as $key => $item) { - $decoded->{$key} = self::decodeValue($item, $references); + if ($kinds[$node] === self::NODE_OBJECT) { + $length = $native->wireGraphObjectLength($graph, $node); + for ($index = 0; $index < $length; $index++) { + $key = $native->wireGraphObjectKey($graph, $node, $index); + $child = $native->wireGraphObjectChild($graph, $node, $index); + $values[$node]->{$key} =& $values[$child]; + } } - return $decoded; - } - - if (array_key_exists('__rustwright_cdp_undefined__', $fields) - || array_key_exists('__rustwright_cdp_symbol__', $fields) - || array_key_exists('__rustwright_cdp_function__', $fields)) { - return null; } - if (array_key_exists('__rustwright_cdp_unserializable_value__', $fields)) { - return match ($fields['__rustwright_cdp_unserializable_value__']) { - 'NaN' => NAN, - 'Infinity' => INF, - '-Infinity' => -INF, - '-0' => -0.0, - default => null, - }; - } + return $values[$native->wireGraphRoot($graph)]; + } - if (array_key_exists('__rustwright_cdp_date__', $fields)) { - try { - return new \DateTimeImmutable((string) $fields['__rustwright_cdp_date__']); - } catch (\Exception) { - return (string) $fields['__rustwright_cdp_date__']; - } - } + private static function allocateNode( + \FFI\CData $graph, + NativeLibrary $native, + int $node, + int $kind, + ): mixed { + return match ($kind) { + self::NODE_NULL => null, + self::NODE_BOOL => $native->wireGraphGetBool($graph, $node), + self::NODE_SIGNED => $native->wireGraphGetSigned($graph, $node), + self::NODE_UNSIGNED => $native->wireGraphGetUnsigned($graph, $node), + self::NODE_FLOAT => $native->wireGraphGetFloat($graph, $node), + self::NODE_STRING => $native->wireGraphGetString($graph, $node), + self::NODE_ARRAY => [], + self::NODE_OBJECT => new \stdClass(), + self::NODE_LEAF => self::decodeLeaf($graph, $native, $node), + default => throw new RustwrightException('Unknown wire node kind: ' . $kind), + }; + } - if (array_key_exists('__rustwright_cdp_url__', $fields)) { - return (string) $fields['__rustwright_cdp_url__']; + private static function decodeLeaf(\FFI\CData $graph, NativeLibrary $native, int $node): mixed + { + $kind = $native->wireGraphLeafKind($graph, $node); + $fields = []; + $count = $native->wireGraphLeafFieldCount($graph, $node); + for ($index = 0; $index < $count; $index++) { + $fields[] = $native->wireGraphLeafField($graph, $node, $index); } - if (array_key_exists('__rustwright_cdp_regexp__', $fields)) { - $regexp = $fields['__rustwright_cdp_regexp__']; - if ($regexp instanceof \stdClass) { - return (object) [ - 'pattern' => property_exists($regexp, 'p') ? $regexp->p : '', - 'flags' => property_exists($regexp, 'f') ? $regexp->f : '', - ]; - } - } + return match ($kind) { + self::LEAF_UNSERIALIZABLE => self::decodeUnserializable($fields[0] ?? ''), + self::LEAF_BIGINT, + self::LEAF_UNDEFINED, + self::LEAF_SYMBOL, + self::LEAF_FUNCTION => null, + self::LEAF_DATE => self::decodeDate($fields[0] ?? ''), + self::LEAF_REGEXP => (object) [ + 'pattern' => $fields[0] ?? '', + 'flags' => $fields[1] ?? '', + ], + self::LEAF_URL => $fields[0] ?? '', + self::LEAF_ERROR => (object) [ + 'name' => $fields[0] ?? '', + 'message' => $fields[1] ?? '', + 'stack' => $fields[2] ?? '', + ], + default => throw new RustwrightException('Unknown wire leaf kind: ' . $kind), + }; + } - if (array_key_exists('__rustwright_cdp_error__', $fields)) { - return self::decodeValue($fields['__rustwright_cdp_error__'], $references); - } + private static function decodeUnserializable(string $value): ?float + { + return match ($value) { + 'NaN' => NAN, + 'Infinity' => INF, + '-Infinity' => -INF, + '-0' => -0.0, + default => null, + }; + } - $decoded = new \stdClass(); - foreach ($fields as $key => $item) { - $decoded->{$key} = self::decodeValue($item, $references); + private static function decodeDate(string $value): \DateTimeImmutable|string + { + try { + return new \DateTimeImmutable($value); + } catch (\Exception) { + return $value; } - return $decoded; } } diff --git a/ruby/lib/rustwright.rb b/ruby/lib/rustwright.rb index 37ec6f4..bde153c 100644 --- a/ruby/lib/rustwright.rb +++ b/ruby/lib/rustwright.rb @@ -386,8 +386,8 @@ def evaluate(expression, argument = UNSET, options = nil, **keywords) out ) @native.check_status!(status, 'rw_page_evaluate') - json = @native.copy_owned_string(@native.pointer_address(out)) - Wire.decode(JSON.parse(json)) + wire_json = @native.copy_owned_string(@native.pointer_address(out)) + Wire.decode(@native, wire_json) end end diff --git a/ruby/lib/rustwright/native.rb b/ruby/lib/rustwright/native.rb index 9f681d4..1d1e76c 100644 --- a/ruby/lib/rustwright/native.rb +++ b/ruby/lib/rustwright/native.rb @@ -15,6 +15,24 @@ class Native rw_last_error: [[], VOIDP], rw_string_free: [[VOIDP], VOID], rw_bytes_free: [[VOIDP, SIZE_T], VOID], + rw_wire_graph_parse: [[VOIDP, VOIDP], INT], + rw_wire_graph_free: [[VOIDP], VOID], + rw_wire_graph_node_count: [[VOIDP, VOIDP], INT], + rw_wire_graph_root: [[VOIDP, VOIDP], INT], + rw_wire_graph_node_kind: [[VOIDP, SIZE_T, VOIDP], INT], + rw_wire_graph_get_bool: [[VOIDP, SIZE_T, VOIDP], INT], + rw_wire_graph_get_signed: [[VOIDP, SIZE_T, VOIDP], INT], + rw_wire_graph_get_unsigned: [[VOIDP, SIZE_T, VOIDP], INT], + rw_wire_graph_get_float: [[VOIDP, SIZE_T, VOIDP], INT], + rw_wire_graph_get_string: [[VOIDP, SIZE_T, VOIDP, VOIDP], INT], + rw_wire_graph_array_length: [[VOIDP, SIZE_T, VOIDP], INT], + rw_wire_graph_array_child: [[VOIDP, SIZE_T, SIZE_T, VOIDP], INT], + rw_wire_graph_object_length: [[VOIDP, SIZE_T, VOIDP], INT], + rw_wire_graph_object_key: [[VOIDP, SIZE_T, SIZE_T, VOIDP, VOIDP], INT], + rw_wire_graph_object_child: [[VOIDP, SIZE_T, SIZE_T, VOIDP], INT], + rw_wire_graph_leaf_kind: [[VOIDP, SIZE_T, VOIDP], INT], + rw_wire_graph_leaf_field_count: [[VOIDP, SIZE_T, VOIDP], INT], + rw_wire_graph_leaf_field: [[VOIDP, SIZE_T, SIZE_T, VOIDP, VOIDP], INT], rw_chromium_executable_path: [[VOIDP], INT], rw_chromium_launch: [[VOIDP, VOIDP], INT], rw_browser_new_page: [[VOIDP, VOIDP], INT], @@ -78,6 +96,54 @@ def size_slot slot end + def int32_slot + scalar_slot('l') + end + + def int64_slot + scalar_slot('q') + end + + def uint64_slot + scalar_slot('Q') + end + + def double_slot + scalar_slot('d') + end + + def int32_value(slot) + slot[0, 4].unpack1('l') + end + + def int64_value(slot) + slot[0, 8].unpack1('q') + end + + def uint64_value(slot) + slot[0, 8].unpack1('Q') + end + + def double_value(slot) + slot[0, 8].unpack1('d') + end + + def copy_borrowed_bytes(pointer, length) + return ''.b if length.zero? + + address = pointer.to_i + raise Rustwright::Error, "Rustwright returned NULL bytes with length #{length}" if address.zero? + + Fiddle::Pointer.new(address).to_str(length).dup.force_encoding(Encoding::BINARY) + end + + def scalar_slot(format) + bytes = [0].pack(format).bytesize + slot = Fiddle::Pointer.malloc(bytes) + slot[0, bytes] = [0].pack(format) + slot + end + def pointer_address(slot) slot[0, Fiddle::SIZEOF_VOIDP].unpack('J').first end diff --git a/ruby/lib/rustwright/wire.rb b/ruby/lib/rustwright/wire.rb index d096e09..a632ca8 100644 --- a/ruby/lib/rustwright/wire.rb +++ b/ruby/lib/rustwright/wire.rb @@ -17,75 +17,175 @@ def initialize(value) module Wire module_function - MARKER = '__rustwright_cdp_unserializable_value__' + NODE_NULL = 0 + NODE_BOOL = 1 + NODE_SIGNED = 2 + NODE_UNSIGNED = 3 + NODE_FLOAT = 4 + NODE_STRING = 5 + NODE_ARRAY = 6 + NODE_OBJECT = 7 + NODE_LEAF = 8 - def decode(value) - decode_value(value, {}) - end + LEAF_UNSERIALIZABLE = 0 + LEAF_BIGINT = 1 + LEAF_DATE = 2 + LEAF_REGEXP = 3 + LEAF_URL = 4 + LEAF_ERROR = 5 + LEAF_UNDEFINED = 6 + LEAF_SYMBOL = 7 + LEAF_FUNCTION = 8 - def decode_value(value, references) - case value - when Array - value.map { |item| decode_value(item, references) } - when Hash - decode_hash(value, references) - else - value - end - end - private_class_method :decode_value - - def decode_hash(value, references) - if value.key?('__rustwright_cdp_array__') - target = [] - references[value['__rustwright_cdp_array__']] = target - target.concat(Array(value['items']).map { |item| decode_value(item, references) }) - return target + def decode(native, wire_json) + unless wire_json.is_a?(String) + raise ArgumentError, 'wire JSON must be a String' end - if value.key?('__rustwright_cdp_object__') - target = {} - references[value['__rustwright_cdp_object__']] = target - Hash(value['entries']).each do |key, item| - target[key] = decode_value(item, references) - end - return target - end + graph_slot = native.pointer_slot + status = native.call(:rw_wire_graph_parse, wire_json, graph_slot) + native.check_status!(status, 'rw_wire_graph_parse') + graph = native.pointer_address(graph_slot) + raise Rustwright::Error, 'rw_wire_graph_parse succeeded without returning a graph' if graph.zero? - if value.key?('__rustwright_cdp_ref__') - return references[value['__rustwright_cdp_ref__']] + begin + materialize(native, graph) + ensure + native.call(:rw_wire_graph_free, graph) end + end - return nil if value.key?('__rustwright_cdp_undefined__') - return nil if value.key?('__rustwright_cdp_symbol__') - return nil if value.key?('__rustwright_cdp_function__') + def materialize(native, graph) + count = read_size(native, :rw_wire_graph_node_count, [graph], 'rw_wire_graph_node_count') + values = Array.new(count) + kinds = Array.new(count) - if value.key?(MARKER) - return decode_unserializable(value[MARKER]) + count.times do |node| + kind = read_int32( + native, + :rw_wire_graph_node_kind, + [graph, node], + 'rw_wire_graph_node_kind' + ) + kinds[node] = kind + values[node] = allocate_node(native, graph, node, kind) end - if value.key?('__rustwright_cdp_date__') - return Time.iso8601(value['__rustwright_cdp_date__']) + count.times do |node| + case kinds[node] + when NODE_ARRAY + length = read_size( + native, + :rw_wire_graph_array_length, + [graph, node], + 'rw_wire_graph_array_length' + ) + length.times do |index| + child = read_size( + native, + :rw_wire_graph_array_child, + [graph, node, index], + 'rw_wire_graph_array_child' + ) + values[node] << values.fetch(child) + end + when NODE_OBJECT + length = read_size( + native, + :rw_wire_graph_object_length, + [graph, node], + 'rw_wire_graph_object_length' + ) + length.times do |index| + key = read_text( + native, + :rw_wire_graph_object_key, + [graph, node, index], + 'rw_wire_graph_object_key' + ) + child = read_size( + native, + :rw_wire_graph_object_child, + [graph, node, index], + 'rw_wire_graph_object_child' + ) + values[node][key] = values.fetch(child) + end + end end - if value.key?('__rustwright_cdp_regexp__') - regexp = value['__rustwright_cdp_regexp__'] - return Regexp.new(regexp['p'], regexp_options(regexp['f'].to_s)) - end + root = read_size(native, :rw_wire_graph_root, [graph], 'rw_wire_graph_root') + values.fetch(root) + end + private_class_method :materialize - if value.key?('__rustwright_cdp_url__') - return URI.parse(value['__rustwright_cdp_url__']) + def allocate_node(native, graph, node, kind) + case kind + when NODE_NULL + nil + when NODE_BOOL + read_int32(native, :rw_wire_graph_get_bool, [graph, node], 'rw_wire_graph_get_bool') != 0 + when NODE_SIGNED + read_int64(native, :rw_wire_graph_get_signed, [graph, node], 'rw_wire_graph_get_signed') + when NODE_UNSIGNED + read_uint64(native, :rw_wire_graph_get_unsigned, [graph, node], 'rw_wire_graph_get_unsigned') + when NODE_FLOAT + read_double(native, :rw_wire_graph_get_float, [graph, node], 'rw_wire_graph_get_float') + when NODE_STRING + read_text(native, :rw_wire_graph_get_string, [graph, node], 'rw_wire_graph_get_string') + when NODE_ARRAY + [] + when NODE_OBJECT + {} + when NODE_LEAF + decode_leaf(native, graph, node) + else + raise Rustwright::Error, "unknown wire node kind #{kind}" end + end + private_class_method :allocate_node - if value.key?('__rustwright_cdp_error__') - return JavaScriptError.new(value['__rustwright_cdp_error__']) + def decode_leaf(native, graph, node) + kind = read_int32(native, :rw_wire_graph_leaf_kind, [graph, node], 'rw_wire_graph_leaf_kind') + fields_count = read_size( + native, + :rw_wire_graph_leaf_field_count, + [graph, node], + 'rw_wire_graph_leaf_field_count' + ) + fields = fields_count.times.map do |index| + read_text( + native, + :rw_wire_graph_leaf_field, + [graph, node, index], + 'rw_wire_graph_leaf_field' + ) end - value.each_with_object({}) do |(key, item), decoded| - decoded[key] = decode_value(item, references) + case kind + when LEAF_UNSERIALIZABLE + decode_unserializable(fields.fetch(0)) + when LEAF_BIGINT + decode_bigint(fields.fetch(0)) + when LEAF_DATE + Time.iso8601(fields.fetch(0)) + when LEAF_REGEXP + Regexp.new(fields.fetch(0), regexp_options(fields.fetch(1))) + when LEAF_URL + URI.parse(fields.fetch(0)) + when LEAF_ERROR + JavaScriptError.new( + 'name' => fields.fetch(0), + 'message' => fields.fetch(1), + 'stack' => fields.fetch(2) + ) + when LEAF_UNDEFINED, LEAF_SYMBOL, LEAF_FUNCTION + nil + else + raise Rustwright::Error, "unknown wire leaf kind #{kind}" end end - private_class_method :decode_hash + private_class_method :decode_leaf def decode_unserializable(value) case value @@ -94,11 +194,18 @@ def decode_unserializable(value) when '-Infinity' then -Float::INFINITY when '-0' then -0.0 else - value.is_a?(String) && value.match?(/\A-?\d+n\z/) ? value[0...-1].to_i : value + value end end private_class_method :decode_unserializable + def decode_bigint(value) + Integer(value, 10) + rescue ArgumentError + value + end + private_class_method :decode_bigint + def regexp_options(flags) options = 0 options |= Regexp::IGNORECASE if flags.include?('i') @@ -107,5 +214,57 @@ def regexp_options(flags) options end private_class_method :regexp_options + + def read_size(native, name, arguments, operation) + slot = native.size_slot + status = native.call(name, *arguments, slot) + native.check_status!(status, operation) + native.size_value(slot) + end + private_class_method :read_size + + def read_int32(native, name, arguments, operation) + slot = native.int32_slot + status = native.call(name, *arguments, slot) + native.check_status!(status, operation) + native.int32_value(slot) + end + private_class_method :read_int32 + + def read_int64(native, name, arguments, operation) + slot = native.int64_slot + status = native.call(name, *arguments, slot) + native.check_status!(status, operation) + native.int64_value(slot) + end + private_class_method :read_int64 + + def read_uint64(native, name, arguments, operation) + slot = native.uint64_slot + status = native.call(name, *arguments, slot) + native.check_status!(status, operation) + native.uint64_value(slot) + end + private_class_method :read_uint64 + + def read_double(native, name, arguments, operation) + slot = native.double_slot + status = native.call(name, *arguments, slot) + native.check_status!(status, operation) + native.double_value(slot) + end + private_class_method :read_double + + def read_text(native, name, arguments, operation) + pointer_slot = native.pointer_slot + length_slot = native.size_slot + status = native.call(name, *arguments, pointer_slot, length_slot) + native.check_status!(status, operation) + native.copy_borrowed_bytes( + native.pointer_address(pointer_slot), + native.size_value(length_slot) + ).force_encoding(Encoding::UTF_8) + end + private_class_method :read_text end end diff --git a/ruby/test/contract_test.rb b/ruby/test/contract_test.rb index 3474422..bd3e3bd 100644 --- a/ruby/test/contract_test.rb +++ b/ruby/test/contract_test.rb @@ -32,32 +32,35 @@ def test_launch_and_screenshot_option_normalization Rustwright::Normalize.screenshot(full_page: true, omitBackground: true, type: :png) ) end + def test_wire_graph_materializes_identity_cycles_and_embedded_nul + library_path = ENV.fetch('RUSTWRIGHT_CAPI_LIB', Rustwright.default_library_path) + skip "native library not found at #{library_path}" unless File.file?(library_path) - def test_wire_wrappers_and_references - wire = { - '__rustwright_cdp_object__' => 1, - 'entries' => { - 'list' => { - '__rustwright_cdp_array__' => 2, - 'items' => [1, { '__rustwright_cdp_ref__' => 2 }] + native = Rustwright::Native.new(library_path) + decoded = Rustwright::Wire.decode( + native, + <<~JSON + { + "__rustwright_cdp_object__": 1, + "entries": { + "self": {"__rustwright_cdp_ref__": 1}, + "shared": { + "__rustwright_cdp_object__": 2, + "entries": {"value": "a\\u0000b"} + }, + "again": {"__rustwright_cdp_ref__": 2}, + "bigint": {"__rustwright_cdp_bigint__": "123n"}, + "malformed_bigint": {"__rustwright_cdp_bigint__": "not-a-number"} + } } - } - } - decoded = Rustwright::Wire.decode(wire) - assert_equal 1, decoded['list'][0] - assert_same decoded['list'], decoded['list'][1] - end - - def test_wire_special_values_and_documented_fallbacks - assert Rustwright::Wire.decode( - '__rustwright_cdp_unserializable_value__' => 'NaN' - ).nan? - assert_equal 123, Rustwright::Wire.decode( - '__rustwright_cdp_unserializable_value__' => '123n' + JSON ) - assert_nil Rustwright::Wire.decode('__rustwright_cdp_undefined__' => true) - assert_nil Rustwright::Wire.decode('__rustwright_cdp_symbol__' => true) - assert_nil Rustwright::Wire.decode('__rustwright_cdp_function__' => true) + + assert_same decoded, decoded.fetch('self') + assert_same decoded.fetch('shared'), decoded.fetch('again') + assert_equal "a\0b", decoded.dig('shared', 'value') + assert_equal 123, decoded.fetch('bigint') + assert_equal 'not-a-number', decoded.fetch('malformed_bigint') end def test_smoke_manifest_is_valid diff --git a/src/lib.rs b/src/lib.rs index 9aca07b..8f3b551 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -53185,6 +53185,10 @@ const WIRE_LEAF_TAGS: [&str; 9] = [ pub struct WireNodeId(usize); impl WireNodeId { + pub fn from_index(index: usize) -> Self { + Self(index) + } + pub fn index(self) -> usize { self.0 }