@rightkit/release 0.2.56 → 0.2.57
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/build-release.mjs +17 -1
- package/cli/right-release.mjs +0 -0
- package/nsis-payload.mjs +155 -0
- package/nsis-payload.test.mjs +139 -0
- package/package.json +32 -31
- package/release.mjs +78 -0
- package/release.test.mjs +11 -0
- package/right-suite-contract.test.mjs +4 -2
- package/rightkit-versions.json +67 -43
- package/tauri-bundle-marker.mjs +165 -0
- package/tauri-bundle-marker.test.mjs +81 -0
package/build-release.mjs
CHANGED
|
@@ -29,6 +29,17 @@ import { buildPathPrefix, collectPreflight, formatPreflight, preflightFailures }
|
|
|
29
29
|
import { assertCleanSource } from "./source-gate.mjs";
|
|
30
30
|
import { acquireHeavyWorkSlot, heavyCommandEnvironment, terminateProcessTree } from "./heavy-command.mjs";
|
|
31
31
|
|
|
32
|
+
// Fingerprint of the pipeline code that can change the bytes we ship or the way
|
|
33
|
+
// they are signed — deliberately NOT the package version, which moves for docs
|
|
34
|
+
// and test-only edits and would force a cold Rust rebuild of every app each time.
|
|
35
|
+
// These four files are the ones whose behaviour the cached target directory can
|
|
36
|
+
// outlive.
|
|
37
|
+
const PIPELINE_FINGERPRINT_SOURCES = ["release.mjs", "sign-windows.mjs", "tauri-bundle-marker.mjs", "nsis-payload.mjs"];
|
|
38
|
+
const PIPELINE_FINGERPRINT = createHash("sha256")
|
|
39
|
+
.update(PIPELINE_FINGERPRINT_SOURCES.map((file) => `${file}:${createHash("sha256").update(readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), file))).digest("hex")}`).join("\n"))
|
|
40
|
+
.digest("hex")
|
|
41
|
+
.slice(0, 16);
|
|
42
|
+
|
|
32
43
|
/** Assemble preflight inputs from the app's own files (mirrors release.mjs). */
|
|
33
44
|
function buildPreflight({ config, configPath, appRoot, repoRoot, platform }) {
|
|
34
45
|
let version;
|
|
@@ -124,8 +135,13 @@ try {
|
|
|
124
135
|
const receiptRoot = path.resolve(process.env.RIGHT_RELEASE_STATE_ROOT ?? path.join(appRoot, ".right-release", "receipts"));
|
|
125
136
|
const signingIdentity = platform === "win" ? {
|
|
126
137
|
contract: target.signingContract ?? "<missing>",
|
|
138
|
+
// Pipeline code participates in cache identity. Without it, a fix to the
|
|
139
|
+
// signing or packaging logic leaves the previous logic's target directory
|
|
140
|
+
// eligible for reuse — which is how outputs from a known-bad pipeline
|
|
141
|
+
// outlived the change that was supposed to retire them.
|
|
142
|
+
pipeline: PIPELINE_FINGERPRINT,
|
|
127
143
|
configSha256: hashFile(configPath),
|
|
128
|
-
receiptInputs: ["raw-exe", "installer"].map((phase) => path.join(receiptRoot, `windows-${phase}.json`)),
|
|
144
|
+
receiptInputs: ["raw-exe", "installer", "embedding"].map((phase) => path.join(receiptRoot, `windows-${phase}.json`)),
|
|
129
145
|
} : null;
|
|
130
146
|
if (signingIdentity) inputHashes[".right-release/signing-identity.json"] = hashFileText(JSON.stringify(signingIdentity));
|
|
131
147
|
const cargoLock = requiredInputs.find((file) => /Cargo\.lock$/i.test(file));
|
package/cli/right-release.mjs
CHANGED
|
File without changes
|
package/nsis-payload.mjs
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Prove the installer actually ships the bytes we signed.
|
|
2
|
+
//
|
|
3
|
+
// Signing the raw EXE and signing the installer are two separate signatures.
|
|
4
|
+
// Between them Tauri copies the EXE into the NSIS payload, and a single byte
|
|
5
|
+
// changed anywhere in that hand-off invalidates the inner Authenticode signature
|
|
6
|
+
// while leaving the outer installer signature perfectly valid. Every check that
|
|
7
|
+
// stops at "the installer is signed" therefore passes on a broken build — which
|
|
8
|
+
// is how invalid binaries shipped.
|
|
9
|
+
//
|
|
10
|
+
// So we open the finished installer, pull the embedded executable back out, and
|
|
11
|
+
// require it to be byte-identical to the EXE we signed. No inference, no proxy.
|
|
12
|
+
// 7-Zip reads the NSIS container format; if it is absent we fail closed rather
|
|
13
|
+
// than record an unverified pass.
|
|
14
|
+
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
|
17
|
+
import os from "node:os";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { spawnSync } from "node:child_process";
|
|
20
|
+
|
|
21
|
+
const SEVEN_ZIP_CANDIDATES = [
|
|
22
|
+
"C:\\Program Files\\7-Zip\\7z.exe",
|
|
23
|
+
"C:\\Program Files (x86)\\7-Zip\\7z.exe",
|
|
24
|
+
"/opt/homebrew/bin/7z",
|
|
25
|
+
"/usr/local/bin/7z",
|
|
26
|
+
"/usr/bin/7z",
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
export function resolveSevenZip({ env = process.env, exists = existsSync, which = commandPath } = {}) {
|
|
30
|
+
const explicit = env.RIGHT_RELEASE_SEVENZIP;
|
|
31
|
+
if (explicit) {
|
|
32
|
+
if (!exists(explicit)) throw new Error(`RIGHT_RELEASE_SEVENZIP does not exist: ${explicit}`);
|
|
33
|
+
return explicit;
|
|
34
|
+
}
|
|
35
|
+
for (const candidate of SEVEN_ZIP_CANDIDATES) {
|
|
36
|
+
if (exists(candidate)) return candidate;
|
|
37
|
+
}
|
|
38
|
+
for (const name of ["7z", "7za", "7zz"]) {
|
|
39
|
+
const found = which(name);
|
|
40
|
+
if (found) return found;
|
|
41
|
+
}
|
|
42
|
+
throw new Error(
|
|
43
|
+
"7-Zip is required to verify the NSIS installer payload but was not found; install 7-Zip (https://www.7-zip.org) or set RIGHT_RELEASE_SEVENZIP to its 7z executable",
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Names of the files NSIS will install, parsed from 7-Zip's technical listing
|
|
49
|
+
* (`-slt`). The column-aligned default listing leaves the date blank for entries
|
|
50
|
+
* NSIS synthesises, which makes positional parsing quietly wrong; the key/value
|
|
51
|
+
* form has no such ambiguity. Directory entries are dropped.
|
|
52
|
+
*
|
|
53
|
+
* The first record describes the archive itself and is separated from the member
|
|
54
|
+
* records by a `----------` rule, so parsing starts after that rule.
|
|
55
|
+
*/
|
|
56
|
+
export function parseSevenZipListing(stdout) {
|
|
57
|
+
const lines = String(stdout).replace(/\r\n?/g, "\n").split("\n");
|
|
58
|
+
const start = lines.findIndex((line) => line.trim() === "----------");
|
|
59
|
+
if (start === -1) return [];
|
|
60
|
+
const entries = [];
|
|
61
|
+
let current = null;
|
|
62
|
+
for (const line of lines.slice(start + 1)) {
|
|
63
|
+
const match = line.match(/^([A-Za-z][A-Za-z0-9 ]*?)\s=\s?(.*)$/);
|
|
64
|
+
if (!match) continue;
|
|
65
|
+
const [, key, value] = match;
|
|
66
|
+
if (key === "Path") {
|
|
67
|
+
if (current) entries.push(current);
|
|
68
|
+
current = { name: value.replace(/\\/g, "/"), sizeBytes: null, folder: false };
|
|
69
|
+
} else if (!current) continue;
|
|
70
|
+
else if (key === "Size") current.sizeBytes = value.trim() === "" ? null : Number(value.trim());
|
|
71
|
+
else if (key === "Folder") current.folder = value.trim() === "+";
|
|
72
|
+
else if (key === "Attributes") current.folder = current.folder || value.includes("D");
|
|
73
|
+
}
|
|
74
|
+
if (current) entries.push(current);
|
|
75
|
+
return entries.filter((entry) => !entry.folder).map(({ name, sizeBytes }) => ({ name, sizeBytes }));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function listNsisPayload(installer, { sevenZip = resolveSevenZip(), run = runSevenZip } = {}) {
|
|
79
|
+
const result = run(sevenZip, ["l", "-tnsis", "-slt", "--", path.resolve(installer)]);
|
|
80
|
+
if (result.status !== 0) {
|
|
81
|
+
throw new Error(`could not read the NSIS payload of ${installer}: ${(result.stderr || result.stdout || "").trim()}`);
|
|
82
|
+
}
|
|
83
|
+
return parseSevenZipListing(result.stdout);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Extract one payload entry to a temporary directory and return its bytes.
|
|
88
|
+
* The caller owns nothing: the temporary directory is always removed.
|
|
89
|
+
*/
|
|
90
|
+
export function extractNsisEntry(installer, entryName, { sevenZip = resolveSevenZip(), run = runSevenZip, tempRoot = os.tmpdir() } = {}) {
|
|
91
|
+
const resolvedInstaller = path.resolve(installer);
|
|
92
|
+
const base = path.posix.basename(String(entryName).replace(/\\/g, "/"));
|
|
93
|
+
if (!base || base === "." || base === "..") throw new Error(`invalid NSIS payload entry name: ${entryName}`);
|
|
94
|
+
const destination = path.join(tempRoot, `right-nsis-${process.pid}-${Math.random().toString(16).slice(2)}`);
|
|
95
|
+
mkdirSync(destination, { recursive: true });
|
|
96
|
+
try {
|
|
97
|
+
// `e` flattens the entry to the destination root, so the extracted path is
|
|
98
|
+
// known regardless of how deep the entry sits inside the payload.
|
|
99
|
+
const result = run(sevenZip, ["e", "-tnsis", "-y", `-o${destination}`, "--", resolvedInstaller, base]);
|
|
100
|
+
const extracted = path.join(destination, base);
|
|
101
|
+
if (result.status !== 0 || !existsSync(extracted)) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
`could not extract ${base} from ${resolvedInstaller}: ${(result.stderr || result.stdout || "no such entry").trim()}`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
return { name: base, bytes: readFileSync(extracted) };
|
|
107
|
+
} finally {
|
|
108
|
+
rmSync(destination, { recursive: true, force: true });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The load-bearing check: the executable inside the installer must be the exact
|
|
114
|
+
* executable we signed. Returns a receipt; throws with the byte-level reason if
|
|
115
|
+
* the bytes drifted.
|
|
116
|
+
*/
|
|
117
|
+
export function verifyNsisEmbeddedBinary({
|
|
118
|
+
installer,
|
|
119
|
+
entryName,
|
|
120
|
+
expectedSha256,
|
|
121
|
+
sevenZip = resolveSevenZip(),
|
|
122
|
+
run = runSevenZip,
|
|
123
|
+
tempRoot = os.tmpdir(),
|
|
124
|
+
}) {
|
|
125
|
+
if (!/^[0-9a-f]{64}$/i.test(String(expectedSha256 ?? ""))) {
|
|
126
|
+
throw new Error(`expected a sha256 digest for the signed ${entryName}, received ${JSON.stringify(expectedSha256)}`);
|
|
127
|
+
}
|
|
128
|
+
const { name, bytes } = extractNsisEntry(installer, entryName, { sevenZip, run, tempRoot });
|
|
129
|
+
const embeddedSha256 = createHash("sha256").update(bytes).digest("hex");
|
|
130
|
+
if (embeddedSha256.toLowerCase() !== String(expectedSha256).toLowerCase()) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
`the installer does not embed the signed ${name}: signed sha256 ${expectedSha256}, embedded sha256 ${embeddedSha256}. `
|
|
133
|
+
+ "Something rewrote the executable after it was signed, so its Authenticode signature is invalid inside the installer.",
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
installer: path.resolve(installer),
|
|
138
|
+
entry: name,
|
|
139
|
+
sha256: embeddedSha256,
|
|
140
|
+
sizeBytes: bytes.length,
|
|
141
|
+
installerSha256: createHash("sha256").update(readFileSync(path.resolve(installer))).digest("hex"),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function runSevenZip(command, args) {
|
|
146
|
+
return spawnSync(command, args, { encoding: "utf8", windowsHide: true, maxBuffer: 32 * 1024 * 1024 });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function commandPath(name) {
|
|
150
|
+
const probe = process.platform === "win32" ? "where" : "which";
|
|
151
|
+
const result = spawnSync(probe, [name], { encoding: "utf8", windowsHide: true });
|
|
152
|
+
if (result.status !== 0) return null;
|
|
153
|
+
const first = String(result.stdout).split(/\r?\n/).map((line) => line.trim()).find(Boolean);
|
|
154
|
+
return first && existsSync(first) ? first : null;
|
|
155
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import test, { after } from "node:test";
|
|
7
|
+
import { listNsisPayload, parseSevenZipListing, resolveSevenZip, verifyNsisEmbeddedBinary } from "./nsis-payload.mjs";
|
|
8
|
+
|
|
9
|
+
const workspace = mkdtempSync(path.join(tmpdir(), "rightkit-nsis-"));
|
|
10
|
+
after(() => rmSync(workspace, { recursive: true, force: true }));
|
|
11
|
+
|
|
12
|
+
// 7-Zip's technical listing: an archive record, a rule, then one record per member.
|
|
13
|
+
// The blank `Attributes` and absent timestamps are what the column-aligned listing
|
|
14
|
+
// gets wrong, so they are reproduced here deliberately.
|
|
15
|
+
const LISTING = [
|
|
16
|
+
"Path = D:\\out\\App_1.2.3_x64-setup.exe",
|
|
17
|
+
"Type = Nsis",
|
|
18
|
+
"",
|
|
19
|
+
"----------",
|
|
20
|
+
"Path = $PLUGINSDIR\\System.dll",
|
|
21
|
+
"Size = 12288",
|
|
22
|
+
"Attributes = ",
|
|
23
|
+
"",
|
|
24
|
+
"Path = app with spaces.exe",
|
|
25
|
+
"Size = 13750736",
|
|
26
|
+
"Attributes = ",
|
|
27
|
+
"",
|
|
28
|
+
"Path = legal",
|
|
29
|
+
"Size = 0",
|
|
30
|
+
"Folder = +",
|
|
31
|
+
"Attributes = D",
|
|
32
|
+
"",
|
|
33
|
+
"Path = legal\\EULA.md",
|
|
34
|
+
"Size = 16723",
|
|
35
|
+
"Attributes = A",
|
|
36
|
+
"",
|
|
37
|
+
].join("\n");
|
|
38
|
+
|
|
39
|
+
test("parses 7-Zip technical listings, including blank dates and spaced names", () => {
|
|
40
|
+
const entries = parseSevenZipListing(LISTING);
|
|
41
|
+
assert.deepEqual(entries.map((entry) => entry.name), [
|
|
42
|
+
"$PLUGINSDIR/System.dll",
|
|
43
|
+
"app with spaces.exe",
|
|
44
|
+
"legal/EULA.md",
|
|
45
|
+
]);
|
|
46
|
+
assert.equal(entries[1].sizeBytes, 13_750_736);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("ignores the archive's own record above the rule", () => {
|
|
50
|
+
const entries = parseSevenZipListing(LISTING);
|
|
51
|
+
assert.equal(entries.some((entry) => entry.name.endsWith("setup.exe")), false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("listNsisPayload surfaces the 7-Zip failure instead of reporting an empty payload", () => {
|
|
55
|
+
assert.throws(
|
|
56
|
+
() => listNsisPayload("installer.exe", {
|
|
57
|
+
sevenZip: "7z",
|
|
58
|
+
run: () => ({ status: 2, stdout: "", stderr: "Cannot open the file as archive" }),
|
|
59
|
+
}),
|
|
60
|
+
/could not read the NSIS payload.*Cannot open the file as archive/s,
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("verifyNsisEmbeddedBinary accepts a payload whose bytes match the signed EXE", () => {
|
|
65
|
+
const installer = path.join(workspace, "setup.exe");
|
|
66
|
+
writeFileSync(installer, "installer-container");
|
|
67
|
+
const payload = Buffer.from("signed-executable-bytes");
|
|
68
|
+
const sha256 = createHash("sha256").update(payload).digest("hex");
|
|
69
|
+
|
|
70
|
+
const receipt = verifyNsisEmbeddedBinary({
|
|
71
|
+
installer,
|
|
72
|
+
entryName: "app.exe",
|
|
73
|
+
expectedSha256: sha256,
|
|
74
|
+
sevenZip: "7z",
|
|
75
|
+
run: (_command, args) => {
|
|
76
|
+
const destination = args.find((arg) => arg.startsWith("-o")).slice(2);
|
|
77
|
+
writeFileSync(path.join(destination, "app.exe"), payload);
|
|
78
|
+
return { status: 0, stdout: "Everything is Ok", stderr: "" };
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
assert.equal(receipt.sha256, sha256);
|
|
83
|
+
assert.equal(receipt.entry, "app.exe");
|
|
84
|
+
assert.equal(receipt.sizeBytes, payload.length);
|
|
85
|
+
assert.equal(receipt.installerSha256, createHash("sha256").update(readFileSync(installer)).digest("hex"));
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("verifyNsisEmbeddedBinary rejects a payload that drifted from the signed EXE", () => {
|
|
89
|
+
const installer = path.join(workspace, "setup-drifted.exe");
|
|
90
|
+
writeFileSync(installer, "installer-container");
|
|
91
|
+
assert.throws(
|
|
92
|
+
() => verifyNsisEmbeddedBinary({
|
|
93
|
+
installer,
|
|
94
|
+
entryName: "app.exe",
|
|
95
|
+
expectedSha256: createHash("sha256").update("signed-executable-bytes").digest("hex"),
|
|
96
|
+
sevenZip: "7z",
|
|
97
|
+
run: (_command, args) => {
|
|
98
|
+
const destination = args.find((arg) => arg.startsWith("-o")).slice(2);
|
|
99
|
+
writeFileSync(path.join(destination, "app.exe"), "rewritten-after-signing");
|
|
100
|
+
return { status: 0, stdout: "Everything is Ok", stderr: "" };
|
|
101
|
+
},
|
|
102
|
+
}),
|
|
103
|
+
/does not embed the signed app\.exe.*Authenticode signature is invalid/s,
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("verifyNsisEmbeddedBinary fails when the entry is absent rather than passing vacuously", () => {
|
|
108
|
+
const installer = path.join(workspace, "setup-missing.exe");
|
|
109
|
+
writeFileSync(installer, "installer-container");
|
|
110
|
+
assert.throws(
|
|
111
|
+
() => verifyNsisEmbeddedBinary({
|
|
112
|
+
installer,
|
|
113
|
+
entryName: "app.exe",
|
|
114
|
+
expectedSha256: "a".repeat(64),
|
|
115
|
+
sevenZip: "7z",
|
|
116
|
+
run: () => ({ status: 2, stdout: "", stderr: "No files to process" }),
|
|
117
|
+
}),
|
|
118
|
+
/could not extract app\.exe/,
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("verifyNsisEmbeddedBinary refuses a caller that has no digest to compare against", () => {
|
|
123
|
+
assert.throws(
|
|
124
|
+
() => verifyNsisEmbeddedBinary({ installer: "setup.exe", entryName: "app.exe", expectedSha256: undefined, sevenZip: "7z", run: () => ({ status: 0 }) }),
|
|
125
|
+
/expected a sha256 digest/,
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("resolveSevenZip names the fix when 7-Zip is absent", () => {
|
|
130
|
+
assert.throws(
|
|
131
|
+
() => resolveSevenZip({ env: {}, exists: () => false, which: () => null }),
|
|
132
|
+
/install 7-Zip.*RIGHT_RELEASE_SEVENZIP/s,
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("resolveSevenZip honours an explicit override and rejects a bad one", () => {
|
|
137
|
+
assert.equal(resolveSevenZip({ env: { RIGHT_RELEASE_SEVENZIP: "C:\\tools\\7z.exe" }, exists: () => true }), "C:\\tools\\7z.exe");
|
|
138
|
+
assert.throws(() => resolveSevenZip({ env: { RIGHT_RELEASE_SEVENZIP: "C:\\gone\\7z.exe" }, exists: () => false }), /does not exist/);
|
|
139
|
+
});
|
package/package.json
CHANGED
|
@@ -1,31 +1,32 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@rightkit/release",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "Portable Right Suite release CLI/SDK: signed installers, updater artifacts, patch/update routing, hardening, R2 publish, and RightApps registration.",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"bin": {
|
|
7
|
-
"right-release": "cli/right-release.mjs"
|
|
8
|
-
},
|
|
9
|
-
"files": [
|
|
10
|
-
"cli",
|
|
11
|
-
"*.mjs",
|
|
12
|
-
"*.json",
|
|
13
|
-
"*.sh",
|
|
14
|
-
"*.py"
|
|
15
|
-
],
|
|
16
|
-
"sideEffects": false,
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
},
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
}
|
|
31
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@rightkit/release",
|
|
3
|
+
"version": "0.2.57",
|
|
4
|
+
"description": "Portable Right Suite release CLI/SDK: signed installers, updater artifacts, patch/update routing, hardening, R2 publish, and RightApps registration.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"right-release": "cli/right-release.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"cli",
|
|
11
|
+
"*.mjs",
|
|
12
|
+
"*.json",
|
|
13
|
+
"*.sh",
|
|
14
|
+
"*.py"
|
|
15
|
+
],
|
|
16
|
+
"sideEffects": false,
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "node --test *.test.mjs",
|
|
19
|
+
"doctor:all": "node --test right-suite-contract.test.mjs",
|
|
20
|
+
"verify:standalone": "node standalone-clone-verify.mjs"
|
|
21
|
+
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"registry": "https://registry.npmjs.org/",
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/adrdsouza/claude.git",
|
|
29
|
+
"directory": "tools/rightkit/packages/release"
|
|
30
|
+
},
|
|
31
|
+
"packageManager": "pnpm@11.18.0"
|
|
32
|
+
}
|
package/release.mjs
CHANGED
|
@@ -10,6 +10,8 @@ import { assertQaBackdoorContract } from "./qa-contract.mjs";
|
|
|
10
10
|
import { assertLegalReleaseContract } from "./legal-contract.mjs";
|
|
11
11
|
import { assertPrimaryReleaseCheckout, dirtyBuildInputs, resolveReleaseBuildInputs } from "./release-invocation.mjs";
|
|
12
12
|
import { collectPreflight, formatPreflight, preflightFailures } from "./preflight.mjs";
|
|
13
|
+
import { patchTauriBundleType } from "./tauri-bundle-marker.mjs";
|
|
14
|
+
import { verifyNsisEmbeddedBinary } from "./nsis-payload.mjs";
|
|
13
15
|
|
|
14
16
|
const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
15
17
|
const HARDENING_SCAN = path.resolve(TOOL_ROOT, "hardeningscan.mjs");
|
|
@@ -193,10 +195,19 @@ if (!opts.skipChecks) {
|
|
|
193
195
|
for (const script of config.checks ?? []) await runPackageScript(config.packageManager, script, workdir);
|
|
194
196
|
}
|
|
195
197
|
|
|
198
|
+
let bundleMarkerReceipts = [];
|
|
196
199
|
if (opts.platform === "win") {
|
|
197
200
|
const rawFiles = target.sign.prePackageFiles.map((p) => path.resolve(root, p));
|
|
198
201
|
await runCommand(target.prePackage, root);
|
|
199
202
|
for (const file of rawFiles) await mustExist(file, `missing raw EXE signing artifact: ${file}`);
|
|
203
|
+
// The marker must reach its FINAL value before signing. Tauri rewrites it during
|
|
204
|
+
// bundling, and doing that to signed bytes invalidates Authenticode — the exact
|
|
205
|
+
// defect that shipped invalid binaries. Idempotent, so an app that still patches
|
|
206
|
+
// in its own prePackage script simply reports alreadyPatched here.
|
|
207
|
+
bundleMarkerReceipts = opts.dryRun ? [] : rawFiles.map((file) => patchTauriBundleType(file, { bundle: target.bundleMarker ?? "nsis" }));
|
|
208
|
+
for (const receipt of bundleMarkerReceipts) {
|
|
209
|
+
console.log(`right-release: bundle marker ${receipt.bundle} ${receipt.alreadyPatched ? "already applied" : "applied"} at offset ${receipt.offset} in ${path.basename(receipt.file)}`);
|
|
210
|
+
}
|
|
200
211
|
await signWindows(rawFiles, "raw-exe", root);
|
|
201
212
|
}
|
|
202
213
|
|
|
@@ -209,6 +220,10 @@ for (const rel of target.artifacts ?? []) {
|
|
|
209
220
|
if (opts.platform === "win" && target.sign?.files?.length) {
|
|
210
221
|
const files = target.sign.files.map((p) => path.resolve(root, p));
|
|
211
222
|
for (const file of files) await mustExist(file, `missing signing artifact: ${file}`);
|
|
223
|
+
// Before the installer earns its own signature, prove it carries the bytes we
|
|
224
|
+
// signed. An installer-only signature on a mutated payload verifies perfectly
|
|
225
|
+
// and still installs a binary Windows rejects.
|
|
226
|
+
await verifyWindowsEmbedding({ root, target, files, markers: bundleMarkerReceipts });
|
|
212
227
|
await signWindows(files, "installer", root);
|
|
213
228
|
if (target.postSign) await runCommand(target.postSign, root);
|
|
214
229
|
const updaterFiles = [...new Set((target.updater?.artifacts ?? []).map((artifact) => path.resolve(root, artifact.file)))];
|
|
@@ -406,6 +421,69 @@ async function signWindows(files, phase, root) {
|
|
|
406
421
|
if (!opts.dryRun) verifyWindowsReceipt(receipt, files, phase);
|
|
407
422
|
}
|
|
408
423
|
|
|
424
|
+
function windowsReceiptPath(root, phase) {
|
|
425
|
+
return path.join(process.env.RIGHT_RELEASE_STATE_ROOT ?? path.join(root, ".right-release", "receipts"), `windows-${phase}.json`);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Close the gap between "the raw EXE was signed" and "the installer ships that
|
|
430
|
+
* EXE". Three independent facts, each able to fail on its own:
|
|
431
|
+
* 1. the signed EXE on disk is byte-identical to what the signing receipt says
|
|
432
|
+
* (catches an in-place rewrite during bundling),
|
|
433
|
+
* 2. it still verifies as Authenticode-valid (catches a mutation that somehow
|
|
434
|
+
* preserved the hash we recorded), and
|
|
435
|
+
* 3. the executable extracted back out of the installer matches it byte for
|
|
436
|
+
* byte (catches a bundler that patched its own copy and left ours alone).
|
|
437
|
+
* Only the third is proof the shipped artifact is correct; the first two name
|
|
438
|
+
* the cause when it is not.
|
|
439
|
+
*/
|
|
440
|
+
async function verifyWindowsEmbedding({ root, target, files, markers }) {
|
|
441
|
+
if (opts.dryRun) {
|
|
442
|
+
console.log("dry-run: verify installer embeds the signed raw EXE");
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
const rawFile = path.resolve(root, target.sign.prePackageFiles[0]);
|
|
446
|
+
const rawReceiptPath = windowsReceiptPath(root, "raw-exe");
|
|
447
|
+
let rawReceipt;
|
|
448
|
+
try { rawReceipt = JSON.parse(readFileSync(rawReceiptPath, "utf8")); } catch { fail(`missing raw EXE signing receipt: ${rawReceiptPath}`); }
|
|
449
|
+
const signed = rawReceipt.files?.find((item) => path.resolve(item.file) === rawFile);
|
|
450
|
+
const signedSha256 = signed?.after?.sha256;
|
|
451
|
+
if (!signedSha256) fail(`raw EXE signing receipt does not record a signed digest for ${rawFile}`);
|
|
452
|
+
|
|
453
|
+
await mustExist(rawFile, `signed raw EXE disappeared before packaging completed: ${rawFile}`);
|
|
454
|
+
const currentSha256 = createHash("sha256").update(readFileSync(rawFile)).digest("hex");
|
|
455
|
+
if (currentSha256 !== signedSha256) {
|
|
456
|
+
fail(
|
|
457
|
+
`packaging rewrote the signed raw EXE: signed sha256 ${signedSha256}, on-disk sha256 ${currentSha256}. `
|
|
458
|
+
+ "Its Authenticode signature is now invalid. Apply every deterministic byte patch before signing, never after.",
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
// signtool, not inference: a hash match proves the bytes, this proves the chain.
|
|
462
|
+
await run("node", [SIGN_WINDOWS, "--verify-only", rawFile], root);
|
|
463
|
+
|
|
464
|
+
const embedded = [];
|
|
465
|
+
for (const installer of files) {
|
|
466
|
+
let receipt;
|
|
467
|
+
try {
|
|
468
|
+
receipt = verifyNsisEmbeddedBinary({ installer, entryName: path.basename(rawFile), expectedSha256: signedSha256 });
|
|
469
|
+
} catch (error) {
|
|
470
|
+
fail(error.message);
|
|
471
|
+
}
|
|
472
|
+
console.log(`right-release: ${path.basename(installer)} embeds the signed ${receipt.entry} (sha256 ${receipt.sha256.slice(0, 16)}…)`);
|
|
473
|
+
embedded.push(receipt);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const receiptPath = windowsReceiptPath(root, "embedding");
|
|
477
|
+
mkdirSync(path.dirname(receiptPath), { recursive: true });
|
|
478
|
+
writeFileSync(receiptPath, `${JSON.stringify({
|
|
479
|
+
schema: 1,
|
|
480
|
+
rawExe: { file: rawFile, signedSha256, verifiedSha256: currentSha256 },
|
|
481
|
+
bundleMarkers: markers ?? [],
|
|
482
|
+
embedded,
|
|
483
|
+
verifiedAt: new Date().toISOString(),
|
|
484
|
+
}, null, 2)}\n`);
|
|
485
|
+
}
|
|
486
|
+
|
|
409
487
|
function verifyWindowsReceipt(receipt, files, phase) {
|
|
410
488
|
if (!existsSync(receipt)) fail(`missing ${phase} Windows signing receipt: ${receipt}`);
|
|
411
489
|
let evidence;
|
package/release.test.mjs
CHANGED
|
@@ -372,6 +372,17 @@ test("Windows tier-neutral build builds raw EXE, signs it, bundles, signs instal
|
|
|
372
372
|
assert.doesNotMatch(result.stdout, /publish-update\.mjs/);
|
|
373
373
|
});
|
|
374
374
|
|
|
375
|
+
test("Windows verifies the installer embeds the signed EXE after bundling and before the installer is signed", () => {
|
|
376
|
+
const result = run(fixture({ publish: true }));
|
|
377
|
+
assert.equal(result.status, 0, result.stderr);
|
|
378
|
+
const rawSignAt = result.stdout.indexOf("signing windows raw-exe");
|
|
379
|
+
const bundleAt = result.stdout.indexOf("node -e", rawSignAt);
|
|
380
|
+
const embeddingAt = result.stdout.indexOf("verify installer embeds the signed raw EXE");
|
|
381
|
+
const installerSignAt = result.stdout.indexOf("signing windows installer");
|
|
382
|
+
assert.ok(embeddingAt > bundleAt, `embedding check must follow bundling:\n${result.stdout}`);
|
|
383
|
+
assert.ok(installerSignAt > embeddingAt, `a mutated payload must never reach the installer signature:\n${result.stdout}`);
|
|
384
|
+
});
|
|
385
|
+
|
|
375
386
|
test("legacy internal tier does not make the build worker upload", () => {
|
|
376
387
|
const result = run(fixture({ publish: true }), "--tier=update");
|
|
377
388
|
assert.equal(result.status, 0, result.stderr);
|
|
@@ -464,11 +464,13 @@ test("RightKit exposes one current version manifest", () => {
|
|
|
464
464
|
"@rightkit/legal": "0.3.0",
|
|
465
465
|
"@rightkit/legal-ui": "0.1.1",
|
|
466
466
|
"@rightkit/license": "0.1.6",
|
|
467
|
-
"@rightkit/release": "0.2.
|
|
467
|
+
"@rightkit/release": "0.2.57",
|
|
468
|
+
"@rightkit/qa": "0.2.0",
|
|
468
469
|
});
|
|
469
470
|
assert.deepEqual(versions.legacyNpm, {
|
|
470
471
|
"@rightkit/legal-ui": ["0.1.0"],
|
|
471
|
-
"@rightkit/release": ["0.2.22", "0.2.29", "0.2.30", "0.2.31", "0.2.41", "0.2.42", "0.2.43", "0.2.44", "0.2.45", "0.2.46", "0.2.49", "0.2.50", "0.2.51", "0.2.53", "0.2.54", "0.2.55"],
|
|
472
|
+
"@rightkit/release": ["0.2.22", "0.2.29", "0.2.30", "0.2.31", "0.2.41", "0.2.42", "0.2.43", "0.2.44", "0.2.45", "0.2.46", "0.2.49", "0.2.50", "0.2.51", "0.2.53", "0.2.54", "0.2.55", "0.2.56"],
|
|
473
|
+
"@rightkit/qa": ["0.1.0"],
|
|
472
474
|
});
|
|
473
475
|
assert.ok(
|
|
474
476
|
new Set([versions.npm["@rightkit/release"], ...versions.legacyNpm["@rightkit/release"]]).has("0.2.42"),
|
package/rightkit-versions.json
CHANGED
|
@@ -1,43 +1,67 @@
|
|
|
1
|
-
{
|
|
2
|
-
"schema": 2,
|
|
3
|
-
"packageManager": "pnpm@11.18.0",
|
|
4
|
-
"npm": {
|
|
5
|
-
"@rightkit/git": "0.2.0",
|
|
6
|
-
"@rightkit/legal": "0.2.0",
|
|
7
|
-
"@rightkit/license": "0.1.5",
|
|
8
|
-
"@rightkit/logs": "0.1.3",
|
|
9
|
-
"@rightkit/platform-ui": "0.1.0",
|
|
10
|
-
"@rightkit/qa": "0.1.0",
|
|
11
|
-
"@rightkit/release": "0.2.47",
|
|
12
|
-
"@rightkit/tauri": "0.1.0",
|
|
13
|
-
"@rightkit/updates": "0.2.3"
|
|
14
|
-
},
|
|
15
|
-
"stagedNpm": {
|
|
16
|
-
"@rightkit/ax": "0.2.0",
|
|
17
|
-
"@rightkit/git": "0.2.0",
|
|
18
|
-
"@rightkit/legal": "0.3.0",
|
|
19
|
-
"@rightkit/legal-ui": "0.1.1",
|
|
20
|
-
"@rightkit/license": "0.1.6",
|
|
21
|
-
"@rightkit/release": "0.2.
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
"@rightkit/
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
"rightkit
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
1
|
+
{
|
|
2
|
+
"schema": 2,
|
|
3
|
+
"packageManager": "pnpm@11.18.0",
|
|
4
|
+
"npm": {
|
|
5
|
+
"@rightkit/git": "0.2.0",
|
|
6
|
+
"@rightkit/legal": "0.2.0",
|
|
7
|
+
"@rightkit/license": "0.1.5",
|
|
8
|
+
"@rightkit/logs": "0.1.3",
|
|
9
|
+
"@rightkit/platform-ui": "0.1.0",
|
|
10
|
+
"@rightkit/qa": "0.1.0",
|
|
11
|
+
"@rightkit/release": "0.2.47",
|
|
12
|
+
"@rightkit/tauri": "0.1.0",
|
|
13
|
+
"@rightkit/updates": "0.2.3"
|
|
14
|
+
},
|
|
15
|
+
"stagedNpm": {
|
|
16
|
+
"@rightkit/ax": "0.2.0",
|
|
17
|
+
"@rightkit/git": "0.2.0",
|
|
18
|
+
"@rightkit/legal": "0.3.0",
|
|
19
|
+
"@rightkit/legal-ui": "0.1.1",
|
|
20
|
+
"@rightkit/license": "0.1.6",
|
|
21
|
+
"@rightkit/release": "0.2.57",
|
|
22
|
+
"@rightkit/qa": "0.2.0"
|
|
23
|
+
},
|
|
24
|
+
"legacyNpm": {
|
|
25
|
+
"@rightkit/legal-ui": [
|
|
26
|
+
"0.1.0"
|
|
27
|
+
],
|
|
28
|
+
"@rightkit/release": [
|
|
29
|
+
"0.2.22",
|
|
30
|
+
"0.2.29",
|
|
31
|
+
"0.2.30",
|
|
32
|
+
"0.2.31",
|
|
33
|
+
"0.2.41",
|
|
34
|
+
"0.2.42",
|
|
35
|
+
"0.2.43",
|
|
36
|
+
"0.2.44",
|
|
37
|
+
"0.2.45",
|
|
38
|
+
"0.2.46",
|
|
39
|
+
"0.2.49",
|
|
40
|
+
"0.2.50",
|
|
41
|
+
"0.2.51",
|
|
42
|
+
"0.2.53",
|
|
43
|
+
"0.2.54",
|
|
44
|
+
"0.2.55",
|
|
45
|
+
"0.2.56"
|
|
46
|
+
],
|
|
47
|
+
"@rightkit/qa": [
|
|
48
|
+
"0.1.0"
|
|
49
|
+
]
|
|
50
|
+
},
|
|
51
|
+
"cargo": {
|
|
52
|
+
"rightkit-license": "0.1.2",
|
|
53
|
+
"rightkit-logs": "0.1.0",
|
|
54
|
+
"rightkit-process": "0.1.0",
|
|
55
|
+
"rightkit-tauri": "0.1.0"
|
|
56
|
+
},
|
|
57
|
+
"stagedCargo": {
|
|
58
|
+
"rightkit-license": "0.1.3",
|
|
59
|
+
"rightkit-tauri": "0.1.1"
|
|
60
|
+
},
|
|
61
|
+
"swift": {
|
|
62
|
+
"rightkit-swift": {
|
|
63
|
+
"url": "https://github.com/bogusyogi/rightkit-swift.git",
|
|
64
|
+
"version": "0.1.0"
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Tauri stamps the bundle kind into the compiled binary as an ASCII marker, and
|
|
2
|
+
// its NSIS bundler rewrites that marker WHILE bundling. Doing that to an EXE we
|
|
3
|
+
// already Authenticode-signed silently invalidates the signature — the installer
|
|
4
|
+
// then ships a binary Windows reports as unsigned/corrupt, which is exactly the
|
|
5
|
+
// failure this module exists to make impossible.
|
|
6
|
+
//
|
|
7
|
+
// The contract: patch the marker to its final value BEFORE signing, so Tauri's
|
|
8
|
+
// own rewrite finds nothing left to change and the signed bytes survive intact.
|
|
9
|
+
// Fail closed on anything ambiguous — a missing marker, more than one marker, or
|
|
10
|
+
// a byte changing outside the marker all mean the assumption no longer holds.
|
|
11
|
+
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
|
|
17
|
+
const MARKER_PREFIX = "__TAURI_BUNDLE_TYPE_VAR_";
|
|
18
|
+
|
|
19
|
+
export const BUNDLE_MARKERS = Object.freeze({
|
|
20
|
+
unknown: "UNK",
|
|
21
|
+
nsis: "NSS",
|
|
22
|
+
msi: "MSI",
|
|
23
|
+
app: "APP",
|
|
24
|
+
dmg: "DMG",
|
|
25
|
+
deb: "DEB",
|
|
26
|
+
rpm: "RPM",
|
|
27
|
+
appimage: "AIM",
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export function bundleMarkerBuffer(code) {
|
|
31
|
+
if (typeof code !== "string" || code.length !== 3) {
|
|
32
|
+
throw new Error(`Tauri bundle marker code must be exactly 3 ASCII characters, received ${JSON.stringify(code)}`);
|
|
33
|
+
}
|
|
34
|
+
return Buffer.from(`${MARKER_PREFIX}${code}`, "ascii");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Rewrite the single unknown bundle marker to the requested bundle kind.
|
|
39
|
+
*
|
|
40
|
+
* Idempotent by design: a binary already carrying the target marker (and no
|
|
41
|
+
* unknown marker) is reported as `alreadyPatched` rather than failing, so a
|
|
42
|
+
* retried build does not have to distinguish "never patched" from "resumed".
|
|
43
|
+
*/
|
|
44
|
+
export function patchTauriBundleType(file, { bundle = "nsis", read = readFileSync, write = writeFileSync } = {}) {
|
|
45
|
+
const targetCode = BUNDLE_MARKERS[bundle];
|
|
46
|
+
if (!targetCode) {
|
|
47
|
+
throw new Error(`unsupported Tauri bundle kind: ${bundle} (expected one of ${Object.keys(BUNDLE_MARKERS).join(", ")})`);
|
|
48
|
+
}
|
|
49
|
+
const resolved = path.resolve(file);
|
|
50
|
+
const unknownMarker = bundleMarkerBuffer(BUNDLE_MARKERS.unknown);
|
|
51
|
+
const targetMarker = bundleMarkerBuffer(targetCode);
|
|
52
|
+
|
|
53
|
+
const before = read(resolved);
|
|
54
|
+
const beforeSha256 = sha256(before);
|
|
55
|
+
const unknownOffsets = findAll(before, unknownMarker);
|
|
56
|
+
const targetOffsets = findAll(before, targetMarker);
|
|
57
|
+
|
|
58
|
+
if (unknownOffsets.length === 0) {
|
|
59
|
+
if (targetOffsets.length === 1) {
|
|
60
|
+
return {
|
|
61
|
+
file: resolved,
|
|
62
|
+
bundle,
|
|
63
|
+
offset: targetOffsets[0],
|
|
64
|
+
changedBytes: 0,
|
|
65
|
+
alreadyPatched: true,
|
|
66
|
+
size: before.length,
|
|
67
|
+
beforeSha256,
|
|
68
|
+
afterSha256: beforeSha256,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
throw new Error(
|
|
72
|
+
`no Tauri unknown bundle marker in ${resolved} and ${targetOffsets.length} ${targetCode} markers; refusing to guess the bundle identity`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
if (unknownOffsets.length !== 1) {
|
|
76
|
+
throw new Error(`expected exactly one Tauri unknown bundle marker in ${resolved}; found ${unknownOffsets.length}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const offset = unknownOffsets[0];
|
|
80
|
+
const after = Buffer.from(before);
|
|
81
|
+
targetMarker.copy(after, offset);
|
|
82
|
+
|
|
83
|
+
const expectedChanges = markerChangeOffsets(unknownMarker, targetMarker, offset);
|
|
84
|
+
const actualChanges = [];
|
|
85
|
+
for (let index = 0; index < before.length; index += 1) {
|
|
86
|
+
if (before[index] !== after[index]) actualChanges.push(index);
|
|
87
|
+
}
|
|
88
|
+
if (actualChanges.join(",") !== expectedChanges.join(",")) {
|
|
89
|
+
throw new Error(`unexpected Tauri bundle marker mutation in ${resolved}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
write(resolved, after);
|
|
93
|
+
const written = read(resolved);
|
|
94
|
+
if (!written.equals(after) || findAll(written, unknownMarker).length !== 0) {
|
|
95
|
+
throw new Error(`failed to verify Tauri ${targetCode} bundle marker in ${resolved}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
file: resolved,
|
|
100
|
+
bundle,
|
|
101
|
+
offset,
|
|
102
|
+
changedBytes: actualChanges.length,
|
|
103
|
+
alreadyPatched: false,
|
|
104
|
+
size: written.length,
|
|
105
|
+
beforeSha256,
|
|
106
|
+
afterSha256: sha256(written),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Back-compat alias for the original app-local helper name. */
|
|
111
|
+
export function patchTauriBundleTypeForNsis(file, options = {}) {
|
|
112
|
+
return patchTauriBundleType(file, { ...options, bundle: "nsis" });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Report which markers a binary currently carries, without writing. Used by the
|
|
117
|
+
* post-package audit to prove the packaged binary is the one that was signed.
|
|
118
|
+
*/
|
|
119
|
+
export function readTauriBundleMarkers(file, { read = readFileSync } = {}) {
|
|
120
|
+
const resolved = path.resolve(file);
|
|
121
|
+
const bytes = read(resolved);
|
|
122
|
+
const markers = {};
|
|
123
|
+
for (const [bundle, code] of Object.entries(BUNDLE_MARKERS)) {
|
|
124
|
+
const offsets = findAll(bytes, bundleMarkerBuffer(code));
|
|
125
|
+
if (offsets.length) markers[bundle] = offsets;
|
|
126
|
+
}
|
|
127
|
+
return { file: resolved, size: bytes.length, sha256: sha256(bytes), markers };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function findAll(haystack, needle) {
|
|
131
|
+
const offsets = [];
|
|
132
|
+
for (let offset = haystack.indexOf(needle); offset !== -1; offset = haystack.indexOf(needle, offset + 1)) {
|
|
133
|
+
offsets.push(offset);
|
|
134
|
+
}
|
|
135
|
+
return offsets;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function markerChangeOffsets(from, to, offset) {
|
|
139
|
+
const changes = [];
|
|
140
|
+
for (let index = 0; index < from.length; index += 1) {
|
|
141
|
+
if (from[index] !== to[index]) changes.push(offset + index);
|
|
142
|
+
}
|
|
143
|
+
return changes;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function sha256(bytes) {
|
|
147
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
151
|
+
const argv = process.argv.slice(2);
|
|
152
|
+
const bundleIndex = argv.indexOf("--bundle");
|
|
153
|
+
const bundle = bundleIndex >= 0 ? argv[bundleIndex + 1] : "nsis";
|
|
154
|
+
const file = argv.filter((arg, index) => arg !== "--bundle" && index !== bundleIndex + 1)[0];
|
|
155
|
+
if (!file) {
|
|
156
|
+
console.error("usage: node tauri-bundle-marker.mjs [--bundle nsis] <binary>");
|
|
157
|
+
process.exit(2);
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
console.log(JSON.stringify(patchTauriBundleType(file, { bundle })));
|
|
161
|
+
} catch (error) {
|
|
162
|
+
console.error(`tauri-bundle-marker: ${error.message}`);
|
|
163
|
+
process.exit(1);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { BUNDLE_MARKERS, patchTauriBundleType, patchTauriBundleTypeForNsis, readTauriBundleMarkers } from "./tauri-bundle-marker.mjs";
|
|
4
|
+
|
|
5
|
+
/** A stand-in binary: filler, one marker, filler. */
|
|
6
|
+
function fakeBinary(code = BUNDLE_MARKERS.unknown, { copies = 1 } = {}) {
|
|
7
|
+
const parts = [Buffer.alloc(64, 0x41)];
|
|
8
|
+
for (let index = 0; index < copies; index += 1) {
|
|
9
|
+
parts.push(Buffer.from(`__TAURI_BUNDLE_TYPE_VAR_${code}`, "ascii"), Buffer.alloc(32, 0x42));
|
|
10
|
+
}
|
|
11
|
+
return Buffer.concat(parts);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function memoryFs(initial) {
|
|
15
|
+
const store = new Map([["binary.exe", Buffer.from(initial)]]);
|
|
16
|
+
return {
|
|
17
|
+
store,
|
|
18
|
+
read: (file) => {
|
|
19
|
+
const key = file.endsWith("binary.exe") ? "binary.exe" : file;
|
|
20
|
+
const value = store.get(key);
|
|
21
|
+
if (!value) throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
|
|
22
|
+
return Buffer.from(value);
|
|
23
|
+
},
|
|
24
|
+
write: (file, bytes) => {
|
|
25
|
+
store.set(file.endsWith("binary.exe") ? "binary.exe" : file, Buffer.from(bytes));
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
test("rewrites the single unknown marker to the NSIS marker and nothing else", () => {
|
|
31
|
+
const fs = memoryFs(fakeBinary());
|
|
32
|
+
const before = fs.read("binary.exe");
|
|
33
|
+
const receipt = patchTauriBundleTypeForNsis("binary.exe", { read: fs.read, write: fs.write });
|
|
34
|
+
const after = fs.read("binary.exe");
|
|
35
|
+
|
|
36
|
+
assert.equal(receipt.alreadyPatched, false);
|
|
37
|
+
assert.equal(receipt.bundle, "nsis");
|
|
38
|
+
assert.equal(receipt.changedBytes, 3, "only the 3-character bundle code may change");
|
|
39
|
+
assert.equal(after.length, before.length, "patching must not resize the binary");
|
|
40
|
+
assert.equal(after.includes(Buffer.from("__TAURI_BUNDLE_TYPE_VAR_NSS")), true);
|
|
41
|
+
assert.equal(after.includes(Buffer.from("__TAURI_BUNDLE_TYPE_VAR_UNK")), false);
|
|
42
|
+
assert.notEqual(receipt.beforeSha256, receipt.afterSha256);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("is idempotent: an already-patched binary is reported, not rewritten", () => {
|
|
46
|
+
const fs = memoryFs(fakeBinary(BUNDLE_MARKERS.nsis));
|
|
47
|
+
const receipt = patchTauriBundleTypeForNsis("binary.exe", { read: fs.read, write: fs.write });
|
|
48
|
+
assert.equal(receipt.alreadyPatched, true);
|
|
49
|
+
assert.equal(receipt.changedBytes, 0);
|
|
50
|
+
assert.equal(receipt.beforeSha256, receipt.afterSha256, "an already-patched binary keeps its signed bytes");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("fails closed when the binary carries more than one unknown marker", () => {
|
|
54
|
+
const fs = memoryFs(fakeBinary(BUNDLE_MARKERS.unknown, { copies: 2 }));
|
|
55
|
+
assert.throws(
|
|
56
|
+
() => patchTauriBundleTypeForNsis("binary.exe", { read: fs.read, write: fs.write }),
|
|
57
|
+
/found 2/,
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("fails closed when no marker of any kind is present", () => {
|
|
62
|
+
const fs = memoryFs(Buffer.alloc(128, 0x41));
|
|
63
|
+
assert.throws(
|
|
64
|
+
() => patchTauriBundleTypeForNsis("binary.exe", { read: fs.read, write: fs.write }),
|
|
65
|
+
/refusing to guess the bundle identity/,
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("rejects an unsupported bundle kind before touching the file", () => {
|
|
70
|
+
const fs = memoryFs(fakeBinary());
|
|
71
|
+
assert.throws(() => patchTauriBundleType("binary.exe", { bundle: "zip", read: fs.read, write: fs.write }), /unsupported Tauri bundle kind/);
|
|
72
|
+
assert.equal(fs.read("binary.exe").includes(Buffer.from("__TAURI_BUNDLE_TYPE_VAR_UNK")), true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("readTauriBundleMarkers reports offsets without writing", () => {
|
|
76
|
+
const fs = memoryFs(fakeBinary(BUNDLE_MARKERS.nsis));
|
|
77
|
+
const report = readTauriBundleMarkers("binary.exe", { read: fs.read });
|
|
78
|
+
assert.deepEqual(report.markers.nsis, [64]);
|
|
79
|
+
assert.equal(report.markers.unknown, undefined);
|
|
80
|
+
assert.match(report.sha256, /^[0-9a-f]{64}$/);
|
|
81
|
+
});
|