@dsh-enhanced/plugin-control-plane 0.1.7 → 0.1.14
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 +153 -32
- package/bin/dsh-local-release-adapter.js +1381 -0
- package/cordis.patch.yml +1 -0
- package/lib/approval.d.ts +13 -0
- package/lib/approval.d.ts.map +1 -0
- package/lib/approval.js +79 -0
- package/lib/approval.js.map +1 -0
- package/lib/attestation.d.ts +14 -0
- package/lib/attestation.d.ts.map +1 -0
- package/lib/attestation.js +222 -0
- package/lib/attestation.js.map +1 -0
- package/lib/catalog-interpreter.d.ts +12 -0
- package/lib/catalog-interpreter.d.ts.map +1 -0
- package/lib/catalog-interpreter.js +100 -0
- package/lib/catalog-interpreter.js.map +1 -0
- package/lib/catalog.d.ts +95 -10
- package/lib/catalog.d.ts.map +1 -1
- package/lib/catalog.js +1031 -18
- package/lib/catalog.js.map +1 -1
- package/lib/cli.d.ts +9 -0
- package/lib/cli.d.ts.map +1 -1
- package/lib/cli.js +1160 -162
- package/lib/cli.js.map +1 -1
- package/lib/host-attestor.d.ts +13 -0
- package/lib/host-attestor.d.ts.map +1 -0
- package/lib/host-attestor.js +139 -0
- package/lib/host-attestor.js.map +1 -0
- package/lib/index.d.ts +9 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +9 -0
- package/lib/index.js.map +1 -1
- package/lib/lockfile.d.ts +7 -0
- package/lib/lockfile.d.ts.map +1 -0
- package/lib/lockfile.js +271 -0
- package/lib/lockfile.js.map +1 -0
- package/lib/release.d.ts +51 -0
- package/lib/release.d.ts.map +1 -0
- package/lib/release.js +1188 -0
- package/lib/release.js.map +1 -0
- package/lib/service.d.ts +9 -11
- package/lib/service.d.ts.map +1 -1
- package/lib/service.js +57 -29
- package/lib/service.js.map +1 -1
- package/lib/sqlite.d.ts +9 -0
- package/lib/sqlite.d.ts.map +1 -0
- package/lib/sqlite.js +709 -0
- package/lib/sqlite.js.map +1 -0
- package/lib/store.d.ts +258 -0
- package/lib/store.d.ts.map +1 -0
- package/lib/store.js +1848 -0
- package/lib/store.js.map +1 -0
- package/lib/tools.d.ts.map +1 -1
- package/lib/tools.js +23 -3
- package/lib/tools.js.map +1 -1
- package/lib/trust.d.ts +79 -0
- package/lib/trust.d.ts.map +1 -0
- package/lib/trust.js +477 -0
- package/lib/trust.js.map +1 -0
- package/lib/types.d.ts +740 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +2 -0
- package/lib/types.js.map +1 -0
- package/lib/version.d.ts +1 -1
- package/lib/version.d.ts.map +1 -1
- package/lib/version.js +1 -1
- package/lib/version.js.map +1 -1
- package/package.json +2 -2
package/lib/catalog.js
CHANGED
|
@@ -1,9 +1,240 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { closeSync, constants, fstatSync, readSync, realpathSync } from 'node:fs';
|
|
1
3
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { lstat, mkdir, open, readFile, readdir, realpath } from 'node:fs/promises';
|
|
5
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
7
|
+
import { openTrustedCatalogCommitInterpreter } from './catalog-interpreter.js';
|
|
8
|
+
export class CatalogAdmissionError extends Error {
|
|
9
|
+
code;
|
|
10
|
+
constructor(code, message) {
|
|
11
|
+
super(`plugin-control-plane catalog-admission[${code}]: ${message}`);
|
|
12
|
+
this.code = code;
|
|
13
|
+
this.name = 'CatalogAdmissionError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
3
16
|
const idPattern = /^[a-z0-9][a-z0-9-]{0,63}$/u;
|
|
4
17
|
const packagePattern = /^@[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/u;
|
|
5
|
-
const versionPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z
|
|
18
|
+
const versionPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:0|[1-9]\d*|[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u;
|
|
6
19
|
const integrityPattern = /^sha512-[A-Za-z0-9+/=]+$/u;
|
|
20
|
+
const digestPattern = /^[a-f0-9]{64}$/u;
|
|
21
|
+
const identityPattern = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/u;
|
|
22
|
+
const signaturePattern = /^[A-Za-z0-9+/]+={0,2}$/u;
|
|
23
|
+
const maximumCatalogBytes = 1_048_576;
|
|
24
|
+
const O_TMPFILE = 0o20200000;
|
|
25
|
+
const catalogCommitInterpreterLauncher = '/usr/bin/python3';
|
|
26
|
+
/**
|
|
27
|
+
* Linux reference commit broker. The fixed system launcher is resolved to a
|
|
28
|
+
* canonical root-owned interpreter, which is opened O_NOFOLLOW, hashed before
|
|
29
|
+
* and after use, and executed through its retained /proc fd with a minimal
|
|
30
|
+
* fixed environment and no shell. The
|
|
31
|
+
* desired and rollback files are O_TMPFILE descriptors; pathname hooks never
|
|
32
|
+
* select the bytes exchanged into the catalog. renameat2(RENAME_EXCHANGE)
|
|
33
|
+
* permits immediate verification and rollback if the target changed.
|
|
34
|
+
*
|
|
35
|
+
* Unix mode bits do not isolate mutually hostile processes sharing one uid. A
|
|
36
|
+
* production deployment that treats a continuously racing same-uid process
|
|
37
|
+
* as an adversary must place this broker and catalog parent under a separate
|
|
38
|
+
* uid (or otherwise make the parent unwritable to workers). This local helper
|
|
39
|
+
* closes deterministic pathname replacement seams and fails closed when the
|
|
40
|
+
* required Linux fd/syscall facilities are unavailable.
|
|
41
|
+
*/
|
|
42
|
+
const catalogCommitHelper = String.raw `
|
|
43
|
+
import ctypes, hashlib, os, stat, sys, time
|
|
44
|
+
AT_FDCWD = -100
|
|
45
|
+
AT_SYMLINK_FOLLOW = 0x400
|
|
46
|
+
RENAME_EXCHANGE = 0x2
|
|
47
|
+
def die(message):
|
|
48
|
+
raise RuntimeError(message)
|
|
49
|
+
def safe_name(value):
|
|
50
|
+
if not value or value in (".", "..") or "/" in value or "\0" in value:
|
|
51
|
+
die("invalid catalog commit name")
|
|
52
|
+
return value.encode()
|
|
53
|
+
def same(left, right):
|
|
54
|
+
return (left.st_dev, left.st_ino) == (right.st_dev, right.st_ino)
|
|
55
|
+
def stable(left, right):
|
|
56
|
+
return same(left, right) and (left.st_size, left.st_mtime_ns, left.st_ctime_ns) == (right.st_size, right.st_mtime_ns, right.st_ctime_ns)
|
|
57
|
+
def digest_fd(fd):
|
|
58
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
59
|
+
result = hashlib.sha256()
|
|
60
|
+
while True:
|
|
61
|
+
chunk = os.read(fd, 65536)
|
|
62
|
+
if not chunk: break
|
|
63
|
+
result.update(chunk)
|
|
64
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
65
|
+
return result.hexdigest()
|
|
66
|
+
def ensure_link(libc, descriptor, directory_fd, name, metadata, digest):
|
|
67
|
+
source = ("/proc/self/fd/%d" % descriptor).encode()
|
|
68
|
+
if libc.linkat(AT_FDCWD, source, directory_fd, name, AT_SYMLINK_FOLLOW) != 0:
|
|
69
|
+
if ctypes.get_errno() != 17:
|
|
70
|
+
die("could not link catalog attempt descriptor: errno %d" % ctypes.get_errno())
|
|
71
|
+
if not matches(snapshot_name(directory_fd, name), metadata, digest):
|
|
72
|
+
die("catalog attempt name belongs to an unknown inode")
|
|
73
|
+
def signal_reverse_window(attempt_fd, marker):
|
|
74
|
+
try:
|
|
75
|
+
fd = os.open(marker, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=attempt_fd)
|
|
76
|
+
except FileExistsError:
|
|
77
|
+
return
|
|
78
|
+
try: os.fsync(fd)
|
|
79
|
+
finally: os.close(fd)
|
|
80
|
+
os.fsync(attempt_fd)
|
|
81
|
+
def snapshot_name(directory_fd, name):
|
|
82
|
+
before = os.stat(name, dir_fd=directory_fd, follow_symlinks=False)
|
|
83
|
+
fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=directory_fd)
|
|
84
|
+
try:
|
|
85
|
+
opened = os.fstat(fd)
|
|
86
|
+
digest = digest_fd(fd)
|
|
87
|
+
finally:
|
|
88
|
+
os.close(fd)
|
|
89
|
+
after = os.stat(name, dir_fd=directory_fd, follow_symlinks=False)
|
|
90
|
+
if not stable(before, opened) or not stable(opened, after):
|
|
91
|
+
die("catalog pathname changed while it was verified")
|
|
92
|
+
return (after, digest)
|
|
93
|
+
def matches(snapshot, metadata, digest):
|
|
94
|
+
return same(snapshot[0], metadata) and snapshot[1] == digest
|
|
95
|
+
if sys.version_info < (3, 8):
|
|
96
|
+
die("Python 3.8 or newer is required")
|
|
97
|
+
if sys.platform != "linux" or len(sys.argv) < 2:
|
|
98
|
+
die("unsupported catalog commit platform")
|
|
99
|
+
desired_fd, catalog_fd, attempt_fd, before_fd, parent_fd = 3, 4, 5, 6, 8
|
|
100
|
+
desired = os.fstat(desired_fd)
|
|
101
|
+
catalog = os.fstat(catalog_fd)
|
|
102
|
+
before_copy = os.fstat(before_fd)
|
|
103
|
+
if not stat.S_ISREG(desired.st_mode) or not stat.S_ISREG(catalog.st_mode) or not stat.S_ISREG(before_copy.st_mode) \
|
|
104
|
+
or not stat.S_ISDIR(os.fstat(attempt_fd).st_mode) or not stat.S_ISDIR(os.fstat(parent_fd).st_mode) \
|
|
105
|
+
or not os.path.isdir("/proc/self/fd"):
|
|
106
|
+
die("descriptor filesystem is unavailable")
|
|
107
|
+
libc = ctypes.CDLL(None, use_errno=True)
|
|
108
|
+
mode = sys.argv[1]
|
|
109
|
+
if mode == "prepare":
|
|
110
|
+
if len(sys.argv) != 8: die("invalid prepare request")
|
|
111
|
+
desired_digest, expected_digest = sys.argv[2], sys.argv[3]
|
|
112
|
+
expected_dev, expected_ino = int(sys.argv[4]), int(sys.argv[5])
|
|
113
|
+
desired_name, before_name = safe_name(sys.argv[6]), safe_name(sys.argv[7])
|
|
114
|
+
if digest_fd(desired_fd) != desired_digest: die("desired catalog digest changed")
|
|
115
|
+
if (catalog.st_dev, catalog.st_ino) != (expected_dev, expected_ino) or digest_fd(catalog_fd) != expected_digest:
|
|
116
|
+
die("catalog descriptor identity changed")
|
|
117
|
+
if (before_copy.st_mode & 0o777) != 0o600 or digest_fd(before_fd) != expected_digest:
|
|
118
|
+
die("catalog rollback descriptor is invalid")
|
|
119
|
+
ensure_link(libc, desired_fd, attempt_fd, desired_name, desired, desired_digest)
|
|
120
|
+
ensure_link(libc, before_fd, attempt_fd, before_name, before_copy, expected_digest)
|
|
121
|
+
os.fsync(attempt_fd)
|
|
122
|
+
print("{}")
|
|
123
|
+
elif mode == "commit":
|
|
124
|
+
if len(sys.argv) != 12: die("invalid commit request")
|
|
125
|
+
expected_dev, expected_ino = int(sys.argv[2]), int(sys.argv[3])
|
|
126
|
+
expected_digest, desired_digest = sys.argv[4], sys.argv[5]
|
|
127
|
+
desired_name, before_name, target, reverse_marker = (safe_name(value) for value in sys.argv[6:10])
|
|
128
|
+
stage_name = safe_name("stage")
|
|
129
|
+
try: pause_ms, reverse_pause_ms = int(sys.argv[10]), int(sys.argv[11])
|
|
130
|
+
except ValueError: die("invalid exchange pause")
|
|
131
|
+
if pause_ms < 0 or pause_ms > 1000 or reverse_pause_ms < 0 or reverse_pause_ms > 1000:
|
|
132
|
+
die("invalid exchange pause")
|
|
133
|
+
renameat2 = getattr(libc, "renameat2", None)
|
|
134
|
+
if renameat2 is None: die("renameat2 is unavailable")
|
|
135
|
+
if (catalog.st_dev, catalog.st_ino) != (expected_dev, expected_ino) or digest_fd(catalog_fd) != expected_digest:
|
|
136
|
+
die("catalog descriptor identity changed")
|
|
137
|
+
if digest_fd(desired_fd) != desired_digest: die("desired catalog digest changed")
|
|
138
|
+
if (before_copy.st_mode & 0o777) != 0o600 or digest_fd(before_fd) != expected_digest:
|
|
139
|
+
die("catalog rollback descriptor is invalid")
|
|
140
|
+
desired_snapshot = snapshot_name(attempt_fd, desired_name)
|
|
141
|
+
before_snapshot = snapshot_name(attempt_fd, before_name)
|
|
142
|
+
if not matches(desired_snapshot, desired, desired_digest): die("catalog desired attempt changed before exchange")
|
|
143
|
+
if not matches(before_snapshot, before_copy, expected_digest): die("catalog before-state recovery changed before exchange")
|
|
144
|
+
if renameat2(attempt_fd, desired_name, parent_fd, target, RENAME_EXCHANGE) != 0:
|
|
145
|
+
die("catalog exchange failed: errno %d" % ctypes.get_errno())
|
|
146
|
+
# Rename the displaced inode away from the reusable desired name. This
|
|
147
|
+
# preserves the exact desired inode at a stable name for retries while the
|
|
148
|
+
# stage name carries whatever was atomically displaced from canonical.
|
|
149
|
+
os.rename(desired_name, stage_name, src_dir_fd=attempt_fd, dst_dir_fd=attempt_fd)
|
|
150
|
+
if pause_ms: time.sleep(pause_ms / 1000.0)
|
|
151
|
+
current = snapshot_name(parent_fd, target)
|
|
152
|
+
displaced = snapshot_name(attempt_fd, stage_name)
|
|
153
|
+
if matches(current, desired, desired_digest) and matches(displaced, catalog, expected_digest):
|
|
154
|
+
os.fsync(parent_fd); os.fsync(attempt_fd); print("{}")
|
|
155
|
+
elif not matches(displaced, catalog, expected_digest):
|
|
156
|
+
target_now = snapshot_name(parent_fd, target); attempt_now = snapshot_name(attempt_fd, stage_name)
|
|
157
|
+
if not matches(target_now, desired, desired_digest) or not matches(attempt_now, displaced[0], displaced[1]):
|
|
158
|
+
die("catalog changed after exchange; journal-named attempt files were preserved")
|
|
159
|
+
if reverse_pause_ms:
|
|
160
|
+
signal_reverse_window(attempt_fd, reverse_marker); time.sleep(reverse_pause_ms / 1000.0)
|
|
161
|
+
if renameat2(attempt_fd, stage_name, parent_fd, target, RENAME_EXCHANGE) != 0:
|
|
162
|
+
die("catalog reverse exchange failed; journal-named attempt files were preserved")
|
|
163
|
+
restored = snapshot_name(parent_fd, target); reverse_displaced = snapshot_name(attempt_fd, stage_name)
|
|
164
|
+
os.fsync(parent_fd); os.fsync(attempt_fd)
|
|
165
|
+
if matches(restored, displaced[0], displaced[1]) and matches(reverse_displaced, desired, desired_digest):
|
|
166
|
+
die("competing catalog was restored by exact reverse exchange")
|
|
167
|
+
die("catalog changed during reverse exchange; journal-named attempt files were preserved")
|
|
168
|
+
else:
|
|
169
|
+
die("catalog target changed after exchange and was left untouched; journal-named attempt files were preserved")
|
|
170
|
+
else:
|
|
171
|
+
die("unsupported catalog commit mode")
|
|
172
|
+
`;
|
|
173
|
+
function openCatalogCommitInterpreter() {
|
|
174
|
+
if (process.platform !== 'linux') {
|
|
175
|
+
throw new CatalogAdmissionError('unsafe-catalog', 'descriptor-backed catalog commits require Linux procfs');
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
realpathSync('/proc/self/fd');
|
|
179
|
+
return openTrustedCatalogCommitInterpreter(catalogCommitInterpreterLauncher).descriptor;
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
if (error instanceof CatalogAdmissionError)
|
|
183
|
+
throw error;
|
|
184
|
+
const detail = error instanceof Error ? `: ${error.message}` : '';
|
|
185
|
+
throw new CatalogAdmissionError('unsafe-catalog', `catalog commit interpreter is unavailable or unsafe${detail}`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function descriptorSnapshot(descriptor) {
|
|
189
|
+
const metadata = fstatSync(descriptor, { bigint: true });
|
|
190
|
+
if (!metadata.isFile() || metadata.size < 1n || metadata.size > 32n * 1024n * 1024n) {
|
|
191
|
+
throw new CatalogAdmissionError('unsafe-catalog', 'catalog commit interpreter has an invalid executable image');
|
|
192
|
+
}
|
|
193
|
+
const bytes = Buffer.alloc(Number(metadata.size));
|
|
194
|
+
let offset = 0;
|
|
195
|
+
while (offset < bytes.length) {
|
|
196
|
+
const count = readSync(descriptor, bytes, offset, bytes.length - offset, offset);
|
|
197
|
+
if (count === 0)
|
|
198
|
+
throw new CatalogAdmissionError('unsafe-catalog', 'catalog commit interpreter ended early');
|
|
199
|
+
offset += count;
|
|
200
|
+
}
|
|
201
|
+
return { dev: metadata.dev, ino: metadata.ino, size: metadata.size, mtimeNs: metadata.mtimeNs, ctimeNs: metadata.ctimeNs,
|
|
202
|
+
sha256: createHash('sha256').update(bytes).digest('hex') };
|
|
203
|
+
}
|
|
204
|
+
function runCatalogCommitHelper(mode, desired, catalog, attemptDirectory, beforeCopy, parentDirectory, arguments_) {
|
|
205
|
+
const interpreter = openCatalogCommitInterpreter();
|
|
206
|
+
try {
|
|
207
|
+
const before = descriptorSnapshot(interpreter);
|
|
208
|
+
const result = spawnSync('/proc/self/fd/7', ['-I', '-S', '-E', '-c', catalogCommitHelper, mode, ...arguments_], {
|
|
209
|
+
env: { LANG: 'C', LC_ALL: 'C' }, shell: false, encoding: 'utf8', maxBuffer: 16_384,
|
|
210
|
+
timeout: 5_000,
|
|
211
|
+
stdio: ['ignore', 'pipe', 'pipe', desired.fd, catalog.fd, attemptDirectory.fd, beforeCopy.fd, interpreter, parentDirectory.fd],
|
|
212
|
+
});
|
|
213
|
+
const after = descriptorSnapshot(interpreter);
|
|
214
|
+
if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size
|
|
215
|
+
|| before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs || before.sha256 !== after.sha256) {
|
|
216
|
+
throw new CatalogAdmissionError('unsafe-catalog', 'catalog commit interpreter changed during execution');
|
|
217
|
+
}
|
|
218
|
+
if (result.error !== undefined || result.status !== 0 || result.signal !== null) {
|
|
219
|
+
const detail = result.stderr.trim().split('\n').at(-1)?.slice(0, 1_000);
|
|
220
|
+
throw new CatalogAdmissionError('conflict', `descriptor-backed catalog ${mode} failed${detail === undefined || detail === '' ? '' : `: ${detail}`}`);
|
|
221
|
+
}
|
|
222
|
+
let output;
|
|
223
|
+
try {
|
|
224
|
+
output = JSON.parse(result.stdout);
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
throw new CatalogAdmissionError('unsafe-catalog', 'catalog commit helper returned invalid output');
|
|
228
|
+
}
|
|
229
|
+
if (typeof output !== 'object' || output === null || Array.isArray(output)
|
|
230
|
+
|| Object.keys(output).length !== 0) {
|
|
231
|
+
throw new CatalogAdmissionError('unsafe-catalog', 'catalog commit helper returned an invalid result');
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
finally {
|
|
235
|
+
closeSync(interpreter);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
7
238
|
function text(value, label) {
|
|
8
239
|
if (typeof value !== 'string' || value.normalize('NFC').trim() === '')
|
|
9
240
|
throw new Error(`plugin-control-plane: ${label} must be a non-empty string`);
|
|
@@ -15,6 +246,73 @@ function textList(value, label) {
|
|
|
15
246
|
}
|
|
16
247
|
return [...new Set(value.map(item => item.normalize('NFC').trim()))].sort();
|
|
17
248
|
}
|
|
249
|
+
function exactFields(value, fields, label) {
|
|
250
|
+
if (Object.keys(value).sort().join('\0') !== [...fields].sort().join('\0')) {
|
|
251
|
+
throw new Error(`plugin-control-plane: ${label} has unknown or missing fields`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
function canonicalFileUrl(value, label) {
|
|
255
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > 2_000
|
|
256
|
+
|| value !== value.normalize('NFC').trim() || value.includes('\0') || value.includes('\r') || value.includes('\n')) {
|
|
257
|
+
throw new Error(`plugin-control-plane: ${label} must be a canonical absolute file URL`);
|
|
258
|
+
}
|
|
259
|
+
let url;
|
|
260
|
+
let path;
|
|
261
|
+
try {
|
|
262
|
+
url = new URL(value);
|
|
263
|
+
path = fileURLToPath(url);
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
throw new Error(`plugin-control-plane: ${label} must be a canonical absolute file URL`);
|
|
267
|
+
}
|
|
268
|
+
if (url.protocol !== 'file:' || url.username !== '' || url.password !== '' || url.host !== ''
|
|
269
|
+
|| url.search !== '' || url.hash !== '' || !isAbsolute(path) || path === '/' || resolve(path) !== path
|
|
270
|
+
|| pathToFileURL(path).href !== value) {
|
|
271
|
+
throw new Error(`plugin-control-plane: ${label} must be a canonical absolute file URL`);
|
|
272
|
+
}
|
|
273
|
+
return { url: value, path };
|
|
274
|
+
}
|
|
275
|
+
function catalogRegistry(value, packageName, version, label) {
|
|
276
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
277
|
+
throw new Error(`plugin-control-plane: ${label}.registry must be an object`);
|
|
278
|
+
}
|
|
279
|
+
const item = value;
|
|
280
|
+
exactFields(item, ['id', 'locator', 'reference'], `${label}.registry`);
|
|
281
|
+
const id = text(item.id, `${label}.registry.id`);
|
|
282
|
+
if (!identityPattern.test(id))
|
|
283
|
+
throw new Error(`plugin-control-plane: ${label}.registry.id is invalid`);
|
|
284
|
+
if (typeof item.locator !== 'string')
|
|
285
|
+
throw new Error(`plugin-control-plane: ${label}.registry.locator is invalid`);
|
|
286
|
+
let protocol;
|
|
287
|
+
try {
|
|
288
|
+
protocol = new URL(item.locator).protocol;
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
throw new Error(`plugin-control-plane: ${label}.registry.locator is invalid`);
|
|
292
|
+
}
|
|
293
|
+
if (protocol === 'file:') {
|
|
294
|
+
const locator = canonicalFileUrl(item.locator, `${label}.registry.locator`);
|
|
295
|
+
const reference = canonicalFileUrl(item.reference, `${label}.registry.reference`);
|
|
296
|
+
const suffix = relative(locator.path, reference.path);
|
|
297
|
+
if (suffix === '' || suffix === '..' || suffix.startsWith(`..${sep}`) || isAbsolute(suffix)
|
|
298
|
+
|| reference.path !== join(locator.path, 'packages', encodeURIComponent(packageName), version, 'package.tgz')) {
|
|
299
|
+
throw new Error(`plugin-control-plane: ${label}.registry.reference must be the package's immutable object under its registry locator`);
|
|
300
|
+
}
|
|
301
|
+
return Object.freeze({ id, locator: locator.url, reference: reference.url });
|
|
302
|
+
}
|
|
303
|
+
let locator;
|
|
304
|
+
try {
|
|
305
|
+
locator = new URL(item.locator);
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
throw new Error(`plugin-control-plane: ${label}.registry.locator is invalid`);
|
|
309
|
+
}
|
|
310
|
+
if (locator.protocol !== 'https:' || locator.username !== '' || locator.password !== '' || locator.search !== '' || locator.hash !== ''
|
|
311
|
+
|| (locator.href !== item.locator && locator.href !== `${item.locator}/`) || item.reference !== `${packageName}@${version}`) {
|
|
312
|
+
throw new Error(`plugin-control-plane: ${label}.registry must use a bounded HTTPS registry package reference`);
|
|
313
|
+
}
|
|
314
|
+
return Object.freeze({ id, locator: item.locator, reference: item.reference });
|
|
315
|
+
}
|
|
18
316
|
function catalogPackage(value, label) {
|
|
19
317
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
20
318
|
throw new Error(`plugin-control-plane: ${label} must be an object`);
|
|
@@ -26,7 +324,8 @@ function catalogPackage(value, label) {
|
|
|
26
324
|
if (!packagePattern.test(packageName) || !versionPattern.test(version) || !integrityPattern.test(integrity)) {
|
|
27
325
|
throw new Error(`plugin-control-plane: ${label} must pin package, exact version, and sha512 integrity`);
|
|
28
326
|
}
|
|
29
|
-
|
|
327
|
+
const registry = item.registry === undefined ? undefined : catalogRegistry(item.registry, packageName, version, label);
|
|
328
|
+
return Object.freeze({ package: packageName, version, integrity, ...(registry === undefined ? {} : { registry }) });
|
|
30
329
|
}
|
|
31
330
|
function catalogRequirements(value, primary, label) {
|
|
32
331
|
if (value === undefined)
|
|
@@ -67,19 +366,739 @@ export function parseCatalog(value) {
|
|
|
67
366
|
});
|
|
68
367
|
return Object.freeze({ schemaVersion: 1, entries: Object.freeze(entries) });
|
|
69
368
|
}
|
|
369
|
+
function catalogDigest(catalog) {
|
|
370
|
+
return createHash('sha256').update(JSON.stringify(catalog)).digest('hex');
|
|
371
|
+
}
|
|
372
|
+
function canonical(value) {
|
|
373
|
+
if (Array.isArray(value))
|
|
374
|
+
return `[${value.map(canonical).join(',')}]`;
|
|
375
|
+
if (typeof value === 'object' && value !== null) {
|
|
376
|
+
return `{${Object.entries(value).filter(([, item]) => item !== undefined)
|
|
377
|
+
.sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => `${JSON.stringify(key)}:${canonical(item)}`).join(',')}}`;
|
|
378
|
+
}
|
|
379
|
+
return JSON.stringify(value);
|
|
380
|
+
}
|
|
381
|
+
function admissionText(value, label, pattern, maximum = 2_000) {
|
|
382
|
+
if (typeof value !== 'string' || Buffer.byteLength(value) > maximum || !pattern.test(value)) {
|
|
383
|
+
throw new CatalogAdmissionError('invalid-input', `${label} is invalid`);
|
|
384
|
+
}
|
|
385
|
+
return value;
|
|
386
|
+
}
|
|
387
|
+
function admissionInteger(value, label) {
|
|
388
|
+
if (!Number.isSafeInteger(value) || Number(value) < 1)
|
|
389
|
+
throw new CatalogAdmissionError('invalid-input', `${label} must be a positive safe integer`);
|
|
390
|
+
return Number(value);
|
|
391
|
+
}
|
|
392
|
+
function exactCandidate(value) {
|
|
393
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
394
|
+
throw new CatalogAdmissionError('invalid-input', 'candidate must be an object');
|
|
395
|
+
const item = value;
|
|
396
|
+
const fields = ['authorities', 'capabilities', 'dshBaseline', 'id', 'integrity', 'package',
|
|
397
|
+
...(item.registry === undefined ? [] : ['registry']), 'requires', 'version'].sort();
|
|
398
|
+
if (Object.keys(item).sort().join('\0') !== fields.join('\0')) {
|
|
399
|
+
throw new CatalogAdmissionError('invalid-input', 'candidate has unknown or missing fields');
|
|
400
|
+
}
|
|
401
|
+
try {
|
|
402
|
+
return parseCatalog({ schemaVersion: 1, entries: [value] }).entries[0];
|
|
403
|
+
}
|
|
404
|
+
catch (error) {
|
|
405
|
+
throw new CatalogAdmissionError('invalid-input', error instanceof Error ? error.message : 'candidate is invalid');
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
export function assertCatalogIdentity(catalog) {
|
|
409
|
+
const packages = new Set();
|
|
410
|
+
for (const entry of catalog.entries) {
|
|
411
|
+
if (packages.has(entry.package)) {
|
|
412
|
+
throw new CatalogAdmissionError('conflict', `catalog contains duplicate package identity ${entry.package}`);
|
|
413
|
+
}
|
|
414
|
+
packages.add(entry.package);
|
|
415
|
+
exactSha512(entry.integrity, `${entry.id}.integrity`);
|
|
416
|
+
for (const requirement of entry.requires)
|
|
417
|
+
exactSha512(requirement.integrity, `${entry.id}.requires integrity`);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
function exactSha512(value, label) {
|
|
421
|
+
const encoded = value.slice('sha512-'.length);
|
|
422
|
+
const bytes = Buffer.from(encoded, 'base64');
|
|
423
|
+
if (bytes.length !== 64 || bytes.toString('base64') !== encoded) {
|
|
424
|
+
throw new CatalogAdmissionError('invalid-input', `${label} must be a canonical 64-byte sha512 integrity`);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
async function canonicalOwnerCatalogPath(path) {
|
|
428
|
+
if (typeof path !== 'string' || !isAbsolute(path) || path === '/' || resolve(path) !== path) {
|
|
429
|
+
throw new CatalogAdmissionError('unsafe-catalog', 'catalog path must be absolute and normalized');
|
|
430
|
+
}
|
|
431
|
+
let actual;
|
|
432
|
+
try {
|
|
433
|
+
actual = await realpath(path);
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
throw new CatalogAdmissionError('unsafe-catalog', 'catalog path must identify an existing canonical owner file');
|
|
437
|
+
}
|
|
438
|
+
if (actual !== path)
|
|
439
|
+
throw new CatalogAdmissionError('unsafe-catalog', 'catalog path must not traverse symbolic links');
|
|
440
|
+
return path;
|
|
441
|
+
}
|
|
442
|
+
async function openOwnerCatalogDirectory(path) {
|
|
443
|
+
let actual;
|
|
444
|
+
try {
|
|
445
|
+
actual = await realpath(path);
|
|
446
|
+
}
|
|
447
|
+
catch {
|
|
448
|
+
throw new CatalogAdmissionError('unsafe-catalog', 'catalog directory must exist');
|
|
449
|
+
}
|
|
450
|
+
if (actual !== path)
|
|
451
|
+
throw new CatalogAdmissionError('unsafe-catalog', 'catalog directory must not traverse symbolic links');
|
|
452
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
453
|
+
try {
|
|
454
|
+
const metadata = await handle.stat();
|
|
455
|
+
const uid = process.getuid?.();
|
|
456
|
+
if (!metadata.isDirectory() || (metadata.mode & 0o022) !== 0 || (uid !== undefined && metadata.uid !== uid)) {
|
|
457
|
+
throw new CatalogAdmissionError('unsafe-catalog', 'catalog directory must be owner-controlled and not group/world writable');
|
|
458
|
+
}
|
|
459
|
+
return handle;
|
|
460
|
+
}
|
|
461
|
+
catch (error) {
|
|
462
|
+
await handle.close();
|
|
463
|
+
throw error;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
function acquireKernelAdmissionLock(handle) {
|
|
467
|
+
const interpreter = openCatalogCommitInterpreter();
|
|
468
|
+
try {
|
|
469
|
+
const before = descriptorSnapshot(interpreter);
|
|
470
|
+
const result = spawnSync('/proc/self/fd/4', ['-I', '-S', '-E', '-c',
|
|
471
|
+
'import fcntl; fcntl.flock(3, fcntl.LOCK_EX | fcntl.LOCK_NB)'], {
|
|
472
|
+
env: { LANG: 'C', LC_ALL: 'C' }, shell: false, encoding: 'utf8', timeout: 5_000,
|
|
473
|
+
stdio: ['ignore', 'pipe', 'pipe', handle.fd, interpreter],
|
|
474
|
+
});
|
|
475
|
+
const after = descriptorSnapshot(interpreter);
|
|
476
|
+
if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size
|
|
477
|
+
|| before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs || before.sha256 !== after.sha256) {
|
|
478
|
+
throw new CatalogAdmissionError('unsafe-catalog', 'catalog lock interpreter changed during execution');
|
|
479
|
+
}
|
|
480
|
+
if (result.error !== undefined || result.status !== 0 || result.signal !== null) {
|
|
481
|
+
throw new CatalogAdmissionError('conflict', 'another live catalog admission owns the kernel lock');
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
finally {
|
|
485
|
+
closeSync(interpreter);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
async function openAdmissionJournalDirectory(catalogPath, parent) {
|
|
489
|
+
const path = join(dirname(catalogPath), `.${basename(catalogPath)}.admissions`);
|
|
490
|
+
try {
|
|
491
|
+
await mkdir(path, { mode: 0o700 });
|
|
492
|
+
}
|
|
493
|
+
catch (error) {
|
|
494
|
+
if (error.code !== 'EEXIST')
|
|
495
|
+
throw error;
|
|
496
|
+
}
|
|
497
|
+
await parent.sync();
|
|
498
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
499
|
+
try {
|
|
500
|
+
const metadata = await handle.stat();
|
|
501
|
+
const uid = process.getuid?.();
|
|
502
|
+
if (!metadata.isDirectory() || (metadata.mode & 0o777) !== 0o700 || (uid !== undefined && metadata.uid !== uid)
|
|
503
|
+
|| await realpath(path) !== path)
|
|
504
|
+
throw new CatalogAdmissionError('unsafe-catalog', 'catalog admission journal directory is unsafe');
|
|
505
|
+
return { path, handle };
|
|
506
|
+
}
|
|
507
|
+
catch (error) {
|
|
508
|
+
await handle.close();
|
|
509
|
+
throw error;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
async function openAttemptDirectory(journalDirectory, name) {
|
|
513
|
+
const path = join(journalDirectory.path, name);
|
|
514
|
+
try {
|
|
515
|
+
await mkdir(path, { mode: 0o700 });
|
|
516
|
+
}
|
|
517
|
+
catch (error) {
|
|
518
|
+
if (error.code !== 'EEXIST')
|
|
519
|
+
throw error;
|
|
520
|
+
}
|
|
521
|
+
await journalDirectory.handle.sync();
|
|
522
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
523
|
+
try {
|
|
524
|
+
const metadata = await handle.stat();
|
|
525
|
+
const uid = process.getuid?.();
|
|
526
|
+
if (!metadata.isDirectory() || (metadata.mode & 0o777) !== 0o700 || (uid !== undefined && metadata.uid !== uid)
|
|
527
|
+
|| await realpath(path) !== path)
|
|
528
|
+
throw new CatalogAdmissionError('unsafe-catalog', 'catalog attempt directory is unsafe');
|
|
529
|
+
return { path, handle };
|
|
530
|
+
}
|
|
531
|
+
catch (error) {
|
|
532
|
+
await handle.close();
|
|
533
|
+
throw error;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
function journalRecord(input, catalogPath, candidate, evidence) {
|
|
537
|
+
const binding = admissionBinding(input, catalogPath, candidate);
|
|
538
|
+
return Object.freeze({ schemaVersion: 1, transitionId: binding.transitionId, bindingDigest: binding.bindingDigest, evidence });
|
|
539
|
+
}
|
|
540
|
+
function attemptId(bindingDigest) {
|
|
541
|
+
return createHash('sha256').update(`dsh-catalog-attempt-v2\0${bindingDigest}`).digest('hex');
|
|
542
|
+
}
|
|
543
|
+
function attemptDirectoryName(catalogPath, id) {
|
|
544
|
+
return `.${basename(catalogPath)}.admission-${id}`;
|
|
545
|
+
}
|
|
546
|
+
function attemptJournalRecord(input, catalogPath, candidate, evidence, before, desired, rollback, parent) {
|
|
547
|
+
const binding = admissionBinding(input, catalogPath, candidate);
|
|
548
|
+
const id = attemptId(binding.bindingDigest);
|
|
549
|
+
return Object.freeze({
|
|
550
|
+
schemaVersion: 2, transitionId: binding.transitionId, bindingDigest: binding.bindingDigest, evidence, attemptId: id, catalogPath,
|
|
551
|
+
attemptDirectoryName: attemptDirectoryName(catalogPath, id),
|
|
552
|
+
names: { desired: 'desired', stage: 'stage', before: 'before', reverseMarker: 'reverse-ready' },
|
|
553
|
+
parent: { dev: String(parent.dev), ino: String(parent.ino) },
|
|
554
|
+
expectedBefore: { dev: String(before.dev), ino: String(before.ino), fileDigest: before.fileDigest },
|
|
555
|
+
desired: { dev: String(desired.dev), ino: String(desired.ino), fileDigest: desired.fileDigest },
|
|
556
|
+
rollback: { dev: String(rollback.dev), ino: String(rollback.ino), fileDigest: rollback.fileDigest },
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
async function readAdmissionJournal(path) {
|
|
560
|
+
let handle;
|
|
561
|
+
try {
|
|
562
|
+
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
563
|
+
}
|
|
564
|
+
catch (error) {
|
|
565
|
+
if (error.code === 'ENOENT')
|
|
566
|
+
return undefined;
|
|
567
|
+
throw error;
|
|
568
|
+
}
|
|
569
|
+
try {
|
|
570
|
+
const metadata = await handle.stat();
|
|
571
|
+
const uid = process.getuid?.();
|
|
572
|
+
if (!metadata.isFile() || metadata.nlink !== 1 || (metadata.mode & 0o777) !== 0o600
|
|
573
|
+
|| (uid !== undefined && metadata.uid !== uid) || metadata.size < 1 || metadata.size > maximumCatalogBytes) {
|
|
574
|
+
throw new CatalogAdmissionError('unsafe-catalog', 'catalog admission journal is unsafe');
|
|
575
|
+
}
|
|
576
|
+
let value;
|
|
577
|
+
try {
|
|
578
|
+
value = JSON.parse(await handle.readFile({ encoding: 'utf8' }));
|
|
579
|
+
}
|
|
580
|
+
catch {
|
|
581
|
+
throw new CatalogAdmissionError('unsafe-catalog', 'catalog admission journal is corrupt');
|
|
582
|
+
}
|
|
583
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
584
|
+
throw new CatalogAdmissionError('unsafe-catalog', 'catalog admission journal is corrupt');
|
|
585
|
+
const item = value;
|
|
586
|
+
const common = typeof item.transitionId === 'string' && digestPattern.test(item.transitionId)
|
|
587
|
+
&& typeof item.bindingDigest === 'string' && digestPattern.test(item.bindingDigest)
|
|
588
|
+
&& typeof item.evidence === 'object' && item.evidence !== null;
|
|
589
|
+
const v1 = item.schemaVersion === 1
|
|
590
|
+
&& Object.keys(item).sort().join('\0') === ['bindingDigest', 'evidence', 'schemaVersion', 'transitionId'].join('\0');
|
|
591
|
+
const names = item.names;
|
|
592
|
+
const safeAttemptName = (name) => typeof name === 'string' && /^[a-z][a-z-]{0,31}$/u.test(name);
|
|
593
|
+
const identity = (entry) => typeof entry === 'object' && entry !== null && !Array.isArray(entry)
|
|
594
|
+
&& Object.keys(entry).sort().join('\0') === ['dev', 'fileDigest', 'ino'].join('\0')
|
|
595
|
+
&& typeof entry.dev === 'string' && /^\d+$/u.test(String(entry.dev))
|
|
596
|
+
&& typeof entry.ino === 'string' && /^\d+$/u.test(String(entry.ino))
|
|
597
|
+
&& typeof entry.fileDigest === 'string' && digestPattern.test(String(entry.fileDigest));
|
|
598
|
+
const v2 = item.schemaVersion === 2
|
|
599
|
+
&& Object.keys(item).sort().join('\0') === ['attemptDirectoryName', 'attemptId', 'bindingDigest', 'catalogPath', 'desired', 'evidence', 'expectedBefore', 'names', 'parent', 'rollback', 'schemaVersion', 'transitionId'].join('\0')
|
|
600
|
+
&& typeof item.attemptId === 'string' && digestPattern.test(item.attemptId)
|
|
601
|
+
&& typeof item.catalogPath === 'string' && isAbsolute(item.catalogPath) && resolve(item.catalogPath) === item.catalogPath
|
|
602
|
+
&& typeof item.attemptDirectoryName === 'string' && item.attemptDirectoryName === attemptDirectoryName(item.catalogPath, item.attemptId)
|
|
603
|
+
&& names !== undefined && Object.keys(names).sort().join('\0') === ['before', 'desired', 'reverseMarker', 'stage'].join('\0')
|
|
604
|
+
&& safeAttemptName(names.desired) && safeAttemptName(names.stage) && safeAttemptName(names.before) && safeAttemptName(names.reverseMarker)
|
|
605
|
+
&& identity(item.expectedBefore) && identity(item.desired) && identity(item.rollback)
|
|
606
|
+
&& typeof item.parent === 'object' && item.parent !== null && !Array.isArray(item.parent)
|
|
607
|
+
&& Object.keys(item.parent).sort().join('\0') === ['dev', 'ino'].join('\0')
|
|
608
|
+
&& typeof item.parent.dev === 'string' && /^\d+$/u.test(String(item.parent.dev))
|
|
609
|
+
&& typeof item.parent.ino === 'string' && /^\d+$/u.test(String(item.parent.ino));
|
|
610
|
+
if (v2) {
|
|
611
|
+
if (item.attemptId !== attemptId(String(item.bindingDigest))) {
|
|
612
|
+
throw new CatalogAdmissionError('unsafe-catalog', 'catalog admission attempt journal identity is corrupt');
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
if (!common || (!v1 && !v2)) {
|
|
616
|
+
throw new CatalogAdmissionError('unsafe-catalog', 'catalog admission journal is corrupt');
|
|
617
|
+
}
|
|
618
|
+
return item;
|
|
619
|
+
}
|
|
620
|
+
finally {
|
|
621
|
+
await handle.close();
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
async function persistAdmissionJournal(directory, record, hooks) {
|
|
625
|
+
const path = join(directory.path, record.schemaVersion === 1
|
|
626
|
+
? `${record.transitionId}.json`
|
|
627
|
+
: `${record.transitionId}.attempt-${record.attemptId}.json`);
|
|
628
|
+
const existing = await readAdmissionJournal(path);
|
|
629
|
+
if (existing !== undefined) {
|
|
630
|
+
if (canonical(existing) !== canonical(record))
|
|
631
|
+
throw new CatalogAdmissionError('conflict', 'catalog transition is already bound to a different admission operation');
|
|
632
|
+
return path;
|
|
633
|
+
}
|
|
634
|
+
let handle;
|
|
635
|
+
try {
|
|
636
|
+
handle = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
637
|
+
await handle.writeFile(`${JSON.stringify(record)}\n`, 'utf8');
|
|
638
|
+
await handle.sync();
|
|
639
|
+
const metadata = await handle.stat();
|
|
640
|
+
const uid = process.getuid?.();
|
|
641
|
+
if (!metadata.isFile() || metadata.nlink !== 1 || (metadata.mode & 0o777) !== 0o600 || (uid !== undefined && metadata.uid !== uid)) {
|
|
642
|
+
throw new CatalogAdmissionError('unsafe-catalog', 'catalog admission journal is unsafe');
|
|
643
|
+
}
|
|
644
|
+
const pathMetadata = await lstat(path);
|
|
645
|
+
if (pathMetadata.dev !== metadata.dev || pathMetadata.ino !== metadata.ino || pathMetadata.isSymbolicLink()) {
|
|
646
|
+
throw new CatalogAdmissionError('unsafe-catalog', 'catalog admission journal path changed during creation');
|
|
647
|
+
}
|
|
648
|
+
await directory.handle.sync();
|
|
649
|
+
await hooks.afterJournalSync?.(path);
|
|
650
|
+
return path;
|
|
651
|
+
}
|
|
652
|
+
catch (error) {
|
|
653
|
+
if (error.code === 'EEXIST') {
|
|
654
|
+
const raced = await readAdmissionJournal(path);
|
|
655
|
+
if (raced !== undefined && canonical(raced) === canonical(record))
|
|
656
|
+
return path;
|
|
657
|
+
throw new CatalogAdmissionError('conflict', 'catalog transition journal lost its create-only race');
|
|
658
|
+
}
|
|
659
|
+
throw error;
|
|
660
|
+
}
|
|
661
|
+
finally {
|
|
662
|
+
await handle?.close();
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
async function admissionAttemptJournal(directory, transitionId) {
|
|
666
|
+
const matches = (await readdir(directory.path, { withFileTypes: true }))
|
|
667
|
+
.filter(entry => entry.isFile() && entry.name.startsWith(`${transitionId}.attempt-`) && entry.name.endsWith('.json'));
|
|
668
|
+
if (matches.length > 1)
|
|
669
|
+
throw new CatalogAdmissionError('conflict', 'catalog transition has multiple durable attempts');
|
|
670
|
+
if (matches.length === 0)
|
|
671
|
+
return undefined;
|
|
672
|
+
const journal = await readAdmissionJournal(join(directory.path, matches[0].name));
|
|
673
|
+
if (journal?.schemaVersion !== 2 || journal.transitionId !== transitionId) {
|
|
674
|
+
throw new CatalogAdmissionError('unsafe-catalog', 'catalog admission attempt journal is corrupt');
|
|
675
|
+
}
|
|
676
|
+
return journal;
|
|
677
|
+
}
|
|
678
|
+
async function validateAttemptArtifacts(directory, journal, state) {
|
|
679
|
+
const parent = await lstat(dirname(journal.catalogPath), { bigint: true });
|
|
680
|
+
if (!parent.isDirectory() || String(parent.dev) !== journal.parent.dev || String(parent.ino) !== journal.parent.ino) {
|
|
681
|
+
throw new CatalogAdmissionError('conflict', 'catalog attempt parent does not match its durable identity');
|
|
682
|
+
}
|
|
683
|
+
const attemptPath = join(directory.path, journal.attemptDirectoryName);
|
|
684
|
+
const metadata = await lstat(attemptPath);
|
|
685
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
|
|
686
|
+
throw new CatalogAdmissionError('conflict', 'catalog attempt artifacts do not match the durable journal');
|
|
687
|
+
}
|
|
688
|
+
const names = await readdir(attemptPath);
|
|
689
|
+
if (names.some(name => !Object.values(journal.names).includes(name))) {
|
|
690
|
+
throw new CatalogAdmissionError('conflict', 'catalog attempt contains an untracked recovery artifact');
|
|
691
|
+
}
|
|
692
|
+
if (!names.includes(journal.names.before)) {
|
|
693
|
+
throw new CatalogAdmissionError('conflict', 'catalog attempt is missing its durable before-state recovery');
|
|
694
|
+
}
|
|
695
|
+
const hasDesired = names.includes(journal.names.desired);
|
|
696
|
+
const hasStage = names.includes(journal.names.stage);
|
|
697
|
+
if (state === 'before' && (!hasDesired || hasStage)) {
|
|
698
|
+
throw new CatalogAdmissionError('conflict', 'catalog before-state attempt has an invalid state carrier');
|
|
699
|
+
}
|
|
700
|
+
if (state === 'after' && hasDesired === hasStage) {
|
|
701
|
+
throw new CatalogAdmissionError('conflict', 'catalog after-state attempt must have exactly one state carrier');
|
|
702
|
+
}
|
|
703
|
+
const carrier = state === 'before' ? journal.names.desired : hasStage ? journal.names.stage : journal.names.desired;
|
|
704
|
+
if (!names.includes(carrier))
|
|
705
|
+
throw new CatalogAdmissionError('conflict', 'catalog attempt is missing its durable state carrier');
|
|
706
|
+
const expected = {
|
|
707
|
+
[journal.names.before]: journal.rollback,
|
|
708
|
+
[carrier]: state === 'before' ? journal.desired : journal.expectedBefore,
|
|
709
|
+
};
|
|
710
|
+
for (const [name, identity] of Object.entries(expected)) {
|
|
711
|
+
const handle = await open(join(attemptPath, name), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
712
|
+
try {
|
|
713
|
+
const file = await handle.stat({ bigint: true });
|
|
714
|
+
const bytes = await handle.readFile();
|
|
715
|
+
if (!file.isFile() || String(file.dev) !== identity.dev || String(file.ino) !== identity.ino
|
|
716
|
+
|| createHash('sha256').update(bytes).digest('hex') !== identity.fileDigest) {
|
|
717
|
+
throw new CatalogAdmissionError('conflict', `catalog attempt ${name} does not match its durable inode and digest`);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
finally {
|
|
721
|
+
await handle.close();
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
async function openOwnerCatalog(path) {
|
|
726
|
+
let handle;
|
|
727
|
+
try {
|
|
728
|
+
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
729
|
+
}
|
|
730
|
+
catch {
|
|
731
|
+
throw new CatalogAdmissionError('unsafe-catalog', 'catalog must be an owner-owned regular file without symbolic links');
|
|
732
|
+
}
|
|
733
|
+
try {
|
|
734
|
+
const metadata = await handle.stat();
|
|
735
|
+
const uid = process.getuid?.();
|
|
736
|
+
const pathMetadata = await lstat(path);
|
|
737
|
+
if (!metadata.isFile() || pathMetadata.isSymbolicLink() || metadata.nlink !== 1
|
|
738
|
+
|| metadata.dev !== pathMetadata.dev || metadata.ino !== pathMetadata.ino
|
|
739
|
+
|| (metadata.mode & 0o777) !== 0o600 || (uid !== undefined && metadata.uid !== uid)) {
|
|
740
|
+
throw new CatalogAdmissionError('unsafe-catalog', 'catalog must be owner-owned mode 0600 with one canonical link');
|
|
741
|
+
}
|
|
742
|
+
return handle;
|
|
743
|
+
}
|
|
744
|
+
catch (error) {
|
|
745
|
+
await handle.close();
|
|
746
|
+
throw error;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
async function loadOwnerCatalogSnapshot(path) {
|
|
750
|
+
const handle = await openOwnerCatalog(path);
|
|
751
|
+
try {
|
|
752
|
+
const metadata = await handle.stat({ bigint: true });
|
|
753
|
+
if (metadata.size > BigInt(maximumCatalogBytes))
|
|
754
|
+
throw new CatalogAdmissionError('invalid-input', 'catalog exceeds 1 MiB');
|
|
755
|
+
const source = await handle.readFile({ encoding: 'utf8' });
|
|
756
|
+
if (Buffer.byteLength(source) > maximumCatalogBytes)
|
|
757
|
+
throw new CatalogAdmissionError('invalid-input', 'catalog exceeds 1 MiB');
|
|
758
|
+
let catalog;
|
|
759
|
+
try {
|
|
760
|
+
catalog = parseCatalog(JSON.parse(source));
|
|
761
|
+
}
|
|
762
|
+
catch (error) {
|
|
763
|
+
throw new CatalogAdmissionError('invalid-input', error instanceof Error ? error.message : 'catalog is invalid');
|
|
764
|
+
}
|
|
765
|
+
assertCatalogIdentity(catalog);
|
|
766
|
+
return { catalog, digest: catalogDigest(catalog), fileDigest: createHash('sha256').update(source).digest('hex'),
|
|
767
|
+
dev: metadata.dev, ino: metadata.ino };
|
|
768
|
+
}
|
|
769
|
+
finally {
|
|
770
|
+
await handle.close();
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
function compareVersions(left, right) {
|
|
774
|
+
const parsed = (value) => {
|
|
775
|
+
const [withoutBuild = ''] = value.split('+', 1);
|
|
776
|
+
const [core = '', prerelease] = withoutBuild.split('-', 2);
|
|
777
|
+
return { core: core.split('.').map(part => BigInt(part)), prerelease: prerelease?.split('.') };
|
|
778
|
+
};
|
|
779
|
+
const a = parsed(left);
|
|
780
|
+
const b = parsed(right);
|
|
781
|
+
for (let index = 0; index < 3; index += 1) {
|
|
782
|
+
const leftPart = a.core[index];
|
|
783
|
+
const rightPart = b.core[index];
|
|
784
|
+
if (leftPart !== rightPart)
|
|
785
|
+
return leftPart < rightPart ? -1 : 1;
|
|
786
|
+
}
|
|
787
|
+
if (a.prerelease === undefined || b.prerelease === undefined)
|
|
788
|
+
return a.prerelease === b.prerelease ? 0 : a.prerelease === undefined ? 1 : -1;
|
|
789
|
+
for (let index = 0; index < Math.max(a.prerelease.length, b.prerelease.length); index += 1) {
|
|
790
|
+
const leftPart = a.prerelease[index];
|
|
791
|
+
const rightPart = b.prerelease[index];
|
|
792
|
+
if (leftPart === undefined || rightPart === undefined)
|
|
793
|
+
return leftPart === rightPart ? 0 : leftPart === undefined ? -1 : 1;
|
|
794
|
+
if (leftPart === rightPart)
|
|
795
|
+
continue;
|
|
796
|
+
const leftNumeric = /^\d+$/u.test(leftPart);
|
|
797
|
+
const rightNumeric = /^\d+$/u.test(rightPart);
|
|
798
|
+
if (leftNumeric && rightNumeric)
|
|
799
|
+
return BigInt(leftPart) < BigInt(rightPart) ? -1 : 1;
|
|
800
|
+
if (leftNumeric !== rightNumeric)
|
|
801
|
+
return leftNumeric ? -1 : 1;
|
|
802
|
+
return leftPart < rightPart ? -1 : 1;
|
|
803
|
+
}
|
|
804
|
+
return 0;
|
|
805
|
+
}
|
|
806
|
+
function exactEntry(left, right) { return canonical(left) === canonical(right); }
|
|
807
|
+
function candidateCatalog(before, candidate) {
|
|
808
|
+
const sameId = before.entries.find(entry => entry.id === candidate.id);
|
|
809
|
+
const samePackage = before.entries.find(entry => entry.package === candidate.package);
|
|
810
|
+
if (sameId !== undefined || samePackage !== undefined) {
|
|
811
|
+
if (sameId !== undefined && samePackage !== undefined && sameId === samePackage && exactEntry(sameId, candidate))
|
|
812
|
+
return before;
|
|
813
|
+
if (sameId === undefined || samePackage === undefined || sameId !== samePackage) {
|
|
814
|
+
throw new CatalogAdmissionError('conflict', `candidate ${candidate.id}/${candidate.package} conflicts with an existing catalog identity`);
|
|
815
|
+
}
|
|
816
|
+
const comparison = compareVersions(candidate.version, sameId.version);
|
|
817
|
+
if (comparison < 0)
|
|
818
|
+
throw new CatalogAdmissionError('conflict', `candidate ${candidate.id} would downgrade ${candidate.package} from ${sameId.version} to ${candidate.version}`);
|
|
819
|
+
if (comparison === 0)
|
|
820
|
+
throw new CatalogAdmissionError('conflict', `candidate ${candidate.id}/${candidate.package}@${candidate.version} conflicts with the existing version`);
|
|
821
|
+
return parseCatalog({ schemaVersion: 1, entries: before.entries.map(entry => entry === sameId ? candidate : entry) });
|
|
822
|
+
}
|
|
823
|
+
return parseCatalog({ schemaVersion: 1, entries: [...before.entries, candidate] });
|
|
824
|
+
}
|
|
825
|
+
/** Pure deterministic transition used while constructing the signed request. */
|
|
826
|
+
export function previewCatalogAdmission(current, rawCandidate) {
|
|
827
|
+
let catalog;
|
|
828
|
+
try {
|
|
829
|
+
catalog = parseCatalog(current);
|
|
830
|
+
}
|
|
831
|
+
catch (error) {
|
|
832
|
+
throw new CatalogAdmissionError('invalid-input', error instanceof Error ? error.message : 'catalog is invalid');
|
|
833
|
+
}
|
|
834
|
+
assertCatalogIdentity(catalog);
|
|
835
|
+
const candidate = exactCandidate(rawCandidate);
|
|
836
|
+
exactSha512(candidate.integrity, `${candidate.id}.integrity`);
|
|
837
|
+
for (const requirement of candidate.requires)
|
|
838
|
+
exactSha512(requirement.integrity, `${candidate.id}.requires integrity`);
|
|
839
|
+
const after = candidateCatalog(catalog, candidate);
|
|
840
|
+
return Object.freeze({ beforeCatalogDigest: catalogDigest(catalog), afterCatalogDigest: catalogDigest(after), catalog: after, candidate });
|
|
841
|
+
}
|
|
842
|
+
function admissionBinding(input, catalogPath, candidate) {
|
|
843
|
+
const catalogId = admissionText(input.catalog.id, 'catalog.id', identityPattern);
|
|
844
|
+
const installationId = admissionText(input.installationId, 'installationId', identityPattern);
|
|
845
|
+
const operationId = admissionText(input.operationId, 'operationId', identityPattern);
|
|
846
|
+
const planId = admissionText(input.plan.id, 'plan.id', identityPattern);
|
|
847
|
+
const planDigest = admissionText(input.plan.digest, 'plan.digest', digestPattern);
|
|
848
|
+
const planRevision = admissionInteger(input.plan.revision, 'plan.revision');
|
|
849
|
+
const releaseId = admissionText(input.release.id, 'release.id', identityPattern);
|
|
850
|
+
const releaseFence = admissionInteger(input.release.fence, 'release.fence');
|
|
851
|
+
const verificationEvidenceDigest = admissionText(input.verificationEvidenceDigest, 'verificationEvidenceDigest', digestPattern);
|
|
852
|
+
const artifactStatementDigest = admissionText(input.artifactStatementDigest, 'artifactStatementDigest', digestPattern);
|
|
853
|
+
const registryReference = admissionText(input.registryReference, 'registryReference', /^.+$/u);
|
|
854
|
+
if (registryReference.includes('\0') || registryReference.includes('\r') || registryReference.includes('\n')) {
|
|
855
|
+
throw new CatalogAdmissionError('invalid-input', 'registryReference is invalid');
|
|
856
|
+
}
|
|
857
|
+
const artifactSignature = admissionText(input.artifactSignature, 'artifactSignature', signaturePattern, 16_384);
|
|
858
|
+
const signatureBytes = Buffer.from(artifactSignature, 'base64');
|
|
859
|
+
if (signatureBytes.length === 0 || signatureBytes.toString('base64') !== artifactSignature) {
|
|
860
|
+
throw new CatalogAdmissionError('invalid-input', 'artifactSignature is not canonical base64');
|
|
861
|
+
}
|
|
862
|
+
const artifactSignatureDigest = createHash('sha256').update(signatureBytes).digest('hex');
|
|
863
|
+
const transitionId = createHash('sha256').update(canonical({
|
|
864
|
+
schemaVersion: 1, catalog: { id: catalogId, path: catalogPath }, registry: {
|
|
865
|
+
id: admissionText(input.registry.id, 'registry.id', identityPattern),
|
|
866
|
+
locator: admissionText(input.registry.locator, 'registry.locator', /^.+$/u),
|
|
867
|
+
},
|
|
868
|
+
expectedBeforeCatalogDigest: admissionText(input.expectedBeforeCatalogDigest, 'expectedBeforeCatalogDigest', digestPattern),
|
|
869
|
+
expectedAfterCatalogDigest: admissionText(input.expectedAfterCatalogDigest, 'expectedAfterCatalogDigest', digestPattern),
|
|
870
|
+
candidate,
|
|
871
|
+
})).digest('hex');
|
|
872
|
+
const bindingDigest = createHash('sha256').update(canonical({
|
|
873
|
+
schemaVersion: 1, transitionId, installationId, operationId,
|
|
874
|
+
plan: { id: planId, digest: planDigest, revision: planRevision }, release: { id: releaseId, fence: releaseFence },
|
|
875
|
+
registryReference, artifactStatementDigest, artifactSignatureDigest, verificationEvidenceDigest,
|
|
876
|
+
})).digest('hex');
|
|
877
|
+
return { transitionId, bindingDigest, artifactSignatureDigest };
|
|
878
|
+
}
|
|
879
|
+
function admissionEvidence(input, catalogPath, candidate, beforeCatalogDigest, afterCatalogDigest) {
|
|
880
|
+
const binding = admissionBinding(input, catalogPath, candidate);
|
|
881
|
+
const admissionId = `catalog-admission-${binding.bindingDigest}`;
|
|
882
|
+
return Object.freeze({ kind: 'catalog-admission', admissionId, catalogId: input.catalog.id, beforeCatalogDigest, afterCatalogDigest,
|
|
883
|
+
registryReference: input.registryReference, artifactStatementDigest: input.artifactStatementDigest,
|
|
884
|
+
artifactSignatureDigest: binding.artifactSignatureDigest, verificationEvidenceDigest: input.verificationEvidenceDigest, candidate });
|
|
885
|
+
}
|
|
886
|
+
/** Derives the receipt identity from the complete request-bound admission. */
|
|
887
|
+
export function catalogAdmissionId(input) {
|
|
888
|
+
const candidate = exactCandidate(input.candidate);
|
|
889
|
+
const path = input.catalog.path;
|
|
890
|
+
if (typeof path !== 'string' || !isAbsolute(path) || path === '/' || resolve(path) !== path) {
|
|
891
|
+
throw new CatalogAdmissionError('invalid-input', 'catalog.path must be an absolute normalized path');
|
|
892
|
+
}
|
|
893
|
+
return admissionEvidence(input, path, candidate, input.expectedBeforeCatalogDigest, input.expectedAfterCatalogDigest).admissionId;
|
|
894
|
+
}
|
|
895
|
+
/**
|
|
896
|
+
* Atomically admits one independently verified candidate into the canonical
|
|
897
|
+
* owner-private catalog. The expected digest is a request-bound CAS fence.
|
|
898
|
+
* Every writer of this owner catalog must use this helper (or honor its shared
|
|
899
|
+
* lock); POSIX rename alone cannot conditionally replace an arbitrary writer.
|
|
900
|
+
*/
|
|
901
|
+
export async function admitCatalogCandidate(input, hooks = {}) {
|
|
902
|
+
const testHooks = hooks;
|
|
903
|
+
const exchangePause = testHooks.afterExchangePauseMilliseconds ?? 0;
|
|
904
|
+
const reverseExchangePause = testHooks.beforeReverseExchangePauseMilliseconds ?? 0;
|
|
905
|
+
if (!Number.isInteger(exchangePause) || exchangePause < 0 || exchangePause > 1_000
|
|
906
|
+
|| !Number.isInteger(reverseExchangePause) || reverseExchangePause < 0 || reverseExchangePause > 1_000) {
|
|
907
|
+
throw new CatalogAdmissionError('invalid-input', 'catalog admission exchange pause must be an integer from 0 through 1000');
|
|
908
|
+
}
|
|
909
|
+
const expectedBeforeCatalogDigest = admissionText(input.expectedBeforeCatalogDigest, 'expectedBeforeCatalogDigest', digestPattern);
|
|
910
|
+
const expectedAfterCatalogDigest = admissionText(input.expectedAfterCatalogDigest, 'expectedAfterCatalogDigest', digestPattern);
|
|
911
|
+
if (expectedBeforeCatalogDigest === expectedAfterCatalogDigest) {
|
|
912
|
+
throw new CatalogAdmissionError('invalid-input', 'catalog admission must authorize one exact catalog change');
|
|
913
|
+
}
|
|
914
|
+
const candidate = exactCandidate(input.candidate);
|
|
915
|
+
if (candidate.registry === undefined || candidate.registry.id !== input.registry.id
|
|
916
|
+
|| candidate.registry.locator !== input.registry.locator || candidate.registry.reference !== input.registryReference) {
|
|
917
|
+
throw new CatalogAdmissionError('invalid-input', 'candidate registry identity and reference must bind the verified release artifact');
|
|
918
|
+
}
|
|
919
|
+
if (candidate.registry.reference.startsWith('file:')) {
|
|
920
|
+
try {
|
|
921
|
+
canonicalFileUrl(candidate.registry.reference, 'candidate.registry.reference');
|
|
922
|
+
}
|
|
923
|
+
catch (error) {
|
|
924
|
+
throw new CatalogAdmissionError('invalid-input', error instanceof Error ? error.message : 'candidate registry reference is invalid');
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
const catalogPath = await canonicalOwnerCatalogPath(input.catalog.path);
|
|
928
|
+
const evidence = admissionEvidence(input, catalogPath, candidate, expectedBeforeCatalogDigest, expectedAfterCatalogDigest);
|
|
929
|
+
const directoryPath = dirname(catalogPath);
|
|
930
|
+
const directory = await openOwnerCatalogDirectory(directoryPath);
|
|
931
|
+
let journalDirectory;
|
|
932
|
+
let attemptDirectory;
|
|
933
|
+
let operationError;
|
|
934
|
+
let cleanupError;
|
|
935
|
+
let result;
|
|
936
|
+
try {
|
|
937
|
+
journalDirectory = await openAdmissionJournalDirectory(catalogPath, directory);
|
|
938
|
+
acquireKernelAdmissionLock(directory);
|
|
939
|
+
const journal = journalRecord(input, catalogPath, candidate, evidence);
|
|
940
|
+
const journalPath = join(journalDirectory.path, `${journal.transitionId}.json`);
|
|
941
|
+
const before = await loadOwnerCatalogSnapshot(catalogPath);
|
|
942
|
+
if (before.digest === expectedAfterCatalogDigest) {
|
|
943
|
+
const exact = before.catalog.entries.find(entry => entry.id === candidate.id && entry.package === candidate.package);
|
|
944
|
+
if (exact === undefined || !exactEntry(exact, candidate)) {
|
|
945
|
+
throw new CatalogAdmissionError('conflict', 'catalog has the expected after digest without the exact candidate');
|
|
946
|
+
}
|
|
947
|
+
const existingJournal = await readAdmissionJournal(journalPath);
|
|
948
|
+
const attemptJournal = await admissionAttemptJournal(journalDirectory, journal.transitionId);
|
|
949
|
+
if (attemptJournal !== undefined)
|
|
950
|
+
await validateAttemptArtifacts(journalDirectory, attemptJournal, 'after');
|
|
951
|
+
if (existingJournal?.schemaVersion === 1 && attemptJournal === undefined) {
|
|
952
|
+
throw new CatalogAdmissionError('conflict', 'catalog after-state is missing its request-bound v2 attempt journal');
|
|
953
|
+
}
|
|
954
|
+
const durableJournal = attemptJournal ?? existingJournal;
|
|
955
|
+
if (durableJournal === undefined) {
|
|
956
|
+
throw new CatalogAdmissionError('conflict', 'catalog after-state has no exact request-bound admission journal');
|
|
957
|
+
}
|
|
958
|
+
else if (durableJournal.bindingDigest !== journal.bindingDigest
|
|
959
|
+
|| durableJournal.transitionId !== journal.transitionId || canonical(durableJournal.evidence) !== canonical(journal.evidence)) {
|
|
960
|
+
throw new CatalogAdmissionError('conflict', 'catalog transition belongs to a different admission operation');
|
|
961
|
+
}
|
|
962
|
+
result = Object.freeze({ evidence, replayed: true });
|
|
963
|
+
}
|
|
964
|
+
else {
|
|
965
|
+
if (before.digest !== expectedBeforeCatalogDigest) {
|
|
966
|
+
throw new CatalogAdmissionError('conflict', `catalog digest changed before admission (expected ${expectedBeforeCatalogDigest}, observed ${before.digest})`);
|
|
967
|
+
}
|
|
968
|
+
const existingAttempt = await admissionAttemptJournal(journalDirectory, journal.transitionId);
|
|
969
|
+
if (existingAttempt !== undefined) {
|
|
970
|
+
await validateAttemptArtifacts(journalDirectory, existingAttempt, 'before');
|
|
971
|
+
throw new CatalogAdmissionError('conflict', `catalog admission has a durable unresolved attempt: ${existingAttempt.attemptDirectoryName}`);
|
|
972
|
+
}
|
|
973
|
+
const preview = previewCatalogAdmission(before.catalog, candidate);
|
|
974
|
+
if (preview.afterCatalogDigest !== expectedAfterCatalogDigest || preview.beforeCatalogDigest !== expectedBeforeCatalogDigest) {
|
|
975
|
+
throw new CatalogAdmissionError('conflict', 'request expected after digest does not match the deterministic catalog transition');
|
|
976
|
+
}
|
|
977
|
+
const next = preview.catalog;
|
|
978
|
+
await persistAdmissionJournal(journalDirectory, journal, hooks);
|
|
979
|
+
const serialized = `${JSON.stringify(next, null, 2)}\n`;
|
|
980
|
+
if (Buffer.byteLength(serialized) > maximumCatalogBytes)
|
|
981
|
+
throw new CatalogAdmissionError('invalid-input', 'admitted catalog would exceed 1 MiB');
|
|
982
|
+
const desiredFileDigest = createHash('sha256').update(serialized).digest('hex');
|
|
983
|
+
const names = { desired: 'desired', stage: 'stage', before: 'before', reverseMarker: 'reverse-ready' };
|
|
984
|
+
let temporary;
|
|
985
|
+
let beforeCopy;
|
|
986
|
+
let expectedCatalog;
|
|
987
|
+
try {
|
|
988
|
+
try {
|
|
989
|
+
temporary = await open(`/proc/self/fd/${directory.fd}`, constants.O_RDWR | O_TMPFILE, 0o600);
|
|
990
|
+
}
|
|
991
|
+
catch {
|
|
992
|
+
throw new CatalogAdmissionError('unsafe-catalog', 'catalog filesystem does not support descriptor-backed temporary files');
|
|
993
|
+
}
|
|
994
|
+
try {
|
|
995
|
+
beforeCopy = await open(`/proc/self/fd/${directory.fd}`, constants.O_RDWR | O_TMPFILE, 0o600);
|
|
996
|
+
}
|
|
997
|
+
catch {
|
|
998
|
+
throw new CatalogAdmissionError('unsafe-catalog', 'catalog filesystem does not support descriptor-backed rollback files');
|
|
999
|
+
}
|
|
1000
|
+
await temporary.writeFile(serialized, 'utf8');
|
|
1001
|
+
await temporary.sync();
|
|
1002
|
+
expectedCatalog = await openOwnerCatalog(catalogPath);
|
|
1003
|
+
const originalBytes = Buffer.alloc(Number((await expectedCatalog.stat()).size));
|
|
1004
|
+
let originalOffset = 0;
|
|
1005
|
+
while (originalOffset < originalBytes.length) {
|
|
1006
|
+
const chunk = await expectedCatalog.read(originalBytes, originalOffset, originalBytes.length - originalOffset, originalOffset);
|
|
1007
|
+
if (chunk.bytesRead === 0)
|
|
1008
|
+
throw new CatalogAdmissionError('conflict', 'catalog descriptor ended during rollback snapshot');
|
|
1009
|
+
originalOffset += chunk.bytesRead;
|
|
1010
|
+
}
|
|
1011
|
+
if (createHash('sha256').update(originalBytes).digest('hex') !== before.fileDigest) {
|
|
1012
|
+
throw new CatalogAdmissionError('conflict', 'catalog bytes changed while preparing rollback');
|
|
1013
|
+
}
|
|
1014
|
+
await beforeCopy.writeFile(originalBytes);
|
|
1015
|
+
await beforeCopy.sync();
|
|
1016
|
+
const temporaryMetadata = await temporary.stat({ bigint: true });
|
|
1017
|
+
const beforeCopyMetadata = await beforeCopy.stat({ bigint: true });
|
|
1018
|
+
const parentMetadata = await directory.stat({ bigint: true });
|
|
1019
|
+
const uid = process.getuid?.();
|
|
1020
|
+
if (!temporaryMetadata.isFile() || temporaryMetadata.nlink !== 0n || (temporaryMetadata.mode & 511n) !== 384n
|
|
1021
|
+
|| (uid !== undefined && temporaryMetadata.uid !== BigInt(uid))) {
|
|
1022
|
+
throw new CatalogAdmissionError('unsafe-catalog', 'unnamed temporary catalog is not an owner-private regular file');
|
|
1023
|
+
}
|
|
1024
|
+
const deterministicAttemptId = attemptId(journal.bindingDigest);
|
|
1025
|
+
attemptDirectory = await openAttemptDirectory(journalDirectory, attemptDirectoryName(catalogPath, deterministicAttemptId));
|
|
1026
|
+
const temporaryPath = join(attemptDirectory.path, names.desired);
|
|
1027
|
+
const attemptJournal = attemptJournalRecord(input, catalogPath, candidate, evidence, before, { dev: temporaryMetadata.dev, ino: temporaryMetadata.ino, fileDigest: desiredFileDigest }, { dev: beforeCopyMetadata.dev, ino: beforeCopyMetadata.ino, fileDigest: before.fileDigest }, { dev: parentMetadata.dev, ino: parentMetadata.ino });
|
|
1028
|
+
await persistAdmissionJournal(journalDirectory, attemptJournal, hooks);
|
|
1029
|
+
runCatalogCommitHelper('prepare', temporary, expectedCatalog, attemptDirectory.handle, beforeCopy, directory, [desiredFileDigest, before.fileDigest, String(before.dev), String(before.ino), names.desired, names.before]);
|
|
1030
|
+
await hooks.afterTemporaryFileSync?.(temporaryPath);
|
|
1031
|
+
await hooks.beforeAtomicRename?.(catalogPath, temporaryPath);
|
|
1032
|
+
runCatalogCommitHelper('commit', temporary, expectedCatalog, attemptDirectory.handle, beforeCopy, directory, [String(before.dev), String(before.ino), before.fileDigest, desiredFileDigest, names.desired, names.before,
|
|
1033
|
+
basename(catalogPath), names.reverseMarker, String(exchangePause), String(reverseExchangePause)]);
|
|
1034
|
+
await hooks.afterAtomicRename?.(catalogPath);
|
|
1035
|
+
}
|
|
1036
|
+
finally {
|
|
1037
|
+
await expectedCatalog?.close();
|
|
1038
|
+
await temporary?.close();
|
|
1039
|
+
await beforeCopy?.close();
|
|
1040
|
+
// Attempt names live in a durable request-bound directory. They are
|
|
1041
|
+
// never removed by pathname after another writer could replace them.
|
|
1042
|
+
}
|
|
1043
|
+
const after = await loadOwnerCatalogSnapshot(catalogPath);
|
|
1044
|
+
if (after.digest !== expectedAfterCatalogDigest) {
|
|
1045
|
+
throw new CatalogAdmissionError('conflict', 'catalog changed after atomic admission');
|
|
1046
|
+
}
|
|
1047
|
+
const admitted = after.catalog.entries.find(entry => entry.id === candidate.id);
|
|
1048
|
+
if (admitted === undefined || !exactEntry(admitted, candidate)) {
|
|
1049
|
+
throw new CatalogAdmissionError('conflict', 'catalog re-read does not contain the exact admitted candidate');
|
|
1050
|
+
}
|
|
1051
|
+
result = Object.freeze({ evidence, replayed: false });
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
catch (error) {
|
|
1055
|
+
operationError = error;
|
|
1056
|
+
}
|
|
1057
|
+
finally {
|
|
1058
|
+
try {
|
|
1059
|
+
await attemptDirectory?.handle.close();
|
|
1060
|
+
}
|
|
1061
|
+
catch (error) {
|
|
1062
|
+
cleanupError ??= error;
|
|
1063
|
+
}
|
|
1064
|
+
try {
|
|
1065
|
+
await journalDirectory?.handle.close();
|
|
1066
|
+
}
|
|
1067
|
+
catch (error) {
|
|
1068
|
+
cleanupError ??= error;
|
|
1069
|
+
}
|
|
1070
|
+
try {
|
|
1071
|
+
await directory.close();
|
|
1072
|
+
}
|
|
1073
|
+
catch (error) {
|
|
1074
|
+
cleanupError ??= error;
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
if (operationError !== undefined)
|
|
1078
|
+
throw operationError;
|
|
1079
|
+
if (cleanupError !== undefined)
|
|
1080
|
+
throw cleanupError;
|
|
1081
|
+
return result;
|
|
1082
|
+
}
|
|
70
1083
|
export async function loadCatalog(path) {
|
|
1084
|
+
const metadata = await lstat(path);
|
|
1085
|
+
const uid = process.getuid?.();
|
|
1086
|
+
if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.nlink !== 1
|
|
1087
|
+
|| (metadata.mode & 0o022) !== 0 || (uid !== undefined && metadata.uid !== uid)
|
|
1088
|
+
|| await realpath(path) !== resolve(path)) {
|
|
1089
|
+
throw new Error('plugin-control-plane: owner catalog must be an owner-owned regular file without writable aliases or symlink traversal');
|
|
1090
|
+
}
|
|
71
1091
|
const source = await readFile(path, 'utf8');
|
|
72
|
-
if (Buffer.byteLength(source) >
|
|
1092
|
+
if (Buffer.byteLength(source) > maximumCatalogBytes)
|
|
73
1093
|
throw new Error('plugin-control-plane: catalog exceeds 1 MiB');
|
|
74
1094
|
return parseCatalog(JSON.parse(source));
|
|
75
1095
|
}
|
|
76
1096
|
/**
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
* release. An owner-created catalog at Config.catalogPath replaces this list.
|
|
1097
|
+
* Package-local example used by documentation and tests. It is deliberately
|
|
1098
|
+
* not a runtime fallback or trust source because it is not signature-verified.
|
|
1099
|
+
* Runtime discovery requires an owner-provided, integrity-pinned regular file.
|
|
81
1100
|
*/
|
|
82
|
-
export const
|
|
1101
|
+
export const exampleIntegrityPinnedCatalog = parseCatalog({
|
|
83
1102
|
schemaVersion: 1,
|
|
84
1103
|
entries: [
|
|
85
1104
|
{
|
|
@@ -158,15 +1177,9 @@ export const firstPartyCatalog = parseCatalog({
|
|
|
158
1177
|
},
|
|
159
1178
|
],
|
|
160
1179
|
});
|
|
161
|
-
export async function
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
}
|
|
165
|
-
catch (error) {
|
|
166
|
-
if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT')
|
|
167
|
-
return firstPartyCatalog;
|
|
168
|
-
throw error;
|
|
169
|
-
}
|
|
1180
|
+
export async function loadCatalogWithMetadata(path) {
|
|
1181
|
+
const catalog = await loadCatalog(path);
|
|
1182
|
+
return Object.freeze({ catalog, digest: catalogDigest(catalog), provenance: 'owner-provided-integrity-pinned' });
|
|
170
1183
|
}
|
|
171
1184
|
export function discover(catalog, capability) {
|
|
172
1185
|
const terms = capability.normalize('NFC').toLocaleLowerCase('en-US').trim().split(/\s+/u).filter(Boolean);
|