Skip to content

fix(sdk): refuse a write through a file or symlink as NotDirectory - #435

Closed
RGB-loop wants to merge 1 commit into
Ephemeral-AI-Lab:mainfrom
RGB-loop:fix/write-through-non-directory
Closed

RGB-loop wants to merge 1 commit into
Ephemeral-AI-Lab:mainfrom
RGB-loop:fix/write-through-non-directory

Conversation

@RGB-loop

Copy link
Copy Markdown
Contributor

Fixes #372. It was closed as completed on 2026-10-21, but it still reproduces on main 8e2fce26; #415 changes another branch of failure() (Capacity) and does not cover this path.

Problem

A write call whose path goes below a regular file or a symlink (/a.txt/x, /dlink/x, where dlink -> dir) is refused as an engine failure:

fs.write / fs.rm / fs.edit / fs.cp / fs.mv  ->  Unknown | Content(WrongLogicalRole)

Mutation::resolve looks a name up under whatever the previous entry was. The content layer answers WrongLogicalRole, and failure() maps that to Unknown. files.rs keeps the Store call Workspace of an Unknown (by design, since its outcome is uncertain). As a result, each such call holds one slot. After as many of them as the Store has call Workspaces (OwnerConfig::default().namespaces, 16), every fs.write/rm/mkdir is refused Failed | Capacity: Store call Workspace capacity. Reads are unaffected and nothing is committed. (From reading files.rs, the slots are only returned when the Store is reopened; I did not run that part.)

The same inputs already get a precise answer elsewhere. fs.mkdir on the write route returns NotDirectory at the blocking entry, and reads return NoSuchPath.

Fix

Mutation::resolve refuses a name below an entry that is not a directory as NotDirectory, with the path of that entry as the message. This is exactly how fs.mkdir answers on the same route. A symlink is not followed, as before. The call becomes a known refusal, so files.rs closes its Workspace. Production change: +6 lines in write/mutation.rs.

Why NotDirectory, not NoSuchPath:

  • Mutation::optional reads NoSuchPath as "absent, create it". With NoSuchPath, write /a.txt/x would go on to the create path and fail differently depending on depth.
  • NotDirectory matches the existing fs.mkdir answer for the same input on the write route, and POSIX ENOTDIR.

Not changed: a genuinely uncertain Unknown still keeps its Workspace. That custody rule is untouched. This PR only stops a certain refusal from being reported as Unknown.

Tests

layerfs-api/tests/write_refusals.rs::a_write_through_a_non_directory_is_not_directory:

  • fs.write, rm, mkdir, edit, cp and mv through a file or a symlink return ("NotDirectory", "<blocking entry>").
  • After one pass over all cases, the test makes namespaces + 1 more such calls that are not fs.mkdir (mkdir never keeps a Workspace), then requires a valid fs.write to succeed. Without the fix, these calls use up all 16 Workspaces and that write is refused Capacity.

On main 8e2fce26 without the fix (only the new test added), it fails:

left:  ("Unknown", "Content(WrongLogicalRole)")
right: ("NotDirectory", "/a.txt")

With the fix, it passes.

Run locally on this branch, rebased on main 8e2fce26 (Linux, Rust 1.95.0, --locked --offline, Disposable fixture, no mount):

  • cargo test -p layerfs-store-service --all-targets passes.
  • cargo test -p layerfs-api --all-targets passes (104 passed, 15 ignored).
  • cargo clippy -p layerfs-api -p layerfs-store-service --all-targets --no-deps -- -D warnings -A clippy::result_large_err -A clippy::unnecessary_sort_by passes. The two allowed lints are pre-existing (store_service.rs, examples/issue367_rm_probe.rs); neither is in the changed files.
  • rustfmt --check passes on the changed files.

The SDK guide (fs-write.md) now lists NotDirectory among the shared write refusals.

Not checked: the pinned Rust 1.85.1 (not installed here), the whole workspace, cargo fmt --all and the boundary guard. Upstream CI has not run on this branch yet; all results above are local.

Production LOC: 109,056 -> 109,062 (delta +6), tools/production_loc.py --root.

🤖 Generated with Claude Code

A write call naming a path below a regular file or a symlink (/a.txt/x,
/dlink/x) walked into it: the content layer answered WrongLogicalRole, the
write route mapped it to Unknown, and an Unknown keeps its Store call
Workspace. fs.write/rm/edit/cp/mv each then held one, and after as many
such calls as the Store has call Workspaces every write was refused
Capacity. Mutation::resolve now refuses a name below a non-directory as
NotDirectory at that entry, as fs.mkdir on the same route already does, so
the call is a known refusal and its Workspace is closed. The SDK guide
lists NotDirectory among the shared write refusals.

Test added: layerfs-api/tests/write_refusals.rs
a_write_through_a_non_directory_is_not_directory. No tests deleted.

Production LOC: 109,056 -> 109,062 (delta +6)
Scope and method: tools/production_loc.py --root on the parent and the
staged tree; layerfs-store-service 10,009 -> 10,015; reference absent
(retired R9c; last counted 65,417 at tree 498dd19). Test and docs are
not counted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
yifanxuaaa added a commit that referenced this pull request Oct 26, 2026
…372)

