|
| 1 | +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ |
| 2 | +// SPDX-License-Identifier: Apache-2.0 |
| 3 | + |
| 4 | +//! Wasm implementation of [`FileCapability`] backed by Node.js `fs`. |
| 5 | +//! |
| 6 | +//! The JS transport is imported via `wasm_bindgen(module = ...)` from |
| 7 | +//! `filesystem.js`, which ships alongside the wasm output. |
| 8 | +
|
| 9 | +use std::future::Future; |
| 10 | + |
| 11 | +use bytes::Bytes; |
| 12 | +use js_sys::{self, Reflect, Uint8Array}; |
| 13 | +use wasm_bindgen::prelude::*; |
| 14 | +use wasm_bindgen_futures::JsFuture; |
| 15 | + |
| 16 | +use libdd_capabilities::file::{FileCapability, FileError, FileMetadata}; |
| 17 | +use libdd_capabilities::maybe_send::MaybeSend; |
| 18 | + |
| 19 | +#[wasm_bindgen(module = "/src/filesystem.js")] |
| 20 | +extern "C" { |
| 21 | + #[wasm_bindgen(js_name = "readFile", catch)] |
| 22 | + fn js_read_file(path: &str) -> Result<js_sys::Promise, JsValue>; |
| 23 | + |
| 24 | + #[wasm_bindgen(js_name = "writeFile", catch)] |
| 25 | + fn js_write_file(path: &str, data: &[u8]) -> Result<js_sys::Promise, JsValue>; |
| 26 | + |
| 27 | + #[wasm_bindgen(js_name = "metadata", catch)] |
| 28 | + fn js_metadata(path: &str) -> Result<js_sys::Promise, JsValue>; |
| 29 | + |
| 30 | + #[wasm_bindgen(js_name = "exists", catch)] |
| 31 | + fn js_exists(path: &str) -> Result<js_sys::Promise, JsValue>; |
| 32 | +} |
| 33 | + |
| 34 | +#[derive(Debug, Clone)] |
| 35 | +pub struct WasmFileCapability; |
| 36 | + |
| 37 | +impl FileCapability for WasmFileCapability { |
| 38 | + fn new() -> Self { |
| 39 | + Self |
| 40 | + } |
| 41 | + |
| 42 | + #[allow(clippy::manual_async_fn)] |
| 43 | + fn read(&self, path: &str) -> impl Future<Output = Result<Bytes, FileError>> + MaybeSend { |
| 44 | + let path = path.to_owned(); |
| 45 | + async move { |
| 46 | + let promise = |
| 47 | + js_read_file(&path).map_err(|e| map_js_error(&e, &path))?; |
| 48 | + let value = JsFuture::from(promise) |
| 49 | + .await |
| 50 | + .map_err(|e| map_js_error(&e, &path))?; |
| 51 | + let array = Uint8Array::unchecked_from_js(value); |
| 52 | + Ok(Bytes::from(array.to_vec())) |
| 53 | + } |
| 54 | + } |
| 55 | + |
| 56 | + #[allow(clippy::manual_async_fn)] |
| 57 | + fn write( |
| 58 | + &self, |
| 59 | + path: &str, |
| 60 | + contents: Bytes, |
| 61 | + ) -> impl Future<Output = Result<(), FileError>> + MaybeSend { |
| 62 | + let path = path.to_owned(); |
| 63 | + async move { |
| 64 | + let promise = js_write_file(&path, &contents) |
| 65 | + .map_err(|e| map_js_error(&e, &path))?; |
| 66 | + JsFuture::from(promise) |
| 67 | + .await |
| 68 | + .map_err(|e| map_js_error(&e, &path))?; |
| 69 | + Ok(()) |
| 70 | + } |
| 71 | + } |
| 72 | + |
| 73 | + #[allow(clippy::manual_async_fn)] |
| 74 | + fn metadata( |
| 75 | + &self, |
| 76 | + path: &str, |
| 77 | + ) -> impl Future<Output = Result<FileMetadata, FileError>> + MaybeSend { |
| 78 | + let path = path.to_owned(); |
| 79 | + async move { |
| 80 | + let promise = |
| 81 | + js_metadata(&path).map_err(|e| map_js_error(&e, &path))?; |
| 82 | + let value = JsFuture::from(promise) |
| 83 | + .await |
| 84 | + .map_err(|e| map_js_error(&e, &path))?; |
| 85 | + parse_metadata(&value, &path) |
| 86 | + } |
| 87 | + } |
| 88 | + |
| 89 | + #[allow(clippy::manual_async_fn)] |
| 90 | + fn exists(&self, path: &str) -> impl Future<Output = Result<bool, FileError>> + MaybeSend { |
| 91 | + let path = path.to_owned(); |
| 92 | + async move { |
| 93 | + let promise = js_exists(&path).map_err(|e| map_js_error(&e, &path))?; |
| 94 | + let value = JsFuture::from(promise) |
| 95 | + .await |
| 96 | + .map_err(|e| map_js_error(&e, &path))?; |
| 97 | + value |
| 98 | + .as_bool() |
| 99 | + .ok_or_else(|| FileError::Io(anyhow::anyhow!("exists({path}) did not return a boolean"))) |
| 100 | + } |
| 101 | + } |
| 102 | +} |
| 103 | + |
| 104 | +fn map_js_error(err: &JsValue, path: &str) -> FileError { |
| 105 | + let code = Reflect::get(err, &JsValue::from_str("code")) |
| 106 | + .ok() |
| 107 | + .and_then(|v| v.as_string()); |
| 108 | + match code.as_deref() { |
| 109 | + Some("ENOENT") => FileError::NotFound(path.to_owned()), |
| 110 | + Some("EACCES") | Some("EPERM") => FileError::PermissionDenied(path.to_owned()), |
| 111 | + _ => { |
| 112 | + let message = Reflect::get(err, &JsValue::from_str("message")) |
| 113 | + .ok() |
| 114 | + .and_then(|v| v.as_string()) |
| 115 | + .unwrap_or_else(|| format!("{err:?}")); |
| 116 | + FileError::Io(anyhow::anyhow!("{message} (path: {path})")) |
| 117 | + } |
| 118 | + } |
| 119 | +} |
| 120 | + |
| 121 | +fn parse_metadata(value: &JsValue, path: &str) -> Result<FileMetadata, FileError> { |
| 122 | + let size = read_bigint_u64(value, "size", path)?; |
| 123 | + // Node populates `stat().ino` on every platform, so `inode` is always Some. |
| 124 | + let inode = Some(read_bigint_u64(value, "inode", path)?); |
| 125 | + let is_file = read_bool(value, "is_file", path)?; |
| 126 | + let is_dir = read_bool(value, "is_dir", path)?; |
| 127 | + Ok(FileMetadata { |
| 128 | + size, |
| 129 | + inode, |
| 130 | + is_file, |
| 131 | + is_dir, |
| 132 | + }) |
| 133 | +} |
| 134 | + |
| 135 | +fn read_bigint_u64(value: &JsValue, key: &str, path: &str) -> Result<u64, FileError> { |
| 136 | + let v = Reflect::get(value, &JsValue::from_str(key)) |
| 137 | + .map_err(|_| FileError::Io(anyhow::anyhow!("metadata({path}) could not read field `{key}`")))?; |
| 138 | + if v.is_undefined() || v.is_null() { |
| 139 | + return Err(FileError::Io(anyhow::anyhow!("metadata({path}) missing field `{key}`"))); |
| 140 | + } |
| 141 | + let bigint = js_sys::BigInt::try_from(v) |
| 142 | + .map_err(|_| FileError::Io(anyhow::anyhow!("metadata({path}) field `{key}` is not a BigInt")))?; |
| 143 | + u64::try_from(bigint) |
| 144 | + .map_err(|_| FileError::Io(anyhow::anyhow!("metadata({path}) field `{key}` overflows u64"))) |
| 145 | +} |
| 146 | + |
| 147 | +fn read_bool(value: &JsValue, key: &str, path: &str) -> Result<bool, FileError> { |
| 148 | + Reflect::get(value, &JsValue::from_str(key)) |
| 149 | + .ok() |
| 150 | + .and_then(|v| v.as_bool()) |
| 151 | + .ok_or_else(|| { |
| 152 | + FileError::Io(anyhow::anyhow!( |
| 153 | + "metadata({path}) is missing boolean field `{key}`" |
| 154 | + )) |
| 155 | + }) |
| 156 | +} |
0 commit comments