@pipobscure/bundle 0.0.1

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 (98) hide show
  1. package/HISTORY.md +1924 -0
  2. package/README.md +623 -0
  3. package/bundle.run +0 -0
  4. package/dist/api.d.ts +147 -0
  5. package/dist/api.d.ts.map +1 -0
  6. package/dist/api.js +174 -0
  7. package/dist/api.js.map +1 -0
  8. package/dist/archive.d.ts +115 -0
  9. package/dist/archive.d.ts.map +1 -0
  10. package/dist/archive.js +188 -0
  11. package/dist/archive.js.map +1 -0
  12. package/dist/audit.d.ts +78 -0
  13. package/dist/audit.d.ts.map +1 -0
  14. package/dist/audit.js +119 -0
  15. package/dist/audit.js.map +1 -0
  16. package/dist/cli.d.ts +23 -0
  17. package/dist/cli.d.ts.map +1 -0
  18. package/dist/cli.js +555 -0
  19. package/dist/cli.js.map +1 -0
  20. package/dist/files.d.ts +53 -0
  21. package/dist/files.d.ts.map +1 -0
  22. package/dist/files.js +118 -0
  23. package/dist/files.js.map +1 -0
  24. package/dist/index.d.ts +10 -0
  25. package/dist/index.d.ts.map +1 -0
  26. package/dist/index.js +35 -0
  27. package/dist/index.js.map +1 -0
  28. package/dist/launch.d.ts +97 -0
  29. package/dist/launch.d.ts.map +1 -0
  30. package/dist/launch.js +267 -0
  31. package/dist/launch.js.map +1 -0
  32. package/dist/main.d.ts +3 -0
  33. package/dist/main.d.ts.map +1 -0
  34. package/dist/main.js +19 -0
  35. package/dist/main.js.map +1 -0
  36. package/dist/manifest.d.ts +139 -0
  37. package/dist/manifest.d.ts.map +1 -0
  38. package/dist/manifest.js +504 -0
  39. package/dist/manifest.js.map +1 -0
  40. package/dist/oidc.d.ts +40 -0
  41. package/dist/oidc.d.ts.map +1 -0
  42. package/dist/oidc.js +320 -0
  43. package/dist/oidc.js.map +1 -0
  44. package/dist/preload.d.ts +14 -0
  45. package/dist/preload.d.ts.map +1 -0
  46. package/dist/preload.js +38 -0
  47. package/dist/preload.js.map +1 -0
  48. package/dist/provider.d.ts +83 -0
  49. package/dist/provider.d.ts.map +1 -0
  50. package/dist/provider.js +206 -0
  51. package/dist/provider.js.map +1 -0
  52. package/dist/record.d.ts +2 -0
  53. package/dist/record.d.ts.map +1 -0
  54. package/dist/record.js +23 -0
  55. package/dist/record.js.map +1 -0
  56. package/dist/recorder.d.ts +64 -0
  57. package/dist/recorder.d.ts.map +1 -0
  58. package/dist/recorder.js +111 -0
  59. package/dist/recorder.js.map +1 -0
  60. package/dist/register.d.ts +2 -0
  61. package/dist/register.d.ts.map +1 -0
  62. package/dist/register.js +28 -0
  63. package/dist/register.js.map +1 -0
  64. package/dist/sea.d.ts +97 -0
  65. package/dist/sea.d.ts.map +1 -0
  66. package/dist/sea.js +220 -0
  67. package/dist/sea.js.map +1 -0
  68. package/dist/sigstore.d.ts +112 -0
  69. package/dist/sigstore.d.ts.map +1 -0
  70. package/dist/sigstore.js +385 -0
  71. package/dist/sigstore.js.map +1 -0
  72. package/dist/skill.d.ts +36 -0
  73. package/dist/skill.d.ts.map +1 -0
  74. package/dist/skill.js +108 -0
  75. package/dist/skill.js.map +1 -0
  76. package/package.json +84 -0
  77. package/shell-base +2 -0
  78. package/skills/audit-bundle/SKILL.md +271 -0
  79. package/src/api.ts +293 -0
  80. package/src/archive.ts +312 -0
  81. package/src/audit.ts +206 -0
  82. package/src/cli.ts +575 -0
  83. package/src/files.ts +156 -0
  84. package/src/index.ts +114 -0
  85. package/src/launch.ts +336 -0
  86. package/src/main.ts +20 -0
  87. package/src/manifest.ts +615 -0
  88. package/src/oidc.ts +372 -0
  89. package/src/preload.ts +40 -0
  90. package/src/provider.ts +270 -0
  91. package/src/record.ts +25 -0
  92. package/src/recorder.ts +166 -0
  93. package/src/register.ts +30 -0
  94. package/src/sea.ts +341 -0
  95. package/src/sigstore.ts +492 -0
  96. package/src/skill.ts +132 -0
  97. package/src/types/node-vfs.d.ts +90 -0
  98. package/src/types/node-zip.d.ts +85 -0
