@orkestrel/test 0.0.8 → 0.0.10

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.
@@ -215,6 +215,39 @@ export declare const REMOVE_TREE_RETRYABLE_CODES: readonly string[];
215
215
  */
216
216
  export declare function removeTree(path: string): void;
217
217
 
218
+ /**
219
+ * Drives a real client upgrade request against a loopback port and reports what the server did.
220
+ *
221
+ * @param port - The port the server listens on at `127.0.0.1`.
222
+ * @param options - Optional request path, offered subprotocols, time bounds, and abort signal.
223
+ * @returns A promise resolving to the server's answer: a claimed upgrade with the protocol it
224
+ * selected, or a refusal with the status it answered.
225
+ * @throws The client's own transport error, such as the `ECONNREFUSED` a closed port answers, the
226
+ * abort reason, or an `Error` when a bound is invalid or the server does not answer within the
227
+ * budget.
228
+ * @remarks Default budget: `1000` milliseconds. The request carries `Connection: Upgrade` and
229
+ * `Upgrade: websocket`, which is what makes a server's `upgrade` handler the one that answers it.
230
+ * The `upgrade`, `response`, and `error` events are mutually exclusive in practice and the promise
231
+ * settles on whichever arrives first, so a second event changes nothing. The client socket is
232
+ * destroyed before every settlement, on the claimed path because an upgraded socket is detached from
233
+ * the request and outlives it otherwise. The request is made with no agent, so no pooled connection
234
+ * survives the call to keep a suite's event loop alive.
235
+ *
236
+ * A server that accepts the connection and answers nothing raises no transport error, so the budget
237
+ * is what ends that call: the rejection names the port and path it was waiting on. The interval is
238
+ * validated for consistency with the wait family but is not used, because this helper parks on the
239
+ * request's events.
240
+ *
241
+ * A `101` is the claimed path's status on the wire and is deliberately not reported: `status` is the
242
+ * refused arm's member, and a claimed upgrade produced no plain answer.
243
+ * @example
244
+ * ```ts
245
+ * const answer = await requestUpgrade(loopback.port, { path: '/socket', protocols: ['chat'] })
246
+ * // { claimed: true, protocol: 'chat' }
247
+ * ```
248
+ */
249
+ export declare function requestUpgrade(port: number, options?: UpgradeOptions): Promise<UpgradeResult>;
250
+
218
251
  /**
219
252
  * Resolves a target that stays below a root directory.
220
253
  *
@@ -243,10 +276,11 @@ export declare interface ScratchInterface {
243
276
  *
244
277
  * @param target - A relative or absolute file path contained by the scratch directory.
245
278
  * @param text - The file contents.
279
+ * @returns The absolute path of the written file.
246
280
  * @throws When the target escapes the scratch directory, the scratch root is missing, a symbolic
247
281
  * link, or a file, or the host refuses to write the file.
248
282
  */
249
- write(target: string, text: string): void;
283
+ write(target: string, text: string): string;
250
284
  /**
251
285
  * Reads a file.
252
286
  *
@@ -294,12 +328,13 @@ export declare interface ScratchInterface {
294
328
  * @param source - The destination path the link points at. The stored value is a path naming that
295
329
  * destination, but its exact text is not promised. The path may name a destination outside the
296
330
  * scratch directory and is not containment-checked.
331
+ * @returns The absolute path of the created link, whatever host mechanism created it.
297
332
  * @throws When the target escapes the scratch directory, the scratch root is missing, a symbolic
298
333
  * link, or a file, or the host refuses to create the link, including a host that creates no
299
334
  * symbolic link when the source names an existing non-directory.
300
335
  * @remarks {@link createLink} owns the host-specific link mechanism.
301
336
  */
