@rightkit/release 0.2.174 → 0.4.0
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 +200 -2
- package/addon-command.mjs +3 -4
- package/android-play-upload.mjs +2 -1
- package/apple-profile-renew.mjs +11 -3
- package/artifact-storage.mjs +35 -2
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/darwin/rightkit-swap.sign.json +1 -1
- package/build-release.mjs +138 -47
- package/cli/right-release.mjs +40 -5
- package/codesign-environment.mjs +2 -2
- package/command-spec.mjs +80 -0
- package/content-digest.mjs +209 -0
- package/deps-cargo.mjs +55 -0
- package/deps.mjs +7 -0
- package/development.mjs +17 -6
- package/direct-bootstrap.mjs +5 -6
- package/disk-command.mjs +1 -0
- package/disk-policy.json +6 -2
- package/disk-stable-root.mjs +13 -9
- package/dist-scan.mjs +102 -0
- package/dmg-build.mjs +53 -4
- package/github-updater-release.mjs +70 -7
- package/icon-pack.mjs +117 -0
- package/in-place-install.mjs +17 -14
- package/init-command.mjs +116 -0
- package/install-app-windows.mjs +24 -7
- package/install-app.mjs +56 -42
- package/ios-asc-credentials.mjs +163 -0
- package/ios-build-context.mjs +129 -0
- package/ios-build-upload.mjs +282 -0
- package/ios-credentials-command.mjs +70 -0
- package/ios-dev.mjs +248 -0
- package/ios-host.mjs +76 -0
- package/ios-lock.mjs +75 -0
- package/ios-package-recipe.mjs +707 -0
- package/ios-testflight.mjs +266 -0
- package/ios-upload.mjs +313 -0
- package/local-publish.mjs +390 -47
- package/macho-approval-hash.mjs +181 -0
- package/managed-cargo.mjs +57 -1
- package/media-sidecar.mjs +20 -4
- package/mirror-root-artifact.mjs +74 -22
- package/native-cargo-layout.mjs +47 -4
- package/npm-license-notices.mjs +159 -0
- package/ownership-command.mjs +2 -1
- package/ownership-dependency-allowlist.json +2 -1
- package/ownership-evidence.mjs +30 -18
- package/ownership-key-command.mjs +69 -2
- package/ownership-mandatory-set.mjs +66 -16
- package/ownership-repositories.json +1 -1
- package/ownership-scope.mjs +94 -0
- package/ownership.mjs +86 -11
- package/package-recipe-targets.mjs +56 -2
- package/package.json +1 -1
- package/pe-content.mjs +133 -0
- package/preflight.mjs +14 -7
- package/project-type.mjs +3 -1
- package/protected-credentials.mjs +28 -1
- package/publication-artifacts.mjs +30 -5
- package/publication-gate.mjs +83 -5
- package/publish-cargo.mjs +124 -18
- package/publish-update.mjs +15 -2
- package/qa-build-features.mjs +16 -13
- package/qa-contract.mjs +30 -2
- package/qa-control-scan.mjs +4 -0
- package/release-dependencies-command.mjs +45 -0
- package/release-dependencies.mjs +60 -0
- package/release-invocation.mjs +118 -8
- package/release-switch-registry.json +126 -27
- package/release-switch-scan.mjs +62 -24
- package/release.mjs +144 -21
- package/rightkit-ownership.json +46 -2
- package/rightkit-versions.json +380 -189
- package/runtime-budgets.mjs +134 -23
- package/sign-updater.mjs +56 -59
- package/source-gate.mjs +8 -1
- package/stage-sidecar.mjs +634 -69
- package/target-distribution.mjs +5 -0
- package/test-command.mjs +190 -0
- package/toolchain-swift.mjs +239 -0
- package/upload-release.mjs +7 -2
- package/web-build.mjs +67 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// Signing-independent approval hashes for staged prebuilt binaries (Orthic-Labs/rightkit#490).
|
|
2
|
+
//
|
|
3
|
+
// `raw-sha256` the sha256 of the file bytes (Windows and Linux binaries).
|
|
4
|
+
// `macho-codesign-strip-linkedit-v1` the sha256 of a thin little-endian 64-bit Mach-O after its code signature is removed:
|
|
5
|
+
// LC_CODE_SIGNATURE and the trailing signature blob are gone, the __LINKEDIT filesize is
|
|
6
|
+
// whatever `codesign --remove-signature` leaves, and the __LINKEDIT vmsize is normalised to
|
|
7
|
+
// the page-rounded filesize, so a signed and an unsigned copy of the same build hash alike.
|
|
8
|
+
//
|
|
9
|
+
// This is the algorithm GenRight's `local-image-approval-hash.mjs` recorded as `approval_hash_algorithm` in its runtime
|
|
10
|
+
// manifest, moved here so apps stop carrying it. Every layout surprise (a fat or big-endian file, a duplicate or non-terminal
|
|
11
|
+
// signature, a load command that changed, padding that is not zero) is a typed refusal, never a guess. The signature is removed
|
|
12
|
+
// from a private copy with `/usr/bin/codesign --remove-signature`; the input file is never modified. macOS only for Mach-O.
|
|
13
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
14
|
+
import { chmodSync, lstatSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
15
|
+
import os from "node:os";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { spawnSync } from "./background-process.mjs";
|
|
18
|
+
|
|
19
|
+
export const APPROVAL_RAW_SHA256 = "raw-sha256";
|
|
20
|
+
export const APPROVAL_MACHO_STRIP_LINKEDIT_V1 = "macho-codesign-strip-linkedit-v1";
|
|
21
|
+
export const APPROVAL_ALGORITHMS = Object.freeze([APPROVAL_RAW_SHA256, APPROVAL_MACHO_STRIP_LINKEDIT_V1]);
|
|
22
|
+
|
|
23
|
+
const LC_CODE_SIGNATURE = 0x1d;
|
|
24
|
+
const LC_SEGMENT_64 = 0x19;
|
|
25
|
+
const MH_MAGIC_64 = 0xfeedfacf;
|
|
26
|
+
const CPU_TYPE_X86_64 = 0x01000007;
|
|
27
|
+
const CPU_TYPE_ARM64 = 0x0100000c;
|
|
28
|
+
|
|
29
|
+
export class ApprovalHashError extends Error {
|
|
30
|
+
constructor(code, message) { super(message); this.name = "ApprovalHashError"; this.code = code; }
|
|
31
|
+
}
|
|
32
|
+
const fail = (message, code = "APPROVAL_HASH_INVALID_MACHO") => { throw new ApprovalHashError(code, `backend integrity: ${message}`); };
|
|
33
|
+
|
|
34
|
+
/** Parse a thin 64-bit little-endian Mach-O. Exported for tests. */
|
|
35
|
+
export function parseMachO(bytes) {
|
|
36
|
+
if (bytes.length < 32 || bytes.readUInt32LE(0) !== MH_MAGIC_64) fail("only little-endian 64-bit thin Mach-O is supported");
|
|
37
|
+
const cpuType = bytes.readUInt32LE(4);
|
|
38
|
+
if (![CPU_TYPE_X86_64, CPU_TYPE_ARM64].includes(cpuType)) fail("unsupported Mach-O CPU type");
|
|
39
|
+
const ncmds = bytes.readUInt32LE(16);
|
|
40
|
+
const sizeofcmds = bytes.readUInt32LE(20);
|
|
41
|
+
const commandEnd = 32 + sizeofcmds;
|
|
42
|
+
if (ncmds > 65_536 || commandEnd > bytes.length || sizeofcmds > bytes.length / 2) fail("invalid Mach-O load command table");
|
|
43
|
+
let cursor = 32;
|
|
44
|
+
let signature = null;
|
|
45
|
+
let linkedit = null;
|
|
46
|
+
const normalizedCommands = [];
|
|
47
|
+
for (let index = 0; index < ncmds; index += 1) {
|
|
48
|
+
if (cursor + 8 > commandEnd) fail("truncated Mach-O load command");
|
|
49
|
+
const command = bytes.readUInt32LE(cursor);
|
|
50
|
+
const commandSize = bytes.readUInt32LE(cursor + 4);
|
|
51
|
+
if (commandSize < 8 || commandSize % 4 !== 0 || cursor + commandSize > commandEnd) fail("invalid Mach-O load command size");
|
|
52
|
+
const commandBytes = Buffer.from(bytes.subarray(cursor, cursor + commandSize));
|
|
53
|
+
if (command === LC_CODE_SIGNATURE) {
|
|
54
|
+
if (commandSize !== 16 || signature) fail("invalid duplicate code signature command");
|
|
55
|
+
const offset = bytes.readUInt32LE(cursor + 8);
|
|
56
|
+
const size = bytes.readUInt32LE(cursor + 12);
|
|
57
|
+
if (!size || offset + size > bytes.length) fail("invalid code signature extent");
|
|
58
|
+
signature = { offset, size };
|
|
59
|
+
} else if (command === LC_SEGMENT_64) {
|
|
60
|
+
if (commandSize < 72) fail("truncated Mach-O segment command");
|
|
61
|
+
const sectionCount = commandBytes.readUInt32LE(64);
|
|
62
|
+
if (sectionCount > 65_536 || 72 + sectionCount * 80 > commandSize) fail("invalid Mach-O segment sections");
|
|
63
|
+
const segmentName = commandBytes.subarray(8, 24).toString("utf8").replace(/\0.*$/s, "");
|
|
64
|
+
if (segmentName === "__LINKEDIT") {
|
|
65
|
+
if (linkedit) fail("duplicate __LINKEDIT segment");
|
|
66
|
+
const vmsize = commandBytes.readBigUInt64LE(32);
|
|
67
|
+
const fileoff = commandBytes.readBigUInt64LE(40);
|
|
68
|
+
const filesize = commandBytes.readBigUInt64LE(48);
|
|
69
|
+
if (fileoff + filesize > BigInt(bytes.length) || vmsize < filesize) fail("invalid __LINKEDIT extent");
|
|
70
|
+
// vmsize and filesize legitimately differ after the signature is removed: they are normalised, never compared raw.
|
|
71
|
+
commandBytes.fill(0, 32, 40);
|
|
72
|
+
commandBytes.fill(0, 48, 56);
|
|
73
|
+
linkedit = { fileoff, filesize, vmsize, vmsizeOffset: cursor + 32 };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (command !== LC_CODE_SIGNATURE) normalizedCommands.push(commandBytes);
|
|
77
|
+
cursor += commandSize;
|
|
78
|
+
}
|
|
79
|
+
if (cursor !== commandEnd) fail("load commands do not fill declared table");
|
|
80
|
+
if (!linkedit) fail("missing __LINKEDIT segment");
|
|
81
|
+
if (signature && (BigInt(signature.offset) < linkedit.fileoff
|
|
82
|
+
|| signature.offset + signature.size !== bytes.length
|
|
83
|
+
|| linkedit.fileoff + linkedit.filesize !== BigInt(bytes.length))) {
|
|
84
|
+
fail("signature is not terminal __LINKEDIT data");
|
|
85
|
+
}
|
|
86
|
+
return { bytes, cpuType, ncmds, sizeofcmds, signature, linkedit, normalizedCommands };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Every byte outside the explicitly permitted ranges must be identical between the captured original and the stripped copy.
|
|
91
|
+
* Permitted: header ncmds/sizeofcmds (checked by the caller), the load-command table (compared command by command, with only
|
|
92
|
+
* the signature command removed and __LINKEDIT vmsize/filesize normalised), the zeroed tail the shorter table leaves, and the
|
|
93
|
+
* signature blob plus its alignment padding at the end of __LINKEDIT. Code, data and all __LINKEDIT content before the
|
|
94
|
+
* signature are compared byte for byte.
|
|
95
|
+
*/
|
|
96
|
+
function assertOnlySignatureDiffers(before, after) {
|
|
97
|
+
const a = before.bytes;
|
|
98
|
+
const b = after.bytes;
|
|
99
|
+
if (!before.signature) {
|
|
100
|
+
if (a.length !== b.length || !a.equals(b)) fail("unsigned Mach-O changed during normalization");
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const same = (from, to, label) => {
|
|
104
|
+
if (from === to) return;
|
|
105
|
+
if (!a.subarray(from, to).equals(b.subarray(from, to))) fail(`signature removal changed ${label}`);
|
|
106
|
+
};
|
|
107
|
+
const beforeTable = 32 + before.sizeofcmds;
|
|
108
|
+
const afterTable = 32 + after.sizeofcmds;
|
|
109
|
+
same(0, 16, "Mach-O header");
|
|
110
|
+
same(24, 32, "Mach-O header");
|
|
111
|
+
if (b.subarray(afterTable, beforeTable).some((byte) => byte !== 0)) fail("signature removal left nonzero bytes after the load commands");
|
|
112
|
+
const keptEnd = Number(after.linkedit.fileoff + after.linkedit.filesize);
|
|
113
|
+
if (b.length !== keptEnd) fail("stripped Mach-O does not end at __LINKEDIT");
|
|
114
|
+
same(beforeTable, keptEnd, "executable or data bytes");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The approval digest of an already-unsigned copy and its signed original: verifies that signature removal changed
|
|
119
|
+
* nothing but the signature, then hashes the unsigned bytes with the normalised __LINKEDIT vmsize. Exported for tests.
|
|
120
|
+
*/
|
|
121
|
+
export function machoApprovalDigest(beforeBytes, afterBytes) {
|
|
122
|
+
const before = parseMachO(beforeBytes);
|
|
123
|
+
const after = parseMachO(Buffer.from(afterBytes));
|
|
124
|
+
if (after.signature) fail("signature remains after normalization");
|
|
125
|
+
if (before.signature && (before.ncmds !== after.ncmds + 1 || before.sizeofcmds !== after.sizeofcmds + 16)) fail("signature removal changed Mach-O command layout");
|
|
126
|
+
if (before.signature) {
|
|
127
|
+
const beforeLinkeditEnd = before.linkedit.fileoff + before.linkedit.filesize;
|
|
128
|
+
const afterLinkeditEnd = after.linkedit.fileoff + after.linkedit.filesize;
|
|
129
|
+
const removedPadding = BigInt(before.signature.offset) - afterLinkeditEnd;
|
|
130
|
+
if (beforeLinkeditEnd !== BigInt(before.signature.offset + before.signature.size)
|
|
131
|
+
|| after.linkedit.fileoff !== before.linkedit.fileoff
|
|
132
|
+
|| afterLinkeditEnd > BigInt(before.signature.offset)
|
|
133
|
+
|| removedPadding < 0n || removedPadding > 15n
|
|
134
|
+
|| BigInt(after.bytes.length) !== afterLinkeditEnd) {
|
|
135
|
+
fail("code signature is outside trailing __LINKEDIT data");
|
|
136
|
+
}
|
|
137
|
+
const start = Number(afterLinkeditEnd);
|
|
138
|
+
const end = before.signature.offset;
|
|
139
|
+
if (before.bytes.subarray(start, end).some((byte) => byte !== 0)) fail("nonzero alignment padding before code signature");
|
|
140
|
+
}
|
|
141
|
+
assertOnlySignatureDiffers(before, after);
|
|
142
|
+
if (before.normalizedCommands.length !== after.normalizedCommands.length
|
|
143
|
+
|| before.normalizedCommands.some((command, index) => !command.equals(after.normalizedCommands[index]))) {
|
|
144
|
+
fail("signature removal changed non-signature load commands");
|
|
145
|
+
}
|
|
146
|
+
const pageSize = before.cpuType === CPU_TYPE_ARM64 ? 16n * 1024n : 4096n;
|
|
147
|
+
const roundedSize = ((after.linkedit.filesize + pageSize - 1n) / pageSize) * pageSize;
|
|
148
|
+
if (after.linkedit.vmsize < roundedSize) fail("invalid __LINKEDIT virtual size");
|
|
149
|
+
after.bytes.writeBigUInt64LE(roundedSize, after.linkedit.vmsizeOffset);
|
|
150
|
+
return createHash("sha256").update(after.bytes).digest("hex");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Default signature removal: `/usr/bin/codesign --remove-signature <copy>` (macOS only). */
|
|
154
|
+
function removeSignatureWithCodesign(copy) {
|
|
155
|
+
const result = spawnSync("/usr/bin/codesign", ["--remove-signature", copy], { stdio: "ignore", timeout: 15_000, windowsHide: true });
|
|
156
|
+
if (result.error || result.status !== 0) throw new ApprovalHashError("APPROVAL_HASH_CODESIGN_FAILED", `backend integrity: codesign normalization failed (${result.error?.message ?? result.status})`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The approval digest of `file` under `algorithm`. `removeSignature(copyPath)` is injectable for tests and for hosts that
|
|
161
|
+
* strip signatures another way; the default needs macOS. The input file is never modified.
|
|
162
|
+
*/
|
|
163
|
+
export function approvalDigest(file, algorithm, { platform = process.platform, removeSignature = removeSignatureWithCodesign, tmpdir = os.tmpdir() } = {}) {
|
|
164
|
+
if (algorithm === APPROVAL_RAW_SHA256) return createHash("sha256").update(readFileSync(file)).digest("hex");
|
|
165
|
+
if (algorithm !== APPROVAL_MACHO_STRIP_LINKEDIT_V1) throw new ApprovalHashError("APPROVAL_HASH_ALGORITHM_UNKNOWN", `unknown approval hash algorithm ${JSON.stringify(algorithm)} (supported: ${APPROVAL_ALGORITHMS.join(", ")})`);
|
|
166
|
+
if (platform !== "darwin") throw new ApprovalHashError("APPROVAL_HASH_HOST_UNSUPPORTED", `${APPROVAL_MACHO_STRIP_LINKEDIT_V1} needs macOS (codesign); this host is ${platform}`);
|
|
167
|
+
if (!lstatSync(file).isFile()) fail("expected regular Mach-O file");
|
|
168
|
+
const original = readFileSync(file);
|
|
169
|
+
const before = parseMachO(original);
|
|
170
|
+
const directory = mkdtempSync(path.join(tmpdir, `rightkit-macho-${process.pid}-${randomBytes(8).toString("hex")}-`), { mode: 0o700 });
|
|
171
|
+
const copy = path.join(directory, "binary");
|
|
172
|
+
try {
|
|
173
|
+
// The copy is written from the captured bytes, never re-read from the path, so the validated snapshot is the one stripped.
|
|
174
|
+
writeFileSync(copy, original, { mode: 0o700 });
|
|
175
|
+
chmodSync(copy, 0o700);
|
|
176
|
+
if (before.signature) removeSignature(copy);
|
|
177
|
+
return machoApprovalDigest(original, readFileSync(copy));
|
|
178
|
+
} finally {
|
|
179
|
+
rmSync(directory, { recursive: true, force: true });
|
|
180
|
+
}
|
|
181
|
+
}
|
package/managed-cargo.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
|
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
5
5
|
import path from "node:path";
|
|
6
|
-
import { spawnSync } from "./background-process.mjs";
|
|
6
|
+
import { spawn as spawnProcess, spawnSync } from "./background-process.mjs";
|
|
7
7
|
|
|
8
8
|
export function managedRuntimeClient(env = process.env, platform = process.platform, exists = existsSync) {
|
|
9
9
|
const roots = managedControlCandidates({ env, platform });
|
|
@@ -18,6 +18,14 @@ export function managedRuntimeClient(env = process.env, platform = process.platf
|
|
|
18
18
|
const client = path.posix.join(env.HOME || homedir(), 'Library', 'Application Support', 'RustRight', 'runtime', 'rustright');
|
|
19
19
|
if (exists(client)) return client;
|
|
20
20
|
}
|
|
21
|
+
// RustRight's own install on Windows (2026-10-18 cutover): the runtime moved out of the control root to
|
|
22
|
+
// <drive>\.rustright\runtime, beside the control root's drive, and runtime-current was retired.
|
|
23
|
+
if (platform === 'win32') {
|
|
24
|
+
for (const root of roots) {
|
|
25
|
+
const client = path.win32.join(path.win32.parse(root).root, '.rustright', 'runtime', 'rustright.exe');
|
|
26
|
+
if (exists(client)) return client;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
21
29
|
return undefined;
|
|
22
30
|
}
|
|
23
31
|
|
|
@@ -72,3 +80,51 @@ export function runCargoSync(args, options = {}, { platform = process.platform,
|
|
|
72
80
|
function unavailable(message) {
|
|
73
81
|
return { status: null, stdout: "", stderr: message, error: Object.assign(new Error(message), { code: 'RK_BROKER_UNAVAILABLE' }) };
|
|
74
82
|
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Async `spawnSync` look-alike for the batch publisher: runs the command through the release spawn boundary
|
|
86
|
+
* (background-process.mjs), collects stdout and stderr, and resolves `{ status, signal, stdout, stderr, error }`.
|
|
87
|
+
* It never rejects: a spawn failure, a timeout or an exceeded `maxBuffer` resolves with `error` set, which is
|
|
88
|
+
* exactly how callers already read a failed `spawnSync`.
|
|
89
|
+
*/
|
|
90
|
+
export function spawnCollect(command, args, options = {}, { spawnAsync = spawnProcess } = {}) {
|
|
91
|
+
const { timeout, maxBuffer = 512 * 1024 * 1024, encoding: _encoding, stdio: _stdio, ...rest } = options;
|
|
92
|
+
return new Promise((resolve) => {
|
|
93
|
+
let child;
|
|
94
|
+
try { child = spawnAsync(command, args, { ...rest, stdio: ["ignore", "pipe", "pipe"] }); } catch (error) { resolve({ status: null, signal: null, stdout: "", stderr: "", error }); return; }
|
|
95
|
+
const out = []; const err = [];
|
|
96
|
+
let size = 0; let settled = false; let timer; let failure;
|
|
97
|
+
const finish = (result) => { if (settled) return; settled = true; clearTimeout(timer); resolve(result); };
|
|
98
|
+
const take = (chunks) => (chunk) => {
|
|
99
|
+
size += chunk.length;
|
|
100
|
+
if (size > maxBuffer) { failure ??= new Error(`output exceeded maxBuffer (${maxBuffer} bytes)`); child.kill("SIGKILL"); return; }
|
|
101
|
+
chunks.push(chunk);
|
|
102
|
+
};
|
|
103
|
+
child.stdout.on("data", take(out));
|
|
104
|
+
child.stderr.on("data", take(err));
|
|
105
|
+
child.on("error", (error) => finish({ status: null, signal: null, stdout: "", stderr: "", error }));
|
|
106
|
+
child.on("close", (status, signal) => finish({
|
|
107
|
+
status, signal, stdout: Buffer.concat(out).toString("utf8"), stderr: Buffer.concat(err).toString("utf8"), error: failure,
|
|
108
|
+
}));
|
|
109
|
+
if (timeout) timer = setTimeout(() => { failure ??= new Error(`timed out after ${timeout}ms`); child.kill("SIGKILL"); }, timeout);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Async twin of `runCargoSync`: the same client, broker-socket and darwin-never-direct selection (it asks
|
|
115
|
+
* `runCargoSync` to choose, with a spawn that only records the invocation), then runs the chosen invocation
|
|
116
|
+
* without blocking the event loop so independent crates can publish concurrently. The broker still
|
|
117
|
+
* serializes the builds themselves.
|
|
118
|
+
*/
|
|
119
|
+
export async function runCargoAsync(args, options = {}, deps = {}) {
|
|
120
|
+
const { spawnAsync = spawnCollect, ...syncDeps } = deps;
|
|
121
|
+
let chosen;
|
|
122
|
+
const probe = runCargoSync(args, options, { ...syncDeps, spawn: (command, commandArgs, commandOptions) => { chosen = { command, args: commandArgs, options: commandOptions }; return { status: 0, stdout: "", stderr: "" }; } });
|
|
123
|
+
if (!chosen) return probe;
|
|
124
|
+
const result = await spawnAsync(chosen.command, chosen.args, chosen.options);
|
|
125
|
+
const platform = syncDeps.platform ?? process.platform;
|
|
126
|
+
if (platform === 'darwin' && result.status !== 0 && /RK_BROKER_UNAVAILABLE|RightKit broker (?:unavailable|endpoint is not configured)/i.test(String(result.stderr ?? ''))) {
|
|
127
|
+
return { ...result, error: unavailable(String(result.stderr).trim()).error };
|
|
128
|
+
}
|
|
129
|
+
return result;
|
|
130
|
+
}
|
package/media-sidecar.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// targets.mac.mediaSidecar = {
|
|
4
4
|
// arch: "aarch64", // catalog arch (default: build host); one arch per build
|
|
5
5
|
// variant: "decode", // catalog variant (default "decode")
|
|
6
|
+
// variants: { ffmpeg: "export-ass" }, // optional per-tool variant override (a tool not named uses `variant`)
|
|
6
7
|
// tools: ["ffmpeg", "ffprobe"], // default both
|
|
7
8
|
// stageDir: "src-tauri/binaries", // Tauri externalBin directory (default)
|
|
8
9
|
// bundleDir: "Contents/MacOS", // mac only: where Tauri puts externalBin (or "Contents/Resources")
|
|
@@ -26,7 +27,7 @@ export const MEDIA_SIDECAR_TOOLS = Object.freeze(["ffmpeg", "ffprobe"]);
|
|
|
26
27
|
const PLATFORM_OS = Object.freeze({ mac: "macos", win: "windows", linux: "linux" });
|
|
27
28
|
const ARCHES = new Set(["aarch64", "x86_64"]);
|
|
28
29
|
const SHA256 = /^[0-9a-f]{64}$/;
|
|
29
|
-
const CONFIG_KEYS = new Set(["arch", "variant", "tools", "stageDir", "bundleDir", "notices", "catalog", "stage"]);
|
|
30
|
+
const CONFIG_KEYS = new Set(["arch", "variant", "variants", "tools", "stageDir", "bundleDir", "notices", "catalog", "stage"]);
|
|
30
31
|
const BUNDLE_DIRS = new Set(["Contents/MacOS", "Contents/Resources"]);
|
|
31
32
|
|
|
32
33
|
const invalid = (code, message) => Object.assign(new Error(message), { code });
|
|
@@ -87,6 +88,18 @@ export function validateMediaSidecarConfig(raw, { platform, hostArch = hostCatal
|
|
|
87
88
|
if (!Array.isArray(tools) || !tools.length || new Set(tools).size !== tools.length || tools.some((tool) => !MEDIA_SIDECAR_TOOLS.includes(tool))) {
|
|
88
89
|
errors.push(`tools must be a non-empty list without duplicates drawn from ${MEDIA_SIDECAR_TOOLS.join(", ")}`);
|
|
89
90
|
}
|
|
91
|
+
// One entry may mix variants (for example the export-ass ffmpeg with the decode ffprobe): `variants` maps a tool to its variant.
|
|
92
|
+
let variants;
|
|
93
|
+
if (raw.variants !== undefined) {
|
|
94
|
+
if (!isObject(raw.variants) || !Object.keys(raw.variants).length) errors.push("variants must be an object mapping a tool to a catalog variant slug");
|
|
95
|
+
else {
|
|
96
|
+
for (const [tool, slug] of Object.entries(raw.variants)) {
|
|
97
|
+
if (!MEDIA_SIDECAR_TOOLS.includes(tool) || (Array.isArray(tools) && !tools.includes(tool))) errors.push(`variants.${tool}: not one of the configured tools`);
|
|
98
|
+
if (typeof slug !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) errors.push(`variants.${tool} must be a catalog variant slug: ${JSON.stringify(slug)}`);
|
|
99
|
+
}
|
|
100
|
+
variants = { ...raw.variants };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
90
103
|
const stageDir = relativePath(raw.stageDir ?? "src-tauri/binaries", "stageDir");
|
|
91
104
|
let bundleDir;
|
|
92
105
|
if (os === "macos") {
|
|
@@ -109,7 +122,7 @@ export function validateMediaSidecarConfig(raw, { platform, hostArch = hostCatal
|
|
|
109
122
|
else stage = raw.stage;
|
|
110
123
|
}
|
|
111
124
|
if (errors.length) throw invalid("MEDIA_SIDECAR_CONFIG_INVALID", `invalid mediaSidecar configuration:\n${errors.map((line) => `- ${line}`).join("\n")}`);
|
|
112
|
-
return { os, arch, variant, tools: [...tools], stageDir, ...(bundleDir ? { bundleDir } : {}), notices, ...(catalog ? { catalog } : {}), ...(stage ? { stage } : {}) };
|
|
125
|
+
return { os, arch, variant, ...(variants ? { variants } : {}), tools: [...tools], stageDir, ...(bundleDir ? { bundleDir } : {}), notices, ...(catalog ? { catalog } : {}), ...(stage ? { stage } : {}) };
|
|
113
126
|
}
|
|
114
127
|
|
|
115
128
|
/** `sign.nestedCode` entries for the macOS bundle: the sidecar executables, hardened runtime, no entitlements. */
|
|
@@ -126,9 +139,10 @@ export function mediaSidecarNestedCode(config) {
|
|
|
126
139
|
export function selectSidecarRows(catalog, config) {
|
|
127
140
|
if (!isObject(catalog) || !Array.isArray(catalog.artifacts)) throw invalid("MEDIA_SIDECAR_CATALOG_INVALID", "catalog has no artifacts array");
|
|
128
141
|
return config.tools.map((tool) => {
|
|
129
|
-
const
|
|
142
|
+
const variant = config.variants?.[tool] ?? config.variant;
|
|
143
|
+
const matches = catalog.artifacts.filter((row) => row.tool === tool && (row.variant ?? "decode") === variant
|
|
130
144
|
&& String(row.os).toLowerCase() === config.os && String(row.arch).toLowerCase() === config.arch);
|
|
131
|
-
const where = `${tool} ${
|
|
145
|
+
const where = `${tool} ${variant} ${config.os}/${config.arch}`;
|
|
132
146
|
if (matches.length === 0) throw invalid("MEDIA_SIDECAR_NO_ROW", `the rightkit-ffmpeg catalog has no ${where} row`);
|
|
133
147
|
if (matches.length > 1) throw invalid("MEDIA_SIDECAR_AMBIGUOUS_ROW", `the rightkit-ffmpeg catalog has ${matches.length} ${where} rows`);
|
|
134
148
|
const [row] = matches;
|
|
@@ -152,6 +166,7 @@ export function planMediaSidecar({ config, catalog, crateDir, crateVersion, cata
|
|
|
152
166
|
artifactId: row.id,
|
|
153
167
|
version: row.version,
|
|
154
168
|
contentSha256: row.contentSha256,
|
|
169
|
+
...(config.variants ? { variant: row.variant ?? "decode" } : {}),
|
|
155
170
|
stagedPath: path.resolve(root, config.stageDir, stagedFileName(row.tool, config.os, config.arch)),
|
|
156
171
|
bundledName: bundledFileName(row.tool, config.os),
|
|
157
172
|
...(config.bundleDir ? { bundlePath: `${config.bundleDir}/${bundledFileName(row.tool, config.os)}` } : {}),
|
|
@@ -242,6 +257,7 @@ export function buildMediaSidecarReceipt(plan, preSign, signed, { notices = [],
|
|
|
242
257
|
tool: file.tool,
|
|
243
258
|
artifactId: file.artifactId,
|
|
244
259
|
version: file.version,
|
|
260
|
+
...(file.variant ? { variant: file.variant } : {}),
|
|
245
261
|
catalogContentSha256: file.contentSha256,
|
|
246
262
|
preSignSha256: file.preSignSha256,
|
|
247
263
|
signedSha256: after.sha256,
|
package/mirror-root-artifact.mjs
CHANGED
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
2
|
+
// Two forms:
|
|
3
|
+
// right-release mirror-root-artifact --file <artifact> --package-root <dir> [--name <filename>]
|
|
4
|
+
// right-release mirror-root-artifact --config <right-release.config.mjs> --platform mac|win [--package-root <dir>]
|
|
5
|
+
// The config form mirrors every entry of `targets.<platform>.mirror`, a list of `"<app-relative file>"` or
|
|
6
|
+
// `{ file, name?, pruneSuffix? }`. `pruneSuffix` (for example "-setup.exe") then removes the other files with that suffix
|
|
7
|
+
// from the managed directory, so it holds exactly the current artifact. Run it as `targets.<platform>.postSign`.
|
|
8
|
+
// Output goes only to RIGHT_RELEASE_ARTIFACT_DIR (outside the repository); it never writes into the repository root.
|
|
9
|
+
import { configuredManagedRootSpellings } from "./artifact-storage.mjs";
|
|
10
|
+
import { copyFileSync, lstatSync, mkdirSync, readdirSync, realpathSync, renameSync, rmSync, statSync } from "node:fs";
|
|
3
11
|
import { execFileSync } from "./background-process.mjs";
|
|
4
12
|
import path from "node:path";
|
|
13
|
+
import { pathToFileURL } from "node:url";
|
|
5
14
|
|
|
6
15
|
const args = parseArgs(process.argv.slice(2));
|
|
7
|
-
|
|
8
|
-
|
|
16
|
+
const usage = "Usage: right-release mirror-root-artifact (--file <artifact> --package-root <dir> [--name <filename>] | --config <file> --platform mac|win [--package-root <dir>])";
|
|
17
|
+
if (!args.config && (!args.file || !args.packageRoot)) {
|
|
18
|
+
console.error(usage);
|
|
19
|
+
process.exit(2);
|
|
20
|
+
}
|
|
21
|
+
if (args.config && (!args.platform || args.file)) {
|
|
22
|
+
console.error(usage);
|
|
9
23
|
process.exit(2);
|
|
10
24
|
}
|
|
11
25
|
if (!process.env.RIGHT_RELEASE_ARTIFACT_DIR) {
|
|
@@ -13,33 +27,68 @@ if (!process.env.RIGHT_RELEASE_ARTIFACT_DIR) {
|
|
|
13
27
|
process.exit(2);
|
|
14
28
|
}
|
|
15
29
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
30
|
+
let entries;
|
|
31
|
+
let rootArg = args.packageRoot;
|
|
32
|
+
if (args.config) {
|
|
33
|
+
const configPath = path.resolve(args.config);
|
|
34
|
+
const loaded = await import(pathToFileURL(configPath).href);
|
|
35
|
+
const config = loaded.default ?? loaded.config ?? loaded;
|
|
36
|
+
const declared = config?.targets?.[args.platform]?.mirror;
|
|
37
|
+
if (!Array.isArray(declared) || declared.length === 0) {
|
|
38
|
+
console.error(`right-release mirror-root-artifact: targets.${args.platform}.mirror declares no files in ${args.config}`);
|
|
39
|
+
process.exit(2);
|
|
40
|
+
}
|
|
41
|
+
entries = declared.map((item) => (typeof item === "string" ? { file: item } : item));
|
|
42
|
+
for (const entry of entries) {
|
|
43
|
+
const unknown = entry && typeof entry === "object" ? Object.keys(entry).filter((key) => !["file", "name", "pruneSuffix"].includes(key)) : ["(not an object)"];
|
|
44
|
+
if (typeof entry?.file !== "string" || !entry.file.trim() || unknown.length || path.isAbsolute(entry.file) || entry.file.replaceAll("\\", "/").split("/").includes("..")) {
|
|
45
|
+
console.error(`right-release mirror-root-artifact: each mirror entry is an app-relative file or { file, name?, pruneSuffix? }: ${JSON.stringify(entry)}`);
|
|
46
|
+
process.exit(2);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
rootArg ??= path.dirname(configPath);
|
|
50
|
+
} else {
|
|
51
|
+
entries = [{ file: args.file, name: args.name }];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const packageRoot = realpathSync(path.resolve(rootArg));
|
|
19
55
|
const managedOutput = path.resolve(process.env.RIGHT_RELEASE_ARTIFACT_DIR);
|
|
20
56
|
const repoRoot = path.resolve(git(packageRoot, "rev-parse", "--show-toplevel"));
|
|
21
57
|
if (inside(managedOutput, repoRoot)) {
|
|
22
58
|
console.error(`right-release mirror-root-artifact: managed output must be outside repository: ${managedOutput}`);
|
|
23
59
|
process.exit(2);
|
|
24
60
|
}
|
|
25
|
-
|
|
26
|
-
console.error(`right-release mirror-root-artifact: unsafe artifact name: ${name}`);
|
|
27
|
-
process.exit(2);
|
|
28
|
-
}
|
|
29
|
-
assertSafePath(source);
|
|
30
|
-
if (!statSync(source).isFile()) {
|
|
31
|
-
console.error(`right-release mirror-root-artifact: source is not a regular file: ${source}`);
|
|
32
|
-
process.exit(2);
|
|
33
|
-
}
|
|
34
|
-
const destinations = new Set([path.join(managedOutput, name)]);
|
|
61
|
+
const safeName = (name) => name === path.basename(name) && /^[A-Za-z0-9][A-Za-z0-9._+-]{0,159}$/.test(name);
|
|
35
62
|
|
|
36
|
-
for (const
|
|
37
|
-
|
|
38
|
-
|
|
63
|
+
for (const entry of entries) {
|
|
64
|
+
const source = path.resolve(packageRoot, entry.file);
|
|
65
|
+
const name = entry.name || path.basename(source);
|
|
66
|
+
if (!safeName(name)) {
|
|
67
|
+
console.error(`right-release mirror-root-artifact: unsafe artifact name: ${name}`);
|
|
68
|
+
process.exit(2);
|
|
69
|
+
}
|
|
70
|
+
if (entry.pruneSuffix !== undefined && (typeof entry.pruneSuffix !== "string" || !/^[A-Za-z0-9._+-]{1,40}$/.test(entry.pruneSuffix) || !name.endsWith(entry.pruneSuffix))) {
|
|
71
|
+
console.error(`right-release mirror-root-artifact: pruneSuffix must be a plain suffix of the artifact name: ${entry.pruneSuffix}`);
|
|
72
|
+
process.exit(2);
|
|
73
|
+
}
|
|
74
|
+
assertSafePath(source);
|
|
75
|
+
if (!statSync(source).isFile()) {
|
|
76
|
+
console.error(`right-release mirror-root-artifact: source is not a regular file: ${source}`);
|
|
77
|
+
process.exit(2);
|
|
78
|
+
}
|
|
79
|
+
const destination = path.join(managedOutput, name);
|
|
80
|
+
if (path.resolve(destination) !== source) atomicCopy(source, destination);
|
|
81
|
+
if (entry.pruneSuffix) {
|
|
82
|
+
for (const stale of readdirSync(managedOutput)) {
|
|
83
|
+
if (stale !== name && stale.endsWith(entry.pruneSuffix) && safeName(stale)) {
|
|
84
|
+
assertSafePath(path.join(managedOutput, stale));
|
|
85
|
+
rmSync(path.join(managedOutput, stale), { force: true });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
console.log(`right-release: managed artifact -> ${destination}`);
|
|
39
90
|
}
|
|
40
91
|
|
|
41
|
-
console.log(`right-release: managed artifact -> ${[...destinations][0]}`);
|
|
42
|
-
|
|
43
92
|
function git(cwd, ...commandArgs) {
|
|
44
93
|
return execFileSync("git", commandArgs, { cwd, encoding: "utf8" }).trim();
|
|
45
94
|
}
|
|
@@ -65,11 +114,14 @@ function atomicCopy(from, to) {
|
|
|
65
114
|
function assertSafePath(target) {
|
|
66
115
|
const absolute = path.resolve(target);
|
|
67
116
|
const root = path.parse(absolute).root;
|
|
117
|
+
// Only the configured managed root itself may be a symlink (same rule as artifact-storage assertSafeMutation).
|
|
118
|
+
const managedRoots = configuredManagedRootSpellings();
|
|
119
|
+
const configured = (candidate) => managedRoots.has(process.platform === "win32" ? path.resolve(candidate).toLowerCase() : path.resolve(candidate));
|
|
68
120
|
let cursor = root;
|
|
69
121
|
for (const segment of path.relative(root, absolute).split(path.sep).filter(Boolean)) {
|
|
70
122
|
cursor = path.join(cursor, segment);
|
|
71
123
|
try {
|
|
72
|
-
if (lstatSync(cursor).isSymbolicLink()) throw new Error(`refusing filesystem mutation through symlink: ${cursor}`);
|
|
124
|
+
if (lstatSync(cursor).isSymbolicLink() && !configured(cursor)) throw new Error(`refusing filesystem mutation through symlink: ${cursor}`);
|
|
73
125
|
} catch (error) {
|
|
74
126
|
if (error.code !== "ENOENT") throw error;
|
|
75
127
|
}
|
package/native-cargo-layout.mjs
CHANGED
|
@@ -18,6 +18,8 @@ export function resolveNativeCargoLayout({
|
|
|
18
18
|
read = (file) => readFileSync(file, "utf8"),
|
|
19
19
|
requireManifest = false,
|
|
20
20
|
requireLock = false,
|
|
21
|
+
// The repository the app lives in. A manifest outside the app searches for its workspace only up to this directory.
|
|
22
|
+
repoRoot,
|
|
21
23
|
} = {}) {
|
|
22
24
|
if (!appRoot) throw new TypeError("native Cargo layout requires appRoot");
|
|
23
25
|
const resolvedAppRoot = path.resolve(appRoot);
|
|
@@ -31,7 +33,7 @@ export function resolveNativeCargoLayout({
|
|
|
31
33
|
const explicitWorkspace = nativeAssembly?.workspaceRoot ?? nativeAssembly?.cargoWorkspaceRoot;
|
|
32
34
|
const workspaceRoot = explicitWorkspace
|
|
33
35
|
? normalizeWorkspaceRoot(resolveDeclaredPath(resolvedAppRoot, explicitWorkspace), exists)
|
|
34
|
-
: discoverWorkspaceRoot({ appRoot: resolvedAppRoot, manifestPath, manifestDir, manifestText, exists, read });
|
|
36
|
+
: discoverWorkspaceRoot({ appRoot: resolvedAppRoot, manifestPath, manifestDir, manifestText, exists, read, repoRoot: repoRoot ? path.resolve(repoRoot) : undefined });
|
|
35
37
|
|
|
36
38
|
const lock = resolveCargoLock({
|
|
37
39
|
appRoot: resolvedAppRoot,
|
|
@@ -84,6 +86,28 @@ export function resolveNativeCargoLayout({
|
|
|
84
86
|
return layout;
|
|
85
87
|
}
|
|
86
88
|
|
|
89
|
+
/**
|
|
90
|
+
* True when the app has a native Cargo manifest the release must hash and build against: the config declares one
|
|
91
|
+
* (nativeAssembly.cargoManifest / manifest) or the historical src-tauri/Cargo.toml exists. A native iOS app (xcodegen
|
|
92
|
+
* project, Rust static library built by iosPackage.prepare) has neither, and then owns no Cargo layout at all.
|
|
93
|
+
*/
|
|
94
|
+
export function nativeCargoDeclared({ appRoot, config, nativeAssembly = config?.nativeAssembly, exists = existsSync } = {}) {
|
|
95
|
+
if (!appRoot) throw new TypeError("native Cargo layout requires appRoot");
|
|
96
|
+
if (nativeAssembly?.cargoManifest ?? nativeAssembly?.manifest) return true;
|
|
97
|
+
return exists(path.resolve(appRoot, DEFAULT_MANIFEST));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The manifest nativeAssembly names, if any (a path relative to the app root, or absolute). */
|
|
101
|
+
export function declaredCargoManifest(config, nativeAssembly = config?.nativeAssembly) {
|
|
102
|
+
return nativeAssembly?.cargoManifest ?? nativeAssembly?.manifest;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** True when the historical Tauri host manifest exists. */
|
|
106
|
+
export function hasDefaultTauriManifest({ appRoot, exists = existsSync } = {}) {
|
|
107
|
+
if (!appRoot) throw new TypeError("native Cargo layout requires appRoot");
|
|
108
|
+
return exists(path.resolve(appRoot, DEFAULT_MANIFEST));
|
|
109
|
+
}
|
|
110
|
+
|
|
87
111
|
export const resolveCargoLayout = resolveNativeCargoLayout;
|
|
88
112
|
export const resolveNativeCargoManifest = resolveNativeCargoLayout;
|
|
89
113
|
export const resolveCargoManifest = resolveNativeCargoLayout;
|
|
@@ -160,20 +184,29 @@ function resolveCargoLock({ appRoot, manifestPath, manifestDir, workspaceRoot, n
|
|
|
160
184
|
return { path: found, source: found ? orderedSources[candidates.indexOf(found)] : undefined, candidates };
|
|
161
185
|
}
|
|
162
186
|
|
|
163
|
-
function discoverWorkspaceRoot({ appRoot, manifestPath, manifestDir, manifestText, exists, read }) {
|
|
187
|
+
function discoverWorkspaceRoot({ appRoot, manifestPath, manifestDir, manifestText, exists, read, repoRoot }) {
|
|
164
188
|
const parsed = parseCargoManifest(manifestText);
|
|
165
189
|
const declaredWorkspace = parsed.workspacePath ?? parsed.packageWorkspace;
|
|
166
190
|
if (declaredWorkspace) {
|
|
167
191
|
return normalizeWorkspaceRoot(resolveDeclaredPath(manifestDir, declaredWorkspace), exists);
|
|
168
192
|
}
|
|
169
193
|
if (parsed.hasWorkspace) return manifestDir;
|
|
194
|
+
// A manifest inside the app searches up to the app root (unchanged); one outside it (a shared crates/ tree) searches up to the
|
|
195
|
+
// nearest directory that contains both, which is where its workspace and Cargo.lock live.
|
|
196
|
+
// Only a caller that names the repository opts in to the climb above the app (native iOS); every other caller keeps the
|
|
197
|
+
// exact historical search, which never leaves the app root.
|
|
198
|
+
let boundary = appRoot;
|
|
199
|
+
if (repoRoot && !isWithin(appRoot, manifestDir)) {
|
|
200
|
+
boundary = commonAncestor(appRoot, manifestDir);
|
|
201
|
+
if (!isWithin(repoRoot, boundary)) boundary = repoRoot;
|
|
202
|
+
}
|
|
170
203
|
let current = manifestDir;
|
|
171
204
|
for (;;) {
|
|
172
205
|
const candidate = path.join(current, "Cargo.toml");
|
|
173
206
|
if (candidate !== manifestPath && exists(candidate) && parseCargoManifest(readIfPresent(candidate, exists, read)).hasWorkspace) return current;
|
|
174
|
-
if (current ===
|
|
207
|
+
if (current === boundary) break;
|
|
175
208
|
const parent = path.dirname(current);
|
|
176
|
-
if (parent === current || !isWithin(
|
|
209
|
+
if (parent === current || !isWithin(boundary, parent)) break;
|
|
177
210
|
current = parent;
|
|
178
211
|
}
|
|
179
212
|
return manifestDir;
|
|
@@ -231,6 +264,16 @@ function readIfPresent(file, exists, read) {
|
|
|
231
264
|
try { return exists(file) ? read(file) : ""; } catch { return ""; }
|
|
232
265
|
}
|
|
233
266
|
|
|
267
|
+
function commonAncestor(left, right) {
|
|
268
|
+
let current = path.resolve(left);
|
|
269
|
+
while (!isWithin(current, path.resolve(right))) {
|
|
270
|
+
const parent = path.dirname(current);
|
|
271
|
+
if (parent === current) return current;
|
|
272
|
+
current = parent;
|
|
273
|
+
}
|
|
274
|
+
return current;
|
|
275
|
+
}
|
|
276
|
+
|
|
234
277
|
function isWithin(root, candidate) {
|
|
235
278
|
const relative = path.relative(root, candidate);
|
|
236
279
|
return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
|