@rightkit/release 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -4
- package/addon-command.mjs +3 -4
- package/android-play-upload.mjs +2 -1
- package/apple-profile-renew.mjs +11 -3
- package/artifact-storage.mjs +35 -2
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/darwin/rightkit-swap.sign.json +1 -1
- package/build-release.mjs +138 -47
- package/cli/right-release.mjs +11 -2
- package/codesign-environment.mjs +2 -2
- package/content-digest.mjs +209 -0
- package/development.mjs +17 -6
- package/direct-bootstrap.mjs +5 -6
- package/disk-command.mjs +1 -0
- package/disk-policy.json +6 -2
- package/disk-stable-root.mjs +13 -9
- package/github-updater-release.mjs +70 -7
- package/in-place-install.mjs +17 -14
- package/install-app-windows.mjs +24 -7
- package/install-app.mjs +21 -40
- package/ios-asc-credentials.mjs +163 -0
- package/ios-build-context.mjs +129 -0
- package/ios-build-upload.mjs +282 -0
- package/ios-credentials-command.mjs +70 -0
- package/ios-dev.mjs +248 -0
- package/ios-host.mjs +76 -0
- package/ios-lock.mjs +75 -0
- package/ios-package-recipe.mjs +707 -0
- package/ios-testflight.mjs +266 -0
- package/ios-upload.mjs +313 -0
- package/local-publish.mjs +92 -37
- package/managed-cargo.mjs +8 -0
- package/mirror-root-artifact.mjs +5 -1
- package/native-cargo-layout.mjs +47 -4
- package/ownership-command.mjs +2 -1
- package/ownership-dependency-allowlist.json +1 -1
- package/ownership-evidence.mjs +30 -18
- package/ownership-key-command.mjs +69 -2
- package/ownership-mandatory-set.mjs +66 -16
- package/ownership-repositories.json +1 -1
- package/ownership-scope.mjs +94 -0
- package/ownership.mjs +49 -7
- package/package-recipe-targets.mjs +41 -1
- package/package.json +1 -1
- package/preflight.mjs +14 -7
- package/project-type.mjs +3 -1
- package/protected-credentials.mjs +28 -1
- package/publication-artifacts.mjs +30 -5
- package/publication-gate.mjs +15 -4
- package/publish-cargo.mjs +109 -22
- package/publish-update.mjs +15 -2
- package/qa-build-features.mjs +16 -13
- package/qa-contract.mjs +30 -2
- package/qa-control-scan.mjs +4 -0
- package/release-invocation.mjs +118 -8
- package/release-switch-registry.json +126 -27
- package/release-switch-scan.mjs +45 -17
- package/release.mjs +69 -17
- package/rightkit-ownership.json +1 -1
- package/rightkit-versions.json +379 -189
- package/runtime-budgets.mjs +120 -16
- package/sign-updater.mjs +56 -59
- package/source-gate.mjs +8 -1
- package/stage-sidecar.mjs +160 -32
- package/target-distribution.mjs +5 -0
- package/upload-release.mjs +7 -2
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// Signature-independent content digest: exact JS port of crates/rightkit-ffmpeg/src/content_digest.rs.
|
|
2
|
+
// PE (Authenticode rule), thin 64-bit LE Mach-O (canonical unsigned image) & universal Mach-O (per-slice).
|
|
3
|
+
// A malformed recognized format FAILS (ContentDigestError); never falls back to a plain hash.
|
|
4
|
+
// Anything else is plain SHA-256. Archive/download SHA-256 remains a separate, independent value.
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
|
|
8
|
+
export class ContentDigestError extends Error {
|
|
9
|
+
constructor(message) { super(message); this.name = "ContentDigestError"; this.code = "CONTENT_DIGEST_MALFORMED"; }
|
|
10
|
+
}
|
|
11
|
+
const malformed = (why) => { throw new ContentDigestError(why); };
|
|
12
|
+
|
|
13
|
+
const MH_MAGIC_64 = 0xfeedfacf, LC_SEGMENT_64 = 0x19, LC_CODE_SIGNATURE = 0x1d;
|
|
14
|
+
const FAT_MAGIC = 0xcafebabe, FAT_MAGIC_64 = 0xcafebabf;
|
|
15
|
+
const CPU_X86_64 = 0x01000007, CPU_ARM64 = 0x0100000c;
|
|
16
|
+
const MAX_FAT_ARCHS = 16;
|
|
17
|
+
|
|
18
|
+
const sha256Parts = (parts) => { const h = createHash("sha256"); for (const p of parts) h.update(p); return h.digest("hex"); };
|
|
19
|
+
export const sha256Bytes = (bytes) => sha256Parts([Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes)]);
|
|
20
|
+
|
|
21
|
+
const le32 = (b, at) => (at + 4 <= b.length ? b.readUInt32LE(at) : null);
|
|
22
|
+
const le16 = (b, at) => (at + 2 <= b.length ? b.readUInt16LE(at) : null);
|
|
23
|
+
const be32 = (b, at) => (at + 4 <= b.length ? b.readUInt32BE(at) : null);
|
|
24
|
+
const le64n = (b, at) => (at + 8 <= b.length ? Number(b.readBigUInt64LE(at)) : null);
|
|
25
|
+
const be64n = (b, at) => (at + 8 <= b.length ? Number(b.readBigUInt64BE(at)) : null);
|
|
26
|
+
|
|
27
|
+
function archName(cputype) {
|
|
28
|
+
if (cputype === CPU_ARM64) return "aarch64";
|
|
29
|
+
if (cputype === CPU_X86_64) return "x86_64";
|
|
30
|
+
malformed("unsupported CPU type");
|
|
31
|
+
}
|
|
32
|
+
const roundUp = (v, to) => Math.floor((v + to - 1) / to) * to;
|
|
33
|
+
|
|
34
|
+
/** @returns {{format:"macho"|"macho-universal"|"pe"|"plain", slices:{arch:string|null, sha256:string}[]}} */
|
|
35
|
+
export function contentIdentity(input) {
|
|
36
|
+
const bytes = Buffer.isBuffer(input) ? input : Buffer.from(input);
|
|
37
|
+
const magicBe = be32(bytes, 0), magicLe = le32(bytes, 0);
|
|
38
|
+
if (magicBe === FAT_MAGIC || magicBe === FAT_MAGIC_64) return fatIdentity(bytes);
|
|
39
|
+
if (magicLe === MH_MAGIC_64) {
|
|
40
|
+
const { arch, sha256 } = machoSliceDigest(bytes);
|
|
41
|
+
return { format: "macho", slices: [{ arch, sha256 }] };
|
|
42
|
+
}
|
|
43
|
+
if ([0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe, 0xbebafeca, 0xbfbafeca].includes(magicBe)) {
|
|
44
|
+
malformed("unsupported Mach-O flavour (only 64-bit little-endian is catalogued)");
|
|
45
|
+
}
|
|
46
|
+
if (bytes.subarray(0, 2).toString("latin1") === "MZ") {
|
|
47
|
+
const { arch, sha256 } = peDigest(bytes);
|
|
48
|
+
return { format: "pe", slices: [{ arch, sha256 }] };
|
|
49
|
+
}
|
|
50
|
+
return { format: "plain", slices: [{ arch: null, sha256: sha256Bytes(bytes) }] };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function contentIdentityFile(file) { return contentIdentity(readFileSync(file)); }
|
|
54
|
+
export function digestFor(identity, arch) {
|
|
55
|
+
return identity.slices.find((s) => s.arch?.toLowerCase() === String(arch).toLowerCase())?.sha256 ?? null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function machoSliceDigest(slice) {
|
|
59
|
+
const bad = (why) => malformed(`Mach-O: ${why}`);
|
|
60
|
+
if (le32(slice, 0) !== MH_MAGIC_64) bad("not a 64-bit little-endian Mach-O");
|
|
61
|
+
const cputype = le32(slice, 4); if (cputype === null) bad("truncated header");
|
|
62
|
+
const arch = archName(cputype);
|
|
63
|
+
const ncmds = le32(slice, 16), sizeofcmds = le32(slice, 20);
|
|
64
|
+
if (ncmds === null || sizeofcmds === null) bad("truncated header");
|
|
65
|
+
const cmdsEnd = 32 + sizeofcmds;
|
|
66
|
+
if (cmdsEnd > slice.length) bad("load commands run past the end of the file");
|
|
67
|
+
const commands = [];
|
|
68
|
+
let offset = 32;
|
|
69
|
+
for (let i = 0; i < ncmds; i += 1) {
|
|
70
|
+
const cmd = le32(slice, offset), size = le32(slice, offset + 4);
|
|
71
|
+
if (cmd === null || size === null) bad("truncated load command");
|
|
72
|
+
if (size < 8 || size % 8 !== 0 || offset + size > cmdsEnd) bad("load command size is invalid");
|
|
73
|
+
commands.push({ offset, size, cmd });
|
|
74
|
+
offset += size;
|
|
75
|
+
}
|
|
76
|
+
if (offset !== cmdsEnd) bad("load commands do not fill sizeofcmds");
|
|
77
|
+
let signature = null, linkedit = null;
|
|
78
|
+
commands.forEach((c, index) => {
|
|
79
|
+
if (c.cmd === LC_CODE_SIGNATURE) {
|
|
80
|
+
if (c.size !== 16 || signature !== null) bad("invalid or repeated LC_CODE_SIGNATURE");
|
|
81
|
+
signature = index;
|
|
82
|
+
} else if (c.cmd === LC_SEGMENT_64) {
|
|
83
|
+
if (c.size < 72) bad("short LC_SEGMENT_64");
|
|
84
|
+
const name = slice.subarray(c.offset + 8, c.offset + 24);
|
|
85
|
+
const end = name.indexOf(0) === -1 ? name.length : name.indexOf(0);
|
|
86
|
+
if (name.subarray(0, end).toString("latin1") === "__LINKEDIT") {
|
|
87
|
+
if (linkedit !== null) bad("repeated __LINKEDIT");
|
|
88
|
+
linkedit = index;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
if (linkedit === null) bad("no __LINKEDIT segment");
|
|
93
|
+
const leOff = commands[linkedit].offset;
|
|
94
|
+
const vmsize = le64n(slice, leOff + 32), fileoff = le64n(slice, leOff + 40), filesize = le64n(slice, leOff + 48);
|
|
95
|
+
if (vmsize === null || fileoff === null || filesize === null) bad("truncated __LINKEDIT");
|
|
96
|
+
const len = slice.length;
|
|
97
|
+
if (fileoff < cmdsEnd || fileoff + filesize !== len || vmsize < filesize || vmsize % 0x1000 !== 0) bad("__LINKEDIT does not run to the end of the file");
|
|
98
|
+
let contentEnd = len;
|
|
99
|
+
if (signature !== null) {
|
|
100
|
+
const off = commands[signature].offset;
|
|
101
|
+
const dataoff = le32(slice, off + 8), datasize = le32(slice, off + 12);
|
|
102
|
+
if (dataoff === null || datasize === null) bad("truncated signature");
|
|
103
|
+
if (dataoff < fileoff || dataoff + datasize !== len) bad("code signature is not the tail of __LINKEDIT");
|
|
104
|
+
contentEnd = dataoff;
|
|
105
|
+
}
|
|
106
|
+
let end = contentEnd;
|
|
107
|
+
while (end > fileoff && slice[end - 1] === 0) end -= 1;
|
|
108
|
+
const canonicalFilesize = end - fileoff;
|
|
109
|
+
const page = cputype === CPU_ARM64 ? 0x4000 : 0x1000;
|
|
110
|
+
const canonicalVmsize = roundUp(canonicalFilesize, page);
|
|
111
|
+
const head = Buffer.alloc(cmdsEnd);
|
|
112
|
+
slice.copy(head, 0, 0, 32);
|
|
113
|
+
let cursor = 32, newLinkeditAt = 0, removed = 0;
|
|
114
|
+
commands.forEach((c, index) => {
|
|
115
|
+
if (index === signature) { removed = c.size; return; }
|
|
116
|
+
if (index === linkedit) newLinkeditAt = cursor;
|
|
117
|
+
slice.copy(head, cursor, c.offset, c.offset + c.size);
|
|
118
|
+
cursor += c.size;
|
|
119
|
+
});
|
|
120
|
+
head.writeBigUInt64LE(BigInt(canonicalVmsize), newLinkeditAt + 32);
|
|
121
|
+
head.writeBigUInt64LE(BigInt(canonicalFilesize), newLinkeditAt + 48);
|
|
122
|
+
head.writeUInt32LE(ncmds - (signature === null ? 0 : 1), 16);
|
|
123
|
+
head.writeUInt32LE(sizeofcmds - removed, 20);
|
|
124
|
+
return { arch, sha256: sha256Parts([head, slice.subarray(cmdsEnd, end)]) };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function fatIdentity(bytes) {
|
|
128
|
+
const bad = (why) => malformed(`universal Mach-O: ${why}`);
|
|
129
|
+
const wide = be32(bytes, 0) === FAT_MAGIC_64;
|
|
130
|
+
const count = be32(bytes, 4);
|
|
131
|
+
if (count === null) bad("truncated header");
|
|
132
|
+
if (count === 0 || count > MAX_FAT_ARCHS) bad("implausible architecture count");
|
|
133
|
+
const entrySize = wide ? 32 : 20;
|
|
134
|
+
const tableEnd = 8 + entrySize * count;
|
|
135
|
+
if (tableEnd > bytes.length) bad("architecture table runs past the end of the file");
|
|
136
|
+
const slices = [], seen = [], ranges = [];
|
|
137
|
+
for (let index = 0; index < count; index += 1) {
|
|
138
|
+
const at = 8 + index * entrySize;
|
|
139
|
+
const cputype = be32(bytes, at);
|
|
140
|
+
const off = wide ? be64n(bytes, at + 8) : be32(bytes, at + 8);
|
|
141
|
+
const size = wide ? be64n(bytes, at + 16) : be32(bytes, at + 12);
|
|
142
|
+
if (cputype === null || off === null || size === null) bad("truncated entry");
|
|
143
|
+
const end = off + size;
|
|
144
|
+
if (size === 0 || off < tableEnd || end > bytes.length) bad("slice is empty or out of bounds");
|
|
145
|
+
if (seen.includes(cputype)) bad("duplicate architecture");
|
|
146
|
+
if (ranges.some(([s, e]) => off < e && s < end)) bad("slices overlap");
|
|
147
|
+
seen.push(cputype); ranges.push([off, end]);
|
|
148
|
+
const { arch, sha256 } = machoSliceDigest(bytes.subarray(off, end));
|
|
149
|
+
if (arch !== archName(cputype)) bad("slice architecture differs from the fat header");
|
|
150
|
+
slices.push({ arch, sha256 });
|
|
151
|
+
}
|
|
152
|
+
return { format: "macho-universal", slices };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function peDigest(bytes) {
|
|
156
|
+
const bad = (why) => malformed(`PE: ${why}`);
|
|
157
|
+
const len = bytes.length;
|
|
158
|
+
const pe = le32(bytes, 0x3c);
|
|
159
|
+
if (pe === null) bad("no PE header offset");
|
|
160
|
+
if (bytes.subarray(pe, pe + 4).toString("latin1") !== "PE\0\0") bad("missing PE signature");
|
|
161
|
+
const machine = le16(bytes, pe + 4);
|
|
162
|
+
const arch = machine === 0x8664 ? "x86_64" : machine === 0xaa64 ? "aarch64" : bad("unsupported machine type");
|
|
163
|
+
const nsections = le16(bytes, pe + 6), optSize = le16(bytes, pe + 20);
|
|
164
|
+
if (nsections === null || optSize === null) bad("truncated COFF header");
|
|
165
|
+
const opt = pe + 24;
|
|
166
|
+
const sectionsAt = opt + optSize;
|
|
167
|
+
if (sectionsAt > len) bad("optional header runs past the end of the file");
|
|
168
|
+
const magic = le16(bytes, opt);
|
|
169
|
+
const dirs = magic === 0x10b ? 96 : magic === 0x20b ? 112 : bad("unknown optional header magic");
|
|
170
|
+
if (optSize < dirs) bad("optional header too short");
|
|
171
|
+
const checksumAt = opt + 64;
|
|
172
|
+
const sizeOfHeaders = le32(bytes, opt + 60), dirCount = le32(bytes, opt + dirs - 4);
|
|
173
|
+
if (sizeOfHeaders === null || dirCount === null) bad("truncated optional header");
|
|
174
|
+
const certEntry = dirCount > 4 && optSize >= dirs + 40 ? opt + dirs + 32 : null;
|
|
175
|
+
const tableEnd = sectionsAt + nsections * 40;
|
|
176
|
+
if (tableEnd > len) bad("section table runs past the end of the file");
|
|
177
|
+
let floor = checksumAt + 4;
|
|
178
|
+
if (certEntry !== null) floor = Math.max(floor, certEntry + 8);
|
|
179
|
+
if (sizeOfHeaders > len || sizeOfHeaders < Math.max(floor, tableEnd)) bad("SizeOfHeaders is inconsistent");
|
|
180
|
+
let certOffset = 0, certSize = 0;
|
|
181
|
+
if (certEntry !== null) {
|
|
182
|
+
certOffset = le32(bytes, certEntry); certSize = le32(bytes, certEntry + 4);
|
|
183
|
+
if (certOffset === null || certSize === null) bad("truncated directory");
|
|
184
|
+
}
|
|
185
|
+
if (certSize > 0 && (certOffset < sizeOfHeaders || certOffset + certSize !== len)) bad("certificate table is not the tail of the file");
|
|
186
|
+
const sections = [];
|
|
187
|
+
for (let i = 0; i < nsections; i += 1) {
|
|
188
|
+
const at = sectionsAt + i * 40;
|
|
189
|
+
const size = le32(bytes, at + 16), off = le32(bytes, at + 20);
|
|
190
|
+
if (size === null || off === null) bad("truncated section");
|
|
191
|
+
if (size > 0) sections.push([off, size]);
|
|
192
|
+
}
|
|
193
|
+
sections.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
|
194
|
+
const parts = [bytes.subarray(0, checksumAt)];
|
|
195
|
+
if (certEntry !== null) {
|
|
196
|
+
parts.push(bytes.subarray(checksumAt + 4, certEntry), bytes.subarray(certEntry + 8, sizeOfHeaders));
|
|
197
|
+
} else parts.push(bytes.subarray(checksumAt + 4, sizeOfHeaders));
|
|
198
|
+
let hashed = sizeOfHeaders, previousEnd = sizeOfHeaders;
|
|
199
|
+
const bodyEnd = len - certSize;
|
|
200
|
+
for (const [off, size] of sections) {
|
|
201
|
+
const end = off + size;
|
|
202
|
+
if (off < previousEnd || end > bodyEnd) bad("section overlaps the headers or the certificate table");
|
|
203
|
+
parts.push(bytes.subarray(off, end));
|
|
204
|
+
hashed += size; previousEnd = end;
|
|
205
|
+
}
|
|
206
|
+
if (hashed > bodyEnd) bad("hashed bytes exceed the file");
|
|
207
|
+
parts.push(bytes.subarray(hashed, bodyEnd));
|
|
208
|
+
return { arch, sha256: sha256Parts(parts) };
|
|
209
|
+
}
|
package/development.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import { stageDevelopmentBinaries, validateStageEntries } from "./stage-binary.m
|
|
|
14
14
|
import { assertStableDesignatedRequirement, readBundleExecutable, requestedCiAdhocSigning, signMacosDevelopmentBundle } from "./development-signing.mjs";
|
|
15
15
|
import { macosCodesignEnvironment } from "./codesign-environment.mjs";
|
|
16
16
|
import { installTauriDevId, resolveTauriDevIdDescriptor, TAURI_DEVID_KIND } from "./install-tauri-devid.mjs";
|
|
17
|
+
import { assertIosDevelopmentSession, installIosDevelopment, prepareIosDevelopment, validateIosDevelopment } from "./ios-dev.mjs";
|
|
17
18
|
import { androidDevelopmentNdkCommand, androidDevelopmentSerial, installAndroidDevelopment, validateAndroidDevelopment } from "./android-dev.mjs";
|
|
18
19
|
|
|
19
20
|
// One standard Cargo profile for every app (templates/cargo-profiles.toml): Cargo's built-in `dev`.
|
|
@@ -58,6 +59,8 @@ export function developmentEnvironment(env = process.env) {
|
|
|
58
59
|
|
|
59
60
|
export function validateDevelopmentConfig(config, platform, { root = process.cwd() } = {}) {
|
|
60
61
|
const target = config?.development?.targets?.[platform];
|
|
62
|
+
// iOS builds its commands from project, scheme and install (ios-dev.mjs); there is no hand-written build array.
|
|
63
|
+
if (platform === "ios") return validateIosDevelopment(target, { root });
|
|
61
64
|
if (!target || !Array.isArray(target.build) || target.build.length === 0) throw new Error(`development.targets.${platform}.build must be a non-empty command array`);
|
|
62
65
|
for (const [index, command] of target.build.entries()) validateCommand(command, `development.targets.${platform}.build[${index}]`);
|
|
63
66
|
if (target.install?.kind === "command") validateCommand(target.install, `development.targets.${platform}.install`);
|
|
@@ -85,8 +88,9 @@ export function validateDevelopmentConfig(config, platform, { root = process.cwd
|
|
|
85
88
|
|
|
86
89
|
export function runDevelopment({ root, platform, config, buildOnly = false, dryRun = false, env = process.env, run = spawnSync,
|
|
87
90
|
codesignEnvironment = macosCodesignEnvironment, serial }) {
|
|
88
|
-
if (!new Set(["win", "mac", "android"]).has(platform)) throw new Error("--platform must be win, mac or
|
|
89
|
-
|
|
91
|
+
if (!new Set(["win", "mac", "android", "ios"]).has(platform)) throw new Error("--platform must be win, mac, android or ios");
|
|
92
|
+
// iOS: --dry-run validates the config and prints the plan on any host; everything else needs macOS with Xcode.
|
|
93
|
+
if (!(platform === "ios" && dryRun)) assertNativeDevelopmentHost(platform);
|
|
90
94
|
const target = validateDevelopmentConfig(config, platform, { root });
|
|
91
95
|
const repoRoot = git(root, ["rev-parse", "--show-toplevel"]);
|
|
92
96
|
assertPrimaryReleaseCheckout(repoRoot, env);
|
|
@@ -94,6 +98,12 @@ export function runDevelopment({ root, platform, config, buildOnly = false, dryR
|
|
|
94
98
|
assertReleaseExecutionAllowed({ cwd: repoRoot, environment: env, command: `right-release dev --platform ${platform}` });
|
|
95
99
|
// Refuse an invalid or off-CI ad hoc opt-in before the (long) build, not after it.
|
|
96
100
|
if (!buildOnly && platform === "mac" && target.install?.kind === "tauri") requestedCiAdhocSigning({ descriptor: target.install, env });
|
|
101
|
+
// iOS: the device or simulator is resolved (and a device proven ready) before the build, and a device build needs the GUI signing session.
|
|
102
|
+
let iosPrepared = null;
|
|
103
|
+
if (platform === "ios") {
|
|
104
|
+
assertIosDevelopmentSession(target, { env, run });
|
|
105
|
+
iosPrepared = prepareIosDevelopment(target, { root, env, run, serial });
|
|
106
|
+
}
|
|
97
107
|
// Android: the device serial is required, so a missing one is refused before the (long) build too.
|
|
98
108
|
if (!buildOnly && platform === "android" && target.install) androidDevelopmentSerial({ descriptor: target.install, serial, env });
|
|
99
109
|
const childEnv = codesignEnvironment(developmentEnvironment(env));
|
|
@@ -104,12 +114,13 @@ export function runDevelopment({ root, platform, config, buildOnly = false, dryR
|
|
|
104
114
|
const startedMs = Date.now();
|
|
105
115
|
// Android: cargo-ndk (Cargo dev profile) fills the generated jniLibs, then the Gradle debug build packages it.
|
|
106
116
|
if (target.ndk) { rmSync(target.ndk.jniLibs, { recursive: true, force: true }); execute(androidDevelopmentNdkCommand(target.ndk), { root, env: childEnv, dryRun, run, label: "native build" }); }
|
|
107
|
-
for (const command of target.build) execute(command, { root, env: childEnv, dryRun, run, label: "build" });
|
|
117
|
+
for (const command of iosPrepared ? iosPrepared.build : target.build) execute(command, { root, env: childEnv, dryRun, run, label: "build" });
|
|
108
118
|
// Dev and QA binaries run from one stable staged path, copied in place (no backup of the old copy), never from the
|
|
109
119
|
// broker's per-generation target folder or a hashed dev-targets folder.
|
|
110
120
|
const staged = target.stage?.length ? stageDevelopmentBinaries(target.stage, { root, platform: platform === "win" ? "win32" : process.platform }) : [];
|
|
111
121
|
for (const file of staged) console.log(`right-release dev: staged ${file}`);
|
|
112
122
|
if (buildOnly || !target.install) return { built: true, installed: false, ...(staged.length ? { staged } : {}) };
|
|
123
|
+
if (iosPrepared) return { built: true, installed: true, ios: installIosDevelopment(iosPrepared.install, { root, env: childEnv, run }) };
|
|
113
124
|
if (target.install.kind === TAURI_DEVID_KIND) {
|
|
114
125
|
// installTauriDevId is async: the lease is held until it settles, so a broker cutover cannot land mid-install.
|
|
115
126
|
const built = resolveTauriArtifact(target.install, { root, platform, env: childEnv });
|
|
@@ -124,7 +135,7 @@ export function runDevelopment({ root, platform, config, buildOnly = false, dryR
|
|
|
124
135
|
}
|
|
125
136
|
|
|
126
137
|
export function assertNativeDevelopmentHost(platform, host = process.platform) {
|
|
127
|
-
if ((platform === "win" && host !== "win32") || (platform === "mac" && host !== "darwin")) throw new Error(`development target ${platform} requires its native host`);
|
|
138
|
+
if ((platform === "win" && host !== "win32") || ((platform === "mac" || platform === "ios") && host !== "darwin")) throw new Error(`development target ${platform} requires its native host`);
|
|
128
139
|
}
|
|
129
140
|
|
|
130
141
|
function install(descriptor, { root, platform, config, env, run, sinceMs = null, serial }) {
|
|
@@ -338,7 +349,7 @@ function inside(root, child) { const rel = path.relative(path.resolve(root), pat
|
|
|
338
349
|
export async function main(args = process.argv.slice(2)) {
|
|
339
350
|
let platform, configPath = "right-release.config.mjs", dryRun = false, buildOnly = false, serial;
|
|
340
351
|
for (let i = 0; i < args.length; i++) {
|
|
341
|
-
if (args[i] === "-h" || args[i] === "--help") { console.log("usage: right-release dev --platform win|mac|android [--config right-release.config.mjs] [--dry-run] [--build-only] [--serial <adb serial>]"); return; }
|
|
352
|
+
if (args[i] === "-h" || args[i] === "--help") { console.log("usage: right-release dev --platform win|mac|android|ios [--config right-release.config.mjs] [--dry-run] [--build-only] [--serial <adb serial | iOS device id>]"); return; }
|
|
342
353
|
if (args[i] === "--platform") platform = args[++i];
|
|
343
354
|
else if (args[i] === "--config") configPath = args[++i];
|
|
344
355
|
else if (args[i] === "--dry-run") dryRun = true;
|
|
@@ -346,7 +357,7 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
346
357
|
else if (args[i] === "--serial") serial = args[++i];
|
|
347
358
|
else throw new Error(`unknown dev option: ${args[i]}`);
|
|
348
359
|
}
|
|
349
|
-
if (!platform) throw new Error("right-release dev requires --platform win|mac|android");
|
|
360
|
+
if (!platform) throw new Error("right-release dev requires --platform win|mac|android|ios");
|
|
350
361
|
const absoluteConfig = path.resolve(configPath);
|
|
351
362
|
const safeEnv = developmentEnvironment(process.env);
|
|
352
363
|
for (const [key, value] of Object.entries(safeEnv)) process.env[key] = value;
|
package/direct-bootstrap.mjs
CHANGED
|
@@ -289,7 +289,7 @@ export function renderPowerShellBootstrap({
|
|
|
289
289
|
"$AcceptedSigners = @{}; $Contract.acceptedManifestSigners.PSObject.Properties | ForEach-Object { $AcceptedSigners[$_.Name] = $_.Value }",
|
|
290
290
|
"$InstallRoot = Join-Path $env:LOCALAPPDATA " + psQuote(installRootSubdir),
|
|
291
291
|
// In place at one fixed path: no versions/ folder, no per-version, per-digest or per-run directory, no junction.
|
|
292
|
-
// `current` is the real install; `current.staging` is written and verified but never run; `current.previous` is
|
|
292
|
+
// `current` is the real install; `current.staging` is written and verified but never run; `current.previous` is a transient park, deleted on success.
|
|
293
293
|
"$Current = Join-Path $InstallRoot 'current'",
|
|
294
294
|
"$Staging = Join-Path $InstallRoot 'current.staging'",
|
|
295
295
|
"$Previous = Join-Path $InstallRoot 'current.previous'",
|
|
@@ -436,7 +436,6 @@ function Invoke-BoundedExe($Path, [string[]]$Arguments, [int]$TimeoutSeconds) {
|
|
|
436
436
|
"$PriorCliCapable = $true; if ($Contract.priorExecutableMinVersion) { $PriorCliCapable = ($null -ne $PriorVersion -and ([version]$PriorVersion -ge [version]$Contract.priorExecutableMinVersion)) }",
|
|
437
437
|
"$PriorStatus = $null; if ($PriorTarget -and $PriorCliCapable) { $PriorExe = Join-Path $Current " + psQuote(executablePath.replaceAll("/", "\\")) + "; $PriorStatusArgs = @(Expand-BootstrapArgs $StatusArgsTemplate ([string]$PriorTarget) $PriorVersion); try { $PriorStatus = ((Invoke-BoundedExe $PriorExe $PriorStatusArgs 60) | Out-String).Trim() } catch {} }",
|
|
438
438
|
"@{ schemaVersion=1; priorTarget=$PriorTarget; priorVersion=$PriorVersion; targetVersion=$Version; priorStatus=$PriorStatus } | ConvertTo-Json -Depth 8 | Set-Content -Encoding UTF8 $Journal",
|
|
439
|
-
"$PriorWasJunction = $false; $CurrentItem = Get-Item -LiteralPath $Current -Force -ErrorAction SilentlyContinue; if ($CurrentItem -and $CurrentItem.LinkType -eq 'Junction') { $PriorWasJunction = $true }",
|
|
440
439
|
"Remove-Retired $Previous",
|
|
441
440
|
"$Switched = $false; $PriorDetached = $false",
|
|
442
441
|
"try {",
|
|
@@ -449,10 +448,10 @@ function Invoke-BoundedExe($Path, [string[]]$Arguments, [int]$TimeoutSeconds) {
|
|
|
449
448
|
" $HealthText = ((Invoke-BoundedExe $StableExe $StatusArgs 120) | Out-String).Trim(); if ($LASTEXITCODE -ne 0) { throw 'Health command failed' }; $Health = $HealthText | ConvertFrom-Json",
|
|
450
449
|
" Assert-Health $Health $Version",
|
|
451
450
|
" $StableBin = Split-Path $StableExe -Parent; $InstallRootResolved = [IO.Path]::GetFullPath($InstallRoot).TrimEnd([IO.Path]::DirectorySeparatorChar); $UserPath = [Environment]::GetEnvironmentVariable('Path','User'); $Kept = @($UserPath -split ';' | Where-Object { $Entry = $_.Trim(); $Entry -ne '' -and -not ($Entry.TrimEnd([IO.Path]::DirectorySeparatorChar).StartsWith(($InstallRootResolved + [IO.Path]::DirectorySeparatorChar), [StringComparison]::OrdinalIgnoreCase) -and -not $Entry.TrimEnd([IO.Path]::DirectorySeparatorChar).Equals($StableBin.TrimEnd([IO.Path]::DirectorySeparatorChar), [StringComparison]::OrdinalIgnoreCase)) }); $NewPath = ((@($StableBin) + $Kept) -join ';'); if ($NewPath -ne $UserPath) { [Environment]::SetEnvironmentVariable('Path', $NewPath, 'User') }",
|
|
452
|
-
//
|
|
453
|
-
//
|
|
454
|
-
|
|
455
|
-
" Remove-Item -Force $Journal; Write-Output (" + psQuote(product + " ") + " + $Version + ' installed')",
|
|
451
|
+
// Fixed-path install only (D01): no versioned folders, pointer junctions or legacy-layout cleanup in the
|
|
452
|
+
// generated script. A pre-existing legacy layout under $InstallRoot is left untouched, never deleted whole.
|
|
453
|
+
// No retained rollback copy (D01): $Previous is a transient park, deleted once health passes.
|
|
454
|
+
" Remove-Retired $Previous; Remove-Item -Force $Journal; Write-Output (" + psQuote(product + " ") + " + $Version + ' installed')",
|
|
456
455
|
"} catch {",
|
|
457
456
|
" $InstallError = $_; $RollbackErrors = New-Object 'System.Collections.Generic.List[string]'",
|
|
458
457
|
" try { if ($Switched) { Remove-Retired $Current }; if ($PriorDetached -and (Test-Path -LiteralPath $Previous)) { Rename-Item -LiteralPath $Previous -NewName (Split-Path -Leaf $Current) } } catch { $RollbackErrors.Add($_.Exception.Message) }",
|
package/disk-command.mjs
CHANGED
|
@@ -580,6 +580,7 @@ export async function runDiskCommand(args = process.argv.slice(2), { env = proce
|
|
|
580
580
|
if (!self.ok) {
|
|
581
581
|
for (const problem of self.problems) stdout(`ERROR: disk run self-check: ${problem}`);
|
|
582
582
|
logEvent(logFile, { event: "disk.run.selfcheck.fail", problems: self.problems, version: self.version });
|
|
583
|
+
return { exitCode: 2, selfCheck: self };
|
|
583
584
|
}
|
|
584
585
|
ensureScratchRoots({ policy, platform, env });
|
|
585
586
|
const before = check();
|
package/disk-policy.json
CHANGED
|
@@ -33,13 +33,14 @@
|
|
|
33
33
|
{"id": "cache-parakeet", "platform": "mac", "path": "~/Library/Caches/parakeet_*", "capBytes": 1073741824, "action": "sweep", "unit": "self"},
|
|
34
34
|
{"id": "cache-swiftright-factory", "platform": "mac", "path": "~/Library/Caches/SwiftRightFactory", "capBytes": 10737418240, "action": "sweep", "unit": "children"},
|
|
35
35
|
{"id": "cache-cutright-engine", "platform": "mac", "path": "~/Library/Caches/cutright-engine", "capBytes": 2147483648, "action": "report", "unit": "children", "escalate": {"factor": 2, "staleDays": 7}},
|
|
36
|
+
{"id": "cache-cutright-app", "platform": "mac", "path": "~/Library/Caches/app.orthiclabs.cutright", "capBytes": 2147483648, "action": "report", "unit": "children", "escalate": {"factor": 2, "staleDays": 7}},
|
|
36
37
|
{"id": "cache-heardright-engine", "platform": "mac", "path": "~/Library/Caches/heardright-engine", "capBytes": 2147483648, "action": "report", "unit": "children", "escalate": {"factor": 2, "staleDays": 7}},
|
|
37
38
|
{"id": "cache-pnpm", "platform": "mac", "path": "~/Library/Caches/pnpm", "capBytes": 3221225472, "action": "report", "unit": "children", "escalate": {"factor": 2, "staleDays": 7}},
|
|
38
39
|
{"id": "cache-python", "platform": "mac", "path": "~/Library/Caches/python", "capBytes": 3221225472, "action": "report", "unit": "children", "escalate": {"factor": 2, "staleDays": 7}},
|
|
39
40
|
{"id": "xcode-deriveddata", "platform": "mac", "path": "~/Library/Developer/Xcode/DerivedData", "capBytes": 10737418240, "action": "sweep", "unit": "children"},
|
|
40
41
|
{"id": "coresimulator", "platform": "mac", "path": "~/Library/Developer/CoreSimulator", "capBytes": 26843545600, "action": "report"},
|
|
41
42
|
{"id": "documents-scratch", "platform": "mac", "path": "~/Documents", "capBytes": 21474836480, "action": "report"},
|
|
42
|
-
{"id": "shared-models", "platform": "mac", "path": "/
|
|
43
|
+
{"id": "shared-models", "platform": "mac", "path": "~/Library/Application Support/Orthic Labs/shared/models", "capBytes": 107374182400, "action": "report"},
|
|
43
44
|
{"id": "stale-release-tarballs", "platform": "mac", "path": "/Volumes/D/claude/rightkit/packages/release/rightkit-release-*.tgz", "capBytes": 1073741824, "action": "sweep", "unit": "self"},
|
|
44
45
|
{"id": "win-temp", "platform": "win", "path": "${TEMP}", "capBytes": 10737418240, "action": "sweep", "unit": "children"},
|
|
45
46
|
{"id": "win-pnpm", "platform": "win", "path": "${LOCALAPPDATA}\\pnpm", "capBytes": 21474836480, "action": "report"},
|
|
@@ -61,7 +62,10 @@
|
|
|
61
62
|
{"id": "leftover-legion-final", "platform": "mac", "path": "/Volumes/D/claude/legion-final-*", "capBytes": 1073741824, "action": "sweep", "unit": "self"},
|
|
62
63
|
{"id": "leftover-pulse-bak", "platform": "win", "path": "${LOCALAPPDATA}\\Programs\\Pulse.bak-*", "capBytes": 1073741824, "action": "sweep", "unit": "self"},
|
|
63
64
|
{"id": "qa-evidence-runs", "platform": "mac", "path": "/Volumes/D/claude/*/.cache/rightkit-qa/*", "capBytes": 5368709120, "action": "sweep", "unit": "self", "staleDays": 3, "skipTracked": true},
|
|
64
|
-
{"id": "qa-evidence-runs", "platform": "win", "path": "D:\\claude\\*\\.cache\\rightkit-qa\\*", "capBytes": 5368709120, "action": "sweep", "unit": "self", "staleDays": 3, "skipTracked": true}
|
|
65
|
+
{"id": "qa-evidence-runs", "platform": "win", "path": "D:\\claude\\*\\.cache\\rightkit-qa\\*", "capBytes": 5368709120, "action": "sweep", "unit": "self", "staleDays": 3, "skipTracked": true},
|
|
66
|
+
{"id": "build-control-targets", "platform": "mac", "path": "/Volumes/D/.rightkit-managed/rightkit-build-control", "capBytes": 429496729600, "action": "report"},
|
|
67
|
+
{"id": "scratch-total", "platform": "mac", "path": "/Volumes/D/scratch", "capBytes": 322122547200, "action": "report"},
|
|
68
|
+
{"id": "claude-tmp", "platform": "mac", "path": "/Volumes/D/claude/tmp", "capBytes": 53687091200, "action": "report"}
|
|
65
69
|
],
|
|
66
70
|
"protectedSegments": ["Application Support", "Keychains", "Keychain", "HeardRightNext", "Credentials", ".ssh", ".rightkit-managed", ".rightkit-swept"]
|
|
67
71
|
}
|
package/disk-stable-root.mjs
CHANGED
|
@@ -64,9 +64,10 @@ export function collectDependencies(pkgRoot) {
|
|
|
64
64
|
return [...found].map(([name, dir]) => ({ name, dir }));
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
/** Copy the installed @rightkit/release package (and its dependencies) into the stable root. The new tree is built
|
|
68
|
-
*
|
|
69
|
-
* `current
|
|
67
|
+
/** Copy the installed @rightkit/release package (and its dependencies) into the stable root. The new tree is built in
|
|
68
|
+
* the fixed `current.staging`, swapped in by rename (the old copy is parked at `current.previous` only until the
|
|
69
|
+
* smoke test at `current` passes, then deleted; a failed swap or smoke run renames it back). No copy is retained.
|
|
70
|
+
* Returns `{ stable, cli, version, replaced, unchanged }`. */
|
|
70
71
|
export function installStableRoot({ cli, platform = process.platform, env = process.env, home = os.homedir(), now = Date.now(), nodePath = process.execPath, smoke = true } = {}) {
|
|
71
72
|
const stable = stableRootPath({ platform, env, home });
|
|
72
73
|
const stableCli = stableCliPath(stable, platform);
|
|
@@ -107,10 +108,10 @@ export function installStableRoot({ cli, platform = process.platform, env = proc
|
|
|
107
108
|
throw error;
|
|
108
109
|
}
|
|
109
110
|
// Swap first, then smoke-run at the fixed `current` path (never from the staging path). A failed run renames
|
|
110
|
-
// `current.previous` back
|
|
111
|
-
|
|
111
|
+
// the transiently parked `current.previous` back; a passed run deletes it (nothing is retained).
|
|
112
|
+
const replaced = existsSync(stable);
|
|
112
113
|
try {
|
|
113
|
-
|
|
114
|
+
swapInPlace({
|
|
114
115
|
staging, destination: stable, previous,
|
|
115
116
|
verify: () => {
|
|
116
117
|
if (!smoke) return;
|
|
@@ -118,7 +119,6 @@ export function installStableRoot({ cli, platform = process.platform, env = proc
|
|
|
118
119
|
if (r.status !== 0) throw new Error(`the installed copy does not run: ${r.stderr || r.stdout || r.error?.message}`);
|
|
119
120
|
},
|
|
120
121
|
});
|
|
121
|
-
replaced = result.previous !== null;
|
|
122
122
|
} catch (error) {
|
|
123
123
|
throw new Error(`disk: could not replace ${stable}: ${error.message}`);
|
|
124
124
|
}
|
|
@@ -130,8 +130,12 @@ export function readScheduleBinding({ platform = process.platform, home = os.hom
|
|
|
130
130
|
if (platform === "darwin") {
|
|
131
131
|
const plist = plistFile ?? file ?? path.join(home, "Library", "LaunchAgents", "dev.rightkit.disk-hygiene.plist");
|
|
132
132
|
if (!existsSync(plist)) return null;
|
|
133
|
-
|
|
134
|
-
|
|
133
|
+
// Parse the plist natively: launchd/plutil formatting and XML escaping must not change the binding.
|
|
134
|
+
const result = run("/usr/bin/plutil", ["-extract", "ProgramArguments", "json", "-o", "-", "--", plist], { encoding: "utf8" });
|
|
135
|
+
let args;
|
|
136
|
+
try { if (result.status === 0) args = JSON.parse(String(result.stdout)); } catch { /* malformed binding fails evaluation below */ }
|
|
137
|
+
const valid = Array.isArray(args) && typeof args[0] === "string" && typeof args[1] === "string";
|
|
138
|
+
return valid ? { node: args[0], cli: args[1], source: plist } : { node: null, cli: null, source: plist };
|
|
135
139
|
}
|
|
136
140
|
const r = run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", `$t = Get-ScheduledTask -TaskName '${label}' -ErrorAction SilentlyContinue; if (-not $t) { exit 3 }; $a = $t.Actions[0]; Write-Output $a.Execute; Write-Output $a.Arguments`], { encoding: "utf8" });
|
|
137
141
|
if (r.status !== 0) return null;
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// GitHub Releases lane of `right-release publish-update` (Orthic-Labs/rightkit#237).
|
|
2
2
|
//
|
|
3
3
|
// The updater artifacts and their minisign signatures are the ones the r2-rightapps lane already uses: they are
|
|
4
|
-
// signed once by `sign-updater.mjs` with the suite updater key (the
|
|
5
|
-
//
|
|
4
|
+
// signed once by `sign-updater.mjs` with the suite updater key (loaded from the OS credential store, never a
|
|
5
|
+
// key file), sealed, and re-verified by `verifyPublicationRelease`. This module only turns the same
|
|
6
6
|
// `platforms` map the r2 lane builds ({ <platform>: { signature, url } }) into a Tauri v2 static `latest.json`
|
|
7
7
|
// and uploads it, with the bundles and their `.sig` files, as assets of the GitHub release `v<version>`, so
|
|
8
8
|
// `https://github.com/<owner>/<repo>/releases/latest/download/latest.json` resolves. It adds no key and no trust root.
|
|
9
|
+
import { createHash, createPublicKey, verify as cryptoVerify } from "node:crypto";
|
|
9
10
|
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
10
11
|
import path from "node:path";
|
|
11
12
|
import { PublicationError, digestBytes, readVerifiedArtifact, verifyArtifactFile } from "./publication-artifacts.mjs";
|
|
@@ -58,13 +59,75 @@ export function githubLatestJsonUrl(repo) {
|
|
|
58
59
|
* [2-byte algorithm][8-byte key id, little endian][64-byte signature].
|
|
59
60
|
*/
|
|
60
61
|
export function updaterSignatureKeyId(signatureText) {
|
|
62
|
+
return parseMinisignSignature(signatureText)?.keyId ?? null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
|
|
66
|
+
|
|
67
|
+
function parseMinisignSignature(signatureText) {
|
|
68
|
+
try {
|
|
69
|
+
const decode = (text) => {
|
|
70
|
+
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(text)) throw new Error("invalid base64");
|
|
71
|
+
const bytes = Buffer.from(text, "base64");
|
|
72
|
+
if (bytes.toString("base64") !== text) throw new Error("invalid base64");
|
|
73
|
+
return bytes;
|
|
74
|
+
};
|
|
75
|
+
const envelope = decode(String(signatureText).trim());
|
|
76
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(envelope);
|
|
77
|
+
const lines = text.split(/\r?\n/);
|
|
78
|
+
if (lines.at(-1) === "") lines.pop();
|
|
79
|
+
if (lines.length !== 4 || !lines[2].startsWith("trusted comment: ")) return null;
|
|
80
|
+
const raw = decode(lines[1]);
|
|
81
|
+
const globalSignature = decode(lines[3]);
|
|
82
|
+
if (globalSignature.length !== 64) return null;
|
|
83
|
+
if (raw.length !== 74) return null;
|
|
84
|
+
const algorithm = raw.subarray(0, 2).toString("latin1");
|
|
85
|
+
if (algorithm !== "Ed" && algorithm !== "ED") return null;
|
|
86
|
+
return { algorithm, keyId: Buffer.from(raw.subarray(2, 10)).reverse().toString("hex").toUpperCase(), signature: raw.subarray(10), globalSignature, trustedComment: Buffer.from(lines[2].slice(17), "utf8") };
|
|
87
|
+
} catch { return null; }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function parseMinisignPublicKey(publicKeyText) {
|
|
91
|
+
const text = String(publicKeyText ?? "").trim();
|
|
61
92
|
try {
|
|
62
|
-
const lines = Buffer.from(
|
|
93
|
+
const lines = Buffer.from(text, "base64").toString("utf8").split(/\r?\n/);
|
|
63
94
|
const raw = Buffer.from(lines[1] ?? "", "base64");
|
|
64
|
-
if (raw.length
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
95
|
+
if (raw.length === 42 && ["Ed", "ED"].includes(raw.subarray(0, 2).toString("latin1"))) {
|
|
96
|
+
return { keyId: Buffer.from(raw.subarray(2, 10)).reverse().toString("hex").toUpperCase(), key: raw.subarray(10) };
|
|
97
|
+
}
|
|
98
|
+
} catch { /* fall through to raw forms */ }
|
|
99
|
+
try {
|
|
100
|
+
const raw = Buffer.from(text, "base64");
|
|
101
|
+
if (raw.length === 42 && ["Ed", "ED"].includes(raw.subarray(0, 2).toString("latin1"))) return { keyId: Buffer.from(raw.subarray(2, 10)).reverse().toString("hex").toUpperCase(), key: raw.subarray(10) };
|
|
102
|
+
if (raw.length === 32) return { keyId: null, key: raw };
|
|
103
|
+
} catch { /* invalid below */ }
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Cryptographic minisign (Ed25519) verification over the EXACT artifact bytes. `Ed` signatures
|
|
109
|
+
* cover the bytes directly; `ED` signatures cover their BLAKE2b-512 prehash. Returns false (never
|
|
110
|
+
* throws) for any malformed signature/key, key-id mismatch, or bad signature.
|
|
111
|
+
*/
|
|
112
|
+
export function verifyMinisignSignature(bytes, signatureText, publicKeyText) {
|
|
113
|
+
const sig = parseMinisignSignature(signatureText);
|
|
114
|
+
const pub = parseMinisignPublicKey(publicKeyText);
|
|
115
|
+
if (!sig || !pub || (pub.keyId && pub.keyId !== sig.keyId)) return false;
|
|
116
|
+
try {
|
|
117
|
+
const data = sig.algorithm === "ED" ? createHash("blake2b512").update(bytes).digest() : Buffer.from(bytes);
|
|
118
|
+
const key = createPublicKey({ key: Buffer.concat([ED25519_SPKI_PREFIX, pub.key]), format: "der", type: "spki" });
|
|
119
|
+
return cryptoVerify(null, data, key, sig.signature)
|
|
120
|
+
&& cryptoVerify(null, Buffer.concat([sig.signature, sig.trustedComment]), key, sig.globalSignature);
|
|
121
|
+
} catch { return false; }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Require a cryptographically valid minisign signature over `bytes` under the declared public key.
|
|
126
|
+
* The key-id check (`assertUpdaterKeyId`) is supplementary only; this is the seal/upload gate.
|
|
127
|
+
*/
|
|
128
|
+
export function assertUpdaterSignature(bytes, signatureText, publicKeyText, label = "updater signature") {
|
|
129
|
+
if (!verifyMinisignSignature(bytes, signatureText, publicKeyText)) {
|
|
130
|
+
throw new PublicationError("PUBLICATION_SIGNATURE_INVALID", `${label} does not cryptographically verify over the exact artifact bytes under the declared updater public key`);
|
|
68
131
|
}
|
|
69
132
|
}
|
|
70
133
|
|
package/in-place-install.mjs
CHANGED
|
@@ -4,10 +4,10 @@ import path from "node:path";
|
|
|
4
4
|
/**
|
|
5
5
|
* The one install rule for every app, helper and dev or QA binary (Adrian, 2026-10-17: "No new id, no new folder"):
|
|
6
6
|
* macOS and Windows tie privacy grants to the path and the code identity, so an install or update replaces the
|
|
7
|
-
* copy at ONE fixed path.
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* copy at ONE fixed path. NO install keeps a retained `<name>.previous` copy (D01/#771): a `previous` path passed
|
|
8
|
+
* to `swapInPlace` is only a TRANSIENT park for the old copy while the new one is verified; it is deleted on
|
|
9
|
+
* success and, on failure, the parked copy is renamed back and the park path is gone. Nothing ever runs from
|
|
10
|
+
* `.previous` or from the staging copy. A stale `<destination>.previous` left by an older RightKit is removed.
|
|
11
11
|
*/
|
|
12
12
|
export const PREVIOUS_SUFFIX = ".previous";
|
|
13
13
|
export const STAGING_SUFFIX = ".staging";
|
|
@@ -24,16 +24,16 @@ const exists = (fs, file) => {
|
|
|
24
24
|
/**
|
|
25
25
|
* Swap `staging` into `destination`, then `verify(destination)` runs.
|
|
26
26
|
*
|
|
27
|
-
* With a `previous` path
|
|
28
|
-
* failure removes the new copy and renames
|
|
29
|
-
*
|
|
27
|
+
* With a `previous` path: the old destination is parked there ONLY until verification passes, then deleted
|
|
28
|
+
* (never retained as a rollback copy). Any failure removes the new copy and renames the parked copy back,
|
|
29
|
+
* so the fixed path is never left empty and no `.previous` remains either way. Renames only.
|
|
30
|
+
* @returns {{destination: string, previous: null}} previous is always null: no copy is retained.
|
|
30
31
|
*
|
|
31
|
-
* With `previous: null` (
|
|
32
|
-
* over the fixed path
|
|
33
|
-
*
|
|
34
|
-
* from an older build. `previous` is always null in the result.
|
|
32
|
+
* With `previous: null` (NO park of any kind): the old destination is removed and `staging` is renamed
|
|
33
|
+
* over the fixed path. Any failure removes the new (absent or partial) copy and the staging copy and
|
|
34
|
+
* rethrows; the old copy is NOT restored, the user reinstalls from an older build.
|
|
35
35
|
*/
|
|
36
|
-
export function swapInPlace({ staging, destination, previous =
|
|
36
|
+
export function swapInPlace({ staging, destination, previous = null, verify = () => {}, fs = nodeFs }) {
|
|
37
37
|
if (previous === null) return replaceWithoutBackup({ staging, destination, verify, fs });
|
|
38
38
|
if (previous === destination || staging === destination || staging === previous) throw new Error("swapInPlace needs three distinct paths");
|
|
39
39
|
let movedAside = false;
|
|
@@ -51,7 +51,10 @@ export function swapInPlace({ staging, destination, previous = previousPathOf(de
|
|
|
51
51
|
try { fs.rmSync(staging, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
52
52
|
throw error;
|
|
53
53
|
}
|
|
54
|
-
|
|
54
|
+
// Verified: the transient park (and any stale retained copy) is deleted, never kept.
|
|
55
|
+
try { if (movedAside) fs.rmSync(previous, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
56
|
+
try { fs.rmSync(previousPathOf(destination), { recursive: true, force: true }); } catch { /* best effort */ }
|
|
57
|
+
return { destination, previous: null };
|
|
55
58
|
}
|
|
56
59
|
|
|
57
60
|
function replaceWithoutBackup({ staging, destination, verify, fs }) {
|
|
@@ -65,7 +68,7 @@ function replaceWithoutBackup({ staging, destination, verify, fs }) {
|
|
|
65
68
|
try { fs.rmSync(staging, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
66
69
|
throw error;
|
|
67
70
|
}
|
|
68
|
-
// No old copy may survive
|
|
71
|
+
// No old copy may survive an install, including one an older RightKit left behind.
|
|
69
72
|
try { fs.rmSync(previousPathOf(destination), { recursive: true, force: true }); } catch { /* best effort */ }
|
|
70
73
|
return { destination, previous: null };
|
|
71
74
|
}
|