diff --git a/crates/browser-runtime/src/lib.rs b/crates/browser-runtime/src/lib.rs index 96f551c..9fec88c 100644 --- a/crates/browser-runtime/src/lib.rs +++ b/crates/browser-runtime/src/lib.rs @@ -167,6 +167,54 @@ pub fn run_fixture( ) } +/// Validate and canonicalize a replacement project snapshot. +/// +/// # Errors +/// +/// Returns a JavaScript error when the snapshot is malformed or exceeds the +/// supplied filesystem limit. +#[wasm_bindgen] +pub fn normalize_filesystem_snapshot( + max_filesystem_bytes: u64, + snapshot: &[u8], +) -> Result, JsError> { + let filesystem = if snapshot.is_empty() { + MemoryFileSystem::new(max_filesystem_bytes) + } else { + MemoryFileSystem::from_snapshot(max_filesystem_bytes, snapshot) + .map_err(|error| JsError::new(&error.to_string()))? + }; + filesystem + .export_snapshot() + .map_err(|error| JsError::new(&error.to_string())) +} + +/// Merge a package/toolchain project image into an existing snapshot. +/// +/// # Errors +/// +/// Returns a JavaScript error when either snapshot is malformed, conflicts +/// with a directory, or would exceed the supplied filesystem limit. +#[wasm_bindgen] +pub fn install_filesystem_snapshot( + max_filesystem_bytes: u64, + current_snapshot: &[u8], + image_snapshot: &[u8], +) -> Result, JsError> { + let mut filesystem = if current_snapshot.is_empty() { + MemoryFileSystem::new(max_filesystem_bytes) + } else { + MemoryFileSystem::from_snapshot(max_filesystem_bytes, current_snapshot) + .map_err(|error| JsError::new(&error.to_string()))? + }; + filesystem + .install_snapshot(image_snapshot) + .map_err(|error| JsError::new(&error.to_string()))?; + filesystem + .export_snapshot() + .map_err(|error| JsError::new(&error.to_string())) +} + /// Stateful browser guest that can suspend and resume blocking terminal input. #[wasm_bindgen] pub struct BrowserGuestSession { diff --git a/crates/memory-fs/src/lib.rs b/crates/memory-fs/src/lib.rs index 20b12fc..36b3775 100644 --- a/crates/memory-fs/src/lib.rs +++ b/crates/memory-fs/src/lib.rs @@ -223,6 +223,47 @@ impl MemoryFileSystem { Ok(snapshot) } + /// Atomically merge a project image snapshot into this filesystem. + /// Existing files at the same paths are replaced and unrelated project + /// files remain present. + /// + /// # Errors + /// + /// Returns [`SnapshotError`] when the image is malformed or the merged + /// filesystem would exceed its byte limit. + pub fn install_snapshot(&mut self, snapshot: &[u8]) -> Result<(), SnapshotError> { + let image = Self::from_snapshot(self.byte_limit, snapshot)?; + let mut merged = self.clone(); + for directory in image + .directories + .iter() + .filter(|path| path.starts_with(b"/project/")) + { + if merged.files.contains_key(directory) { + return Err(SnapshotError::DuplicatePath); + } + merged.directories.insert(directory.clone()); + } + for (path, bytes) in image.files { + if merged.directories.contains(&path) { + return Err(SnapshotError::DuplicatePath); + } + let replaced_length = merged.files.get(&path).map_or(0, Vec::len) as u64; + let next_size = merged + .stored_bytes + .checked_sub(replaced_length) + .and_then(|size| size.checked_add(bytes.len() as u64)) + .ok_or(SnapshotError::CapacityExceeded)?; + if next_size > merged.byte_limit { + return Err(SnapshotError::CapacityExceeded); + } + merged.files.insert(path, bytes); + merged.stored_bytes = next_size; + } + *self = merged; + Ok(()) + } + fn open_file( &mut self, path: Vec, @@ -998,4 +1039,40 @@ mod tests { Err(FileSystemError::PermissionDenied) ); } + + #[test] + fn installs_project_images_atomically_with_file_replacement() { + let mut base = MemoryFileSystem::new(16); + for (path, contents) in [ + (&b"/project/keep"[..], &b"base"[..]), + (&b"/project/replace"[..], &b"old"[..]), + ] { + let handle = base.open(path, CREATE_READ_WRITE).unwrap(); + base.write(handle, contents).unwrap(); + } + let mut image = MemoryFileSystem::new(16); + image.create_directory(b"/project/package").unwrap(); + for (path, contents) in [ + (&b"/project/replace"[..], &b"new"[..]), + (&b"/project/package/module.py"[..], &b"pass"[..]), + ] { + let handle = image.open(path, CREATE_READ_WRITE).unwrap(); + image.write(handle, contents).unwrap(); + } + + base.install_snapshot(&image.export_snapshot().unwrap()) + .unwrap(); + assert_eq!(base.read_file(b"/project/keep"), Some(&b"base"[..])); + assert_eq!(base.read_file(b"/project/replace"), Some(&b"new"[..])); + assert_eq!( + base.read_file(b"/project/package/module.py"), + Some(&b"pass"[..]) + ); + let before_failed_install = base.export_snapshot().unwrap(); + assert_eq!( + base.install_snapshot(&[0; 12]), + Err(SnapshotError::InvalidHeader) + ); + assert_eq!(base.export_snapshot().unwrap(), before_failed_install); + } } diff --git a/web/index.html b/web/index.html index 43489cb..38d4ecd 100644 --- a/web/index.html +++ b/web/index.html @@ -73,6 +73,14 @@

