Skip to main content

bun_runtime/
fetch_api.rs

1// @trace REQ-ENG-001 [entity:BaoRuntime] [api:fetch]
2// @trace REQ-ENG-006 REQ-STL-001
3// fetch() entry point. The WHATWG Headers/Request/Response classes live in
4// web_fetch_classes.rs (full JS implementations installed by
5// globals::install_web_apis); this module owns the native fetch() function
6// and its input/init parsing.
7//
8// ## BCE-007/R4 + BCE-20260619-010: FetchTasklet event-driven paradigm
9//
10// fetch() now delegates to `fetch_async::start` which uses
11// `AsyncHTTP::init + HTTPThread::schedule` (single epoll thread, O(1) OS
12// threads). The HTTPThread calls back `on_http_done` (pure-Rust), which
13// enqueues a `ConcurrentTask` on the JS thread's MiniEventLoop. The JS
14// thread auto-wakes and resolves/rejects the Promise in `resolve_tasklet`.
15//
16// This replaced the `thread::spawn` + `drain_pending` polling model which
17// had three flaws (O(N) OS threads, busy-poll sleep, fragile drain coupling).
18// See `fetch_async.rs` module-level doc for the full BCE analysis.
19use ::std::sync::Arc;
20use ::std::sync::atomic::AtomicBool;
21
22use bun_core::ZBox;
23
24use mozjs::conversions::unsafe_jsstr_to_string;
25use mozjs::jsapi::*;
26use mozjs::jsval::{Int32Value, JSVal, ObjectValue, StringValue, UndefinedValue};
27use mozjs::rooted;
28use mozjs::rust::wrappers2::JS_DefineFunction;
29
30thread_local! {
31    static TL_STEALTH_PROFILE: ::std::cell::RefCell<Option<bao_stealth::StealthProfile>> = const { ::std::cell::RefCell::new(None) };
32}
33
34/// Store the current page's stealth profile so fetch() can apply TLS/HTTP2 fingerprints.
35pub fn set_fetch_stealth_profile(profile: Option<bao_stealth::StealthProfile>) {
36    TL_STEALTH_PROFILE.with(|p| *p.borrow_mut() = profile);
37}
38
39/// Returns true if a stealth profile has been explicitly set on this thread.
40pub fn is_fetch_stealth_profile_set() -> bool {
41    TL_STEALTH_PROFILE.with(|p| p.borrow().is_some())
42}
43
44/// Clone the current thread's stealth profile. Single source shared by every
45/// page egress path (fetch, WebSocket wss://) so all TLS handshakes from one
46/// page present the identical JA3/JA4 fingerprint (REQ-STL-001 fingerprint
47/// consistency).
48pub fn get_fetch_stealth_profile() -> Option<bao_stealth::StealthProfile> {
49    TL_STEALTH_PROFILE.with(|p| p.borrow().clone())
50}
51
52/// Idempotent: install Firefox default profile if none has been set on this thread.
53/// Called by `globals::install_all` so fetch() gets TLS/HTTP2 fingerprints by default.
54pub fn ensure_default_fetch_stealth_profile() {
55    if !is_fetch_stealth_profile_set() {
56        set_fetch_stealth_profile(Some(bao_stealth::StealthProfile::firefox_default()));
57    }
58}
59
60pub fn install_fetch_global(
61    cx: &mut mozjs::context::JSContext,
62    global: mozjs::rust::Handle<*mut JSObject>,
63) {
64    unsafe {
65        JS_DefineFunction(
66            cx,
67            global,
68            c"fetch".as_ptr(),
69            ::std::option::Option::Some(fetch_fn),
70            1,
71            JSPROP_ENUMERATE as u32,
72        );
73    }
74}
75
76#[allow(unsafe_op_in_unsafe_fn)]
77unsafe extern "C" fn fetch_fn(cx: *mut JSContext, argc: u32, vp: *mut JSVal) -> bool {
78    let args = CallArgs::from_vp(vp, argc);
79    let wrapped_cx = mozjs::context::JSContext::from_ptr(::std::ptr::NonNull::new_unchecked(cx));
80    if argc == 0 {
81        JS_ReportErrorUTF8(cx, c"fetch requires a URL or Request argument".as_ptr());
82        return false;
83    }
84
85    // ── Input: string URL or Request-like object (WHATWG fetch(input, init)) ──
86    // A Request object contributes url/method/headers/body as the base; init
87    // overrides any field it carries. The full classes live in
88    // web_fetch_classes.rs; their instance shape is url (string), method
89    // (uppercased string), headers (Headers instance) and _bodyText /
90    // _bodyBytes / _bodyBlob body slots.
91    let input_val = *args.get(0).ptr;
92    let url: String;
93    let mut method: String = "GET".to_string();
94    let mut headers: Vec<(String, String)> = Vec::new();
95    let mut body: Option<Vec<u8>> = None;
96    // WHATWG fetch signal: init.signal wins over the Request base. Snapshot
97    // of the raw JSVal here (the object stays reachable through args —
98    // init/Request object on the argv stack); rooted where consumed below.
99    let mut signal_val: Option<JSVal> = None;
100    // init.tls (undici dispatcher tls subset — Node-stack fetch parity for
101    // self-signed/private-PKI servers): parsed only from the init object;
102    // `None` = zero behavioural change (system roots, verify on, URL SNI).
103    let mut tls_init: Option<crate::fetch_async::FetchTlsInit> = None;
104
105    if input_val.is_string() {
106        url = crate::js_to_rust_string(cx, input_val);
107    } else if input_val.is_object() {
108        // BCE-012: root to_object() result — JS_GetProperty can trigger GC
109        rooted!(&in(wrapped_cx) let req_obj = input_val.to_object());
110        // Request base signal: read the raw `_signal` slot, not the `signal`
111        // getter (which lazily allocates a fresh AbortController signal per
112        // read — wrong object to wire and needless allocation per fetch).
113        {
114            let sv = get_val_prop(cx, req_obj.handle(), "_signal");
115            if sv.is_object() {
116                signal_val = Some(sv);
117            }
118        }
119        match get_string_prop(cx, req_obj.handle().into(), "url") {
120            ::std::option::Option::Some(u) => url = u,
121            ::std::option::Option::None => {
122                JS_ReportErrorUTF8(
123                    cx,
124                    c"fetch requires a string URL or a Request object".as_ptr(),
125                );
126                return false;
127            }
128        }
129        if let ::std::option::Option::Some(m) =
130            get_string_prop(cx, req_obj.handle().into(), "method")
131        {
132            method = m;
133        }
134        let mut h_val = UndefinedValue();
135        JS_GetProperty(
136            cx,
137            req_obj.handle().into(),
138            c"headers".as_ptr(),
139            MutableHandle::<Value> {
140                _phantom_0: ::std::marker::PhantomData,
141                ptr: &mut h_val,
142            },
143        );
144        if h_val.is_object() {
145            headers = parse_headers_init(cx, h_val);
146        }
147        // Body slots, in the order the Request constructor stores them.
148        if let ::std::option::Option::Some(t) =
149            get_string_prop(cx, req_obj.handle().into(), "_bodyText")
150        {
151            body = Some(t.into_bytes());
152        } else {
153            let mut b_val = UndefinedValue();
154            JS_GetProperty(
155                cx,
156                req_obj.handle().into(),
157                c"_bodyBytes".as_ptr(),
158                MutableHandle::<Value> {
159                    _phantom_0: ::std::marker::PhantomData,
160                    ptr: &mut b_val,
161                },
162            );
163            if b_val.is_object() {
164                body = crate::node_buffer::collect_byte_view(cx, b_val);
165            } else {
166                let mut blob_val = UndefinedValue();
167                JS_GetProperty(
168                    cx,
169                    req_obj.handle().into(),
170                    c"_bodyBlob".as_ptr(),
171                    MutableHandle::<Value> {
172                        _phantom_0: ::std::marker::PhantomData,
173                        ptr: &mut blob_val,
174                    },
175                );
176                if blob_val.is_object() {
177                    match extract_blob_bytes(cx, blob_val) {
178                        Ok(b) => body = b,
179                        Err(msg) => {
180                            let c_msg = ZBox::from_bytes(msg.as_bytes());
181                            JS_ReportErrorUTF8(cx, c"%s".as_ptr(), c_msg.as_ptr());
182                            return false;
183                        }
184                    }
185                } else {
186                    // FormData slot: web_fetch_classes Request parks the live
187                    // object on _bodyFormData; the multipart serialization
188                    // (boundary generated here, at send time) happens in
189                    // extract_formdata_multipart.
190                    let mut fd_val = UndefinedValue();
191                    JS_GetProperty(
192                        cx,
193                        req_obj.handle().into(),
194                        c"_bodyFormData".as_ptr(),
195                        MutableHandle::<Value> {
196                            _phantom_0: ::std::marker::PhantomData,
197                            ptr: &mut fd_val,
198                        },
199                    );
200                    if fd_val.is_object() {
201                        match extract_formdata_multipart(cx, fd_val, &mut headers) {
202                            Ok(b) => body = b,
203                            Err(msg) => {
204                                let c_msg = ZBox::from_bytes(msg.as_bytes());
205                                JS_ReportErrorUTF8(cx, c"%s".as_ptr(), c_msg.as_ptr());
206                                return false;
207                            }
208                        }
209                    }
210                }
211            }
212        }
213    } else {
214        JS_ReportErrorUTF8(
215            cx,
216            c"fetch requires a string URL or a Request object".as_ptr(),
217        );
218        return false;
219    }
220
221    // ── init overrides (WHATWG: init fields win over the Request base) ──
222    if argc > 1 {
223        let opts = *args.get(1).ptr;
224        if opts.is_object() {
225            // BCE-012: root to_object() result — JS_GetProperty can trigger GC
226            rooted!(&in(wrapped_cx) let opts_obj = opts.to_object());
227            if let ::std::option::Option::Some(m) =
228                get_string_prop(cx, opts_obj.handle().into(), "method")
229            {
230                method = m;
231            }
232            let mut h_val = UndefinedValue();
233            // BCE (error.rs:74): clearing probe — user init object on the
234            // servo ScriptThread context; a throwing `headers` accessor
235            // must read as "absent", not leak a pending exception.
236            bao_stealth::engine_props::get_property_clearing(
237                cx,
238                opts_obj.handle().into(),
239                c"headers",
240                &mut h_val,
241            );
242            if h_val.is_object() {
243                headers = parse_headers_init(cx, h_val);
244            }
245            // body: only an explicitly present init.body overrides (null
246            // clears it), matching the WHATWG "init wins when present" rule.
247            // BCE (error.rs:74): both probes run against the caller-supplied
248            // init object on the servo ScriptThread context (browser mode) —
249            // a throwing `body` accessor makes them fail WITH the exception
250            // pending. Clearing probes: failure reads as "absent"/undefined.
251            let mut has_body = false;
252            bao_stealth::engine_props::has_property_clearing(
253                cx,
254                opts_obj.handle().into(),
255                c"body",
256                &mut has_body,
257            );
258            if has_body {
259                let mut b_val = UndefinedValue();
260                bao_stealth::engine_props::get_property_clearing(
261                    cx,
262                    opts_obj.handle().into(),
263                    c"body",
264                    &mut b_val,
265                );
266                match extract_body_bytes(cx, b_val, &mut headers) {
267                    Ok(b) => body = b,
268                    Err(msg) => {
269                        let c_msg = ZBox::from_bytes(msg.as_bytes());
270                        JS_ReportErrorUTF8(cx, c"%s".as_ptr(), c_msg.as_ptr());
271                        return false;
272                    }
273                }
274            }
275            // init.signal wins over the Request base signal (WHATWG
276            // "init wins when present" — same rule as body/method/headers).
277            {
278                let sv = get_val_prop(cx, opts_obj.handle(), "signal");
279                if sv.is_object() {
280                    signal_val = Some(sv);
281                }
282            }
283            // init.tls (fetch-specific, undici dispatcher tls subset): parse
284            // AFTER the WHATWG fields so a malformed tls object fails closed
285            // before any request is scheduled. Absent/null = no change.
286            {
287                let tv = get_val_prop(cx, opts_obj.handle(), "tls");
288                if !tv.is_undefined() && !tv.is_null() {
289                    match parse_tls_init(cx, tv) {
290                        Ok(t) => tls_init = Some(t),
291                        Err(msg) => {
292                            let c_msg = ZBox::from_bytes(msg.as_bytes());
293                            JS_ReportErrorUTF8(cx, c"%s".as_ptr(), c_msg.as_ptr());
294                            return false;
295                        }
296                    }
297                }
298            }
299        }
300    }
301
302    // ── data: URL short-circuit (local scheme — never enters HTTPThread) ──
303    // BCE-20260816-FETCH-DATA: a `data:` URL has no host, but the generic
304    // AsyncHTTP path treats the scheme as one — bun_http parses host
305    // "data" and the JS thread blocks in a DNS retry loop (strace shows
306    // repeated NXDOMAIN A-queries for "data"; timers never fire, buffered
307    // stdout never flushes). WHATWG fetch processes data: URLs locally:
308    // parse the payload here and settle the Promise without scheduling.
309    if url.starts_with("data:") {
310        // SAFETY: cx is live on this thread; args is the current call frame.
311        unsafe { handle_data_url_fetch(cx, &args, &method, &url) };
312        return true;
313    }
314
315    if let ::std::option::Option::Some(pos) = url.find("://") {
316        let host_part = &url[pos + 3..];
317        let host = host_part
318            .split('/')
319            .next()
320            .unwrap_or(host_part)
321            .split(':')
322            .next()
323            .unwrap_or(host_part);
324        if let ::std::result::Result::Err(e) = crate::permission_bridge::check_net(host) {
325            let c_msg = ZBox::from_bytes(e.as_bytes());
326            JS_ReportErrorUTF8(cx, c"%s".as_ptr(), c_msg.as_ptr());
327            return false;
328        }
329    }
330
331    // ── Method resolution ──
332    // The full bun_http::Method table (IANA method registry: PROPFIND,
333    // REPORT, MKCOL, ...). Unknown tokens throw instead of silently falling
334    // back to GET — a PROPFIND answered by a GET handler is a misroute, not
335    // a degradation. Arbitrary (non-registry) tokens would need a
336    // method-as-string plumbing through AsyncHTTP; the closed enum is the
337    // wire contract inherited from upstream Bun.
338    let method_upper = method.to_uppercase();
339    let Some(bun_method) = bun_http::Method::which(method_upper.as_bytes()) else {
340        let msg = format!(
341            "fetch: HTTP method \"{}\" is not supported by the Bao HTTP wire layer (supported: IANA method registry tokens such as GET/POST/PROPFIND/REPORT)",
342            method_upper
343        );
344        let c_msg = ZBox::from_bytes(msg.as_bytes());
345        JS_ReportErrorUTF8(cx, c"%s".as_ptr(), c_msg.as_ptr());
346        return false;
347    };
348
349    // ── AbortSignal triage (WHATWG fetch init.signal / Request.signal) ──────
350    // Pre-aborted: reject immediately, no request is scheduled. Live signal:
351    // wire the cancellation channel and register the abort listener. Shape
352    // probe: boolean `aborted` + callable `addEventListener` (holds for the
353    // globals.rs shim AND servo's native DOM AbortSignal).
354    let mut signal_active: Option<JSVal> = None;
355    let mut signal_pre_aborted = false;
356    if let Some(sv) = signal_val {
357        if sv.is_object() {
358            rooted!(&in(wrapped_cx) let sig_obj = sv.to_object());
359            if is_abort_signal_shape(cx, sig_obj.handle()) {
360                let mut ab_val = UndefinedValue();
361                JS_GetProperty(
362                    cx,
363                    sig_obj.handle().into(),
364                    c"aborted".as_ptr(),
365                    MutableHandle::<Value> {
366                        _phantom_0: ::std::marker::PhantomData,
367                        ptr: &mut ab_val,
368                    },
369                );
370                if ab_val.is_boolean() && ab_val.to_boolean() {
371                    signal_pre_aborted = true;
372                } else {
373                    signal_active = Some(sv);
374                }
375            }
376        }
377    }
378
379    // ── FetchTasklet event-driven: create PENDING Promise, delegate to fetch_async ──
380    // @trace REQ-ENG-010 [entity:FetchTasklet] — O(1) OS threads
381    rooted!(&in(wrapped_cx) let null_global = ::std::ptr::null_mut::<JSObject>());
382    let promise = mozjs_sys::jsapi::JS::NewPromiseObject(cx, null_global.handle().into());
383    if promise.is_null() {
384        args.rval().set(UndefinedValue());
385        return true;
386    }
387
388    let promise_val = ObjectValue(promise);
389
390    if signal_pre_aborted {
391        // Pre-aborted signal: the fetch never reaches the network. Reject
392        // with DOMException AbortError ("The operation was aborted").
393        rooted!(&in(wrapped_cx) let promise_obj = promise);
394        // SAFETY: cx is live on this thread; promise_obj is a pending Promise.
395        unsafe {
396            crate::fetch_async::reject_promise_with_abort_error(cx, promise_obj.handle().into());
397        }
398        args.rval().set(promise_val);
399        return true;
400    }
401
402    let profile: Option<bao_stealth::StealthProfile> =
403        TL_STEALTH_PROFILE.with(|p| p.borrow().clone());
404
405    if let Some(sv) = signal_active {
406        // Live signal: wire the cancellation channel (flag → AsyncHTTP
407        // Signals.aborted) and register the JS abort listener.
408        let abort_id = crate::fetch_async::new_abort_id();
409        let flag = Arc::new(AtomicBool::new(false));
410        // SAFETY: cx is live on this thread; promise_val is the pending Promise.
411        unsafe {
412            crate::fetch_async::start_fetch(
413                cx,
414                promise_val,
415                profile,
416                bun_method,
417                url,
418                headers,
419                body,
420                ::std::option::Option::Some(crate::fetch_async::AbortRequest {
421                    id: abort_id,
422                    flag: ::std::sync::Arc::clone(&flag),
423                }),
424                tls_init,
425            );
426            register_abort_listener(cx, sv, abort_id);
427        }
428        args.rval().set(promise_val);
429        return true;
430    }
431
432    // SAFETY: cx is live on this thread; promise_val is the pending Promise.
433    unsafe {
434        crate::fetch_async::start_fetch(
435            cx,
436            promise_val,
437            profile,
438            bun_method,
439            url,
440            headers,
441            body,
442            None,
443            tls_init,
444        );
445    }
446
447    args.rval().set(promise_val);
448    true
449}
450
451// ── data: URL fetch (local scheme — WHATWG scheme fetch for "data") ─────────
452
453/// WHATWG data: URL processor (https://fetch.spec.whatwg.org/#data-URL-processor,
454/// simplified): splits at the first comma, honours a trailing `;base64`
455/// marker (ASCII case-insensitive), percent-decodes the payload, and applies
456/// forgiving-base64 when the marker is present. Returns `(mime_type, body)`
457/// or a rejection message (surfaced as a TypeError on the fetch Promise).
458fn parse_data_url(url: &str) -> ::std::result::Result<(String, Vec<u8>), String> {
459    let rest = &url["data:".len()..];
460    let Some(comma) = rest.find(',') else {
461        return Err("fetch data: URL is missing the comma (,) delimiter".to_string());
462    };
463    let header = &rest[..comma];
464    let data = &rest[comma + 1..];
465
466    // `;base64` marker: last 7 bytes of the header, case-insensitive.
467    let base64 = header.len() >= 7 && header[..].to_ascii_lowercase().ends_with(";base64");
468    let mime_raw = if base64 {
469        &header[..header.len() - ";base64".len()]
470    } else {
471        header
472    };
473    // The header must be a MIME type (`type/subtype`); anything else (or
474    // empty) falls back to the spec default.
475    let mime = if !mime_raw.is_empty() && mime_raw.contains('/') {
476        mime_raw.to_string()
477    } else {
478        "text/plain;charset=US-ASCII".to_string()
479    };
480
481    let decoded = percent_decode(data.as_bytes());
482    if base64 {
483        // Forgiving-base64: strip ASCII whitespace; missing padding is
484        // tolerated by the decoder length estimate. A dangling single byte
485        // can never be valid base64. bun_base64's decoder is lenient (it
486        // stops at the first invalid byte and returns the partial decode),
487        // so validate the alphabet strictly first — a data: URL with
488        // garbage must reject, not resolve with a truncated body.
489        let cleaned: Vec<u8> = decoded
490            .iter()
491            .copied()
492            .filter(|&b| !matches!(b, b' ' | b'\t' | b'\n' | b'\r' | b'\x0c'))
493            .collect();
494        let invalid_payload = "fetch data: URL has an invalid base64 payload".to_string();
495        // Padding may only appear as the final 1-2 '=' bytes.
496        let data_end = cleaned
497            .iter()
498            .position(|&b| b == b'=')
499            .unwrap_or(cleaned.len());
500        if cleaned.len() % 4 == 1
501            || cleaned.iter().any(|&b| {
502                !b.is_ascii_alphanumeric() && b != b'+' && b != b'/' && b != b'='
503            })
504            || cleaned[data_end..].iter().any(|&b| b != b'=')
505            || cleaned.len() - data_end > 2
506        {
507            return Err(invalid_payload);
508        }
509        bun_base64::decode_alloc(&cleaned)
510            .map(|v| (mime, v))
511            .map_err(|_| invalid_payload)
512    } else {
513        Ok((mime, decoded))
514    }
515}
516
517/// WHATWG percent-decoder: `%XY` with two hex digits decodes to one byte;
518/// an invalid escape passes the `%` through literally.
519fn percent_decode(input: &[u8]) -> Vec<u8> {
520    fn hex_val(b: u8) -> Option<u8> {
521        match b {
522            b'0'..=b'9' => Some(b - b'0'),
523            b'a'..=b'f' => Some(b - b'a' + 10),
524            b'A'..=b'F' => Some(b - b'A' + 10),
525            _ => None,
526        }
527    }
528    let mut out = Vec::with_capacity(input.len());
529    let mut i = 0;
530    while i < input.len() {
531        if input[i] == b'%' && i + 2 < input.len() {
532            if let (Some(hi), Some(lo)) = (hex_val(input[i + 1]), hex_val(input[i + 2])) {
533                out.push(hi << 4 | lo);
534                i += 3;
535                continue;
536            }
537        }
538        out.push(input[i]);
539        i += 1;
540    }
541    out
542}
543
544/// Reject `promise` with the realm's real `TypeError` (so `instanceof
545/// TypeError` holds, matching the fetch network-error shape); a plain
546/// message object is used only if the realm genuinely lacks the constructor.
547///
548/// # Safety
549///
550/// `cx` must be a live `JSContext*` on the current thread; `promise_h` a
551/// handle to a pending Promise.
552#[allow(unsafe_op_in_unsafe_fn)]
553unsafe fn reject_promise_type_error(
554    cx: *mut JSContext,
555    promise_h: Handle<*mut JSObject>,
556    msg: &str,
557) {
558    let mut wrapped_cx =
559        mozjs::context::JSContext::from_ptr(::std::ptr::NonNull::new_unchecked(cx));
560    let cx_ref = &mut wrapped_cx;
561    let global = JS::CurrentGlobalOrNull(cx);
562    if !global.is_null() {
563        rooted!(&in(cx_ref) let global_root = global);
564        let mut te_val = UndefinedValue();
565        JS_GetProperty(
566            cx,
567            global_root.handle().into(),
568            c"TypeError".as_ptr(),
569            MutableHandle::<Value> {
570                _phantom_0: ::std::marker::PhantomData,
571                ptr: &mut te_val,
572            },
573        );
574        if te_val.is_object() {
575            rooted!(&in(cx_ref) let te_obj = te_val.to_object());
576            rooted!(&in(cx_ref) let te_fn = ObjectValue(te_obj.get()));
577            let c_msg = ZBox::from_bytes(msg.as_bytes());
578            let msg_js = JS_NewStringCopyZ(cx, c_msg.as_ptr());
579            if !msg_js.is_null() {
580                rooted!(&in(cx_ref) let msg_root = StringValue(&*msg_js));
581                let elems = [msg_root.get()];
582                let call_args = HandleValueArray {
583                    length_: 1,
584                    elements_: elems.as_ptr(),
585                };
586                rooted!(&in(cx_ref) let undef_this = ::std::ptr::null_mut::<JSObject>());
587                let mut err_val = UndefinedValue();
588                let called = JS_CallFunctionValue(
589                    cx,
590                    undef_this.handle().into(),
591                    te_fn.handle().into(),
592                    &call_args,
593                    MutableHandle::<Value> {
594                        _phantom_0: ::std::marker::PhantomData,
595                        ptr: &mut err_val,
596                    },
597                );
598                if called && err_val.is_object() {
599                    rooted!(&in(cx_ref) let err_root = err_val);
600                    JS::RejectPromise(cx, promise_h, err_root.handle().into());
601                    return;
602                }
603            }
604        }
605    }
606    // Fallback: plain message object.
607    rooted!(&in(cx_ref) let err_obj = JS_NewPlainObject(cx));
608    let c_msg = ZBox::from_bytes(msg.as_bytes());
609    let msg_js = JS_NewStringCopyZ(cx, c_msg.as_ptr());
610    if !err_obj.is_null() && !msg_js.is_null() {
611        rooted!(&in(cx_ref) let msg_root = StringValue(&*msg_js));
612        JS_DefineProperty(
613            cx,
614            err_obj.handle().into(),
615            c"message".as_ptr(),
616            msg_root.handle().into(),
617            JSPROP_ENUMERATE as u32,
618        );
619    }
620    rooted!(&in(cx_ref) let ev = if err_obj.is_null() {
621        UndefinedValue()
622    } else {
623        ObjectValue(err_obj.get())
624    });
625    JS::RejectPromise(cx, promise_h, ev.handle().into());
626}
627
628/// Settle a `fetch("data:...")` call: parse the URL locally and resolve the
629/// returned Promise with a `Response` built by the realm's Response class
630/// (status 200, `content-type` from the URL header, binary-safe body), or
631/// reject with a TypeError (parse failure / non-GET-HEAD method).
632///
633/// # Safety
634///
635/// `cx` must be a live `JSContext*` on the current thread; `args` the active
636/// CallArgs frame whose `rval` receives the new Promise.
637#[allow(unsafe_op_in_unsafe_fn)]
638unsafe fn handle_data_url_fetch(cx: *mut JSContext, args: &CallArgs, method: &str, url: &str) {
639    let mut wrapped_cx =
640        mozjs::context::JSContext::from_ptr(::std::ptr::NonNull::new_unchecked(cx));
641    let cx_ref = &mut wrapped_cx;
642
643    rooted!(&in(cx_ref) let null_global = ::std::ptr::null_mut::<JSObject>());
644    let promise = mozjs_sys::jsapi::JS::NewPromiseObject(cx, null_global.handle().into());
645    if promise.is_null() {
646        args.rval().set(UndefinedValue());
647        return;
648    }
649    args.rval().set(ObjectValue(promise));
650    rooted!(&in(cx_ref) let promise_root = promise);
651    let promise_h = promise_root.handle().into();
652
653    // WHATWG scheme fetch for data: only the safe methods are allowed;
654    // anything else is a network error (TypeError).
655    let method_upper = method.to_uppercase();
656    let outcome: ::std::result::Result<(String, Vec<u8>), String> =
657        if method_upper != "GET" && method_upper != "HEAD" {
658        Err(format!(
659            "fetch data: URL only supports GET/HEAD requests (got {})",
660            method_upper
661        ))
662    } else {
663        parse_data_url(url)
664    };
665
666    let (mime, bytes) = match outcome {
667        Ok(v) => v,
668        Err(msg) => {
669            reject_promise_type_error(cx, promise_h, &msg);
670            return;
671        }
672    };
673
674    // Response construction: `new Response(body, init)` via the realm's
675    // Response class (web_fetch_classes) so text()/json()/arrayBuffer()/
676    // blob() all work with binary-safe body storage. HEAD carries no body.
677    let global = JS::CurrentGlobalOrNull(cx);
678    if global.is_null() {
679        reject_promise_type_error(cx, promise_h, "fetch data: no realm global");
680        return;
681    }
682    rooted!(&in(cx_ref) let global_root = global);
683    let mut resp_ctor_val = UndefinedValue();
684    JS_GetProperty(
685        cx,
686        global_root.handle().into(),
687        c"Response".as_ptr(),
688        MutableHandle::<Value> {
689            _phantom_0: ::std::marker::PhantomData,
690            ptr: &mut resp_ctor_val,
691        },
692    );
693    if !resp_ctor_val.is_object() {
694        // Fail closed — no silent degraded Response shape (hard rule: no
695        // placeholder success).
696        reject_promise_type_error(
697            cx,
698            promise_h,
699            "fetch data: Response class is not available in this realm",
700        );
701        return;
702    }
703    rooted!(&in(cx_ref) let resp_ctor = resp_ctor_val.to_object());
704    rooted!(&in(cx_ref) let resp_fn = ObjectValue(resp_ctor.get()));
705
706    // Body: Uint8Array over the decoded bytes (HEAD → null body).
707    rooted!(&in(cx_ref) let body_arr = if method_upper == "HEAD" {
708        ::std::ptr::null_mut::<JSObject>()
709    } else {
710        mozjs_sys::jsapi::JS_NewUint8Array(cx, bytes.len())
711    });
712    if method_upper != "HEAD" && body_arr.is_null() {
713        reject_promise_type_error(cx, promise_h, "fetch data: body allocation failed");
714        return;
715    }
716    if method_upper != "HEAD" && !bytes.is_empty() {
717        let mut ta_len: usize = 0;
718        let mut shared = false;
719        let mut data: *mut u8 = ::std::ptr::null_mut();
720        let unwrapped = JS_GetObjectAsUint8Array(body_arr.get(), &mut ta_len, &mut shared, &mut data);
721        if unwrapped.is_null() || data.is_null() || ta_len < bytes.len() {
722            reject_promise_type_error(cx, promise_h, "fetch data: body view failed");
723            return;
724        }
725        ::std::ptr::copy_nonoverlapping(bytes.as_ptr(), data, bytes.len());
726    }
727
728    // init: { status: 200, statusText: "OK", headers: { "content-type": mime } }
729    rooted!(&in(cx_ref) let init_obj = JS_NewPlainObject(cx));
730    if init_obj.is_null() {
731        reject_promise_type_error(cx, promise_h, "fetch data: init allocation failed");
732        return;
733    }
734    rooted!(&in(cx_ref) let status_val = Int32Value(200));
735    JS_DefineProperty(
736        cx,
737        init_obj.handle().into(),
738        c"status".as_ptr(),
739        status_val.handle().into(),
740        JSPROP_ENUMERATE as u32,
741    );
742    let st_js = JS_NewStringCopyZ(cx, c"OK".as_ptr());
743    if !st_js.is_null() {
744        rooted!(&in(cx_ref) let st_val = StringValue(&*st_js));
745        JS_DefineProperty(
746            cx,
747            init_obj.handle().into(),
748            c"statusText".as_ptr(),
749            st_val.handle().into(),
750            JSPROP_ENUMERATE as u32,
751        );
752    }
753    rooted!(&in(cx_ref) let headers_obj = JS_NewPlainObject(cx));
754    if !headers_obj.is_null() {
755        let c_mime = ZBox::from_bytes(mime.as_bytes());
756        let mime_js = JS_NewStringCopyZ(cx, c_mime.as_ptr());
757        if !mime_js.is_null() {
758            rooted!(&in(cx_ref) let mime_val = StringValue(&*mime_js));
759            JS_DefineProperty(
760                cx,
761                headers_obj.handle().into(),
762                c"content-type".as_ptr(),
763                mime_val.handle().into(),
764                JSPROP_ENUMERATE as u32,
765            );
766        }
767        rooted!(&in(cx_ref) let hv = ObjectValue(headers_obj.get()));
768        JS_DefineProperty(
769            cx,
770            init_obj.handle().into(),
771            c"headers".as_ptr(),
772            hv.handle().into(),
773            JSPROP_ENUMERATE as u32,
774        );
775    }
776
777    let elems = [
778        if method_upper == "HEAD" {
779            UndefinedValue()
780        } else {
781            ObjectValue(body_arr.get())
782        },
783        ObjectValue(init_obj.get()),
784    ];
785    let call_args = HandleValueArray {
786        length_: 2,
787        elements_: elems.as_ptr(),
788    };
789    rooted!(&in(cx_ref) let undef_this = ::std::ptr::null_mut::<JSObject>());
790    let mut resp_val = UndefinedValue();
791    let called = JS_CallFunctionValue(
792        cx,
793        undef_this.handle().into(),
794        resp_fn.handle().into(),
795        &call_args,
796        MutableHandle::<Value> {
797            _phantom_0: ::std::marker::PhantomData,
798            ptr: &mut resp_val,
799        },
800    );
801    if !called || !resp_val.is_object() {
802        reject_promise_type_error(
803            cx,
804            promise_h,
805            "fetch data: failed to construct Response",
806        );
807        return;
808    }
809    rooted!(&in(cx_ref) let resp_root = resp_val);
810    JS::ResolvePromise(cx, promise_h, resp_root.handle().into());
811}
812
813// ── AbortSignal listener wiring ─────────────────────────────────────────────
814
815/// Native `abort` event trampoline. The abort id is stamped onto the
816/// function object itself (`_abortId`, permanent + readonly); the callee is
817/// recovered via `args.calleev()` so no global lookup is needed (works in
818/// any realm, page or CLI).
819#[allow(non_snake_case)]
820#[allow(unsafe_op_in_unsafe_fn)]
821unsafe extern "C" fn bao_abort_listener_native(
822    cx: *mut JSContext,
823    argc: u32,
824    vp: *mut JSVal,
825) -> bool {
826    let args = CallArgs::from_vp(vp, argc);
827    let callee_v = args.calleev();
828    if callee_v.is_object() {
829        let mut wrapped_cx =
830            mozjs::context::JSContext::from_ptr(::std::ptr::NonNull::new_unchecked(cx));
831        let cx_ref = &mut wrapped_cx;
832        // BCE-012: root the callee across the property read (can trigger GC)
833        rooted!(&in(cx_ref) let callee_obj = callee_v.to_object());
834        let mut id_val = UndefinedValue();
835        JS_GetProperty(
836            cx,
837            callee_obj.handle().into(),
838            c"_abortId".as_ptr(),
839            MutableHandle::<Value> {
840                _phantom_0: ::std::marker::PhantomData,
841                ptr: &mut id_val,
842            },
843        );
844        if id_val.is_int32() {
845            crate::fetch_async::trigger_abort(id_val.to_int32() as u32);
846        }
847    }
848    args.rval().set(UndefinedValue());
849    true
850}
851
852/// Register the abort listener on a live AbortSignal:
853/// `signal.addEventListener('abort', trampoline)`. The trampoline carries
854/// the abort id; when the signal fires it calls
855/// [`crate::fetch_async::trigger_abort`], which sets the shared flag and
856/// schedules the HTTPThread shutdown for the in-flight request.
857///
858/// # Safety
859///
860/// `cx` must be a live `JSContext*` on the current thread; `signal_val`
861/// must be an object value protected from GC by the caller's stack frame.
862#[allow(unsafe_op_in_unsafe_fn)]
863unsafe fn register_abort_listener(cx: *mut JSContext, signal_val: JSVal, abort_id: u32) {
864    unsafe {
865        let mut wrapped_cx =
866            mozjs::context::JSContext::from_ptr(::std::ptr::NonNull::new_unchecked(cx));
867        let cx_ref = &mut wrapped_cx;
868        // BCE-012: root the signal across the addEventListener call
869        rooted!(&in(cx_ref) let signal_obj = signal_val.to_object());
870
871        let listener_fn = JS_NewFunction(
872            cx,
873            Some(bao_abort_listener_native),
874            1,
875            0,
876            c"__baoFetchAbort".as_ptr(),
877        );
878        let listener_fn = JS_NewFunction(
879            cx,
880            Some(bao_abort_listener_native),
881            1,
882            0,
883            c"__baoFetchAbort".as_ptr(),
884        );
885        if listener_fn.is_null() {
886            return;
887        }
888        let listener_obj = JS_GetFunctionObject(listener_fn);
889        if listener_obj.is_null() {
890            return;
891        }
892        // BCE-012: root the listener function object across the calls below
893        rooted!(&in(cx_ref) let listener = listener_obj);
894
895        // Stamp the abort id as the listener's identity (permanent +
896        // readonly: JS must not be able to repoint one fetch's abort at
897        // another's channel).
898        rooted!(&in(cx_ref) let id_val = Int32Value(abort_id as i32));
899        JS_DefineProperty(
900            cx,
901            listener.handle().into(),
902            c"_abortId".as_ptr(),
903            id_val.handle().into(),
904            (JSPROP_PERMANENT | JSPROP_READONLY) as u32,
905        );
906
907        // signal.addEventListener('abort', listener)
908        let c_type = ZBox::from_bytes(b"abort");
909        let type_js = JS_NewStringCopyZ(cx, c_type.as_ptr());
910        if type_js.is_null() {
911            return;
912        }
913        rooted!(&in(cx_ref) let type_val = StringValue(&*type_js));
914        rooted!(&in(cx_ref) let listener_val = ObjectValue(listener.get()));
915        let call_args_arr = [type_val.get(), listener_val.get()];
916        let call_args = HandleValueArray {
917            length_: call_args_arr.len(),
918            elements_: call_args_arr.as_ptr(),
919        };
920        let mut rval = UndefinedValue();
921        let added = JS_CallFunctionName(
922            cx,
923            signal_obj.handle().into(),
924            c"addEventListener".as_ptr(),
925            &call_args,
926            MutableHandle::<Value> {
927                _phantom_0: ::std::marker::PhantomData,
928                ptr: &mut rval,
929            },
930        );
931        if !added {
932            // BCE (P0 browser startup panic, servo error.rs:74): the signal
933            // can be a caller-supplied duck-typed AbortSignal running on the
934            // servo ScriptThread context (browser mode) — a throwing
935            // addEventListener leaves the exception pending. Capture, clear,
936            // and route it (same contract as timers.rs fire_callback);
937            // swallowing it silently would leave a stale exception to
938            // detonate servo's `assert!(!JS_IsExceptionPending)`.
939            let mut exn = UndefinedValue();
940            JS_GetPendingException(
941                cx,
942                MutableHandle::<Value> {
943                    _phantom_0: ::std::marker::PhantomData,
944                    ptr: &mut exn,
945                },
946            );
947            JS_ClearPendingException(cx);
948            rooted!(&in(cx_ref) let reason_root = exn);
949            if !exn.is_undefined() {
950                crate::uncaught::route_uncaught_exception(cx, exn);
951            }
952        }
953    }
954}
955
956/// AbortSignal structural probe: boolean `aborted` property + callable
957/// `addEventListener` (the globals.rs shim has both as own/prototype
958/// members; servo's DOM AbortSignal satisfies the same surface).
959///
960/// # Safety
961///
962/// `cx` must be a live `JSContext*` on the current thread; `obj` must be
963/// GC-protected by the caller.
964#[allow(unsafe_op_in_unsafe_fn)]
965unsafe fn is_abort_signal_shape(
966    cx: *mut JSContext,
967    obj: mozjs::rust::Handle<*mut JSObject>,
968) -> bool {
969    unsafe {
970        let ab_val = get_val_prop(cx, obj, "aborted");
971        if !ab_val.is_boolean() {
972            return false;
973        }
974        let ael_val = get_val_prop(cx, obj, "addEventListener");
975        ael_val.is_object() && IsCallable(ael_val.to_object())
976    }
977}
978
979// ── fetch input/init value helpers ─────────────────────────────────────────
980
981/// Read a string-valued property off `obj`; `None` when absent or non-string.
982///
983/// # Safety
984///
985/// `cx` must be a live `JSContext*` on the current thread; `obj` must be
986/// protected from GC by the caller's stack frame.
987#[allow(unsafe_op_in_unsafe_fn)]
988unsafe fn get_string_prop(
989    cx: *mut JSContext,
990    obj: mozjs::rust::Handle<*mut JSObject>,
991    name: &str,
992) -> Option<String> {
993    unsafe {
994        let c_name = ZBox::from_bytes(name.as_bytes());
995        let mut v = UndefinedValue();
996        // BCE (P0 browser startup panic, servo error.rs:74): this reader
997        // probes caller-supplied objects (fetch init / Request inputs).
998        // In browser mode it runs on the servo ScriptThread context, where a
999        // throwing getter makes JS_GetProperty return false WITH the
1000        // exception pending; an unconsumed pending exception detonates
1001        // servo's `assert!(!JS_IsExceptionPending)` in `throw_dom_exception`
1002        // on the next error path. Clearing helper: failed probe reads as
1003        // "property absent".
1004        bao_stealth::engine_props::get_property_clearing(
1005            cx,
1006            obj.into(),
1007            c_name.as_cstr(),
1008            &mut v,
1009        );
1010        if v.is_string() {
1011            Some(crate::js_to_rust_string(cx, v))
1012        } else {
1013            None
1014        }
1015    }
1016}
1017
1018/// True when `constructor_val` is (pointer-identical to) the global
1019/// `global_name` constructor — constructor identity check for FormData /
1020/// Blob inputs (JS-defined classes whose instances link back to the
1021/// constructor through the prototype chain).
1022///
1023/// NOT usable for URLSearchParams: that constructor is native and its
1024/// instances are plain JSClass objects with own method props and no
1025/// prototype/constructor linkage, so `.constructor` resolves to Object —
1026/// use [`is_url_search_params_shape`] for those.
1027///
1028/// # Safety
1029///
1030/// `cx` must be a live `JSContext*` on the current thread; `constructor_val`
1031/// must be protected from GC by the caller's stack frame.
1032#[allow(unsafe_op_in_unsafe_fn)]
1033unsafe fn is_global_ctor(cx: *mut JSContext, constructor_val: JSVal, global_name: &str) -> bool {
1034    unsafe {
1035        if !constructor_val.is_object() {
1036            return false;
1037        }
1038        let global = mozjs_sys::jsapi::JS::CurrentGlobalOrNull(cx);
1039        if global.is_null() {
1040            return false;
1041        }
1042        let wrapped_cx =
1043            mozjs::context::JSContext::from_ptr(::std::ptr::NonNull::new_unchecked(cx));
1044        // BCE-012: root the global across the property read (can trigger GC)
1045        rooted!(&in(wrapped_cx) let global_rooted = global);
1046        let c_name = ZBox::from_bytes(global_name.as_bytes());
1047        let mut g_val = UndefinedValue();
1048        JS_GetProperty(
1049            cx,
1050            global_rooted.handle().into(),
1051            c_name.as_ptr(),
1052            MutableHandle::<Value> {
1053                _phantom_0: ::std::marker::PhantomData,
1054                ptr: &mut g_val,
1055            },
1056        );
1057        g_val.is_object() && g_val.to_object() == constructor_val.to_object()
1058    }
1059}
1060
1061/// URLSearchParams structural probe (mirrors `_bao_is_urlsearchparams` in
1062/// web_fetch_classes.rs). The runtime's URLSearchParams is a native
1063/// constructor whose instances are plain JSClass objects with own method
1064/// props and no prototype/constructor linkage — `instanceof` against the
1065/// prototype-less constructor throws (JSMSG_BAD_PROTOTYPE) and
1066/// `.constructor` identity resolves to Object. The method surface
1067/// append+getAll+entries+forEach is the discriminator: FormData lacks
1068/// forEach/entries, Map lacks append/getAll, Blob lacks all four.
1069///
1070/// # Safety
1071///
1072/// `cx` must be a live `JSContext*` on the current thread; `obj` must be
1073/// protected from GC by the caller's stack frame.
1074#[allow(unsafe_op_in_unsafe_fn)]
1075unsafe fn is_url_search_params_shape(
1076    cx: *mut JSContext,
1077    obj: mozjs::rust::Handle<*mut JSObject>,
1078) -> bool {
1079    unsafe {
1080        for name in ["append", "getAll", "entries", "forEach"] {
1081            let c_name = ZBox::from_bytes(name.as_bytes());
1082            let mut v = UndefinedValue();
1083            JS_GetProperty(
1084                cx,
1085                obj.into(),
1086                c_name.as_ptr(),
1087                MutableHandle::<Value> {
1088                    _phantom_0: ::std::marker::PhantomData,
1089                    ptr: &mut v,
1090                },
1091            );
1092            if !v.is_object() || !IsCallable(v.to_object()) {
1093                return false;
1094            }
1095        }
1096        true
1097    }
1098}
1099
1100/// Concatenate a Bao Blob's `_chunks` (array of Uint8Array) into owned bytes.
1101///
1102/// # Safety
1103///
1104/// `cx` must be a live `JSContext*`; `blob_val` must be a protected object
1105/// value whose `_chunks` (when present) is a JS array of byte views.
1106#[allow(unsafe_op_in_unsafe_fn)]
1107unsafe fn extract_blob_bytes(
1108    cx: *mut JSContext,
1109    blob_val: JSVal,
1110) -> ::std::result::Result<Option<Vec<u8>>, String> {
1111    unsafe {
1112        let wrapped_cx =
1113            mozjs::context::JSContext::from_ptr(::std::ptr::NonNull::new_unchecked(cx));
1114        // BCE-012: root to_object() result — JS_GetProperty can trigger GC
1115        rooted!(&in(wrapped_cx) let blob = blob_val.to_object());
1116        let mut chunks_val = UndefinedValue();
1117        JS_GetProperty(
1118            cx,
1119            blob.handle().into(),
1120            c"_chunks".as_ptr(),
1121            MutableHandle::<Value> {
1122                _phantom_0: ::std::marker::PhantomData,
1123                ptr: &mut chunks_val,
1124            },
1125        );
1126        if !chunks_val.is_object() {
1127            // Blob-like without synchronous byte storage (e.g. a DOM Blob
1128            // from another realm whose bytes live behind an async
1129            // arrayBuffer()). Fail closed — no empty-body substitute.
1130            return Err("fetch: Blob bodies without synchronous byte storage are not supported yet (no streaming request-body infrastructure)".to_string());
1131        }
1132        // BCE-012: root to_object() result — JS_GetElement can trigger GC
1133        rooted!(&in(wrapped_cx) let chunks = chunks_val.to_object());
1134        let mut is_array = false;
1135        rooted!(&in(wrapped_cx) let arr_probe = chunks_val);
1136        IsArrayObject(cx, arr_probe.handle().into(), &mut is_array);
1137        if !is_array {
1138            return Err("fetch: Blob bodies without synchronous byte storage are not supported yet (no streaming request-body infrastructure)".to_string());
1139        }
1140        let mut len_val = UndefinedValue();
1141        JS_GetProperty(
1142            cx,
1143            chunks.handle().into(),
1144            c"length".as_ptr(),
1145            MutableHandle::<Value> {
1146                _phantom_0: ::std::marker::PhantomData,
1147                ptr: &mut len_val,
1148            },
1149        );
1150        let len = if len_val.is_int32() && len_val.to_int32() > 0 {
1151            len_val.to_int32() as usize
1152        } else {
1153            0
1154        };
1155        let mut out: Vec<u8> = Vec::new();
1156        for i in 0..len as u32 {
1157            let mut el = UndefinedValue();
1158            JS_GetElement(
1159                cx,
1160                chunks.handle().into(),
1161                i,
1162                MutableHandle::<Value> {
1163                    _phantom_0: ::std::marker::PhantomData,
1164                    ptr: &mut el,
1165                },
1166            );
1167            match crate::node_buffer::collect_byte_view(cx, el) {
1168                ::std::option::Option::Some(bytes) => out.extend_from_slice(&bytes),
1169                ::std::option::Option::None => {
1170                    return Err("fetch: Blob chunk is not a byte view".to_string());
1171                }
1172            }
1173        }
1174        Ok(Some(out))
1175    }
1176}
1177
1178// ── FormData multipart serialization ────────────────────────────────────────
1179//
1180// Mirrors upstream Bun `Blob.zig fromDOMFormData` byte-for-byte:
1181//   boundary:   "----WebKitFormBoundary" + hex of 16 random bytes (Bun prints
1182//               its VM's nextUUID() bytes as hex; zero-padded here so the
1183//               boundary is always 22+32 chars)
1184//   per entry:  "--{b}\r\n"
1185//               "Content-Disposition: form-data; name=\"{name}\""
1186//               string:  "\"\r\n\r\n{value}\r\n"
1187//               file:    "\"; filename=\"{filename}\"\r\n"
1188//                        "Content-Type: {ct}\r\n\r\n{bytes}\r\n"
1189//               (ct = Blob.type when non-empty, else application/octet-stream)
1190//   terminator: "--{b}--\r\n"
1191//   header:     "multipart/form-data; boundary={b}"
1192// Upstream Bun performs no quote escaping in name/filename — aligned.
1193// Default filename follows the WHATWG/servo rule (dom/formdata.rs
1194// create_an_entry): explicit filename > File.name > "blob".
1195
1196/// One classified FormData entry value.
1197enum MultipartValue {
1198    /// String field.
1199    Text(String),
1200    /// Blob/File field: filename, per-part content-type, raw bytes.
1201    File {
1202        filename: String,
1203        content_type: String,
1204        bytes: Vec<u8>,
1205    },
1206}
1207
1208/// Generate the multipart boundary (WebKit-style, upstream Bun shape).
1209fn generate_multipart_boundary() -> ::std::result::Result<String, String> {
1210    let mut raw = [0u8; 16];
1211    getrandom::fill(&mut raw)
1212        .map_err(|e| format!("fetch: multipart boundary randomness unavailable: {}", e))?;
1213    let mut s = String::with_capacity("----WebKitFormBoundary".len() + 32);
1214    s.push_str("----WebKitFormBoundary");
1215    for b in raw {
1216        s.push_str(&format!("{:02x}", b));
1217    }
1218    Ok(s)
1219}
1220
1221/// Pure encoder: entries + boundary → multipart/form-data body bytes.
1222/// Kept free of JS interaction so the wire format is unit-testable.
1223fn encode_multipart(entries: &[(String, MultipartValue)], boundary: &str) -> Vec<u8> {
1224    let mut out: Vec<u8> = Vec::new();
1225    for (name, value) in entries {
1226        out.extend_from_slice(b"--");
1227        out.extend_from_slice(boundary.as_bytes());
1228        out.extend_from_slice(b"\r\n");
1229        out.extend_from_slice(b"Content-Disposition: form-data; name=\"");
1230        out.extend_from_slice(name.as_bytes());
1231        match value {
1232            MultipartValue::Text(text) => {
1233                out.extend_from_slice(b"\"\r\n\r\n");
1234                out.extend_from_slice(text.as_bytes());
1235            }
1236            MultipartValue::File {
1237                filename,
1238                content_type,
1239                bytes,
1240            } => {
1241                out.extend_from_slice(b"\"; filename=\"");
1242                out.extend_from_slice(filename.as_bytes());
1243                out.extend_from_slice(b"\"\r\n");
1244                out.extend_from_slice(b"Content-Type: ");
1245                out.extend_from_slice(content_type.as_bytes());
1246                out.extend_from_slice(b"\r\n\r\n");
1247                out.extend_from_slice(bytes);
1248            }
1249        }
1250        out.extend_from_slice(b"\r\n");
1251    }
1252    out.extend_from_slice(b"--");
1253    out.extend_from_slice(boundary.as_bytes());
1254    out.extend_from_slice(b"--\r\n");
1255    out
1256}
1257
1258/// Convert an arbitrary JSVal to a Rust String via ToString (names, string
1259/// field values, filenames).
1260///
1261/// # Safety
1262///
1263/// `cx` must be a live `JSContext*` on the current thread; `v` must be
1264/// protected from GC by the caller's stack frame.
1265#[allow(unsafe_op_in_unsafe_fn)]
1266unsafe fn val_to_rust_string(
1267    cx: *mut JSContext,
1268    v: JSVal,
1269) -> ::std::result::Result<String, String> {
1270    unsafe {
1271        if v.is_string() {
1272            return Ok(crate::js_to_rust_string(cx, v));
1273        }
1274        if v.is_null_or_undefined() {
1275            return Ok(String::new());
1276        }
1277        let mut wrapped_cx =
1278            mozjs::context::JSContext::from_ptr(::std::ptr::NonNull::new_unchecked(cx));
1279        rooted!(&in(wrapped_cx) let root = v);
1280        let jsstr = mozjs::rust::ToString(&mut wrapped_cx, root.handle());
1281        if jsstr.is_null() {
1282            return Err(
1283                "fetch: FormData entry name/value could not be converted to string".to_string(),
1284            );
1285        }
1286        let str_val = StringValue(&*jsstr);
1287        Ok(crate::js_to_rust_string(cx, str_val))
1288    }
1289}
1290
1291/// Read a property off `obj` as a raw JSVal.
1292///
1293/// # Safety
1294///
1295/// `cx` must be a live `JSContext*`; `obj` must be GC-protected by the caller.
1296#[allow(unsafe_op_in_unsafe_fn)]
1297unsafe fn get_val_prop(
1298    cx: *mut JSContext,
1299    obj: mozjs::rust::Handle<*mut JSObject>,
1300    name: &str,
1301) -> JSVal {
1302    unsafe {
1303        let c_name = ZBox::from_bytes(name.as_bytes());
1304        let mut v = UndefinedValue();
1305        // BCE (error.rs:74): same clearing-probe contract as get_string_prop
1306        // — a failed read on a caller-supplied object consumes its pending
1307        // exception instead of leaking it onto the ScriptThread context.
1308        bao_stealth::engine_props::get_property_clearing(
1309            cx,
1310            obj.into(),
1311            c_name.as_cstr(),
1312            &mut v,
1313        );
1314        v
1315    }
1316}
1317
1318/// FormData structural probe: `_data` array + callable getAll (mirrors
1319/// `_bao_is_formdata` in web_fetch_classes.rs). Runs BEFORE the
1320/// URLSearchParams probe — FormData's WHATWG iteration surface
1321/// (entries/forEach) also satisfies that predicate.
1322///
1323/// # Safety
1324///
1325/// `cx` must be a live `JSContext*`; `obj` must be GC-protected by the caller.
1326#[allow(unsafe_op_in_unsafe_fn)]
1327unsafe fn is_formdata_shape(cx: *mut JSContext, obj: mozjs::rust::Handle<*mut JSObject>) -> bool {
1328    unsafe {
1329        let data_val = get_val_prop(cx, obj, "_data");
1330        if !data_val.is_object() {
1331            return false;
1332        }
1333        let wrapped_cx =
1334            mozjs::context::JSContext::from_ptr(::std::ptr::NonNull::new_unchecked(cx));
1335        rooted!(&in(wrapped_cx) let data_probe = data_val);
1336        let mut is_array = false;
1337        IsArrayObject(cx, data_probe.handle().into(), &mut is_array);
1338        if !is_array {
1339            return false;
1340        }
1341        let get_all = get_val_prop(cx, obj, "getAll");
1342        get_all.is_object() && IsCallable(get_all.to_object())
1343    }
1344}
1345
1346/// Serialize a FormData object (globals.rs class: `_data` array of
1347/// `{ name, value, filename }` records) into multipart/form-data bytes and
1348/// default the Content-Type header. Blob/File values are read through their
1349/// synchronous `_chunks` storage; anything else fails closed.
1350///
1351/// # Safety
1352///
1353/// `cx` must be a live `JSContext*`; `formdata_val` must be protected from
1354/// GC by the caller's stack frame. `headers` receives the defaulted
1355/// content-type.
1356#[allow(unsafe_op_in_unsafe_fn)]
1357unsafe fn extract_formdata_multipart(
1358    cx: *mut JSContext,
1359    formdata_val: JSVal,
1360    headers: &mut Vec<(String, String)>,
1361) -> ::std::result::Result<Option<Vec<u8>>, String> {
1362    unsafe {
1363        let wrapped_cx =
1364            mozjs::context::JSContext::from_ptr(::std::ptr::NonNull::new_unchecked(cx));
1365        // BCE-012: root to_object() result — the JS reads below can trigger GC
1366        rooted!(&in(wrapped_cx) let form = formdata_val.to_object());
1367
1368        let data_val = get_val_prop(cx, form.handle(), "_data");
1369        if !data_val.is_object() {
1370            return Err("fetch: FormData body has no _data entries array".to_string());
1371        }
1372        // BCE-012: root the entries array across element reads
1373        rooted!(&in(wrapped_cx) let data = data_val.to_object());
1374        let mut is_array = false;
1375        rooted!(&in(wrapped_cx) let arr_probe = data_val);
1376        IsArrayObject(cx, arr_probe.handle().into(), &mut is_array);
1377        if !is_array {
1378            return Err("fetch: FormData body has no _data entries array".to_string());
1379        }
1380        let len_val = get_val_prop(cx, data.handle(), "length");
1381        let len = if len_val.is_int32() && len_val.to_int32() > 0 {
1382            len_val.to_int32() as usize
1383        } else {
1384            0
1385        };
1386
1387        let boundary = generate_multipart_boundary()?;
1388        let mut entries: Vec<(String, MultipartValue)> = Vec::new();
1389        for i in 0..len as u32 {
1390            let mut el = UndefinedValue();
1391            JS_GetElement(
1392                cx,
1393                data.handle().into(),
1394                i,
1395                MutableHandle::<Value> {
1396                    _phantom_0: ::std::marker::PhantomData,
1397                    ptr: &mut el,
1398                },
1399            );
1400            if !el.is_object() {
1401                return Err("fetch: FormData entry is not an object".to_string());
1402            }
1403            // BCE-012: root the entry across its property reads
1404            rooted!(&in(wrapped_cx) let entry = el.to_object());
1405            let name_val = get_val_prop(cx, entry.handle(), "name");
1406            let name = val_to_rust_string(cx, name_val)?;
1407            let value_val = get_val_prop(cx, entry.handle(), "value");
1408
1409            if value_val.is_object() {
1410                // Blob/File field. Filename: explicit > File.name > "blob".
1411                // Content-type: Blob.type > application/octet-stream.
1412                rooted!(&in(wrapped_cx) let blob = value_val.to_object());
1413                let filename_val = get_val_prop(cx, entry.handle(), "filename");
1414                let mut filename = if filename_val.is_string() {
1415                    ::std::option::Option::Some(crate::js_to_rust_string(cx, filename_val))
1416                } else {
1417                    ::std::option::Option::None
1418                };
1419                if filename.as_deref().map_or(true, |f| f.is_empty()) {
1420                    let name_prop = get_val_prop(cx, blob.handle(), "name");
1421                    filename = if name_prop.is_string() {
1422                        ::std::option::Option::Some(crate::js_to_rust_string(cx, name_prop))
1423                    } else {
1424                        ::std::option::Option::Some("blob".to_string())
1425                    };
1426                }
1427                let type_prop = get_val_prop(cx, blob.handle(), "type");
1428                let content_type = if type_prop.is_string() {
1429                    let t = crate::js_to_rust_string(cx, type_prop);
1430                    if t.is_empty() {
1431                        "application/octet-stream".to_string()
1432                    } else {
1433                        t
1434                    }
1435                } else {
1436                    "application/octet-stream".to_string()
1437                };
1438                let bytes = extract_blob_bytes(cx, value_val)?
1439                    .ok_or_else(|| {
1440                        "fetch: FormData file entry without synchronous byte storage is not supported yet (no streaming request-body infrastructure)".to_string()
1441                    })?;
1442                entries.push((
1443                    name,
1444                    MultipartValue::File {
1445                        filename: filename.unwrap_or_else(|| "blob".to_string()),
1446                        content_type,
1447                        bytes,
1448                    },
1449                ));
1450            } else {
1451                entries.push((
1452                    name,
1453                    MultipartValue::Text(val_to_rust_string(cx, value_val)?),
1454                ));
1455            }
1456        }
1457
1458        let body = encode_multipart(&entries, &boundary);
1459        let has_ct = headers
1460            .iter()
1461            .any(|(n, _)| n.eq_ignore_ascii_case("content-type"));
1462        if !has_ct {
1463            headers.push((
1464                "Content-Type".to_string(),
1465                format!("multipart/form-data; boundary={}", boundary),
1466            ));
1467        }
1468        Ok(Some(body))
1469    }
1470}
1471
1472/// Extract fetch `init.body` bytes. Accepted forms: string, byte views
1473/// (Buffer/Uint8Array/TypedArray/DataView/ArrayBuffer), Bao Blob (`_chunks`
1474/// storage), URLSearchParams (serialized; defaults
1475/// `application/x-www-form-urlencoded;charset=UTF-8` when no content-type
1476/// header is set), FormData (multipart/form-data with a generated boundary).
1477/// Anything else fails closed with an explicit error — silently dropping a
1478/// body turns a POST into an empty POST.
1479///
1480/// # Safety
1481///
1482/// `cx` must be a live `JSContext*`; `body_val` must be protected from GC by
1483/// the caller's stack frame. `headers` receives the defaulted content-type.
1484#[allow(unsafe_op_in_unsafe_fn)]
1485unsafe fn extract_body_bytes(
1486    cx: *mut JSContext,
1487    body_val: JSVal,
1488    headers: &mut Vec<(String, String)>,
1489) -> ::std::result::Result<Option<Vec<u8>>, String> {
1490    unsafe {
1491        if body_val.is_null_or_undefined() {
1492            return Ok(None);
1493        }
1494        if body_val.is_string() {
1495            return Ok(Some(crate::js_to_rust_string(cx, body_val).into_bytes()));
1496        }
1497        if !body_val.is_object() {
1498            return Err(format!(
1499                "fetch: unsupported body type (expected string / BufferSource / Blob / URLSearchParams / FormData)"
1500            ));
1501        }
1502        let wrapped_cx =
1503            mozjs::context::JSContext::from_ptr(::std::ptr::NonNull::new_unchecked(cx));
1504        // BCE-012: root to_object() result — the JS calls below can trigger GC
1505        rooted!(&in(wrapped_cx) let obj = body_val.to_object());
1506
1507        // Byte views: Buffer / Uint8Array / TypedArray / DataView / ArrayBuffer.
1508        if let ::std::option::Option::Some(bytes) =
1509            crate::node_buffer::collect_byte_view(cx, body_val)
1510        {
1511            return Ok(Some(bytes));
1512        }
1513
1514        // Constructor-identity probe for the class forms.
1515        let mut ctor_val = UndefinedValue();
1516        JS_GetProperty(
1517            cx,
1518            obj.handle().into(),
1519            c"constructor".as_ptr(),
1520            MutableHandle::<Value> {
1521                _phantom_0: ::std::marker::PhantomData,
1522                ptr: &mut ctor_val,
1523            },
1524        );
1525
1526        // FormData → multipart/form-data. BEFORE the URLSearchParams probe:
1527        // FormData's WHATWG iteration surface (entries/forEach) also
1528        // satisfies that predicate.
1529        if is_formdata_shape(cx, obj.handle()) || is_global_ctor(cx, ctor_val, "FormData") {
1530            return extract_formdata_multipart(cx, body_val, headers);
1531        }
1532
1533        // URLSearchParams → serialize via toString(), default the content-type.
1534        if is_url_search_params_shape(cx, obj.handle()) {
1535            // BCE-012: root the object across the toString call
1536            let mut s_val = UndefinedValue();
1537            let called = JS_CallFunctionName(
1538                cx,
1539                obj.handle().into(),
1540                c"toString".as_ptr(),
1541                &HandleValueArray::empty(),
1542                MutableHandle::<Value> {
1543                    _phantom_0: ::std::marker::PhantomData,
1544                    ptr: &mut s_val,
1545                },
1546            );
1547            if !called || !s_val.is_string() {
1548                return Err("fetch: URLSearchParams body could not be serialized".to_string());
1549            }
1550            let has_ct = headers
1551                .iter()
1552                .any(|(n, _)| n.eq_ignore_ascii_case("content-type"));
1553            if !has_ct {
1554                headers.push((
1555                    "Content-Type".to_string(),
1556                    "application/x-www-form-urlencoded;charset=UTF-8".to_string(),
1557                ));
1558            }
1559            return Ok(Some(crate::js_to_rust_string(cx, s_val).into_bytes()));
1560        }
1561
1562        // Blob-ish (numeric size + callable arrayBuffer). The Bao Blob stores
1563        // `_chunks` synchronously; realm-foreign Blobs fail closed inside.
1564        let mut size_val = UndefinedValue();
1565        JS_GetProperty(
1566            cx,
1567            obj.handle().into(),
1568            c"size".as_ptr(),
1569            MutableHandle::<Value> {
1570                _phantom_0: ::std::marker::PhantomData,
1571                ptr: &mut size_val,
1572            },
1573        );
1574        let mut ab_val = UndefinedValue();
1575        JS_GetProperty(
1576            cx,
1577            obj.handle().into(),
1578            c"arrayBuffer".as_ptr(),
1579            MutableHandle::<Value> {
1580                _phantom_0: ::std::marker::PhantomData,
1581                ptr: &mut ab_val,
1582            },
1583        );
1584        if size_val.is_number() && ab_val.is_object() && IsCallable(ab_val.to_object()) {
1585            return extract_blob_bytes(cx, body_val);
1586        }
1587
1588        Err("fetch: unsupported body type (expected string / BufferSource / Blob / URLSearchParams / FormData; streams are not supported)".to_string())
1589    }
1590}
1591
1592// ── WHATWG fetch init.headers parsing (BCE-20260814-FETCH-H) ──────────────
1593
1594/// Safety valve: a hostile/broken iterator that never reports `done` must
1595/// not hang the JS thread. WHATWG has no hard limit but a 1024-entry cap is
1596/// far beyond any legitimate header list.
1597const MAX_HEADER_ENTRIES: usize = 1024;
1598
1599/// Parse WHATWG fetch `init.headers` into header entries.
1600///
1601/// Accepted forms (WHATWG Fetch spec Headers-fill):
1602/// 1. Sequence of pairs: `[["name","value"], ...]` or `[{name, value}, ...]`.
1603/// 2. Headers-like object with callable `entries()` (servo DOM Headers, the
1604///    orphaned web_fetch_classes Headers) — drained through the JS iterator
1605///    protocol (`next()` until `done`).
1606/// 3. Record `{ "name": "value" }` — also covers this module's `Headers`
1607///    class, whose entries are own enumerable string-valued data props; its
1608///    installed `get`/`set`/`has` method props are skipped by the
1609///    string-value filter.
1610///
1611/// # Safety
1612///
1613/// `cx` must be a live `JSContext*` on the current thread; `headers_val`
1614/// must be protected from GC by the caller's stack frame.
1615#[allow(unsafe_op_in_unsafe_fn)]
1616unsafe fn parse_headers_init(cx: *mut JSContext, headers_val: JSVal) -> Vec<(String, String)> {
1617    unsafe {
1618        let mut out: Vec<(String, String)> = Vec::new();
1619        if !headers_val.is_object() {
1620            return out;
1621        }
1622        let wrapped_cx =
1623            mozjs::context::JSContext::from_ptr(::std::ptr::NonNull::new_unchecked(cx));
1624        // BCE-012: root to_object() result — the JS calls below can trigger GC
1625        rooted!(&in(wrapped_cx) let obj = headers_val.to_object());
1626
1627        // Form 1: sequence of pairs
1628        let mut is_array = false;
1629        rooted!(&in(wrapped_cx) let obj_val = headers_val);
1630        IsArrayObject(cx, obj_val.handle().into(), &mut is_array);
1631        if is_array {
1632            let mut len_val = UndefinedValue();
1633            JS_GetProperty(
1634                cx,
1635                obj.handle().into(),
1636                c"length".as_ptr(),
1637                MutableHandle::<Value> {
1638                    _phantom_0: ::std::marker::PhantomData,
1639                    ptr: &mut len_val,
1640                },
1641            );
1642            let len = if len_val.is_int32() && len_val.to_int32() > 0 {
1643                (len_val.to_int32() as usize).min(MAX_HEADER_ENTRIES)
1644            } else {
1645                0
1646            };
1647            for i in 0..len as u32 {
1648                let mut el_val = UndefinedValue();
1649                JS_GetElement(
1650                    cx,
1651                    obj.handle().into(),
1652                    i,
1653                    MutableHandle::<Value> {
1654                        _phantom_0: ::std::marker::PhantomData,
1655                        ptr: &mut el_val,
1656                    },
1657                );
1658                if let Some(pair) = parse_header_entry(cx, el_val) {
1659                    out.push(pair);
1660                }
1661            }
1662            return out;
1663        }
1664
1665        // Form 2: Headers-like with callable entries() — iterator protocol.
1666        // JS_GetProperty walks the prototype chain, so prototype methods
1667        // (servo DOM Headers / web_fetch_classes) resolve here too.
1668        let mut entries_val = UndefinedValue();
1669        JS_GetProperty(
1670            cx,
1671            obj.handle().into(),
1672            c"entries".as_ptr(),
1673            MutableHandle::<Value> {
1674                _phantom_0: ::std::marker::PhantomData,
1675                ptr: &mut entries_val,
1676            },
1677        );
1678        if entries_val.is_object() && IsCallable(entries_val.to_object()) {
1679            // BCE-012: root the entries function across the call
1680            rooted!(&in(wrapped_cx) let _entries_fn = entries_val.to_object());
1681            let mut iter_val = UndefinedValue();
1682            let called = JS_CallFunctionName(
1683                cx,
1684                obj.handle().into(),
1685                c"entries".as_ptr(),
1686                &HandleValueArray::empty(),
1687                MutableHandle::<Value> {
1688                    _phantom_0: ::std::marker::PhantomData,
1689                    ptr: &mut iter_val,
1690                },
1691            );
1692            if called && iter_val.is_object() {
1693                // BCE-012: root the iterator across the next() calls
1694                rooted!(&in(wrapped_cx) let iter = iter_val.to_object());
1695                loop {
1696                    if out.len() >= MAX_HEADER_ENTRIES {
1697                        break;
1698                    }
1699                    let mut next_val = UndefinedValue();
1700                    let advanced = JS_CallFunctionName(
1701                        cx,
1702                        iter.handle().into(),
1703                        c"next".as_ptr(),
1704                        &HandleValueArray::empty(),
1705                        MutableHandle::<Value> {
1706                            _phantom_0: ::std::marker::PhantomData,
1707                            ptr: &mut next_val,
1708                        },
1709                    );
1710                    if !advanced || !next_val.is_object() {
1711                        break;
1712                    }
1713                    // BCE-012: root the iterator result across property reads
1714                    rooted!(&in(wrapped_cx) let res = next_val.to_object());
1715                    let mut done_val = UndefinedValue();
1716                    JS_GetProperty(
1717                        cx,
1718                        res.handle().into(),
1719                        c"done".as_ptr(),
1720                        MutableHandle::<Value> {
1721                            _phantom_0: ::std::marker::PhantomData,
1722                            ptr: &mut done_val,
1723                        },
1724                    );
1725                    if done_val.is_boolean() && done_val.to_boolean() {
1726                        break;
1727                    }
1728                    let mut pair_val = UndefinedValue();
1729                    JS_GetProperty(
1730                        cx,
1731                        res.handle().into(),
1732                        c"value".as_ptr(),
1733                        MutableHandle::<Value> {
1734                            _phantom_0: ::std::marker::PhantomData,
1735                            ptr: &mut pair_val,
1736                        },
1737                    );
1738                    if let Some(pair) = parse_header_entry(cx, pair_val) {
1739                        out.push(pair);
1740                    }
1741                }
1742            }
1743            return out;
1744        }
1745
1746        // Form 3: record / this module's Headers class — own enumerable
1747        // string-keyed props with string values. The string-value filter
1748        // skips the class's get/set/has method props (they are functions).
1749        let mut ids = mozjs::rust::IdVector::new(cx);
1750        if GetPropertyKeys(cx, obj.handle().into(), JSITER_OWNONLY, ids.handle_mut()) {
1751            for jsid in &*ids {
1752                if !jsid.is_string() {
1753                    continue;
1754                }
1755                let key_str_ptr = jsid.to_string();
1756                if key_str_ptr.is_null() {
1757                    continue;
1758                }
1759                let key =
1760                    unsafe_jsstr_to_string(cx, ::std::ptr::NonNull::new_unchecked(key_str_ptr));
1761                let c_key = ZBox::from_bytes(key.as_bytes());
1762                let mut v_val = UndefinedValue();
1763                JS_GetProperty(
1764                    cx,
1765                    obj.handle().into(),
1766                    c_key.as_ptr(),
1767                    MutableHandle::<Value> {
1768                        _phantom_0: ::std::marker::PhantomData,
1769                        ptr: &mut v_val,
1770                    },
1771                );
1772                if v_val.is_string() {
1773                    out.push((key, crate::js_to_rust_string(cx, v_val)));
1774                }
1775            }
1776        }
1777        out
1778    }
1779}
1780
1781/// Parse one `[name, value]` / `{name, value}` pair — an element of the
1782/// sequence form, or the `value` of a Headers-like iterator result.
1783///
1784/// # Safety
1785///
1786/// `cx` must be a live `JSContext*` on the current thread; `pair_val` must
1787/// be protected from GC by the caller's stack frame.
1788#[allow(unsafe_op_in_unsafe_fn)]
1789unsafe fn parse_header_entry(cx: *mut JSContext, pair_val: JSVal) -> Option<(String, String)> {
1790    unsafe {
1791        if !pair_val.is_object() {
1792            return None;
1793        }
1794        let wrapped_cx =
1795            mozjs::context::JSContext::from_ptr(::std::ptr::NonNull::new_unchecked(cx));
1796        // BCE-012: root to_object() result — JS_GetElement/JS_GetProperty can trigger GC
1797        rooted!(&in(wrapped_cx) let pair = pair_val.to_object());
1798
1799        let mut is_array = false;
1800        rooted!(&in(wrapped_cx) let pair_root = pair_val);
1801        IsArrayObject(cx, pair_root.handle().into(), &mut is_array);
1802        if is_array {
1803            // ["name", "value"]
1804            let mut n_val = UndefinedValue();
1805            let mut v_val = UndefinedValue();
1806            JS_GetElement(
1807                cx,
1808                pair.handle().into(),
1809                0,
1810                MutableHandle::<Value> {
1811                    _phantom_0: ::std::marker::PhantomData,
1812                    ptr: &mut n_val,
1813                },
1814            );
1815            JS_GetElement(
1816                cx,
1817                pair.handle().into(),
1818                1,
1819                MutableHandle::<Value> {
1820                    _phantom_0: ::std::marker::PhantomData,
1821                    ptr: &mut v_val,
1822                },
1823            );
1824            if n_val.is_string() && v_val.is_string() {
1825                return Some((
1826                    crate::js_to_rust_string(cx, n_val),
1827                    crate::js_to_rust_string(cx, v_val),
1828                ));
1829            }
1830            return None;
1831        }
1832
1833        // { name, value }
1834        let mut n_val = UndefinedValue();
1835        let mut v_val = UndefinedValue();
1836        JS_GetProperty(
1837            cx,
1838            pair.handle().into(),
1839            c"name".as_ptr(),
1840            MutableHandle::<Value> {
1841                _phantom_0: ::std::marker::PhantomData,
1842                ptr: &mut n_val,
1843            },
1844        );
1845        JS_GetProperty(
1846            cx,
1847            pair.handle().into(),
1848            c"value".as_ptr(),
1849            MutableHandle::<Value> {
1850                _phantom_0: ::std::marker::PhantomData,
1851                ptr: &mut v_val,
1852            },
1853        );
1854        if n_val.is_string() && v_val.is_string() {
1855            return Some((
1856                crate::js_to_rust_string(cx, n_val),
1857                crate::js_to_rust_string(cx, v_val),
1858            ));
1859        }
1860        None
1861    }
1862}
1863
1864// ── init.tls parsing (undici dispatcher tls subset) ────────────────────────
1865
1866/// Safety valve: a hostile `ca` array must not grow the parsed trust store
1867/// unboundedly (each entry becomes a handshake-time X509_STORE member).
1868/// 256 entries is far beyond any legitimate CA bundle (a full system root
1869/// store is ~150; undici dispatchers carry a handful).
1870const MAX_CA_ENTRIES: usize = 256;
1871
1872/// Parse WHATWG-fetch `init.tls` — the Node undici `dispatcher` tls option
1873/// subset: `{ ca?: string|string[]|BufferSource|BufferSource[],
1874/// rejectUnauthorized?: boolean, servername?: string }`.
1875///
1876/// - `ca`: PEM strings are decoded to DER by BoringSSL (`pem_parse_certs`);
1877///   byte views are taken as raw DER (or PEM bytes, sniffed by the BEGIN
1878///   marker). A provided `ca` that yields zero certs fails closed — a typo
1879///   must not silently degrade to system roots.
1880/// - `rejectUnauthorized`: explicit verification opt-out (Node semantics;
1881///   verification still runs by default — this is a user instruction, never
1882///   a silent fallback).
1883/// - `servername`: non-empty SNI override (empty string is ambiguous between
1884///   "no override" and Node's SNI-suppression `''` — rejected loudly rather
1885///   than silently picking one).
1886///
1887/// Unknown keys are ignored (undici ignores unknown dispatcher tls options).
1888///
1889/// # Safety
1890///
1891/// `cx` must be a live `JSContext*` on the current thread; `tls_val` must be
1892/// protected from GC by the caller's stack frame.
1893#[allow(unsafe_op_in_unsafe_fn)]
1894unsafe fn parse_tls_init(
1895    cx: *mut JSContext,
1896    tls_val: JSVal,
1897) -> ::std::result::Result<crate::fetch_async::FetchTlsInit, String> {
1898    unsafe {
1899        if !tls_val.is_object() {
1900            return Err(
1901                "fetch: init.tls must be an object ({ ca, rejectUnauthorized, servername })"
1902                    .to_string(),
1903            );
1904        }
1905        let wrapped_cx =
1906            mozjs::context::JSContext::from_ptr(::std::ptr::NonNull::new_unchecked(cx));
1907        // BCE-012: root to_object() result — the JS reads below can trigger GC
1908        rooted!(&in(wrapped_cx) let obj = tls_val.to_object());
1909
1910        let mut out = crate::fetch_async::FetchTlsInit::default();
1911
1912        // ca → DER trust list (Override semantics; absent = system roots).
1913        let ca_val = get_val_prop(cx, obj.handle(), "ca");
1914        if !ca_val.is_undefined() && !ca_val.is_null() {
1915            let mut ders: Vec<Box<[u8]>> = Vec::new();
1916            collect_ca_ders(cx, ca_val, &mut ders)?;
1917            if ders.is_empty() {
1918                return Err("fetch: init.tls.ca contained no parseable certificate".to_string());
1919            }
1920            out.ca_certs_der = ders.into_boxed_slice();
1921        }
1922
1923        // rejectUnauthorized → explicit verify opt-out.
1924        let ra_val = get_val_prop(cx, obj.handle(), "rejectUnauthorized");
1925        if !ra_val.is_undefined() && !ra_val.is_null() {
1926            if !ra_val.is_boolean() {
1927                return Err("fetch: init.tls.rejectUnauthorized must be a boolean".to_string());
1928            }
1929            out.reject_unauthorized = ::std::option::Option::Some(ra_val.to_boolean());
1930        }
1931
1932        // servername → SNI override.
1933        let sn_val = get_val_prop(cx, obj.handle(), "servername");
1934        if !sn_val.is_undefined() && !sn_val.is_null() {
1935            if !sn_val.is_string() {
1936                return Err("fetch: init.tls.servername must be a string".to_string());
1937            }
1938            let sn = crate::js_to_rust_string(cx, sn_val);
1939            if sn.is_empty() {
1940                return Err(
1941                    "fetch: init.tls.servername must be a non-empty host string".to_string(),
1942                );
1943            }
1944            if sn.as_bytes().contains(&0) {
1945                return Err("fetch: init.tls.servername must not contain NUL".to_string());
1946            }
1947            out.servername = ::std::option::Option::Some(sn);
1948        }
1949
1950        Ok(out)
1951    }
1952}
1953
1954/// Append the DER certs carried by one `init.tls.ca` value — a PEM string, a
1955/// byte view (raw DER, or PEM bytes sniffed by the BEGIN marker), or an
1956/// array mixing both. Recursion depth is bounded by construction: arrays
1957/// iterate elements, and only the string/byte-view leaf forms parse.
1958///
1959/// # Safety
1960///
1961/// `cx` must be a live `JSContext*`; `val` must be protected from GC by the
1962/// caller's stack frame.
1963#[allow(unsafe_op_in_unsafe_fn)]
1964unsafe fn collect_ca_ders(
1965    cx: *mut JSContext,
1966    val: JSVal,
1967    out: &mut Vec<Box<[u8]>>,
1968) -> ::std::result::Result<(), String> {
1969    unsafe {
1970        if out.len() >= MAX_CA_ENTRIES {
1971            return Err(format!("fetch: init.tls.ca exceeds {} entries", MAX_CA_ENTRIES));
1972        }
1973        // PEM string → DER (BoringSSL validates the block structure; a PEM
1974        // that yields zero certs is an error, not a silent no-op).
1975        if val.is_string() {
1976            let pem = crate::js_to_rust_string(cx, val);
1977            let ders = bao_boringssl_bridge::pem_parse_certs(&pem);
1978            if ders.is_empty() {
1979                return Err(
1980                    "fetch: init.tls.ca PEM string contained no parseable certificate".to_string(),
1981                );
1982            }
1983            for der in ders {
1984                if out.len() >= MAX_CA_ENTRIES {
1985                    break;
1986                }
1987                out.push(der.into_boxed_slice());
1988            }
1989            return Ok(());
1990        }
1991        if val.is_object() {
1992            let wrapped_cx =
1993                mozjs::context::JSContext::from_ptr(::std::ptr::NonNull::new_unchecked(cx));
1994            // BCE-012: root to_object() result — the JS reads below can trigger GC
1995            rooted!(&in(wrapped_cx) let obj = val.to_object());
1996
1997            // Array form: each element is a PEM string / DER byte view.
1998            let mut is_array = false;
1999            rooted!(&in(wrapped_cx) let probe = val);
2000            IsArrayObject(cx, probe.handle().into(), &mut is_array);
2001            if is_array {
2002                let mut len_val = UndefinedValue();
2003                JS_GetProperty(
2004                    cx,
2005                    obj.handle().into(),
2006                    c"length".as_ptr(),
2007                    MutableHandle::<Value> {
2008                        _phantom_0: ::std::marker::PhantomData,
2009                        ptr: &mut len_val,
2010                    },
2011                );
2012                let len = if len_val.is_int32() && len_val.to_int32() > 0 {
2013                    (len_val.to_int32() as usize).min(MAX_CA_ENTRIES)
2014                } else {
2015                    0
2016                };
2017                for i in 0..len as u32 {
2018                    let mut el = UndefinedValue();
2019                    JS_GetElement(
2020                        cx,
2021                        obj.handle().into(),
2022                        i,
2023                        MutableHandle::<Value> {
2024                            _phantom_0: ::std::marker::PhantomData,
2025                            ptr: &mut el,
2026                        },
2027                    );
2028                    collect_ca_ders(cx, el, out)?;
2029                }
2030                return Ok(());
2031            }
2032
2033            // Byte view: PEM bytes (sniffed) or one raw DER certificate.
2034            // Unparseable DER is skipped fail-closed downstream by
2035            // apply_ca_certs_der (the store stays without it — verification
2036            // fails against the override, never silently passes).
2037            if let ::std::option::Option::Some(bytes) = crate::node_buffer::collect_byte_view(cx, val)
2038            {
2039                let looks_pem = bytes
2040                    .windows(b"-----BEGIN".len())
2041                    .any(|w| w == &b"-----BEGIN"[..]);
2042                if looks_pem {
2043                    let pem = String::from_utf8_lossy(&bytes).into_owned();
2044                    let ders = bao_boringssl_bridge::pem_parse_certs(&pem);
2045                    if ders.is_empty() {
2046                        return Err(
2047                            "fetch: init.tls.ca PEM bytes contained no parseable certificate"
2048                                .to_string(),
2049                        );
2050                    }
2051                    for der in ders {
2052                        if out.len() >= MAX_CA_ENTRIES {
2053                            break;
2054                        }
2055                        out.push(der.into_boxed_slice());
2056                    }
2057                    return Ok(());
2058                }
2059                out.push(bytes.into_boxed_slice());
2060                return Ok(());
2061            }
2062        }
2063        Err("fetch: init.tls.ca entries must be PEM strings or DER byte views (Buffer/Uint8Array)".to_string())
2064    }
2065}
2066
2067#[cfg(test)]
2068mod tests {
2069    use super::{MultipartValue, encode_multipart, generate_multipart_boundary};
2070
2071    // ── REQ-SEC-001: CORS Bypass Unit Tests ──────────────────────────────
2072    // @trace TEST-SEC-001 [req:REQ-SEC-001] [level:unit]
2073
2074    /// REQ-SEC-001: fetch global is installed on page realm via install_all_native.
2075    #[test]
2076    fn cors_bypass_fetch_global_installed_for_page() {
2077        let source = include_str!("fetch_api.rs");
2078        assert!(
2079            source.contains("pub fn install_fetch_global"),
2080            "REQ-SEC-001: install_fetch_global must be pub for page realm installation"
2081        );
2082    }
2083
2084    /// REQ-SEC-001: fetch delegates to fetch_async::start (event-driven, no CORS).
2085    #[test]
2086    fn cors_bypass_fetch_uses_event_driven_no_cors() {
2087        let source = include_str!("fetch_api.rs");
2088        assert!(
2089            source.contains("crate::fetch_async::start"),
2090            "REQ-SEC-001: fetch must delegate to fetch_async::start"
2091        );
2092        // Split string literal to avoid self-match in include_str source
2093        let forbidden_cors = ["cors", "_check"].join("");
2094        assert!(
2095            !source.contains(&forbidden_cors),
2096            "REQ-SEC-001 REGRESSION: fetch must NOT contain cors check"
2097        );
2098        // Split string literal to avoid self-match in include_str source
2099        let forbidden_cors_preflight = ["Access-Control", "-Request-Method"].join("");
2100        assert!(
2101            !source.contains(&forbidden_cors_preflight),
2102            "REQ-SEC-001 REGRESSION: fetch must NOT send CORS preflight headers"
2103        );
2104    }
2105
2106    /// BCE-20260619-010: old thread::spawn/drain code is removed.
2107    #[test]
2108    fn bce_010_no_spawn_or_drain() {
2109        let source = include_str!("fetch_api.rs");
2110        // Split string literals to avoid self-match in include_str source
2111        let forbidden_spawn = ["spawn", "_fetch_worker"].join("");
2112        let forbidden_drain = ["drain", "_pending_fetches"].join("");
2113        let forbidden_blocking = ["do_fetch", "_blocking"].join("");
2114        assert!(
2115            !source.contains(&forbidden_spawn),
2116            "BCE-010 REGRESSION: spawn fetch worker must be removed"
2117        );
2118        assert!(
2119            !source.contains(&forbidden_drain),
2120            "BCE-010 REGRESSION: drain pending fetches must be removed"
2121        );
2122        assert!(
2123            !source.contains(&forbidden_blocking),
2124            "BCE-010 REGRESSION: do fetch blocking must be removed"
2125        );
2126    }
2127
2128    /// BCE-20260814-FETCH-H: init.headers must be parsed (three WHATWG
2129    /// forms), not dropped. Split string literals to avoid self-match in
2130    /// include_str source.
2131    #[test]
2132    fn bce_fetch_h_headers_not_dropped() {
2133        let source = include_str!("fetch_api.rs");
2134        let parse_call = ["parse_", "headers_init"].join("");
2135        assert!(
2136            source.contains(&parse_call),
2137            "BCE-20260814-FETCH-H REGRESSION: fetch_fn must parse init.headers"
2138        );
2139        let dropped_form = ["let headers: Vec<(String, String)> = ", "Vec::new();"].join("");
2140        assert!(
2141            !source.contains(&dropped_form),
2142            "BCE-20260814-FETCH-H REGRESSION: init.headers must not be dropped as an empty Vec"
2143        );
2144        // All three WHATWG init forms must be handled.
2145        let seq_form = ["[\"name\",\"value\"]"].join("");
2146        assert!(
2147            source.contains(&seq_form),
2148            "BCE-20260814-FETCH-H: sequence pair form must be documented/parseable"
2149        );
2150    }
2151
2152    /// init.tls (undici dispatcher tls subset) must be parsed and plumbed to
2153    /// the SSLConfig injection — the gap this feature closed was "self-signed
2154    /// server ⇒ only fail-closed, no configuration surface". Split string
2155    /// literals to avoid self-match in include_str source.
2156    #[test]
2157    fn init_tls_parsed_and_injected() {
2158        let source = include_str!("fetch_api.rs");
2159        let parse_call = ["parse_", "tls_init"].join("");
2160        assert!(
2161            source.contains(&parse_call),
2162            "TEST-ENG-FETCH-TLS REGRESSION: fetch_fn must parse init.tls"
2163        );
2164        // Fail-closed parsing: a provided ca that parses to zero certs must
2165        // be an error, never a silent fallback to system roots.
2166        let fail_closed = ["no parseable ", "certificate"].join("");
2167        assert!(
2168            source.contains(&fail_closed),
2169            "TEST-ENG-FETCH-TLS REGRESSION: unparseable init.tls.ca must fail closed"
2170        );
2171        // rejectUnauthorized is Node-semantics explicit opt-out (boolean only).
2172        assert!(
2173            source.contains("rejectUnauthorized"),
2174            "TEST-ENG-FETCH-TLS REGRESSION: rejectUnauthorized option missing"
2175        );
2176        // servername SNI override must flow through.
2177        assert!(
2178            source.contains("servername"),
2179            "TEST-ENG-FETCH-TLS REGRESSION: servername option missing"
2180        );
2181    }
2182
2183    // ── FormData multipart serialization unit tests ─────────────────────
2184    // @trace TEST-ENG-FETCH-FORMDATA [req:REQ-ENG-001 REQ-ENG-006] [level:unit]
2185
2186    const TEST_BOUNDARY: &str = "----WebKitFormBoundary0123456789abcdef0123456789abcdef";
2187
2188    /// Upstream Bun Blob.zig fromDOMFormData wire format: per-entry framing,
2189    /// Content-Disposition, per-file Content-Type, terminator.
2190    #[test]
2191    fn multipart_encode_text_and_file_entries() {
2192        let entries = vec![
2193            (
2194                "field".to_string(),
2195                MultipartValue::Text("hello world".to_string()),
2196            ),
2197            (
2198                "upload".to_string(),
2199                MultipartValue::File {
2200                    filename: "a.txt".to_string(),
2201                    content_type: "text/plain".to_string(),
2202                    bytes: b"file-bytes".to_vec(),
2203                },
2204            ),
2205            (
2206                "noType".to_string(),
2207                MultipartValue::File {
2208                    filename: "blob".to_string(),
2209                    content_type: "application/octet-stream".to_string(),
2210                    bytes: vec![0u8, 1, 2],
2211                },
2212            ),
2213        ];
2214        let body = encode_multipart(&entries, TEST_BOUNDARY);
2215        let text = String::from_utf8_lossy(&body).to_string();
2216        let expected = concat!(
2217            "------WebKitFormBoundary0123456789abcdef0123456789abcdef\r\n",
2218            "Content-Disposition: form-data; name=\"field\"\r\n",
2219            "\r\n",
2220            "hello world\r\n",
2221            "------WebKitFormBoundary0123456789abcdef0123456789abcdef\r\n",
2222            "Content-Disposition: form-data; name=\"upload\"; filename=\"a.txt\"\r\n",
2223            "Content-Type: text/plain\r\n",
2224            "\r\n",
2225            "file-bytes\r\n",
2226            "------WebKitFormBoundary0123456789abcdef0123456789abcdef\r\n",
2227            "Content-Disposition: form-data; name=\"noType\"; filename=\"blob\"\r\n",
2228            "Content-Type: application/octet-stream\r\n",
2229            "\r\n",
2230        );
2231        assert!(
2232            text.starts_with(expected),
2233            "multipart per-entry framing mismatch:\n{}",
2234            text
2235        );
2236        assert!(
2237            text.ends_with("------WebKitFormBoundary0123456789abcdef0123456789abcdef--\r\n"),
2238            "multipart terminator missing:\n{}",
2239            text
2240        );
2241        // Binary file bytes survive verbatim (no lossy transform).
2242        assert!(body.windows(3).any(|w| w == [0u8, 1, 2]));
2243    }
2244
2245    /// Empty FormData → boundary terminator only (RFC 7578 permits an empty
2246    /// parts list; Bun emits the same shape).
2247    #[test]
2248    fn multipart_encode_empty_formdata() {
2249        let body = encode_multipart(&[], TEST_BOUNDARY);
2250        assert_eq!(
2251            body,
2252            format!("{}--\r\n", format!("--{}", TEST_BOUNDARY)).into_bytes()
2253        );
2254    }
2255
2256    /// Boundary uniqueness: two generations must differ (random 128-bit).
2257    #[test]
2258    fn multipart_boundary_unique_per_generation() {
2259        let a = generate_multipart_boundary().expect("boundary gen");
2260        let b = generate_multipart_boundary().expect("boundary gen");
2261        assert_ne!(a, b, "multipart boundary repeated across generations");
2262        assert!(a.starts_with("----WebKitFormBoundary"));
2263        assert_eq!(a.len(), 22 + 32, "boundary must be prefix + 32 hex chars");
2264        assert!(
2265            a["----WebKitFormBoundary".len()..]
2266                .chars()
2267                .all(|c| c.is_ascii_hexdigit()),
2268            "boundary suffix must be hex"
2269        );
2270    }
2271}