@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
@@ -0,0 +1,183 @@
1
+ // RightKit standard Cargo profile contract (Adrian, 2026-10-16; Astra-reviewed option C).
2
+ //
3
+ // Every app workspace-root Cargo.toml carries the tables of templates/cargo-profiles.toml verbatim:
4
+ // - [profile.dev] and [profile.dev.package."*"] equal the template (no missing, changed or extra keys),
5
+ // - panic = "unwind" in dev and release,
6
+ // - no [profile.release-iterate] (and nothing that inherits it),
7
+ // - no [profile.test] overrides (test inherits dev).
8
+ // The same scan also flags the two source patterns the single dev profile makes wrong, because a dev
9
+ // build now has debug_assertions on:
10
+ // - `cfg_attr(not(debug_assertions), windows_subsystem = ...)`: an installed dev app would open a
11
+ // console window on Windows. Set windows_subsystem = "windows" unconditionally.
12
+ // - an environment-variable override gated by cfg(debug_assertions): move it to the app's test-mode
13
+ // feature. Heuristic: a cfg(debug_assertions) attribute or cfg! within 12 lines before an env read.
14
+ // Suppress a reviewed site with a `rightkit-allow: debug-assertions-env` comment on the cfg line or
15
+ // the line above it.
16
+ // The template file is the single source: this module reads it, it never copies the values.
17
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
18
+ import path from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+ import { parse } from "smol-toml";
21
+ import { findCargoManifests, isExcludedScanDirectory } from "./cargo-contract.mjs";
22
+
23
+ export const CARGO_PROFILE_TEMPLATE = fileURLToPath(new URL("./templates/cargo-profiles.toml", import.meta.url));
24
+ export const RETIRED_PROFILE = "release-iterate";
25
+ const ENV_ALLOW_MARKER = "rightkit-allow: debug-assertions-env";
26
+ const ENV_WINDOW_LINES = 12;
27
+ // Never part of an app's shipped source: tests, examples, benches and vendored reference copies.
28
+ const SOURCE_SKIP = new Set(["tests", "examples", "benches", "reference"]);
29
+ const ENV_READ = /env::var|var_os|env::set_var|env::remove_var|\benv!\s*\(|option_env!|_env\(|env_var|env_nonempty/;
30
+ const WINDOWS_SUBSYSTEM_GATED = /cfg_attr\s*\((?:(?!\]).)*?not\s*\(\s*debug_assertions\s*\)(?:(?!\]).)*?windows_subsystem/gs;
31
+
32
+ export function loadCargoProfileTemplate(file = CARGO_PROFILE_TEMPLATE) {
33
+ const profile = parse(readFileSync(file, "utf8")).profile ?? {};
34
+ const { package: packages = {}, ...dev } = profile.dev ?? {};
35
+ return { dev, devPackageStar: packages["*"] ?? {}, releasePanic: profile.release?.panic };
36
+ }
37
+
38
+ const isTable = (value) => value && typeof value === "object" && !Array.isArray(value);
39
+ const show = (value) => JSON.stringify(value);
40
+
41
+ // Compare one table with the template: every template key must match, and no other key may exist.
42
+ function compareTable(actual, expected, tableName, finding) {
43
+ if (!isTable(actual)) { finding("profile-missing", `${tableName} is missing`); return; }
44
+ for (const [key, value] of Object.entries(expected)) {
45
+ if (!(key in actual)) finding("profile-key", `${tableName} is missing ${key} = ${show(value)}`);
46
+ else if (actual[key] !== value) finding("profile-key", `${tableName} has ${key} = ${show(actual[key])}; the template requires ${show(value)}`);
47
+ }
48
+ for (const key of Object.keys(actual)) if (!(key in expected)) finding("profile-extra", `${tableName} has ${key}, which is not in the template`);
49
+ }
50
+
51
+ // Findings for one workspace-root manifest (already parsed).
52
+ export function checkManifestProfiles(manifest, template, file) {
53
+ const findings = [];
54
+ const finding = (rule, message) => findings.push({ rule, file, message });
55
+ const profiles = isTable(manifest.profile) ? manifest.profile : {};
56
+ const dev = isTable(profiles.dev) ? profiles.dev : undefined;
57
+ const { package: devPackages, ...devRest } = dev ?? {};
58
+ // Subtables of [profile.dev] are checked separately below.
59
+ const devKeys = Object.fromEntries(Object.entries(devRest).filter(([, value]) => !isTable(value)));
60
+ compareTable(dev ? devKeys : undefined, template.dev, "[profile.dev]", finding);
61
+ compareTable(isTable(devPackages) ? devPackages["*"] : undefined, template.devPackageStar, '[profile.dev.package."*"]', finding);
62
+ if (isTable(devPackages)) for (const name of Object.keys(devPackages)) if (name !== "*") finding("profile-extra", `[profile.dev.package.${show(name)}] is not in the template`);
63
+ if (dev) for (const name of Object.keys(dev)) if (isTable(dev[name]) && name !== "package") finding("profile-extra", `[profile.dev.${name}] is not in the template`);
64
+ // [profile.dev] panic is checked with the rest of the dev table above.
65
+ if (profiles.release?.panic !== template.releasePanic) finding("panic", `[profile.release] panic must be ${show(template.releasePanic)} (found ${show(profiles.release?.panic)})`);
66
+ if (RETIRED_PROFILE in profiles) finding("retired-profile", `[profile.${RETIRED_PROFILE}] is retired; delete it and its package overrides`);
67
+ for (const [name, table] of Object.entries(profiles)) {
68
+ if (isTable(table) && table.inherits === RETIRED_PROFILE) finding("retired-profile", `[profile.${name}] inherits the retired ${RETIRED_PROFILE} profile`);
69
+ }
70
+ if ("test" in profiles) finding("profile-extra", "[profile.test] overrides are not allowed; test inherits dev");
71
+ return findings;
72
+ }
73
+
74
+ // Non-shipping workspaces: fixed prefixes plus the app's declared `cargoProfileExempt` directories.
75
+ const NON_SHIPPING_PREFIXES = ["qa", "benchmarks"];
76
+ const CARGO_PROFILE_EXEMPT_KEY = "cargoProfileExempt";
77
+ const RELEASE_CONFIG = "right-release.config.mjs";
78
+ const STRING_LITERAL = /"([^"\\\n]*)"|'([^'\\\n]*)'/g;
79
+
80
+ // release.mjs imports the app's release config as an ES module. This scanner is synchronous and has only the
81
+ // app root, so it reads the literal `cargoProfileExempt` array from the same file: every entry must be a plain
82
+ // string literal naming a directory inside the app. Anything else is a finding, never silently ignored.
83
+ export function readCargoProfileExempt(root) {
84
+ const file = path.join(root, RELEASE_CONFIG);
85
+ const findings = [];
86
+ if (!existsSync(file)) return { paths: [], findings };
87
+ const match = new RegExp(`\\b${CARGO_PROFILE_EXEMPT_KEY}\\s*:\\s*\\[([^\\]]*)\\]`).exec(readFileSync(file, "utf8"));
88
+ if (!match) return { paths: [], findings };
89
+ const body = match[1];
90
+ if (body.replace(STRING_LITERAL, "").replace(/[,\s]/g, "") !== "") {
91
+ findings.push({ rule: "invalid-exempt", file: RELEASE_CONFIG, message: `${CARGO_PROFILE_EXEMPT_KEY} must be an array of string literals` });
92
+ }
93
+ const paths = [];
94
+ for (const [, double, single] of body.matchAll(STRING_LITERAL)) {
95
+ const entry = double ?? single;
96
+ const normalized = path.posix.normalize(entry.replace(/\\/g, "/")).replace(/\/+$/, "");
97
+ if (normalized === "." || normalized === "" || path.posix.isAbsolute(normalized) || normalized.split("/").includes("..")) {
98
+ findings.push({ rule: "invalid-exempt", file: RELEASE_CONFIG, message: `${CARGO_PROFILE_EXEMPT_KEY} entry ${JSON.stringify(entry)} must be a relative directory inside the app` });
99
+ } else paths.push(normalized);
100
+ }
101
+ return { paths, findings };
102
+ }
103
+
104
+ const dirOf = (file) => path.posix.dirname(file);
105
+ const isWithinDir = (dir, ancestor) => ancestor === "." || dir === ancestor || dir.startsWith(`${ancestor}/`);
106
+
107
+ // The app's profile roots. Cargo reads profiles only from a workspace root, and a shipping app can ship binaries
108
+ // from several independent workspaces, so every manifest with a top-level [workspace] table is a root, and so is
109
+ // every standalone package manifest that no workspace contains. Only declared non-shipping directories are exempt:
110
+ // the fixed qa/ and benchmarks/ prefixes plus `cargoProfileExempt`. Exempt workspaces still count as containers.
111
+ export function findProfileRoots(root, manifests = findCargoManifests(root), declared = readCargoProfileExempt(root)) {
112
+ const parsed = [];
113
+ const findings = [...declared.findings];
114
+ for (const manifest of manifests) {
115
+ const file = path.relative(root, manifest).split(path.sep).join("/");
116
+ try { parsed.push({ manifest, file, toml: parse(readFileSync(manifest, "utf8")) }); }
117
+ catch (error) { findings.push({ rule: "invalid-toml", file, message: `is not valid TOML: ${error.message}` }); }
118
+ }
119
+ const exemptPrefixes = [...NON_SHIPPING_PREFIXES, ...declared.paths];
120
+ const isWorkspace = ({ toml }) => isTable(toml.workspace);
121
+ const roots = parsed.filter((entry) => {
122
+ if (exemptPrefixes.some((prefix) => entry.file.startsWith(`${prefix}/`))) return false;
123
+ if (isWorkspace(entry)) return true;
124
+ return !parsed.some((other) => other !== entry && isWorkspace(other) && isWithinDir(dirOf(entry.file), dirOf(other.file)));
125
+ });
126
+ return { roots, findings };
127
+ }
128
+
129
+ function* rustFiles(dir) {
130
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
131
+ const full = path.join(dir, entry.name);
132
+ if (entry.isDirectory()) {
133
+ if (isExcludedScanDirectory(entry.name, path.basename(dir)) || SOURCE_SKIP.has(entry.name)) continue;
134
+ yield* rustFiles(full);
135
+ } else if (entry.isFile() && entry.name.endsWith(".rs")) yield full;
136
+ }
137
+ }
138
+
139
+ // Source patterns the single dev profile makes wrong (see the header). Returns findings with a line number.
140
+ export function scanDebugAssertionSources(root) {
141
+ const findings = [];
142
+ for (const file of rustFiles(root)) {
143
+ const text = readFileSync(file, "utf8");
144
+ if (!text.includes("debug_assertions")) continue;
145
+ const label = path.relative(root, file).split(path.sep).join("/");
146
+ const lineAt = (index) => text.slice(0, index).split("\n").length;
147
+ for (const match of text.matchAll(WINDOWS_SUBSYSTEM_GATED)) {
148
+ findings.push({ rule: "windows-subsystem", file: label, line: lineAt(match.index), message: 'cfg_attr(not(debug_assertions), windows_subsystem = ...) opens a console for installed dev apps; set windows_subsystem = "windows" unconditionally' });
149
+ }
150
+ const lines = text.split("\n");
151
+ lines.forEach((line, index) => {
152
+ const trimmed = line.trim();
153
+ if (trimmed.startsWith("//") || !line.includes("debug_assertions") || line.includes("windows_subsystem")) return;
154
+ if (!/#!?\[cfg|cfg!\s*\(/.test(line)) return;
155
+ if (line.includes(ENV_ALLOW_MARKER) || (lines[index - 1] ?? "").includes(ENV_ALLOW_MARKER)) return;
156
+ if (ENV_READ.test(lines.slice(index, index + ENV_WINDOW_LINES + 1).join("\n"))) {
157
+ findings.push({ rule: "debug-assertions-env", file: label, line: index + 1, message: "environment-variable override gated by cfg(debug_assertions); move it to the app's test-mode feature" });
158
+ }
159
+ });
160
+ }
161
+ return findings;
162
+ }
163
+
164
+ export function scanCargoProfileContract(root, { template = loadCargoProfileTemplate(), sources = true } = {}) {
165
+ const scanRoot = path.resolve(root);
166
+ const { roots, findings } = findProfileRoots(scanRoot);
167
+ for (const { toml, file } of roots) findings.push(...checkManifestProfiles(toml, template, file));
168
+ if (sources && existsSync(scanRoot)) findings.push(...scanDebugAssertionSources(scanRoot));
169
+ return findings;
170
+ }
171
+
172
+ export function formatCargoProfileFindings(findings, label) {
173
+ return findings.map(({ rule, file, line, message }) => `${label} ${file}${line ? `:${line}` : ""} [${rule}] ${message}`);
174
+ }
175
+
176
+ // Throws one error listing every finding for the app; returns 0 when the app conforms.
177
+ export function assertCargoProfileContract(root, label = path.basename(path.resolve(root)), options) {
178
+ const findings = scanCargoProfileContract(root, options);
179
+ if (findings.length) {
180
+ throw new Error(`${label} breaks the RightKit Cargo profile contract (templates/cargo-profiles.toml):\n${formatCargoProfileFindings(findings, label).join("\n")}`);
181
+ }
182
+ return 0;
183
+ }
package/cargo-target.mjs CHANGED
@@ -9,6 +9,14 @@
9
9
  // (see cargo-contract.mjs) remains a separate, cache-policy-only decision:
10
10
  // whether Cache V2's lease/slot/prune/floor machinery should run at all, not
11
11
  // where the target directory lives.
12
+ //
13
+ // NOT A STABLE PATH. On a broker-managed host the directory this reports is
14
+ // `<managed>/rightkit-build-control/ws/<hash>/g/<generation>/target`, and the generation
15
+ // changes over time; `cargo-guard` uses a hashed `dev-targets/<project>-<hash>` folder.
16
+ // Both are build output only. Nothing may run from them: macOS ties privacy grants to the
17
+ // path an app runs from. Dev and QA binaries are copied in place to a fixed staged path
18
+ // (`development.targets.<platform>.stage`, `stage-binary.mjs`; rightkit-qa's
19
+ // `.rightkit-qa-bin` stash) and run from there.
12
20
  import { realpathSync } from "node:fs";
13
21
  import { execFileSync } from "./background-process.mjs";
14
22
  import { runCargoSync } from "./managed-cargo.mjs";
@@ -18,6 +18,10 @@ const commands = new Map([
18
18
  ["hardeningscan", "hardeningscan.mjs"],
19
19
  ["sign-windows", "sign-windows.mjs"],
20
20
  ["sign-updater", "sign-updater.mjs"],
21
+ ["sign-dev-artifact", "sign-dev-artifact.mjs"],
22
+ ["sign-candidate", "sign-candidate.mjs"],
23
+ ["engine-locate", "engine-locator.mjs"],
24
+ ["stage-sidecar", "stage-sidecar-command.mjs"],
21
25
  ["signing-agent", "signing-agent-command.mjs"],
22
26
  ["create-mac-updater", "create-mac-updater.mjs"],
23
27
  ["mirror-root-artifact", "mirror-root-artifact.mjs"],
@@ -31,6 +35,7 @@ const commands = new Map([
31
35
  ["swift-package", "swift-package-command.mjs"],
32
36
  ["bundle-scan", "runtime-bundle-scan.mjs"],
33
37
  ["identity-scan", "packaged-app-identity.mjs"],
38
+ ["apple-profile-create", "apple-profile-create.mjs"],
34
39
  ]);
35
40
 
36
41
  const args = process.argv.slice(2);
@@ -177,12 +182,16 @@ function printHelp() {
177
182
  console.log(`right-release <command> [options]
178
183
 
179
184
  Commands:
185
+ stage-sidecar [--config <file>] [--target <triple>] [--json] Stage declarative sidecars into src-tauri/binaries (Tauri externalBin naming)
180
186
  dmg build --app <.app> --out <dmg> --volname <name> [--background <png>] [--spec <appdmg.json>]
181
187
  [--icon-size N] [--app-position x,y] [--applications-position x,y] [--window w,h] [--window-position x,y]
182
188
  Build branded UDZO image without Finder
183
189
  install-app --dmg <path.dmg> [--app-name Pulse.app] [--bundle-id <id>] [--dest /Applications]
184
190
  [--relaunch|--no-relaunch] [--json]
185
191
  Verify a signed DMG, quit, replace & relaunch hidden; no UI, no osascript (macOS)
192
+ The replaced app stays beside the fixed path as <App>.app.previous
193
+ install-app --installer <Setup.exe> --dest <fixed install folder> --exe <App.exe> [--json]
194
+ Windows: run the installer in place at the fixed folder, keep <dest>.previous
186
195
  build [--platform mac|win] [--skip-checks] [--resume-from <phase>]
187
196
  Resume macOS at prepackage, sign, package, post-sign, or harden
188
197
  dev --platform win|mac [--config <path>] [--dry-run] [--build-only]
@@ -191,6 +200,12 @@ Commands:
191
200
  Stage SwiftPM app; build+sign MSIX (Windows) or assemble+sign+notarize .app (macOS); never uploads
192
201
  bundle-scan [--allow-name <basename>]... <bundle-root>...
193
202
  Fail when a bundle carries Node/Python runtimes or script sources
203
+ sign-dev-artifact --artifact <dir> --manifest <sign.json> [--metadata <payload.json>] [--evidence <path>] [--dry-run]
204
+ Developer ID sign an existing CI dev artifact per file with fixed identifiers; never builds, notarizes or publishes (macOS)
205
+ sign-candidate --platform win|mac --artifact <dir> --candidate-receipt <receipt.json> --out <binding.json> [--manifest <sign.json>] [--publisher <subject>] [--dry-run]
206
+ Sign the exact unsigned candidate bytes (sha256 checked against the candidate receipt) instead of rebuilding; writes a candidate-sha -> signed-sha binding receipt
207
+ engine-locate --engine cutright|genright [--sha256 <hex>] [--json]
208
+ Print the verified path of the installed CutRight/GenRight engine (fixed route, signature and identity checked, no override)
194
209
  identity-scan --target <path> [--identifier <id>] [--team <OU>] [--cdhash <hex>] [--arch <arch>]...
195
210
  Assert codesign Identifier/Team/cdhash/lipo archs; reject ad-hoc
196
211
  upload --release <id> --platform mac|win --tier patch|update
@@ -232,10 +247,7 @@ Commands:
232
247
  ownership trust-sync Advance this host's protected trust to the committed key log
233
248
  ownership trust-sync --check Read-only: say whether a sync would ask for approval, and for what (exit 3 if so)
234
249
  ownership trust-compare [--json] Print this host's protected-trust digest (Mac is the reference)
235
- ownership-approval --draft <input.json> [--out <draft.json>]
236
- Draft an exception for native owner approval
237
- ownership-approval --verify <signed.json> --context <scan.json>
238
- Verify exact signed exception coverage
250
+ ownership-approval Retired no-op: exceptions are plain entries in the repo exceptions file, edited directly and reviewed through git
239
251
  ownership-report --workspace <dir> [--markdown <file>] [--json-out <file>]
240
252
  Report cross-app ownership & pending requests
241
253
  ownership-mandatory [--workspace <dir>] [--app <name>]... [--amendment v2.1] [--enforce] [--json]
@@ -133,7 +133,7 @@ function acquire(root, { expectedCommit, expectedTree, requiredPaths = [], runne
133
133
  const rows = parseTree(runGit(root, ["ls-tree", "-r", "-z", "--full-tree", tree], { encoding: "buffer", maxBuffer: MAX_TREE_BYTES, timeout: COMMAND_TIMEOUT_MS, runner, env }), objectFormat);
134
134
  const wanted = new Set(requiredPaths.filter(validatePath));
135
135
  const shouldRead = (row) => row.type === "blob" && row.mode !== "120000" && (
136
- wanted.has(row.rel) || ["Cargo.toml", "package.json", "Package.swift", "Package.resolved", "ownership-exceptions.json", "Cargo.lock", "pnpm-lock.yaml", "package-lock.json", "npm-shrinkwrap.json", "yarn.lock"].includes(path.posix.basename(row.rel)) ||
136
+ wanted.has(row.rel) || ["Cargo.toml", "package.json", "Package.swift", "Package.resolved", "ownership-exceptions.json", "pnpm-workspace.yaml", "Cargo.lock", "pnpm-lock.yaml", "package-lock.json", "npm-shrinkwrap.json", "yarn.lock"].includes(path.posix.basename(row.rel)) ||
137
137
  /\.(?:rs|ts|tsx|js|mjs|cjs|jsx|swift|m|mm|c|cc|cpp|h|py|go|kt|java|cs)$/i.test(row.rel)
138
138
  );
139
139
  const candidates = rows.filter(shouldRead);
@@ -0,0 +1,176 @@
1
+ // Declarative metadata refresh for right-release sign-dev-artifact (Orthic-Labs/rightkit#257).
2
+ //
3
+ // Signing changes bytes, so a JSON file that records a digest of signed bytes goes stale. The sign manifest declares, per value, which
4
+ // JSON pointer of which file is recomputed from which artifact path:
5
+ //
6
+ // { "schema": 1, "refreshRoot": "<dir inside the artifact>", "entries": [...],
7
+ // "refresh": [ { "file": "share/app/release.json", "pointer": "/runtime/sha256", "compute": "sha256-file", "of": "bin/app" } ] }
8
+ //
9
+ // `file` and `of` are relative to `refreshRoot` (default: the artifact root). Computes:
10
+ // sha256-file hex sha256 of the bytes of `of`
11
+ // sha256-tree-v1 hex sha256 over the regular files below the directory `of`: sorted by relative POSIX path (UTF-8 bytes), each
12
+ // contributing path bytes, 0x00, the length as 8 big-endian bytes, then the file bytes. Symlinks and
13
+ // non-regular files are refused.
14
+ // verify-sha256-file does not write: refuses unless the value at the pointer already equals the sha256 of `of` (a digest that must
15
+ // survive signing unchanged)
16
+ // Optional per operation: `format` (a template with one `{value}`, applied to the computed digest, for locator strings that embed it) and
17
+ // `currentMatches` (a regular expression the CURRENT string value must match before it is overwritten, so a value of another kind, such as
18
+ // a signed attestation locator, is refused instead of replaced).
19
+ //
20
+ // Ordering is enforced, never reordered: every operation runs after the manifest entries it reads (the entries equal to or below `of`)
21
+ // are signed and before the first entry that contains a file the refresh writes, so the bundle holding refreshed files is signed last
22
+ // over their final bytes. An operation may not read a file a later operation writes. JSON is rewritten in place keeping key order, and a
23
+ // file the serializer cannot reproduce byte for byte (apart from the edited value) is refused rather than reformatted.
24
+ import { createHash } from "node:crypto";
25
+ import { existsSync, lstatSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
26
+ import path from "node:path";
27
+ import { assertInsideOnDisk, contained, fail, isWithin, toPosix } from "./dev-artifact-support.mjs";
28
+
29
+ export const REFRESH_COMPUTES = Object.freeze(["sha256-file", "sha256-tree-v1", "verify-sha256-file"]);
30
+ const OPERATION_KEYS = new Set(["file", "pointer", "compute", "of", "format", "currentMatches"]);
31
+
32
+ const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
33
+
34
+ /** Digest of the regular files below `root` (see the header). Refuses symlinks and anything that is not a regular file or directory. */
35
+ export function sha256TreeV1(root) {
36
+ const files = [];
37
+ const visit = (directory) => {
38
+ for (const name of readdirSync(directory)) {
39
+ const absolute = path.join(directory, name);
40
+ const info = lstatSync(absolute);
41
+ if (info.isSymbolicLink()) fail("SIGN_DEV_ARTIFACT_REFRESH_TREE_INVALID", `refresh tree contains a symlink: ${toPosix(path.relative(root, absolute))}`);
42
+ if (info.isDirectory()) visit(absolute);
43
+ else if (info.isFile()) files.push({ relative: toPosix(path.relative(root, absolute)), absolute });
44
+ else fail("SIGN_DEV_ARTIFACT_REFRESH_TREE_INVALID", `refresh tree contains a non-regular file: ${toPosix(path.relative(root, absolute))}`);
45
+ }
46
+ };
47
+ if (!lstatSync(root).isDirectory()) fail("SIGN_DEV_ARTIFACT_REFRESH_TREE_INVALID", `sha256-tree-v1 needs a directory: ${root}`);
48
+ visit(root);
49
+ files.sort((a, b) => Buffer.compare(Buffer.from(a.relative), Buffer.from(b.relative)));
50
+ const digest = createHash("sha256");
51
+ for (const { relative, absolute } of files) {
52
+ const bytes = readFileSync(absolute);
53
+ const length = Buffer.alloc(8);
54
+ length.writeBigUInt64BE(BigInt(bytes.length));
55
+ digest.update(Buffer.from(relative)).update(Buffer.from([0])).update(length).update(bytes);
56
+ }
57
+ return digest.digest("hex");
58
+ }
59
+
60
+ const decodePointer = (pointer) => pointer.slice(1).split("/").map((token) => token.replace(/~1/g, "/").replace(/~0/g, "~"));
61
+
62
+ /** Parse and validate `refresh` and `refreshRoot` of a raw sign manifest. Returns { root, operations } with absolute paths. */
63
+ export function readRefreshPlan(raw, artifact) {
64
+ if (raw.refresh === undefined && raw.refreshRoot === undefined) return { root: artifact, operations: [] };
65
+ if (!Array.isArray(raw.refresh) || raw.refresh.length === 0) fail("SIGN_DEV_ARTIFACT_REFRESH_INVALID", "sign manifest refresh must be a non-empty array of operations");
66
+ const root = raw.refreshRoot === undefined ? artifact : contained(artifact, raw.refreshRoot, "sign manifest refreshRoot");
67
+ const operations = raw.refresh.map((item, index) => {
68
+ const label = `sign manifest refresh[${index}]`;
69
+ if (!item || typeof item !== "object" || Array.isArray(item)) fail("SIGN_DEV_ARTIFACT_REFRESH_INVALID", `${label} must be an object`);
70
+ for (const key of Object.keys(item)) if (!OPERATION_KEYS.has(key)) fail("SIGN_DEV_ARTIFACT_REFRESH_INVALID", `${label} has an unknown field: ${key}`);
71
+ if (!REFRESH_COMPUTES.includes(item.compute)) fail("SIGN_DEV_ARTIFACT_REFRESH_INVALID", `${label}.compute must be one of ${REFRESH_COMPUTES.join(", ")}`);
72
+ if (typeof item.pointer !== "string" || !item.pointer.startsWith("/") || /~(?![01])/.test(item.pointer)) fail("SIGN_DEV_ARTIFACT_REFRESH_INVALID", `${label}.pointer must be a JSON pointer starting with "/"`);
73
+ if (item.format !== undefined && (typeof item.format !== "string" || item.format.split("{value}").length !== 2)) fail("SIGN_DEV_ARTIFACT_REFRESH_INVALID", `${label}.format must be a string with exactly one {value}`);
74
+ if (item.format !== undefined && item.compute === "verify-sha256-file") fail("SIGN_DEV_ARTIFACT_REFRESH_INVALID", `${label}.format does not apply to verify-sha256-file`);
75
+ let currentMatches;
76
+ if (item.currentMatches !== undefined) {
77
+ if (typeof item.currentMatches !== "string" || item.currentMatches.length > 200) fail("SIGN_DEV_ARTIFACT_REFRESH_INVALID", `${label}.currentMatches must be a short regular expression string`);
78
+ try { currentMatches = new RegExp(item.currentMatches); } catch { fail("SIGN_DEV_ARTIFACT_REFRESH_INVALID", `${label}.currentMatches is not a valid regular expression`); }
79
+ }
80
+ return {
81
+ index, artifact, file: contained(root, item.file, `${label}.file`), of: contained(root, item.of, `${label}.of`),
82
+ pointer: item.pointer, compute: item.compute, format: item.format, currentMatches, writes: item.compute !== "verify-sha256-file",
83
+ declared: { file: item.file, pointer: item.pointer, compute: item.compute, of: item.of },
84
+ };
85
+ });
86
+ return { root, operations };
87
+ }
88
+
89
+ /**
90
+ * Split the manifest entries around the refresh and enforce the ordering rules. Returns `{ before, after }`: the entries to sign
91
+ * before the refresh runs and the entries (the first one containing a refreshed file, and everything after it) to sign after it.
92
+ * Also refuses a payload metadata file that sits inside a signed entry, since refreshing it after that entry is signed would break
93
+ * the signature. `entries` carry absolute `file` paths.
94
+ */
95
+ export function planRefresh(entries, operations, metadataFile) {
96
+ const insideSigned = entries.find((entry) => isWithin(entry.file, metadataFile));
97
+ if (insideSigned) {
98
+ fail("SIGN_DEV_ARTIFACT_METADATA_IN_SIGNED_ENTRY", `payload metadata ${path.basename(metadataFile)} lies inside the signed entry ${path.basename(insideSigned.file)}, and refreshing its hashes after signing would break that signature. Keep the payload metadata outside every signed bundle (for example at the artifact root).`);
99
+ }
100
+ if (operations.length === 0) return { before: entries, after: [] };
101
+ const written = operations.filter((operation) => operation.writes).map((operation) => operation.file);
102
+ const firstContaining = entries.findIndex((entry) => written.some((file) => isWithin(entry.file, file)));
103
+ const split = firstContaining < 0 ? entries.length : firstContaining;
104
+ for (const operation of operations) {
105
+ entries.forEach((entry, index) => {
106
+ if (isWithin(operation.of, entry.file) && index >= split) {
107
+ fail("SIGN_DEV_ARTIFACT_REFRESH_ORDER", `refresh reads ${toPosix(path.relative(path.dirname(operation.of), entry.file)) || path.basename(entry.file)}, which is signed after the bundle holding the refreshed files; list it before that bundle so its final bytes are read`);
108
+ }
109
+ });
110
+ operations.forEach((later) => {
111
+ if (later.index > operation.index && later.writes && isWithin(operation.of, later.file)) {
112
+ fail("SIGN_DEV_ARTIFACT_REFRESH_ORDER", `refresh[${operation.index}] reads a file that refresh[${later.index}] writes later; list the writer first`);
113
+ }
114
+ });
115
+ }
116
+ for (const operation of operations) {
117
+ if (!existsSync(operation.of)) fail("SIGN_DEV_ARTIFACT_MISSING", `refresh input is missing: ${operation.declared.of}`);
118
+ if (!existsSync(operation.file)) fail("SIGN_DEV_ARTIFACT_MISSING", `refresh file is missing: ${operation.declared.file}`);
119
+ }
120
+ return { before: entries.slice(0, split), after: entries.slice(split) };
121
+ }
122
+
123
+ function readJson(file, label) {
124
+ const text = readFileSync(file, "utf8");
125
+ let value;
126
+ try { value = JSON.parse(text); } catch { fail("SIGN_DEV_ARTIFACT_REFRESH_JSON_INVALID", `${label} is not readable JSON`); }
127
+ const indent = /^\{\n( +)"/.exec(text)?.[1].length ?? 2;
128
+ const newline = text.endsWith("\n") ? "\n" : "";
129
+ const serialize = (candidate) => `${JSON.stringify(candidate, null, indent)}${newline}`;
130
+ if (serialize(value) !== text) fail("SIGN_DEV_ARTIFACT_REFRESH_JSON_FORMAT", `${label} is not formatted the way this tool writes JSON (${indent}-space indent, key order kept), so refreshing it would reformat more than the edited value; refusing`);
131
+ return { value, serialize };
132
+ }
133
+
134
+ function locate(root, pointer, label) {
135
+ const tokens = decodePointer(pointer);
136
+ let container = root;
137
+ for (const token of tokens.slice(0, -1)) {
138
+ if (container === null || typeof container !== "object" || !Object.hasOwn(container, token)) fail("SIGN_DEV_ARTIFACT_REFRESH_POINTER_MISSING", `${label}: pointer ${pointer} does not exist; the refresh only updates values that are already there`);
139
+ container = container[token];
140
+ }
141
+ const key = tokens.at(-1);
142
+ if (container === null || typeof container !== "object" || Array.isArray(container) || !Object.hasOwn(container, key)) fail("SIGN_DEV_ARTIFACT_REFRESH_POINTER_MISSING", `${label}: pointer ${pointer} does not exist; the refresh only updates values that are already there`);
143
+ return { container, key };
144
+ }
145
+
146
+ /**
147
+ * Run the operations in order. Returns one record per operation: `{ file, pointer, compute, of, before, after, changed }`
148
+ * (`verified: true` and no change for verify-sha256-file). Writes atomically, one operation at a time.
149
+ */
150
+ export function applyRefresh(operations, root) {
151
+ return operations.map((operation) => {
152
+ const label = `${operation.declared.file} ${operation.pointer}`;
153
+ // A link can appear after the plan was read: check every path again on disk right before it is read or written.
154
+ assertInsideOnDisk(operation.artifact, operation.of, `${label} input`);
155
+ assertInsideOnDisk(operation.artifact, operation.file, `${label} file`);
156
+ const digest = operation.compute === "sha256-tree-v1" ? sha256TreeV1(operation.of) : sha256(readFileSync(operation.of));
157
+ const { value, serialize } = readJson(operation.file, operation.declared.file);
158
+ const { container, key } = locate(value, operation.pointer, label);
159
+ const before = container[key];
160
+ if (typeof before !== "string") fail("SIGN_DEV_ARTIFACT_REFRESH_POINTER_INVALID", `${label} is not a string value`);
161
+ const record = { file: toPosix(path.relative(root, operation.file)), pointer: operation.pointer, compute: operation.compute, of: toPosix(path.relative(root, operation.of)), before };
162
+ if (operation.compute === "verify-sha256-file") {
163
+ if (before !== digest) fail("SIGN_DEV_ARTIFACT_REFRESH_VERIFY_FAILED", `${label} records ${before}, but ${operation.declared.of} is now ${digest}; this digest must survive signing unchanged`);
164
+ return { ...record, after: before, changed: false, verified: true };
165
+ }
166
+ if (operation.currentMatches && !operation.currentMatches.test(before)) fail("SIGN_DEV_ARTIFACT_REFRESH_UNRECOMPUTABLE", `${label} is ${JSON.stringify(before)}, which does not match ${operation.currentMatches}; it is not a value this refresh can recompute, so it is left alone and signing stops`);
167
+ const after = operation.format === undefined ? digest : operation.format.replace("{value}", digest);
168
+ container[key] = after;
169
+ const staging = `${operation.file}.refresh`;
170
+ assertInsideOnDisk(operation.artifact, staging, `${label} staging file`);
171
+ rmSync(staging, { force: true }); // removes a stale file or a planted link itself, never what a link points at
172
+ writeFileSync(staging, serialize(value), { flag: "wx" });
173
+ renameSync(staging, operation.file);
174
+ return { ...record, after, changed: after !== before };
175
+ });
176
+ }
@@ -0,0 +1,61 @@
1
+ // Shared by sign-dev-artifact.mjs and dev-artifact-refresh.mjs (Orthic-Labs/rightkit#257): the typed error and the path helpers.
2
+ import { lstatSync, realpathSync } from "node:fs";
3
+ import path from "node:path";
4
+
5
+ export class SignDevArtifactError extends Error {
6
+ constructor(code, message) {
7
+ super(message);
8
+ this.name = "SignDevArtifactError";
9
+ this.code = code;
10
+ }
11
+ }
12
+
13
+ export const fail = (code, message) => { throw new SignDevArtifactError(code, message); };
14
+ export const toPosix = (value) => value.split(path.sep).join("/");
15
+
16
+ /** True when `child` is `parent` or lies below it. Both absolute. */
17
+ export const isWithin = (parent, child) => child === parent || child.startsWith(parent + path.sep);
18
+
19
+ /**
20
+ * Refuse `absolute` unless every existing component between `base` and it stays inside `base` on disk. Each component is lstat'ed; a
21
+ * symlink (or junction) must resolve, through realpath, to a location inside the real `base`, and a dangling or unreadable one is
22
+ * refused. A path whose tail does not exist yet is judged by its existing prefix. Call it again immediately before every read or
23
+ * write, since a link can appear after the lexical check. Returns `absolute`.
24
+ */
25
+ export function assertInsideOnDisk(base, absolute, label) {
26
+ let realBase;
27
+ try { realBase = realpathSync(base); } catch { fail("SIGN_DEV_ARTIFACT_PATH_INVALID", `${label}: the artifact directory cannot be resolved: ${base}`); }
28
+ let current = path.resolve(base);
29
+ const parts = path.relative(current, absolute).split(path.sep).filter(Boolean);
30
+ for (const part of parts) {
31
+ current = path.join(current, part);
32
+ let info;
33
+ try {
34
+ info = lstatSync(current);
35
+ } catch (error) {
36
+ if (error.code === "ENOENT" || error.code === "ENOTDIR") return absolute;
37
+ fail("SIGN_DEV_ARTIFACT_PATH_INVALID", `${label}: cannot inspect ${toPosix(path.relative(base, current))} (${error.code ?? error.message})`);
38
+ }
39
+ if (!info.isSymbolicLink()) continue;
40
+ let real;
41
+ try { real = realpathSync(current); } catch { fail("SIGN_DEV_ARTIFACT_PATH_ESCAPE", `${label}: ${toPosix(path.relative(base, current))} is a symlink that does not resolve`); }
42
+ if (!isWithin(realBase, real)) fail("SIGN_DEV_ARTIFACT_PATH_ESCAPE", `${label}: ${toPosix(path.relative(base, current))} is a symlink that leaves the artifact directory`);
43
+ }
44
+ return absolute;
45
+ }
46
+
47
+ /**
48
+ * A path relative to `base` that stays inside it: no absolute path, no `..`, not empty, and not `base` itself (so "." and "a/.."
49
+ * are refused). Lexical first, then every existing component is checked on disk (see assertInsideOnDisk), so a symlink inside the
50
+ * artifact cannot redirect outside it. Returns the absolute path.
51
+ */
52
+ export function contained(base, relative, label) {
53
+ if (typeof relative !== "string" || !relative || path.isAbsolute(relative) || path.win32.isAbsolute(relative) || relative.split(/[\\/]/).includes("..")) {
54
+ fail("SIGN_DEV_ARTIFACT_PATH_INVALID", `${label} must be a relative path inside the artifact directory: ${JSON.stringify(relative)}`);
55
+ }
56
+ const absolute = path.resolve(base, relative);
57
+ if (absolute === path.resolve(base) || !isWithin(path.resolve(base), absolute)) {
58
+ fail("SIGN_DEV_ARTIFACT_PATH_INVALID", `${label} must name a path below the artifact directory, not the directory itself: ${JSON.stringify(relative)}`);
59
+ }
60
+ return assertInsideOnDisk(base, absolute, label);
61
+ }