package/src/files.ts ADDED
@@ -0,0 +1,156 @@
1
+ import * as FS from 'node:fs';
2
+ import * as PATH from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ // Working out what belongs in an archive, for the cases where observing a run
6
+ // is not the right tool.
7
+ //
8
+ // `record` — run the application and write down what it read — is the primary
9
+ // answer and the honest one: it produces exactly the set that was used. It has
10
+ // one blind spot, and it is the one that matters for a *verifier*: code loaded
11
+ // lazily on a path the observation run never took. `sigstore.ts` requires
12
+ // `@sigstore/verify` only when it meets an archive with a `SIGSTORE=` field, so
13
+ // a build that signs with a local key never touches it — and the resulting
14
+ // bundle would then be unable to check a sigstore signature it later met.
15
+ //
16
+ // So the two are used together. A dependency closure computed from
17
+ // `node_modules` gives completeness, and the observation run is kept as the
18
+ // check: anything read that the computed list did not contain is a real gap,
19
+ // and the build says so.
20
+
21
+ export interface WalkOptions {
22
+ /** Directory names skipped wherever they appear (default: ['node_modules']). */
23
+ exclude?: string[] | undefined;
24
+ }
25
+
26
+ /**
27
+ * Every file under `dir`, as `/`-separated paths relative to it, sorted.
28
+ *
29
+ * A nested `node_modules` is skipped by default: its contents belong to the
30
+ * packages inside it, which `dependencyFiles` reaches through the dependency
31
+ * graph instead — so a hoisted tree and a nested one produce the same set.
32
+ */
33
+ export function walk(dir: string, { exclude = ['node_modules'] }: WalkOptions = {}, prefix = ''): string[] {
34
+ const out: string[] = [];
35
+ for (const entry of FS.readdirSync(dir, { withFileTypes: true })) {
36
+ if (exclude.includes(entry.name)) continue;
37
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
38
+ if (entry.isDirectory()) out.push(...walk(PATH.join(dir, entry.name), { exclude }, relative));
39
+ else if (entry.isFile()) out.push(relative);
40
+ }
41
+ return out.sort();
42
+ }
43
+
44
+ /**
45
+ * Every file of `names` and of everything they depend on, as paths relative to
46
+ * `base`, sorted. Packages are located the way node locates them, so the answer
47
+ * describes the tree that is actually installed rather than what a lock file
48
+ * says should be.
49
+ *
50
+ * A dependency that is not installed under `base` — missing, or hoisted above
51
+ * it, where no member path can name it — is an error rather than a silent
52
+ * omission, because a bundle whose closure is incomplete is a bundle that
53
+ * fails at runtime on a machine other than the one that built it. Only an
54
+ * optional dependency may be absent.
55
+ */
56
+ export function dependencyFiles(names: string[], base: string): string[] {
57
+ const root = PATH.resolve(base);
58
+ const files: string[] = [];
59
+ const seen = new Set<string>();
60
+ // Each dependency is looked up from the package that depends on it, not from
61
+ // the root: a tree can hold several versions of one package, and which one
62
+ // a `require` gets depends on where it is asked from.
63
+ const queue = names.map((name) => ({ name, from: root, optional: false }));
64
+
65
+ while (queue.length) {
66
+ const { name, from, optional } = queue.shift()!;
67
+ const dir = locate(name, from, root);
68
+ if (!dir) {
69
+ if (optional) continue; // optional, and not installed
70
+ throw new Error(`'${name}', needed by ${PATH.relative(root, from) || 'the root'}, is not installed ` +
71
+ `under ${root} — a bundle without it fails wherever the code that needs it runs`);
72
+ }
73
+ if (seen.has(dir)) continue;
74
+ seen.add(dir);
75
+
76
+ const relative = PATH.relative(root, dir);
77
+ for (const file of walk(dir)) files.push(posix(relative, file));
78
+
79
+ const manifest = JSON.parse(FS.readFileSync(PATH.join(dir, 'package.json'), 'utf-8')) as {
80
+ dependencies?: Record<string, string>;
81
+ optionalDependencies?: Record<string, string>;
82
+ };
83
+ const optionals = manifest.optionalDependencies ?? {};
84
+ for (const dep of Object.keys(manifest.dependencies ?? {})) {
85
+ queue.push({ name: dep, from: dir, optional: dep in optionals });
86
+ }
87
+ for (const dep of Object.keys(optionals)) queue.push({ name: dep, from: dir, optional: true });
88
+ }
89
+ return files.sort();
90
+ }
91
+
92
+ // Where `name` is installed as seen from `from`: the nearest `node_modules/name`
93
+ // on the way up, the search node's resolver makes. It looks for the directory
94
+ // rather than resolving `name/package.json`, because a package whose `exports`
95
+ // does not list its manifest would refuse that — and be mistaken for missing.
96
+ // The search stops at `root`: a package above it cannot become a member of an
97
+ // archive rooted there, so finding one is the same as not finding one.
98
+ function locate(name: string, from: string, root: string): string | null {
99
+ for (let dir = from; ; dir = PATH.dirname(dir)) {
100
+ if (PATH.basename(dir) !== 'node_modules') {
101
+ const candidate = PATH.join(dir, 'node_modules', name);
102
+ if (FS.existsSync(PATH.join(candidate, 'package.json'))) return candidate;
103
+ }
104
+ if (dir === root || PATH.dirname(dir) === dir) return null;
105
+ }
106
+ }
107
+
108
+ export interface ModuleFilesOptions {
109
+ /** Directory the result is relative to. */
110
+ base: string;
111
+ /** Individual files to include, relative to `base`. */
112
+ files?: string[] | undefined;
113
+ /** Directories to include whole, relative to `base`. */
114
+ dirs?: string[] | undefined;
115
+ /** Package names whose dependency closure to include. */
116
+ dependencies?: string[] | undefined;
117
+ /** Keep only files matching one of these; a bare extension counts. */
118
+ filter?: ((name: string) => boolean) | undefined;
119
+ }
120
+
121
+ /** The three sources above, combined and de-duplicated. */
122
+ export function moduleFiles({ base, files = [], dirs = [], dependencies = [], filter }: ModuleFilesOptions): string[] {
123
+ const all = [
124
+ ...files,
125
+ ...dirs.flatMap((dir) => walk(PATH.join(base, dir)).map((name) => posix(dir, name))),
126
+ ...dependencyFiles(dependencies, base),
127
+ ];
128
+ const kept = filter ? all.filter(filter) : all;
129
+ return [...new Set(kept)].sort();
130
+ }
131
+
132
+ /**
133
+ * The shell launcher this package ships: the prefix that turns an archive into
134
+ * a file you can run by name. `bundle sign --launcher` uses it, so nobody has
135
+ * to know it lives inside `node_modules`.
136
+ */
137
+ export function launcherPath(): string {
138
+ return PATH.join(packageRoot(), 'shell-base');
139
+ }
140
+
141
+ /** This package's own root — the directory its `package.json` sits in. */
142
+ export function packageRoot(): string {
143
+ return PATH.resolve(PATH.dirname(fileURLToPath(import.meta.url)), '..');
144
+ }
145
+
146
+ /**
147
+ * The directory this package's runnable modules are in: `dist` once compiled,
148
+ * `src` when the source is being run directly under node's type stripping.
149
+ */
150
+ export function moduleDir(): string {
151
+ return PATH.basename(PATH.dirname(fileURLToPath(import.meta.url)));
152
+ }
153
+
154
+ function posix(dir: string, name: string): string {
155
+ return `${dir.split(PATH.sep).join('/')}/${name}`;
156
+ }
package/src/index.ts ADDED
@@ -0,0 +1,114 @@
1
+ // The package root: everything needed to build, sign, verify and run an archive
2
+ // from code, and nothing that needs a flag to import.
3
+ //
4
+ // The two VFS providers are deliberately *not* re-exported here. Importing
5
+ // either needs `node:vfs`, which only exists under `--experimental-vfs`, and
6
+ // creating or verifying an archive does not — so a plain `import
7
+ // '@pipobscure/bundle'` must not drag that requirement in. They have their own
8
+ // entry points:
9
+ //
10
+ // @pipobscure/bundle/provider the verifying provider, and register()
11
+ // @pipobscure/bundle/register a preload that registers it and nothing else
12
+ // @pipobscure/bundle/recorder the recording provider, and register()
13
+ // @pipobscure/bundle/record a preload that registers it and nothing else
14
+
15
+ // The high-level drive: create, sign, verify, inspect, run.
16
+ export {
17
+ createBundle,
18
+ signBundle,
19
+ verifyBundle,
20
+ verifyBundleSync,
21
+ inspectBundle,
22
+ runBundle,
23
+ fileSigner,
24
+ mountArgv,
25
+ registerPath,
26
+ type BuildResult,
27
+ type CreateOptions,
28
+ type SignOptions,
29
+ type VerifyBundleOptions,
30
+ type RunOptions,
31
+ type RunResult,
32
+ type Inspection,
33
+ } from './api.ts';
34
+
35
+ // The archive layer, for callers assembling members themselves.
36
+ export {
37
+ bundle,
38
+ rebundle,
39
+ createArchive,
40
+ keySigner,
41
+ members,
42
+ fromDirectory,
43
+ fromArchive,
44
+ type Member,
45
+ type Signer,
46
+ type Signature,
47
+ type EmitResult,
48
+ type BundleOptions,
49
+ type RebundleOptions,
50
+ } from './archive.ts';
51
+
52
+ // The format layer: the manifest, the signature marker, and verification.
53
+ export {
54
+ AUTHORITY,
55
+ buildManifest,
56
+ parseManifest,
57
+ parseSignature,
58
+ formatSignature,
59
+ signatureOf,
60
+ verify,
61
+ verifySync,
62
+ STATES,
63
+ type ArchiveSource,
64
+ type ManifestFields,
65
+ type SignatureMarker,
66
+ type VerificationResult,
67
+ type VerificationState,
68
+ type VerifyOptions,
69
+ } from './manifest.ts';
70
+
71
+ // Working out what belongs in an archive, for what observing a run cannot see.
72
+ export {
73
+ walk,
74
+ moduleFiles,
75
+ dependencyFiles,
76
+ launcherPath,
77
+ packageRoot,
78
+ moduleDir,
79
+ type WalkOptions,
80
+ type ModuleFilesOptions,
81
+ } from './files.ts';
82
+
83
+ // The audit gate — step 3 of building a bundle, and the thing that makes it a
84
+ // step rather than a suggestion.
85
+ export {
86
+ prepare as prepareAudit,
87
+ check as checkAudit,
88
+ approve as approveAudit,
89
+ verdictPath,
90
+ type Verdict,
91
+ type Finding,
92
+ type AuditOptions,
93
+ type Preparation,
94
+ } from './audit.ts';
95
+
96
+ // Installing the auditing skill into a project.
97
+ export {
98
+ skills,
99
+ skill,
100
+ install as installSkill,
101
+ DEFAULT_SKILLS_DIR,
102
+ type SkillInfo,
103
+ type InstallResult,
104
+ } from './skill.ts';
105
+
106
+ // The CLI, as a function — so a host can offer the same commands without
107
+ // spawning anything.
108
+ export { main as cli, USAGE, type Console as CliConsole } from './cli.ts';
109
+
110
+ // Signing through sigstore, and the OIDC flows that identity comes from. Both
111
+ // are namespaced: they are a signer implementation and its plumbing, not part
112
+ // of the archive format, and naming them that way keeps that visible.
113
+ export * as sigstore from './sigstore.ts';
114
+ export * as oidc from './oidc.ts';
package/src/launch.ts ADDED
@@ -0,0 +1,336 @@
1
+ import * as VFS from 'node:vfs';
2
+ import * as FS from 'node:fs';
3
+ import * as PATH from 'node:path';
4
+ import * as ZLIB from 'node:zlib';
5
+ import { createRequire } from 'node:module';
6
+ import { pathToFileURL } from 'node:url';
7
+ import { open as openBundle, type ProviderOptions } from './provider.ts';
8
+ import { message, signatureOf, verifySync, type VerificationResult } from './manifest.ts';
9
+
10
+ // Verifying a container, mounting it, and running what is inside — the one path
11
+ // every shape of this tool ends up taking, factored out of the SEA it used to
12
+ // live in.
13
+ //
14
+ // Three callers reach it, and the differences between them are smaller than
15
+ // they look:
16
+ //
17
+ // * **A self-validating executable.** The archive is appended to the running
18
+ // binary, so the container is `process.execPath` and the signature covers
19
+ // the runtime and the verifier along with the application.
20
+ // * **A verifying node.** The same binary with no archive appended: it takes
21
+ // one on its command line, checks it, and runs it —
22
+ // `node-verifying ./my-app.zip --some --app --args`. One runtime, any
23
+ // number of applications, none of them trusted until they verify.
24
+ // * **A library.** `run()` does the same thing in a process you already have.
25
+ //
26
+ // In every case the mount is the *verifying* provider rather than a plain
27
+ // `ZipProvider`, so this is not a signature check at startup and nothing more:
28
+ // every member is re-hashed against its signed digest as it is first read, for
29
+ // the whole life of the process.
30
+
31
+ /** What a caller can decide about a container before it is allowed to run. */
32
+ export interface LaunchOptions {
33
+ /** Extra trusted roots, as PEM text or paths to PEM files. */
34
+ roots?: string[] | undefined;
35
+ /** Require this sigstore signing identity. */
36
+ identity?: string | undefined;
37
+ /** Require this sigstore OIDC issuer. */
38
+ issuer?: string | undefined;
39
+ /** Path to the sigstore trust root to check against. */
40
+ trustedRoot?: string | undefined;
41
+ /**
42
+ * Run a container whose signature is good but whose chain is not anchored
43
+ * in the trust store (default: false).
44
+ */
45
+ allowUntrusted?: boolean | undefined;
46
+ /**
47
+ * Recompute every member digest at mount rather than on first read
48
+ * (default: false — reads check them anyway, and this is startup latency).
49
+ */
50
+ deep?: boolean | undefined;
51
+ /** Entry point inside the archive, overriding its package.json `main`. */
52
+ entry?: string | undefined;
53
+ /**
54
+ * What to do when the container does not verify. The default prints the
55
+ * reason and exits 1; nothing from the archive has run at that point.
56
+ */
57
+ onRefuse?: ((reason: string) => void) | undefined;
58
+ }
59
+
60
+ /** Where a mounted container ended up, and what it is. */
61
+ export interface Mounted {
62
+ /** The generated mount point the archive is visible at. */
63
+ root: string;
64
+ /** The mount, so a caller can unmount it. */
65
+ vfs: VFS.VirtualFileSystem;
66
+ /** The resolved entry point, as an absolute path under `root`. */
67
+ entry: string;
68
+ }
69
+
70
+ /**
71
+ * Verify `container` and mount it. Returns where it landed; nothing has been
72
+ * executed out of it yet.
73
+ */
74
+ export function mount(container: string, options: LaunchOptions = {}): Mounted {
75
+ const settings: ProviderOptions = {
76
+ roots: options.roots,
77
+ identity: options.identity,
78
+ issuer: options.issuer,
79
+ trustedRoot: options.trustedRoot,
80
+ allowUntrusted: options.allowUntrusted,
81
+ deep: options.deep,
82
+ name: 'bundle-launch',
83
+ };
84
+
85
+ let provider;
86
+ try {
87
+ provider = openBundle(container, settings);
88
+ } catch (err) {
89
+ refuse(options, message(err));
90
+ }
91
+
92
+ const vfs = VFS.create(provider, { emitExperimentalWarning: false });
93
+ const root = vfs.mount();
94
+ return { root, vfs, entry: entryPoint(root, options.entry) };
95
+ }
96
+
97
+ /**
98
+ * Run what is at a mount's entry point. A CommonJS entry is `require()`d and an
99
+ * ES module is `import()`ed, chosen the way node itself chooses — the archive's
100
+ * `package.json` `type` and the entry's own extension.
101
+ */
102
+ export async function start({ root, entry }: Mounted): Promise<void> {
103
+ if (isModule(root, entry)) await import(pathToFileURL(entry).href);
104
+ else createRequire(PATH.join(root, 'package.json'))(entry);
105
+ }
106
+
107
+ /** Verify a container, mount it, and run the application inside. */
108
+ export async function run(container: string, options: LaunchOptions = {}): Promise<void> {
109
+ await start(mount(container, options));
110
+ }
111
+
112
+ /**
113
+ * Verify the running executable and run the archive appended to it — the
114
+ * self-validating shape, where the signature covers the runtime, the verifier
115
+ * and the application as one file.
116
+ */
117
+ export async function runSelf(options: LaunchOptions = {}): Promise<void> {
118
+ await run(process.execPath, options);
119
+ }
120
+
121
+ /** Verify a container without mounting or running it. */
122
+ export function verify(container: string, options: LaunchOptions = {}): VerificationResult {
123
+ const roots = (options.roots ?? []).map((root) => (root.includes('-----BEGIN') ? root : FS.readFileSync(root, 'utf-8')));
124
+ return verifySync(container, {
125
+ extraRoots: roots, deep: options.deep ?? false,
126
+ identity: options.identity, issuer: options.issuer, trustedRoot: options.trustedRoot,
127
+ });
128
+ }
129
+
130
+ /**
131
+ * What, if anything, is appended to a container.
132
+ *
133
+ * This is how one binary can be both shapes: an executable with a signed
134
+ * archive behind it runs *that*, and the same executable with nothing appended
135
+ * takes an archive from its command line instead. The discriminator is the
136
+ * signature marker rather than the presence of a ZIP, because a SEA carries an
137
+ * archive of its own inside its blob — one that is not at the tail, and never
138
+ * carries a marker.
139
+ */
140
+ export function appended(container: string): 'signed' | 'unsigned' | 'none' {
141
+ if (signatureOf(container) !== null) return 'signed';
142
+ try {
143
+ // An archive at the tail with no marker is the one case worth naming:
144
+ // somebody appended an application and never signed it.
145
+ ZLIB.ZipFile.openSync(container).closeSync();
146
+ return 'unsigned';
147
+ } catch {
148
+ return 'none';
149
+ }
150
+ }
151
+
152
+ // --------------------------------------------------------- the command line ---
153
+
154
+ /**
155
+ * The command line of a verifying node.
156
+ *
157
+ * Options are read up to the first non-option argument, which is the archive;
158
+ * everything after it belongs to the application, untouched. That is the shape
159
+ * of every command that wraps another one — `env`, `nice`, `time` — and it is
160
+ * what lets an application have flags of its own that collide with these.
161
+ */
162
+ export const USAGE = `usage: <runtime> [options] <archive> [args...]
163
+
164
+ Verifies an archive and runs the application inside it. Nothing from the archive
165
+ runs until its signature, its certificate chain and its member digests check out,
166
+ and every member is re-hashed as it is read for the life of the process.
167
+
168
+ options:
169
+ -r, --root <file> extra trusted root certificate (PEM); repeatable
170
+ --identity <san> require this sigstore signing identity
171
+ --issuer <url> require this sigstore OIDC issuer
172
+ --untrusted run an archive whose signature is good but unanchored
173
+ --deep check every member digest at mount, not on first read
174
+ --entry <path> entry point inside the archive, overriding its main
175
+ --verify report the archive's trust state and stop
176
+ -h, --help this
177
+ --version the verifier's version, and the runtime's
178
+
179
+ The same policy can come from the environment — BUNDLE_ROOTS, BUNDLE_IDENTITY,
180
+ BUNDLE_ISSUER, BUNDLE_SIGSTORE_ROOT, BUNDLE_ALLOW_UNTRUSTED — which is what a
181
+ container built with no policy of its own falls back to.
182
+ `;
183
+
184
+ /** Options baked into a runtime at build time, and whether they are the last word. */
185
+ export interface Baked extends LaunchOptions {
186
+ /**
187
+ * A runtime built with a policy of its own accepts no policy from its
188
+ * command line: a binary that demands a signing identity is not one whose
189
+ * user can ask it to stop. Adding a root, requiring a different identity
190
+ * and `--untrusted` are all refused. What remains — `--entry`, `--verify`,
191
+ * `--help` — cannot loosen anything.
192
+ */
193
+ sealed?: boolean | undefined;
194
+ }
195
+
196
+ /**
197
+ * Run a verifying node's command line. Returns the exit code; the application's
198
+ * own exit code is its business, set the ordinary way from inside it.
199
+ */
200
+ export async function main(argv: string[], baked: Baked = {}): Promise<number> {
201
+ const flags: LaunchOptions & { roots: string[] } = { roots: [...(baked.roots ?? [])] };
202
+ let verifyOnly = false;
203
+ let i = 0;
204
+
205
+ const sealedRefusal = (flag: string): number => {
206
+ process.stderr.write(`${flag} is not accepted: this runtime was built with a policy of its own\n`);
207
+ return 64;
208
+ };
209
+
210
+ for (; i < argv.length; i++) {
211
+ const arg = argv[i]!;
212
+ if (arg === '--') { i++; break; }
213
+ if (!arg.startsWith('-') || arg === '-') break;
214
+
215
+ // `--flag=value` and `--flag value` both, because both are written.
216
+ const eq = arg.indexOf('=');
217
+ const name = eq === -1 ? arg : arg.slice(0, eq);
218
+ const inline = eq === -1 ? undefined : arg.slice(eq + 1);
219
+ const value = (): string => {
220
+ const next = inline ?? argv[++i];
221
+ if (next === undefined) throw new Error(`${name} needs a value`);
222
+ return next;
223
+ };
224
+
225
+ switch (name) {
226
+ case '-h': case '--help':
227
+ process.stdout.write(USAGE);
228
+ return 0;
229
+ case '--version':
230
+ process.stdout.write(`${version()} (node ${process.versions.node})\n`);
231
+ return 0;
232
+ case '--verify':
233
+ verifyOnly = true;
234
+ break;
235
+ case '--deep':
236
+ flags.deep = true;
237
+ break;
238
+ case '--entry':
239
+ flags.entry = value();
240
+ break;
241
+ case '-r': case '--root':
242
+ if (baked.sealed) return sealedRefusal(name);
243
+ flags.roots.push(value());
244
+ break;
245
+ case '--identity':
246
+ if (baked.sealed) return sealedRefusal(name);
247
+ flags.identity = value();
248
+ break;
249
+ case '--issuer':
250
+ if (baked.sealed) return sealedRefusal(name);
251
+ flags.issuer = value();
252
+ break;
253
+ case '--untrusted':
254
+ if (baked.sealed) return sealedRefusal(name);
255
+ flags.allowUntrusted = true;
256
+ break;
257
+ default:
258
+ process.stderr.write(`unknown option ${name}\n\n${USAGE}`);
259
+ return 64;
260
+ }
261
+ }
262
+
263
+ const archive = argv[i];
264
+ if (archive === undefined) {
265
+ process.stderr.write(`no archive to run\n\n${USAGE}`);
266
+ return 64;
267
+ }
268
+
269
+ const options: LaunchOptions = {
270
+ ...baked,
271
+ ...flags,
272
+ roots: flags.roots.length > 0 ? flags.roots : undefined,
273
+ };
274
+ const container = PATH.resolve(archive);
275
+
276
+ if (verifyOnly) {
277
+ const result = verify(container, options);
278
+ process.stdout.write(`${result.state.toUpperCase()} — ${result.reason}\n`);
279
+ if (result.identity) process.stdout.write(` identity: ${result.identity}\n`);
280
+ return result.state === 'valid' ? 0 : 1;
281
+ }
282
+
283
+ // The application sees the argv it would have had from `--vfs-load`: the
284
+ // archive's own path where a script path goes, and its arguments from
285
+ // index 2 on. The runtime's own options are gone by then — they were the
286
+ // runtime's, not the program's.
287
+ process.argv = [process.argv[0]!, container, ...argv.slice(i + 1)];
288
+
289
+ await run(container, options);
290
+ return 0;
291
+ }
292
+
293
+ /** This package's version, read out of the mount the verifier is running from. */
294
+ function version(): string {
295
+ try {
296
+ const manifest = JSON.parse(
297
+ FS.readFileSync(PATH.join(import.meta.dirname, '..', 'package.json'), 'utf-8'),
298
+ ) as { name?: string; version?: string };
299
+ return `${manifest.name ?? 'bundle'} ${manifest.version ?? '0.0.0'}`;
300
+ } catch {
301
+ return 'bundle';
302
+ }
303
+ }
304
+
305
+ // ------------------------------------------------------------------ details ---
306
+
307
+ // The archive's entry point: an explicit override, else its package.json
308
+ // `main`, else `index.js` — node's own order for a directory.
309
+ function entryPoint(root: string, override: string | undefined): string {
310
+ if (override) return PATH.resolve(root, override);
311
+ const manifest = readPackage(root);
312
+ return PATH.resolve(root, typeof manifest['main'] === 'string' ? manifest['main'] : 'index.js');
313
+ }
314
+
315
+ function isModule(root: string, entry: string): boolean {
316
+ if (entry.endsWith('.mjs')) return true;
317
+ if (entry.endsWith('.cjs')) return false;
318
+ return readPackage(root)['type'] === 'module';
319
+ }
320
+
321
+ function readPackage(root: string): Record<string, unknown> {
322
+ try {
323
+ return JSON.parse(FS.readFileSync(PATH.join(root, 'package.json'), 'utf-8')) as Record<string, unknown>;
324
+ } catch {
325
+ return {};
326
+ }
327
+ }
328
+
329
+ function refuse(options: LaunchOptions, reason: string): never {
330
+ if (options.onRefuse) {
331
+ options.onRefuse(reason);
332
+ throw new Error(reason);
333
+ }
334
+ process.stderr.write(`refusing to run: ${reason}\n`);
335
+ process.exit(1);
336
+ }
package/src/main.ts ADDED
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+ import { main } from './cli.ts';
3
+
4
+ // The executable entry point, in every launch mode this tool has:
5
+ //
6
+ // * `bundle …` / `node dist/main.js …` — an ordinary CLI.
7
+ // * `--vfs-load=<archive>` — node runs the mounted package's
8
+ // `main`, which is this file, out of the archive.
9
+ // * the SEA container, whose bootstrap mounts its own tail and then requires
10
+ // the package inside it, landing here.
11
+ //
12
+ // In all three, `process.argv` is [runtime, entry, ...userArgs], so the user's
13
+ // arguments always start at index 2.
14
+ //
15
+ // Nothing but argv handling lives here. `cli.ts` returns an exit code rather
16
+ // than exiting, so it stays callable in-process — by a test, or by a host that
17
+ // wants the commands without a subprocess — and the one place that turns a code
18
+ // into an exit is this file.
19
+
20
+ process.exitCode = await main(process.argv.slice(2));