@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.
Files changed (86) hide show
  1. package/README.md +121 -4
  2. package/addon-command.mjs +6 -13
  3. package/android-aab-scan.mjs +265 -0
  4. package/android-dev.mjs +111 -0
  5. package/android-ndk.mjs +77 -0
  6. package/android-play-upload.mjs +262 -0
  7. package/android-release.mjs +344 -0
  8. package/android-test-fixtures.mjs +79 -0
  9. package/apple-profile-create.mjs +363 -0
  10. package/bin/darwin/rightkit-swap +0 -0
  11. package/bin/darwin/rightkit-swap.sign.json +6 -0
  12. package/build-release.mjs +73 -13
  13. package/cargo-guard.mjs +2 -0
  14. package/cargo-profile-contract.mjs +183 -0
  15. package/cargo-target.mjs +8 -0
  16. package/cli/right-release.mjs +16 -4
  17. package/committed-source.mjs +1 -1
  18. package/dev-artifact-refresh.mjs +176 -0
  19. package/dev-artifact-support.mjs +61 -0
  20. package/development-signing.mjs +71 -4
  21. package/development.mjs +93 -47
  22. package/direct-bootstrap.mjs +26 -41
  23. package/disk-command.mjs +298 -5
  24. package/disk-policy.json +7 -5
  25. package/disk-policy.mjs +105 -16
  26. package/disk-stable-root.mjs +19 -16
  27. package/engine-locator.mjs +116 -0
  28. package/external-bin.mjs +133 -0
  29. package/github-release.mjs +2 -2
  30. package/github-updater-release.mjs +154 -0
  31. package/hardeningscan.mjs +1 -1
  32. package/in-place-install.mjs +56 -0
  33. package/install-app-windows.mjs +63 -0
  34. package/install-app.mjs +53 -23
  35. package/install-tauri-devid.mjs +172 -0
  36. package/inventory-check.mjs +1 -1
  37. package/local-publish.mjs +11 -1
  38. package/mac-package-recipe.mjs +304 -0
  39. package/mac-signing-matrix.mjs +23 -11
  40. package/managed-cargo.mjs +6 -0
  41. package/media-sidecar-catalog.mjs +45 -0
  42. package/media-sidecar.mjs +306 -0
  43. package/ownership-approval-command.mjs +10 -44
  44. package/ownership-approval.mjs +3 -42
  45. package/ownership-catalog-config.json +2 -1
  46. package/ownership-catalog.mjs +4 -2
  47. package/ownership-command.mjs +7 -9
  48. package/ownership-dependency-allowlist.json +25 -1
  49. package/ownership-evidence.mjs +8 -5
  50. package/ownership-git-inventory.mjs +41 -7
  51. package/ownership-lockfiles.mjs +91 -8
  52. package/ownership-mandatory-set.json +36 -16
  53. package/ownership-report.mjs +5 -6
  54. package/ownership-scan-worker.mjs +2 -2
  55. package/ownership-verdict.mjs +10 -7
  56. package/ownership.mjs +47 -29
  57. package/package-recipe-targets.mjs +95 -0
  58. package/package.json +7 -4
  59. package/portable-development.mjs +32 -115
  60. package/preflight.mjs +4 -6
  61. package/project-type.mjs +1 -1
  62. package/publication-artifacts.mjs +34 -0
  63. package/publish-update.mjs +72 -11
  64. package/qa-adoption-exemptions.json +5 -0
  65. package/qa-build-features.mjs +7 -0
  66. package/qa-contract.mjs +182 -0
  67. package/qa-control-scan.mjs +3 -3
  68. package/release-qa-command.mjs +38 -12
  69. package/release-qa-installed.mjs +136 -0
  70. package/release-qa-receipt.mjs +23 -2
  71. package/release.mjs +285 -14
  72. package/rightkit-ownership.json +537 -5
  73. package/rightkit-sync.mjs +27 -1
  74. package/rightkit-versions.json +111 -63
  75. package/runtime-budget-classes.json +28 -0
  76. package/runtime-budgets.mjs +4 -1
  77. package/sign-candidate.mjs +161 -0
  78. package/sign-dev-artifact.mjs +198 -0
  79. package/stage-binary.mjs +60 -0
  80. package/stage-sidecar-command.mjs +41 -0
  81. package/stage-sidecar.mjs +424 -0
  82. package/swap-helper-bundle.mjs +105 -0
  83. package/swift-mac-dmg.mjs +2 -1
  84. package/templates/cargo-profiles.toml +28 -0
  85. package/upload-release.mjs +11 -1
  86. 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, policyEntryDigest, resolveKeyLogTrust, sha256, verifyException, verifyProtectedProvenance } from "./ownership-approval.mjs";
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
- const matchers = { digest, sources, prefilter };
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 today = options.today ?? new Date().toISOString().slice(0, 10), problems = [], covered = [], violations = [], warnings = [];
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 p = e?.payload ?? e ?? {};
685
- const where = `exception #${e?.index} (${p.fingerprint ?? "?"})`;
686
- const missing = ["fingerprint", "occurrences", "policyDigest", "minimumScannerVersion", "minimumCatalogVersion", "issued", "reason", "owner", "expires", "requestId"].filter((k) => !p[k] || (Array.isArray(p[k]) && !p[k].length));
687
- if (missing.length) { problems.push(`${where}: missing ${missing.join(", ")}`); continue; }
688
- const approvedBy = e.approvedBy ?? p.approvedBy;
689
- if (approvedBy !== undefined && approvedBy !== "Adrian") { problems.push(`${where}: approvedBy must be Adrian`); continue; }
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; detected findings remain open backlog requests until distinct Windows & macOS keys are enrolled");
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.enrollmentPending && !result.violations.some(isBlockingFinding) && !result.problems.length && !coverage.length, rows, ...result, findings: scanned.findings, coverage, metadata };
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.enrollmentPending && !result.violations.some(isBlockingFinding) && !result.problems.length && !coverage.length, rows, ...result, findings: scanned.findings, coverage, metadata };
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.165",
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/Orthic-Labs/rightkit.git",
41
+ "url": "git+https://github.com/bogusyogi/rightkit.git",
39
42
  "directory": "packages/release"
