fix(runtime): mark a listing from the directory it actually read

A readdir of /data/alias is dispatched at /data/real and answers with
that directory's entries, but the marks were read off the typed path,
so every link inside an aliased directory came back unmarked: a wasi
guest read a directory link there as a directory and walked into it.
link_names_under resolves a link prefix now, the way the listing itself
is resolved. link_stats_under needs no such resolution, since it is
handed the path the router already rewrote.

The python door also reads the marks after the listing rather than
before, so a directory that will not list fails as readdir rather than
as the mark read.
This commit is contained in:
Zecheng Zhang
2026-08-21 13:17:34 -07:00
parent f3bfa49cf5
commit 4cd8c64fa2
10 changed files with 107 additions and 7 deletions
+34
View File
@@ -416,6 +416,40 @@
}
]
},
{
"id": "scandir_marks_links_through_a_directory_link",
"hosts": [
"python"
],
"world": {
"runtimes": [
"wasi",
"vfs"
],
"mounts": {
"/ram": {
"resource": "ram",
"files": {
"real/t.txt": "hello\n",
"real/sub/deep.txt": "x\n"
}
}
}
},
"steps": [
{
"command": "ln -s /ram/real/t.txt /ram/real/lk && ln -s /ram/real/sub /ram/real/dlk && ln -s /ram/real /ram/alias",
"expect": { "exit": 0 }
},
{
"command": "python3 -c \"import os\nfor e in sorted(os.scandir('/ram/alias'), key=lambda x: x.name):\n print(e.name, 'link' if e.is_symlink() else ('dir' if e.is_dir() else 'file'))\"",
"expect": {
"exit": 0,
"stdout": "dlk link\nlk link\nsub dir\nt.txt file\n"
}
}
]
},
{
"id": "rename_a_link_moves_the_name",
"hosts": [
+5 -2
View File
@@ -40,12 +40,15 @@ class MountResolver(Protocol):
...
def link_children(self, directory: str) -> set[str]:
"""The names of the symlinks living directly under ``directory``.
"""The names of the symlinks in the directory ``directory`` names.
Per directory, not per path, because a listing is where the
answer is needed and one table read serves every entry in it;
asked per entry it would be a readlink apiece for a fact the
name plane can hand over whole.
name plane can hand over whole. Answers for the directory the
path *names*, resolving a link the way the listing itself is
resolved, so a listing through an alias is marked from the
directory it actually read.
Args:
directory (str): absolute virtual directory path.
+5 -1
View File
@@ -184,8 +184,12 @@ class RuntimeVFS:
path (str): guest-absolute virtual path.
"""
entries: list[VFSEntry] = []
listing = self.call("readdir", path)
# After the listing, not before: a directory that will not list
# (ENOENT, or a link cycle the namespace refuses to resolve)
# must fail as readdir, not as the mark read.
links = self._link_names(path)
for raw in self.call("readdir", path):
for raw in listing:
linked = raw.rstrip("/").rsplit("/", 1)[-1] in links
if raw.endswith("/"):
entries.append(
@@ -471,10 +471,18 @@ class Namespace:
stat and has only to learn which of those names the node table
owns.
Resolves a link prefix first, because a listing does: a readdir
of ``/data/alias`` is dispatched at ``/data/real`` and answers
with that directory's entries, so the marks have to come from
there too or every link inside an aliased directory reads as
whatever its followed stat said. ``link_stats_under`` needs no
such resolution: it is handed the path the router already
rewrote.
Args:
directory (str): absolute virtual directory path.
"""
return {name for name, _ in self._links_under(directory)}
return {name for name, _ in self._links_under(self.follow(directory))}
def _links_under(self, directory: str) -> list[tuple[str, NodeMeta]]:
"""The links living directly under a directory, as (name, meta).
@@ -425,6 +425,18 @@ async def test_link_names_under_is_one_level_of_names(namespace):
assert namespace.link_names_under("/other") == set()
@pytest.mark.asyncio
async def test_link_names_under_answers_for_the_directory_a_link_names(
namespace):
# A readdir of an alias is dispatched at its target and answers with
# that directory's entries, so the marks come from there. Asking the
# typed path left every link inside an aliased directory unmarked,
# and a dir link inside it then read as a directory a walk recurses.
await namespace.symlink("/data/real/lk", "/data/real/t.txt", 1.0)
await namespace.symlink("/data/alias", "/data/real", 1.0)
assert namespace.link_names_under("/data/alias") == {"lk"}
@pytest.mark.asyncio
async def test_link_stats_below_spans_the_whole_subtree(namespace):
await namespace.symlink("/data/a", "/t1", 1.0)
@@ -32,12 +32,15 @@ export interface MountResolver {
/** The prefix owning `path` by longest match, or null. */
ownerOf(path: string): string | null
/**
* The names of the symlinks living directly under `directory`.
* The names of the symlinks in the directory `directory` names.
*
* Per directory, not per path, because a listing is where the answer
* is needed and one table read serves every entry in it; asked per
* entry it would be a readlink apiece for a fact the name plane can
* hand over whole.
* hand over whole. Answers for the directory the path *names*,
* resolving a link the way the listing itself is resolved, so a
* listing through an alias is marked from the directory it actually
* read.
*/
linkChildren(directory: string): Set<string>
}
@@ -168,6 +168,9 @@ export class RuntimeVFS {
if (!Array.isArray(out)) {
throw new TypeError(`runtime vfs: readdir ${path} expected array`)
}
// After the listing, not before: a directory that will not list
// (ENOENT, or a link cycle the namespace refuses to resolve) must
// fail as readdir, not as the mark read.
const links = this.resolver.linkChildren(path)
return await Promise.all(
out.map(async (raw): Promise<VFSEntry> => {
@@ -93,6 +93,20 @@ describe('runtime door readdir', () => {
isDir: false,
})
})
// Dispatch follows the alias and answers with the target's entries,
// so the marks have to come from the target too. Reading them off the
// typed path left a link inside an aliased directory unmarked, and a
// directory link there then read as a directory a guest walk descends.
it('marks the links inside a directory reached through a link', async () => {
const { ws } = mkWorld()
await ws.dispatch('mkdir', '/data/real')
await ws.fs.writeFile('/data/real/t.txt', 'hi')
await ws.namespace.symlink('/data/real/lk', '/data/real/t.txt', 1)
await ws.namespace.symlink('/data/alias', '/data/real', 1)
const entries = await doorOn(ws).readdir('/data/alias')
expect(entries.find((e) => e.path.endsWith('/lk'))).toMatchObject({ isLink: true })
})
})
// The wiring itself: the workspace hands its runtimes a resolver that
@@ -128,6 +128,18 @@ describe('Namespace symlink table', () => {
await ws.close()
})
// A readdir of an alias is dispatched at its target and answers with
// that directory's entries, so the marks come from there. Asking the
// typed path left every link inside an aliased directory unmarked, and
// a dir link inside it then read as a directory a walk recurses.
it('linkNamesUnder answers for the directory a link names', async () => {
const ws = new Workspace({ '/data': new RAMResource() })
await ws.namespace.symlink('/data/real/lk', '/data/real/t.txt', 1)
await ws.namespace.symlink('/data/alias', '/data/real', 1)
expect(ws.namespace.linkNamesUnder('/data/alias')).toEqual(new Set(['lk']))
await ws.close()
})
it('purgeUnder drops nested entries', async () => {
const ws = new Workspace({ '/data': new RAMResource() })
await ws.namespace.symlink('/data/sub/a', '/t1', 1)
@@ -382,8 +382,15 @@ export class Namespace {
// readdir row's link mark needs, which is a name question rather than
// a stat one: the door already holds every entry's stat and has only
// to learn which of those names the node table owns.
//
// Resolves a link prefix first, because a listing does: a readdir of
// `/data/alias` is dispatched at `/data/real` and answers with that
// directory's entries, so the marks have to come from there too or
// every link inside an aliased directory reads as whatever its
// followed stat said. `linkStatsUnder` needs no such resolution: it is
// handed the path the router already rewrote.
linkNamesUnder(directory: string): Set<string> {
return new Set(this.linksUnder(directory).map(([name]) => name))
return new Set(this.linksUnder(this.follow(directory)).map(([name]) => name))
}
// The links living directly under a directory, as (name, meta).