302
- link(target: string, source: string): void;
337
+ link(target: string, source: string): string;
303
338
  /**
304
339
  * Removes a file, an empty directory, or a directory and its descendants.
305
340
  *
@@ -339,6 +374,112 @@ export declare interface ScratchOptions {
339
374
  readonly files?: Readonly<Record<string, string>>;
340
375
  }
341
376
 
377
+ /**
378
+ * Checks whether this host accepts a filename carrying a raw byte no UTF-8 decoder resolves.
379
+ *
380
+ * @returns True if a name ending in byte `0x80` is written and read back; false otherwise, including
381
+ * every host refusal.
382
+ * @throws Nothing the attempt itself raises. Failing to allocate the probe directory, and failing to
383
+ * remove it afterwards, both propagate.
384
+ * @remarks Byte `0x80` is an invalid UTF-8 lead byte. POSIX stores the name verbatim and Windows
385
+ * rejects it with `ENOENT`, so the answer is true on POSIX and false on Windows. The path is passed
386
+ * as a `Buffer` because the byte survives no string round trip.
387
+ */
388
+ export declare function supportsBytes(): boolean;
389
+
390
+ /**
391
+ * Checks whether this host treats two names differing only by case as distinct files.
392
+ *
393
+ * @returns True if `A` and `a` hold the contents each was written with; false otherwise, including
394
+ * every host refusal.
395
+ * @throws Nothing the attempt itself raises. Failing to allocate the probe directory, and failing to
396
+ * remove it afterwards, both propagate.
397
+ * @remarks The names `A` and `a` differ by case and by nothing else, which is what makes the reading
398
+ * an answer about case folding rather than an answer about two unrelated files. A case-folding volume
399
+ * routes the second write onto the first entry, so reading the first back returns the second's
400
+ * contents and the answer is false. The answer is true on a typical POSIX host and false on a
401
+ * case-folding Windows or macOS volume.
402
+ */
403
+ export declare function supportsCase(): boolean;
404
+
405
+ /**
406
+ * Checks whether this host links a directory, by creating one link and reading through it.
407
+ *
408
+ * @returns True if the created link reports as a symbolic link, resolves to a directory, and reaches
409
+ * the destination's contents; false otherwise, including every host refusal.
410
+ * @throws Nothing the attempt itself raises. Failing to allocate the probe directory, and failing to
411
+ * remove it afterwards, both propagate.
412
+ * @remarks `symlinkSync(source, target, 'junction')` creates a directory junction on Windows, which
413
+ * needs no privilege, and Node ignores the type argument off Windows, so one call covers both hosts.
414
+ * The answer is false on a filesystem carrying neither reparse points nor symbolic links. Every call
415
+ * probes and cleans up after itself, so a host whose answer changes is read again rather than
416
+ * remembered.
417
+ */
418
+ export declare function supportsDirectoryLinks(): boolean;
419
+
420
+ /**
421
+ * Checks whether this host links a file, by creating one link and reading the file through it.
422
+ *
423
+ * @returns True if the file's contents are readable through the link; false otherwise, including
424
+ * every host refusal.
425
+ * @throws Nothing the attempt itself raises. Failing to allocate the probe directory, and failing to
426
+ * remove it afterwards, both propagate.
427
+ * @remarks `symlinkSync(source, target, 'file')` needs the symbolic-link privilege, which Windows
428
+ * grants under Developer Mode or administrator rights and refuses with `EPERM` otherwise, so the
429
+ * answer is true on POSIX and on a privileged Windows host. Where it is false, no mechanism reaches a
430
+ * file through a link and a proof that reads one back cannot run. This is a separate question from
431
+ * {@link supportsDirectoryLinks}, which an unprivileged Windows host answers true through a junction
432
+ * while answering this one false.
433
+ */
434
+ export declare function supportsFileLinks(): boolean;
435
+
436
+ /**
437
+ * Checks whether POSIX permission bits round-trip through this host's `chmod` and `stat`.
438
+ *
439
+ * @returns True if a directory created with mode `0o700` reports that mode back; false otherwise,
440
+ * including every host refusal.
441
+ * @throws Nothing the attempt itself raises. Failing to allocate the probe directory, and failing to
442
+ * remove it afterwards, both propagate.
443
+ * @remarks POSIX reports `mode & 0o777 === 0o700` and Windows reports `0o666` regardless, so the
444
+ * answer is true on POSIX and false on Windows. Storing a bit is a narrower question than enforcing
445
+ * it: a POSIX host running as uid `0` stores every bit faithfully and bypasses the access check the
446
+ * bits describe, so a caller that needs a permission to be enforced probes the refusal it needs
447
+ * rather than reading this.
448
+ */
449
+ export declare function supportsMode(): boolean;
450
+
451
+ /**
452
+ * Options for driving a client upgrade request.
453
+ *
454
+ * @remarks The time bounds and abort signal bound the wait for the server's answer, so a server
455
+ * that accepts the connection and never answers ends the call rather than parking it.
456
+ */
457
+ export declare interface UpgradeOptions extends WaitOptions {
458
+ /** The request path, written with its leading slash. Defaults to `/`. */
459
+ readonly path?: string;
460
+ /**
461
+ * The subprotocol tokens the request offers. They are sent as one comma-separated
462
+ * `Sec-WebSocket-Protocol` field, and an empty or omitted list sends no field at all.
463
+ */
464
+ readonly protocols?: readonly string[];
465
+ }
466
+
467
+ /**
468
+ * What one server did with a client upgrade request.
469
+ *
470
+ * @remarks `claimed` is the discriminant. The claimed arm carries `protocol`, the subprotocol the
471
+ * server selected, which is `undefined` when it selected none; a claimed upgrade produced no plain
472
+ * answer, so it carries no status and the `101` on the wire is deliberately not reported as one.
473
+ * The refused arm carries `status`, the plain answer's status, and no subprotocol.
474
+ */
475
+ export declare type UpgradeResult = {
476
+ readonly claimed: true;
477
+ readonly protocol: string | undefined;
478
+ } | {
479
+ readonly claimed: false;
480
+ readonly status: number;
481
+ };
482
+
342
483
  /**
343
484
  * Waits for a socket to close, accepting a peer reset as a forced close.
344
485
  *
@@ -215,6 +215,39 @@ export declare const REMOVE_TREE_RETRYABLE_CODES: readonly string[];
215
215
  */
