From 5782a8daf1e3763c5b71d3d00e9340ad8f335763 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 7 Sep 2026 05:51:04 +0200 Subject: [PATCH] fix(fs): expose embedded asset metadata --- changelog.d/9945-embedded-fs-metadata.md | 1 + crates/perry-runtime/src/embedded.rs | 165 +++++++++++++++++- crates/perry-runtime/src/fs/dirent.rs | 29 +++ crates/perry-runtime/src/fs/mod.rs | 8 +- crates/perry-runtime/src/fs/stats.rs | 31 ++++ .../perry/tests/issue_5731_embedded_assets.rs | 17 +- 6 files changed, 245 insertions(+), 6 deletions(-) create mode 100644 changelog.d/9945-embedded-fs-metadata.md diff --git a/changelog.d/9945-embedded-fs-metadata.md b/changelog.d/9945-embedded-fs-metadata.md new file mode 100644 index 0000000000..1d58fbcea8 --- /dev/null +++ b/changelog.d/9945-embedded-fs-metadata.md @@ -0,0 +1 @@ +Fixed `statSync`, `lstatSync`, `existsSync`, and `readdirSync` to expose files and inferred directories from the embedded `$perryfs` filesystem. diff --git a/crates/perry-runtime/src/embedded.rs b/crates/perry-runtime/src/embedded.rs index e5380f9ce7..d99600970d 100644 --- a/crates/perry-runtime/src/embedded.rs +++ b/crates/perry-runtime/src/embedded.rs @@ -20,6 +20,7 @@ //! The global never frees (matching Perry's "embedded data lives for the life of //! the process" model), mirroring the `crate::shared_sab` registry pattern. +use std::collections::BTreeMap; use std::sync::{Mutex, OnceLock}; use crate::object::{js_object_alloc, js_object_set_field_by_name, ObjectHeader}; @@ -43,6 +44,21 @@ struct EmbeddedAsset { bytes: &'static [u8], } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct EmbeddedMetadata { + pub(crate) is_file: bool, + pub(crate) is_directory: bool, + pub(crate) size: usize, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct EmbeddedDirEntry { + pub(crate) relative_path: String, + pub(crate) name: String, + pub(crate) parent_path: String, + pub(crate) is_directory: bool, +} + static EMBEDDED_ASSETS: OnceLock>> = OnceLock::new(); fn registry() -> &'static Mutex> { @@ -59,6 +75,15 @@ fn normalize_key(path: &str) -> String { p.strip_prefix("./").unwrap_or(p).to_string() } +fn perry_virtual_key(path: &str) -> Option<(String, String)> { + let unified = path.replace('\\', "/"); + if unified == "$perryfs" || unified == VIRTUAL_PREFIX { + return Some((String::new(), "$perryfs".to_string())); + } + let key = unified.strip_prefix(VIRTUAL_PREFIX)?.trim_matches('/'); + Some((key.to_string(), format!("$perryfs/{key}"))) +} + /// Register an embedded asset. Called once per file from the generated /// `__attribute__((constructor))` before the runtime starts. Both `name_ptr` /// and `bytes_ptr` point at static literals in the binary, so the recorded @@ -99,6 +124,93 @@ pub fn lookup(path: &str) -> Option<&'static [u8]> { reg.iter().find(|a| a.name == key).map(|a| a.bytes) } +/// Metadata for an embedded file or an inferred `$perryfs` directory. +/// Directories are implicit: every prefix before a registered asset exists. +pub(crate) fn metadata(path: &str) -> Option { + let key = normalize_key(path); + let reg = registry().lock().unwrap_or_else(|e| e.into_inner()); + if let Some(asset) = reg.iter().find(|asset| asset.name == key) { + return Some(EmbeddedMetadata { + is_file: true, + is_directory: false, + size: asset.bytes.len(), + }); + } + let (directory_key, _) = perry_virtual_key(path)?; + let prefix = if directory_key.is_empty() { + String::new() + } else { + format!("{directory_key}/") + }; + reg.iter() + .any(|asset| asset.name.starts_with(&prefix)) + .then_some(EmbeddedMetadata { + is_file: false, + is_directory: true, + size: 0, + }) +} + +/// Sorted children of an inferred `$perryfs` directory. Recursive entries use +/// paths relative to the requested directory, matching Node's string result; +/// `name` and `parent_path` retain the pieces needed to build `fs.Dirent`. +pub(crate) fn read_dir(path: &str, recursive: bool) -> Option> { + let (directory_key, display_path) = perry_virtual_key(path)?; + let prefix = if directory_key.is_empty() { + String::new() + } else { + format!("{directory_key}/") + }; + let reg = registry().lock().unwrap_or_else(|e| e.into_inner()); + let mut paths = BTreeMap::::new(); + for asset in reg.iter() { + let Some(remainder) = asset.name.strip_prefix(&prefix) else { + continue; + }; + if remainder.is_empty() { + continue; + } + let parts = remainder.split('/').collect::>(); + let end = if recursive { parts.len() } else { 1 }; + for index in 1..=end { + let relative_path = parts[..index].join("/"); + let is_directory = index < parts.len(); + paths + .entry(relative_path) + .and_modify(|known_directory| *known_directory |= is_directory) + .or_insert(is_directory); + if !recursive { + break; + } + } + } + if paths.is_empty() { + return None; + } + Some( + paths + .into_iter() + .map(|(relative_path, is_directory)| { + let (parent_relative, name) = relative_path + .rsplit_once('/') + .unwrap_or(("", relative_path.as_str())); + let parent_path = if parent_relative.is_empty() { + display_path.clone() + } else { + format!("{display_path}/{parent_relative}") + }; + let name = name.to_string(); + EmbeddedDirEntry { + relative_path, + name, + parent_path, + is_directory, + } + }) + .collect(), + ) +} + /// True if `path` is an embedded-asset virtual path (carries the `$perryfs/` /// or `/$bunfs/root/` prefix), independent of whether it actually resolves. /// `fs` uses this to treat an unresolved virtual path as missing rather than @@ -106,7 +218,9 @@ pub fn lookup(path: &str) -> Option<&'static [u8]> { /// [`lookup`]. pub fn is_virtual_path(path: &str) -> bool { let unified = path.replace('\\', "/"); - unified.starts_with(VIRTUAL_PREFIX) || unified.starts_with(BUNFS_ROOT_PREFIX) + unified == "$perryfs" + || unified.starts_with(VIRTUAL_PREFIX) + || unified.starts_with(BUNFS_ROOT_PREFIX) } /// Snapshot of `(name, size)` for every embedded asset, in registration order. @@ -308,8 +422,15 @@ mod tests { fn register_and_lookup_by_both_paths() { const NAME: &[u8] = b"embed-test/asset.txt"; const DATA: &[u8] = b"embedded-bytes"; + const NESTED_NAME: &[u8] = b"embed-test/nested/two.bin"; unsafe { js_register_embedded_asset(NAME.as_ptr(), NAME.len(), DATA.as_ptr(), DATA.len()); + js_register_embedded_asset( + NESTED_NAME.as_ptr(), + NESTED_NAME.len(), + DATA.as_ptr(), + DATA.len(), + ); } // Found by bare key, by `$perryfs/` virtual path, and via backslashes. assert_eq!(lookup("embed-test/asset.txt"), Some(DATA)); @@ -317,10 +438,52 @@ mod tests { assert_eq!(lookup("$perryfs\\embed-test\\asset.txt"), Some(DATA)); // `is_virtual_path` is a pure prefix test; presence is `lookup`. assert!(is_virtual_path("$perryfs/anything")); + assert!(is_virtual_path("$perryfs")); assert!(is_virtual_path("/$bunfs/root/assets/help.zst")); assert!(!is_virtual_path("not/registered.txt")); assert!(lookup("not/registered.txt").is_none()); assert!(lookup("$perryfs/not-registered").is_none()); + + assert_eq!( + metadata("$perryfs/embed-test/asset.txt"), + Some(EmbeddedMetadata { + is_file: true, + is_directory: false, + size: DATA.len(), + }) + ); + assert_eq!( + metadata("$perryfs/embed-test/nested"), + Some(EmbeddedMetadata { + is_file: false, + is_directory: true, + size: 0, + }) + ); + assert_eq!( + metadata("$perryfs"), + Some(EmbeddedMetadata { + is_file: false, + is_directory: true, + size: 0, + }) + ); + let entries = read_dir("$perryfs/embed-test", false).expect("virtual directory exists"); + assert_eq!( + entries + .iter() + .map(|entry| (entry.name.as_str(), entry.is_directory)) + .collect::>(), + vec![("asset.txt", false), ("nested", true)] + ); + let recursive = read_dir("$perryfs/embed-test", true).expect("virtual directory exists"); + assert_eq!( + recursive + .iter() + .map(|entry| entry.relative_path.as_str()) + .collect::>(), + vec!["asset.txt", "nested", "nested/two.bin"] + ); } #[test] diff --git a/crates/perry-runtime/src/fs/dirent.rs b/crates/perry-runtime/src/fs/dirent.rs index 6fdf20a9ec..c0985eb3b3 100644 --- a/crates/perry-runtime/src/fs/dirent.rs +++ b/crates/perry-runtime/src/fs/dirent.rs @@ -39,6 +39,18 @@ impl DirentKind { } } + fn embedded(is_directory: bool) -> Self { + Self { + is_file: !is_directory, + is_dir: is_directory, + is_symlink: false, + is_block_device: false, + is_character_device: false, + is_fifo: false, + is_socket: false, + } + } + #[cfg(feature = "regex-engine")] pub(crate) fn is_file(self) -> bool { self.is_file @@ -404,6 +416,23 @@ pub extern "C" fn js_fs_readdir_sync(path_value: f64, options_value: f64) -> f64 let recursive = options_bool_field(options_value, b"recursive"); let encoding_buffer = readdir_encoding_buffer(options_value); + if let Some(entries) = crate::embedded::read_dir(&path_str, recursive) { + let mut arr = js_array_alloc(entries.len() as u32); + for entry in &entries { + let value = if with_file_types { + build_dirent_object( + &entry.name, + &entry.parent_path, + DirentKind::embedded(entry.is_directory), + ) + } else { + bytes_to_readdir_value(entry.relative_path.as_bytes(), encoding_buffer) + }; + arr = js_array_push_f64(arr, value); + } + return f64::from_bits(i64::cast_unsigned(arr as i64)); + } + match fs::read_dir(&path_str) { Ok(entries) => { if recursive && !with_file_types { diff --git a/crates/perry-runtime/src/fs/mod.rs b/crates/perry-runtime/src/fs/mod.rs index 71e7958fdf..7875ec484e 100644 --- a/crates/perry-runtime/src/fs/mod.rs +++ b/crates/perry-runtime/src/fs/mod.rs @@ -654,10 +654,10 @@ pub extern "C" fn js_fs_exists_sync(path_value: f64) -> i32 { None => return 0, }; - // #5731 — a registered embedded asset exists for the life of the - // process; an unresolved `$perryfs/...` path does not (and must not - // fall through to a disk check of the literal virtual path). - if crate::embedded::lookup(&path_str).is_some() { + // #5731/#9941 — a registered embedded file or inferred directory + // exists for the life of the process; an unresolved `$perryfs/...` + // path must not fall through to a disk check of the virtual spelling. + if crate::embedded::metadata(&path_str).is_some() { return 1; } if crate::embedded::is_virtual_path(&path_str) { diff --git a/crates/perry-runtime/src/fs/stats.rs b/crates/perry-runtime/src/fs/stats.rs index c1c4755296..c6ed17464c 100644 --- a/crates/perry-runtime/src/fs/stats.rs +++ b/crates/perry-runtime/src/fs/stats.rs @@ -446,6 +446,31 @@ fn metadata_special_file_predicates(meta: Option<&fs::Metadata>) -> (bool, bool, (false, false, false, false) } +unsafe fn embedded_stats(path: &str, bigint: bool) -> Option { + let meta = crate::embedded::metadata(path)?; + let mode = if meta.is_directory { + 0o040555 + } else { + 0o100444 + }; + Some(build_stats_object( + meta.is_file, + meta.is_directory, + false, + meta.size as u64, + mode, + -1.0, + -1.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + bigint, + None, + )) +} + /// `fs.statSync(path)` — returns a Stats-like object with Node-compatible /// predicate methods and scalar fields, or throws a Node-shaped fs Error when /// metadata lookup fails. @@ -467,6 +492,9 @@ pub extern "C" fn js_fs_stat_sync_options(path_value: f64, options_value: f64) - ) } }; + if let Some(stats) = embedded_stats(&path_str, bigint) { + return stats; + } match fs::metadata(&path_str) { Ok(meta) => { let is_file = meta.is_file(); @@ -529,6 +557,9 @@ pub extern "C" fn js_fs_lstat_sync_options(path_value: f64, options_value: f64) ) } }; + if let Some(stats) = embedded_stats(&path_str, bigint) { + return stats; + } match fs::symlink_metadata(&path_str) { Ok(meta) => { let ft = meta.file_type(); diff --git a/crates/perry/tests/issue_5731_embedded_assets.rs b/crates/perry/tests/issue_5731_embedded_assets.rs index e1316f1c1d..4862c0820a 100644 --- a/crates/perry/tests/issue_5731_embedded_assets.rs +++ b/crates/perry/tests/issue_5731_embedded_assets.rs @@ -4,7 +4,8 @@ //! At runtime they are reachable three ways, all exercised here: //! * `import { embeddedFiles } from "perry"` — `{ name, size, type }` per asset //! * `import { readEmbedded } from "perry"` — bytes as a `Buffer` -//! * `node:fs` (`readFileSync` / `existsSync`) via the `$perryfs/` path +//! * `node:fs` reads, existence, metadata, and directory listing through the +//! `$perryfs/` virtual filesystem //! plus `isStandaloneExecutable` (always `true` in a compiled binary). //! //! Asset embedding is host-targeted: Unix-like systems compile a `cc` object; @@ -51,7 +52,15 @@ console.log("viaFs:", fs.readFileSync("$perryfs/dist/index.html", "utf8")); const html = files.find(f => f.name === "dist/index.html"); console.log("type:", html.type, "size:", html.size); console.log("exists:", fs.existsSync("$perryfs/dist/assets/app.js")); +console.log("existsDir:", fs.existsSync("$perryfs/dist/assets")); console.log("existsMissing:", fs.existsSync("$perryfs/nope.txt")); +const embeddedStat = fs.statSync("$perryfs/dist/assets/app.js"); +console.log("stat:", embeddedStat.isFile(), embeddedStat.size); +console.log("statDir:", fs.statSync("$perryfs/dist/assets").isDirectory()); +console.log("rootEntries:", fs.readdirSync("$perryfs").join(",")); +console.log("distEntries:", fs.readdirSync("$perryfs/dist").join(",")); +const assetEntry = fs.readdirSync("$perryfs/dist", { withFileTypes: true })[0]; +console.log("dirent:", assetEntry.name, assetEntry.isDirectory(), assetEntry.parentPath); try { readEmbedded("nope.txt"); console.log("throwMissing: no"); } catch (e) { console.log("throwMissing: yes"); } "#, @@ -100,7 +109,13 @@ catch (e) { console.log("throwMissing: yes"); } viaFs: HELLO_EMBED\n\ type: text/html; charset=utf-8 size: 11\n\ exists: true\n\ + existsDir: true\n\ existsMissing: false\n\ + stat: true 14\n\ + statDir: true\n\ + rootEntries: dist\n\ + distEntries: assets,index.html\n\ + dirent: assets true $perryfs/dist\n\ throwMissing: yes\n", "unexpected runtime output" );