40
43
  },
41
44
  "dependencies": {
42
- "smol-toml": "1.9.0"
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",
@@ -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 { spawnSync } from "./background-process.mjs";
4
+ import { previousPathOf, stagingPathOf, swapInPlace } from "./in-place-install.mjs";
5
5
 
6
6
  const CHUNK_BYTES = 1024 * 1024;
7
7
 
8
- /** Install an unsigned development payload through an owned, versioned `current` pointer. */
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 })}\n`);
11
- let backup = null;
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
- const sourceDigest = digestTree(source);
31
- digest = sourceDigest;
32
- versionRoot = path.join(installRoot, "versions", `dev-${digest}`);
33
+ digest = digestTree(source);
33
34
  current = path.join(installRoot, "current");
34
- emit("STAGE", { sourceDirectory: source, versionRoot, digest });
35
-
36
- fs.mkdirSync(path.dirname(versionRoot), { recursive: true });
37
- if (exists(versionRoot)) {
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
- validateCurrent(current, installRoot, platform);
52
- if (exists(current)) {
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
- fs.symlinkSync(versionRoot, current, platform === "win32" ? "junction" : "dir");
58
- switched = true;
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
- if (switched) removeIfExists(current);
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, versionRoot, digest, message: error?.message ?? String(error) });
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
- return { ...ok("ownership", excepted ? `no unapproved copies (${excepted} signed findings)` : "no copies of RightKit-owned capabilities"), evidence };
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 request a signed, dated exception)"), evidence };
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");