diff --git a/crates/oxide_vm/src/bindings/bind_global.rs b/crates/oxide_vm/src/bindings/bind_global.rs index ac5f339..41850ca 100644 --- a/crates/oxide_vm/src/bindings/bind_global.rs +++ b/crates/oxide_vm/src/bindings/bind_global.rs @@ -13,6 +13,10 @@ pub fn bind_global(core: &Arc, session: &KernelSession, global: &mut &[ ("escape", crate::builtins::global::js_escape as *const (), 1), ("unescape", crate::builtins::global::js_unescape as *const (), 1), + ("encodeURI", crate::builtins::global::encode_uri as *const (), 1), + ("decodeURI", crate::builtins::global::decode_uri as *const (), 1), + ("encodeURIComponent", crate::builtins::global::encode_uri_component as *const (), 1), + ("decodeURIComponent", crate::builtins::global::decode_uri_component as *const (), 1), ], ); } diff --git a/crates/oxide_vm/src/builtins/error.rs b/crates/oxide_vm/src/builtins/error.rs index 0f067a6..ef4fb60 100644 --- a/crates/oxide_vm/src/builtins/error.rs +++ b/crates/oxide_vm/src/builtins/error.rs @@ -61,6 +61,11 @@ pub fn create_syntax_error(vm: &mut Vm, msg: &str) -> JsValue { create_error_object(vm, proto_ptr, "SyntaxError", msg) } +pub fn create_uri_error(vm: &mut Vm, msg: &str) -> JsValue { + let proto_ptr = P::as_ptr(&vm.session().builtin_world().uri_error_proto) as *mut JsObject; + create_error_object(vm, proto_ptr, "URIError", msg) +} + fn get_msg(vm: &mut Vm, args: &[u8]) -> String { if args.len() > 1 { coercion::to_string(vm.kernel_core().string_forge().as_ref(), vm.reg(args[1])) diff --git a/crates/oxide_vm/src/builtins/global.rs b/crates/oxide_vm/src/builtins/global.rs index 2869bf0..c7c0ace 100644 --- a/crates/oxide_vm/src/builtins/global.rs +++ b/crates/oxide_vm/src/builtins/global.rs @@ -1,131 +1,20 @@ +mod annex_b; +mod uri; + use crate::coercion; -use crate::native::NativeResult; use crate::vm::Vm; -const ESCAPE_SAFE: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@*_+-./"; +pub use annex_b::{js_escape, js_unescape}; +pub use uri::{decode_uri, decode_uri_component, encode_uri, encode_uri_component}; -fn escape_string(input: &str) -> String { - let mut out = String::with_capacity(input.len()); - for ch in input.chars() { - if ESCAPE_SAFE.contains(ch) { - out.push(ch); - continue; - } - - let code = ch as u32; - if code <= 0xFF { - out.push('%'); - out.push_str(&format!("{code:02X}")); - continue; - } - - let mut buf = [0u16; 2]; - for unit in ch.encode_utf16(&mut buf).iter() { - out.push_str(&format!("%u{:04X}", *unit)); - } +fn string_arg(vm: &mut Vm, args: &[u8]) -> String { + if args.len() > 1 { + coercion::to_string(vm.kernel_core().string_forge().as_ref(), vm.reg(args[1])) + } else { + "undefined".to_string() } - out } fn parse_hex_u8(slice: &[u8]) -> Option { std::str::from_utf8(slice).ok().and_then(|s| u8::from_str_radix(s, 16).ok()) } - -fn parse_hex_u16(slice: &[u8]) -> Option { - std::str::from_utf8(slice).ok().and_then(|s| u16::from_str_radix(s, 16).ok()) -} - -fn unescape_string(input: &str) -> String { - let bytes = input.as_bytes(); - let mut out = String::with_capacity(input.len()); - let mut i = 0; - - while i < bytes.len() { - if bytes[i] == b'%' { - if i + 6 <= bytes.len() && bytes[i + 1] == b'u' { - if let Some(unit) = parse_hex_u16(&bytes[i + 2..i + 6]) { - if (0xD800..=0xDBFF).contains(&unit) - && i + 12 <= bytes.len() - && bytes[i + 6] == b'%' - && bytes[i + 7] == b'u' - { - if let Some(low) = parse_hex_u16(&bytes[i + 8..i + 12]) { - if (0xDC00..=0xDFFF).contains(&low) { - let pair = [unit, low]; - let decoded = char::decode_utf16(pair).next(); - match decoded { - Some(Ok(ch)) => out.push(ch), - _ => { - out.push_str(&input[i..i + 12]); - } - } - i += 12; - continue; - } - } - } - - if let Some(ch) = char::from_u32(unit as u32) { - out.push(ch); - } else { - out.push_str(&input[i..i + 6]); - } - i += 6; - continue; - } - } else if i + 3 <= bytes.len() { - if let Some(value) = parse_hex_u8(&bytes[i + 1..i + 3]) { - out.push(value as char); - i += 3; - continue; - } - } - } - - let ch = input[i..].chars().next().expect("valid utf-8 char boundary"); - out.push(ch); - i += ch.len_utf8(); - } - - out -} - -pub fn js_escape(vm: &mut Vm, args: &[u8]) -> NativeResult { - let input = if args.len() > 1 { - coercion::to_string(vm.kernel_core().string_forge().as_ref(), vm.reg(args[1])) - } else { - "undefined".to_string() - }; - NativeResult::Ok(vm.intern(&escape_string(&input))) -} - -pub fn js_unescape(vm: &mut Vm, args: &[u8]) -> NativeResult { - let input = if args.len() > 1 { - coercion::to_string(vm.kernel_core().string_forge().as_ref(), vm.reg(args[1])) - } else { - "undefined".to_string() - }; - NativeResult::Ok(vm.intern(&unescape_string(&input))) -} - -#[cfg(test)] -mod tests { - use super::{escape_string, unescape_string}; - - #[test] - fn escape_encodes_spaces_and_unicode() { - assert_eq!(escape_string("hello world"), "hello%20world"); - assert_eq!(escape_string("AΩ你"), "A%u03A9%u4F60"); - } - - #[test] - fn unescape_decodes_percent_sequences() { - assert_eq!(unescape_string("hello%20world"), "hello world"); - assert_eq!(unescape_string("%u03A9%u4F60"), "Ω你"); - } - - #[test] - fn unescape_keeps_invalid_sequences() { - assert_eq!(unescape_string("%uXYZ1%2G"), "%uXYZ1%2G"); - } -} diff --git a/crates/oxide_vm/src/builtins/global/annex_b.rs b/crates/oxide_vm/src/builtins/global/annex_b.rs new file mode 100644 index 0000000..4aea2a7 --- /dev/null +++ b/crates/oxide_vm/src/builtins/global/annex_b.rs @@ -0,0 +1,118 @@ +use crate::native::NativeResult; +use crate::vm::Vm; + +const ESCAPE_SAFE: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@*_+-./"; + +fn parse_hex_u16(slice: &[u8]) -> Option { + std::str::from_utf8(slice).ok().and_then(|s| u16::from_str_radix(s, 16).ok()) +} + +fn escape_string(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + for ch in input.chars() { + if ESCAPE_SAFE.contains(ch) { + out.push(ch); + continue; + } + + let code = ch as u32; + if code <= 0xFF { + out.push('%'); + out.push_str(&format!("{code:02X}")); + continue; + } + + let mut buf = [0u16; 2]; + for unit in ch.encode_utf16(&mut buf).iter() { + out.push_str(&format!("%u{:04X}", *unit)); + } + } + out +} + +fn unescape_string(input: &str) -> String { + let bytes = input.as_bytes(); + let mut out = String::with_capacity(input.len()); + let mut i = 0; + + while i < bytes.len() { + if bytes[i] == b'%' { + if i + 6 <= bytes.len() && bytes[i + 1] == b'u' { + if let Some(unit) = parse_hex_u16(&bytes[i + 2..i + 6]) { + if (0xD800..=0xDBFF).contains(&unit) + && i + 12 <= bytes.len() + && bytes[i + 6] == b'%' + && bytes[i + 7] == b'u' + { + if let Some(low) = parse_hex_u16(&bytes[i + 8..i + 12]) { + if (0xDC00..=0xDFFF).contains(&low) { + let pair = [unit, low]; + let decoded = char::decode_utf16(pair).next(); + match decoded { + Some(Ok(ch)) => out.push(ch), + _ => { + out.push_str(&input[i..i + 12]); + } + } + i += 12; + continue; + } + } + } + + if let Some(ch) = char::from_u32(unit as u32) { + out.push(ch); + } else { + out.push_str(&input[i..i + 6]); + } + i += 6; + continue; + } + } else if i + 3 <= bytes.len() { + if let Some(value) = super::parse_hex_u8(&bytes[i + 1..i + 3]) { + out.push(value as char); + i += 3; + continue; + } + } + } + + let ch = input[i..].chars().next().expect("valid utf-8 char boundary"); + out.push(ch); + i += ch.len_utf8(); + } + + out +} + +pub fn js_escape(vm: &mut Vm, args: &[u8]) -> NativeResult { + let input = super::string_arg(vm, args); + NativeResult::Ok(vm.intern(&escape_string(&input))) +} + +pub fn js_unescape(vm: &mut Vm, args: &[u8]) -> NativeResult { + let input = super::string_arg(vm, args); + NativeResult::Ok(vm.intern(&unescape_string(&input))) +} + +#[cfg(test)] +mod tests { + use super::{escape_string, unescape_string}; + + #[test] + fn escape_encodes_spaces_and_unicode() { + assert_eq!(escape_string("hello world"), "hello%20world"); + assert_eq!(escape_string("AΩ你"), "A%u03A9%u4F60"); + } + + #[test] + fn unescape_decodes_percent_sequences() { + assert_eq!(unescape_string("hello%20world"), "hello world"); + assert_eq!(unescape_string("%u03A9%u4F60"), "Ω你"); + } + + #[test] + fn unescape_keeps_invalid_sequences() { + assert_eq!(unescape_string("%uXYZ1%2G"), "%uXYZ1%2G"); + } +} diff --git a/crates/oxide_vm/src/builtins/global/uri.rs b/crates/oxide_vm/src/builtins/global/uri.rs new file mode 100644 index 0000000..d2291da --- /dev/null +++ b/crates/oxide_vm/src/builtins/global/uri.rs @@ -0,0 +1,151 @@ +use crate::native::NativeResult; +use crate::vm::Vm; + +const URI_UNESCAPED: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.!~*'()"; +const URI_RESERVED: &str = ";/?:@&=+$,#"; +const URI_SAFE: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.!~*'();/?:@&=+$,#"; +const URI_ERROR_MESSAGE: &str = "malformed URI sequence"; + +fn encode_uri_string(input: &str, safe: &str) -> String { + let mut out = String::with_capacity(input.len()); + for ch in input.chars() { + if ch.is_ascii() && safe.contains(ch) { + out.push(ch); + continue; + } + + let mut buf = [0u8; 4]; + for byte in ch.encode_utf8(&mut buf).as_bytes() { + out.push_str(&format!("%{byte:02X}")); + } + } + out +} + +fn read_percent_byte(bytes: &[u8], pos: usize) -> Result { + if pos + 3 > bytes.len() || bytes[pos] != b'%' { + return Err(()); + } + super::parse_hex_u8(&bytes[pos + 1..pos + 3]).ok_or(()) +} + +fn utf8_sequence_len(first: u8) -> Result { + match first { + 0x00..=0x7F => Ok(1), + 0xC2..=0xDF => Ok(2), + 0xE0..=0xEF => Ok(3), + 0xF0..=0xF4 => Ok(4), + _ => Err(()), + } +} + +fn decode_uri_string(input: &str, preserve_reserved: bool) -> Result { + let bytes = input.as_bytes(); + let mut out = String::with_capacity(input.len()); + let mut i = 0; + + while i < bytes.len() { + if bytes[i] != b'%' { + let ch = input[i..].chars().next().expect("valid utf-8 char boundary"); + out.push(ch); + i += ch.len_utf8(); + continue; + } + + let first = read_percent_byte(bytes, i)?; + let sequence_len = utf8_sequence_len(first)?; + let raw_start = i; + let mut encoded = Vec::with_capacity(sequence_len); + encoded.push(first); + i += 3; + + for _ in 1..sequence_len { + let byte = read_percent_byte(bytes, i)?; + if byte & 0b1100_0000 != 0b1000_0000 { + return Err(()); + } + encoded.push(byte); + i += 3; + } + + let decoded = std::str::from_utf8(&encoded).map_err(|_| ())?; + if preserve_reserved && decoded.len() == 1 { + let ch = decoded.chars().next().expect("single decoded char"); + if URI_RESERVED.contains(ch) { + out.push_str(&input[raw_start..i]); + continue; + } + } + out.push_str(decoded); + } + + Ok(out) +} + +fn uri_error(vm: &mut Vm) -> NativeResult { + NativeResult::Err(crate::builtins::error::create_uri_error(vm, URI_ERROR_MESSAGE)) +} + +pub fn encode_uri(vm: &mut Vm, args: &[u8]) -> NativeResult { + let input = super::string_arg(vm, args); + NativeResult::Ok(vm.intern(&encode_uri_string(&input, URI_SAFE))) +} + +pub fn encode_uri_component(vm: &mut Vm, args: &[u8]) -> NativeResult { + let input = super::string_arg(vm, args); + NativeResult::Ok(vm.intern(&encode_uri_string(&input, URI_UNESCAPED))) +} + +pub fn decode_uri(vm: &mut Vm, args: &[u8]) -> NativeResult { + let input = super::string_arg(vm, args); + match decode_uri_string(&input, true) { + Ok(decoded) => NativeResult::Ok(vm.intern(&decoded)), + Err(()) => uri_error(vm), + } +} + +pub fn decode_uri_component(vm: &mut Vm, args: &[u8]) -> NativeResult { + let input = super::string_arg(vm, args); + match decode_uri_string(&input, false) { + Ok(decoded) => NativeResult::Ok(vm.intern(&decoded)), + Err(()) => uri_error(vm), + } +} + +#[cfg(test)] +mod tests { + use super::{decode_uri_string, encode_uri_string, URI_UNESCAPED}; + + #[test] + fn encode_uri_keeps_reserved_and_encodes_space() { + assert_eq!( + encode_uri_string("https://example.com/path?q=hello world", super::URI_SAFE), + "https://example.com/path?q=hello%20world" + ); + } + + #[test] + fn encode_uri_component_encodes_reserved() { + assert_eq!(encode_uri_string("a=1&b=2", URI_UNESCAPED), "a%3D1%26b%3D2"); + } + + #[test] + fn decode_uri_preserves_reserved_escapes() { + assert_eq!( + decode_uri_string("https://example.com/path%3Fq=hello%20world", true).unwrap(), + "https://example.com/path%3Fq=hello world" + ); + } + + #[test] + fn decode_uri_component_decodes_reserved() { + assert_eq!(decode_uri_string("a%3D1%26b%3D2", false).unwrap(), "a=1&b=2"); + } + + #[test] + fn decode_uri_rejects_malformed_sequences() { + assert!(decode_uri_string("%", false).is_err()); + assert!(decode_uri_string("%E0%A4", false).is_err()); + assert!(decode_uri_string("%ED%A0%80", false).is_err()); + } +} diff --git a/crates/oxide_vm/tests/builtins_globals_tests.rs b/crates/oxide_vm/tests/builtins_globals_tests.rs index dfbc85a..4f3aadc 100644 --- a/crates/oxide_vm/tests/builtins_globals_tests.rs +++ b/crates/oxide_vm/tests/builtins_globals_tests.rs @@ -1,8 +1,9 @@ use oxide_compiler::compiler::Compiler; use oxide_parser::Allocator; +use oxide_types::value::JsValue; use oxide_vm::vm::Vm; -fn eval(source: &str) -> Result { +fn eval(source: &str) -> Result { let allocator = Allocator::default(); let program = oxide_parser::parse(&allocator, source).map_err(|e| format!("Parse error: {:?}", e))?; let module = Compiler::new().compile(&program).map_err(|e| format!("Compile error: {}", e))?; @@ -10,6 +11,22 @@ fn eval(source: &str) -> Result { vm.run(&module) } +fn eval_with_vm(source: &str) -> (Vm, Result) { + let allocator = Allocator::default(); + let program = oxide_parser::parse(&allocator, source).expect("parse"); + let module = Compiler::new().compile(&program).expect("compile"); + let mut vm = Vm::new(); + let result = vm.run(&module); + (vm, result) +} + +fn str_val(vm: &Vm, val: JsValue) -> String { + vm.kernel_core() + .string_forge() + .lookup(val.as_string_index()) + .unwrap_or_default() +} + #[test] fn eval_nan_returns_nan() { let result = eval("NaN").unwrap(); @@ -83,3 +100,34 @@ fn global_this_points_to_global_object() { assert!(result.is_bool()); assert!(result.as_bool()); } + +#[test] +fn encode_uri_preserves_reserved_chars() { + let (vm, result) = eval_with_vm("encodeURI('https://example.com/path?q=hello world')"); + assert_eq!(str_val(&vm, result.unwrap()), "https://example.com/path?q=hello%20world"); +} + +#[test] +fn encode_uri_component_encodes_reserved_chars() { + let (vm, result) = eval_with_vm("encodeURIComponent('a=1&b=2')"); + assert_eq!(str_val(&vm, result.unwrap()), "a%3D1%26b%3D2"); +} + +#[test] +fn decode_uri_preserves_escaped_reserved_chars() { + let (vm, result) = eval_with_vm("decodeURI('https://example.com/path%3Fq=hello%20world')"); + assert_eq!(str_val(&vm, result.unwrap()), "https://example.com/path%3Fq=hello world"); +} + +#[test] +fn decode_uri_component_decodes_reserved_chars() { + let (vm, result) = eval_with_vm("decodeURIComponent('a%3D1%26b%3D2')"); + assert_eq!(str_val(&vm, result.unwrap()), "a=1&b=2"); +} + +#[test] +fn decode_uri_component_malformed_sequence_throws_uri_error() { + let result = eval("try { decodeURIComponent('%') } catch (e) { e instanceof URIError }").unwrap(); + assert!(result.is_bool()); + assert!(result.as_bool()); +}