diff --git a/README.md b/README.md index fef99dce..d4cff73b 100644 --- a/README.md +++ b/README.md @@ -2795,8 +2795,139 @@ refuses a root that is a symlink or resolves outside `/orgs`, and a workspace whose marker names another store. The org row stays, so its id is never reused. +Before it deletes anything it reads all of `orgs//`, every name as the +bytes on disk, never through a link (it names a path holding a name that is +not valid UTF-8 as a POSIX shell's `$'…'` spells it, each such byte `\xNN`), +and it refuses, deleting nothing: + +- **on Windows, while the recorded users file's path cannot be fully + resolved on this host, or runs through a network share nothing places + beside `orgs//`** — checked before the others: a link or another + reparse point on its path whose target cannot be opened (a dangling link + too, so a users file recorded as a file link into `orgs//` and moved + out as a refusal below says is refused here, though nothing of it is left + there), a part of it this host cannot read, a volume whose paths do not + resolve, or a share on any volume, a DFS link or one that cannot be + reached: such a path may lead under `orgs//`. If the users file does + not lie under `orgs//`, delete `orgs//` by hand and run the purge + again; if it does, move it out of `orgs//` first, then do the same. +- **while that would take the users file a pre-store build reads** — the + one `OBSERVOGRAM_USERS_FILE` named at the import, else + `/users.json`. This is decided by what deleting the directory + takes, not by the users file's path: an entry under `orgs//` is the + users file when it is the same file (the path, or a mount or a link on it, + leads there, or it is a hard link of it) or when, on another filesystem, it + has the users file's size and modification time (the users file seen + through an NFS, SMB, overlay or FUSE mount, or a copy that kept them). The + refusal names each such entry: move them out of `orgs//`, then run the + purge again. Through such a mount whose directories show another inode or + modification time than theirs under `orgs//` (an overlay mount with an + upper directory shows that directory's), purge-org then cannot trace the + users file's path back and refuses it as one that does not exist (below); + a refusal naming an entry by its size and modification time names that + second step: if the purge then refuses because the users file does not + exist, delete `orgs//` by hand and run the purge again. The store still + records the users file's old path, where the in-place `packc store export` + writes it and where a pre-store build started with + `OBSERVOGRAM_USERS_FILE` set to it reads it: if that path no longer leads + to the moved file, make it lead there again before you roll back. +- **while the users file exists, no entry under `orgs//` is it, and an + entry there has its name** — the name its path ends in, as written or + through a link on it, ignoring case: a mount that is down keeps what was + written in its mount point meanwhile, so the path leads to such a file + now, and once the mount is back to the file of that name under + `orgs//`. The refusal names each such entry (and, for a link, what it + leads to under `orgs//`): move them out of `orgs//`, then run the + purge again; if the purge then refuses because the users file does not + exist, delete `orgs//` by hand and run the purge again. If one of + them is the users file, make the users file's path lead to it again + before you roll back. An entry of that name that is not the users file + is refused alike. So is a users file that does not exist whose path + leads into a directory under `orgs//` while an entry there has its + name: a directory on its path may be the mount point of a mount that is + down — `orgs//other` bind-mounted on `orgs//sub/m`, the users + file in `other` recorded through it, and unmounted — and once the mount + is back the path leads to that entry. Move the entries it names out of + `orgs//`, then run the purge again; if the users file is among them, + make its path lead to it again before you roll back. +- **while a recorded users file — or, with none recorded, + `/users.json` as a link — does not exist and its path does not + lead under `orgs//`** (but for one only through directories + `orgs//`, or its path, lies in, below), since a filesystem not mounted + now may lead it there. If it lies on a filesystem that is not mounted, + mount it and run the purge again; if it does not lie under `orgs//` (it was deleted, + say), delete `orgs//` by hand and run the purge again: with no + directory left it checks no users file and only drops the org's records. + After the first refusal's move, with any mount on its path up, that path + leads under `orgs//`, where nothing of the users file is left; the + purge goes on when purge-org can trace it there — through a bind mount, a + link, or an NFS, SMB, overlay or FUSE mount that shows that directory's own + inode and modification time — while no entry under `orgs//` has the + users file's name (above); through any other such mount it is refused + here, the second step its first refusal names. Once that org is purged its directory is gone, and the recorded path + leads into a directory that does not exist: purging another org goes on + when the path is looked up only in directories its `orgs//` lies in + (`/orgs` and those above it, by their real paths) or that the + path `/orgs/` is looked up in (with the workspace, or its + `orgs`, reached through a link, the directory that link is in and those + above it), and no entry under `orgs//` has the users file's name, + since a filesystem mounted again on one of those directories would cover + `orgs//` too, or the link `/orgs/` is reached through, + and a directory under `orgs//` mounted there would show the users file + by its name (purge-org does not see a link there to the real path of + `orgs//`). So does a users file deleted in one of those directories. With + none recorded and nothing at `/users.json`, there is no users + file to keep, and the purge goes on. +- **while the users file, or a directory under `orgs//`, cannot be + read** — run the purge as a user who can read it (a FUSE mount whose + server stopped answering: unmount it), or, when the users file does not + lie under `orgs//`, delete the directory by hand as above. +- **while a mount point is under `orgs//`, or the directory is one**: + on Node 22 the delete stops part-way at a mount point, and on Node 23 and + later it first deletes the files of the filesystem mounted there. Unmount + it, then run the purge again; where it holds mounts, which keep it busy + (`umount`: target is busy), the refusal names them to unmount first, in + order, each after the mounts in it. On Windows a mount point is a folder + another volume is mounted in, read through a handle on each directory: a + junction is none, and neither is `orgs//` reached through one, on the + volume the junction leads to. On Linux this includes a bind mount of + another directory of the same filesystem, which `/proc/self/mountinfo` + lists (not one a later mount over a directory above it covers, which it + lists too: nothing is mounted at its path then, and the purge goes on — + unless that path leads through a mount of the covered mount's own + filesystem, such as `mount --bind /srv /srv` over it or a recursive bind + whose copy of it was unmounted, where it may show the very directory the + covered mount is mounted on, which the delete cannot remove: the refusal + then names the mounts to unmount first, in order, before you unmount it — + and where one of them showed another directory there than the one it is + mounted on, the workspace's (the deployment bind-mounted over the directory + that holds the covered mount, which purge-org cannot tell apart: a declared + false refusal), unmounting it takes the workspace away, so mount it there + again once the covered mount is unmounted, then run the purge again; + and a mount on `/` itself covers nothing, since a path is looked up from + the root and never crosses a mount stacked on it); + elsewhere such a mount cannot be told from a directory, so unmount + any under `orgs//` yourself before you purge. It takes a file on + another filesystem than its directory for a mount point only when + `/proc/self/mountinfo` lists it: an overlay mount whose layers lie on + different filesystems shows its lower layers' files so, with nothing + mounted there. Where there is no `/proc/self/mountinfo`, it takes every + such file for one; on Linux, mount proc at `/proc` + (`mount -t proc proc /proc`) and run the purge again. When the users file + lies under `orgs//` as well, the refusal names it too: move it out of + `orgs//` and unmount, then run the purge again — unmounted alone, its + path may no longer lead there (a link of its name in a directory mounted + there from outside `orgs//`, to a file under it), and nothing would + keep the purge from deleting it. A mount that was down already when + purge-org first ran tells nothing of such a link. +- **while a directory under `orgs//` changed as it read it**: when the + users file exists, it reads each directory again before it goes on, and + one that changed — an entry another process added, removed or renamed in + it — may have hidden the users file from it. Stop what changes files + there, then run the purge again. + **The purge cannot be undone and asks for no confirmation.** It deletes -everything under `orgs//` at once: the org's packs, its +everything under `orgs//`: the org's packs, its `deploys.jsonl` (that org's deploy audit), its rollback `snapshots/`, its `runs/` and its `journeys/`. A `packc store backup` holds only the database, not these files, so copy `/orgs//` first if any diff --git a/deploy/k8s/README.md b/deploy/k8s/README.md index 3267cebb..d0b67a1f 100644 --- a/deploy/k8s/README.md +++ b/deploy/k8s/README.md @@ -291,7 +291,10 @@ scale to 1. `store purge-org ` (the files of an org removed with `tools/org-admin.mjs remove`) runs the same way; it cannot be undone and asks for no confirmation, and it deletes that org's packs, `deploys.jsonl`, `snapshots/`, `runs/` and `journeys/`, which no `store backup` holds, so -copy `/data/workspace/orgs/` off the claim first. +copy `/data/workspace/orgs/` off the claim first. It refuses, deleting +nothing, while that would take the users file a pre-store build reads, or a +mount point is under that directory; the README's "Purge A Removed Org" +names the ways out. ### Storage class diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e0a913ab..988bf304 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,25 @@ ## Unreleased +### Follow-up `purge-org-users-file-identity`: purge-org decides the users file by what deleting the root takes +- **`packc store purge-org` decides whether it would delete the users file by what deleting the org's root takes, not by how the users file's path is spelled** (**a declared default change**, on every platform; `server/store/users-file-place.mjs`, new; `server/store/ops.mjs`). The users file a pre-store build reads — the one recorded at the import when `OBSERVOGRAM_USERS_FILE` was set then, else `/users.json` — may have its bytes under the root while its path lies outside it, and the check compared the path alone (realpath, which never crosses a mount back to its source). So with org beta's root at `/srv/ws/orgs/beta` and the users file recorded as `/mnt/b/users.json`, `/mnt/b` a bind mount of `/srv/ws/orgs/beta/sub`, `packc store purge-org beta` deleted `/srv/ws/orgs/beta/sub/users.json` — named directly, through a file link (`/cfg/users.json` → `/mnt/b/users.json`) or through a directory link (`/cfg/d` → `/mnt/b`); an NFS or SMB loopback mount of such a directory is the same hole on another filesystem; and with nothing recorded, a `/users.json` that is a link into the root was deleted alike. purge-org now walks the root as `rmSync` takes it — `lstatSync` each entry, every directory listed, never through a link (a symlink, or a junction on Windows) — and follows the users file's path through every link (`statSync`). The users file lies under the root when an entry is the same file: the same device and inode (on Windows the volume serial's low 32 bits, all a share reports, as `server/store/paths.mjs` places a path), or, on another device, the same size and modification time, compared at the coarser of the two clocks' precision — a view of it through another filesystem: an NFS or SMB mount reports the server's inode under another device (SMB keeps 100 ns), an overlay mount another device and, with `xino` or once the file is copied up into an upper directory under the root, another inode too, and a FUSE mount without inode numbers (sshfs without `-o use_ino`) another inode, in whole seconds. The refusal names every such entry, hard links included, so one move clears them: `the recorded users file lies under : it is there — nothing was deleted. Move out of , then run purge-org again. If then no longer leads to the users file, make it lead there again before a pre-store build or the in-place packc store export uses it` (`the users file /users.json (the one a pre-store build reads: OBSERVOGRAM_USERS_FILE was not set at the import) …` when none was recorded; for a view, ` under it has its size and modification time on another filesystem (the users file seen through a network or overlay mount, or a copy that kept them)`, its way out with a second step after `then run purge-org again`: `; if it then refuses because does not exist (a mount whose directories show another inode or modification time than theirs under , as an overlay mount with an upper directory does, cannot be traced back there), delete by hand, then run purge-org again: it drops org 's records`). The way out holds followed literally: once no entry under the root is the users file, the purge goes through when the recorded path still leads to the users file (a hard link or a copy moved out) or leads under the root, where nothing of it is left (below); through a view whose directories show another inode or modification time — an overlay mount with an upper directory shows that directory's, not those of the lower one under the root (reproduced with a real overlay, `xino` on or off) — nothing traces the path back, so purge-org refuses it as a users file that does not exist, and deleting the root by hand, the second step the view's refusal names, lets purge-org drop the org's records. Declared false refusals, each with that way out: a hard link under the root to a users file outside it (`rmSync` removes only the link), and a copy of it under the root on another device that kept its size and modification time (`cp -p`, `rsync -t`, `tar`). +- **A recorded users file that does not exist is decided by where its path leads, so `move it out first` works** (**a declared default change**). `the recorded users file lies under — move it out first; nothing was deleted` came back word for word after the move: the record still names the path under the root, realpath falls back to the path as written, and the check refused again. The comparison by path now holds only while the recorded users file exists. One that does not is followed as the kernel resolves a path — every link that exists followed, a dangling one by its target — to the nearest directory that exists there: when that is a directory under the root (the same device and inode, or the view rule above) and no entry under the root has the users file's name (below), nothing of the users file is there to delete and the purge goes on — a users file recorded under the root and moved out, or reached through a bind mount of a directory under the root, a view that shows that directory's own inode and modification time, or a link into it (on Windows, not a dangling link: a path through a link whose target cannot be opened is refused before this check, by `purge-org-unresolvable-links`' check in `server/store/paths.mjs` below, whose way out — deleting the root by hand — holds). Any other is refused, failing closed (but one whose path runs only through directories the root, or its path, lies in, below), since a filesystem not mounted now may lead it under the root: with the bind mount above down, its mount point is an empty directory, and purge-org deleted `/srv/ws/orgs/beta/sub/users.json` behind it. The refusal: `the recorded users file does not exist, and nothing on this host tells where its path leads[ (to )]: the nearest part of it that exists, , is no directory under , and a filesystem not mounted now may lead it under — nothing was deleted. If it lies on a filesystem that is not mounted now, mount it, then run purge-org again; if it does not lie under , delete by hand, then run purge-org again: it drops org 's records`. A declared false refusal: a recorded users file outside the root that no longer exists (deleted on purpose after the upgrade) is refused — unless its path runs only through directories the root, or its path, lies in (below) — and its way out holds — with no root left, purge-org checks no users file and drops the records. With nothing recorded, an absent `/users.json` — nothing at its path, not even a link (a store that keeps no local users may have none) — is nothing to keep: the purge goes on, as before. A `/users.json` that is a link whose users file does not exist is followed as a recorded one is: with `/users.json` → `/mnt/b/users.json` and the bind mount above down, it was taken for absent and purge-org deleted `/srv/ws/orgs/beta/sub/users.json`; now it is refused, as `the users file /users.json (the one a pre-store build reads: OBSERVOGRAM_USERS_FILE was not set at the import) does not exist, …`, with the same ways out. +- **A users file purge-org cannot read is refused** (**a declared default change**: the comparison fell back to the path as written, and the purge went through). `statSync` failing otherwise than that it does not exist — `EACCES` on a directory of its path for the user purge-org runs as, a link loop or a chain of more than 40 links — or a part of its path that cannot be read: `the recorded users file cannot be read on this host (EACCES[ at ]), so nothing tells whether it lies under — nothing was deleted. Run purge-org as a user who can read ; or, if it does not lie under , delete by hand, then run purge-org again: it drops org 's records` (for a loop, `ELOOP: a link loop, or a chain of more than 40 links`, with the second way out alone). +- **purge-org reads the whole root before it deletes it, and refuses a mount point under it or a directory it cannot read** (**declared default changes**). On Node 22 `rmSync` stops part-way at a mount point (`EBUSY`); on Node 23 and later it first deletes the files of the filesystem mounted there; and a directory mounted under the root from outside it may hold the users file (purge-org deleted a users file outside the root whose directory was mounted under it, on Node 23 and later). A mount point is a directory on another filesystem than the directory that holds it, on Linux an entry `/proc/self/mountinfo` lists (a bind mount of a directory of the same filesystem, which `lstatSync` cannot tell from a directory: with `/srv/x` bind-mounted at `/m`, `rmSync` deleted `/srv/x`'s files on Node 23 and later and then failed with `EBUSY` (Node 22 failed there with `EBUSY`), and when `/srv/x` held the users file, the `lies under` refusal named `/m/users.json` to move out, and after the move the purge deleted the rest of `/srv/x` the same way; or a file mounted on a file; matched by the root's real path, as the kernel lists mount points; not one a later mount covers — mounted in the same parent mount over a directory above it, or covering that parent so — which the kernel lists too while its path shows a directory of the filesystem mounted above, where nothing is mounted: with a tmpfs mounted on `` over a bind mount at `/m`, the workspace made on the tmpfs purges, where `umount /m` says `not mounted` — unless that path leads through a mount of the filesystem the covered mount lies in (its parent mount's device, as `/proc/self/mountinfo` lists it, or, where it does not list that mount — a chroot into a directory that is no mount point — the filesystem `/` shows, compared with the directory at the covered mount's path now), where it may show the very directory the covered mount is mounted on, which `rmdir` refuses while it is a mount point: with `mount --bind ` over a bind mount at `/m` (an fstab line mounted out of order), or `mount --rbind ` and its copy at `/m` unmounted as the refusal said, `rmSync` deleted part of the root, then failed there with `EBUSY` and the org's records stayed, and every later run failed the same way; such a mount is refused, `/m (the same filesystem as , listed in /proc/self/mountinfo, covered by a later mount through which its path may show what it is mounted on: first unmount )`, naming the mounts in a covering mount before it (a declared false refusal where that mount shows another directory there — the workspace's, with the deployment bind-mounted over the directory that holds the covered mount: followed literally, `umount ` took the workspace away and purge-org, run again, said `no database at /observogram.db — nothing to purge`, so the refusal 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 /m is unmounted, before you run purge-org again`, and followed so — `umount `, `umount /m`, the workspace mounted on `` again — the purge passes, reproduced with real mounts in a private mount namespace); a mount on `/` itself covers nothing, since a path is looked up from the process's root and never crosses a mount stacked on it: with a tmpfs mounted on `/` in a chroot, or `mount --bind / /`, a bind mount at `/m` is refused still, where `rmSync` failed with `EBUSY`), a directory the walk reached already (one directory mounted at a second place under the root, a loop among them), or on Windows a folder another volume is mounted in; any other entry on another filesystem than its directory is one only as `/proc/self/mountinfo` lists it, since an overlay mount whose layers lie on different filesystems (`xino=off`) shows its directories on its own device and a lower layer's files on that layer's, with nothing mounted there (a workspace in one, its lower layer on tmpfs and its upper on ext4, purges); the root itself mounted (a volume per org) is refused alike: ` holds a mount point, (another filesystem than ; or the same filesystem as , listed in /proc/self/mountinfo): deleting would take the files of the filesystem mounted there (Node 23 and later) or stop part-way at it (Node 22), and a directory mounted from outside may hold the users file — nothing was deleted. Unmount what is mounted there, then run purge-org again` (with the users file under the root as well, it names that too: below), and for the root, `… is a mount point: … Unmount it, then run purge-org again: it deletes what is left at and drops org 's records, and the unmounted filesystem's files stay`. A directory under the root it cannot read was where `rmSync` failed part-way; now nothing is deleted (an unreadable `/proc/self/mountinfo` is refused alike): ` under cannot be read (EACCES), so purge-org cannot tell what deleting takes — nothing was deleted. Run purge-org as a user who can read it; or, if the recorded users file does not lie under , delete by hand as such a user, then run purge-org again: it drops org 's records` (for another error, such as `ENOTCONN` from a FUSE mount whose server stopped answering: `If it is a filesystem mounted there that stopped answering, unmount it, then run purge-org again; or, if …, delete by hand, then …`). +- **purge-org reads each directory under the root again before it goes on, and refuses one that changed while it read the root** (**a declared default change**; `server/store/users-file-place.mjs`). The walk read each directory once, at its own moment, so an entry another process renamed while it ran, from a directory not read yet into one read already, was in neither listing: with the users file recorded as `/mnt/b/users.json`, `/mnt/b` a bind mount of `/z/sub`, `/z/sub` renamed into `/a` once `/a` was read hid the users file, and `rmSync` deleted it (reproduced on ext4 with a real bind mount, the rename made at that step of the walk). Now, before a purge whose users file exists goes on, each directory the walk read is read again — the same directory (device and inode), the same change time, which moves with every entry added, removed or renamed in it, and the same names — and one that changed is refused: ` under changed while purge-org read (an entry added, removed or renamed in it, or a mount over it), so purge-org cannot tell what deleting takes — nothing was deleted. Stop what changes files under , then run purge-org again` (the root itself: ` changed while purge-org read it …`). Run again with nothing changing it, the walk finds the users file and refuses it as lying under the root. `server/test-store-ops.mjs` (one more, over `server/fixtures/mount-fs.mjs`, whose directories now carry a change time that moves with each entry added, removed or renamed in them, and whose `coarse` clock keeps every change time the same): the rename into a directory read already, or into the root, refused by the change time and the names, and on a coarse clock by the names; a rename on, before the second reading, that leaves the names as the walk found them, refused by the change time; on a coarse clock, a mount over a directory read already holding the same names, refused by its inode. Suite total 1510 → 1511 tests. +- **purge-org reads a name under the root that is not valid UTF-8** (**a declared default change**: a refusal where it purged; `server/store/users-file-place.mjs`). The walk listed each directory as strings, and `node:fs` gives a name that is not valid UTF-8 with U+FFFD in place of each such byte (`c\xffd` as `c�d`), a name no lookup finds: `lstatSync` of it failed with `ENOENT`, which the walk took for an entry gone while it ran, so it read nothing under it, while `rmSync`, which lists names as bytes, deleted all of it. With the users file recorded as `/cfg/users.json`, a link to `/c\xffd/users.json` (or `/run\xe9/inner/users.json`), purge-org deleted it, as `develop` does (reproduced on ext4); and a mount point below such a name went unseen, and Node 22's `rmSync` failed there with `EBUSY` (reproduced with a bind mount from the root's own filesystem and with a tmpfs). The walk now lists each name as the bytes the filesystem holds and follows one that is not valid UTF-8, and every path under it, as bytes; a refusal names such a path as a POSIX shell's `$'…'` spells it, each such byte `\xNN`, so it can be typed: `the recorded users file /cfg/users.json lies under : it is $'/c\xffd/users.json' there — nothing was deleted. Move $'/c\xffd/users.json' out of , then run purge-org again. …`. Followed literally in a shell (`mv $'/c\xffd/users.json' /keep/`), the purge passes and the moved file stays; a mount point below such a name is refused as one, `$'/c\xffd/inner/m' (another filesystem than $'/c\xffd/inner')` or `(the same filesystem as …, listed in /proc/self/mountinfo)`, and passes once unmounted. A name that is valid UTF-8 is read and named as before. `server/test-store-ops.mjs` (one more, over `server/fixtures/mount-fs.mjs`, which now spells a byte that is not part of a UTF-8 character as U+DCNN, takes a path as a Buffer, and lists names as `node:fs` does — as strings, each such byte U+FFFD, or with `{ encoding: 'buffer' }` as bytes): the users file in such a directory reached through a link, refused, and the way out followed, also with a quote and a backslash in the path, escaped (`$'/it\'s\\c\xffd/users.json'`; the fixture keeps a backslash in a name on POSIX paths); with the users file outside, a tree of such names, purged; below such a name a directory from another filesystem mounted there, a same-filesystem bind mount `/proc/self/mountinfo` lists (also under a root whose path is not ASCII, `/srv/données/…`), and a directory it cannot read, refused. Suite total 1511 → 1512 tests. +- **purge-org refuses an entry under the root with the users file's name, which a mount that is down leads to once it is back** (**a declared default change**: a refusal where it purged; `server/store/users-file-place.mjs`). Failing closed through a mount that is down held only while the users file did not exist, and a mount that is down keeps what was written in its mount point meanwhile: with the users file recorded as `/mnt/b/users.json`, `/mnt/b` a bind mount of `/sub` that is down, and a `users.json` written in `/mnt/b` meanwhile, the path led to that file, outside the root, and purge-org deleted `/sub/users.json`, the users file the path leads to once the mount is back (reproduced on ext4 with a real bind mount, as `develop` does). Now, when the users file exists and no entry is it, an entry under the root with its name — the name its path ends in, as written or at each link it leads through, compared ignoring case and Unicode normalization (as a case-insensitive filesystem may look it up) — is refused, on every platform, recorded or `/users.json` alike: `the recorded users file is no file under , but under it has its name: 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 out of , then run purge-org again; if it then refuses because does not exist (the users file seen there through a mount that cannot be traced back), delete by hand, then run purge-org again: it drops org 's records. …`, ending, as the refusal of a users file under the root does, with making the path lead to a moved users file again. An entry of that name that is a link names what it leads to under the root too, which `rmSync` deletes once the link is moved. Followed literally — the entry moved out, the mount down or up — the purge passes and the moved file stays; mounted again first, the refusal of a users file under the root comes, with the same way out. A declared false refusal: an entry under the root with the users file's name that is not it (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) shows its names, so the users file seen through it, which passed, is refused by its name. `server/test-store-ops.mjs` (one more, over `server/fixtures/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, `/users.json` a link there — and a link of that name under the root naming what it leads to; mounted again, refused as lying under the root; moved out, passing with the mount up or down; and an entry of that name below a name that is not valid UTF-8, refused naming it as `$'…'` spells it and passing once moved out. The place suite's users file outside the root now has a name no entry under the root has (`/srv/accounts.json` and the like), a fixture whose names match is refused by the name, and the FUSE view's case expects that refusal. Suite total 1512 → 1513 tests. +- **A users file moved out of an org's root as its refusal said no longer refuses every later purge of another org** (**a declared default change**: a purge where it refused; `server/store/users-file-place.mjs`). Once that org was purged, the record still named the path in its root, which no longer exists, and every purge of another org was refused as a recorded users file that does not exist — `… the nearest part of it that exists, /orgs, is no directory under /orgs/beta …` — until the path led to the moved file again, which meant re-creating directories in the purged org's root; neither the refusal nor README said so. Now a users file that does not exist, whose path is looked up only in directories the root lies in — its parent and theirs, on the root's real path — or that the root's own path, `/orgs/`, is looked up in (with a workspace, or its `orgs/`, reached through a link in a directory the root does not lie in, that directory and those above it — `/home/u` and `/home` with `OBSERVOGRAM_WORKSPACE=/home/u/ws`, a link to `/data/ws`), each by its path (case folded on Windows) and its device and inode, while no entry under the root has the name its path ends in (as written or at each link it leads through, ignoring case and Unicode normalization), is nothing to keep, and the purge goes on: a filesystem mounted again on one of those directories covers the root too, or hides the link the root's own path is reached through, so through it the path leads under the root by a directory under the root mounted in it, which shows the users file by its name, or by a link purge-org does not see (below). A recorded users file deleted in one of those directories passes alike. With an entry of that name under the root it is refused as before — `/orgs` a bind mount of `/x` that is down, the users file written through it at `/x/gamma/sub/users.json` (with a real bind mount, this rule without the name check purged it) — and so is a path looked up in any other directory (a link in `/cfg` into the purged directory), with the same ways out. `server/test-store-ops.mjs` (one more, on the real filesystem through `purgeOrg`): the users file recorded under acme's root refused, moved out as the refusal says, acme purged, then beta removed and purged, the moved file kept, with the workspace named by its real path, through a directory link to it in a directory the roots do not lie in (a junction on Windows), and with its `orgs/` such a link — refused without this rule, and through either link with only the directories on the root's real path. The place suite's path in a sibling org's directory that does not exist now passes — deeper in it, a file deleted in `/ws`, through a link in `/ws`, and with the root named through a link (by its real path) too, or through a link in a directory it does not lie in (the workspace, `/home/u/ws` → `/ws`, or its `orgs/`), and a file deleted in `/home/u` — while through a link in a directory neither the root nor its path lies in, to a file or a directory, it is refused (so is a path through `/home/u/ws` with the root named by its real path), and with an entry of its name under the root refused, passing once that is moved out; on Windows paths, a sibling's directory by any spelling passes, and a directory that differs from one the root lies in by case alone (case-sensitive) is refused. Suite total 1513 → 1514 tests. +- **purge-org reads a root of many files without running out of memory** (`server/store/users-file-place.mjs`; no refusal, text or default changes). The walk kept every entry's stats — eighteen bigints, some 770 bytes an entry — until it ended, since the users file was placed only after it: with 100 directories of 1,000 empty files under the root, purge-org under `--max-old-space-size=40` died with `JavaScript heap out of memory`, nothing deleted, while `rmSync` of the same tree completed (in a 1 GB heap, about 1.3 million entries; the k8s one-off pod sets no limit, so where it failed depended on the host). Now the users file is placed before the walk and each entry is compared with it, and with its name, as the walk reaches it; it is placed again after the walk, and one created, replaced or written meanwhile (another device, inode, size or modification time) is compared with each entry read again, in the walk's order. Of a directory the walk keeps its path, device, inode, change and modification time and a digest of its names, and it takes a directory's entries from its listing as it goes, holding only the listings of the directories it is in, as Node 22's `rmSync` does. So its memory grows with the directories under the root — a few hundred bytes each, and its path: with 100,000 directories, some 1 MB more for each 10 characters of the root's path — and with the longest listing, not with the files. With the root's path 40 characters long (Node 22.16 and 22.22), that root passes in a 16 MB heap, one directory of 100,000 files in 32 MB (`rmSync`: 24 MB), 100 directories of 1,000 empty directories in 40 MB (`rmSync`: 8 MB), and one directory of 100,000 empty directories, whose listing it holds while it reads each, in 64 MB (`rmSync`: 24 MB). `server/test-store-ops.mjs` (one more): over an injected filesystem that makes up a root of 250,000 files — 150 directories of 1,000 and one of 100,000 — as each is asked for, the check passes in a child with a 32 MB heap, where at the parent commit it died out of memory (and so it does with the directory's entries pushed ahead of the walk), and a root of one directory of 100,000 empty directories, its path 40 characters long, in one with a 64 MB heap; a users file replaced while the walk runs by a hard link to a file under the root (another inode alone, its two names there named in the walk's order) or by a link to a file of another filesystem with the same inode number, size and time (another device alone), created as a hard link, or written in place through an NFS view (its size alone, or its time alone, moved) is refused as lying under the root. Suite total 1514 → 1515 tests. +- **purge-org refuses a users file that does not exist whose path leads into a directory under the root while an entry under the root has its name** (**a declared default change**: a refusal where it purged; `server/store/users-file-place.mjs`). Such a path passed without the name check a users file that exists has, and a directory on it may be the mount point of a mount that is down: with `/other` bind-mounted on `/sub/m` and the users file, `/other/users.json`, recorded through it — as `/cfg/users.json`, a link to `/sub/m/users.json`, or as `/sub/m/users.json` — purge-org refused the mount point, and unmounted as that refusal said, purged the root and deleted `/other/users.json`, the file the record leads to once the mount is back (reproduced on ext4 with a real bind mount in a private mount namespace; `develop` deleted it recorded through the link, and refused it recorded under the root). Now it is refused by the name, as a users file that exists is: `the recorded users file does not exist, and its path leads into , under , but 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 out of , then run purge-org again. If the users file is among them, make lead to it again before a pre-store build or the in-place packc store export uses it` (`… leads into , but …` when that directory is the root; an entry of that name that is a link names what it leads to under the root too). Followed literally — the entry moved out of the root — the purge passes and the moved file stays. The refusal of a users file that exists with an entry of its name reads as before. A users file moved out of the root as the refusal of one under it says still passes when 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. `server/test-store-ops.mjs` (one more): over `server/fixtures/mount-fs.mjs`, that bind mount up refused as a mount point and, unmounted, refused by the name — recorded through a file link, directly or through a directory link — with a link of that name naming what it leads to, and moved out passing; on the real filesystem through `purgeOrg`, with no mount made, what that mount leaves once it is down refused — recorded directly or through a directory link — nothing deleted, and moved out purged, the moved file kept; at the parent commit both parts let the purge through. The place suite's NFS and SMB views' way out now meets `/up/users.json`, another entry of the users file's name there, is refused by it, and passes once that is moved out too. Suite total 1515 → 1516 tests. +- **purge-org's refusal of a mount point under the root names the users file under the root as well, to move out with the unmounting** (**a declared default change**: a refusal's text and its way out; `server/store/users-file-place.mjs`). Unmounting, that refusal's way out, may take away the path to the users file: with a directory from outside the root, `/src`, bind-mounted on `/sub/m`, `/src/users.json` a link to `/x/data.json`, which holds the users file's bytes, and the users file recorded as `/sub/m/users.json` (or as `/cfg/users.json`, a link to it), purge-org refused the mount point, and 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 `/x/data.json` (reproduced on ext4 with a real bind mount in a private mount namespace; `develop` refused it while mounted, and unmounted it refused the record `/sub/m/users.json` by the path as written — the refusal whose way out failed — and deleted `/x/data.json` recorded through the link). Now, when the walk finds the users file under the root besides a mount point — the entries that are it, placed as for the refusal of a users file under the root, none at a mount point — the refusal names them: ` holds a mount point, (…): deleting would take the files of the filesystem mounted there (Node 23 and later) or stop part-way at it (Node 22), and a directory mounted from outside may hold the users file; and the recorded users file lies under : it is there — nothing was deleted. Move out of 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 then no longer leads to the users file, make it lead there again before a pre-store build or the in-place packc store export uses it` (a view named as that refusal names one, with its second step). Followed literally — the file moved out and the mount point unmounted — the purge passes and the moved file stays (reproduced so with the real bind mount); with `/other` mounted there instead (above), the refusal names `/other/users.json` alike, and followed, the purge passes. A mount with no users file under the root besides is refused as before. Not seen: such a mount that was down already when purge-org first ran (below). `server/test-store-ops.mjs`: the test of `/other` bind-mounted on `/sub/m` (above) extended, no new test — that mount up, refused naming the users file, and followed, passing; `/src` mounted there from outside, its `users.json` a link to `/x/data.json`, recorded directly and through a file link, refused naming it, and followed, passing with the moved file kept; unmounted first, passing (declared). With `users-file-place.mjs` at the parent commit both fail. The total stays 1516. +- **purge-org passes over a covered mount under the root in a chroot into a directory that is no mount point** (**a declared default change**: a purge where it refused; `server/store/users-file-place.mjs`). There `/proc/self/mountinfo` lists neither `/` nor the mount the process's root lies in, while the mounts in it name that mount as their parent, and a covered mount whose parent is not listed was taken for one whose path may show what it is mounted on: with `/work/outside` bind-mounted on `/m` and a tmpfs mounted on the workspace over it (the case above, which purges outside a chroot), purge-org refused `/m (the same filesystem as , listed in /proc/self/mountinfo, covered by a later mount through which its path may show what it is mounted on: first unmount )`, and followed literally, `umount ` unmounted the tmpfs holding the whole deployment — the database and every org (reproduced with real mounts in a private mount namespace, in a chroot into a plain directory; with the chroot's root bind-mounted on itself, so that the parent is listed, it purged). A parent the kernel does not list is the mount the root lies in, so its filesystem is now taken as `/` shows it (a lookup of `/` never crosses a mount stacked on it) and compared with what is at the covered mount's path, as `rmSync` takes it (a link there is a link of the filesystem above, deleted without being followed — not what it leads to): of another filesystem, the covered mount is passed over, as outside a chroot, and the purge goes on; of the same (`mount --bind ` over it, in the chroot), it is refused naming what to unmount first, as before, and followed literally — `umount `, then `umount /m` as the next refusal says — the purge passes and the mounted directory's files stay. `server/test-store-ops.mjs` (one more, over `server/fixtures/mount-fs.mjs`, whose `chroot` option lists neither `/` nor the root's mount): that tmpfs over a bind mount at `/m`, and over the root itself, passing; `/m` in the tmpfs a link to a directory of the root's own filesystem passing, the link's target kept (compared through the link, it was refused naming `` first); `/m` mounted again on the tmpfs refused and passing once unmounted; `` bound on itself refused naming it first and, followed, passing; a tmpfs mounted on `/` in the chroot covering nothing. With `users-file-place.mjs` at the parent commit the first fails, refused naming `` first. Suite total 1516 → 1517 tests. +- **purge-org's refusal of a mount point under the root names the mounts in it to unmount first** (**a declared default change**: a refusal's text; `server/store/users-file-place.mjs`). The walk never enters a mount point, so the mounts in it went unnamed, though `/proc/self/mountinfo` lists them: with `/work/outside` bind-mounted on `/m` and `/work/inner` on `/m/n`, purge-org refused `/m (the same filesystem as , listed in /proc/self/mountinfo)` alone, and followed literally, `umount /m` said `target is busy`, and every later run came back with the same refusal — the way out worked only with `umount /m/n` first, or `umount -R` (reproduced with real mounts in a private mount namespace). A covered mount's refusal named the mounts in it already (above); now a mount point's does too, in order, each after the mounts in it — a mount in it that a later one over a directory above it covers after the one that covers it: `/m (the same filesystem as , listed in /proc/self/mountinfo, holding mounts, which keep it busy: first unmount /m/n/o, then /m/n, then /m/p)` (on another filesystem, `(another filesystem than , holding a mount, which keeps it busy: first unmount /m/n)`), and followed so — each named, then `umount /m`, as the refusal says — the purge passes and the mounted directories' files stay (reproduced with the real mounts). With a mount stacked on `/m`, `umount /m` takes the topmost, so the mounts in that alone are named, and once it is off, the lower mount's. `server/test-store-ops.mjs` (one more, over `server/fixtures/mount-fs.mjs`): a bind at `/m` holding one at `/m/n`, one in that at `/m/n/o` and one beside it at `/m/p`, 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; in a mount in it, one at `/m/n/x/y` and a later one at `/m/n/x`, which covers it and holds one at `/m/n/x/z`, named `/m/n/x/z, then /m/n/x, then /m/n/x/y, then /m/n`, part-way the covered one then `/m/n`, and passing once unmounted so with the covered mount's directory's file kept (with the mounts in a mount in `/m` named the innermost first, which the suite did not tell from this order, the refusal named `/m/n/x/y` first, and on real mounts `umount` of it said no mount point specified, `/m/n` and `/m` stayed busy, and the same refusal came back); a tmpfs on `/m` holding a bind alike; a mount stacked on `/m` naming the mounts in the topmost alone, and once that is off, the lower mount's. With `users-file-place.mjs` at the parent commit it fails, refused naming `/m` alone. Suite total 1517 → 1518 tests. +- **On Windows purge-org reads a directory's volume through a handle, so a root reached through a junction is no mount point on any libuv** (**a declared default change**: a purge where it refused; `server/store/users-file-place.mjs`). On the Windows leg on Node 22.16.0, with a workspace whose `orgs` is a junction to a directory on its own volume, purge-org refused the root, `\orgs\acme (another filesystem than \orgs) is a mount point: …`, where Node 22.23 went on. The root's device came from `lstatSync`, its directory's from `statSync` of the junction; libuv answers a stat by the path's name (`GetFileInformationByName`, Windows 11 24H2 and Server 2025) unless its last name is a reparse point, which it opens, reading the volume's serial (`FILE_FS_VOLUME_INFORMATION`), and libuv 1.49.0 to 1.50.x (Node 22.16.0's 1.49.2) declared `FILE_STAT_BASIC_INFORMATION` with its last two fields swapped, so the one by name reported the upper half of the file's 128-bit id (0 on NTFS) in place of the serial — no mask of its low 32 bits makes it the volume's (libuv 1.51.0 fixed it). On Windows a volume is mounted only in a folder, so purge-org now compares only directories there, each read through a handle opened on it (`openSync`, `fstatSync`: the serial on every libuv, a junction followed to the volume it leads to), and a file or a link under the root lies on its directory's volume — with a junction under the root to a directory outside it, the walk on libuv 1.49.0 to 1.50.x refused it as a mount point too. A volume mounted in a folder under the root, or on the root, is refused as before, on either libuv: ` (another volume mounted in it)`. A stat by name still reports no serial on those libuv versions, so a users file whose path ends in a link (opened) reads as on another device than its entry under the root and is named as a view of it — refused still, as `… has its size and modification time on another filesystem`. Tests: `server/test-store-ops.mjs` (one more), over the injected Windows host (`server/fixtures/mount-fs.mjs`: `fastDev`, the device a stat by name reports where libuv answers it so, and `openSync`/`fstatSync`/`closeSync`), with each libuv's stat by name: a root whose workspace's `orgs` is a directory, a junction to one on its own volume, or a junction to one on another volume, with a junction under it leading out, purges or names the users file under it, while a volume mounted in a folder under the root, or on the root, is refused, every handle closed. With `users-file-place.mjs` at the parent commit it fails on libuv 1.49.2's stat, refused as the Windows leg was. Suite total 1518 → 1519 tests. +- **Known, not fixed.** Where there is no `/proc/self/mountinfo` (off Linux, or `/proc` not mounted), a bind mount under the root of a directory from outside it, on the root's own filesystem, is a directory to `lstatSync` (the same device): the walk enters it, and a users file seen through it is named as an entry to move. There, too, any entry on another filesystem than its directory is refused as a mount point, a lower layer's file of an overlay mount whose layers lie on different filesystems included: on Linux with `/proc` not mounted that is a false refusal, whose way out is to mount proc at `/proc` (`mount -t proc proc /proc`), then run purge-org again. A view that reports neither the inode nor the size and modification time (some FUSE filesystems) is not matched as the users file: through a view of a directory, which shows its names, the users file is refused by its name (above), but a file seen under another name is not. A recorded path that passes through a link under the root keeps the file the link leads to (`rmSync` deletes the link alone), but leads nowhere afterwards, as before. A users file moved out as a refusal says is no longer where the store records it: the in-place `packc store export` writes the users file at the recorded path, and a pre-store build reads it where `OBSERVOGRAM_USERS_FILE` names it, so make the recorded path lead to it again before either needs it (the refusal says so). Not seen either: what another process moves, links or mounts under the root after the check, before `rmSync` takes it (as before); a directory changed and changed back within one tick of a coarse filesystem clock, its change time and names as they were; a same-filesystem bind mount made on a directory under the root after `/proc/self/mountinfo` was read, before the walk reaches it, or uncovered there then (a bind mount from the same filesystem over a directory above the root, unmounted); and, through a mount that is down, what the name does not tell — a file mounted on a file of another name (`/etc/obs/users.json` the mount point of `/sub/accounts.json`), its mount point a file of its own, what a link of that name under the root leads to back through the mount that is down, or what a link of that name in a directory mounted under the root from outside it leads to under the root, once that mount was down before purge-org saw it up (up, its refusal names the users file there); and a filesystem that is down now, mounted on a directory the root lies in, whose link leads a users file that does not exist into the root another way than by its name there (through a second mount of the root's filesystem), or mounted on one only the root's path as named is looked up in (`/home/u`, holding the link to the workspace), by a link to the root's real path. Unchanged: the Windows refusal of a path it cannot place runs first. +- **Docs.** README "Purge A Removed Org" lists what purge-org refuses before it deletes anything — on Windows first, a recorded users file whose path cannot be fully resolved or runs through a share nothing places (`purge-org-unresolvable-links`, below); the users file a pre-store build reads, by what deleting the directory takes; an entry with the users file's name, which a mount that is down may lead its path to; a recorded users file that does not exist whose path does not lead under it (but for one only through directories it, or its path, lies in, with no entry under it of the users file's name); a users file or directory it cannot read; a mount point; a directory that changed as it read it — with each way out, the users file's recorded path to make lead to a moved file before a rollback, and, off Linux, a same-filesystem bind mount to unmount by hand; it no longer says the purge deletes the directory "at once" (across a mount point it did not). `deploy/k8s/README.md`'s purge note points there. +- **Tests.** `server/test-store-ops.mjs` (fifteen more): over an injected filesystem (`server/fixtures/mount-fs.mjs`, new: several filesystems, bind mounts that stack and cover as Linux's do, views of a directory through another filesystem, folders a volume is mounted in, directories that cannot be read or can only be listed, on POSIX or Windows paths), the users file under the root through a bind mount that is up, by its path there, through a file link and a directory link, and as spelled, refused naming its entry, and `/users.json` through a link when none is recorded, while links under the root that lead out — to the users file, to its directory, to another filesystem — are not followed and the purge passes; every hard link named and one move clearing them; NFS, SMB, overlay (`xino`, an upper directory under the root, and one outside it) and sshfs-like views refused, a FUSE view keeping neither not matched as the users file, another file of the same size or time not matched, a copy keeping both refused and its way out passing, and the NFS and SMB views' way out followed passing (the path leads to the view of the directory under the root, where nothing of the users file is left), while through an overlay whose upper directory lies outside the root (its merged directory showing that directory's inode and time), an `xino` overlay and an sshfs-like view it is refused as a users file that does not exist, the second step each view's refusal names; a directory mounted from outside, a file mounted there, one directory reached twice, a loop and the root itself refused and passing once unmounted; a file on another device than its directory that `/proc/self/mountinfo` does not list (an overlay's lower layer on another filesystem) passing, and a file from another filesystem mounted on a file, which it lists, refused, and one of the root's own filesystem, holding the users file or not, refused and passing once unmounted; a same-filesystem bind mount from outside entered with no `/proc/self/mountinfo`, and where it lists one (a space in its path escaped) — holding the users file or other files, under a root reached through a link (by its real path), or the root itself — refused and passing once unmounted, and an unreadable one refused; a mount it lists that a later mount over a directory above it covers, a mount in one and one in that, and the root itself covered passing, and a mount made there on the filesystem above refused and passing once unmounted, and likewise a bind mount under the root with the workspace on a filesystem mounted on `/ws` beside one mounted on `/w` (no directory above `/ws`), and one covered by a mount of its own filesystem (`/ws` bound on itself, a recursive bind's copy of it unmounted, a bind mount on the filesystem above of the directory it lies in; a tmpfs on `/m` under `/ws` bound on itself, placed by its parent mount's filesystem, not its own; a stack on `/ws` — `/ws` bound on `/alt`, a tmpfs on `/ws`, `/alt` bound over it — by the topmost mount of the stack) and the root itself so covered refused naming what to unmount first (and, the deployment bind-mounted on `/ws` over the directory holding the bind at `/m`, so that `/ws/observogram.db` goes with `umount /ws`, naming the workspace to mount there again once `/m` is unmounted, and passing once mounted again) — what covers its parent mount (`/ws/orgs` bound on itself, the bind at `/m` made in it, then `/ws` bound on itself) before what is above it in that parent (a tmpfs stacked on `/ws/orgs` in it), two coverers in one parent (`/ws/orgs`, then `/ws`, each bound on itself) the outermost first, and the mounts in it (a bind at `/m/n`, which keeps it busy) last — and passing once unmounted in that order, while with a mount stacked on `/` — a tmpfs, or `/` bound on itself — a bind mount under the root, holding other files or the users file, is refused and passes once unmounted; an unreadable directory refused and passing for a user who can read it, one it can list but not search (`r--`) refused naming the entry it cannot read, and a stale FUSE mount refused naming unmounting it; on Windows paths, a junction, a share of a directory under the root (by the serial's low 32 bits; compared on all 64 a copy read as a view) and a volume mounted in a folder; a users file that does not exist through a mount that is down refused — recorded, or, none recorded, `/users.json` a link there — mounted again refused as under the root, and moved out passing; recorded under the root and moved out, or reached by a link whose target is gone from the root (relative, with `..`) or by a chain of 40 links (as many as Linux follows in a path), passing, and one deleted outside the root, through a link to a mount that is down (also by a link into the root and on by a link under it, the rest of the path kept after each), or at a mount point that is down on another filesystem whose directory has the inode number of one under the root but another modification time refused; `/users.json` absent passing; a directory on the path this user may not search refused and passing for one who can, a link loop refused, and a part of the path that stops answering once `statSync` found nothing there refused; on Windows paths, moved out from under the root by any spelling, through a junction or a share passing, and a drive that does not exist or a file deleted outside the root refused; and on the real filesystem, through `purgeOrg`, a hard link under the root to a recorded users file outside it refused and its way out passing with the users file kept, the recorded users file through a directory link into the root refused and passing once moved out, `/users.json` with a hard link under the root refused when none is recorded and its way out passing, the users file recorded under the root refused and passing once moved out (it was refused again), and a recorded users file outside the root that no longer exists refused — named directly or through a directory link (as a temporary directory on macOS is reached, `/var` → `/private/var`), the refusal naming where its path leads as the host resolves it — and passing once the root is deleted by hand, with `/users.json` absent passing. The purge-org docs test also reads the refusals in README and the k8s note, and each way out the README names in purge-org's own refusals. `server/test-store-paths.mjs`: its pin of purge-org's order reads the place check between the Windows refusal and the comparison by path, which now holds only while the users file exists. Suite total 1495 → 1510 tests. + ### Follow-up `compare-in-bundle`: Compare in the static bundle - **One collation for every `tools/lib` sort** (**a declared default change**, on a server too): each of the 23 `localeCompare` calls in `artefact-model.mjs`, `crawler.mjs`, `diff.mjs`, `library.mjs`, `pack-conformance.mjs`, `promql-canon.mjs`, `promql.mjs`, `traceability-graph.mjs` and `waivers.mjs` passes `'en'` — the CLDR root order, which English does not tailor — where it passed nothing and so followed the runtime's default collation (Node's follows `LC_ALL`; a browser's, its language). The diff, the traceability graph, canonical PromQL and the identity keys and behaviour hashes built from it, the crawl, the library build, the waivers list and the placeholder report now order text the same under every process and browser locale. Nothing else in the nine modules changes; their exports are unchanged; `VENDOR-MANIFEST.json` is regenerated (nine hashes). A process under `C`, `C.UTF-8` or `en_*` answers byte for byte what it did (every golden unchanged); one whose locale tailors Latin letters (Czech, Danish, Estonian, Lithuanian, Hungarian, Slovak and the like) answered differently before (under `LC_ALL=cs_CZ.UTF-8` eight diff goldens failed and the traceability graph moved) and now answers the same. **On such a server**, an artefact whose identity key holds canonical PromQL, or a sorted list that now sorts differently, gets a new key, so a verdict recorded on it before the upgrade does not carry at the pack's next re-registration. Migration: check the collation where the server runs (`node -p "new Intl.Collator().resolvedOptions().locale"` in its environment); `en`, `en-US` or `und` — nothing moves. Otherwise, after each pack's first re-registration on this version, read its `verdict.carry` audit row (`dropped`, `droppedCount`) and record those verdicts again. Answers that are not stored only re-order. Guarded: `tools/test-vendor-manifest.mjs` refuses a `localeCompare(` or `Intl.Collator(` call anywhere under `tools/lib` that does not pass `'en'`, naming `file:line` (one more test in `tools/test-vendor-manifest.mjs`; 1476 → 1477 tests); `tools/test-golden-diff.mjs` hashes the `/api/diff` body shape (the diff and the graph, which no golden covers) of three pairs, and `diffPacks` of the `payment-service.vs.production-curated` golden pair, in a child under each of `C`, `cs_CZ.UTF-8`, `da_DK.UTF-8`, `et_EE.UTF-8` and `lt_LT.UTF-8`, and requires every hash equal (a harness check, no new test; where Node ignores `LC_ALL`, as on Windows, it passes trivially and the static guard still holds every call). - **The taxonomy binds as it is served** (**a declared default change**, in the live studio and the bundle alike): `tools/lib/artefact-classify.mjs` `compileTaxonomy(json, { timed = true } = {})` and `validateTaxonomy(json, { timed = true } = {})` pass `timed` to every id rule's `compileBoundedPattern`. The default is unchanged, so the server (at start) and the bundle builder (at bake: `--taxonomy`, or `OBSERVOGRAM_TAXONOMY` on the build machine) time every pattern against the four adversarial ids as before. `studio/taxonomy.mjs` `bindTaxonomy` compiles the document it is served with `{ timed: false }` — the server timed it at start, the builder at bake — and every static check (anchoring, length, flags, nested quantifiers) still runs in the browser. A browser that ran the patterns over the 50 ms budget (a slow phone, a throttled tab) bound the default families instead, saying so only through `state.taxonomyBindError`; it now binds the served override. `VENDOR-MANIFEST.json` regenerated (exports unchanged by name). One more test in `tools/test-artefact-classify.mjs` (1477 → 1478 tests): with every clock a runtime offers (`Date.now`, `performance.now`, the process's CPU time) stepping past the budget on each read, `compileTaxonomy` and `validateTaxonomy` refuse both taxonomy fixtures as too slow by default and accept them with `{ timed: false }` (an unanchored pattern still refused), `bindTaxonomy` binds the served override, and the builder's `loadTaxonomyFile` still refuses. diff --git a/docs/UPDATE_JOURNEY.md b/docs/UPDATE_JOURNEY.md index 2f4050e9..6d05a1f9 100644 --- a/docs/UPDATE_JOURNEY.md +++ b/docs/UPDATE_JOURNEY.md @@ -1807,3 +1807,210 @@ take `{ timed }`; the studio binds what it is served untimed — the server or the builder timed the same document — and the shim's three computing routes answer `500` naming the reason when the taxonomy a bundle bakes is not the one the classifier holds. + +### Follow-up: the users file by what deleting the root takes + +`packc store purge-org` deletes a removed org's root with `rmSync`, and it +refused only a users file whose path lay under that root. The path is not +where the bytes are: with the users file recorded as `/mnt/b/users.json`, +`/mnt/b` a bind mount of the root's `sub` directory, realpath does not cross +the mount back to its source, so the path compared as outside the root and +purge-org deleted the file — named directly, through a file link or a +directory link to the mount, through an NFS or SMB loopback mount, and with +nothing recorded, a `/users.json` that is a link into the root. purge-org +now walks the root as `rmSync` takes it, never through a link, and follows the +users file's path through every link: the users file lies under the root when +an entry is the same file — the same device and inode (on Windows the volume +serial's low 32 bits), or, on another device, the same size and modification +time at the coarser clock's precision, a view of it through a network, +overlay or FUSE mount. The refusal names every such entry, hard links +included, and its way out is to move them out of the root. A hard link under +the root to a users file outside it, and a copy under the root on another +device that kept its size and time, are refused alike (declared false +refusals). The walk also refuses a mount point under the root — on Node 22 +`rmSync` stops part-way at one, on Node 23 and later it first deletes the +files of the filesystem mounted there — the root itself mounted, and a +directory under the root it cannot read, where `rmSync` failed part-way; now +nothing is deleted. A bind mount of a directory from outside the root, on the +root's own filesystem, cannot be told from a directory by `lstatSync`: on +Node 23 and later `rmSync` deleted that directory's files, outside the root, +and then failed on the mount point, and when it held the users file the +refusal named the entry to move out, after which the purge went on the same +way. On Linux `/proc/self/mountinfo` lists every mount point, and purge-org, +reading the root by its real path there, refuses it as a mount point; once it +is unmounted the purge passes. The kernel lists a mount that a later one +covers too — a filesystem mounted over a directory above it, as fstab entries +mounted out of order leave it, or an autofs mount over a directory that holds +mounts — while its path shows a directory of the filesystem mounted above, +where nothing is mounted to unmount; purge-org passes over such a mount, +unless that path leads through a mount of the covered mount's own +filesystem — `mount --bind /srv /srv` over it, or a recursive bind whose +copy of it was unmounted — where it may show the very directory the covered +mount is mounted on, which `rmdir` refuses while it is a mount point: +`rmSync` deleted part of the root there, then failed with `EBUSY`. Such a +mount is refused, naming the mounts to unmount first, in order. Where the +covering mount shows another directory there than the one it is mounted on — +the deployment bind-mounted over the directory that holds the covered mount — +the refusal is false (declared), and unmounting that mount takes the +workspace away: followed literally, the way out ended with purge-org finding +no database. So the refusal says to mount it there again once the covered +mount is unmounted, before purge-org runs again; followed so, the purge +passes. A +mount on `/` itself covers nothing: a path is looked up from the process's +root and never crosses a mount stacked on it (a tmpfs mounted on `/` in a +chroot, or `mount --bind / /`), so a mount under the root is refused still. +Where there is no such file, the walk enters it. + +The refusal of a users file recorded under the root said "move it out +first", and after the move it came back word for word: the record still named +the path under the root, and the comparison fell back to it as written. The +comparison by path now holds only while the users file exists. One that does +not is followed to where its path leads — every link that exists followed, a +dangling one by its target — and its nearest directory that exists: one under +the root, with no entry there of the users file's name (below), means nothing +of the users file is left there to delete, and the purge goes on; any other is +refused, since a mount that is down leaves only an empty mount point, and +with the bind mount above down purge-org had deleted the users file behind it. +On Windows a path through a dangling link is refused before this, as a +path the native realpath cannot fully resolve (the section above), and +deleting the root by hand lets purge-org drop the records. +Through a view whose directories show another inode or modification time than +theirs under the root — an overlay mount's merged +directory shows its upper directory's — the path of a users file moved out +leads to no directory purge-org can tell is under the root, so it is refused +too, and the view's refusal names that second step: delete the root by hand. +A users file deleted on purpose outside the root is refused alike (a declared +false refusal), and its way out is deleting the root by hand, after which +purge-org drops the records. A users file purge-org cannot read (`EACCES` on +its path, a link loop), which compared as written and was purged, is refused +too. Tests: 1495 → 1510 (fifteen more in `server/test-store-ops.mjs`). + +The walk read each directory once, at its own moment, so another process +renaming a directory, while it ran, from one the walk had not read yet into +one it had read already — the directory holding the users file, which the +bind mount above leads to — hid it from both listings, and the purge deleted +the users file. When the users file exists and no entry is it, purge-org now +reads each directory again before it goes on: the same directory, the same +change time, which moves with every entry added, removed or renamed in it, +and the same names. One that changed is refused, deleting nothing; stop what +changes files there and run the purge again, and the walk finds the users +file. Tests: 1510 → 1511 (one more in `server/test-store-ops.mjs`). + +The walk listed names as strings, and `node:fs` gives a name that is not +valid UTF-8 with U+FFFD in place of each such byte: a name no lookup finds, +which the walk took for an entry gone while it ran, so it read nothing under +it, while `rmSync`, which lists names as bytes, deleted the users file there. +The walk now lists each name as the bytes the filesystem holds and follows +such a name as bytes, so the users file, a mount point or a directory it +cannot read below it is refused; a refusal names that path as a POSIX +shell's `$'…'` spells it, so moving the users file out, as the refusal says, +can be typed. Tests: 1511 → 1512 (one more in `server/test-store-ops.mjs`). + +Failing closed through a mount that is down held only while the users file +did not exist, and a mount that is down keeps what was written in its mount +point meanwhile: with a `users.json` written in `/mnt/b` while the bind +mount above was down, the recorded path led to that file, outside the root, +and purge-org deleted `/sub/users.json`, the users file the path leads +to once the mount is back. When the users file exists and no entry is it, an +entry under the root with its name — the name its path ends in, as written +or at a link it leads through, ignoring case — is now refused, naming each +such entry and what one that is a link leads to under the root; moving them +out of the root is its way out. An entry of that name that is not the users +file is refused alike (a declared false refusal), and the users file seen +through a view of a directory under the root that reports neither its inode +nor its size and time is refused by its name. Tests: 1512 → 1513 (one more +in `server/test-store-ops.mjs`). + +Following the refusal of a users file recorded under the root — moving it +out, then purging that org — left the record naming a path in a directory +that no longer exists, and every later purge of another org was refused as a +recorded users file that does not exist: the nearest part of its path that +exists, `/orgs`, was no directory under the other root. Making +the path lead to the moved file again meant re-creating directories in the +purged org's root, and neither the refusal nor README said so. A users file +that does not exist is now nothing to keep, too, when its path is looked up +only in directories the root lies in — its parent and theirs, on its real +path — or that the root's own path is looked up in, as purge-org names it +(with the workspace, or its `orgs`, reached through a link in a directory +the root does not lie in, that directory and those above it), and no entry +under the root has its name: a filesystem mounted again on one of them +covers the root as well, or hides the link the root's own path is reached +through, so through it the path leads under the root by a directory under +the root mounted there, which shows the users file by its name, or by a link +purge-org does not see. Through any other directory, or with an entry of +that name under the root, it is refused as before. Tests: 1513 → 1514 (one +more in `server/test-store-ops.mjs`). + +The walk kept every entry's stats until it ended, since the users file was +placed only after it: some 770 bytes an entry, so a root of 100 directories +of 1,000 empty files ran purge-org out of memory in a 40 MB heap, deleting +nothing, where `rmSync` of the same tree completed — at a 1 GB heap about +1.3 million entries, wherever the host set the limit. The users file is now +placed before the walk, and each entry compared with it and with its name as +the walk reaches it; placed again after the walk, one created, replaced or +written meanwhile is compared with each entry read again. Of a directory the +walk keeps its path, its identity, its times and a digest of its names, and it +takes a directory's entries from its listing as it goes, holding only the +listings of the directories it is in, as Node 22's `rmSync` does: its memory +grows with the directories under the root and the longest listing, not with +the files, and that root passes in 16 MB. Tests: 1514 → 1515 (one more in +`server/test-store-ops.mjs`). + +A users file that does not exist whose path leads into a directory under the +root passed without the name check a users file that exists has, and a +directory on that path may be the mount point of a mount that is down: with +`/other` bind-mounted on `/sub/m` and the users file in `other` +recorded through it, purge-org refused the mount point, and once it was +unmounted, as that refusal said, the purge deleted the users file. Such a +path is now refused by the name too when an entry under the root has it, +naming each such entry; moving them out of the root is its way out, and the +purge then passes. A users file moved out of the root as the refusal of one +under it says passes as before unless another entry under the root has its +name, which is refused alike (a declared false refusal) until it is moved out +too. Tests: 1515 → 1516 (one more in `server/test-store-ops.mjs`). + +The refusal of a mount point under the root said only to unmount it, and +unmounting may take away the path to the users file: with a directory from +outside the root mounted under it, holding a link of the users file's name +to a file under the root, the path led into the empty mount point once it +was unmounted, no entry under the root had the users file's name, and the +purge deleted the file. That refusal now names the users file the walk finds +under the root as well, to move out with the unmounting; followed, the purge +passes and the moved file stays. A mount that was down already when +purge-org first ran tells nothing of such a link (declared). One test in +`server/test-store-ops.mjs` is extended; the total stays 1516. + +In a chroot into a directory that is no mount point, `/proc/self/mountinfo` +lists neither `/` nor the mount the root lies in, and the mounts in it name a +parent it does not list; a covered mount with such a parent was taken for one +whose path may show what it is mounted on, so a tmpfs mounted on the workspace +over a bind mount at `/m` — which passes outside a chroot — was +refused there, and its way out, unmounting the tmpfs first, unmounted the +filesystem holding the whole deployment. The parent not listed is the mount +the root lies in, whose filesystem `/` shows: a covered mount whose path shows +a directory of another filesystem is passed over, one of the same filesystem +refused naming what to unmount first, as before, and followed literally the +purge passes. Tests: 1516 → 1517 (one more in `server/test-store-ops.mjs`). + +A mount point under the root holding mounts was refused naming it alone: the +walk never enters a mount point, so the mounts in it went unnamed, though +`/proc/self/mountinfo` lists them, and followed literally — `umount /m` +— the way out said target is busy, and every later run repeated the +refusal. The refusal now names the mounts in it to unmount first, in order, +each after the mounts in it, as a covered mount's does (a mount stacked on it +names the mounts in the topmost alone, which `umount` takes); followed so, +the purge passes. Tests: 1517 → 1518 (one more in `server/test-store-ops.mjs`). + +On the Windows leg on Node 22.16.0, with a workspace whose `orgs` is a +junction to a directory on its own volume, purge-org refused the root as a +mount point on another filesystem than that junction, where Node 22.23 went +on. libuv answers a stat by the path's name unless its last name is a +reparse point, which it opens, and libuv 1.49.0 to 1.50.x read the device of +the one by name from the upper half of the file's 128-bit id, 0 on NTFS, not +the volume's serial; the root (by name) and the junction (opened) differed. +On Windows a volume is mounted only in a folder, so only directories are +compared there, each read through a handle opened on it, which reports the +serial on every libuv; a file or a link under the root lies on its +directory's volume. A volume mounted in a folder under the root, or on the +root, is refused as before. Tests: 1518 → 1519 (one more in +`server/test-store-ops.mjs`). diff --git a/server/fixtures/mount-fs.mjs b/server/fixtures/mount-fs.mjs new file mode 100644 index 00000000..751454f3 --- /dev/null +++ b/server/fixtures/mount-fs.mjs @@ -0,0 +1,297 @@ +// server/fixtures/mount-fs.mjs — a filesystem as node:fs's lstatSync, statSync, readdirSync, readlinkSync and +// realpathSync answer it, and Linux's /proc/self/mountinfo, with what a test runner cannot make for real (no root, no +// mount(8), maybe no symlink privilege): several filesystems, bind mounts, a mount that is down (its mount point left +// empty, or holding what was written in it meanwhile), views of a directory through another filesystem as an NFS, SMB, +// overlay or FUSE mount reports them, a directory nobody may read or search, and link loops — on POSIX paths or, with +// node:path's Windows rules, on Windows ones (server/test-store-ops.mjs, the users-file place suite over +// server/store/users-file-place.mjs). +// +// Imports nothing: the caller passes node:path's posix or win32, so tools/test-platform.mjs's P5 holds. +// +// mountFs(path, { fold, devs, proc }) → a volume set, built with: +// dir(p, { dev, ino }) mkdir -p; a directory created takes its parent's device, the last one `dev` if given +// (a directory on another device than its parent is what a mount point looks like), +// and the inode number `ino` if given (each filesystem numbers its own inodes, so a +// directory on another device may have the number of one under the root); +// file(p, { size, mtimeNs, dev }) a file (its directories made as dir does), on its directory's device, or on `dev` +// if given with no mount there (an overlay mount whose layers lie on different +// filesystems, xino=off, shows a lower layer's files so); +// hardlink(existing, p) the same file at a second name; +// symlink(p, target) a symbolic link (a junction on Windows reads the same to lstatSync); +// bind(source, target) mount the directory at source on target: the same files, devices and inodes. A mount +// lies in the mount its target was in (its parent), as Linux's do: one on a path mounted +// already stacks on that mount, and a later one on a directory above it in its parent +// covers it — its path then shows what the mount above holds, and unmounting it fails +// with EINVAL (umount(8): not mounted), while /proc/self/mountinfo lists it still. On +// POSIX paths a mount on / stacks on the root and covers nothing: a lookup starts at the +// process's root and never crosses a mount stacked on it (mount(8) and umount(8) there +// take the topmost); on Windows paths a root (a drive, a UNC share) is the volume +// mounted there; +// mountFolder(source, target) Windows' volume mounted in a folder: lstatSync answers the folder itself (on its +// parent's volume), statSync and every lookup through it the volume mounted there; +// view(source, target, how) mount it as another filesystem shows it: how = { dev, ino(i), mtimeNs(t) } maps what +// stat reports through the view (each defaults to the source's own); +// unmount(target) unmount what target shows mounted there: EINVAL when nothing is, EBUSY when a mount +// lies in it; +// rename(from, to), remove(p), deny(p, code, { list }) (readdirSync and every lookup through p +// fail with code, EACCES by default; ENOTCONN is a FUSE mount whose server stopped +// answering; with list, readdirSync still names p's entries — a directory that may be +// read but not searched, r--, where lstatSync of each entry fails); +// fs { lstatSync, statSync, readdirSync, readlinkSync, realpathSync, readFileSync, openSync, +// fstatSync, closeSync }, stats as bigints: { dev, ino, size, mtimeNs, ctimeNs, isFile(), +// isDirectory(), isSymbolicLink() }; readFileSync answers /proc/self/mountinfo alone, and only +// with proc (else ENOENT); openSync opens what a path leads to, every link followed (a +// directory too, as libuv opens one on Windows), and fstatSync answers it as a handle does; +// opened() how many of what openSync opened closeSync has not closed. +// A name that is not valid UTF-8 is spelled with each byte 0xNN that is not part of a UTF-8 character as the lone +// surrogate U+DCNN (Python's surrogateescape); the fs takes a path as such a string or as a Buffer of its bytes, and +// answers as node:fs does: readdirSync lists names as strings, each such byte U+FFFD (a name no lookup finds), or with +// { encoding: 'buffer' } as their bytes; readlinkSync, realpathSync and /proc/self/mountinfo (read as UTF-8) likewise. +// fold: names compared case-insensitively (NTFS); devs: { '': its device } (default 1n each); proc: Linux's +// /proc/self/mountinfo, listing / and every mount now, each with its mount ID, its parent's and its filesystem's device +// (as 0:: a bind mount the device of what it shows, a view the view's), one covered too (its path with the +// kernel's octal escapes), on POSIX paths; chroot: the process's root is a directory of a mount whose mount point lies +// above it (a chroot into a directory that is no mount point), so /proc/self/mountinfo lists neither / nor that mount, +// while the mounts in it name its ID as their parent, as the kernel lists them; +// coarse: a clock coarser than every change made, so every change time reads the same and none moves. +// fastDev: the device lstatSync and statSync report where libuv answers a stat by the path's name, through +// GetFileInformationByName (Windows 11 24H2, Server 2025) — a path whose last name is no link (a junction, a symlink) and +// no folder a volume is mounted in, which libuv opens instead (FILE_FS_VOLUME_INFORMATION: the volume's serial, as +// fstatSync of a handle reports it on every libuv). libuv 1.49.0 to 1.50.x (Node 22.16.0's 1.49.2) declared +// FILE_STAT_BASIC_INFORMATION with its last two fields swapped and read the device from the upper half of the file's +// 128-bit id: 0n on NTFS, whatever the volume. null (the default): the volume's own, as libuv 1.51.0 and later report it. +// Each inode gets a distinct inode number and a modification time with nanoseconds, unless given, and a change time +// that moves, as the kernel moves a directory's, with every entry added, removed or renamed in it (unless coarse). + +const MAX_LINKS = 40; +// A path's bytes (U+DCNN the byte 0xNN), a Buffer as such a string, and a string as node:fs answers one (UTF-8 decoded). +const bytesOf = (s) => Buffer.concat([...s].map((ch) => { + const c = ch.charCodeAt(0); + return ch.length === 1 && c >= 0xdc80 && c <= 0xdcff ? Buffer.from([c - 0xdc00]) : Buffer.from(ch, 'utf8'); +})); +function fromBytes(b) { + let s = ''; + for (let i = 0; i < b.length;) { + const n = b[i] < 0x80 ? 1 : b[i] >= 0xc2 && b[i] < 0xe0 ? 2 : b[i] >= 0xe0 && b[i] < 0xf0 ? 3 : b[i] >= 0xf0 && b[i] < 0xf5 ? 4 : 0; + const seq = b.subarray(i, i + n); + const ch = n && seq.length === n ? seq.toString('utf8') : ''; + if (ch && Buffer.from(ch, 'utf8').equals(seq)) { s += ch; i += n; } else { s += String.fromCharCode(0xdc00 + b[i]); i += 1; } + } + return s; +} +const str = (p) => (typeof p === 'string' ? p : fromBytes(p)); +const answered = (s) => bytesOf(s).toString('utf8'); + +export function mountFs(path, { fold = false, devs = {}, proc = false, chroot = false, coarse = false, fastDev = null } = {}) { + const key = (s) => (fold ? s.toLowerCase() : s); + const split = (s) => s.split(path.sep === '\\' ? /[\\/]+/ : /\/+/).filter(Boolean); // on POSIX paths a backslash is part of a name + const fail = (code, p) => Object.assign(new Error(`${code}: ${p}`), { code }); + let next = 1000n; + let clock = 1_770_000_000_000_000_000n; + const now = () => (coarse ? clock : (clock += 1_000_003n)); + const inode = (kind, dev, extra = {}) => { + next += 1n; + return { kind, dev, ino: next, size: kind === 'dir' ? 4096n : 0n, mtimeNs: 1_760_000_000_000_000_000n + next * 1_234_567n, ctimeNs: now(), ...extra }; + }; + const changed = (dir) => { dir.ctimeNs = now(); }; // an entry added, removed or renamed in dir + const tops = new Map(); // a root (C:\, /) folded → its directory + const TOP = 21; // the mount ID of / (each root), as /proc/self/mountinfo lists it + let mountId = TOP; + const mounts = new Map(); // its parent mount's ID and its mount point's path folded → { id, parent, node, how, folder, at } + const entries = (dir) => dir.entries; + // What is mounted on top at `at` in the mount `mnt`: the mount there, then each stacked on it. + const mountedAt = (mnt, at) => { + let top = null; + for (let m = mounts.get(`${mnt}|${key(at)}`); m; m = mounts.get(`${m.id}|${key(at)}`)) top = m; + return top; + }; + // / on POSIX paths, the process's root: a lookup starts there and never crosses a mount stacked on it (`mount --bind / /`, + // or a tmpfs mounted on / in a chroot, which /proc/self/mountinfo lists at /), while mount(8) and umount(8) take the + // topmost mount there. A Windows root (a drive, a UNC share) names the volume mounted there, which every lookup reaches. + const processRoot = (abs) => path.sep === '/' && abs === '/'; + + // Where p leads: { node, how, at, mnt, reparse } — how is the view the node is seen through, mnt the ID of the mount it + // lies in, reparse whether p's last name is a link or a folder a volume is mounted in (a reparse point on Windows); the + // last name followed when follow. + function locate(p, follow) { + let hops = 0; + let stack; + let todo; + let reparse = false; + const start = (abs) => { + const top = path.parse(abs).root; + const m = processRoot(top) ? null : mountedAt(TOP, top); + const node = m ? m.node : tops.get(key(top)); + if (!node) throw fail('ENOENT', p); + stack = [{ node, how: m?.how ?? null, at: top, mnt: m?.id ?? TOP }]; + todo = split(abs.slice(top.length)); + }; + start(path.resolve(str(p))); + while (todo.length) { + const [name, ...rest] = todo; + todo = rest; + const cur = stack.at(-1); + if (name === '.') continue; + if (name === '..') { if (stack.length > 1) stack.pop(); continue; } + if (cur.node.kind !== 'dir') throw fail('ENOTDIR', p); + if (cur.node.denied) throw fail(cur.node.denied, p); + const child = entries(cur.node).get(key(name)); + if (!child) throw fail('ENOENT', p); + const at = path.join(cur.at, name); + const m = mountedAt(cur.mnt, at); + const folder = m?.folder && !todo.length && !follow; // lstatSync of a folder a volume is mounted in: the folder + const pos = m && !folder ? { node: m.node, how: m.how, at, mnt: m.id } : { node: child.node, how: cur.how, at, mnt: cur.mnt }; + if (!todo.length && (m?.folder || pos.node.kind === 'link')) reparse = true; + if (pos.node.kind === 'link' && (todo.length || follow)) { + if (++hops > MAX_LINKS) throw fail('ELOOP', p); + const target = pos.node.target; + if (path.isAbsolute(target)) { + const after = todo; + start(path.resolve(target)); + todo = [...todo, ...after]; + } else { + todo = [...split(target), ...todo]; + } + continue; + } + stack.push(pos); + } + return { ...stack.at(-1), reparse }; + } + // A stat by name (lstatSync, statSync) or of a handle (fstatSync, byName false): see fastDev. + const stats = ({ node, how, reparse }, byName = true) => ({ + dev: byName && fastDev !== null && !reparse && !how ? fastDev : how?.dev ?? node.dev, + ino: how?.ino ? how.ino(node.ino) : node.ino, + size: node.size, + mtimeNs: how?.mtimeNs ? how.mtimeNs(node.mtimeNs) : node.mtimeNs, + ctimeNs: node.ctimeNs, + isFile: () => node.kind === 'file', + isDirectory: () => node.kind === 'dir', + isSymbolicLink: () => node.kind === 'link', + }); + const parentOf = (p) => { + const abs = path.resolve(p); + const pos = locate(path.dirname(abs), true); + if (pos.node.kind !== 'dir') throw fail('ENOTDIR', p); + return { dir: pos.node, name: path.basename(abs) }; + }; + + const fds = new Map(); + let fd = 2; + const vol = { + opened: () => fds.size, + dir(p, { dev = null, ino = null } = {}) { + const abs = path.resolve(p); + const top = path.parse(abs).root; + if (!tops.has(key(top)) && !mounts.has(key(top))) tops.set(key(top), { ...inode('dir', devs[top] ?? 1n), entries: new Map() }); + let at = top; + const names = split(abs.slice(top.length)); + names.forEach((name, i) => { + const parent = locate(at, true).node; + at = path.join(at, name); + if (!entries(parent).has(key(name))) { + const last = i === names.length - 1; + const node = inode('dir', last && dev !== null ? dev : parent.dev, last && ino !== null ? { ino } : {}); + entries(parent).set(key(name), { name, node: { ...node, entries: new Map() } }); + changed(parent); + } + }); + return vol; + }, + file(p, { size = 23n, mtimeNs, dev = null } = {}) { + vol.dir(path.dirname(path.resolve(p))); + const { dir, name } = parentOf(p); + entries(dir).set(key(name), { name, node: inode('file', dev ?? dir.dev, { size, ...(mtimeNs === undefined ? {} : { mtimeNs }) }) }); + changed(dir); + return vol; + }, + hardlink(existing, p) { + const node = locate(existing, false).node; + vol.dir(path.dirname(path.resolve(p))); + const { dir, name } = parentOf(p); + entries(dir).set(key(name), { name, node }); + changed(dir); + return vol; + }, + symlink(p, target) { + vol.dir(path.dirname(path.resolve(p))); + const { dir, name } = parentOf(p); + entries(dir).set(key(name), { name, node: inode('link', dir.dev, { target }) }); + changed(dir); + return vol; + }, + bind(source, target) { return vol.view(source, target, null); }, + view(source, target, how, { folder = false } = {}) { + const src = locate(source, true); + const abs = path.resolve(target); + if (path.parse(abs).root !== abs) vol.dir(abs); + let parent = TOP; + if (processRoot(abs)) parent = mountedAt(TOP, abs)?.id ?? TOP; + else try { parent = locate(abs, true).mnt; } catch { /* a drive no volume answers for yet */ } + mountId += 1; + mounts.set(`${parent}|${key(abs)}`, { id: mountId, parent, node: src.node, how: how ? { ...src.how, ...how } : src.how, folder, at: abs }); + return vol; + }, + mountFolder(source, target) { return vol.view(source, target, null, { folder: true }); }, + unmount(target) { + const abs = path.resolve(target); + let mnt = null; + if (processRoot(abs)) mnt = mountedAt(TOP, abs)?.id ?? null; + else try { mnt = locate(abs, true).mnt; } catch { /* nothing there */ } + const m = [...mounts.values()].find((x) => x.id === mnt && key(x.at) === key(abs)); + if (!m) throw fail('EINVAL', target); // umount(8): not mounted + if ([...mounts.values()].some((x) => x.parent === m.id)) throw fail('EBUSY', target); + mounts.delete(`${m.parent}|${key(m.at)}`); + return vol; + }, + rename(from, to) { + const a = parentOf(from); + const entry = entries(a.dir).get(key(a.name)); + if (!entry) throw fail('ENOENT', from); + vol.dir(path.dirname(path.resolve(to))); + const b = parentOf(to); + entries(a.dir).delete(key(a.name)); + entries(b.dir).set(key(b.name), { name: b.name, node: entry.node }); + changed(a.dir); + changed(b.dir); + return vol; + }, + remove(p) { const { dir, name } = parentOf(p); entries(dir).delete(key(name)); changed(dir); return vol; }, + deny(p, code = 'EACCES', { list = false } = {}) { Object.assign(locate(p, true).node, { denied: code, listed: list }); return vol; }, + fs: { + lstatSync: (p) => stats(locate(p, false)), + statSync: (p) => stats(locate(p, true)), + readdirSync(p, options) { + const pos = locate(p, true); + if (pos.node.kind !== 'dir') throw fail('ENOTDIR', p); + if (pos.node.denied && !pos.node.listed) throw fail(pos.node.denied, p); + const names = [...entries(pos.node).values()].map((e) => e.name).sort(); + return (options?.encoding ?? options) === 'buffer' ? names.map(bytesOf) : names.map(answered); + }, + readlinkSync(p) { + const pos = locate(p, false); + if (pos.node.kind !== 'link') throw fail('EINVAL', p); + return answered(pos.node.target); + }, + realpathSync: (p) => answered(locate(p, true).at), + openSync(p) { + const pos = locate(p, true); + if (pos.node.denied && !pos.node.listed) throw fail(pos.node.denied, p); + fds.set(++fd, pos); + return fd; + }, + fstatSync(n) { + if (!fds.has(n)) throw fail('EBADF', n); + return stats(fds.get(n), false); + }, + closeSync(n) { if (!fds.delete(n)) throw fail('EBADF', n); }, + readFileSync(p) { + if (!proc || p !== '/proc/self/mountinfo') throw fail('ENOENT', p); + const octal = (s) => s.replace(/[ \t\n\\]/g, (c) => `\\${c.charCodeAt(0).toString(8).padStart(3, '0')}`); + return answered([...(chroot ? [] : [{ id: TOP, parent: 1, at: '/', node: tops.get(key('/')) }]), ...mounts.values()] + .map((m) => `${m.id} ${m.parent} 0:${m.how?.dev ?? m.node?.dev ?? 0n} / ${octal(m.at)} rw,relatime - fixture fixture rw\n`).join('')); + }, + }, + }; + return vol; +} diff --git a/server/store/ops.mjs b/server/store/ops.mjs index 93213879..43dc26c7 100644 --- a/server/store/ops.mjs +++ b/server/store/ops.mjs @@ -97,6 +97,7 @@ import { parse as parseYaml } from '../../tools/lib/mini-yaml.mjs'; import { closeStore, nowIso, openStore, resolveDbPath, tx } from './db.mjs'; import { assertNotInUse, identifyFile } from './backup.mjs'; import { comparedPath } from './paths.mjs'; +import { usersFilePlace } from './users-file-place.mjs'; import { listAudit, writeAudit } from './audit.mjs'; import { getMeta, getMetaJson, isIdentityArmed, putMeta, setMeta, storeId } from './meta.mjs'; import { getOrg, listOrgs, setOrgRoot } from './orgs.mjs'; @@ -787,7 +788,10 @@ export function formatRekey(r) { // packs/index.json, slice 4), one org.purge row; then the marker rewritten // from legacy_hashes (by 'purge-org'; it carries no pack key). A workspace // whose marker names another store is refused: its orgs/ are not this -// store's to delete. +// store's to delete. Before rmSync, the root is read whole: deleting it must +// not take the users file a pre-store build reads, decided by what it takes, +// not by the path's spelling, and nothing under it may be a mount point +// (store/users-file-place.mjs). export async function purgeOrg(id, { dbPath = resolveDbPath(), base = baseWorkspacePath(), out = process.stdout } = {}) { if (!id) throw refuse('name the org: packc store purge-org '); if (dbPath === MEMORY) throw refuse('OBSERVOGRAM_DB is :memory: — an in-memory store lives in one process; purge from the server\'s database file'); @@ -829,7 +833,13 @@ export async function purgeOrg(id, { dbPath = resolveDbPath(), base = baseWorksp + `be reached), may lead under it — nothing was deleted. If it does not lie under ${root}, delete ${root} by hand, then run ` + `purge-org again: it drops org ${id}'s records. If it does, move it out of ${root} first, then do the same`); } - if (users?.startsWith(compared(root) + sep)) { + // By what deleting the root takes, not by the path's spelling (store/users-file-place.mjs): the users file a + // pre-store build reads, seen under the root through a mount, a link or a hard link, a mount point under it, and + // a recorded users file that does not exist by where its path leads — so the path check below holds only while it + // exists, and "move it out first", followed, lets the purge through. + const place = present ? usersFilePlace(usersFile || join(base, 'users.json'), root, { recorded: Boolean(usersFile), id }) : null; + if (place?.refusal) throw refuse(place.refusal); + if (users?.startsWith(compared(root) + sep) && place.exists) { throw refuse(`the recorded users file ${usersFile} lies under ${root} — move it out first; nothing was deleted`); } const underRoot = (k) => !isAbsolute(k) && slashed(k).startsWith(`${rel}/`); diff --git a/server/store/users-file-place.mjs b/server/store/users-file-place.mjs new file mode 100644 index 00000000..d51f286a --- /dev/null +++ b/server/store/users-file-place.mjs @@ -0,0 +1,699 @@ +// server/store/users-file-place.mjs — whether `packc store purge-org` would delete the users file a pre-store build +// reads, decided by what deleting the org's root takes, not by how the users file's path is spelled. +// +// purge-org (server/store/ops.mjs) deletes a removed org's root with rmSync(root, { recursive: true }). The users +// file a pre-store build reads is the one recorded at the import (schema_meta.users_file, the path +// OBSERVOGRAM_USERS_FILE named then), or /users.json when none was recorded. Its path may lie outside the root +// while its bytes lie under it: through a mount of a directory under the root (/mnt/b/users.json, /mnt/b a bind +// mount of /sub), reached directly or through a link — realpath never crosses a mount back to its source — +// or as a hard link. Comparing the path alone, purge-org deleted it. So the check reads what rmSync would delete: +// +// - the walk: the root and everything under it, as rmSync takes it — lstat each entry, list each directory, each name +// as the bytes the filesystem holds, as rmSync lists them (as a string, node:fs gives a name that is not valid UTF-8 +// with U+FFFD in place of each such byte, a name no lookup finds, and the walk read nothing under it), never through +// a link (a symlink, or a junction on Windows). A refusal names a path holding such a name as a POSIX shell's $'…' +// spells it, each such byte \xNN. A mount point under the root is refused: a directory on another filesystem than +// the directory that holds it, an entry /proc/self/mountinfo lists (Linux: a bind mount of a directory of the same +// filesystem, which lstatSync cannot tell from a directory, or a file mounted on a file; +// matched by the root's real path, as the kernel lists it; not one a later mount covers — mounted in the same +// parent mount on a directory above it, or covering that parent so — which the kernel lists too, while its path +// shows a directory of the filesystem mounted above, where nothing is mounted, unless that path leads through a +// mount of the filesystem the covered one lies in (its parent mount's device, as /proc/self/mountinfo lists it, or, +// where it does not list the parent — a chroot into a directory that is no mount point, whose mount the kernel lists +// nowhere while the mounts in it name it as their parent — the filesystem / shows, against what is at the covered +// mount's path now as rmSync takes it: a link there is a link of the filesystem above, deleted without being +// followed, not what it leads to): `mount --bind /srv /srv` over it, a recursive bind whose copy of it was unmounted, or a +// bind mount, on the filesystem above, of a directory it lies in — its path may then show the very directory it is +// mounted on, which rmdir refuses (EBUSY) while that is a mount point anywhere, so it is refused, naming what to +// unmount first, in order (a declared false refusal where that mount shows another directory there — the workspace's, +// with the deployment bind-mounted over the directory that holds the covered mount: unmounting it takes the workspace +// away, and followed without more, the way out ended at "no database", so the refusal says to mount it there again +// once the covered mount is unmounted, before purge-org runs again); a mount on / +// itself covers nothing, since a path is looked up from the process's root and a lookup never crosses a mount stacked +// on it, so with a tmpfs mounted on / in a chroot, or `mount --bind / /`, a mount under the root is refused still), a +// directory the walk reached already +// (one directory mounted at a second place under the root), or on Windows a folder another volume is mounted in — on +// Windows only a directory is compared, its volume read through a handle opened on it (fstatSync), never a stat by name, +// which libuv 1.49.0 to 1.50.x (Node 22.16.0) answer with half of the file's 128-bit id in place of the volume serial, +// so a root reached through a junction (its workspace's orgs one) read as a mount point; a directory reached through a +// junction is one on the volume the junction leads to. One holding mounts (a bind at /m/n in the one at /m), +// which keep it busy — `umount /m` fails (target is busy), and the walk never enters it to name them — is refused +// naming them to unmount first, in order, each after the mounts in it, as a covered mount's refusal names them (for a +// stack, the mounts in its topmost mount, which umount takes). Any other entry on another filesystem than its directory +// is one only as /proc/self/mountinfo lists it: an overlay mount whose layers lie on different filesystems (xino=off) +// shows its directories on its own device and a lower layer's files on that layer's, with nothing mounted there +// (without /proc/self/mountinfo, see below). On Node 22 rmSync stops part-way at a mount point; on Node 23 and later it +// first deletes the files of the filesystem mounted there; and a directory mounted from outside the root may hold +// the users file. The users file found under the root besides (none of it at a mount point, as placed below) is named +// in that refusal, to move out with the unmounting: unmounted, its path may lead there no more — a link of its name in +// a directory mounted there from outside the root, to a file under it — and run again as the refusal said, the purge +// went on into the empty mount point and deleted it. The root itself mounted (a volume per org) is refused alike. A +// directory the walk cannot read is refused: what lies in it cannot be told, and rmSync would fail part-way there. The +// walk keeps nothing of a file it passes — each entry is compared with the users file, and with its name, as the walk +// reaches it (below) — and of a directory its path, device, inode, change and modification time and a digest of its +// names, holding besides only the listing of each directory it is in, as Node 22's rmSync does: kept entry by entry, +// the stats of a root of millions of files took more memory than the heap had, and purge-org died where rmSync +// completed. +// - the users file, its path followed through every link (statSync), before the walk and again after it — one that +// changed meanwhile (another device, inode, size or modification time) is compared with each entry read again: it +// lies under the root when an entry the walk found is the same file — the same device and inode (on Windows the volume serial's low 32 bits, all a +// share reports, as server/store/paths.mjs places a path; on libuv 1.49.0 to 1.50.x a stat by name reports none, so a +// users file whose path ends in a link — opened, with the serial — reads as on another device than its entry, by name, +// and is matched as a view, below) — or, on another device, the same kind with the same +// modification time, compared at the coarser of the two clocks' precision (an SMB view keeps 100 ns, an sshfs +// one seconds), and for a file the same size, for a directory the same inode. That is a view of it through +// another filesystem: an NFS or SMB mount of a directory under the root reports the server's inode with +// another device; an overlay mount reports another device and, with xino or once the file is copied up into an +// upper directory under the root, another inode too. A copy under the root that kept the users file's size and +// modification time on another device reads the same (a declared false refusal; moving it out is the way out). +// Every matching entry is named, so one move clears them all (hard links included). Through a view whose +// directories show another inode or modification time than theirs under the root (an overlay mount with an upper +// directory shows that directory's), the path then leads where nothing traces it back, and it is refused as a users +// file that does not exist (below): a view's refusal names that second step, deleting the root by hand. +// - a users file that does not exist: nothing of it is under the root to delete, unless its path leads there through +// what is not there now — a filesystem not mounted. So its place is found as the kernel would resolve the path, +// every link that exists followed (a dangling one by its target), to the nearest directory that exists there. One +// the walk read (by identity, as above) means it leads under the root — a users file moved out of the root, +// recorded there or reached through a mount of a directory under it or a link into it — and, while no entry under +// the root has the name the path ends in, the purge goes on. With one it is refused by that name (below): a +// directory on its path may be the mount point of a mount that is down, an empty directory under the root, and +// mounted again the path leads to the file of that name in the directory mounted there (/other bind-mounted on +// /sub/m, the users file at /other/users.json recorded through it: unmounted, as the refusal of that +// mount point said, the purge deleted it). The purge goes on, too, when the path is looked up only in directories +// the root lies in (its parent and theirs, on the root's real path) or that the root's own path, as purge-org names +// it, is looked up in (every link on it followed: a workspace, or its orgs/, reached through a link in a directory +// the root does not lie in, that directory and those above it) — each by its path, case folded on Windows, and its +// device and inode — and no entry under the root has the name the path ends in: the directory of an org purged +// before, which the users file was moved out of as its refusal said, or a file deleted in one of them. A filesystem +// mounted again on one of them covers the root too, or hides the link the root's own path is reached through, so +// through it the path leads under the root by a directory under the root mounted in it, which shows the users file +// by its name, or by a link that is not seen (below). Any other is refused, failing closed: a mount that is down +// leaves its mount point an empty directory, which tells nothing. So a recorded users file deleted elsewhere outside +// the root is refused too (declared), and deleting the root by hand is its way out. The default /users.json, +// which nothing records, absent — nothing at its path, not even a link — is nothing to keep (a store that keeps no +// local users may have none); a link there that leads nowhere now is placed as a recorded path is, since through a +// mount that is down it may lead under the root. +// - a users file that cannot be read — statSync failing otherwise (EACCES on a directory of its path, a link loop), +// or a part of its path that cannot be read — is refused. +// - a users file that exists and that no entry is: before the purge goes on, each directory the walk read is read +// again — the same directory (device and inode), the same change time, which moves with every entry added, removed +// or renamed in it, and the same names. The walk read each directory at its own moment, so an entry another process +// renamed while it ran, from a directory not read yet into one read already (the users file, or a directory holding +// it), was in neither listing. A directory that changed is refused; stopping what changes it and running purge-org +// again is its way out. +// - and then an entry under the root with its name — the name its path ends in, as written or at each link it leads +// through, compared ignoring case and Unicode normalization: a mount that is down keeps what was written in its mount +// point meanwhile, so the path leads to such a file now, and mounted again to the file of that name in the directory +// mounted there, under the root. It is refused, naming each such entry and, for one that is a link, what it leads to +// under the root (rmSync deletes that once the link is moved); moving them out of the root is its way out. An entry of +// that name that is not the users file is refused too (a declared false refusal). A view of a directory under the root +// shows its names, so the users file seen through one that reports neither the inode nor the size and modification +// time is refused by its name. A users file that does not exist whose path leads into a directory the walk read is +// refused so too (above); there moving the entries out is the whole way out. +// +// Without /proc/self/mountinfo (off Linux, or /proc not mounted), a bind mount under the root of a directory from +// outside it, on the same filesystem, is a directory to lstatSync (the same device), and the walk enters it; and any +// entry on another device than its directory is refused as a mount point, a lower layer's file of an overlay mount whose +// layers lie on different filesystems too (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). A view that reports neither the inode nor the +// size and modification time (some FUSE filesystems) is not matched as the users file (through a view of a directory, by +// its name, above); and a recorded path that passes through a link under the root keeps the file the link leads to +// (rmSync deletes the link alone), but leads nowhere once it is gone. Not seen either: what another process moves, links +// or mounts under the root after the check, before rmSync takes it; a directory changed and changed back within one tick +// of a coarse filesystem clock, its change time and names as they were; a same-filesystem bind mount made on a directory +// under the root after /proc/self/mountinfo was read, before the walk reaches it, or uncovered there then (a bind mount +// from the same filesystem over a directory above the root, unmounted); through a mount that is down, what the +// name does not tell — a file mounted on a file of another name, its mount point a file of its own, what a link of +// that name under the root leads to back through the mount that is down, or what a link of that name in a directory +// mounted under the root from outside it leads to under the root, once that mount is down before purge-org saw it up +// (up, its refusal names the users file there); and a filesystem that is down now, mounted on a directory the root lies in, +// whose link leads a users file that does not exist into the root another way than by its name there (through a second +// mount of the root's filesystem), or mounted on one only the root's path as named is looked up in (the directory +// holding the link to the workspace, say), by a link to the root's real path. +// +// usersFilePlace(file, root, { recorded, id, fs, path, windows }) → { exists, refusal }: exists is whether the users +// file's path leads to a file now (true), or not (false), or null when the check stopped before it could tell; +// refusal is purge-org's message, or null when deleting the root takes nothing of the users file. recorded is false +// for /users.json when nothing was recorded; id is the org's. fs = { lstatSync, statSync, readdirSync, +// readlinkSync, realpathSync, readFileSync, and on Windows openSync, fstatSync and closeSync } (node:fs's; the stats +// called with { bigint: true }, readdirSync with { encoding: 'buffer' }), path (node:path's) and windows (the platform) are injectable (server/test-store-ops.mjs, over +// server/fixtures/mount-fs.mjs); the default is this host. + +import { createHash } from 'node:crypto'; +import * as nodeFs from 'node:fs'; +import * as nodePath from 'node:path'; + +const ON_WINDOWS = process.platform === 'win32'; +const GONE = new Set(['ENOENT', 'ENOTDIR']); +const SECOND = 1_000_000_000n; +const MAX_LINKS = 40; // Linux's MAXSYMLINKS +const MOUNTINFO = '/proc/self/mountinfo'; +// A mount point as /proc/self/mountinfo spells it (its fifth field): the kernel's octal escapes (\040 a space, \011, +// \012, \134) undone. +const unoctal = (s) => s.replace(/\\([0-7]{3})/g, (_, o) => String.fromCharCode(Number.parseInt(o, 8))); + +// The precision of a timestamp in ns: the largest power of ten, up to a second, that divides it. +function precision(ns) { + let q = 1n; + while (q < SECOND && ns % (q * 10n) === 0n) q *= 10n; + return q; +} +// Two modification times are the same at the coarser of their precisions (one clock may keep less). +function sameTime(a, b) { + const q = precision(a) > precision(b) ? precision(a) : precision(b); + return a - (a % q) === b - (b % q); +} + +const list = (items) => (items.length < 3 ? items.join(' and ') : `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`); + +// A path the walk holds as bytes — a name in it is not valid UTF-8 — spelled as a POSIX shell's $'…' takes it: each byte +// that is not part of a UTF-8 character as \xNN, a backslash or a quote escaped, so a refusal names a path one can type. +function spelled(bytes) { + let s = ''; + for (let i = 0; i < bytes.length;) { + const b = bytes[i]; + const n = b < 0x80 ? 1 : b >= 0xc2 && b < 0xe0 ? 2 : b >= 0xe0 && b < 0xf0 ? 3 : b >= 0xf0 && b < 0xf5 ? 4 : 0; + const seq = bytes.subarray(i, i + n); + const ch = n && seq.length === n ? seq.toString('utf8') : ''; + if (ch && Buffer.from(ch, 'utf8').equals(seq)) { + s += ch === '\\' || ch === "'" ? `\\${ch}` : ch; + i += n; + } else { + s += `\\x${b.toString(16).padStart(2, '0')}`; + i += 1; + } + } + return `$'${s}'`; +} + +export function usersFilePlace(file, root, { recorded = true, id = '', fs = nodeFs, path = nodePath, windows = ON_WINDOWS } = {}) { + const lstat = (p) => fs.lstatSync(p, { bigint: true }); + const stat = (p) => fs.statSync(p, { bigint: true }); + const dev = (st) => (windows ? st.dev & 0xffffffffn : st.dev); + const same = (a, b) => { + if (dev(a) === dev(b)) return a.ino === b.ino; + if (a.isFile() && b.isFile()) return a.size === b.size && sameTime(a.mtimeNs, b.mtimeNs); + if (a.isDirectory() && b.isDirectory()) return a.ino === b.ino && sameTime(a.mtimeNs, b.mtimeNs); + return false; + }; + // A path under the root is a string, or the bytes the filesystem holds once a name on it is not valid UTF-8 (below). + const named = (p) => (typeof p === 'string' ? p : spelled(p)); + const under = (p) => typeof p !== 'string' || p.startsWith(root + path.sep); + const who = recorded ? `the recorded users file ${file}` + : `the users file ${file} (the one a pre-store build reads: OBSERVOGRAM_USERS_FILE was not set at the import)`; + const code = (e) => e?.code || e?.message || 'error'; + const stopped = (refusal) => ({ exists: null, refusal }); + + // The walk: what rmSync(root, { recursive: true }) would delete. + const denied = (e) => e?.code === 'EACCES' || e?.code === 'EPERM'; + const unreadable = (at, e) => stopped(`${named(at)}${under(at) ? ` under ${root}` : ''} cannot be read (${code(e)}), so purge-org cannot ` + + `tell what deleting ${root} takes — nothing was deleted. ` + + (denied(e) ? `Run purge-org as a user who can read it; or, if ${who} does not lie under ${root}, delete ${root} by hand as such ` + + 'a user' + : `If it is a filesystem mounted there that stopped answering, unmount it, then run purge-org again; or, if ${who} does not ` + + `lie under ${root}, delete ${root} by hand`) + + `, then run purge-org again: it drops org ${id}'s records`); + const mounts = []; // { at, why } + // A file's device and inode, as one string (joined, so one flat string and not a rope of its parts). + const idOf = (st) => [dev(st), st.ino].join(':'); + const walked = new Map(); // a directory's idOf → its path + // { at, key, ctimeNs, names, mtimeNs }: each directory the walk listed, as it found it — the same directory (key, its + // idOf) with the same change time and names (a digest of them): an entry added, removed or renamed in it, or a mount over + // it, changes one of them — and its modification time, which with its device and inode places a users file that does + // not exist (below). Of the other entries the walk keeps only those that are the users file or have its name (below): + // with the listings of the directories it is in (frames), its memory is a few hundred bytes and the path for each + // directory under the root, and nothing for a file. + const read = []; + const DIRECTORY = { isFile: () => false, isDirectory: () => true }; + // Each name as the bytes the filesystem holds: listed as a string, a name that is not valid UTF-8 comes back with U+FFFD + // in place of each such byte, a name no lookup finds, and what lies under it would go unread. A name that is valid UTF-8 + // is kept as a string and joins its directory's path as one, as before; any other is kept as its bytes, and it and every + // path under it are joined as bytes. A directory's names, in the walk's order: as strings, then by their bytes. + const nameIn = (b) => { + const s = b.toString('utf8'); + return Buffer.from(s, 'utf8').equals(b) ? s : b; + }; + const bytesOf = (n) => (typeof n === 'string' ? Buffer.from(n, 'utf8') : n); + const keyOf = (n) => (typeof n === 'string' ? n : n.toString('utf8')); + const order = (x, y) => { + const [a, b] = [keyOf(x), keyOf(y)]; + return a < b ? -1 : a > b ? 1 : Buffer.compare(bytesOf(x), bytesOf(y)); + }; + const listing = (at) => Array.from(fs.readdirSync(at, { encoding: 'buffer' }), nameIn).sort(order); + const namesOf = (names) => { + const digest = createHash('sha256'); + for (const n of names) digest.update(typeof n === 'string' ? `${n}\0` : Buffer.concat([n, Buffer.from([0])])); + return digest.digest('base64'); + }; + const child = (at, n) => (typeof at === 'string' && typeof n === 'string' ? path.join(at, n) + : Buffer.concat([Buffer.from(at), Buffer.from(path.sep), bytesOf(n)])); + const rootBytes = Buffer.byteLength(root); + const relative = (at) => (typeof at === 'string' ? at.slice(root.length) : at.subarray(rootBytes).toString('utf8')); + // On Windows a volume is mounted only in a folder, so only a directory may lie on another volume than the directory that + // holds it, and its volume is read through a handle opened on it (fstatSync: the volume's serial, FILE_FS_VOLUME_INFORMATION's, + // as libuv reads it on every version), the same whether its path ends in a junction or not. A stat by name is not that: + // libuv answers one by the path's name (GetFileInformationByName, Windows 11 24H2 and Server 2025) unless its last name is + // a reparse point (a junction, a symlink, a folder a volume is mounted in), which it opens, and 1.49.0 to 1.50.x (Node + // 22.16.0's 1.49.2) read the device of the one by name from the upper half of the file's 128-bit id (0 on NTFS): the root + // reached through a junction to its workspace's orgs (statSync of the junction, opened) read as on another filesystem + // than the root (lstatSync, by name), and purge-org refused it as a mount point. A file is never one on Windows, and a link + // the walk does not follow. + const volumeOf = (at) => { + const fd = fs.openSync(at, 'r'); + try { return dev(fs.fstatSync(fd, { bigint: true })); } finally { fs.closeSync(fd); } + }; + let above; + try { above = windows ? volumeOf(path.dirname(root)) : dev(stat(path.dirname(root))); } catch (e) { return unreadable(path.dirname(root), e); } + // The mount points Linux lists at the root or under it, as paths relative to it ('' the root itself): a bind mount of a + // directory of the same filesystem among them, which lstatSync cannot tell from a directory. The kernel lists real + // paths, so the root's is compared. Each maps to { covered, first }: whether it is a covered mount whose path may show + // what it is mounted on (below), and the mount points to unmount first, in order — the mounts in it, which keep it busy, + // after what covers it (a lower mount of a stack maps to null: umount takes the topmost). No such file (off Linux, /proc + // not mounted): only the walk tells. + const listed = new Map(); + let info = null; + if (!windows) { + try { info = fs.readFileSync(MOUNTINFO, 'utf8'); } catch (e) { if (!GONE.has(e?.code)) return unreadable(MOUNTINFO, e); } + } + if (info !== null) { + let real; + try { real = fs.realpathSync(root); } catch (e) { return unreadable(root, e); } + // Each line's mount ID, its parent mount's, its filesystem's device (major:minor) and its mount point. The kernel lists + // a mount that a later one covers too — one mounted in the same parent mount on a directory above it, or covering that + // parent so — while its path shows what the mount above holds there. Of another filesystem, that is a directory where + // nothing is mounted, and unmounting it fails (not mounted): such a covered mount is passed over. Through a mount of the + // filesystem the covered mount lies in — a bind mount of a directory above it (`mount --bind /srv /srv`, a recursive one + // whose copy of the covered mount was unmounted, or one made on the filesystem mounted above) — it may be the very + // directory the covered mount is mounted on, which rmdir and unlink refuse (EBUSY) while it is a mount point anywhere: + // such a covered mount is refused, naming what to unmount first. + const lines = []; + const inParent = new Map(); // a parent mount's ID → the mounts in it + for (const line of info.split('\n')) { + const [mount, parent, device, , field] = line.split(' '); + if (field === undefined) continue; + const m = { id: mount, parent, device, at: unoctal(field) }; + lines.push(m); + if (!inParent.has(parent)) inParent.set(parent, []); + inParent.get(parent).push(m); + } + const byId = new Map(lines.map((m) => [m.id, m])); + // A mount on / itself covers nothing: a path is looked up from the process's root, and a lookup never crosses a mount + // stacked on it (`mount --bind / /`, or a tmpfs mounted on / in a chroot), which the kernel lists at / all the same. + const above = (a, b) => a !== path.sep && b !== a && b.startsWith(a + path.sep); + const hidden = new Map(); + const covered = (m) => { + if (!hidden.has(m)) { + hidden.set(m, false); // a loop of parents, which no kernel lists, reads as not covered + const parent = byId.get(m.parent); + hidden.set(m, inParent.get(m.parent).some((c) => above(c.at, m.at)) || (parent !== undefined && covered(parent))); + } + return hidden.get(m); + }; + // The mount a lookup of at ends in: the deepest of those it crosses (not covered, mounted on a directory above it or on + // at), the topmost of a stack there — on /, the lowest, the process's root. + const endsIn = (at) => { + const crossed = lines.filter((y) => (y.at === path.sep || y.at === at || at.startsWith(y.at + path.sep)) && !covered(y)); + const depth = Math.max(...crossed.map((y) => y.at.length)); + const stack = crossed.filter((y) => y.at.length === depth); + return stack.find((y) => (y.at === path.sep ? !stack.some((z) => z.id === y.parent) : !stack.some((z) => z.parent === y.id))); + }; + // Whether the path of the covered mount m may show what it is mounted on: it leads through a mount of the filesystem m + // lies in — its parent mount's. A parent the kernel does not list has its mount point above the process's root, so it + // is the mount the root lies in (a chroot into a directory that is no mount point): its filesystem is the one / shows, + // since a lookup of / never crosses a mount stacked on it, and the filesystem the lookup of m's path ends in is the one + // the entry there shows now, as rmSync takes it (lstat): a link there is a link of the filesystem above, which rmSync + // deletes without following it — compared through it (stat), a link in the tmpfs to a directory of the root's filesystem + // read as showing the mount, and the refusal named the tmpfs holding the whole deployment to unmount first. Nothing at + // m's path now (or nothing listed on it) reads as showing it. + const shows = (m) => { + const [x, parent] = [endsIn(m.at), byId.get(m.parent)]; + if (x === undefined) return true; + if (parent !== undefined) return x.device === parent.device; + try { return dev(lstat(m.at)) === dev(stat(path.sep)); } catch { return true; } + }; + // The mounts in a mount keep it busy (umount: target is busy), so they come off first, each after the mounts in it: the + // mounts in c (the outermost first), then c — once for each (seen). + const outermost = (a, b) => a.at.length - b.at.length || (a.at < b.at ? -1 : a.at > b.at ? 1 : 0); + const within = (seen) => { + const inner = (c) => { + if (seen.has(c)) return []; + seen.add(c); + return [...[...(inParent.get(c.id) ?? [])].sort(outermost).flatMap(inner), c]; + }; + return inner; + }; + const inside = (m, seen = new Set([m])) => [...(inParent.get(m.id) ?? [])].sort(outermost).flatMap(within(seen)).map((c) => c.at); + // What to unmount, in order, before the covered mount m can be unmounted at its path: what covers its parent, then each + // mount in its parent over a directory above it (the outermost first), and the mounts in m. + const firstOff = (m) => { + const seen = new Set([m]); + const uncover = (n) => { + const parent = byId.get(n.parent); + const outer = []; + if (parent !== undefined && !seen.has(parent) && covered(parent)) { + seen.add(parent); + outer.push(...uncover(parent)); + } + return [...outer, ...inParent.get(n.parent).filter((c) => above(c.at, n.at)).sort(outermost).flatMap(within(seen))]; + }; + return [...uncover(m).map((c) => c.at), ...inside(m, seen)]; + }; + // A mount stacked on m (mounted on m's own mount point, in m): umount at that path takes the topmost of the stack. + const stacked = (m) => lines.some((c) => c.parent === m.id && c.at === m.at); + for (const m of lines) { + if (m.at !== real && !m.at.startsWith(real + path.sep)) continue; + const at = m.at.slice(real.length); + if (covered(m)) { if (!listed.has(at) && shows(m)) listed.set(at, { covered: true, first: firstOff(m) }); } + else if (!stacked(m)) listed.set(at, { covered: false, first: inside(m) }); + else if (!listed.has(at)) listed.set(at, null); + } + } + // The users file, its path followed through every link, placed before the walk (and again after it, below), and the names + // its path ends in — as written or at each link it leads through, compared ignoring case and Unicode normalization, as a + // case-insensitive filesystem may look a name up: a mount shows a directory's entries by their names, so through one + // that is down now the path may lead to such an entry. The walk compares each entry with both as it reaches it. + let before = null; + try { before = stat(file); } catch { /* placed after the walk, below */ } + const fold = (s) => s.normalize('NFC').toLowerCase(); + const nameOf = (p) => (typeof p === 'string' ? path.basename(p) : p.subarray(p.lastIndexOf(path.sep.charCodeAt(0)) + 1).toString('utf8')); + const ends = new Set(); + for (let at = file, hops = 0; hops <= MAX_LINKS; hops += 1) { + ends.add(fold(path.basename(at))); + try { + if (!lstat(at).isSymbolicLink()) break; + at = path.resolve(fs.realpathSync(path.dirname(at)), fs.readlinkSync(at)); + } catch { break; } + } + const found = []; // { path, st }: the entries that are the users file as placed before the walk + const namesakes = []; // { path, st }: the entries under the root with a name its path ends in + // The walk, depth first, each directory's entries in order: a frame per directory it is in, { at, dev, names, i } — its + // listing, as rmSync holds the listing of each directory it is in, and the next entry's index. + const frames = []; + const visit = (at, from, parentDev) => { + let st; + try { st = lstat(at); } catch (e) { + if (GONE.has(e?.code) && at !== root) return null; // gone while the walk ran: nothing of it to delete + return unreadable(at, e); + } + if (before && same(st, before)) found.push({ path: at, st }); + if (at !== root && ends.has(fold(nameOf(at)))) namesakes.push({ path: at, st }); + // A non-directory is a mount point only as a file mounted on a file, which Linux lists: on another device than its + // directory it may be none, as an overlay mount whose layers lie on different filesystems (xino=off) shows a lower + // layer's files on that layer's device and its directories on its own. So where /proc/self/mountinfo tells, only one + // it lists is refused. + const mountedHere = listed.has(relative(at)); + const { covered = false, first = [] } = listed.get(relative(at)) ?? {}; + // The mounts in it keep it busy (`umount `: target is busy, and the same refusal came back), so they are named first. + // What covers it may show another directory there than the one it is mounted on — the workspace's, with the deployment + // bind-mounted over the directory that holds the covered mount — and unmounting that takes the workspace away (followed + // without the step, purge-org found no database): so the hint says to mount it there again once the covered mount is off. + const hint = !first.length ? '' + : covered ? `, covered by a later mount through which its path may show what it is mounted on: first unmount ` + + `${first.join(', then ')}; if the workspace goes with ${first.length === 1 ? 'it' : 'one of them'} (a mount that showed another ` + + `directory there than the one it is mounted on — the workspace's), mount it there again once ${named(at)} is unmounted, before ` + + 'you run purge-org again' + : `, holding ${first.length === 1 ? 'a mount, which keeps' : 'mounts, which keep'} it busy: first unmount ${first.join(', then ')}`; + // On Windows a directory's volume, read through a handle (volumeOf, above); a file, or a link, lies on its directory's. + let here = dev(st); + if (windows) { + if (!st.isDirectory()) here = parentDev; + else try { here = volumeOf(at); } catch (e) { + if (GONE.has(e?.code) && at !== root) return null; + return unreadable(at, e); + } + } + if (here !== parentDev && (st.isDirectory() || mountedHere || info === null)) { + mounts.push({ at, why: windows ? 'another volume mounted in it' : `another filesystem than ${named(from)}${hint}` }); + return null; + } + if (mountedHere) { mounts.push({ at, why: `the same filesystem as ${named(from)}, listed in ${MOUNTINFO}${hint}` }); return null; } + if (!st.isDirectory()) return null; + const key = idOf(st); + if (walked.has(key)) { mounts.push({ at, why: `the directory ${named(walked.get(key))} again` }); return null; } + walked.set(key, at); + let names; + try { names = listing(at); } catch (e) { return unreadable(at, e); } + read.push({ at, key, ctimeNs: st.ctimeNs, names: namesOf(names), mtimeNs: st.mtimeNs }); + frames.push({ at, dev: here, names, i: 0 }); + return null; + }; + let walk = visit(root, path.dirname(root), above); + while (!walk && frames.length) { + const frame = frames.at(-1); + if (frame.i === frame.names.length) frames.pop(); + else walk = visit(child(frame.at, frame.names[frame.i++]), frame.at, frame.dev); + } + if (walk) return walk; + // Each entry the walk read that test holds for, read again in the walk's order, entering each directory it read — the + // walk keeps none: { path, st }. A mount point is passed over, as the walk passed it. + const atMount = new Set(mounts.map((m) => named(m.at))); + const again = (test) => { + const hits = []; + const down = []; + const look = (at) => { + if (atMount.size && atMount.has(named(at))) return; + let s; + try { s = lstat(at); } catch { return; } + if (test(s)) hits.push({ path: at, st: s }); + if (s.isDirectory() && walked.has(idOf(s))) { try { down.push({ at, names: listing(at), i: 0 }); } catch { /* gone since */ } } + }; + look(root); + while (down.length) { + const frame = down.at(-1); + if (frame.i === frame.names.length) down.pop(); + else look(child(frame.at, frame.names[frame.i++])); + } + return hits; + }; + // The entries under the root that are the users file st, placed after the walk: as the walk found them, unless it changed + // while the walk ran (created, replaced, written: another device, inode, size or modification time than before it), when + // each entry is read again — none at a mount point. + const entriesOf = (st) => { + const kept = before && dev(before) === dev(st) && before.ino === st.ino && before.size === st.size && before.mtimeNs === st.mtimeNs; + return (kept ? found : again((s) => same(s, st))).filter((e) => !atMount.has(named(e.path))); + }; + // Where under the root the users file st lies (kin, its entries): the same file, or a view of it on another filesystem; + // then, for a view, the step after the move its way out may take. + const liesUnder = (kin, st) => { + const ids = kin.filter((e) => dev(e.st) === dev(st)).map((e) => named(e.path)); + const views = kin.filter((e) => dev(e.st) !== dev(st)).map((e) => named(e.path)); + return { + where: (ids.length ? `it is ${list(ids)} there` : '') + + (views.length ? `${ids.length ? ', and ' : ''}${list(views)} under it ${views.length === 1 ? 'has' : 'have'} its ` + + `${st.isFile() ? 'size and ' : ''}modification time on another filesystem (the users file seen through a network or ` + + 'overlay mount, or a copy that kept them)' : ''), + // Seen through a view, the path may then lead where nothing traces it back to the root, and a users file that does + // not exist there is refused (below): an overlay mount's merged directory shows its upper directory's inode and time. + then: views.length ? `; if it then refuses because ${file} 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` : '', + after: `. If ${file} then no longer leads to the users file, make it lead there again before a pre-store build or the in-place ` + + '`packc store export` uses it', + }; + }; + if (mounts[0]?.at === root) { + return stopped(`${root} (${mounts[0].why}) is a mount point: deleting it would take the files of the filesystem mounted there and then fail ` + + 'on the mount point itself (Node 23 and later; Node 22 fails at once) — nothing was deleted. Unmount it, then run purge-org ' + + `again: it deletes what is left at ${root} and drops org ${id}'s records, and the unmounted filesystem's files stay`); + } + if (mounts.length) { + const points = mounts.map((m) => `${named(m.at)} (${m.why})`).sort(); + const held = `${root} holds ${mounts.length === 1 ? 'a mount point' : 'mount points'}, ${list(points)}: deleting ` + + `${root} would take the files of the filesystem mounted there (Node 23 and later) or stop part-way at it (Node 22), and a ` + + `directory mounted from outside ${root} may hold the users file`; + // The users file under the root besides, found by the walk: unmounted, its path may lead there no more — a link of its + // name in a directory mounted there from outside the root, to a file under it — so that the purge, run again as this + // refusal said, went on into the empty mount point and deleted it. It is named here, to move out with the unmounting. + let now = null; + try { now = stat(file); } catch { /* nothing of it found under the root to name */ } + const kin = now ? entriesOf(now) : []; + if (!kin.length) return stopped(`${held} — nothing was deleted. Unmount what is mounted there, then run purge-org again`); + const { where, then, after } = liesUnder(kin, now); + return { + exists: true, + refusal: `${held}; and ${who} lies under ${root}: ${where} — nothing was deleted. Move ${list(kin.map((e) => named(e.path)))} 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)${then}${after}`, + }; + } + + // Where a path that does not exist leads: each link that exists followed (a dangling one by its target), '..' taken + // from the directory reached, as the kernel resolves a path; { place, nearest, nearestSt, dirs } — dirs each directory + // a name was looked up in on the way, { at, st } ('..' is not: from a mount it leads to its mount point's directory, + // as from the directory there now) — or { at, error }. + const split = (p) => p.split(windows ? /[\\/]+/ : /\/+/).filter(Boolean); + const lead = (p) => { + let todo; + let cur; + let curSt; + let hops = 0; + const dirs = []; + const from = (abs) => { + cur = path.parse(abs).root; + todo = split(abs.slice(cur.length)); + try { curSt = lstat(cur); } catch { curSt = null; } + }; + from(path.resolve(p)); + while (todo.length && curSt) { + const [name, ...rest] = todo; + if (name === '.' || name === '..') { + todo = rest; + if (name === '..' && path.dirname(cur) !== cur) { + cur = path.dirname(cur); + try { curSt = lstat(cur); } catch (e) { return { at: cur, error: e }; } + } + continue; + } + const next = path.join(cur, name); + dirs.push({ at: cur, st: curSt }); + let st; + try { st = lstat(next); } catch (e) { + if (GONE.has(e?.code)) break; + return { at: next, error: e }; + } + if (st.isSymbolicLink()) { + if (++hops > MAX_LINKS) return { at: next, error: { code: 'ELOOP' } }; + let target; + try { target = fs.readlinkSync(next); } catch (e) { return { at: next, error: e }; } + if (path.isAbsolute(target)) { + from(target); + todo = [...todo, ...rest]; + } else { + todo = [...split(target), ...rest]; + } + continue; + } + if (!st.isDirectory()) break; + cur = next; + curSt = st; + todo = rest; + } + return { place: todo.length ? path.join(cur, ...todo) : cur, nearest: curSt ? cur : null, nearestSt: curSt, dirs }; + }; + + // The directories the root lies in — its parent and theirs, as its real path passes through them — and those a name is + // looked up in on its path as purge-org names it, every link on it followed (lead, above: with a workspace, or its + // orgs/, reached through a link in a directory the root does not lie in, that directory and those above it): each by + // its path (case folded on Windows) → its device and inode. + const pathKey = (p) => (windows ? p.toLowerCase() : p); + const holding = () => { + const dirs = new Map(); + try { + for (let d = path.dirname(fs.realpathSync(root)); !dirs.has(pathKey(d)); d = path.dirname(d)) dirs.set(pathKey(d), idOf(stat(d))); + for (const { at, st: was } of lead(root).dirs ?? []) if (!dirs.has(pathKey(at))) dirs.set(pathKey(at), idOf(was)); + } catch { dirs.clear(); } + return dirs; + }; + + // The users file, its path followed through every link, placed again now that the walk is done. + const cannot = (at, e) => stopped(`${who} cannot be read on this host (${code(e)}${at === file ? '' : ` at ${at}`}` + + `${e?.code === 'ELOOP' ? `: a link loop, or a chain of more than ${MAX_LINKS} links` : ''}), so nothing tells whether it lies ` + + `under ${root} — nothing was deleted. ${denied(e) ? `Run purge-org as a user who can read ${at}; or, if it` : 'If it'} does not ` + + `lie under ${root}, delete ${root} by hand, then run purge-org again: it drops org ${id}'s records`); + // The refusal of the entries under the root with the name the users file's path ends in (namesakes), where no entry is the + // users file: mounted again, a mount that is down may lead the path to one of them. It names each, and for one that is a + // link what it leads to under the root too, which rmSync deletes while the link moves; moving them out is its way out. + // opening says where the path leads now and ends naming the root ("under it"); why, what a mount that is down leaves + // there; then, a step after the move. + const byName = (opening, why, then = '') => { + const links = namesakes.filter((e) => e.st.isSymbolicLink()); + const tos = []; + for (const link of links) { try { tos.push(stat(link.path)); } catch { /* leads nowhere */ } } + const besides = new Set(namesakes.map((e) => named(e.path))); + const targets = tos.length ? again((s) => tos.some((to) => same(s, to))).filter((e) => e.path !== root && !besides.has(named(e.path))) : []; + const pathsOf = (es) => list(es.map((e) => named(e.path)).sort()); + return `${opening}, but ${pathsOf(namesakes)} under it ${namesakes.length === 1 ? 'has' : 'have'} its name` + + (targets.length ? `, and ${pathsOf(targets)} ${targets.length === 1 ? 'is' : 'are'} what ` + + `${links.length === 1 ? 'a link of that name leads' : 'links of that name lead'} to` : '') + + `: ${why}, and mounted again it may lead the path there — nothing was deleted. Move ${pathsOf([...namesakes, ...targets])} out of ` + + `${root}, then run purge-org again${then}. If the users file is among them, make ${file} lead to it again before a pre-store build ` + + 'or the in-place `packc store export` uses it'; + }; + let st; + try { st = stat(file); } catch (e) { + if (!GONE.has(e?.code)) return cannot(file, e); + // It does not exist, so nothing of it is under the root to delete — unless its path leads there through what is not + // there now: a filesystem not mounted. Where it leads (lead, above): under the root — the nearest directory that + // exists is one the walk read — with no entry under the root of its name, the purge goes on: a users file moved out of + // the root, recorded there or reached through a mount of a directory under it or a link into it. Elsewhere nothing + // tells, so it is refused, failing closed. /users.json, the default nothing records, is absent on every store + // that keeps no local users: absent — nothing at its path, not even a link — it is nothing to keep. A link there is + // placed as a recorded path is. + const nothingAt = () => { try { lstat(file); return false; } catch (e2) { return GONE.has(e2?.code); } }; + if (!recorded && nothingAt()) return { exists: false, refusal: null }; + const led = lead(file); + if (led.error) return cannot(led.at, led.error); + const walkedDir = ({ key, mtimeNs }) => { + const [d, i] = key.split(':').map(BigInt); + return same({ ...DIRECTORY, dev: d, ino: i, mtimeNs }, led.nearestSt); + }; + // Into a directory the walk read, with an entry under the root of the users file's name, it is refused by the name: a + // directory on its path may be the mount point of a mount that is down (an empty directory, which tells nothing), and + // mounted again the path leads to the file of that name in the directory mounted there — under the root. + const into = led.nearestSt ? read.find(walkedDir) : undefined; + if (into && !namesakes.length) return { exists: false, refusal: null }; + if (into) { + return { + exists: false, + refusal: byName(`${who} does not exist, and its path leads into ${named(into.at)}${into.at === root ? '' : `, under ${root}`}`, + 'a directory on its path may be the mount point of a mount that is down'), + }; + } + // A path looked up only in directories the root lies in, or its own path is looked up in, to one where the rest of it + // does not exist — the directory of an org purged before, which the users file was moved out of as its refusal said: + // a filesystem mounted again on one of them covers the root too, or hides the link the root's own path is reached + // through, so the path leads under the root then by a directory under the root mounted in it, where the users file + // keeps its name, or by a link that is not seen (the header). With no entry under the root of that name, the purge + // goes on. + if (led.nearestSt && led.dirs.length) { + const holders = holding(); + if (led.dirs.every(({ at, st: was }) => holders.get(pathKey(at)) === idOf(was)) && !namesakes.length) return { exists: false, refusal: null }; + } + return stopped(`${who} does not exist, and nothing on this host tells where its path leads` + + `${led.place === path.resolve(file) ? '' : ` (to ${led.place})`}: ` + + (led.nearest === null ? 'no part of it exists' : `the nearest part of it that exists, ${led.nearest}, is no directory under ${root}`) + + `, and a filesystem not mounted now may lead it under ${root} — nothing was deleted. If it lies on a filesystem that is not ` + + `mounted now, mount it, then run purge-org again; if it does not lie under ${root}, delete ${root} by hand, then run purge-org ` + + `again: it drops org ${id}'s records`); + } + // The entries that are the users file (entriesOf, above). + const kin = entriesOf(st); + if (!kin.length) { + // The walk read each directory at its own moment: an entry another process renamed, while it ran, from a directory + // not read yet into one read already (the users file, or a directory holding it) was in neither listing, and rmSync + // deleted it. So before the purge goes on, each directory the walk read is read again, now that the users file is + // placed: the same directory (device and inode), the same change time — which moves with every entry added, removed + // or renamed in it — and the same names. Then none changed from its listing to now, and the walk is what lay under + // the root at one moment, the users file placed in it. A directory that changed is refused. + const changed = read.find(({ at, key, ctimeNs, names }) => { + try { + const now = lstat(at); + return idOf(now) !== key || now.ctimeNs !== ctimeNs || namesOf(listing(at)) !== names; + } catch { return true; } + }); + if (changed) { + const { at } = changed; + return stopped(`${at === root ? root : `${named(at)} under ${root}`} changed while purge-org read ${at === root ? 'it' : 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`); + } + // A mount that is down keeps what was written in its mount point meanwhile: a file of the users file's name there is + // what its path leads to now, and mounted again the path leads to the file of that name in the directory mounted + // there — under the root. So an entry under the root with the name the path ends in, as written or at a link it leads + // through (compared ignoring case and Unicode normalization, as a case-insensitive filesystem may look a name up), is + // refused; one that is a link names what it leads to under the root too, which rmSync deletes while the link moves. + if (!namesakes.length) return { exists: true, refusal: null }; + return { + exists: true, + refusal: byName(`${who} is no file under ${root}`, 'a mount that is down keeps what was written in its mount point meanwhile', + `; if it then refuses because ${file} 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`), + }; + } + const { where, then, after } = liesUnder(kin, st); + return { + exists: true, + refusal: `${who} lies under ${root}: ${where} — nothing was deleted. Move ${list(kin.map((e) => named(e.path)))} out of ${root}, then run ` + + `purge-org again${then}${after}`, + }; +} diff --git a/server/test-store-ops.mjs b/server/test-store-ops.mjs index 22ce66d2..a989a64b 100644 --- a/server/test-store-ops.mjs +++ b/server/test-store-ops.mjs @@ -15,7 +15,10 @@ * and start the store build again: it refuses, and after `packc store * import --replace` the next start re-imports them. `packc store * rekey-issuer` (--to, --clear) closes the OIDC upgrade gate's IdP moves; - * `packc store purge-org` deletes a removed org's files, and `packc store + * `packc store purge-org` deletes a removed org's files — never while that + * would take the users file a pre-store build reads, decided by what deleting + * the root takes (server/store/users-file-place.mjs, over the mounts a runner + * cannot make in server/fixtures/mount-fs.mjs) — and `packc store * restore` warns when the workspace's marker names another store. `packc * store audit` (slice 5, the audit) lists the rows from a shell, newest * first, one JSON line each, and writes none. @@ -48,9 +51,9 @@ const { test } = await import('node:test'); const assert = (await import('node:assert/strict')).default; const { spawnSync } = await import('node:child_process'); const { createHmac } = await import('node:crypto'); -const { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } = await import('node:fs'); +const { chmodSync, copyFileSync, existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } = await import('node:fs'); const { tmpdir } = await import('node:os'); -const { dirname, join } = await import('node:path'); +const { dirname, join, posix, win32 } = await import('node:path'); const { fileURLToPath, pathToFileURL } = await import('node:url'); const { isWin32, win32Skip, WIN32 } = await import('./fixtures/platform.mjs'); @@ -66,6 +69,8 @@ const { exportStore, formatExport, formatPurge, formatRekey, COOKIE_NOTE, purgeOrg, rekeyIssuer, REPLACE_REQUESTED, requestReplace, restoreMarkerWarning, } = await import('./store/ops.mjs'); const { backupStore, restoreStore } = await import('./store/backup.mjs'); +const { usersFilePlace } = await import('./store/users-file-place.mjs'); +const { mountFs } = await import('./fixtures/mount-fs.mjs'); const admin = await import('./identity-admin.mjs'); const { hashPassword, resolveSession, verifyPassword } = await import('./auth.mjs'); const boot = await import('./boot.mjs'); @@ -2243,7 +2248,12 @@ test('packc store rekey-issuer: usage errors exit 2; the store line, the report; // ---------- purge-org ---------- const purge = (base, id, opts = {}) => purgeOrg(id, { dbPath: dbOf(base), base, out: silent, ...opts }); -const refusedOp = (re) => (e) => e.code === 'ERR_OBSERVOGRAM_STORE_REFUSED' && re.test(e.message); +// A mismatch names what was thrown: a validation function that returns false leaves only assert.rejects' message (a +// path), and the summary a CI log ends with prints the error line alone, never the TAP block's `actual`. +const refusedOp = (re) => (e) => { + if (e?.code === 'ERR_OBSERVOGRAM_STORE_REFUSED' && re.test(e.message)) return true; + throw new assert.AssertionError({ message: `expected a refusal matching ${re}, got ${e?.code ?? e?.name}: ${e?.message}`, actual: e, expected: re }); +}; function cliEnv(base) { const env = { ...process.env }; for (const k of STRIP) { delete env[`OBSERVOGRAM_${k}`]; delete env[`TOMOGRAPH_${k}`]; } @@ -2251,9 +2261,8 @@ function cliEnv(base) { return env; } -// A flat deployment with two created orgs, acme removed (its files stay). -async function removedOrgDeployment() { - const base = tempDir(); +// A flat deployment with two created orgs, acme removed (its files stay); at base, a directory that exists, if given. +async function removedOrgDeployment(base = tempDir()) { usersJson(base, ['alice']); pack(base, 'p1'); await start(base); @@ -2337,7 +2346,7 @@ test('replace and export texts: the CLI confirmation, both usages and the README assert.ok(readme.includes(`\n# ${REPLACE_REQUESTED}\n`), 'the README quotes the confirmation line as the CLI prints it'); }); -test('purge-org docs: the README and the k8s note say it cannot be undone, what it deletes, and that a store backup holds none of it', () => { +test('purge-org docs: the README and the k8s note say it cannot be undone, what it deletes, that a store backup holds none of it, and what it refuses before deleting; the README names each users-file refusal\'s way out as purge-org does', () => { const section = (text, heading) => { const at = text.indexOf(heading); assert.notEqual(at, -1, heading); @@ -2353,6 +2362,33 @@ test('purge-org docs: the README and the k8s note say it cannot be undone, what for (const part of ['deploys.jsonl', 'snapshots/', 'runs/', 'journeys/']) assert.ok(text.includes(part), `${name}: names ${part}`); assert.match(text, /store backup` holds/, `${name}: a store backup does not cover the files`); assert.match(text, /copy `[^`]*orgs\/\/?` .*first/, `${name}: copy the directory first`); + assert.match(text, /users file a pre-store build reads/, `${name}: the users-file refusal`); + assert.match(text, /mount point is under/, `${name}: the mount-point refusal`); + } + // The README names each users-file refusal's way out, as purge-org (server/store/users-file-place.mjs) names it. + for (const way of [ + 'move them out of `orgs//`, then run the purge again', + 'if the purge then refuses because the users file does not exist, delete `orgs//` by hand and run the purge again', + 'make it lead there again before you roll back', + 'mount it and run the purge again', + 'delete `orgs//` by hand and run the purge again', + 'run the purge as a user who can read it', + 'Unmount it, then run the purge again', + 'names the mounts to unmount first, in order, before you unmount it', + 'names them to unmount first, in order, each after the mounts in it', + 'mount it there again once the covered mount is unmounted, then run the purge again', + 'move it out of `orgs//` and unmount, then run the purge again', + 'Stop what changes files there, then run the purge again', + // On Windows, first: a path that cannot be fully resolved (server/store/paths.mjs, ops.mjs's refusal). + 'if it does, move it out of `orgs//` first, then do the same', + ]) assert.ok(readme.includes(way), `README: ${way}`); + const place = readFileSync(join(HERE, 'store', 'users-file-place.mjs'), 'utf8').replace(/[`']\s*\+\s*[`']/g, ''); + for (const way of ['out of ${root}, then run purge-org again', 'if it then refuses because ${file} does not exist', + 'make it lead there again before a pre-store build', 'mount it, then run purge-org again', + 'delete ${root} by hand, then run purge-org again', 'Run purge-org as a user who can read', 'Unmount it, then run purge-org again', + ': first unmount ', 'mount it there again once ${named(at)} is unmounted, before you run purge-org again', + 'out of ${root} and unmount what is mounted there, then run purge-org again', 'Stop what changes files under ${root}, then run purge-org again']) { + assert.ok(place.includes(way), `purge-org's refusal names the same way out: ${way}`); } }); @@ -2419,6 +2455,1190 @@ test('packc store purge-org: usage exits 2; the store line, the report; a refusa assert.match(rm.stdout, /removed org beta — its files under .*orgs.beta stay; `packc store purge-org beta` deletes them with the server stopped/); }); +// ---------- purge-org: the users file by what deleting the root takes ---------- + +// The users file a pre-store build reads may have its bytes under the purged root while its path lies outside it — +// through a mount of a directory under the root, reached directly or through a link (realpath never crosses a mount +// back to its source), or as a hard link — and purge-org compared the path alone, so it deleted it. usersFilePlace +// (server/store/users-file-place.mjs) decides by what rmSync(root) would take: these run it over an injected +// filesystem (server/fixtures/mount-fs.mjs) with the mounts a test runner cannot make, on POSIX and Windows paths; +// the real-filesystem cases below run through purgeOrg itself. +const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +const ROOT_B = '/ws/orgs/beta'; +const betaVolume = (opts) => mountFs(posix, opts).dir(`${ROOT_B}/packs`).file(`${ROOT_B}/packs/b1.pack.yaml`).file(`${ROOT_B}/sub/users.json`) + .file('/srv/accounts.json'); +const placeIn = (vol, file, opts = {}) => usersFilePlace(file, ROOT_B, { fs: vol.fs, path: posix, windows: false, id: 'beta', ...opts }); +const underRoot = (file, entries, root = ROOT_B) => `the recorded users file ${file} lies under ${root}: it is ${entries} there — nothing was deleted. ` + + `Move ${entries} out of ${root}, then run purge-org again. If ${file} then no longer leads to the users file, make it lead there ` + + 'again before a pre-store build or the in-place `packc store export` uses it'; +// The users file outside the root while an entry under it has its name: through a mount that is down, whose mount point +// holds a file of that name meanwhile, the path leads there once it is mounted again. targets: what a link of that name +// leads to under the root. +const namesake = (file, entries, { targets = [], root = ROOT_B } = {}) => { + const listed = (xs) => (xs.length < 3 ? xs.join(' and ') : `${xs.slice(0, -1).join(', ')} and ${xs.at(-1)}`); + const them = listed([...entries, ...targets].sort()); + return `the recorded users file ${file} is no file under ${root}, but ${listed(entries)} under it ${entries.length === 1 ? 'has' : 'have'} its name` + + (targets.length ? `, and ${listed(targets)} ${targets.length === 1 ? 'is' : 'are'} 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 ${them} out of ${root}, then run purge-org again; if it then refuses because ${file} 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 beta's ` + + `records. If the users file is among them, make ${file} lead to it again before a pre-store build or the in-place \`packc store ` + + 'export` uses it'; +}; +// The users file does not exist, its path leads into a directory under the root (into), and an entry under the root has its +// name: a directory on its path may be the mount point of a mount that is down, and mounted again the path leads there. +const namesakeMissing = (file, into, entries, { targets = [], root = ROOT_B } = {}) => { + const listed = (xs) => (xs.length < 3 ? xs.join(' and ') : `${xs.slice(0, -1).join(', ')} and ${xs.at(-1)}`); + return `the recorded users file ${file} does not exist, and its path leads into ${into}${into === root ? '' : `, under ${root}`}, but ` + + `${listed(entries)} under it ${entries.length === 1 ? 'has' : 'have'} its name` + + (targets.length ? `, and ${listed(targets)} ${targets.length === 1 ? 'is' : 'are'} what a link of that name leads to` : '') + + ': 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 ${listed([...entries, ...targets].sort())} out of ${root}, then run purge-org again. If the users file is among ` + + `them, make ${file} lead to it again before a pre-store build or the in-place \`packc store export\` uses it`; +}; +const unknownPlace =(file, nearest, { to = null, root = ROOT_B } = {}) => `the recorded users file ${file} does not exist, and nothing on this host ` + + `tells where its path leads${to ? ` (to ${to})` : ''}: ${nearest === null ? 'no part of it exists' : `the nearest part of it that exists, ${nearest}, is no directory under ${root}`}, ` + + `and a filesystem not mounted now may lead it under ${root} — nothing was deleted. If it lies on a filesystem that is not mounted now, ` + + `mount it, then run purge-org again; if it does not lie under ${root}, delete ${root} by hand, then run purge-org again: it drops org ` + + 'beta\'s records'; +const mounted = (what, root = ROOT_B) => `${root} holds a mount point, ${what}: deleting ${root} would take the files of the filesystem mounted there ` + + `(Node 23 and later) or stop part-way at it (Node 22), and a directory mounted from outside ${root} may hold the users file — ` + + 'nothing was deleted. Unmount what is mounted there, then run purge-org again'; +// A mount point under the root while the users file lies under it besides: unmounted, its path may lead there no more, so +// the refusal names the entries to move out with the unmounting. +const mountedHolding = (what, file, entries, root = ROOT_B) => `${root} holds a mount point, ${what}: deleting ${root} would take the files of the ` + + `filesystem mounted there (Node 23 and later) or stop part-way at it (Node 22), and a directory mounted from outside ${root} may hold the users ` + + `file; and the recorded users file ${file} lies under ${root}: it is ${entries} there — nothing was deleted. Move ${entries} 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 ${file} then no longer leads to the users file, make it lead there again before a pre-store build or the in-place ` + + '`packc store export` uses it'; + +test('purge-org users file: under the root through a bind mount that is up — by its path there, through a file link and a directory link to it, and as spelled — is refused naming its entry under the root; one outside the root is purged, also when a link under the root leads to it, to its directory or to another filesystem (rmSync deletes the link alone)', () => { + const vol = betaVolume().dir('/mnt/b').bind(`${ROOT_B}/sub`, '/mnt/b') + .symlink('/cfg/users.json', '/mnt/b/users.json').symlink('/cfg/d', '/mnt/b'); + for (const file of ['/mnt/b/users.json', '/cfg/users.json', '/cfg/d/users.json', `${ROOT_B}/sub/users.json`]) { + assert.deepEqual(placeIn(vol, file), { exists: true, refusal: underRoot(file, `${ROOT_B}/sub/users.json`) }, file); + } + // The mount is what realpath does not cross: the same device and inode as the entry, by another path. + assert.deepEqual([vol.fs.statSync('/mnt/b/users.json').ino, vol.fs.statSync('/mnt/b/users.json').dev], + [vol.fs.statSync(`${ROOT_B}/sub/users.json`).ino, vol.fs.statSync(`${ROOT_B}/sub/users.json`).dev]); + assert.deepEqual(placeIn(vol, '/srv/accounts.json'), { exists: true, refusal: null }, 'outside the root: purged'); + // A link under the root that leads out — to the users file, to its directory, to a directory on another filesystem — is + // what rmSync deletes, never what it leads to: the walk does not follow it, so it is neither the users file nor a mount point. + const links = betaVolume().dir('/srv7', { dev: 7n }).file('/srv7/x.json') + .symlink(`${ROOT_B}/link.json`, '/srv/accounts.json').symlink(`${ROOT_B}/near`, '/srv').symlink(`${ROOT_B}/sub/far`, '/srv7'); + assert.deepEqual(placeIn(links, '/srv/accounts.json'), { exists: true, refusal: null }, 'links under the root that lead out: purged'); + // The users file a pre-store build reads when none was recorded: /users.json, through a link into the root. + vol.symlink('/ws/users.json', `${ROOT_B}/sub/users.json`); + assert.equal(placeIn(vol, '/ws/users.json', { recorded: false }).refusal, + underRoot('/ws/users.json', `${ROOT_B}/sub/users.json`).replace('the recorded users file /ws/users.json', + 'the users file /ws/users.json (the one a pre-store build reads: OBSERVOGRAM_USERS_FILE was not set at the import)')); + // The way out, followed: the entry moved out of the root, the record leads nowhere under it. + vol.rename(`${ROOT_B}/sub/users.json`, '/keep/users.json'); + assert.deepEqual(placeIn(vol, '/keep/users.json'), { exists: true, refusal: null }); +}); + +test('purge-org users file: every hard link under the root is named, so one move clears them all; with none left, the purge passes', () => { + const vol = betaVolume().hardlink('/srv/accounts.json', `${ROOT_B}/b/u.json`).hardlink('/srv/accounts.json', `${ROOT_B}/a/users.json`).dir('/keep'); + const both = `${ROOT_B}/a/users.json and ${ROOT_B}/b/u.json`; + assert.deepEqual(placeIn(vol, '/srv/accounts.json'), { exists: true, refusal: underRoot('/srv/accounts.json', both) }); + vol.rename(`${ROOT_B}/a/users.json`, '/keep/1.json').rename(`${ROOT_B}/b/u.json`, '/keep/2.json'); + assert.deepEqual(placeIn(vol, '/srv/accounts.json'), { exists: true, refusal: null }, 'both moved out: purged, and /srv/accounts.json stays'); +}); + +test('purge-org users file: a view through another filesystem — NFS (the inode kept), SMB (100 ns times), overlay (xino, an upper directory under the root, and one outside it), FUSE without inode numbers (whole seconds) — is refused by its size and modification time; another size or time is not; a copy that kept both reads the same (declared); moved out as the refusal says, its path through the NFS or SMB view leads to the directory under the root where nothing of it is left, and the purge passes once no entry under the root has its name, and through a view whose directories show another inode or time it is refused as a users file that does not exist — the second step the first refusal names, deleting the root by hand', () => { + const T = 1_760_000_123_456_789_123n; + const vol = betaVolume().file(`${ROOT_B}/sub/users.json`, { size: 117n, mtimeNs: T }).file(`${ROOT_B}/up/users.json`, { size: 90n }) + .file(`${ROOT_B}/packs/same-size.yaml`, { size: 117n, mtimeNs: T + 1_000_000_000n }) + .file(`${ROOT_B}/packs/same-time.yaml`, { size: 118n, mtimeNs: T }).dir('/srv/upper'); + const ino = vol.fs.statSync(`${ROOT_B}/sub/users.json`).ino; + // An overlay mount with lowerdir=/sub and an upper directory outside the root: its merged directory shows the + // upper directory's inode and modification time, a file only in the lower its own, on the overlay's device. + const [sub, upper] = [vol.fs.statSync(`${ROOT_B}/sub`), vol.fs.statSync('/srv/upper')]; + vol.view(`${ROOT_B}/sub`, '/mnt/nfs', { dev: 41n }) + .view(`${ROOT_B}/sub`, '/mnt/smb', { dev: 42n, mtimeNs: (t) => t - (t % 100n) }) + .view(`${ROOT_B}/sub`, '/mnt/ovl', { dev: 43n, ino: (i) => i | (1n << 62n) }) + .view(`${ROOT_B}/up`, '/mnt/upper', { dev: 44n, ino: () => ino + 7n }) + .view(`${ROOT_B}/sub`, '/mnt/sshfs', { dev: 45n, ino: () => 2n, mtimeNs: (t) => t - (t % 1_000_000_000n) }) + .view(`${ROOT_B}/sub`, '/mnt/merged', { dev: 48n, ino: (i) => (i === sub.ino ? upper.ino : i), mtimeNs: (t) => (t === sub.mtimeNs ? upper.mtimeNs : t) }); + for (const [file, entry] of [['/mnt/nfs/users.json', 'sub'], ['/mnt/smb/users.json', 'sub'], ['/mnt/ovl/users.json', 'sub'], ['/mnt/upper/users.json', 'up'], + ['/mnt/sshfs/users.json', 'sub'], ['/mnt/merged/users.json', 'sub']]) { + assert.equal(vol.fs.statSync(file).dev === vol.fs.statSync(`${ROOT_B}/${entry}/users.json`).dev, false, `${file} is another device`); + assert.equal(placeIn(vol, file).refusal, `the recorded users file ${file} lies under ${ROOT_B}: ${ROOT_B}/${entry}/users.json under it has its size ` + + 'and modification time on another filesystem (the users file seen through a network or overlay mount, or a copy that kept ' + + `them) — nothing was deleted. Move ${ROOT_B}/${entry}/users.json out of ${ROOT_B}, then run purge-org again; if it then refuses ` + + `because ${file} does not exist (a mount whose directories show another inode or modification time than theirs under ${ROOT_B}, ` + + `as an overlay mount with an upper directory does, cannot be traced back there), delete ${ROOT_B} by hand, then run purge-org ` + + `again: it drops org beta's records. If ${file} then no longer leads to the users file, make it lead there again before a ` + + 'pre-store build or the in-place `packc store export` uses it', file); + } + // A FUSE view that keeps neither the size nor the time is not matched (README: known, not fixed); a view of a directory + // shows its names, so the entries of the users file's name are refused by it. + vol.view(`${ROOT_B}/sub`, '/mnt/fuse', { dev: 46n, ino: () => 3n, mtimeNs: () => 0n }); + assert.deepEqual(placeIn(vol, '/mnt/fuse/users.json'), + { exists: true, refusal: namesake('/mnt/fuse/users.json', [`${ROOT_B}/sub/users.json`, `${ROOT_B}/up/users.json`]) }); + // On another device, another file with the users file's size or its time is not it; one with both reads the same. + vol.dir('/other', { dev: 47n }).file('/other/accounts.json', { size: 117n, mtimeNs: T + 5n }); + assert.deepEqual(placeIn(vol, '/other/accounts.json'), { exists: true, refusal: null }, 'the same size, another time'); + vol.file('/other/copy.json', { size: 117n, mtimeNs: T }); + assert.match(placeIn(vol, '/other/copy.json').refusal, /^the recorded users file \/other\/copy\.json lies under \/ws\/orgs\/beta: \/ws\/orgs\/beta\/sub\/users\.json under it has its size and modification time on another filesystem/, + 'a copy that kept its size and time (cp -p) on another device is refused: a declared false refusal'); + vol.rename(`${ROOT_B}/sub/users.json`, '/keep-copy.json'); + assert.deepEqual(placeIn(vol, '/other/copy.json'), { exists: true, refusal: null }, 'its way out: moved out of the root, purged'); + // The views' way out, followed: with the users file moved out of the root, its path through the NFS or SMB view leads to + // that view of /sub — the same directory by its inode and modification time — where nothing of it is left. While + // another entry under the root has its name (/up/users.json, which the upper view above shows), a mount that is down + // on its path may lead it there, and it is refused by that name; moved out too, the purge passes. + for (const file of ['/mnt/nfs/users.json', '/mnt/smb/users.json']) { + assert.deepEqual(placeIn(vol, file), { exists: false, refusal: namesakeMissing(file, `${ROOT_B}/sub`, [`${ROOT_B}/up/users.json`]) }, + `${file}: moved out, an entry of its name left`); + } + vol.rename(`${ROOT_B}/up/users.json`, '/keep-up.json'); + for (const file of ['/mnt/nfs/users.json', '/mnt/smb/users.json']) { + assert.deepEqual(placeIn(vol, file), { exists: false, refusal: null }, `${file}: moved out, it leads under the root`); + } + // Through a view whose directories show another inode or time — the overlay's merged directory (its upper directory's), + // an xino overlay's, an sshfs one's — nothing traces the path back to /sub: refused as a users file that does not + // exist, the second step its first refusal names, whose way out is deleting the root by hand (the real-filesystem + // case below: then purge-org drops the records). + for (const at of ['/mnt/merged', '/mnt/ovl', '/mnt/sshfs']) { + assert.deepEqual(placeIn(vol, `${at}/users.json`), { exists: null, refusal: unknownPlace(`${at}/users.json`, at) }, `${at}: moved out`); + } +}); + +test('purge-org users file: a mount point under the root — another filesystem, a file mounted there, one directory reached twice, a loop, the root itself, a mount of the same filesystem /proc/self/mountinfo lists (of a directory or a file) — is refused before anything is deleted, and once unmounted the purge passes; with no /proc/self/mountinfo, a same-filesystem bind mount from outside is entered as a directory; a file on another device than its directory that /proc/self/mountinfo does not list (an overlay mount\'s lower layer on another filesystem) is none, nor is a mount it lists that a later mount of another filesystem over a directory above it covers (one of its own filesystem may show the directory it is mounted on: refused, naming what to unmount first), though a mount stacked on / covers none', () => { + const vol = betaVolume().dir('/srv5', { dev: 7n }).file('/srv5/accounts.json').bind('/srv5', `${ROOT_B}/m`); + // The reverse of a mount view: a directory from outside mounted under the root, holding the users file. + assert.deepEqual(placeIn(vol, '/srv5/accounts.json'), { exists: null, refusal: mounted(`${ROOT_B}/m (another filesystem than ${ROOT_B})`) }); + vol.unmount(`${ROOT_B}/m`); + assert.deepEqual(placeIn(vol, '/srv5/accounts.json'), { exists: true, refusal: null }, 'unmounted: the users file is outside, purged'); + vol.bind('/srv5/accounts.json', `${ROOT_B}/u.json`); + assert.equal(placeIn(vol, '/srv/accounts.json').refusal, mounted(`${ROOT_B}/u.json (another filesystem than ${ROOT_B})`), 'a file mounted there'); + vol.unmount(`${ROOT_B}/u.json`).bind(`${ROOT_B}/packs`, `${ROOT_B}/z/packs2`); + assert.equal(placeIn(vol, '/srv/accounts.json').refusal, mounted(`${ROOT_B}/z/packs2 (the directory ${ROOT_B}/packs again)`), 'one directory reached twice'); + vol.unmount(`${ROOT_B}/z/packs2`).bind(ROOT_B, `${ROOT_B}/sub/loop`); + assert.equal(placeIn(vol, '/srv/accounts.json').refusal, mounted(`${ROOT_B}/sub/loop (the directory ${ROOT_B} again)`), 'a loop ends'); + vol.unmount(`${ROOT_B}/sub/loop`).dir('/vol', { dev: 8n }).file('/vol/packs/b2.pack.yaml').bind('/vol', ROOT_B); + assert.deepEqual(placeIn(vol, '/srv/accounts.json'), { + exists: null, + refusal: `${ROOT_B} (another filesystem than /ws/orgs) is a mount point: deleting it would take the files of the filesystem mounted there and ` + + 'then fail on the mount point itself (Node 23 and later; Node 22 fails at once) — nothing was deleted. Unmount it, then run purge-org ' + + `again: it deletes what is left at ${ROOT_B} and drops org beta's records, and the unmounted filesystem's files stay`, + }, 'the root itself mounted'); + vol.unmount(ROOT_B); + assert.deepEqual(placeIn(vol, '/srv/accounts.json'), { exists: true, refusal: null }); + // A bind mount from outside on the root's own filesystem is a directory to lstatSync (the same device). With no + // /proc/self/mountinfo to list it (off Linux), the walk enters it, and the users file seen through it is named as an entry. + vol.dir('/srv6').file('/srv6/accounts.json').bind('/srv6', `${ROOT_B}/m6`); + assert.equal(placeIn(vol, '/srv6/accounts.json').refusal, underRoot('/srv6/accounts.json', `${ROOT_B}/m6/accounts.json`), 'no mountinfo: entered'); + // Linux lists it in /proc/self/mountinfo (a space in its path as \040), and it is refused, holding the users file or not: + // rmSync deleted the files of the directory mounted there, outside the root (Node 23 and later), then failed on the mount + // point (Node 22 failed there), and once the users file was moved out of it as the "lies under" refusal said, the purge + // deleted the rest of it the same way. Unmounted, the purge passes. The root, reached through a link, is compared by its + // real path, as the kernel lists mount points; the root itself mounted from the same filesystem is refused as a mount point. + const linux = betaVolume({ proc: true }).dir('/srv6').file('/srv6/accounts.json').file('/srv6/other-data').dir('/srv7').file('/srv7/other-data') + .bind('/srv6', `${ROOT_B}/m 6`); + const same = (at, root = ROOT_B) => `${at} (the same filesystem as ${root}, listed in /proc/self/mountinfo)`; + assert.deepEqual(placeIn(linux, '/srv6/accounts.json'), { exists: null, refusal: mounted(same(`${ROOT_B}/m 6`)) }, 'holding the users file'); + linux.unmount(`${ROOT_B}/m 6`).bind('/srv7', `${ROOT_B}/m7`); + assert.deepEqual(placeIn(linux, '/srv6/accounts.json'), { exists: null, refusal: mounted(same(`${ROOT_B}/m7`)) }, 'holding other files'); + linux.symlink('/lnk', '/ws'); + assert.equal(usersFilePlace('/srv6/accounts.json', '/lnk/orgs/beta', { fs: linux.fs, path: posix, windows: false, id: 'beta' }).refusal, + mounted(same('/lnk/orgs/beta/m7', '/lnk/orgs/beta'), '/lnk/orgs/beta'), 'the root through a link'); + linux.unmount(`${ROOT_B}/m7`); + assert.deepEqual(placeIn(linux, '/srv6/accounts.json'), { exists: true, refusal: null }, 'unmounted: the users file is outside, purged'); + linux.dir('/srv8').file('/srv8/packs/b2.pack.yaml').bind('/srv8', ROOT_B); + assert.equal(placeIn(linux, '/srv6/accounts.json').refusal, `${same(ROOT_B, '/ws/orgs')} is a mount point: deleting it would take the files of ` + + 'the filesystem mounted there and then fail on the mount point itself (Node 23 and later; Node 22 fails at once) — nothing was deleted. ' + + `Unmount it, then run purge-org again: it deletes what is left at ${ROOT_B} and drops org beta's records, and the unmounted filesystem's ` + + 'files stay', 'the root itself, from the same filesystem'); + linux.unmount(ROOT_B); + assert.deepEqual(placeIn(linux, '/srv6/accounts.json'), { exists: true, refusal: null }); + // A mount that a later one covers — a filesystem mounted on /ws over a tree that held mounts already (fstab entries + // mounted out of order, an autofs mount over a directory that holds mounts) — is listed in /proc/self/mountinfo still, + // on the filesystem now hidden, while its path shows a plain directory of the filesystem mounted above: nothing is + // mounted there, and the purge passes (it was refused as a mount point, and unmounting, its way out, failed: not + // mounted). A mount in a covered mount is covered too, and one in that (with the parents followed one level up only, + // /m/n/o was refused, and unmounting it failed: not mounted). One made there on the filesystem above is refused, + // and once unmounted the purge passes, the covered one listed still; the root itself likewise. + const covering = (vol) => vol.dir('/fresh', { dev: 70n }).dir('/fresh/x').bind('/fresh', '/ws').file(`${ROOT_B}/packs/b1.pack.yaml`); + const covered = covering(betaVolume({ proc: true }).dir('/srv6').dir('/srv7').dir('/srv8').bind('/srv6', `${ROOT_B}/m`) + .bind('/srv7', `${ROOT_B}/m/n`).bind('/srv8', `${ROOT_B}/m/n/o`)).dir(`${ROOT_B}/m/n/o`); + for (const at of ['m', 'm/n', 'm/n/o']) { + assert.ok(covered.fs.readFileSync('/proc/self/mountinfo').includes(` ${ROOT_B}/${at} rw`), `${at}: listed still`); + assert.equal(covered.fs.lstatSync(`${ROOT_B}/${at}`).dev, 70n, `${at}: a directory of the filesystem above`); + assert.throws(() => covered.unmount(`${ROOT_B}/${at}`), { code: 'EINVAL' }, `${at}: not mounted`); + } + assert.deepEqual(placeIn(covered, '/srv/accounts.json'), { exists: true, refusal: null }, 'covered mounts: none is a mount point, purged'); + covered.bind('/fresh/x', `${ROOT_B}/m`); + assert.deepEqual(placeIn(covered, '/srv/accounts.json'), { exists: null, refusal: mounted(same(`${ROOT_B}/m`)) }, 'mounted there on the filesystem above'); + covered.unmount(`${ROOT_B}/m`); + assert.deepEqual(placeIn(covered, '/srv/accounts.json'), { exists: true, refusal: null }, 'unmounted as its refusal says: purged'); + const coveredRoot = covering(betaVolume({ proc: true }).dir('/srv8').bind('/srv8', ROOT_B)); + assert.throws(() => coveredRoot.unmount(ROOT_B), { code: 'EINVAL' }, 'the root: not mounted'); + assert.deepEqual(placeIn(coveredRoot, '/srv/accounts.json'), { exists: true, refusal: null }, 'the root, covered: purged'); + coveredRoot.bind('/fresh/x', ROOT_B); + assert.ok(placeIn(coveredRoot, '/srv/accounts.json').refusal.startsWith(`${same(ROOT_B, '/ws/orgs')} is a mount point: `), 'the root mounted on the filesystem above'); + coveredRoot.unmount(ROOT_B); + assert.deepEqual(placeIn(coveredRoot, '/srv/accounts.json'), { exists: true, refusal: null }, 'the root unmounted as its refusal says: purged'); + // A mount covers what lies under the directory it is mounted on, not every path that begins with its own: with the + // workspace on a filesystem mounted on /ws and another mounted on /w, a bind mount at /m of a directory of the + // workspace's filesystem is reached at its path, and refused (/w taken for a directory above /ws, it was passed over as + // covered, and rmSync failed there with EBUSY); once unmounted as its refusal says, the purge passes. + const beside = covering(betaVolume({ proc: true })).file('/ws/src/other-data').bind('/ws/src', `${ROOT_B}/m`).dir('/wfs', { dev: 72n }) + .bind('/wfs', '/w'); + assert.deepEqual(beside.fs.readdirSync(`${ROOT_B}/m`), ['other-data'], '/w beside /ws: /m reached at its path'); + assert.deepEqual(placeIn(beside, '/srv/accounts.json'), { exists: null, refusal: mounted(same(`${ROOT_B}/m`)) }, '/w beside /ws'); + beside.unmount(`${ROOT_B}/m`); + assert.deepEqual(placeIn(beside, '/srv/accounts.json'), { exists: true, refusal: null }, '/w beside /ws: unmounted as its refusal says, purged'); + // Covered by a mount of the filesystem it lies in — /ws bound on itself (an fstab line mounted out of order), a recursive + // bind of /ws whose copy of the mount at /m was unmounted as its refusal said, or a filesystem mounted on /ws that + // holds a bind mount of the directory it lies in — the covered mount's path shows the very directory it is mounted on, + // which rmdir refuses while it is a mount point (rmSync deleted part of the root, then failed there with EBUSY), and + // unmounting it at its path fails (not mounted). It is refused, naming what to unmount first; followed literally, the + // purge passes. The root itself likewise. + const coveredSame = (at, first, root = ROOT_B) => `${at} (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 ${first}; if the workspace goes with ` + + `${first.includes(', then ') ? 'one of them' : 'it'} (a mount that showed another directory there than the one it is mounted on — the ` + + `workspace's), mount it there again once ${at} is unmounted, before you run purge-org again)`; + const selfBound = betaVolume({ proc: true }).dir('/srv6').file('/srv6/other-data').dir(`${ROOT_B}/m`); + const point = selfBound.fs.lstatSync(`${ROOT_B}/m`).ino; + selfBound.bind('/srv6', `${ROOT_B}/m`).bind('/ws', '/ws'); + assert.equal(selfBound.fs.lstatSync(`${ROOT_B}/m`).ino, point, '/ws bound on itself: /m shows the directory mounted on'); + assert.throws(() => selfBound.unmount(`${ROOT_B}/m`), { code: 'EINVAL' }, '/ws bound on itself: /m not mounted'); + assert.deepEqual(placeIn(selfBound, '/srv/accounts.json'), { exists: null, refusal: mounted(coveredSame(`${ROOT_B}/m`, '/ws')) }, '/ws bound on itself'); + selfBound.unmount('/ws').unmount(`${ROOT_B}/m`); + assert.deepEqual(placeIn(selfBound, '/srv/accounts.json'), { exists: true, refusal: null }, '/ws bound on itself: unmounted as its refusal says, purged'); + // The declared false refusal: the deployment at /data/ws, holding /m as a plain directory, bind-mounted on /ws over + // the original directory, where a bind at /m lies covered — a mount of the same filesystem that shows another + // directory there than the one it is mounted on, so nothing at /m is a mount point, yet it is refused naming /ws + // first. Followed literally, `umount /ws` took the workspace away with it, and purge-org, run again, found no database + // at /ws/observogram.db (the refusal said nothing of mounting it again). It now says to mount it there again once /m + // is unmounted; followed so, the purge passes, and the mounted directory's files stay. + const declared = mountFs(posix, { proc: true }).dir(`${ROOT_B}/m`).dir('/srv6').file('/srv6/other-data').bind('/srv6', `${ROOT_B}/m`) + .file('/srv/accounts.json').file('/data/ws/observogram.db').file(`/data${ROOT_B}/packs/b1.pack.yaml`).dir(`/data${ROOT_B}/m`).bind('/data/ws', '/ws'); + assert.equal(declared.fs.lstatSync(`${ROOT_B}/m`).ino, declared.fs.lstatSync(`/data${ROOT_B}/m`).ino, 'the deployment over the original: /m shows the deployment\'s own directory'); + assert.throws(() => declared.unmount(`${ROOT_B}/m`), { code: 'EINVAL' }, 'the deployment over the original: /m not mounted'); + assert.deepEqual(placeIn(declared, '/srv/accounts.json'), { exists: null, refusal: mounted(coveredSame(`${ROOT_B}/m`, '/ws')) }, + 'the deployment over the original: refused, naming /ws first and the workspace to mount again'); + declared.unmount('/ws').unmount(`${ROOT_B}/m`); + assert.throws(() => declared.fs.lstatSync('/ws/observogram.db'), { code: 'ENOENT' }, 'unmounted as its refusal says: the workspace went with /ws'); + declared.bind('/data/ws', '/ws'); + assert.deepEqual(placeIn(declared, '/srv/accounts.json'), { exists: true, refusal: null }, 'the workspace mounted there again, as its refusal says: purged'); + assert.deepEqual(declared.fs.readdirSync('/srv6'), ['other-data'], 'the mounted directory\'s files stay'); + const rbound =betaVolume({ proc: true }).dir('/srv6').file('/srv6/other-data').bind('/srv6', `${ROOT_B}/m`).bind('/ws', '/ws') + .bind('/srv6', `${ROOT_B}/m`); + assert.deepEqual(placeIn(rbound, '/srv/accounts.json'), { exists: null, refusal: mounted(same(`${ROOT_B}/m`)) }, 'a recursive bind: its copy'); + assert.throws(() => rbound.unmount('/ws'), { code: 'EBUSY' }, 'a recursive bind: busy while its copy is mounted'); + rbound.unmount(`${ROOT_B}/m`); + assert.deepEqual(placeIn(rbound, '/srv/accounts.json'), { exists: null, refusal: mounted(coveredSame(`${ROOT_B}/m`, '/ws')) }, + 'a recursive bind, its copy unmounted as its refusal says'); + rbound.unmount('/ws').unmount(`${ROOT_B}/m`); + assert.deepEqual(placeIn(rbound, '/srv/accounts.json'), { exists: true, refusal: null }, 'a recursive bind: unmounted as its refusal says, purged'); + const inAbove = betaVolume({ proc: true }).dir('/srv6').bind('/srv6', `${ROOT_B}/m`).bind('/ws', '/keep').dir('/fresh', { dev: 70n }) + .bind('/fresh', '/ws').bind('/keep/orgs', '/ws/orgs').unmount('/keep'); + assert.deepEqual(placeIn(inAbove, '/srv/accounts.json'), { exists: null, refusal: mounted(coveredSame(`${ROOT_B}/m`, '/ws/orgs, then /ws')) }, + 'a bind mount of the directory it lies in, on the filesystem above'); + inAbove.unmount('/ws/orgs').unmount('/ws').unmount(`${ROOT_B}/m`); + assert.deepEqual(placeIn(inAbove, '/srv/accounts.json'), { exists: true, refusal: null }, 'on the filesystem above: unmounted as its refusal says, purged'); + const rootBound = betaVolume({ proc: true }).bind(ROOT_B, ROOT_B).bind('/ws', '/ws'); + assert.ok(placeIn(rootBound, '/srv/accounts.json').refusal.startsWith(`${coveredSame(ROOT_B, '/ws', '/ws/orgs')} is a mount point: `), + 'the root bound on itself, /ws bound on itself'); + rootBound.unmount('/ws').unmount(ROOT_B); + assert.deepEqual(placeIn(rootBound, '/srv/accounts.json'), { exists: true, refusal: null }, 'the root: unmounted as its refusal says, purged'); + // Whether the covered mount's path leads through a mount of the filesystem it lies in is decided by its parent mount's + // filesystem and the topmost mount of a stack, and the hint names what `umount` takes, in that order, when followed + // literally. Each of these read otherwise under a change to users-file-place.mjs the suite stayed green under, and on real + // mounts the purge then passed the covered mount over (rmSync deleted part of the root and failed there with EBUSY), or the + // hint's first step failed (not mounted; target is busy) and the same refusal came back. A tmpfs on /m under /ws bound + // on itself: compared by its own filesystem it was passed over. + const onTmpfs = betaVolume({ proc: true }).dir('/tfs', { dev: 47n }).file('/tfs/t').bind('/tfs', `${ROOT_B}/m`).bind('/ws', '/ws'); + assert.match(onTmpfs.fs.readFileSync('/proc/self/mountinfo'), new RegExp(`^\\d+ 21 0:47 / ${ROOT_B}/m `, 'm'), 'a tmpfs on /m: listed on its own device'); + assert.deepEqual(placeIn(onTmpfs, '/srv/accounts.json'), { exists: null, refusal: mounted(coveredSame(`${ROOT_B}/m`, '/ws')) }, 'a tmpfs on /m, /ws bound on itself'); + onTmpfs.unmount('/ws').unmount(`${ROOT_B}/m`); + assert.deepEqual(placeIn(onTmpfs, '/srv/accounts.json'), { exists: true, refusal: null }, 'a tmpfs on /m: unmounted as its refusal says, purged'); + // A stack on /ws — /ws bound on /alt, a tmpfs mounted on /ws, then /alt bound on /ws over it — leads the path through + // its topmost mount, of the root's filesystem (by the lowest, the tmpfs, it was passed over); both come off first. + const stackedWs = betaVolume({ proc: true }).dir('/srv6').file('/srv6/other-data').bind('/srv6', `${ROOT_B}/m`).dir('/alt').bind('/ws', '/alt') + .dir('/fresh', { dev: 70n }).bind('/fresh', '/ws').bind('/alt', '/ws'); + assert.equal(stackedWs.fs.lstatSync(`${ROOT_B}/m`).dev, 1n, 'a stack on /ws: /m shows the directory mounted on'); + assert.deepEqual(placeIn(stackedWs, '/srv/accounts.json'), { exists: null, refusal: mounted(coveredSame(`${ROOT_B}/m`, '/ws, then /ws')) }, + 'a stack on /ws, the root\'s filesystem on top'); + stackedWs.unmount('/ws').unmount('/ws').unmount(`${ROOT_B}/m`); + assert.deepEqual(placeIn(stackedWs, '/srv/accounts.json'), { exists: true, refusal: null }, 'a stack on /ws: unmounted as its refusal says, purged'); + // The covered mount's parent mount covered itself, nothing in it above the covered mount — /ws/orgs bound on itself, the + // bind at /m made in it, then /ws bound on itself: the hint names what covers the parent (it named nothing, and + // `umount /m`, the way out then, said not mounted). + const inCovered = betaVolume({ proc: true }).bind('/ws/orgs', '/ws/orgs').dir('/srv6').file('/srv6/other-data').bind('/srv6', `${ROOT_B}/m`).bind('/ws', '/ws'); + assert.deepEqual(placeIn(inCovered, '/srv/accounts.json'), { exists: null, refusal: mounted(coveredSame(`${ROOT_B}/m`, '/ws')) }, 'in a covered /ws/orgs'); + inCovered.unmount('/ws').unmount(`${ROOT_B}/m`); + assert.deepEqual(placeIn(inCovered, '/srv/accounts.json'), { exists: true, refusal: null }, 'in a covered /ws/orgs: unmounted as its refusal says, purged'); + // Two coverers in one parent — /ws/orgs, then /ws, each bound on itself over the bind at /m — come off outermost + // first: /ws/orgs is reached only once /ws is unmounted (named first, `umount /ws/orgs` said not mounted). + const twoAbove = betaVolume({ proc: true }).dir('/srv6').file('/srv6/other-data').bind('/srv6', `${ROOT_B}/m`).bind('/ws/orgs', '/ws/orgs').bind('/ws', '/ws'); + assert.throws(() => twoAbove.unmount('/ws/orgs'), { code: 'EINVAL' }, 'two coverers: /ws/orgs not mounted while /ws is'); + assert.deepEqual(placeIn(twoAbove, '/srv/accounts.json'), { exists: null, refusal: mounted(coveredSame(`${ROOT_B}/m`, '/ws, then /ws/orgs')) }, 'two coverers in one parent'); + twoAbove.unmount('/ws').unmount('/ws/orgs').unmount(`${ROOT_B}/m`); + assert.deepEqual(placeIn(twoAbove, '/srv/accounts.json'), { exists: true, refusal: null }, 'two coverers: unmounted as its refusal says, purged'); + // What covers the parent comes before what is above the covered mount in the parent: /ws/orgs bound on itself, the bind at + // /m in it, a tmpfs stacked on /ws/orgs in it, then /ws bound on itself (named after, `umount /ws/orgs` said not mounted). + const parentCovered = betaVolume({ proc: true }).bind('/ws/orgs', '/ws/orgs').dir('/srv6').file('/srv6/other-data').bind('/srv6', `${ROOT_B}/m`) + .dir('/fresh', { dev: 70n }).bind('/fresh', '/ws/orgs').bind('/ws', '/ws'); + assert.deepEqual(placeIn(parentCovered, '/srv/accounts.json'), { exists: null, refusal: mounted(coveredSame(`${ROOT_B}/m`, '/ws, then /ws/orgs')) }, + 'the parent covered, and a mount above in it'); + parentCovered.unmount('/ws').unmount('/ws/orgs').unmount(`${ROOT_B}/m`); + assert.deepEqual(placeIn(parentCovered, '/srv/accounts.json'), { exists: true, refusal: null }, 'the parent covered, and a mount above in it: unmounted as its refusal says, purged'); + // The mounts in the covered mount keep it busy, and come after it is uncovered: a bind at /m/n in the one at /m + // (left out, `umount /m` said target is busy). + const inner = betaVolume({ proc: true }).dir('/srv6').dir('/srv7').file('/srv7/other-data').bind('/srv6', `${ROOT_B}/m`).bind('/srv7', `${ROOT_B}/m/n`).bind('/ws', '/ws'); + assert.deepEqual(placeIn(inner, '/srv/accounts.json'), { exists: null, refusal: mounted(coveredSame(`${ROOT_B}/m`, `/ws, then ${ROOT_B}/m/n`)) }, 'a mount in the covered mount'); + inner.unmount('/ws'); + assert.throws(() => inner.unmount(`${ROOT_B}/m`), { code: 'EBUSY' }, 'a mount in the covered mount: busy until that is unmounted'); + inner.unmount(`${ROOT_B}/m/n`).unmount(`${ROOT_B}/m`); + assert.deepEqual(placeIn(inner, '/srv/accounts.json'), { exists: true, refusal: null }, 'a mount in the covered mount: unmounted as its refusal says, purged'); + // A mount stacked on / itself — a tmpfs mounted on / in a chroot, or `mount --bind / /` — covers nothing: a path is looked + // up from the process's root, and a lookup never crosses a mount stacked on it, so /m is reached at its path still. + // It is refused, holding other files or the users file (it was passed over as covered: rmSync failed there with EBUSY, and + // holding the users file, the "lies under" refusal named /m/accounts.json, in the directory mounted there, to move + // out), and once unmounted as its refusal says, the purge passes. + for (const [how, onRoot] of [['a tmpfs mounted on /', (v) => v.dir('/fresh', { dev: 70n }).bind('/fresh', '/')], ['mount --bind / /', (v) => v.bind('/', '/')]]) { + for (const [source, file] of [['/srv7', '/srv/accounts.json'], ['/srv6', '/srv6/accounts.json']]) { + const stacked = onRoot(betaVolume({ proc: true }).file('/srv6/accounts.json').file('/srv7/other-data').bind(source, `${ROOT_B}/m`)); + assert.match(stacked.fs.readFileSync('/proc/self/mountinfo'), /^\d+ 21 \S+ \/ \/ /m, `${how}: listed at /, in the root's mount`); + assert.deepEqual(stacked.fs.readdirSync(`${ROOT_B}/m`), stacked.fs.readdirSync(source), `${how}: /m reached at its path`); + assert.deepEqual(placeIn(stacked, file), { exists: null, refusal: mounted(same(`${ROOT_B}/m`)) }, `${how}: ${source} mounted there`); + stacked.unmount(`${ROOT_B}/m`); + assert.deepEqual(placeIn(stacked, file), { exists: true, refusal: null }, `${how}: ${source} unmounted as its refusal says, purged`); + } + } + // A file on another device than its directory is a mount point only as /proc/self/mountinfo lists it (a file mounted on a + // file, which Linux alone makes): an overlay mount whose layers lie on different filesystems (xino=off) shows its + // directories on its own device and a lower layer's files on that layer's, with nothing mounted there, and the purge + // passes (it was refused as a mount point, and unmounting, its way out, could not be followed). A file from another + // filesystem mounted on a file under the root is listed, and refused. With no /proc/self/mountinfo to tell, a file on + // another device is refused as one mounted there (above). + linux.file(`${ROOT_B}/packs/lower.pack.yaml`, { dev: 60n }).file(`${ROOT_B}/sub/lower.json`, { dev: 60n }); + assert.deepEqual(placeIn(linux, '/srv6/accounts.json'), { exists: true, refusal: null }, 'an overlay\'s lower-layer files: no mount point'); + linux.dir('/srv9', { dev: 9n }).file('/srv9/x.json').bind('/srv9/x.json', `${ROOT_B}/packs/m.json`); + assert.equal(placeIn(linux, '/srv6/accounts.json').refusal, mounted(`${ROOT_B}/packs/m.json (another filesystem than ${ROOT_B}/packs)`), + 'a file from another filesystem mounted on a file, listed'); + linux.unmount(`${ROOT_B}/packs/m.json`); + assert.deepEqual(placeIn(linux, '/srv6/accounts.json'), { exists: true, refusal: null }); + // A file of the root's own filesystem mounted on a file under it is a file to lstatSync (the same device), and listed: + // refused, holding the users file or not. Passed, rmSync failed there with EBUSY, and the users file mounted there was + // refused as an entry under the root to move out, which fails on a mount point (mv: Device or resource busy). Unmounted + // as its refusal says, the purge passes. + linux.file('/srv6/src.json').file(`${ROOT_B}/m.json`).bind('/srv6/src.json', `${ROOT_B}/m.json`); + for (const file of ['/srv6/accounts.json', '/srv6/src.json']) { + assert.deepEqual(placeIn(linux, file), { exists: null, refusal: mounted(same(`${ROOT_B}/m.json`)) }, `a file of the same filesystem mounted on a file: ${file}`); + } + linux.unmount(`${ROOT_B}/m.json`); + assert.deepEqual(placeIn(linux, '/srv6/src.json'), { exists: true, refusal: null }, 'unmounted as its refusal says: purged'); + // /proc/self/mountinfo there but unreadable: nothing tells which directory is a mount point, so it is refused. + const locked = { ...linux.fs, readFileSync: (p) => { throw Object.assign(new Error(`EACCES: ${p}`), { code: 'EACCES' }); } }; + assert.equal(placeIn({ fs: locked }, '/srv6/accounts.json').refusal, `/proc/self/mountinfo cannot be read (EACCES), so purge-org cannot tell what ` + + `deleting ${ROOT_B} takes — nothing was deleted. Run purge-org as a user who can read it; or, if the recorded users file /srv6/accounts.json ` + + `does not lie under ${ROOT_B}, delete ${ROOT_B} by hand as such a user, then run purge-org again: it drops org beta's records`); +}); + +test('purge-org users file: in a chroot into a directory that is no mount point, /proc/self/mountinfo lists neither / nor the mount the root lies in, and the mounts in it name a parent it does not list — a mount under the root that a later mount of another filesystem covers is passed over there too (it was refused, and its way out, unmounting the covering mount, unmounted the filesystem holding the workspace), one a mount of the root\'s own filesystem covers is refused naming what to unmount first, and a mount stacked on / covers none', () => { + const same = (at, root = ROOT_B) => `${at} (the same filesystem as ${root}, listed in /proc/self/mountinfo)`; + const coveredSame = (at, first) => `${at} (the same filesystem as ${ROOT_B}, listed in /proc/self/mountinfo, covered by a later mount through which ` + + `its path may show what it is mounted on: first unmount ${first}; 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 ${at} is unmounted, before you run purge-org again)`; + const chrooted = () => betaVolume({ proc: true, chroot: true }).dir('/srv6').file('/srv6/other-data').bind('/srv6', `${ROOT_B}/m`); + // A tmpfs mounted on the workspace over the bind mount at /m (c86f545's case), in the chroot: /m is a plain + // directory of the tmpfs, and the purge passes. Refused, its way out named the tmpfs, the whole deployment's filesystem. + const covered = chrooted().dir('/fresh', { dev: 70n }).dir('/fresh/x').bind('/fresh', '/ws').file(`${ROOT_B}/packs/b1.pack.yaml`).dir(`${ROOT_B}/m`); + const info = covered.fs.readFileSync('/proc/self/mountinfo'); + assert.doesNotMatch(info, /^\d+ \d+ \S+ \/ \/ /m, 'the chroot: nothing listed at /'); + const [, parent] = info.match(new RegExp(`^\\d+ (\\d+) \\S+ / ${ROOT_B}/m `, 'm')); + assert.doesNotMatch(info, new RegExp(`^${parent} `, 'm'), '/m names a parent mount that is not listed'); + assert.equal(covered.fs.lstatSync(`${ROOT_B}/m`).dev, 70n, '/m: a directory of the filesystem above'); + assert.throws(() => covered.unmount(`${ROOT_B}/m`), { code: 'EINVAL' }, '/m: not mounted'); + assert.deepEqual(placeIn(covered, '/srv/accounts.json'), { exists: true, refusal: null }, 'in the chroot, the covered mount is passed over: purged'); + // /m in the tmpfs a link to a directory of the root's own filesystem (/srv7, holding a file): what is at the covered + // mount's path is the link, of the tmpfs, which rmSync deletes without following it, so the covered mount is passed over + // and the purge goes on, the link's target kept. Compared through the link (statSync), the target's filesystem is the one + // / shows, and it was refused naming the tmpfs, the whole deployment's filesystem, to unmount first. + const linked = chrooted().dir('/fresh', { dev: 70n }).bind('/fresh', '/ws').file(`${ROOT_B}/packs/b1.pack.yaml`).dir('/srv7').file('/srv7/g') + .symlink(`${ROOT_B}/m`, '/srv7'); + assert.ok(linked.fs.lstatSync(`${ROOT_B}/m`).isSymbolicLink(), '/m: a link'); + assert.equal(linked.fs.lstatSync(`${ROOT_B}/m`).dev, 70n, '/m: a link of the filesystem above'); + assert.equal(linked.fs.statSync(`${ROOT_B}/m`).dev, linked.fs.statSync('/').dev, '/m leads to a directory of the filesystem / shows'); + assert.throws(() => linked.unmount(`${ROOT_B}/m`), { code: 'EINVAL' }, '/m a link: not mounted'); + assert.deepEqual(placeIn(linked, '/srv/accounts.json'), { exists: true, refusal: null }, 'in the chroot, the covered mount under a link of the filesystem above is passed over: purged'); + // The root itself so covered, and /m mounted again on the filesystem above, read as outside a chroot. + const coveredRoot = betaVolume({ proc: true, chroot: true }).dir('/srv8').bind('/srv8', ROOT_B).dir('/fresh', { dev: 70n }).bind('/fresh', '/ws') + .file(`${ROOT_B}/packs/b1.pack.yaml`); + assert.deepEqual(placeIn(coveredRoot, '/srv/accounts.json'), { exists: true, refusal: null }, 'the root, covered, in the chroot: purged'); + covered.dir('/fresh/y').bind('/fresh/y', `${ROOT_B}/m`); + assert.deepEqual(placeIn(covered, '/srv/accounts.json'), { exists: null, refusal: mounted(same(`${ROOT_B}/m`)) }, 'mounted there on the filesystem above'); + covered.unmount(`${ROOT_B}/m`); + assert.deepEqual(placeIn(covered, '/srv/accounts.json'), { exists: true, refusal: null }, 'unmounted as its refusal says: purged'); + // Covered by a mount of the root's own filesystem — /ws bound on itself in the chroot — /m shows the very directory + // it is mounted on, and is refused naming what to unmount first; followed literally, the purge passes. + const selfBound = chrooted().bind('/ws', '/ws'); + assert.throws(() => selfBound.unmount(`${ROOT_B}/m`), { code: 'EINVAL' }, '/ws bound on itself: /m not mounted'); + assert.deepEqual(placeIn(selfBound, '/srv/accounts.json'), { exists: null, refusal: mounted(coveredSame(`${ROOT_B}/m`, '/ws')) }, '/ws bound on itself, in the chroot'); + selfBound.unmount('/ws').unmount(`${ROOT_B}/m`); + assert.deepEqual(placeIn(selfBound, '/srv/accounts.json'), { exists: true, refusal: null }, '/ws bound on itself: unmounted as its refusal says, purged'); + // A tmpfs mounted on / in the chroot, listed at / with the unlisted parent, covers nothing: /m is refused as a mount point. + const stacked = chrooted().dir('/fresh', { dev: 70n }).bind('/fresh', '/'); + assert.match(stacked.fs.readFileSync('/proc/self/mountinfo'), new RegExp(`^\\d+ ${parent} \\S+ / / `, 'm'), 'listed at /, in the unlisted mount'); + assert.deepEqual(placeIn(stacked, '/srv/accounts.json'), { exists: null, refusal: mounted(same(`${ROOT_B}/m`)) }, 'a tmpfs on / in the chroot: /srv6 mounted there'); + stacked.unmount(`${ROOT_B}/m`); + assert.deepEqual(placeIn(stacked, '/srv/accounts.json'), { exists: true, refusal: null }, 'unmounted as its refusal says: purged'); +}); + +test('purge-org users file: a mount point under the root holding mounts is refused naming them to unmount first, in order, each after the mounts in it — named alone, `umount /m` said target is busy and the same refusal came back; a mount stacked on it names the mounts in the topmost alone', () => { + const same = (at, root = ROOT_B) => `${at} (the same filesystem as ${root}, listed in /proc/self/mountinfo)`; + const busy = (at, first, why = `the same filesystem as ${ROOT_B}, listed in /proc/self/mountinfo`) => `${at} (${why}, holding ` + + `${first.includes(', then ') ? 'mounts, which keep' : 'a mount, which keeps'} it busy: first unmount ${first})`; + // A bind at /m holding one at /m/n, in that one at /m/n/o, and beside it one at /m/p: the walk never + // enters /m, so they went unnamed, though /proc/self/mountinfo lists them. Each comes after the mounts in it. + const nested = betaVolume({ proc: true }).dir('/srv6').dir('/srv7').dir('/srv8').dir('/srv9').file('/srv9/other-data').bind('/srv6', `${ROOT_B}/m`) + .bind('/srv7', `${ROOT_B}/m/n`).bind('/srv8', `${ROOT_B}/m/n/o`).bind('/srv9', `${ROOT_B}/m/p`); + assert.throws(() => nested.unmount(`${ROOT_B}/m`), { code: 'EBUSY' }, 'the mounts in /m keep it busy'); + assert.deepEqual(placeIn(nested, '/srv/accounts.json'), { exists: null, refusal: mounted(busy(`${ROOT_B}/m`, `${ROOT_B}/m/n/o, then ${ROOT_B}/m/n, then ${ROOT_B}/m/p`)) }, + 'refused naming the mounts in it, each after the mounts in it'); + // Followed literally — the named order, then /m, as the refusal says — the purge passes, and the mounted directories' files stay. + nested.unmount(`${ROOT_B}/m/n/o`).unmount(`${ROOT_B}/m/n`); + assert.deepEqual(placeIn(nested, '/srv/accounts.json'), { exists: null, refusal: mounted(busy(`${ROOT_B}/m`, `${ROOT_B}/m/p`)) }, 'part-way: the mount left in it'); + nested.unmount(`${ROOT_B}/m/p`).unmount(`${ROOT_B}/m`); + assert.deepEqual(placeIn(nested, '/srv/accounts.json'), { exists: true, refusal: null }, 'unmounted as its refusal says: purged'); + assert.deepEqual(nested.fs.readdirSync('/srv9'), ['other-data'], 'the mounted directory\'s files stay'); + // A mount in it that a later one over a directory above it covers comes after the one that covers it (unmounted first, it + // is not mounted). A mount of another filesystem holding one is named alike. + const coveredIn = betaVolume({ proc: true }).dir('/srv6').dir('/srv7').dir('/srv8').bind('/srv6', `${ROOT_B}/m`).bind('/srv8', `${ROOT_B}/m/n/o`) + .bind('/srv7', `${ROOT_B}/m/n`); + assert.throws(() => coveredIn.unmount(`${ROOT_B}/m/n/o`), { code: 'EINVAL' }, 'covered in /m: not mounted'); + assert.deepEqual(placeIn(coveredIn, '/srv/accounts.json'), { exists: null, refusal: mounted(busy(`${ROOT_B}/m`, `${ROOT_B}/m/n, then ${ROOT_B}/m/n/o`)) }, 'a covered mount in it'); + coveredIn.unmount(`${ROOT_B}/m/n`).unmount(`${ROOT_B}/m/n/o`).unmount(`${ROOT_B}/m`); + assert.deepEqual(placeIn(coveredIn, '/srv/accounts.json'), { exists: true, refusal: null }, 'a covered mount in it: unmounted as its refusal says, purged'); + // In a mount in it, two at different depths, one covering the other: a bind at /m/n/x/y, in the one at /m/n, then one + // at /m/n/x, in /m/n too, which covers it and holds one at /m/n/x/z. The mounts in /m/n are named the + // one that covers first (the outermost), each after the mounts in it, then /m/n — the cases above order the mounts in + // /m alone, and give a mount in them one mount at most, so the order of the mounts in a mount in it went untested, and + // named the covered one first, on real mounts `umount /m/n/x/y` said no mount point specified, /m/n and + // /m stayed busy, and the same refusal came back. + const deepIn = betaVolume({ proc: true }).dir('/srv6').dir('/srv7').dir('/srv8').file('/srv8/h').dir('/srv9').dir('/srv10').bind('/srv6', `${ROOT_B}/m`) + .bind('/srv7', `${ROOT_B}/m/n`).bind('/srv8', `${ROOT_B}/m/n/x/y`).bind('/srv9', `${ROOT_B}/m/n/x`).bind('/srv10', `${ROOT_B}/m/n/x/z`); + assert.throws(() => deepIn.unmount(`${ROOT_B}/m/n/x/y`), { code: 'EINVAL' }, 'covered by /m/n/x: not mounted'); + assert.throws(() => deepIn.unmount(`${ROOT_B}/m/n`), { code: 'EBUSY' }, 'the mounts in /m/n keep it busy'); + assert.deepEqual(placeIn(deepIn, '/srv/accounts.json'), + { exists: null, refusal: mounted(busy(`${ROOT_B}/m`, `${ROOT_B}/m/n/x/z, then ${ROOT_B}/m/n/x, then ${ROOT_B}/m/n/x/y, then ${ROOT_B}/m/n`)) }, + 'in a mount in it, the one that covers first, after the mount in it, then the covered one, then the mount they are in'); + deepIn.unmount(`${ROOT_B}/m/n/x/z`).unmount(`${ROOT_B}/m/n/x`); + assert.deepEqual(placeIn(deepIn, '/srv/accounts.json'), { exists: null, refusal: mounted(busy(`${ROOT_B}/m`, `${ROOT_B}/m/n/x/y, then ${ROOT_B}/m/n`)) }, + 'part-way: the covered one, mounted now, then the mount it is in'); + deepIn.unmount(`${ROOT_B}/m/n/x/y`).unmount(`${ROOT_B}/m/n`).unmount(`${ROOT_B}/m`); + assert.deepEqual(placeIn(deepIn, '/srv/accounts.json'), { exists: true, refusal: null }, 'in a mount in it: unmounted as its refusal says, purged'); + assert.deepEqual(deepIn.fs.readdirSync('/srv8'), ['h'], 'the covered mount\'s directory\'s files stay'); + const onTmpfs = betaVolume({ proc: true }).dir('/tfs', { dev: 47n }).bind('/tfs', `${ROOT_B}/m`).dir('/srv7').bind('/srv7', `${ROOT_B}/m/n`); + assert.deepEqual(placeIn(onTmpfs, '/srv/accounts.json'), { exists: null, refusal: mounted(busy(`${ROOT_B}/m`, `${ROOT_B}/m/n`, `another filesystem than ${ROOT_B}`)) }, + 'a tmpfs on /m holding a mount'); + onTmpfs.unmount(`${ROOT_B}/m/n`).unmount(`${ROOT_B}/m`); + assert.deepEqual(placeIn(onTmpfs, '/srv/accounts.json'), { exists: true, refusal: null }, 'a tmpfs on /m: unmounted as its refusal says, purged'); + // A mount stacked on /m: `umount /m` 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. + const stack = betaVolume({ proc: true }).dir('/srv6').dir('/srv7').dir('/srv8').dir('/srv9').bind('/srv6', `${ROOT_B}/m`).bind('/srv7', `${ROOT_B}/m/n`) + .bind('/srv8', `${ROOT_B}/m`); + assert.deepEqual(placeIn(stack, '/srv/accounts.json'), { exists: null, refusal: mounted(same(`${ROOT_B}/m`)) }, 'a stack on /m, nothing in its topmost'); + stack.bind('/srv9', `${ROOT_B}/m/q`); + assert.deepEqual(placeIn(stack, '/srv/accounts.json'), { exists: null, refusal: mounted(busy(`${ROOT_B}/m`, `${ROOT_B}/m/q`)) }, 'a stack on /m: the mount in its topmost'); + stack.unmount(`${ROOT_B}/m/q`).unmount(`${ROOT_B}/m`); + assert.deepEqual(placeIn(stack, '/srv/accounts.json'), { exists: null, refusal: mounted(busy(`${ROOT_B}/m`, `${ROOT_B}/m/n`)) }, 'the topmost off: the lower mount\'s'); + stack.unmount(`${ROOT_B}/m/n`).unmount(`${ROOT_B}/m`); + assert.deepEqual(placeIn(stack, '/srv/accounts.json'), { exists: true, refusal: null }, 'a stack on /m: unmounted as its refusal says, purged'); +}); + +test('purge-org users file: a directory under the root this user cannot read is refused, nothing deleted, and read by a user who can, the purge passes; one it may list but not search (r--) is refused naming the entry it cannot read; one a stopped FUSE server cannot answer for is refused naming unmounting it', () => { + const vol = betaVolume().dir(`${ROOT_B}/runs/locked`).file(`${ROOT_B}/runs/locked/r.json`).deny(`${ROOT_B}/runs/locked`); + assert.deepEqual(placeIn(vol, '/srv/accounts.json'), { + exists: null, + refusal: `${ROOT_B}/runs/locked under ${ROOT_B} cannot be read (EACCES), so purge-org cannot tell what deleting ${ROOT_B} takes — nothing ` + + 'was deleted. Run purge-org as a user who can read it; or, if the recorded users file /srv/accounts.json does not lie under ' + + `${ROOT_B}, delete ${ROOT_B} by hand as such a user, then run purge-org again: it drops org beta's records`, + }); + const reader = betaVolume().dir(`${ROOT_B}/runs/locked`).file(`${ROOT_B}/runs/locked/r.json`); + assert.deepEqual(placeIn(reader, '/srv/accounts.json'), { exists: true, refusal: null }, 'the way out: a user who can read it'); + // A directory it may list but not search (r--): readdirSync names its entries but lstatSync of each fails (rmSync fails + // there too) — refused naming the entry it cannot read. + const listed = betaVolume().file(`${ROOT_B}/runs/listed/r.json`).deny(`${ROOT_B}/runs/listed`, 'EACCES', { list: true }); + assert.deepEqual(placeIn(listed, '/srv/accounts.json'), { + exists: null, + refusal: `${ROOT_B}/runs/listed/r.json under ${ROOT_B} cannot be read (EACCES), so purge-org cannot tell what deleting ${ROOT_B} takes — ` + + 'nothing was deleted. Run purge-org as a user who can read it; or, if the recorded users file /srv/accounts.json does not lie under ' + + `${ROOT_B}, delete ${ROOT_B} by hand as such a user, then run purge-org again: it drops org beta's records`, + }); + // A FUSE mount under the root whose server stopped answering is no permission matter: its way out is to unmount it. + const stale = betaVolume().dir(`${ROOT_B}/sshfs`).deny(`${ROOT_B}/sshfs`, 'ENOTCONN'); + assert.equal(placeIn(stale, '/srv/accounts.json').refusal, `${ROOT_B}/sshfs under ${ROOT_B} cannot be read (ENOTCONN), so purge-org cannot ` + + `tell what deleting ${ROOT_B} takes — nothing was deleted. If it is a filesystem mounted there that stopped answering, unmount it, then ` + + `run purge-org again; or, if the recorded users file /srv/accounts.json does not lie under ${ROOT_B}, delete ${ROOT_B} by hand, then run ` + + 'purge-org again: it drops org beta\'s records'); +}); + +test('purge-org users file: a directory under the root that changes while the walk reads it — the directory holding the users file renamed from one not read yet into one read already, or into the root — is refused, nothing deleted, and run again with nothing changing it, the walk finds the users file; so is a change that leaves the names as they were (by its change time), and, on a coarse clock, a change that leaves the change time (by the names) and a mount over a directory read already (by its inode)', () => { + // The users file recorded as /mnt/b/users.json, /mnt/b a bind mount of /z/sub: its bytes lie under the root alone. + // The walk reads , /a, /packs, /sub, /z; another process acts at the nth lstatSync of a path + // (the walk's is the first, the second reading's the second). + const tree = (opts) => betaVolume(opts).dir(`${ROOT_B}/a`).file(`${ROOT_B}/z/sub/users.json`).dir('/mnt/b').bind(`${ROOT_B}/z/sub`, '/mnt/b'); + const during = (vol, file, acts) => { + const seen = new Map(); + const lstatSync = (p, o) => { + seen.set(p, (seen.get(p) ?? 0) + 1); + acts[`${p}#${seen.get(p)}`]?.(); + return vol.fs.lstatSync(p, o); + }; + return placeIn(vol, file, { fs: { ...vol.fs, lstatSync } }); + }; + const changed = (at) => ({ + exists: null, + refusal: `${at === ROOT_B ? ROOT_B : `${at} under ${ROOT_B}`} changed while purge-org read ${at === ROOT_B ? 'it' : ROOT_B} (an entry added, ` + + `removed or renamed in it, or a mount over it), so purge-org cannot tell what deleting ${ROOT_B} takes — nothing was deleted. Stop ` + + `what changes files under ${ROOT_B}, then run purge-org again`, + }); + assert.deepEqual(placeIn(tree(), '/mnt/b/users.json'), { exists: true, refusal: underRoot('/mnt/b/users.json', `${ROOT_B}/z/sub/users.json`) }, + 'nothing changing it: refused'); + for (const coarse of [false, true]) { + // Renamed into /a, read already, before /z is read: in neither listing. Unseen, the purge deleted the users file. + const vol = tree({ coarse }); + assert.deepEqual(during(vol, '/mnt/b/users.json', { [`${ROOT_B}/packs#1`]: () => vol.rename(`${ROOT_B}/z/sub`, `${ROOT_B}/a/sub`) }), + changed(`${ROOT_B}/a`), coarse ? 'a coarse clock: by the names' : 'by the change time and the names'); + assert.deepEqual(placeIn(vol, '/mnt/b/users.json'), { exists: true, refusal: underRoot('/mnt/b/users.json', `${ROOT_B}/a/sub/users.json`) }, + 'the way out: run again with nothing changing it, the walk finds it'); + const into = tree({ coarse }); + assert.deepEqual(during(into, '/mnt/b/users.json', { [`${ROOT_B}/z#1`]: () => into.rename(`${ROOT_B}/z/sub`, `${ROOT_B}/moved`) }), + changed(ROOT_B), 'into the root, read already'); + } + // Renamed into /a while the walk runs, then on into the root, read again already, before /a is read again: /a + // has the names the walk found, and its change time moved. + const twice = tree(); + assert.deepEqual(during(twice, '/mnt/b/users.json', { + [`${ROOT_B}/z#1`]: () => twice.rename(`${ROOT_B}/z/sub`, `${ROOT_B}/a/sub`), + [`${ROOT_B}/a#2`]: () => twice.rename(`${ROOT_B}/a/sub`, `${ROOT_B}/moved`), + }), changed(`${ROOT_B}/a`), 'the same names, by the change time'); + // On a coarse clock, a directory from outside the root mounted over /a once it is read, with the same names: another + // inode. Unseen, it is a mount point under the root the walk did not refuse, and on Node 23 and later rmSync deletes the + // files of /srv5 through it, the users file among them. + const over = betaVolume({ coarse: true }).remove(`${ROOT_B}/sub/users.json`).file(`${ROOT_B}/a/users.json`).file('/srv5/users.json'); + assert.deepEqual(placeIn(over, '/srv5/users.json'), { exists: true, refusal: namesake('/srv5/users.json', [`${ROOT_B}/a/users.json`]) }, + 'nothing mounted: outside the root, refused by the name /a/users.json has (the same names as /srv5 hold)'); + assert.deepEqual(during(over, '/srv5/users.json', { [`${ROOT_B}/packs#1`]: () => over.bind('/srv5', `${ROOT_B}/a`) }), + changed(`${ROOT_B}/a`), 'a mount over a directory read already, by its inode'); +}); + +// A root too large to keep in memory entry by entry, made up as each entry is asked for by an injected filesystem that keeps +// none of it, the users file outside: 'files', 150 directories of 1,000 files and one of 100,000, at /ws/orgs/beta; or +// 'directories', one directory of 100,000 empty directories, the root's path 40 characters long. Each entry's stats as +// node:fs gives them with { bigint: true }, eighteen bigints. The child prints the place and how many lstatSync calls it +// answered. +const HUGE_ROOT = ` +const { posix } = await import('node:path'); +const { usersFilePlace } = await import(${JSON.stringify(pathToFileURL(join(HERE, 'store', 'users-file-place.mjs')).href)}); +const DIRECTORIES = process.argv[1] === 'directories'; +const ROOT = DIRECTORIES ? '/var/lib/observogram/workspace/orgs/beta' : '/ws/orgs/beta'; +const USERS = posix.join(ROOT, '..', '..', 'users.json'); +const [DIRS, FILES, WIDE] = DIRECTORIES ? [0, 0, 100_000] : [150, 1000, 100_000]; +const [IFMT, IFDIR, IFREG, IFLNK] = [0o170000n, 0o040000n, 0o100000n, 0o120000n]; +class BigIntStats { + constructor(dir, ino) { + const ns = 1_760_000_000_000_000_000n + ino * 1_000_003n; + const ms = ns / 1_000_000n; + Object.assign(this, { dev: 2049n, mode: (dir ? IFDIR : IFREG) | 0o644n, nlink: ino - ino + 1n, uid: ino - ino, gid: ino - ino, + rdev: ino - ino, blksize: ino - ino + 4096n, ino, size: dir ? ino - ino + 4096n : ino - ino, blocks: ino - ino, + atimeMs: ms, mtimeMs: ms + 0n, ctimeMs: ms + 1n, birthtimeMs: ms - 1n, atimeNs: ns, mtimeNs: ns + 0n, ctimeNs: ns + 1n, birthtimeNs: ns - 1n }); + } + isFile() { return (this.mode & IFMT) === IFREG; } + isDirectory() { return (this.mode & IFMT) === IFDIR; } + isSymbolicLink() { return (this.mode & IFMT) === IFLNK; } +} +const fail = (code, p) => Object.assign(new Error(code + ': ' + p), { code }); +// The root and the directories it lies in ('/', '/ws', '/ws/orgs', '/ws/orgs/beta'), then /wide. +const above = ROOT.split('/').map((_, i, parts) => parts.slice(0, i + 1).join('/') || '/'); +const dirs = new Map([...above.map((p, i) => [p, BigInt(i + 1)]), [ROOT + '/wide', BigInt(above.length + 1)]]); +const R = new RegExp('^' + ROOT + '/r([0-9]+)(?:/f([0-9]+)[.]json)?$'); +const W = new RegExp('^' + ROOT + '/wide/w([0-9]+)' + (DIRECTORIES ? '' : '[.]json') + '$'); +const RD = new RegExp('^' + ROOT + '/r[0-9]+$'); +let lstats = 0; +const lstatSync = (p) => { + lstats += 1; + if (dirs.has(p)) return new BigIntStats(true, dirs.get(p)); + if (p === USERS) return new BigIntStats(false, BigInt(above.length + 2)); + const r = R.exec(p); + if (r && Number(r[1]) < DIRS && Number(r[2] ?? 0) < FILES) { + return new BigIntStats(r[2] === undefined, r[2] === undefined ? BigInt(100 + Number(r[1])) : BigInt(1_000_000 + Number(r[1]) * FILES + Number(r[2]))); + } + const w = W.exec(p); + if (w && Number(w[1]) < WIDE) return new BigIntStats(DIRECTORIES, BigInt(500_000_000 + Number(w[1]))); + throw fail('ENOENT', p); +}; +const names = (n, name) => Array.from({ length: n }, (_, i) => Buffer.from(name(i))); +const fs = { + lstatSync, + statSync: lstatSync, + readdirSync(p) { + if (p === ROOT) return [...names(DIRS, (i) => 'r' + i), Buffer.from('wide')]; + if (p === ROOT + '/wide') return names(WIDE, (i) => 'w' + i + (DIRECTORIES ? '' : '.json')); + if (RD.test(p)) return names(FILES, (i) => 'f' + i + '.json'); + if (DIRECTORIES && W.test(p)) return []; + throw fail('ENOTDIR', p); + }, + readlinkSync: (p) => { throw fail('EINVAL', p); }, + realpathSync: (p) => p, + readFileSync: (p) => { throw fail('ENOENT', p); }, +}; +const place = usersFilePlace(USERS, ROOT, { fs, path: posix, windows: false, id: 'beta' }); +console.log(JSON.stringify({ place, lstats })); +`; + +test('purge-org users file: the walk keeps nothing of a file it passes, and of a directory\'s entries only the listings of the directories it is in — a root of 250,000 files, 150 directories of 1,000 and one of 100,000, is read in a 32 MB heap, where keeping each entry\'s stats ran out of memory, and one directory of 100,000 empty directories, the root\'s path 40 characters long, in 64 MB; so a users file created, replaced or written while the walk runs is compared with each entry again: refused as lying under the root, as when nothing changes it', () => { + // Kept, the stats of 250,000 entries take some 175 MB: purge-org died, out of memory, where rmSync of the same tree + // completed. Each entry is compared with the users file as the walk reaches it, and a directory's entries are taken from + // its listing one by one as the walk goes, not pushed ahead of it. + // Of a directory the walk keeps its path, device and inode, times and a digest of its names, and it holds the listing of + // each directory it is in: one directory of 100,000 empty directories, the root's path 40 characters long, needs more heap + // than the 250,000 files — 48 MB is too little. + const env = { ...process.env }; + delete env.NODE_OPTIONS; // the heap limit is this child's alone + for (const [shape, heap, entries] of [['files', 32, 250_000], ['directories', 64, 100_000]]) { + const r = spawnSync(process.execPath, [`--max-old-space-size=${heap}`, '--input-type=module', '-e', HUGE_ROOT, shape], { env, encoding: 'utf8', timeout: 120_000 }); + assert.equal(r.status, 0, `${shape}: the child ended ${r.signal ?? r.status}: ${r.stderr.split('\n').find((l) => /heap|Error/.test(l)) ?? r.stderr.slice(0, 400)}`); + const { place, lstats } = JSON.parse(r.stdout); + assert.deepEqual(place, { exists: true, refusal: null }, `${shape}: the users file outside the root: purged`); + assert.ok(lstats > entries, `${shape}: every entry read (${lstats} lstatSync calls)`); + } + // So the users file is placed before the walk and again after it: one created, replaced or written meanwhile — once the + // walk has read /packs, before /sub — is compared with each entry again. Written in place, through an NFS view + // of /sub, within one tick of a coarse clock (its size alone moves) or to the same size (its time alone moves). + const racing = (vol, file, act, write = null) => { + const ino = vol.fs.statSync(`${ROOT_B}/sub/users.json`).ino; + let acted = false; + const as = (s) => (acted && write && s.ino === ino ? { ...s, ...write(s) } : s); + const lstatSync = (p, o) => { + if (!acted && p === `${ROOT_B}/packs`) { acted = true; act(); } + return as(vol.fs.lstatSync(p, o)); + }; + return placeIn(vol, file, { fs: { ...vol.fs, lstatSync, statSync: (p, o) => as(vol.fs.statSync(p, o)) } }); + }; + const viewed = (file) => new RegExp(`^the recorded users file ${esc(file)} lies under ${esc(ROOT_B)}: ${esc(`${ROOT_B}/sub/users.json`)} under it has ` + + 'its size and modification time on another filesystem'); + // Replaced by a hard link to a file under the root that kept its size and time (cp -p): another inode alone. Its two + // names under the root are named in the walk's order, as when nothing changes it. + const replaced = betaVolume(); + const was = replaced.fs.statSync('/srv/accounts.json'); + replaced.file(`${ROOT_B}/sub/users.json`, { mtimeNs: was.mtimeNs }).hardlink(`${ROOT_B}/sub/users.json`, `${ROOT_B}/z.json`); + const twoNames = { exists: true, refusal: underRoot('/srv/accounts.json', `${ROOT_B}/sub/users.json and ${ROOT_B}/z.json`) }; + assert.deepEqual(racing(replaced, '/srv/accounts.json', () => replaced.remove('/srv/accounts.json').hardlink(`${ROOT_B}/sub/users.json`, '/srv/accounts.json')), + twoNames, 'replaced by a hard link to a file under the root'); + assert.deepEqual(placeIn(replaced, '/srv/accounts.json'), twoNames, 'nothing changing it'); + // Replaced by a link to a file of another filesystem with the same inode number (one per filesystem), size and time, while + // a copy under the root kept that size and time: another device alone, and the copy reads as the users file. + const other = betaVolume(); + const its = other.fs.statSync('/srv/accounts.json'); + other.file(`${ROOT_B}/sub/users.json`, { mtimeNs: its.mtimeNs }).file('/srv9/accounts.json', { mtimeNs: its.mtimeNs }) + .view('/srv9', '/mnt/srv9', { dev: 9n, ino: () => its.ino }); + assert.match(racing(other, '/srv/accounts.json', () => other.remove('/srv/accounts.json').symlink('/srv/accounts.json', '/mnt/srv9/accounts.json')).refusal ?? '', + viewed('/srv/accounts.json'), 'replaced by a file of another filesystem with the same inode number'); + const created = betaVolume(); + assert.deepEqual(racing(created, '/srv/new.json', () => created.hardlink(`${ROOT_B}/sub/users.json`, '/srv/new.json')), + { exists: true, refusal: underRoot('/srv/new.json', `${ROOT_B}/sub/users.json`) }, 'created as a hard link to a file under the root'); + for (const [how, write] of [['its size', (s) => ({ size: s.size + 1n })], ['its time', (s) => ({ mtimeNs: s.mtimeNs + 1_000_000_000n })]]) { + const nfs = betaVolume().view(`${ROOT_B}/sub`, '/mnt/nfs', { dev: 41n }); + assert.match(racing(nfs, '/mnt/nfs/users.json', () => {}, write).refusal ?? '', viewed('/mnt/nfs/users.json'), `written in place, ${how} moved`); + } +}); + +test('purge-org users file: a name under the root that is not valid UTF-8 is read as the bytes the filesystem holds — the users file in such a directory, reached through a link, is refused naming it as a POSIX shell\'s $\'…\' spells it (a quote and a backslash escaped), and moved out as that says, the purge passes; below such a name an entry of the users file\'s name, a mount point (also under a root whose path is not ASCII) and a directory it cannot read are refused; with the users file outside, a tree of such names passes', () => { + // node:fs lists a name that is not valid UTF-8 as a string with U+FFFD in place of each such byte (c\xffd as 'c\ufffdd'), a + // name no lookup finds: lstatSync of it fails with ENOENT, the walk took that for an entry gone while it ran, and so read + // nothing of that directory, while rmSync, which lists names as bytes, deleted the users file in it. The fixture spells + // such a byte 0xNN as U+DCNN. + const C = `${ROOT_B}/c\udcffd`; + const shown = (rest = '') => `$'${ROOT_B}/c\\xffd${rest}'`; + const vol = betaVolume().remove(`${ROOT_B}/sub/users.json`).file(`${C}/users.json`).file(`${ROOT_B}/run\udce9/inner/data.json`) + .symlink('/cfg/users.json', `${C}/users.json`).dir('/keep'); + assert.deepEqual(vol.fs.readdirSync(ROOT_B), ['c\ufffdd', 'packs', 'run\ufffd', 'sub'], 'listed as a string, as node:fs lists it'); + assert.throws(() => vol.fs.lstatSync(`${ROOT_B}/c\ufffdd`), { code: 'ENOENT' }, 'a name no lookup finds'); + assert.deepEqual(placeIn(vol, '/cfg/users.json'), { exists: true, refusal: underRoot('/cfg/users.json', shown('/users.json')) }); + // The way out, followed (mv $'/ws/orgs/beta/c\xffd/users.json' /keep/ in a shell): the link then leads under the root, where + // nothing of the users file is left, and the users file outside a tree of such names is purged. + vol.rename(`${C}/users.json`, '/keep/users.json'); + assert.deepEqual(placeIn(vol, '/cfg/users.json'), { exists: false, refusal: null }); + assert.deepEqual(placeIn(vol, '/keep/users.json'), { exists: true, refusal: null }, 'outside, the tree read again unchanged: purged'); + // A quote or a backslash in such a path is escaped as $'…' takes it (\' and \\), so the path named can be typed as it + // stands: mv $'/ws/orgs/beta/it\'s\\c\xffd/users.json' /keep/ moves the users file. Unescaped, the quote ended the $'…'. + const Q = `${ROOT_B}/it's\\c\udcffd`; + vol.file(`${Q}/users.json`).symlink('/cfg/q.json', `${Q}/users.json`); + assert.deepEqual(placeIn(vol, '/cfg/q.json'), { exists: true, refusal: underRoot('/cfg/q.json', `$'${ROOT_B}/it\\'s\\\\c\\xffd/users.json'`) }, + 'a quote and a backslash escaped'); + vol.rename(`${Q}/users.json`, '/keep/q.json'); + assert.deepEqual(placeIn(vol, '/cfg/q.json'), { exists: false, refusal: null }, 'moved out as that says: purged'); + // Below such a name, an entry of the users file's name: a mount that is down keeps what was written in its mount point + // meanwhile (the users file recorded as /mnt/b/users.json, /mnt/b a bind mount of /c\xffd that is down, a users.json + // written there), and mounted again the path leads to that entry. Refused by its name; moved out as that says, the purge + // passes. Passed, the purge deleted it. + vol.file('/mnt/b/users.json').file(`${C}/users.json`); + assert.deepEqual(placeIn(vol, '/mnt/b/users.json'), { exists: true, refusal: namesake('/mnt/b/users.json', [shown('/users.json')]) }, + 'an entry of its name below such a name'); + vol.rename(`${C}/users.json`, '/keep/b.json'); + assert.deepEqual(placeIn(vol, '/mnt/b/users.json'), { exists: true, refusal: null }, 'moved out as that says: purged'); + // Below such a name: a directory from another filesystem mounted there, a bind mount of one of the same filesystem that + // /proc/self/mountinfo lists (the kernel writes the name's bytes, read as UTF-8 as the walk's own path is), a directory it + // cannot read. Unmounted, the purge passes. + const mnt = betaVolume({ proc: true }).dir('/srv5', { dev: 7n }).file('/srv5/accounts.json').dir('/srv6').file('/srv6/data.json') + .dir(`${C}/m`).bind('/srv5', `${C}/m`); + assert.deepEqual(placeIn(mnt, '/srv5/accounts.json'), { exists: null, refusal: mounted(`${shown('/m')} (another filesystem than ${shown()})`) }); + mnt.unmount(`${C}/m`).bind('/srv6', `${C}/m6`); + assert.deepEqual(placeIn(mnt, '/srv5/accounts.json'), + { exists: null, refusal: mounted(`${shown('/m6')} (the same filesystem as ${shown()}, listed in /proc/self/mountinfo)`) }); + mnt.unmount(`${C}/m6`); + assert.deepEqual(placeIn(mnt, '/srv5/accounts.json'), { exists: true, refusal: null }, 'unmounted: purged'); + // Under a root whose path is not ASCII (a workspace at /srv/données): a path held as bytes is longer than the root's + // characters, and the bind mount below such a name that /proc/self/mountinfo lists is refused all the same. Missed, the + // walk entered it as a directory and the purge went on to rmSync, which failed there with EBUSY (Node 22). + const RU = '/srv/données/orgs/beta'; + const accented = mountFs(posix, { proc: true }).dir(`${RU}/packs`).file('/srv/accounts.json').dir('/srv6').file('/srv6/data.json') + .dir(`${RU}/c\udcffd/m6`).bind('/srv6', `${RU}/c\udcffd/m6`); + const placeU = () => usersFilePlace('/srv/accounts.json', RU, { fs: accented.fs, path: posix, windows: false, id: 'beta' }); + assert.deepEqual(placeU(), { + exists: null, + refusal: mounted(`$'${RU}/c\\xffd/m6' (the same filesystem as $'${RU}/c\\xffd', listed in /proc/self/mountinfo)`, RU), + }, 'under a root whose path is not ASCII'); + accented.unmount(`${RU}/c\udcffd/m6`); + assert.deepEqual(placeU(), { exists: true, refusal: null }, 'unmounted as its refusal says: purged'); + mnt.dir(`${C}/locked`).deny(`${C}/locked`); + assert.equal(placeIn(mnt, '/srv5/accounts.json').refusal, `${shown('/locked')} under ${ROOT_B} cannot be read (EACCES), so purge-org cannot ` + + `tell what deleting ${ROOT_B} takes — nothing was deleted. Run purge-org as a user who can read it; or, if the recorded users file ` + + `/srv5/accounts.json does not lie under ${ROOT_B}, delete ${ROOT_B} by hand as such a user, then run purge-org again: it drops org beta's records`); +}); + +test('purge-org users file on Windows paths: through a junction, through a share of a directory under the root (the volume serial\'s low 32 bits, all a share reports), and a volume mounted in a folder under the root', () => { + const RW = 'C:\\ws\\orgs\\beta'; + const C = 0x1_0000_0007n; // a local stat may report all 64 bits of the serial; a share only the low 32 + const vol = mountFs(win32, { fold: true, devs: { 'C:\\': C, 'D:\\': 9n } }).file(`${RW}\\sub\\users.json`).file('C:\\out\\accounts.json') + .symlink('C:\\cfg\\d', `${RW}\\sub`) + .view(`${RW}\\sub`, '\\\\myhost\\betasub\\', { dev: C & 0xffffffffn, mtimeNs: (t) => t - (t % 100n) }); + const placeW = (file, opts = {}) => usersFilePlace(file, RW, { fs: vol.fs, path: win32, windows: true, id: 'beta', ...opts }); + for (const file of ['C:\\cfg\\d\\users.json', '\\\\myhost\\betasub\\users.json', 'c:\\WS\\orgs\\BETA\\sub\\users.json']) { + assert.deepEqual(placeW(file), { exists: true, refusal: underRoot(file, `${RW}\\sub\\users.json`, RW) }, `the same volume and file id: ${file}`); + } + assert.deepEqual(placeW('C:\\out\\accounts.json'), { exists: true, refusal: null }); + // The share's serial is the volume's: a copy under the root that kept the size and time is another file on it (another id). + vol.file(`${RW}\\copy\\users.json`, { size: 23n, mtimeNs: vol.fs.statSync('C:\\out\\accounts.json').mtimeNs }) + .view('C:\\out', '\\\\myhost\\out\\', { dev: C & 0xffffffffn }); + assert.deepEqual(placeW('\\\\myhost\\out\\accounts.json'), { exists: true, refusal: null }, 'one volume through the share: compared by file id'); + assert.match(placeW('\\\\myhost\\out\\accounts.json', { windows: false }).refusal, /has its size and modification time on another filesystem/, + 'with all 64 bits compared, the share read as another filesystem and the copy as a view of it'); + vol.file('D:\\v\\x.txt').mountFolder('D:\\v', `${RW}\\mnt`); + assert.match(placeW('C:\\out\\accounts.json').refusal, /^C:\\ws\\orgs\\beta holds a mount point, C:\\ws\\orgs\\beta\\mnt \(another volume mounted in it\): /); +}); + +test('purge-org users file on Windows paths, on either libuv: a root reached through a junction to its workspace\'s orgs, on its own volume or another, is no mount point, nor is a junction under the root, while a volume mounted in a folder under the root, or on the root, is one — a stat by name on libuv 1.49.0 to 1.50.x (Node 22.16.0) reports no volume serial, and the root through a junction was refused as a mount point', () => { + // libuv answers a stat by the path's name (GetFileInformationByName) unless its last name is a reparse point — a junction, + // a symlink, a folder a volume is mounted in — which it opens, reading the volume's serial (FILE_FS_VOLUME_INFORMATION), as + // it reads a handle's (fstatSync). 1.49.0 to 1.50.x read the device of a stat by name from the upper half of the file's + // 128-bit id, 0 on NTFS; 1.51.0 and later the serial, as through a handle. + const C = 7n; + const D = 9n; + const E = 11n; + for (const [libuv, fastDev] of [['1.49.2', 0n], ['1.51.0', null]]) { + const vol = mountFs(win32, { fold: true, devs: { 'C:\\': C, 'D:\\': D, 'E:\\': E }, fastDev }) + .file('C:\\data\\orgs\\acme\\sub\\users.json').file('D:\\orgs\\acme\\sub\\users.json').file('C:\\out\\accounts.json').dir('C:\\data\\w2').dir('C:\\data\\w3') + .symlink('C:\\data\\w2\\orgs', 'C:\\data\\orgs').symlink('C:\\data\\w3\\orgs', 'D:\\orgs') + .symlink('C:\\data\\orgs\\acme\\out', 'C:\\out').symlink('D:\\orgs\\acme\\out', 'C:\\out'); // junctions under the roots, leading out + const placeW = (file, root) => usersFilePlace(file, root, { fs: vol.fs, path: win32, windows: true, id: 'acme' }); + // The workspace's orgs a directory (C:\data), a junction to one on the volume it lies on (C:\data\w2), as the Windows CI + // leg's test made it, and to one on another volume (C:\data\w3 → D:\orgs): the root lies on the volume the junction leads to. + for (const base of ['C:\\data', 'C:\\data\\w2', 'C:\\data\\w3']) { + const root = `${base}\\orgs\\acme`; + const file = `${root}\\sub\\users.json`; + assert.deepEqual(placeW(file, root), { exists: true, refusal: underRoot(file, file, root) }, `libuv ${libuv}: ${root}`); + assert.deepEqual(placeW('C:\\out\\accounts.json', root), { exists: true, refusal: null }, `libuv ${libuv}: ${root}, its junction out no mount point`); + } + // A volume mounted in a folder under the root, and on the root itself (a volume per org), on either libuv. + vol.file('E:\\v\\x.txt').mountFolder('E:\\v', 'C:\\data\\orgs\\acme\\mnt'); + assert.match(placeW('C:\\out\\accounts.json', 'C:\\data\\w2\\orgs\\acme').refusal, + /^C:\\data\\w2\\orgs\\acme holds a mount point, C:\\data\\w2\\orgs\\acme\\mnt \(another volume mounted in it\): /, `libuv ${libuv}`); + vol.file('E:\\beta\\packs\\a.yaml').mountFolder('E:\\beta', 'C:\\data\\orgs\\beta'); + assert.equal(placeW('C:\\out\\accounts.json', 'C:\\data\\w2\\orgs\\beta').refusal, 'C:\\data\\w2\\orgs\\beta (another volume mounted in it) is a mount ' + + 'point: deleting it would take the files of the filesystem mounted there and then fail on the mount point itself (Node 23 and later; ' + + 'Node 22 fails at once) — nothing was deleted. Unmount it, then run purge-org again: it deletes what is left at ' + + 'C:\\data\\w2\\orgs\\beta and drops org acme\'s records, and the unmounted filesystem\'s files stay', `libuv ${libuv}`); + assert.equal(vol.opened(), 0, `libuv ${libuv}: every handle closed`); + } +}); + +test('purge-org users file that does not exist: through a mount that is down it is refused, failing closed — recorded, or, none recorded, /users.json a link there; mounted again, refused as lying under the root; moved out of the root as that refusal says, the purge passes', () => { + const vol = betaVolume().dir('/mnt/b').symlink('/cfg/users.json', '/mnt/b/users.json').symlink('/ws/users.json', '/mnt/b/users.json'); + const byDefault = (text) => text.replace('the recorded users file /ws/users.json', + 'the users file /ws/users.json (the one a pre-store build reads: OBSERVOGRAM_USERS_FILE was not set at the import)'); + // The bytes are under the root; the mount that leads there is down, so its mount point is an empty directory. + for (const [file, to] of [['/mnt/b/users.json', null], ['/cfg/users.json', '/mnt/b/users.json']]) { + assert.deepEqual(placeIn(vol, file), { exists: null, refusal: unknownPlace(file, '/mnt/b', { to }) }, file); + } + // None recorded, /users.json a link through that mount is no absent default: taken for one, the purge deleted + // /sub/users.json behind the mount that is down. + assert.deepEqual(placeIn(vol, '/ws/users.json', { recorded: false }), + { exists: null, refusal: byDefault(unknownPlace('/ws/users.json', '/mnt/b', { to: '/mnt/b/users.json' })) }, '/ws/users.json, none recorded'); + vol.bind(`${ROOT_B}/sub`, '/mnt/b'); // the way out: mount it, then run purge-org again + assert.deepEqual(placeIn(vol, '/mnt/b/users.json'), { exists: true, refusal: underRoot('/mnt/b/users.json', `${ROOT_B}/sub/users.json`) }); + assert.deepEqual(placeIn(vol, '/ws/users.json', { recorded: false }), + { exists: true, refusal: byDefault(underRoot('/ws/users.json', `${ROOT_B}/sub/users.json`)) }, '/ws/users.json, none recorded, mounted'); + vol.rename(`${ROOT_B}/sub/users.json`, '/keep/users.json'); // that refusal's way out: move it out, then run purge-org again + for (const [file, opts] of [['/mnt/b/users.json', {}], ['/cfg/users.json', {}], ['/ws/users.json', { recorded: false }]]) { + assert.deepEqual(placeIn(vol, file, opts), { exists: false, refusal: null }, `${file} leads under the root, where nothing of it is left`); + } +}); + +test('purge-org users file that exists outside the root: through a mount that is down whose mount point holds a file of its name meanwhile, an entry under the root with that name is refused — named directly, through a file link, through a link of another name, in another case or Unicode normalization, and, none recorded, /users.json a link there — and one that is a link names what it leads to under the root too; mounted again, refused as lying under the root; moved out as either refusal says, the purge passes', () => { + // The users file recorded as /mnt/b/users.json, /mnt/b a bind mount of /sub that is down, and a users.json written + // in its mount point meanwhile: the path leads to that file now, and to /sub/users.json once mounted again. Taken + // for a users file outside the root, the purge deleted /sub/users.json. + const vol = mountFs(posix).dir(`${ROOT_B}/packs`).file(`${ROOT_B}/packs/b1.pack.yaml`).file(`${ROOT_B}/sub/users.json`, { size: 300n }) + .file('/mnt/b/users.json', { size: 40n }).file('/mnt/c/Users.json').symlink('/cfg/users.json', '/mnt/b/users.json') + .symlink('/cfg/x.json', '/mnt/b/users.json').symlink('/ws/users.json', '/mnt/b/users.json'); + const sub = [`${ROOT_B}/sub/users.json`]; + for (const file of ['/mnt/b/users.json', '/cfg/users.json', '/cfg/x.json', '/mnt/c/Users.json']) { + assert.deepEqual(placeIn(vol, file), { exists: true, refusal: namesake(file, sub) }, file); + } + assert.deepEqual(placeIn(vol, '/ws/users.json', { recorded: false }), { + exists: true, + refusal: namesake('/ws/users.json', sub).replace('the recorded users file /ws/users.json', + 'the users file /ws/users.json (the one a pre-store build reads: OBSERVOGRAM_USERS_FILE was not set at the import)'), + }, '/ws/users.json, none recorded'); + // The same name in another Unicode normalization (macOS writes names decomposed, NFD): a filesystem may look it up as one. + const nfd = mountFs(posix).dir(`${ROOT_B}/packs`).file(`${ROOT_B}/sub/caf\u00e9.json`).file('/mnt/b/cafe\u0301.json'); + assert.deepEqual(placeIn(nfd, '/mnt/b/cafe\u0301.json'), + { exists: true, refusal: namesake('/mnt/b/cafe\u0301.json', [`${ROOT_B}/sub/caf\u00e9.json`]) }); + // A link of that name under the root: mounted again, the path leads through it to what it names, which rmSync deletes + // while the link moves out — named too. + const linked = mountFs(posix).dir(`${ROOT_B}/packs`).file(`${ROOT_B}/data/real.json`).symlink(`${ROOT_B}/sub/users.json`, '../data/real.json') + .file('/mnt/b/users.json'); + assert.deepEqual(placeIn(linked, '/mnt/b/users.json'), + { exists: true, refusal: namesake('/mnt/b/users.json', [`${ROOT_B}/sub/users.json`], { targets: [`${ROOT_B}/data/real.json`] }) }); + // The way out by mounting it: the mount hides the file written in its mount point, and the path leads under the root. + vol.bind(`${ROOT_B}/sub`, '/mnt/b'); + assert.deepEqual(placeIn(vol, '/mnt/b/users.json'), { exists: true, refusal: underRoot('/mnt/b/users.json', sub[0]) }, 'mounted again'); + // Moved out of the root, as either refusal says: the purge passes, the mount up or down. + vol.rename(sub[0], '/keep/users.json'); + assert.deepEqual(placeIn(vol, '/mnt/b/users.json'), { exists: false, refusal: null }, 'mounted, moved out: it leads under the root'); + vol.unmount('/mnt/b'); + for (const file of ['/mnt/b/users.json', '/cfg/users.json', '/cfg/x.json', '/mnt/c/Users.json']) { + assert.deepEqual(placeIn(vol, file), { exists: true, refusal: null }, `${file}, unmounted, moved out`); + } + linked.rename(`${ROOT_B}/sub/users.json`, '/keep/users.json').rename(`${ROOT_B}/data/real.json`, '/keep/real.json'); + assert.deepEqual(placeIn(linked, '/mnt/b/users.json'), { exists: true, refusal: null }, 'the link and what it names moved out'); +}); + +test('purge-org users file that does not exist: its path leads into a directory under the root while an entry under the root has its name — the mount point of a bind mount of another directory under the root, holding the users file, that is down — and it is refused naming that entry (for a link of that name, what it leads to too), recorded through a file link, directly or through a directory link; with the mount up it is refused as a mount point, naming the users file under the root to move out with the unmounting, and so it is with a directory from outside the root mounted there whose link of the users file\'s name leads to a file under the root (unmounted as the mount point\'s refusal said, it was purged, the file deleted); moved out as either refusal says, the purge passes, and on the real filesystem through purgeOrg the moved file stays', async () => { + // /other bind-mounted on /sub/m, and the users file, /other/users.json, recorded through the mount. Up, + // the mount is refused as a mount point, naming the users file under the root too; unmounted, as that refusal said, + // /sub/m is an empty directory the walk read, and the purge went on and deleted the users file behind it: the path + // leads into a directory under the root, which passed without the name check. + const sub = `${ROOT_B}/sub/m`; + const behind = `${ROOT_B}/other/users.json`; + const point = `${sub} (the same filesystem as ${ROOT_B}/sub, listed in /proc/self/mountinfo)`; + const bound = () => mountFs(posix, { proc: true }).dir(`${ROOT_B}/packs`).file(`${ROOT_B}/packs/b1.pack.yaml`).dir(sub).file(behind) + .symlink('/cfg/users.json', `${sub}/users.json`).symlink('/cfg/d', sub).bind(`${ROOT_B}/other`, sub); + const vol = bound(); + const files = ['/cfg/users.json', `${sub}/users.json`, '/cfg/d/users.json']; + for (const file of files) { + assert.deepEqual(placeIn(vol, file), { exists: true, refusal: mountedHolding(point, file, behind) }, `${file}, mounted`); + // Followed literally: moved out and unmounted, the purge passes. + const followed = bound().rename(behind, '/keep/users.json').unmount(sub); + assert.deepEqual(placeIn(followed, file), { exists: false, refusal: null }, `${file}: moved out and unmounted as the refusal says`); + } + // A directory from outside the root mounted there instead, its users.json a link to a file under the root that holds the + // users file's bytes (/src bind-mounted on /sub/m, /src/users.json → /x/data.json): no entry under the root has + // the users file's name, so unmounted as the mount point's refusal said, the path led into the empty mount point and the + // purge deleted /x/data.json. Up, the refusal names it to move out with the unmounting; followed, the purge passes + // and the moved file stays. Unmounted before purge-org ever saw the mount up, nothing under the root tells (declared). + const data = `${ROOT_B}/x/data.json`; + const outside = () => mountFs(posix, { proc: true }).dir(`${ROOT_B}/packs`).file(`${ROOT_B}/packs/b1.pack.yaml`).dir(sub).file(data) + .symlink('/src/users.json', data).symlink('/cfg/users.json', `${sub}/users.json`).bind('/src', sub); + for (const file of [`${sub}/users.json`, '/cfg/users.json']) { + const up = outside(); + assert.equal(up.fs.statSync(file).ino, up.fs.statSync(data).ino, `${file}: mounted, the record leads to ${data}`); + assert.deepEqual(placeIn(up, file), { exists: true, refusal: mountedHolding(point, file, data) }, `${file}, mounted from outside`); + up.rename(data, '/keep/data.json').unmount(sub); + assert.deepEqual(placeIn(up, file), { exists: false, refusal: null }, `${file}: moved out and unmounted as the refusal says`); + assert.ok(up.fs.statSync('/keep/data.json').isFile(), `${file}: the moved file stays`); + assert.deepEqual(placeIn(outside().unmount(sub), file), { exists: false, refusal: null }, `${file}: unmounted first, nothing tells (declared)`); + } + vol.unmount(sub); + for (const file of files) { + assert.deepEqual(placeIn(vol, file), { exists: false, refusal: namesakeMissing(file, sub, [behind]) }, `${file}, unmounted`); + } + // A link of that name under the root names what it leads to there too, which rmSync deletes once the link is moved out. + const linked = mountFs(posix).dir(`${ROOT_B}/packs`).dir(sub).file(`${ROOT_B}/data/real.json`).symlink(behind, '../data/real.json'); + assert.deepEqual(placeIn(linked, `${sub}/users.json`), + { exists: false, refusal: namesakeMissing(`${sub}/users.json`, sub, [behind], { targets: [`${ROOT_B}/data/real.json`] }) }); + // Moved out as the refusal says: the path leads into a directory under the root where nothing of it is, and no entry has its name. + vol.rename(behind, '/keep/users.json'); + for (const file of files) assert.deepEqual(placeIn(vol, file), { exists: false, refusal: null }, `${file}, moved out`); + linked.rename(behind, '/keep/users.json').rename(`${ROOT_B}/data/real.json`, '/keep/real.json'); + assert.deepEqual(placeIn(linked, `${sub}/users.json`), { exists: false, refusal: null }, 'the link and what it names moved out'); + + // On the real filesystem, through purgeOrg — no mount needed: what that mount leaves once it is down, /sub/m an empty + // directory and the users file at /other/users.json, recorded as /sub/m/users.json and through a directory link. + const base = await removedOrgDeployment(); + const root = join(base, 'orgs', 'acme'); + const m = join(root, 'sub', 'm'); + const usersAt = join(root, 'other', 'users.json'); + mkdirSync(m, { recursive: true }); + usersJson(base, ['alice'], usersAt); + const cfg = tempDir('ops-cfg'); + symlinkSync(m, join(cfg, 'd'), 'junction'); // a junction needs no privilege on Windows; a symlink elsewhere + const filesBefore = readdirSync(root, { recursive: true }).sort(); + for (const usersFile of [join(m, 'users.json'), join(cfg, 'd', 'users.json')]) { + await change(base, (db) => tx(db, () => meta.putMeta(db, 'users_file', usersFile))); + await assert.rejects(purge(base, 'acme'), refusedOp(new RegExp(`^${esc(namesakeMissing(usersFile, m, [usersAt], { root }))}$`)), usersFile); + assert.deepEqual(readdirSync(root, { recursive: true }).sort(), filesBefore, 'nothing deleted'); + } + const keep = join(tempDir('ops-keep'), 'users.json'); + renameSync(usersAt, keep); + assert.equal((await purge(base, 'acme')).deleted, true); + assert.equal(existsSync(root), false); + assert.deepEqual(Object.keys(JSON.parse(readFileSync(keep, 'utf8')).users), ['alice'], 'the moved users file stays'); +}); + +test('purge-org users file that does not exist: recorded under the root and moved out, or reached by a link whose target is gone from the root, or by a chain of 40 links (Linux\'s limit), the purge passes; so does one whose path leads only through directories the root lies in, by its real path, or that its path as named is looked up in (the workspace or its orgs/ reached through a link in a directory the root does not lie in), to one where the rest does not exist (a sibling org\'s directory purged before), while no entry under the root has its name; one whose place lies elsewhere — a file deleted outside the root, a link to a mount that is down (also by a link into the root and on by a link under it, the rest of the path kept after each), a mount point that is down on another filesystem whose directory has the inode number of one under the root but another modification time, a path no part of which exists, a path through a directory neither the root nor its path lies in, or one whose name an entry under the root has — is refused, failing closed; none recorded and /users.json absent, the purge passes', () => { + const vol = betaVolume().symlink('/cfg/users.json', `${ROOT_B}/sub/users.json`).symlink('/cfg/gone', `${ROOT_B}/gone`) + .symlink('/cfg/rel', '../ws/orgs/beta/sub/x/..').dir('/mnt/b').symlink('/cfg/m.json', '/mnt/b/users.json') + .symlink('/ws/s.json', 'orgs/beta2/users.json').symlink('/cfg/s.json', '/ws/orgs/beta2/users.json').symlink('/cfg/d', '/ws/orgs') + .symlink('/lnk', '/ws').symlink('/cfg/ds', `${ROOT_B}/sub`).symlink(`${ROOT_B}/sub/l`, '/mnt/b'); + // A chain of 40 links, as many as Linux follows in one path, to where the users file lies under the root. + for (let i = 1; i < 40; i += 1) vol.symlink(`/cfg/hop${i}`, `/cfg/hop${i + 1}`); + vol.symlink('/cfg/hop40', `${ROOT_B}/sub/users.json`); + assert.equal(placeIn(vol, `${ROOT_B}/sub/users.json`).refusal, underRoot(`${ROOT_B}/sub/users.json`, `${ROOT_B}/sub/users.json`), 'recorded under the root: refused'); + vol.rename(`${ROOT_B}/sub/users.json`, '/keep/users.json'); + for (const file of [`${ROOT_B}/sub/users.json`, '/cfg/users.json', '/cfg/gone/users.json', `${ROOT_B}/sub/deeper/users.json`, '/cfg/rel/users.json', '/cfg/hop1']) { + assert.deepEqual(placeIn(vol, file), { exists: false, refusal: null }, `${file}: moved out, it leads under the root`); + } + assert.deepEqual(placeIn(vol, '/srv/gone.json'), { exists: null, refusal: unknownPlace('/srv/gone.json', '/srv') }, 'deleted outside the root (declared)'); + assert.deepEqual(placeIn(vol, '/cfg/m.json'), { exists: null, refusal: unknownPlace('/cfg/m.json', '/mnt/b', { to: '/mnt/b/users.json' }) }); + // By a link into the root (/cfg/ds → /sub) and on by a link under it (/sub/l → /mnt/b): the rest of the path + // is kept after each absolute link, so it leads to the mount that is down, not to /sub. + assert.deepEqual(placeIn(vol, '/cfg/ds/l/users.json'), + { exists: null, refusal: unknownPlace('/cfg/ds/l/users.json', '/mnt/b', { to: '/mnt/b/users.json' }) }); + // A mount point that is down on another filesystem than the root's (/data/b, on a disk mounted at /data), its directory + // with the inode number of /sub — each filesystem numbers its own inodes — but its own modification time: no view + // of /sub, so nothing tells where the path leads. + const sub = vol.fs.statSync(`${ROOT_B}/sub`); + vol.dir('/data', { dev: 50n }).dir('/data/b', { ino: sub.ino }); + const down = vol.fs.statSync('/data/b'); + assert.deepEqual([down.ino === sub.ino, down.dev === sub.dev, down.mtimeNs === sub.mtimeNs], [true, false, false]); + assert.deepEqual(placeIn(vol, '/data/b/users.json'), { exists: null, refusal: unknownPlace('/data/b/users.json', '/data/b') }); + // Only through directories the root lies in (/, /ws, /ws/orgs), into a sibling org's directory purged before — where the + // users file was moved out of, as its refusal said — or a file deleted there: a filesystem mounted again on any of them + // covers the root too. Named through a link to one of them, and with the root named through a link (by its real path). + for (const file of ['/ws/orgs/beta2/users.json', '/ws/orgs/beta2/sub/users.json', '/ws/gone.json', '/ws/s.json', '/lnk/orgs/beta2/users.json']) { + assert.deepEqual(placeIn(vol, file), { exists: false, refusal: null }, `${file}: through directories the root lies in`); + assert.deepEqual(usersFilePlace(file, '/lnk/orgs/beta', { fs: vol.fs, path: posix, windows: false, id: 'beta' }), { exists: false, refusal: null }, + `${file}, the root named through a link`); + } + // Through a directory the root does not lie in — a link there, to a file or a directory — a filesystem mounted on it now + // down may lead the path anywhere. + assert.deepEqual(placeIn(vol, '/cfg/s.json'), { exists: null, refusal: unknownPlace('/cfg/s.json', '/ws/orgs', { to: '/ws/orgs/beta2/users.json' }) }); + assert.deepEqual(placeIn(vol, '/cfg/d/beta2/users.json'), + { exists: null, refusal: unknownPlace('/cfg/d/beta2/users.json', '/ws/orgs', { to: '/ws/orgs/beta2/users.json' }) }); + // The root named through a link in a directory it does not lie in — the workspace (/home/u/ws → /ws) or its orgs/ + // (/w2/orgs → /ws/orgs): its path is looked up in that directory and those above it too, and a filesystem mounted again + // on one of them hides the link the root's own path is reached through. A path looked up only in those and in the + // directories the root lies in passes. With the root named by its real path, a path through /home/u/ws is looked up in + // directories its path is not (/home, /home/u), and refused; so is one through /w2/orgs, the root named through /home/u/ws. + vol.symlink('/home/u/ws', '/ws').symlink('/w2/orgs', '/ws/orgs'); + const placeAt = (file, root) => usersFilePlace(file, root, { fs: vol.fs, path: posix, windows: false, id: 'beta' }); + for (const [root, file] of [['/home/u/ws/orgs/beta', '/home/u/ws/orgs/beta2/users.json'], ['/home/u/ws/orgs/beta', '/home/u/ws/orgs/beta2/sub/users.json'], + ['/home/u/ws/orgs/beta', '/home/u/ws/gone.json'], ['/home/u/ws/orgs/beta', '/home/u/gone.json'], ['/w2/orgs/beta', '/w2/orgs/beta2/users.json']]) { + assert.deepEqual(placeAt(file, root), { exists: false, refusal: null }, `${file}, the root named ${root}`); + } + assert.deepEqual(placeIn(vol, '/home/u/ws/orgs/beta2/users.json'), + { exists: null, refusal: unknownPlace('/home/u/ws/orgs/beta2/users.json', '/ws/orgs', { to: '/ws/orgs/beta2/users.json' }) }, 'the root named by its real path'); + assert.deepEqual(placeAt('/w2/orgs/beta2/users.json', '/home/u/ws/orgs/beta'), + { exists: null, refusal: unknownPlace('/w2/orgs/beta2/users.json', '/ws/orgs', { to: '/ws/orgs/beta2/users.json', root: '/home/u/ws/orgs/beta' }) }, + 'through a link the root\'s path does not pass'); + // /ws/orgs a bind mount of /x, down now: the users file written through it is /x/beta2/users.json, and an + // entry under the root with its name is refused (a directory under the root mounted above it keeps its names). + vol.file(`${ROOT_B}/x/beta2/users.json`); + assert.deepEqual(placeIn(vol, '/ws/orgs/beta2/users.json'), { exists: null, refusal: unknownPlace('/ws/orgs/beta2/users.json', '/ws/orgs') }); + assert.deepEqual(placeIn(vol, '/ws/s.json'), { exists: null, refusal: unknownPlace('/ws/s.json', '/ws/orgs', { to: '/ws/orgs/beta2/users.json' }) }); + vol.rename(`${ROOT_B}/x/beta2/users.json`, '/keep/x/users.json'); + assert.deepEqual(placeIn(vol, '/ws/orgs/beta2/users.json'), { exists: false, refusal: null }, 'moved out, no entry of its name'); + assert.deepEqual(placeIn(vol, '/ws/users.json', { recorded: false }), { exists: false, refusal: null }, 'the default nothing records, absent: nothing to keep'); +}); + +test('purge-org users file that cannot be read: a directory on its path this user may not search is refused, and as a user who can the purge passes; a link loop is refused; a part of its path that stops answering once statSync found nothing there is refused', () => { + const vol = betaVolume().file('/priv/accounts.json').deny('/priv').symlink('/cfg/a', '/cfg/b').symlink('/cfg/b', '/cfg/a'); + assert.deepEqual(placeIn(vol, '/priv/accounts.json'), { + exists: null, + refusal: `the recorded users file /priv/accounts.json cannot be read on this host (EACCES), so nothing tells whether it lies under ${ROOT_B} — ` + + 'nothing was deleted. Run purge-org as a user who can read /priv/accounts.json; or, if it does not lie under ' + + `${ROOT_B}, delete ${ROOT_B} by hand, then run purge-org again: it drops org beta's records`, + }); + const reader = betaVolume().file('/priv/accounts.json'); + assert.deepEqual(placeIn(reader, '/priv/accounts.json'), { exists: true, refusal: null }, 'the way out: a user who can read it'); + assert.equal(placeIn(vol, '/cfg/a').refusal, `the recorded users file /cfg/a cannot be read on this host (ELOOP: a link loop, or a chain of more ` + + `than 40 links), so nothing tells whether it lies under ${ROOT_B} — nothing was deleted. If it does not lie under ${ROOT_B}, delete ` + + `${ROOT_B} by hand, then run purge-org again: it drops org beta's records`); + assert.match(placeIn(vol, '/priv/accounts.json', { recorded: false }).refusal, /^the users file \/priv\/accounts\.json \(the one a pre-store build reads: OBSERVOGRAM_USERS_FILE was not set at the import\) cannot be read on this host \(EACCES\)/); + // A part of its path that stops answering once statSync found nothing there (a FUSE server stopping between the two + // reads): where the path leads cannot be told — refused naming that part, failing closed. + const stopping = betaVolume().dir('/mnt/f'); + const flaky = { + ...stopping.fs, + lstatSync: (p, o) => { + if (p === '/mnt/f') throw Object.assign(new Error('ENOTCONN: /mnt/f'), { code: 'ENOTCONN' }); + return stopping.fs.lstatSync(p, o); + }, + }; + assert.deepEqual(placeIn(stopping, '/mnt/f/users.json', { fs: flaky }), { + exists: null, + refusal: `the recorded users file /mnt/f/users.json cannot be read on this host (ENOTCONN at /mnt/f), so nothing tells whether it lies under ${ROOT_B} — ` + + `nothing was deleted. If it does not lie under ${ROOT_B}, delete ${ROOT_B} by hand, then run purge-org again: it drops org beta's records`, + }); +}); + +test('purge-org users file that does not exist, on Windows paths: moved out from under the root, by any spelling or through a junction into it, or in a sibling org\'s directory purged before, by any spelling, the purge passes; through a share whose directory is gone, on a drive that does not exist, deleted outside the root, or through a directory whose name differs from one the root lies in by case alone (case-sensitive), refused', () => { + const RW = 'C:\\ws\\orgs\\beta'; + const C = 0x1_0000_0007n; + const vol = mountFs(win32, { fold: true, devs: { 'C:\\': C } }).file(`${RW}\\sub\\users.json`).dir('C:\\out') + .symlink('C:\\cfg\\d', `${RW}\\sub`).view(`${RW}\\sub`, '\\\\myhost\\betasub\\', { dev: C & 0xffffffffn }); + const placeW = (file) => usersFilePlace(file, RW, { fs: vol.fs, path: win32, windows: true, id: 'beta' }); + vol.rename(`${RW}\\sub\\users.json`, 'C:\\keep\\users.json'); + for (const file of [`${RW}\\sub\\users.json`, 'c:/WS/orgs/BETA/sub/users.json', 'C:\\cfg\\d\\users.json', '\\\\myhost\\betasub\\users.json', + 'C:\\ws\\orgs\\acme\\sub\\users.json', 'c:/WS/orgs/ACME/sub/users.json']) { + assert.deepEqual(placeW(file), { exists: false, refusal: null }, `${file}: nothing of it under the root`); + } + assert.deepEqual(placeW('C:\\out\\users.json'), { exists: null, refusal: unknownPlace('C:\\out\\users.json', 'C:\\out', { root: RW }) }); + assert.deepEqual(placeW('Q:\\users.json'), { exists: null, refusal: unknownPlace('Q:\\users.json', null, { root: RW }) }, 'a drive that does not exist'); + // A directory NTFS keeps case-sensitive (per directory): C:\WS beside C:\ws is another directory, which the root does not lie in. + const cased = mountFs(win32, { devs: { 'C:\\': C } }).dir(`${RW}\\packs`).dir('C:\\WS\\orgs'); + assert.deepEqual(usersFilePlace('C:\\WS\\orgs\\acme\\users.json', RW, { fs: cased.fs, path: win32, windows: true, id: 'beta' }), + { exists: null, refusal: unknownPlace('C:\\WS\\orgs\\acme\\users.json', 'C:\\WS\\orgs', { root: RW }) }, 'another directory by case'); +}); + +test('purge-org: a users file recorded outside the root with a hard link under it is refused naming the link, nothing deleted; with the link moved out, the purge passes and the users file stays', async () => { + const base = await removedOrgDeployment(); + const root = join(base, 'orgs', 'acme'); + const srv = tempDir('ops-users'); + const usersFile = join(srv, 'users.json'); + usersJson(srv, ['alice'], usersFile); + await change(base, (db) => tx(db, () => meta.putMeta(db, 'users_file', usersFile))); + linkSync(usersFile, join(root, 'u.json')); + const filesBefore = readdirSync(root, { recursive: true }).sort(); + await assert.rejects(purge(base, 'acme'), refusedOp(new RegExp(`^the recorded users file ${esc(usersFile)} lies under ${esc(root)}: it is ${esc(join(root, 'u.json'))} there — nothing was deleted\\. Move ${esc(join(root, 'u.json'))} out of ${esc(root)}, then run purge-org again\\.`))); + assert.deepEqual(readdirSync(root, { recursive: true }).sort(), filesBefore, 'nothing deleted'); + renameSync(join(root, 'u.json'), join(srv, 'moved.json')); + const r = await purge(base, 'acme'); + assert.equal(r.deleted, true); + assert.equal(existsSync(root), false); + assert.deepEqual(Object.keys(JSON.parse(readFileSync(usersFile, 'utf8')).users), ['alice'], 'the users file stays'); +}); + +test('purge-org: the recorded users file reached through a directory link into the root is refused naming its entry under the root (by its path, realpath placed it under the root too), nothing deleted; moved out as the refusal says, the purge passes', async () => { + const base = await removedOrgDeployment(); + const root = join(base, 'orgs', 'acme'); + const cfg = tempDir('ops-cfg'); + usersJson(join(root, 'sub'), ['alice'], join(root, 'sub', 'users.json')); + symlinkSync(join(root, 'sub'), join(cfg, 'd'), 'junction'); // a junction needs no privilege on Windows; a symlink elsewhere + const usersFile = join(cfg, 'd', 'users.json'); + await change(base, (db) => tx(db, () => meta.putMeta(db, 'users_file', usersFile))); + await assert.rejects(purge(base, 'acme'), refusedOp(new RegExp(`^the recorded users file ${esc(usersFile)} lies under ${esc(root)}: it is ${esc(join(root, 'sub', 'users.json'))} there — nothing was deleted`))); + assert.ok(existsSync(join(root, 'sub', 'users.json')) && existsSync(join(root, 'packs', 'a1.pack.yaml')), 'nothing deleted'); + // The way out, followed: moved out of the root, the record leads through the link to a directory under the root, where + // nothing of the users file is left. + const keep = join(tempDir('ops-keep'), 'users.json'); + renameSync(join(root, 'sub', 'users.json'), keep); + assert.equal((await purge(base, 'acme')).deleted, true); + assert.deepEqual(Object.keys(JSON.parse(readFileSync(keep, 'utf8')).users), ['alice'], 'the moved users file stays'); +}); + +test('purge-org: with no users file recorded, /users.json — the one a pre-store build reads — with a hard link under the root is refused; with the link moved out, the purge passes and /users.json stays', async () => { + const base = await removedOrgDeployment(); + const root = join(base, 'orgs', 'acme'); + await read(base, (db) => assert.equal(meta.getMeta(db, 'users_file'), null, 'none recorded: OBSERVOGRAM_USERS_FILE was not set at the import')); + mkdirSync(join(root, 'sub')); + linkSync(join(base, 'users.json'), join(root, 'sub', 'users.json')); + const entry = join(root, 'sub', 'users.json'); + await assert.rejects(purge(base, 'acme'), refusedOp(new RegExp(`^the users file ${esc(join(base, 'users.json'))} \\(the one a pre-store build reads: OBSERVOGRAM_USERS_FILE was not set at the import\\) lies under ${esc(root)}: it is ${esc(entry)} there — nothing was deleted\\. Move ${esc(entry)} out of ${esc(root)}, then run purge-org again\\.`))); + renameSync(entry, join(tempDir('ops-keep'), 'users.json')); + assert.equal((await purge(base, 'acme')).deleted, true); + assert.deepEqual(Object.keys(JSON.parse(readFileSync(join(base, 'users.json'), 'utf8')).users), ['alice'], '/users.json stays'); +}); + +test('purge-org: the users file recorded under the root is refused naming it, nothing deleted; moved out of the root as the refusal says, the purge passes — it used to refuse again, and again — and the moved file stays', async () => { + const base = await removedOrgDeployment(); + const root = join(base, 'orgs', 'acme'); + const usersFile = join(root, 'sub', 'users.json'); + usersJson(base, ['alice'], usersFile); + await change(base, (db) => tx(db, () => meta.putMeta(db, 'users_file', usersFile))); + await assert.rejects(purge(base, 'acme'), refusedOp(new RegExp(`^${esc(underRoot(usersFile, usersFile, root))}$`))); + assert.ok(existsSync(usersFile) && existsSync(join(root, 'packs', 'a1.pack.yaml')), 'nothing deleted'); + const keep = join(tempDir('ops-keep'), 'users.json'); + renameSync(usersFile, keep); + const r = await purge(base, 'acme'); + assert.equal(r.deleted, true); + assert.equal(existsSync(root), false); + assert.deepEqual(Object.keys(JSON.parse(readFileSync(keep, 'utf8')).users), ['alice'], 'the moved users file stays'); +}); + +test('purge-org: the users file recorded under one org\'s root and moved out as the refusal says, that org purged, a later purge of another org passes — the record leads into the purged root, which no longer exists, through directories the other root lies in, where a filesystem mounted again covers that root too, and with the workspace or its orgs/ reached through a directory link (a junction on Windows) in a directory the roots do not lie in, through that directory too, where one hides the link the root\'s own path is reached through; it used to be refused as a users file that does not exist, for every org purged afterwards (through such a link, also with only the directories on the root\'s real path) — and the moved file stays', async () => { + // The workspace named by its real path; through a link to it in a directory the roots do not lie in (/home/u/ws → + // /data/ws); and with its orgs/ such a link (/data/w2/orgs → /data/orgs). + const d = tempDir('ops-linked'); + for (const dir of [join(d, 'home', 'u'), join(d, 'data', 'ws'), join(d, 'data', 'w2'), join(d, 'data', 'orgs')]) mkdirSync(dir, { recursive: true }); + symlinkSync(join(d, 'data', 'ws'), join(d, 'home', 'u', 'ws'), 'junction'); // a junction needs no privilege on Windows; a symlink elsewhere + symlinkSync(join(d, 'data', 'orgs'), join(d, 'data', 'w2', 'orgs'), 'junction'); + for (const at of [undefined, join(d, 'home', 'u', 'ws'), join(d, 'data', 'w2')]) { + const base = await removedOrgDeployment(at); + const acme = join(base, 'orgs', 'acme'); + const beta = join(base, 'orgs', 'beta'); + const usersFile = join(acme, 'sub', 'users.json'); + usersJson(base, ['alice'], usersFile); + await change(base, (db) => tx(db, () => meta.putMeta(db, 'users_file', usersFile))); + await assert.rejects(purge(base, 'acme'), refusedOp(new RegExp(`^${esc(underRoot(usersFile, usersFile, acme))}$`)), base); + const keep = join(tempDir('ops-keep'), 'users.json'); + renameSync(usersFile, keep); + assert.equal((await purge(base, 'acme')).deleted, true, base); + await change(base, (db) => admin.removeOrgSoft(db, 'cli', 'beta')); + const r = await purge(base, 'beta'); + assert.equal(r.deleted, true, base); + assert.equal(existsSync(beta), false, base); + assert.deepEqual(Object.keys(JSON.parse(readFileSync(keep, 'utf8')).users), ['alice'], `${base}: the moved users file stays`); + } +}); + +test('purge-org: a recorded users file outside the root that no longer exists is refused (declared), naming where its path leads as this host resolves it — through a directory link, as a temporary directory on macOS is (/var → /private/var), not where it is spelled — nothing deleted; with the root deleted by hand as the refusal says, the purge passes and drops the org\'s records; none recorded and /users.json deleted, the purge passes', async () => { + const base = await removedOrgDeployment(); + const root = join(base, 'orgs', 'acme'); + const srv = tempDir('ops-users'); + const linked = join(tempDir('ops-link'), 'srv'); + symlinkSync(srv, linked, 'junction'); // a junction needs no privilege on Windows; a symlink elsewhere + // Where the path leads and its nearest part that exists are named as this host resolves them, every link followed: the + // real path of the users file's directory. Through a link — on macOS the temporary directory is reached through one — + // that is not how the path is spelled, and the refusal names the place it leads to. + for (const usersFile of [join(srv, 'users.json'), join(linked, 'users.json')]) { + const near = realpathSync(dirname(usersFile)); + const place = join(near, 'users.json'); + await change(base, (db) => tx(db, () => meta.putMeta(db, 'users_file', usersFile))); + await assert.rejects(purge(base, 'acme'), refusedOp(new RegExp(`^${esc(`the recorded users file ${usersFile} does not exist, and nothing on this host tells where its path leads${place === usersFile ? '' : ` (to ${place})`}: the nearest part of it that exists, ${near}, is no directory under ${root}`)}.* If it lies on a filesystem that is not mounted now, mount it, then run purge-org again; if it does not lie under ${esc(root)}, delete ${esc(root)} by hand, then run purge-org again: it drops org acme's records$`))); + assert.ok(existsSync(join(root, 'packs', 'a1.pack.yaml')), 'nothing deleted'); + } + const rows = await read(base, (db) => actions(db).length); + rmSync(root, { recursive: true }); + const r = await purge(base, 'acme'); + assert.deepEqual(formatPurge(r)[0], `purged org acme: nothing on disk at ${root}`); + await read(base, (db) => assert.deepEqual(actions(db).slice(rows), ['org.purge:cli:acme'])); + + const oidcOnly = await removedOrgDeployment(); + rmSync(join(oidcOnly, 'users.json')); + assert.equal((await purge(oidcOnly, 'acme')).deleted, true, 'the default users file nothing records, absent: nothing to keep'); +}); + // ---------- audit: the CLI reader ---------- // A flat deployment with two orgs and planted rows: the deployment's by diff --git a/server/test-store-paths.mjs b/server/test-store-paths.mjs index c8c85810..05c4c420 100644 --- a/server/test-store-paths.mjs +++ b/server/test-store-paths.mjs @@ -722,7 +722,7 @@ test('every path comparison in ops.mjs and backup.mjs goes through compared; rea assert.match(ops, /const own = compared\(path\);[^]*?if \(compared\(file\) === own\) continue;/, 'assertNoOtherStore'); assert.match(ops, /const inPlace = compared\(target\) === compared\(base\);\s*if \(!inPlace && compared\(target, base\) === compared\(base\)\) \{/, 'the in-place detection'); assert.match(ops, /const b = compared\(base\);\s*if \(!compared\(path\)\.startsWith\(b \+ sep\)\) \{\s*const placed = compared\(path, base\);\s*if \(placed === null \|\| placed\.startsWith\(b \+ sep\)\) \{/, 'the no-marker database check'); - assert.match(ops, /const users = [^;]*\? compared\(usersFile, root, \{ deleting: true \}\) : undefined;\s*if \(users === null\) \{[^}]*\}\s*if \(users\?\.startsWith\(compared\(root\) \+ sep\)\)/, 'purge-org\'s users file'); + assert.match(ops, /const users = [^;]*\? compared\(usersFile, root, \{ deleting: true \}\) : undefined;\s*if \(users === null\) \{[^}]*\}\s*const place = present \? usersFilePlace\([^;]*\) : null;\s*if \(place\?\.refusal\) throw refuse\(place\.refusal\);\s*if \(users\?\.startsWith\(compared\(root\) \+ sep\) && place\.exists\)/, 'purge-org\'s users file: by path, then by what deleting the root takes (store/users-file-place.mjs), the path check only while the file exists'); // Each such refusal names its way out (its message from the guard to the throw's end, the parts joined). const joined = (file) => readFileSync(join(HERE, 'store', file), 'utf8'); const refusal = (raw, guard) => {