216
216
  export declare function removeTree(path: string): void;
217
217
 
218
+ /**
219
+ * Drives a real client upgrade request against a loopback port and reports what the server did.
220
+ *
221
+ * @param port - The port the server listens on at `127.0.0.1`.
222
+ * @param options - Optional request path, offered subprotocols, time bounds, and abort signal.
223
+ * @returns A promise resolving to the server's answer: a claimed upgrade with the protocol it
224
+ * selected, or a refusal with the status it answered.
225
+ * @throws The client's own transport error, such as the `ECONNREFUSED` a closed port answers, the
226
+ * abort reason, or an `Error` when a bound is invalid or the server does not answer within the
227
+ * budget.
228
+ * @remarks Default budget: `1000` milliseconds. The request carries `Connection: Upgrade` and
229
+ * `Upgrade: websocket`, which is what makes a server's `upgrade` handler the one that answers it.
230
+ * The `upgrade`, `response`, and `error` events are mutually exclusive in practice and the promise
231
+ * settles on whichever arrives first, so a second event changes nothing. The client socket is
232
+ * destroyed before every settlement, on the claimed path because an upgraded socket is detached from
233
+ * the request and outlives it otherwise. The request is made with no agent, so no pooled connection
234
+ * survives the call to keep a suite's event loop alive.
235
+ *
236
+ * A server that accepts the connection and answers nothing raises no transport error, so the budget
237
+ * is what ends that call: the rejection names the port and path it was waiting on. The interval is
238
+ * validated for consistency with the wait family but is not used, because this helper parks on the
239
+ * request's events.
240
+ *
241
+ * A `101` is the claimed path's status on the wire and is deliberately not reported: `status` is the
242
+ * refused arm's member, and a claimed upgrade produced no plain answer.
243
+ * @example
244
+ * ```ts
245
+ * const answer = await requestUpgrade(loopback.port, { path: '/socket', protocols: ['chat'] })
246
+ * // { claimed: true, protocol: 'chat' }
247
+ * ```
248
+ */
249
+ export declare function requestUpgrade(port: number, options?: UpgradeOptions): Promise<UpgradeResult>;
250
+
218
251
  /**
219
252
  * Resolves a target that stays below a root directory.
220
253
  *
@@ -243,10 +276,11 @@ export declare interface ScratchInterface {
243
276
  *
244
277
  * @param target - A relative or absolute file path contained by the scratch directory.
245
278
  * @param text - The file contents.
279
+ * @returns The absolute path of the written file.
246
280
  * @throws When the target escapes the scratch directory, the scratch root is missing, a symbolic
247
281
  * link, or a file, or the host refuses to write the file.
248
282
  */
