schornai 0.2.0 → 0.3.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.
@@ -7,7 +7,7 @@ import { dirname, join, resolve } from 'node:path';
7
7
  import { createInterface } from 'node:readline/promises';
8
8
  import { Transform } from 'node:stream';
9
9
  import { pipeline } from 'node:stream/promises';
10
- import { r as runServiceCLI, e as extractArchive } from './shared/cli-BxEHldVH.mjs';
10
+ import { r as runServiceCLI, e as extractArchive } from './shared/cli-DwJIziOh.mjs';
11
11
  import { i as initConfig, w as writeJson, l as loadConfig, r as resolveFrom, o as object, a as literal, d as defaultConfigPath, s as string, c as createLogger, b as run, e as binary, n as number, f as boolean } from './shared/docker-Dj7cdbkw.mjs';
12
12
  import { g as generateSecuritySecrets, h as hasLegacyLayout } from './shared/installer-CWopk_6Z.mjs';
13
13
  import { execFile } from 'node:child_process';
@@ -409,6 +409,7 @@ async function runDefaultCLI() {
409
409
  console.log(`Downloading schorn.ai setup from ${latestSetupUrl}`);
410
410
  const archive = join(temporary, "setup.tar");
411
411
  const id = await downloadSetup(archive, signal);
412
+ console.log("Download complete. Verifying and extracting schorn.ai setup...");
412
413
  const payload = join(temporary, "payload");
413
414
  await mkdir(payload, { mode: 0o700 });
414
415
  const inspected = await extractArchive(archive, payload, id, signal);
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { r as runServiceCLI } from './shared/cli-BxEHldVH.mjs';
2
+ import { r as runServiceCLI } from './shared/cli-DwJIziOh.mjs';
3
3
  import 'node:util';
4
4
  import 'node:fs';
5
5
  import 'node:fs/promises';
@@ -6,7 +6,7 @@ import 'node:path';
6
6
  import 'node:child_process';
7
7
  import 'node:timers/promises';
8
8
  import './shared/docker-Dj7cdbkw.mjs';
9
- export { r as runServiceCLI } from './shared/cli-BxEHldVH.mjs';
9
+ export { r as runServiceCLI } from './shared/cli-DwJIziOh.mjs';
10
10
  import 'node:os';
11
11
  import 'node:crypto';
12
12
  import 'node:http';
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { promisify, isDeepStrictEqual, parseArgs } from 'node:util';
3
3
  import u, { createReadStream, existsSync, createWriteStream, openSync, closeSync } from 'node:fs';
4
- import ro, { mkdir, readFile, mkdtemp, rm, cp, writeFile, rename, statfs, readdir, chmod, stat, open } from 'node:fs/promises';
4
+ import ro, { lstat, readdir, realpath, mkdir, symlink, readlink, unlink, readFile, mkdtemp, rm, cp, writeFile, rename, statfs, chmod, stat, open } from 'node:fs/promises';
5
5
  import R, { win32, posix, basename, join, resolve, dirname as dirname$1 } from 'node:path';
6
6
  import { execFile, spawn } from 'node:child_process';
7
7
  import { setTimeout as setTimeout$1 } from 'node:timers/promises';
@@ -151,6 +151,46 @@ function archivePath(value) {
151
151
  }
152
152
  return posix.normalize(value).replace(/^\.\//, "");
153
153
  }
154
+ function assertRelativeLink(target) {
155
+ if (!target ||
156
+ target.startsWith("/") ||
157
+ target.includes("\\") ||
158
+ /^[A-Za-z]:/.test(target))
159
+ throw new Error("XRAG_UNSAFE_LINK");
160
+ }
161
+ async function assertSafeLink(name, link, lookup) {
162
+ assertRelativeLink(link.target);
163
+ const resolved = link.hard
164
+ ? []
165
+ : posix
166
+ .dirname(name)
167
+ .split("/")
168
+ .filter((part) => part !== ".");
169
+ const pending = link.target.split("/");
170
+ let hops = 0;
171
+ while (pending.length) {
172
+ const part = pending.shift();
173
+ if (!part || part === ".")
174
+ continue;
175
+ if (part === "..") {
176
+ if (!resolved.length)
177
+ throw new Error("XRAG_UNSAFE_LINK");
178
+ resolved.pop();
179
+ continue;
180
+ }
181
+ const nested = await lookup([...resolved, part].join("/"));
182
+ if (nested) {
183
+ assertRelativeLink(nested.target);
184
+ if (++hops > 40)
185
+ throw new Error("XRAG_UNSAFE_LINK");
186
+ if (nested.hard)
187
+ resolved.length = 0;
188
+ pending.unshift(...nested.target.split("/"));
189
+ }
190
+ else
191
+ resolved.push(part);
192
+ }
193
+ }
154
194
  /** Stream even very large archives; only small metadata files are held in memory. */
155
195
  async function inspectArchive(source, signal) {
156
196
  const hash = createHash("sha256");
@@ -178,11 +218,7 @@ async function inspectArchive(source, signal) {
178
218
  names.add(name);
179
219
  if (entry.type === "SymbolicLink" || entry.type === "Link") {
180
220
  const target = entry.linkpath ?? "";
181
- if (!target ||
182
- target.startsWith("/") ||
183
- target.includes("\\") ||
184
- /^[A-Za-z]:/.test(target))
185
- throw new Error("XRAG_UNSAFE_LINK");
221
+ assertRelativeLink(target);
186
222
  archivePath(posix.normalize(posix.join(entry.type === "Link" ? "." : posix.dirname(name), target)));
187
223
  links.set(name, { target, hard: entry.type === "Link" });
188
224
  }
@@ -227,37 +263,8 @@ async function inspectArchive(source, signal) {
227
263
  }
228
264
  // Resolve links component by component: normalizing "link/../x" first can hide
229
265
  // an escape when that intermediate link points at an ancestor directory.
230
- for (const [name, link] of links) {
231
- const resolved = link.hard
232
- ? []
233
- : posix
234
- .dirname(name)
235
- .split("/")
236
- .filter((part) => part !== ".");
237
- const pending = link.target.split("/");
238
- let hops = 0;
239
- while (pending.length) {
240
- const part = pending.shift();
241
- if (!part || part === ".")
242
- continue;
243
- if (part === "..") {
244
- if (!resolved.length)
245
- throw new Error("XRAG_UNSAFE_LINK");
246
- resolved.pop();
247
- continue;
248
- }
249
- const nested = links.get([...resolved, part].join("/"));
250
- if (nested) {
251
- if (++hops > 40)
252
- throw new Error("XRAG_UNSAFE_LINK");
253
- if (nested.hard)
254
- resolved.length = 0;
255
- pending.unshift(...nested.target.split("/"));
256
- }
257
- else
258
- resolved.push(part);
259
- }
260
- }
266
+ for (const [name, link] of links)
267
+ await assertSafeLink(name, link, (path) => links.get(path));
261
268
  const xrag = xragSchema.parse(JSON.parse(metadata.get("xrag.json")?.toString("utf8") ?? "null"));
262
269
  const required = xrag.xrag_type === "setup"
263
270
  ? ["setup.sh", "update.mts", "apps/service/cli.mjs"]
@@ -270,21 +277,85 @@ async function inspectArchive(source, signal) {
270
277
  : [];
271
278
  if (required.some((name) => !files.has(name)))
272
279
  throw new Error("XRAG_MISSING_PAYLOAD");
273
- return { id: hash.digest("hex"), size, xrag, metadata };
280
+ return { id: hash.digest("hex"), size, xrag, metadata, links };
274
281
  }
282
+ /** Extract only into a fresh, private directory owned by the caller. */
275
283
  async function extractArchive(file, destination, expectedId, signal) {
284
+ if (!(await lstat(destination)).isDirectory())
285
+ throw new Error("XRAG_UNSAFE_DESTINATION");
286
+ if ((await readdir(destination)).length)
287
+ throw new Error("XRAG_DESTINATION_NOT_EMPTY");
288
+ const root = await realpath(destination);
276
289
  const result = await inspectArchive(createReadStream(file), signal);
277
290
  if (result.id !== expectedId)
278
291
  throw new Error("XRAG_CHECKSUM_MISMATCH");
279
292
  signal?.throwIfAborted();
293
+ let extractionError;
280
294
  await So({
281
295
  file,
282
- cwd: destination,
283
- strict: true,
296
+ cwd: root,
297
+ // tar's strict mode rejects while other entries are still being written.
298
+ // Keep warnings fatal, but wait for those writes before callers clean up.
299
+ strict: false,
300
+ onwarn(code, message, data) {
301
+ extractionError ??= Object.assign(new Error(`${code}: ${message}`), data);
302
+ },
284
303
  preservePaths: false,
285
304
  noChmod: false,
286
305
  noMtime: true,
306
+ // tar rejects even validated in-tree symlink chains. Install these last,
307
+ // unchanged, so extraction never writes through an archive symlink.
308
+ filter: (_path, entry) => !("type" in entry && entry.type === "SymbolicLink"),
287
309
  });
310
+ if (extractionError)
311
+ throw extractionError;
312
+ signal?.throwIfAborted();
313
+ const created = [];
314
+ try {
315
+ for (const [name, link] of result.links) {
316
+ if (link.hard)
317
+ continue;
318
+ signal?.throwIfAborted();
319
+ let parent = root;
320
+ for (const part of posix.dirname(name).split("/")) {
321
+ if (part === ".")
322
+ continue;
323
+ parent = join(parent, part);
324
+ await mkdir(parent).catch((error) => {
325
+ if (error.code !== "EEXIST")
326
+ throw error;
327
+ });
328
+ if (!(await lstat(parent)).isDirectory())
329
+ throw new Error("XRAG_LINK_PARENT");
330
+ }
331
+ const path = join(root, name);
332
+ await symlink(link.target, path);
333
+ created.push(path);
334
+ }
335
+ // Check actual filesystem resolution too: case/Unicode aliases can make
336
+ // distinct archive names refer to the same path on macOS.
337
+ for (const [name, link] of result.links) {
338
+ if (link.hard)
339
+ continue;
340
+ signal?.throwIfAborted();
341
+ await assertSafeLink(name, link, async (path) => {
342
+ try {
343
+ if (!(await lstat(join(root, path))).isSymbolicLink())
344
+ return;
345
+ return { target: await readlink(join(root, path)), hard: false };
346
+ }
347
+ catch (error) {
348
+ if (["ENOENT", "ENOTDIR"].includes(error.code ?? ""))
349
+ return;
350
+ throw error;
351
+ }
352
+ });
353
+ }
354
+ }
355
+ catch (error) {
356
+ await Promise.allSettled(created.map((path) => unlink(path)));
357
+ throw error;
358
+ }
288
359
  return result;
289
360
  }
290
361
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "schornai",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "The schorn.ai CLI, including machine service commands.",
5
5
  "type": "module",
6
6
  "bin": {