From 67c9545ce58dd8b772b50a3063adac5942183a7c Mon Sep 17 00:00:00 2001 From: leepokai <109857817+leepokai@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:46:15 +0800 Subject: [PATCH] fix(store-service): report a symlink's target length as its fs.stat size fs.stat answered a symlink's size as the length of the canonical object that stores its target: the object envelope plus the LFS4LNK header, 27 bytes more than the target (a link to `a.txt` reported 32). A mounted Workspace reports the target's length (base/view.rs stat_resolved and the FUSE attributes), so the two views of one symlink disagreed. snapshot::size now decodes the stored target with SymlinkTarget::decode, as the Workspace base view does, and returns its byte length. The operator file history and stats use the same helper and now count a symlink the same way. Tests: new layerfs-api tests/stat_symlink.rs (a link and a dangling link); it fails on main with `left: Some(Int(32)) right: Some(Int(5))` and passes with the fix. The fs.stat reference in the layerfs-js-sdk skill now says what `size` means for each type. Verification (Linux arm64 VM, Rust 1.85.1): cargo fmt --all --check; clippy -p layerfs-store-service -p layerfs-api --all-targets -D warnings; test -p layerfs-store-service and each of the 46 layerfs-api test targets under a 120 s timeout: all passed. check_product_boundary.py reports the same 4 violations as main, none in the changed files. No tests deleted. Production LOC: 102160 -> 102161 (delta +1) Scope: combined core+api. Core: 96788 -> 96789 (layerfs-store-service 10009 -> 10010); api: 5372 -> 5372. Method: tools/production_loc.py --root --json on git archive of the first parent and of the staged tree. Reference absent (retired R9c; last counted 65,417 at tree 498dd1917812). --- .../references/methods/fs-read.md | 4 +- core/crates/layerfs-api/tests/stat_symlink.rs | 38 +++++++++++++++++++ .../src/read/snapshot.rs | 9 +++-- 3 files changed, 47 insertions(+), 4 deletions(-) create mode 100644 core/crates/layerfs-api/tests/stat_symlink.rs 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) } }