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:
@@ -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": [
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user