System call trace


+        

Project images

+
+ + + + + +

WebAssembly capabilities

diff --git a/web/src/main.ts b/web/src/main.ts index b80fd30..5ddb6ed 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -38,6 +38,10 @@ const startButton = requiredElement("#start"); const stopButton = requiredElement("#stop"); const terminalInput = requiredElement("#terminal-input"); const sendInputButton = requiredElement("#send-input"); +const projectImage = requiredElement("#project-image"); +const replaceImageButton = requiredElement("#replace-image"); +const installImageButton = requiredElement("#install-image"); +const exportImageButton = requiredElement("#export-image"); let worker: Worker; let workerReady = false; @@ -45,6 +49,7 @@ let running = false; let waitingInput = false; let runId = 0; let preserveDiagnosticStatus = false; +let imageRequestId = 0; function setControls(): void { startButton.disabled = !workerReady || running; @@ -52,6 +57,10 @@ function setControls(): void { fixture.disabled = running; terminalInput.disabled = !waitingInput; sendInputButton.disabled = !waitingInput; + projectImage.disabled = !workerReady || running; + replaceImageButton.disabled = !workerReady || running; + installImageButton.disabled = !workerReady || running; + exportImageButton.disabled = !workerReady || running; } function renderFeatures(results: FeatureResult[]): void { @@ -153,6 +162,31 @@ function createWorker(): void { setControls(); return; } + if (event.data.kind === "filesystem-image") { + if (event.data.requestId !== imageRequestId) { + return; + } + if (event.data.operation === "export") { + const url = URL.createObjectURL( + new Blob([event.data.snapshot as Uint8Array], { + type: "application/vnd.binarrow.project", + }), + ); + const download = document.createElement("a"); + download.href = url; + download.download = "binarrow-project.binarrow-project"; + download.click(); + URL.revokeObjectURL(url); + status.textContent = "Project snapshot exported"; + } else { + status.textContent = + event.data.operation === "replace" + ? "Project snapshot imported" + : "Project image installed"; + } + status.dataset.state = "ready"; + return; + } if (event.data.runId !== runId) { return; } @@ -241,4 +275,38 @@ terminalInput.addEventListener("keydown", (event) => { } }); +async function importProjectImage(mode: "replace" | "install"): Promise { + const file = projectImage.files?.[0]; + if (!file) { + status.textContent = "Choose a project image first"; + status.dataset.state = "error"; + return; + } + imageRequestId += 1; + const command: WorkerCommand = { + kind: "filesystem-import", + requestId: imageRequestId, + maxFilesystemBytes: parseLimit(filesystemLimit), + mode, + snapshot: new Uint8Array(await file.arrayBuffer()), + }; + worker.postMessage(command); +} + +replaceImageButton.addEventListener("click", () => { + void importProjectImage("replace"); +}); +installImageButton.addEventListener("click", () => { + void importProjectImage("install"); +}); +exportImageButton.addEventListener("click", () => { + imageRequestId += 1; + const command: WorkerCommand = { + kind: "filesystem-export", + requestId: imageRequestId, + maxFilesystemBytes: parseLimit(filesystemLimit), + }; + worker.postMessage(command); +}); + createWorker(); diff --git a/web/src/probe.ts b/web/src/probe.ts index 225c01e..8f5997c 100644 --- a/web/src/probe.ts +++ b/web/src/probe.ts @@ -68,6 +68,18 @@ export type WorkerCommand = runId: number; input: string; endOfFile: boolean; + } + | { + kind: "filesystem-export"; + requestId: number; + maxFilesystemBytes: bigint; + } + | { + kind: "filesystem-import"; + requestId: number; + maxFilesystemBytes: bigint; + mode: "replace" | "install"; + snapshot: Uint8Array; }; export type WorkerMessage = @@ -78,4 +90,10 @@ export type WorkerMessage = fixture: FixtureName; execution: ExecutionReport; } + | { + kind: "filesystem-image"; + requestId: number; + operation: "export" | "replace" | "install"; + snapshot: Uint8Array; + } | { kind: "error"; message: string }; diff --git a/web/src/probe.worker.ts b/web/src/probe.worker.ts index 42d4b2e..37b5227 100644 --- a/web/src/probe.worker.ts +++ b/web/src/probe.worker.ts @@ -7,6 +7,8 @@ import { typedFunctionReferences, } from "wasm-feature-detect"; import initBrowserRuntime, { + install_filesystem_snapshot, + normalize_filesystem_snapshot, start_fixture, } from "./generated/binarrow_browser_runtime.js"; import type { BrowserGuestSession } from "./generated/binarrow_browser_runtime.js"; @@ -245,6 +247,46 @@ async function execute( return continueSession(new Uint8Array(), false); } +async function handleFilesystemCommand( + command: Extract< + WorkerCommand, + { kind: "filesystem-export" | "filesystem-import" } + >, +): Promise { + const current = await loadFilesystemSnapshot(); + let snapshot: Uint8Array; + let operation: "export" | "replace" | "install"; + if (command.kind === "filesystem-export") { + snapshot = normalize_filesystem_snapshot( + command.maxFilesystemBytes, + current, + ); + operation = "export"; + } else if (command.mode === "replace") { + snapshot = normalize_filesystem_snapshot( + command.maxFilesystemBytes, + command.snapshot, + ); + await saveFilesystemSnapshot(snapshot); + operation = "replace"; + } else { + snapshot = install_filesystem_snapshot( + command.maxFilesystemBytes, + current, + command.snapshot, + ); + await saveFilesystemSnapshot(snapshot); + operation = "install"; + } + const message: WorkerMessage = { + kind: "filesystem-image", + requestId: command.requestId, + operation, + snapshot, + }; + self.postMessage(message); +} + const ready = Promise.all([createReport(), initializeRuntime()]); ready @@ -264,6 +306,13 @@ self.addEventListener("message", (event: MessageEvent) => { const command = event.data; void ready .then(async () => { + if ( + command.kind === "filesystem-export" || + command.kind === "filesystem-import" + ) { + await handleFilesystemCommand(command); + return; + } if (command.kind === "input" && command.runId !== activeRunId) { return; } diff --git a/web/src/styles.css b/web/src/styles.css index 539b1de..3cb2a7a 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -117,6 +117,24 @@ h2 { opacity: 0.55; } +#image-controls { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; + padding: 1rem; + border: 1px solid #aaa38c; +} + +#image-controls button, +#image-controls input { + padding: 0.65rem; + border: 1px solid #817a66; + background: #fffdf6; + color: inherit; + font: inherit; +} + dl { display: grid; grid-template-columns: minmax(12rem, 1fr) auto; diff --git a/web/tests/probe.spec.ts b/web/tests/probe.spec.ts index e447845..275f74a 100644 --- a/web/tests/probe.spec.ts +++ b/web/tests/probe.spec.ts @@ -111,6 +111,45 @@ test("persists project files in OPFS across a page reload", async ({ page }) => ); }); +test("exports, replaces, and restores deterministic project images", async ({ + page, +}) => { + const emptyDownload = await Promise.all([ + page.waitForEvent("download"), + page.getByRole("button", { name: "Export project" }).click(), + ]).then(([download]) => download.path()); + expect(emptyDownload).not.toBeNull(); + + await page + .getByLabel("Fixture") + .selectOption("project-persistence-write"); + await page.getByRole("button", { name: "Start" }).click(); + await expect(page.getByRole("status")).toHaveText("Guest exited"); + const populatedDownload = await Promise.all([ + page.waitForEvent("download"), + page.getByRole("button", { name: "Export project" }).click(), + ]).then(([download]) => download.path()); + expect(populatedDownload).not.toBeNull(); + + await page.getByLabel("Snapshot or package image").setInputFiles(emptyDownload!); + await page.getByRole("button", { name: "Replace project" }).click(); + await expect(page.getByRole("status")).toHaveText("Project snapshot imported"); + await page.getByLabel("Fixture").selectOption("project-persistence-read"); + await page.getByRole("button", { name: "Start" }).click(); + await expect(page.locator("#exit-code")).toHaveText("5"); + + await page + .getByLabel("Snapshot or package image") + .setInputFiles(populatedDownload!); + await page.getByRole("button", { name: "Replace project" }).click(); + await expect(page.getByRole("status")).toHaveText("Project snapshot imported"); + await page.getByRole("button", { name: "Start" }).click(); + await expect(page.getByRole("status")).toHaveText("Guest exited"); + await expect(page.getByLabel("Guest terminal output")).toHaveText( + "persistent project data", + ); +}); + test("creates, modifies, lists, renames, and deletes guest files", async ({ page, }) => {