diff --git a/.agents/skills/layerfs-js-sdk/references/methods/fs-read.md b/.agents/skills/layerfs-js-sdk/references/methods/fs-read.md index 1b57bca97..1e9c53e67 100644 --- a/.agents/skills/layerfs-js-sdk/references/methods/fs-read.md +++ b/.agents/skills/layerfs-js-sdk/references/methods/fs-read.md @@ -156,7 +156,9 @@ const page = await layerfs.fs.list("work", "/src"); Describes one path. -**Returns:** `{ kind: "ok", snapshot, root, path, type, inode, size }`. +**Returns:** `{ kind: "ok", snapshot, root, path, type, inode, size }`. `size` is +a file's length in bytes, a symlink's target length (as `lstat` reports it in a +mounted Workspace) and 0 for a folder. ```js const exists = await layerfs.fs.stat("work", "/package.json").then(() => true, (e) => { if (isRefusal(e, "NoSuchPath")) return false; throw e; }); diff --git a/core/crates/layerfs-api/tests/stat_symlink.rs b/core/crates/layerfs-api/tests/stat_symlink.rs new file mode 100644 index 000000000..355f9d08c --- /dev/null +++ b/core/crates/layerfs-api/tests/stat_symlink.rs @@ -0,0 +1,38 @@ +//! `fs.stat` gives a symlink the size a mounted Workspace gives it: the +//! length of its target, not of the object that stores the target. +#[path = "support/installed_store.rs"] +#[allow(dead_code)] +mod installed; +#[path = "support/sdk.rs"] +#[allow(dead_code)] +mod support; + +use layerfs_bridge::control::SdkValue; +use std::fs; +use support::Fixture; + +#[test] +fn a_symlink_size_is_its_target_length() { + let fixture = Fixture::new("stat-symlink", |source| { + fs::write(source.join("a.txt"), b"a\n").unwrap(); + std::os::unix::fs::symlink("a.txt", source.join("link")).unwrap(); + std::os::unix::fs::symlink("/does/not/exist", source.join("dangling")).unwrap(); + 3 + }); + for (path, target) in [("/link", "a.txt"), ("/dangling", "/does/not/exist")] { + let stat = fixture.call( + "fs.stat", + vec![ + ("branch", SdkValue::text("main")), + ("path", SdkValue::text(path)), + ], + ); + assert_eq!(stat.get("type"), Some(&SdkValue::text("symlink")), "{path}"); + assert_eq!( + stat.get("size"), + Some(&SdkValue::Int(target.len() as i64)), + "{path}" + ); + } + fixture.cleanup(); +} diff --git a/core/crates/layerfs-store-service/src/read/snapshot.rs b/core/crates/layerfs-store-service/src/read/snapshot.rs index a0e229907..1e1ef8a44 100644 --- a/core/crates/layerfs-store-service/src/read/snapshot.rs +++ b/core/crates/layerfs-store-service/src/read/snapshot.rs @@ -10,7 +10,7 @@ use crate::store::{Store, StorePorts}; use layerfs_bridge::control::{SdkRefusal, SdkValue}; use layerfs_content::compare::{as_text, EntryFacts, TreeTable, MAXIMUM_TEXT_BYTES}; use layerfs_content::filesystem::path::PathName; -use layerfs_content::filesystem::FilesystemRootId; +use layerfs_content::filesystem::{FilesystemRootId, SymlinkTarget}; use layerfs_content::object::inode_leaf::InodeKind; use layerfs_content::{AuthenticatedObjects, ContentError, ObjectId}; use layerfs_history::{LayerStackRecord, NodeId}; @@ -129,7 +129,9 @@ pub fn kind_name(kind: InodeKind) -> &'static str { } /// Logical size of a file or symlink's content; a directory is 0. A regular -/// file's length is Store metadata: no payload or delta chain is read. +/// file's length is Store metadata: no payload or delta chain is read. A +/// symlink's is its target's length, as a mounted Workspace reports it, not +/// the length of the object that stores the target. pub fn size(reader: &StorePorts, entry: &EntryFacts) -> Result { match entry.value.kind { InodeKind::Directory => Ok(0), @@ -140,7 +142,8 @@ pub fn size(reader: &StorePorts, entry: &EntryFacts) -> Result .ok_or_else(|| args::failed("missing file length")), InodeKind::Symlink => { layerfs_content::AuthenticatedObjects::read_canonical(reader, entry.value.content_root) - .map(|canonical| canonical.len() as u64) + .and_then(|canonical| SymlinkTarget::decode(&canonical)) + .map(|target| target.as_bytes().len() as u64) .map_err(args::content) } }