249
- write(target: string, text: string): void;
283
+ write(target: string, text: string): string;
250
284
  /**
251
285
  * Reads a file.
252
286
  *
@@ -294,12 +328,13 @@ export declare interface ScratchInterface {
294
328
  * @param source - The destination path the link points at. The stored value is a path naming that
295
329
  * destination, but its exact text is not promised. The path may name a destination outside the
296
330
  * scratch directory and is not containment-checked.
331
+ * @returns The absolute path of the created link, whatever host mechanism created it.
297
332
  * @throws When the target escapes the scratch directory, the scratch root is missing, a symbolic
298
333
  * link, or a file, or the host refuses to create the link, including a host that creates no
299
334
  * symbolic link when the source names an existing non-directory.
300
335
  * @remarks {@link createLink} owns the host-specific link mechanism.
301
336
  */
302
- link(target: string, source: string): void;
337
+ link(target: string, source: string): string;
303
338
  /**
304
339
  * Removes a file, an empty directory, or a directory and its descendants.
305
340
  *
@@ -339,6 +374,112 @@ export declare interface ScratchOptions {
339
374
  readonly files?: Readonly<Record<string, string>>;
340
375
  }
341
376
 
377
+ /**
378
+ * Checks whether this host accepts a filename carrying a raw byte no UTF-8 decoder resolves.
379
+ *
380
+ * @returns True if a name ending in byte `0x80` is written and read back; false otherwise, including
381
+ * every host refusal.
382
+ * @throws Nothing the attempt itself raises. Failing to allocate the probe directory, and failing to
383
+ * remove it afterwards, both propagate.
384
+ * @remarks Byte `0x80` is an invalid UTF-8 lead byte. POSIX stores the name verbatim and Windows
385
+ * rejects it with `ENOENT`, so the answer is true on POSIX and false on Windows. The path is passed
386
+ * as a `Buffer` because the byte survives no string round trip.
387
+ */
388
+ export declare function supportsBytes(): boolean;
389
+
390
+ /**
391
+ * Checks whether this host treats two names differing only by case as distinct files.
392
+ *
393
+ * @returns True if `A` and `a` hold the contents each was written with; false otherwise, including
394
+ * every host refusal.
395
+ * @throws Nothing the attempt itself raises. Failing to allocate the probe directory, and failing to
396
+ * remove it afterwards, both propagate.
397
+ * @remarks The names `A` and `a` differ by case and by nothing else, which is what makes the reading
398
+ * an answer about case folding rather than an answer about two unrelated files. A case-folding volume
399
+ * routes the second write onto the first entry, so reading the first back returns the second's
400
+ * contents and the answer is false. The answer is true on a typical POSIX host and false on a
401
+ * case-folding Windows or macOS volume.
402
+ */
403
+ export declare function supportsCase(): boolean;
404
+
405
+ /**
406
+ * Checks whether this host links a directory, by creating one link and reading through it.
407
+ *
408
+ * @returns True if the created link reports as a symbolic link, resolves to a directory, and reaches
409
+ * the destination's contents; false otherwise, including every host refusal.
410
+ * @throws Nothing the attempt itself raises. Failing to allocate the probe directory, and failing to
411
+ * remove it afterwards, both propagate.
412
+ * @remarks `symlinkSync(source, target, 'junction')` creates a directory junction on Windows, which
413
+ * needs no privilege, and Node ignores the type argument off Windows, so one call covers both hosts.
414
+ * The answer is false on a filesystem carrying neither reparse points nor symbolic links. Every call
415
+ * probes and cleans up after itself, so a host whose answer changes is read again rather than
416
+ * remembered.
417
+ */
418
+ export declare function supportsDirectoryLinks(): boolean;
419
+
420
+ /**
421
+ * Checks whether this host links a file, by creating one link and reading the file through it.
422
+ *
423
+ * @returns True if the file's contents are readable through the link; false otherwise, including
424
+ * every host refusal.
425
+ * @throws Nothing the attempt itself raises. Failing to allocate the probe directory, and failing to
426
+ * remove it afterwards, both propagate.
427
+ * @remarks `symlinkSync(source, target, 'file')` needs the symbolic-link privilege, which Windows
428
+ * grants under Developer Mode or administrator rights and refuses with `EPERM` otherwise, so the
429
+ * answer is true on POSIX and on a privileged Windows host. Where it is false, no mechanism reaches a
430
+ * file through a link and a proof that reads one back cannot run. This is a separate question from
431
+ * {@link supportsDirectoryLinks}, which an unprivileged Windows host answers true through a junction
432
+ * while answering this one false.
433
+ */
434
+ export declare function supportsFileLinks(): boolean;
435
+
436
+ /**
437
+ * Checks whether POSIX permission bits round-trip through this host's `chmod` and `stat`.
438
+ *
439
+ * @returns True if a directory created with mode `0o700` reports that mode back; false otherwise,
440
+ * including every host refusal.
441
+ * @throws Nothing the attempt itself raises. Failing to allocate the probe directory, and failing to
442
+ * remove it afterwards, both propagate.
443
+ * @remarks POSIX reports `mode & 0o777 === 0o700` and Windows reports `0o666` regardless, so the
444
+ * answer is true on POSIX and false on Windows. Storing a bit is a narrower question than enforcing
445
+ * it: a POSIX host running as uid `0` stores every bit faithfully and bypasses the access check the
446
+ * bits describe, so a caller that needs a permission to be enforced probes the refusal it needs
447
+ * rather than reading this.
448
+ */
449
+ export declare function supportsMode(): boolean;
450
+
451
+ /**
452
+ * Options for driving a client upgrade request.
453
+ *
454
+ * @remarks The time bounds and abort signal bound the wait for the server's answer, so a server
455
+ * that accepts the connection and never answers ends the call rather than parking it.
456
+ */
457
+ export declare interface UpgradeOptions extends WaitOptions {
458
+ /** The request path, written with its leading slash. Defaults to `/`. */
459
+ readonly path?: string;
460
+ /**
461
+ * The subprotocol tokens the request offers. They are sent as one comma-separated
462
+ * `Sec-WebSocket-Protocol` field, and an empty or omitted list sends no field at all.
463
+ */
464
+ readonly protocols?: readonly string[];
465
+ }
466
+
467
+ /**
468
+ * What one server did with a client upgrade request.
469
+ *
470
+ * @remarks `claimed` is the discriminant. The claimed arm carries `protocol`, the subprotocol the
471
+ * server selected, which is `undefined` when it selected none; a claimed upgrade produced no plain
472
+ * answer, so it carries no status and the `101` on the wire is deliberately not reported as one.
473
+ * The refused arm carries `status`, the plain answer's status, and no subprotocol.
474
+ */
475
+ export declare type UpgradeResult = {
476
+ readonly claimed: true;
477
+ readonly protocol: string | undefined;
478
+ } | {
479
+ readonly claimed: false;
480
+ readonly status: number;
481
+ };
482
+
342
483
  /**
343
484
  * Waits for a socket to close, accepting a peer reset as a forced close.
344
485
  *
@@ -1,9 +1,11 @@
1
+ import { Buffer } from "node:buffer";
1
2
  import { lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
2
- import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
3
+ import { request } from "node:http";
4
+ import { tmpdir } from "node:os";
5
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
3
6
  import { fileURLToPath } from "node:url";
4
7
  import { waitForDelay } from "../core/index.js";
5
8
  import { once } from "node:events";
6
- import { tmpdir } from "node:os";
7
9
  //#region src/server/constants.ts
8
10
  /**
9
11
  * The attempts `removeTree` makes before rethrowing a retryable removal error.
@@ -312,6 +314,239 @@ async function destroyScratch(scratch, options) {
312
314
  await waitForDelay(interval);
313
315
  }
314
316
  }
317
+ /**
318
+ * Drives a real client upgrade request against a loopback port and reports what the server did.
319
+ *
320
+ * @param port - The port the server listens on at `127.0.0.1`.
321
+ * @param options - Optional request path, offered subprotocols, time bounds, and abort signal.
322
+ * @returns A promise resolving to the server's answer: a claimed upgrade with the protocol it
323
+ * selected, or a refusal with the status it answered.
324
+ * @throws The client's own transport error, such as the `ECONNREFUSED` a closed port answers, the
325
+ * abort reason, or an `Error` when a bound is invalid or the server does not answer within the
326
+ * budget.
327
+ * @remarks Default budget: `1000` milliseconds. The request carries `Connection: Upgrade` and
328
+ * `Upgrade: websocket`, which is what makes a server's `upgrade` handler the one that answers it.
329
+ * The `upgrade`, `response`, and `error` events are mutually exclusive in practice and the promise
330
+ * settles on whichever arrives first, so a second event changes nothing. The client socket is
331
+ * destroyed before every settlement, on the claimed path because an upgraded socket is detached from
332
+ * the request and outlives it otherwise. The request is made with no agent, so no pooled connection
333
+ * survives the call to keep a suite's event loop alive.
334
+ *
335
+ * A server that accepts the connection and answers nothing raises no transport error, so the budget
336
+ * is what ends that call: the rejection names the port and path it was waiting on. The interval is
337
+ * validated for consistency with the wait family but is not used, because this helper parks on the
338
+ * request's events.
339
+ *
340
+ * A `101` is the claimed path's status on the wire and is deliberately not reported: `status` is the
341
+ * refused arm's member, and a claimed upgrade produced no plain answer.
342
+ * @example
343
+ * ```ts
344
+ * const answer = await requestUpgrade(loopback.port, { path: '/socket', protocols: ['chat'] })
345
+ * // { claimed: true, protocol: 'chat' }
346
+ * ```
347
+ */
348
+ async function requestUpgrade(port, options) {
349
+ const budget = options?.budget ?? 1e3;
350
+ const interval = options?.interval ?? 10;
351
+ if (!Number.isFinite(budget) || budget < 0) throw new Error("Upgrade budget must be finite and non-negative");
352
+ if (!Number.isFinite(interval) || interval < 0) throw new Error("Upgrade interval must be finite and non-negative");
353
+ const signal = options?.signal;
354
+ signal?.throwIfAborted();
355
+ const path = options?.path ?? "/";
356
+ const target = `127.0.0.1:${port}${path}`;
357
+ const headers = {
358
+ connection: "Upgrade",
359
+ upgrade: "websocket"
360
+ };
361
+ const protocols = options?.protocols ?? [];
362
+ if (protocols.length > 0) headers["sec-websocket-protocol"] = protocols.join(", ");
363
+ const request$1 = request({
364
+ agent: false,
365
+ headers,
366
+ host: "127.0.0.1",
367
+ path,
368
+ port
369
+ });
370
+ const settled = Promise.withResolvers();
371
+ const expiry = Promise.withResolvers();
372
+ const aborted = Promise.withResolvers();
373
+ const subscription = new AbortController();
374
+ request$1.on("upgrade", (response, socket) => {
375
+ const protocol = response.headers["sec-websocket-protocol"];
376
+ socket.destroy();
377
+ settled.resolve({
378
+ claimed: true,
379
+ protocol
380
+ });
381
+ });
382
+ request$1.on("response", (response) => {
383
+ const status = response.statusCode;
384
+ response.destroy();
385
+ request$1.destroy();
386
+ if (status === void 0) {
387
+ settled.reject(/* @__PURE__ */ new Error(`Upgrade request to ${target} was answered without a status`));
388
+ return;
389
+ }
390
+ settled.resolve({
391
+ claimed: false,
392
+ status
393
+ });
394
+ });
395
+ request$1.on("error", (error) => {
396
+ request$1.destroy();
397
+ settled.reject(error);
398
+ });
399
+ signal?.addEventListener("abort", () => aborted.reject(signal.reason), {
400
+ once: true,
401
+ signal: subscription.signal
402
+ });
403
+ const timer = setTimeout(() => {
404
+ expiry.reject(/* @__PURE__ */ new Error(`Upgrade request to ${target} was not answered within ${budget}ms`));
405
+ }, budget);
406
+ request$1.end();
407
+ try {
408
+ return await Promise.race([
409
+ settled.promise,
410
+ expiry.promise,
411
+ aborted.promise
412
+ ]);
413
+ } finally {
414
+ clearTimeout(timer);
415
+ subscription.abort();
416
+ request$1.destroy();
417
+ }
418
+ }
419
+ /**
420
+ * Checks whether this host links a directory, by creating one link and reading through it.
421
+ *
422
+ * @returns True if the created link reports as a symbolic link, resolves to a directory, and reaches
423
+ * the destination's contents; false otherwise, including every host refusal.
424
+ * @throws Nothing the attempt itself raises. Failing to allocate the probe directory, and failing to
425
+ * remove it afterwards, both propagate.
426
+ * @remarks `symlinkSync(source, target, 'junction')` creates a directory junction on Windows, which
427
+ * needs no privilege, and Node ignores the type argument off Windows, so one call covers both hosts.
428
+ * The answer is false on a filesystem carrying neither reparse points nor symbolic links. Every call
429
+ * probes and cleans up after itself, so a host whose answer changes is read again rather than
430
+ * remembered.
431
+ */
432
+ function supportsDirectoryLinks() {
433
+ const directory = mkdtempSync(join(tmpdir(), "orkestrel-test-directory-links-"));
434
+ try {
435
+ const source = join(directory, "source");
436
+ const link = join(directory, "link");
437
+ mkdirSync(source);
438
+ writeFileSync(join(source, "marker.txt"), "marked");
439
+ symlinkSync(source, link, "junction");
440
+ return lstatSync(link).isSymbolicLink() && statSync(link).isDirectory() && readFileSync(join(link, "marker.txt"), "utf8") === "marked";
441
+ } catch {
442
+ return false;
443
+ } finally {
444
+ removeTree(directory);
445
+ }
446
+ }
447
+ /**
448
+ * Checks whether this host links a file, by creating one link and reading the file through it.
449
+ *
450
+ * @returns True if the file's contents are readable through the link; false otherwise, including
451
+ * every host refusal.
452
+ * @throws Nothing the attempt itself raises. Failing to allocate the probe directory, and failing to
453
+ * remove it afterwards, both propagate.
454
+ * @remarks `symlinkSync(source, target, 'file')` needs the symbolic-link privilege, which Windows
455
+ * grants under Developer Mode or administrator rights and refuses with `EPERM` otherwise, so the
456
+ * answer is true on POSIX and on a privileged Windows host. Where it is false, no mechanism reaches a
457
+ * file through a link and a proof that reads one back cannot run. This is a separate question from
458
+ * {@link supportsDirectoryLinks}, which an unprivileged Windows host answers true through a junction
459
+ * while answering this one false.
460
+ */
461
+ function supportsFileLinks() {
462
+ const directory = mkdtempSync(join(tmpdir(), "orkestrel-test-file-links-"));
463
+ try {
464
+ const source = join(directory, "source.txt");
465
+ const link = join(directory, "link.txt");
466
+ writeFileSync(source, "linked");
467
+ symlinkSync(source, link, "file");
468
+ return readFileSync(link, "utf8") === "linked";
469
+ } catch {
470
+ return false;
471
+ } finally {
472
+ removeTree(directory);
473
+ }
474
+ }
475
+ /**
476
+ * Checks whether POSIX permission bits round-trip through this host's `chmod` and `stat`.
477
+ *
478
+ * @returns True if a directory created with mode `0o700` reports that mode back; false otherwise,
479
+ * including every host refusal.
480
+ * @throws Nothing the attempt itself raises. Failing to allocate the probe directory, and failing to
481
+ * remove it afterwards, both propagate.
482
+ * @remarks POSIX reports `mode & 0o777 === 0o700` and Windows reports `0o666` regardless, so the
483
+ * answer is true on POSIX and false on Windows. Storing a bit is a narrower question than enforcing
484
+ * it: a POSIX host running as uid `0` stores every bit faithfully and bypasses the access check the
485
+ * bits describe, so a caller that needs a permission to be enforced probes the refusal it needs
486
+ * rather than reading this.
487
+ */
488
+ function supportsMode() {
489
+ const directory = mkdtempSync(join(tmpdir(), "orkestrel-test-mode-"));
490
+ try {
491
+ const path = join(directory, "moded");
492
+ mkdirSync(path, { mode: 448 });
493
+ return (statSync(path).mode & 511) === 448;
494
+ } catch {
495
+ return false;
496
+ } finally {
497
+ removeTree(directory);
498
+ }
499
+ }
500
+ /**
501
+ * Checks whether this host treats two names differing only by case as distinct files.
502
+ *
503
+ * @returns True if `A` and `a` hold the contents each was written with; false otherwise, including
504
+ * every host refusal.
505
+ * @throws Nothing the attempt itself raises. Failing to allocate the probe directory, and failing to
506
+ * remove it afterwards, both propagate.
507
+ * @remarks The names `A` and `a` differ by case and by nothing else, which is what makes the reading
508
+ * an answer about case folding rather than an answer about two unrelated files. A case-folding volume
509
+ * routes the second write onto the first entry, so reading the first back returns the second's
510
+ * contents and the answer is false. The answer is true on a typical POSIX host and false on a
511
+ * case-folding Windows or macOS volume.
512
+ */
513
+ function supportsCase() {
514
+ const directory = mkdtempSync(join(tmpdir(), "orkestrel-test-case-"));
515
+ try {
516
+ const upper = join(directory, "A");
517
+ const lower = join(directory, "a");
518
+ writeFileSync(upper, "upper");
519
+ writeFileSync(lower, "lower");
520
+ return readFileSync(upper, "utf8") === "upper" && readFileSync(lower, "utf8") === "lower";
521
+ } catch {
522
+ return false;
523
+ } finally {
524
+ removeTree(directory);
525
+ }
526
+ }
527
+ /**
528
+ * Checks whether this host accepts a filename carrying a raw byte no UTF-8 decoder resolves.
529
+ *
530
+ * @returns True if a name ending in byte `0x80` is written and read back; false otherwise, including
531
+ * every host refusal.
532
+ * @throws Nothing the attempt itself raises. Failing to allocate the probe directory, and failing to
533
+ * remove it afterwards, both propagate.
534
+ * @remarks Byte `0x80` is an invalid UTF-8 lead byte. POSIX stores the name verbatim and Windows
535
+ * rejects it with `ENOENT`, so the answer is true on POSIX and false on Windows. The path is passed
536
+ * as a `Buffer` because the byte survives no string round trip.
537
+ */
538
+ function supportsBytes() {
539
+ const directory = mkdtempSync(join(tmpdir(), "orkestrel-test-bytes-"));
540
+ try {
541
+ const name = Buffer.concat([Buffer.from(`${directory}${sep}`), Buffer.from([128])]);
542
+ writeFileSync(name, "raw");
543
+ return readFileSync(name, "utf8") === "raw";
544
+ } catch {
545
+ return false;
546
+ } finally {
547
+ removeTree(directory);
548
+ }
549
+ }
315
550
  //#endregion
