@rightkit/release 0.2.165 → 0.2.173
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 +16 -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 +93 -47
- package/direct-bootstrap.mjs +26 -41
- package/disk-command.mjs +298 -5
- package/disk-policy.json +7 -5
- package/disk-policy.mjs +105 -16
- 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/install-tauri-devid.mjs +172 -0
- package/inventory-check.mjs +1 -1
- package/local-publish.mjs +11 -1
- package/mac-package-recipe.mjs +304 -0
- 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-recipe-targets.mjs +95 -0
- package/package.json +7 -4
- package/portable-development.mjs +32 -115
- package/preflight.mjs +4 -6
- package/project-type.mjs +1 -1
- 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 +285 -14
- package/rightkit-ownership.json +537 -5
- package/rightkit-sync.mjs +27 -1
- package/rightkit-versions.json +111 -63
- 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/stage-sidecar-command.mjs +41 -0
- package/stage-sidecar.mjs +424 -0
- package/swap-helper-bundle.mjs +105 -0
- package/swift-mac-dmg.mjs +2 -1
- package/templates/cargo-profiles.toml +28 -0
- package/upload-release.mjs +11 -1
- package/win-package-recipe.mjs +321 -0
package/ownership.mjs
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
// RightKit ownership scan. Public exports retain the original findings-array API.
|
|
2
2
|
import { isBlockingFinding } from "./ownership-verdict.mjs";
|
|
3
3
|
import { spawnSync } from "./background-process.mjs";
|
|
4
|
-
import { canonicalRepositoryIdentity, loadTrustedExternal, occurrenceHash,
|
|
5
|
-
import installedManifest from "./rightkit-ownership.json" with { type: "json" };
|
|
4
|
+
import { canonicalRepositoryIdentity, loadTrustedExternal, occurrenceHash, resolveKeyLogTrust, sha256, verifyProtectedProvenance } from "./ownership-approval.mjs";
|
|
6
5
|
import { parse as parseToml } from "smol-toml";
|
|
7
6
|
import { sourceNonCode } from "./ownership-source.mjs";
|
|
8
7
|
import { OWNERSHIP_LOCKFILES, transitiveDependencies } from "./ownership-lockfiles.mjs";
|
|
@@ -40,12 +39,20 @@ function sourceMatchers(manifest) {
|
|
|
40
39
|
const flags = [...new Set(`m${sources.map(({ re }) => re.flags.replace(/[gy]/g, "")).join("")}`)].join("");
|
|
41
40
|
prefilter = new RegExp(sources.map(({ f }) => `(?:${f.pattern})`).join("|") || "(?!)", flags);
|
|
42
41
|
} catch { prefilter = /[\s\S]/; }
|
|
43
|
-
|
|
42
|
+
// `excludePaths` (exact files or directory prefixes) keeps a source rule off the paths that
|
|
43
|
+
// hold its own positive samples, such as the scanner's fixtures.
|
|
44
|
+
const excluded = new Map(sources.filter(({ f }) => f.excludePaths?.length).map(({ f }) => [f.id, f.excludePaths]));
|
|
45
|
+
const matchers = { digest, sources, prefilter, excluded };
|
|
44
46
|
catalogCache.set(digest, matchers);
|
|
45
47
|
if (catalogCache.size > 8) catalogCache.delete(catalogCache.keys().next().value);
|
|
46
48
|
return matchers;
|
|
47
49
|
}
|
|
48
50
|
|
|
51
|
+
function sourceExcluded(matchers, id, rel) {
|
|
52
|
+
const paths = matchers.excluded?.get(id);
|
|
53
|
+
return Boolean(paths?.some((p) => rel === p || rel.startsWith(p.endsWith("/") ? p : `${p}/`)));
|
|
54
|
+
}
|
|
55
|
+
|
|
49
56
|
function sourceFindings(file, matchers) {
|
|
50
57
|
const ext = path.posix.extname(file.rel).toLowerCase();
|
|
51
58
|
const key = `${matchers.digest}:${ext}:${file.cacheKey ?? inputDigest(file)}`;
|
|
@@ -194,7 +201,7 @@ function fileInventory(appRoot, trust, pathPrefix = "", repositoryRoot = appRoot
|
|
|
194
201
|
const inventory = gitListedInventory(appRoot, {
|
|
195
202
|
repositoryRoot,
|
|
196
203
|
protectedPaths: [...protectedPaths],
|
|
197
|
-
relevant: (rel) => ["Cargo.toml", "package.json", "Package.swift", "Package.resolved", ...OWNERSHIP_LOCKFILES].includes(path.posix.basename(rel)) || CODE_EXT.has(path.posix.extname(rel).toLowerCase()),
|
|
204
|
+
relevant: (rel) => ["Cargo.toml", "package.json", "Package.swift", "Package.resolved", "pnpm-workspace.yaml", ...OWNERSHIP_LOCKFILES].includes(path.posix.basename(rel)) || CODE_EXT.has(path.posix.extname(rel).toLowerCase()),
|
|
198
205
|
maxFileBytes: (rel) => CODE_EXT.has(path.posix.extname(rel).toLowerCase()) || protectedPaths.has(rel) ? 2 * 1024 * 1024 : 64 * 1024 * 1024,
|
|
199
206
|
});
|
|
200
207
|
for (const item of inventory.coverage) if (item.reason === "file-over-size-limit") item.reason = protectedPaths.has(item.path) ? "protected-provenance-file-over-2MiB" : CODE_EXT.has(path.posix.extname(item.path).toLowerCase()) ? "source-file-over-2MiB" : "manifest-file-over-64MiB";
|
|
@@ -214,7 +221,7 @@ function committedFileInventory(appRoot, trust, pathPrefix, committed) {
|
|
|
214
221
|
if (entry.mode === "120000") { coverage.push({ path: rel, status: "unscanned", reason: "committed-symlink" }); continue; }
|
|
215
222
|
if (entry.mode === "160000" || entry.type === "commit") { coverage.push({ path: rel, status: "unscanned", reason: "committed-gitlink" }); continue; }
|
|
216
223
|
if (entry.type !== "blob") { coverage.push({ path: rel, status: "unscanned", reason: "committed-unsupported-entry" }); continue; }
|
|
217
|
-
const relevant = protectedPaths.has(entry.rel) || ["Cargo.toml", "package.json", "Package.swift", "Package.resolved", "ownership-exceptions.json", ...OWNERSHIP_LOCKFILES].includes(path.posix.basename(rel)) || CODE_EXT.has(path.posix.extname(rel).toLowerCase());
|
|
224
|
+
const relevant = protectedPaths.has(entry.rel) || ["Cargo.toml", "package.json", "Package.swift", "Package.resolved", "ownership-exceptions.json", "pnpm-workspace.yaml", ...OWNERSHIP_LOCKFILES].includes(path.posix.basename(rel)) || CODE_EXT.has(path.posix.extname(rel).toLowerCase());
|
|
218
225
|
const bytes = relevant ? entry.bytes : undefined;
|
|
219
226
|
if (relevant && !bytes) coverage.push({ path: rel, status: "unscanned", reason: "committed-blob-unavailable" });
|
|
220
227
|
const isExceptionFile = path.posix.basename(rel) === "ownership-exceptions.json";
|
|
@@ -579,7 +586,7 @@ function scanInternal(appRoot, manifest, { inventory = fileInventory(appRoot), t
|
|
|
579
586
|
}
|
|
580
587
|
if (size > 2 * 1024 * 1024) { coverage.push({ path: rel, status: "unscanned", reason: "source-file-over-2MiB" }); continue; }
|
|
581
588
|
contentDigests.push({ path: rel, digest: inputDigest(file) });
|
|
582
|
-
for (const finding of sourceFindings(file, matchers)) findings.push({ ...finding, file: rel });
|
|
589
|
+
for (const finding of sourceFindings(file, matchers)) if (!sourceExcluded(matchers, finding.fingerprint, rel)) findings.push({ ...finding, file: rel });
|
|
583
590
|
}
|
|
584
591
|
if (pathPrefix) {
|
|
585
592
|
const prefix = (p) => p === "." ? pathPrefix.slice(0, -1) : `${pathPrefix}${p}`;
|
|
@@ -675,31 +682,42 @@ function enrollmentPending(trust, state) {
|
|
|
675
682
|
function findingPolicy(c, f) {
|
|
676
683
|
return { tier: f.tier ?? c.tier ?? "block", ...(f.policy ? { policy: f.policy } : {}) };
|
|
677
684
|
}
|
|
685
|
+
/**
|
|
686
|
+
* Plain exception entries (Orthic-Labs/rightkit#262), reviewed through git, no signatures:
|
|
687
|
+
* { capability, package (alias: crate), reason, owner, date }, optional narrowing { fingerprint, path }.
|
|
688
|
+
* An entry marks every finding of that capability/package (and fingerprint/path when given) `excepted`;
|
|
689
|
+
* an entry that matches no finding is reported as a stale warning; a malformed or legacy signed entry is a problem.
|
|
690
|
+
*/
|
|
691
|
+
export function validateExceptionEntry(entry) {
|
|
692
|
+
const problems = [];
|
|
693
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return ["not an object"];
|
|
694
|
+
if (entry.payload || entry.signature !== undefined || entry.schema !== undefined) return ["legacy signed exception record; replace it with a plain entry {capability, package, reason, owner, date}"];
|
|
695
|
+
for (const key of ["capability", "reason", "owner"]) if (typeof entry[key] !== "string" || !entry[key].trim()) problems.push(`missing ${key}`);
|
|
696
|
+
const owner = entry.package ?? entry.crate;
|
|
697
|
+
if (typeof owner !== "string" || !owner.trim()) problems.push("missing package (crate or package)");
|
|
698
|
+
if (typeof entry.date !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(entry.date) || Number.isNaN(Date.parse(`${entry.date}T00:00:00Z`))) problems.push("date must be YYYY-MM-DD");
|
|
699
|
+
for (const key of ["fingerprint", "path"]) if (entry[key] !== undefined && (typeof entry[key] !== "string" || !entry[key])) problems.push(`${key} must be a non-empty string when present`);
|
|
700
|
+
return problems;
|
|
701
|
+
}
|
|
702
|
+
export function exceptionMatches(entry, finding) {
|
|
703
|
+
const owner = entry.package ?? entry.crate;
|
|
704
|
+
if (finding.capability !== entry.capability || finding.package !== owner) return false;
|
|
705
|
+
if (entry.fingerprint !== undefined && finding.fingerprint !== entry.fingerprint) return false;
|
|
706
|
+
if (entry.path !== undefined && !(finding.file === entry.path || finding.file?.startsWith(`${entry.path.replace(/\/$/, "")}/`))) return false;
|
|
707
|
+
return true;
|
|
708
|
+
}
|
|
678
709
|
export function evaluate(findings, exceptions, options = {}) {
|
|
679
|
-
const
|
|
680
|
-
const metadata = options.metadata ?? findings.metadata ?? {};
|
|
681
|
-
const catalogDigest = metadata.catalogDigest ?? options.catalogDigest ?? "";
|
|
682
|
-
const identity = options.repositoryIdentity ?? metadata.repositoryIdentity ?? "";
|
|
710
|
+
const problems = [], covered = [], violations = [], warnings = [];
|
|
683
711
|
for (const e of exceptions) {
|
|
684
|
-
const
|
|
685
|
-
const
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
if (!/^\d{4}-\d{2}-\d{2}$/.test(p.expires) || Number.isNaN(Date.parse(`${p.expires}T00:00:00Z`)) || p.expires < today) { problems.push(`${where}: invalid or expired expires date`); continue; }
|
|
691
|
-
if (!p.trackingRef || typeof p.trackingRef !== "object" || !p.trackingRef.verified || !/^https:\/\//.test(p.trackingRef.url ?? "") || !p.trackingRef.id) { problems.push(`${where}: tracking must be a verified RightKit request reference`); continue; }
|
|
692
|
-
let policyDigest;
|
|
693
|
-
try { policyDigest = policyEntryDigest(options.manifest ?? installedManifest, p.fingerprint); } catch (error) { problems.push(`${where}: ${error.message}`); continue; }
|
|
694
|
-
const { index, ...record } = e;
|
|
695
|
-
const verification = verifyException(record, { repositoryIdentity: identity, catalogDigest, policyDigest, findings, trust: options.trust, today, scannerVersion: options.scannerVersion ?? metadata.scannerVersion ?? SCANNER_VERSION, catalogVersion: options.catalogVersion ?? metadata.catalogVersion });
|
|
696
|
-
if (!verification?.ok) { problems.push(`${where}: signature verification failed${verification?.problems?.length ? ` (${verification.problems.join("; ")})` : ""}`); continue; }
|
|
697
|
-
const matching = findings.filter((f) => f.fingerprint === p.fingerprint && p.occurrences.some((item) => item.path === f.file && item.hash === occurrenceHash(f)));
|
|
698
|
-
if (!matching.length) { problems.push(`${where}: stale, matches no signed occurrence`); continue; }
|
|
699
|
-
for (const f of matching) covered.push({ ...f, occurrenceHash: occurrenceHash(f), exception: e });
|
|
712
|
+
const where = `exception #${e?.index} (${e?.capability ?? "?"}/${e?.package ?? e?.crate ?? "?"})`;
|
|
713
|
+
const invalid = validateExceptionEntry(e);
|
|
714
|
+
if (invalid.length) { problems.push(`${where}: ${invalid.join("; ")}`); continue; }
|
|
715
|
+
const matching = findings.filter((f) => exceptionMatches(e, f));
|
|
716
|
+
if (!matching.length) { warnings.push(`${where}: stale, matches no current finding (remove it from the exceptions file)`); continue; }
|
|
717
|
+
for (const f of matching) if (!covered.some((x) => x.occurrenceHash === occurrenceHash(f))) covered.push({ ...f, occurrenceHash: occurrenceHash(f), exception: e });
|
|
700
718
|
}
|
|
701
719
|
const pending = enrollmentPending(options.trust, options.trustEnrollment);
|
|
702
|
-
if (pending) warnings.push("approval enrollment pending
|
|
720
|
+
if (pending) warnings.push("approval enrollment pending (warning only): exceptions are plain git-reviewed entries and no scan result depends on enrolled approval keys");
|
|
703
721
|
for (const f of findings) if (!covered.some((x) => x.occurrenceHash === occurrenceHash(f))) violations.push({ ...f, occurrenceHash: occurrenceHash(f) });
|
|
704
722
|
return { violations, covered, problems, warnings, enrollmentPending: pending };
|
|
705
723
|
}
|
|
@@ -744,7 +762,7 @@ function checkAppInternal(appRoot, manifest, options, readAuthority) {
|
|
|
744
762
|
const result = evaluate(scanned.findings, loadExceptions(appRoot), { ...options, manifest, trust, trustEnrollment: enrollment, metadata });
|
|
745
763
|
const coverage = [...new Map(metadata.coverage.map((x) => [`${x.path}\0${x.reason}`, x])).values()];
|
|
746
764
|
const rows = capabilityTable(manifest, scanned.deps, { ...result, coverage });
|
|
747
|
-
return { ok: !result.
|
|
765
|
+
return { ok: !result.violations.some(isBlockingFinding) && !result.problems.length && !coverage.length, rows, ...result, findings: scanned.findings, coverage, metadata };
|
|
748
766
|
}
|
|
749
767
|
|
|
750
768
|
function checkCommitted(appRoot, manifest, options, acquire, readAuthority = readCommittedKeyAuthority) {
|
|
@@ -812,7 +830,7 @@ function checkCommitted(appRoot, manifest, options, acquire, readAuthority = rea
|
|
|
812
830
|
: trustState?.pending !== undefined || trustState?.enrolled !== undefined ? trustState : undefined, metadata });
|
|
813
831
|
const coverage = [...new Map(metadata.coverage.map((x) => [`${x.path}\0${x.reason}`, x])).values()];
|
|
814
832
|
const rows = capabilityTable(manifest, scanned.deps, { ...result, coverage });
|
|
815
|
-
return { ok: !result.
|
|
833
|
+
return { ok: !result.violations.some(isBlockingFinding) && !result.problems.length && !coverage.length, rows, ...result, findings: scanned.findings, coverage, metadata };
|
|
816
834
|
}
|
|
817
835
|
|
|
818
836
|
/** Protected production scan: acquire and inspect exact HEAD blobs without reading checkout contents. */
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Declarative package targets (bogusyogi/rightkit#345). `targets.win.winPackage` and `targets.mac.macPackage` replace
|
|
2
|
+
// the per-app NSIS / DMG hook scripts. They expand to the SAME hook shape release.mjs already runs (prePackage,
|
|
3
|
+
// package, sign, notarize, dmg), so every existing gate still applies unchanged: QA-control scans, the bundle
|
|
4
|
+
// marker patch before signing, raw-EXE and installer Authenticode, the NSIS embedding verification, Developer ID
|
|
5
|
+
// signing of the .app and its nested code, app notarization before packing, the signed DMG and its notarization.
|
|
6
|
+
// The standalone recipes (win-package-recipe.mjs, mac-package-recipe.mjs) hold the same steps for direct callers.
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { validateWinPackage, WinPackageConfigError } from "./win-package-recipe.mjs";
|
|
9
|
+
|
|
10
|
+
export class PackageRecipeTargetError extends Error {
|
|
11
|
+
constructor(message) { super(message); this.name = "PackageRecipeTargetError"; this.code = "RK_PACKAGE_RECIPE_TARGET"; }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const fail = (message) => { throw new PackageRecipeTargetError(message); };
|
|
15
|
+
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
16
|
+
const rel = (appRoot, file) => path.relative(appRoot, file).split(path.sep).join("/");
|
|
17
|
+
// Tauri never signs or creates updater artifacts here: release.mjs signs, and sign-updater signs the signed bytes.
|
|
18
|
+
const withoutRecipe = (target) => {
|
|
19
|
+
if (!isObject(target) || !("recipe" in target)) return target;
|
|
20
|
+
const { recipe: _ignored, ...rest } = target;
|
|
21
|
+
return rest;
|
|
22
|
+
};
|
|
23
|
+
const QUIET_TAURI_ENV = { TAURI_SIGNING_PRIVATE_KEY: "", TAURI_SIGNING_PRIVATE_KEY_PASSWORD: "" };
|
|
24
|
+
|
|
25
|
+
/** `targets.win.winPackage` → prePackage (`tauri build --no-bundle`) and package (`tauri bundle --bundles nsis`). */
|
|
26
|
+
export function expandWinPackageTarget(target, { appRoot, config = {} }) {
|
|
27
|
+
if (!target?.winPackage) return withoutRecipe(target);
|
|
28
|
+
if (target.prePackage !== undefined) fail("targets.win.winPackage replaces prePackage; remove the prePackage script");
|
|
29
|
+
if (target.winPackage.sidecars !== undefined) fail("targets.win.winPackage.sidecars is not supported in a right-release target: declare sidecars through externalBin or mediaSidecar, which release.mjs signs with the raw EXE");
|
|
30
|
+
let plan;
|
|
31
|
+
try { plan = validateWinPackage({ appRoot, config, target }); } catch (error) {
|
|
32
|
+
if (error instanceof WinPackageConfigError) fail(`targets.win.winPackage: ${(error.issues ?? [error.message]).join("; ")}`);
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
if (target.signingContract !== undefined && target.signingContract !== "windows-raw-exe-authenticode-before-nsis-v1") fail(`targets.win.winPackage builds an NSIS installer; signingContract must be windows-raw-exe-authenticode-before-nsis-v1, got ${target.signingContract}`);
|
|
36
|
+
const cwd = rel(plan.appRoot, plan.tauriCwd) || ".";
|
|
37
|
+
const override = { bundle: { createUpdaterArtifacts: false, windows: { nsis: { installMode: plan.perMachine ? "perMachine" : "currentUser" } } } };
|
|
38
|
+
// `recipe` is set only here: a hand-written one is dropped so it can never select a recipe path.
|
|
39
|
+
const { winPackage, recipe: _ignoredWin, ...rest } = target;
|
|
40
|
+
return {
|
|
41
|
+
...rest,
|
|
42
|
+
signingContract: "windows-raw-exe-authenticode-before-nsis-v1",
|
|
43
|
+
prePackage: { cmd: "pnpm", args: ["exec", "tauri", "build", "--no-bundle", "--target", plan.triple], cwd, env: QUIET_TAURI_ENV },
|
|
44
|
+
package: { cmd: "pnpm", args: ["exec", "tauri", "bundle", "--bundles", "nsis", "--target", plan.triple, "--config", JSON.stringify(override)], cwd, env: QUIET_TAURI_ENV },
|
|
45
|
+
sign: { ...(target.sign ?? {}), prePackageFiles: [rel(plan.appRoot, plan.rawExe)], files: [rel(plan.appRoot, plan.installerFile)] },
|
|
46
|
+
recipe: { kind: "winPackage", productName: plan.productName, updater: plan.updater },
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const MAC_KEYS = new Set(["universal", "tauriCwd", "features"]);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `targets.mac.macPackage` → prePackage (`tauri build --bundles app`, universal by default), the .app as the
|
|
54
|
+
* Developer ID signing set, app notarization before packing, and right-release's own signed DMG from the stapled app.
|
|
55
|
+
* The app path and DMG path are `notarize.app` and `notarize.file`; DMG branding is `targets.mac.dmg`.
|
|
56
|
+
*/
|
|
57
|
+
export function expandMacPackageTarget(target, { appRoot }) {
|
|
58
|
+
if (!target?.macPackage) return withoutRecipe(target);
|
|
59
|
+
const spec = target.macPackage;
|
|
60
|
+
if (!isObject(spec)) fail("targets.mac.macPackage must be an object");
|
|
61
|
+
const unknown = Object.keys(spec).filter((key) => !MAC_KEYS.has(key));
|
|
62
|
+
if (unknown.length) fail(`targets.mac.macPackage has unknown key(s): ${unknown.join(", ")} (allowed: ${[...MAC_KEYS].join(", ")})`);
|
|
63
|
+
if (target.prePackage !== undefined || target.package !== undefined) fail("targets.mac.macPackage replaces prePackage and package; remove both scripts");
|
|
64
|
+
if (typeof target.notarize?.app !== "string" || !target.notarize.app.endsWith(".app")) fail("targets.mac.macPackage needs notarize.app: the built .app path");
|
|
65
|
+
if (typeof target.notarize?.file !== "string" || !target.notarize.file.endsWith(".dmg")) fail("targets.mac.macPackage needs notarize.file: the .dmg path right-release builds");
|
|
66
|
+
if (target.dmg === false) fail("targets.mac.macPackage always builds a DMG; remove dmg: false");
|
|
67
|
+
if (spec.universal !== undefined && typeof spec.universal !== "boolean") fail("targets.mac.macPackage.universal must be a boolean");
|
|
68
|
+
if (spec.features !== undefined && (!Array.isArray(spec.features) || spec.features.some((f) => typeof f !== "string" || !/^[A-Za-z0-9_,:-]+$/.test(f)))) fail("targets.mac.macPackage.features must be Cargo feature names");
|
|
69
|
+
const cwd = spec.tauriCwd ?? ".";
|
|
70
|
+
if (typeof cwd !== "string" || path.isAbsolute(cwd) || cwd.split(/[\\/]/).includes("..")) fail("targets.mac.macPackage.tauriCwd must be a relative path inside the app");
|
|
71
|
+
// Tauri's universal-apple-darwin target builds both slices and lipos them itself.
|
|
72
|
+
const triple = spec.universal === false ? (process.arch === "arm64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin") : "universal-apple-darwin";
|
|
73
|
+
const override = { bundle: { createUpdaterArtifacts: false, macOS: { signingIdentity: null } } };
|
|
74
|
+
const args = ["exec", "tauri", "build", "--target", triple, "--bundles", "app", "--no-sign", "--config", JSON.stringify(override)];
|
|
75
|
+
if (spec.features?.length) args.push("--features", spec.features.join(","));
|
|
76
|
+
const prePackageFiles = target.sign?.prePackageFiles ?? [target.notarize.app];
|
|
77
|
+
if (!prePackageFiles.some((entry) => (typeof entry === "string" ? entry : entry?.file) === target.notarize.app)) fail("targets.mac.macPackage: sign.prePackageFiles must include notarize.app");
|
|
78
|
+
const { macPackage, recipe: _ignoredMac, ...rest } = target;
|
|
79
|
+
return {
|
|
80
|
+
...rest,
|
|
81
|
+
prePackage: { cmd: "pnpm", args, cwd, env: QUIET_TAURI_ENV },
|
|
82
|
+
sign: { ...(target.sign ?? {}), prePackageFiles },
|
|
83
|
+
notarize: { ...target.notarize, appBeforePackage: true },
|
|
84
|
+
dmg: isObject(target.dmg) ? target.dmg : {},
|
|
85
|
+
recipe: { kind: "macPackage", universal: triple === "universal-apple-darwin" },
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Expand whichever declarative package key the target carries. */
|
|
90
|
+
export function expandPackageRecipeTarget(target, { platform, appRoot, config }) {
|
|
91
|
+
if (platform === "win") return expandWinPackageTarget(target, { appRoot, config });
|
|
92
|
+
if (platform === "mac") return expandMacPackageTarget(target, { appRoot, config });
|
|
93
|
+
if (target?.winPackage || target?.macPackage) fail(`winPackage / macPackage are not valid on ${platform}`);
|
|
94
|
+
return target;
|
|
95
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rightkit/release",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.173",
|
|
4
4
|
"description": "Portable release CLI/SDK with fail-closed public GitHub Releases and private R2 + RightApps distribution.",
|
|
5
5
|
"license": "MIT OR Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -18,10 +18,13 @@
|
|
|
18
18
|
},
|
|
19
19
|
"files": [
|
|
20
20
|
"cli",
|
|
21
|
+
"bin",
|
|
22
|
+
"templates",
|
|
21
23
|
"*.mjs",
|
|
22
24
|
"*.json",
|
|
23
25
|
"*.sh",
|
|
24
26
|
"msix-repair-guard.ps1",
|
|
27
|
+
"bin/darwin/rightkit-swap",
|
|
25
28
|
"*.py",
|
|
26
29
|
"LICENSE-MIT",
|
|
27
30
|
"LICENSE-APACHE",
|
|
@@ -35,14 +38,14 @@
|
|
|
35
38
|
},
|
|
36
39
|
"repository": {
|
|
37
40
|
"type": "git",
|
|
38
|
-
"url": "git+https://github.com/
|
|
41
|
+
"url": "git+https://github.com/bogusyogi/rightkit.git",
|
|
39
42
|
"directory": "packages/release"
|
|
40
43
|
},
|
|
41
44
|
"dependencies": {
|
|
42
|
-
"smol-toml": "1.9.
|
|
45
|
+
"smol-toml": "1.9.1"
|
|
43
46
|
},
|
|
44
47
|
"scripts": {
|
|
45
|
-
"test": "node --test --test-concurrency=1 --test-force-exit *.test.mjs",
|
|
48
|
+
"test": "node --import ./test-support/broker-guard.mjs --test --test-concurrency=1 --test-force-exit *.test.mjs",
|
|
46
49
|
"test:registry-parity": "node registry-parity.mjs --allow-unpublished",
|
|
47
50
|
"test:signer:windows": "node sign-release-manifest.integration.mjs",
|
|
48
51
|
"doctor:all": "node --test right-suite-contract.test.mjs",
|
package/portable-development.mjs
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
|
-
import {
|
|
4
|
+
import { previousPathOf, stagingPathOf, swapInPlace } from "./in-place-install.mjs";
|
|
5
5
|
|
|
6
6
|
const CHUNK_BYTES = 1024 * 1024;
|
|
7
7
|
|
|
8
|
-
/**
|
|
8
|
+
/**
|
|
9
|
+
* Install an unsigned development payload in place at the fixed path `<installRoot>/current` (a real directory).
|
|
10
|
+
* No versions/ folder, no digest folder, no symlink or junction. The previous copy is kept once, as
|
|
11
|
+
* `<installRoot>/current.previous` (rename it back to roll back); nothing ever runs from it. A legacy `current`
|
|
12
|
+
* symlink or junction from the old layout is moved aside to `.previous` the same way.
|
|
13
|
+
*/
|
|
9
14
|
export function installPortableDevelopment(descriptor, { root, platform = process.platform, env = process.env } = {}) {
|
|
10
|
-
const emit = (event, fields = {}) => process.stdout.write(`${JSON.stringify({ event: `DEV_INSTALL_${event}`, ...fields })}
|
|
11
|
-
|
|
12
|
-
let switched = false;
|
|
13
|
-
let versionRoot;
|
|
15
|
+
const emit = (event, fields = {}) => process.stdout.write(`${JSON.stringify({ event: `DEV_INSTALL_${event}`, ...fields })}
|
|
16
|
+
`);
|
|
14
17
|
let digest;
|
|
15
18
|
let current;
|
|
16
19
|
try {
|
|
@@ -27,110 +30,40 @@ export function installPortableDevelopment(descriptor, { root, platform = proces
|
|
|
27
30
|
const sourceEntrypoint = resolveContained(source, descriptor.entrypoint, "entrypoint");
|
|
28
31
|
const sourceEntrypointInfo = fs.lstatSync(sourceEntrypoint);
|
|
29
32
|
if (!sourceEntrypointInfo.isFile() || sourceEntrypointInfo.isSymbolicLink()) throw new Error("development entrypoint must be a regular file inside sourceDirectory");
|
|
30
|
-
|
|
31
|
-
digest = sourceDigest;
|
|
32
|
-
versionRoot = path.join(installRoot, "versions", `dev-${digest}`);
|
|
33
|
+
digest = digestTree(source);
|
|
33
34
|
current = path.join(installRoot, "current");
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (exists(
|
|
38
|
-
if (digestTree(versionRoot) !== digest) throw new Error(`development version digest collision: ${versionRoot}`);
|
|
39
|
-
} else {
|
|
40
|
-
const staging = path.join(path.dirname(versionRoot), `.stage-${process.pid}-${randomSuffix()}`);
|
|
41
|
-
try {
|
|
42
|
-
copyTree(source, staging);
|
|
43
|
-
if (digestTree(staging) !== digest) throw new Error("staged development payload did not match source digest");
|
|
44
|
-
fs.renameSync(staging, versionRoot);
|
|
45
|
-
} catch (error) {
|
|
46
|
-
removeIfExists(staging);
|
|
47
|
-
throw error;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
35
|
+
const staging = stagingPathOf(current);
|
|
36
|
+
const previous = previousPathOf(current);
|
|
37
|
+
emit("STAGE", { sourceDirectory: source, current, staging, digest });
|
|
38
|
+
if (exists(current) && !fs.lstatSync(current).isDirectory() && !fs.lstatSync(current).isSymbolicLink()) throw new Error(`development current is not a directory; refusing to replace: ${current}`);
|
|
50
39
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
backup = path.join(installRoot, `.current-backup-${process.pid}-${randomSuffix()}`);
|
|
54
|
-
fs.renameSync(current, backup);
|
|
55
|
-
}
|
|
40
|
+
fs.mkdirSync(installRoot, { recursive: true });
|
|
41
|
+
removeIfExists(staging);
|
|
56
42
|
try {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
emit("ACTIVATE", { current, versionRoot });
|
|
60
|
-
const activeRoot = fs.realpathSync(current);
|
|
61
|
-
if (!samePath(activeRoot, fs.realpathSync(versionRoot), platform)) throw new Error("development current pointer did not resolve to staged version");
|
|
62
|
-
const entrypoint = resolveContained(versionRoot, descriptor.entrypoint, "entrypoint");
|
|
63
|
-
const entryDigest = hashFile(entrypoint);
|
|
64
|
-
if (entryDigest !== hashFile(path.join(source, descriptor.entrypoint))) throw new Error("development entrypoint readback did not match source");
|
|
65
|
-
if (backup) removeIfExists(backup);
|
|
66
|
-
emit("PRUNE", pruneDevelopmentVersions(installRoot, versionRoot, { platform }));
|
|
67
|
-
emit("SUCCEEDED", { installRoot, current, versionRoot, digest, entrypoint: descriptor.entrypoint, entrypointDigest: entryDigest });
|
|
68
|
-
return { installRoot, current, versionRoot, digest, entrypoint: path.join(current, descriptor.entrypoint), entrypointDigest: entryDigest };
|
|
43
|
+
copyTree(source, staging);
|
|
44
|
+
if (digestTree(staging) !== digest) throw new Error("staged development payload did not match source digest");
|
|
69
45
|
} catch (error) {
|
|
70
|
-
|
|
71
|
-
if (backup && exists(backup)) fs.renameSync(backup, current);
|
|
46
|
+
removeIfExists(staging);
|
|
72
47
|
throw error;
|
|
73
48
|
}
|
|
49
|
+
const sourceEntryDigest = hashFile(sourceEntrypoint);
|
|
50
|
+
let entryDigest;
|
|
51
|
+
swapInPlace({
|
|
52
|
+
staging, destination: current, previous,
|
|
53
|
+
verify: (destination) => {
|
|
54
|
+
entryDigest = hashFile(resolveContained(destination, descriptor.entrypoint, "entrypoint"));
|
|
55
|
+
if (entryDigest !== sourceEntryDigest) throw new Error("development entrypoint readback did not match source");
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
emit("ACTIVATE", { current, previous });
|
|
59
|
+
emit("SUCCEEDED", { installRoot, current, previous, digest, entrypoint: descriptor.entrypoint, entrypointDigest: entryDigest });
|
|
60
|
+
return { installRoot, current, previous, versionRoot: current, digest, entrypoint: path.join(current, descriptor.entrypoint), entrypointDigest: entryDigest };
|
|
74
61
|
} catch (error) {
|
|
75
|
-
emit("FAILED", { installRoot: descriptor?.installRoot,
|
|
62
|
+
emit("FAILED", { installRoot: descriptor?.installRoot, current, digest, message: error?.message ?? String(error) });
|
|
76
63
|
throw error;
|
|
77
64
|
}
|
|
78
65
|
}
|
|
79
66
|
|
|
80
|
-
/**
|
|
81
|
-
* Development installs keep only the active version: dev builds are rebuilt from source, so there is
|
|
82
|
-
* no rollback copy. A version a running process still uses is skipped and removed by the next
|
|
83
|
-
* install. Never throws: a prune problem must not fail an install that already activated.
|
|
84
|
-
*/
|
|
85
|
-
export function pruneDevelopmentVersions(installRoot, keepRoot, { platform = process.platform, runningExecutables = null } = {}) {
|
|
86
|
-
const versionsRoot = path.join(installRoot, "versions");
|
|
87
|
-
const removed = []; const skipped = []; let reclaimedBytes = 0;
|
|
88
|
-
let running = null;
|
|
89
|
-
const inUse = (dir) => {
|
|
90
|
-
if (platform === "win32") return false; // Windows: a loaded image or DLL makes the rename below fail.
|
|
91
|
-
running ??= runningExecutables ?? listRunningExecutables();
|
|
92
|
-
return running.some((exe) => isWithin(dir, exe, platform));
|
|
93
|
-
};
|
|
94
|
-
const candidates = [];
|
|
95
|
-
for (const [dir, pattern] of [[versionsRoot, /^(dev-[0-9a-f]+|\.stage-.+|\.prune-.+)$/], [installRoot, /^\.(current-backup|prune)-.+$/]]) {
|
|
96
|
-
let names = [];
|
|
97
|
-
try { names = fs.readdirSync(dir); } catch { continue; }
|
|
98
|
-
for (const name of names) if (pattern.test(name)) candidates.push(path.join(dir, name));
|
|
99
|
-
}
|
|
100
|
-
for (const candidate of candidates) {
|
|
101
|
-
if (samePath(candidate, keepRoot, platform)) continue;
|
|
102
|
-
if (path.basename(candidate).includes(`-${process.pid}-`)) continue; // this process's own stage/backup
|
|
103
|
-
try {
|
|
104
|
-
if (inUse(candidate)) { skipped.push({ path: candidate, reason: "in-use" }); continue; }
|
|
105
|
-
const bytes = treeBytes(candidate);
|
|
106
|
-
// Rename first: on Windows this fails while any file inside is loaded, so a running version is skipped whole.
|
|
107
|
-
const doomed = path.basename(candidate).startsWith(".prune-") ? candidate : path.join(path.dirname(candidate), `.prune-${randomSuffix()}`);
|
|
108
|
-
if (doomed !== candidate) fs.renameSync(candidate, doomed);
|
|
109
|
-
fs.rmSync(doomed, { recursive: true, force: true });
|
|
110
|
-
removed.push(candidate); reclaimedBytes += bytes;
|
|
111
|
-
} catch (error) {
|
|
112
|
-
skipped.push({ path: candidate, reason: error?.code ?? error?.message ?? String(error) });
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
return { kept: keepRoot, removed: removed.length, skipped, reclaimedBytes };
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function listRunningExecutables() {
|
|
119
|
-
const result = spawnSync("ps", ["-axo", "comm="], { encoding: "utf8" });
|
|
120
|
-
return result.status === 0 ? result.stdout.split(/\r?\n/).map((line) => line.trim()).filter((line) => path.isAbsolute(line)) : [];
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function treeBytes(target) {
|
|
124
|
-
let total = 0;
|
|
125
|
-
const stat = fs.lstatSync(target);
|
|
126
|
-
if (!stat.isDirectory()) return stat.size;
|
|
127
|
-
for (const entry of fs.readdirSync(target, { withFileTypes: true })) {
|
|
128
|
-
const child = path.join(target, entry.name);
|
|
129
|
-
total += entry.isDirectory() && !entry.isSymbolicLink() ? treeBytes(child) : fs.lstatSync(child).size;
|
|
130
|
-
}
|
|
131
|
-
return total;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
67
|
function validateDescriptor(descriptor, root, platform) {
|
|
135
68
|
if (!descriptor || descriptor.kind !== "portable" || descriptor.activation?.kind !== "current") throw new Error("unsupported portable development descriptor");
|
|
136
69
|
if (typeof root !== "string" || !path.isAbsolute(root)) throw new Error("repository root must be absolute");
|
|
@@ -214,24 +147,8 @@ function copyTree(source, target) {
|
|
|
214
147
|
}
|
|
215
148
|
}
|
|
216
149
|
|
|
217
|
-
function validateCurrent(current, installRoot, platform) {
|
|
218
|
-
if (!exists(current)) return;
|
|
219
|
-
const stat = fs.lstatSync(current);
|
|
220
|
-
if (!stat.isSymbolicLink()) throw new Error(`development current pointer is not a symlink or junction; refusing to replace: ${current}`);
|
|
221
|
-
let target;
|
|
222
|
-
try { target = fs.realpathSync(current); } catch (error) { throw new Error(`development current pointer is dangling or unreadable: ${current}`, { cause: error }); }
|
|
223
|
-
const versionsRoot = path.join(installRoot, "versions");
|
|
224
|
-
const versionName = path.basename(target);
|
|
225
|
-
if (!isWithin(fs.realpathSync(installRoot), target, platform)
|
|
226
|
-
|| !samePath(path.dirname(target), fs.realpathSync(versionsRoot), platform)
|
|
227
|
-
|| !versionName || versionName === "." || versionName === "..") {
|
|
228
|
-
throw new Error(`development current pointer does not resolve to an owned version: ${current}`);
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
|
|
232
150
|
function removeIfExists(target) {
|
|
233
151
|
if (!exists(target)) return;
|
|
234
152
|
fs.rmSync(target, { recursive: true, force: true });
|
|
235
153
|
}
|
|
236
154
|
|
|
237
|
-
function randomSuffix() { return `${Date.now()}-${Math.random().toString(16).slice(2)}`; }
|
package/preflight.mjs
CHANGED
|
@@ -341,13 +341,11 @@ export function ownershipCheck(repoRoot, options = {}) {
|
|
|
341
341
|
try { checked = (options.evidenceFactory ?? createOwnershipEvidence)(repoRoot, options.scanRoot ?? repoRoot); }
|
|
342
342
|
catch (error) { return fail("ownership", `ownership evidence failed: ${error.message}`, "restore protected RightKit ownership trust and catalog"); }
|
|
343
343
|
const { result, evidence, floor } = checked;
|
|
344
|
-
if (checked.enrollmentPending) {
|
|
345
|
-
const diagnostics = [...(result.problems ?? []), ...(result.violations ?? []).map((entry) => `${entry.fingerprint} at ${entry.file}${entry.line ? `:${entry.line}` : ""}`)];
|
|
346
|
-
return { ...warn("ownership", `${checked.warning}${diagnostics.length ? `; ${diagnostics.length} diagnostic finding/problem(s) retained` : ""}`, null), evidence, diagnostics, result, floor };
|
|
347
|
-
}
|
|
348
344
|
if (checked.ok) {
|
|
349
345
|
const excepted = result.covered.length;
|
|
350
|
-
|
|
346
|
+
const detail = excepted ? `no unapproved copies (${excepted} excepted findings)` : "no copies of RightKit-owned capabilities";
|
|
347
|
+
// Pending approval enrollment is a warning only (#262); real findings still fail below.
|
|
348
|
+
return checked.enrollmentPending ? { ...warn("ownership", `${detail}; ${checked.warning}`, null), evidence, result, floor } : { ...ok("ownership", detail), evidence };
|
|
351
349
|
}
|
|
352
350
|
const first = result.violations[0];
|
|
353
351
|
const detail = !floor.ok
|
|
@@ -355,7 +353,7 @@ export function ownershipCheck(repoRoot, options = {}) {
|
|
|
355
353
|
: first
|
|
356
354
|
? `${result.violations.length} own cop${result.violations.length === 1 ? "y" : "ies"} of RightKit capabilities, e.g. ${first.fingerprint} at ${first.file}${first.line ? `:${first.line}` : ""}`
|
|
357
355
|
: result.problems[0] ?? result.coverage[0]?.reason ?? "ownership scan incomplete";
|
|
358
|
-
return { ...fail("ownership", detail, "run: right-release ownership (move to RightKit, or
|
|
356
|
+
return { ...fail("ownership", detail, "run: right-release ownership (move to RightKit, or add a dated entry to the repo exceptions file)"), evidence };
|
|
359
357
|
}
|
|
360
358
|
|
|
361
359
|
export function formatPreflight(checks) {
|
package/project-type.mjs
CHANGED
|
@@ -40,4 +40,4 @@ export function targetUsesMsix(target) {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
/** SwiftPM targets build and package inside prePackage (mac DMG) or the MSIX step; no separate `package` command is required. */
|
|
43
|
-
export const packageCommandOptional = (target, projectType) => targetUsesMsix(target) || (projectType === "swiftpm" && Boolean(target?.prePackage?.cmd));
|
|
43
|
+
export const packageCommandOptional = (target, projectType) => targetUsesMsix(target) || Boolean(target?.winPackage || target?.macPackage) || (projectType === "swiftpm" && Boolean(target?.prePackage?.cmd));
|
|
@@ -3,6 +3,7 @@ import { readFileSync, statSync, openSync, fstatSync, readSync, closeSync } from
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { verifySealedRelease } from "./release-state.mjs";
|
|
5
5
|
import { verifyOwnershipEvidence } from "./ownership-evidence.mjs";
|
|
6
|
+
import { validateMediaSidecarReceipt } from "./media-sidecar.mjs";
|
|
6
7
|
|
|
7
8
|
export class PublicationError extends Error {
|
|
8
9
|
constructor(code, message) { super(`[${code}] ${message}`); this.name = "PublicationError"; this.code = code; }
|
|
@@ -55,15 +56,25 @@ export function verifyPublicationRelease({ sealedDir, repoRoot, appRoot, commit,
|
|
|
55
56
|
if (!ownership.ok || manifest.ownershipEvidence?.commit !== commit) throw new PublicationError("PUBLICATION_OWNERSHIP_INVALID", `sealed ownership evidence rejected: ${ownership.reason ?? "commit mismatch"}`);
|
|
56
57
|
if (!manifest.checkpoints?.includes("signed") || !manifest.checkpoints?.includes("hardened")) throw new PublicationError("PUBLICATION_SIGNATURE_MISSING", "signed & hardened seal checkpoints are required");
|
|
57
58
|
if (configPath && digestBytes(readFileSync(configPath)) !== manifest.signing?.configSha256) throw new PublicationError("PUBLICATION_POLICY_MISMATCH", "release config differs from sealed signing policy");
|
|
59
|
+
if (manifest.mediaSidecars !== undefined) {
|
|
60
|
+
try { validateMediaSidecarReceipt(manifest.mediaSidecars); }
|
|
61
|
+
catch (error) { throw new PublicationError("PUBLICATION_SIGNATURE_INVALID", error.message); }
|
|
62
|
+
}
|
|
58
63
|
const receipts = manifest.signing?.receipts;
|
|
59
64
|
if (!Array.isArray(receipts) || !receipts.length) throw new PublicationError("PUBLICATION_SIGNATURE_MISSING", "sealed signing receipts are required");
|
|
60
65
|
let notarized = false;
|
|
61
66
|
let signed = false;
|
|
67
|
+
let androidVerified = false;
|
|
62
68
|
for (const receipt of receipts) {
|
|
63
69
|
const artifact = readVerifiedArtifact(path.resolve(appRoot, receipt.file), receipt);
|
|
64
70
|
let value;
|
|
65
71
|
try { value = JSON.parse(artifact.bytes); } catch { throw new PublicationError("PUBLICATION_SIGNATURE_INVALID", "invalid sealed signing receipt"); }
|
|
66
72
|
if (value.schema !== 1) throw new PublicationError("PUBLICATION_SIGNATURE_INVALID", "malformed sealed signing receipt");
|
|
73
|
+
if (value.contract !== undefined) {
|
|
74
|
+
verifyAndroidPublicationReceipt(value, manifest, platform);
|
|
75
|
+
androidVerified = true; signed = true;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
67
78
|
if (Array.isArray(value.files)) {
|
|
68
79
|
if (!value.files.length || value.files.some((item) => !/^[a-f0-9]{64}$/.test(item.after?.sha256 ?? "") || item.timestampPresent !== true
|
|
69
80
|
|| (platform === "win" ? item.authenticode !== "Valid" || item.subject !== "CN=Damned Ventures LLC" : item.codesign !== "strict-valid" || item.identity !== value.identity))) throw new PublicationError("PUBLICATION_SIGNATURE_INVALID", "sealed signature verification evidence rejected");
|
|
@@ -81,6 +92,7 @@ export function verifyPublicationRelease({ sealedDir, repoRoot, appRoot, commit,
|
|
|
81
92
|
}
|
|
82
93
|
if (!signed) throw new PublicationError("PUBLICATION_SIGNATURE_MISSING", "sealed native signing evidence is required");
|
|
83
94
|
if (platform === "mac" && !notarized) throw new PublicationError("PUBLICATION_NOTARIZATION_MISSING", "sealed notarization evidence is required");
|
|
95
|
+
if (platform === "android" && !androidVerified) throw new PublicationError("PUBLICATION_SIGNATURE_MISSING", "sealed android AAB upload-key receipt is required");
|
|
84
96
|
const artifacts = new Map();
|
|
85
97
|
for (const entry of manifest.files ?? []) {
|
|
86
98
|
if (typeof entry.name !== "string" || path.basename(entry.name) !== entry.name || artifacts.has(entry.name)) throw new PublicationError("PUBLICATION_SEAL_INVALID", "unsafe or duplicate sealed artifact name");
|
|
@@ -90,6 +102,28 @@ export function verifyPublicationRelease({ sealedDir, repoRoot, appRoot, commit,
|
|
|
90
102
|
return { ...sealed, artifacts };
|
|
91
103
|
}
|
|
92
104
|
|
|
105
|
+
// Android AAB receipt (android-release.mjs: contract "android-aab-upload-key-v1"). The receipt was already read from the
|
|
106
|
+
// manifest's signing.receipts and hash-verified by the caller; here it must describe exactly the sealed "aab" file and be
|
|
107
|
+
// signed by the pinned upload certificate. Fails closed with a specific message on any mismatch.
|
|
108
|
+
const ANDROID_RECEIPT_CONTRACT = "android-aab-upload-key-v1";
|
|
109
|
+
function verifyAndroidPublicationReceipt(value, manifest, platform) {
|
|
110
|
+
const invalid = (message) => { throw new PublicationError("PUBLICATION_SIGNATURE_INVALID", message); };
|
|
111
|
+
if (value.contract !== ANDROID_RECEIPT_CONTRACT) invalid(`unrecognized signing receipt contract: ${JSON.stringify(value.contract)}`);
|
|
112
|
+
if (platform !== "android" || manifest.platform !== "android") invalid("android AAB receipt in a non-android release");
|
|
113
|
+
const signer = value.signer?.certSha256;
|
|
114
|
+
if (!/^[a-f0-9]{64}$/.test(signer ?? "")) invalid("android AAB receipt has no signer certificate sha256");
|
|
115
|
+
if (value.signer.debugCertificate === true || /Android Debug/i.test(String(value.signer.subject ?? ""))) invalid("android AAB receipt is signed with the Android debug certificate");
|
|
116
|
+
const pinned = manifest.signing?.uploadCertSha256;
|
|
117
|
+
if (!/^[a-f0-9]{64}$/.test(pinned ?? "")) invalid("sealed signing policy has no pinned android upload certificate");
|
|
118
|
+
if (signer !== pinned) invalid("android AAB signer certificate is not the pinned upload certificate");
|
|
119
|
+
const aabs = (manifest.files ?? []).filter((entry) => entry.role === "aab");
|
|
120
|
+
if (aabs.length !== 1) invalid(aabs.length ? "sealed release has more than one aab entry" : "sealed release has no aab entry");
|
|
121
|
+
const [aab] = aabs;
|
|
122
|
+
if (!/^[a-f0-9]{64}$/.test(value.file?.sha256 ?? "") || value.file.sha256 !== aab.sha256) invalid("android AAB receipt sha256 differs from the sealed aab entry");
|
|
123
|
+
if (!Number.isInteger(value.file.sizeBytes) || value.file.sizeBytes !== aab.sizeBytes) invalid("android AAB receipt size differs from the sealed aab entry");
|
|
124
|
+
if (value.file.name !== aab.name) invalid("android AAB receipt names a different file than the sealed aab entry");
|
|
125
|
+
}
|
|
126
|
+
|
|
93
127
|
// Bounded memory for large artifacts. Transport must read back against this digest.
|
|
94
128
|
export function verifyArtifactFile(file, evidence) {
|
|
95
129
|
if (!/^[a-f0-9]{64}$/.test(evidence?.sha256 ?? "")) throw new PublicationError("PUBLICATION_SEAL_INVALID", "publication hash evidence is required");
|