codex-copilot-dx 0.6.11 → 0.6.12
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 +44 -33
- package/package.json +1 -1
- package/src/cli-options.mjs +2 -2
- package/src/doctor.mjs +1 -1
- package/src/pm-studio-asar.mjs +156 -0
- package/src/pm-studio-provenance.mjs +701 -0
- package/src/pm-studio-setup-lock.mjs +336 -0
- package/src/pm-studio-setup.mjs +456 -5
- package/src/pm-studio-status.mjs +25 -3
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { PM_STUDIO_2_9_10_RECIPE } from "./pm-studio-asar.mjs";
|
|
7
|
+
|
|
8
|
+
const fsp = fs.promises;
|
|
9
|
+
|
|
10
|
+
export const PM_STUDIO_OFFICIAL_REPOSITORY = "gim-home/max-studio";
|
|
11
|
+
export const PM_STUDIO_OFFICIAL_MAX_DOWNLOAD_BYTES = 512 * 1024 * 1024;
|
|
12
|
+
|
|
13
|
+
const GITHUB_API_ORIGIN = "https://api.github.com";
|
|
14
|
+
const GITHUB_WEB_ORIGIN = "https://github.com";
|
|
15
|
+
const GITHUB_RELEASE_ASSET_ORIGIN = "https://release-assets.githubusercontent.com";
|
|
16
|
+
const MAX_REDIRECTS = 2;
|
|
17
|
+
const MAX_XATTR_OUTPUT_BYTES = 64 * 1024 * 1024;
|
|
18
|
+
const VERSION_PATTERN = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/;
|
|
19
|
+
const SHA256_DIGEST_PATTERN = /^sha256:([0-9a-f]{64})$/;
|
|
20
|
+
const LOCALE_MISSING_PATTERN = /^(?:Contents\/Resources\/[a-z]{2,3}(?:_(?:[A-Z]{2}|[0-9]{3}))?\.lproj|Contents\/Frameworks\/Electron Framework\.framework\/Versions\/A\/Resources\/[a-z]{2,3}(?:_(?:[A-Z]{2}|[0-9]{3}))?(?:_(?:FEMININE|MASCULINE|NEUTER))?\.lproj(?:\/locale\.pak)?)$/;
|
|
21
|
+
const IGNORED_XATTRS = new Set(PM_STUDIO_2_9_10_RECIPE.sourceBundleContent.ignoredXattrs);
|
|
22
|
+
|
|
23
|
+
function provenanceError(code, message) {
|
|
24
|
+
const error = new Error(message);
|
|
25
|
+
error.name = "PmStudioProvenanceError";
|
|
26
|
+
error.code = code;
|
|
27
|
+
return error;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function assertVersionAndArch(version, arch) {
|
|
31
|
+
if (typeof version !== "string" || !VERSION_PATTERN.test(version)) {
|
|
32
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_INPUT_INVALID", "PM Studio version must be an exact stable version");
|
|
33
|
+
}
|
|
34
|
+
if (arch !== "arm64" && arch !== "x64") {
|
|
35
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_INPUT_INVALID", "PM Studio architecture must be arm64 or x64");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function exactReleaseUrl(version) {
|
|
40
|
+
return `${GITHUB_WEB_ORIGIN}/${PM_STUDIO_OFFICIAL_REPOSITORY}/releases/tag/v${version}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function exactDownloadUrl(version, assetName) {
|
|
44
|
+
return `${GITHUB_WEB_ORIGIN}/${PM_STUDIO_OFFICIAL_REPOSITORY}/releases/download/v${version}/${assetName}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function expectedPmStudioAssetName(version, arch) {
|
|
48
|
+
assertVersionAndArch(version, arch);
|
|
49
|
+
return `PM-Studio-${version}-mac-${arch}.zip`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function exactUrl(value, expected) {
|
|
53
|
+
if (typeof value !== "string") return false;
|
|
54
|
+
try {
|
|
55
|
+
const parsed = new URL(value);
|
|
56
|
+
return parsed.href === expected
|
|
57
|
+
&& parsed.username === ""
|
|
58
|
+
&& parsed.password === ""
|
|
59
|
+
&& parsed.search === ""
|
|
60
|
+
&& parsed.hash === "";
|
|
61
|
+
} catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function selectOfficialPmStudioRelease(release, { version, arch } = {}) {
|
|
67
|
+
const assetName = expectedPmStudioAssetName(version, arch);
|
|
68
|
+
const tag = `v${version}`;
|
|
69
|
+
const releaseUrl = exactReleaseUrl(version);
|
|
70
|
+
if (!release || typeof release !== "object"
|
|
71
|
+
|| release.tag_name !== tag
|
|
72
|
+
|| release.draft !== false
|
|
73
|
+
|| release.prerelease !== false
|
|
74
|
+
|| !exactUrl(release.html_url, releaseUrl)
|
|
75
|
+
|| !Array.isArray(release.assets)) {
|
|
76
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_RELEASE_INVALID", "Official PM Studio release metadata is not exact");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const matches = release.assets.filter((asset) => asset?.name === assetName);
|
|
80
|
+
if (matches.length !== 1) {
|
|
81
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_ASSET_INVALID", "Official PM Studio release asset is missing or ambiguous");
|
|
82
|
+
}
|
|
83
|
+
const asset = matches[0];
|
|
84
|
+
const digest = typeof asset.digest === "string" ? SHA256_DIGEST_PATTERN.exec(asset.digest) : null;
|
|
85
|
+
const downloadUrl = exactDownloadUrl(version, assetName);
|
|
86
|
+
if (!Number.isSafeInteger(asset.size) || asset.size <= 0
|
|
87
|
+
|| !digest
|
|
88
|
+
|| !exactUrl(asset.browser_download_url, downloadUrl)) {
|
|
89
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_ASSET_INVALID", "Official PM Studio release asset cannot be verified");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
repository: PM_STUDIO_OFFICIAL_REPOSITORY,
|
|
94
|
+
version,
|
|
95
|
+
tag,
|
|
96
|
+
arch,
|
|
97
|
+
releaseUrl,
|
|
98
|
+
asset: {
|
|
99
|
+
name: assetName,
|
|
100
|
+
size: asset.size,
|
|
101
|
+
sha256: digest[1],
|
|
102
|
+
downloadUrl,
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function safeHeaders(response) {
|
|
108
|
+
return response?.headers && typeof response.headers.get === "function"
|
|
109
|
+
? response.headers
|
|
110
|
+
: new Headers(response?.headers || {});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function fetchOfficialRelease({ version, arch, fetchImpl, signal }) {
|
|
114
|
+
const url = `${GITHUB_API_ORIGIN}/repos/${PM_STUDIO_OFFICIAL_REPOSITORY}/releases/tags/v${version}`;
|
|
115
|
+
let response;
|
|
116
|
+
try {
|
|
117
|
+
response = await fetchImpl(url, {
|
|
118
|
+
method: "GET",
|
|
119
|
+
redirect: "error",
|
|
120
|
+
signal,
|
|
121
|
+
headers: {
|
|
122
|
+
Accept: "application/vnd.github+json",
|
|
123
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
} catch {
|
|
127
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_RELEASE_FETCH_FAILED", "Could not fetch official PM Studio release metadata");
|
|
128
|
+
}
|
|
129
|
+
if (!response || response.redirected === true || response.status !== 200 || typeof response.json !== "function") {
|
|
130
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_RELEASE_FETCH_FAILED", "Could not fetch official PM Studio release metadata");
|
|
131
|
+
}
|
|
132
|
+
let release;
|
|
133
|
+
try {
|
|
134
|
+
release = await response.json();
|
|
135
|
+
} catch {
|
|
136
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_RELEASE_INVALID", "Official PM Studio release metadata is invalid");
|
|
137
|
+
}
|
|
138
|
+
return selectOfficialPmStudioRelease(release, { version, arch });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function isRedirectStatus(status) {
|
|
142
|
+
return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function validateRedirect(currentUrl, location) {
|
|
146
|
+
let current;
|
|
147
|
+
let next;
|
|
148
|
+
try {
|
|
149
|
+
current = new URL(currentUrl);
|
|
150
|
+
next = new URL(location, current);
|
|
151
|
+
} catch {
|
|
152
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_REDIRECT_INVALID", "Official PM Studio artifact redirect is not allowed");
|
|
153
|
+
}
|
|
154
|
+
if (next.protocol !== "https:"
|
|
155
|
+
|| next.username !== ""
|
|
156
|
+
|| next.password !== ""
|
|
157
|
+
|| next.port !== ""
|
|
158
|
+
|| next.hash !== "") {
|
|
159
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_REDIRECT_INVALID", "Official PM Studio artifact redirect is not allowed");
|
|
160
|
+
}
|
|
161
|
+
const allowed = current.origin === GITHUB_WEB_ORIGIN
|
|
162
|
+
? next.origin === GITHUB_RELEASE_ASSET_ORIGIN
|
|
163
|
+
: current.origin === GITHUB_RELEASE_ASSET_ORIGIN && next.origin === GITHUB_RELEASE_ASSET_ORIGIN;
|
|
164
|
+
if (!allowed) {
|
|
165
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_REDIRECT_INVALID", "Official PM Studio artifact redirect is not allowed");
|
|
166
|
+
}
|
|
167
|
+
return next.href;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function sameDigest(actual, expected) {
|
|
171
|
+
const actualBytes = Buffer.from(actual, "hex");
|
|
172
|
+
const expectedBytes = Buffer.from(expected, "hex");
|
|
173
|
+
return actualBytes.length === expectedBytes.length && timingSafeEqual(actualBytes, expectedBytes);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function writeChunk(file, chunk) {
|
|
177
|
+
const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
178
|
+
let offset = 0;
|
|
179
|
+
while (offset < value.length) {
|
|
180
|
+
const { bytesWritten } = await file.write(value, offset, value.length - offset, null);
|
|
181
|
+
if (bytesWritten <= 0) {
|
|
182
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_DOWNLOAD_FAILED", "Official PM Studio artifact download failed");
|
|
183
|
+
}
|
|
184
|
+
offset += bytesWritten;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function streamArtifact(response, { archivePath, asset, maxDownloadBytes }) {
|
|
189
|
+
const headers = safeHeaders(response);
|
|
190
|
+
const contentLength = headers.get("content-length");
|
|
191
|
+
if (contentLength !== null) {
|
|
192
|
+
if (!/^(?:0|[1-9]\d*)$/.test(contentLength)) {
|
|
193
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_DOWNLOAD_SIZE_MISMATCH", "Official PM Studio artifact length is invalid");
|
|
194
|
+
}
|
|
195
|
+
const declared = Number(contentLength);
|
|
196
|
+
if (!Number.isSafeInteger(declared) || declared > maxDownloadBytes) {
|
|
197
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_DOWNLOAD_TOO_LARGE", "Official PM Studio artifact exceeds the download limit");
|
|
198
|
+
}
|
|
199
|
+
if (declared !== asset.size) {
|
|
200
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_DOWNLOAD_SIZE_MISMATCH", "Official PM Studio artifact length does not match release metadata");
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (!response.body || typeof response.body[Symbol.asyncIterator] !== "function") {
|
|
204
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_DOWNLOAD_FAILED", "Official PM Studio artifact has no streaming body");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const hash = createHash("sha256");
|
|
208
|
+
let size = 0;
|
|
209
|
+
let file;
|
|
210
|
+
try {
|
|
211
|
+
file = await fsp.open(archivePath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600);
|
|
212
|
+
for await (const chunk of response.body) {
|
|
213
|
+
const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
214
|
+
size += value.length;
|
|
215
|
+
if (!Number.isSafeInteger(size) || size > maxDownloadBytes) {
|
|
216
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_DOWNLOAD_TOO_LARGE", "Official PM Studio artifact exceeds the download limit");
|
|
217
|
+
}
|
|
218
|
+
hash.update(value);
|
|
219
|
+
await writeChunk(file, value);
|
|
220
|
+
}
|
|
221
|
+
} catch (error) {
|
|
222
|
+
if (error?.code?.startsWith?.("PM_STUDIO_PROVENANCE_")) throw error;
|
|
223
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_DOWNLOAD_FAILED", "Official PM Studio artifact download failed");
|
|
224
|
+
} finally {
|
|
225
|
+
if (file) await file.close().catch(() => {});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (size !== asset.size) {
|
|
229
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_DOWNLOAD_SIZE_MISMATCH", "Official PM Studio artifact size does not match release metadata");
|
|
230
|
+
}
|
|
231
|
+
const digest = hash.digest("hex");
|
|
232
|
+
if (!sameDigest(digest, asset.sha256)) {
|
|
233
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_DOWNLOAD_DIGEST_MISMATCH", "Official PM Studio artifact digest does not match release metadata");
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function downloadArtifact({ selected, archivePath, fetchImpl, maxDownloadBytes, signal }) {
|
|
238
|
+
let currentUrl = selected.asset.downloadUrl;
|
|
239
|
+
let redirects = 0;
|
|
240
|
+
for (;;) {
|
|
241
|
+
let response;
|
|
242
|
+
try {
|
|
243
|
+
response = await fetchImpl(currentUrl, { method: "GET", redirect: "manual", signal });
|
|
244
|
+
} catch {
|
|
245
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_DOWNLOAD_FAILED", "Official PM Studio artifact download failed");
|
|
246
|
+
}
|
|
247
|
+
if (!response || response.redirected === true) {
|
|
248
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_REDIRECT_INVALID", "Official PM Studio artifact redirect is not allowed");
|
|
249
|
+
}
|
|
250
|
+
if (response.status >= 300 && response.status < 400 && !isRedirectStatus(response.status)) {
|
|
251
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_REDIRECT_INVALID", "Official PM Studio artifact redirect is not allowed");
|
|
252
|
+
}
|
|
253
|
+
if (isRedirectStatus(response.status)) {
|
|
254
|
+
if (redirects >= MAX_REDIRECTS) {
|
|
255
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_REDIRECT_INVALID", "Official PM Studio artifact redirect is not allowed");
|
|
256
|
+
}
|
|
257
|
+
const location = safeHeaders(response).get("location");
|
|
258
|
+
if (!location) {
|
|
259
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_REDIRECT_INVALID", "Official PM Studio artifact redirect is not allowed");
|
|
260
|
+
}
|
|
261
|
+
currentUrl = validateRedirect(currentUrl, location);
|
|
262
|
+
redirects += 1;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (response.status !== 200) {
|
|
266
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_DOWNLOAD_FAILED", "Official PM Studio artifact download failed");
|
|
267
|
+
}
|
|
268
|
+
await streamArtifact(response, { archivePath, asset: selected.asset, maxDownloadBytes });
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function isWithin(root, candidate) {
|
|
274
|
+
return candidate === root || candidate.startsWith(`${root}${path.sep}`);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function findExtractedApp(destination) {
|
|
278
|
+
const candidates = [];
|
|
279
|
+
const visit = async (directory) => {
|
|
280
|
+
const names = await fsp.readdir(directory);
|
|
281
|
+
names.sort((left, right) => Buffer.compare(Buffer.from(left), Buffer.from(right)));
|
|
282
|
+
for (const name of names) {
|
|
283
|
+
const entryPath = path.join(directory, name);
|
|
284
|
+
const stat = await fsp.lstat(entryPath);
|
|
285
|
+
if (name === "PM Studio.app") {
|
|
286
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
287
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_EXTRACT_INVALID", "Extracted PM Studio app is not an ordinary directory");
|
|
288
|
+
}
|
|
289
|
+
candidates.push(entryPath);
|
|
290
|
+
await visit(entryPath);
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
if (stat.isDirectory() && !stat.isSymbolicLink()) {
|
|
294
|
+
await visit(entryPath);
|
|
295
|
+
} else if (stat.isSymbolicLink() || stat.isFile()) {
|
|
296
|
+
if (stat.isFile() && stat.nlink !== 1) {
|
|
297
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_EXTRACT_INVALID", "Extracted PM Studio artifact contains a hard link");
|
|
298
|
+
}
|
|
299
|
+
} else {
|
|
300
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_EXTRACT_INVALID", "Extracted PM Studio artifact contains an unsupported entry");
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
await visit(destination);
|
|
305
|
+
if (candidates.length !== 1) {
|
|
306
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_EXTRACT_INVALID", "Extracted artifact must contain exactly one PM Studio.app");
|
|
307
|
+
}
|
|
308
|
+
return candidates[0];
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function sameFileIdentity(before, after) {
|
|
312
|
+
return before.dev === after.dev
|
|
313
|
+
&& before.ino === after.ino
|
|
314
|
+
&& before.size === after.size
|
|
315
|
+
&& before.mtimeNs === after.mtimeNs
|
|
316
|
+
&& before.ctimeNs === after.ctimeNs;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function hashRegularFile(filePath, expected) {
|
|
320
|
+
let file;
|
|
321
|
+
const hash = createHash("sha256");
|
|
322
|
+
const buffer = Buffer.allocUnsafe(1024 * 1024);
|
|
323
|
+
try {
|
|
324
|
+
file = await fsp.open(filePath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
|
|
325
|
+
const before = await file.stat({ bigint: true });
|
|
326
|
+
if (!sameFileIdentity(expected, before)) {
|
|
327
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_TREE_CHANGED", "PM Studio app changed while it was being verified");
|
|
328
|
+
}
|
|
329
|
+
for (;;) {
|
|
330
|
+
const { bytesRead } = await file.read(buffer, 0, buffer.length, null);
|
|
331
|
+
if (bytesRead === 0) break;
|
|
332
|
+
hash.update(buffer.subarray(0, bytesRead));
|
|
333
|
+
}
|
|
334
|
+
const after = await file.stat({ bigint: true });
|
|
335
|
+
if (!sameFileIdentity(before, after)) {
|
|
336
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_TREE_CHANGED", "PM Studio app changed while it was being verified");
|
|
337
|
+
}
|
|
338
|
+
const pathAfter = await fsp.lstat(filePath, { bigint: true });
|
|
339
|
+
if (!sameFileIdentity(before, pathAfter)) {
|
|
340
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_TREE_CHANGED", "PM Studio app changed while it was being verified");
|
|
341
|
+
}
|
|
342
|
+
} catch (error) {
|
|
343
|
+
if (error?.code?.startsWith?.("PM_STUDIO_PROVENANCE_")) throw error;
|
|
344
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_TREE_INVALID", "PM Studio app contains an unreadable file");
|
|
345
|
+
} finally {
|
|
346
|
+
if (file) await file.close().catch(() => {});
|
|
347
|
+
}
|
|
348
|
+
return hash.digest("hex");
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function defaultReadXattrOutput(appPath) {
|
|
352
|
+
if (process.platform !== "darwin") return "";
|
|
353
|
+
const result = spawnSync("/usr/bin/xattr", ["-r", "-s", "-x", "-l", appPath], {
|
|
354
|
+
encoding: "utf8",
|
|
355
|
+
maxBuffer: MAX_XATTR_OUTPUT_BYTES,
|
|
356
|
+
});
|
|
357
|
+
if (result.error || result.status !== 0 || typeof result.stdout !== "string") {
|
|
358
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_XATTR_INVALID",
|
|
359
|
+
"PM Studio extended attributes could not be inspected");
|
|
360
|
+
}
|
|
361
|
+
return result.stdout;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function parseXattrOutput(output, root) {
|
|
365
|
+
const byPath = new Map();
|
|
366
|
+
const seen = new Set();
|
|
367
|
+
let current = null;
|
|
368
|
+
const flush = () => {
|
|
369
|
+
if (!current) return;
|
|
370
|
+
if (!IGNORED_XATTRS.has(current.name)) {
|
|
371
|
+
const key = `${current.relative}\0${current.name}`;
|
|
372
|
+
if (seen.has(key)) {
|
|
373
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_XATTR_INVALID",
|
|
374
|
+
"PM Studio contains a duplicate extended attribute");
|
|
375
|
+
}
|
|
376
|
+
seen.add(key);
|
|
377
|
+
const value = Buffer.from(current.hex.join(""), "hex");
|
|
378
|
+
const attributes = byPath.get(current.relative) || [];
|
|
379
|
+
attributes.push([current.name, value.length, createHash("sha256").update(value).digest("hex")]);
|
|
380
|
+
byPath.set(current.relative, attributes);
|
|
381
|
+
}
|
|
382
|
+
current = null;
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
for (const line of String(output || "").split(/\r?\n/)) {
|
|
386
|
+
if (!line) continue;
|
|
387
|
+
const separator = line.lastIndexOf(": ");
|
|
388
|
+
if ((line === `${root}:` || line.startsWith(`${root}: `) || line.startsWith(`${root}${path.sep}`))
|
|
389
|
+
&& separator >= root.length && line.endsWith(":")) {
|
|
390
|
+
flush();
|
|
391
|
+
const target = line.slice(0, separator);
|
|
392
|
+
const relative = target === root ? "." : path.relative(root, target).split(path.sep).join("/");
|
|
393
|
+
if (!relative || relative === ".." || relative.startsWith("../") || path.isAbsolute(relative)) {
|
|
394
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_XATTR_INVALID",
|
|
395
|
+
"PM Studio extended attribute path escapes the app bundle");
|
|
396
|
+
}
|
|
397
|
+
const name = line.slice(separator + 2, -1);
|
|
398
|
+
if (!name) {
|
|
399
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_XATTR_INVALID",
|
|
400
|
+
"PM Studio contains an unnamed extended attribute");
|
|
401
|
+
}
|
|
402
|
+
current = { relative, name, hex: [] };
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
if (!current) {
|
|
406
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_XATTR_INVALID",
|
|
407
|
+
"PM Studio extended attributes could not be parsed");
|
|
408
|
+
}
|
|
409
|
+
const data = line.includes(" |") ? line.slice(0, line.indexOf(" |")) : line;
|
|
410
|
+
const tokens = data.trim().split(/\s+/);
|
|
411
|
+
if (!/^[0-9a-f]{8}$/i.test(tokens[0])
|
|
412
|
+
|| tokens.slice(1).some((token) => !/^[0-9a-f]{2}$/i.test(token))) {
|
|
413
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_XATTR_INVALID",
|
|
414
|
+
"PM Studio extended attribute bytes could not be parsed");
|
|
415
|
+
}
|
|
416
|
+
current.hex.push(...tokens.slice(1));
|
|
417
|
+
}
|
|
418
|
+
flush();
|
|
419
|
+
for (const attributes of byPath.values()) {
|
|
420
|
+
attributes.sort((left, right) => Buffer.compare(Buffer.from(left[0]), Buffer.from(right[0])));
|
|
421
|
+
}
|
|
422
|
+
return byPath;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async function snapshotAppTree(appPath, readXattrOutput) {
|
|
426
|
+
const root = path.resolve(appPath);
|
|
427
|
+
let rootStat;
|
|
428
|
+
let rootReal;
|
|
429
|
+
try {
|
|
430
|
+
rootStat = await fsp.lstat(root, { bigint: true });
|
|
431
|
+
rootReal = await fsp.realpath(root);
|
|
432
|
+
} catch {
|
|
433
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_TREE_INVALID", "PM Studio app path is not readable");
|
|
434
|
+
}
|
|
435
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
|
|
436
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_TREE_INVALID", "PM Studio app path is not an ordinary directory");
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const entries = new Map();
|
|
440
|
+
const normalizedPaths = new Map();
|
|
441
|
+
const remember = (relative) => {
|
|
442
|
+
const normalized = relative.normalize("NFC");
|
|
443
|
+
const existing = normalizedPaths.get(normalized);
|
|
444
|
+
if (existing && existing !== relative) {
|
|
445
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_TREE_INVALID", "PM Studio app contains colliding paths");
|
|
446
|
+
}
|
|
447
|
+
normalizedPaths.set(normalized, relative);
|
|
448
|
+
};
|
|
449
|
+
remember(".");
|
|
450
|
+
entries.set(".", { type: "directory", mode: Number(rootStat.mode & 0o7777n) });
|
|
451
|
+
|
|
452
|
+
const visit = async (directory, expectedDirectoryStat) => {
|
|
453
|
+
let names;
|
|
454
|
+
try {
|
|
455
|
+
names = await fsp.readdir(directory);
|
|
456
|
+
} catch {
|
|
457
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_TREE_INVALID", "PM Studio app contains an unreadable directory");
|
|
458
|
+
}
|
|
459
|
+
names.sort((left, right) => Buffer.compare(Buffer.from(left), Buffer.from(right)));
|
|
460
|
+
for (const name of names) {
|
|
461
|
+
const fullPath = path.join(directory, name);
|
|
462
|
+
const relative = path.relative(root, fullPath).split(path.sep).join("/");
|
|
463
|
+
let stat;
|
|
464
|
+
try {
|
|
465
|
+
stat = await fsp.lstat(fullPath, { bigint: true });
|
|
466
|
+
} catch {
|
|
467
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_TREE_CHANGED", "PM Studio app changed while it was being verified");
|
|
468
|
+
}
|
|
469
|
+
remember(relative);
|
|
470
|
+
const mode = Number(stat.mode & 0o7777n);
|
|
471
|
+
if (stat.isDirectory() && !stat.isSymbolicLink()) {
|
|
472
|
+
entries.set(relative, { type: "directory", mode });
|
|
473
|
+
await visit(fullPath, stat);
|
|
474
|
+
} else if (stat.isSymbolicLink()) {
|
|
475
|
+
let target;
|
|
476
|
+
let targetReal;
|
|
477
|
+
try {
|
|
478
|
+
target = await fsp.readlink(fullPath);
|
|
479
|
+
if (path.isAbsolute(target)) throw new Error("absolute");
|
|
480
|
+
const lexicalTarget = path.resolve(path.dirname(fullPath), target);
|
|
481
|
+
if (!isWithin(root, lexicalTarget)) throw new Error("escape");
|
|
482
|
+
targetReal = await fsp.realpath(fullPath);
|
|
483
|
+
} catch {
|
|
484
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_SYMLINK_INVALID", "PM Studio app contains an unsafe symlink");
|
|
485
|
+
}
|
|
486
|
+
if (!isWithin(rootReal, targetReal)) {
|
|
487
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_SYMLINK_INVALID", "PM Studio app contains an unsafe symlink");
|
|
488
|
+
}
|
|
489
|
+
let pathAfter;
|
|
490
|
+
try {
|
|
491
|
+
pathAfter = await fsp.lstat(fullPath, { bigint: true });
|
|
492
|
+
} catch {
|
|
493
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_TREE_CHANGED", "PM Studio app changed while it was being verified");
|
|
494
|
+
}
|
|
495
|
+
if (!sameFileIdentity(stat, pathAfter)) {
|
|
496
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_TREE_CHANGED", "PM Studio app changed while it was being verified");
|
|
497
|
+
}
|
|
498
|
+
entries.set(relative, { type: "symlink", mode, target });
|
|
499
|
+
} else if (stat.isFile()) {
|
|
500
|
+
if (stat.nlink !== 1n) {
|
|
501
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_HARDLINK_INVALID", "PM Studio app contains a hard-linked file");
|
|
502
|
+
}
|
|
503
|
+
entries.set(relative, {
|
|
504
|
+
type: "file",
|
|
505
|
+
mode,
|
|
506
|
+
size: stat.size.toString(),
|
|
507
|
+
sha256: await hashRegularFile(fullPath, stat),
|
|
508
|
+
});
|
|
509
|
+
} else {
|
|
510
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_TREE_INVALID", "PM Studio app contains an unsupported entry");
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
let directoryAfter;
|
|
514
|
+
try {
|
|
515
|
+
directoryAfter = await fsp.lstat(directory, { bigint: true });
|
|
516
|
+
} catch {
|
|
517
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_TREE_CHANGED", "PM Studio app changed while it was being verified");
|
|
518
|
+
}
|
|
519
|
+
if (!sameFileIdentity(expectedDirectoryStat, directoryAfter)) {
|
|
520
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_TREE_CHANGED", "PM Studio app changed while it was being verified");
|
|
521
|
+
}
|
|
522
|
+
};
|
|
523
|
+
await visit(root, rootStat);
|
|
524
|
+
let xattrOutput;
|
|
525
|
+
try {
|
|
526
|
+
xattrOutput = await readXattrOutput(root);
|
|
527
|
+
} catch (error) {
|
|
528
|
+
if (error?.code?.startsWith?.("PM_STUDIO_PROVENANCE_")) throw error;
|
|
529
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_XATTR_INVALID",
|
|
530
|
+
"PM Studio extended attributes could not be inspected");
|
|
531
|
+
}
|
|
532
|
+
const xattrs = parseXattrOutput(xattrOutput, root);
|
|
533
|
+
for (const [relative, attributes] of xattrs) {
|
|
534
|
+
const entry = entries.get(relative);
|
|
535
|
+
if (!entry) {
|
|
536
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_TREE_CHANGED",
|
|
537
|
+
"PM Studio changed while its extended attributes were being verified");
|
|
538
|
+
}
|
|
539
|
+
entry.xattrs = attributes;
|
|
540
|
+
}
|
|
541
|
+
for (const entry of entries.values()) entry.xattrs ||= [];
|
|
542
|
+
return entries;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function entriesEqual(left, right) {
|
|
546
|
+
if (left.type !== right.type || left.mode !== right.mode) return false;
|
|
547
|
+
if (JSON.stringify(left.xattrs) !== JSON.stringify(right.xattrs)) return false;
|
|
548
|
+
if (left.type === "file") return left.size === right.size && left.sha256 === right.sha256;
|
|
549
|
+
if (left.type === "symlink") return left.target === right.target;
|
|
550
|
+
return true;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
export async function comparePmStudioAppTrees(installedAppPath, officialAppPath, {
|
|
554
|
+
readXattrOutput = defaultReadXattrOutput,
|
|
555
|
+
} = {}) {
|
|
556
|
+
if (typeof installedAppPath !== "string" || !installedAppPath
|
|
557
|
+
|| typeof officialAppPath !== "string" || !officialAppPath) {
|
|
558
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_INPUT_INVALID", "Both PM Studio app paths are required");
|
|
559
|
+
}
|
|
560
|
+
if (typeof readXattrOutput !== "function") {
|
|
561
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_INPUT_INVALID",
|
|
562
|
+
"PM Studio extended attribute reader is invalid");
|
|
563
|
+
}
|
|
564
|
+
const installed = await snapshotAppTree(installedAppPath, readXattrOutput);
|
|
565
|
+
const official = await snapshotAppTree(officialAppPath, readXattrOutput);
|
|
566
|
+
const ignoredMissing = [];
|
|
567
|
+
|
|
568
|
+
for (const [relative, officialEntry] of official) {
|
|
569
|
+
const installedEntry = installed.get(relative);
|
|
570
|
+
if (!installedEntry) {
|
|
571
|
+
if (LOCALE_MISSING_PATTERN.test(relative)) {
|
|
572
|
+
ignoredMissing.push(relative);
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_TREE_MISMATCH", `Installed PM Studio is missing ${relative}`);
|
|
576
|
+
}
|
|
577
|
+
if (!entriesEqual(installedEntry, officialEntry)) {
|
|
578
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_TREE_MISMATCH", `Installed PM Studio differs at ${relative}`);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
for (const relative of installed.keys()) {
|
|
582
|
+
if (!official.has(relative)) {
|
|
583
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_TREE_MISMATCH", `Installed PM Studio contains an extra entry at ${relative}`);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
return {
|
|
588
|
+
matched: true,
|
|
589
|
+
comparedEntries: official.size - ignoredMissing.length,
|
|
590
|
+
ignoredMissing,
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function validateVerificationOptions({
|
|
595
|
+
installedAppPath,
|
|
596
|
+
version,
|
|
597
|
+
arch,
|
|
598
|
+
fetchImpl,
|
|
599
|
+
extractor,
|
|
600
|
+
maxDownloadBytes,
|
|
601
|
+
readXattrOutput,
|
|
602
|
+
}) {
|
|
603
|
+
assertVersionAndArch(version, arch);
|
|
604
|
+
if (typeof installedAppPath !== "string" || !installedAppPath
|
|
605
|
+
|| typeof fetchImpl !== "function"
|
|
606
|
+
|| typeof extractor !== "function"
|
|
607
|
+
|| typeof readXattrOutput !== "function"
|
|
608
|
+
|| !Number.isSafeInteger(maxDownloadBytes)
|
|
609
|
+
|| maxDownloadBytes <= 0) {
|
|
610
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_INPUT_INVALID", "PM Studio provenance options are invalid");
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
export async function verifyOfficialPmStudioProvenance({
|
|
615
|
+
installedAppPath,
|
|
616
|
+
version,
|
|
617
|
+
arch = process.arch,
|
|
618
|
+
fetchImpl = globalThis.fetch,
|
|
619
|
+
extractor,
|
|
620
|
+
tmpRoot = os.tmpdir(),
|
|
621
|
+
maxDownloadBytes = PM_STUDIO_OFFICIAL_MAX_DOWNLOAD_BYTES,
|
|
622
|
+
readXattrOutput = defaultReadXattrOutput,
|
|
623
|
+
signal,
|
|
624
|
+
} = {}) {
|
|
625
|
+
validateVerificationOptions({
|
|
626
|
+
installedAppPath,
|
|
627
|
+
version,
|
|
628
|
+
arch,
|
|
629
|
+
fetchImpl,
|
|
630
|
+
extractor,
|
|
631
|
+
maxDownloadBytes,
|
|
632
|
+
readXattrOutput,
|
|
633
|
+
});
|
|
634
|
+
const selected = await fetchOfficialRelease({ version, arch, fetchImpl, signal });
|
|
635
|
+
if (selected.asset.size > maxDownloadBytes) {
|
|
636
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_DOWNLOAD_TOO_LARGE", "Official PM Studio artifact exceeds the download limit");
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
let workspace;
|
|
640
|
+
try {
|
|
641
|
+
let resolvedTmpRoot;
|
|
642
|
+
try {
|
|
643
|
+
resolvedTmpRoot = await fsp.realpath(tmpRoot);
|
|
644
|
+
const stat = await fsp.stat(resolvedTmpRoot);
|
|
645
|
+
if (!stat.isDirectory()) throw new Error("not a directory");
|
|
646
|
+
workspace = await fsp.mkdtemp(path.join(resolvedTmpRoot, "ccdx-pm-official-"));
|
|
647
|
+
await fsp.chmod(workspace, 0o700);
|
|
648
|
+
} catch {
|
|
649
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_TEMP_FAILED", "Could not create a private PM Studio verification directory");
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const archivePath = path.join(workspace, selected.asset.name);
|
|
653
|
+
const destination = path.join(workspace, "extracted");
|
|
654
|
+
await fsp.mkdir(destination, { mode: 0o700 });
|
|
655
|
+
await fsp.chmod(destination, 0o700);
|
|
656
|
+
await downloadArtifact({ selected, archivePath, fetchImpl, maxDownloadBytes, signal });
|
|
657
|
+
|
|
658
|
+
try {
|
|
659
|
+
await extractor({
|
|
660
|
+
archivePath,
|
|
661
|
+
destination,
|
|
662
|
+
version: selected.version,
|
|
663
|
+
arch: selected.arch,
|
|
664
|
+
asset: {
|
|
665
|
+
name: selected.asset.name,
|
|
666
|
+
size: selected.asset.size,
|
|
667
|
+
sha256: selected.asset.sha256,
|
|
668
|
+
},
|
|
669
|
+
});
|
|
670
|
+
} catch {
|
|
671
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_EXTRACT_FAILED", "Could not extract the official PM Studio artifact");
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
let officialAppPath;
|
|
675
|
+
try {
|
|
676
|
+
officialAppPath = await findExtractedApp(destination);
|
|
677
|
+
} catch (error) {
|
|
678
|
+
if (error?.code?.startsWith?.("PM_STUDIO_PROVENANCE_")) throw error;
|
|
679
|
+
throw provenanceError("PM_STUDIO_PROVENANCE_EXTRACT_INVALID", "Extracted PM Studio artifact is invalid");
|
|
680
|
+
}
|
|
681
|
+
const comparison = await comparePmStudioAppTrees(installedAppPath, officialAppPath, {
|
|
682
|
+
readXattrOutput,
|
|
683
|
+
});
|
|
684
|
+
return {
|
|
685
|
+
verified: true,
|
|
686
|
+
repository: selected.repository,
|
|
687
|
+
version: selected.version,
|
|
688
|
+
tag: selected.tag,
|
|
689
|
+
arch: selected.arch,
|
|
690
|
+
releaseUrl: selected.releaseUrl,
|
|
691
|
+
asset: {
|
|
692
|
+
name: selected.asset.name,
|
|
693
|
+
size: selected.asset.size,
|
|
694
|
+
sha256: selected.asset.sha256,
|
|
695
|
+
},
|
|
696
|
+
comparison,
|
|
697
|
+
};
|
|
698
|
+
} finally {
|
|
699
|
+
if (workspace) await fsp.rm(workspace, { recursive: true, force: true, maxRetries: 3 });
|
|
700
|
+
}
|
|
701
|
+
}
|