@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.
- package/README.md +56 -6
- package/contracts/v1/fixtures.json +1206 -0
- package/dist/cli/index.js +28 -7
- package/dist/contracts/hosted-v1.d.ts +105 -0
- package/dist/contracts/hosted-v1.d.ts.map +1 -0
- package/dist/contracts/hosted-v1.js +41 -0
- package/dist/contracts/stream-v1.d.ts +117 -0
- package/dist/contracts/stream-v1.d.ts.map +1 -0
- package/dist/contracts/stream-v1.js +35 -0
- package/dist/hosted/index.d.ts +58 -0
- package/dist/hosted/index.d.ts.map +1 -0
- package/dist/hosted/index.js +266 -0
- package/dist/hosted/transport.d.ts +36 -0
- package/dist/hosted/transport.d.ts.map +1 -0
- package/dist/hosted-v1-aavn7ktb.js +4114 -0
- package/dist/hosted-v1-gdr9extc.js +84 -0
- package/dist/index.js +27 -6
- package/dist/mcp/index.js +27 -6
- package/dist/server/index.js +27 -6
- package/dist/storage.js +27 -6
- package/docs/hosted-sdk.md +71 -0
- package/docs/wire-contracts.md +24 -0
- package/package.json +27 -6
- package/scripts/ci-linux-suite.ts +23 -13
- package/scripts/macos_artifact.ts +40 -33
- package/scripts/native/prebuilds/darwin-universal/recordings_fs_guard.node +0 -0
- package/scripts/native/recordings_fs_guard.c +36 -4
- package/scripts/native-core-receipt.py +171 -0
- package/scripts/native_fs_guard.ts +2 -0
- package/scripts/release-suite-gate.ts +227 -150
- package/scripts/resolve_tailscale_cli.sh +24 -3
- package/src/native/Recordings/RecordingsLib/BlockingOperation.swift +29 -0
- package/src/native/Recordings/RecordingsLib/Info.plist +2 -2
- package/src/native/Recordings/RecordingsLib/ProjectStore.swift +4 -4
- package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +228 -60
- package/src/native/Recordings/RecordingsLib/RecordingPasteTarget.swift +114 -0
- package/src/native/Recordings/RecordingsLib/RecordingProvider.swift +13 -3
- package/src/native/Recordings/RecordingsTests/BlockingOperationTests.swift +85 -0
- package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +441 -72
- package/src/native/Recordings/RecordingsTests/PipeClosureFixture.swift +215 -0
- package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +10 -10
- package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +1 -1
- package/src/native/Recordings/RecordingsTests/RecordingFrozenPasteTargetTests.swift +50 -0
- package/src/native/Recordings/RecordingsTests/RecordingPasteTargetTrackerTests.swift +48 -0
- package/src/native/Recordings/RecordingsTests/RecordingProviderTests.swift +115 -2
- package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +93 -21
- package/src/native/Recordings/RecordingsTests/TestHomeDirectory.swift +5 -1
- package/src/native/Recordings/build.sh +2 -1
- package/dist/__tests__/helpers/installer-guard-execution.d.ts +0 -22
- package/dist/__tests__/helpers/installer-guard-execution.d.ts.map +0 -1
- package/dist/__tests__/helpers/installer-preflight.d.ts +0 -22
- package/dist/__tests__/helpers/installer-preflight.d.ts.map +0 -1
- package/dist/__tests__/helpers/native-fs-guard.d.ts +0 -2
- package/dist/__tests__/helpers/native-fs-guard.d.ts.map +0 -1
- package/dist/__tests__/helpers/source-assertions.d.ts +0 -171
- package/dist/__tests__/helpers/source-assertions.d.ts.map +0 -1
- package/dist/__tests__/preload.d.ts +0 -2
- 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"
|
|
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()
|
|
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
|
-
//
|
|
302
|
-
//
|
|
303
|
-
|
|
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
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
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
|
-
|
|
374
|
-
for (
|
|
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",
|