@rightkit/release 0.6.16 → 0.6.20
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 +7 -3
- package/admission-host.mjs +9 -5
- package/app-closure.mjs +9 -3
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/rightkit-gate.mjs +469 -102
- package/bin/rightkit-linux-setup.mjs +15 -0
- package/broker-admission.mjs +184 -0
- package/broker-wire.mjs +15 -3
- package/build-launch.mjs +4 -1
- package/build-ownership.mjs +125 -36
- package/build-release.mjs +39 -7
- package/cargo-contract.mjs +4 -1
- package/cargo-guard.mjs +25 -5
- package/cargo-target-shim.mjs +56 -7
- package/clippy-route.mjs +8 -7
- package/crate-graph.mjs +5 -1
- package/credential-provider.mjs +174 -0
- package/deterministic-scans.mjs +247 -40
- package/development.mjs +8 -0
- package/disk-policy.mjs +2 -2
- package/gate-executor.mjs +5 -3
- package/gate-stages.mjs +34 -3
- package/gradle-lock.mjs +206 -0
- package/heavy-command.mjs +33 -2
- package/linux-artifacts.mjs +2 -1
- package/linux-setup.mjs +106 -0
- package/local-publish.mjs +21 -10
- package/local-qualify.mjs +9 -3
- package/managed-cargo.mjs +54 -18
- package/ownership-lockfiles.mjs +14 -3
- package/ownership.mjs +5 -1
- package/package.json +17 -11
- package/protected-credentials.mjs +13 -5
- package/publication-gate.mjs +9 -4
- package/python-lock.mjs +209 -0
- package/qa-admission-policy.json +32 -0
- package/qa-layout.mjs +229 -0
- package/qa-runner.mjs +156 -0
- package/release-artifact-path.mjs +29 -3
- package/release-config.mjs +1 -1
- package/release-qa-command.mjs +58 -8
- package/release-qa-policy.mjs +5 -3
- package/release-source-identity.mjs +1 -0
- package/release.mjs +10 -2
- package/rightkit-ownership.json +1465 -79
- package/rightkit-registry-resolver.mjs +7 -3
- package/rightkit-versions.json +173 -36
- package/runtime-budget-classes.json +4 -0
- package/runtime-budgets.mjs +5 -1
- package/shell-contract.mjs +1 -1
- package/snapshot-broker.mjs +107 -9
- package/snapshot-check.mjs +2 -0
- package/snapshot-request.mjs +20 -3
- package/snapshot.mjs +11 -4
- package/stage-commands.mjs +2 -1
- package/stage-ledger.mjs +15 -0
- package/stage-sidecar.mjs +17 -3
- package/suite-signing.mjs +40 -4
- package/swift-lock.mjs +334 -0
- package/test-command.mjs +13 -3
- package/tool-build.mjs +156 -0
- package/unit-gate.mjs +9 -2
- package/windows-identity-plan.mjs +119 -0
- package/windows-identity.mjs +136 -0
package/snapshot-request.mjs
CHANGED
|
@@ -294,7 +294,10 @@ const lstatOrNull = (p) => { try { return fs.lstatSync(p); } catch (e) { if (e.c
|
|
|
294
294
|
* `..` or empty component, not inside `.git`) and no input that is, or holds, either declaration file. A present but invalid canonical declaration is an
|
|
295
295
|
* error: it is never read as the legacy one.
|
|
296
296
|
*/
|
|
297
|
-
|
|
297
|
+
/** Host platform names a declared input may scope itself to (`{ path, platforms }`). */
|
|
298
|
+
export const DECLARED_INPUT_PLATFORMS = Object.freeze({ darwin: "mac", win32: "win", linux: "linux" });
|
|
299
|
+
|
|
300
|
+
export function readDeclaredInputs(snapshotRoot, { platform = process.platform } = {}) {
|
|
298
301
|
let file = null; let at = null; let meta = null;
|
|
299
302
|
for (const candidate of COMPILE_INPUT_DECLARATIONS) {
|
|
300
303
|
let here = snapshotRoot; let found = true;
|
|
@@ -313,8 +316,21 @@ export function readDeclaredInputs(snapshotRoot) {
|
|
|
313
316
|
let value;
|
|
314
317
|
try { value = JSON.parse(fs.readFileSync(at, "utf8")); } catch (e) { fail("declared-inputs", `${file} is not JSON (${e.message})`); }
|
|
315
318
|
if (!value || typeof value !== "object" || !Array.isArray(value.inputs)) fail("declared-inputs", `${file}: \`inputs\` must be a list of paths`);
|
|
316
|
-
|
|
319
|
+
// An entry is a path (every platform) or { path, platforms }: a platform-scoped input (a Windows-only identity package) is declared
|
|
320
|
+
// only on the hosts it names, so another host neither requires nor digests it. Unknown keys or platform names refuse.
|
|
321
|
+
const host = DECLARED_INPUT_PLATFORMS[platform] ?? platform;
|
|
322
|
+
const kept = [];
|
|
323
|
+
for (const entry of value.inputs) {
|
|
324
|
+
let input = entry;
|
|
325
|
+
if (entry && typeof entry === "object" && !Array.isArray(entry)) {
|
|
326
|
+
const extra = Object.keys(entry).filter((k) => k !== "path" && k !== "platforms");
|
|
327
|
+
if (extra.length) fail("declared-inputs", `${file}: an input object has only path and platforms (found ${extra.join(", ")})`);
|
|
328
|
+
const names = Object.values(DECLARED_INPUT_PLATFORMS);
|
|
329
|
+
if (!Array.isArray(entry.platforms) || !entry.platforms.length || entry.platforms.some((x) => !names.includes(x))) fail("declared-inputs", `${file}: ${JSON.stringify(entry.path)}: platforms must be a non-empty list of ${names.join(", ")}`);
|
|
330
|
+
input = entry.path;
|
|
331
|
+
}
|
|
317
332
|
if (typeof input !== "string" || !input) fail("declared-inputs", `${file}: every input is a non-empty relative path`);
|
|
333
|
+
const here = !(entry && typeof entry === "object") || entry.platforms.includes(host);
|
|
318
334
|
const components = input.split(/[\\/]/);
|
|
319
335
|
if (path.isAbsolute(input) || path.posix.isAbsolute(input) || path.win32.isAbsolute(input) || components.some((c) => c === "" || c === "." || c === "..")) fail("declared-inputs", `${file}: ${input} is not a plain relative path inside the snapshot`);
|
|
320
336
|
if (components.some((c) => c.toLowerCase() === ".git")) fail("declared-inputs", `${file}: ${input} is inside .git; repository metadata is never a compile input`);
|
|
@@ -322,8 +338,9 @@ export function readDeclaredInputs(snapshotRoot) {
|
|
|
322
338
|
const parts = declaration.split("/");
|
|
323
339
|
if (components.length <= parts.length && components.every((c, i) => c === parts[i])) fail("declared-inputs", `${file}: ${input} overlaps the declaration file itself (${declaration})`);
|
|
324
340
|
}
|
|
341
|
+
if (here) kept.push(input);
|
|
325
342
|
}
|
|
326
|
-
return
|
|
343
|
+
return kept;
|
|
327
344
|
}
|
|
328
345
|
|
|
329
346
|
/** The inputs a request supplied must be exactly the declaration the snapshot holds (the broker's check): a throw naming the difference. */
|
package/snapshot.mjs
CHANGED
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
// unreadable processes, build-ownership.mjs).
|
|
28
28
|
//
|
|
29
29
|
// Exclusion: one sync per snapshot at a time (`<root>/<repo-key>.lock`, re-checked before every write and before the verdict).
|
|
30
|
-
// It narrows, but cannot close, the window in which two processes both replace one stale lock;
|
|
31
|
-
// from before the sync through the stage (joint plan step 4)
|
|
30
|
+
// It narrows, but cannot close, the window in which two processes both replace one stale lock; the per-repository build ownership
|
|
31
|
+
// record held from before the sync through the stage (joint plan step 4) closes it, and the broker re-verifies the tree.
|
|
32
32
|
|
|
33
33
|
import crypto from "node:crypto";
|
|
34
34
|
import fs from "node:fs";
|
|
@@ -543,6 +543,13 @@ function inspectSnapshot({ dir, entries, checkout, platform = process.platform,
|
|
|
543
543
|
}
|
|
544
544
|
// Every candidate, ledger or new, must be ignored by the commit's own rules, as the neutral oracle judges them.
|
|
545
545
|
const ignored = neutralIgnored(dir, candidates.map((c) => c.path));
|
|
546
|
+
// Git's own semantics for a directory: `git status` shows nothing for one whose every entry is ignored (or that holds nothing), even
|
|
547
|
+
// when no rule names the directory itself (a root rule `src-tauri/gen/schemas/` leaves `src-tauri/gen` unnamed). Judged deepest
|
|
548
|
+
// first, so a directory counts as ignored exactly when every file and subdirectory below it does; a single unignored file still refuses.
|
|
549
|
+
for (const d of candidates.filter((c) => c.type === "dir" && !ignored.has(c.path)).sort((a, b) => b.path.length - a.path.length)) {
|
|
550
|
+
const below = candidates.filter((c) => c.path.startsWith(`${d.path}/`));
|
|
551
|
+
if (below.every((c) => ignored.has(c.path))) ignored.add(d.path);
|
|
552
|
+
}
|
|
546
553
|
for (const c of candidates) if (!ignored.has(c.path)) return problem(`${c.path} is not in the commit's tree and is not ignored by the commit's own ignore rules (the checkout's info/exclude and global excludes do not count)`);
|
|
547
554
|
return { problem: null, admitted: [...candidates].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)) };
|
|
548
555
|
}
|
|
@@ -942,11 +949,11 @@ export function snapshotCommand(o, args, deps = {}) {
|
|
|
942
949
|
// The managed root exists (the gate never creates it) and the root is private, BEFORE build ownership is taken: taking it
|
|
943
950
|
// creates directories of its own, and must never be what brings a missing managed root into being.
|
|
944
951
|
guardRoot(root, managedRoot, { device: o.device });
|
|
945
|
-
// A sync replaces snapshot bytes, so it runs only under the
|
|
952
|
+
// A sync replaces snapshot bytes, so it runs only under the repository's build ownership, taken before the first write (joined when
|
|
946
953
|
// this gate already holds it); a verify only reads.
|
|
947
954
|
if (!verify) {
|
|
948
955
|
const repo = repositoryOf(o.cwd);
|
|
949
|
-
ownership = (o.acquireOwnership ?? acquireBuildOwnership)({ file: ownershipFile({ platform: o.platform, beside: o.snapshotRoot }), stage: "snapshot", snapshot: path.join(root, repo.key), commit: resolveCommit(repo.checkout, rev).commit, platform: o.platform, env: o.env });
|
|
956
|
+
ownership = (o.acquireOwnership ?? acquireBuildOwnership)({ file: ownershipFile({ platform: o.platform, beside: o.snapshotRoot, repository: repo.key }), stage: "snapshot", snapshot: path.join(root, repo.key), commit: resolveCommit(repo.checkout, rev).commit, platform: o.platform, env: o.env });
|
|
950
957
|
}
|
|
951
958
|
const r = verify ? verifySnapshot({ checkout: o.cwd, rev, root, managedRoot, device: o.device, ...(envAt >= 0 ? { environment: { expected: args[envAt + 1], compute: deps.environmentDigest } } : {}) })
|
|
952
959
|
: syncSnapshot({ checkout: o.cwd, rev, root, managedRoot, device: o.device });
|
package/stage-commands.mjs
CHANGED
|
@@ -41,7 +41,8 @@ export function expectedStageCommands({ kind, unit, config, manifest, platform }
|
|
|
41
41
|
const pack = [];
|
|
42
42
|
if (kind === "npm") {
|
|
43
43
|
// The swap helper builds and signs a binary into its own directory of the snapshot (a declared stage output), then packs from there like any unit.
|
|
44
|
-
|
|
44
|
+
// A Linux direct origin builds none: the helper is built and signed hosted (publish.yml build-swap/sign-swap).
|
|
45
|
+
if (unit === SWAP_HELPER_PACKAGE && platform !== "linux") pack.push(CARGO(".", SWAP_HELPER_CARGO_ARGS), CARGO(".", SWAP_HELPER_METADATA_ARGS));
|
|
45
46
|
pack.push(PNPM(npmPackArgs(dir, ANY_ABSOLUTE_PATH)));
|
|
46
47
|
} else {
|
|
47
48
|
compile.unshift(CARGO(crateDir, qualifyFmtArgs(unit)));
|
package/stage-ledger.mjs
CHANGED
|
@@ -148,6 +148,21 @@ export function attributionProblems({ before, after, reported, receipt }) {
|
|
|
148
148
|
return problems;
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
/**
|
|
152
|
+
* The attribution of a step that reports no file list (an app's web build, `right-release web-build`): the declared compile inputs
|
|
153
|
+
* (`.rightkit/compile-inputs.json`) are the only paths it may write, so every path it added or changed must lie under one of them and
|
|
154
|
+
* nothing may be removed. Anything else is an unattributed write. `declared` are snapshot-relative posix paths.
|
|
155
|
+
*/
|
|
156
|
+
export function declaredOutputProblems({ before, after, declared }) {
|
|
157
|
+
const d = diffEntries(before, after);
|
|
158
|
+
const roots = declared.map((x) => x.replaceAll("\\", "/").replace(/\/+$/, ""));
|
|
159
|
+
const under = (p) => roots.some((r) => p === r || p.startsWith(`${r}/`));
|
|
160
|
+
const problems = [];
|
|
161
|
+
for (const p of [...d.added, ...d.changed]) if (!under(p)) problems.push(`${p} was written by the web build but lies under no declared compile input`);
|
|
162
|
+
for (const p of d.removed) problems.push(`${p} was removed by the web build`);
|
|
163
|
+
return problems;
|
|
164
|
+
}
|
|
165
|
+
|
|
151
166
|
/** Every `path` string a staging receipt or its `--json` result carries (files, their notices), app-relative. */
|
|
152
167
|
export function reportedPaths(result) {
|
|
153
168
|
const out = new Set();
|
package/stage-sidecar.mjs
CHANGED
|
@@ -603,17 +603,25 @@ export function defaultRun(command, args, { cwd, env = process.env } = {}) {
|
|
|
603
603
|
*/
|
|
604
604
|
const isDebugSymbolMember = (member) => member.split("/").some((part) => part.toLowerCase().endsWith(".dsym"));
|
|
605
605
|
|
|
606
|
-
|
|
606
|
+
/**
|
|
607
|
+
* The tar that lists and unpacks archives. On Windows it is the System32 bsdtar, named explicitly: PATH often finds Git for Windows'
|
|
608
|
+
* GNU tar first, which reads the colon in `D:\...` as a remote host and cannot list the cached archive (as direct-bootstrap.mjs).
|
|
609
|
+
*/
|
|
610
|
+
export function archiveTar({ platform = process.platform, env = process.env } = {}) {
|
|
611
|
+
return platform === "win32" ? path.win32.join(env.SystemRoot ?? "C:\\Windows", "System32", "tar.exe") : "tar";
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
export function defaultExtractMember({ archive, fileName, destDir, run = defaultRun, tar = archiveTar() }) {
|
|
607
615
|
const wanted = normalizeArchiveMember(fileName);
|
|
608
616
|
if (wanted.error) fail("STAGE_SIDECAR_EXTRACT_FAILED", `archive member ${JSON.stringify(fileName)} ${wanted.error}`);
|
|
609
|
-
const listing = run(
|
|
617
|
+
const listing = run(tar, ["-tf", archive], { cwd: path.dirname(archive) });
|
|
610
618
|
if (listing.status !== 0) fail("STAGE_SIDECAR_EXTRACT_FAILED", `cannot list ${archive}`);
|
|
611
619
|
const lines = String(listing.stdout).split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
612
620
|
const members = wanted.byPath
|
|
613
621
|
? lines.filter((line) => (line.startsWith("./") ? line.slice(2) : line) === wanted.value)
|
|
614
622
|
: lines.filter((line) => path.posix.basename(line) === wanted.value && !isDebugSymbolMember(line));
|
|
615
623
|
if (members.length !== 1) fail("STAGE_SIDECAR_EXTRACT_FAILED", `${archive} has ${members.length} members ${wanted.byPath ? "at the path" : "named"} ${wanted.value}, expected exactly one`);
|
|
616
|
-
const extracted = run(
|
|
624
|
+
const extracted = run(tar, ["-xf", archive, "-C", destDir, members[0]], { cwd: path.dirname(archive) });
|
|
617
625
|
if (extracted.status !== 0) fail("STAGE_SIDECAR_EXTRACT_FAILED", `cannot extract ${members[0]} from ${archive}`);
|
|
618
626
|
const out = path.join(destDir, ...members[0].split("/"));
|
|
619
627
|
const inside = path.relative(path.resolve(destDir), path.resolve(out));
|
|
@@ -1990,6 +1998,12 @@ export async function stageSidecars({ appRoot, manifest, target, cacheRoot, deps
|
|
|
1990
1998
|
};
|
|
1991
1999
|
validateStageSidecarReceipt(receipt, { target: plan.target.triple });
|
|
1992
2000
|
const receiptFile = path.resolve(appRoot, plan.receiptPath);
|
|
2001
|
+
// A receipt that would differ only in `stagedAt` is kept as it is: staging the same target again with the same results (the gate staged it
|
|
2002
|
+
// first in a split, rightkit-gate stageSidecarsOnce) leaves every byte unchanged, so the gate's stage ledger sees no change to its staged outputs.
|
|
2003
|
+
const sameAs = (old) => { const { stagedAt: _a, ...a } = old; const { stagedAt: _b, ...b } = receipt; return JSON.stringify(a) === JSON.stringify(b); };
|
|
2004
|
+
let previous = null;
|
|
2005
|
+
try { previous = JSON.parse(fs.readFileSync(receiptFile, "utf8")); } catch { previous = null; }
|
|
2006
|
+
if (previous && sameAs(previous)) return { receipt: previous, receiptPath: receiptFile, files: previous.files, externalBinNames: previous.files.filter((file) => file.externalBin).map((file) => file.name), unchanged: true };
|
|
1993
2007
|
fs.mkdirSync(path.dirname(receiptFile), { recursive: true });
|
|
1994
2008
|
fs.writeFileSync(receiptFile, `${JSON.stringify(receipt, null, 2)}\n`);
|
|
1995
2009
|
return { receipt, receiptPath: receiptFile, files, externalBinNames: files.filter((file) => file.externalBin).map((file) => file.name) };
|
package/suite-signing.mjs
CHANGED
|
@@ -34,6 +34,7 @@ import { signMacosEntries } from "./mac-sign-core.mjs";
|
|
|
34
34
|
import { runMacCodesignCommands } from "./macos-signing-session.mjs";
|
|
35
35
|
import { resolveManagedRoots } from "./managed-roots.mjs";
|
|
36
36
|
import { isInside, realish } from "./qa-snapshot.mjs";
|
|
37
|
+
import { GATE_TARGET_DIRS_ENV } from "./qa-layout.mjs";
|
|
37
38
|
import { requireSignerToken, signWindowsFiles, verifyWindowsFiles } from "./sign-windows.mjs";
|
|
38
39
|
import { QA_APP_BINARY_ENV, QA_SIGNED_FILES_ENV, SIGN_SUBJECT, SIGN_TEAM, SuiteSigningError, assertBundleIdMatches, containedRelative, productIdentity } from "./suite-signing-policy.mjs";
|
|
39
40
|
|
|
@@ -52,6 +53,31 @@ function codesignReport(file, { run, env, root }) {
|
|
|
52
53
|
return { identifier: /^Identifier=(.+)$/m.exec(text)?.[1]?.trim(), team: /^TeamIdentifier=(.+)$/m.exec(text)?.[1]?.trim(), adhoc: /^Signature=adhoc$/m.test(text) };
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
// The QA bundle runs under app.rightkit.qa.<id> with no embedded.provisionprofile, so a provisioning profile bound to the product id cannot apply to it, and
|
|
57
|
+
// AMFI refuses to spawn a bundle that claims a profile-gated entitlement without one (POSIX 163, "Launchd job spawn failed"). Suite (QA) signing therefore
|
|
58
|
+
// drops the profile-gated keys; profile-gated capabilities (iCloud/CloudKit, push) are not exercisable in Mac source QA. Release signing never goes through here.
|
|
59
|
+
const isProfileGatedEntitlement = (key) => key === "com.apple.application-identifier" || key === "com.apple.developer.team-identifier" || key.startsWith("com.apple.developer.") || key === "aps-environment" || key === "keychain-access-groups";
|
|
60
|
+
|
|
61
|
+
/** The entitlements file to sign `index` with: the original when no key is profile-gated, else a derived plist in signedDir without them. */
|
|
62
|
+
function qaEntitlements({ file, index, signedDir, run, env, root }) {
|
|
63
|
+
const options = { cwd: root, env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true, shell: false };
|
|
64
|
+
const failed = (why) => refuse("SIGN_ENTITLEMENTS_UNREADABLE", `cannot read the entitlements ${file}: ${why}`, { file });
|
|
65
|
+
const read = run("plutil", ["-convert", "json", "-o", "-", file], options);
|
|
66
|
+
if (read?.error || read?.status !== 0) failed(read?.error?.message || output(read).trim() || `plutil exit ${read?.status ?? "unavailable"}`);
|
|
67
|
+
let parsed;
|
|
68
|
+
try { parsed = JSON.parse(read.stdout); } catch (cause) { failed(`plutil did not return JSON (${cause.message})`); }
|
|
69
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) failed("the plist is not a dictionary");
|
|
70
|
+
const dropped = Object.keys(parsed).filter(isProfileGatedEntitlement);
|
|
71
|
+
if (!dropped.length) return { file, dropped };
|
|
72
|
+
const derivedDir = path.join(signedDir, "qa-entitlements");
|
|
73
|
+
fs.mkdirSync(derivedDir, { recursive: true });
|
|
74
|
+
const derived = path.join(derivedDir, `${index}.plist`);
|
|
75
|
+
fs.writeFileSync(derived, JSON.stringify(Object.fromEntries(Object.entries(parsed).filter(([key]) => !isProfileGatedEntitlement(key)))));
|
|
76
|
+
const write = run("plutil", ["-convert", "xml1", derived], options);
|
|
77
|
+
if (write?.error || write?.status !== 0) failed(`cannot write ${derived}: ${write?.error?.message || output(write).trim() || `plutil exit ${write?.status ?? "unavailable"}`}`);
|
|
78
|
+
return { file: derived, dropped };
|
|
79
|
+
}
|
|
80
|
+
|
|
55
81
|
/** Mac: sign each copy and read it back against the release-shaped identity. Returns one evidence record per file. */
|
|
56
82
|
function signMac({ copies, name, config, appRoot, env, run, deps, identifier, install, signedDir, mainIndex }) {
|
|
57
83
|
const settings = readDevSigningSettings(install, config, { root: appRoot });
|
|
@@ -78,6 +104,13 @@ function signMac({ copies, name, config, appRoot, env, run, deps, identifier, in
|
|
|
78
104
|
const entitlements = (index === mainIndex ? lookupBundlePath(settings.entitlements, ".") : undefined) ?? lookupBundlePath(settings.entitlements, relative);
|
|
79
105
|
return { file: copy.file, ...(id !== undefined ? { identifier: id } : {}), expectedIdentifier: id ?? path.basename(copy.file), ...(entitlements ? { entitlements } : {}) };
|
|
80
106
|
});
|
|
107
|
+
entries.forEach((entry, index) => {
|
|
108
|
+
entry.entitlementsDropped = [];
|
|
109
|
+
if (!entry.entitlements) return;
|
|
110
|
+
const derived = qaEntitlements({ file: entry.entitlements, index, signedDir, run, env, root: appRoot });
|
|
111
|
+
entry.entitlements = derived.file;
|
|
112
|
+
entry.entitlementsDropped = derived.dropped;
|
|
113
|
+
});
|
|
81
114
|
const signOne = deps.signOne ?? ((args) => runMacCodesignCommands([args], { env, run, cwd: appRoot })[0]);
|
|
82
115
|
const inspect = deps.inspect ?? ((command, args) => {
|
|
83
116
|
const result = run(command, args, { cwd: appRoot, env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true, shell: false });
|
|
@@ -85,7 +118,7 @@ function signMac({ copies, name, config, appRoot, env, run, deps, identifier, in
|
|
|
85
118
|
return output(result);
|
|
86
119
|
});
|
|
87
120
|
let records;
|
|
88
|
-
try { records = signMacosEntries(entries.map(({ expectedIdentifier: _derived, ...entry }) => entry), { identity: signing.identity, signOne, inspect, bundle: signedDir }); }
|
|
121
|
+
try { records = signMacosEntries(entries.map(({ expectedIdentifier: _derived, entitlementsDropped: _dropped, ...entry }) => entry), { identity: signing.identity, signOne, inspect, bundle: signedDir }); }
|
|
89
122
|
catch (cause) { refuse("SIGN_FAILED", `${name}: ${cause.message}`, { cause }); }
|
|
90
123
|
return records.map((record, index) => {
|
|
91
124
|
const entry = entries[index];
|
|
@@ -99,7 +132,7 @@ function signMac({ copies, name, config, appRoot, env, run, deps, identifier, in
|
|
|
99
132
|
catch (error) { if (!(error instanceof MacosDevelopmentSigningError)) throw error; refuse("SIGN_REQUIREMENT_MISMATCH", `${copies[index].path}: ${error.message}`, { expected, actual: error.installedRequirement }); }
|
|
100
133
|
// assertStableDesignatedRequirement is silent about an unreadable or cdhash-bound requirement (it has no grants to protect); here either is a refusal.
|
|
101
134
|
if (requirement !== expected) refuse("SIGN_REQUIREMENT_MISMATCH", `${copies[index].path}: the designated requirement could not be read back, or is cdhash-bound, instead of ${JSON.stringify(expected)}`, { expected });
|
|
102
|
-
return { platform: "mac", file: copies[index].path, signed: record.file, sha256Before: record.before.sha256, sha256After: record.after.sha256, identifier: entry.expectedIdentifier, team: report.team, identity: signing.identity, requirement };
|
|
135
|
+
return { platform: "mac", file: copies[index].path, signed: record.file, sha256Before: record.before.sha256, sha256After: record.after.sha256, identifier: entry.expectedIdentifier, team: report.team, identity: signing.identity, requirement, entitlementsDropped: entry.entitlementsDropped };
|
|
103
136
|
});
|
|
104
137
|
}
|
|
105
138
|
|
|
@@ -164,7 +197,9 @@ export function runSignStep({ name, sign, appRoot, config, env = process.env, pl
|
|
|
164
197
|
const { identifier, mainBinaryName } = productIdentity({ readFile: read, manifestPath: install.manifestPath });
|
|
165
198
|
assertBundleIdMatches(config, identifier);
|
|
166
199
|
// 2. Where each file comes from.
|
|
167
|
-
|
|
200
|
+
// A broker host names a suite's outputs by its carried receipts; a direct host by the output directories the gate pinned for the stage
|
|
201
|
+
// (RIGHTKIT_GATE_TARGET_DIRS, test-command.mjs). With neither, nothing says where a suite's files are, and nothing is signed.
|
|
202
|
+
if (!carried?.map && !env[GATE_TARGET_DIRS_ENV]) refuse("SIGN_RECEIPT_MISSING", `${name}: no carried receipts in this environment (${carried?.error ?? "RIGHTKIT_CARRIED_CARGO_MAP is not set"}) and no gate-pinned output directories (${GATE_TARGET_DIRS_ENV}); a sign step signs a file an earlier suite built`);
|
|
168
203
|
if (typeof outputDirsOf !== "function") refuse("SIGN_RECEIPT_MISSING", `${name}: the run has no receipt reader`);
|
|
169
204
|
const sources = sign.files.map((item, index) => {
|
|
170
205
|
const file = { ...item, path: containedRelative(item.path, `${name}.files[${index}].path`) };
|
|
@@ -175,7 +210,8 @@ export function runSignStep({ name, sign, appRoot, config, env = process.env, pl
|
|
|
175
210
|
// 3. A gate-managed copy, outside everything the build owns.
|
|
176
211
|
const signingRoot = deps.signingDirectory ?? defaultSigningDirectory({ env, platform });
|
|
177
212
|
signedDir = path.join(signingRoot, `${name}-${process.pid}-${Date.now().toString(36)}-${randomBytes(4).toString("hex")}`);
|
|
178
|
-
|
|
213
|
+
// A direct host (no carrier) has no broker section: the checkout and the suites' own output directories are still forbidden.
|
|
214
|
+
const section = carried?.map?.broker ?? {};
|
|
179
215
|
const forbidden = [section.snapshot?.path, section.checkout, env.RK_GATE_CHECKOUT, ...sources.flatMap((source) => source.dirs)].filter((p) => typeof p === "string" && p);
|
|
180
216
|
for (const place of forbidden) if (isInside(signedDir, place)) refuse("SIGN_COPY_PLACE", `${name}: the signing directory ${signedDir} is inside ${place} (the snapshot, the checkout or a broker output directory)`);
|
|
181
217
|
fs.mkdirSync(signedDir, { recursive: true, mode: 0o700 });
|
package/swift-lock.mjs
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
// File-only SwiftPM lockfile check (class A scan `swift-lock`; Adrian 2026-11-01: "the build should not be able to proceed if the lockfile
|
|
2
|
+
// isn't a match"). Like `cargo --locked` and `pnpm --frozen-lockfile`, a committed Package.resolved that disagrees with the declared
|
|
3
|
+
// dependencies must fail before any compile. This module reads committed files and executes nothing: Package.swift is code, so SwiftPM is
|
|
4
|
+
// never run; the `.package(...)` declarations are read literally, and a declaration that cannot be read literally is a finding (never a
|
|
5
|
+
// silent pass). It checks the ROOT package's own declarations against the pins: transitive pins and the pins' own consistency with
|
|
6
|
+
// dependency manifests are SwiftPM's (`--disable-automatic-resolution` in the class B swift-build scan).
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
|
|
9
|
+
// ---- versions -------------------------------------------------------------------------------------------------------------
|
|
10
|
+
const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
11
|
+
export function parseVersion(text) {
|
|
12
|
+
const m = SEMVER.exec(String(text ?? "").trim());
|
|
13
|
+
if (!m) return null;
|
|
14
|
+
return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]), pre: m[4] ? m[4].split(".") : [], text: String(text).trim() };
|
|
15
|
+
}
|
|
16
|
+
const cmpIdent = (a, b) => {
|
|
17
|
+
const an = /^\d+$/.test(a); const bn = /^\d+$/.test(b);
|
|
18
|
+
if (an && bn) return Math.sign(Number(a) - Number(b));
|
|
19
|
+
if (an) return -1;
|
|
20
|
+
if (bn) return 1;
|
|
21
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
22
|
+
};
|
|
23
|
+
export function compareVersions(a, b) {
|
|
24
|
+
for (const k of ["major", "minor", "patch"]) if (a[k] !== b[k]) return a[k] < b[k] ? -1 : 1;
|
|
25
|
+
if (!a.pre.length && !b.pre.length) return 0;
|
|
26
|
+
if (!a.pre.length) return 1;
|
|
27
|
+
if (!b.pre.length) return -1;
|
|
28
|
+
for (let i = 0; i < Math.max(a.pre.length, b.pre.length); i++) {
|
|
29
|
+
if (a.pre[i] === undefined) return -1;
|
|
30
|
+
if (b.pre[i] === undefined) return 1;
|
|
31
|
+
const c = cmpIdent(a.pre[i], b.pre[i]);
|
|
32
|
+
if (c) return c;
|
|
33
|
+
}
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
36
|
+
const V = (major, minor, patch) => ({ major, minor, patch, pre: [], text: `${major}.${minor}.${patch}` });
|
|
37
|
+
|
|
38
|
+
/** Requirement as { type, ... } -> readable text. */
|
|
39
|
+
export function requirementText(r) {
|
|
40
|
+
switch (r.type) {
|
|
41
|
+
case "exact": return `exact ${r.version}`;
|
|
42
|
+
case "from": case "upToNextMajor": return `from ${r.version} (up to next major)`;
|
|
43
|
+
case "upToNextMinor": return `from ${r.version} (up to next minor)`;
|
|
44
|
+
case "range": return `${r.lower}${r.closed ? "..." : "..<"}${r.upper}`;
|
|
45
|
+
case "branch": return `branch ${r.branch}`;
|
|
46
|
+
case "revision": return `revision ${r.revision}`;
|
|
47
|
+
default: return JSON.stringify(r);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** null when the pin ({ version, branch, revision }) satisfies the requirement, else the reason. */
|
|
52
|
+
export function requirementProblem(req, pin) {
|
|
53
|
+
const pinned = pin.version ? `version ${pin.version}` : pin.branch ? `branch ${pin.branch}` : pin.revision ? `revision ${pin.revision}` : "no version";
|
|
54
|
+
if (req.type === "branch") return pin.branch === req.branch ? null : `pins ${pinned}, not branch ${req.branch}`;
|
|
55
|
+
if (req.type === "revision") return pin.revision && String(pin.revision).toLowerCase() === req.revision.toLowerCase() ? null : `pins ${pinned}, not revision ${req.revision}`;
|
|
56
|
+
const v = parseVersion(pin.version);
|
|
57
|
+
if (!v) return `pins ${pinned}, which is not a semantic version`;
|
|
58
|
+
const lo = (text) => parseVersion(text);
|
|
59
|
+
switch (req.type) {
|
|
60
|
+
case "exact": { const want = lo(req.version); return compareVersions(v, want) === 0 ? null : `pins version ${pin.version}, not ${req.version}`; }
|
|
61
|
+
case "from": case "upToNextMajor": { const a = lo(req.version); return compareVersions(v, a) >= 0 && compareVersions(v, V(a.major + 1, 0, 0)) < 0 ? null : `pins version ${pin.version}, outside ${req.version} up to next major`; }
|
|
62
|
+
case "upToNextMinor": { const a = lo(req.version); return compareVersions(v, a) >= 0 && compareVersions(v, V(a.major, a.minor + 1, 0)) < 0 ? null : `pins version ${pin.version}, outside ${req.version} up to next minor`; }
|
|
63
|
+
case "range": {
|
|
64
|
+
const a = lo(req.lower); const b = lo(req.upper);
|
|
65
|
+
const upper = compareVersions(v, b);
|
|
66
|
+
return compareVersions(v, a) >= 0 && (req.closed ? upper <= 0 : upper < 0) ? null : `pins version ${pin.version}, outside ${req.lower}${req.closed ? "..." : "..<"}${req.upper}`;
|
|
67
|
+
}
|
|
68
|
+
default: return `has an unknown requirement kind ${req.type}`;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---- locations ------------------------------------------------------------------------------------------------------------
|
|
73
|
+
/** SwiftPM's package identity of a URL or path: its last path component without `.git`, lower-cased. */
|
|
74
|
+
export function identityOf(location) {
|
|
75
|
+
const parts = String(location).replace(/[\\/]+$/, "").split(/[\\/:]/);
|
|
76
|
+
return (parts[parts.length - 1] ?? "").replace(/\.git$/i, "").toLowerCase();
|
|
77
|
+
}
|
|
78
|
+
/** A comparable form of a repository location: no scheme, user, `.git`, trailing slash or case; scp form equals URL form. */
|
|
79
|
+
export function normalizeLocation(location) {
|
|
80
|
+
let t = String(location).trim().toLowerCase().replace(/^[a-z][a-z0-9+.-]*:\/\//, "").replace(/^[^@/]+@/, "");
|
|
81
|
+
t = t.replace(/^([^/:]+):(?!\d+\/)/, "$1/").replace(/\/+$/, "").replace(/\.git$/, "").replace(/\/+$/, "");
|
|
82
|
+
return t;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---- Package.swift --------------------------------------------------------------------------------------------------------
|
|
86
|
+
/** The text with comments blanked (newlines kept, offsets unchanged); string literals are left whole. */
|
|
87
|
+
function maskComments(text) {
|
|
88
|
+
let out = ""; let i = 0;
|
|
89
|
+
while (i < text.length) {
|
|
90
|
+
const c = text[i]; const two = text.slice(i, i + 2);
|
|
91
|
+
if (text.startsWith('"""', i)) { const end = text.indexOf('"""', i + 3); const stop = end < 0 ? text.length : end + 3; out += text.slice(i, stop); i = stop; continue; }
|
|
92
|
+
if (c === '"') { let j = i + 1; while (j < text.length && text[j] !== '"' && text[j] !== "\n") j += text[j] === "\\" ? 2 : 1; out += text.slice(i, j + 1); i = j + 1; continue; }
|
|
93
|
+
if (two === "//") { while (i < text.length && text[i] !== "\n") { out += " "; i++; } continue; }
|
|
94
|
+
if (two === "/*") {
|
|
95
|
+
let depth = 0;
|
|
96
|
+
while (i < text.length) {
|
|
97
|
+
if (text.startsWith("/*", i)) { depth++; out += " "; i += 2; } else if (text.startsWith("*/", i)) { depth--; out += " "; i += 2; if (!depth) break; } else { out += text[i] === "\n" ? "\n" : " "; i++; }
|
|
98
|
+
}
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
out += c; i++;
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Index just past the `)` that closes the `(` at `open`, or -1. Strings are skipped whole. */
|
|
107
|
+
function closeParen(text, open) {
|
|
108
|
+
let depth = 0;
|
|
109
|
+
for (let i = open; i < text.length; i++) {
|
|
110
|
+
const c = text[i];
|
|
111
|
+
if (c === '"') { if (text.startsWith('"""', i)) { const e = text.indexOf('"""', i + 3); if (e < 0) return -1; i = e + 2; continue; } i++; while (i < text.length && text[i] !== '"' && text[i] !== "\n") i += text[i] === "\\" ? 2 : 1; continue; }
|
|
112
|
+
if (c === "(" || c === "[" || c === "{") depth++;
|
|
113
|
+
else if (c === ")" || c === "]" || c === "}") { depth--; if (depth === 0) return i + 1; if (depth < 0) return -1; }
|
|
114
|
+
}
|
|
115
|
+
return -1;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function splitArgs(body) {
|
|
119
|
+
const args = []; let depth = 0; let start = 0;
|
|
120
|
+
for (let i = 0; i < body.length; i++) {
|
|
121
|
+
const c = body[i];
|
|
122
|
+
if (c === '"') { i++; while (i < body.length && body[i] !== '"') i += body[i] === "\\" ? 2 : 1; continue; }
|
|
123
|
+
if (c === "(" || c === "[" || c === "{") depth++;
|
|
124
|
+
else if (c === ")" || c === "]" || c === "}") depth--;
|
|
125
|
+
else if (c === "," && depth === 0) { args.push(body.slice(start, i)); start = i + 1; }
|
|
126
|
+
}
|
|
127
|
+
const last = body.slice(start);
|
|
128
|
+
if (last.trim() !== "" || args.length) args.push(last);
|
|
129
|
+
return args.map((a) => a.trim());
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const STR = '"((?:[^"\\\\\\n]|\\\\[^(\\n])*)"';
|
|
133
|
+
const strOf = (expr) => { const m = new RegExp(`^${STR}$`).exec(expr.trim()); return m ? m[1] : null; };
|
|
134
|
+
const RANGE = new RegExp(`^${STR}\\s*(\\.\\.<|\\.\\.\\.)\\s*${STR}$`);
|
|
135
|
+
const FUNC = new RegExp(`^(?:Package\\.Dependency\\.Requirement)?\\.(upToNextMajor|upToNextMinor|exact|branch|revision)\\(\\s*(?:(?:from|_)\\s*:\\s*)?${STR}\\s*\\)$`);
|
|
136
|
+
|
|
137
|
+
function versionOrNull(s) { return parseVersion(s) ? s : null; }
|
|
138
|
+
|
|
139
|
+
/** Parse one `.package(...)` argument list: { kind: "remote"|"local", url, path, requirement } or { problem }. */
|
|
140
|
+
function parseDependency(args) {
|
|
141
|
+
let url = null; let local = null; let requirement = null;
|
|
142
|
+
const fail = (problem) => ({ problem });
|
|
143
|
+
for (const arg of args) {
|
|
144
|
+
const lab = /^([A-Za-z_]\w*)\s*:\s*([\s\S]*)$/.exec(arg);
|
|
145
|
+
if (lab) {
|
|
146
|
+
const [, label, expr] = lab;
|
|
147
|
+
if (label === "name" || label === "traits") continue;
|
|
148
|
+
if (label === "url") { url = strOf(expr); if (url === null) return fail(`url is not a string literal (${arg.slice(0, 60)})`); continue; }
|
|
149
|
+
if (label === "path") { local = strOf(expr); if (local === null) return fail(`path is not a string literal (${arg.slice(0, 60)})`); continue; }
|
|
150
|
+
if (label === "exact" || label === "from") {
|
|
151
|
+
const v = strOf(expr);
|
|
152
|
+
if (v === null || !versionOrNull(v)) return fail(`${label}: is not a literal semantic version (${expr.slice(0, 60)})`);
|
|
153
|
+
if (requirement) return fail("more than one requirement");
|
|
154
|
+
requirement = label === "exact" ? { type: "exact", version: v } : { type: "from", version: v };
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (label === "branch" || label === "revision") {
|
|
158
|
+
const v = strOf(expr);
|
|
159
|
+
if (v === null || v === "") return fail(`${label}: is not a literal string (${expr.slice(0, 60)})`);
|
|
160
|
+
if (requirement) return fail("more than one requirement");
|
|
161
|
+
requirement = label === "branch" ? { type: "branch", branch: v } : { type: "revision", revision: v };
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
return fail(`unsupported argument label ${label}:`);
|
|
165
|
+
}
|
|
166
|
+
if (requirement) return fail("more than one requirement");
|
|
167
|
+
const range = RANGE.exec(arg);
|
|
168
|
+
if (range) {
|
|
169
|
+
if (!versionOrNull(range[1]) || !versionOrNull(range[3])) return fail(`range bounds are not literal semantic versions (${arg.slice(0, 60)})`);
|
|
170
|
+
requirement = { type: "range", lower: range[1], upper: range[3], closed: range[2] === "..." };
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const fn = FUNC.exec(arg);
|
|
174
|
+
if (fn) {
|
|
175
|
+
const [, kind, v] = fn;
|
|
176
|
+
if ((kind === "exact" || kind === "upToNextMajor" || kind === "upToNextMinor") && !versionOrNull(v)) return fail(`${kind} is not a literal semantic version (${arg.slice(0, 60)})`);
|
|
177
|
+
requirement = kind === "branch" ? { type: "branch", branch: v } : kind === "revision" ? { type: "revision", revision: v } : { type: kind, version: v };
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
return fail(`requirement is not a literal the checker reads (${arg.slice(0, 60)})`);
|
|
181
|
+
}
|
|
182
|
+
if (local !== null && url !== null) return fail("declares both url: and path:");
|
|
183
|
+
if (local !== null) return { kind: "local", path: local };
|
|
184
|
+
if (url === null) return fail("has no url: or path: (registry or computed dependency)");
|
|
185
|
+
if (!requirement) return fail("remote dependency has no requirement");
|
|
186
|
+
return { kind: "remote", url, requirement };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** { deps: [{ line, kind, url, path, identity, requirement }], problems: [{ line, text }] } from Package.swift source, read literally. */
|
|
190
|
+
export function parsePackageSwift(source) {
|
|
191
|
+
const text = maskComments(String(source));
|
|
192
|
+
const deps = []; const problems = [];
|
|
193
|
+
const lineAt = (idx) => text.slice(0, idx).split("\n").length;
|
|
194
|
+
// Lines inside an `#if` ... `#endif` region cannot be evaluated without Swift's compiler conditions.
|
|
195
|
+
const conditional = new Set(); let depth = 0;
|
|
196
|
+
text.split("\n").forEach((l, i) => {
|
|
197
|
+
const t = l.trim();
|
|
198
|
+
if (/^#if\b/.test(t)) depth++;
|
|
199
|
+
if (depth > 0) conditional.add(i + 1);
|
|
200
|
+
if (/^#endif\b/.test(t)) depth = Math.max(0, depth - 1);
|
|
201
|
+
});
|
|
202
|
+
const re = /\.package\s*\(/g; let m;
|
|
203
|
+
while ((m = re.exec(text))) {
|
|
204
|
+
const open = m.index + m[0].length - 1;
|
|
205
|
+
const line = lineAt(m.index);
|
|
206
|
+
const end = closeParen(text, open);
|
|
207
|
+
if (end < 0) { problems.push({ line, text: "unbalanced .package( declaration" }); break; }
|
|
208
|
+
const parsed = parseDependency(splitArgs(text.slice(open + 1, end - 1)));
|
|
209
|
+
re.lastIndex = end;
|
|
210
|
+
if (parsed.problem) { problems.push({ line, text: `.package declaration cannot be read literally: ${parsed.problem}` }); continue; }
|
|
211
|
+
if (conditional.has(line)) { problems.push({ line, text: `.package declaration is inside an #if region, which cannot be evaluated literally (${parsed.kind === "remote" ? parsed.url : parsed.path})` }); continue; }
|
|
212
|
+
deps.push({ line, ...parsed, identity: identityOf(parsed.kind === "remote" ? parsed.url : parsed.path) });
|
|
213
|
+
}
|
|
214
|
+
return { deps, problems };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ---- Package.resolved -----------------------------------------------------------------------------------------------------
|
|
218
|
+
/** { pins: [{ identity, location, version, branch, revision }], problems: [text] } for Package.resolved versions 1, 2 and 3. */
|
|
219
|
+
export function parseResolved(source) {
|
|
220
|
+
let doc;
|
|
221
|
+
try { doc = JSON.parse(String(source).replace(/^/, "")); } catch (e) { return { pins: [], problems: [`not valid JSON: ${e.message}`] }; }
|
|
222
|
+
const v = doc?.version;
|
|
223
|
+
let raw;
|
|
224
|
+
if (v === 1) raw = doc?.object?.pins;
|
|
225
|
+
else if (v === 2 || v === 3) raw = doc?.pins;
|
|
226
|
+
else return { pins: [], problems: [`unsupported Package.resolved version ${JSON.stringify(v)} (the checker reads 1, 2 and 3)`] };
|
|
227
|
+
if (!Array.isArray(raw)) return { pins: [], problems: [`Package.resolved version ${v} has no pins array`] };
|
|
228
|
+
const pins = [];
|
|
229
|
+
for (const p of raw) {
|
|
230
|
+
const location = v === 1 ? p?.repositoryURL : p?.location;
|
|
231
|
+
if (typeof location !== "string") return { pins: [], problems: [`a Package.resolved version ${v} pin has no ${v === 1 ? "repositoryURL" : "location"}`] };
|
|
232
|
+
const state = p.state ?? {};
|
|
233
|
+
pins.push({ identity: identityOf(location), location, version: state.version ?? null, branch: state.branch ?? null, revision: state.revision ?? null });
|
|
234
|
+
}
|
|
235
|
+
return { pins, problems: [] };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Findings for the declared remote dependencies against the pins. `label(dep)` prefixes each finding with its file (and line). */
|
|
239
|
+
function matchPins(deps, pins, { label, resolvedFile }) {
|
|
240
|
+
const out = [];
|
|
241
|
+
for (const d of deps) {
|
|
242
|
+
const named = pins.filter((p) => p.identity === d.identity);
|
|
243
|
+
const where = `${label(d)}: package ${d.identity} requires ${requirementText(d.requirement)}`;
|
|
244
|
+
if (!named.length) { out.push(`${where} but ${resolvedFile} has no pin for it`); continue; }
|
|
245
|
+
const pin = named.find((p) => normalizeLocation(p.location) === normalizeLocation(d.url));
|
|
246
|
+
if (!pin) { out.push(`${where} from ${d.url} but ${resolvedFile} pins ${named.map((p) => p.location).join(", ")}`); continue; }
|
|
247
|
+
const why = requirementProblem(d.requirement, pin);
|
|
248
|
+
if (why) out.push(`${where} but ${resolvedFile} ${why}`);
|
|
249
|
+
}
|
|
250
|
+
return out;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const join = (dir, f) => (dir === "" || dir === "." ? f : `${dir}/${f}`);
|
|
254
|
+
const tryRead = (read, exists, file) => (exists(file) ? { text: read(file) } : null);
|
|
255
|
+
|
|
256
|
+
/** Findings for the Swift package at `dir` (repo-relative, "." for the root). `read`/`exists` take repo-relative paths. */
|
|
257
|
+
export function packageLockProblems({ dir, read, exists }) {
|
|
258
|
+
const manifest = join(dir, "Package.swift"); const resolvedFile = join(dir, "Package.resolved");
|
|
259
|
+
const src = tryRead(read, exists, manifest);
|
|
260
|
+
if (!src) return [];
|
|
261
|
+
const { deps, problems } = parsePackageSwift(src.text);
|
|
262
|
+
const out = problems.map((p) => `${manifest}:${p.line}: ${p.text}`);
|
|
263
|
+
const remote = deps.filter((d) => d.kind === "remote");
|
|
264
|
+
const resolved = tryRead(read, exists, resolvedFile);
|
|
265
|
+
if (!resolved) {
|
|
266
|
+
if (remote.length) out.push(`${manifest}: ${remote.length} remote ${remote.length === 1 ? "dependency" : "dependencies"} (${remote.map((d) => d.identity).join(", ")}) and no ${resolvedFile}; commit the lockfile`);
|
|
267
|
+
return out;
|
|
268
|
+
}
|
|
269
|
+
const parsed = parseResolved(resolved.text);
|
|
270
|
+
if (parsed.problems.length) return [...out, ...parsed.problems.map((p) => `${resolvedFile}: ${p}`)];
|
|
271
|
+
out.push(...matchPins(remote, parsed.pins, { label: (d) => `${manifest}:${d.line}`, resolvedFile }));
|
|
272
|
+
return out;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ---- Xcode projects -------------------------------------------------------------------------------------------------------
|
|
276
|
+
const unquote = (v) => { const t = v.trim(); return t.startsWith('"') && t.endsWith('"') ? t.slice(1, -1).replace(/\\"/g, '"') : t; };
|
|
277
|
+
const field = (block, name) => { const m = new RegExp(`(?:^|[\\s;{])${name}\\s*=\\s*("(?:[^"\\\\]|\\\\.)*"|[^;\\s]+)\\s*;`).exec(block); return m ? unquote(m[1]) : null; };
|
|
278
|
+
|
|
279
|
+
/** { deps, problems } for the XCRemoteSwiftPackageReference objects of a project.pbxproj. */
|
|
280
|
+
export function parsePbxproj(source) {
|
|
281
|
+
const text = String(source); const deps = []; const problems = [];
|
|
282
|
+
const re = /isa\s*=\s*XCRemoteSwiftPackageReference\s*;/g; let m;
|
|
283
|
+
while ((m = re.exec(text))) {
|
|
284
|
+
const line = text.slice(0, m.index).split("\n").length;
|
|
285
|
+
const open = text.lastIndexOf("{", m.index);
|
|
286
|
+
let depth = 0; let end = -1;
|
|
287
|
+
for (let i = open; i < text.length; i++) { if (text[i] === "{") depth++; else if (text[i] === "}") { depth--; if (depth === 0) { end = i; break; } } }
|
|
288
|
+
if (open < 0 || end < 0) { problems.push({ line, text: "unbalanced XCRemoteSwiftPackageReference object" }); continue; }
|
|
289
|
+
const block = text.slice(open + 1, end);
|
|
290
|
+
const url = field(block, "repositoryURL");
|
|
291
|
+
const reqOpen = block.search(/requirement\s*=\s*\{/);
|
|
292
|
+
if (!url || reqOpen < 0) { problems.push({ line, text: "XCRemoteSwiftPackageReference has no repositoryURL or requirement the checker reads" }); continue; }
|
|
293
|
+
const req = block.slice(block.indexOf("{", reqOpen) + 1, block.indexOf("}", reqOpen));
|
|
294
|
+
const kind = field(req, "kind");
|
|
295
|
+
let requirement = null;
|
|
296
|
+
if (kind === "exactVersion") { const v = field(req, "version"); if (v && parseVersion(v)) requirement = { type: "exact", version: v }; }
|
|
297
|
+
else if (kind === "upToNextMajorVersion") { const v = field(req, "minimumVersion"); if (v && parseVersion(v)) requirement = { type: "upToNextMajor", version: v }; }
|
|
298
|
+
else if (kind === "upToNextMinorVersion") { const v = field(req, "minimumVersion"); if (v && parseVersion(v)) requirement = { type: "upToNextMinor", version: v }; }
|
|
299
|
+
else if (kind === "versionRange") { const a = field(req, "minimumVersion"); const b = field(req, "maximumVersion"); if (a && b && parseVersion(a) && parseVersion(b)) requirement = { type: "range", lower: a, upper: b, closed: false }; }
|
|
300
|
+
else if (kind === "branch") { const b = field(req, "branch"); if (b) requirement = { type: "branch", branch: b }; }
|
|
301
|
+
else if (kind === "revision") { const r = field(req, "revision"); if (r) requirement = { type: "revision", revision: r }; }
|
|
302
|
+
if (!requirement) { problems.push({ line, text: `requirement of ${url} cannot be read literally (kind ${kind ?? "missing"})` }); continue; }
|
|
303
|
+
deps.push({ line, kind: "remote", url, identity: identityOf(url), requirement });
|
|
304
|
+
}
|
|
305
|
+
return { deps, problems };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Findings for one `<name>.xcodeproj` directory (repo-relative). */
|
|
309
|
+
export function xcodeProjectLockProblems({ project, read, exists }) {
|
|
310
|
+
const pbx = `${project}/project.pbxproj`;
|
|
311
|
+
const resolvedFile = `${project}/project.xcworkspace/xcshareddata/swiftpm/Package.resolved`;
|
|
312
|
+
const src = tryRead(read, exists, pbx);
|
|
313
|
+
if (!src) return [];
|
|
314
|
+
const { deps, problems } = parsePbxproj(src.text);
|
|
315
|
+
const out = problems.map((p) => `${pbx}:${p.line}: ${p.text}`);
|
|
316
|
+
const resolved = tryRead(read, exists, resolvedFile);
|
|
317
|
+
if (!resolved) {
|
|
318
|
+
if (deps.length) out.push(`${pbx}: ${deps.length} remote Swift ${deps.length === 1 ? "package" : "packages"} (${deps.map((d) => d.identity).join(", ")}) and no ${resolvedFile}; commit the lockfile`);
|
|
319
|
+
return out;
|
|
320
|
+
}
|
|
321
|
+
const parsed = parseResolved(resolved.text);
|
|
322
|
+
if (parsed.problems.length) return [...out, ...parsed.problems.map((p) => `${resolvedFile}: ${p}`)];
|
|
323
|
+
out.push(...matchPins(deps, parsed.pins, { label: (d) => `${pbx}:${d.line}`, resolvedFile }));
|
|
324
|
+
return out;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** The scan's repository-relative lock owners for a changed file: a Swift package directory or an `.xcodeproj` directory. */
|
|
328
|
+
export function lockOwnerOf(file, { exists, ancestorDirs }) {
|
|
329
|
+
const parts = file.split("/");
|
|
330
|
+
const xi = parts.findIndex((p) => p.endsWith(".xcodeproj"));
|
|
331
|
+
if (xi >= 0) return { kind: "xcode", dir: parts.slice(0, xi + 1).join("/") };
|
|
332
|
+
const owner = ancestorDirs(path.posix.dirname(file)).reverse().find((d) => exists(join(d, "Package.swift")));
|
|
333
|
+
return owner === undefined ? null : { kind: "package", dir: owner === "" ? "." : owner };
|
|
334
|
+
}
|