Repository navigation
fix(store): purge-org decides the users file by what deleting the root takes — a mount or link of a directory under the root no longer lets it delete the users file, and "move it out first" works when followed - #163
Merged
Conversation
…by what deleting the root takes, not by the path's spelling — /mnt/b/users.json, /mnt/b a bind mount of <root>/sub (named directly, through a file link or a directory link), compared as outside the root since realpath never crosses a mount back to its source, and was deleted; a mount point under the root, or the root mounted, is refused purge-org walks the root as rmSync takes it (lstat, never through a link) and refuses when an entry is the users file a pre-store build reads (the recorded one, else <base>/users.json): the same device and inode (on Windows the serial's low 32 bits), or on another device the same size and modification time at the coarser clock's precision (NFS, SMB, overlay and FUSE views). It names every such entry, hard links included. A mount point under the root, the root itself mounted, and a directory the walk cannot read are refused before anything is deleted: on Node 22 rmSync stops part-way at a mount point, on Node 23 and later it first empties the filesystem mounted there (reproduced on 24.19: a users file outside the root whose directory was mounted under it was deleted). The checks by path, and the Windows null refusal, run first and are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…st by where its path leads, so "move it out first" works — after the move the record still named the path under the root, realpath fell back to it as written and the same refusal came back; through a mount that is down (an empty mount point) the users file under the root was deleted; and one it cannot read (EACCES, a link loop), compared as written, was purged The comparison by path now holds only while the users file exists. One that does not is followed as the kernel resolves a path, every link that exists followed and a dangling one by its target, to the nearest directory that exists: a directory under the root (by identity) means nothing of it is left there and the purge goes on; any other is refused, failing closed, naming the ways out (mount it; or delete the root by hand when it does not lie under it). A users file outside the root deleted on purpose is refused alike (declared). An absent <base>/users.json, which nothing records, is nothing to keep, as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…ore it deletes anything — the users file a pre-store build reads, decided by what deleting the directory takes; a recorded users file that does not exist whose path does not lead under it; what it cannot read; a mount point — with each way out, and no longer says it deletes the directory "at once" The k8s note points there, and the purge-org docs test reads both and pins that each way out the README names is one purge-org's refusals name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…ollows a link under the root, passes an entry of a directory it may list but not search, passes an error finding where a missing users file's path leads, or drops the directory view rule — each of these changes to server/store/users-file-place.mjs stayed green
Four new cases, each in the existing test it belongs to, so the total stays
1440:
- links under the root that lead out (to the users file, to its directory,
to a directory on another filesystem): the purge passes. When the walk
stat()ed through links, each gave a false refusal ("lies under", "a mount
point").
- a directory it may list but not search (r--): readdirSync names the
entries, lstatSync of each fails with EACCES, and the purge is refused
naming that entry. This needs deny(p, code, { list }) in
server/fixtures/mount-fs.mjs.
- a part of the users file's path that stops answering once statSync found
nothing there: the purge is refused, naming that part.
- the NFS and SMB views' way out, followed: with the users file moved out of
the root, its path leads to the view of the directory under the root (the
same inode and modification time), and the purge passes.
The code is unchanged. The CHANGELOG Tests bullet names the four cases.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…recorded, for absent only when nothing is at its path — a link there that leads nowhere now (<base>/users.json → /mnt/b/users.json, /mnt/b a bind mount of <root>/sub that is down) was taken for absent and <root>/sub/users.json was deleted; it is now placed as a recorded path is, and refused With nothing recorded, users-file-place.mjs returned "absent, nothing to keep" as soon as statSync of <base>/users.json found nothing. A dangling link is found as nothing by statSync too. With the link leading through a bind mount of a directory under the root, and that mount down, the purge went on and deleted the users file behind the empty mount point, as develop did. Reproduced at 90e0fc5 with a real bind mount, in a private mount namespace: exit 0, and <root>/sub/users.json was missing. Now the default counts as absent only when lstatSync finds nothing at its path (ENOENT or ENOTDIR). A link that exists goes through lead(), as a recorded path does. It passes when it leads to a directory under the root. Otherwise it is refused: "the users file <base>/users.json (the one a pre-store build reads: …) does not exist, and nothing on this host tells where its path leads (to /mnt/b/users.json) …". Both ways out were followed literally against the real mount: - mount it: the lies-under refusal; move the file out; purged, with the moved file kept; - delete the root by hand: purged, and the org's records dropped. A dangling default link into the root, and no <base>/users.json at all, still pass. Test: the mount-down case in server/test-store-ops.mjs adds the default link. It is refused while the mount is down, refused as under the root once mounted, and passes once the file is moved out. It fails without the fix. Total stays 1440. README "Purge A Removed Org" and the CHANGELOG say so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…ot itself, that /proc/self/mountinfo lists — a bind mount of a directory from outside the root on its own filesystem is a directory to lstatSync, so the walk entered it, rmSync deleted that directory's files outside the root (Node 23 and later) before failing with EBUSY, and when it held the users file, moving it out as the "lies under" refusal said led to the same Reproduced at ad93aea with real bind mounts, in a private mount namespace, through tools/cli.mjs: - <X>/srv7 (same device as the root) bind-mounted at <root>/m7, the users file outside: Node 24.19 deleted <X>/srv7/other-data and threw EBUSY; Node 22.22 threw a raw EBUSY at rmdir <root>/m7. - <X>/srv6 holding the recorded users file, bind-mounted at <root>/m6: refused "Move <root>/m6/users.json out of <root>, then run purge-org again". Followed literally (the mv crosses mounts), the next purge on Node 24 deleted <X>/srv6/other-data and threw EBUSY; on Node 22 it threw a raw EBUSY. The way out did not work. users-file-place.mjs now reads /proc/self/mountinfo (off Windows; no such file, as off Linux or with /proc not mounted, leaves the walk alone) and compares the root by its real path, as the kernel lists mount points, with its octal escapes undone. An entry listed there is a mount point, after the device check: "<root>/m6 (the same filesystem as <root>, listed in /proc/self/mountinfo)" with the existing "Unmount what is mounted there" refusal, and the root itself with "… is a mount point … Unmount it". A /proc/self/mountinfo that exists but cannot be read is refused as an unreadable directory is. After, on Node 22.22 and 24.19, each refused deleting nothing, and its way out followed literally (umount, purge again) passed with the outside files kept: a directory bind with a space in its name, one holding the users file, the root itself bound from outside, a file bound at <root>/u.json, and a bind under a workspace reached through a symlink. A plain purge still passes. Test: the mount-point test in server/test-store-ops.mjs. The fixture (server/fixtures/mount-fs.mjs) gains realpathSync and, with { proc: true }, /proc/self/mountinfo generated from its mounts. Without mountinfo the walk still enters a same-filesystem bind (the declared limit). With it, binds at "<root>/m 6" (holding the users file) and <root>/m7 (other files only) are refused, and so is <root>/m7 under a root reached through a link, and the root itself. Each passes once unmounted. An unreadable mountinfo is refused. With users-file-place.mjs at ad93aea the test fails ("holding the users file"). Each of these mutations fails it too: no realpath, no unescape, a decimal unescape, the root not listed, an unreadable file taken for absent. The test count is unchanged (1440). Docs: the module header and the CHANGELOG no longer say /proc/self/mountinfo is not read. The "Known, not fixed" item now applies only where there is no such file, and the mount-point, Docs and Tests bullets were updated. The journey section and README "Purge A Removed Org" now say the same: on Linux such a bind is refused, and elsewhere it is unmounted by hand. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…e it goes on, and refuses one that changed while it read the root — a directory renamed, while the walk ran, from one it had not read yet into one it had read already was in neither listing, so the users file in it (/mnt/b/users.json, /mnt/b a bind mount of <root>/z/sub, <root>/z/sub moved into <root>/a) was deleted The walk read each directory once, at its own moment, and the module declared "a rename by another process between the two is not seen". Reproduced at 941c92c: - injected fs (the verifier's probe): a hard link of the users file at <root>/z/u.json moved into <root>/a, read already, when the walk reaches <root>/packs: the refusal was null; the same tree with no rename is refused. - real ext4, a real bind mount, in a private mount namespace: the users file recorded as <X>/mnt/b/users.json, <X>/mnt/b a bind mount of <root>/z/sub, and a real renameSync of <root>/z/sub into <root>/a made at that step of the walk: the refusal was null, and the rmSync purgeOrg runs next deleted the users file (gone through the mount and from the disk). users-file-place.mjs now keeps, for each directory the walk lists, its device, inode and change time (from the lstat before the listing) and its names. When the users file exists and no entry is it, each of those directories is read again before the purge goes on, after the users file was placed. A directory that is not the same directory with the same change time and the same names is refused: "<dir> under <root> changed while purge-org read <root> (an entry added, removed or renamed in it, or a mount over it), so purge-org cannot tell what deleting <root> takes — nothing was deleted. Stop what changes files under <root>, then run purge-org again". After, on the same real bind mount, the rename is refused, deleting nothing, and run again with nothing changing it, the walk finds the users file at <root>/a/sub/users.json and refuses it as lying under the root. The verifier's probe is refused too. Test: one more in server/test-store-ops.mjs, over server/fixtures/mount-fs.mjs, whose inodes now carry a change time that moves with every entry added, removed or renamed in a directory, and whose { coarse: true } keeps every change time the same. With users-file-place.mjs at 941c92c it fails. Each part of the second reading is pinned: - no second reading: the rename into <root>/a fails; - no change time: a rename on into the root before <root>/a is read again (its names as the walk found them) fails; - no names: the rename on a coarse clock fails; - no inode: on a coarse clock, a directory from outside mounted over <root>/a once it is read, holding the same names, fails. The rename into the root itself is refused naming the root. Docs: the module header, a CHANGELOG bullet (Suite total 1440 → 1441) and the "Known, not fixed" bullet, which now says what is not seen: what is moved, linked or mounted under the root after the check, a directory changed and changed back within one tick of a coarse filesystem clock, and a same-filesystem bind mount made under the root after /proc/self/mountinfo was read, before the walk reaches it. README "Purge A Removed Org" lists the refusal with its way out (the purge-org docs test pins it against the module's text), and the journey section closes with Tests: 1440 → 1441. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…ystem names the second step its way out may take — moved out as it said, /ovl/users.json (/ovl an overlay mount with lowerdir=<root>/sub and an upper directory, whose merged directory shows the upper's inode and modification time) was refused again as a users file that does not exist, while README and CHANGELOG said the purge then goes on Reproduced at 4e5e31f with a real overlay, in a private mount namespace, through tools/cli.mjs: the users file recorded as <X>/ovl/users.json, <X>/ovl an overlay with lowerdir=<root>/sub and an upperdir outside the root, xino on or off. The first run refused it as lying under the root: "Move <root>/sub/users.json out of <root>, then run purge-org again". Followed (moved out, the overlay mounted again), the second run refused again: "the recorded users file <X>/ovl/users.json does not exist, and nothing on this host tells where its path leads: the nearest part of it that exists, <X>/ovl, is no directory under <root> ...". The merged directory shows the upper directory's inode and modification time, not <root>/sub's, so nothing ties the path back to the root. That refusal's own way out (delete <root> by hand, then run purge-org again) works, and a read-only overlay (lower layers only) shows <root>/sub's inode and time and purges; but README said "After the first refusal's move, with any mount on its path up, that path leads under orgs/<id>/ ... so the purge goes on", and CHANGELOG "The way out holds followed literally". The rule for a users file that does not exist still fails closed: the merged directory is no directory the walk read, and nothing ties it to <root>/sub short of parsing each kind of mount's options or source (an overlay's layers, an sshfs or SMB server's path), which this check does not. So a refusal naming an entry by its size and modification time on another filesystem now names the second step: "Move <entry> out of <root>, then run purge-org again; if it then refuses because <path> does not exist (a mount whose directories show another inode or modification time than theirs under <root>, as an overlay mount with an upper directory does, cannot be traced back there), delete <root> by hand, then run purge-org again: it drops org <id>'s records. If <path> then no longer leads ...". A refusal naming entries on the users file's own filesystem is unchanged. After, on the same real overlay, xino on and off: the first refusal names the second step; followed literally (moved out, run, refused as above, <root> deleted by hand, run) the purge drops the records, exit 0, and the moved users file stays. With the overlay left mounted through the move, and with a read-only overlay, the second run purges. Test: server/test-store-ops.mjs's view test (extended, no new test; the total stays 1441) adds an overlay at /mnt/merged over <root>/sub with an upper directory outside the root, its merged directory showing the upper directory's inode and time; pins each view's refusal with the second step; and follows the way out: through that overlay, the xino overlay and the sshfs-like view, the moved-out path is refused as a users file that does not exist (the real-filesystem test of a recorded users file that no longer exists deletes the root by hand and passes). With users-file-place.mjs at 4e5e31f it fails, and so does the purge-org docs test, which now pins the second step in README and in the module. unknownPlace moves up beside underRoot. Docs: README "Purge A Removed Org" names the second step, and says the purge goes on after the move when purge-org can trace the path back (a bind mount, a link, or a mount that shows that directory's own inode and modification time) and is refused otherwise; the CHANGELOG bullets quote the way out with its second step and say when it holds; the module header and the journey section say the same. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…ectory for a mount point only when /proc/self/mountinfo lists it — a workspace in an overlay mount whose layers lie on different filesystems (xino=off) shows a lower layer's files on that layer's device, so it was refused as holding a mount point at <root>/packs/a1.pack.yaml, where nothing is mounted and unmounting, its way out, could not be followed Reproduced at 6b15327 with a real overlay, in a private mount namespace, through tools/cli.mjs: the deployment made on a tmpfs lower layer, the upper and work directories on ext4, the overlay mounted with xino=off and OBSERVOGRAM_WORKSPACE set to the workspace in its merged directory. The directories report the overlay's device (48), the lower layer's files another (50), as overlayfs reports a non-directory when its layers are not on one filesystem and xino is off. purge-org refused: "<root> holds a mount point, <root>/packs/a1.pack.yaml (another filesystem than <root>/packs) … Unmount what is mounted there, then run purge-org again", exit 1. Nothing is mounted there, /proc/self/mountinfo lists nothing under the root, and running it again gave the same refusal. develop purges this workspace. users-file-place.mjs now takes a directory on another device than the directory that holds it for a mount point, as before, and any other entry on another device only when /proc/self/mountinfo lists it: a non-directory is a mount point only as a file mounted on a file, which Linux alone makes, and lists. A listed entry on another device keeps the "another filesystem than <dir>" text; one on the same device keeps "the same filesystem as <dir>, listed in /proc/self/mountinfo". Where there is no /proc/self/mountinfo (off Linux, /proc not mounted) nothing changes: such an entry is refused as before. After, on the same real overlay, the purge deletes <root> and rewrites the marker, exit 0. Also real, in private namespaces: a tmpfs file bind-mounted on a file under the root is still refused ("another filesystem than <root>/packs"), and purges once unmounted with the tmpfs file kept; an ext4 file bind-mounted on a file under the root is refused as listed in /proc/self/mountinfo; and the overlay with /proc unmounted is refused as before, and purges once proc is mounted at /proc again — the residual, declared below. Everything made was unmounted and removed; findmnt lists nothing of it. Test: server/test-store-ops.mjs's mount-point test (extended, no new test; the total stays 1441) adds, where /proc/self/mountinfo is read, two files under the root on another device than their directories with no mount there (an overlay's lower layer), and the purge passes; and a file from another filesystem mounted on a file under the root, which /proc/self/mountinfo lists, refused as another filesystem, passing once unmounted. server/fixtures/mount-fs.mjs's file() takes a dev for that. With users-file-place.mjs at 6b15327 the test fails ("an overlay's lower-layer files: no mount point"); with the new condition's listed check dropped the file mount fails, and with its no-mountinfo check dropped the existing no-mountinfo file mount fails. Docs: the module header says what a mount point is now, and that without /proc/self/mountinfo any entry on another device than its directory is refused, a lower layer's file included — on Linux with /proc not mounted a false refusal, whose way out is to mount proc at /proc (mount -t proc proc /proc), then run purge-org again. The CHANGELOG mount-point bullet, its "Known, not fixed" and Tests bullets, and README "Purge A Removed Org" say the same. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…s file outside the root that no longer exists as the host resolves its path — it spelled the path's nearest part as created and named no place, so with the temporary directory reached through a link (macOS: /var → /private/var) it failed, though the refusal named truly where the path leads; the case now runs through a directory link on every host too Reproduced at f5bc546 on Linux, TMPDIR set to <X>/var/tmp with <X>/var a link to private/var, as macOS reaches /var/folders through /var → /private/var: node --test server/test-store-ops.mjs fails test 77 ("a recorded users file outside the root that no longer exists ..."). The refusal read "the recorded users file <X>/var/tmp/…/users.json does not exist, and nothing on this host tells where its path leads (to <X>/private/var/tmp/…/users.json): the nearest part of it that exists, <X>/private/var/tmp/…, is no directory under <root> …", and the test expected the nearest part as spelled at its creation and no "(to …)". users-file-place.mjs follows every link on the path (lead()), as the kernel resolves it, and the refusal names where it leads when that is not where it is spelled: true on every host, so the expectation was wrong, not the refusal. Tests 12 and 27 (the export ones) fail under that TMPDIR at develop 4123bc7 too; they are not this branch's and are left as they are. Linux and Windows CI were not affected: their temporary directory is its own real path. Test: that test (no new test; the total stays 1441) now expects the place and the nearest part as this host resolves them (realpathSync of the users file's directory), naming the place when it differs from the spelling, and runs the refusal twice: the users file named as created, and through a directory link to its directory (a junction on Windows, as the directory-link test above makes one), which differs on every host. With the expectation spelled as before, the linked case fails on a plain Linux temporary directory ("(to /tmp/observogram-ops-users-…/users.json)" not expected); now the test passes there and under the linked TMPDIR, where test 77 failed. The product is unchanged. Docs: the CHANGELOG Tests bullet says the case runs named directly or through a directory link, the refusal naming where its path leads as the host resolves it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…TF-8 as the bytes the filesystem holds — listed as a string it came back with U+FFFD in place of each such byte, a name no lookup finds, so the walk took it for one gone while it ran and read nothing under it, and the users file there (<root>/c\xffd/users.json, recorded as a link to it) was deleted Reproduced at 84c12b5 and at develop 4123bc7, through purgeOrg on ext4: org acme removed, <root>/c\xffd/users.json holding the users file, the recorded users file <X>/cfg/users.json a symlink to it. Both purged it (deleted=true, the bytes gone). The same layout named cxd is refused at 84c12b5 ("it is <root>/cxd/users.json there"). The walk called readdirSync(at) with the default utf8 encoding: 'c\xffd' came back as 'c�d', lstatSync of it failed with ENOENT, and the walk's "gone while the walk ran" skip dropped the directory and everything under it, while rmSync (Node 22's rimraf lists Buffer names) deleted it. The same skip hid a mount point below such a name: in a private mount namespace, a bind mount of a same-filesystem directory and a tmpfs, each at <root>/c\xffd/inner/m, both gave a raw "EBUSY: resource busy or locked, rmdir '<root>/c�d/inner/m'" at 84c12b5. users-file-place.mjs now lists each directory with { encoding: 'buffer' }. A name that is valid UTF-8 joins its directory's path as a string, as before. Any other name, and every path under it, is joined as bytes with path.sep; the mount point text names the directory the walk came from rather than path.dirname of a Buffer, the /proc/self/mountinfo key of such a path is its bytes after the root read as UTF-8 (as the file itself is read), and the second reading compares names by their bytes. A refusal names such a path as a POSIX shell's $'…' spells it, each byte that is not part of a UTF-8 character as \xNN, a backslash or a quote escaped, so it can be typed. The paths of names that are valid UTF-8, and every refusal about them, are unchanged. After, same scripts and namespaces (under /tmp/claude-0/fu/wt-y-pu-r21, removed; findmnt lists nothing of it): - c\xffd, and run\xe9/inner/users.json: refused, "it is $'<root>/run\xe9/inner/users.json' there — nothing was deleted. Move $'<root>/run\xe9/inner/users.json' out of <root>, then run purge-org again …", nothing deleted. Followed literally in bash (mv $'<root>/run\xe9/inner/users.json' <X>/keep/): purged, the moved file kept. cxd is refused as before. - the users file outside, other files under run\xe9/inner: purged. - the bind mount below c\xffd: "$'<root>/c\xffd/inner/m' (the same filesystem as $'<root>/c\xffd/inner', listed in /proc/self/mountinfo)"; the tmpfs: "(another filesystem than $'<root>/c\xffd/inner')". After umount, each purged with the mounted directory's files kept. Test: one more in server/test-store-ops.mjs (1441 → 1442), over server/fixtures/mount-fs.mjs. The fixture now spells a byte that is not part of a UTF-8 character as U+DCNN, takes a path as a Buffer, and answers as node:fs does: readdirSync lists strings with U+FFFD for each such byte, or bytes with { encoding: 'buffer' }; readlinkSync, realpathSync and /proc/self/mountinfo likewise. The test covers the users file in <root>/c\xffd reached through a link, refused naming it as $'…', with its way out followed; with the users file outside, a tree of such names, purged; and below such a name a mount from another filesystem, a same-filesystem bind mount that /proc/self/mountinfo lists, and a directory it cannot read, each refused. With users-file-place.mjs at 84c12b5 it fails (refusal null). So does each of these mutations: a refusal naming the path with U+FFFD, the mountinfo key taken from the whole path, a name joined as its UTF-8 decoding, and a byte spelled as U+FFFD in $'…'. The test needs no root, no mount and no such name on disk: it runs over the fixture on POSIX paths on every platform. Docs: the module header says the walk lists names as bytes and how a refusal names such a path. A CHANGELOG bullet (Suite total 1441 → 1442) and a journey paragraph (Tests: 1441 → 1442) describe the change. README "Purge A Removed Org" says it reads every name as the bytes on disk, and how it names such a path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…file's name — a mount that is down keeps what was written in its mount point meanwhile, so the recorded path (/mnt/b/users.json, /mnt/b a bind mount of <root>/sub that is down, a users.json written there) led to that file outside the root, and <root>/sub/users.json, the users file the path leads to once the mount is back, was deleted Reproduced at b656e5a and at develop 4123bc7, through purgeOrg on ext4: org beta removed, the users file at <root>/sub/users.json, users_file recorded as <X>/mnt/b/users.json, a users.json of its own written in <X>/mnt/b. With <X>/mnt/b a bind mount of <root>/sub (in a private mount namespace) it is refused at b656e5a ("lies under <root>: it is <root>/sub/users.json there"); with the mount down it was purged (deleted=true) and <root>/sub/users.json was gone — mounting it again failed, its source gone. develop purged it alike. Over server/fixtures/mount-fs.mjs, file('/ws/orgs/beta/sub/users.json', { size: 300n }) and file('/mnt/b/users.json', { size: 40n }) passed (exists=true, refusal null). Failing closed through a mount that is down (b83d0ff) held only while the users file did not exist: statSync found the file written in the mount point, and no entry under the root was it. users-file-place.mjs: when the users file exists, no entry is it and no directory changed, an entry under the root whose name is one the users file's path ends in — as written, and at each link it leads through to the file — compared ignoring case and Unicode normalization (NFC, lower case; as a case-insensitive filesystem may look a name up), is refused, on every platform, recorded or <base>/users.json alike. One that is a link names what it leads to under the root too (statSync of it, matched as entries are), which rmSync deletes once the link is moved out. The refusal: "<who> is no file under <root>, but <entry> under it has its name[, and <x> is what a link of that name leads to]: a mount that is down keeps what was written in its mount point meanwhile, and mounted again it may lead the path there — nothing was deleted. Move <entries> out of <root>, then run purge-org again; if it then refuses because <path> does not exist (the users file seen there through a mount that cannot be traced back), delete <root> by hand, then run purge-org again: it drops org <id>'s records. If the users file is among them, make <path> lead to it again before a pre-store build or the in-place `packc store export` uses it". A declared false refusal: an entry of that name that is not the users file; moving it out is the way out. A view of a directory under the root that reports neither the inode nor the size and modification time (some FUSE filesystems), which passed, shows the directory's names and is refused by the name; moved out, its path leads where nothing traces it back, the second step the refusal names. Not seen (declared): a file mounted on a file of another name whose mount is down, its mount point a file of its own, and what a link of that name under the root leads to back through the mount that is down. server/test-store-ops.mjs, one more test, over mount-fs.mjs: through a mount that is down whose mount point holds a file of its name, refused — named directly, through a file link, through a link of another name, in another case or Unicode normalization, and, none recorded, <base>/users.json a link there — a link of that name naming what it leads to; mounted again, refused as lying under the root; moved out, passing with the mount up or down. It fails at b656e5a (refusal null), and with each part of the rule taken out in turn (the case fold, NFC, the link chain, the link's target). The place suite's users file outside the root shared its name with betaVolume's <root>/sub/users.json: it now has a name no entry under the root has (/srv/accounts.json, /srv5, /srv6, /priv, /other and C:\out likewise), the UTF-8 case takes <root>/sub/users.json out of its volume, the coarse-clock mount over <root>/a (which must hold /srv5's names) expects the name refusal with nothing mounted, and the FUSE view's case expects the name refusal. After, same script and private namespaces (under /tmp/claude-0/fu/wt-y-pu-r24a and -r24b, removed; findmnt lists nothing of it): the mount down, refused ("is no file under <root>, but <root>/sub/users.json under it has its name …"); <root>/sub/users.json moved out as it says, then purged, and the moved file (alice, bob, carol) and the file in the mount point stay. The mount down, refused; mounted again, refused as lying under the root; moved out with the mount up, purged, the moved file kept. Docs: README "Purge A Removed Org" names the refusal and its way out; CHANGELOG bullet (Tests 1442 → 1443), its Known-not-fixed (the FUSE view, what the name does not tell), Docs and Tests text; UPDATE_JOURNEY paragraph, Tests: 1442 → 1443; the module's and the fixture's headers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…ose path is looked up only in directories the root lies in, with no entry under the root of its name — moved out of acme's root as its refusal said and acme purged, the record named a path in a directory that no longer exists, and every later purge of another org was refused ("the nearest part of it that exists, <base>/orgs, is no directory under <base>/orgs/beta") until the path led to the moved file again, which meant re-creating directories in the purged root
Reproduced at 13d77ca through purgeOrg (the new test) and through `packc
store purge-org` on ext4 (a store set up through bootStore, under
/tmp/claude-0/fu/wt-y-pu-r25-*, removed): users_file recorded as
<ws>/orgs/acme/sub/users.json; purge acme refused ("lies under
<ws>/orgs/acme: it is <ws>/orgs/acme/sub/users.json there …"); the file
moved to <X>/keepA; purge acme purged; beta removed; purge beta refused,
exit 1: "the recorded users file <ws>/orgs/acme/sub/users.json does not
exist, and nothing on this host tells where its path leads: the nearest
part of it that exists, <ws>/orgs, is no directory under <ws>/orgs/beta
…" — and so for every org purged afterwards. After, the same script:
"purged org beta: deleted <ws>/orgs/beta", exit 0, keepA intact.
users-file-place.mjs: lead() records each directory it looks a name up in
('..' is not counted: from a mount it leads to its mount point's
directory, as from the directory there now). A users file that does not
exist, every such directory one the root lies in — its parent and theirs
on the root's real path, each by its path (case folded on Windows) and its
device and inode — and whose name (as written or at each link it leads
through, folded as 13d77ca folds it) no entry under the root has, is
nothing to keep: a filesystem mounted again on one of those directories
covers the root too, so through it the path leads under the root only by
a directory under the root mounted above it, which shows the users file
by its name. The name check 13d77ca applies to a users file that exists
is factored out and shared. Any other path is refused as before, with the
same refusal and ways out. Declared not seen: a filesystem down now,
mounted on a directory the root lies in, whose link leads into the root
another way (through a second mount of the root's filesystem).
Real mounts, inside `unshare -m --propagation private` (under
/tmp/claude-0/fu/wt-y-pu-r25atk-*, removed; findmnt lists nothing of
it), at 13d77ca and after: <ws>/orgs a bind mount of <root>/x that is
down, the users file written through it at <root>/x/gamma/sub/users.json
and recorded as <ws>/orgs/gamma/sub/users.json — refused at both, the file
kept (with the name check taken out, purged and the file deleted); an
empty mount point outside the root, and a link in <X>/cfg into a
sibling's directory that does not exist — refused at both; recorded
directly in a sibling's directory that does not exist — refused at
13d77ca, purged after.
server/test-store-ops.mjs, one more test, on the real filesystem through
purgeOrg (no root, no mount): the users file recorded under acme's root
refused, moved out, acme purged, beta removed and purged, the moved file
kept — it fails at 13d77ca. The place suite's path in a sibling org's
directory that does not exist now expects the purge to go on (deeper in
it, a file deleted in /ws, a link in /ws, the root named through a link),
and it adds refusals: through a link in a directory the root does not lie
in, to a file or to a directory, and with an entry of its name under the
root (passing once that is moved out). The Windows-paths case adds a
sibling's directory by any spelling, passing, and a directory that differs
from one the root lies in by case alone, on a case-sensitive directory,
refused. Each part taken out in turn fails a test: the name check, every
directory (only the nearest checked), the real path (the root as
spelled), the device and inode, the case fold on Windows.
Docs: README "Purge A Removed Org" says when a users file that does not
exist lets the purge go on through directories the root lies in;
CHANGELOG bullet (Suite total 1443 → 1444), the earlier bullet's declared
false refusal and "any other is refused" narrowed to it, Known-not-fixed,
Docs and Tests text; UPDATE_JOURNEY paragraph, Tests: 1443 → 1444; the
module's header.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…f a directory's entries only the listings of the directories it is in — it kept every entry's stats until it ended, so with 100 directories of 1,000 empty files under the root it died out of memory in a 40 MB heap, nothing deleted, where rmSync of the same tree completed Reproduced at 4439b0e on ext4 (under /tmp/claude-0/fu/wt-y-pu-mem, removed): <ws>/orgs/beta/runs/r0..r99 with 1,000 empty files each, <ws>/users.json outside; usersFilePlace(<ws>/users.json, <ws>/orgs/beta) under node --max-old-space-size=40 ended "FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory", while fs.rmSync(<ws>/orgs/beta, { recursive: true }) of a copy under the same flag printed "rmSync done". Unlimited, the walk took 785 ms and the heap held 90 MB at the end. entries[] kept { path, BigIntStats } for every entry (eighteen bigints, some 770 bytes) because the users file was stat'ed only after the walk. users-file-place.mjs: the users file is stat'ed before the walk, and each entry is compared with it — and with the names its path ends in (computed before the walk now, as namesakesOf computed them after it) — as the walk reaches it; only matching entries are kept (found, namesakes). It is stat'ed again after the walk, with every refusal in the order it had: when it changed meanwhile (another device, inode, size or modification time — created, replaced, written), each entry is read again in the walk's order and compared with it, as entries were. The targets of a namesake link are found by the same reading. Of a directory the walk keeps its device and inode (one joined string, the walked key), its change time, a sha256 digest of its names and its modification time — what the second reading and the place of a users file that does not exist compare — about 340 bytes a directory, half of what template-literal ropes took. The walk now takes a directory's entries from its listing as it goes (a frame per directory it is in, its names kept as strings unless not valid UTF-8, in the order it had) instead of pushing every child ahead of it, so it holds only the listings of the directories it is in, as Node 22's rmSync does. No refusal, text or default changes; the place suite passes unchanged. After, same trees and flag: usersFilePlace passes in 16 MB (heap 10 MB at the end, 761 ms). Measured under --max-old-space-size against rmSync: one directory of 100,000 files passes in 32 MB (rmSync 24 MB; before: not in 96 MB), 100,000 empty directories in 40 MB (rmSync 16 MB; before: not in 96 MB). The memory grows with the directories under the root and the longest listing, not with the files. server/test-store-ops.mjs, one more test: over an injected filesystem that makes up a root of 250,000 files — 150 directories of 1,000 and one of 100,000, stats with node's eighteen bigints — as each is asked for, the check in a child with --max-old-space-size=32 passes and reads every entry. With users-file-place.mjs at 4439b0e the child dies (SIGABRT, "heap out of memory"); so it does with this commit's per-entry comparison but each directory's children pushed ahead of the walk. The test runs as any user on every platform (POSIX paths over the injected filesystem). It also pins the second stat, over server/fixtures/mount-fs.mjs, the change made once the walk has read <root>/packs: the users file replaced by a hard link to a file under the root that kept its size and time (another inode alone; its two names there named in the walk's order, as with nothing changing it), replaced by a link to a file of another filesystem with the same inode number, size and time while a copy under the root kept them (another device alone), created as a hard link, and written in place through an NFS view (size alone, time alone) — each refused as lying under the root, as at 4439b0e. Taking out the second reading, or any one of device, inode, size and modification time from the comparison of the two stats, or reading entries again directory by directory instead of in the walk's order, fails it. Docs: the module's header; CHANGELOG bullet (Suite total 1444 → 1445); UPDATE_JOURNEY paragraph, Tests: 1444 → 1445. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…directory on another filesystem with the inode number of one under the root for a view of it whatever its modification time, drops the rest of a missing users file's path after an absolute link, or follows fewer than 40 links — each of these changes to server/store/users-file-place.mjs stayed green Reproduced at 0f25ead, each change applied alone to users-file-place.mjs (scratch worktree under /tmp/claude-0/fu/wt-y-pu-mut, removed), with `node --test-name-pattern=purge-org server/test-store-ops.mjs` and `node server/test-store-paths.mjs` at 0 failures for: a directory view without the inode check (M7), without the time check (M8), an error finding where a missing users file's path leads passed (M19), the rest of the path dropped after an absolute link (M25), and a link-hop limit of 1 in that lookup (M26). At cc5c468, M7, M19 and M25 fail already (the overlay and sshfs views' second step, the part of the path that stops answering, the refusal's "(to …)"); M8 and M26 stayed green, and M25 failed only by the place a refusal names, never by the pass it gives: <cfg>/d a link to <root>/sub, <root>/sub/l a link to a mount point that is down, the record <cfg>/d/l/users.json missing — refused, under M25 passed. Three cases, in the test of a users file that does not exist, so the total stays 1445: - a mount point that is down on another filesystem than the root's, its directory with the inode number of <root>/sub (each filesystem numbers its own inodes) and its own modification time: refused as a users file that does not exist. Without the time check it passed, the mount-down hole again. This needs dir(p, { ino }) in server/fixtures/mount-fs.mjs. - /cfg/ds/l/users.json, /cfg/ds a link to <root>/sub and <root>/sub/l a link to /mnt/b, a mount point that is down: refused naming /mnt/b and "(to /mnt/b/users.json)". Without the rest of the path it passed. - a chain of 40 links (as many as Linux follows in a path: 40 resolve, 41 fail with ELOOP, checked here) to the users file's place under the root, moved out: the purge passes. With a limit of 1, or 39, it is refused as a link loop. Each case fails alone under its change and passes as the module is. The code is unchanged. The CHANGELOG Tests bullet names the three cases. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…that a later mount covers — with a tmpfs mounted on the workspace over a bind mount at <root>/m, <root>/m was a plain directory of the tmpfs, yet purge-org refused it as a mount point, and unmounting, its way out, failed with "not mounted" Reproduced at 4da1239 with real mounts in a private mount namespace, through tools/cli.mjs: D/outside bind-mounted on D/ws/orgs/acme/m, a tmpfs mounted on D/ws, the removed-org deployment made on the tmpfs and D/ws/orgs/acme/m created there (the same device as its parent). /proc/self/mountinfo still lists D/ws/orgs/acme/m (mount 186, parent 98) beside D/ws (187, parent 98). purge-org refused: "<root> holds a mount point, <root>/m (the same filesystem as <root>, listed in /proc/self/mountinfo): … Unmount what is mounted there, then run purge-org again", exit 1; `umount D/ws/orgs/acme/m` said "not mounted" (exit 32), and the next purge refused word for word. 941c92c took every path mountinfo lists under the root's real path for a mount point there, and the kernel lists a mount that a later one covers too. users-file-place.mjs now reads each mountinfo line's mount ID and parent mount ID beside its mount point, and passes over a covered mount: one with a mount in the same parent mount on a directory above it, or whose parent is covered so. Through that parent the path walk crosses into the mount above before it reaches the covered mount point, so nothing is mounted at its path. A mount stacked on another at the same path has that mount as its parent and stays listed, as does every mount the walk can reach. After, real, in private namespaces (everything made unmounted and removed; findmnt lists none of it): the case above purges, exit 0, with D/outside/f kept; so do a bind mount inside the covered one (D/ws/orgs/acme/m/n, parent 186) and the root itself covered (a bind on D/ws/orgs/acme, then the tmpfs on D/ws); a same-filesystem bind mount made on the tmpfs at D/ws/orgs/acme/m (parent 187, over the covered one) is refused as before, and followed literally — umount D/ws/orgs/acme/m — the purge passes with the bind's source kept; the root likewise ("… is a mount point … Unmount it"; umount; purged). Test: server/test-store-ops.mjs's mount-point test (extended, no new test; the total stays 1445), where /proc/self/mountinfo is read: two mounts covered by a filesystem mounted on /ws — one in the other — listed still, their paths directories of the filesystem above, unmounting them EINVAL; the purge passes; a same-filesystem mount made there on the filesystem above refused, and once unmounted as its refusal says, passing; the root covered passing, the root mounted on the filesystem above refused and passing once unmounted. server/fixtures/mount-fs.mjs now places each mount in the mount its target was in (its parent), so a mount on a path mounted already stacks on it and one over a directory above covers it; /proc/self/mountinfo lists each mount's ID and its parent's, a covered one too; unmount fails with EINVAL where nothing is mounted (umount(8): not mounted) and EBUSY where a mount lies in it. With users-file-place.mjs at 4da1239 the test fails ("covered mounts: none is a mount point, purged", the refusal above); with the parent-covered check dropped it fails on the mount in the covered one (<root>/m/n refused); with the covering mount taken from any parent, not the same one, it fails at once ("holding the users file": the bind at <root>/m 6 passed over). Docs: the module header says a covered mount is no mount point, and adds to what is not seen a same-filesystem bind mount uncovered between the mountinfo read and the walk (a bind mount from the same filesystem over a directory above the root, unmounted). The CHANGELOG mount-point bullet, its "Known, not fixed" and Tests bullets, UPDATE_JOURNEY's mount paragraph and README "Purge A Removed Org" say the same. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…path leads into a directory under the root while an entry under the root has its name — with <root>/other bind-mounted on <root>/sub/m and the users file recorded through it, the mount was refused as a mount point, and unmounted as that refusal said, the purge deleted <root>/other/users.json Reproduced at c86f545 with a real bind mount in a private mount namespace (unshare -m --propagation private, under /tmp/claude-0/fu/wt-y-pu-r32-*, removed; findmnt lists none of it), through tools/cli.mjs on a removed-org deployment: <root>/sub/m and <root>/other made, the users file copied to <root>/other/users.json, `mount --bind <root>/other <root>/sub/m`. (a) Recorded as D/cfg/users.json, a link to <root>/sub/m/users.json: purge-org refused "<root> holds a mount point, <root>/sub/m (the same filesystem as <root>/sub, listed in /proc/self/mountinfo) … Unmount what is mounted there, then run purge-org again"; after `umount <root>/sub/m` it printed "purged org acme: deleted <root>", exit 0, and <root>/other/users.json was gone. (b) Recorded as <root>/sub/m/users.json: the same. At develop 4123bc7, (a) was refused mounted ("lies under <root> — move it out first") and purged once unmounted, the file deleted; (b) was refused mounted, unmounted and after the move alike. A users file that does not exist whose path leads into a directory the walk read — here the empty mount point <root>/sub/m — passed at once: the name check 13d77ca made because a mount that is down tells nothing held only for a users file that exists and for a path through directories the root lies in. users-file-place.mjs: that pass now also needs no entry under the root with the name the path ends in (as written or at each link it leads through, folded as before). With one it is refused: "the recorded users file <path> does not exist, and its path leads into <dir>, under <root>, but <entry> under it has its name: a directory on its path may be the mount point of a mount that is down, and mounted again it may lead the path there — nothing was deleted. Move <entry> out of <root>, then run purge-org again. If the users file is among them, make <path> lead to it again before a pre-store build or the in-place `packc store export` uses it", naming what a link of that name leads to under the root too. The refusal of a users file that exists with an entry of its name is built by the same function now and reads byte for byte as before. After, the same script: both cases refused after the umount, exit 1, the file kept; followed literally — <root>/other/users.json moved to D/keep — "purged org acme: deleted <root>", exit 0, the moved file intact. The mount-point refusal is unchanged. Problem (2)'s way out (the users file recorded under the root, moved out) passes as before while no other entry under the root has its name; one that has it is refused alike (a declared false refusal), and moving it out too is its way out. Test: server/test-store-ops.mjs, one more (1445 → 1446), with no root and no mount (a junction for the directory link, so Windows runs it too): over server/fixtures/mount-fs.mjs with /proc/self/mountinfo, that bind mount up refused as a mount point and, unmounted, refused by the name — recorded through a file link, directly and through a directory link — a link of that name naming what it leads to, and moved out passing; on the real filesystem through purgeOrg, what that mount leaves once it is down (<root>/sub/m empty, the users file at <root>/other/users.json) refused, recorded directly and through a junction, nothing deleted, then moved out and purged with the moved file kept. With users-file-place.mjs at c86f545 it fails at "/cfg/users.json, unmounted" (refusal null), and with the injected part taken out, at the real-filesystem part ("Missing expected rejection"). Run as uid 65534 (setpriv) it passes. The place suite's view case, whose fixture holds <root>/up/users.json besides the users file, now expects the NFS and SMB views' way out refused by that name, passing once it is moved out too. Docs: the module header; README "Purge A Removed Org" (the name bullet covers a users file that does not exist whose path leads into a directory under the root, and the path after the first refusal's move passes while no entry has the users file's name); CHANGELOG bullet (Suite total 1445 → 1446), the earlier "the purge goes on" narrowed; UPDATE_JOURNEY paragraph, Tests: 1445 → 1446, the earlier paragraph narrowed alike. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…ose path is looked up only in directories the root's own path is looked up in too — with the workspace reached through a link in a directory the root does not lie in (/home/u/ws → /data/ws), or its orgs/ such a link, the users file moved out of acme's root as its refusal said and acme purged, every later purge of another org was still refused ("the nearest part of it that exists, /data/ws/orgs, is no directory under /home/u/ws/orgs/beta")
Reproduced at 0f53b12 on the real filesystem through purgeOrg (node
22.22), no mount involved: D/home/u and D/data/ws made, D/home/u/ws a link
to D/data/ws, a removed-org deployment at D/home/u/ws, the users file
written at <ws>/orgs/acme/sub/users.json and recorded there. purge acme
refused ("lies under"); moved out as it said, acme purged; beta removed,
purge beta refused: "the recorded users file D/home/u/ws/orgs/acme/sub/
users.json does not exist, and nothing on this host tells where its path
leads (to D/data/ws/orgs/acme/sub/users.json): the nearest part of it that
exists, D/data/ws/orgs, is no directory under D/home/u/ws/orgs/beta …". The
same with the workspace at D/data/ws and its orgs/ a link to D/data/orgs;
with the workspace named by its real path, beta purged.
4439b0e passed such a path only when every directory it is looked up in
is one the root lies in, by the root's real path: the recorded path is
looked up in D/home/u (the link ws) and D/home, which the root's real path
does not pass.
users-file-place.mjs: the directories that pass are now also those a name
is looked up in on the root's own path as purge-org names it
(<base>/orgs/<id>, every link on it followed, as lead() follows the users
file's), each by its path and its device and inode as before. A
filesystem mounted again on one of them covers the root, or hides the link
the root's own path is reached through. Declared in the header's "Not
seen", CHANGELOG's "Known, not fixed": a filesystem down now, mounted on a
directory only the root's path as named is looked up in, whose link leads
the path to the root's real path. The name check stays: with an entry of
the users file's name under beta, purge beta is refused as before;
followed literally — beta deleted by hand — the purge passes and drops its
records. A path through a directory neither the root nor its path lies in
(/cfg, or D/home/u/ws with the root named by its real path) is refused as
before. After, the same script: purge beta passes in all three layouts,
the moved users file kept. No refusal text changes.
Test: no new test (the total stays 1446). server/test-store-ops.mjs's
real-filesystem later-purge test now runs with the workspace named
directly, through a directory link in a directory the roots do not lie in,
and with its orgs/ such a link (junctions, which need no privilege on
Windows); the place suite's sibling-directory case names the root
through /home/u/ws → /ws and /w2/orgs → /ws/orgs, passing paths through
/home/u/ws (deeper, a file deleted in /ws and in /home/u) and /w2/orgs, and
refusing a path through /home/u/ws with the root named by its real path
and one through /w2/orgs with the root named through /home/u/ws. With
users-file-place.mjs at 0f53b12 both fail (the place suite at
"/home/u/ws/orgs/beta2/users.json, the root named /home/u/ws/orgs/beta",
the real-filesystem one with the refusal above).
Docs: the module header; README "Purge A Removed Org"; CHANGELOG's
later-purge bullet (and its test narrative), "Known, not fixed", the Docs
bullet and the forward references in the "move it out first" bullet;
UPDATE_JOURNEY's later-purge paragraph (Tests: 1443 → 1444 unchanged).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
… file of the root's own filesystem mounted on a file, names a path below a name that is not valid UTF-8 with a quote or a backslash unescaped, misses an entry of the users file's name below such a name, or misses a mount below one under a root whose path is not ASCII — each of these changes to server/store/users-file-place.mjs stayed green Reproduced at b76ff7e, each change applied alone to users-file-place.mjs (scratch worktree under /tmp/claude-0/fu/wt-y-pu-mut, removed), with `node --test --test-name-pattern=purge-org server/test-store-ops.mjs` (26 tests) and `node --test server/test-store-paths.mjs` at 0 failures: (1) `if (mountedHere && st.isDirectory())`, which drops 941c92c's "or a file mounted on a file" for one of the same filesystem; (2) spelled() without escaping `\` and `'`; (3) nameOf() taking a path held as bytes whole; (4) relative() slicing such a path by the root's characters, not its bytes. On the real filesystem through purgeOrg (node 22.22, in a private mount namespace, under /tmp/claude-0/fu/wt-y-pu-real-*, removed), at b76ff7e and with each change: - (1) `mount --bind <out>/src.json <root>/m.json`, one filesystem: refused ("<root>/m.json (the same filesystem as <root>, listed in /proc/self/mountinfo)"); unmounted as it says, purged, src.json kept. Changed, rmSync threw "EBUSY: … unlink '<root>/m.json'"; with src.json the recorded users file it was refused as "lies under … Move <root>/m.json out", and mv failed: Device or resource busy. - (2) the users file at <root>/it's\c\xffd/users.json, recorded as a link to it: the refusal names $'<root>/it\'s\\c\xffd/users.json'; that mv, evaluated by bash as typed, moved it, and the purge passed. Changed, it named $'<root>/it's\c\xffd/users.json', which bash rejects ("unexpected EOF while looking for matching `''"), and the purge stayed refused. - (3) the users file recorded as <T>/mnt/b/users.json (a mount point that is down, a users.json written there), <root>/c\xffd/users.json under the root: refused by its name, named as $'…'; moved out as typed, purged. Changed, the purge deleted <root>/c\xffd/users.json. - (4) a workspace at <T>/données/ws, <T>/outside6 bind-mounted at <root>/c\xffd/m6: refused as listed in /proc/self/mountinfo; `umount $'<root>/c\xffd/m6'` as typed, purged, outside6/data.json kept. Changed, the walk entered it and rmSync threw "EBUSY: … rmdir '<root>/c�d/m6'". Four cases, each in the existing test it belongs to, so the total stays 1446: - the mount-point test: a file of the root's own filesystem bind-mounted on a file under the root, the users file elsewhere and the users file itself mounted there: refused as a mount point /proc/self/mountinfo lists; unmounted, the purge passes. - the non-UTF-8 test: <root>/it's\c\xffd/users.json through a link, refused naming $'<root>/it\'s\\c\xffd/users.json'; moved out, it passes. server/fixtures/mount-fs.mjs split a POSIX path at a backslash too; it now keeps a backslash in a name on POSIX paths (Windows paths as before). - the non-UTF-8 test: the users file /mnt/b/users.json outside the root and <root>/c\xffd/users.json under it: refused by that name; moved out, it passes. - the non-UTF-8 test: the root /srv/données/orgs/beta, /srv6 bind-mounted at <root>/c\xffd/m6 and listed: refused; unmounted, it passes. Each case fails alone under its change ("a file of the same filesystem mounted on a file", "a quote and a backslash escaped", "an entry of its name below such a name", "under a root whose path is not ASCII") and passes as the module is. The code is unchanged. CHANGELOG: the Tests bullet names the file mount; the non-UTF-8 bullet's test narrative the escaping and the root that is not ASCII; the name bullet's the entry below such a name. README, UPDATE_JOURNEY and the module header describe no case changed here; no Tests pair moves. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…hape and the root's path length — one directory of 100,000 empty directories needs 60 MB with a 40-character root path (rmSync 24 MB), not the 40 MB (rmSync 16 MB) it gave for any 100,000 empty directories; the users-file place suite reads such a root in a 64 MB heap Reproduced at 93382ab (node 22.22 and 22.16) on real trees — tmpfs in a private mount namespace under /tmp/claude-0/fu/wt-y-pu-mem, and the files on disk under /tmp/claude-0/fu/wt-y-pu-ab (both removed) — usersFilePlace(<ws>/users.json, <ws>/orgs/beta) under --max-old-space-size, the users file outside the root, the root's path 40 characters long; the smallest heap in 4 MB steps it passed in, the same on both nodes, three runs each at the directories' edges: - 100 directories of 1,000 empty files: 16 MB (12 too; rmSync 8 MB) - one directory of 100,000 empty files: 28 MB (rmSync 24 MB, out of memory in 16) - 100 directories of 1,000 empty directories: 40 MB, not 36 (rmSync 8) - one directory of 100,000 empty directories: 60 MB, out of memory in 40, 48 and 56 (rmSync: out of memory in 16, done in 24) With a 91-character root path the last two need 44 and 64 MB. The walk keeps of each directory its path, device and inode, times and a digest of its names: measured with gc() over the injected filesystem, some 280 bytes and its path a directory, so 100,000 directories take about 1 MB more for each 10 characters of the root's path; one directory of 100,000 adds its listing, held while the walk reads each. The CHANGELOG gave "100,000 empty directories in 40 MB (rmSync: 16 MB)" with neither the shape nor the path length. CHANGELOG: of a directory the walk keeps its path too; its memory grows by a few hundred bytes and the path for each directory under the root, with 100,000 about 1 MB for each 10 characters of the root's path; with the root's path 40 characters long (Node 22.16 and 22.22) the 100 x 1,000 files root passes in 16 MB, one directory of 100,000 files in 32 MB (rmSync 24), 100 directories of 1,000 empty directories in 40 MB (rmSync 8) and one directory of 100,000 empty directories in 64 MB (rmSync 24). The module header, the walk's comment and UPDATE_JOURNEY name the path among what the walk keeps of a directory; nothing else there states a figure that changes. Test: the memory test's child takes a shape — 'files', the 250,000-file root as before (identical paths, inode numbers and 250,460 lstatSync calls; it needs 20 MB, run in 32), or 'directories', one directory of 100,000 empty directories under /var/lib/observogram/workspace/orgs/beta (40 characters) — and the test runs both, the second in a 64 MB heap. In the same test, so the total stays 1446 and no Tests pair moves. Over the injected filesystem it needs 52 MB (out of memory in 48) on both nodes; run in 40 MB, the figure the CHANGELOG gave, or in 48, the test fails: "directories: the child ended SIGABRT: FATAL ERROR: ... JavaScript heap out of memory" (checked in a scratch worktree, removed). The module's code is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
… stacked on / — a lookup never crosses a mount on the process's root, yet one listed at / was taken for covering every mount in its parent mount, so with a tmpfs mounted on / in a chroot, or `mount --bind / /`, the bind mount at <root>/m was passed over and rmSync failed there with EBUSY; holding the users file, the "lies under" refusal named <root>/m/users.json, in the bind's source, to move out Reproduced at 2edb379 with real mounts in a private mount namespace, through tools/cli.mjs on Node 22.22: a chroot (into a plain directory, and into one bound on itself), a removed-org deployment at /work/ws in it, and /work/outside (the same filesystem; files f and g) bind-mounted on /work/ws/orgs/acme/m. purge-org refused <root>/m as a mount point. Then `mount --bind / /` (mountinfo: "193 98 /work/ws/orgs/acme/m", "194 98 /"), or `mount -t tmpfs tmpfs /` over the self-bound root ("194 186 …/m", "195 186 /"): `ls /work/ws/orgs/acme/m` still showed f and g, yet purge-org passed the check and rmSync threw "EBUSY: resource busy or locked, rmdir '/work/ws/orgs/acme/m'" (exit 1; on Node 22 nothing was deleted). With the users file recorded as /work/outside/users.json it refused "… lies under <root>: it is <root>/m/users.json there … Move <root>/m/users.json out", and followed, that moves the users file out of its bind source into the same rmSync. Cause: c86f545's covered() takes a mount in the same parent mount on a directory above another for covering it, and above('/', x) held for every x. A path is looked up from the process's root, and a lookup never crosses a mount stacked on it (`umount /` takes the topmost; the kernel lists such a mount at / all the same), so a mount on / covers nothing. above() now never holds for /. After, real, the same namespaces (every mount under /tmp/claude-0/fu/wt-y-pu-r4-*, all unmounted and removed): the four cases (bind or tmpfs on /, users file recorded outside or not) refuse "<root> holds a mount point, <root>/m (the same filesystem as <root>, listed in /proc/self/mountinfo) … Unmount what is mounted there, then run purge-org again", and followed literally — `umount /work/ws/orgs/acme/m` — the purge passes, exit 0, /work/outside's f, g and users.json kept. c86f545's cases are unchanged: a tmpfs on the workspace over a bind mount at <root>/m, a mount in that one, and the root itself covered purge; a mount made there on the tmpfs, from another filesystem or the same one, and the root likewise, are refused and pass once unmounted. Test: server/test-store-ops.mjs's mount-point test (extended, no new test; the total stays 1446): with a tmpfs-like filesystem mounted on /, and with / bound on itself — each listed at / in the root's mount — a bind mount at <root>/m, holding other files or the users file, is reached at its path, refused as a mount point, and once unmounted as its refusal says, passes. server/fixtures/mount-fs.mjs modelled a mount on / as crossed; on POSIX paths a lookup now starts at / and never crosses a mount stacked on it, while a mount and an unmount there take the topmost (a Windows root, a drive or a share, is still the volume mounted there). With users-file-place.mjs at 2edb379 the test fails at once ("a tmpfs mounted on /: /srv7 mounted there": { exists: true, refusal: null }); the other three cases fail too (passed over, or the "lies under" refusal). Docs: the module header, CHANGELOG's mount-point and Tests bullets, UPDATE_JOURNEY's mount paragraph and README "Purge A Removed Org" say a mount on / covers nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
… the users file under the root as well, to move out with the unmounting — with /src bind-mounted on <root>/sub/m, /src/users.json a link to <root>/x/data.json (the users file's bytes) and the users file recorded as <root>/sub/m/users.json, unmounted as that refusal said, the path led into the empty mount point, where no entry under the root has the users file's name, and the purge deleted <root>/x/data.json Reproduced at dacac21 with a real bind mount in a private mount namespace (every path under /tmp/claude-0/fu/wt-y-pu-r4-*, unmounted and removed), through purgeOrg on Node 22.22: D/src, outside the root, bind-mounted on <root>/sub/m; D/src/users.json a link to <root>/x/data.json, which holds the users file's bytes; the users file recorded as <root>/sub/m/users.json, or as D/cfg/users.json, a link to it. Mounted, the record leads to the users file, and purge-org refused "<root> holds a mount point, <root>/sub/m (the same filesystem as <root>/sub, listed in /proc/self/mountinfo) … Unmount what is mounted there, then run purge-org again". After `umount <root>/sub/m`, as it says: "PURGED deleted=true", <root>/x/data.json gone. develop (4123bc7) refused it while mounted; unmounted it refused the direct record by its path as written (the refusal whose way out failed) and deleted data.json recorded through the link. Cause: the mount-point refusal returned before the users file was placed, and its way out, unmounting, takes away the path to the users file: the record then leads into <root>/sub/m, an empty directory the walk read, and no entry under the root has the name the path ends in (data.json is the bytes, the link of the users file's name lies in D/src), so the purge went on. Fix (server/store/users-file-place.mjs): when the walk finds mount points, the users file is placed as for its own refusal (entriesOf: the entries the walk found, or, if it changed meanwhile, each entry read again — none at a mount point, which the read-again pass now passes over as the walk does). With none, the refusal reads as before. With some, it names them: "…, and a directory mounted from outside <root> may hold the users file; and the recorded users file <path> lies under <root>: it is <root>/x/data.json there — nothing was deleted. Move <root>/x/data.json out of <root> and unmount what is mounted there, then run purge-org again (unmounted, its path may no longer lead there, and nothing would keep the purge from deleting it). If <path> then no longer leads to the users file, make it lead there again before …" (a view named as the refusal of a users file under the root names one, with its second step; liesUnder now builds both). exists is true then. After, real, the same namespace: both records refused as above; followed literally — `mv <root>/x/data.json D/keep/; umount <root>/sub/m` — the purge passes, D/keep/data.json intact. 0f53b12's shape (<root>/other bound on <root>/sub/m, the users file at <root>/other/[deep/]users.json or u.json, recorded through a file link or directly) is refused naming <root>/other/… alike, and followed, passes with the moved file kept. Test: server/test-store-ops.mjs's test of 0f53b12's shape (extended, no new test; the total stays 1446), over server/fixtures/mount-fs.mjs: that mount up, refused naming <root>/other/users.json, and followed — moved out and unmounted — passing; /src mounted there from outside, its users.json a link to <root>/x/data.json, recorded directly and through a file link, refused naming data.json, and followed, passing with the moved file kept; unmounted before purge-org saw it up, passing (declared). The docs test pins the new way out in README and in the module. With users-file-place.mjs at dacac21 the test fails at once (0f53b12's shape, mounted: the plain mount refusal, exists null), and with that assertion left out, at /src's ("mounted from outside"). Docs: the module header, README "Purge A Removed Org" (the mount-point bullet; the namesake bullet no longer says the mount-point refusal says only to unmount), CHANGELOG (a bullet, the mount-point bullet's pointer, and Known, not fixed) and UPDATE_JOURNEY say the refusal names the users file, and declare a mount that was down already when purge-org first ran, whose directory from outside holds such a link, as not seen; where they tell 0f53b12's shape, unmounted as the mount-point refusal "said", in the past. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…th leads through a mount of its own filesystem, naming what to unmount first — with /srv/x bind-mounted on <root>/m and `mount --bind <base> <base>` over it (or `mount --rbind`, its copy at <root>/m unmounted as the refusal said), the path showed the very directory the mount sits on, yet purge-org passed it over as covered and rmSync deleted part of the root, then failed with EBUSY Reproduced at a5ea1d8 with real mounts in private mount namespaces, through tools/cli.mjs on Node 22.22: a removed-org deployment at D/ws, D/outside (the same filesystem; file f) bind-mounted on D/ws/orgs/acme/m, and <root>/a.txt, <root>/zz.txt added. (a) `mount --bind D/ws D/ws`: mountinfo lists "186 98 254:0 …/acme/m" and "187 98 254:0 …/ws"; purge-org failed "EBUSY: resource busy or locked, rmdir '<root>/m'" (exit 1) with a.txt deleted, zz.txt, packs/ and m left and the org's records kept. (b) `mount --rbind D/ws D/ws`: the copy at <root>/m (188, parent 187) refused as a mount point; followed literally (`umount <root>/m`, rc 0) the next run failed the same way. (c) A bind of <root> on itself, then the self-bind of D/ws: "EBUSY … rmdir '<root>'". D/outside/f was kept each time. Cause: c86f545's covered() passes over every mount that a later one in the same parent mount covers, reasoning that its path then shows a directory of the filesystem mounted above. When the path leads through a mount of the covered mount's own filesystem it may show the very dentry the covered mount sits on, and the kernel refuses rmdir and unlink of a dentry that is a mount point anywhere in the namespace. users-file-place.mjs now reads each mountinfo line's device (major:minor) too, and finds the mount a lookup of the covered mount's path ends in: the deepest uncovered mount on it or on a directory above it, the topmost of a stack (on /, the lowest, the process's root, as dacac21 has it). Of another device than the covered mount's parent mount, the covered mount is passed over as before; of the same, it is refused: "<root>/m (the same filesystem as <root>, listed in /proc/self/mountinfo, covered by a later mount through which its path may show what it is mounted on: first unmount <base>) … Unmount what is mounted there, then run purge-org again" — the mounts to unmount first, in order: what covers its parent mount, each covering mount in that parent (outermost first), and the mounts in the covered one, each after the mounts in it, which keep it busy. A visible mount at the same path is refused as before, so (b) first names the copy. mountinfo's devices are compared with each other, not with lstat's, so it holds where stat reports another device than mountinfo lists (each btrfs subvolume has its own); a mount of the same filesystem that shows another directory there is a declared false refusal. After, real, the same namespaces on Node 22.22 and 22.16 (every mount under /tmp/claude-0/fu/wt-y-pu-r4*, unmounted with the namespace and removed; /proc/self/mountinfo lists none of it): (a) refused "first unmount D/ws", and followed literally — `umount D/ws`, `umount <root>/m` — purged, exit 0, D/outside/f kept; (b) the copy refused, then "first unmount D/ws", followed likewise and purged; (c) "<root> (…: first unmount D/ws) is a mount point …", `umount D/ws`, `umount <root>`, purged; a bind in the covered mount (<root>/m/n): "first unmount D/ws, then <root>/m/n", followed, purged; a tmpfs on D/ws holding a bind of the original D/ws/orgs at D/ws/orgs (a5ea1d8: EBUSY likewise): "first unmount D/ws/orgs, then D/ws", followed, purged. c86f545's case (a tmpfs on D/ws over the bind at <root>/m) still purges, and dacac21's (`mount --bind / /`) is still refused and passes once unmounted. Test: server/test-store-ops.mjs's mount-point test (extended, no new test; the total stays 1446): /ws bound on itself, a recursive bind's copy at <root>/m unmounted, and a filesystem mounted on /ws holding a bind of the original /ws/orgs — <root>/m showing the directory it is mounted on, unmounting it there EINVAL, /ws busy (EBUSY) while the copy is mounted — refused naming what to unmount first, and passing once unmounted in that order; the root bound on itself under /ws bound on itself likewise. The README way-out check reads the new way out in both README and the module. server/fixtures/mount-fs.mjs lists each mount's filesystem device in /proc/self/mountinfo (it listed the mount ID there). With users-file-place.mjs at a5ea1d8 the test fails at once ("/ws bound on itself": { exists: true, refusal: null }); each of the four cases purges there. Docs: the module header, README "Purge A Removed Org", CHANGELOG's mount-point and Tests bullets and UPDATE_JOURNEY's mount paragraph say a covered mount whose path leads through a mount of its own filesystem is refused, naming what to unmount first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…mount on /w for one on a directory above /ws, or follows a covered mount's parents one level up only — each of these changes to server/store/users-file-place.mjs stayed green, and on real mounts the first passed a bind mount under the root over (rmSync failed there with EBUSY) and the second refused a mount point where nothing is mounted Reproduced at 1b7a9d5, each change applied alone to users-file-place.mjs (scratch worktrees /tmp/claude-0/fu/wt-y-pu-m1 and -m2, removed), with `node --test --test-name-pattern=purge-org server/test-store-ops.mjs` (26 tests) and `node --test server/test-store-paths.mjs` at 0 failures: (1) above() without the path separator, `b.startsWith(a)`; (2) covered() following the parent chain one level only, the parent's own siblings asked in place of covered(parent). On the real filesystem through purgeOrg (Node 22.22, in private mount namespaces under /tmp/claude-0/fu/wt-y-pu-r44*, every mount gone with its namespace, removed), at 1b7a9d5 and with each change: - (1) The reported setup — D/outside bind-mounted on D/ws/orgs/acme/m, a tmpfs on D/w — is refused as a mount point either way since 1b7a9d5; changed, the refusal adds a false first step ("covered by a later mount through which its path may show what it is mounted on: first unmount D/w"). With the workspace on a tmpfs mounted on D/ws (or its orgs/ on one), a tmpfs on D/w, and D/ws/src, on the workspace's tmpfs, bind-mounted on <root>/m: 1b7a9d5 refuses <root>/m ("the same filesystem as <root>, listed in /proc/self/mountinfo"), and followed literally — `umount <root>/m` — purges with D/ws/src's file kept. Changed, D/w is taken for a directory above D/ws, the tmpfs and the bind in it for covered, and their path leads through / (another device than the tmpfs): passed over, rmSync threw "EBUSY: resource busy or locked, rmdir '<root>/m'". - (2) D/srv6, D/srv7 and D/srv8 bind-mounted on <root>/m, <root>/m/n and <root>/m/n/o in turn, then a tmpfs on D/ws, the deployment made on it and <root>/m/n/o created there: 1b7a9d5 purges, D/srv8's file kept. Changed, it refuses "<root> holds a mount point, <root>/m/n/o (the same filesystem as <root>/m/n, listed in /proc/self/mountinfo) …", and `umount <root>/m/n/o` says "not mounted" — c86f545's dead end. The reported shape (a bind on D/ws/orgs, one on <root>/sub in it, one on <root>/sub/m in that, then the tmpfs on D/ws) reads the same: 1b7a9d5 purges; changed, <root>/sub/m is refused and umount says not mounted. Test: server/test-store-ops.mjs's mount-point test (extended, no new test; the total stays 1446), over server/fixtures/mount-fs.mjs: the covered chain goes a level deeper (<root>/m/n/o, in <root>/m/n, in <root>/m: listed still, a directory of the filesystem above, unmounting it EINVAL; purged), and with the workspace on a filesystem mounted on /ws and another mounted on /w, a bind mount at <root>/m of a directory of the workspace's filesystem is reached at its path, refused, and passes once unmounted as its refusal says. With change (1) the test fails at "/w beside /ws" ({ exists: true, refusal: null }); with change (2) at "covered mounts: none is a mount point, purged" (<root>/m/n/o refused). Docs: CHANGELOG's Tests bullet names both cases. Nothing purge-org does changes, so README, the module header and UPDATE_JOURNEY stand. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
… chroot into a directory that is no mount point — there /proc/self/mountinfo lists neither / nor the mount the root lies in, and a covered mount whose parent it does not list was refused as one whose path may show what it is mounted on, so a tmpfs on the workspace over a bind mount at <root>/m, which purges outside a chroot, was refused with "first unmount <base>", and followed literally that unmounted the filesystem holding the whole deployment Reproduced at 6a9fcf3 with real mounts in a private mount namespace, through tools/cli.mjs on Node 22.22, in a chroot into a plain directory (no mount point): a removed-org deployment at /work/ws, /work/outside bind-mounted on /work/ws/orgs/acme/m, then a tmpfs mounted on /work/ws and the workspace made on it (c86f545's case). mountinfo listed "193 98 254:0 /work/ws/orgs/acme/m" and "194 98 0:48 /work/ws" and no line with ID 98; <root>/m was a plain tmpfs directory (dev 48) and `umount <root>/m` said "not mounted". purge-org refused "<root>/m (the same filesystem as <root>, listed in /proc/self/mountinfo, covered by a later mount through which its path may show what it is mounted on: first unmount /work/ws)" (exit 1). Followed literally, `umount /work/ws` (rc 0) unmounted the tmpfs: /work/ws then showed only the underlying "orgs". At 2edb379 the same run purged; so did 6a9fcf3 with the chroot's root bind-mounted on itself (the parent listed, as 186). Cause: 1b7a9d5's shows() read a covered mount whose parent mount is not listed as showing what it is mounted on (`parent === undefined`), and only a code comment said so. The kernel lists a mount only when its mount point is reachable from the process's root, so in a chroot into a directory that is no mount point the mount the root lies in is listed nowhere while every mount in it names its ID as their parent, and every covered mount in it was refused, its way out naming the covering mount — the workspace's own filesystem. users-file-place.mjs: a covered mount whose parent is not listed takes that parent for the mount the process's root lies in (the only mount a listed mount's unlisted parent can be), whose filesystem / shows, since a lookup of / never crosses a mount stacked on it (dacac21), and compares it with the device of the directory at the covered mount's path now — the filesystem the lookup ends in. Another device: the covered mount is passed over, as outside a chroot. The same (`mount --bind /work/ws /work/ws` over it, in the chroot): refused naming /work/ws first, as before; followed literally — `umount /work/ws`, then `umount <root>/m` as the next refusal says — the purge passes and /work/outside/f stays (reproduced so). A parent that is listed reads as before; nothing at the covered mount's path reads as showing it. server/fixtures/mount-fs.mjs: a `chroot` option, under which /proc/self/mountinfo lists neither / nor the root's mount, the mounts in it naming its ID (TOP) as their parent, as the kernel lists a chroot into a plain directory. server/test-store-ops.mjs (one more): over that fixture, the tmpfs over a bind mount at <root>/m, and over the root itself, passing (with users-file-place.mjs at the parent commit the first is refused, naming /ws first); <root>/m mounted again on the tmpfs refused and passing once unmounted; /ws bound on itself refused naming it first and, followed, passing; a tmpfs on / in the chroot covering nothing. Docs: the module header states the rule; CHANGELOG's purge-org entry names the parent mount's device, or / in a chroot, and a new entry this change with its reproduction; UPDATE_JOURNEY likewise. Tests: 1446 → 1447. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
… covered mount by its own filesystem in place of its parent mount's or by the lowest mount of a stack on its path, or names what to unmount first without what covers its parent mount, innermost first, after what is above it in that parent, or without the mounts in it — each of these changes to server/store/users-file-place.mjs stayed green, and on real mounts the first two passed a bind mount under the root over (rmSync failed there with EBUSY) and the others named a first step that failed (not mounted; target is busy) or none Reproduced at 2ff689f, each change applied alone to users-file-place.mjs (a detached scratch worktree /tmp/claude-0/fu/wt-y-pu-r52, removed), with `node --test --test-name-pattern=purge-org server/test-store-ops.mjs` (27 tests) and `node --test server/test-store-paths.mjs` at 0 failures: (1) shows() comparing the mount the path ends in with the covered mount's own device in place of its parent mount's; (2) endsIn() taking the first line of a stack, or its lowest mount, for the mount the lookup ends in; (3) firstOff() without uncover(parent), what covers the covered mount's parent mount; (4) firstOff() with the coverers in one parent innermost first; (5) firstOff() with what covers the parent after what is above the covered mount in that parent; (6) firstOff() without the mounts in the covered mount. 1b7a9d5's other readings stay caught (a covered mount never refused, shows() always true or false, endsIn() counting covered mounts, the device read from the mount ID, the hint dropped, within() listing a mount before the mounts in it), and 2ff689f's chroot test catches shows() reading a covered mount with an unlisted parent as not showing it. Two readings survive with no effect: endsIn() finding no mount (a lookup always crosses one uncovered mount, / or the outermost coverer, so it cannot), and nothing at a covered mount's path reading as not showing it (the walk never reaches such a path). On the real filesystem through tools/cli.mjs (Node 22.22, in private mount namespaces under /tmp/claude-0/fu/wt-y-pu-r52-proof-*, every mount gone with its namespace, removed), D/outside (holding f) bind-mounted on <root>/m unless said otherwise, <root> holding a.txt and zz.txt, at 2ff689f and with each change: - (1) A tmpfs on <root>/m, then `mount --bind D/ws D/ws`: 2ff689f refuses "<root>/m (… covered by a later mount through which its path may show what it is mounted on: first unmount D/ws)"; followed (`umount D/ws`, `umount <root>/m`), it purges. Changed: "EBUSY: resource busy or locked, rmdir '<root>/m'", a.txt deleted. - (2) `mount --bind D/ws D/alt`, a tmpfs on D/ws, `mount --bind D/alt D/ws`: 2ff689f refuses "first unmount D/ws, then D/ws", and followed (both mounts at D/ws, then <root>/m) purges with f kept. Changed, either way: EBUSY at <root>/m, a.txt deleted. - (3) `mount --bind D/ws/orgs D/ws/orgs`, the bind at <root>/m in it, then `mount --bind D/ws D/ws`: 2ff689f refuses "first unmount D/ws", and followed purges. Changed: no first step, and `umount <root>/m`, the way out then, says "not mounted"; the same refusal comes back. - (4) The bind at <root>/m, then D/ws/orgs and D/ws each bound on itself: 2ff689f refuses "first unmount D/ws, then D/ws/orgs", and followed purges. Changed: "first unmount D/ws/orgs, then D/ws", and `umount D/ws/orgs` says "not mounted"; after `umount D/ws` the refusal names D/ws/orgs still. - (5) D/ws/orgs bound on itself, the bind at <root>/m in it, a tmpfs on D/ws/orgs (stacked in the self-bind), then `mount --bind D/ws D/ws`: 2ff689f refuses "first unmount D/ws, then D/ws/orgs", and followed (D/ws, the tmpfs at D/ws/orgs, <root>/m) purges with f kept. Changed: "first unmount D/ws/orgs, then D/ws", and `umount D/ws/orgs` says "not mounted". - (6) A bind at <root>/m/n in the one at <root>/m, then `mount --bind D/ws D/ws`: 2ff689f refuses "first unmount D/ws, then <root>/m/n", and followed purges with f and n kept. Changed: "first unmount D/ws", and after it `umount <root>/m` says "target is busy". Test: server/test-store-ops.mjs's mount-point test (extended, no new test; the total stays 1447), over server/fixtures/mount-fs.mjs: a tmpfs on <root>/m under /ws bound on itself (listed on its own device) refused naming /ws first; a stack on /ws (/ws bound on /alt, a tmpfs on /ws, /alt bound over it) refused naming /ws twice; /ws/orgs bound on itself with the bind at <root>/m in it, then /ws bound on itself, refused naming /ws; two coverers in one parent refused naming /ws, then /ws/orgs (unmounting /ws/orgs first EINVAL, as umount says not mounted); the parent covered and a tmpfs stacked above in it refused naming /ws, then /ws/orgs; a bind at <root>/m/n in the covered mount refused naming /ws, then <root>/m/n (unmounting <root>/m before it EBUSY) — each passing once unmounted in the order named. With change (1) the test fails at "a tmpfs on <root>/m, /ws bound on itself" ({ exists: true, refusal: null }), with (2) at "a stack on /ws, the root's filesystem on top" (the same), with (3) at "in a covered /ws/orgs" (no first step), with (4) at "two coverers in one parent" (/ws/orgs, then /ws), with (5) at "the parent covered, and a mount above in it" (/ws/orgs, then /ws), with (6) at "a mount in the covered mount" (<root>/m/n left out). Docs: CHANGELOG's Tests bullet names the cases. Nothing purge-org does changes, so README, the module header and UPDATE_JOURNEY stand. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…s to mount the workspace there again once the covered mount is unmounted, where what covers it showed the workspace's directory and not the one it is mounted on — with the deployment bind-mounted on <base> over the directory holding a bind at <root>/m (the declared false refusal), "first unmount <base>" followed literally took the workspace away, and purge-org then said "no database at <base>/observogram.db — nothing to purge" Reproduced at 1049f5f with real mounts in a private mount namespace, through tools/cli.mjs on Node 22.22: a removed-org deployment at D/data/ws holding <root>/m as a plain directory; the original D/ws holding a bind mount of D/outside at D/ws/orgs/acme/m; then `mount --bind D/data/ws D/ws` over it (the same filesystem, another directory). purge-org refused "<root>/m (the same filesystem as <root>, listed in /proc/self/mountinfo, covered by a later mount through which its path may show what it is mounted on: first unmount D/ws)" (exit 1). Followed literally — `umount D/ws` (rc 0), `umount <root>/m` (rc 0), purge-org again — it failed with "no database at D/ws/observogram.db — nothing to purge" (exit 1): D/ws showed the original directory again, and the refusal said nothing of mounting the workspace back. With the workspace mounted on D/ws again, the purge passed and D/outside/f stayed. Cause: the hint 1b7a9d5 added names the mounts to unmount first and nothing after. A covering mount of the covered mount's filesystem may show the very directory the covered mount is mounted on (`mount --bind D/ws D/ws`, where unmounting it leaves the workspace in place) or another directory of that filesystem (the deployment bind-mounted over the directory that holds the covered mount), which purge-org cannot tell apart; in the second case the workspace lives on the covering mount and goes with it. users-file-place.mjs: the hint goes on "; if the workspace goes with it (a mount that showed another directory there than the one it is mounted on — the workspace's), mount it there again once <root>/m is unmounted, before you run purge-org again" ("one of them" with several mounts to unmount first). Followed literally on real mounts: the deployment over the original directory — `umount D/ws`, `umount <root>/m`, `mount --bind D/data/ws D/ws`, purge-org — purges with D/outside/f kept; D/ws bound on itself, and D/ws/orgs then D/ws each bound on itself ("first unmount D/ws, then D/ws/orgs; if the workspace goes with one of them …"), unmounted in that order with the workspace staying, purge without any remount. Nothing purge-org deletes or passes over changes. server/test-store-ops.mjs: the mount-point test (extended; the total stays 1447) models the case over server/fixtures/mount-fs.mjs — the deployment at /data/ws (observogram.db, <root>/m a plain directory) bound on /ws over the original directory holding a bind at <root>/m — and reads the refusal naming /ws first and the workspace to mount again; unmounting /ws and <root>/m as it says leaves nothing at /ws/observogram.db, and with /data/ws bound on /ws again the purge passes, the mounted directory's files kept. Every covered-mount expectation in that test and the chroot test reads the new wording; the docs test reads the new way out in README and the module. With users-file-place.mjs at the parent commit, the mount-point test, the chroot test and the docs test fail (the refusal ends at "first unmount /ws"). Docs: README's "Purge A Removed Org", the module header, CHANGELOG's purge-org entry (with the reproduction) and Tests bullet, and UPDATE_JOURNEY name the step. No new test, so the Tests chain stands. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
… the mounts in it to unmount first, in order — with /work/outside bind-mounted on <root>/m and /work/inner on <root>/m/n, it named <root>/m alone, since the walk never enters a mount point, and followed literally, `umount <root>/m` said target is busy and every later run came back with the same refusal; a covered mount's refusal named them already Reproduced at ca89ada with real mounts in a private mount namespace, through tools/cli.mjs on Node 22.22: a removed-org deployment at D/ws, D/outside bind-mounted on D/ws/orgs/acme/m and D/inner on D/ws/orgs/acme/m/n. purge-org refused "<root>/m (the same filesystem as <root>, listed in /proc/self/mountinfo)" (exit 1); `umount D/ws/orgs/acme/m` failed with "target is busy", and purge-org, run again, refused word for word. The way out worked only with `umount D/ws/orgs/acme/m/n` first, or `umount -R`. The mounts in a mount point keep it busy, and /proc/self/mountinfo lists them, so the refusal now names them to unmount first, in order, each after the mounts in it — firstOff's within() (the order a covered mount's refusal gives the mounts in it) shared with the uncovered case as inside(m): "<root>/m (the same filesystem as <root>, listed in /proc/self/mountinfo, holding mounts, which keep it busy: first unmount <root>/m/n/o, then <root>/m/n, then <root>/m/p)"; on another filesystem, "(another filesystem than <root>, holding a mount, which keeps it busy: first unmount <root>/m/n)". A mount in it that a later one over a directory above it covers comes after the one that covers it. With a mount stacked on <root>/m, umount takes the topmost, so the mounts in that alone are named (the lower mount's are covered by it), and once it is off, the lower mount's. Followed literally with the real mounts — a bind at <root>/m holding binds at <root>/m/n, <root>/m/n/o and <root>/m/p, unmounted in the named order, then <root>/m — the purge passes (exit 0), and the mounted directories' files stay. server/test-store-ops.mjs (one more, 1447 → 1448) models the case over server/fixtures/mount-fs.mjs, whose unmount already fails with EBUSY while a mount is in the one unmounted: the nested binds refused naming the three in that order, part-way naming what is left in it, and passing once unmounted so with the mounted directories' files kept; one in it covered by a later mount named after what covers it; a tmpfs on <root>/m holding a bind alike; a mount stacked on <root>/m naming the mounts in its topmost alone, and once that is off, the lower mount's. The docs test reads the new way out in README. With users-file-place.mjs at the parent commit the new test fails, refused naming <root>/m alone. Docs: README's "Purge A Removed Org", the module header, a CHANGELOG entry (with the reproduction) and UPDATE_JOURNEY name the step; the Tests chain goes on 1447 → 1448. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…root into a directory that is no mount point, compares a covered mount's path through a link there with the filesystem / shows — that change to server/store/users-file-place.mjs stayed green, and on real mounts a link at <root>/m in the tmpfs covering it, to a directory of the root's filesystem, was refused naming the tmpfs holding the whole deployment to unmount first, the very outcome 2ff689f fixed Reproduced at bf1ad37 with real mounts in a private mount namespace, through tools/cli.mjs on Node 22.22, in a chroot into a plain directory (no mount point): /work/outside bind-mounted on /work/ws/orgs/acme/m, a tmpfs mounted on /work/ws and the workspace made on it, then <root>/m in the tmpfs replaced by a link to /work/inner, a directory of the root filesystem holding g. mountinfo listed "194 98 254:0 /work/ws/orgs/acme/m" and "195 98 0:49 /work/ws" and no line with ID 98. bf1ad37 purged, with /work/inner/g kept (rmSync deletes the link and never follows it). With shows() reading dev(stat(m.at)) in place of dev(lstat(m.at)) — the link followed to its target, on the filesystem / shows — purge-org refused "<root>/m (the same filesystem as <root>, listed in /proc/self/mountinfo, covered by a later mount through which its path may show what it is mounted on: first unmount /work/ws; …)" (exit 1): /work/ws is the tmpfs holding the database and every org, 2ff689f's outcome again. That change applied alone to users-file-place.mjs (a detached scratch worktree /tmp/claude-0/fu/wt-y-pu-r6, removed) left `node --test --test-name-pattern=purge-org server/test-store-ops.mjs` at 28 pass, 0 fail: 2ff689f's chroot test covers <root>/m in the covering tmpfs as a directory and as a bind mount, never as a link. Test: server/test-store-ops.mjs's chroot test (extended, no new test; the total stays 1448), over server/fixtures/mount-fs.mjs: /srv6 bound on <root>/m, a tmpfs bound on /ws over it, and <root>/m in the tmpfs a link to /srv7, a directory of the root's filesystem holding a file — lstatSync answers a link on the tmpfs's device, statSync /'s device, umount says not mounted (EINVAL), and the purge passes. With the change the test fails there, refused naming /ws first. The module header and the comment at shows() say what the fallback compares: what is at the covered mount's path as rmSync takes it, a link there a link of the filesystem above and not what it leads to; the CHANGELOG entry for 2ff689f says the same and names the case. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…e mounts in a mount in a mount point under the root the innermost first — that change to server/store/users-file-place.mjs stayed green, and on real mounts the refusal of <root>/m named a covered mount in <root>/m/n first, `umount` of it said no mount point specified, <root>/m/n and <root>/m stayed busy, and the same refusal came back Reproduced at eef0747 with real mounts in a private mount namespace, through tools/cli.mjs on Node 22.22: a removed-org deployment at D/ws, D/outside bind-mounted on D/ws/orgs/acme/m, D/inner on <root>/m/n, D/innermost (holding h) on <root>/m/n/x/y, then D/outside2 on <root>/m/n/x — in <root>/m/n too, covering y — and D/deep on <root>/m/n/x/z, in that. mountinfo listed 186 at <root>/m, 187 (in 186) at <root>/m/n, 188 and 189 (both in 187) at <root>/m/n/x/y and <root>/m/n/x, 190 (in 189) at <root>/m/n/x/z. eef0747 refused "<root>/m (…, holding mounts, which keep it busy: first unmount <root>/m/n/x/z, then <root>/m/n/x, then <root>/m/n/x/y, then <root>/m/n)", and followed literally — the four, then <root>/m — every umount returned 0 and the purge passed, h kept. With within()'s `.sort(outermost)` over the mounts in a mount changed to `.sort(outermost).reverse()` — the innermost first — it refused "first unmount <root>/m/n/x/y, then <root>/m/n/x/z, then <root>/m/n/x, then <root>/m/n": followed literally, `umount <root>/m/n/x/y` said "no mount point specified" (rc 32; it is covered by <root>/m/n/x), the next two returned 0, `umount <root>/m/n` and `umount <root>/m` said "target is busy", and purge-org refused again naming "<root>/m/n/x/y, then <root>/m/n". That change applied alone to users-file-place.mjs left `node --test --test-name-pattern=purge-org server/test-store-ops.mjs` at 28 pass, 0 fail (the same change to inside()'s sort, over the mounts in the mount point itself, fails one): bf1ad37's holding-mounts test orders siblings in <root>/m alone, and gives each mount in them one mount at most, so within()'s order of two mounts in a mount in it — one covering the other — was never exercised. Test: server/test-store-ops.mjs's holding-mounts test (extended, no new test; the total stays 1448), over server/fixtures/mount-fs.mjs: /srv6 on <root>/m, /srv7 on <root>/m/n, /srv8 (holding h) on <root>/m/n/x/y, /srv9 on <root>/m/n/x, which covers it (unmount: EINVAL) and holds /srv10 on <root>/m/n/x/z; <root>/m/n busy (EBUSY); refused naming "<root>/m/n/x/z, then <root>/m/n/x, then <root>/m/n/x/y, then <root>/m/n" — part-way, the first two off, "<root>/m/n/x/y, then <root>/m/n" — and, unmounted so, the purge passes with /srv8's h kept. With the change the test fails there, refused naming <root>/m/n/x/y first (the mutation on inside()'s sort and the one naming a mount before the mounts in it fail it too). The CHANGELOG entry for bf1ad37 names the case. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…org-users-file-identity Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…op's 1472 — #157's 1425 → 1438 → 1439, #158's 1439 → … → 1468 and #159's 1468 → 1472 come first, so the follow-up's pairs in its CHANGELOG entry and its journey section read 1472 → 1487 → 1488 → … → 1495, and the total that stays reads 1493 (they read 1425 → 1440 → … → 1448, and 1446); 1495 is the measured npm test total at this head Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…n — refusedOp returned false on a mismatch, so assert.rejects reported only its message (the deployment's path) and the summary a CI log ends with, which prints a failure's error line alone, said nothing of the refusal purge-org gave; now the mismatch is an AssertionError naming the error's code and message. The Windows leg on Node 22.16.0 failed the junction-linked later-purge test at its first refusal with only the path to show for it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
… handle — on Node 22.16.0 (libuv 1.49.2) a root reached through a junction to its workspace's orgs was refused as a mount point
The Windows leg on Node 22.16.0 refused the junction-linked later-purge test's
first purge with '<d>\data\w2\orgs\acme (another filesystem than
<d>\data\w2\orgs) is a mount point', where Node 22.23 (libuv 1.51.0) went on.
users-file-place.mjs compared the root's lstatSync device with its directory's
statSync device, and that directory was the junction. libuv answers a stat by
the path's name (GetFileInformationByName) unless its last name is a reparse
point, which it opens and reads the volume's serial from
(FILE_FS_VOLUME_INFORMATION). libuv 1.49.0 to 1.50.x declared
FILE_STAT_BASIC_INFORMATION with FileId128 and VolumeSerialNumber swapped, so
the stat by name reported the upper half of the file's 128-bit id, 0 on NTFS,
and no 32-bit mask could turn that into the serial.
On Windows a volume is mounted only in a folder, so purge-org now compares
only directories there. It reads each one's volume through a handle
(openSync, fstatSync, closeSync), which reports the serial on every libuv and
follows a junction to the volume it leads to. A file or a link under the root
lies on its directory's volume: on libuv 1.49.0 to 1.50.x a junction under the
root was refused as a mount point too. A volume mounted in a folder under the
root, or on the root, is refused as before ('another volume mounted in it').
The test models both libuv behaviours over the injected Windows host
(mount-fs.mjs: fastDev, plus openSync/fstatSync/closeSync). At the parent
commit it fails on 1.49.2's stat with the Windows leg's refusal. Tests:
1495 -> 1496.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…ers-file-identity Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…op's 1476 — #161's 1472 → 1476 comes first, so the follow-up's pairs in its CHANGELOG entry and its journey section read 1476 → 1491 → 1492 → … → 1500, and the total that stays reads 1497 (they read 1472 → 1487 → … → 1496, and 1493); 1500 is the measured npm test total at this head Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…file link into the root once it is moved out — #161's unresolvable-path check runs first and refuses a dangling link, which this follow-up's entry, journey and README said purge-org follows by its target and lets through; README "Purge A Removed Org" names that check and its ways out In the merged tree purge-org runs #161's check (compared(usersFile, root, { deleting: true }) answering null on Windows) before usersFilePlace. With the users file recorded as C:\cfg\users.json, a file link to <root>\sub\users.json, and moved out of the root as the refusal says, the native realpath cannot open the link's target and lstat answers the link, so #161's check refuses it; a directory link (C:\cfg\d -> <root>\sub) still resolves and passes. Checked with server/fixtures/ntfs.mjs. Its way out, deleting the root by hand and purging again, holds: with no root left purge-org checks no users file. The README list now opens with that refusal and its two ways out, and the docs test pins the second. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
MoebiusX
marked this pull request as ready for review
October 14, 2026 21:05
…identity Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…op's 1495 — #162's 1476 → … → 1495 comes first, so the follow-up's pairs in its CHANGELOG entry and its journey section read 1495 → 1510 → 1511 → … → 1519, and the total that stays reads 1516 (they read 1476 → 1491 → … → 1500, and 1497); 1519 is the measured npm test total at this head Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
MoebiusX
added a commit
that referenced
this pull request
Oct 14, 2026
…ugh a handle — on Node 22.16.x (libuv 1.49.2) a path through a share of the workspace compared as outside it server/store/paths.mjs placed a path reached through a share by a file id it shares with a directory of the path it is checked against, and judged one nothing places by its volume, both read with statSync. libuv answers a stat by the path's name (GetFileInformationByName, Windows 11 24H2 and Server 2025) unless the path is reached through a share or its last name is a reparse point, which it opens (FILE_FS_VOLUME_INFORMATION: the serial). libuv 1.49.0 to 1.50.x declared FILE_STAT_BASIC_INFORMATION with FileId128 before VolumeSerialNumber (fixed in 1.51.0), so a stat by name reported the upper half of the 128-bit file id, 0 on NTFS. A local path and its share then had no file id in common and their volumes never matched: a directory export to \\myhost\ws\exports\new with the workspace at C:\ws was written into the workspace. On Windows a file id and a volume are now read through a handle (openSync, fstatSync, closeSync), which reports the serial on every libuv, as #163 reads a directory's volume in users-file-place.mjs. Every handle is closed before the comparison goes on. server/fixtures/ntfs.mjs models both libuvs (fastDev: 0n for 1.49.2, null for 1.51.0's 64-bit serial by name; a path through a share or ending in a link or reparse point is opened) and returns openSync, fstatSync, closeSync and opened(). Its header no longer says a local stat may report all 64 bits on every libuv. The new test in server/test-store-paths.mjs loops both models. At the parent commit it fails on 1.49.2 (the export through \\myhost\ws was written). Tests: 1495 -> 1496. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MoebiusX
pushed a commit
that referenced
this pull request
Oct 15, 2026
…chain this follow-up's Tests pair from develop's 1519 — the develop merge cd24602 dropped #163's entry (17 lines, now byte-for-byte develop's again, below this entry) and left this entry's pair at 1495 → 1496, which test-doc-test-totals matched against #163's journey pair from 1495; 1519 → 1520 is the measured npm test total Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This closes the two purge-org problems you asked to have fixed in their own PR ("close both in their own PR, keep #161 as is"). #161 is untouched. Cut from
origin/develop4123bc7, with develop merged in since (now 8500b48, #161 and #162 included).The two problems
<base>/users.jsonthat links into the root.<root>— move it out first" had a way out that failed when followed literally. After the move, the record still named the path under the root, and the same refusal came back word for word.Both were reproduced at develop with real bind mounts in a private mount namespace, through
packc store purge-orgon a real store:<base>/users.jsonlinked into the root: the users file was deleted;<root>/ma bind mount of a tmpfs from outside the root: Node 22.22 failed on it withEBUSYbefore deleting the files there, but Node 24.19 deleted them, the users file among them;The fix (
server/store/users-file-place.mjs, new;server/store/ops.mjs)purge-org decides by what deleting the root would take, not by how the path is spelled.
rmSyncwould take it:lstaton each entry, never following a link. Names are read as bytes, so a name that is not valid UTF-8 is read too./proc/self/mountinfolists; mounts a later mount has covered are skipped;Declared default changes (CHANGELOG
purge-org-users-file-identity)<base>/users.jsontoo.EACCES, a link loop), a mount point under the root, a directory it cannot read, and a directory that changed while it read the root.Review
Before anything was built, a design attack tried to break the plan with real mounts. It found that the old check's order kept problem 2 open, plus holes through overlayfs, through an unrecorded
<base>/users.jsonand through mount points. All were taken. Then three rounds followed, each with a proof reviewer (every commit green alone, every fix mutation-checked) and an attacker using real bind mounts, tmpfs, overlay and unprivileged runs.rmSyncdeleted them (major);users.json;/taken as covering every mount (dacac21);mount --bind <base> <base>: rmSync failed with EBUSY part-way. The refusal now names what to unmount first, in order (1b7a9d5);/'s filesystem decides there (2ff689f);Gate
Every commit was checked on its own, with both Node versions and Playwright:
npm test: passes on Node 22.22 and 22.16.0, apart from the 1 known environmental skip.vendor-manifest:check,sync-spec --checkandtest-doc-test-totalsall pass.The Tests chain ran 1425 → 1448 on the branch's own base. Rounds 1 and 2 were also run at HEAD through the node-floor run and the live gates.
Develop merged in (no rebase)
users-file-place.mjsor the purge-org users-file block.windows-latest. No newSKIP win32:line; README's 19 hold.npm test1495 tests, 1494 pass + the 1 known environmental skip, on Node 22.22 and 22.16.0 with Playwright; the floor run 1459 tests, 0 fail; goldens byte-identical;vendor-manifest:check,sync-spec --check,test-doc-test-totalsandtest-platformpass; both live gates green.On the Windows CI legs
The first Windows run (07503ec) failed one of this PR's tests on
windows (node 22.16.0)only;windows (node 22)passed.<w2>\orgs\acme (another filesystem than <w2>\orgs) is a mount point, withorgs\a junction on the same volume.FILE_STAT_BASIC_INFORMATIONdeclaresFileId128beforeVolumeSerialNumber, swapped against Windows' layout (fixed in libuv 1.51.0, Node 22.23). So a stat by name reports device 0 on NTFS, while a path ending in a junction is opened and reports the real volume serial. No mask can reconcile them.openSync+fstatSync+closeSync), which gives the real serial on every libuv and follows a junction to its volume. On Windows only directories are compared, since a volume can only be mounted in a folder. A new test over the injected Windows host models both libuv versions and fails without the fix. POSIX behaviour is unchanged.Merge with #161 (now on develop)
ops.mjs,paths.mjs,test-store-paths.mjsandntfs.mjsmerged on their own.purgeOrgboth checks run, in this order: fix(store): on Windows purge-org refuses a users file whose recorded path cannot be fully resolved — a link or reparse point whose target cannot be opened, or a volume whose paths do not resolve #161's unresolvable-path check (compared(usersFile, root, { deleting: true })), then this PR'susersFilePlace, then the path check gated on the file existing. Neither is dropped; the structural test intest-store-paths.mjsthat pins the order passes.windows (node 22.16.0)now passes there.npm test1500 tests, 1499 pass + the 1 known environmental skip, on Node 22.22 and 22.16.0 with Playwright; the floor run 1464 tests, 0 fail; goldens byte-identical;vendor-manifest:check,sync-spec --checkand the totals pass; both live gates green. Independent check: ok.Develop 8500b48 (#162) merged in
server/store/.npm test1519 tests, 1518 pass + the 1 known environmental skip, on Node 22.22 and 22.16.0 with Playwright; the floor run 1483 tests, 0 fail; goldens byte-identical;vendor-manifest:check,sync-spec --checkand the totals pass; both live gates green. Independent check: ok.Known, not fixed
/procnot mounted, two limits follow from having no mountinfo:/proc. The way out is to mount/proc.use_ino) is not matched as the users file; such a view of a directory is still refused by the file's name. NFS, SMB and FUSE were modelled with an injected filesystem only, because this container has none of them.rmSyncis not seen.<root>/subelsewhere with something mounted on that other path) is passed over. This is pre-existing (the same on develop), outside the two problems this PR fixes, and found by round 5's attacker.🤖 Generated with Claude Code
https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
Generated by Claude Code