@nimbus-sh/core 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -7
- package/dist/_shared/error-text.d.ts +15 -0
- package/dist/_shared/error-text.d.ts.map +1 -0
- package/dist/_shared/error-text.js +21 -0
- package/dist/_shared/exports-resolver.d.ts +19 -7
- package/dist/_shared/exports-resolver.d.ts.map +1 -1
- package/dist/_shared/exports-resolver.js +1 -1
- package/dist/_shared/retry.d.ts.map +1 -1
- package/dist/_shared/retry.js +3 -1
- package/dist/_shared/w7-frame.js +2 -1
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +30 -16
- package/dist/observability/oom-classify.d.ts +1 -1
- package/dist/observability/oom-classify.d.ts.map +1 -1
- package/dist/observability/oom-classify.js +7 -0
- package/dist/observability/oom-discriminator.d.ts +1 -1
- package/dist/runtime/esbuild-service.d.ts +4 -3
- package/dist/runtime/esbuild-service.d.ts.map +1 -1
- package/dist/runtime/esbuild-service.js +9 -4
- package/dist/runtime/installed-runtimes.d.ts +4 -3
- package/dist/runtime/installed-runtimes.d.ts.map +1 -1
- package/dist/runtime/long-running-handle.d.ts +3 -3
- package/dist/runtime/long-running-handle.d.ts.map +1 -1
- package/dist/runtime/os-contracts.d.ts +7 -5
- package/dist/runtime/os-contracts.d.ts.map +1 -1
- package/dist/runtime/port-registry.d.ts +28 -0
- package/dist/runtime/port-registry.d.ts.map +1 -1
- package/dist/runtime/port-registry.js +79 -9
- package/dist/runtime/process-logs.d.ts +9 -1
- package/dist/runtime/process-logs.d.ts.map +1 -1
- package/dist/runtime/project-detect.d.ts +6 -1
- package/dist/runtime/project-detect.d.ts.map +1 -1
- package/dist/runtime/require-resolver.d.ts.map +1 -1
- package/dist/runtime/require-resolver.js +3 -4
- package/dist/runtime/runtime-registry.d.ts +4 -3
- package/dist/runtime/runtime-registry.d.ts.map +1 -1
- package/dist/runtime/runtime-registry.js +2 -1
- package/dist/runtime/sqlite-runtime-fs-bridge.js +1 -5
- package/dist/runtime/virtual-socket-kernel.d.ts.map +1 -1
- package/dist/runtime/virtual-socket-kernel.generated.d.ts +1 -1
- package/dist/runtime/virtual-socket-kernel.generated.d.ts.map +1 -1
- package/dist/runtime/virtual-socket-kernel.generated.js +2 -2
- package/dist/runtime/virtual-socket-kernel.js +10 -5
- package/dist/runtime/wasm-runner.d.ts.map +1 -1
- package/dist/runtime/wasm-runner.js +22 -14
- package/dist/shell/features.d.ts.map +1 -1
- package/dist/shell/features.js +5 -4
- package/dist/shell/unix-commands.d.ts +14 -1
- package/dist/shell/unix-commands.d.ts.map +1 -1
- package/dist/shell/unix-commands.js +115 -69
- package/dist/substrate/lifo/commands/net/ip.d.ts.map +1 -1
- package/dist/substrate/lifo/commands/system/ps.d.ts +2 -1
- package/dist/substrate/lifo/commands/system/ps.d.ts.map +1 -1
- package/dist/substrate/lifo/kernel/ServiceManager.d.ts.map +1 -1
- package/dist/substrate/lifo/kernel/ServiceManager.js +0 -2
- package/dist/substrate/lifo/kernel/network/NetworkStack.d.ts +22 -11
- package/dist/substrate/lifo/kernel/network/NetworkStack.d.ts.map +1 -1
- package/dist/substrate/lifo/kernel/network/NetworkStack.js +0 -2
- package/dist/substrate/lifo/kernel/network/tunnel/WebSocketTunnel.d.ts +2 -1
- package/dist/substrate/lifo/kernel/network/tunnel/WebSocketTunnel.d.ts.map +1 -1
- package/dist/substrate/lifo/kernel/network/tunnel/WebSocketTunnel.js +3 -4
- package/dist/substrate/lifo/kernel/vfs/VFS.d.ts +17 -0
- package/dist/substrate/lifo/kernel/vfs/VFS.d.ts.map +1 -1
- package/dist/substrate/lifo/kernel/vfs/VFS.js +33 -0
- package/dist/substrate/lifo/node-compat/esbuild.d.ts.map +1 -1
- package/dist/substrate/lifo/node-compat/esbuild.js +2 -4
- package/dist/substrate/lifo/node-compat/index.d.ts.map +1 -1
- package/dist/substrate/lifo/node-compat/index.js +3 -2
- package/dist/substrate/lifo/node-compat/loose-equality.d.ts +19 -0
- package/dist/substrate/lifo/node-compat/loose-equality.d.ts.map +1 -0
- package/dist/substrate/lifo/node-compat/loose-equality.js +91 -0
- package/dist/substrate/lifo/node-compat/process.d.ts.map +1 -1
- package/dist/substrate/lifo/node-compat/process.js +6 -3
- package/dist/substrate/lifo/shell/Shell.d.ts +19 -0
- package/dist/substrate/lifo/shell/Shell.d.ts.map +1 -1
- package/dist/substrate/lifo/shell/Shell.js +86 -75
- package/dist/substrate/lifo/shell/lexer.d.ts +9 -0
- package/dist/substrate/lifo/shell/lexer.d.ts.map +1 -1
- package/dist/substrate/lifo/shell/lexer.js +40 -0
- package/dist/vfs/seed-project.d.ts.map +1 -1
- package/dist/vfs/seed-project.js +8 -2
- package/dist/vfs/sqlite-vfs.d.ts +61 -1
- package/dist/vfs/sqlite-vfs.d.ts.map +1 -1
- package/dist/vfs/sqlite-vfs.js +199 -35
- package/package.json +1 -1
- package/src/_shared/error-text.ts +21 -0
- package/src/_shared/exports-resolver.ts +24 -4
- package/src/_shared/retry.ts +4 -2
- package/src/_shared/w7-frame.ts +2 -1
- package/src/constants.ts +30 -16
- package/src/observability/oom-classify.ts +7 -0
- package/src/observability/oom-discriminator.ts +1 -1
- package/src/runtime/esbuild-service.ts +16 -11
- package/src/runtime/installed-runtimes.ts +4 -3
- package/src/runtime/long-running-handle.ts +3 -3
- package/src/runtime/os-contracts.ts +7 -5
- package/src/runtime/port-registry.ts +97 -9
- package/src/runtime/process-logs.ts +12 -3
- package/src/runtime/project-detect.ts +7 -1
- package/src/runtime/require-resolver.ts +7 -7
- package/src/runtime/runtime-registry.ts +9 -7
- package/src/runtime/sqlite-runtime-fs-bridge.ts +9 -6
- package/src/runtime/virtual-socket-kernel.generated.ts +2 -2
- package/src/runtime/virtual-socket-kernel.ts +10 -5
- package/src/runtime/wasm-runner.ts +55 -29
- package/src/shell/features.ts +5 -4
- package/src/shell/unix-commands.ts +273 -128
- package/src/substrate/lifo/commands/net/ip.ts +15 -15
- package/src/substrate/lifo/commands/system/ps.ts +2 -1
- package/src/substrate/lifo/kernel/ServiceManager.ts +0 -2
- package/src/substrate/lifo/kernel/network/NetworkStack.ts +24 -17
- package/src/substrate/lifo/kernel/network/tunnel/WebSocketTunnel.ts +37 -12
- package/src/substrate/lifo/kernel/vfs/VFS.ts +34 -0
- package/src/substrate/lifo/node-compat/esbuild.ts +16 -8
- package/src/substrate/lifo/node-compat/index.ts +3 -2
- package/src/substrate/lifo/node-compat/loose-equality.ts +81 -0
- package/src/substrate/lifo/node-compat/process.ts +5 -2
- package/src/substrate/lifo/shell/Shell.ts +92 -70
- package/src/substrate/lifo/shell/lexer.ts +38 -0
- package/src/vfs/seed-project.ts +10 -3
- package/src/vfs/sqlite-vfs.ts +209 -36
package/src/vfs/sqlite-vfs.ts
CHANGED
|
@@ -83,6 +83,26 @@ const CONTENT_ID_ALLOCATION_ATTEMPTS = 8;
|
|
|
83
83
|
// generateGitNetworkFacetCode (git-network-facet.ts) and the parallel
|
|
84
84
|
// preamble (parallel/generated-workers.ts).
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* The Node `process` global as far as this file probes it. workerd provides
|
|
88
|
+
* no `process`, so every member stays optional and every read stays guarded.
|
|
89
|
+
*/
|
|
90
|
+
interface NodeProcessLike {
|
|
91
|
+
env?: Record<string, string | undefined>;
|
|
92
|
+
memoryUsage?: () => { heapUsed: number };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Live view of the global object. `process` is not in the Workers lib, so its
|
|
97
|
+
* shape is declared here rather than assumed present.
|
|
98
|
+
*/
|
|
99
|
+
const nodeHost = globalThis as { process?: NodeProcessLike };
|
|
100
|
+
|
|
101
|
+
/** Single gate for the W2.5b install-pipeline diagnostics below. */
|
|
102
|
+
function installPipelineDiagEnabled(): boolean {
|
|
103
|
+
return nodeHost.process?.env?.NIMBUS_DIAG_INSTALL_PIPELINE === '1';
|
|
104
|
+
}
|
|
105
|
+
|
|
86
106
|
// ── Types ───────────────────────────────────────────────────────────────────
|
|
87
107
|
|
|
88
108
|
export type VfsInodeKind = 'file' | 'directory' | 'symlink';
|
|
@@ -254,6 +274,8 @@ const CHUNK_ROWS_PER_SQL_EXEC = 33;
|
|
|
254
274
|
const CONTENT_IDS_PER_SQL_EXEC = 50;
|
|
255
275
|
const TRANSACTION_DURATION_SAMPLE_COUNT = 128;
|
|
256
276
|
const CONTENT_SCHEMA_MIGRATION = 'content_generations_v1';
|
|
277
|
+
/** Storage key of the shared scratch tree. `normalizeVfsPath` drops the slash. */
|
|
278
|
+
const TMP_ROOT = 'tmp';
|
|
257
279
|
export const VFS_APPEND_RECEIPT_LIMIT = 2048;
|
|
258
280
|
const INODE_KIND_FILE = 0;
|
|
259
281
|
const INODE_KIND_DIRECTORY = 1;
|
|
@@ -679,6 +701,9 @@ export class SqliteVFS {
|
|
|
679
701
|
private readonly activeStagingContentIds = new Set<string>();
|
|
680
702
|
/** True only while durable GC work or a known abandoned staging row exists. */
|
|
681
703
|
private maintenancePending = false;
|
|
704
|
+
// Keyset cursor for the orphan scan: each maintenance call reads one bounded
|
|
705
|
+
// page of distinct content ids past this bound, wrapping to '' at the end.
|
|
706
|
+
private orphanScanCursor = '';
|
|
682
707
|
|
|
683
708
|
// Stage 2 transaction/phase telemetry. Scalar writes stay cheap; the
|
|
684
709
|
// percentile is computed from the fixed ring only when diagnostics read it.
|
|
@@ -1317,7 +1342,7 @@ export class SqliteVFS {
|
|
|
1317
1342
|
private blobToUint8Array(blob: unknown): Uint8Array {
|
|
1318
1343
|
if (blob instanceof Uint8Array) return blob;
|
|
1319
1344
|
if (blob instanceof ArrayBuffer) return new Uint8Array(blob);
|
|
1320
|
-
if (ArrayBuffer.isView(blob)) return new Uint8Array(
|
|
1345
|
+
if (ArrayBuffer.isView(blob)) return new Uint8Array(blob.buffer, blob.byteOffset, blob.byteLength);
|
|
1321
1346
|
return new Uint8Array(0);
|
|
1322
1347
|
}
|
|
1323
1348
|
|
|
@@ -1338,6 +1363,82 @@ export class SqliteVFS {
|
|
|
1338
1363
|
return this.blobToUint8Array(rows[0].data);
|
|
1339
1364
|
}
|
|
1340
1365
|
|
|
1366
|
+
/**
|
|
1367
|
+
* Principals whose `/tmp` is private, keyed by uid, valued by the storage
|
|
1368
|
+
* root their `/tmp` resolves to.
|
|
1369
|
+
*
|
|
1370
|
+
* `/tmp` keeps its path in every view and only the bytes behind it differ,
|
|
1371
|
+
* so nothing has to be told which principal it is. The credential is the
|
|
1372
|
+
* only per-process state visible where paths are RESOLVED — there is no pid
|
|
1373
|
+
* and no cwd down here — so it is what the private view is keyed on.
|
|
1374
|
+
*
|
|
1375
|
+
* Resolution rather than a mount, because a mount diverges the two planes:
|
|
1376
|
+
* the shell writing `/tmp/a` and the file API writing `/tmp/b` landed in
|
|
1377
|
+
* different trees under the same name. `resolvePath` already takes `cred`,
|
|
1378
|
+
* and every plane goes through it.
|
|
1379
|
+
*
|
|
1380
|
+
* Registration is also what makes a principal CONFINED for `chmod`. The two
|
|
1381
|
+
* properties travel together because they answer one question: is this
|
|
1382
|
+
* principal a guest in this filesystem. Nothing here applies to an
|
|
1383
|
+
* unregistered credential, so the ordinary session user is untouched.
|
|
1384
|
+
*/
|
|
1385
|
+
private confinedTmpRoots = new Map<number, string>();
|
|
1386
|
+
|
|
1387
|
+
/**
|
|
1388
|
+
* Confine a principal. `tmpRoot` is a storage key, not a logical path — the
|
|
1389
|
+
* caller owns creating and chowning it, because a per-principal `chown` is
|
|
1390
|
+
* uid-0 only and a guest cannot provision its own.
|
|
1391
|
+
*/
|
|
1392
|
+
confinePrincipal(uid: number, tmpRoot: string): void {
|
|
1393
|
+
const root = normalizeVfsPath(tmpRoot);
|
|
1394
|
+
if (root === '') throw vfsError('EINVAL', 'a private /tmp root cannot be the filesystem root');
|
|
1395
|
+
this.confinedTmpRoots.set(uid, root);
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
/** Drop a confinement. A principal's `/tmp` dies with it; its home does not. */
|
|
1399
|
+
releasePrincipal(uid: number): void {
|
|
1400
|
+
this.confinedTmpRoots.delete(uid);
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
/**
|
|
1404
|
+
* Logical path -> storage key, for one credential.
|
|
1405
|
+
*
|
|
1406
|
+
* Everything under `/tmp` belongs to the caller's own private root, which is
|
|
1407
|
+
* what makes the same path mean different bytes per principal. Idempotent: a
|
|
1408
|
+
* key already inside that root is returned untouched, so the several methods
|
|
1409
|
+
* that derive a key before handing it on cannot stack the rewrite.
|
|
1410
|
+
*/
|
|
1411
|
+
private storageKey(path: string, cred: VfsCred): string {
|
|
1412
|
+
const key = normalizeVfsPath(path);
|
|
1413
|
+
const root = this.confinedTmpRoots.get(cred.uid);
|
|
1414
|
+
if (root === undefined) return key;
|
|
1415
|
+
if (key === root || key.startsWith(`${root}/`)) return key;
|
|
1416
|
+
if (key === TMP_ROOT) return root;
|
|
1417
|
+
if (!key.startsWith(`${TMP_ROOT}/`)) return key;
|
|
1418
|
+
return `${root}/${key.slice(TMP_ROOT.length + 1)}`;
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
/**
|
|
1422
|
+
* Storage key -> the name this credential knows it by, or `null` when it has
|
|
1423
|
+
* none. The inverse of {@link storageKey}, for the one surface that reports
|
|
1424
|
+
* paths it was not asked about: {@link list}.
|
|
1425
|
+
*
|
|
1426
|
+
* A confined caller has no name for the shared scratch tree — `/tmp` is its
|
|
1427
|
+
* own root — nor for another principal's, so both answer `null` and are
|
|
1428
|
+
* omitted. An unconfined caller sees storage as it is, which is what the
|
|
1429
|
+
* kernel and the session user need.
|
|
1430
|
+
*/
|
|
1431
|
+
private logicalPath(key: string, cred: VfsCred): string | null {
|
|
1432
|
+
const root = this.confinedTmpRoots.get(cred.uid);
|
|
1433
|
+
if (root === undefined) return key;
|
|
1434
|
+
if (key === root) return TMP_ROOT;
|
|
1435
|
+
if (key.startsWith(`${root}/`)) return `${TMP_ROOT}/${key.slice(root.length + 1)}`;
|
|
1436
|
+
// The SHARED scratch root has no name here — this caller's `/tmp` is its
|
|
1437
|
+
// own root, which already supplied that entry. Returning it too would
|
|
1438
|
+
// enumerate one name twice, for two different directories.
|
|
1439
|
+
return key === TMP_ROOT || key.startsWith(`${TMP_ROOT}/`) ? null : key;
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1341
1442
|
// ── Filesystem operations ─────────────────────────────────────────────
|
|
1342
1443
|
|
|
1343
1444
|
as(cred: VfsCred): CredentialedVfs {
|
|
@@ -1399,7 +1500,7 @@ export class SqliteVFS {
|
|
|
1399
1500
|
writeBatch: (payload) => this.writeBatch(payload, bound),
|
|
1400
1501
|
writeStream: (stream, options) => this.writeStream(stream, options, bound),
|
|
1401
1502
|
mkdirBatch: (paths) => this.mkdirBatch(paths, bound),
|
|
1402
|
-
revision: (path) => this.revision(path),
|
|
1503
|
+
revision: (path) => this.revision(path, bound),
|
|
1403
1504
|
epoch: this._epoch,
|
|
1404
1505
|
};
|
|
1405
1506
|
}
|
|
@@ -1430,7 +1531,7 @@ export class SqliteVFS {
|
|
|
1430
1531
|
followLeaf: boolean,
|
|
1431
1532
|
allowMissing: boolean,
|
|
1432
1533
|
): { path: string; inode: INode | undefined } {
|
|
1433
|
-
let current =
|
|
1534
|
+
let current = this.storageKey(path, cred);
|
|
1434
1535
|
const seen = new Set<string>();
|
|
1435
1536
|
for (let hops = 0; hops <= 40; hops++) {
|
|
1436
1537
|
const parts = current.split('/').filter(Boolean);
|
|
@@ -1449,8 +1550,13 @@ export class SqliteVFS {
|
|
|
1449
1550
|
seen.add(prefix);
|
|
1450
1551
|
const target = dec.decode(this.readInodeBytes(prefix, inode));
|
|
1451
1552
|
const suffix = parts.slice(index + 1).join('/');
|
|
1553
|
+
// An ABSOLUTE target is a logical path the same way the caller's
|
|
1554
|
+
// was, so it goes through the same rewrite: otherwise a symlink to
|
|
1555
|
+
// /tmp/y stored inside a private tree would read the shared one,
|
|
1556
|
+
// which is the one way out of the confinement. A RELATIVE target
|
|
1557
|
+
// resolves against a key that is already private, so it stays there.
|
|
1452
1558
|
const resolvedTarget = target.startsWith('/')
|
|
1453
|
-
?
|
|
1559
|
+
? this.storageKey(target, cred)
|
|
1454
1560
|
: normalizeVfsPath(`${this.parentPath(prefix)}/${target}`);
|
|
1455
1561
|
current = suffix ? normalizeVfsPath(`${resolvedTarget}/${suffix}`) : resolvedTarget;
|
|
1456
1562
|
restarted = true;
|
|
@@ -1496,6 +1602,27 @@ export class SqliteVFS {
|
|
|
1496
1602
|
if (resolved.inode?.kind !== 'directory') throw vfsError('ENOTDIR', parent);
|
|
1497
1603
|
}
|
|
1498
1604
|
|
|
1605
|
+
/**
|
|
1606
|
+
* POSIX sticky-bit restriction on a shared directory.
|
|
1607
|
+
*
|
|
1608
|
+
* Write permission on a directory is normally enough to remove or rename
|
|
1609
|
+
* anything inside it, which is why `/tmp` is `1777` and not `0777`: the
|
|
1610
|
+
* sticky bit narrows that to the entry's owner, the directory's owner, and
|
|
1611
|
+
* root. Nothing enforced it here, so a world-writable shared directory gave
|
|
1612
|
+
* every principal the ability to delete every other principal's files —
|
|
1613
|
+
* the mode said one thing and the filesystem did another.
|
|
1614
|
+
*/
|
|
1615
|
+
private checkStickyParentMutation(path: string, inode: INode, cred: VfsCred): void {
|
|
1616
|
+
if (cred.uid === 0) return;
|
|
1617
|
+
const parent = this.parentPath(normalizeVfsPath(path));
|
|
1618
|
+
if (parent === '') return;
|
|
1619
|
+
const parentInode = this.inodes.get(parent);
|
|
1620
|
+
if (!parentInode || (parentInode.mode & 0o1000) === 0) return;
|
|
1621
|
+
if (cred.uid !== parentInode.uid && cred.uid !== inode.uid) {
|
|
1622
|
+
throw vfsError('EPERM', normalizeVfsPath(path));
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1499
1626
|
/**
|
|
1500
1627
|
* Shared resolver for the boolean probes (exists/isDirectory/isFile/
|
|
1501
1628
|
* isSymlink). Resolution-structure failures — a missing or non-directory
|
|
@@ -1536,9 +1663,11 @@ export class SqliteVFS {
|
|
|
1536
1663
|
* under it changed in this DO lifetime). `revision('')` equals the
|
|
1537
1664
|
* global clock by construction (every mutation stamps all ancestors).
|
|
1538
1665
|
*/
|
|
1539
|
-
revision(path?: string): number {
|
|
1666
|
+
revision(path?: string, cred?: VfsCred): number {
|
|
1540
1667
|
if (path === undefined) return this._revision;
|
|
1541
|
-
|
|
1668
|
+
// A credentialed caller asks about its own view; a confined one asking
|
|
1669
|
+
// after /tmp/x means its own file, so the counter must be that file's.
|
|
1670
|
+
const p = cred === undefined ? normalizeVfsPath(path) : this.storageKey(path, cred);
|
|
1542
1671
|
if (p === '') return this._revision;
|
|
1543
1672
|
return this._pathRevisions.get(p) ?? 0;
|
|
1544
1673
|
}
|
|
@@ -1726,7 +1855,7 @@ export class SqliteVFS {
|
|
|
1726
1855
|
}
|
|
1727
1856
|
|
|
1728
1857
|
private mkdir(path: string, options: { recursive?: boolean; mode?: number } | undefined, cred: VfsCred): void {
|
|
1729
|
-
const normalized =
|
|
1858
|
+
const normalized = this.storageKey(path, cred);
|
|
1730
1859
|
this.assertMutationsAllowed([normalized]);
|
|
1731
1860
|
if (this.exists(normalized, cred)) return;
|
|
1732
1861
|
|
|
@@ -1832,7 +1961,7 @@ export class SqliteVFS {
|
|
|
1832
1961
|
|
|
1833
1962
|
private symlink(target: string, path: string, cred: VfsCred): void {
|
|
1834
1963
|
this.assertMutationsAllowed([path]);
|
|
1835
|
-
const normalized =
|
|
1964
|
+
const normalized = this.storageKey(path, cred);
|
|
1836
1965
|
const prior = this.checkAccess(normalized, 0, cred, { followLeaf: false, allowMissingLeaf: true });
|
|
1837
1966
|
if (prior.inode) throw vfsError('EEXIST', normalized);
|
|
1838
1967
|
this.checkParentAccess(normalized, cred);
|
|
@@ -2816,6 +2945,25 @@ export class SqliteVFS {
|
|
|
2816
2945
|
const inode = resolved.inode;
|
|
2817
2946
|
if (!inode) throw vfsError('ENOENT', path);
|
|
2818
2947
|
if (cred.uid !== 0 && cred.uid !== inode.uid) throw vfsError('EPERM', resolved.path);
|
|
2948
|
+
// A confined principal owns its own triad and nothing else. Refusing chmod
|
|
2949
|
+
// outright would be simpler and wrong: execution is gated on the x bit, so
|
|
2950
|
+
// a guest that writes build.sh and cannot chmod it cannot run it. What
|
|
2951
|
+
// must not happen is WIDENING past its own principal, so the group, other
|
|
2952
|
+
// and setuid/setgid/sticky bits keep the value they were provisioned with
|
|
2953
|
+
// while the owner triad moves freely. `u+x` works; `+x` and `777` do not.
|
|
2954
|
+
//
|
|
2955
|
+
// Refused, never clamped. Quietly narrowing a mutation to the part that
|
|
2956
|
+
// was allowed reports success for something other than what was asked, so
|
|
2957
|
+
// the refusal names the spelling that works instead.
|
|
2958
|
+
if (cred.uid !== 0 && this.confinedTmpRoots.has(cred.uid)) {
|
|
2959
|
+
const beyondOwner = (mode & 0o7777) & ~0o700;
|
|
2960
|
+
if (beyondOwner !== ((inode.mode & 0o7777) & ~0o700)) {
|
|
2961
|
+
throw vfsError(
|
|
2962
|
+
'EPERM',
|
|
2963
|
+
`${resolved.path}: mode change would grant permission outside your own principal; use u+x`,
|
|
2964
|
+
);
|
|
2965
|
+
}
|
|
2966
|
+
}
|
|
2819
2967
|
this.assertMutationsAllowed([inode.path]);
|
|
2820
2968
|
const full = inodeTypeBits(inode.kind) | (mode & 0o7777);
|
|
2821
2969
|
inode.mode = full;
|
|
@@ -2886,7 +3034,7 @@ export class SqliteVFS {
|
|
|
2886
3034
|
// Before the walk, deliberately — see VfsListPage.
|
|
2887
3035
|
const epoch = this._epoch;
|
|
2888
3036
|
const rev = this._revision;
|
|
2889
|
-
const from = after === null || after === undefined ? '' :
|
|
3037
|
+
const from = after === null || after === undefined ? '' : this.storageKey(after, cred);
|
|
2890
3038
|
// this.inodes is insertion-ordered, not path-ordered, so the sort is what
|
|
2891
3039
|
// makes `after` a resume key at all. Paid once per page against a map the
|
|
2892
3040
|
// DO already holds whole.
|
|
@@ -2908,8 +3056,15 @@ export class SqliteVFS {
|
|
|
2908
3056
|
} catch {
|
|
2909
3057
|
continue;
|
|
2910
3058
|
}
|
|
3059
|
+
// Reported in the caller's OWN path space. Enumerating raw storage keys
|
|
3060
|
+
// would name a private root the caller cannot address and does not know
|
|
3061
|
+
// it has, and a caller feeding such a path back would be asking about
|
|
3062
|
+
// someone else's tree. `null` means the path has no name for this
|
|
3063
|
+
// caller, which is the same OMIT the access check above performs.
|
|
3064
|
+
const logical = this.logicalPath(path, cred);
|
|
3065
|
+
if (logical === null) continue;
|
|
2911
3066
|
entries.push({
|
|
2912
|
-
path,
|
|
3067
|
+
path: logical,
|
|
2913
3068
|
kind: inode.kind,
|
|
2914
3069
|
size: inode.size,
|
|
2915
3070
|
rev: this._pathRevisions.get(path) ?? 0,
|
|
@@ -2919,7 +3074,7 @@ export class SqliteVFS {
|
|
|
2919
3074
|
}
|
|
2920
3075
|
|
|
2921
3076
|
private readdir(path: string, cred: VfsCred): { name: string; type: VfsInodeKind }[] {
|
|
2922
|
-
const np =
|
|
3077
|
+
const np = this.storageKey(path, cred);
|
|
2923
3078
|
const resolved = np ? this.checkAccess(np, 0o4, cred) : { path: '', inode: undefined };
|
|
2924
3079
|
const inode = resolved.inode;
|
|
2925
3080
|
if (inode && inode.kind !== 'directory') throw vfsError('ENOTDIR', path);
|
|
@@ -2927,7 +3082,7 @@ export class SqliteVFS {
|
|
|
2927
3082
|
if (!kids) {
|
|
2928
3083
|
// W2.5b diagnostic: empty children-set for a directory we expected
|
|
2929
3084
|
// to be populated.
|
|
2930
|
-
if ((
|
|
3085
|
+
if (installPipelineDiagEnabled()) {
|
|
2931
3086
|
// eslint-disable-next-line no-console
|
|
2932
3087
|
console.warn(
|
|
2933
3088
|
'[sqlite-vfs/W2.5b] readdir miss path=' + np +
|
|
@@ -2949,7 +3104,7 @@ export class SqliteVFS {
|
|
|
2949
3104
|
// This distinguishes (a) "children index broken" from (b) "inodes
|
|
2950
3105
|
// map lost entries".
|
|
2951
3106
|
if (
|
|
2952
|
-
(
|
|
3107
|
+
installPipelineDiagEnabled() &&
|
|
2953
3108
|
kids.size !== results.length
|
|
2954
3109
|
) {
|
|
2955
3110
|
// eslint-disable-next-line no-console
|
|
@@ -2977,22 +3132,24 @@ export class SqliteVFS {
|
|
|
2977
3132
|
const inode = resolved.inode;
|
|
2978
3133
|
if (!inode) throw vfsError('ENOENT', path);
|
|
2979
3134
|
this.checkParentAccess(resolved.path, cred);
|
|
3135
|
+
this.checkStickyParentMutation(resolved.path, inode, cred);
|
|
2980
3136
|
if (inode.isDir) throw vfsError('EISDIR', resolved.path);
|
|
2981
3137
|
this.writeBatch({ inodes: [], chunks: [], deletePaths: [resolved.path] }, cred);
|
|
2982
3138
|
}
|
|
2983
3139
|
|
|
2984
3140
|
private rmdir(path: string, cred: VfsCred): void {
|
|
2985
3141
|
this.assertMutationsAllowed([path]);
|
|
2986
|
-
const np =
|
|
3142
|
+
const np = this.storageKey(path, cred);
|
|
2987
3143
|
const resolved = this.checkAccess(np, 0, cred, { followLeaf: false });
|
|
2988
3144
|
this.checkParentAccess(resolved.path, cred);
|
|
3145
|
+
const inode = this.inodes.get(np);
|
|
3146
|
+
if (!inode) throw vfsError('ENOENT', path);
|
|
3147
|
+
this.checkStickyParentMutation(resolved.path, inode, cred);
|
|
2989
3148
|
// Check if empty using children index (O(1) instead of O(N))
|
|
2990
3149
|
const kids = this.children.get(np);
|
|
2991
3150
|
if (kids && kids.size > 0) {
|
|
2992
3151
|
throw vfsError('ENOTEMPTY', path);
|
|
2993
3152
|
}
|
|
2994
|
-
const inode = this.inodes.get(np);
|
|
2995
|
-
if (!inode) throw vfsError('ENOENT', path);
|
|
2996
3153
|
if (!inode.isDir) throw vfsError('ENOTDIR', path);
|
|
2997
3154
|
this.writeBatch({ inodes: [], chunks: [], deletePaths: [np] }, cred);
|
|
2998
3155
|
}
|
|
@@ -3018,6 +3175,7 @@ export class SqliteVFS {
|
|
|
3018
3175
|
const resolved = this.checkAccess(path, 0, cred, { followLeaf: false });
|
|
3019
3176
|
if (!resolved.inode) throw vfsError('ENOENT', normalizeVfsPath(path));
|
|
3020
3177
|
this.checkParentAccess(resolved.path, cred);
|
|
3178
|
+
this.checkStickyParentMutation(resolved.path, resolved.inode, cred);
|
|
3021
3179
|
|
|
3022
3180
|
const removable = this.collectSubtreeInodes([resolved.path]);
|
|
3023
3181
|
// The directories the recursive walk used to enumerate are exactly the
|
|
@@ -3067,6 +3225,7 @@ export class SqliteVFS {
|
|
|
3067
3225
|
}
|
|
3068
3226
|
const inode = this.inodes.get(oldPath);
|
|
3069
3227
|
if (!inode) throw new Error("ENOENT: " + oldPath);
|
|
3228
|
+
this.checkStickyParentMutation(oldPath, inode, cred);
|
|
3070
3229
|
|
|
3071
3230
|
// W-3 (WASI filesystem WASI): if newPath already exists, unlink it first so the
|
|
3072
3231
|
// SQL UPDATE doesn't conflict on inodes.path uniqueness. POSIX rename(2)
|
|
@@ -3077,6 +3236,7 @@ export class SqliteVFS {
|
|
|
3077
3236
|
// (the latter is rare but POSIX permits it for empty dirs).
|
|
3078
3237
|
const destInode = this.inodes.get(newPath);
|
|
3079
3238
|
if (destInode) {
|
|
3239
|
+
this.checkStickyParentMutation(newPath, destInode, cred);
|
|
3080
3240
|
if (destInode.isDir) {
|
|
3081
3241
|
// POSIX semantics: rename onto a non-empty dir is an error
|
|
3082
3242
|
// (ENOTEMPTY); rename onto an empty dir is allowed. We surface
|
|
@@ -3283,14 +3443,14 @@ export class SqliteVFS {
|
|
|
3283
3443
|
// ── Batch write (npm install fast path) ───────────────────────────────
|
|
3284
3444
|
|
|
3285
3445
|
private normalizeBatchInode(entry: BatchInodeEntry, cred: VfsCred): NormalizedBatchInodeEntry {
|
|
3286
|
-
const path =
|
|
3446
|
+
const path = this.storageKey(entry.path, cred);
|
|
3287
3447
|
const prior = this.inodes.get(path);
|
|
3288
3448
|
const newUid = cred.uid === 0 ? (entry.uid ?? 1000) : cred.uid;
|
|
3289
3449
|
const newGid = cred.uid === 0 ? (entry.gid ?? 1000) : cred.gid;
|
|
3290
3450
|
return {
|
|
3291
3451
|
...entry,
|
|
3292
3452
|
path,
|
|
3293
|
-
parentPath:
|
|
3453
|
+
parentPath: this.storageKey(entry.parentPath, cred),
|
|
3294
3454
|
mode: prior
|
|
3295
3455
|
? prior.mode
|
|
3296
3456
|
: inodeKind(entry) === 'symlink'
|
|
@@ -3324,7 +3484,7 @@ export class SqliteVFS {
|
|
|
3324
3484
|
};
|
|
3325
3485
|
|
|
3326
3486
|
for (const path of payload.deletePaths ?? []) {
|
|
3327
|
-
const normalized =
|
|
3487
|
+
const normalized = this.storageKey(path, cred);
|
|
3328
3488
|
const existing = this.checkAccess(normalized, 0, cred, {
|
|
3329
3489
|
followLeaf: false,
|
|
3330
3490
|
allowMissingLeaf: true,
|
|
@@ -3337,13 +3497,13 @@ export class SqliteVFS {
|
|
|
3337
3497
|
else checkParent(entry.path);
|
|
3338
3498
|
}
|
|
3339
3499
|
for (const chunk of payload.chunks) {
|
|
3340
|
-
const path =
|
|
3500
|
+
const path = this.storageKey(chunk.path, cred);
|
|
3341
3501
|
if (!pending.has(path)) this.checkAccess(path, 0o2, cred, { followLeaf: false });
|
|
3342
3502
|
}
|
|
3343
3503
|
return {
|
|
3344
3504
|
...payload,
|
|
3345
3505
|
inodes,
|
|
3346
|
-
deletePaths: payload.deletePaths?.map(
|
|
3506
|
+
deletePaths: payload.deletePaths?.map((path) => this.storageKey(path, cred)),
|
|
3347
3507
|
};
|
|
3348
3508
|
}
|
|
3349
3509
|
|
|
@@ -4118,21 +4278,34 @@ export class SqliteVFS {
|
|
|
4118
4278
|
let stagingScanComplete = true;
|
|
4119
4279
|
|
|
4120
4280
|
if (transactions < maximum) {
|
|
4121
|
-
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
|
|
4125
|
-
|
|
4126
|
-
|
|
4127
|
-
|
|
4128
|
-
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
|
|
4281
|
+
// The page is selected before the orphan predicates so a scan that finds
|
|
4282
|
+
// nothing still reads only one page, not the whole covering index.
|
|
4283
|
+
const pageRows = [...this.sql.exec(
|
|
4284
|
+
`SELECT page.content_id AS content_id,
|
|
4285
|
+
(NOT EXISTS (
|
|
4286
|
+
SELECT 1 FROM content_lifecycle AS lifecycle
|
|
4287
|
+
WHERE lifecycle.content_id = page.content_id
|
|
4288
|
+
)
|
|
4289
|
+
AND ${sqlNoInodeContentReference('page.content_id')}) AS orphaned
|
|
4290
|
+
FROM (
|
|
4291
|
+
SELECT chunks.content_id
|
|
4292
|
+
FROM file_chunks AS chunks
|
|
4293
|
+
WHERE chunks.content_id > ?
|
|
4294
|
+
GROUP BY chunks.content_id
|
|
4295
|
+
ORDER BY chunks.content_id
|
|
4296
|
+
LIMIT ?
|
|
4297
|
+
) AS page
|
|
4298
|
+
ORDER BY page.content_id`,
|
|
4299
|
+
this.orphanScanCursor,
|
|
4132
4300
|
CONTENT_IDS_PER_SQL_EXEC,
|
|
4133
4301
|
)];
|
|
4134
|
-
orphanScanComplete =
|
|
4135
|
-
|
|
4302
|
+
orphanScanComplete = pageRows.length < CONTENT_IDS_PER_SQL_EXEC;
|
|
4303
|
+
this.orphanScanCursor = orphanScanComplete
|
|
4304
|
+
? ''
|
|
4305
|
+
: String(pageRows[pageRows.length - 1].content_id);
|
|
4306
|
+
const contentIds = pageRows
|
|
4307
|
+
.filter((row) => Number(row.orphaned) === 1)
|
|
4308
|
+
.map((row) => String(row.content_id));
|
|
4136
4309
|
if (contentIds.length > 0) {
|
|
4137
4310
|
const candidates = contentIds
|
|
4138
4311
|
.map((_, index) => index === 0 ? 'SELECT ? AS content_id' : 'SELECT ?')
|
|
@@ -4341,7 +4514,7 @@ export class SqliteVFS {
|
|
|
4341
4514
|
/** Best-effort process.memoryUsage().heapUsed; 0 in DO contexts. */
|
|
4342
4515
|
private _safeHeapUsed(): number {
|
|
4343
4516
|
try {
|
|
4344
|
-
const mu =
|
|
4517
|
+
const mu = nodeHost.process?.memoryUsage?.();
|
|
4345
4518
|
return Number(mu?.heapUsed) || 0;
|
|
4346
4519
|
} catch {
|
|
4347
4520
|
return 0;
|
|
@@ -4536,7 +4709,7 @@ export class SqliteVFS {
|
|
|
4536
4709
|
// `_addToChildrenIndex` uses Set.add so repeated calls are idempotent;
|
|
4537
4710
|
// gating it on `prior === undefined` was the bug. Counters remain
|
|
4538
4711
|
// gated correctly so they don't double-count.
|
|
4539
|
-
const __diag = (
|
|
4712
|
+
const __diag = installPipelineDiagEnabled();
|
|
4540
4713
|
const replacedPaths = new Set<string>();
|
|
4541
4714
|
for (const entry of plan.inodes) {
|
|
4542
4715
|
const prior = this.inodes.get(entry.path);
|