@rightkit/release 0.2.164 → 0.2.172
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 +121 -4
- package/addon-command.mjs +6 -13
- package/android-aab-scan.mjs +265 -0
- package/android-dev.mjs +111 -0
- package/android-ndk.mjs +77 -0
- package/android-play-upload.mjs +262 -0
- package/android-release.mjs +344 -0
- package/android-test-fixtures.mjs +79 -0
- package/apple-profile-create.mjs +363 -0
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/darwin/rightkit-swap.sign.json +6 -0
- package/build-release.mjs +73 -13
- package/cargo-guard.mjs +2 -0
- package/cargo-profile-contract.mjs +183 -0
- package/cargo-target.mjs +8 -0
- package/cli/right-release.mjs +14 -4
- package/committed-source.mjs +1 -1
- package/dev-artifact-refresh.mjs +176 -0
- package/dev-artifact-support.mjs +61 -0
- package/development-signing.mjs +71 -4
- package/development.mjs +78 -46
- package/direct-bootstrap.mjs +26 -41
- package/disk-command.mjs +315 -13
- package/disk-policy.json +7 -5
- package/disk-policy.mjs +141 -22
- package/disk-stable-root.mjs +19 -16
- package/engine-locator.mjs +116 -0
- package/external-bin.mjs +133 -0
- package/github-release.mjs +2 -2
- package/github-updater-release.mjs +154 -0
- package/hardeningscan.mjs +1 -1
- package/in-place-install.mjs +56 -0
- package/install-app-windows.mjs +63 -0
- package/install-app.mjs +53 -23
- package/local-publish.mjs +11 -1
- package/mac-signing-matrix.mjs +23 -11
- package/managed-cargo.mjs +6 -0
- package/media-sidecar-catalog.mjs +45 -0
- package/media-sidecar.mjs +306 -0
- package/ownership-approval-command.mjs +10 -44
- package/ownership-approval.mjs +3 -42
- package/ownership-catalog-config.json +2 -1
- package/ownership-catalog.mjs +4 -2
- package/ownership-command.mjs +7 -9
- package/ownership-dependency-allowlist.json +25 -1
- package/ownership-evidence.mjs +8 -5
- package/ownership-git-inventory.mjs +41 -7
- package/ownership-lockfiles.mjs +91 -8
- package/ownership-mandatory-set.json +36 -16
- package/ownership-report.mjs +5 -6
- package/ownership-scan-worker.mjs +2 -2
- package/ownership-verdict.mjs +10 -7
- package/ownership.mjs +47 -29
- package/package.json +7 -4
- package/portable-development.mjs +32 -115
- package/preflight.mjs +4 -6
- package/publication-artifacts.mjs +34 -0
- package/publish-update.mjs +72 -11
- package/qa-adoption-exemptions.json +5 -0
- package/qa-build-features.mjs +7 -0
- package/qa-contract.mjs +182 -0
- package/qa-control-scan.mjs +3 -3
- package/release-qa-command.mjs +38 -12
- package/release-qa-installed.mjs +136 -0
- package/release-qa-receipt.mjs +23 -2
- package/release.mjs +283 -14
- package/rightkit-ownership.json +537 -5
- package/rightkit-sync.mjs +26 -0
- package/rightkit-versions.json +110 -62
- package/runtime-budget-classes.json +28 -0
- package/runtime-budgets.mjs +4 -1
- package/sign-candidate.mjs +161 -0
- package/sign-dev-artifact.mjs +198 -0
- package/stage-binary.mjs +60 -0
- package/swap-helper-bundle.mjs +105 -0
- package/templates/cargo-profiles.toml +28 -0
- package/upload-release.mjs +11 -1
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// GitHub Releases lane of `right-release publish-update` (Orthic-Labs/rightkit#237).
|
|
2
|
+
//
|
|
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 same TAURI_SIGNING_PRIVATE_KEY / key file as
|
|
5
|
+
// every other app), sealed, and re-verified by `verifyPublicationRelease`. This module only turns the same
|
|
6
|
+
// `platforms` map the r2 lane builds ({ <platform>: { signature, url } }) into a Tauri v2 static `latest.json`
|
|
7
|
+
// and uploads it, with the bundles and their `.sig` files, as assets of the GitHub release `v<version>`, so
|
|
8
|
+
// `https://github.com/<owner>/<repo>/releases/latest/download/latest.json` resolves. It adds no key and no trust root.
|
|
9
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { PublicationError, digestBytes, readVerifiedArtifact, verifyArtifactFile } from "./publication-artifacts.mjs";
|
|
12
|
+
import { inspectRemoteAsset, runCommand } from "./github-release.mjs";
|
|
13
|
+
|
|
14
|
+
const REPO_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
|
15
|
+
const SEMVER_RE = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/;
|
|
16
|
+
// GitHub rewrites other characters in an asset name, which would make the manifest url differ from the asset.
|
|
17
|
+
const ASSET_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,199}$/;
|
|
18
|
+
// Tauri v2 updater platform keys: <os>-<arch>, optionally <os>-<arch>-<installer>.
|
|
19
|
+
const PLATFORM_RE = /^(?:windows|darwin|linux)-(?:x86_64|aarch64|i686|armv7)(?:-(?:nsis|msi|app|appimage|deb|rpm))?$/;
|
|
20
|
+
const KEY_ID_RE = /^[0-9A-F]{16}$/;
|
|
21
|
+
|
|
22
|
+
export const LATEST_JSON_NAME = "latest.json";
|
|
23
|
+
|
|
24
|
+
export function assertGitHubRepository(repo) {
|
|
25
|
+
if (!REPO_RE.test(repo ?? "")) throw new PublicationError("PUBLICATION_GITHUB_REPOSITORY_INVALID", "release config distribution.repository must be owner/repo for github-releases");
|
|
26
|
+
return repo;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function assertUpdaterPlatform(platform) {
|
|
30
|
+
if (!PLATFORM_RE.test(platform ?? "")) throw new PublicationError("PUBLICATION_UPDATER_PLATFORM_INVALID", `updater platform must be a Tauri target such as windows-x86_64 or darwin-aarch64, got: ${platform}`);
|
|
31
|
+
return platform;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function assertAssetName(name) {
|
|
35
|
+
if (!ASSET_NAME_RE.test(name ?? "")) throw new PublicationError("PUBLICATION_ASSET_NAME_INVALID", `GitHub release asset names must match ${ASSET_NAME_RE}, got: ${name}`);
|
|
36
|
+
return name;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function assertUpdaterVersion(version) {
|
|
40
|
+
if (!SEMVER_RE.test(version ?? "")) throw new PublicationError("PUBLICATION_VERSION_INVALID", `updater version must be SemVer, got: ${version}`);
|
|
41
|
+
return version;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const githubTag = (version) => `v${assertUpdaterVersion(version)}`;
|
|
45
|
+
|
|
46
|
+
export function githubAssetUrl(repo, tag, name) {
|
|
47
|
+
return `https://github.com/${assertGitHubRepository(repo)}/releases/download/${tag}/${assertAssetName(name)}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The stable URL an app configures as its updater endpoint (resolves to the newest non-draft, non-prerelease release). */
|
|
51
|
+
export function githubLatestJsonUrl(repo) {
|
|
52
|
+
return `https://github.com/${assertGitHubRepository(repo)}/releases/latest/download/${LATEST_JSON_NAME}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Key id (16 upper-case hex digits, as minisign prints it) of the key that made a Tauri `.sig` file's content.
|
|
57
|
+
* The `.sig` text is base64 of a minisign signature file; its second line is base64 of
|
|
58
|
+
* [2-byte algorithm][8-byte key id, little endian][64-byte signature].
|
|
59
|
+
*/
|
|
60
|
+
export function updaterSignatureKeyId(signatureText) {
|
|
61
|
+
try {
|
|
62
|
+
const lines = Buffer.from(String(signatureText).trim(), "base64").toString("utf8").split(/\r?\n/);
|
|
63
|
+
const raw = Buffer.from(lines[1] ?? "", "base64");
|
|
64
|
+
if (raw.length !== 74 || raw[0] !== 0x45 || (raw[1] !== 0x64 && raw[1] !== 0x44)) return null;
|
|
65
|
+
return Buffer.from(raw.subarray(2, 10)).reverse().toString("hex").toUpperCase();
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Refuses a signature made by any key other than the configured suite key id (distribution.updaterKeyId). */
|
|
72
|
+
export function assertUpdaterKeyId(signatureText, expectedKeyId, label = "updater signature") {
|
|
73
|
+
if (expectedKeyId === undefined) return;
|
|
74
|
+
if (!KEY_ID_RE.test(expectedKeyId)) throw new PublicationError("PUBLICATION_SIGNATURE_KEY_MISMATCH", "distribution.updaterKeyId must be 16 upper-case hex digits");
|
|
75
|
+
const actual = updaterSignatureKeyId(signatureText);
|
|
76
|
+
if (actual !== expectedKeyId) throw new PublicationError("PUBLICATION_SIGNATURE_KEY_MISMATCH", `${label} was not made by updater key ${expectedKeyId} (found ${actual ?? "unreadable"})`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Tauri v2 static manifest. `platforms` is { <target>: { signature, url } }, exactly what the r2 lane builds. */
|
|
80
|
+
export function buildLatestJson({ version, notes, pubDate, platforms }) {
|
|
81
|
+
assertUpdaterVersion(version);
|
|
82
|
+
const entries = Object.entries(platforms ?? {});
|
|
83
|
+
if (!entries.length) throw new PublicationError("PUBLICATION_ROUTE_INVALID", "latest.json needs at least one platform");
|
|
84
|
+
const sorted = {};
|
|
85
|
+
for (const [name, entry] of entries.sort(([a], [b]) => a.localeCompare(b))) {
|
|
86
|
+
assertUpdaterPlatform(name);
|
|
87
|
+
if (!entry?.signature || !/^https:\/\//.test(entry.url ?? "")) throw new PublicationError("PUBLICATION_SIGNATURE_MISSING", `latest.json platform ${name} needs a signature and an https url`);
|
|
88
|
+
sorted[name] = { signature: entry.signature, url: entry.url };
|
|
89
|
+
}
|
|
90
|
+
return { version, notes: notes ?? "", pub_date: pubDate, platforms: sorted };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The win and mac runs publish separately to one latest.json: keep the other platform's entries for the same version. */
|
|
94
|
+
export function mergeLatestJson(existing, next) {
|
|
95
|
+
if (!existing) return next;
|
|
96
|
+
if (existing.version !== next.version) throw new PublicationError("PUBLICATION_MANIFEST_CONFLICT", `remote latest.json is for ${existing.version}, not ${next.version}; refusing to overwrite it`);
|
|
97
|
+
return buildLatestJson({ version: next.version, notes: next.notes, pubDate: next.pub_date, platforms: { ...(existing.platforms ?? {}), ...next.platforms } });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Uploads the sealed updater bundles, their signatures and latest.json to release `tag` of `repo`, creating the
|
|
102
|
+
* (public, non-draft) release when it does not exist. Every sealed asset is byte-verified after upload; latest.json
|
|
103
|
+
* is read back and compared. `assets` is [{ file, evidence }] with the sealed artifact as evidence.
|
|
104
|
+
*/
|
|
105
|
+
export function publishGitHubUpdaterRelease({ repo, tag, title, notes, assets, latest, stateDir, dryRun = false, run = runCommand }) {
|
|
106
|
+
assertGitHubRepository(repo);
|
|
107
|
+
const byName = new Map();
|
|
108
|
+
for (const asset of assets) {
|
|
109
|
+
const name = assertAssetName(path.basename(asset.file));
|
|
110
|
+
if (byName.has(name) && byName.get(name).file !== asset.file) throw new PublicationError("PUBLICATION_ASSET_NAME_INVALID", `duplicate updater asset name: ${name}`);
|
|
111
|
+
byName.set(name, asset);
|
|
112
|
+
}
|
|
113
|
+
// A dry run makes no network call: it only reports the assets it would publish.
|
|
114
|
+
if (dryRun) return { status: "would-publish", tag, assets: [...byName.keys(), LATEST_JSON_NAME] };
|
|
115
|
+
const existing = run("gh", ["release", "view", tag, "--repo", repo, "--json", "tagName"], { allowFailure: true });
|
|
116
|
+
|
|
117
|
+
mkdirSync(stateDir, { recursive: true });
|
|
118
|
+
if (!existing.ok) {
|
|
119
|
+
const notesFile = path.join(stateDir, "release-notes.md");
|
|
120
|
+
writeFileSync(notesFile, `${notes}\n`);
|
|
121
|
+
try { run("gh", ["release", "create", tag, "--repo", repo, "--title", title, "--notes-file", notesFile]); }
|
|
122
|
+
finally { rmSync(notesFile, { force: true }); }
|
|
123
|
+
}
|
|
124
|
+
const missing = [...byName.values()].filter((asset) => inspectRemoteAsset(tag, repo, asset.file, run, asset.evidence) === "missing");
|
|
125
|
+
if (missing.length) run("gh", ["release", "upload", tag, ...missing.map((asset) => asset.file), "--repo", repo]);
|
|
126
|
+
for (const asset of byName.values()) {
|
|
127
|
+
if (inspectRemoteAsset(tag, repo, asset.file, run, asset.evidence) !== "match") throw new PublicationError("PUBLICATION_REMOTE_VERIFY_FAILED", `GitHub release asset missing after upload: ${repo}/${tag}/${path.basename(asset.file)}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Merge with the other platform's entries, then upload latest.json last so it never points at a missing asset.
|
|
131
|
+
const downloadDir = path.join(stateDir, "remote-latest");
|
|
132
|
+
rmSync(downloadDir, { recursive: true, force: true });
|
|
133
|
+
mkdirSync(downloadDir, { recursive: true });
|
|
134
|
+
let remote = null;
|
|
135
|
+
const fetched = run("gh", ["release", "download", tag, "--repo", repo, "--pattern", LATEST_JSON_NAME, "--dir", downloadDir], { allowFailure: true });
|
|
136
|
+
if (fetched.ok) remote = JSON.parse(readFileSync(path.join(downloadDir, LATEST_JSON_NAME), "utf8"));
|
|
137
|
+
else if (!/no assets match|not found/i.test(`${fetched.output ?? ""}\n${fetched.error ?? ""}`)) throw new PublicationError("PUBLICATION_REMOTE_VERIFY_FAILED", `cannot read remote latest.json: ${fetched.error || fetched.output}`);
|
|
138
|
+
rmSync(downloadDir, { recursive: true, force: true });
|
|
139
|
+
const merged = mergeLatestJson(remote, latest);
|
|
140
|
+
const bytes = Buffer.from(`${JSON.stringify(merged, null, 2)}\n`);
|
|
141
|
+
const manifestFile = path.join(stateDir, LATEST_JSON_NAME);
|
|
142
|
+
writeFileSync(manifestFile, bytes);
|
|
143
|
+
run("gh", ["release", "upload", tag, manifestFile, "--repo", repo, "--clobber"]);
|
|
144
|
+
const verifyDir = path.join(stateDir, "verify-latest");
|
|
145
|
+
rmSync(verifyDir, { recursive: true, force: true });
|
|
146
|
+
mkdirSync(verifyDir, { recursive: true });
|
|
147
|
+
try {
|
|
148
|
+
run("gh", ["release", "download", tag, "--repo", repo, "--pattern", LATEST_JSON_NAME, "--dir", verifyDir]);
|
|
149
|
+
verifyArtifactFile(path.join(verifyDir, LATEST_JSON_NAME), readVerifiedArtifact(manifestFile, { sha256: digestBytes(bytes) }));
|
|
150
|
+
} finally {
|
|
151
|
+
rmSync(verifyDir, { recursive: true, force: true });
|
|
152
|
+
}
|
|
153
|
+
return { status: existing.ok ? "updated" : "created", tag, assets: [...byName.keys(), LATEST_JSON_NAME], latestJsonUrl: githubLatestJsonUrl(repo), manifest: merged };
|
|
154
|
+
}
|
package/hardeningscan.mjs
CHANGED
|
@@ -70,7 +70,7 @@ const contentRules = [
|
|
|
70
70
|
["gpl-flag", /--enable-gpl|--enable-nonfree/gi, "GPL/nonfree ffmpeg flag leaked"],
|
|
71
71
|
["stale-model", /tdt_w15_p6/gi, "stale TDT p6 string leaked"],
|
|
72
72
|
// Not allowable through hardening evidence: a shipped artifact never contains the QA control server.
|
|
73
|
-
["qa-control-marker", new RegExp(QA_CONTROL_MARKER, "g"), "QA control server shipped (rightkit-qa-driver qa-control)"],
|
|
73
|
+
["qa-control-marker", new RegExp(QA_CONTROL_MARKER, "g"), "QA control server shipped (rightkit-qa control, or rightkit-qa-driver qa-control)"],
|
|
74
74
|
["experiment-model", /Canary-Qwen|Nemotron|dqlstm|bias-experiments/gi, "experiment/model-history marker leaked"],
|
|
75
75
|
];
|
|
76
76
|
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import nodeFs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
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
|
+
* 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. Rollback is a persistent `<name>.previous` beside it (rename it back). Nothing ever runs
|
|
8
|
+
* from `.previous` or from the staging copy.
|
|
9
|
+
*/
|
|
10
|
+
export const PREVIOUS_SUFFIX = ".previous";
|
|
11
|
+
export const STAGING_SUFFIX = ".staging";
|
|
12
|
+
|
|
13
|
+
export const previousPathOf = (destination) => `${destination}${PREVIOUS_SUFFIX}`;
|
|
14
|
+
/** Fixed (not per-pid) staging sibling. Nothing runs from it: it is fully written and verified, then renamed. */
|
|
15
|
+
export const stagingPathOf = (destination) => `${destination}${STAGING_SUFFIX}`;
|
|
16
|
+
|
|
17
|
+
const exists = (fs, file) => {
|
|
18
|
+
if (typeof fs.lstatSync !== "function") return fs.existsSync(file);
|
|
19
|
+
try { fs.lstatSync(file); return true; } catch (error) { if (error?.code === "ENOENT") return false; throw error; }
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Swap `staging` into `destination`: the old destination becomes `previous` (replacing any older `.previous`),
|
|
24
|
+
* then `verify(destination)` runs. Any failure removes the new copy and renames `previous` back, so the fixed
|
|
25
|
+
* path is never left empty. Renames only: no copy, no new path.
|
|
26
|
+
* @returns {{destination: string, previous: string|null}} previous is null when nothing was installed before.
|
|
27
|
+
*/
|
|
28
|
+
export function swapInPlace({ staging, destination, previous = previousPathOf(destination), verify = () => {}, fs = nodeFs }) {
|
|
29
|
+
if (previous === destination || staging === destination || staging === previous) throw new Error("swapInPlace needs three distinct paths");
|
|
30
|
+
let movedAside = false;
|
|
31
|
+
try {
|
|
32
|
+
if (exists(fs, destination)) {
|
|
33
|
+
if (exists(fs, previous)) fs.rmSync(previous, { recursive: true, force: true });
|
|
34
|
+
fs.renameSync(destination, previous);
|
|
35
|
+
movedAside = true;
|
|
36
|
+
}
|
|
37
|
+
fs.renameSync(staging, destination);
|
|
38
|
+
verify(destination);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
try { fs.rmSync(destination, { recursive: true, force: true }); } catch { /* keep the original error */ }
|
|
41
|
+
if (movedAside && exists(fs, previous)) fs.renameSync(previous, destination);
|
|
42
|
+
try { fs.rmSync(staging, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
return { destination, previous: movedAside ? previous : null };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Copy one file to its fixed path in place: temp sibling, then rename, keeping `<file>.previous`. Returns the path. */
|
|
49
|
+
export function replaceFileInPlace(from, destination, { fs = nodeFs } = {}) {
|
|
50
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
51
|
+
const staging = stagingPathOf(destination);
|
|
52
|
+
fs.rmSync(staging, { force: true });
|
|
53
|
+
fs.copyFileSync(from, staging);
|
|
54
|
+
swapInPlace({ staging, destination });
|
|
55
|
+
return destination;
|
|
56
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import nodeFs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { spawnSync } from "./background-process.mjs";
|
|
5
|
+
import { previousPathOf } from "./in-place-install.mjs";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `right-release install-app --installer <Setup.exe> --dest <install folder> --exe <App.exe>` on Windows (Orthic-Labs/rightkit#258).
|
|
9
|
+
*
|
|
10
|
+
* The same rule as the macOS install: the app is installed and updated IN PLACE at one fixed folder (`--dest`, the
|
|
11
|
+
* folder the NSIS installer already owns, for example `%LOCALAPPDATA%\Programs\Pulse`). Rollback is one
|
|
12
|
+
* `<dest>.previous` beside it (a copy of the folder as it was; replaced on each install; nothing runs from it), never a
|
|
13
|
+
* versioned, digest or per-run folder. The silent installer is pinned to the fixed folder with `/D=` so it cannot choose another one.
|
|
14
|
+
*
|
|
15
|
+
* Why a copy and not a rename: a running app holds its folder open on Windows, so a rename fails; the installer closes
|
|
16
|
+
* the app and updates the same path in place instead. A failed install copies `<dest>.previous` back over `<dest>`.
|
|
17
|
+
*/
|
|
18
|
+
export class InstallAppWindowsError extends Error {
|
|
19
|
+
constructor(code, message) { super(message); this.name = "InstallAppWindowsError"; this.code = code; }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function windowsInstallArguments(dest) {
|
|
23
|
+
// NSIS: /D must be the last argument, unquoted, and names the install folder.
|
|
24
|
+
return ["/S", `/D=${dest}`];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const sha256 = (fs, file) => createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
|
28
|
+
|
|
29
|
+
export function installAppWindows(opts, { platform = process.platform, fs = nodeFs, run = spawnSync, event = () => {} } = {}) {
|
|
30
|
+
if (platform !== "win32") throw new InstallAppWindowsError("unsupported", `the Windows installer mode runs on win32, not ${platform}`);
|
|
31
|
+
const { installer, dest, exe } = opts;
|
|
32
|
+
if (!installer || !path.win32.isAbsolute(installer)) throw new InstallAppWindowsError("usage", "--installer must be an absolute path to the NSIS setup executable");
|
|
33
|
+
if (!dest || !path.win32.isAbsolute(dest) || path.win32.parse(dest).root === dest) throw new InstallAppWindowsError("usage", "--dest must be the fixed absolute install folder, not a drive root");
|
|
34
|
+
if (!exe || exe !== path.win32.basename(exe)) throw new InstallAppWindowsError("usage", "--exe must be the bare executable name inside --dest");
|
|
35
|
+
if (!fs.existsSync(installer)) throw new InstallAppWindowsError("installer-missing", `installer not found: ${installer}`);
|
|
36
|
+
const previous = previousPathOf(dest);
|
|
37
|
+
const installed = path.win32.join(dest, exe);
|
|
38
|
+
const hadInstall = fs.existsSync(dest);
|
|
39
|
+
event("start", { installer, dest, replacing: hadInstall });
|
|
40
|
+
if (hadInstall) {
|
|
41
|
+
// One rollback copy, replaced on every install.
|
|
42
|
+
fs.rmSync(previous, { recursive: true, force: true });
|
|
43
|
+
fs.cpSync(dest, previous, { recursive: true });
|
|
44
|
+
event("previous-kept", { previous });
|
|
45
|
+
}
|
|
46
|
+
const restore = () => {
|
|
47
|
+
if (!hadInstall || !fs.existsSync(previous)) return false;
|
|
48
|
+
fs.cpSync(previous, dest, { recursive: true, force: true });
|
|
49
|
+
return true;
|
|
50
|
+
};
|
|
51
|
+
const result = run(installer, windowsInstallArguments(dest), { stdio: "inherit", windowsHide: true, shell: false });
|
|
52
|
+
if (result.status !== 0) {
|
|
53
|
+
const restored = restore();
|
|
54
|
+
throw new InstallAppWindowsError("installer-failed", `installer exited ${result.status ?? "abnormally"}${result.error ? `: ${result.error.message}` : ""}; ${restored ? `restored ${dest} from ${previous}` : "nothing was installed before"}`);
|
|
55
|
+
}
|
|
56
|
+
// The installer must have updated the fixed folder: the executable is at the same path as before, not in a new folder.
|
|
57
|
+
if (!fs.existsSync(installed) || fs.statSync(installed).size === 0) {
|
|
58
|
+
const restored = restore();
|
|
59
|
+
throw new InstallAppWindowsError("path-changed", `the installer did not place ${exe} at the fixed path ${installed}; ${restored ? `restored from ${previous}` : "nothing was installed before"}`);
|
|
60
|
+
}
|
|
61
|
+
event("replaced", { installed, previous: hadInstall ? previous : null, sha256: sha256(fs, installed) });
|
|
62
|
+
return { ok: true, installed, previous: hadInstall ? previous : null };
|
|
63
|
+
}
|
package/install-app.mjs
CHANGED
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
// Never uses a shell, osascript, AppleEvents, or foreground activation.
|
|
5
5
|
import { execFile as nodeExecFile, execFileSync as nodeExecFileSync } from "./background-process.mjs";
|
|
6
6
|
import { detachWithRetry } from "./hdiutil-detach.mjs";
|
|
7
|
+
import { previousPathOf } from "./in-place-install.mjs";
|
|
8
|
+
import { assertShippedSwapHelperSigned } from "./swap-helper-bundle.mjs";
|
|
9
|
+
import { installAppWindows, InstallAppWindowsError } from "./install-app-windows.mjs";
|
|
7
10
|
import fs from "node:fs";
|
|
8
11
|
import os from "node:os";
|
|
9
12
|
import path from "node:path";
|
|
@@ -19,11 +22,11 @@ export class InstallAppError extends Error {
|
|
|
19
22
|
}
|
|
20
23
|
}
|
|
21
24
|
|
|
22
|
-
const VALUE_FLAGS = new Set(["--dmg", "--zip", "--app", "--app-name", "--bundle-id", "--dest"]);
|
|
25
|
+
const VALUE_FLAGS = new Set(["--dmg", "--zip", "--app", "--app-name", "--bundle-id", "--dest", "--installer", "--exe"]);
|
|
23
26
|
const BOOL_FLAGS = new Set(["--relaunch", "--no-relaunch", "--json", "--dev", "--recover"]);
|
|
24
27
|
|
|
25
28
|
export function parseArgs(argv) {
|
|
26
|
-
const opts = { dmg: null, zip: null, app: null, dev: false, appName: null, bundleId: null, dest: "/Applications", relaunch: null, json: false, recover: false };
|
|
29
|
+
const opts = { dmg: null, zip: null, app: null, installer: null, exe: null, dev: false, appName: null, bundleId: null, dest: "/Applications", relaunch: null, json: false, recover: false };
|
|
27
30
|
for (let i = 0; i < argv.length; i += 1) {
|
|
28
31
|
const flag = argv[i];
|
|
29
32
|
if (VALUE_FLAGS.has(flag)) {
|
|
@@ -35,6 +38,8 @@ export function parseArgs(argv) {
|
|
|
35
38
|
else if (flag === "--app") opts.app = value;
|
|
36
39
|
else if (flag === "--app-name") opts.appName = value;
|
|
37
40
|
else if (flag === "--bundle-id") opts.bundleId = value;
|
|
41
|
+
else if (flag === "--installer") opts.installer = value;
|
|
42
|
+
else if (flag === "--exe") opts.exe = value;
|
|
38
43
|
else opts.dest = value;
|
|
39
44
|
} else if (BOOL_FLAGS.has(flag)) {
|
|
40
45
|
if (flag === "--json") opts.json = true;
|
|
@@ -45,6 +50,12 @@ export function parseArgs(argv) {
|
|
|
45
50
|
throw new InstallAppError("usage", `unknown argument: ${flag}`);
|
|
46
51
|
}
|
|
47
52
|
}
|
|
53
|
+
if (opts.installer !== null) {
|
|
54
|
+
// Windows: run the sealed NSIS installer in place at one fixed folder, keeping <dest>.previous.
|
|
55
|
+
if (opts.dmg !== null || opts.zip !== null || opts.app !== null || opts.recover || opts.dev) throw new InstallAppError("usage", "--installer cannot be combined with --dmg, --zip, --app, --dev or --recover");
|
|
56
|
+
if (!argv.includes("--dest") || opts.exe === null) throw new InstallAppError("usage", "--installer requires --dest <fixed install folder> and --exe <App.exe>");
|
|
57
|
+
return opts;
|
|
58
|
+
}
|
|
48
59
|
if (argv.includes("--relaunch") && argv.includes("--no-relaunch")) throw new InstallAppError("usage", "--relaunch and --no-relaunch are mutually exclusive");
|
|
49
60
|
const inputs = [opts.dmg, opts.zip, opts.app].filter((v) => v !== null).length;
|
|
50
61
|
if (opts.recover) {
|
|
@@ -220,10 +231,15 @@ function coded(code, message) {
|
|
|
220
231
|
|
|
221
232
|
// Sync on purpose: signal-handler rollback must be able to swap back. Throws an error whose `code`
|
|
222
233
|
// is in SWAP_FALLBACK_CODES when no atomic swap is possible here (no helper, or exit 3 / ENOTSUP / EINVAL).
|
|
223
|
-
|
|
234
|
+
// A helper that exists must be code-signed (#258): an unsigned one is a typed SWAP_HELPER_UNSIGNED error naming the fix,
|
|
235
|
+
// never a silent downgrade. No helper at all keeps the documented journaled two-rename fallback, which is logged
|
|
236
|
+
// (`swap-fallback`, then `replaced {swap}`).
|
|
237
|
+
export function createSwapPaths({ env = process.env, resolve = resolveSwapHelper, execFileSync = nodeExecFileSync, assertSigned = assertShippedSwapHelperSigned } = {}) {
|
|
238
|
+
const checked = new Set();
|
|
224
239
|
return (a, b) => {
|
|
225
240
|
const helper = resolve({ env });
|
|
226
241
|
if (!helper) throw coded("ENOHELPER", `${SWAP_HELPER_NAME} helper not found`);
|
|
242
|
+
if (!checked.has(helper)) { assertSigned({ file: helper, execFileSync }); checked.add(helper); }
|
|
227
243
|
try { execFileSync(helper, [a, b], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); }
|
|
228
244
|
catch (error) {
|
|
229
245
|
const text = `${error?.stderr || ""}`.trim();
|
|
@@ -251,12 +267,15 @@ const defaultIsAlive = (pid) => {
|
|
|
251
267
|
try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; }
|
|
252
268
|
};
|
|
253
269
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
return
|
|
270
|
+
// Keep the bundle it replaced as `<installed>.previous` beside the fixed path (replacing any older one), by rename.
|
|
271
|
+
// Rollback is renaming it back; nothing ever runs from it. It is never sent to the Trash: a Trash copy is a second
|
|
272
|
+
// path macOS can resolve for the same bundle id.
|
|
273
|
+
async function keepPreviousBundle(run, fsImpl, bundle, installed) {
|
|
274
|
+
const previous = previousPathOf(installed);
|
|
275
|
+
if (bundle === previous) return previous;
|
|
276
|
+
fsImpl.rmSync(previous, { recursive: true, force: true });
|
|
277
|
+
await moveAside(run, fsImpl, bundle, previous);
|
|
278
|
+
return previous;
|
|
260
279
|
}
|
|
261
280
|
|
|
262
281
|
// Deterministic roll-forward/back of any interrupted install journal in `dest`. Idempotent.
|
|
@@ -292,8 +311,8 @@ export async function recoverInstalls({ dest, appName = null, run, fsImpl, home,
|
|
|
292
311
|
let ok = j.phase === "verified";
|
|
293
312
|
if (!ok) { try { await verifyBundle(run, j.installed, { assess: !j.dev, staple: false }); ok = true; } catch { ok = false; } }
|
|
294
313
|
if (ok) {
|
|
295
|
-
try { await
|
|
296
|
-
catch { action = "rolled-forward-
|
|
314
|
+
try { await keepPreviousBundle(run, fsImpl, j.staged, j.installed); action = "rolled-forward"; }
|
|
315
|
+
catch { action = "rolled-forward-previous-pending"; keepJournal = true; keepStaged = true; }
|
|
297
316
|
} else {
|
|
298
317
|
swapBack(swapPaths, fsImpl, j.staged, j.installed); // staged now holds the bad new bundle
|
|
299
318
|
action = "rolled-back";
|
|
@@ -306,8 +325,8 @@ export async function recoverInstalls({ dest, appName = null, run, fsImpl, home,
|
|
|
306
325
|
let ok = j.phase === "verified";
|
|
307
326
|
if (!ok) { try { await verifyBundle(run, j.installed, { assess: !j.dev, staple: false }); ok = true; } catch { ok = false; } }
|
|
308
327
|
if (ok) {
|
|
309
|
-
try { await
|
|
310
|
-
catch { action = "rolled-forward-
|
|
328
|
+
try { await keepPreviousBundle(run, fsImpl, j.backup, j.installed); action = "rolled-forward"; }
|
|
329
|
+
catch { action = "rolled-forward-previous-pending"; keepJournal = true; }
|
|
311
330
|
} else {
|
|
312
331
|
rm(j.installed);
|
|
313
332
|
fsImpl.renameSync(j.backup, j.installed);
|
|
@@ -376,7 +395,7 @@ export async function installApp(opts, deps = {}) {
|
|
|
376
395
|
if (s.mode === "atomic") {
|
|
377
396
|
// installed was never absent. Before the exchange only the staged copy needs removing; after it,
|
|
378
397
|
// exchange back (installed = old again, staged = the new copy) and drop the new copy. Once final,
|
|
379
|
-
// staged holds the verified-old bundle awaiting
|
|
398
|
+
// staged holds the verified-old bundle awaiting retirement to .previous, so it must not be touched.
|
|
380
399
|
if (!s.final) {
|
|
381
400
|
if (s.swapped) swapBack(swapPaths, fsImpl, s.staged, s.installed);
|
|
382
401
|
removeQuiet(s.staged);
|
|
@@ -486,8 +505,9 @@ export async function installApp(opts, deps = {}) {
|
|
|
486
505
|
// Stage a verified copy on the destination volume so the swap is rename(2) only.
|
|
487
506
|
failedStage = "stage";
|
|
488
507
|
const base = appName.replace(/\.app$/, "");
|
|
508
|
+
// Staging is never run from: it is verified, then renamed into place. The rollback copy is the fixed `<installed>.previous`.
|
|
489
509
|
const staged = path.join(opts.dest, `.${base}.rightkit-install-${pid}.app`);
|
|
490
|
-
const backup = hadInstalled ?
|
|
510
|
+
const backup = hadInstalled ? previousPathOf(installed) : null;
|
|
491
511
|
const journal = journalPathFor(opts.dest, appName);
|
|
492
512
|
const journalData = { schema: 1, appName, pid, phase: "staging", dev, installed, backup, staged, mount: mountPoint, mountRoot };
|
|
493
513
|
removeQuiet(staged);
|
|
@@ -512,7 +532,7 @@ export async function installApp(opts, deps = {}) {
|
|
|
512
532
|
|
|
513
533
|
// Swap. Preferred: one atomic exchange (renamex_np RENAME_SWAP), so `installed` is never absent even
|
|
514
534
|
// under SIGKILL; the old bundle then sits at the staged path. Fallback (no helper, or a volume without
|
|
515
|
-
// RENAME_SWAP): old -> backup, staged -> installed, journaled. The old bundle is never
|
|
535
|
+
// RENAME_SWAP): old -> backup, staged -> installed, journaled. The old bundle is never retired before
|
|
516
536
|
// the new one is verified.
|
|
517
537
|
failedStage = "replace";
|
|
518
538
|
let jd = journalData;
|
|
@@ -535,6 +555,7 @@ export async function installApp(opts, deps = {}) {
|
|
|
535
555
|
swap.mode = "two-rename";
|
|
536
556
|
jd = { ...journalData, swap: "two-rename" };
|
|
537
557
|
writeJournal(fsImpl, journal, { ...jd, phase: "swapping" });
|
|
558
|
+
removeQuiet(backup); // an older .previous; the one kept copy is the bundle moving aside now
|
|
538
559
|
fsImpl.renameSync(installed, backup); swap.backedUp = true;
|
|
539
560
|
fsImpl.renameSync(staged, installed);
|
|
540
561
|
swap.stagedIn = true;
|
|
@@ -555,16 +576,18 @@ export async function installApp(opts, deps = {}) {
|
|
|
555
576
|
writeJournal(fsImpl, journal, { ...jd, phase: "verified", mount: null, mountRoot: null });
|
|
556
577
|
swap.final = true;
|
|
557
578
|
|
|
558
|
-
// Only now
|
|
559
|
-
let
|
|
579
|
+
// Only now is the old bundle retired, to <installed>.previous (never the Trash).
|
|
580
|
+
let kept = null;
|
|
581
|
+
let keepFailed = false;
|
|
560
582
|
const oldBundle = swapMode === "atomic" ? staged : backup; // atomic: the exchange left the old bundle at the staged path
|
|
561
583
|
if (hadInstalled && oldBundle) {
|
|
562
|
-
try {
|
|
563
|
-
catch (error) { event("
|
|
584
|
+
try { kept = await keepPreviousBundle(run, fsImpl, oldBundle, installed); }
|
|
585
|
+
catch (error) { event("previous-keep-failed", { backup: oldBundle, reason: errText(error) }); kept = oldBundle; }
|
|
564
586
|
}
|
|
565
|
-
if (!oldBundle ||
|
|
587
|
+
if (!oldBundle || kept !== previousPathOf(installed)) { if (!oldBundle) removeQuiet(journal); }
|
|
588
|
+
else removeQuiet(journal);
|
|
566
589
|
swap = null;
|
|
567
|
-
event("replaced", { installed, previous:
|
|
590
|
+
event("replaced", { installed, previous: kept, swap: swapMode });
|
|
568
591
|
|
|
569
592
|
// Relaunch hidden and in the background only.
|
|
570
593
|
const relaunch = opts.relaunch === null ? wasRunning : opts.relaunch;
|
|
@@ -573,7 +596,7 @@ export async function installApp(opts, deps = {}) {
|
|
|
573
596
|
await run("/usr/bin/open", ["-g", "-j", "-b", bundleId]);
|
|
574
597
|
event("relaunched", { bundleId });
|
|
575
598
|
}
|
|
576
|
-
const result = { ok: true, app: appName, installed, bundleId, teamId: newTeam, previous:
|
|
599
|
+
const result = { ok: true, app: appName, installed, bundleId, teamId: newTeam, previous: kept, swap: swapMode, relaunched: relaunch, ...(dev ? { dev: true } : {}) };
|
|
577
600
|
event("done", result);
|
|
578
601
|
return result;
|
|
579
602
|
} catch (error) {
|
|
@@ -685,6 +708,7 @@ export async function main(argv = process.argv.slice(2), io = {}) {
|
|
|
685
708
|
stderr(`right-release install-app: ${error.message}`);
|
|
686
709
|
stderr("usage: right-release install-app (--dmg <path.dmg> | --dev (--zip <path.zip> | --app <path.app> | --dmg <path.dmg>)) [--app-name Pulse.app] [--bundle-id dev.orthic.pulse] [--dest /Applications] [--relaunch|--no-relaunch] [--json]");
|
|
687
710
|
stderr(" right-release install-app --recover [--dest /Applications] [--app-name Pulse.app] [--json]");
|
|
711
|
+
stderr(" right-release install-app --installer <Setup.exe> --dest <fixed install folder> --exe <App.exe> [--json] (Windows: in place, keeps <dest>.previous)");
|
|
688
712
|
stderr(" --dev installs a Developer ID-signed, NOT notarized build (skips spctl/stapler, clears quarantine on the new bundle).");
|
|
689
713
|
return 2;
|
|
690
714
|
}
|
|
@@ -696,10 +720,16 @@ export async function main(argv = process.argv.slice(2), io = {}) {
|
|
|
696
720
|
else stdout(`install-app: ${event}${Object.keys(rest).length ? ` ${JSON.stringify(rest)}` : ""}`);
|
|
697
721
|
};
|
|
698
722
|
try {
|
|
723
|
+
if (opts.installer !== null) {
|
|
724
|
+
installAppWindows(opts, { ...io.deps, event: (name, data = {}) => emit({ event: name, ...data }) });
|
|
725
|
+
emit({ event: "done", dest: opts.dest });
|
|
726
|
+
return 0;
|
|
727
|
+
}
|
|
699
728
|
if (opts.recover) await recoverApp(opts, { ...io.deps, emit });
|
|
700
729
|
else await installApp(opts, { ...io.deps, emit });
|
|
701
730
|
return 0;
|
|
702
731
|
} catch (error) {
|
|
732
|
+
if (error instanceof InstallAppWindowsError) { stderr(`install-app: ${error.code}: ${error.message}`); return error.code === "unsupported" ? 3 : 1; }
|
|
703
733
|
return error.code === "unsupported" ? 3 : 1;
|
|
704
734
|
}
|
|
705
735
|
}
|
package/local-publish.mjs
CHANGED
|
@@ -17,7 +17,8 @@ import { GATE_SCHEMA, defaultEvidencePath, enforcePublicationGate, publishGatedT
|
|
|
17
17
|
import { runCargoPublish } from "./publish-cargo.mjs";
|
|
18
18
|
import { cargoIndexUrl } from "./cargo-registry-readback.mjs";
|
|
19
19
|
import { readCargoPackage } from "./crate-graph.mjs";
|
|
20
|
-
import { recordPublished } from "./rightkit-sync.mjs";
|
|
20
|
+
import { recordPublished, stageSourceVersions } from "./rightkit-sync.mjs";
|
|
21
|
+
import { prepareSwapHelper, SWAP_HELPER_CARGO_ARGS, SWAP_HELPER_PACKAGE } from "./swap-helper-bundle.mjs";
|
|
21
22
|
|
|
22
23
|
export const LOCAL_OPERATOR = "agent (local-build grant)";
|
|
23
24
|
export const LOCAL_HOST_POLICY_REASON = "Adrian 2026-10-14 local-build grant (.rightkit-local-build.json); Windows covered by the next CI run";
|
|
@@ -194,7 +195,16 @@ export async function runLocalPublish(kind, argv, deps = {}) {
|
|
|
194
195
|
const cleanup = () => fs.rmSync(scratch, { recursive: true, force: true });
|
|
195
196
|
try {
|
|
196
197
|
let artifactFile; const packCommands = [];
|
|
198
|
+
if (kind === "npm" && unit === SWAP_HELPER_PACKAGE) {
|
|
199
|
+
// #258: the package ships a Developer-ID-signed bin/darwin/rightkit-swap; build, sign and verify it before packing (fails closed).
|
|
200
|
+
try { (deps.prepareSwapHelper ?? prepareSwapHelper)({ workspaceRoot, packageDir: unitPath, platform, run, env, log }); }
|
|
201
|
+
catch (error) { refuse(`${error.code ? `[${error.code}] ` : ""}${error.message}`); }
|
|
202
|
+
packCommands.push(`rightkit cargo ${SWAP_HELPER_CARGO_ARGS.join(" ")} && codesign --verify --strict bin/darwin/rightkit-swap`);
|
|
203
|
+
}
|
|
197
204
|
if (kind === "npm") {
|
|
205
|
+
// The feed a tarball ships must list the version it ships with (#319): stage it before packing.
|
|
206
|
+
const feedFile = path.join(workspaceRoot, "packages", "release", "rightkit-versions.json");
|
|
207
|
+
if (fs.existsSync(feedFile)) (deps.stageSourceVersions ?? stageSourceVersions)([{ registry: "npm", name: unit, version }], { file: feedFile, root: workspaceRoot, log });
|
|
198
208
|
const args = ["--dir", dir, "pack", "--pack-destination", scratch];
|
|
199
209
|
packCommands.push(`pnpm ${args.join(" ")}`);
|
|
200
210
|
const packed = run("pnpm", args, { cwd: workspaceRoot, env });
|
package/mac-signing-matrix.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Per-file macOS signing matrix for `sign.prePackageFiles[]` object entries and `sign.nestedCode`.
|
|
2
2
|
//
|
|
3
3
|
// sign: {
|
|
4
|
-
// prePackageFiles: ["dist/tool", { file: "dist/App.app", entitlements: "release/app.entitlements.plist" }
|
|
5
|
-
//
|
|
4
|
+
// prePackageFiles: ["dist/tool", { file: "dist/App.app", entitlements: "release/app.entitlements.plist" },
|
|
5
|
+
// { file: "dist/legion-hook", identifier: "com.example.legion-hook" }],
|
|
6
|
+
// nestedCode: { "Contents/Helpers/Helper": { options: ["library"], identifier: "com.example.helper" },
|
|
6
7
|
// "Contents/PlugIns/Ext.appex": { entitlements: "release/ext.entitlements.plist" } },
|
|
7
8
|
// }
|
|
8
9
|
//
|
|
@@ -16,8 +17,12 @@ const invalid = (message) => Object.assign(new Error(message), { code: "MAC_SIGN
|
|
|
16
17
|
|
|
17
18
|
/** codesign `--options` names accepted in `options[]` (man codesign, OPTION FLAGS). `runtime` is always on. */
|
|
18
19
|
export const CODESIGN_OPTION_FLAGS = Object.freeze(["expires", "hard", "host", "kill", "library", "runtime"]);
|
|
19
|
-
const ENTRY_KEYS = new Set(["file", "entitlements", "hardenedRuntime", "options"]);
|
|
20
|
-
const SETTING_KEYS = new Set(["entitlements", "hardenedRuntime", "options"]);
|
|
20
|
+
const ENTRY_KEYS = new Set(["file", "entitlements", "hardenedRuntime", "options", "identifier"]);
|
|
21
|
+
const SETTING_KEYS = new Set(["entitlements", "hardenedRuntime", "options", "identifier"]);
|
|
22
|
+
// A stable per-file code identity: codesign `--identifier`. A loose tool otherwise gets an identifier derived from its file
|
|
23
|
+
// name or cdhash, and macOS keys privacy grants (TCC) to the code identity, so it must not change between builds.
|
|
24
|
+
// Same pattern as mac-sign-core.mjs (not imported: that module imports this one).
|
|
25
|
+
const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9.-]{0,254}$/;
|
|
21
26
|
|
|
22
27
|
const isPlainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
23
28
|
|
|
@@ -33,7 +38,11 @@ function looksLikePlist(file) {
|
|
|
33
38
|
}
|
|
34
39
|
|
|
35
40
|
function validateSettings(settings, label, { root, errors, isFile }) {
|
|
36
|
-
const out = { entitlements: undefined, hardenedRuntime: true, options: [] };
|
|
41
|
+
const out = { entitlements: undefined, hardenedRuntime: true, options: [], identifier: undefined };
|
|
42
|
+
if (settings.identifier !== undefined) {
|
|
43
|
+
if (typeof settings.identifier !== "string" || !IDENTIFIER_PATTERN.test(settings.identifier)) errors.push(`${label}.identifier must be 1-255 characters of letters, digits, '.' and '-', starting with a letter or digit`);
|
|
44
|
+
else out.identifier = settings.identifier;
|
|
45
|
+
}
|
|
37
46
|
if (settings.entitlements !== undefined) {
|
|
38
47
|
const value = settings.entitlements;
|
|
39
48
|
if (typeof value !== "string" || !value.trim()) errors.push(`${label}.entitlements must be a non-empty path string`);
|
|
@@ -79,8 +88,8 @@ function validateNestedKey(key) {
|
|
|
79
88
|
/**
|
|
80
89
|
* Validate and normalize `sign` for a macOS target. Collects every problem, then throws one
|
|
81
90
|
* MAC_SIGN_CONFIG_INVALID error. Returns { entries, nestedCode } where
|
|
82
|
-
* entries: [{ file, entitlements?: absolute, options: [] }] (file stays as declared)
|
|
83
|
-
* nestedCode: [{ key, entitlements?: absolute, options: [] }]
|
|
91
|
+
* entries: [{ file, identifier?, entitlements?: absolute, options: [] }] (file stays as declared)
|
|
92
|
+
* nestedCode: [{ key, identifier?, entitlements?: absolute, options: [] }]
|
|
84
93
|
* Injectable: isFile.
|
|
85
94
|
*/
|
|
86
95
|
export function validateMacSigningConfig(sign, { root, isFile = (file) => existsSync(file) && statSync(file).isFile() } = {}) {
|
|
@@ -106,7 +115,7 @@ export function validateMacSigningConfig(sign, { root, isFile = (file) => exists
|
|
|
106
115
|
if (seen.has(file)) errors.push(`${label}: duplicate signing entry ${file}`);
|
|
107
116
|
seen.add(file);
|
|
108
117
|
const normalized = validateSettings(settings, label, context);
|
|
109
|
-
entries.push({ file, ...(normalized.entitlements ? { entitlements: normalized.entitlements } : {}), options: normalized.options });
|
|
118
|
+
entries.push({ file, ...(normalized.identifier ? { identifier: normalized.identifier } : {}), ...(normalized.entitlements ? { entitlements: normalized.entitlements } : {}), options: normalized.options });
|
|
110
119
|
});
|
|
111
120
|
}
|
|
112
121
|
if (sign.nestedCode !== undefined) {
|
|
@@ -119,7 +128,7 @@ export function validateMacSigningConfig(sign, { root, isFile = (file) => exists
|
|
|
119
128
|
if (!isPlainObject(settings)) { errors.push(`${label} must be an object { entitlements?, hardenedRuntime?, options? }`); continue; }
|
|
120
129
|
for (const name of Object.keys(settings)) if (!SETTING_KEYS.has(name)) errors.push(`${label}: unknown key ${JSON.stringify(name)} (allowed: ${[...SETTING_KEYS].join(", ")})`);
|
|
121
130
|
const normalized = validateSettings(settings, label, context);
|
|
122
|
-
nestedCode.push({ key, ...(normalized.entitlements ? { entitlements: normalized.entitlements } : {}), options: normalized.options });
|
|
131
|
+
nestedCode.push({ key, ...(normalized.identifier ? { identifier: normalized.identifier } : {}), ...(normalized.entitlements ? { entitlements: normalized.entitlements } : {}), options: normalized.options });
|
|
123
132
|
}
|
|
124
133
|
if (nestedCode.length) {
|
|
125
134
|
const apps = entries.filter((entry) => /\.app\/?$/.test(entry.file));
|
|
@@ -132,7 +141,7 @@ export function validateMacSigningConfig(sign, { root, isFile = (file) => exists
|
|
|
132
141
|
}
|
|
133
142
|
|
|
134
143
|
/** True when an entry or nested item carries anything beyond the default (hardened runtime, no entitlements). */
|
|
135
|
-
export const hasNonDefaultSettings = (item) => Boolean(item.entitlements) || (item.options?.length ?? 0) > 0;
|
|
144
|
+
export const hasNonDefaultSettings = (item) => Boolean(item.entitlements) || Boolean(item.identifier) || (item.options?.length ?? 0) > 0;
|
|
136
145
|
|
|
137
146
|
const depth = (file) => file.split(path.sep).length;
|
|
138
147
|
|
|
@@ -162,7 +171,7 @@ export function planMacSigning({ entries, nestedCode = [], expand = expandMacSig
|
|
|
162
171
|
const real = realpath(file);
|
|
163
172
|
if (real !== appReal && !real.startsWith(appReal + path.sep)) throw invalid(`${label}: resolves outside the bundle`);
|
|
164
173
|
if (real === mainReal) throw invalid(`${label}: the main executable is signed with the bundle; set entitlements on its sign.prePackageFiles entry`);
|
|
165
|
-
byPath.set(file, { file, ...(item.entitlements ? { entitlements: item.entitlements } : {}), options: item.options });
|
|
174
|
+
byPath.set(file, { file, ...(item.identifier ? { identifier: item.identifier } : {}), ...(item.entitlements ? { entitlements: item.entitlements } : {}), options: item.options });
|
|
166
175
|
}
|
|
167
176
|
}
|
|
168
177
|
const nested = [...byPath.values()].sort((a, b) => depth(b.file) - depth(a.file) || a.file.localeCompare(b.file));
|
|
@@ -178,6 +187,9 @@ export function matrixReceiptProblems(evidenceFiles, entries, hashFile) {
|
|
|
178
187
|
if (!item) { problems.push(`missing receipt entry for ${entry.file}`); continue; }
|
|
179
188
|
const wantOptions = ["runtime", ...(entry.options ?? [])].join(",");
|
|
180
189
|
if ((item.codesignOptions ?? "runtime") !== wantOptions) problems.push(`${entry.file}: signed with options ${item.codesignOptions ?? "runtime"}, expected ${wantOptions}`);
|
|
190
|
+
if (entry.identifier) {
|
|
191
|
+
if (item.identifier !== entry.identifier) problems.push(`${entry.file}: signed with identifier ${item.identifier ?? "(none recorded)"}, expected ${entry.identifier}`);
|
|
192
|
+
} else if (item.identifier) problems.push(`${entry.file}: signed with identifier ${item.identifier} the plan did not declare`);
|
|
181
193
|
if (entry.entitlements) {
|
|
182
194
|
if (item.entitlements?.sha256 !== hashFile(entry.entitlements)) problems.push(`${entry.file}: receipt entitlements digest does not match ${entry.entitlements}`);
|
|
183
195
|
} else if (item.entitlements) problems.push(`${entry.file}: signed with entitlements the plan did not declare`);
|
package/managed-cargo.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { discoverBrokerSocket, managedControlCandidates, resolveManagedRoots } from './managed-roots.mjs';
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { homedir } from "node:os";
|
|
3
4
|
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
4
5
|
import path from "node:path";
|
|
5
6
|
import { spawnSync } from "./background-process.mjs";
|
|
@@ -12,6 +13,11 @@ export function managedRuntimeClient(env = process.env, platform = process.platf
|
|
|
12
13
|
const client = paths.join(root, "runtime-current", platform === 'win32' ? "rustright.exe" : "rustright");
|
|
13
14
|
if (exists(client)) return client;
|
|
14
15
|
}
|
|
16
|
+
// RustRight's own install (2026-10-17 cutover): the Mac agent-bin shims exec this client directly.
|
|
17
|
+
if (platform === 'darwin') {
|
|
18
|
+
const client = path.posix.join(env.HOME || homedir(), 'Library', 'Application Support', 'RustRight', 'runtime', 'rustright');
|
|
19
|
+
if (exists(client)) return client;
|
|
20
|
+
}
|
|
15
21
|
return undefined;
|
|
16
22
|
}
|
|
17
23
|
|