@rightkit/release 0.2.151 → 0.2.153
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/LICENSE-APACHE +201 -0
- package/LICENSE-MIT +21 -0
- package/README.md +117 -8
- package/build-release.mjs +7 -2
- package/cli/right-release.mjs +18 -5
- package/committed-source.mjs +19 -1
- package/crate-graph.mjs +121 -0
- package/development-signing.mjs +8 -0
- package/development.mjs +46 -13
- package/devtools-policy.json +5 -0
- package/devtools-policy.mjs +119 -0
- package/direct-bootstrap.mjs +9 -3
- package/dmg-build.mjs +19 -5
- package/dmg-layout.mjs +2 -2
- package/dmg-spec.mjs +97 -0
- package/execution-policy.mjs +17 -1
- package/github-release.mjs +25 -7
- package/github-upload-gate.mjs +84 -0
- package/hardeningscan.mjs +3 -0
- package/inventory-check.mjs +4 -4
- package/mac-bundle-signing.mjs +1 -1
- package/mac-sign-core.mjs +97 -0
- package/mac-signing-matrix.mjs +186 -0
- package/msix-appinstaller.mjs +4 -1
- package/msix-config.mjs +5 -1
- package/msix-repair-guard.mjs +24 -0
- package/msix-repair-guard.ps1 +356 -0
- package/ownership-catalog-config.json +6 -7
- package/ownership-command.mjs +69 -8
- package/ownership-evidence.mjs +1 -1
- package/ownership-key-authority.mjs +1 -1
- package/ownership-key-command.mjs +15 -3
- package/ownership-key-elevation.mjs +11 -2
- package/ownership-mandatory-set.json +420 -0
- package/ownership-mandatory-set.mjs +273 -0
- package/ownership.mjs +2 -2
- package/package.json +15 -3
- package/packaged-app-identity.mjs +88 -0
- package/preflight.mjs +25 -3
- package/prune-r2.mjs +28 -3
- package/publication-gate.mjs +49 -10
- package/publish-cargo.mjs +21 -1
- package/publish-swift.mjs +78 -141
- package/publish-update.mjs +24 -2
- package/qa-build-features.mjs +216 -0
- package/qa-control-scan.mjs +435 -0
- package/registry-parity.mjs +18 -1
- package/release-invocation.mjs +2 -1
- package/release-state.mjs +9 -0
- package/release-switch-registry.json +33 -0
- package/release-switch-scan.mjs +452 -0
- package/release.mjs +320 -26
- package/renamed-away.mjs +29 -0
- package/rightkit-ownership.json +682 -709
- package/rightkit-sync.mjs +8 -4
- package/rightkit-version-policy.mjs +18 -0
- package/rightkit-versions.json +211 -127
- package/runtime-budget-classes.json +4 -0
- package/runtime-budgets.mjs +870 -0
- package/runtime-bundle-scan.mjs +61 -0
- package/sign-macos.mjs +28 -30
- package/sign-updater.mjs +5 -2
- package/swift-mac-dmg.mjs +7 -2
- package/swift-package-command.mjs +1 -1
- package/upload-release.mjs +10 -37
package/github-release.mjs
CHANGED
|
@@ -367,7 +367,10 @@ function runCombined(command, args, commandRunner = spawnSync) {
|
|
|
367
367
|
}
|
|
368
368
|
|
|
369
369
|
function usage(code) {
|
|
370
|
-
console.log(
|
|
370
|
+
console.log([
|
|
371
|
+
"usage: right-release github --release <sealed-id> --tier patch|update --platform mac|win [--config right-release.config.mjs] [--repo owner/repo] [--dry-run]",
|
|
372
|
+
" right-release github --addon <config> --platform mac|win [--repo owner/repo] [--dry-run]",
|
|
373
|
+
].join("\n"));
|
|
371
374
|
process.exit(code);
|
|
372
375
|
}
|
|
373
376
|
|
|
@@ -375,33 +378,48 @@ const realOrSelf = (file) => { try { return realpathSync(file); } catch { return
|
|
|
375
378
|
if (process.argv[1] && realOrSelf(process.argv[1]) === realOrSelf(fileURLToPath(import.meta.url))) {
|
|
376
379
|
const args = process.argv.slice(2);
|
|
377
380
|
const options = { platform: process.platform === "win32" ? "win" : "mac", releaseId: "", addon: "", repo: "", dryRun: false };
|
|
381
|
+
let tier = "";
|
|
382
|
+
let configName = "right-release.config.mjs";
|
|
378
383
|
for (let index = 0; index < args.length; index += 1) {
|
|
379
384
|
if (args[index] === "--platform") options.platform = args[++index];
|
|
380
385
|
else if (args[index] === "--release") options.releaseId = args[++index];
|
|
381
386
|
else if (args[index] === "--addon") options.addon = args[++index];
|
|
382
387
|
else if (args[index] === "--repo") options.repo = args[++index];
|
|
388
|
+
else if (args[index] === "--tier") tier = args[++index];
|
|
389
|
+
else if (args[index].startsWith("--tier=")) tier = args[index].slice("--tier=".length);
|
|
390
|
+
else if (args[index] === "--config") configName = args[++index];
|
|
383
391
|
else if (args[index] === "--dry-run") options.dryRun = true;
|
|
384
392
|
else if (args[index] === "-h" || args[index] === "--help") usage(0);
|
|
385
393
|
else throw new Error(`unknown argument: ${args[index]}`);
|
|
386
394
|
}
|
|
387
395
|
if (Boolean(options.releaseId) === Boolean(options.addon)) usage(2);
|
|
388
|
-
|
|
396
|
+
if (options.addon && (tier || configName !== "right-release.config.mjs")) throw new Error("--tier and --config apply to --release only; an add-on takes its configuration from --addon");
|
|
389
397
|
let plan;
|
|
390
398
|
if (options.addon) {
|
|
399
|
+
const repoRoot = runCommand("git", ["rev-parse", "--show-toplevel"]);
|
|
391
400
|
const configPath = path.resolve(options.addon);
|
|
392
401
|
const config = (await import(`${pathToFileURL(configPath).href}?github=${Date.now()}`)).default;
|
|
393
402
|
options.repo ||= config.distribution?.repository ?? "";
|
|
394
403
|
if (config.distribution?.provider !== "github-releases") throw new Error("add-on config must select github-releases distribution");
|
|
395
404
|
plan = prepareGitHubAddonRelease({ repoRoot, config, configRoot: path.dirname(configPath), ...options });
|
|
396
405
|
} else {
|
|
397
|
-
|
|
406
|
+
// A sealed installer release has one authorization, shared with `right-release upload`: an explicit tier, the
|
|
407
|
+
// primary checkout, a clean tree, a sealed commit equal to HEAD, ownership evidence, and the configured
|
|
408
|
+
// github-releases provider and repository. It finishes before any plan is built or any gh call is made.
|
|
409
|
+
const { authorizeGitHubUpload } = await import("./github-upload-gate.mjs");
|
|
410
|
+
let authority;
|
|
411
|
+
try {
|
|
412
|
+
authority = await authorizeGitHubUpload({ command: "right-release github", tier, releaseId: options.releaseId, platform: options.platform, configName, repo: options.repo });
|
|
413
|
+
} catch (error) {
|
|
414
|
+
console.error(`right-release github: ${error.message}`);
|
|
415
|
+
process.exit(1);
|
|
416
|
+
}
|
|
417
|
+
options.repo = authority.repo;
|
|
398
418
|
// Hardening step 4: the direct GitHub lane is a publication path too; installed QA is recorded like `upload` does, never enforced.
|
|
399
419
|
if (!options.dryRun) {
|
|
400
|
-
|
|
401
|
-
const configPath = path.resolve("right-release.config.mjs");
|
|
402
|
-
admitInstalledQa({ repoRoot, manifest: sealed.manifest, platform: options.platform, configName: "right-release.config.mjs", releaseConfigSha256: existsSync(configPath) ? createHash("sha256").update(readFileSync(configPath)).digest("hex") : undefined }, { provider: "github-releases" });
|
|
420
|
+
admitInstalledQa({ repoRoot: authority.repoRoot, manifest: authority.sealed.manifest, platform: options.platform, configName: path.relative(process.cwd(), authority.configPath) || configName, releaseConfigSha256: sha256(authority.configPath) }, { provider: "github-releases" });
|
|
403
421
|
}
|
|
404
|
-
plan = prepareGitHubRelease({ repoRoot,
|
|
422
|
+
plan = prepareGitHubRelease({ repoRoot: authority.repoRoot, releaseId: options.releaseId, platform: options.platform, repo: options.repo });
|
|
405
423
|
}
|
|
406
424
|
const result = publishGitHubRelease(plan, options);
|
|
407
425
|
console.log(`right-release github: ${result.status} ${result.tag}`);
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Authorization shared by every command that can send a sealed installer release to GitHub Releases.
|
|
2
|
+
//
|
|
3
|
+
// `right-release upload` (alias `publish`) and `right-release github --release` both end in
|
|
4
|
+
// publishGitHubRelease(). Each calls authorizeGitHubUpload() before it plans or contacts GitHub, so the
|
|
5
|
+
// rules below exist once:
|
|
6
|
+
// - an explicit tier, patch or update, never inferred;
|
|
7
|
+
// - the primary checkout, a clean tree, and a sealed release built from the current HEAD;
|
|
8
|
+
// - sealed ownership evidence that still matches the committed source;
|
|
9
|
+
// - an explicit distribution.provider of github-releases and a distribution.repository, with any --repo
|
|
10
|
+
// argument required to equal it (the origin remote is never consulted).
|
|
11
|
+
// Order matters: the release config is a module that runs on import, so it is loaded only after the tree is
|
|
12
|
+
// proven clean and the sealed commit equals HEAD.
|
|
13
|
+
import { existsSync } from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { pathToFileURL } from "node:url";
|
|
16
|
+
import { spawnSync } from "./background-process.mjs";
|
|
17
|
+
import { verifyOwnershipEvidence } from "./ownership-evidence.mjs";
|
|
18
|
+
import { assertPrimaryReleaseCheckout } from "./release-invocation.mjs";
|
|
19
|
+
import { verifySealedRelease } from "./release-state.mjs";
|
|
20
|
+
import { assertCleanSource } from "./source-gate.mjs";
|
|
21
|
+
|
|
22
|
+
/** Throws unless the operator named a tier. There is no default and no inference from the version or the notes. */
|
|
23
|
+
export function assertUploadTier(tier) {
|
|
24
|
+
if (tier !== "patch" && tier !== "update") throw new Error("tier is required (patch|update)");
|
|
25
|
+
return tier;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Returns the GitHub repository a release config publishes to, or throws.
|
|
30
|
+
* `requestedRepo` is an optional command-line `--repo`; it may only restate the configured repository.
|
|
31
|
+
*/
|
|
32
|
+
export function resolveGitHubDistribution(releaseConfig, requestedRepo = "") {
|
|
33
|
+
if (releaseConfig?.distribution?.provider !== "github-releases") {
|
|
34
|
+
throw new Error("release config distribution.provider must be github-releases");
|
|
35
|
+
}
|
|
36
|
+
const configuredRepo = releaseConfig?.distribution?.repository ?? "";
|
|
37
|
+
if (!configuredRepo) throw new Error("release config distribution.repository is required for github-releases");
|
|
38
|
+
if (requestedRepo && requestedRepo !== configuredRepo) throw new Error(`--repo must match configured distribution.repository (${configuredRepo})`);
|
|
39
|
+
return configuredRepo;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Runs every local check that must pass before a sealed release may be planned for GitHub. Performs no network
|
|
44
|
+
* call and writes nothing. Resolves with the verified inputs the caller needs; throws an Error on the first refusal.
|
|
45
|
+
*/
|
|
46
|
+
export async function authorizeGitHubUpload({
|
|
47
|
+
command = "right-release upload",
|
|
48
|
+
tier,
|
|
49
|
+
releaseId,
|
|
50
|
+
platform,
|
|
51
|
+
configName = "right-release.config.mjs",
|
|
52
|
+
repo = "",
|
|
53
|
+
cwd = process.cwd(),
|
|
54
|
+
}) {
|
|
55
|
+
assertUploadTier(tier);
|
|
56
|
+
if (!releaseId) throw new Error("--release is required");
|
|
57
|
+
if (platform !== "win" && platform !== "mac") throw new Error("--platform must be win or mac");
|
|
58
|
+
|
|
59
|
+
const repoRoot = git(cwd, ["rev-parse", "--show-toplevel"]);
|
|
60
|
+
assertPrimaryReleaseCheckout(repoRoot);
|
|
61
|
+
const candidateCommit = git(repoRoot, ["rev-parse", "HEAD"]);
|
|
62
|
+
assertCleanSource({
|
|
63
|
+
status: git(repoRoot, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]),
|
|
64
|
+
commandId: command,
|
|
65
|
+
});
|
|
66
|
+
const platformDir = platform === "win" ? "windows" : "mac";
|
|
67
|
+
const sealed = verifySealedRelease(path.join(repoRoot, ".right-release", "sealed", releaseId, platformDir));
|
|
68
|
+
if (sealed.manifest.commit !== candidateCommit) throw new Error("sealed release was built from a different commit than the current checkout");
|
|
69
|
+
|
|
70
|
+
const configPath = path.resolve(cwd, configName);
|
|
71
|
+
if (!existsSync(configPath)) throw new Error(`release config is required: ${configName}`);
|
|
72
|
+
const releaseConfig = (await import(`${pathToFileURL(configPath).href}?upload=${Date.now()}`)).default;
|
|
73
|
+
const ownership = verifyOwnershipEvidence(sealed.manifest.ownershipEvidence, repoRoot, path.dirname(configPath));
|
|
74
|
+
if (!ownership.ok) throw new Error(`sealed ownership evidence rejected: ${ownership.reason}`);
|
|
75
|
+
const configuredRepo = resolveGitHubDistribution(releaseConfig, repo);
|
|
76
|
+
|
|
77
|
+
return { repoRoot, platformDir, sealed, configPath, releaseConfig, repo: configuredRepo };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function git(cwd, runArgs) {
|
|
81
|
+
const result = spawnSync("git", runArgs, { cwd, encoding: "utf8", windowsHide: true });
|
|
82
|
+
if (result.status !== 0) throw new Error(`git ${runArgs.join(" ")} failed: ${result.stderr}`);
|
|
83
|
+
return result.stdout.trim();
|
|
84
|
+
}
|
package/hardeningscan.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { createReadStream, readFileSync, realpathSync } from "node:fs";
|
|
|
4
4
|
import { lstat, readdir } from "node:fs/promises";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { HARDENING_RULES } from "./hardening-evidence.mjs";
|
|
7
|
+
import { QA_CONTROL_MARKER } from "./qa-control-scan.mjs";
|
|
7
8
|
|
|
8
9
|
const rawArgs = process.argv.slice(2);
|
|
9
10
|
if (rawArgs.length === 0 || rawArgs.includes("-h") || rawArgs.includes("--help")) {
|
|
@@ -68,6 +69,8 @@ const contentRules = [
|
|
|
68
69
|
["secret-env-name", /OPENAI_API_KEY|ANTHROPIC_API_KEY|APPLE_PASSWORD|NOTARY_PROFILE/gi, "secret/env key name leaked"],
|
|
69
70
|
["gpl-flag", /--enable-gpl|--enable-nonfree/gi, "GPL/nonfree ffmpeg flag leaked"],
|
|
70
71
|
["stale-model", /tdt_w15_p6/gi, "stale TDT p6 string leaked"],
|
|
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)"],
|
|
71
74
|
["experiment-model", /Canary-Qwen|Nemotron|dqlstm|bias-experiments/gi, "experiment/model-history marker leaked"],
|
|
72
75
|
];
|
|
73
76
|
|
package/inventory-check.mjs
CHANGED
|
@@ -24,11 +24,11 @@ const CACHE_TTL_MS = 60 * 60 * 1000;
|
|
|
24
24
|
const CONCURRENCY = 8;
|
|
25
25
|
const REQUEST_TIMEOUT_MS = 15_000;
|
|
26
26
|
const CRATES_API_INTERVAL_MS = 1000;
|
|
27
|
-
const USER_AGENT = "rightkit-inventory-check/1 (+https://github.com/
|
|
27
|
+
const USER_AGENT = "rightkit-inventory-check/1 (+https://github.com/Orthic-Labs/rightkit)";
|
|
28
28
|
const EXCLUDED_PREFIX = "build-control-";
|
|
29
29
|
const OWNER_PROBE_CRATE = "rightkit-fs";
|
|
30
|
-
const EXTRA_ACCOUNT_CRATES = Object.freeze([
|
|
31
|
-
const SWIFT_REPO = "
|
|
30
|
+
const EXTRA_ACCOUNT_CRATES = Object.freeze([]);
|
|
31
|
+
const SWIFT_REPO = "Orthic-Labs/rightkit"; // RightKitSwift is tagged on the public repository itself
|
|
32
32
|
const SWIFT_KEY = "rightkit-swift";
|
|
33
33
|
const SWIFT_NAME = "RightKitSwift";
|
|
34
34
|
const SKIP_DIRS = new Set(["node_modules", ".git", "target", "dist", "build", ".cache"]);
|
|
@@ -628,7 +628,7 @@ async function checkThirdParty({ repoRoot, records, live, findings }) {
|
|
|
628
628
|
for (const { where, manifest } of cargoSources) {
|
|
629
629
|
if (!manifest) continue;
|
|
630
630
|
for (const dep of cargoDeclarations(manifest)) {
|
|
631
|
-
if (internalCargo.has(dep.name) || dep.name.startsWith("rightkit-")
|
|
631
|
+
if (internalCargo.has(dep.name) || dep.name.startsWith("rightkit-")) continue;
|
|
632
632
|
add("cargo", dep.name, dep.req, where);
|
|
633
633
|
}
|
|
634
634
|
}
|
package/mac-bundle-signing.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { discoverNestedCode } from "./swift-mac-app.mjs";
|
|
|
6
6
|
|
|
7
7
|
const fail = (message) => Object.assign(new Error(message), { code: "MAC_SIGN_ENTRY_INVALID" });
|
|
8
8
|
|
|
9
|
-
function mainExecutableName(app) {
|
|
9
|
+
export function mainExecutableName(app) {
|
|
10
10
|
try {
|
|
11
11
|
const plist = readFileSync(path.join(app, "Contents", "Info.plist"), "utf8");
|
|
12
12
|
const match = /<key>CFBundleExecutable<\/key>\s*<string>([^<]+)<\/string>/.exec(plist);
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Signing loop for sign-macos.mjs, separated from the CLI so tests can drive it with a fake signer.
|
|
2
|
+
// One entry = one codesign invocation, in the given order (the caller orders inside-out).
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileEvidence } from "./macos-file-evidence.mjs";
|
|
6
|
+
import { CODESIGN_OPTION_FLAGS } from "./mac-signing-matrix.mjs";
|
|
7
|
+
|
|
8
|
+
export const SIGN_MANIFEST_SCHEMA = 1;
|
|
9
|
+
|
|
10
|
+
/** `--options` value: hardened runtime first (unless disabled), then the extra names. */
|
|
11
|
+
export function codesignOptionsValue({ hardenedRuntime = true, options = [] } = {}) {
|
|
12
|
+
return [...(hardenedRuntime ? ["runtime"] : []), ...options.filter((item) => item !== "runtime")].join(",");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9.-]{0,254}$/;
|
|
16
|
+
|
|
17
|
+
/** A codesign `--identifier` value: reverse-DNS characters (letters, digits, `.`, `-`), no spaces, no leading dash. Returns it, or throws. */
|
|
18
|
+
export function validateCodesignIdentifier(identifier, label = "codesign identifier") {
|
|
19
|
+
if (typeof identifier !== "string" || !IDENTIFIER_PATTERN.test(identifier)) {
|
|
20
|
+
throw Object.assign(new Error(`${label} must be 1-255 characters of letters, digits, '.' and '-', starting with a letter or digit: ${JSON.stringify(identifier)}`), { code: "MAC_SIGN_IDENTIFIER_INVALID" });
|
|
21
|
+
}
|
|
22
|
+
return identifier;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* codesign argv for one entry. With no entitlements/options/identifier it is the historical `--force --options runtime --timestamp --sign <id> <file>`.
|
|
27
|
+
* An optional `identifier` adds `--identifier <id>` between `--timestamp` and `--sign`.
|
|
28
|
+
*/
|
|
29
|
+
export function macCodesignArgs({ file, identity, entitlements, hardenedRuntime, options, identifier }) {
|
|
30
|
+
const flags = codesignOptionsValue({ hardenedRuntime, options });
|
|
31
|
+
if (identifier !== undefined) validateCodesignIdentifier(identifier);
|
|
32
|
+
return ["--force", ...(flags ? ["--options", flags] : []), "--timestamp",
|
|
33
|
+
...(identifier !== undefined ? ["--identifier", identifier] : []), "--sign", identity,
|
|
34
|
+
...(entitlements ? ["--entitlements", path.resolve(entitlements)] : []), file];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Parse the JSON manifest release.mjs writes for a signing step with per-file settings. */
|
|
38
|
+
export function parseSignManifest(text) {
|
|
39
|
+
let manifest;
|
|
40
|
+
try { manifest = JSON.parse(text); } catch { throw new Error("signing manifest is not valid JSON"); }
|
|
41
|
+
if (manifest?.schema !== SIGN_MANIFEST_SCHEMA || !Array.isArray(manifest.entries) || !manifest.entries.length) throw new Error(`signing manifest must be { schema: ${SIGN_MANIFEST_SCHEMA}, entries: [...] } with at least one entry`);
|
|
42
|
+
return manifest.entries.map((entry, index) => {
|
|
43
|
+
const label = `signing manifest entry ${index}`;
|
|
44
|
+
if (!entry || typeof entry.file !== "string" || !path.isAbsolute(entry.file)) throw new Error(`${label}: file must be an absolute path`);
|
|
45
|
+
if (entry.entitlements !== undefined && (typeof entry.entitlements !== "string" || !path.isAbsolute(entry.entitlements))) throw new Error(`${label}: entitlements must be an absolute path`);
|
|
46
|
+
if (entry.options !== undefined && (!Array.isArray(entry.options) || entry.options.some((item) => !CODESIGN_OPTION_FLAGS.includes(item)))) throw new Error(`${label}: options must be codesign option names (${CODESIGN_OPTION_FLAGS.join(", ")})`);
|
|
47
|
+
if (entry.hardenedRuntime !== undefined && typeof entry.hardenedRuntime !== "boolean") throw new Error(`${label}: hardenedRuntime must be a boolean`);
|
|
48
|
+
if (entry.identifier !== undefined) validateCodesignIdentifier(entry.identifier, `${label}: identifier`);
|
|
49
|
+
return { file: entry.file, ...(entry.identifier !== undefined ? { identifier: entry.identifier } : {}), ...(entry.entitlements ? { entitlements: entry.entitlements } : {}), hardenedRuntime: entry.hardenedRuntime ?? true, options: entry.options ?? [] };
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Sign, verify and record evidence for each entry. Throws Error(message) on the first failure.
|
|
57
|
+
* Injectable: signOne(args) -> spawn-style result, inspect(cmd, args) -> output string (throws on failure),
|
|
58
|
+
* evidence(file), hashFile(file).
|
|
59
|
+
* Optional per-file identifiers: `identifiers` is a map from bundle-relative path (resolved against `bundle`; the bundle itself is `.`) to a codesign
|
|
60
|
+
* identifier, or a function (relativePath, entry) -> identifier | undefined. An entry's own `identifier` wins; with neither,
|
|
61
|
+
* no `--identifier` is passed. Every identifier is validated before anything is signed.
|
|
62
|
+
*/
|
|
63
|
+
export function signMacosEntries(entries, { identity, signOne, inspect, evidence = fileEvidence, hashFile = (file) => fileEvidence(file).sha256, identifiers, bundle }) {
|
|
64
|
+
const planned = entries.map((entry) => {
|
|
65
|
+
const relative = bundle ? path.relative(bundle, entry.file).split(path.sep).join("/") || "." : entry.file;
|
|
66
|
+
const mapped = typeof identifiers === "function" ? identifiers(relative, entry) : identifiers ? Object.hasOwn(identifiers, relative) ? identifiers[relative] : undefined : undefined;
|
|
67
|
+
const identifier = entry.identifier ?? mapped;
|
|
68
|
+
if (identifier !== undefined) validateCodesignIdentifier(identifier, `identifier for ${relative}`);
|
|
69
|
+
return identifier === undefined ? entry : { ...entry, identifier };
|
|
70
|
+
});
|
|
71
|
+
const records = [];
|
|
72
|
+
for (const entry of planned) {
|
|
73
|
+
const before = evidence(entry.file);
|
|
74
|
+
const signed = signOne(macCodesignArgs({ ...entry, identity }));
|
|
75
|
+
if (!signed || signed.error || signed.status !== 0) throw new Error(`codesign failed: ${signed?.stderr ?? signed?.error?.message ?? signed?.status}`);
|
|
76
|
+
const verification = inspect("codesign", ["--verify", "--strict", "--verbose=4", entry.file]);
|
|
77
|
+
const details = inspect("codesign", ["-dvv", entry.file]);
|
|
78
|
+
if (!/valid on disk/i.test(verification) || !/satisfies its designated requirement/i.test(verification)) {
|
|
79
|
+
throw new Error(`strict codesign verification failed for ${entry.file}`);
|
|
80
|
+
}
|
|
81
|
+
if (!new RegExp(`^Authority=${escapeRegExp(identity)}$`, "m").test(details) && !details.includes(`Authority=${identity}`)) {
|
|
82
|
+
throw new Error(`codesign identity mismatch for ${entry.file}: expected ${identity}`);
|
|
83
|
+
}
|
|
84
|
+
const record = { file: entry.file, before, after: evidence(entry.file), codesign: "strict-valid", identity, timestampPresent: /(?:Timestamp|Signed Time)[=:]/i.test(details) };
|
|
85
|
+
if (!record.timestampPresent) throw new Error(`Developer ID timestamp is missing for ${entry.file}`);
|
|
86
|
+
record.codesignOptions = codesignOptionsValue(entry);
|
|
87
|
+
if (entry.identifier !== undefined) {
|
|
88
|
+
if (!details.includes(`Identifier=${entry.identifier}\n`)) throw new Error(`codesign identifier mismatch for ${entry.file}: expected ${entry.identifier}`);
|
|
89
|
+
record.identifier = entry.identifier;
|
|
90
|
+
}
|
|
91
|
+
if (entry.entitlements) record.entitlements = { file: path.resolve(entry.entitlements), sha256: hashFile(entry.entitlements) };
|
|
92
|
+
records.push(record);
|
|
93
|
+
}
|
|
94
|
+
return records;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const readSignManifest = (file) => parseSignManifest(readFileSync(file, "utf8"));
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// Per-file macOS signing matrix for `sign.prePackageFiles[]` object entries and `sign.nestedCode`.
|
|
2
|
+
//
|
|
3
|
+
// sign: {
|
|
4
|
+
// prePackageFiles: ["dist/tool", { file: "dist/App.app", entitlements: "release/app.entitlements.plist" }],
|
|
5
|
+
// nestedCode: { "Contents/Helpers/Helper": { options: ["library"] },
|
|
6
|
+
// "Contents/PlugIns/Ext.appex": { entitlements: "release/ext.entitlements.plist" } },
|
|
7
|
+
// }
|
|
8
|
+
//
|
|
9
|
+
// This module owns the config shape (validation, fail-early at `right-release doctor`) and the
|
|
10
|
+
// inside-out plan. It never runs codesign: sign-macos.mjs (via mac-sign-core.mjs) executes the plan.
|
|
11
|
+
import { existsSync, lstatSync, openSync, closeSync, readSync, realpathSync, statSync } from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { expandMacSigningEntries, mainExecutableName } from "./mac-bundle-signing.mjs";
|
|
14
|
+
|
|
15
|
+
const invalid = (message) => Object.assign(new Error(message), { code: "MAC_SIGN_CONFIG_INVALID" });
|
|
16
|
+
|
|
17
|
+
/** codesign `--options` names accepted in `options[]` (man codesign, OPTION FLAGS). `runtime` is always on. */
|
|
18
|
+
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"]);
|
|
21
|
+
|
|
22
|
+
const isPlainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
23
|
+
|
|
24
|
+
/** Plist sniff: XML plists contain `<plist`, binary plists start with `bplist00`. Catches wrong or empty files. */
|
|
25
|
+
function looksLikePlist(file) {
|
|
26
|
+
const fd = openSync(file, "r");
|
|
27
|
+
try {
|
|
28
|
+
const buffer = Buffer.alloc(4096);
|
|
29
|
+
const length = readSync(fd, buffer, 0, buffer.length, 0);
|
|
30
|
+
const head = buffer.subarray(0, length);
|
|
31
|
+
return head.subarray(0, 8).toString("latin1") === "bplist00" || head.toString("utf8").includes("<plist");
|
|
32
|
+
} finally { closeSync(fd); }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function validateSettings(settings, label, { root, errors, isFile }) {
|
|
36
|
+
const out = { entitlements: undefined, hardenedRuntime: true, options: [] };
|
|
37
|
+
if (settings.entitlements !== undefined) {
|
|
38
|
+
const value = settings.entitlements;
|
|
39
|
+
if (typeof value !== "string" || !value.trim()) errors.push(`${label}.entitlements must be a non-empty path string`);
|
|
40
|
+
else if (path.isAbsolute(value) || /^[A-Za-z]:/.test(value)) errors.push(`${label}.entitlements must be relative to the release config directory: ${value}`);
|
|
41
|
+
else {
|
|
42
|
+
const resolved = path.resolve(root, value);
|
|
43
|
+
const relative = path.relative(root, resolved);
|
|
44
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) errors.push(`${label}.entitlements must stay inside the release config directory: ${value}`);
|
|
45
|
+
else if (!isFile(resolved)) errors.push(`${label}.entitlements file not found: ${value}`);
|
|
46
|
+
else if (!looksLikePlist(resolved)) errors.push(`${label}.entitlements is not a property list (expected an XML or binary plist): ${value}`);
|
|
47
|
+
else out.entitlements = resolved;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (settings.hardenedRuntime !== undefined) {
|
|
51
|
+
if (typeof settings.hardenedRuntime !== "boolean") errors.push(`${label}.hardenedRuntime must be a boolean`);
|
|
52
|
+
else if (settings.hardenedRuntime === false) errors.push(`${label}.hardenedRuntime: false is not allowed: macOS Developer ID signing is notarized and notarization rejects code without the hardened runtime (omit the field; it defaults to true)`);
|
|
53
|
+
}
|
|
54
|
+
if (settings.options !== undefined) {
|
|
55
|
+
if (!Array.isArray(settings.options) || settings.options.some((item) => typeof item !== "string")) errors.push(`${label}.options must be an array of codesign --options names`);
|
|
56
|
+
else {
|
|
57
|
+
const seen = new Set();
|
|
58
|
+
for (const item of settings.options) {
|
|
59
|
+
if (!CODESIGN_OPTION_FLAGS.includes(item)) errors.push(`${label}.options: unsupported codesign option ${JSON.stringify(item)} (allowed: ${CODESIGN_OPTION_FLAGS.join(", ")})`);
|
|
60
|
+
else if (seen.has(item)) errors.push(`${label}.options: duplicate option ${JSON.stringify(item)}`);
|
|
61
|
+
seen.add(item);
|
|
62
|
+
}
|
|
63
|
+
out.options = settings.options.filter((item) => item !== "runtime");
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function validateNestedKey(key) {
|
|
70
|
+
if (!key.trim() || key !== key.trim()) return "must be a non-empty path without surrounding whitespace";
|
|
71
|
+
if (key.includes("\0") || key.includes("\\")) return "must use forward slashes and no NUL";
|
|
72
|
+
if (path.posix.isAbsolute(key) || /^[A-Za-z]:/.test(key)) return "must be relative to the .app bundle";
|
|
73
|
+
const segments = key.split("/");
|
|
74
|
+
if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) return "must not contain empty, '.' or '..' segments or a trailing slash";
|
|
75
|
+
if (segments[0] !== "Contents" || segments.length < 2) return "must name code inside the bundle, starting with Contents/";
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Validate and normalize `sign` for a macOS target. Collects every problem, then throws one
|
|
81
|
+
* 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: [] }]
|
|
84
|
+
* Injectable: isFile.
|
|
85
|
+
*/
|
|
86
|
+
export function validateMacSigningConfig(sign, { root, isFile = (file) => existsSync(file) && statSync(file).isFile() } = {}) {
|
|
87
|
+
const errors = [];
|
|
88
|
+
const entries = [];
|
|
89
|
+
const nestedCode = [];
|
|
90
|
+
if (!isPlainObject(sign)) throw invalid("sign must be an object");
|
|
91
|
+
const context = { root: path.resolve(root), errors, isFile };
|
|
92
|
+
if (!Array.isArray(sign.prePackageFiles) || !sign.prePackageFiles.length) errors.push("sign.prePackageFiles must be a non-empty array");
|
|
93
|
+
else {
|
|
94
|
+
const seen = new Set();
|
|
95
|
+
sign.prePackageFiles.forEach((entry, index) => {
|
|
96
|
+
const label = `sign.prePackageFiles[${index}]`;
|
|
97
|
+
let file;
|
|
98
|
+
let settings = {};
|
|
99
|
+
if (typeof entry === "string") file = entry;
|
|
100
|
+
else if (isPlainObject(entry)) {
|
|
101
|
+
for (const key of Object.keys(entry)) if (!ENTRY_KEYS.has(key)) errors.push(`${label}: unknown key ${JSON.stringify(key)} (allowed: ${[...ENTRY_KEYS].join(", ")})`);
|
|
102
|
+
file = entry.file;
|
|
103
|
+
settings = entry;
|
|
104
|
+
} else { errors.push(`${label} must be a path string or { file, entitlements?, hardenedRuntime?, options? }`); return; }
|
|
105
|
+
if (typeof file !== "string" || !file.trim()) { errors.push(`${label}.file must be a non-empty path string`); return; }
|
|
106
|
+
if (seen.has(file)) errors.push(`${label}: duplicate signing entry ${file}`);
|
|
107
|
+
seen.add(file);
|
|
108
|
+
const normalized = validateSettings(settings, label, context);
|
|
109
|
+
entries.push({ file, ...(normalized.entitlements ? { entitlements: normalized.entitlements } : {}), options: normalized.options });
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
if (sign.nestedCode !== undefined) {
|
|
113
|
+
if (!isPlainObject(sign.nestedCode)) errors.push("sign.nestedCode must be an object keyed by path inside the .app bundle");
|
|
114
|
+
else {
|
|
115
|
+
for (const [key, settings] of Object.entries(sign.nestedCode)) {
|
|
116
|
+
const label = `sign.nestedCode[${JSON.stringify(key)}]`;
|
|
117
|
+
const keyProblem = validateNestedKey(key);
|
|
118
|
+
if (keyProblem) errors.push(`${label}: key ${keyProblem}`);
|
|
119
|
+
if (!isPlainObject(settings)) { errors.push(`${label} must be an object { entitlements?, hardenedRuntime?, options? }`); continue; }
|
|
120
|
+
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
|
+
const normalized = validateSettings(settings, label, context);
|
|
122
|
+
nestedCode.push({ key, ...(normalized.entitlements ? { entitlements: normalized.entitlements } : {}), options: normalized.options });
|
|
123
|
+
}
|
|
124
|
+
if (nestedCode.length) {
|
|
125
|
+
const apps = entries.filter((entry) => /\.app\/?$/.test(entry.file));
|
|
126
|
+
if (apps.length !== 1) errors.push(`sign.nestedCode needs exactly one .app entry in sign.prePackageFiles to resolve its paths against (found ${apps.length})`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (errors.length) throw invalid(`invalid macOS signing configuration:\n${errors.map((line) => `- ${line}`).join("\n")}`);
|
|
131
|
+
return { entries, nestedCode };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** 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;
|
|
136
|
+
|
|
137
|
+
const depth = (file) => file.split(path.sep).length;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Inside-out signing plan for a macOS target.
|
|
141
|
+
* entries: [{ file: absolute, entitlements?, options }] top-level signing entries, in declared order
|
|
142
|
+
* nestedCode: validated `sign.nestedCode` items (paths relative to the single .app entry)
|
|
143
|
+
* Returns { nested, top }: `nested` is every nested code item of each .app entry, deepest first,
|
|
144
|
+
* defaults unless named by nestedCode; `top` is the entries themselves. Fails (before any signing) when
|
|
145
|
+
* a nestedCode path is missing, is a symlink, escapes the bundle, or names the bundle's main executable.
|
|
146
|
+
* Injectable: expand, lstat, realpath, mainName.
|
|
147
|
+
*/
|
|
148
|
+
export function planMacSigning({ entries, nestedCode = [], expand = expandMacSigningEntries, lstat = lstatSync, realpath = realpathSync, mainName = mainExecutableName }) {
|
|
149
|
+
const { nested: discovered } = expand(entries.map((entry) => entry.file));
|
|
150
|
+
const byPath = new Map(discovered.map((file) => [file, { file, options: [] }]));
|
|
151
|
+
if (nestedCode.length) {
|
|
152
|
+
const app = entries.find((entry) => /\.app\/?$/.test(entry.file));
|
|
153
|
+
if (!app) throw invalid("sign.nestedCode requires an .app entry in sign.prePackageFiles");
|
|
154
|
+
const appReal = realpath(app.file);
|
|
155
|
+
const mainReal = path.join(appReal, "Contents", "MacOS", mainName(app.file));
|
|
156
|
+
for (const item of nestedCode) {
|
|
157
|
+
const label = `sign.nestedCode[${JSON.stringify(item.key)}]`;
|
|
158
|
+
const file = path.join(app.file, ...item.key.split("/"));
|
|
159
|
+
let info;
|
|
160
|
+
try { info = lstat(file); } catch { throw invalid(`${label}: not found inside ${app.file}`); }
|
|
161
|
+
if (info.isSymbolicLink()) throw invalid(`${label}: symlinks cannot be signed; name the real file`);
|
|
162
|
+
const real = realpath(file);
|
|
163
|
+
if (real !== appReal && !real.startsWith(appReal + path.sep)) throw invalid(`${label}: resolves outside the bundle`);
|
|
164
|
+
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 });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const nested = [...byPath.values()].sort((a, b) => depth(b.file) - depth(a.file) || a.file.localeCompare(b.file));
|
|
169
|
+
return { nested, top: entries };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Receipt-side check: every entry's recorded entitlements digest and options match the plan. */
|
|
173
|
+
export function matrixReceiptProblems(evidenceFiles, entries, hashFile) {
|
|
174
|
+
const problems = [];
|
|
175
|
+
const byFile = new Map((evidenceFiles ?? []).map((item) => [path.resolve(item.file), item]));
|
|
176
|
+
for (const entry of entries) {
|
|
177
|
+
const item = byFile.get(path.resolve(entry.file));
|
|
178
|
+
if (!item) { problems.push(`missing receipt entry for ${entry.file}`); continue; }
|
|
179
|
+
const wantOptions = ["runtime", ...(entry.options ?? [])].join(",");
|
|
180
|
+
if ((item.codesignOptions ?? "runtime") !== wantOptions) problems.push(`${entry.file}: signed with options ${item.codesignOptions ?? "runtime"}, expected ${wantOptions}`);
|
|
181
|
+
if (entry.entitlements) {
|
|
182
|
+
if (item.entitlements?.sha256 !== hashFile(entry.entitlements)) problems.push(`${entry.file}: receipt entitlements digest does not match ${entry.entitlements}`);
|
|
183
|
+
} else if (item.entitlements) problems.push(`${entry.file}: signed with entitlements the plan did not declare`);
|
|
184
|
+
}
|
|
185
|
+
return problems;
|
|
186
|
+
}
|
package/msix-appinstaller.mjs
CHANGED
|
@@ -12,12 +12,15 @@ export function generateAppInstaller(o) {
|
|
|
12
12
|
if (!o.name || !o.publisher) errors.push("name and publisher are required");
|
|
13
13
|
const hours = o.hoursBetweenUpdateChecks ?? 24;
|
|
14
14
|
if (!Number.isInteger(hours) || hours < 0 || hours > 255) errors.push("hoursBetweenUpdateChecks must be an integer 0-255");
|
|
15
|
+
if (o.updateBlocksActivation && !o.showPrompt) errors.push("updateBlocksActivation requires showPrompt: true (App Installer schema)");
|
|
16
|
+
if (o.automaticBackgroundTask !== undefined && typeof o.automaticBackgroundTask !== "boolean") errors.push("automaticBackgroundTask must be a boolean");
|
|
15
17
|
if (errors.length) throw fail(errors.join("; "));
|
|
16
18
|
return `<?xml version="1.0" encoding="utf-8"?>
|
|
17
19
|
<AppInstaller xmlns="http://schemas.microsoft.com/appx/appinstaller/2018" Version="${xml(o.version)}" Uri="${xml(o.uri)}">
|
|
18
20
|
<MainPackage Name="${xml(o.name)}" Publisher="${xml(o.publisher)}" Version="${xml(o.version)}" ProcessorArchitecture="${o.architecture}" Uri="${xml(o.msixUri)}" />
|
|
19
21
|
<UpdateSettings>
|
|
20
|
-
<OnLaunch HoursBetweenUpdateChecks="${hours}" ShowPrompt="${o.showPrompt ? "true" : "false"}" UpdateBlocksActivation="${o.updateBlocksActivation ? "true" : "false"}"
|
|
22
|
+
<OnLaunch HoursBetweenUpdateChecks="${hours}" ShowPrompt="${o.showPrompt ? "true" : "false"}" UpdateBlocksActivation="${o.updateBlocksActivation ? "true" : "false"}" />${o.automaticBackgroundTask === false ? "" : `
|
|
23
|
+
<AutomaticBackgroundTask />`}
|
|
21
24
|
</UpdateSettings>
|
|
22
25
|
</AppInstaller>
|
|
23
26
|
`;
|
package/msix-config.mjs
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// virtualization: { fileSystemWrite: "enabled|disabled", registryWrite: "enabled|disabled" }, // explicit, required
|
|
13
13
|
// assets: { storeLogo, square150, square44 },
|
|
14
14
|
// runtime: { dlls: ["swiftCore.dll", ...], systemDlls?: [], dependencies?: [{ name, minVersion, provides: [] }] },
|
|
15
|
-
// appInstaller?: { uri, msixUri, out, hoursBetweenUpdateChecks?, showPrompt?, updateBlocksActivation? },
|
|
15
|
+
// appInstaller?: { uri, msixUri, out, hoursBetweenUpdateChecks?, showPrompt?, updateBlocksActivation? (needs showPrompt), automaticBackgroundTask? (default true) },
|
|
16
16
|
// },
|
|
17
17
|
// updater?: { kind: "tauri" | "appinstaller", artifacts: [...] }, // tauri stays the default
|
|
18
18
|
// installer: { artifacts: [{ file: "<out>.msix", key }] },
|
|
@@ -27,6 +27,8 @@ export const updaterKind = (target) => target?.updater?.kind ?? "tauri";
|
|
|
27
27
|
|
|
28
28
|
const isHttps = (value) => { try { return new URL(value).protocol === "https:"; } catch { return false; } };
|
|
29
29
|
|
|
30
|
+
const versionedUri = (value, version) => { try { return decodeURIComponent(new URL(value).pathname).includes(version); } catch { return false; } };
|
|
31
|
+
|
|
30
32
|
/** Returns an array of human-readable problems (empty when valid). */
|
|
31
33
|
export function validateMsixTarget(target, app = "app") {
|
|
32
34
|
const e = [];
|
|
@@ -56,6 +58,8 @@ export function validateMsixTarget(target, app = "app") {
|
|
|
56
58
|
else {
|
|
57
59
|
if (!isHttps(a.uri)) at("msix.appInstaller.uri must be an https URL");
|
|
58
60
|
if (!isHttps(a.msixUri)) at("msix.appInstaller.msixUri must be an https URL");
|
|
61
|
+
else if (id.version && !versionedUri(a.msixUri, id.version)) at(`msix.appInstaller.msixUri must contain the package version ${id.version} in its path so each release keeps its own install source`);
|
|
62
|
+
if (a.updateBlocksActivation && !a.showPrompt) at("msix.appInstaller.updateBlocksActivation requires showPrompt: true");
|
|
59
63
|
if (!/\.appinstaller$/i.test(a.out ?? "")) at("msix.appInstaller.out must end with .appinstaller");
|
|
60
64
|
}
|
|
61
65
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Canonical MSIX repair guard (W08): the PowerShell script an app writes outside its package so a per-user scheduled task can
|
|
2
|
+
// re-add a package that an interrupted update left `Modified, NeedsRemediation`. This module returns the script text and its
|
|
3
|
+
// SHA-256 so consumers (SwiftRight's SwiftRightBridge embeds a copy) can pin the exact bytes.
|
|
4
|
+
//
|
|
5
|
+
// The script file is stored with LF line endings and ASCII only (see .gitattributes); the hash covers those bytes.
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
|
|
11
|
+
export const MSIX_REPAIR_GUARD_FILE = "msix-repair-guard.ps1";
|
|
12
|
+
export const MSIX_REPAIR_GUARD_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), MSIX_REPAIR_GUARD_FILE);
|
|
13
|
+
|
|
14
|
+
export function msixRepairGuardSha256(text) {
|
|
15
|
+
return createHash("sha256").update(Buffer.from(String(text).replace(/\r\n/g, "\n"), "utf8")).digest("hex");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The guard script, its SHA-256 (hex, over the LF-normalised bytes) and the guard protocol version it declares. */
|
|
19
|
+
export function msixRepairGuard() {
|
|
20
|
+
const text = readFileSync(MSIX_REPAIR_GUARD_PATH, "utf8").replace(/\r\n/g, "\n");
|
|
21
|
+
const match = /^\$GuardVersion = (\d+)$/m.exec(text);
|
|
22
|
+
if (!match) throw new Error("msix-repair-guard.ps1 declares no $GuardVersion");
|
|
23
|
+
return { file: MSIX_REPAIR_GUARD_FILE, text, sha256: msixRepairGuardSha256(text), guardVersion: Number(match[1]) };
|
|
24
|
+
}
|