use crate::runtime::{Control, ExtensionServices}; use crate::storage::{StorageError, StoreKind}; use base64::Engine; use base64::alphabet; use base64::engine::general_purpose::{GeneralPurpose, GeneralPurposeConfig, STANDARD}; use hmac::{Hmac, KeyInit, Mac}; use md5::Md5; use rquickjs::{Ctx, Function, Object}; use sha1::Sha1; use sha2::{Digest, Sha256}; use std::sync::Arc; use std::time::Duration; pub(crate) fn register<'js>( ctx: &Ctx<'js>, control: Arc, services: &ExtensionServices, ) -> rquickjs::Result> { let host = Object::new(ctx.clone())?; crate::url_host::register(ctx, &host)?; crate::utility_host::register(ctx, &host, Arc::clone(&control), services)?; crate::legacy_host::register(ctx, &host, Arc::clone(&control), services)?; let item_control = Arc::clone(&control); host.set( "downloadItemActive", Function::new(ctx.clone(), move || !item_control.item_id().is_empty())?, )?; let status_control = Arc::clone(&control); let downloads = Arc::clone(&services.downloads); host.set( "downloadStatus", Function::new(ctx.clone(), move |status: String| { let id = status_control.item_id(); if id.is_empty() { return; } let _ = match status.trim().to_lowercase().as_str() { "preparing" => downloads.progress.preparing(&id, ""), "downloading" => downloads.progress.downloading(&id), "finalizing" => downloads.progress.finalizing(&id), _ => Ok(()), }; })?, )?; let resolution_control = Arc::clone(&control); host.set( "resolutionRemaining", Function::new(ctx.clone(), move || { resolution_control .resolution() .map_or(60_000, |budget| budget.remaining().as_millis() as u64) })?, )?; crate::crypto_host::register(ctx, &host, Arc::clone(&control))?; crate::file_host::register(ctx, &host, Arc::clone(&control), services)?; crate::ffmpeg_host::register(ctx, &host, Arc::clone(&control), services)?; crate::download_host::register(ctx, &host, Arc::clone(&control), services)?; crate::network_host::register(ctx, &host, Arc::clone(&control), services)?; crate::session_host::register(ctx, &host, Arc::clone(&control), services)?; host.set("authEnabled", services.auth.is_some())?; if let Some(auth) = &services.auth { let auth = Arc::clone(auth); let control = Arc::clone(&control); host.set( "authCall", Function::new( ctx.clone(), move |method: String, arguments: String, expires_is_float: bool| { let arguments = zeroize::Zeroizing::new(arguments); let result = serde_json::from_str::>(&arguments) .map_err(|error| error.to_string()) .and_then(|arguments| { auth.call(&method, &arguments, expires_is_float, || { control.check().map_err(|error| error.to_string()) }) }); match result { Ok(value) => value, Err(error) if method == "generatePKCE" => { serde_json::json!({"error":error}) } Err(error) => serde_json::json!({"success":false,"error":error}), } .to_string() }, )?, )?; } host.set("storageEnabled", services.storage.is_some())?; if let Some(store) = &services.storage { let reader = Arc::clone(store); host.set( "storageRead", Function::new(ctx.clone(), move |credentials: bool, key: String| { let kind = if credentials { StoreKind::Credentials } else { StoreKind::Storage }; match reader.get(kind, &key) { Ok(Some(value)) => serde_json::json!({"found":true,"value":value}), Ok(None) => serde_json::json!({"found":false}), Err(error) => serde_json::json!({"error":error.to_string()}), } .to_string() })?, )?; let writer = Arc::clone(store); host.set( "storageWrite", Function::new( ctx.clone(), move |credentials: bool, key: String, value: String| { let kind = if credentials { StoreKind::Credentials } else { StoreKind::Storage }; let result = serde_json::from_str(&value) .map_err(StorageError::from) .and_then(|value| writer.set(kind, &key, value)); storage_result(result) }, )?, )?; let remover = Arc::clone(store); host.set( "storageRemove", Function::new(ctx.clone(), move |credentials: bool, key: String| { let kind = if credentials { StoreKind::Credentials } else { StoreKind::Storage }; storage_result(remover.remove(kind, &key)) })?, )?; } host.set( "encodeBytes", Function::new(ctx.clone(), |value: Vec| STANDARD.encode(value))?, )?; host.set( "base64Encode", Function::new(ctx.clone(), |value: String| STANDARD.encode(value))?, )?; host.set( "base64Decode", Function::new(ctx.clone(), |value: String, url_safe: bool| { // Go accepts nonzero padding bits and ignores CR/LF, but requires padding. let value = value.replace(['\r', '\n'], ""); let config = GeneralPurposeConfig::new().with_decode_allow_trailing_bits(true); let decoded = GeneralPurpose::new(&alphabet::STANDARD, config) .decode(&value) .or_else(|error| { if url_safe { GeneralPurpose::new(&alphabet::URL_SAFE, config).decode(&value) } else { Err(error) } }) .unwrap_or_default(); decode_go_utf8_owned(decoded) })?, )?; host.set( "encode", Function::new(ctx.clone(), |value: String| value.into_bytes())?, )?; host.set("decode", Function::new(ctx.clone(), decode_go_utf8_owned)?)?; host.set( "md5", Function::new(ctx.clone(), |value: String| { crate::binary::encode(&Md5::digest(value), "hex").expect("hex encoding") })?, )?; host.set( "sha256", Function::new(ctx.clone(), |value: String| { crate::binary::encode(&Sha256::digest(value), "hex").expect("hex encoding") })?, )?; host.set( "hmacSHA256", Function::new(ctx.clone(), |message: String, key: String| { let mut mac = Hmac::::new_from_slice(key.as_bytes()) .expect("HMAC accepts any key length"); mac.update(message.as_bytes()); crate::binary::encode(&mac.finalize().into_bytes(), "hex").expect("hex encoding") })?, )?; host.set( "hmacSHA256Base64", Function::new(ctx.clone(), |message: String, key: String| { let mut mac = Hmac::::new_from_slice(key.as_bytes()) .expect("HMAC accepts any key length"); mac.update(message.as_bytes()); STANDARD.encode(mac.finalize().into_bytes()) })?, )?; host.set( "hmacSHA1", Function::new(ctx.clone(), |key: Vec, message: Vec| { let mut mac = Hmac::::new_from_slice(&key).expect("HMAC accepts any key length"); mac.update(&message); mac.finalize().into_bytes().to_vec() })?, )?; host.set( "quoteJSON", Function::new(ctx.clone(), |value: String| { // Go encoding/json escapes HTML and JS separators in strings. serde_json::to_string(&value) .expect("JSON string encoding") .replace('&', "\\u0026") .replace('<', "\\u003c") .replace('>', "\\u003e") .replace('\u{2028}', "\\u2028") .replace('\u{2029}', "\\u2029") })?, )?; host.set( "sortKeys", Function::new(ctx.clone(), |mut keys: Vec| { // UTF-8/code-point ordering, rather than JS sort's UTF-16 ordering. keys.sort(); keys })?, )?; let sleep_control = Arc::clone(&control); host.set( "sleep", Function::new(ctx.clone(), move |milliseconds: u64| { sleep_control.sleep(Duration::from_millis(milliseconds.min(300_000))) })?, )?; Ok(host) } fn storage_result(result: Result<(), StorageError>) -> String { match result { Ok(()) => serde_json::json!({"success":true}), Err(error) => serde_json::json!({"success":false,"error":error.to_string()}), } .to_string() } // Go's UTF-8 decoder consumes one byte for every invalid rune. Rust's lossy // conversion groups some invalid prefixes, which would change extension text. pub(crate) fn decode_go_utf8_owned(bytes: Vec) -> String { // Text responses/files already own their buffer. Reuse it for valid UTF-8 // instead of holding a second full copy while QuickJS creates its string. match String::from_utf8(bytes) { Ok(text) => text, Err(error) => decode_go_utf8(error.as_bytes()), } } pub(crate) fn decode_go_utf8(mut bytes: &[u8]) -> String { let mut decoded = String::with_capacity(bytes.len()); while !bytes.is_empty() { match std::str::from_utf8(bytes) { Ok(valid) => { decoded.push_str(valid); break; } Err(error) => { let prefix = error.valid_up_to(); decoded .push_str(std::str::from_utf8(&bytes[..prefix]).expect("valid UTF-8 prefix")); decoded.push('\u{fffd}'); bytes = &bytes[prefix + 1..]; } } } decoded } #[cfg(test)] mod tests { #[test] #[ignore = "manual release-process peak-memory measurement"] fn benchmark_owned_text_memory() { let bytes = vec![b'x'; spotiflac_network::MAX_RESPONSE_BYTES]; match std::env::var("SPOTIFLAC_TEXT_MEMORY_MODE").as_deref() { Ok("copy") => { let text = super::decode_go_utf8(&bytes); assert_eq!(text.len(), bytes.len()); std::hint::black_box((&bytes, &text)); } Ok("reuse") => { let pointer = bytes.as_ptr(); let text = super::decode_go_utf8_owned(bytes); assert_eq!(text.as_ptr(), pointer); assert_eq!(text.len(), spotiflac_network::MAX_RESPONSE_BYTES); std::hint::black_box(&text); } _ => panic!("set SPOTIFLAC_TEXT_MEMORY_MODE to copy or reuse"), } } #[test] fn owned_text_reuses_large_buffers_and_preserves_invalid_byte_decoding() { let bytes = "Music 音楽 🎵\0".repeat(128 * 1024).into_bytes(); let pointer = bytes.as_ptr(); let capacity = bytes.capacity(); let text = super::decode_go_utf8_owned(bytes); assert_eq!(text.as_ptr(), pointer); assert_eq!(text.capacity(), capacity); assert_eq!(text, "Music 音楽 🎵\0".repeat(128 * 1024)); for bytes in [ &b""[..], b"a\xffb", b"\xe2\x82", b"\xed\xa0\x80", b"\xf0\x9f\x8e\xb5\0\xff", ] { assert_eq!( super::decode_go_utf8_owned(bytes.to_vec()), super::decode_go_utf8(bytes), ); } assert_eq!( super::decode_go_utf8_owned(vec![0xe2, 0x82]), "\u{fffd}\u{fffd}" ); } #[test] fn digest_upgrade_preserves_extension_hash_and_hmac_encodings() { let runtime = crate::ExtensionRuntime::load( r#"registerExtension({run() { return [ utils.md5('abc'), utils.sha256('abc'), utils.hmacSHA256('message', 'key') ]; }});"#, "{}", crate::RuntimeLimits::default(), ) .unwrap(); let result: serde_json::Value = serde_json::from_str(&runtime.call("run", "[]", None, 1000).unwrap()).unwrap(); assert_eq!( result, serde_json::json!([ "900150983cd24fb0d6963f7d28e17f72", "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", "6e9ef29b75fffc5b7abae527d58fdadb2fe42e7219011976917343065f58ed4a", ]) ); runtime.shutdown(); } }