@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/record.ts ADDED
@@ -0,0 +1,25 @@
1
+ import { preload, sibling } from './preload.ts';
2
+ import type * as Recorder from './recorder.ts';
3
+
4
+ // The preload that puts manifest recording back where `--vfs-manifest` used to
5
+ // be: registering a recording provider is all it does, so `--vfs-load` finds it
6
+ // already in place when it picks a provider for a directory.
7
+ //
8
+ // BUNDLE_MANIFEST=app.manifest node --experimental-vfs \
9
+ // -r @pipobscure/bundle/record --vfs-load=./lib
10
+ //
11
+ // `--import @pipobscure/bundle/record` works just as well: mounting is deferred
12
+ // until both `-r` and `--import` preloads have run, and this module imports
13
+ // cleanly from ESM.
14
+ //
15
+ // Configure it through the environment — a preload takes no arguments:
16
+ //
17
+ // BUNDLE_MANIFEST where to write the file list; unset records nothing
18
+ //
19
+ // For anything more (recording an archive mount, several manifests, keeping an
20
+ // existing list), import `@pipobscure/bundle/recorder` and call `register()` or
21
+ // `recording()` from a preload module of your own.
22
+
23
+ preload(() => {
24
+ sibling<typeof Recorder>(import.meta.filename, 'recorder').register();
25
+ });
@@ -0,0 +1,166 @@
1
+ import * as VFS from 'node:vfs';
2
+ import * as FS from 'node:fs';
3
+ import { isMainThread } from 'node:worker_threads';
4
+
5
+ // Manifest recording, in userland: the replacement for the `--vfs-manifest`
6
+ // flag now that mounting from the command line is `--vfs-load` and provider
7
+ // selection is the only place that mount can be influenced.
8
+ //
9
+ // node -r @pipobscure/bundle/record --vfs-load=./lib
10
+ //
11
+ // The idea is unchanged — run the application once and write down every file it
12
+ // actually reads through the mount, which is the exact set an archive of it
13
+ // needs to contain — but it is a *provider* that does it rather than a hook
14
+ // inside `node:vfs`. `recording()` wraps a provider class and records reads on
15
+ // the way through; `register()` installs a recording `RealFSProvider` so a
16
+ // directory mount picks it up.
17
+ //
18
+ // Two differences from the flag it replaces, both deliberate:
19
+ //
20
+ // * The flag hooked `readFile()`/`readFileSync()` only. Those are where the
21
+ // module loader and ordinary `fs.readFile` calls converge, but a
22
+ // `createReadStream()` goes through `open()` and a handle instead, and was
23
+ // never recorded. Here read-only `open()`s are recorded too: a file that
24
+ // was opened but never read is harmless in a bundle, while a streamed file
25
+ // that is missing from one is not.
26
+ //
27
+ // * Recording is a property of a mount rather than of the process. The
28
+ // command line mounts exactly one source — `--vfs-load` is the only flag
29
+ // left that mounts anything — so in the ordinary case it is the same file
30
+ // the flag produced; a program that mounts more directories of its own
31
+ // through a recording provider adds them to the same list.
32
+
33
+ export interface ManifestOptions {
34
+ /**
35
+ * Start a fresh list. Defaults to true only on the main thread: worker
36
+ * threads inherit `execArgv`, so this preload runs in them too, and a
37
+ * worker must append to what the main thread already wrote rather than
38
+ * wipe it mid-run.
39
+ */
40
+ truncate?: boolean | undefined;
41
+ }
42
+
43
+ export interface RecorderOptions extends ManifestOptions {
44
+ /** Where to write the file list (default: the `BUNDLE_MANIFEST` variable). */
45
+ manifest?: string | Manifest | undefined;
46
+ /** Identifier reported in diagnostics (default: 'bundle-manifest'). */
47
+ name?: string | undefined;
48
+ }
49
+
50
+ /**
51
+ * The manifest file itself: which paths have been written, and the appending.
52
+ * Paths are appended as soon as they are read rather than buffered and flushed
53
+ * at exit, so nothing is lost if the process is killed instead of exiting.
54
+ */
55
+ export class Manifest {
56
+ #path: string;
57
+ #seen = new Set<string>();
58
+
59
+ // Concurrent appends are safe without any coordination — O_APPEND writes do
60
+ // not interleave — which is what lets worker threads share one manifest.
61
+ constructor(path: string, { truncate = isMainThread }: ManifestOptions = {}) {
62
+ this.#path = path;
63
+ if (truncate) FS.writeFileSync(path, '');
64
+ }
65
+
66
+ get path(): string { return this.#path; }
67
+
68
+ /** Every path recorded so far, in the order they were first read. */
69
+ get paths(): string[] { return [...this.#seen]; }
70
+
71
+ /**
72
+ * Record one VFS path, once. Best effort: bookkeeping must never break the
73
+ * read that triggered it.
74
+ */
75
+ record(path: string): void {
76
+ try {
77
+ const relative = String(path).replace(/^\/+/, '');
78
+ if (relative === '' || this.#seen.has(relative)) return;
79
+ this.#seen.add(relative);
80
+ FS.appendFileSync(this.#path, `${relative}\n`);
81
+ } catch {
82
+ // ignored
83
+ }
84
+ }
85
+ }
86
+
87
+ /**
88
+ * The shape `recording()` accepts and returns — any provider class. The rest
89
+ * parameter is what lets the wrapper be a mixin over a class whose constructor
90
+ * takes whatever it likes, and hand back one with the same signature.
91
+ */
92
+ type ProviderClass = new (...args: any[]) => VFS.VirtualProvider;
93
+
94
+ /**
95
+ * Wrap a provider class so every read through it is recorded in `manifest`.
96
+ * Works over any `VirtualProvider` subclass — a `RealFSProvider` for a
97
+ * directory mount, a `ZipProvider` or the signed-archive provider from
98
+ * './provider.ts' for an archive one — and leaves the constructor signature of
99
+ * the class it wraps untouched:
100
+ *
101
+ * const Recording = recording(VFS.RealFSProvider, new Manifest('app.manifest'));
102
+ * const provider = new Recording('/path/to/directory');
103
+ */
104
+ export function recording<T extends ProviderClass>(Base: T, manifest: Manifest): T {
105
+ return class Recording extends Base {
106
+ override async readFile(path: string, options?: unknown): Promise<Buffer> {
107
+ const content = await super.readFile(path, options);
108
+ manifest.record(path);
109
+ return content;
110
+ }
111
+
112
+ override readFileSync(path: string, options?: unknown): Buffer {
113
+ const content = super.readFileSync(path, options);
114
+ manifest.record(path);
115
+ return content;
116
+ }
117
+
118
+ override async open(path: string, flags?: string | number, mode?: number) {
119
+ const handle = await super.open(path, flags, mode);
120
+ if (reads(flags)) manifest.record(path);
121
+ return handle;
122
+ }
123
+
124
+ override openSync(path: string, flags?: string | number, mode?: number) {
125
+ const handle = super.openSync(path, flags, mode);
126
+ if (reads(flags)) manifest.record(path);
127
+ return handle;
128
+ }
129
+ } as T;
130
+ }
131
+
132
+ /**
133
+ * Register a recording `RealFSProvider` so a directory loaded with
134
+ * `--vfs-load` is backed by it. Meant to be preloaded, before the mount is made:
135
+ *
136
+ * BUNDLE_MANIFEST=app.manifest node --experimental-vfs \
137
+ * -r @pipobscure/bundle/record --vfs-load=./lib
138
+ *
139
+ * With no manifest destination there is nothing to record, so nothing is
140
+ * registered and directory mounts are left exactly as node makes them; the
141
+ * return value is then null.
142
+ */
143
+ export function register(options: RecorderOptions = {}): Manifest | null {
144
+ const configured = options.manifest ?? process.env['BUNDLE_MANIFEST'];
145
+ if (!configured) return null;
146
+
147
+ const manifest = configured instanceof Manifest ? configured : new Manifest(configured, options);
148
+ const Recording = recording(VFS.RealFSProvider, manifest);
149
+ VFS.registerProvider({
150
+ name: options.name ?? 'bundle-manifest',
151
+ // Directory mounts only: an archive mount has its own provider (and,
152
+ // for a `.bundle`, one that verifies what it serves), and quietly
153
+ // replacing it here would be the wrong trade for a file list.
154
+ canHandle: (_resolvedPath, stats) => stats.isDirectory(),
155
+ create: (resolvedPath) => new Recording(resolvedPath),
156
+ });
157
+ return manifest;
158
+ }
159
+
160
+ // Whether `flags` opens for reading. Mirrors how `node:fs` reads them: a string
161
+ // means what it says, a number is a bitmask, anything else is 'r'.
162
+ function reads(flags: string | number | undefined): boolean {
163
+ if (typeof flags === 'string') return flags[0] === 'r' || flags.includes('+');
164
+ if (typeof flags !== 'number') return true;
165
+ return (flags & (FS.constants.O_WRONLY | FS.constants.O_TRUNC)) === 0;
166
+ }
@@ -0,0 +1,30 @@
1
+ import { preload, sibling } from './preload.ts';
2
+ import type * as Provider from './provider.ts';
3
+
4
+ // The preload entry point: registering the signed-archive provider is all this
5
+ // does, so `--vfs-load` finds it already in place when it picks a provider for
6
+ // its source.
7
+ //
8
+ // node --experimental-vfs -r @pipobscure/bundle/register --vfs-load=app.bundle
9
+ //
10
+ // `--import @pipobscure/bundle/register` works just as well: mounting is
11
+ // deferred until both `-r` and `--import` preloads have run, and this module
12
+ // imports cleanly from ESM. Either way the point is that registering is a
13
+ // preload flag and nothing more — no module of your own to write.
14
+ //
15
+ // Configure it through the environment — a preload takes no arguments:
16
+ //
17
+ // BUNDLE_ROOTS extra trusted root certificates, as a
18
+ // path-delimiter-separated list of PEM files
19
+ // BUNDLE_ALLOW_UNTRUSTED accept a good signature whose chain is not anchored
20
+ // in the trust store
21
+ // BUNDLE_IDENTITY require this sigstore signing identity
22
+ // BUNDLE_ISSUER require this sigstore OIDC issuer
23
+ // BUNDLE_SIGSTORE_ROOT path to the sigstore trust root to check against
24
+ //
25
+ // For anything more, import `@pipobscure/bundle/provider` and call `register()`
26
+ // with options from a preload module of your own.
27
+
28
+ preload(() => {
29
+ sibling<typeof Provider>(import.meta.filename, 'provider').register();
30
+ });
package/src/sea.ts ADDED
@@ -0,0 +1,341 @@
1
+ import * as FS from 'node:fs';
2
+ import * as OS from 'node:os';
3
+ import * as PATH from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { mount, start, verify, type Baked, type Mounted } from './launch.ts';
6
+ import { signBundle, createBundle, type BuildResult } from './api.ts';
7
+ import type { VerificationResult } from './manifest.ts';
8
+ import { moduleFiles, packageRoot, moduleDir } from './files.ts';
9
+ import type { Signer } from './archive.ts';
10
+
11
+ // Building the executables: a node runtime with this package inside it, which
12
+ // verifies an archive before running anything out of it.
13
+ //
14
+ // One base binary, two things to do with it:
15
+ //
16
+ // [ node runtime | SEA blob: stub + verifier.bundle ]
17
+ // a *verifying node* — `node-verifying ./my-app.zip` checks that archive
18
+ // and runs it. Any archive, checked every time, none of them baked in.
19
+ //
20
+ // [ node runtime | SEA blob: stub + verifier.bundle ] [ app.bundle ]
21
+ // \______________ the prefix, and part of the app archive's ______/
22
+ // \____________ signed region ____________________/
23
+ // a *self-validating executable* — the same base with an application
24
+ // appended and the whole file signed as one.
25
+ //
26
+ // The second is the first with an archive behind it, which is not a
27
+ // coincidence: appending is `sign --prefix`, the same operation that puts a
28
+ // shebang in front of an archive. What makes it self-validating is that the
29
+ // whole-file hash covers the prefix too, so the runtime and the verifier are
30
+ // signed by the same signature that covers the application. There is nothing to
31
+ // check the checker against because the checker is inside what is checked.
32
+ //
33
+ // Which shape a binary is, it decides at startup by looking at its own tail —
34
+ // see `appended()` in `./launch.ts`. That is why one base serves both, and why
35
+ // a verifying node built today can become a self-validating executable
36
+ // tomorrow with nothing but `bundle sign --prefix`.
37
+ //
38
+ // ## The package rides in the blob as an archive
39
+ //
40
+ // The stub runs before anything is mounted, so it cannot import this package
41
+ // the ordinary way. It does not have to: `"useVfs": true` with
42
+ // `"vfsArchive": <bundle>` (nodejs/node#65675 and the `vfsArchive` that
43
+ // followed it) embeds a ZIP in the executable and mounts it as the file system
44
+ // the main script runs from. So the stub is three lines — require this
45
+ // package's launcher by a relative path and hand over — and the machinery that
46
+ // used to do it by hand, mounting a raw asset through a `ZipBuffer`, is gone.
47
+ //
48
+ // ## What runs before the check
49
+ //
50
+ // The stub and the verifier execute before any signature has been verified.
51
+ // That is not a hole so much as the place where the trust has to start: in a
52
+ // self-validating executable both live inside the prefix, which is inside the
53
+ // hashed region, so tampering with either invalidates the signature over the
54
+ // application — and an attacker who can rewrite the executable's own runtime
55
+ // could equally rewrite a verifier that ran first. The application never runs
56
+ // until the check passes.
57
+
58
+ // ------------------------------------------------------------------ runtime ---
59
+
60
+ // The runtime half moved to `./launch.ts`, where the verifying node's command
61
+ // line lives beside it. These are the names this module has always exported;
62
+ // they are kept because a container built by an older version of this package
63
+ // still calls them, and because "verify myself and run what is in me" reads
64
+ // better than "run the container that happens to be process.execPath".
65
+
66
+ export interface BootstrapOptions extends Baked {
67
+ /** The container to verify and mount (default: `process.execPath`). */
68
+ container?: string | undefined;
69
+ }
70
+ export type { Mounted };
71
+
72
+ /**
73
+ * Verify the running container and mount the archive appended to it. Returns
74
+ * where it landed; nothing has been executed out of it yet.
75
+ */
76
+ export function mountSelf(options: BootstrapOptions = {}): Mounted {
77
+ return mount(options.container ?? process.execPath, options);
78
+ }
79
+
80
+ /** Verify the running container, mount it, and run the application inside. */
81
+ export async function bootstrap(options: BootstrapOptions = {}): Promise<void> {
82
+ await start(mountSelf(options));
83
+ }
84
+
85
+ /**
86
+ * Verify the running container without mounting it — for an application that
87
+ * wants to report on its own provenance ("signed by X at Y").
88
+ */
89
+ export function verifySelf(options: BootstrapOptions = {}): VerificationResult {
90
+ return verify(options.container ?? process.execPath, options);
91
+ }
92
+
93
+ // -------------------------------------------------------------------- build ---
94
+
95
+ /** The default flags the container runs itself with. */
96
+ export const SEA_EXEC_ARGV = ['--no-warnings', '--experimental-vfs'];
97
+
98
+ export interface VerifierOptions {
99
+ /**
100
+ * Include the sigstore libraries, so the container can check a
101
+ * sigstore-signed archive rather than degrading it to untrusted
102
+ * (default: true). Costs roughly a megabyte of dependency tree.
103
+ */
104
+ sigstore?: boolean | undefined;
105
+ }
106
+
107
+ export interface SeaBaseOptions extends VerifierOptions {
108
+ /** Where to write the base binary. */
109
+ output: string;
110
+ /**
111
+ * The node binary to embed (default: the running one). This is the runtime
112
+ * the finished container ships, so it decides which platform it runs on.
113
+ */
114
+ node?: string | undefined;
115
+ /** Options baked into the stub and handed to `bootstrap()` at startup. */
116
+ bootstrap?: BootstrapOptions | undefined;
117
+ /** Runtime flags the container applies to itself. */
118
+ execArgv?: string[] | undefined;
119
+ /** A prebuilt verifier bundle to embed, instead of building one. */
120
+ verifier?: string | undefined;
121
+ /** Scratch directory for the generated stub and config. */
122
+ scratch?: string | undefined;
123
+ }
124
+
125
+ export interface SeaOptions extends SeaBaseOptions {
126
+ /** The application archive to append. Signed or not; it is re-signed here. */
127
+ app: string;
128
+ /** A base built earlier, instead of building one now. */
129
+ base?: string | undefined;
130
+ /** Sign the finished container. Without one it is built but left unsigned. */
131
+ signer?: Signer | undefined;
132
+ hashAlg?: string | undefined;
133
+ signAlg?: string | undefined;
134
+ /** Progress, one line at a time. */
135
+ log?: ((line: string) => void) | undefined;
136
+ }
137
+
138
+ export interface SeaBaseResult {
139
+ output: string;
140
+ size: number;
141
+ /** Members of the verifier bundle embedded in the blob. */
142
+ verifier: string[];
143
+ }
144
+
145
+ /**
146
+ * Build the SEA base: a node runtime whose injected main mounts this package
147
+ * out of its own blob and hands over to `bootstrap()`. The result is a binary
148
+ * with no application in it yet — append one with `buildSea` or with
149
+ * `sign --prefix`.
150
+ */
151
+ export async function createSeaBase(options: SeaBaseOptions): Promise<SeaBaseResult> {
152
+ const scratch = options.scratch ?? FS.mkdtempSync(PATH.join(OS.tmpdir(), 'bundle-sea-'));
153
+ const owned = !options.scratch;
154
+ try {
155
+ let verifier = options.verifier;
156
+ let contents: string[];
157
+ if (verifier) {
158
+ contents = [];
159
+ } else {
160
+ verifier = PATH.join(scratch, 'verifier.bundle');
161
+ const files = verifierFiles(options);
162
+ await createBundle({ base: packageRoot(), files, output: verifier });
163
+ contents = files;
164
+ }
165
+
166
+ const stub = PATH.join(scratch, 'stub.js');
167
+ FS.writeFileSync(stub, stubSource(anchorPolicy(options.bootstrap ?? {})));
168
+
169
+ const config = PATH.join(scratch, 'sea-config.json');
170
+ FS.writeFileSync(config, `${JSON.stringify({
171
+ main: stub,
172
+ output: PATH.resolve(options.output),
173
+ disableExperimentalSEAWarning: true,
174
+ useSnapshot: false,
175
+ useCodeCache: false,
176
+ // The verifier bundle is the executable's file system rather than
177
+ // an asset it has to unpack: node embeds the ZIP as it is and
178
+ // mounts it, and the stub — injected at the root of that mount —
179
+ // requires this package out of it by relative path.
180
+ useVfs: true,
181
+ vfsArchive: verifier,
182
+ // The stub is injected at the root of that bundle, where this
183
+ // package's own `"type": "module"` decides what a `.js` is — so it
184
+ // is an ES module, and saying so is what makes it one. Note that
185
+ // `"mainFormat": "commonjs"` would not do the opposite: the mount's
186
+ // package.json still wins, and the only way to a CommonJS stub is a
187
+ // `.cjs` extension.
188
+ mainFormat: 'module',
189
+ execArgv: options.execArgv ?? SEA_EXEC_ARGV,
190
+ execArgvExtension: 'none',
191
+ ...(options.node ? { executable: PATH.resolve(options.node) } : {}),
192
+ }, null, 2)}\n`);
193
+
194
+ const built = spawnSync(process.execPath, ['--no-warnings', '--build-sea', config],
195
+ { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8' });
196
+ if (built.error) throw built.error;
197
+ if (built.status !== 0) {
198
+ throw new Error(`--build-sea failed (exit ${built.status}): ${(built.stderr || built.stdout || '').trim()}`);
199
+ }
200
+ FS.chmodSync(options.output, 0o755);
201
+
202
+ // `--build-sea` ignores configuration keys it does not know, so a node
203
+ // without `vfsArchive` would produce a binary that builds cleanly and
204
+ // fails at startup with nothing mounted. Run the result once: it costs
205
+ // milliseconds and it is the difference between finding that out here
206
+ // and finding it out in front of a user. A cross-build cannot be run,
207
+ // so it is not checked — the platform it is for is not this one.
208
+ if (!options.node) selftest(options.output);
209
+
210
+ return { output: options.output, size: FS.statSync(options.output).size, verifier: contents };
211
+ } finally {
212
+ if (owned) FS.rmSync(scratch, { recursive: true, force: true });
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Build a self-validating executable: a SEA base with `app` appended and the
218
+ * whole thing signed as one file. Everything the container will check — the
219
+ * runtime, the verifier and the application — is inside what the signature
220
+ * covers.
221
+ */
222
+ export async function buildSea(options: SeaOptions): Promise<BuildResult> {
223
+ const log = options.log ?? (() => {});
224
+ const scratch = options.scratch ?? FS.mkdtempSync(PATH.join(OS.tmpdir(), 'bundle-sea-'));
225
+ const owned = !options.scratch;
226
+ try {
227
+ let base = options.base;
228
+ if (!base) {
229
+ base = PATH.join(scratch, 'sea-base');
230
+ log('* building the SEA base (node runtime + verifier)');
231
+ const built = await createSeaBase({ ...options, output: base, scratch });
232
+ log(` base: ${built.size} bytes, ${built.verifier.length} verifier members`);
233
+ }
234
+
235
+ log(`* appending ${options.app} behind ${PATH.basename(base)}`);
236
+ const res = await signBundle({
237
+ source: options.app,
238
+ output: options.output,
239
+ prefix: base,
240
+ executable: true,
241
+ hashAlg: options.hashAlg,
242
+ signAlg: options.signAlg,
243
+ signer: options.signer,
244
+ });
245
+ log(res.signed ? `* signed: ${res.hash}` : '* built unsigned — it will refuse to run until it is signed');
246
+ return res;
247
+ } finally {
248
+ if (owned) FS.rmSync(scratch, { recursive: true, force: true });
249
+ }
250
+ }
251
+
252
+ /**
253
+ * The files the embedded verifier needs, as paths relative to this package's
254
+ * root. That is this package's own compiled modules plus, unless turned off,
255
+ * the sigstore libraries — resolved through `node_modules` rather than listed,
256
+ * so the set cannot fall behind the dependency tree.
257
+ */
258
+ export function verifierFiles({ sigstore = true }: VerifierOptions = {}): string[] {
259
+ return moduleFiles({
260
+ base: packageRoot(),
261
+ files: ['package.json'],
262
+ dirs: [moduleDir()],
263
+ dependencies: sigstore ? SIGSTORE_PACKAGES : [],
264
+ // Source maps and declarations are for reading the code, not running
265
+ // it, and a verifier that ships inside every executable should carry
266
+ // only what it executes.
267
+ filter: (name) => !name.endsWith('.map') && !name.endsWith('.d.ts') && !name.endsWith('.d.cts'),
268
+ });
269
+ }
270
+
271
+ /** The sigstore libraries verification needs; signing pulls in more at runtime. */
272
+ const SIGSTORE_PACKAGES = ['@sigstore/verify', '@sigstore/bundle', '@sigstore/protobuf-specs', '@sigstore/tuf'];
273
+
274
+ /**
275
+ * Resolve the paths in a baked policy, because a policy is baked *here* and read
276
+ * *there*. `--root build/certs/root.pem` is a sentence about the directory the
277
+ * build ran in; the binary that carries it may be run anywhere, by anyone, and a
278
+ * trust root it cannot find is a container that refuses everything. PEM text
279
+ * passes through untouched — it is not a path and has no directory to be
280
+ * relative to.
281
+ */
282
+ function anchorPolicy(options: BootstrapOptions): BootstrapOptions {
283
+ const anchored = { ...options };
284
+ if (options.roots) {
285
+ anchored.roots = options.roots.map((root) => (root.includes('-----BEGIN') ? root : PATH.resolve(root)));
286
+ }
287
+ if (options.trustedRoot) anchored.trustedRoot = PATH.resolve(options.trustedRoot);
288
+ return anchored;
289
+ }
290
+
291
+ /**
292
+ * Run the built binary once, to prove that the file system inside it mounts and
293
+ * this package can be required out of it. `--version` is the cheapest thing
294
+ * that touches all of that, and it works whichever shape the binary is.
295
+ */
296
+ function selftest(output: string): void {
297
+ const res = spawnSync(PATH.resolve(output), ['--version'],
298
+ { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8' });
299
+ if (res.error) throw res.error;
300
+ if (res.status !== 0 || !/bundle/.test(res.stdout)) {
301
+ throw new Error(
302
+ `the built executable does not run (exit ${res.status}): ${(res.stderr || res.stdout || '').trim()}\n` +
303
+ 'A node whose --build-sea does not understand "vfsArchive" builds exactly this: ' +
304
+ 'the configuration key is ignored, nothing is mounted, and the stub has nothing to require.');
305
+ }
306
+ }
307
+
308
+ /**
309
+ * The CommonJS stub injected as the SEA's main script. It runs at the root of
310
+ * the mounted verifier bundle, so requiring this package is a relative path and
311
+ * nothing more; everything else it needs to decide, `launch` decides by looking
312
+ * at the binary's own tail.
313
+ *
314
+ * It stays the one piece no test can exercise from source, so it stays small.
315
+ */
316
+ export function stubSource(options: BootstrapOptions): string {
317
+ const dir = moduleDir();
318
+ const entry = `./${dir}/launch${dir === 'src' ? '.ts' : '.js'}`;
319
+ return `// Generated by @pipobscure/bundle. The SEA main, running at the root of the
320
+ // archive this executable carries: hand over to the launcher, which verifies
321
+ // either the archive appended to this file or the one named on the command
322
+ // line before running anything out of it.
323
+ import * as launch from ${JSON.stringify(entry)};
324
+
325
+ const OPTIONS = ${JSON.stringify(options, null, 2)};
326
+
327
+ try {
328
+ // Only a binary with nothing behind it takes an archive from the command
329
+ // line. One with an *unsigned* archive appended is not a launcher with a
330
+ // stray tail — it is a container somebody forgot to sign, and saying so is
331
+ // worth more than falling back to a usage message.
332
+ const code = launch.appended(process.execPath) === 'none'
333
+ ? await launch.main(process.argv.slice(2), OPTIONS)
334
+ : await launch.runSelf(OPTIONS);
335
+ if (typeof code === 'number' && code !== 0) process.exitCode = code;
336
+ } catch (err) {
337
+ process.stderr.write(\`\${err && err.stack || err}\\n\`);
338
+ process.exitCode = 1;
339
+ }
340
+ `;
341
+ }