From 49d82ba84cdffe57b3496d61bac386e732f3cfab Mon Sep 17 00:00:00 2001 From: Carlos Montero Date: Wed, 14 Oct 2026 23:38:53 +0200 Subject: [PATCH 1/2] =?UTF-8?q?fix(store):=20on=20Windows=20the=20path=20c?= =?UTF-8?q?omparison=20reads=20a=20path's=20volume=20through=20a=20handle?= =?UTF-8?q?=20=E2=80=94=20on=20Node=2022.16.x=20(libuv=201.49.2)=20a=20pat?= =?UTF-8?q?h=20through=20a=20share=20of=20the=20workspace=20compared=20as?= =?UTF-8?q?=20outside=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit server/store/paths.mjs placed a path reached through a share by a file id it shares with a directory of the path it is checked against, and judged one nothing places by its volume, both read with statSync. libuv answers a stat by the path's name (GetFileInformationByName, Windows 11 24H2 and Server 2025) unless the path is reached through a share or its last name is a reparse point, which it opens (FILE_FS_VOLUME_INFORMATION: the serial). libuv 1.49.0 to 1.50.x declared FILE_STAT_BASIC_INFORMATION with FileId128 before VolumeSerialNumber (fixed in 1.51.0), so a stat by name reported the upper half of the 128-bit file id, 0 on NTFS. A local path and its share then had no file id in common and their volumes never matched: a directory export to \\myhost\ws\exports\new with the workspace at C:\ws was written into the workspace. On Windows a file id and a volume are now read through a handle (openSync, fstatSync, closeSync), which reports the serial on every libuv, as #163 reads a directory's volume in users-file-place.mjs. Every handle is closed before the comparison goes on. server/fixtures/ntfs.mjs models both libuvs (fastDev: 0n for 1.49.2, null for 1.51.0's 64-bit serial by name; a path through a share or ending in a link or reparse point is opened) and returns openSync, fstatSync, closeSync and opened(). Its header no longer says a local stat may report all 64 bits on every libuv. The new test in server/test-store-paths.mjs loops both models. At the parent commit it fails on 1.49.2 (the export through \\myhost\ws was written). Tests: 1495 -> 1496. Co-Authored-By: Claude Opus 5.5 --- docs/CHANGELOG.md | 4 ++ server/fixtures/ntfs.mjs | 55 ++++++++++++++----- server/store/paths.mjs | 38 +++++++++---- server/test-store-paths.mjs | 103 +++++++++++++++++++++++++++++------- 4 files changed, 157 insertions(+), 43 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e0a913ab..ed8e3b72 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Follow-up `paths-volume-through-handle`: on Windows the store's path comparison reads a path's volume through a handle + +- **On Node 22.16.x the store's offline checks place a path through a share again** (a fix; `server/store/paths.mjs`; Linux and macOS compare exactly as before). `comparedPath` placed a path reached through a share by a file id it shares with a directory of the path it is checked against, and judged one nothing places by its volume (the serial's low 32 bits), both read with `statSync`. libuv answers a stat by the path's name (`GetFileInformationByName`, Windows 11 24H2 and Server 2025) unless the path is reached through a share or its last name is a reparse point, which it opens and reads the volume's serial from (`FILE_FS_VOLUME_INFORMATION`); libuv 1.49.0 to 1.50.x (Node 22.16.x ships 1.49.2) declared `FILE_STAT_BASIC_INFORMATION` with `FileId128` before `VolumeSerialNumber`, swapped against Windows' layout (fixed in 1.51.0), so a stat by name reported the upper half of the file's 128-bit id as the device, 0 on NTFS. There a local path and a share of it had no file id in common, and a local volume never matched a share's: with the workspace at `C:\ws`, a directory export to `\\myhost\ws\exports\new` (which is `C:\ws\exports\new`), or to `C:\ws\exports\new` with the workspace named `\\myhost\ws`, compared as outside the workspace and was written into it, and so was one through a share of a directory under it (`\\myhost\exports`, which no file id places and which was refused as lying on the workspace's volume); the in-place detection and backup's own-file check missed the workspace and the database's `-wal` through a share alike, and purge-org refused a users file a share placed outside the root (a false refusal). On Windows a file id and a volume are now read through a handle opened on the path (`openSync`, `fstatSync`, `closeSync`; libuv opens a directory too), which reports the serial on every libuv, every handle closed before the comparison goes on; a path that does not open is passed over as one that did not stat was. `comparedPath` takes `openSync`, `fstatSync` and `closeSync` in place of `statSync`. `server/fixtures/ntfs.mjs` models both libuvs (`fastDev`: the device a stat by name reports — `null`, all 64 bits of the serial as 1.51.0 reads it, or `0n` as 1.49.2 does; a path through a share, or whose last name is a link or another reparse point, is opened, with the serial's low 32 bits) and returns `openSync`, `fstatSync`, `closeSync` and `opened()`; its header no longer says a local stat may report all 64 bits on every libuv. One more test in `server/test-store-paths.mjs` (1495 → 1496 tests): on libuv 1.51.0 and 1.49.2, a directory export through `\\myhost\ws` into `C:\ws`, and one on the drive into the workspace named through it, refused as inside it, the workspace through its share refused as the in-place export, a backup over the database's `-wal` through `\\myhost\dbdir` refused, purge-org refusing a users file placed under the root and purging with one placed beside it, a share nothing places on the workspace's volume refused, one on another volume written, and every handle closed; at the parent commit it fails on 1.49.2 (the export through `\\myhost\ws` was written). Every comparison the suite builds injects the fixture's handles. + ### 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/server/fixtures/ntfs.mjs b/server/fixtures/ntfs.mjs index a471695b..46add298 100644 --- a/server/fixtures/ntfs.mjs +++ b/server/fixtures/ntfs.mjs @@ -11,9 +11,15 @@ // resolves to \\host\share\ and the on-disk rest, never to the volume's own // path; a share the volume does not serve is ENOENT. What statSync reports // names the file whatever path reached it: one file id per file, and the -// volume's serial — of which a share reports only the low 32 bits -// (FILE_FS_VOLUME_INFORMATION's), while a local stat may report all 64 -// (FILE_STAT_BASIC_INFORMATION's). A symbolic link (a directory or file +// volume's serial — of which a handle reports only the low 32 bits +// (FILE_FS_VOLUME_INFORMATION's): fstatSync of one, and a stat libuv answers +// by opening the path, as it does a path through a share or one whose last +// name is a link. A stat of any other path libuv answers by its name +// (GetFileInformationByName, Windows 11 24H2 and Server 2025), from +// FILE_STAT_BASIC_INFORMATION: all 64 bits of the serial on libuv 1.51.0 and +// later, while 1.49.0 to 1.50.x (Node 22.16.0's 1.49.2) declared that +// struct's last two fields swapped and report the upper half of the file's +// 128-bit id instead, 0 on NTFS (fastDev). A symbolic link (a directory or file // symlink, or a junction) is followed to its target by the native realpath // and statSync, wherever it sits in the path; lstatSync follows each but the // last part, so it reports a link itself. Through a link whose target cannot @@ -47,8 +53,9 @@ import { win32 } from 'node:path'; const enoent = (p) => Object.assign(new Error(`ENOENT: no such file or directory, realpath '${p}'`), { code: 'ENOENT' }); /** - * ntfsVolume(files, { short, shares, mapped, links, reparse, unmanaged, unnormalized }) → { realpathSync, statSync, lstatSync } — node:fs's - * realpathSync, statSync and lstatSync as Windows runs them on this volume: + * ntfsVolume(files, { short, shares, mapped, links, reparse, unmanaged, unnormalized, fastDev }) → { realpathSync, statSync, lstatSync, + * openSync, fstatSync, closeSync, opened } — node:fs's realpathSync, statSync, lstatSync, openSync, fstatSync and closeSync as + * Windows runs them on this volume: * files the on-disk spellings of the volume's files, e.g. 'C:\\ws\\orgs\\acme\\packs\\own.pack.yaml' * (each one's directories exist too); * short { '': '' }, e.g. { 'C:\\ws': 'WS~1' }; @@ -66,16 +73,24 @@ const enoent = (p) => Object.assign(new Error(`ENOENT: no such file or directory * unnormalized true for SMB with no normalized-name query (before Windows 10/Server 1709): the native realpath of a * path through a share names each part as the client opened it, by its name on disk, and a link on the way * (here every link reached through a share is taken for a junction, which the server follows) by its own - * name, not its target's. + * name, not its target's; + * fastDev the device a stat libuv answers by the path's name reports (a local path whose last name is no link or other + * reparse point): null (the default), the volume's serial, all 64 bits, as libuv 1.51.0 and later read it; + * 0n as libuv 1.49.0 to 1.50.x (Node 22.16.0's 1.49.2) read it, the upper half of the file's 128-bit id. * realpathSync(p) the JS one: p resolved, in the caller's spelling (from a link's target on, the target's), or ENOENT; * realpathSync.native(p) the on-disk spelling (\\host\share\… through a share; unnormalized, as opened), or ENOENT * (a reparse point's code through one) — EISDIR when it ends on an unmanaged drive; * statSync(p) { dev, ino } as bigints — the volume's serial (its low 32 bits through a share) and the * file's id, the same through every name of the file — and isSymbolicLink() (false), or ENOENT * (a reparse point's code through one); - * lstatSync(p) the same, of a link itself when p ends at one (isSymbolicLink() true). + * lstatSync(p) the same, of a link itself when p ends at one (isSymbolicLink() true); + * openSync(p) a handle on what p leads to, every link followed (a directory too: libuv opens one with + * FILE_FLAG_BACKUP_SEMANTICS), or statSync's error; + * fstatSync(fd) what statSync reports, as a handle reports it: the serial's low 32 bits on every libuv; + * closeSync(fd) closes it (EBADF when it is not open); + * opened() how many handles openSync opened that closeSync has not closed. */ -export function ntfsVolume(files, { short = {}, shares = {}, mapped = {}, links = {}, reparse = {}, unmanaged = [], unnormalized = false } = {}) { +export function ntfsVolume(files, { short = {}, shares = {}, mapped = {}, links = {}, reparse = {}, unmanaged = [], unnormalized = false, fastDev = null } = {}) { const names = new Map(); // a directory's on-disk path, folded → Map(name folded → name on disk) const add = (dir, name) => { const key = dir.toLowerCase(); @@ -121,11 +136,13 @@ export function ntfsVolume(files, { short = {}, shares = {}, mapped = {}, links return { local: out }; } // The file a path reaches, each link on the way followed (with follow, the last part too): its on-disk path, the share - // it was reached through, if any, the path as the JS realpath spells it, and the share p names and the parts below it. + // it was reached through, if any, the path as the JS realpath spells it, the share p names and the parts below it, and + // whether p's last name is a link (followed). function reach(p, follow = true) { let abs = win32.resolve(p); let spelled = abs; let opened = null; + let last = false; for (let hops = 0; ; hops += 1) { if (hops > 31) throw Object.assign(new Error(`ELOOP: too many symbolic links encountered, realpath '${p}'`), { code: 'ELOOP' }); // a link loop if (/^\\\\\?\\UNC\\/i.test(abs)) abs = `\\${abs.slice(7)}`; // \\?\UNC\host\share\… is \\host\share\… @@ -140,7 +157,8 @@ export function ntfsVolume(files, { short = {}, shares = {}, mapped = {}, links if (hops === 0) opened = { share, parts: abs.slice(root.length).split('\\').filter(Boolean) }; } const at = onVolume(share ? win32.join(share.dir, abs.slice(root.length)) : abs, p, follow); - if (at.local) return { local: at.local, share, spelled, opened }; + if (at.local) return { local: at.local, share, spelled, opened, last }; + if (!at.rest.length) last = true; abs = win32.resolve(at.link, ...at.rest); spelled = abs; } @@ -162,13 +180,24 @@ export function ntfsVolume(files, { short = {}, shares = {}, mapped = {}, links return native(p); }; const ids = new Map(); // an on-disk path, folded → its file id - const stat = ({ local, share }) => { + // A stat by name, or (byName false) of a handle: libuv opens a path through a share, or whose last name is a link or + // another reparse point, to stat it, and reads the serial's low 32 bits there; any other it answers by name (fastDev). + const stat = ({ local, share, last }, byName = true) => { const key = local.toLowerCase(); if (!ids.has(key)) ids.set(key, BigInt(ids.size + 1)); const serial = 0x5eed000000000000n | BigInt(local.charCodeAt(0)); // one per drive letter, above 32 bits too - return { dev: share ? serial & 0xffffffffn : serial, ino: ids.get(key), isSymbolicLink: () => linked.has(key) }; // only lstatSync ends at one + const opens = !byName || share || last || linked.has(key) || points.has(key); + return { dev: opens ? serial & 0xffffffffn : fastDev ?? serial, ino: ids.get(key), isSymbolicLink: () => linked.has(key) }; // only lstatSync ends at one }; const statSync = (p) => stat(reach(p)); const lstatSync = (p) => stat(reach(p, false)); - return { realpathSync, statSync, lstatSync }; + const fds = new Map(); + let fd = 2; + const openSync = (p) => { fds.set(++fd, reach(p)); return fd; }; + const fstatSync = (n) => { + if (!fds.has(n)) throw Object.assign(new Error('EBADF: bad file descriptor, fstat'), { code: 'EBADF' }); + return stat(fds.get(n), false); + }; + const closeSync = (n) => { if (!fds.delete(n)) throw Object.assign(new Error('EBADF: bad file descriptor, close'), { code: 'EBADF' }); }; + return { realpathSync, statSync, lstatSync, openSync, fstatSync, closeSync, opened: () => fds.size }; } diff --git a/server/store/paths.mjs b/server/store/paths.mjs index 12eea9e4..bc51b1a7 100644 --- a/server/store/paths.mjs +++ b/server/store/paths.mjs @@ -26,9 +26,19 @@ // it is checked against are not under one name (their realpaths' roots // differ), p is placed by the nearest of itself and its directories that is // that path or one of its directories — the same file id on the same volume -// (statSync, with the volume serial's low 32 bits: all a share reports) — -// and spelled from there. A path reached through a share that this cannot -// place may still lie inside that path: a share of a directory below it +// (with the volume serial's low 32 bits: all a share reports) — and spelled +// from there. A file id and a volume are read through a handle opened on the +// path (openSync, fstatSync, closeSync: libuv opens a directory too), which +// reports the serial on every libuv, never a stat by name: libuv answers one +// by the path's name (GetFileInformationByName, Windows 11 24H2 and Server +// 2025) unless the path is reached through a share or its last name is a +// reparse point, which it opens, and 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 a stat by name reported the upper half of the file's 128-bit id +// as the device, 0 on NTFS: C:\ws read as on another volume than \\myhost\ws, +// nothing placed a path through that share, and a directory export to +// \\myhost\ws\exports\new was written into the workspace. A path reached +// through a share that this cannot place may still lie inside that path: a share of a directory below it // (\\myhost\exports, which is C:\ws\exports, beside C:\ws) names none of its // directories. So on that path's volume such a path answers null — it may lie // inside — and the export, purge-org and no-marker checks refuse it, naming @@ -110,17 +120,18 @@ // crawlScopeRefusal's placement, which places only a local path while the // workspace is named through a share (it refuses any other path off a local // drive). That form only compares: every message, record and return value -// keeps the path as given. host = { windows, path, realpathSync, statSync, -// lstatSync } (the platform, node:path's, node:fs's realpathSync with its -// .native, node:fs's statSync and lstatSync) is injectable -// (server/test-store-paths.mjs); the default is this host. +// keeps the path as given. host = { windows, path, realpathSync, lstatSync, +// openSync, fstatSync, closeSync } (the platform, node:path's, node:fs's +// realpathSync with its .native, lstatSync, openSync, fstatSync and +// closeSync) is injectable (server/test-store-paths.mjs); the default is this +// host. -import { lstatSync, realpathSync, statSync } from 'node:fs'; +import { closeSync, fstatSync, lstatSync, openSync, realpathSync } from 'node:fs'; import * as nodePath from 'node:path'; const ON_WINDOWS = process.platform === 'win32'; -export function comparedPath(own, { windows = ON_WINDOWS, path = nodePath, realpathSync: fsRealpath = realpathSync, statSync: fsStat = statSync, lstatSync: fsLstat = lstatSync } = {}) { +export function comparedPath(own, { windows = ON_WINDOWS, path = nodePath, realpathSync: fsRealpath = realpathSync, lstatSync: fsLstat = lstatSync, openSync: fsOpen = openSync, fstatSync: fsFstat = fstatSync, closeSync: fsClose = closeSync } = {}) { if (!windows) return own; const real = (p, unread = []) => { // the native realpath of p's longest prefix it can read, the rest as written; unread gets the parts it cannot try { return fsRealpath.native(p); } catch { @@ -134,14 +145,19 @@ export function comparedPath(own, { windows = ON_WINDOWS, path = nodePath, realp return folded.endsWith('\\') ? folded.slice(0, -1) : folded; // a root too, so
+ \ prefixes what lies under it }; const root = (native) => form(path.parse(native).root); - const stat = (p) => { try { return fsStat(p, { bigint: true }); } catch { return null; } }; + const stat = (p) => { // p's stats through a handle opened on it, closed before it answers, or null when p cannot be opened + try { + const fd = fsOpen(p, 'r'); + try { return fsFstat(fd, { bigint: true }); } finally { fsClose(fd); } + } catch { return null; } + }; const fileId = (p) => { const st = stat(p); return st && `${st.dev & 0xffffffffn}:${st.ino}`; }; const lineage = (native) => { // native and its ancestors, nearest first const dirs = [native]; while (path.dirname(dirs.at(-1)) !== dirs.at(-1)) dirs.push(path.dirname(dirs.at(-1))); return dirs; }; - const volume = (native) => { // the volume serial's low 32 bits of native's nearest path that exists, or null + const volume = (native) => { // the volume serial's low 32 bits of native's nearest path that opens, or null for (const dir of lineage(native)) { const st = stat(dir); if (st) return st.dev & 0xffffffffn; } return null; }; diff --git a/server/test-store-paths.mjs b/server/test-store-paths.mjs index c8c85810..cb5f7edb 100644 --- a/server/test-store-paths.mjs +++ b/server/test-store-paths.mjs @@ -43,10 +43,14 @@ * database's own — the in-place detection, the scan for another store, the * no-marker check, the own-file check — decide by path alone: one only * placement equates is refused naming the way out (the scan reads it), never - * exported in place over the workspace or called the database itself. Each - * check's predicate is run here + * exported in place over the workspace or called the database itself. On + * Node 22.16.0 (libuv 1.49.2) a stat by name reports no volume, so a local + * path and a share of it had no file id in common: nothing placed a path + * through the share, and a directory export through a share of the workspace + * was written into it. File ids and volumes are read through a handle, and + * that test runs on both libuvs. Each check's predicate is run here * as ops.mjs and backup.mjs run it, over comparedPath with an injected host — - * Windows' path rules, an NTFS volume's realpath and statSync + * Windows' path rules, an NTFS volume's realpath, stats and handles * (server/fixtures/ntfs.mjs) and the platform — so the proof runs on every * host. One test runs the default host on a real directory, so the * platform's choice of rule is pinned too: the expected value reads isWin32 @@ -78,6 +82,24 @@ const { comparedPath } = await import('./store/paths.mjs'); const HERE = dirname(fileURLToPath(import.meta.url)); +// The injected Windows host over a volume (server/fixtures/ntfs.mjs): its realpath, lstatSync and handles, which +// comparedPath reads file ids and volumes through, and its statSync — the stat by name it read them from before, so at +// the parent commit this suite runs over the same libuv. +const hostOf = (volume, over = {}) => ({ + windows: true, path: win32, realpathSync: volume.realpathSync, statSync: volume.statSync, lstatSync: volume.lstatSync, + openSync: volume.openSync, fstatSync: volume.fstatSync, closeSync: volume.closeSync, ...over, +}); +// openSync, fstatSync and closeSync over statOf(p): a handle answers what statOf answers for the path it was opened on. +const handlesOf = (statOf) => { + const fds = new Map(); + let fd = 2; + return { + openSync: (p) => { const st = statOf(p); fds.set(++fd, st); return fd; }, + fstatSync: (n) => fds.get(n), + closeSync: (n) => { fds.delete(n); }, + }; +}; + const VOLUME = ntfsVolume([ 'C:\\ws\\observogram.db', 'C:\\ws\\exports\\keep.txt', @@ -86,7 +108,7 @@ const VOLUME = ntfsVolume([ 'C:\\ws2\\observogram.db', 'C:\\out\\x.txt', ], { short: { 'C:\\ws': 'WS~1' } }); -const key = comparedPath(null, { windows: true, path: win32, realpathSync: VOLUME.realpathSync, statSync: VOLUME.statSync, lstatSync: VOLUME.lstatSync }); +const key = comparedPath(null, hostOf(VOLUME)); const SEP = '\\'; // The comparison this replaces on Windows: ops.mjs's realOr, the spelling JS realpathSync keeps. const bySpelling = (p) => { try { return VOLUME.realpathSync(p); } catch { return win32.resolve(p); } }; @@ -108,13 +130,13 @@ const SHARED = ntfsVolume([ shares: { '\\\\myhost\\ws': 'C:\\ws', '\\\\localhost\\C$': 'C:\\', '\\\\127.0.0.1\\C$': 'C:\\', '\\\\myhost\\out': 'D:\\out', '\\\\nas01\\obs': 'E:\\obs' }, mapped: { 'Z:': '\\\\myhost\\ws' }, }); -const shared = comparedPath(null, { windows: true, path: win32, realpathSync: SHARED.realpathSync, statSync: SHARED.statSync, lstatSync: SHARED.lstatSync }); -// The native realpath alone: a statSync that tells every path apart (no two share a file id or a volume), so nothing +const shared = comparedPath(null, hostOf(SHARED)); +// The native realpath alone: a stat that tells every path apart (no two share a file id or a volume), so nothing // is placed and nothing is taken to lie inside — a share keeps its own name, and C:\ws is not \\myhost\ws. const byNameOf = (volume) => { const ids = new Map(); const apart = (p) => { if (!ids.has(p)) ids.set(p, BigInt(ids.size + 1)); return { dev: ids.get(p), ino: ids.get(p) }; }; - return comparedPath(null, { windows: true, path: win32, realpathSync: volume.realpathSync, statSync: apart, lstatSync: volume.lstatSync }); + return comparedPath(null, hostOf(volume, { statSync: apart, ...handlesOf(apart) })); }; const byName = byNameOf(SHARED); @@ -296,7 +318,7 @@ const SUBSHARES = { '\\\\myhost\\dbdir': 'C:\\ws\\db', '\\\\myhost\\cout': 'C:\\out', '\\\\myhost\\dout': 'D:\\out', '\\\\nas01\\obs': 'E:\\obs', }; const SUBSHARED = ntfsVolume(SUBSHARED_FILES, { shares: SUBSHARES, mapped: { 'Y:': '\\\\myhost\\exports' } }); -const subshared = comparedPath(null, { windows: true, path: win32, realpathSync: SUBSHARED.realpathSync, statSync: SUBSHARED.statSync, lstatSync: SUBSHARED.lstatSync }); +const subshared = comparedPath(null, hostOf(SUBSHARED)); test('a path through a share no file id places, on the volume of the path it is checked against, may lie inside it — the export, purge-org and the no-marker check refuse it, and named on the drive it is judged; a share on another volume or host is an ordinary export target', () => { const [base, root] = ['C:\\ws', 'C:\\ws\\orgs\\beta']; @@ -340,6 +362,36 @@ test('a path through a share no file id places, on the volume of the path it is assert.equal(ownFile('C:\\ws\\observogram.db', '\\\\myhost\\exports\\observogram.db-wal', subshared), null, 'a backup target, not its own file'); }); +// libuv answers a stat by the path's name (GetFileInformationByName, Windows 11 24H2 and Server 2025) unless the path is +// reached through a share or its last name is a reparse point, which it opens: 1.51.0 and later read all 64 bits of the +// volume's serial by name, 1.49.0 to 1.50.x (Node 22.16.0's 1.49.2) the upper half of the file's 128-bit id — 0 on +// NTFS — while a handle reports the serial's low 32 bits on every one (server/fixtures/ntfs.mjs, fastDev). +const LIBUVS = [['1.51.0', null], ['1.49.2', 0n]]; + +test('on every libuv a path through a share is placed by its file id and judged by its volume, both read through a handle — on 1.49.2 a stat by name reports no volume, and a directory export through a share of the workspace was written into it', () => { + const [base, root] = ['C:\\ws', 'C:\\ws\\orgs\\beta']; + for (const [libuv, fastDev] of LIBUVS) { + const volume = ntfsVolume(SUBSHARED_FILES, { shares: SUBSHARES, fastDev }); + const k = comparedPath(null, hostOf(volume)); + const serial = (p) => { const fd = volume.openSync(p); try { return volume.fstatSync(fd).dev; } finally { volume.closeSync(fd); } }; + assert.equal(serial('C:\\ws'), serial('\\\\myhost\\ws'), `libuv ${libuv}: through a handle C:\\ws and \\\\myhost\\ws report one serial`); + assert.equal(volume.statSync('C:\\ws').dev === 0n, fastDev === 0n, `libuv ${libuv}: by name C:\\ws reports ${fastDev === 0n ? 'no volume' : 'its volume'}`); + // A share of the workspace (\\myhost\ws, which is C:\ws) places a path through it, and the workspace named through it + // places a local path: inside, refused. + assert.equal(exportRelation('\\\\myhost\\ws\\exports\\new', base, k), 'inside', `libuv ${libuv}: a directory export through \\\\myhost\\ws into ${base} is refused`); + assert.equal(exportRelation('C:\\ws\\exports\\new', '\\\\myhost\\ws', k), 'inside', `libuv ${libuv}: and one on the drive into the workspace named through it`); + assert.equal(inPlace('\\\\myhost\\ws', base, k), 'placed', `libuv ${libuv}: the workspace through its share is refused, not the in-place export`); + assert.equal(ownFile('C:\\ws\\db\\observogram.db', '\\\\myhost\\dbdir\\observogram.db-wal', k), 'placed-wal', `libuv ${libuv}: a backup over the database's -wal through a share is refused`); + assert.equal(usersUnderRoot('\\\\myhost\\orgs\\beta\\users.json', root, k), true, `libuv ${libuv}: purge-org refuses a users file placed under the root`); + assert.equal(usersUnderRoot('\\\\myhost\\orgs\\users.json', root, k), false, `libuv ${libuv}: and purges with one placed beside it`); + // A share nothing places, on the workspace's volume, may lie inside it: refused. One on another volume is written. + assert.equal(exportRelation('\\\\myhost\\exports\\new', base, k), 'unplaced', `libuv ${libuv}: a directory export through \\\\myhost\\exports is refused`); + assert.equal(dbUnderWorkspace('\\\\myhost\\dbdir\\observogram.db', base, k), 'unplaced', `libuv ${libuv}: the no-marker check refuses the database through \\\\myhost\\dbdir`); + assert.equal(exportRelation('\\\\myhost\\dout\\new', base, k), null, `libuv ${libuv}: a share on another volume is written`); + assert.equal(volume.opened(), 0, `libuv ${libuv}: every handle opened is closed`); + } +}); + // The same shares when a check runs with \\myhost\betasub (a directory under beta's root) and \\myhost\exports down — // the server's sharing stopped, or its name not resolving: neither realpath nor statSync reads a path through them, by // their names or by the drives still mapped to them (X:, Y:), so no file id places one and its volume is unknown. @@ -348,7 +400,7 @@ const DOWN = ntfsVolume(SUBSHARED_FILES, { shares: Object.fromEntries(Object.entries(SUBSHARES).filter(([unc]) => !['\\\\myhost\\betasub', '\\\\myhost\\exports'].includes(unc))), mapped: { 'X:': '\\\\myhost\\betasub', 'Y:': '\\\\myhost\\exports' }, }); -const down = comparedPath(null, { windows: true, path: win32, realpathSync: DOWN.realpathSync, statSync: DOWN.statSync, lstatSync: DOWN.lstatSync }); +const down = comparedPath(null, hostOf(DOWN)); // A share may come back or go down, and a drive letter be mapped or unmapped, while a check runs: flipping(from, to, n) // answers the check's first n host calls from one volume and every later one from the other. X: mapped to // \\myhost\betasub with that share served, down, and X: not mapped: each numbers SUBSHARED_FILES and their directories @@ -358,7 +410,15 @@ const flipping = (from, to, n) => { const now = () => (calls++ < n ? from : to); const realpathSync = (p) => now().realpathSync(p); realpathSync.native = (p) => now().realpathSync.native(p); - const k = comparedPath(null, { windows: true, path: win32, realpathSync, statSync: (p, o) => now().statSync(p, o), lstatSync: (p) => now().lstatSync(p) }); + // A handle is answered by the volume it was opened on: it reads the file it opened, whatever came back or went down since. + const fds = new Map(); + let fd = 2; + const openSync = (p, flags) => { const volume = now(); fds.set(++fd, { volume, fd: volume.openSync(p, flags) }); return fd; }; + const fstatSync = (h, o) => { calls += 1; return fds.get(h).volume.fstatSync(fds.get(h).fd, o); }; + const closeSync = (h) => { calls += 1; fds.get(h).volume.closeSync(fds.get(h).fd); fds.delete(h); }; + const k = comparedPath(null, { + windows: true, path: win32, realpathSync, statSync: (p, o) => now().statSync(p, o), lstatSync: (p) => now().lstatSync(p), openSync, fstatSync, closeSync, + }); return Object.assign(k, { calls: () => calls }); }; const numbered = (volume) => { @@ -418,7 +478,7 @@ const DFS = ntfsVolume([...SUBSHARED_FILES, 'F:\\dfsroot\\readme.txt'], { shares: { ...Object.fromEntries(Object.entries(SUBSHARES).filter(([unc]) => unc !== '\\\\myhost\\betasub')), '\\\\corp\\dfs': 'F:\\dfsroot' }, mapped: { 'Z:': '\\\\corp\\dfs' }, }); -const dfs = comparedPath(null, { windows: true, path: win32, realpathSync: DFS.realpathSync, statSync: DFS.statSync, lstatSync: DFS.lstatSync }); +const dfs = comparedPath(null, hostOf(DFS)); test('purge-org refuses a users file recorded through any network share no file id places, whatever volume it reports — a DFS link whose target is down reads the namespace root\'s — and purges with one on a local drive outside the root, or placed outside it', () => { const root = 'C:\\ws\\orgs\\beta'; @@ -455,11 +515,11 @@ const LINKS = { 'C:\\links\\out': 'C:\\out', 'C:\\links\\betadir': 'C:\\ws\\orgs\\beta\\sub', 'C:\\links\\gone': 'C:\\gone', 'C:\\cfg\\old.json': 'C:\\gone\\users.json', }; const LINKS_SERVED = ntfsVolume(SUBSHARED_FILES, { shares: SUBSHARES, links: LINKS }); -const linksServed = comparedPath(null, { windows: true, path: win32, realpathSync: LINKS_SERVED.realpathSync, statSync: LINKS_SERVED.statSync, lstatSync: LINKS_SERVED.lstatSync }); +const linksServed = comparedPath(null, hostOf(LINKS_SERVED)); const LINKS_DOWN = ntfsVolume(SUBSHARED_FILES, { shares: Object.fromEntries(Object.entries(SUBSHARES).filter(([unc]) => unc !== '\\\\myhost\\betasub')), links: LINKS, }); -const linksDown = comparedPath(null, { windows: true, path: win32, realpathSync: LINKS_DOWN.realpathSync, statSync: LINKS_DOWN.statSync, lstatSync: LINKS_DOWN.lstatSync }); +const linksDown = comparedPath(null, hostOf(LINKS_DOWN)); test('purge-org refuses a users file recorded through a link on the drive whose target cannot be opened — a directory or file symlink to a share that is down, a chain ending at one, a link to a path that does not exist — and judges one through a link whose target opens where it leads', () => { const root = 'C:\\ws\\orgs\\beta'; @@ -510,9 +570,9 @@ test('purge-org refuses a users file recorded through a link on the drive whose // junction is followed to its target (SINCE_1709). const JUNCTIONED = { shares: { ...SUBSHARES, '\\\\localhost\\C$': 'C:\\' }, mapped: { 'Z:': '\\\\myhost\\ws' }, links: { 'C:\\ws\\links\\j': 'C:\\ws\\orgs\\beta\\sub', 'C:\\ws\\links\\o': 'C:\\out' } }; const BEFORE_1709 = ntfsVolume(SUBSHARED_FILES, { ...JUNCTIONED, unnormalized: true }); -const before1709 = comparedPath(null, { windows: true, path: win32, realpathSync: BEFORE_1709.realpathSync, statSync: BEFORE_1709.statSync, lstatSync: BEFORE_1709.lstatSync }); +const before1709 = comparedPath(null, hostOf(BEFORE_1709)); const SINCE_1709 = ntfsVolume(SUBSHARED_FILES, JUNCTIONED); -const since1709 = comparedPath(null, { windows: true, path: win32, realpathSync: SINCE_1709.realpathSync, statSync: SINCE_1709.statSync, lstatSync: SINCE_1709.lstatSync }); +const since1709 = comparedPath(null, hostOf(SINCE_1709)); test('purge-org refuses a users file recorded through a junction on a share\'s volume that the native realpath names as the client opened it (SMB before Windows 10/Server 1709) — placed by the share\'s directory, it compared as outside the root — and judges a path through the share that passes no link as before', () => { const root = 'C:\\ws\\orgs\\beta'; @@ -548,7 +608,7 @@ test('purge-org refuses a users file recorded through a junction on a share\'s v if (win32.resolve(p).startsWith('\\\\')) throw Object.assign(new Error(`ENOENT: no such file or directory, lstat '${p}'`), { code: 'ENOENT' }); return BEFORE_1709.lstatSync(p); }; - const blinked = comparedPath(null, { windows: true, path: win32, realpathSync: BEFORE_1709.realpathSync, statSync: BEFORE_1709.statSync, lstatSync: lstatDown }); + const blinked = comparedPath(null, hostOf(BEFORE_1709, { lstatSync: lstatDown })); assert.equal(usersUnderRoot(through, root, blinked), 'unplaced', `lstatSync cannot read the parts the walk read: refused (${through})`); // Only purge-org passes deleting: the export compares a path through the junction as before. assert.equal(exportRelation('\\\\myhost\\ws\\links\\j\\new', 'C:\\ws', before1709), 'inside', 'the export\'s comparison is unchanged'); @@ -566,7 +626,7 @@ const REPARSE = ntfsVolume(SUBSHARED_FILES, { 'C:\\tagged\\betasub': { code: 'EACCES', lstat: false }, }, }); -const reparse = comparedPath(null, { windows: true, path: win32, realpathSync: REPARSE.realpathSync, statSync: REPARSE.statSync, lstatSync: REPARSE.lstatSync }); +const reparse = comparedPath(null, hostOf(REPARSE)); test('purge-org refuses a users file recorded through any other reparse point whose target cannot be opened — one lstatSync answers as no link, or cannot read either — and judges a path not created yet as before', () => { const root = 'C:\\ws\\orgs\\beta'; @@ -595,7 +655,7 @@ const RAMDISK = ntfsVolume([...SUBSHARED_FILES, 'R:\\ws\\observogram.db', 'R:\\w links: { 'R:\\links\\betasub': 'R:\\ws\\orgs\\beta\\sub', 'C:\\links\\ramsub': 'R:\\ws\\orgs\\beta\\sub' }, unmanaged: ['R:'], }); -const ramdisk = comparedPath(null, { windows: true, path: win32, realpathSync: RAMDISK.realpathSync, statSync: RAMDISK.statSync, lstatSync: RAMDISK.lstatSync }); +const ramdisk = comparedPath(null, hostOf(RAMDISK)); test('on a volume whose paths the native realpath cannot name (a RAM disk that bypasses the Volume Mount Manager), purge-org refuses a users file — a short name or a link there is read as written — as through a link to it; one outside the root is a declared false refusal', () => { const root = 'R:\\ws\\orgs\\beta'; @@ -641,7 +701,12 @@ clonedRealpath.native = (p) => (onMachineB(p) ? MACHINEB.realpathSync.native(p) : onDisk(p) ? asDrive('E', DISK.realpathSync.native(asDrive('C', p))) : HOST.realpathSync.native(p)); const clonedStat = (p) => (onMachineB(p) ? MACHINEB.statSync(p) : onDisk(p) ? DISK.statSync(asDrive('C', p)) : HOST.statSync(p)); const clonedLstat = (p) => (onMachineB(p) ? MACHINEB.lstatSync(p) : onDisk(p) ? DISK.lstatSync(asDrive('C', p)) : HOST.lstatSync(p)); -const cloned = comparedPath(null, { windows: true, path: win32, realpathSync: clonedRealpath, statSync: clonedStat, lstatSync: clonedLstat }); +const clonedFstat = (p) => { // what a handle opened on p reports, on the machine or the disk p is on + const [volume, at] = onMachineB(p) ? [MACHINEB, p] : onDisk(p) ? [DISK, asDrive('C', p)] : [HOST, p]; + const fd = volume.openSync(at); + try { return volume.fstatSync(fd); } finally { volume.closeSync(fd); } +}; +const cloned = comparedPath(null, { windows: true, path: win32, realpathSync: clonedRealpath, statSync: clonedStat, lstatSync: clonedLstat, ...handlesOf(clonedFstat) }); test('a machine cloned from this host\'s image, or this drive\'s disk image attached as E:, keeps its serial and its files\' ids — a containment refusal holds for its paths, and no check takes one for the workspace, the database or one of its files: each is refused naming the way out, and the scan reads it', () => { const [base, root, dbFile] = ['C:\\ws', 'C:\\ws\\orgs\\beta', 'C:\\ws\\observogram.db']; From 4e43023d46b3c130abeb601ce4883528e3bf4068 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 07:11:24 +0000 Subject: [PATCH 2/2] =?UTF-8?q?docs:=20restore=20purge-org-users-file-iden?= =?UTF-8?q?tity's=20CHANGELOG=20entry=20and=20re-chain=20this=20follow-up'?= =?UTF-8?q?s=20Tests=20pair=20from=20develop's=201519=20=E2=80=94=20the=20?= =?UTF-8?q?develop=20merge=20cd24602=20dropped=20#163's=20entry=20(17=20li?= =?UTF-8?q?nes,=20now=20byte-for-byte=20develop's=20again,=20below=20this?= =?UTF-8?q?=20entry)=20and=20left=20this=20entry's=20pair=20at=201495=20?= =?UTF-8?q?=E2=86=92=201496,=20which=20test-doc-test-totals=20matched=20ag?= =?UTF-8?q?ainst=20#163's=20journey=20pair=20from=201495;=201519=20?= =?UTF-8?q?=E2=86=92=201520=20is=20the=20measured=20npm=20test=20total?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ --- docs/CHANGELOG.md | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ed8e3b72..d525a124 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,7 +4,26 @@ ### Follow-up `paths-volume-through-handle`: on Windows the store's path comparison reads a path's volume through a handle -- **On Node 22.16.x the store's offline checks place a path through a share again** (a fix; `server/store/paths.mjs`; Linux and macOS compare exactly as before). `comparedPath` placed a path reached through a share by a file id it shares with a directory of the path it is checked against, and judged one nothing places by its volume (the serial's low 32 bits), both read with `statSync`. libuv answers a stat by the path's name (`GetFileInformationByName`, Windows 11 24H2 and Server 2025) unless the path is reached through a share or its last name is a reparse point, which it opens and reads the volume's serial from (`FILE_FS_VOLUME_INFORMATION`); libuv 1.49.0 to 1.50.x (Node 22.16.x ships 1.49.2) declared `FILE_STAT_BASIC_INFORMATION` with `FileId128` before `VolumeSerialNumber`, swapped against Windows' layout (fixed in 1.51.0), so a stat by name reported the upper half of the file's 128-bit id as the device, 0 on NTFS. There a local path and a share of it had no file id in common, and a local volume never matched a share's: with the workspace at `C:\ws`, a directory export to `\\myhost\ws\exports\new` (which is `C:\ws\exports\new`), or to `C:\ws\exports\new` with the workspace named `\\myhost\ws`, compared as outside the workspace and was written into it, and so was one through a share of a directory under it (`\\myhost\exports`, which no file id places and which was refused as lying on the workspace's volume); the in-place detection and backup's own-file check missed the workspace and the database's `-wal` through a share alike, and purge-org refused a users file a share placed outside the root (a false refusal). On Windows a file id and a volume are now read through a handle opened on the path (`openSync`, `fstatSync`, `closeSync`; libuv opens a directory too), which reports the serial on every libuv, every handle closed before the comparison goes on; a path that does not open is passed over as one that did not stat was. `comparedPath` takes `openSync`, `fstatSync` and `closeSync` in place of `statSync`. `server/fixtures/ntfs.mjs` models both libuvs (`fastDev`: the device a stat by name reports — `null`, all 64 bits of the serial as 1.51.0 reads it, or `0n` as 1.49.2 does; a path through a share, or whose last name is a link or another reparse point, is opened, with the serial's low 32 bits) and returns `openSync`, `fstatSync`, `closeSync` and `opened()`; its header no longer says a local stat may report all 64 bits on every libuv. One more test in `server/test-store-paths.mjs` (1495 → 1496 tests): on libuv 1.51.0 and 1.49.2, a directory export through `\\myhost\ws` into `C:\ws`, and one on the drive into the workspace named through it, refused as inside it, the workspace through its share refused as the in-place export, a backup over the database's `-wal` through `\\myhost\dbdir` refused, purge-org refusing a users file placed under the root and purging with one placed beside it, a share nothing places on the workspace's volume refused, one on another volume written, and every handle closed; at the parent commit it fails on 1.49.2 (the export through `\\myhost\ws` was written). Every comparison the suite builds injects the fixture's handles. +- **On Node 22.16.x the store's offline checks place a path through a share again** (a fix; `server/store/paths.mjs`; Linux and macOS compare exactly as before). `comparedPath` placed a path reached through a share by a file id it shares with a directory of the path it is checked against, and judged one nothing places by its volume (the serial's low 32 bits), both read with `statSync`. libuv answers a stat by the path's name (`GetFileInformationByName`, Windows 11 24H2 and Server 2025) unless the path is reached through a share or its last name is a reparse point, which it opens and reads the volume's serial from (`FILE_FS_VOLUME_INFORMATION`); libuv 1.49.0 to 1.50.x (Node 22.16.x ships 1.49.2) declared `FILE_STAT_BASIC_INFORMATION` with `FileId128` before `VolumeSerialNumber`, swapped against Windows' layout (fixed in 1.51.0), so a stat by name reported the upper half of the file's 128-bit id as the device, 0 on NTFS. There a local path and a share of it had no file id in common, and a local volume never matched a share's: with the workspace at `C:\ws`, a directory export to `\\myhost\ws\exports\new` (which is `C:\ws\exports\new`), or to `C:\ws\exports\new` with the workspace named `\\myhost\ws`, compared as outside the workspace and was written into it, and so was one through a share of a directory under it (`\\myhost\exports`, which no file id places and which was refused as lying on the workspace's volume); the in-place detection and backup's own-file check missed the workspace and the database's `-wal` through a share alike, and purge-org refused a users file a share placed outside the root (a false refusal). On Windows a file id and a volume are now read through a handle opened on the path (`openSync`, `fstatSync`, `closeSync`; libuv opens a directory too), which reports the serial on every libuv, every handle closed before the comparison goes on; a path that does not open is passed over as one that did not stat was. `comparedPath` takes `openSync`, `fstatSync` and `closeSync` in place of `statSync`. `server/fixtures/ntfs.mjs` models both libuvs (`fastDev`: the device a stat by name reports — `null`, all 64 bits of the serial as 1.51.0 reads it, or `0n` as 1.49.2 does; a path through a share, or whose last name is a link or another reparse point, is opened, with the serial's low 32 bits) and returns `openSync`, `fstatSync`, `closeSync` and `opened()`; its header no longer says a local stat may report all 64 bits on every libuv. One more test in `server/test-store-paths.mjs` (1519 → 1520 tests): on libuv 1.51.0 and 1.49.2, a directory export through `\\myhost\ws` into `C:\ws`, and one on the drive into the workspace named through it, refused as inside it, the workspace through its share refused as the in-place export, a backup over the database's `-wal` through `\\myhost\dbdir` refused, purge-org refusing a users file placed under the root and purging with one placed beside it, a share nothing places on the workspace's volume refused, one on another volume written, and every handle closed; at the parent commit it fails on 1.49.2 (the export through `\\myhost\ws` was written). Every comparison the suite builds injects the fixture's handles. + +### 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).