use crate::host::decode_go_utf8; use crate::runtime::{Control, ExtensionServices}; use base64::Engine; use rquickjs::{Array, Ctx, Function, Object, TypedArray, Value}; use spotiflac_network::{HttpRequest, HttpResponse}; use std::collections::BTreeMap; use std::sync::Arc; pub(crate) fn register<'js>( ctx: &Ctx<'js>, host: &Object<'js>, control: Arc, services: &ExtensionServices, ) -> rquickjs::Result<()> { host.set("networkEnabled", services.network.is_some())?; let Some(session) = &services.network else { return Ok(()); }; let validator = Arc::clone(session); host.set( "validateURL", Function::new(ctx.clone(), move |url: String| { validator.validate_url(&url).err() })?, )?; let clearer = Arc::clone(session); host.set( "clearCookies", Function::new(ctx.clone(), move || { clearer.clear_cookies(); true })?, )?; let session = Arc::clone(session); host.set( "request", Function::new( ctx.clone(), move |url: String, method: String, body: String, headers: Object<'js>, default_json: bool, user_agent: String, fetch: bool| -> rquickjs::Result> { let ctx = headers.ctx().clone(); let headers = headers .props::() .collect::>>()?; let result = session.request( HttpRequest { url, method, body, headers, default_json, user_agent, }, || control.check().map_err(|error| error.to_string()), ); match result { Ok(response) => response_object(&ctx, response, fetch), Err(error) => { let object = Object::new(ctx)?; object.set("error", error)?; Ok(object) } } }, )?, )?; host.set( "decodeBuffer", Function::new(ctx.clone(), |bytes: TypedArray<'js, u8>| { decode_buffer(&bytes) })?, )?; host.set( "parseJSONBuffer", Function::new(ctx.clone(), |ctx: Ctx<'js>, bytes: TypedArray<'js, u8>| { parse_json(&ctx, &decode_buffer(&bytes)) })?, )?; host.set( "encodeBuffer", Function::new(ctx.clone(), |bytes: TypedArray<'js, u8>| { encode_buffer(&bytes) })?, )?; Ok(()) } fn parse_json<'js>(ctx: &Ctx<'js>, text: &str) -> rquickjs::Result> { let text = spotiflac_core::normalize_json_surrogates(text); let bytes = text.as_bytes(); let (mut position, mut depth) = (0, 0usize); // Go validates every float64 before overwriting duplicate object keys. // The engine supplies syntax validation and allocates directly in its heap. while position < bytes.len() { match bytes[position] { b'"' => { position += 1; while position < bytes.len() && bytes[position] != b'"' { position += if bytes[position] == b'\\' { 2 } else { 1 }; } } b'[' | b'{' => { depth += 1; if depth > 10_000 { return Err(rquickjs::Exception::throw_syntax( ctx, "JSON exceeds maximum depth", )); } } b']' | b'}' => depth = depth.saturating_sub(1), b'-' | b'0'..=b'9' => { let start = position; while position < bytes.len() && matches!( bytes[position], b'0'..=b'9' | b'-' | b'+' | b'.' | b'e' | b'E' ) { position += 1; } if !text[start..position] .parse::() .is_ok_and(f64::is_finite) { return Err(rquickjs::Exception::throw_syntax( ctx, "invalid JSON number", )); } continue; } _ => {} } position += 1; } ctx.json_parse(text.as_bytes()) } fn response_object<'js>( ctx: &Ctx<'js>, response: HttpResponse, fetch: bool, ) -> rquickjs::Result> { let object = Object::new(ctx.clone())?; object.set("status", response.status)?; object.set("ok", (200..300).contains(&response.status))?; object.set("url", response.url)?; let headers = Object::new(ctx.clone())?; for (key, values) in response.headers { if values.len() == 1 { headers.set(key, &values[0])?; } else { let array = Array::new(ctx.clone())?; for (index, value) in values.into_iter().enumerate() { array.set(index, value)?; } headers.set(key, array)?; } } object.set("headers", headers)?; if fetch { object.set("statusText", response.status_text)?; // Copy into engine-owned memory so retained responses count against the // VM heap limit. No base64 intermediate or per-byte JS Number array. object.set("bytes", TypedArray::new_copy(ctx.clone(), response.body)?)?; } else { object.set("statusCode", response.status)?; object.set("body", crate::host::decode_go_utf8_owned(response.body))?; } Ok(object) } #[allow(unsafe_code)] fn decode_buffer(bytes: &TypedArray<'_, u8>) -> String { // SAFETY: the VM has one worker and exposes no shared-memory workers. No // JavaScript or engine operation runs while borrowing its buffer. The owned // Rust String is completed before returning through rquickjs into JavaScript. let bytes = unsafe { bytes.as_bytes() }.unwrap_or_default(); decode_go_utf8(bytes) } #[allow(unsafe_code)] fn encode_buffer(bytes: &TypedArray<'_, u8>) -> String { // SAFETY: same single-worker, no-engine-call borrowing rule as decode_buffer. let bytes = unsafe { bytes.as_bytes() }.unwrap_or_default(); base64::engine::general_purpose::STANDARD.encode(bytes) } #[cfg(test)] mod tests { use super::*; #[test] fn text_response_preserves_status_headers_unicode_and_invalid_bytes() { let runtime = rquickjs::Runtime::new().unwrap(); let context = rquickjs::Context::full(&runtime).unwrap(); context.with(|ctx| { for body in ["Music 音楽 🎵\0".as_bytes(), &b"a\xe2\x82b"[..]] { let response = response_object( &ctx, HttpResponse { status: 201, status_text: "Created".into(), url: "https://example.invalid/metadata".into(), headers: BTreeMap::from([( "Content-Type".into(), vec!["text/plain".into()], )]), body: body.to_vec(), }, false, ) .unwrap(); assert_eq!( response.get::<_, String>("body").unwrap(), decode_go_utf8(body) ); assert_eq!(response.get::<_, u16>("statusCode").unwrap(), 201); assert!(response.get::<_, bool>("ok").unwrap()); assert_eq!( response .get::<_, Object>("headers") .unwrap() .get::<_, String>("Content-Type") .unwrap(), "text/plain", ); } }); } }