316
551
  //#region src/server/factories.ts
317
552
  /**
@@ -360,6 +595,7 @@ function createScratch(options) {
360
595
  if (!scratch.has(".")) throw new Error("Scratch directory does not exist");
361
596
  mkdirSync(dirname(candidate), { recursive: true });
362
597
  writeFileSync(candidate, text);
598
+ return candidate;
363
599
  },
364
600
  read(target) {
365
601
  const candidate = resolveContained(path, target);
@@ -403,6 +639,7 @@ function createScratch(options) {
403
639
  if (!scratch.has(".")) throw new Error("Scratch directory does not exist");
404
640
  mkdirSync(dirname(candidate), { recursive: true });
405
641
  createLink(candidate, source);
642
+ return candidate;
406
643
  },
407
644
  remove(target) {
408
645
  const candidate = resolveContained(path, target);
@@ -495,6 +732,6 @@ function createCookieJar() {
495
732
  };
496
733
  }
497
734
  //#endregion
498
- export { REMOVE_TREE_MAX_ATTEMPTS, REMOVE_TREE_RETRYABLE_CODES, REMOVE_TREE_RETRY_DELAY_MS, createCookieJar, createLink, createLoopback, createScratch, destroyScratch, isExcluded, isRunning, matchesIdentity, readInventory, removeTree, resolveContained, waitForSocketClose };
735
+ export { REMOVE_TREE_MAX_ATTEMPTS, REMOVE_TREE_RETRYABLE_CODES, REMOVE_TREE_RETRY_DELAY_MS, createCookieJar, createLink, createLoopback, createScratch, destroyScratch, isExcluded, isRunning, matchesIdentity, readInventory, removeTree, requestUpgrade, resolveContained, supportsBytes, supportsCase, supportsDirectoryLinks, supportsFileLinks, supportsMode, waitForSocketClose };
499
736
 
500
737
  //# sourceMappingURL=index.js.map