diff --git a/lp-app/lpa-fs-opfs/src/opfs_read.rs b/lp-app/lpa-fs-opfs/src/opfs_read.rs index 59e1da03e..1c70e7d62 100644 --- a/lp-app/lpa-fs-opfs/src/opfs_read.rs +++ b/lp-app/lpa-fs-opfs/src/opfs_read.rs @@ -62,8 +62,13 @@ pub async fn load_tree_filtered( } FileSystemHandleKind::File => { let file_handle: FileSystemFileHandle = handle.unchecked_into(); - let bytes = read_file_bytes(&file_handle, &child_path).await?; - out.push((LpPathBuf::from(child_path.as_str()), bytes)); + match read_file_bytes(&file_handle, &child_path).await? { + Some(bytes) => out.push((LpPathBuf::from(child_path.as_str()), bytes)), + None => log::warn!( + "opfs: skipping implausibly large file at {child_path} \ + (> {MAX_FILE_BYTES} bytes; corrupt-store recovery)" + ), + } } _ => { log::warn!("opfs: unknown handle kind at {child_path}, skipping"); @@ -105,15 +110,30 @@ pub async fn list_child_dirs(dir: &FileSystemDirectoryHandle) -> Result Result, OpfsError> { +/// Refuse to load files no legitimate package member can reach. Library +/// content is device-bound (JSON, GLSL, SVG — kilobytes); the only known +/// way to exceed this is store corruption (pre-fix WebKit wrote the whole +/// wasm heap into every file — see `opfs_write::write_file`). Reading such +/// a file into the memory-primary tree would OOM the tab all over again, +/// so the mount skips it instead. +const MAX_FILE_BYTES: f64 = 16.0 * 1024.0 * 1024.0; + +/// `Ok(None)` when the file exceeds [`MAX_FILE_BYTES`]. +async fn read_file_bytes( + handle: &FileSystemFileHandle, + path: &str, +) -> Result>, OpfsError> { let file = JsFuture::from(handle.get_file()) .await .map_err(|e| OpfsError::new("get_file", path.to_string(), e))?; let file: File = file .dyn_into() .map_err(|e| OpfsError::new("get_file", path.to_string(), e))?; + if file.size() > MAX_FILE_BYTES { + return Ok(None); + } let buffer = JsFuture::from(file.array_buffer()) .await .map_err(|e| OpfsError::new("array_buffer", path.to_string(), e))?; - Ok(Uint8Array::new(&buffer).to_vec()) + Ok(Some(Uint8Array::new(&buffer).to_vec())) } diff --git a/lp-app/lpa-fs-opfs/src/opfs_write.rs b/lp-app/lpa-fs-opfs/src/opfs_write.rs index ac13898db..cececa2b6 100644 --- a/lp-app/lpa-fs-opfs/src/opfs_write.rs +++ b/lp-app/lpa-fs-opfs/src/opfs_write.rs @@ -71,8 +71,15 @@ async fn write_via_writable( .dyn_into() .map_err(|e| OpfsError::new("create_writable", path.as_str().to_string(), e))?; + // Copy into a JS-owned array before writing. `write_with_u8_array` + // passes a Uint8Array VIEW over wasm linear memory, and WebKit's + // `write()` ignores the view's offset/length and writes the entire + // underlying buffer (observed on WebKit 26.5; Chrome honors the view) + // — passing wasm memory directly dumps the whole wasm heap into every + // file. The copy's buffer is exactly `bytes`, so either reading works. + let copy = js_sys::Uint8Array::from(bytes); let write_promise = stream - .write_with_u8_array(bytes) + .write_with_buffer_source(©) .map_err(|e| OpfsError::new("write", path.as_str().to_string(), e))?; JsFuture::from(write_promise) .await diff --git a/lp-app/lpa-fs-opfs/tests/opfs_ops.rs b/lp-app/lpa-fs-opfs/tests/opfs_ops.rs index aefde3330..ee9da466c 100644 --- a/lp-app/lpa-fs-opfs/tests/opfs_ops.rs +++ b/lp-app/lpa-fs-opfs/tests/opfs_ops.rs @@ -114,3 +114,42 @@ async fn remove_missing_errors() { let result = remove_path(&dir, LpPath::new("/nope.txt")).await; assert!(result.is_err()); } + +/// The write must persist exactly the bytes passed — no view-length +/// confusion. Guards the WebKit whole-buffer bug (`write()` on a wasm +/// memory view wrote the entire heap): with the JS-owned copy in +/// `write_file` this size is exact on every engine. +#[wasm_bindgen_test] +async fn write_persists_exact_length() { + let dir = fresh_test_dir("t-exact-length").await; + let payload = vec![0x42u8; 4096]; + write_file(&dir, LpPath::new("/exact.bin"), &payload) + .await + .unwrap(); + let tree = tree_map(load_tree(&dir).await.unwrap()); + assert_eq!(tree["/exact.bin"].len(), payload.len()); + assert_eq!(tree["/exact.bin"], payload); +} + +/// Corrupt-store recovery: a file no legitimate package could contain +/// (pre-fix WebKit wrote the whole wasm heap into every file) is skipped +/// by the mount instead of being loaded into memory. +#[wasm_bindgen_test] +async fn load_tree_skips_implausibly_large_files() { + let dir = fresh_test_dir("t-oversize").await; + write_file(&dir, LpPath::new("/ok.json"), b"{}") + .await + .unwrap(); + // 17 MiB of zeros — just over the 16 MiB cap. + let huge = vec![0u8; 17 * 1024 * 1024]; + write_file(&dir, LpPath::new("/heap-dump.json"), &huge) + .await + .unwrap(); + + let tree = tree_map(load_tree(&dir).await.unwrap()); + assert!(tree.contains_key("/ok.json")); + assert!( + !tree.contains_key("/heap-dump.json"), + "oversized file must be skipped, not loaded" + ); +}