@hasna/recordings 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.
Files changed (58) hide show
  1. package/README.md +56 -6
  2. package/contracts/v1/fixtures.json +1206 -0
  3. package/dist/cli/index.js +28 -7
  4. package/dist/contracts/hosted-v1.d.ts +105 -0
  5. package/dist/contracts/hosted-v1.d.ts.map +1 -0
  6. package/dist/contracts/hosted-v1.js +41 -0
  7. package/dist/contracts/stream-v1.d.ts +117 -0
  8. package/dist/contracts/stream-v1.d.ts.map +1 -0
  9. package/dist/contracts/stream-v1.js +35 -0
  10. package/dist/hosted/index.d.ts +58 -0
  11. package/dist/hosted/index.d.ts.map +1 -0
  12. package/dist/hosted/index.js +266 -0
  13. package/dist/hosted/transport.d.ts +36 -0
  14. package/dist/hosted/transport.d.ts.map +1 -0
  15. package/dist/hosted-v1-aavn7ktb.js +4114 -0
  16. package/dist/hosted-v1-gdr9extc.js +84 -0
  17. package/dist/index.js +27 -6
  18. package/dist/mcp/index.js +27 -6
  19. package/dist/server/index.js +27 -6
  20. package/dist/storage.js +27 -6
  21. package/docs/hosted-sdk.md +71 -0
  22. package/docs/wire-contracts.md +24 -0
  23. package/package.json +27 -6
  24. package/scripts/ci-linux-suite.ts +23 -13
  25. package/scripts/macos_artifact.ts +40 -33
  26. package/scripts/native/prebuilds/darwin-universal/recordings_fs_guard.node +0 -0
  27. package/scripts/native/recordings_fs_guard.c +36 -4
  28. package/scripts/native-core-receipt.py +171 -0
  29. package/scripts/native_fs_guard.ts +2 -0
  30. package/scripts/release-suite-gate.ts +227 -150
  31. package/scripts/resolve_tailscale_cli.sh +24 -3
  32. package/src/native/Recordings/RecordingsLib/BlockingOperation.swift +29 -0
  33. package/src/native/Recordings/RecordingsLib/Info.plist +2 -2
  34. package/src/native/Recordings/RecordingsLib/ProjectStore.swift +4 -4
  35. package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +228 -60
  36. package/src/native/Recordings/RecordingsLib/RecordingPasteTarget.swift +114 -0
  37. package/src/native/Recordings/RecordingsLib/RecordingProvider.swift +13 -3
  38. package/src/native/Recordings/RecordingsTests/BlockingOperationTests.swift +85 -0
  39. package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +441 -72
  40. package/src/native/Recordings/RecordingsTests/PipeClosureFixture.swift +215 -0
  41. package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +10 -10
  42. package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +1 -1
  43. package/src/native/Recordings/RecordingsTests/RecordingFrozenPasteTargetTests.swift +50 -0
  44. package/src/native/Recordings/RecordingsTests/RecordingPasteTargetTrackerTests.swift +48 -0
  45. package/src/native/Recordings/RecordingsTests/RecordingProviderTests.swift +115 -2
  46. package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +93 -21
  47. package/src/native/Recordings/RecordingsTests/TestHomeDirectory.swift +5 -1
  48. package/src/native/Recordings/build.sh +2 -1
  49. package/dist/__tests__/helpers/installer-guard-execution.d.ts +0 -22
  50. package/dist/__tests__/helpers/installer-guard-execution.d.ts.map +0 -1
  51. package/dist/__tests__/helpers/installer-preflight.d.ts +0 -22
  52. package/dist/__tests__/helpers/installer-preflight.d.ts.map +0 -1
  53. package/dist/__tests__/helpers/native-fs-guard.d.ts +0 -2
  54. package/dist/__tests__/helpers/native-fs-guard.d.ts.map +0 -1
  55. package/dist/__tests__/helpers/source-assertions.d.ts +0 -171
  56. package/dist/__tests__/helpers/source-assertions.d.ts.map +0 -1
  57. package/dist/__tests__/preload.d.ts +0 -2
  58. package/dist/__tests__/preload.d.ts.map +0 -1
@@ -29,6 +29,7 @@
29
29
  *
30
30
  * Usage:
31
31
  * bun scripts/ci-linux-suite.ts --check validate the quarantine file, print counts