A write call naming a path below a regular file or a symlink (/a.txt/x,
/dlink/x) was refused Unknown (Content WrongLogicalRole), and an Unknown
keeps its Store call Workspace: 16 such calls refused every later write
with Capacity until the Store reopened. Mutation::resolve now refuses a
name below a non-directory as NotDirectory at that entry, as fs.mkdir
already does, so the call is a known refusal and its Workspace closes.
Symlinks are not followed by Store calls, as before.

Rebased by the maintainer onto main after #465 moved the crates to
engine/; LOC recounted. Checked on macOS with the pinned cargo 1.85.1:
write_refusals passes and fails on main's mutation.rs with
("Unknown", "Content(WrongLogicalRole)"); layerfs-api concurrent_commits,
method_refusal_order, lane_merge, sdk_chunk_deltas, sdk_method_golden
(write_cost is ignored by default); layerfs-store-service tests; fmt and
Clippy -D warnings clean. Known follow-ups from review: other read-only
resolve errors still map to Unknown, and a symlink to a directory in the
middle of a path is refused although the mount follows it.

Production LOC: 102165 -> 102171 (delta +6)
  First-parent comparison (main 41a848f -> this merge). core 38241,
  engine 61291 -> 61297 (layerfs-store-service 10010 -> 10016), api 2633;
  reference absent (retired R9c; last counted 65,417 at tree
  498dd19). Counted with tools/production_loc.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@yifanxuaaa

Copy link
Copy Markdown
Contributor

Merged into main as fbecc17, thanks! #465 moved the crates to engine/, so I rebased your commit onto main (9e3ae56, your authorship kept) and recounted the LOC line (102165 -> 102171, +6 in layerfs-store-service under engine). Checked with the pinned Rust 1.85.1: write_refusals and the related layerfs-api and store-service tests pass, fmt and Clippy -D warnings are clean. This PR's branch still holds the pre-rebase commit, so GitHub shows it as closed rather than merged.

@yifanxuaaa yifanxuaaa closed this Oct 26, 2026
yifanxuaaa added a commit that referenced this pull request Oct 26, 2026
A write call's view reads (resolve's stat and lookup, the file read of
fs.edit, and the list, stat and readlink of recursive rm and cp) take the
overlay read-only, so they change nothing. Their engine errors still went
through failure() to Unknown, and files.rs keeps the call Workspace of an
Unknown: 16 calls that failed reading the Store (a damaged object, for
example) refused every later write with Capacity until the Store
reopened. This is the class #435 fixed for one case (a name below a
non-directory, now NotDirectory).

read_failure() maps such an engine error to a known Failed, so files.rs
closes the Workspace. An Unknown from a step that applies a change, sets
up the call or reserves serials still keeps its Workspace. The
layerfs-js-sdk fs.write guide lists Failed among the shared refusals.

Test added: layerfs-api tests/write_refusals.rs
a_failed_read_keeps_no_store_call_workspace damages the stored file
contents of a closed test Store out of band (metadata, which a bind reads,
shares one pack with the lookup's objects, so a lookup alone cannot be
damaged), then makes 17 fs.edit calls: each is refused Failed for the
read. On main it fails with Unknown, and without that first check call 16
is refused "Capacity: Store call Workspace capacity". No tests deleted.

Checks on macOS with cargo 1.85.1: layerfs-api write_refusals,
concurrent_commits, method_refusal_order, lane_merge, sdk_chunk_deltas,
sdk_method_golden, diff_resume and sdk_compare; layerfs-store-service
tests; fmt and Clippy -D warnings clean.

Production LOC: 102171 -> 102187 (delta +16)
Scope: first-party core, engine and API production source. Core: 38241
-> 38241; engine: 61297 -> 61313 (layerfs-store-service 10016 -> 10032:
read_failure, and rustfmt splitting three calls that grew past the line
width); api: 2633 -> 2633. Method: tools/production_loc.py --root on git
archive of the first parent and the staged tree (5c005e0 ->
4ccb640). Reference absent (retired R9c; last counted 65,417 at tree
498dd19).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
yifanxuaaa added a commit that referenced this pull request Oct 26, 2026
… no Store call Workspace

Follow-up to #435. A write call's view reads (resolve, the fs.edit file
read, and the list/stat/readlink of recursive rm and cp) change nothing,
but their engine errors were Unknown, and an Unknown keeps the call's
Store call Workspace: 16 calls that failed reading the Store refused
every later write with Capacity until the Store reopened. They are now a
known Failed, so the Workspace closes; an Unknown from a step that
applies a change still keeps it.

Checked on macOS with cargo 1.85.1: the new write_refusals test passes,
and on main it fails with Unknown (call 16 Capacity); eight layerfs-api
write-route targets and the layerfs-store-service tests pass; fmt and
Clippy -D warnings clean.

Production LOC: 102171 -> 102187 (delta +16)
  First-parent comparison (main fbecc17 -> this merge). core 38241,
  engine 61297 -> 61313 (layerfs-store-service 10016 -> 10032), api 2633;
  reference absent (retired R9c; last counted 65,417 at tree
  498dd19). Counted with tools/production_loc.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Write through a file or symlink is refused Unknown and leaks a Store call Workspace (Capacity after 16)

2 participants