@gjsify/cli 0.44.0 → 0.46.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/dist/cli.gjs.mjs +202 -167
- package/lib/commands/gettext.d.ts +2 -1
- package/lib/commands/gettext.js +105 -47
- package/lib/commands/onboard.d.ts +7 -0
- package/lib/commands/onboard.js +349 -96
- package/lib/commands/ship.d.ts +3 -0
- package/lib/commands/ship.js +850 -58
- package/lib/commands/trust.d.ts +6 -0
- package/lib/commands/trust.js +1 -1
- package/lib/commands/upgrade.d.ts +23 -0
- package/lib/commands/upgrade.js +95 -12
- package/lib/templates/install.mjs.tmpl +2 -2
- package/lib/types/config-data.d.ts +127 -1
- package/lib/utils/gi-search-path.js +11 -3
- package/lib/utils/msgfmt-merge.d.ts +35 -0
- package/lib/utils/msgfmt-merge.js +102 -0
- package/lib/utils/npm-otp.d.ts +27 -2
- package/lib/utils/npm-otp.js +82 -7
- package/lib/utils/onboard-discovery.d.ts +68 -0
- package/lib/utils/onboard-discovery.js +178 -0
- package/lib/utils/onboard-probe.d.ts +118 -1
- package/lib/utils/onboard-probe.js +153 -4
- package/lib/utils/prompt-output.d.ts +12 -0
- package/lib/utils/prompt-output.js +54 -0
- package/lib/utils/prompt.js +22 -2
- package/lib/utils/publishable-packages.d.ts +1 -1
- package/lib/utils/publishable-packages.js +1 -0
- package/lib/utils/resolve-npm-package.d.ts +56 -0
- package/lib/utils/resolve-npm-package.js +69 -0
- package/lib/utils/ship/app-runtime.d.ts +214 -0
- package/lib/utils/ship/app-runtime.js +429 -0
- package/lib/utils/ship/depends.d.ts +50 -0
- package/lib/utils/ship/depends.js +117 -3
- package/lib/utils/ship/discover.js +103 -1
- package/lib/utils/ship/dmg.d.ts +71 -0
- package/lib/utils/ship/dmg.js +181 -0
- package/lib/utils/ship/flatpak.js +1 -1
- package/lib/utils/ship/formats.d.ts +108 -7
- package/lib/utils/ship/formats.js +778 -15
- package/lib/utils/ship/launcher.d.ts +13 -3
- package/lib/utils/ship/launcher.js +419 -17
- package/lib/utils/ship/layout.d.ts +330 -0
- package/lib/utils/ship/layout.js +504 -0
- package/lib/utils/ship/localize-metadata.d.ts +22 -0
- package/lib/utils/ship/localize-metadata.js +126 -0
- package/lib/utils/ship/mime.js +1 -9
- package/lib/utils/ship/msi.d.ts +126 -0
- package/lib/utils/ship/msi.js +434 -0
- package/lib/utils/ship/node-runtime.d.ts +71 -0
- package/lib/utils/ship/node-runtime.js +122 -0
- package/lib/utils/ship/payload.d.ts +156 -5
- package/lib/utils/ship/payload.js +599 -14
- package/lib/utils/ship/pe-launcher.d.ts +29 -0
- package/lib/utils/ship/pe-launcher.js +663 -0
- package/lib/utils/ship/plan.d.ts +21 -0
- package/lib/utils/ship/plan.js +55 -7
- package/lib/utils/ship/plist.d.ts +98 -0
- package/lib/utils/ship/plist.js +256 -0
- package/lib/utils/ship/rpm.js +16 -3
- package/lib/utils/ship/schemas.d.ts +61 -0
- package/lib/utils/ship/schemas.js +118 -0
- package/lib/utils/ship/scripts.js +7 -3
- package/lib/utils/ship/settings.d.ts +91 -2
- package/lib/utils/ship/settings.js +93 -1
- package/lib/utils/ship/share-dirs.d.ts +53 -0
- package/lib/utils/ship/share-dirs.js +76 -0
- package/lib/utils/ship/signing.d.ts +311 -0
- package/lib/utils/ship/signing.js +506 -0
- package/lib/utils/ship/stage-manifest.d.ts +40 -12
- package/lib/utils/ship/stage-manifest.js +99 -13
- package/lib/utils/ship/stage-writer.d.ts +48 -0
- package/lib/utils/ship/stage-writer.js +91 -2
- package/lib/utils/ship/types.d.ts +191 -14
- package/lib/utils/ship/xml.d.ts +7 -0
- package/lib/utils/ship/xml.js +25 -0
- package/lib/utils/ship/zip.d.ts +69 -0
- package/lib/utils/ship/zip.js +245 -0
- package/lib/utils/system-gi.d.ts +20 -5
- package/lib/utils/system-gi.js +36 -13
- package/lib/utils/trust-registry.js +5 -0
- package/package.json +19 -19
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
// Signing the payload, and submitting the artifact for notarisation
|
|
2
|
+
// (ADR 0024 § A12–§ A17).
|
|
3
|
+
//
|
|
4
|
+
// FOUR PROPERTIES, each one a decision the ADR settles and this file implements:
|
|
5
|
+
//
|
|
6
|
+
// 1. `--sign` takes an IDENTITY, not a certificate (§ A12). `codesign` is handed
|
|
7
|
+
// a STRING and looks the private key up itself; `-` is the reserved value for
|
|
8
|
+
// ad-hoc, and this tree already writes `codesign --force --sign -` in five
|
|
9
|
+
// places. So there is no `--certificate`, no `--p12` and no `--password`
|
|
10
|
+
// anywhere on this surface — getting a key into a keychain is the signing
|
|
11
|
+
// HOST's job (§ A14), and nothing here can leak a secret it is never given.
|
|
12
|
+
// 2. An absent identity SKIPS, loudly, at exit 0 (§ A13). Unsigned is the
|
|
13
|
+
// default path and a legitimate deliverable; what § 5 refuses is the other
|
|
14
|
+
// direction — claiming a signature that was not made.
|
|
15
|
+
// 3. Signing is a payload MUTATION, not a wrapper (§ A4). Under hardened
|
|
16
|
+
// runtime a Developer-ID-signed main executable will not load ad-hoc-signed
|
|
17
|
+
// dylibs, and all 106 Mach-O images in the shipped darwin GTK closure carry
|
|
18
|
+
// an ad-hoc `LC_CODE_SIGNATURE` already (they must — `install_name_tool`
|
|
19
|
+
// invalidates the original during relocation). So the darwin leg re-signs
|
|
20
|
+
// every image, and the result is new BYTES for the packer rather than a
|
|
21
|
+
// container around the old ones.
|
|
22
|
+
// 4. THE ORDER IS STRUCTURAL, not conventional (§ A17). `readStage` compares
|
|
23
|
+
// each file's SIZE against `.gjsify-ship-stage.json`, and a size is no more
|
|
24
|
+
// re-sign-proof than a digest. BOTH halves are measured and they agree:
|
|
25
|
+
// append one byte to a staged file and `readStage` refuses with "… is 6
|
|
26
|
+
// bytes in the stage and 5 in its manifest"; and an ad-hoc `codesign
|
|
27
|
+
// --force --sign -` over one staged image took it from 34 816 to 34 848
|
|
28
|
+
// bytes (+32) on macos-latest/arm64. {@link signPayload} therefore TAKES what `readStage`
|
|
29
|
+
// returned and RETURNS what the packer consumes: the signed bytes cannot
|
|
30
|
+
// exist before the validation, because they are computed from its output,
|
|
31
|
+
// and the arriving stage is never written to at all.
|
|
32
|
+
//
|
|
33
|
+
// THREE THINGS THIS FILE USED TO REFUSE TO DO, and the measurement that changed
|
|
34
|
+
// each of them (ADR 0040). Every one is marked UNVERIFIED where it is: this
|
|
35
|
+
// repository has no macOS host of its own, so what follows is argv and file
|
|
36
|
+
// structure decided from Apple's documentation and held by unit tests, with the
|
|
37
|
+
// darwin CI leg as the only thing that has ever run `codesign` here.
|
|
38
|
+
//
|
|
39
|
+
// * IT NOW SEALS THE `<App>.app` BUNDLE, and the reason it did not was WRONG.
|
|
40
|
+
// The header used to say a bundle seal "would not survive into the `.zip`"
|
|
41
|
+
// because a script main executable's signature lives in an extended attribute
|
|
42
|
+
// and the payload round trip carries none. That is Apple's rule for a LOOSE
|
|
43
|
+
// file, not for a bundle. Apple's TN3126 gives four cases and ours is the
|
|
44
|
+
// second: *"If the item is a bundle without a Mach-O image, the code signature
|
|
45
|
+
// is stored in the bundle's _CodeSignature directory"* — regular files, mode
|
|
46
|
+
// 0644, which a plain zip carries like any other. TN2206 states the same from
|
|
47
|
+
// the other side: *"a properly-signed app that has all of its files in the
|
|
48
|
+
// correct places will not contain any signatures stored as extended
|
|
49
|
+
// attributes."* So the seal is reachable, and what was actually blocking it was
|
|
50
|
+
// this file's own refusal of a changed file set. {@link signPayload} now allows
|
|
51
|
+
// exactly the additions the seal makes and still refuses every other one.
|
|
52
|
+
// * IT NOW PASSES `--options runtime`, `--entitlements` and (for a real identity)
|
|
53
|
+
// `--timestamp`. The hardened runtime is a bit in the CODE DIRECTORY, which an
|
|
54
|
+
// ad-hoc signature has, so the flag is accepted with `--sign -` as it is with a
|
|
55
|
+
// Developer ID. `--timestamp` is not: a timestamp lives in the CMS structure
|
|
56
|
+
// and an ad-hoc signature has no CMS and no certificate to pin, so it is passed
|
|
57
|
+
// only for a named identity — and Apple documents nothing about what
|
|
58
|
+
// `codesign --sign - --timestamp` does, which is a second reason not to send it.
|
|
59
|
+
// * IT NOW STAPLES what `stapler` accepts. `stapler(1)` is explicit —
|
|
60
|
+
// *"stapler works only with UDIF disk images, signed \"flat\" installer
|
|
61
|
+
// packages, and certain code-signed executable bundles such as \".app\""* — and
|
|
62
|
+
// Apple's notarisation guide adds the exception that decides our zip:
|
|
63
|
+
// *"While you can notarize a ZIP archive, you can't staple to it directly.
|
|
64
|
+
// Instead, run stapler against each item that you added to the archive."*
|
|
65
|
+
// `canCarryTicket` in `utils/ship/formats.ts` is that fact per format, so the
|
|
66
|
+
// `.dmg` is stapled and the `.zip` is told what a user has to do instead.
|
|
67
|
+
//
|
|
68
|
+
// WHAT IS STILL NOT DONE, deliberately: `signtool` is never given a timestamp URL
|
|
69
|
+
// (see the win32 row), and the entitlement § A16 is about —
|
|
70
|
+
// `com.apple.security.cs.disable-library-validation` — is NOT granted, because
|
|
71
|
+
// § A4's re-sign of every image in the closure is the design of record and
|
|
72
|
+
// granting the entitlement would quietly make it look unnecessary.
|
|
73
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
74
|
+
import { dirname, join, sep } from 'node:path';
|
|
75
|
+
import { isOnPath } from '../check-system-deps.js';
|
|
76
|
+
import { describeExit, spawnToCompletion } from '../spawn.js';
|
|
77
|
+
import { listFilesRecursive } from './discover.js';
|
|
78
|
+
import { classifyBinary } from './payload.js';
|
|
79
|
+
import { renderEntitlements } from './plist.js';
|
|
80
|
+
import { writePayload } from './stage-writer.js';
|
|
81
|
+
/**
|
|
82
|
+
* The signers, keyed by the LAYOUT they sign — never by the host they run on.
|
|
83
|
+
*
|
|
84
|
+
* Linux is deliberately absent rather than present-and-empty. A `.deb` and an
|
|
85
|
+
* `.rpm` carry no per-file signature: the artifact is signed as a whole, by the
|
|
86
|
+
* repository that serves it (`debsigs`, `rpmsign`), with a key that belongs to
|
|
87
|
+
* that repository and not to this build. A row here would make `--sign` on Linux
|
|
88
|
+
* look like it did something.
|
|
89
|
+
*/
|
|
90
|
+
export const SIGNERS = {
|
|
91
|
+
darwin: {
|
|
92
|
+
layoutOs: 'darwin',
|
|
93
|
+
tool: 'codesign',
|
|
94
|
+
// The tool is Apple's and ships with the Command Line Tools. There is no
|
|
95
|
+
// Linux `codesign`, so unlike every other step of the darwin leg this one
|
|
96
|
+
// really is host-bound — which is why it lives on the FINISH phase and
|
|
97
|
+
// `gjsify ship darwin --stage` needs none of it.
|
|
98
|
+
signOn: ['darwin'],
|
|
99
|
+
installHint: 'install the Xcode Command Line Tools (`xcode-select --install`) on a macOS host',
|
|
100
|
+
signs: 'macho',
|
|
101
|
+
// `--force` because every image in the shipped closure is ALREADY signed
|
|
102
|
+
// ad-hoc (§ A4: 106 of 106) — `install_name_tool` invalidates the original
|
|
103
|
+
// during relocation and the relocator re-signs. Without it `codesign`
|
|
104
|
+
// refuses a file that already carries a signature.
|
|
105
|
+
//
|
|
106
|
+
// `--options runtime` on BOTH identities and `--timestamp` on only one:
|
|
107
|
+
// the hardened runtime is a code-directory bit, which an ad-hoc signature
|
|
108
|
+
// has, while a timestamp is a CMS countersignature over a CERTIFICATE, and
|
|
109
|
+
// an ad-hoc signature has neither ("Ad-hoc signing does not use an identity
|
|
110
|
+
// at all", `codesign(1)`). Apple documents nothing about what
|
|
111
|
+
// `--sign - --timestamp` does, so it is not sent rather than guessed at —
|
|
112
|
+
// and the one thing `codesign(1)` does promise about the flag is that "if
|
|
113
|
+
// the timestamp authority service cannot be contacted … the signing
|
|
114
|
+
// operation will fail", which is not a failure worth inventing for a
|
|
115
|
+
// signature that cannot carry the result.
|
|
116
|
+
args: ({ identity, file, entitlements, adhoc }) => [
|
|
117
|
+
'--force',
|
|
118
|
+
'--sign',
|
|
119
|
+
identity,
|
|
120
|
+
'--options',
|
|
121
|
+
'runtime',
|
|
122
|
+
...(entitlements === undefined ? [] : ['--entitlements', entitlements]),
|
|
123
|
+
...(adhoc ? [] : ['--timestamp']),
|
|
124
|
+
file,
|
|
125
|
+
],
|
|
126
|
+
// FOUR OF THE REFERENCE'S SIX, and both omissions are decisions:
|
|
127
|
+
//
|
|
128
|
+
// * `com.apple.security.cs.get-task-allow` is granted by
|
|
129
|
+
// `refs/node/tools/osx-entitlements.plist:15` and is a DEBUGGING
|
|
130
|
+
// entitlement — it lets another process attach to this one. Apple's
|
|
131
|
+
// notarisation rules refuse a Developer-ID artifact carrying it, so
|
|
132
|
+
// shipping it would trade a working debug build for an artifact that
|
|
133
|
+
// cannot be distributed. The reference signs its own build; we sign what
|
|
134
|
+
// a stranger downloads.
|
|
135
|
+
// * `com.apple.security.cs.disable-library-validation` is § A16's open
|
|
136
|
+
// question and stays open. § A4's design of record is that every Mach-O
|
|
137
|
+
// in the closure is re-signed with the SAME identity as the launcher, so
|
|
138
|
+
// library validation has nothing to object to — and granting the
|
|
139
|
+
// entitlement anyway would make that re-sign look optional the first
|
|
140
|
+
// time somebody reads this list.
|
|
141
|
+
//
|
|
142
|
+
// The four that remain are what a shipped V8 needs under a hardened
|
|
143
|
+
// runtime, plus the one our own loader story needs:
|
|
144
|
+
// `@gjsify/node-gi`'s `maybeReexecForGtkRuntime` sets `DYLD_*` for its
|
|
145
|
+
// child on darwin, and a hardened process may not do that without
|
|
146
|
+
// `allow-dyld-environment-variables`.
|
|
147
|
+
entitlements: [
|
|
148
|
+
'com.apple.security.cs.allow-jit',
|
|
149
|
+
'com.apple.security.cs.allow-unsigned-executable-memory',
|
|
150
|
+
'com.apple.security.cs.disable-executable-page-protection',
|
|
151
|
+
'com.apple.security.cs.allow-dyld-environment-variables',
|
|
152
|
+
],
|
|
153
|
+
// The `.app` is a bundle, and a bundle's signature is the seal over its
|
|
154
|
+
// whole tree — the images alone leave `codesign --verify` with nothing to
|
|
155
|
+
// read at the bundle level.
|
|
156
|
+
sealsBundle: true,
|
|
157
|
+
sealAddPrefix: (bundleRoot) => `${bundleRoot}/Contents/_CodeSignature/`,
|
|
158
|
+
configKey: 'gjsify.ship.sign.darwin.identity',
|
|
159
|
+
gap: 'the images are signed and the bundle is sealed; UNVERIFIED end to end — no macOS host in this ' +
|
|
160
|
+
'repository has run a `--sign` over a real `.app`, and ADR 0024 § A16 still leaves library ' +
|
|
161
|
+
'validation unmeasured',
|
|
162
|
+
},
|
|
163
|
+
win32: {
|
|
164
|
+
layoutOs: 'win32',
|
|
165
|
+
tool: 'signtool',
|
|
166
|
+
signOn: ['win32'],
|
|
167
|
+
installHint: 'install the Windows SDK (signtool.exe ships in its `bin/<version>/x64` directory)',
|
|
168
|
+
signs: 'pe',
|
|
169
|
+
// `/n` takes a SUBJECT NAME and looks the certificate up in a store, which
|
|
170
|
+
// is the same shape as `codesign --sign <identity>` and the reason § A12's
|
|
171
|
+
// interface is one flag for both OSes. `/fd sha256` names the file digest;
|
|
172
|
+
// Authenticode's default is SHA-1, which no current Windows accepts.
|
|
173
|
+
//
|
|
174
|
+
// ⚠️ UNVERIFIED. Nothing in this repository has ever run `signtool`: it
|
|
175
|
+
// needs a certificate, there is no ad-hoc mode on Windows, and § A5
|
|
176
|
+
// records that SmartScreen only WARNS until per-file-hash reputation
|
|
177
|
+
// accrues — signed or not. The argv is unit-tested, the skip and the
|
|
178
|
+
// refusals are e2e-tested, and the invocation itself has no proof.
|
|
179
|
+
args: ({ identity, file }) => ['sign', '/n', identity, '/fd', 'sha256', file],
|
|
180
|
+
// Windows has no equivalent concept. An Authenticode signature carries no
|
|
181
|
+
// capability claims — what a hardened runtime and an entitlement do on
|
|
182
|
+
// macOS is done on Windows by the manifest and by AppContainer, neither of
|
|
183
|
+
// which this signer touches.
|
|
184
|
+
entitlements: [],
|
|
185
|
+
// A Windows program directory is a DIRECTORY and nothing more: there is no
|
|
186
|
+
// manifest to seal and no per-directory signature format. `signtool` signs
|
|
187
|
+
// images, one at a time, which is what the loop above already does.
|
|
188
|
+
sealsBundle: false,
|
|
189
|
+
sealAddPrefix: () => '',
|
|
190
|
+
configKey: 'gjsify.ship.sign.win32.identity',
|
|
191
|
+
gap: 'no timestamp countersignature is requested, so the signature expires with the certificate ' +
|
|
192
|
+
'(UNVERIFIED: no run in this repository has ever invoked signtool)',
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
/**
|
|
196
|
+
* Resolve the identity for this run, or say why there is none.
|
|
197
|
+
*
|
|
198
|
+
* `flag` beats `config` and neither is a yargs `default:` — the uniform
|
|
199
|
+
* precedence `resolveShipSettings` documents. `config` is `undefined` on the
|
|
200
|
+
* `--from-stage` path and that is not an omission: § A14 amends § A1 to *a
|
|
201
|
+
* format declares where it can be packed; the RUN declares what it can sign
|
|
202
|
+
* with*, and the finishing host has no project to read a default out of.
|
|
203
|
+
*/
|
|
204
|
+
export function resolveSignPlan(input) {
|
|
205
|
+
const signer = SIGNERS[input.layoutOs];
|
|
206
|
+
if (signer === undefined) {
|
|
207
|
+
if (input.flag !== undefined) {
|
|
208
|
+
throw new Error(`gjsify ship: --sign has nothing to sign in the ${input.layoutOs} layout. A .deb and an .rpm ` +
|
|
209
|
+
'carry no per-file signature — the artifact is signed as a whole by the repository that ' +
|
|
210
|
+
"serves it (`debsigs`, `rpmsign`), with that repository's key and not with a build-time " +
|
|
211
|
+
'identity. Drop --sign, or assemble the darwin or windows layout.');
|
|
212
|
+
}
|
|
213
|
+
return { kind: 'unsupported' };
|
|
214
|
+
}
|
|
215
|
+
const configured = input.config?.[input.layoutOs]?.identity;
|
|
216
|
+
const [identity, source] = input.flag !== undefined ? [input.flag, 'flag'] : [configured, 'config'];
|
|
217
|
+
// The reference's `[ -z "$SIGN" ]` — an EMPTY identity is a skip and not an
|
|
218
|
+
// error, and the skip is printed rather than silent so a pipeline that
|
|
219
|
+
// captures the artifact list still shows it (§ A13). Ours names the step it
|
|
220
|
+
// skipped, which the reference's own copy-pasted message does not.
|
|
221
|
+
if (identity === undefined || identity === '') {
|
|
222
|
+
const said = identity === ''
|
|
223
|
+
? `an empty identity was ${source === 'flag' ? 'passed to --sign' : `set in \`${signer.configKey}\``}`
|
|
224
|
+
: 'no identity was given';
|
|
225
|
+
return {
|
|
226
|
+
kind: 'skip',
|
|
227
|
+
message: `${said} — skipping ${signer.tool}. An unsigned artifact is the default path and a ` +
|
|
228
|
+
`legitimate deliverable (ADR 0024 § A13). Pass \`--sign <identity>\` or set ` +
|
|
229
|
+
`\`${signer.configKey}\` to sign; \`--sign -\` signs ad-hoc and needs no developer identity.`,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
return { kind: 'sign', identity, signer, source };
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Refuse a signing run this host cannot make, and say which half is missing.
|
|
236
|
+
*
|
|
237
|
+
* Two checks and not one, for the same reason `assertCanPack` splits them: the
|
|
238
|
+
* wrong OS needs another machine, an absent tool needs a package. Both are
|
|
239
|
+
* knowable before anything is packed.
|
|
240
|
+
*/
|
|
241
|
+
export function assertHostCanSign(signer) {
|
|
242
|
+
const host = process.platform;
|
|
243
|
+
if (!signer.signOn.includes(host)) {
|
|
244
|
+
throw new Error(`gjsify ship: signing the ${signer.layoutOs} layout needs ${signer.tool}, which runs on ` +
|
|
245
|
+
`${signer.signOn.join(', ')} and this host is ${host}. Assembly is not host-bound — ` +
|
|
246
|
+
'`gjsify ship <os> --stage` here, then `gjsify ship --from-stage <dir> --sign <identity>` ' +
|
|
247
|
+
'on a host that has both the tool and the identity (ADR 0024 § A1, § A14).');
|
|
248
|
+
}
|
|
249
|
+
if (!isOnPath(signer.tool)) {
|
|
250
|
+
throw new Error(`gjsify ship: --sign was given and \`${signer.tool}\` is not on PATH, so nothing can make the ` +
|
|
251
|
+
`signature — ${signer.installHint}. Dropping --sign produces an unsigned artifact, which is ` +
|
|
252
|
+
'a legitimate output (ADR 0024 § A13); silently producing one while --sign was asked for is not.');
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* What a signing run did to the payload's FILE SET, split three ways.
|
|
257
|
+
*
|
|
258
|
+
* PURE, and extracted from {@link signPayload} for one reason: it is the whole of
|
|
259
|
+
* the seal decision and the only half of it that can be checked without a macOS
|
|
260
|
+
* host. Everything else about a bundle seal needs `codesign`, which this
|
|
261
|
+
* repository has on exactly one CI leg; this function needs nothing.
|
|
262
|
+
*
|
|
263
|
+
* `sealPrefix` absent means "this signer seals nothing", and then EVERY addition
|
|
264
|
+
* is unexpected — which is what the file did before ADR 0040 and is still the
|
|
265
|
+
* right answer for `signtool`.
|
|
266
|
+
*/
|
|
267
|
+
export function partitionSignedFileSet(input) {
|
|
268
|
+
const planned = new Set(input.planned);
|
|
269
|
+
const arrived = new Set(input.arrived);
|
|
270
|
+
const added = [...arrived].filter((path) => !planned.has(path)).sort();
|
|
271
|
+
const prefix = input.sealPrefix;
|
|
272
|
+
return {
|
|
273
|
+
sealed: prefix === undefined ? [] : added.filter((path) => path.startsWith(prefix)),
|
|
274
|
+
unexpected: prefix === undefined ? added : added.filter((path) => !path.startsWith(prefix)),
|
|
275
|
+
removed: [...planned].filter((path) => !arrived.has(path)).sort(),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
/** `codesign --sign -`: the one identity value that names no identity at all. */
|
|
279
|
+
export const ADHOC_IDENTITY = '-';
|
|
280
|
+
/**
|
|
281
|
+
* Re-sign every signable image in the payload, and hand back the new bytes.
|
|
282
|
+
*
|
|
283
|
+
* The signature is made on FILES because that is the only interface `codesign`
|
|
284
|
+
* and `signtool` have, so the payload is materialised into `workDir`, signed
|
|
285
|
+
* there, and read back. `workDir` is a scratch directory rather than the stage:
|
|
286
|
+
* writing into an arriving stage would make the next `--from-stage` run of the
|
|
287
|
+
* same tree fail `readStage`'s size comparison (measured — see the header), and
|
|
288
|
+
* `writeStage`'s wipe would delete the payload it was about to pack.
|
|
289
|
+
*
|
|
290
|
+
* TWO STEPS ON DARWIN, ONE EVERYWHERE ELSE: every Mach-O in the closure, then the
|
|
291
|
+
* bundle itself. The second is what `codesign --verify <App>.app` reads, and it
|
|
292
|
+
* writes five regular files into `Contents/_CodeSignature/` that the returned
|
|
293
|
+
* payload carries — so the seal reaches the `.zip` and the `.dmg` alike, which is
|
|
294
|
+
* the correction ADR 0040 makes to this file's previous claim that it could not.
|
|
295
|
+
*
|
|
296
|
+
* THE FILE SET MAY ONLY GROW WHERE THE SEAL WRITES, and that is asserted rather
|
|
297
|
+
* than assumed. Both tools rewrite an image in place, so a payload that gained a
|
|
298
|
+
* path outside `sealAddPrefix` — or lost one anywhere — means something else ran,
|
|
299
|
+
* and its files would be packed with no mode from the plan.
|
|
300
|
+
*/
|
|
301
|
+
export async function signPayload(input) {
|
|
302
|
+
const { payload, signer, workDir } = input;
|
|
303
|
+
writePayload(workDir, payload, '');
|
|
304
|
+
const adhoc = input.identity === ADHOC_IDENTITY;
|
|
305
|
+
let entitlements;
|
|
306
|
+
if (signer.entitlements.length > 0) {
|
|
307
|
+
mkdirSync(dirname(input.entitlementsPath), { recursive: true });
|
|
308
|
+
writeFileSync(input.entitlementsPath, renderEntitlements(signer.entitlements));
|
|
309
|
+
entitlements = input.entitlementsPath;
|
|
310
|
+
}
|
|
311
|
+
const run = async (file, what) => {
|
|
312
|
+
const args = signer.args({ identity: input.identity, file, adhoc, ...(entitlements ? { entitlements } : {}) });
|
|
313
|
+
if (input.verbose)
|
|
314
|
+
input.log(`${signer.tool} ${args.join(' ')}`);
|
|
315
|
+
const result = await spawnToCompletion(signer.tool, args, {
|
|
316
|
+
completion: 'return',
|
|
317
|
+
cwd: workDir,
|
|
318
|
+
stdio: 'inherit',
|
|
319
|
+
notFound: () => new Error(`gjsify ship: \`${signer.tool}\` is not on PATH — ${signer.installHint}.`),
|
|
320
|
+
});
|
|
321
|
+
if (result.code !== 0) {
|
|
322
|
+
throw new Error(`gjsify ship: ${signer.tool} failed on ${what} with ${describeExit(result)}. ` +
|
|
323
|
+
'Nothing is packed from a payload whose signature failed: an artifact that is half ' +
|
|
324
|
+
'signed installs and is refused at first launch, which is worse than not shipping.');
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
const signable = payload.filter((entry) => classifyBinary(entry.data) === signer.signs);
|
|
328
|
+
if (signable.length === 0 && !signer.sealsBundle) {
|
|
329
|
+
// NOT silent, and not an error either. A `--app gjs` payload really is
|
|
330
|
+
// JavaScript and a launcher, so there is nothing for the loader to
|
|
331
|
+
// validate — but a run that asked for a signature and made none has to
|
|
332
|
+
// say so, or "signed" and "signed nothing" look identical in the log.
|
|
333
|
+
//
|
|
334
|
+
// `&& !sealsBundle` is what keeps the message TRUE rather than what
|
|
335
|
+
// narrows it: a darwin run with no image still seals the bundle, which is
|
|
336
|
+
// a signature, so printing "the artifact carries no signature" there would
|
|
337
|
+
// be the log contradicting the artifact.
|
|
338
|
+
input.log(`nothing in this payload is a ${signer.signs === 'macho' ? 'Mach-O' : 'PE'} image, so ` +
|
|
339
|
+
`${signer.tool} signed 0 file(s). The artifact carries no signature.`);
|
|
340
|
+
return [...payload];
|
|
341
|
+
}
|
|
342
|
+
for (const entry of signable) {
|
|
343
|
+
await run(join(workDir, entry.path.split('/').join(sep)), entry.path);
|
|
344
|
+
}
|
|
345
|
+
// THE IMAGES FIRST, THE SEAL LAST, and the order is not stylistic: a bundle
|
|
346
|
+
// seal hashes the files it seals, so sealing before the closure is signed
|
|
347
|
+
// would record the digests of the images this run is about to replace and
|
|
348
|
+
// produce a bundle that verifies against a tree it no longer contains.
|
|
349
|
+
let sealPrefix;
|
|
350
|
+
if (signer.sealsBundle) {
|
|
351
|
+
if (input.bundleRoot === '') {
|
|
352
|
+
throw new Error(`gjsify ship: the ${signer.layoutOs} signer seals a bundle and this layout has no root to ` +
|
|
353
|
+
'seal. `Layout.root` is what names the artifact directory; a signer that sealed the whole ' +
|
|
354
|
+
'stage would sign the payload of every other format beside it.');
|
|
355
|
+
}
|
|
356
|
+
sealPrefix = signer.sealAddPrefix(input.bundleRoot);
|
|
357
|
+
await run(join(workDir, input.bundleRoot.split('/').join(sep)), `${input.bundleRoot} (bundle seal)`);
|
|
358
|
+
}
|
|
359
|
+
// THE FILE SET MAY ONLY GROW WHERE THE SEAL WRITES. Both tools rewrite an
|
|
360
|
+
// image in place, so any other new path means something else ran — and a file
|
|
361
|
+
// that appeared unnoticed would be packed with no mode from the plan, while
|
|
362
|
+
// one that vanished would be packed as absent.
|
|
363
|
+
const changes = partitionSignedFileSet({
|
|
364
|
+
arrived: listFilesRecursive(workDir),
|
|
365
|
+
planned: payload.map((entry) => entry.path),
|
|
366
|
+
...(sealPrefix === undefined ? {} : { sealPrefix }),
|
|
367
|
+
});
|
|
368
|
+
if (changes.unexpected.length > 0 || changes.removed.length > 0) {
|
|
369
|
+
throw new Error(`gjsify ship: ${signer.tool} changed the payload's file set — ` +
|
|
370
|
+
`${changes.unexpected.length} added (${changes.unexpected.slice(0, 3).join(', ') || '—'}), ` +
|
|
371
|
+
`${changes.removed.length} removed (${changes.removed.slice(0, 3).join(', ') || '—'}). ` +
|
|
372
|
+
'Signing is a mutation of the files that are already in the payload (ADR 0024 § A4), plus ' +
|
|
373
|
+
`exactly the seal's own directory${sealPrefix === undefined ? '' : ` (\`${sealPrefix}\`)`}.`);
|
|
374
|
+
}
|
|
375
|
+
const sealed = changes.sealed;
|
|
376
|
+
input.log(`${signer.tool} signed ${signable.length} of ${payload.length} payload file(s) as ` +
|
|
377
|
+
`${adhoc ? 'ad-hoc (`-`)' : `\`${input.identity}\``}` +
|
|
378
|
+
`${sealPrefix === undefined ? '' : ` and sealed ${input.bundleRoot} (${sealed.length} seal file(s))`}` +
|
|
379
|
+
` — ${signer.gap}.`);
|
|
380
|
+
// Modes come from the PLAN, exactly as `readStage` insists: the signer
|
|
381
|
+
// rewrites bytes and this re-read must not turn a 0755 launcher into
|
|
382
|
+
// whatever the scratch directory's umask produced. The seal's own files have
|
|
383
|
+
// no plan entry — they did not exist when the plan was made — so they take
|
|
384
|
+
// 0644, which is what `codesign` writes them as
|
|
385
|
+
// (`bundlediskrep.cpp`: `O_WRONLY | O_CREAT | O_TRUNC, 0644`).
|
|
386
|
+
const read = (path) => new Uint8Array(readFileSync(join(workDir, path.split('/').join(sep))));
|
|
387
|
+
return [
|
|
388
|
+
...payload.map((entry) => ({ path: entry.path, mode: entry.mode, data: read(entry.path) })),
|
|
389
|
+
...sealed.map((path) => ({ path, mode: 0o644, data: read(path) })),
|
|
390
|
+
].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
391
|
+
}
|
|
392
|
+
export const NOTARIES = {
|
|
393
|
+
darwin: {
|
|
394
|
+
layoutOs: 'darwin',
|
|
395
|
+
tool: 'xcrun',
|
|
396
|
+
notarizeOn: ['darwin'],
|
|
397
|
+
installHint: 'install the Xcode Command Line Tools (`xcode-select --install`) on a macOS host',
|
|
398
|
+
args: (credential, artifact) => ['notarytool', 'submit', '--keychain-profile', credential, '--wait', artifact],
|
|
399
|
+
},
|
|
400
|
+
};
|
|
401
|
+
/**
|
|
402
|
+
* Resolve the notarisation credential, or say why there is none.
|
|
403
|
+
*
|
|
404
|
+
* Deliberately NOT defaultable from the project. An identity is a name a machine
|
|
405
|
+
* may hold for years and is worth writing into a repository; a notarisation
|
|
406
|
+
* credential is a per-run account credential, and a config key for it is an
|
|
407
|
+
* invitation to commit one.
|
|
408
|
+
*/
|
|
409
|
+
export function resolveNotaryPlan(input) {
|
|
410
|
+
const notary = NOTARIES[input.layoutOs];
|
|
411
|
+
if (notary === undefined) {
|
|
412
|
+
if (input.flag !== undefined) {
|
|
413
|
+
throw new Error(`gjsify ship: --notarize has no meaning in the ${input.layoutOs} layout. Notarisation is ` +
|
|
414
|
+
"Apple's pre-clearance of a signed artifact and nothing else asks for it — Windows " +
|
|
415
|
+
'SmartScreen accrues reputation per file hash instead (ADR 0024 § A5).');
|
|
416
|
+
}
|
|
417
|
+
return { kind: 'unsupported' };
|
|
418
|
+
}
|
|
419
|
+
if (input.flag === undefined || input.flag === '') {
|
|
420
|
+
return {
|
|
421
|
+
kind: 'skip',
|
|
422
|
+
message: `no notarisation credential was given — skipping ${notary.tool} notarytool. Pass ` +
|
|
423
|
+
'`--notarize <keychain-profile>`, naming a profile a prior `notarytool store-credentials` ' +
|
|
424
|
+
"put in this host's keychain.",
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
if (input.sign.kind !== 'sign') {
|
|
428
|
+
throw new Error('gjsify ship: --notarize was given without an identity to sign with. Notarisation is a check ' +
|
|
429
|
+
'ON a signature, so submitting an unsigned artifact has nothing to clear. Pass ' +
|
|
430
|
+
'`--sign <identity>` as well, or drop --notarize.');
|
|
431
|
+
}
|
|
432
|
+
return { kind: 'notarize', credential: input.flag, notary };
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Submit one written artifact for notarisation.
|
|
436
|
+
*
|
|
437
|
+
* ⚠️ UNVERIFIED END TO END. Everything up to the `spawnToCompletion` is covered
|
|
438
|
+
* — the argv by `signing.spec.ts`, the guard and both refusals by
|
|
439
|
+
* `tests/e2e/ship-signing` — and the invocation itself has never run: it needs
|
|
440
|
+
* an Apple Developer account, and § A17's whole point is that M6's proof does
|
|
441
|
+
* not. Whoever first runs this on a real credential should record what it did
|
|
442
|
+
* beside § A15, which is where the question was left open.
|
|
443
|
+
*/
|
|
444
|
+
export async function notarizeArtifact(input) {
|
|
445
|
+
const { notary, credential } = input.plan;
|
|
446
|
+
const host = process.platform;
|
|
447
|
+
if (!notary.notarizeOn.includes(host)) {
|
|
448
|
+
throw new Error(`gjsify ship: notarising the ${notary.layoutOs} layout needs ${notary.tool}, which runs on ` +
|
|
449
|
+
`${notary.notarizeOn.join(', ')} and this host is ${host}.`);
|
|
450
|
+
}
|
|
451
|
+
const args = notary.args(credential, input.artifact);
|
|
452
|
+
if (input.verbose)
|
|
453
|
+
input.log(`${notary.tool} ${args.join(' ')}`);
|
|
454
|
+
const result = await spawnToCompletion(notary.tool, args, {
|
|
455
|
+
completion: 'return',
|
|
456
|
+
stdio: 'inherit',
|
|
457
|
+
notFound: () => new Error(`gjsify ship: \`${notary.tool}\` is not on PATH — ${notary.installHint}.`),
|
|
458
|
+
});
|
|
459
|
+
if (result.code !== 0) {
|
|
460
|
+
throw new Error(`gjsify ship: ${notary.tool} notarytool failed with ${describeExit(result)}.`);
|
|
461
|
+
}
|
|
462
|
+
input.log(`${notary.tool} notarytool accepted ${input.artifact}.`);
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Attach the notarisation ticket to an artifact that can hold one.
|
|
466
|
+
*
|
|
467
|
+
* WHY IT IS A SEPARATE CALL AND NOT THE TAIL OF {@link notarizeArtifact}: not
|
|
468
|
+
* every artifact the notary service ACCEPTS can carry a ticket, and the pair that
|
|
469
|
+
* comes apart is exactly the one this command produces. Apple:
|
|
470
|
+
* *"While you can notarize a ZIP archive, you can't staple to it directly.
|
|
471
|
+
* Instead, run stapler against each item that you added to the archive. Then
|
|
472
|
+
* create a new ZIP file containing the stapled items for distribution."*
|
|
473
|
+
* `stapler(1)` gives the accepting side of the same list: *"stapler works only
|
|
474
|
+
* with UDIF disk images, signed \"flat\" installer packages, and certain
|
|
475
|
+
* code-signed executable bundles such as \".app\"."* So the `.dmg` is stapled
|
|
476
|
+
* here and the `.zip` is told, in the artifact log, what a person has to do
|
|
477
|
+
* instead — which is a fact about the CONTAINER and therefore lives on the format
|
|
478
|
+
* (`canCarryTicket`), not in a branch here.
|
|
479
|
+
*
|
|
480
|
+
* ⚠️ UNVERIFIED END TO END, for the same reason `notarizeArtifact` is: stapling
|
|
481
|
+
* needs a real notarisation, which needs an Apple account (ADR 0024 § A15, § A17).
|
|
482
|
+
* The argv is unit-tested and the invocation has never run in this repository.
|
|
483
|
+
*
|
|
484
|
+
* `stapler(1)` also states the constraint that decides the ORDER: *"Stapling does
|
|
485
|
+
* not invalidate the code signature and must be run after an executable or archive
|
|
486
|
+
* has been code-signed and notarized … Code-signing a supported file format
|
|
487
|
+
* invalidates any stapled tickets"* — so nothing may re-sign after this, and
|
|
488
|
+
* nothing in `packOne` does.
|
|
489
|
+
*/
|
|
490
|
+
export async function stapleArtifact(input) {
|
|
491
|
+
const args = ['stapler', 'staple', input.artifact];
|
|
492
|
+
if (input.verbose)
|
|
493
|
+
input.log(`${input.notary.tool} ${args.join(' ')}`);
|
|
494
|
+
const result = await spawnToCompletion(input.notary.tool, args, {
|
|
495
|
+
completion: 'return',
|
|
496
|
+
stdio: 'inherit',
|
|
497
|
+
notFound: () => new Error(`gjsify ship: \`${input.notary.tool}\` is not on PATH — ${input.notary.installHint}.`),
|
|
498
|
+
});
|
|
499
|
+
if (result.code !== 0) {
|
|
500
|
+
throw new Error(`gjsify ship: ${input.notary.tool} stapler failed with ${describeExit(result)}. The artifact is ` +
|
|
501
|
+
'notarised and NOT stapled, which means every launch asks Gatekeeper to check online — and ' +
|
|
502
|
+
'fails closed on a machine with no network. Re-run the finish phase; `stapler` requires ' +
|
|
503
|
+
'internet access and the ticket may not have propagated yet.');
|
|
504
|
+
}
|
|
505
|
+
input.log(`${input.notary.tool} stapler attached the ticket to ${input.artifact}.`);
|
|
506
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { FormatDescriptor, FormatId, PackSettings, ShipSettings, StagedFile, StagedMode } from './types.js';
|
|
1
|
+
import type { FormatDescriptor, FormatId, HostOs, PackSettings, ShipSettings, StagedFile, StagedMode } from './types.js';
|
|
2
2
|
/** The one file a stage carries that is not payload. Lives at the stage ROOT. */
|
|
3
3
|
export declare const STAGE_MANIFEST_FILE = ".gjsify-ship-stage.json";
|
|
4
4
|
/**
|
|
@@ -16,21 +16,49 @@ export declare const STAGE_MANIFEST_FILE = ".gjsify-ship-stage.json";
|
|
|
16
16
|
* `settings.flatpak` would not skip a field, it would pack a Flatpak against
|
|
17
17
|
* whatever runtime IT defaults to — a different `org.gnome.Platform` version
|
|
18
18
|
* than the project asked for, at exit 0.
|
|
19
|
-
*/
|
|
20
|
-
export declare const STAGE_SCHEMA_VERSION = 2;
|
|
21
|
-
/**
|
|
22
|
-
* The OS whose layout `planStage` produces.
|
|
23
19
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
20
|
+
* 3 added `settings.minNodeVersion` and 4 added `settings.app`. Same case as 2
|
|
21
|
+
* for the same reason: an older reader ignoring `app` does not skip a field, it
|
|
22
|
+
* has no concept of an interpreter choice at all, so it seeds `gjs >= …` for a
|
|
23
|
+
* stage whose launcher — already rendered, already in the tree it is packing —
|
|
24
|
+
* execs `node`. The package then declares one interpreter and runs another.
|
|
25
|
+
*
|
|
26
|
+
* Stated as a consequence and not as an incident, because it never happened:
|
|
27
|
+
* `app` and the launcher landed together. An earlier version of this comment
|
|
28
|
+
* claimed the incident anyway, which is worth leaving on the record — a rule
|
|
29
|
+
* with an invented reason gets "simplified" back out the first time somebody
|
|
30
|
+
* notices the reason is not real.
|
|
31
|
+
*
|
|
32
|
+
* WHY TWO BUMPS IN ONE CHANGE, when neither was ever released. Because 3 and 4
|
|
33
|
+
* are DIFFERENT SHAPES and 3 briefly existed: the intermediate commit wrote
|
|
34
|
+
* `schema: 3` with no `app`. Folding `app` into 3 would have made this reader
|
|
35
|
+
* meet that stage and fail on `settings.app must be "gjs" or "node", got
|
|
36
|
+
* undefined` — fail-closed, but naming a field instead of the one thing the
|
|
37
|
+
* reader can do about it. At 4 the schema check above catches it first and says
|
|
38
|
+
* "re-run the `--stage` phase with this gjsify", which is the whole reason this
|
|
39
|
+
* constant's header is an argument for bumping whenever a reader could mis-read.
|
|
40
|
+
*
|
|
41
|
+
* 5 added `settings.name`, and it was decided against the criterion above rather
|
|
42
|
+
* than reflexively — #1354 M2a changed two things and only one of them warrants a
|
|
43
|
+
* bump:
|
|
44
|
+
*
|
|
45
|
+
* - NOT the two new format ids. `formats[]` and `overlay`'s keys carry new
|
|
46
|
+
* VALUES in fields whose meaning is unchanged, and `readStageManifest` already
|
|
47
|
+
* refuses a `formats[i]` it does not know BY NAME. An older gjsify meeting a
|
|
48
|
+
* macOS stage therefore fails closed with a message about the format, which is
|
|
49
|
+
* the right message. Adding a member to a validated enum is the case this
|
|
50
|
+
* header says not to bump for.
|
|
51
|
+
* - YES `settings.name`. It is REQUIRED, so this reader meeting a schema-4 stage
|
|
52
|
+
* would fail on `settings.name must be a string` — naming a field instead of
|
|
53
|
+
* the one thing the reader can do about it. That is the same shape the 3-to-4
|
|
54
|
+
* paragraph describes, and at 5 the schema check above fires first and says
|
|
55
|
+
* "re-run the `--stage` phase with this gjsify".
|
|
29
56
|
*/
|
|
30
|
-
export declare const
|
|
57
|
+
export declare const STAGE_SCHEMA_VERSION = 5;
|
|
31
58
|
/** What this stage was assembled FOR, in the repo-wide `${process.platform}-${process.arch}` spelling. */
|
|
32
59
|
export interface StageTarget {
|
|
33
|
-
|
|
60
|
+
/** The layout the stage carries, in the `process.platform` spelling — `win32`, never `windows`. */
|
|
61
|
+
os: HostOs;
|
|
34
62
|
arch: string;
|
|
35
63
|
}
|
|
36
64
|
/** One staged path, its planned mode, and the size it had when the stage was written. */
|