32
+ * bun scripts/ci-linux-suite.ts --all every discovered tracked test, including quarantine
32
33
  * bun scripts/ci-linux-suite.ts --gated newline-separated gated files
33
34
  * bun scripts/ci-linux-suite.ts --quarantined newline-separated quarantined files
34
35
  * bun scripts/ci-linux-suite.ts --verify-run <log> assert a gated run honoured the partition
@@ -41,6 +42,13 @@ import { spawnSync } from "node:child_process";
41
42
  export const QUARANTINE_FILE = ".github/linux-quarantine.txt";
42
43
 
43
44
  const TEST_SUFFIX = ".test.ts";
45
+ const BUN_TEST_SUFFIX = /[._](?:test|spec)\.(?:[cm]?[jt]sx?)$/;
46
+
47
+ function supportedTestFiles(paths: string[]): string[] {
48
+ const unsupported = paths.filter(path => BUN_TEST_SUFFIX.test(path) && !path.endsWith(TEST_SUFFIX));
49
+ if (unsupported.length) throw new Error(`unsupported test suffix; extend discovery and JUnit contracts before adding:\n${unsupported.join("\n")}`);
50
+ return paths.filter(path => path.endsWith(TEST_SUFFIX));
51
+ }
44
52
 
45
53
  /** Directories never worth walking. `.build` holds SwiftPM output, which can be enormous. */
46
54
  const SKIPPED_DIRS = new Set([".git", "node_modules", ".build", "dist"]);
