@papillon6814/copi 0.1.0 → 0.2.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/LICENSE.unicode +39 -0
- package/README.md +57 -5
- package/dist/native/rename-exclusive-arm64 +0 -0
- package/dist/native/rename-exclusive-arm64.manifest.json +12 -0
- package/dist/src/apply.d.ts +9 -0
- package/dist/src/apply.js +110 -9
- package/dist/src/catalog.d.ts +21 -4
- package/dist/src/catalog.js +265 -133
- package/dist/src/cli.d.ts +2 -1
- package/dist/src/cli.js +73 -3
- package/dist/src/groups.js +8 -3
- package/dist/src/inspect.d.ts +3 -1
- package/dist/src/inspect.js +16 -6
- package/dist/src/instruction-reference.d.ts +6 -0
- package/dist/src/instruction-reference.js +21 -0
- package/dist/src/manage.d.ts +2 -0
- package/dist/src/manage.js +329 -0
- package/dist/src/migrate.d.ts +25 -0
- package/dist/src/migrate.js +171 -0
- package/dist/src/migration-backup.d.ts +22 -0
- package/dist/src/migration-backup.js +403 -0
- package/dist/src/migration-inventory.d.ts +9 -0
- package/dist/src/migration-inventory.js +237 -0
- package/dist/src/migration-resolution.d.ts +28 -0
- package/dist/src/migration-resolution.js +1618 -0
- package/dist/src/migration.d.ts +49 -0
- package/dist/src/migration.js +1004 -0
- package/dist/src/names.d.ts +11 -0
- package/dist/src/names.js +40 -0
- package/dist/src/native-placement.d.ts +16 -0
- package/dist/src/native-placement.js +557 -0
- package/dist/src/refresh.d.ts +20 -0
- package/dist/src/refresh.js +143 -0
- package/dist/src/resource-mutations.d.ts +45 -0
- package/dist/src/resource-mutations.js +225 -0
- package/dist/src/source-content.d.ts +4 -0
- package/dist/src/source-content.js +208 -0
- package/dist/src/storage-journal.d.ts +111 -0
- package/dist/src/storage-journal.js +613 -0
- package/dist/src/storage-root-lease.d.ts +7 -0
- package/dist/src/storage-root-lease.js +92 -0
- package/dist/src/storage-transaction.d.ts +100 -0
- package/dist/src/storage-transaction.js +3951 -0
- package/dist/src/terminal-editor.d.ts +8 -0
- package/dist/src/terminal-editor.js +391 -0
- package/dist/src/types.d.ts +18 -2
- package/dist/src/unicode-case-folding-data.d.ts +1 -0
- package/dist/src/unicode-case-folding-data.js +1596 -0
- package/dist/src/unicode-case-folding.d.ts +2 -0
- package/dist/src/unicode-case-folding.js +9 -0
- package/dist/src/wizard.d.ts +11 -3
- package/dist/src/wizard.js +274 -18
- package/package.json +5 -3
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { StoredCatalog } from "./types.ts";
|
|
2
|
+
export interface NameConflict {
|
|
3
|
+
namespace: "item" | "group";
|
|
4
|
+
key: string;
|
|
5
|
+
ids: string[];
|
|
6
|
+
}
|
|
7
|
+
export declare function nameKey(name: string): string;
|
|
8
|
+
export declare function findNameConflicts(catalog: StoredCatalog): NameConflict[];
|
|
9
|
+
export declare function assertNoNameConflicts(catalog: StoredCatalog): void;
|
|
10
|
+
/** A rename during resolution may touch a conflict member only, and must use a free name. */
|
|
11
|
+
export declare function assertNameAvailable(catalog: StoredCatalog, namespace: "item" | "group", name: string, id?: string): void;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { foldCaseUnicode17 } from "./unicode-case-folding.js";
|
|
2
|
+
export function nameKey(name) {
|
|
3
|
+
const key = foldCaseUnicode17(name.trim().normalize("NFKC")).normalize("NFKC");
|
|
4
|
+
if (key.length === 0)
|
|
5
|
+
throw new Error("Name must not be empty after normalization.");
|
|
6
|
+
return key;
|
|
7
|
+
}
|
|
8
|
+
export function findNameConflicts(catalog) {
|
|
9
|
+
const conflicts = [];
|
|
10
|
+
for (const namespace of ["item", "group"]) {
|
|
11
|
+
const keys = new Map();
|
|
12
|
+
for (const entry of namespace === "item" ? catalog.items : catalog.groups) {
|
|
13
|
+
const key = nameKey(entry.name);
|
|
14
|
+
const ids = keys.get(key) ?? [];
|
|
15
|
+
ids.push(entry.id);
|
|
16
|
+
keys.set(key, ids);
|
|
17
|
+
}
|
|
18
|
+
for (const [key, ids] of keys)
|
|
19
|
+
if (ids.length > 1)
|
|
20
|
+
conflicts.push({ namespace, key, ids });
|
|
21
|
+
}
|
|
22
|
+
return conflicts;
|
|
23
|
+
}
|
|
24
|
+
export function assertNoNameConflicts(catalog) {
|
|
25
|
+
if (findNameConflicts(catalog).length > 0) {
|
|
26
|
+
throw new Error("Duplicate names in the copi library. Only listing, masked viewing, and name conflict resolution are allowed; resolve the names interactively with copi groups.");
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** A rename during resolution may touch a conflict member only, and must use a free name. */
|
|
30
|
+
export function assertNameAvailable(catalog, namespace, name, id) {
|
|
31
|
+
const key = nameKey(name);
|
|
32
|
+
const conflicts = findNameConflicts(catalog);
|
|
33
|
+
if (conflicts.length > 0 && !conflicts.some((conflict) => conflict.namespace === namespace && conflict.ids.includes(id ?? ""))) {
|
|
34
|
+
assertNoNameConflicts(catalog);
|
|
35
|
+
}
|
|
36
|
+
const entries = namespace === "item" ? catalog.items : catalog.groups;
|
|
37
|
+
if (entries.some((entry) => entry.id !== id && nameKey(entry.name) === key)) {
|
|
38
|
+
throw new Error(`Duplicate ${namespace} name; choose a unique display name.`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface RenameExclusiveReviewedSourceInput {
|
|
2
|
+
libraryDir: string;
|
|
3
|
+
stagePath: string;
|
|
4
|
+
targetPath: string;
|
|
5
|
+
expectedStage: {
|
|
6
|
+
dev: string;
|
|
7
|
+
ino: string;
|
|
8
|
+
};
|
|
9
|
+
expectedParent: {
|
|
10
|
+
dev: string;
|
|
11
|
+
ino: string;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
/** @internal Read-only capability check that must precede native-placement transaction writes. */
|
|
15
|
+
export declare function preflightNativeSourcePlacement(libraryDir: string): Promise<void>;
|
|
16
|
+
export declare function renameExclusiveReviewedSource(input: RenameExclusiveReviewedSourceInput): Promise<void>;
|
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { constants as fsConstants } from "node:fs";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { access, lstat, open, realpath } from "node:fs/promises";
|
|
5
|
+
import { constants as osConstants } from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
const protocolVersion = 1;
|
|
9
|
+
const helperName = "rename-exclusive-arm64";
|
|
10
|
+
const maxRecordBytes = 64 * 1024;
|
|
11
|
+
const maxHelperBytes = 16 * 1024 * 1024;
|
|
12
|
+
const maxManifestBytes = 64 * 1024;
|
|
13
|
+
const helperTimeoutMs = 30_000;
|
|
14
|
+
const uint64Maximum = 18446744073709551615n;
|
|
15
|
+
const arm64CpuType = 0x0100000c;
|
|
16
|
+
const machO64Magic = 0xfeedfacf;
|
|
17
|
+
const machOExecuteFileType = 2;
|
|
18
|
+
const lcMain = 0x80000028;
|
|
19
|
+
const lcUnixThread = 0x5;
|
|
20
|
+
class NativePlacementError extends Error {
|
|
21
|
+
code;
|
|
22
|
+
errno;
|
|
23
|
+
constructor(code, message, errno) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = "NativePlacementError";
|
|
26
|
+
this.code = code;
|
|
27
|
+
this.errno = errno;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const knownErrnos = new Set(Object.values(osConstants.errno).filter(Number.isInteger));
|
|
31
|
+
function placementError(code, errno) {
|
|
32
|
+
const message = code === "filesystem-error" || code === "post-placement-identity-mismatch"
|
|
33
|
+
? "Native source placement failed in the filesystem."
|
|
34
|
+
: code === "invalid-input"
|
|
35
|
+
? "Native source-placement input is invalid."
|
|
36
|
+
: code === "unsupported-platform"
|
|
37
|
+
? "Native source placement requires macOS arm64."
|
|
38
|
+
: code === "unsafe-path"
|
|
39
|
+
? "Native source-placement paths are unsafe."
|
|
40
|
+
: code === "stage-identity-mismatch"
|
|
41
|
+
? "The reviewed stage identity no longer matches."
|
|
42
|
+
: code === "parent-identity-mismatch"
|
|
43
|
+
? "The reviewed target parent identity no longer matches."
|
|
44
|
+
: code === "helper-unavailable"
|
|
45
|
+
? "The required packaged native source-placement helper is unavailable."
|
|
46
|
+
: code === "helper-invalid"
|
|
47
|
+
? "The packaged native source-placement helper is invalid."
|
|
48
|
+
: code === "helper-spawn-failed"
|
|
49
|
+
? "The native source-placement helper could not be started."
|
|
50
|
+
: code === "helper-timeout"
|
|
51
|
+
? "The native source-placement helper timed out."
|
|
52
|
+
: code === "helper-output-limit"
|
|
53
|
+
? "The native source-placement helper exceeded the output limit."
|
|
54
|
+
: code === "helper-protocol-invalid"
|
|
55
|
+
? "The native source-placement helper returned an invalid protocol response."
|
|
56
|
+
: code === "helper-signal"
|
|
57
|
+
? "The native source-placement helper terminated by signal."
|
|
58
|
+
: code === "destination-exists"
|
|
59
|
+
? "The source placement destination already exists."
|
|
60
|
+
: code === "unsupported-volume"
|
|
61
|
+
? "The volume does not support exclusive native source placement."
|
|
62
|
+
: "The native source-placement request was rejected.";
|
|
63
|
+
return new NativePlacementError(code, message, errno);
|
|
64
|
+
}
|
|
65
|
+
function isRecord(value) {
|
|
66
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
67
|
+
}
|
|
68
|
+
function hasExactKeys(record, keys) {
|
|
69
|
+
const actual = Object.keys(record).sort();
|
|
70
|
+
const expected = [...keys].sort();
|
|
71
|
+
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
|
|
72
|
+
}
|
|
73
|
+
function hasRequiredKeys(record, keys) {
|
|
74
|
+
return keys.every((key) => Object.hasOwn(record, key));
|
|
75
|
+
}
|
|
76
|
+
function asNativePlacementError(error, fallback) {
|
|
77
|
+
if (error instanceof NativePlacementError)
|
|
78
|
+
return error;
|
|
79
|
+
return placementError(fallback, systemErrno(error));
|
|
80
|
+
}
|
|
81
|
+
function systemErrno(error) {
|
|
82
|
+
if (!isRecord(error))
|
|
83
|
+
return undefined;
|
|
84
|
+
const numericErrno = error.errno;
|
|
85
|
+
if (typeof numericErrno === "number" && Number.isInteger(numericErrno))
|
|
86
|
+
return Math.abs(numericErrno);
|
|
87
|
+
const code = error.code;
|
|
88
|
+
if (typeof code === "string") {
|
|
89
|
+
const errno = osConstants.errno[code];
|
|
90
|
+
if (typeof errno === "number")
|
|
91
|
+
return errno;
|
|
92
|
+
}
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
function parseIdentity(value) {
|
|
96
|
+
if (!isRecord(value) || !hasExactKeys(value, ["dev", "ino"]))
|
|
97
|
+
return undefined;
|
|
98
|
+
const parseValue = (part) => {
|
|
99
|
+
if (typeof part !== "string" || !/^(?:0|[1-9][0-9]*)$/.test(part))
|
|
100
|
+
return undefined;
|
|
101
|
+
try {
|
|
102
|
+
const parsed = BigInt(part);
|
|
103
|
+
return parsed <= uint64Maximum ? parsed : undefined;
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
const dev = parseValue(value.dev);
|
|
110
|
+
const ino = parseValue(value.ino);
|
|
111
|
+
if (dev === undefined || ino === undefined)
|
|
112
|
+
return undefined;
|
|
113
|
+
return { dev, ino };
|
|
114
|
+
}
|
|
115
|
+
function isCanonicalAbsolutePath(value) {
|
|
116
|
+
if (typeof value !== "string" || value.length === 0 || !path.isAbsolute(value) || value.includes("\0") || value.includes("\\")) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
if (path.resolve(value) !== value || Buffer.from(value, "utf8").toString("utf8") !== value)
|
|
120
|
+
return false;
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
function strictlyContains(root, candidate) {
|
|
124
|
+
const relative = path.relative(root, candidate);
|
|
125
|
+
return relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
126
|
+
}
|
|
127
|
+
function isWithin(root, candidate) {
|
|
128
|
+
return root === candidate || strictlyContains(root, candidate);
|
|
129
|
+
}
|
|
130
|
+
function inputRequestBytes(input) {
|
|
131
|
+
const request = {
|
|
132
|
+
protocolVersion,
|
|
133
|
+
operation: "renameExclusiveReviewedSource",
|
|
134
|
+
libraryDir: input.libraryDir,
|
|
135
|
+
stagePath: input.stagePath,
|
|
136
|
+
targetPath: input.targetPath,
|
|
137
|
+
expectedStage: input.expectedStage,
|
|
138
|
+
expectedParent: input.expectedParent,
|
|
139
|
+
};
|
|
140
|
+
const bytes = Buffer.from(`${JSON.stringify(request)}\n`, "utf8");
|
|
141
|
+
if (bytes.length > maxRecordBytes)
|
|
142
|
+
throw placementError("invalid-input");
|
|
143
|
+
return bytes;
|
|
144
|
+
}
|
|
145
|
+
function validateInput(value) {
|
|
146
|
+
if (!isRecord(value) || !hasRequiredKeys(value, ["libraryDir", "stagePath", "targetPath", "expectedStage", "expectedParent"])) {
|
|
147
|
+
throw placementError("invalid-input");
|
|
148
|
+
}
|
|
149
|
+
if (!isCanonicalAbsolutePath(value.libraryDir) || value.libraryDir === path.parse(value.libraryDir).root ||
|
|
150
|
+
!isCanonicalAbsolutePath(value.stagePath) || !isCanonicalAbsolutePath(value.targetPath)) {
|
|
151
|
+
throw placementError("unsafe-path");
|
|
152
|
+
}
|
|
153
|
+
const stageIdentity = parseIdentity(value.expectedStage);
|
|
154
|
+
const parentIdentity = parseIdentity(value.expectedParent);
|
|
155
|
+
if (stageIdentity === undefined || parentIdentity === undefined)
|
|
156
|
+
throw placementError("invalid-input");
|
|
157
|
+
if (!strictlyContains(value.libraryDir, value.stagePath) || !strictlyContains(value.libraryDir, value.targetPath) ||
|
|
158
|
+
!isWithin(value.libraryDir, path.dirname(value.stagePath)) || !isWithin(value.libraryDir, path.dirname(value.targetPath)) ||
|
|
159
|
+
value.stagePath === value.targetPath || strictlyContains(value.stagePath, value.targetPath) ||
|
|
160
|
+
strictlyContains(value.targetPath, value.stagePath)) {
|
|
161
|
+
throw placementError("unsafe-path");
|
|
162
|
+
}
|
|
163
|
+
const validated = {
|
|
164
|
+
libraryDir: value.libraryDir,
|
|
165
|
+
stagePath: value.stagePath,
|
|
166
|
+
targetPath: value.targetPath,
|
|
167
|
+
expectedStage: { dev: stageIdentity.dev.toString(), ino: stageIdentity.ino.toString() },
|
|
168
|
+
expectedParent: { dev: parentIdentity.dev.toString(), ino: parentIdentity.ino.toString() },
|
|
169
|
+
};
|
|
170
|
+
return {
|
|
171
|
+
...validated,
|
|
172
|
+
stageIdentity,
|
|
173
|
+
parentIdentity,
|
|
174
|
+
requestBytes: inputRequestBytes(validated),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
function assertSupportedNativePlatform() {
|
|
178
|
+
if (process.platform !== "darwin" || process.arch !== "arm64")
|
|
179
|
+
throw placementError("unsupported-platform");
|
|
180
|
+
}
|
|
181
|
+
async function pathInfoWithoutSymlinks(pathname) {
|
|
182
|
+
const root = path.parse(pathname).root;
|
|
183
|
+
let current = root;
|
|
184
|
+
let info = await lstat(current, { bigint: true });
|
|
185
|
+
if (info.isSymbolicLink() || !info.isDirectory())
|
|
186
|
+
throw placementError("unsafe-path");
|
|
187
|
+
const components = pathname.slice(root.length).split(path.sep).filter((component) => component.length > 0);
|
|
188
|
+
for (const [index, component] of components.entries()) {
|
|
189
|
+
current = path.join(current, component);
|
|
190
|
+
info = await lstat(current, { bigint: true });
|
|
191
|
+
if (info.isSymbolicLink())
|
|
192
|
+
throw placementError("unsafe-path");
|
|
193
|
+
if (index < components.length - 1 && !info.isDirectory())
|
|
194
|
+
throw placementError("unsafe-path");
|
|
195
|
+
}
|
|
196
|
+
return info;
|
|
197
|
+
}
|
|
198
|
+
async function assertLibraryAndFilesystem(input) {
|
|
199
|
+
let rootInfo;
|
|
200
|
+
let stageInfo;
|
|
201
|
+
let stageParentInfo;
|
|
202
|
+
let targetParentInfo;
|
|
203
|
+
try {
|
|
204
|
+
rootInfo = await pathInfoWithoutSymlinks(input.libraryDir);
|
|
205
|
+
stageInfo = await pathInfoWithoutSymlinks(input.stagePath);
|
|
206
|
+
stageParentInfo = await pathInfoWithoutSymlinks(path.dirname(input.stagePath));
|
|
207
|
+
targetParentInfo = await pathInfoWithoutSymlinks(path.dirname(input.targetPath));
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
if (error instanceof NativePlacementError)
|
|
211
|
+
throw error;
|
|
212
|
+
const errno = systemErrno(error);
|
|
213
|
+
const code = errno === osConstants.errno.ENOENT
|
|
214
|
+
? (error instanceof Error && error.message.includes(input.stagePath) ? "stage-identity-mismatch" : "unsafe-path")
|
|
215
|
+
: "unsafe-path";
|
|
216
|
+
throw placementError(code, errno);
|
|
217
|
+
}
|
|
218
|
+
if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink())
|
|
219
|
+
throw placementError("unsafe-path");
|
|
220
|
+
if (await realpath(input.libraryDir).catch(() => "") !== input.libraryDir)
|
|
221
|
+
throw placementError("unsafe-path");
|
|
222
|
+
if (!stageInfo.isFile() && !stageInfo.isDirectory())
|
|
223
|
+
throw placementError("stage-identity-mismatch");
|
|
224
|
+
if (!stageParentInfo.isDirectory() || !targetParentInfo.isDirectory())
|
|
225
|
+
throw placementError("unsafe-path");
|
|
226
|
+
if (stageInfo.dev !== input.stageIdentity.dev || stageInfo.ino !== input.stageIdentity.ino) {
|
|
227
|
+
throw placementError("stage-identity-mismatch", osConstants.errno.ESTALE);
|
|
228
|
+
}
|
|
229
|
+
if (targetParentInfo.dev !== input.parentIdentity.dev || targetParentInfo.ino !== input.parentIdentity.ino) {
|
|
230
|
+
throw placementError("parent-identity-mismatch", osConstants.errno.ESTALE);
|
|
231
|
+
}
|
|
232
|
+
if (rootInfo.dev !== stageInfo.dev || rootInfo.dev !== stageParentInfo.dev || rootInfo.dev !== targetParentInfo.dev) {
|
|
233
|
+
throw placementError("filesystem-error", osConstants.errno.EXDEV);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
function packagePaths() {
|
|
237
|
+
let modulePath;
|
|
238
|
+
try {
|
|
239
|
+
modulePath = fileURLToPath(import.meta.url);
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
throw placementError("helper-invalid");
|
|
243
|
+
}
|
|
244
|
+
const moduleDirectory = path.dirname(modulePath);
|
|
245
|
+
if (path.basename(moduleDirectory) !== "src")
|
|
246
|
+
throw placementError("helper-invalid");
|
|
247
|
+
const parentDirectory = path.dirname(moduleDirectory);
|
|
248
|
+
const packageRoot = path.basename(parentDirectory) === "dist" ? path.dirname(parentDirectory) : parentDirectory;
|
|
249
|
+
return {
|
|
250
|
+
packageRoot,
|
|
251
|
+
helperPath: path.join(packageRoot, "dist", "native", helperName),
|
|
252
|
+
manifestPath: path.join(packageRoot, "dist", "native", `${helperName}.manifest.json`),
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
function sameFileIdentity(left, right) {
|
|
256
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
257
|
+
}
|
|
258
|
+
async function readCheckedPackageFile(filePath, maxBytes) {
|
|
259
|
+
const before = await pathInfoWithoutSymlinks(filePath);
|
|
260
|
+
if (!before.isFile() || before.isSymbolicLink() || before.size > BigInt(maxBytes))
|
|
261
|
+
throw placementError("helper-invalid");
|
|
262
|
+
const handle = await open(filePath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
263
|
+
try {
|
|
264
|
+
const opened = await handle.stat({ bigint: true });
|
|
265
|
+
if (!opened.isFile() || !sameFileIdentity(before, opened) || opened.size > BigInt(maxBytes)) {
|
|
266
|
+
throw placementError("helper-invalid");
|
|
267
|
+
}
|
|
268
|
+
const bytes = await handle.readFile();
|
|
269
|
+
const [afterHandle, afterPath] = await Promise.all([handle.stat({ bigint: true }), pathInfoWithoutSymlinks(filePath)]);
|
|
270
|
+
if (!sameFileIdentity(opened, afterHandle) || !sameFileIdentity(opened, afterPath) || !afterPath.isFile()) {
|
|
271
|
+
throw placementError("helper-invalid");
|
|
272
|
+
}
|
|
273
|
+
return { bytes, mode: Number(opened.mode & 511n) };
|
|
274
|
+
}
|
|
275
|
+
finally {
|
|
276
|
+
await handle.close();
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
function hasArm64ExecutableMachO(bytes) {
|
|
280
|
+
if (bytes.length < 32 || bytes.readUInt32LE(0) !== machO64Magic || bytes.readInt32LE(4) !== arm64CpuType ||
|
|
281
|
+
bytes.readUInt32LE(12) !== machOExecuteFileType)
|
|
282
|
+
return false;
|
|
283
|
+
const commandCount = bytes.readUInt32LE(16);
|
|
284
|
+
const commandBytes = bytes.readUInt32LE(20);
|
|
285
|
+
const commandEnd = 32 + commandBytes;
|
|
286
|
+
if (commandCount === 0 || commandCount > 65_536 || commandEnd > bytes.length)
|
|
287
|
+
return false;
|
|
288
|
+
let offset = 32;
|
|
289
|
+
let hasExecutableEntry = false;
|
|
290
|
+
for (let index = 0; index < commandCount; index += 1) {
|
|
291
|
+
if (offset + 8 > commandEnd)
|
|
292
|
+
return false;
|
|
293
|
+
const command = bytes.readUInt32LE(offset);
|
|
294
|
+
const size = bytes.readUInt32LE(offset + 4);
|
|
295
|
+
if (size < 8 || size % 4 !== 0 || offset + size > commandEnd)
|
|
296
|
+
return false;
|
|
297
|
+
if (command === lcMain || command === lcUnixThread)
|
|
298
|
+
hasExecutableEntry = true;
|
|
299
|
+
offset += size;
|
|
300
|
+
}
|
|
301
|
+
return offset === commandEnd && hasExecutableEntry;
|
|
302
|
+
}
|
|
303
|
+
async function loadPackagedHelper() {
|
|
304
|
+
const paths = packagePaths();
|
|
305
|
+
let artifact;
|
|
306
|
+
try {
|
|
307
|
+
artifact = await readCheckedPackageFile(paths.helperPath, maxHelperBytes);
|
|
308
|
+
}
|
|
309
|
+
catch (error) {
|
|
310
|
+
if (systemErrno(error) === osConstants.errno.ENOENT)
|
|
311
|
+
throw placementError("helper-unavailable");
|
|
312
|
+
throw placementError("helper-invalid");
|
|
313
|
+
}
|
|
314
|
+
if ((artifact.mode & 0o111) === 0 || !hasArm64ExecutableMachO(artifact.bytes))
|
|
315
|
+
throw placementError("helper-invalid");
|
|
316
|
+
try {
|
|
317
|
+
await access(paths.helperPath, fsConstants.X_OK);
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
throw placementError("helper-invalid");
|
|
321
|
+
}
|
|
322
|
+
let manifestFile;
|
|
323
|
+
try {
|
|
324
|
+
manifestFile = await readCheckedPackageFile(paths.manifestPath, maxManifestBytes);
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
throw placementError("helper-invalid");
|
|
328
|
+
}
|
|
329
|
+
let manifest;
|
|
330
|
+
try {
|
|
331
|
+
manifest = JSON.parse(manifestFile.bytes.toString("utf8"));
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
throw placementError("helper-invalid");
|
|
335
|
+
}
|
|
336
|
+
if (!isRecord(manifest) || manifest.artifact !== helperName || manifest.architecture !== "arm64" ||
|
|
337
|
+
manifest.platform !== "darwin" || manifest.protocolVersion !== protocolVersion ||
|
|
338
|
+
typeof manifest.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(manifest.sha256)) {
|
|
339
|
+
throw placementError("helper-invalid");
|
|
340
|
+
}
|
|
341
|
+
const digest = createHash("sha256").update(artifact.bytes).digest("hex");
|
|
342
|
+
if (digest !== manifest.sha256)
|
|
343
|
+
throw placementError("helper-invalid");
|
|
344
|
+
return paths;
|
|
345
|
+
}
|
|
346
|
+
async function nearestExistingVolumeDirectory(libraryDir) {
|
|
347
|
+
const requested = path.resolve(libraryDir);
|
|
348
|
+
if (requested === path.parse(requested).root)
|
|
349
|
+
throw placementError("unsafe-path");
|
|
350
|
+
let current = requested;
|
|
351
|
+
while (true) {
|
|
352
|
+
let info;
|
|
353
|
+
try {
|
|
354
|
+
info = await lstat(current, { bigint: true });
|
|
355
|
+
}
|
|
356
|
+
catch (error) {
|
|
357
|
+
if (systemErrno(error) === osConstants.errno.ENOENT) {
|
|
358
|
+
const parent = path.dirname(current);
|
|
359
|
+
if (parent === current)
|
|
360
|
+
throw placementError("filesystem-error", osConstants.errno.ENOENT);
|
|
361
|
+
current = parent;
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
throw asNativePlacementError(error, "filesystem-error");
|
|
365
|
+
}
|
|
366
|
+
if (current === requested && info.isSymbolicLink())
|
|
367
|
+
throw placementError("unsafe-path");
|
|
368
|
+
if (!info.isDirectory() && !info.isSymbolicLink())
|
|
369
|
+
throw placementError("unsafe-path");
|
|
370
|
+
let canonical;
|
|
371
|
+
try {
|
|
372
|
+
canonical = await realpath(current);
|
|
373
|
+
}
|
|
374
|
+
catch (error) {
|
|
375
|
+
throw asNativePlacementError(error, "unsafe-path");
|
|
376
|
+
}
|
|
377
|
+
let canonicalInfo;
|
|
378
|
+
try {
|
|
379
|
+
canonicalInfo = await lstat(canonical, { bigint: true });
|
|
380
|
+
}
|
|
381
|
+
catch (error) {
|
|
382
|
+
throw asNativePlacementError(error, "filesystem-error");
|
|
383
|
+
}
|
|
384
|
+
if (!canonicalInfo.isDirectory() || canonicalInfo.isSymbolicLink() || await realpath(canonical).catch(() => "") !== canonical) {
|
|
385
|
+
throw placementError("unsafe-path");
|
|
386
|
+
}
|
|
387
|
+
return canonical;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
function nativeErrnoMatches(code, errno) {
|
|
391
|
+
const errnos = osConstants.errno;
|
|
392
|
+
if (!Number.isInteger(errno) || errno <= 0 || !knownErrnos.has(errno))
|
|
393
|
+
return false;
|
|
394
|
+
switch (code) {
|
|
395
|
+
case "destination-exists":
|
|
396
|
+
return errno === errnos.EEXIST;
|
|
397
|
+
case "unsupported-volume":
|
|
398
|
+
return errno === errnos.ENOTSUP || errno === errnos.EOPNOTSUPP;
|
|
399
|
+
case "invalid-request":
|
|
400
|
+
case "unsupported-protocol":
|
|
401
|
+
return errno === errnos.EINVAL;
|
|
402
|
+
case "unsafe-path":
|
|
403
|
+
return errno === errnos.EINVAL || errno === errnos.ENOTDIR || errno === errnos.ELOOP;
|
|
404
|
+
case "parent-identity-mismatch":
|
|
405
|
+
return errno === errnos.ESTALE;
|
|
406
|
+
case "stage-identity-mismatch":
|
|
407
|
+
return errno === errnos.ENOENT || errno === errnos.ESTALE;
|
|
408
|
+
case "filesystem-error":
|
|
409
|
+
case "post-placement-identity-mismatch":
|
|
410
|
+
return true;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
function parseNativeRecord(bytes) {
|
|
414
|
+
if (bytes.length === 0 || bytes.length > maxRecordBytes || bytes[bytes.length - 1] !== 0x0a ||
|
|
415
|
+
bytes.includes(0x0d) || bytes.indexOf(0x0a) !== bytes.length - 1) {
|
|
416
|
+
throw placementError("helper-protocol-invalid");
|
|
417
|
+
}
|
|
418
|
+
let decoded;
|
|
419
|
+
try {
|
|
420
|
+
decoded = JSON.parse(bytes.subarray(0, bytes.length - 1).toString("utf8"));
|
|
421
|
+
}
|
|
422
|
+
catch {
|
|
423
|
+
throw placementError("helper-protocol-invalid");
|
|
424
|
+
}
|
|
425
|
+
if (!isRecord(decoded) || decoded.protocolVersion !== protocolVersion || typeof decoded.ok !== "boolean") {
|
|
426
|
+
throw placementError("helper-protocol-invalid");
|
|
427
|
+
}
|
|
428
|
+
if (decoded.ok) {
|
|
429
|
+
if (!hasExactKeys(decoded, ["protocolVersion", "ok"]))
|
|
430
|
+
throw placementError("helper-protocol-invalid");
|
|
431
|
+
return { protocolVersion: 1, ok: true };
|
|
432
|
+
}
|
|
433
|
+
if (!hasExactKeys(decoded, ["protocolVersion", "ok", "error"]) || !isRecord(decoded.error) ||
|
|
434
|
+
!hasExactKeys(decoded.error, ["code", "errno"]) || typeof decoded.error.code !== "string" ||
|
|
435
|
+
typeof decoded.error.errno !== "number" || !Number.isInteger(decoded.error.errno) ||
|
|
436
|
+
!nativeErrnoMatches(decoded.error.code, decoded.error.errno)) {
|
|
437
|
+
throw placementError("helper-protocol-invalid");
|
|
438
|
+
}
|
|
439
|
+
const code = decoded.error.code;
|
|
440
|
+
return { protocolVersion: 1, ok: false, error: { code, errno: decoded.error.errno } };
|
|
441
|
+
}
|
|
442
|
+
function terminate(child) {
|
|
443
|
+
if (child === undefined)
|
|
444
|
+
return;
|
|
445
|
+
try {
|
|
446
|
+
child.kill("SIGKILL");
|
|
447
|
+
}
|
|
448
|
+
catch {
|
|
449
|
+
// The caller already has a stable failure to report; child runtime text is intentionally discarded.
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
function runNativeHelper(paths, requestBytes, args = ["--protocol-version", "1"]) {
|
|
453
|
+
return new Promise((resolve, reject) => {
|
|
454
|
+
let child;
|
|
455
|
+
let settled = false;
|
|
456
|
+
let stdoutBytes = 0;
|
|
457
|
+
let stderrBytes = 0;
|
|
458
|
+
const stdout = [];
|
|
459
|
+
const startedAt = Date.now();
|
|
460
|
+
let timer;
|
|
461
|
+
const clearDeadline = () => clearTimeout(timer);
|
|
462
|
+
const rejectOnce = (error, killChild) => {
|
|
463
|
+
if (settled)
|
|
464
|
+
return;
|
|
465
|
+
settled = true;
|
|
466
|
+
clearDeadline();
|
|
467
|
+
if (killChild)
|
|
468
|
+
terminate(child);
|
|
469
|
+
reject(error);
|
|
470
|
+
};
|
|
471
|
+
const deadline = () => rejectOnce(placementError("helper-timeout"), true);
|
|
472
|
+
timer = setTimeout(deadline, helperTimeoutMs);
|
|
473
|
+
try {
|
|
474
|
+
child = spawn(paths.helperPath, [...args], {
|
|
475
|
+
cwd: paths.packageRoot,
|
|
476
|
+
shell: false,
|
|
477
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
catch {
|
|
481
|
+
rejectOnce(placementError("helper-spawn-failed"), false);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
child.on("error", () => rejectOnce(placementError("helper-spawn-failed"), false));
|
|
485
|
+
child.stdin.on("error", () => rejectOnce(placementError("helper-protocol-invalid"), true));
|
|
486
|
+
child.stdout.on("error", () => rejectOnce(placementError("helper-protocol-invalid"), true));
|
|
487
|
+
child.stderr.on("error", () => rejectOnce(placementError("helper-protocol-invalid"), true));
|
|
488
|
+
child.stdout.on("data", (chunk) => {
|
|
489
|
+
if (settled)
|
|
490
|
+
return;
|
|
491
|
+
stdoutBytes += chunk.length;
|
|
492
|
+
if (stdoutBytes > maxRecordBytes) {
|
|
493
|
+
rejectOnce(placementError("helper-output-limit"), true);
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
stdout.push(chunk);
|
|
497
|
+
});
|
|
498
|
+
child.stderr.on("data", (chunk) => {
|
|
499
|
+
if (settled)
|
|
500
|
+
return;
|
|
501
|
+
stderrBytes += chunk.length;
|
|
502
|
+
if (stderrBytes > maxRecordBytes)
|
|
503
|
+
rejectOnce(placementError("helper-output-limit"), true);
|
|
504
|
+
});
|
|
505
|
+
child.on("close", (exitCode, signal) => {
|
|
506
|
+
if (settled)
|
|
507
|
+
return;
|
|
508
|
+
clearDeadline();
|
|
509
|
+
if (Date.now() - startedAt >= helperTimeoutMs) {
|
|
510
|
+
rejectOnce(placementError("helper-timeout"), false);
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
if (signal !== null) {
|
|
514
|
+
rejectOnce(placementError("helper-signal"), false);
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
let response;
|
|
518
|
+
try {
|
|
519
|
+
response = parseNativeRecord(Buffer.concat(stdout, stdoutBytes));
|
|
520
|
+
}
|
|
521
|
+
catch (error) {
|
|
522
|
+
rejectOnce(asNativePlacementError(error, "helper-protocol-invalid"), false);
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
if (response.ok) {
|
|
526
|
+
if (exitCode !== 0)
|
|
527
|
+
rejectOnce(placementError("helper-protocol-invalid"), false);
|
|
528
|
+
else {
|
|
529
|
+
settled = true;
|
|
530
|
+
resolve();
|
|
531
|
+
}
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
if (exitCode !== 1) {
|
|
535
|
+
rejectOnce(placementError("helper-protocol-invalid"), false);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
rejectOnce(placementError(response.error.code, response.error.errno), false);
|
|
539
|
+
});
|
|
540
|
+
if (requestBytes.length > 0)
|
|
541
|
+
child.stdin.end(requestBytes);
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
/** @internal Read-only capability check that must precede native-placement transaction writes. */
|
|
545
|
+
export async function preflightNativeSourcePlacement(libraryDir) {
|
|
546
|
+
assertSupportedNativePlatform();
|
|
547
|
+
const volumeRoot = await nearestExistingVolumeDirectory(libraryDir);
|
|
548
|
+
const paths = await loadPackagedHelper();
|
|
549
|
+
await runNativeHelper(paths, Buffer.alloc(0), ["--check-exclusive-rename", volumeRoot]);
|
|
550
|
+
}
|
|
551
|
+
export async function renameExclusiveReviewedSource(input) {
|
|
552
|
+
const validated = validateInput(input);
|
|
553
|
+
assertSupportedNativePlatform();
|
|
554
|
+
await assertLibraryAndFilesystem(validated);
|
|
555
|
+
const paths = await loadPackagedHelper();
|
|
556
|
+
await runNativeHelper(paths, validated.requestBytes);
|
|
557
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type DiscoveryOptions } from "./discovery.ts";
|
|
2
|
+
import { type ChangeApproval, type SavedChangeView } from "./resource-mutations.ts";
|
|
3
|
+
import type { Catalog, ResourceCandidate, SavedItem } from "./types.ts";
|
|
4
|
+
declare const refreshBrand: unique symbol;
|
|
5
|
+
export interface PreparedRefresh {
|
|
6
|
+
readonly [refreshBrand]: true;
|
|
7
|
+
readonly reviewId: string;
|
|
8
|
+
}
|
|
9
|
+
export interface RefreshView extends SavedChangeView {
|
|
10
|
+
readonly originalCatalog: Catalog;
|
|
11
|
+
readonly source: ResourceCandidate;
|
|
12
|
+
}
|
|
13
|
+
/** Metadata checks only. Never follows arbitrary links, including links above the selected boundary. */
|
|
14
|
+
export declare function resolveRefreshPath(requested: string): Promise<string>;
|
|
15
|
+
/** Call only after explicit discovery-root/scope consent; no implicit HOME or parent search. */
|
|
16
|
+
export declare function discoverRefreshOrigin(item: SavedItem, options: DiscoveryOptions): Promise<ResourceCandidate | undefined>;
|
|
17
|
+
export declare function prepareRefresh(libraryDir: string, itemId: string, source: ResourceCandidate): Promise<PreparedRefresh>;
|
|
18
|
+
export declare function readRefresh(prepared: PreparedRefresh): RefreshView;
|
|
19
|
+
export declare function commitRefresh(prepared: PreparedRefresh, approval: ChangeApproval): Promise<SavedItem>;
|
|
20
|
+
export {};
|