diff --git a/crates/browser/src/lib.rs b/crates/browser/src/lib.rs index 6a1f6e8..f761083 100644 --- a/crates/browser/src/lib.rs +++ b/crates/browser/src/lib.rs @@ -6,3 +6,4 @@ pub mod font_loader; pub mod img_loader; pub mod loader; pub mod script_loader; +pub mod storage; diff --git a/crates/browser/src/storage.rs b/crates/browser/src/storage.rs new file mode 100644 index 0000000..83d88bb --- /dev/null +++ b/crates/browser/src/storage.rs @@ -0,0 +1,163 @@ +//! Web Storage persistence: loads and saves localStorage data to disk. +//! +//! Each origin gets its own file under `~/.we/storage/`. The file format is +//! the simple binary encoding from [`we_js::storage::StorageArea`]. + +use std::fs; +use std::path::PathBuf; +use we_js::storage::StorageArea; +use we_url::Origin; + +/// Manages on-disk persistence of `localStorage` data. +pub struct StorageManager { + storage_dir: PathBuf, +} + +impl Default for StorageManager { + fn default() -> Self { + Self::new() + } +} + +impl StorageManager { + /// Create a manager using the default directory (`~/.we/storage/`). + pub fn new() -> Self { + let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); + let storage_dir = PathBuf::from(home).join(".we").join("storage"); + Self { storage_dir } + } + + /// Create a manager backed by a custom directory (useful for tests). + pub fn with_dir(dir: PathBuf) -> Self { + Self { storage_dir: dir } + } + + /// Derive a safe filename from an origin. + fn file_path(&self, origin: &Origin) -> Option { + match origin { + Origin::Opaque => None, + Origin::Tuple(..) => { + // Sanitize the serialized origin into a filesystem-safe name. + let serialized = origin.serialize(); + let safe: String = serialized + .chars() + .map(|c| match c { + '/' | ':' | '?' | '#' | '\\' | '*' | '"' | '<' | '>' | '|' => '_', + c => c, + }) + .collect(); + Some(self.storage_dir.join(safe)) + } + } + } + + /// Load a `StorageArea` from disk for the given origin. + /// Returns an empty area if the file doesn't exist or is corrupt. + pub fn load(&self, origin: &Origin) -> StorageArea { + let path = match self.file_path(origin) { + Some(p) => p, + None => return StorageArea::new(), + }; + match fs::read(&path) { + Ok(data) => StorageArea::deserialize(&data).unwrap_or_default(), + Err(_) => StorageArea::new(), + } + } + + /// Save a `StorageArea` to disk for the given origin. + pub fn save(&self, origin: &Origin, area: &StorageArea) { + let path = match self.file_path(origin) { + Some(p) => p, + None => return, + }; + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } + let data = area.serialize(); + let _ = fs::write(&path, data); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roundtrip_persistence() { + let dir = std::env::temp_dir().join("we_storage_test_roundtrip"); + let _ = fs::remove_dir_all(&dir); + let mgr = StorageManager::with_dir(dir.clone()); + + let origin = Origin::Tuple( + "https".to_string(), + we_url::Host::Domain("example.com".to_string()), + None, + ); + + // Start empty. + let area = mgr.load(&origin); + assert_eq!(area.length(), 0); + + // Save some data. + let mut area = StorageArea::new(); + area.set_item("key", "value").unwrap(); + area.set_item("foo", "bar").unwrap(); + mgr.save(&origin, &area); + + // Load it back. + let loaded = mgr.load(&origin); + assert_eq!(loaded.length(), 2); + assert_eq!(loaded.get_item("key"), Some("value")); + assert_eq!(loaded.get_item("foo"), Some("bar")); + + // Clean up. + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn opaque_origin_gets_no_storage() { + let dir = std::env::temp_dir().join("we_storage_test_opaque"); + let _ = fs::remove_dir_all(&dir); + let mgr = StorageManager::with_dir(dir.clone()); + + let mut area = StorageArea::new(); + area.set_item("a", "b").unwrap(); + mgr.save(&Origin::Opaque, &area); + + let loaded = mgr.load(&Origin::Opaque); + assert_eq!(loaded.length(), 0); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn different_origins_isolated() { + let dir = std::env::temp_dir().join("we_storage_test_isolation"); + let _ = fs::remove_dir_all(&dir); + let mgr = StorageManager::with_dir(dir.clone()); + + let origin_a = Origin::Tuple( + "https".to_string(), + we_url::Host::Domain("a.com".to_string()), + None, + ); + let origin_b = Origin::Tuple( + "https".to_string(), + we_url::Host::Domain("b.com".to_string()), + None, + ); + + let mut area_a = StorageArea::new(); + area_a.set_item("shared_key", "from_a").unwrap(); + mgr.save(&origin_a, &area_a); + + let mut area_b = StorageArea::new(); + area_b.set_item("shared_key", "from_b").unwrap(); + mgr.save(&origin_b, &area_b); + + assert_eq!(mgr.load(&origin_a).get_item("shared_key"), Some("from_a")); + assert_eq!(mgr.load(&origin_b).get_item("shared_key"), Some("from_b")); + + let _ = fs::remove_dir_all(&dir); + } +} diff --git a/crates/js/src/dom_bridge.rs b/crates/js/src/dom_bridge.rs index 7ac5236..df6f432 100644 --- a/crates/js/src/dom_bridge.rs +++ b/crates/js/src/dom_bridge.rs @@ -2205,6 +2205,326 @@ fn extract_domain_from_origin(origin: &str) -> String { } } +// ── Web Storage (localStorage / sessionStorage) ──────────────────── + +/// Internal property key that marks an object as a Storage proxy. +/// Value: `"local"` or `"session"`. +const STORAGE_TYPE_KEY: &str = "__storage_type__"; + +/// Built-in method names on Storage objects. Property proxy access should +/// NOT intercept these keys, so that `storage.getItem` returns the method +/// rather than calling `getItem("getItem")`. +const STORAGE_BUILTIN_KEYS: &[&str] = &[ + "getItem", + "setItem", + "removeItem", + "clear", + "key", + "length", + STORAGE_TYPE_KEY, +]; + +/// Check whether `gc_ref` is a Storage proxy object and return the type. +fn storage_type(gc: &Gc, gc_ref: GcRef) -> Option { + if let Some(HeapObject::Object(data)) = gc.get(gc_ref) { + if let Some(prop) = data.properties.get(STORAGE_TYPE_KEY) { + if let Value::String(s) = &prop.value { + return Some(s.clone()); + } + } + } + None +} + +/// Resolve a property get on a Storage proxy object. +/// +/// Handles `storage.length` (dynamic) and `storage[key]` → `getItem(key)`. +/// Returns `None` if `gc_ref` is not a Storage object or the key is a +/// built-in method. +pub fn resolve_storage_get( + gc: &Gc, + bridge: &Rc, + gc_ref: GcRef, + key: &str, +) -> Option { + let stype = storage_type(gc, gc_ref)?; + + // "length" is always dynamic — return the current item count. + if key == "length" { + let len = if stype == "local" { + bridge.local_storage.borrow().length() + } else { + bridge.session_storage.borrow().length() + }; + return Some(Value::Number(len as f64)); + } + + // Don't intercept built-in method names. + if STORAGE_BUILTIN_KEYS.contains(&key) { + return None; + } + + // Proxy: treat as getItem(key). + let val = if stype == "local" { + bridge + .local_storage + .borrow() + .get_item(key) + .map(String::from) + } else { + bridge + .session_storage + .borrow() + .get_item(key) + .map(String::from) + }; + + Some(match val { + Some(v) => Value::String(v), + None => Value::Null, + }) +} + +/// Handle a property set on a Storage proxy object. +/// +/// Intercepts `storage[key] = value` → `setItem(key, value)`. +/// Returns `true` if the set was handled. +pub fn handle_storage_set( + bridge: &Rc, + gc_ref: GcRef, + key: &str, + val: &Value, + gc: &Gc, +) -> bool { + let stype = match storage_type(gc, gc_ref) { + Some(s) => s, + None => return false, + }; + + // Don't intercept built-in method/property names. + if STORAGE_BUILTIN_KEYS.contains(&key) { + return false; + } + + let value_str = match val { + Value::String(s) => s.clone(), + other => other.to_string(), + }; + + if stype == "local" { + // Ignore quota errors on proxy set (mirrors browser behavior). + let _ = bridge.local_storage.borrow_mut().set_item(key, &value_str); + } else { + let _ = bridge + .session_storage + .borrow_mut() + .set_item(key, &value_str); + } + true +} + +// ── Storage native callbacks ─────────────────────────────────────── + +fn storage_get_item(args: &[Value], ctx: &mut NativeContext) -> Result { + let bridge = ctx + .dom_bridge + .ok_or_else(|| RuntimeError::type_error("no document attached"))?; + let stype = storage_type( + ctx.gc, + match &ctx.this { + Value::Object(r) => *r, + _ => return Err(RuntimeError::type_error("getItem called on non-Storage")), + }, + ) + .ok_or_else(|| RuntimeError::type_error("getItem called on non-Storage"))?; + + let key = args + .first() + .map(|v| v.to_js_string(ctx.gc)) + .unwrap_or_default(); + + let result = if stype == "local" { + bridge + .local_storage + .borrow() + .get_item(&key) + .map(String::from) + } else { + bridge + .session_storage + .borrow() + .get_item(&key) + .map(String::from) + }; + + Ok(match result { + Some(v) => Value::String(v), + None => Value::Null, + }) +} + +fn storage_set_item(args: &[Value], ctx: &mut NativeContext) -> Result { + let bridge = ctx + .dom_bridge + .ok_or_else(|| RuntimeError::type_error("no document attached"))?; + let stype = storage_type( + ctx.gc, + match &ctx.this { + Value::Object(r) => *r, + _ => return Err(RuntimeError::type_error("setItem called on non-Storage")), + }, + ) + .ok_or_else(|| RuntimeError::type_error("setItem called on non-Storage"))?; + + let key = args + .first() + .map(|v| v.to_js_string(ctx.gc)) + .unwrap_or_default(); + let value = args + .get(1) + .map(|v| v.to_js_string(ctx.gc)) + .unwrap_or_default(); + + let result = if stype == "local" { + bridge.local_storage.borrow_mut().set_item(&key, &value) + } else { + bridge.session_storage.borrow_mut().set_item(&key, &value) + }; + + if result.is_err() { + return Err(RuntimeError { + kind: ErrorKind::Error, + message: "QuotaExceededError: storage quota exceeded".to_string(), + }); + } + + Ok(Value::Undefined) +} + +fn storage_remove_item(args: &[Value], ctx: &mut NativeContext) -> Result { + let bridge = ctx + .dom_bridge + .ok_or_else(|| RuntimeError::type_error("no document attached"))?; + let stype = storage_type( + ctx.gc, + match &ctx.this { + Value::Object(r) => *r, + _ => return Err(RuntimeError::type_error("removeItem called on non-Storage")), + }, + ) + .ok_or_else(|| RuntimeError::type_error("removeItem called on non-Storage"))?; + + let key = args + .first() + .map(|v| v.to_js_string(ctx.gc)) + .unwrap_or_default(); + + if stype == "local" { + bridge.local_storage.borrow_mut().remove_item(&key); + } else { + bridge.session_storage.borrow_mut().remove_item(&key); + } + + Ok(Value::Undefined) +} + +fn storage_clear(_args: &[Value], ctx: &mut NativeContext) -> Result { + let bridge = ctx + .dom_bridge + .ok_or_else(|| RuntimeError::type_error("no document attached"))?; + let stype = storage_type( + ctx.gc, + match &ctx.this { + Value::Object(r) => *r, + _ => return Err(RuntimeError::type_error("clear called on non-Storage")), + }, + ) + .ok_or_else(|| RuntimeError::type_error("clear called on non-Storage"))?; + + if stype == "local" { + bridge.local_storage.borrow_mut().clear(); + } else { + bridge.session_storage.borrow_mut().clear(); + } + + Ok(Value::Undefined) +} + +fn storage_key(args: &[Value], ctx: &mut NativeContext) -> Result { + let bridge = ctx + .dom_bridge + .ok_or_else(|| RuntimeError::type_error("no document attached"))?; + let stype = storage_type( + ctx.gc, + match &ctx.this { + Value::Object(r) => *r, + _ => return Err(RuntimeError::type_error("key called on non-Storage")), + }, + ) + .ok_or_else(|| RuntimeError::type_error("key called on non-Storage"))?; + + let index = match args.first() { + Some(Value::Number(n)) => *n as usize, + _ => return Ok(Value::Null), + }; + + let result = if stype == "local" { + bridge.local_storage.borrow().key(index).map(String::from) + } else { + bridge.session_storage.borrow().key(index).map(String::from) + }; + + Ok(match result { + Some(k) => Value::String(k), + None => Value::Null, + }) +} + +/// Create a Storage JS object (used for both localStorage and sessionStorage). +fn create_storage_object( + gc: &mut Gc, + storage_type_str: &str, + object_proto: Option, +) -> GcRef { + let mut data = ObjectData::new(); + if let Some(proto) = object_proto { + data.prototype = Some(proto); + } + + // Mark this object as a Storage proxy. + data.properties.insert( + STORAGE_TYPE_KEY.to_string(), + Property::builtin(Value::String(storage_type_str.to_string())), + ); + + let obj_ref = gc.alloc(HeapObject::Object(data)); + + // Register methods. + let methods: &[NativeMethod] = &[ + ("getItem", storage_get_item), + ("setItem", storage_set_item), + ("removeItem", storage_remove_item), + ("clear", storage_clear), + ("key", storage_key), + ]; + for &(name, callback) in methods { + let func = make_native(gc, name, callback); + set_builtin_prop(gc, obj_ref, name, Value::Function(func)); + } + + obj_ref +} + +/// Register `localStorage` and `sessionStorage` globals on the VM. +/// Called from `Vm::attach_document`. +pub fn init_storage_objects(vm: &mut Vm) { + let local_ref = create_storage_object(&mut vm.gc, "local", vm.object_prototype); + vm.set_global("localStorage", Value::Object(local_ref)); + + let session_ref = create_storage_object(&mut vm.gc, "session", vm.object_prototype); + vm.set_global("sessionStorage", Value::Object(session_ref)); +} + // ── Tests ─────────────────────────────────────────────────────────── #[cfg(test)] @@ -3523,4 +3843,265 @@ mod tests { v => panic!("expected 'example.com', got {v:?}"), } } + + // ── Web Storage tests ────────────────────────────────────── + + #[test] + fn test_localstorage_exists() { + let result = eval_with_doc("", "typeof localStorage").unwrap(); + match result { + Value::String(s) => assert_eq!(s, "object"), + v => panic!("expected 'object', got {v:?}"), + } + } + + #[test] + fn test_sessionstorage_exists() { + let result = eval_with_doc("", "typeof sessionStorage").unwrap(); + match result { + Value::String(s) => assert_eq!(s, "object"), + v => panic!("expected 'object', got {v:?}"), + } + } + + #[test] + fn test_localstorage_set_get_item() { + let result = eval_with_doc( + "", + r#" + localStorage.setItem("key", "value"); + localStorage.getItem("key") + "#, + ) + .unwrap(); + match result { + Value::String(s) => assert_eq!(s, "value"), + v => panic!("expected 'value', got {v:?}"), + } + } + + #[test] + fn test_localstorage_get_nonexistent() { + let result = eval_with_doc( + "", + r#"localStorage.getItem("nonexistent")"#, + ) + .unwrap(); + assert!(matches!(result, Value::Null)); + } + + #[test] + fn test_localstorage_remove_item() { + let result = eval_with_doc( + "", + r#" + localStorage.setItem("x", "1"); + localStorage.removeItem("x"); + localStorage.getItem("x") + "#, + ) + .unwrap(); + assert!(matches!(result, Value::Null)); + } + + #[test] + fn test_localstorage_clear() { + let result = eval_with_doc( + "", + r#" + localStorage.setItem("a", "1"); + localStorage.setItem("b", "2"); + localStorage.clear(); + localStorage.length + "#, + ) + .unwrap(); + match result { + Value::Number(n) => assert_eq!(n, 0.0), + v => panic!("expected 0, got {v:?}"), + } + } + + #[test] + fn test_localstorage_length() { + let result = eval_with_doc( + "", + r#" + localStorage.setItem("a", "1"); + localStorage.setItem("b", "2"); + localStorage.setItem("c", "3"); + localStorage.length + "#, + ) + .unwrap(); + match result { + Value::Number(n) => assert_eq!(n, 3.0), + v => panic!("expected 3, got {v:?}"), + } + } + + #[test] + fn test_localstorage_key() { + let result = eval_with_doc( + "", + r#" + localStorage.setItem("first", "1"); + localStorage.setItem("second", "2"); + localStorage.key(0) + "#, + ) + .unwrap(); + match result { + Value::String(s) => assert_eq!(s, "first"), + v => panic!("expected 'first', got {v:?}"), + } + } + + #[test] + fn test_localstorage_key_out_of_bounds() { + let result = eval_with_doc( + "", + r#" + localStorage.setItem("a", "1"); + localStorage.key(99) + "#, + ) + .unwrap(); + assert!(matches!(result, Value::Null)); + } + + #[test] + fn test_localstorage_proxy_get() { + let result = eval_with_doc( + "", + r#" + localStorage.setItem("myKey", "myValue"); + localStorage["myKey"] + "#, + ) + .unwrap(); + match result { + Value::String(s) => assert_eq!(s, "myValue"), + v => panic!("expected 'myValue', got {v:?}"), + } + } + + #[test] + fn test_localstorage_proxy_set() { + let result = eval_with_doc( + "", + r#" + localStorage["myKey"] = "myValue"; + localStorage.getItem("myKey") + "#, + ) + .unwrap(); + match result { + Value::String(s) => assert_eq!(s, "myValue"), + v => panic!("expected 'myValue', got {v:?}"), + } + } + + #[test] + fn test_localstorage_proxy_get_nonexistent() { + let result = eval_with_doc( + "", + r#"localStorage["nonexistent"]"#, + ) + .unwrap(); + assert!(matches!(result, Value::Null)); + } + + #[test] + fn test_localstorage_overwrite() { + let result = eval_with_doc( + "", + r#" + localStorage.setItem("k", "v1"); + localStorage.setItem("k", "v2"); + var r = localStorage.getItem("k") + "," + localStorage.length; + r + "#, + ) + .unwrap(); + match result { + Value::String(s) => assert_eq!(s, "v2,1"), + v => panic!("expected 'v2,1', got {v:?}"), + } + } + + #[test] + fn test_sessionstorage_set_get() { + let result = eval_with_doc( + "", + r#" + sessionStorage.setItem("s", "session_val"); + sessionStorage.getItem("s") + "#, + ) + .unwrap(); + match result { + Value::String(s) => assert_eq!(s, "session_val"), + v => panic!("expected 'session_val', got {v:?}"), + } + } + + #[test] + fn test_local_and_session_are_separate() { + let result = eval_with_doc( + "", + r#" + localStorage.setItem("shared", "local_val"); + sessionStorage.setItem("shared", "session_val"); + localStorage.getItem("shared") + "|" + sessionStorage.getItem("shared") + "#, + ) + .unwrap(); + match result { + Value::String(s) => assert_eq!(s, "local_val|session_val"), + v => panic!("expected 'local_val|session_val', got {v:?}"), + } + } + + #[test] + fn test_localstorage_methods_are_functions() { + let result = + eval_with_doc("", "typeof localStorage.getItem").unwrap(); + match result { + Value::String(s) => assert_eq!(s, "function"), + v => panic!("expected 'function', got {v:?}"), + } + } + + #[test] + fn test_localstorage_set_take_roundtrip() { + // Verify that set/take local storage works via the VM API. + let doc = doc_from_html(""); + let mut vm = Vm::new(); + vm.attach_document(doc); + + // Pre-populate storage. + let mut area = crate::storage::StorageArea::new(); + area.set_item("preloaded", "yes").unwrap(); + vm.set_local_storage(area); + + // Read it via JS. + let program = Parser::parse(r#"localStorage.getItem("preloaded")"#).expect("parse failed"); + let func = compiler::compile(&program).expect("compile failed"); + let result = vm.execute(&func).unwrap(); + match result { + Value::String(s) => assert_eq!(s, "yes"), + v => panic!("expected 'yes', got {v:?}"), + } + + // Write via JS and take back. + let program = + Parser::parse(r#"localStorage.setItem("added", "byjs")"#).expect("parse failed"); + let func = compiler::compile(&program).expect("compile failed"); + vm.execute(&func).unwrap(); + + let taken = vm.take_local_storage().unwrap(); + assert_eq!(taken.get_item("preloaded"), Some("yes")); + assert_eq!(taken.get_item("added"), Some("byjs")); + } } diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 8569e2d..630f7b4 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -10,6 +10,7 @@ pub mod gc; pub mod lexer; pub mod parser; pub mod regex; +pub mod storage; pub mod timers; pub mod vm; diff --git a/crates/js/src/storage.rs b/crates/js/src/storage.rs new file mode 100644 index 0000000..e05f7b4 --- /dev/null +++ b/crates/js/src/storage.rs @@ -0,0 +1,295 @@ +//! Web Storage API: in-memory storage area with quota enforcement. +//! +//! Provides the `StorageArea` type used by both `localStorage` and +//! `sessionStorage`. Persistence (for localStorage) is handled by the +//! browser crate's `StorageManager`. + +use std::collections::HashMap; + +/// Maximum storage per origin: 5 MB (counted in UTF-16 code units × 2 bytes). +const QUOTA_BYTES: usize = 5 * 1024 * 1024; + +/// Errors that can occur during storage operations. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StorageError { + /// The 5 MB per-origin quota would be exceeded. + QuotaExceeded, +} + +/// A single origin's storage area (used for both localStorage and sessionStorage). +#[derive(Debug, Clone)] +pub struct StorageArea { + /// Key-value data. + data: HashMap, + /// Insertion-order key list (for the `key(index)` method). + keys: Vec, + /// Current size in bytes (UTF-16 code units × 2). + size: usize, +} + +impl Default for StorageArea { + fn default() -> Self { + Self::new() + } +} + +impl StorageArea { + pub fn new() -> Self { + Self { + data: HashMap::new(), + keys: Vec::new(), + size: 0, + } + } + + /// Number of key-value pairs. + pub fn length(&self) -> usize { + self.data.len() + } + + /// Return the key at the given index (insertion order), or `None`. + pub fn key(&self, index: usize) -> Option<&str> { + self.keys.get(index).map(|s| s.as_str()) + } + + /// Retrieve the value for `key`, or `None` if not present. + pub fn get_item(&self, key: &str) -> Option<&str> { + self.data.get(key).map(|s| s.as_str()) + } + + /// Set a key-value pair. Returns `Err(QuotaExceeded)` if the 5 MB limit + /// would be exceeded. + pub fn set_item(&mut self, key: &str, value: &str) -> Result<(), StorageError> { + let new_entry_size = Self::entry_size(key, value); + let old_entry_size = self + .data + .get(key) + .map(|v| Self::entry_size(key, v)) + .unwrap_or(0); + let new_total = self.size - old_entry_size + new_entry_size; + + if new_total > QUOTA_BYTES { + return Err(StorageError::QuotaExceeded); + } + + if !self.data.contains_key(key) { + self.keys.push(key.to_string()); + } + self.data.insert(key.to_string(), value.to_string()); + self.size = new_total; + Ok(()) + } + + /// Remove a key-value pair. No-op if the key does not exist. + pub fn remove_item(&mut self, key: &str) { + if let Some(value) = self.data.remove(key) { + self.size -= Self::entry_size(key, &value); + self.keys.retain(|k| k != key); + } + } + + /// Remove all key-value pairs. + pub fn clear(&mut self) { + self.data.clear(); + self.keys.clear(); + self.size = 0; + } + + /// Size of one entry in bytes: (key + value) counted as UTF-16 code units × 2. + fn entry_size(key: &str, value: &str) -> usize { + (key.encode_utf16().count() + value.encode_utf16().count()) * 2 + } + + /// Serialize to a simple binary format for on-disk persistence. + /// + /// Format: `count:u32` then for each entry `key_len:u32 key_bytes val_len:u32 val_bytes`. + /// All integers are little-endian. + pub fn serialize(&self) -> Vec { + let mut buf = Vec::new(); + buf.extend_from_slice(&(self.keys.len() as u32).to_le_bytes()); + for key in &self.keys { + let value = &self.data[key]; + let kb = key.as_bytes(); + let vb = value.as_bytes(); + buf.extend_from_slice(&(kb.len() as u32).to_le_bytes()); + buf.extend_from_slice(kb); + buf.extend_from_slice(&(vb.len() as u32).to_le_bytes()); + buf.extend_from_slice(vb); + } + buf + } + + /// Deserialize from the binary format produced by [`serialize`]. + pub fn deserialize(data: &[u8]) -> Option { + let mut pos = 0; + if data.len() < 4 { + return None; + } + let count = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?) as usize; + pos += 4; + + let mut area = StorageArea::new(); + for _ in 0..count { + if pos + 4 > data.len() { + return None; + } + let key_len = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?) as usize; + pos += 4; + if pos + key_len > data.len() { + return None; + } + let key = std::str::from_utf8(&data[pos..pos + key_len]).ok()?; + pos += key_len; + + if pos + 4 > data.len() { + return None; + } + let val_len = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?) as usize; + pos += 4; + if pos + val_len > data.len() { + return None; + } + let value = std::str::from_utf8(&data[pos..pos + val_len]).ok()?; + pos += val_len; + + // Ignore quota errors during deserialization (data already on disk). + let _ = area.set_item(key, value); + } + Some(area) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn basic_get_set_remove() { + let mut s = StorageArea::new(); + assert_eq!(s.length(), 0); + assert_eq!(s.get_item("foo"), None); + + s.set_item("foo", "bar").unwrap(); + assert_eq!(s.length(), 1); + assert_eq!(s.get_item("foo"), Some("bar")); + + s.set_item("foo", "baz").unwrap(); + assert_eq!(s.length(), 1); + assert_eq!(s.get_item("foo"), Some("baz")); + + s.remove_item("foo"); + assert_eq!(s.length(), 0); + assert_eq!(s.get_item("foo"), None); + } + + #[test] + fn key_ordering() { + let mut s = StorageArea::new(); + s.set_item("c", "3").unwrap(); + s.set_item("a", "1").unwrap(); + s.set_item("b", "2").unwrap(); + assert_eq!(s.key(0), Some("c")); + assert_eq!(s.key(1), Some("a")); + assert_eq!(s.key(2), Some("b")); + assert_eq!(s.key(3), None); + } + + #[test] + fn clear() { + let mut s = StorageArea::new(); + s.set_item("a", "1").unwrap(); + s.set_item("b", "2").unwrap(); + s.clear(); + assert_eq!(s.length(), 0); + assert_eq!(s.get_item("a"), None); + } + + #[test] + fn quota_exceeded() { + let mut s = StorageArea::new(); + // Create a string that's about 2.5 MB in UTF-16 (1.25M chars × 2 bytes) + let big_value: String = "x".repeat(1_250_000); + s.set_item("a", &big_value).unwrap(); + s.set_item("b", &big_value).unwrap(); + // Third should exceed the 5 MB quota. + assert_eq!( + s.set_item("c", &big_value), + Err(StorageError::QuotaExceeded) + ); + } + + #[test] + fn serialize_deserialize_roundtrip() { + let mut s = StorageArea::new(); + s.set_item("hello", "world").unwrap(); + s.set_item("foo", "bar").unwrap(); + s.set_item("empty", "").unwrap(); + + let bytes = s.serialize(); + let s2 = StorageArea::deserialize(&bytes).unwrap(); + assert_eq!(s2.length(), 3); + assert_eq!(s2.get_item("hello"), Some("world")); + assert_eq!(s2.get_item("foo"), Some("bar")); + assert_eq!(s2.get_item("empty"), Some("")); + // Insertion order preserved. + assert_eq!(s2.key(0), Some("hello")); + assert_eq!(s2.key(1), Some("foo")); + assert_eq!(s2.key(2), Some("empty")); + } + + #[test] + fn deserialize_empty() { + let s = StorageArea::new(); + let bytes = s.serialize(); + let s2 = StorageArea::deserialize(&bytes).unwrap(); + assert_eq!(s2.length(), 0); + } + + #[test] + fn deserialize_invalid() { + assert!(StorageArea::deserialize(&[]).is_none()); + assert!(StorageArea::deserialize(&[0xFF]).is_none()); + } + + #[test] + fn remove_updates_key_index() { + let mut s = StorageArea::new(); + s.set_item("a", "1").unwrap(); + s.set_item("b", "2").unwrap(); + s.set_item("c", "3").unwrap(); + s.remove_item("b"); + assert_eq!(s.length(), 2); + assert_eq!(s.key(0), Some("a")); + assert_eq!(s.key(1), Some("c")); + } + + #[test] + fn update_does_not_duplicate_key() { + let mut s = StorageArea::new(); + s.set_item("a", "1").unwrap(); + s.set_item("a", "2").unwrap(); + assert_eq!(s.length(), 1); + assert_eq!(s.key(0), Some("a")); + assert_eq!(s.get_item("a"), Some("2")); + } + + #[test] + fn unicode_keys_and_values() { + let mut s = StorageArea::new(); + s.set_item("キー", "値").unwrap(); + assert_eq!(s.get_item("キー"), Some("値")); + assert_eq!(s.length(), 1); + + let bytes = s.serialize(); + let s2 = StorageArea::deserialize(&bytes).unwrap(); + assert_eq!(s2.get_item("キー"), Some("値")); + } + + #[test] + fn remove_nonexistent_is_noop() { + let mut s = StorageArea::new(); + s.set_item("a", "1").unwrap(); + s.remove_item("nonexistent"); + assert_eq!(s.length(), 1); + } +} diff --git a/crates/js/src/vm.rs b/crates/js/src/vm.rs index dc24b24..ac66648 100644 --- a/crates/js/src/vm.rs +++ b/crates/js/src/vm.rs @@ -244,6 +244,10 @@ pub struct DomBridge { pub cookie_jar: RefCell, /// The URL of the current document, used for cookie domain/path matching. pub document_url: RefCell>, + /// localStorage area for the current origin. + pub local_storage: RefCell, + /// sessionStorage area for the current browsing context. + pub session_storage: RefCell, } /// Context passed to native functions, providing GC access and `this` binding. @@ -850,10 +854,13 @@ impl Vm { origin: RefCell::new(String::new()), cookie_jar: RefCell::new(we_net::cookie::CookieJar::new()), document_url: RefCell::new(None), + local_storage: RefCell::new(crate::storage::StorageArea::new()), + session_storage: RefCell::new(crate::storage::StorageArea::new()), }); self.dom_bridge = Some(bridge); crate::dom_bridge::init_document_object(self); crate::dom_bridge::init_event_system(self); + crate::dom_bridge::init_storage_objects(self); } /// Set the document origin for Same-Origin Policy enforcement. @@ -888,6 +895,38 @@ impl Vm { .map(|bridge| bridge.cookie_jar.replace(we_net::cookie::CookieJar::new())) } + /// Set the localStorage area (typically loaded from disk by the browser). + pub fn set_local_storage(&mut self, area: crate::storage::StorageArea) { + if let Some(bridge) = &self.dom_bridge { + *bridge.local_storage.borrow_mut() = area; + } + } + + /// Take the localStorage area from the DOM bridge (to persist to disk). + pub fn take_local_storage(&mut self) -> Option { + self.dom_bridge.as_ref().map(|bridge| { + bridge + .local_storage + .replace(crate::storage::StorageArea::new()) + }) + } + + /// Set the sessionStorage area. + pub fn set_session_storage(&mut self, area: crate::storage::StorageArea) { + if let Some(bridge) = &self.dom_bridge { + *bridge.session_storage.borrow_mut() = area; + } + } + + /// Take the sessionStorage area from the DOM bridge. + pub fn take_session_storage(&mut self) -> Option { + self.dom_bridge.as_ref().map(|bridge| { + bridge + .session_storage + .replace(crate::storage::StorageArea::new()) + }) + } + /// Detach the DOM document from the VM, returning it. /// /// This removes the `document` global and disconnects the DOM bridge. @@ -2123,6 +2162,10 @@ impl Vm { /// Returns `Some(value)` if the key is a recognized DOM property, `None` otherwise. fn resolve_dom_property(&mut self, gc_ref: GcRef, key: &str) -> Option { let bridge = Rc::clone(self.dom_bridge.as_ref()?); + // Try Storage proxy properties first. + if let Some(val) = crate::dom_bridge::resolve_storage_get(&self.gc, &bridge, gc_ref, key) { + return Some(val); + } // Try node wrapper properties first. if let Some(val) = crate::dom_bridge::resolve_dom_get(&mut self.gc, &bridge, gc_ref, key) { return Some(val); @@ -2135,6 +2178,10 @@ impl Vm { /// Returns `true` if the property was handled (caller should skip normal set). fn handle_dom_property_set(&mut self, gc_ref: GcRef, key: &str, val: &Value) -> bool { if let Some(bridge) = self.dom_bridge.clone() { + // Check for Storage proxy sets (localStorage["key"] = "val"). + if crate::dom_bridge::handle_storage_set(&bridge, gc_ref, key, val, &self.gc) { + return true; + } // Check for document-level dynamic properties (e.g. document.cookie). if crate::dom_bridge::handle_document_set(&bridge, gc_ref, key, val, &self.gc) { return true;