@@ -92,7 +100,7 @@ export function entriesMissingReason(text: string): string[] {
92
100
  * in the working tree.
93
101
  */
94
102
  export function testFilesFromGit(repoRoot: string): string[] {
95
- const result = spawnSync("git", ["-C", repoRoot, "ls-files", "-z", `*${TEST_SUFFIX}`], {
103
+ const result = spawnSync("git", ["-C", repoRoot, "ls-files", "-z"], {
96
104
  encoding: "utf8",
97
105
  });
98
106
  if (result.status !== 0) {
@@ -100,7 +108,7 @@ export function testFilesFromGit(repoRoot: string): string[] {
100
108
  }
101
109
  // -z because a path may legally contain a newline, and a newline-split enumeration would report
102
110
  // one such path as two missing files — a disagreement that reads as a broken walker.
103
- return result.stdout.split("\0").filter((path) => path.length > 0).sort();
111
+ return supportedTestFiles(result.stdout.split("\0").filter((path) => path.length > 0)).sort();
104
112
  }
105
113
 
106
114
  /**
@@ -118,13 +126,13 @@ export function testFilesFromWalk(repoRoot: string): string[] {
118
126
  if (entry.isDirectory()) {
119
127
  if (SKIPPED_DIRS.has(entry.name)) continue;
120
128
  walk(absolute);
121
- } else if (entry.isFile() && entry.name.endsWith(TEST_SUFFIX)) {
129
+ } else if (entry.isFile()) {
122
130
  found.push(relative(repoRoot, absolute).split(sep).join("/"));
123
131
  }
124
132
  }
125
133
  };
126
134
  walk(repoRoot);
127
- return found.sort();
135
+ return supportedTestFiles(found).sort();
128
136
  }
129
137
 
130
138
  export type DiscoveryDisagreement = { trackedNotOnDisk: string[]; onDiskUntracked: string[] };
@@ -257,7 +265,7 @@ function fail(message: string): never {
257
265
  process.exit(1);
258
266
  }
259
267
 
260
- function loadPartition(repoRoot: string): Partition & { discovered: string[] } {
268
+ function loadPartition(repoRoot: string, requireNonemptyGated = true): Partition & { discovered: string[] } {
261
269
  const fromGit = testFilesFromGit(repoRoot);
262
270
  const { trackedNotOnDisk, onDiskUntracked } = compareDiscovery(fromGit, testFilesFromWalk(repoRoot));
263
271
  if (trackedNotOnDisk.length > 0) {
@@ -293,16 +301,14 @@ function loadPartition(repoRoot: string): Partition & { discovered: string[] } {
293
301
  } catch (error) {
294
302
  fail(error instanceof Error ? error.message : String(error));
295
303
  }
296
- if (split.gated.length === 0) {
304
+ if (requireNonemptyGated && split.gated.length === 0) {
297
305
  fail(
298
306
  `Every discovered test file is quarantined by ${QUARANTINE_FILE}. The gate would run nothing.`,
299
307
  );
300
308
  }
301
- // The workflow expands `--gated` unquoted so the runner receives one argument per file, which is
302
- // only safe while no path contains whitespace. Asserting it here keeps that assumption in the
303
- // same place as the list, instead of leaving a shell-quoting bug to be discovered by a suite that
304
- // silently stopped being gated.
305
- const whitespace = split.gated.filter((path) => /\s/.test(path));
309
+ // Keep the established newline/shell-safe manifest contract in both selections.
310
+ // Quarantined paths are still executable inputs to the explicit all-tests mode.
311
+ const whitespace = fromGit.filter((path) => /\s/.test(path));
306
312
  if (whitespace.length > 0) {
307
313
  fail(
308
314
  "Test paths containing whitespace cannot be passed through unquoted word splitting:\n" +
@@ -318,8 +324,12 @@ function main(argv: string[]): void {
318
324
  fail(`Run this from the repository root; ${QUARANTINE_FILE} is not there.`);
319
325
  }
320
326
  const mode = argv[0];
321
- const { gated, quarantined, discovered } = loadPartition(repoRoot);
327
+ const { gated, quarantined, discovered } = loadPartition(repoRoot, mode !== "--all");
322
328
 
329
+ if (mode === "--all") {
330
+ console.log(discovered.join("\n"));
331
+ return;
332
+ }
323
333
  if (mode === "--gated") {
324
334
  console.log(gated.join("\n"));
325
335
  return;
@@ -395,7 +405,7 @@ function main(argv: string[]): void {
395
405
  }
396
406
  return;
397
407
  }
398
- fail("usage: --check | --gated | --quarantined | --verify-run <junit.xml>");
408
+ fail("usage: --check | --all | --gated | --quarantined | --verify-run <junit.xml>");
399
409
  }
400
410
 
401
411
  if (import.meta.main) {
@@ -1385,41 +1385,48 @@ export function verifyAndExtractArchiveDescriptors(
1385
1385
  throw new Error("artifact verifier archive digest mismatch");
1386
1386
  }
1387
1387
  const entries = inspectZipArchiveBytes(archive);
1388
- const outputRoot = `/dev/fd/${outputDirectoryDescriptor}`;
1389
- if (readdirSync(outputRoot).length !== 0) {
1390
- throw new Error("artifact verifier output directory must be empty");
1391
- }
1392
- const ordered = [...entries].sort((left, right) => {
1393
- const depth = left.name.split("/").length - right.name.split("/").length;
1394
- if (depth !== 0) return depth;
1395
- if (left.isDirectory !== right.isDirectory) return left.isDirectory ? -1 : 1;
1396
- return compareUnsignedUtf8(left.name, right.name);
1397
- });
1398
- for (const entry of ordered) {
1399
- const leaf = entry.isDirectory ? entry.name.slice(0, -1) : entry.name;
1400
- const target = join(outputRoot, ...leaf.split("/"));
1401
- const mode = entry.unixMode & 0o777;
1402
- if (entry.isDirectory) {
1403
- mkdirSync(target, { mode });
1404
- chmodSync(target, mode);
1405
- continue;
1406
- }
1407
- const descriptor = openSync(
1408
- target,
1409
- constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
1410
- mode,
1411
- );
1412
- try {
1413
- const payload = zipEntryPayload(archive, entry);
1414
- let offset = 0;
1415
- while (offset < payload.length) offset += writeSync(descriptor, payload, offset);
1416
- fchmodSync(descriptor, mode);
1417
- fsyncSync(descriptor);
1418
- } finally {
1419
- closeSync(descriptor);
1388
+ // /dev/fd/<directory>/child is not traversable on Darwin. Keep all
1389
+ // extraction relative to the borrowed capability, including every ancestor.
1390
+ const guard = nativeFsGuard();
1391
+ const outputRoot = guard.duplicateDirectoryDescriptor(outputDirectoryDescriptor);
1392
+ try {
1393
+ if (guard.readDir(outputRoot).length !== 0) {
1394
+ throw new Error("artifact verifier output directory must be empty");
1395
+ }
1396
+ const ordered = [...entries].sort((left, right) => {
1397
+ const depth = left.name.split("/").length - right.name.split("/").length;
1398
+ if (depth !== 0) return depth;
1399
+ if (left.isDirectory !== right.isDirectory) return left.isDirectory ? -1 : 1;
1400
+ return compareUnsignedUtf8(left.name, right.name);
1401
+ });
1402
+ for (const entry of ordered) {
1403
+ const parts = (entry.isDirectory ? entry.name.slice(0, -1) : entry.name).split("/");
1404
+ const leaf = parts.pop()!;
1405
+ let parent = outputRoot;
1406
+ try {
1407
+ // Close each ancestor as we descend; even a large ZIP keeps only a
1408
+ // bounded number of descriptors open. openDirAt refuses symlinks.
1409
+ for (const component of parts) {
1410
+ const ancestor = parent;
1411
+ parent = guard.openDirAt(ancestor, component);
1412
+ if (ancestor !== outputRoot) guard.close(ancestor);
1413
+ }
1414
+ const mode = entry.unixMode & 0o777;
1415
+ if (entry.isDirectory) {
1416
+ const directory = guard.mkdirAt(parent, leaf, mode);
1417
+ guard.close(directory);
1418
+ } else {
1419
+ guard.writeFileAt(parent, leaf, zipEntryPayload(archive, entry), mode);
1420
+ }
1421
+ guard.fsyncHandle(parent);
1422
+ } finally {
1423
+ if (parent !== outputRoot) guard.close(parent);
1424
+ }
1420
1425
  }
1426
+ guard.fsyncHandle(outputRoot);
1427
+ } finally {
1428
+ guard.close(outputRoot);
1421
1429
  }
1422
- fsyncSync(outputDirectoryDescriptor);
1423
1430
  }
1424
1431
 
1425
1432
  export function withPrivatelyExtractedArchiveApp<T>(
@@ -5,6 +5,7 @@
5
5
  #include <dirent.h>
6
6
  #include <errno.h>
7
7
  #include <fcntl.h>
8
+ #include <limits.h>
8
9
  #include <stdint.h>
9
10
  #include <stdbool.h>
10
11
  #include <signal.h>
@@ -314,6 +315,32 @@ static napi_value open_trusted_home(napi_env env, napi_callback_info info) {
314
315
  return make_handle(env, fd);
315
316
  }
316
317
 
318
+ static napi_value duplicate_directory_descriptor(napi_env env, napi_callback_info info) {
319
+ napi_value argv[1];
320
+ size_t argc = 1;
321
+ double descriptor;
322
+ if (!check_napi(env, napi_get_cb_info(env, info, &argc, argv, NULL, NULL), "duplicateDirectoryDescriptor args") || argc != 1 ||
323
+ !check_napi(env, napi_get_value_double(env, argv[0], &descriptor), "read directory descriptor")) return NULL;
324
+ if (!(descriptor >= 0 && descriptor <= INT_MAX) || descriptor != (int)descriptor) {
325
+ throw_message(env, "INVALID_DESCRIPTOR", "directory descriptor must be a non-negative integer");
326
+ return NULL;
327
+ }
328
+ // Borrow the caller's capability, never its path or ownership of the fd.
329
+ int fd = fcntl((int)descriptor, F_DUPFD_CLOEXEC, 0);
330
+ if (fd < 0) { throw_errno(env, "duplicate directory descriptor"); return NULL; }
331
+ struct stat details;
332
+ if (fstat(fd, &details) != 0) {
333
+ int saved_errno = errno;
334
+ close(fd); errno = saved_errno; throw_errno(env, "stat duplicated directory"); return NULL;
335
+ }
336
+ if (!S_ISDIR(details.st_mode)) {
337
+ close(fd);
338
+ throw_message(env, "INVALID_DESCRIPTOR", "descriptor must reference a directory");
339
+ return NULL;
340
+ }
341
+ return make_handle(env, fd);
342
+ }
343
+
317
344
  static napi_value open_dir_at(napi_env env, napi_callback_info info) {
318
345
  napi_value argv[2];
319
346
  size_t argc = 2;
@@ -370,8 +397,13 @@ static napi_value read_dir(napi_env env, napi_callback_info info) {
370
397
  if (stream == NULL) { close(iterator_fd); throw_errno(env, "open directory stream"); return NULL; }
371
398
  if (!check_napi(env, napi_create_array(env, &array), "create directory array")) { closedir(stream); return NULL; }
372
399
  uint32_t index = 0;
373
- errno = 0;
374
- for (struct dirent *entry = readdir(stream); entry != NULL; entry = readdir(stream)) {
400
+ int saved_errno = 0;
401
+ for (;;) {
402
+ /* EOF preserves errno. Successful N-API work in the preceding iteration
403
+ * need not preserve it, so only the immediately following read owns it. */
404
+ errno = 0;
405
+ struct dirent *entry = readdir(stream);
406
+ if (entry == NULL) { saved_errno = errno; break; }
375
407
  if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) continue;
376
408
  if (!valid_leaf(entry->d_name)) {
377
409
  closedir(stream);
@@ -385,7 +417,6 @@ static napi_value read_dir(napi_env env, napi_callback_info info) {
385
417
  return NULL;
386
418
  }
387
419
  }
388
- int saved_errno = errno;
389
420
  closedir(stream);
390
421
  if (saved_errno != 0) { errno = saved_errno; throw_errno(env, "read directory capability"); return NULL; }
391
422
  return array;
@@ -717,7 +748,7 @@ static napi_value write_file_at(napi_env env, napi_callback_info info) {
717
748
  }
718
749
  offset += (size_t)count;
719
750
  }
720
- if (fsync(fd) != 0) {
751
+ if (fchmod(fd, (mode_t)(mode & 0777)) != 0 || fsync(fd) != 0) {
721
752
  saved_errno = errno; close(fd); errno = saved_errno; throw_errno(env, "fsync written file"); return NULL;
722
753
  }
723
754
  if (close(fd) != 0) { throw_errno(env, "close written file"); return NULL; }
@@ -1132,6 +1163,7 @@ static napi_value unlink_file_handle_at(napi_env env, napi_callback_info info) {
1132
1163
 
1133
1164
  static napi_value init(napi_env env, napi_value exports) {
1134
1165
  const napi_property_descriptor properties[] = {
1166
+ { "duplicateDirectoryDescriptor", NULL, duplicate_directory_descriptor, NULL, NULL, NULL, napi_default, NULL },
1135
1167
  { "openTrustedHome", NULL, open_trusted_home, NULL, NULL, NULL, napi_default, NULL },
1136
1168
  { "openDirAt", NULL, open_dir_at, NULL, NULL, NULL, napi_default, NULL },
1137
1169
  { "openRegularAt", NULL, open_regular_at, NULL, NULL, NULL, napi_default, NULL },
@@ -0,0 +1,171 @@
1
+ #!/usr/bin/env python3
2
+ """Describe reviewed npm bytes; registry verification is an explicit read-only step."""
3
+ import argparse
4
+ import base64
5
+ import hashlib
6
+ import io
7
+ import json
8
+ import pathlib
9
+ import re
10
+ import subprocess
11
+ import tarfile
12
+ import urllib.request
13
+ from datetime import datetime, timezone
14
+
15
+ PACKAGE = '@hasna/recordings'
16
+ CORE = 'src/native/Recordings/'
17
+ REGISTRY = 'https://registry.npmjs.org/'
18
+ LIMIT = 128 * 1024 * 1024
19
+
20
+
21
+ def digest(data):
22
+ return hashlib.sha256(data).hexdigest()
23
+
24
+
25
+ def read_archive(blob):
26
+ if not blob or len(blob) > LIMIT:
27
+ raise ValueError('Archive size is outside the allowed range')
28
+ files = {}
29
+ total = 0
30
+ seen = set()
31
+ with tarfile.open(fileobj=io.BytesIO(blob), mode='r:*') as archive:
32
+ for member in archive:
33
+ parts = member.name.rstrip('/').split('/')
34
+ if (len(seen) >= 20000 or member.name in seen or len(member.name) > 1024
35
+ or parts[0] != 'package' or any(p in ('', '.', '..') for p in parts)
36
+ or '\\' in member.name or any(ord(c) < 32 for c in member.name)
37
+ or not (member.isfile() or member.isdir())):
38
+ raise ValueError('Archive contains an unsafe or duplicate entry')
39
+ seen.add(member.name)
40
+ if member.isdir():
41
+ continue
42
+ total += member.size
43
+ if member.size > LIMIT or total > LIMIT * 2:
44
+ raise ValueError('Expanded archive is too large')
45
+ handle = archive.extractfile(member)
46
+ if handle is None:
47
+ raise ValueError('Archive member cannot be read')
48
+ data = handle.read(member.size + 1)
49
+ if len(data) != member.size:
50
+ raise ValueError('Archive member is truncated')
51
+ files['/'.join(parts[1:])] = (data, member.mode & 0o777)
52
+ return files
53
+
54
+
55
+ def create_receipt(blob, revision):
56
+ if not re.fullmatch('[a-f0-9]{40}', revision):
57
+ raise ValueError('A full public source revision is required')
58
+ files = read_archive(blob)
59
+ try:
60
+ package = json.loads(files['package.json'][0])
61
+ version = package['version']
62
+ if package['name'] != PACKAGE or not re.fullmatch(r'\d+\.\d+\.\d+', version):
63
+ raise ValueError('Unexpected package identity')
64
+ manifest = files[CORE + 'Package.swift'][0].decode()
65
+ for required in ('Package.resolved', 'RecordingsLib/RecordingEngine.swift', 'RecordingsLib/RecordingProvider.swift'):
66
+ if CORE + required not in files:
67
+ raise ValueError('Required native source is missing')
68
+ if (not re.search(r'\.library\(\s*name:\s*"RecordingsLib"\s*,\s*targets:\s*\["RecordingsLib"\]', manifest)
69
+ or not re.search(r'swift-tools-version:\s*6\.2\b', manifest)
70
+ or not re.search(r'\.macOS\(\s*\.v26\s*\)', manifest)):
71
+ raise ValueError('Required Swift library or platform declaration is missing')
72
+ except (KeyError, TypeError, UnicodeError, json.JSONDecodeError) as error:
73
+ raise ValueError('Invalid native package metadata') from error
74
+ native = [{'path': path[len(CORE):], 'sha256': digest(data), 'bytes': len(data), 'mode': mode}
75
+ for path, (data, mode) in sorted(files.items()) if path.startswith(CORE)]
76
+ tree = ''.join(f"{f['path']}\0{f['mode']:o}\0{f['bytes']}\0{f['sha256']}\n" for f in native).encode()
77
+ return {
78
+ 'schemaVersion': 1, 'kind': 'hasna.recordings.native-core',
79
+ 'package': {'name': PACKAGE, 'version': version},
80
+ 'source': {'repository': 'https://github.com/hasna/apps', 'revision': revision},
81
+ 'distribution': {'status': 'prepared'},
82
+ 'archive': {'url': REGISTRY + '@hasna/recordings/-/recordings-' + version + '.tgz',
83
+ 'bytes': len(blob), 'sha256': digest(blob),
84
+ 'integrity': 'sha512-' + base64.b64encode(hashlib.sha512(blob).digest()).decode()},
85
+ 'native': {'packagePath': CORE.rstrip('/'), 'product': 'RecordingsLib',
86
+ 'swiftToolsVersion': '6.2', 'minimumMacOS': '26.0',
87
+ 'treeSHA256': digest(tree), 'files': native},
88
+ }
89
+
90
+
91
+ class NoRedirects(urllib.request.HTTPRedirectHandler):
92
+ def redirect_request(self, *args, **kwargs):
93
+ raise ValueError('Registry redirects are not accepted')
94
+
95
+
96
+ def registry_bytes(url):
97
+ if not url.startswith(REGISTRY):
98
+ raise ValueError('Unexpected registry authority')
99
+ opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirects())
100
+ with opener.open(url, timeout=30) as response:
101
+ data = response.read(LIMIT + 1)
102
+ if len(data) > LIMIT:
103
+ raise ValueError('Registry response is too large')
104
+ return data
105
+
106
+
107
+ def verify_registry(receipt, fetch=registry_bytes):
108
+ version = receipt['package']['version']
109
+ metadata = json.loads(fetch(REGISTRY + '@hasna%2Frecordings/' + version))
110
+ if (metadata.get('name') != PACKAGE or metadata.get('version') != version
111
+ or metadata.get('dist', {}).get('tarball') != receipt['archive']['url']
112
+ or metadata.get('dist', {}).get('integrity') != receipt['archive']['integrity']):
113
+ raise ValueError('Registry metadata differs from the reviewed archive')
114
+ remote = fetch(receipt['archive']['url'])
115
+ if len(remote) != receipt['archive']['bytes'] or digest(remote) != receipt['archive']['sha256']:
116
+ raise ValueError('Registry archive differs from the reviewed bytes')
117
+ receipt['distribution'] = {'status': 'published', 'registry': REGISTRY,
118
+ 'verifiedAt': datetime.now(timezone.utc).isoformat()}
119
+
120
+
121
+ def verify_source(receipt, repository):
122
+ revision = receipt['source']['revision']
123
+ prefix = 'apps/recordings/' + CORE
124
+ result = subprocess.run(['git', '-c', 'tar.umask=0022', 'archive', '--format=tar', revision, '--', prefix, 'apps/recordings/package.json'],
125
+ cwd=repository, check=True, capture_output=True, timeout=30)
126
+ with tarfile.open(fileobj=io.BytesIO(result.stdout), mode='r:') as archive:
127
+ package = archive.extractfile('apps/recordings/package.json')
128
+ metadata = json.load(package) if package else {}
129
+ if metadata.get('name') != PACKAGE or metadata.get('version') != receipt['package']['version']:
130
+ raise ValueError('Package version differs from the public source revision')
131
+ for expected in receipt['native']['files']:
132
+ try:
133
+ member = archive.getmember(prefix + expected['path'])
134
+ handle = archive.extractfile(member)
135
+ data = handle.read() if handle else b''
136
+ except KeyError as error:
137
+ raise ValueError('Native archive member is absent from the source revision') from error
138
+ if (not member.isfile() or len(data) != expected['bytes'] or digest(data) != expected['sha256']
139
+ or member.mode & 0o777 != expected['mode']):
140
+ raise ValueError('Native archive bytes differ from the public source revision')
141
+
142
+
143
+ def main():
144
+ parser = argparse.ArgumentParser(description=__doc__)
145
+ parser.add_argument('archive', type=pathlib.Path)
146
+ parser.add_argument('--source-revision', required=True)
147
+ parser.add_argument('--repository', type=pathlib.Path, default=pathlib.Path(__file__).resolve().parents[3])
148
+ parser.add_argument('--output', required=True, type=pathlib.Path)
149
+ parser.add_argument('--verify-registry', action='store_true', help='Compare public registry bytes; does not publish')
150
+ args = parser.parse_args()
151
+ if args.archive.is_symlink() or not args.archive.is_file() or args.archive.stat().st_size > LIMIT:
152
+ raise ValueError('Expected a regular bounded npm archive')
153
+ blob = args.archive.read_bytes()
154
+ receipt = create_receipt(blob, args.source_revision)
155
+ verify_source(receipt, args.repository)
156
+ if args.verify_registry:
157
+ verify_registry(receipt)
158
+ # Refuse replacement: a new verification writes a separate reviewable receipt.
159
+ with args.output.open('x') as output:
160
+ json.dump(receipt, output, indent=2)
161
+ output.write('\n')
162
+ print(json.dumps({'status': receipt['distribution']['status'], 'package': receipt['package'],
163
+ 'archiveSHA256': receipt['archive']['sha256'], 'nativeTreeSHA256': receipt['native']['treeSHA256']}))
164
+
165
+
166
+ if __name__ == '__main__':
167
+ try:
168
+ main()
169
+ except Exception:
170
+ # Exception messages can contain local paths or registry/credential diagnostics.
171
+ raise SystemExit('Native core receipt verification failed; no published receipt was produced.')
@@ -16,6 +16,7 @@ export type NativeMetadata = {
16
16
  export type NativeHandle = object;
17
17
 
18
18
  export type NativeFsGuard = {
19
+ duplicateDirectoryDescriptor(descriptor: number): NativeHandle;
19
20
  openTrustedHome(path: string, uid: number): NativeHandle;
20
21
  openDirAt(parent: NativeHandle, leaf: string): NativeHandle;
21
22
  openRegularAt(
@@ -122,6 +123,7 @@ export function nativeFsGuard(): NativeFsGuard {
122
123
  }
123
124
  const loaded = createRequire(import.meta.url)(path) as Partial<NativeFsGuard>;
124
125
  for (const name of [
126
+ "duplicateDirectoryDescriptor",
125
127
  "openTrustedHome",
126
128
  "openDirAt",
127
129
  "openRegularAt",