@poe-platform/safe-fs 0.1.53 → 0.1.55

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.
@@ -23,7 +23,28 @@ export interface DirectoryEntry {
23
23
  }
24
24
  export interface FileSystemCapabilities {
25
25
  readonly readOnly?: boolean;
26
+ readonly read?: boolean;
27
+ readonly stat?: boolean;
28
+ readonly readdir?: boolean;
29
+ readonly realpath?: boolean;
30
+ readonly access?: boolean;
31
+ readonly write?: boolean;
26
32
  readonly append?: boolean;
33
+ readonly exclusiveCreate?: boolean;
34
+ readonly explicitDirectories?: boolean;
35
+ readonly implicitDirectories?: boolean;
36
+ readonly mkdir?: boolean;
37
+ readonly recursiveMkdir?: boolean;
38
+ readonly remove?: boolean;
39
+ readonly removeDirectory?: boolean;
40
+ readonly recursiveRemove?: boolean;
41
+ readonly rename?: boolean;
42
+ readonly copy?: boolean;
43
+ readonly exclusiveCopy?: boolean;
44
+ readonly readlink?: boolean;
45
+ readonly truncate?: boolean;
46
+ readonly streamingAppend?: boolean;
47
+ readonly randomAccessWrite?: boolean;
27
48
  readonly symlinks?: boolean;
28
49
  readonly hardlinks?: boolean;
29
50
  readonly permissions?: boolean;
@@ -65,6 +86,7 @@ export interface ReadStreamOptions extends FsOptions {
65
86
  }
66
87
  export interface FileSystem {
67
88
  readonly capabilities: FileSystemCapabilities;
89
+ capabilitiesFor?(path: string, options?: FsOptions): Promise<FileSystemCapabilities>;
68
90
  readFile(path: string, options?: ReadFileOptions): Promise<Uint8Array>;
69
91
  writeFile(path: string, data: Uint8Array, options?: WriteFileOptions): Promise<void>;
70
92
  appendFile(path: string, data: Uint8Array, options?: AppendFileOptions): Promise<void>;
@@ -0,0 +1,4 @@
1
+ import type { FileSystemCapabilities } from "../contracts/filesystem.js";
2
+ export declare function requireCapabilities(...values: readonly (boolean | undefined)[]): boolean | undefined;
3
+ export declare function readOnlyCapabilities(capabilities: FileSystemCapabilities): FileSystemCapabilities;
4
+ export declare function quotaCapabilities(capabilities: FileSystemCapabilities): FileSystemCapabilities;
@@ -0,0 +1,25 @@
1
+ export function requireCapabilities(...values) {
2
+ return values.some(value => value === false) ? false : values.every(value => value === true) ? true : undefined;
3
+ }
4
+ export function readOnlyCapabilities(capabilities) {
5
+ const inspection = Object.fromEntries([
6
+ "read", "stat", "readdir", "realpath", "access", "readlink", "explicitDirectories", "implicitDirectories",
7
+ "symlinks", "streamingRead",
8
+ ].filter(name => capabilities[name] !== undefined).map(name => [name, capabilities[name]]));
9
+ return Object.freeze({
10
+ ...inspection, readOnly: true, write: false, append: false, exclusiveCreate: false,
11
+ mkdir: false, recursiveMkdir: false, remove: false, removeDirectory: false, recursiveRemove: false,
12
+ rename: false, copy: false, exclusiveCopy: false, truncate: false, streamingAppend: false,
13
+ randomAccessWrite: false, hardlinks: false, permissions: false, timestamps: false,
14
+ atomicRename: false, streamingWrite: false,
15
+ });
16
+ }
17
+ export function quotaCapabilities(capabilities) {
18
+ const streamingWrite = requireCapabilities(capabilities.write, capabilities.append, !capabilities.readOnly);
19
+ const streamingAppend = requireCapabilities(capabilities.append, !capabilities.readOnly);
20
+ const { streamingWrite: ignoredWrite, streamingAppend: ignoredAppend, ...rest } = capabilities;
21
+ return Object.freeze({ ...rest,
22
+ ...(streamingWrite === undefined ? {} : { streamingWrite }),
23
+ ...(streamingAppend === undefined ? {} : { streamingAppend }),
24
+ });
25
+ }
@@ -2,6 +2,28 @@ import type { AppendFileOptions, CopyFileOptions, DirectoryEntry, EntryCompariso
2
2
  import type { ByteSource } from "../../contracts/io.js";
3
3
  export declare class MemoryFileSystem implements FileSystem {
4
4
  readonly capabilities: Readonly<{
5
+ read: true;
6
+ stat: true;
7
+ readdir: true;
8
+ realpath: true;
9
+ access: true;
10
+ write: true;
11
+ append: true;
12
+ exclusiveCreate: true;
13
+ explicitDirectories: true;
14
+ implicitDirectories: false;
15
+ mkdir: true;
16
+ recursiveMkdir: true;
17
+ remove: true;
18
+ removeDirectory: true;
19
+ recursiveRemove: true;
20
+ rename: true;
21
+ copy: true;
22
+ exclusiveCopy: true;
23
+ readlink: true;
24
+ truncate: true;
25
+ streamingAppend: true;
26
+ randomAccessWrite: true;
5
27
  readOnly: false;
6
28
  symlinks: true;
7
29
  hardlinks: true;
@@ -48,6 +48,11 @@ const compareOwnedMemory = async (own, peer, options) => {
48
48
  };
49
49
  export class MemoryFileSystem {
50
50
  capabilities = Object.freeze({
51
+ read: true, stat: true, readdir: true, realpath: true, access: true,
52
+ write: true, append: true, exclusiveCreate: true, explicitDirectories: true, implicitDirectories: false,
53
+ mkdir: true, recursiveMkdir: true, remove: true, removeDirectory: true, recursiveRemove: true,
54
+ rename: true, copy: true, exclusiveCopy: true, readlink: true, truncate: true,
55
+ streamingAppend: true, randomAccessWrite: true,
51
56
  readOnly: false,
52
57
  symlinks: true,
53
58
  hardlinks: true,
@@ -9,6 +9,7 @@ export declare class MountFileSystem implements FileSystem {
9
9
  private readonly mounts;
10
10
  constructor(options: MountFileSystemOptions);
11
11
  private select;
12
+ capabilitiesFor(path: string, options?: FsOptions): Promise<FileSystemCapabilities>;
12
13
  private protected;
13
14
  private error;
14
15
  private operation;
@@ -1,6 +1,7 @@
1
1
  import { FsError, isFsError, toFsError } from "../../contracts/errors.js";
2
2
  import { readBytes } from "../../contracts/io.js";
3
3
  import { finishCleanup } from "../../contracts/cleanup.js";
4
+ import { readOnlyCapabilities } from "../capabilities.js";
4
5
  import { normalizePath, validatePath } from "../../contracts/virtual-path.js";
5
6
  import { compareIdentity } from "./identity.js";
6
7
  import { compareEntries, registerEntryAuthority, registerEntryView } from "./comparison.js";
@@ -79,14 +80,32 @@ export class MountFileSystem {
79
80
  const streamingWrite = streaming("streamingWrite", "writeStream");
80
81
  const append = all("append") ? true
81
82
  : mounts.every(({ backend }) => backend.capabilities.append === false) ? false : undefined;
83
+ const common = (capability) => {
84
+ const optional = {
85
+ symlinks: ["symlink", "readlink"], hardlinks: ["link"], permissions: ["chmod"], timestamps: ["utimes"], readlink: ["readlink"],
86
+ };
87
+ const values = mounts.map(({ backend }) => {
88
+ if (backend.capabilities.readOnly === true
89
+ && !["read", "stat", "readdir", "realpath", "access", "readlink", "explicitDirectories", "implicitDirectories"].includes(capability))
90
+ return false;
91
+ const declared = backend.capabilities[capability];
92
+ return declared === true && optional[capability]?.some(method => typeof backend[method] !== "function") ? false : declared;
93
+ });
94
+ if (["rename", "copy", "exclusiveCopy"].includes(capability) && mounts.length > 1)
95
+ return undefined;
96
+ return values.every(value => value === true) ? true : values.every(value => value === false) ? false : undefined;
97
+ };
98
+ const semantics = Object.fromEntries([
99
+ "read", "stat", "readdir", "realpath", "access",
100
+ "write", "append", "exclusiveCreate", "explicitDirectories", "implicitDirectories", "mkdir", "recursiveMkdir",
101
+ "remove", "removeDirectory", "recursiveRemove", "rename", "copy", "exclusiveCopy", "readlink", "truncate",
102
+ "streamingAppend", "randomAccessWrite", "symlinks", "hardlinks", "permissions", "timestamps",
103
+ ].map(capability => [capability, common(capability)]).filter(([, value]) => value !== undefined));
82
104
  this.capabilities = Object.freeze({
83
105
  get snapshotRmdir() { return mounts.some(({ backend }) => backend.capabilities.snapshotRmdir === true); },
84
106
  readOnly: all("readOnly"),
85
107
  ...(append === undefined ? {} : { append }),
86
- symlinks: all("symlinks", ["readlink", "symlink"]),
87
- hardlinks: all("hardlinks", ["link"]),
88
- permissions: all("permissions", ["chmod"]),
89
- timestamps: all("timestamps", ["utimes"]),
108
+ ...semantics,
90
109
  atomicRename: mounts.length === 1 && all("atomicRename"),
91
110
  ...(streamingRead === undefined ? {} : { streamingRead }),
92
111
  ...(streamingWrite === undefined ? {} : { streamingWrite }),
@@ -95,6 +114,24 @@ export class MountFileSystem {
95
114
  select(path) {
96
115
  return this.mounts.find((mount) => within(mount.path, path));
97
116
  }
117
+ async capabilitiesFor(path, options = {}) {
118
+ return this.operation("capabilitiesFor", path, options, async () => {
119
+ const location = await this.resolve(path, options, { allowMissing: true });
120
+ const capabilities = await location.mount.backend.capabilitiesFor?.(location.local, options)
121
+ ?? location.mount.backend.capabilities;
122
+ if (location.synthetic)
123
+ return readOnlyCapabilities(capabilities);
124
+ if (this.mounts.length === 1 || capabilities.readOnly === true)
125
+ return Object.freeze({ ...capabilities });
126
+ const { rename: ignoredRename, copy: ignoredCopy, exclusiveCopy: ignoredExclusiveCopy, ...selected } = capabilities;
127
+ const cannotPublish = capabilities.write === false && capabilities.streamingWrite === false && capabilities.exclusiveCreate === false;
128
+ return Object.freeze({
129
+ ...selected,
130
+ ...(capabilities.copy === false && capabilities.exclusiveCopy === false && cannotPublish ? { copy: false } : {}),
131
+ ...(capabilities.exclusiveCopy === false && capabilities.exclusiveCreate === false && capabilities.streamingWrite === false ? { exclusiveCopy: false } : {}),
132
+ });
133
+ });
134
+ }
98
135
  protected(path) {
99
136
  return path === "/" || this.mounts.some((mount) => within(path, mount.path));
100
137
  }
@@ -1,4 +1,5 @@
1
1
  import { platform } from "#safe-fs-platform";
2
+ import { requireCapabilities } from "../capabilities.js";
2
3
  import { finishCleanup } from "../../contracts/cleanup.js";
3
4
  import { collectBytes, readBytes } from "../../contracts/io.js";
4
5
  import { FsError, toFsError } from "../../contracts/errors.js";
@@ -83,9 +84,35 @@ export class OverlayFileSystem {
83
84
  ? readable.every((capability) => capability === true) ? true : undefined : false;
84
85
  const append = !writable || this.#upper.capabilities.append === false ? false
85
86
  : this.#upper.capabilities.append === true ? true : undefined;
87
+ const upper = this.#upper.capabilities;
88
+ const mutation = requireCapabilities(writable, upper.mkdir, upper.rename, upper.remove, upper.exclusiveCreate, upper.timestamps, upper.permissions);
89
+ const effectiveAppend = requireCapabilities(append, mutation);
90
+ const effectiveStreamingWrite = requireCapabilities(streamingWrite, mutation);
91
+ const semantics = Object.fromEntries([
92
+ ...["read", "stat", "readdir", "realpath", "access"].map(capability => [capability,
93
+ requireCapabilities(upper[capability], this.#lower.capabilities[capability])]),
94
+ ["write", requireCapabilities(mutation, upper.write)],
95
+ ["exclusiveCreate", requireCapabilities(mutation, upper.exclusiveCreate)],
96
+ ["mkdir", requireCapabilities(mutation, upper.mkdir)],
97
+ ["recursiveMkdir", requireCapabilities(mutation, upper.recursiveMkdir)],
98
+ ["remove", requireCapabilities(mutation, upper.remove)],
99
+ ["removeDirectory", requireCapabilities(mutation, upper.removeDirectory)],
100
+ ["recursiveRemove", requireCapabilities(mutation, upper.recursiveRemove)],
101
+ ["rename", requireCapabilities(mutation, upper.rename, upper.write)],
102
+ ["copy", requireCapabilities(mutation, upper.write)],
103
+ ["exclusiveCopy", requireCapabilities(mutation, upper.exclusiveCreate)],
104
+ ["truncate", requireCapabilities(mutation, upper.truncate)],
105
+ ["streamingAppend", requireCapabilities(mutation, streamingWrite, upper.streamingAppend)],
106
+ ["randomAccessWrite", requireCapabilities(mutation, upper.randomAccessWrite)],
107
+ ["explicitDirectories", requireCapabilities(upper.explicitDirectories, this.#lower.capabilities.explicitDirectories)],
108
+ ].filter(([, value]) => value !== undefined));
86
109
  this.capabilities = Object.freeze({
87
- readOnly: !writable,
88
- ...(append === undefined ? {} : { append }),
110
+ ...semantics,
111
+ implicitDirectories: false,
112
+ readlink: upper.readlink === true && this.#lower.capabilities.readlink === true ? true
113
+ : upper.readlink === false && this.#lower.capabilities.readlink === false ? false : undefined,
114
+ ...(upper.readOnly === undefined ? {} : { readOnly: upper.readOnly }),
115
+ ...(effectiveAppend === undefined ? {} : { append: effectiveAppend }),
89
116
  atomicRename: false,
90
117
  hardlinks: false,
91
118
  symlinks: writable && this.#upper.capabilities.symlinks === true
@@ -94,7 +121,7 @@ export class OverlayFileSystem {
94
121
  permissions: writable && this.#upper.capabilities.permissions === true && typeof this.#upper.chmod === "function",
95
122
  timestamps: writable && this.#upper.capabilities.timestamps === true && typeof this.#upper.utimes === "function",
96
123
  ...(streamingRead === undefined ? {} : { streamingRead }),
97
- ...(streamingWrite === undefined ? {} : { streamingWrite }),
124
+ ...(effectiveStreamingWrite === undefined ? {} : { streamingWrite: effectiveStreamingWrite }),
98
125
  });
99
126
  Object.defineProperty(this, "capabilities", { writable: false, configurable: false });
100
127
  }
@@ -1,3 +1,4 @@
1
+ import { quotaCapabilities } from "../capabilities.js";
1
2
  export class FileSystemQuotaError extends Error {
2
3
  maxBytes;
3
4
  constructor(maxBytes) {
@@ -99,6 +100,10 @@ export function withFileSystemQuota(fs, options) {
99
100
  };
100
101
  return new Proxy(fs, {
101
102
  get(target, property) {
103
+ if (property === "capabilities")
104
+ return quotaCapabilities(target.capabilities);
105
+ if (property === "capabilitiesFor")
106
+ return async (path, fsOptions) => quotaCapabilities(await target.capabilitiesFor?.(path, fsOptions) ?? target.capabilities);
102
107
  const replacement = Reflect.get(mutations, property);
103
108
  if (typeof replacement === "function")
104
109
  return replacement;
@@ -4,6 +4,7 @@ export declare class ReadOnlyFileSystem implements FileSystem {
4
4
  #private;
5
5
  constructor(filesystem: FileSystem);
6
6
  get capabilities(): FileSystemCapabilities;
7
+ capabilitiesFor(path: string, options?: FsOptions): Promise<FileSystemCapabilities>;
7
8
  readFile(path: string, options?: ReadFileOptions): Promise<Uint8Array>;
8
9
  stat(path: string, options?: FsOptions): Promise<FileStat>;
9
10
  lstat(path: string, options?: FsOptions): Promise<FileStat>;
@@ -2,6 +2,7 @@ import { ACCESS_MODES } from "../../contracts/filesystem.js";
2
2
  import { FsError } from "../../contracts/errors.js";
3
3
  import { readBytes } from "../../contracts/io.js";
4
4
  import { compareEntries, registerEntryView } from "../mount/comparison.js";
5
+ import { readOnlyCapabilities } from "../capabilities.js";
5
6
  function readOnly(syscall, path, dest) {
6
7
  throw new FsError("EROFS", { syscall, path, ...(dest === undefined ? {} : { dest }) });
7
8
  }
@@ -26,7 +27,8 @@ export class ReadOnlyFileSystem {
26
27
  this.#filesystem = filesystem;
27
28
  registerEntryView(this, async (path) => ({ filesystem: this.#filesystem, path, readOnly: true }));
28
29
  const streamingRead = typeof filesystem.readStream === "function" ? filesystem.capabilities.streamingRead : false;
29
- this.#capabilities = Object.freeze({
30
+ this.#capabilities = readOnlyCapabilities({
31
+ ...filesystem.capabilities,
30
32
  readOnly: true,
31
33
  append: false,
32
34
  symlinks: filesystem.capabilities.symlinks === true && typeof filesystem.readlink === "function",
@@ -41,6 +43,10 @@ export class ReadOnlyFileSystem {
41
43
  get capabilities() {
42
44
  return this.#capabilities;
43
45
  }
46
+ async capabilitiesFor(path, options) {
47
+ const capabilities = await this.#filesystem.capabilitiesFor?.(path, options) ?? this.#filesystem.capabilities;
48
+ return readOnlyCapabilities(capabilities);
49
+ }
44
50
  async readFile(path, options) {
45
51
  return new Uint8Array(await this.#filesystem.readFile(path, options));
46
52
  }
@@ -80,6 +80,11 @@ function nativeError(error) {
80
80
  */
81
81
  export class RealFileSystem {
82
82
  capabilities = Object.freeze({
83
+ read: true, stat: true, readdir: true, realpath: true, access: true,
84
+ write: true, append: true, exclusiveCreate: true, explicitDirectories: true, implicitDirectories: false,
85
+ mkdir: true, recursiveMkdir: true, remove: true, removeDirectory: true, recursiveRemove: true,
86
+ rename: true, copy: true, exclusiveCopy: true, readlink: true, truncate: true,
87
+ streamingAppend: true, randomAccessWrite: true,
83
88
  readOnly: false, symlinks: true, hardlinks: true, permissions: true,
84
89
  timestamps: true, atomicRename: true, streamingRead: true, streamingWrite: true,
85
90
  });
@@ -22,6 +22,28 @@ export declare class S3RenameError extends FsError {
22
22
  }
23
23
  export declare class S3FileSystem implements FileSystem {
24
24
  readonly capabilities: Readonly<{
25
+ read: true;
26
+ stat: true;
27
+ readdir: true;
28
+ realpath: true;
29
+ access: true;
30
+ write: true;
31
+ explicitDirectories: true;
32
+ implicitDirectories: true;
33
+ mkdir: true;
34
+ recursiveMkdir: true;
35
+ remove: true;
36
+ removeDirectory: true;
37
+ recursiveRemove: true;
38
+ copy: true;
39
+ readlink: false;
40
+ exclusiveCopy: boolean;
41
+ append: boolean;
42
+ exclusiveCreate: boolean;
43
+ truncate: boolean;
44
+ rename: boolean;
45
+ streamingAppend: boolean;
46
+ randomAccessWrite: false;
25
47
  readOnly: boolean;
26
48
  symlinks: false;
27
49
  hardlinks: false;
@@ -104,6 +104,18 @@ export class S3FileSystem {
104
104
  this.maxStreamBytes = validateLimit(options.maxStreamBytes ?? 5_000_000_000, "maxStreamBytes", 0, 5_000_000_000);
105
105
  this.maxListEntries = validateLimit(options.maxListEntries ?? 100_000, "maxListEntries", 1);
106
106
  this.capabilities = Object.freeze({
107
+ read: true, stat: true, readdir: true, realpath: true, access: true,
108
+ write: true, explicitDirectories: true, implicitDirectories: true, mkdir: true, recursiveMkdir: true,
109
+ remove: true, removeDirectory: true, recursiveRemove: true, copy: true, readlink: false,
110
+ exclusiveCopy: options.transport.capabilities?.conditionalCopy === true,
111
+ append: options.transport.capabilities?.conditionalPut === true,
112
+ exclusiveCreate: options.transport.capabilities?.conditionalPut === true,
113
+ truncate: options.transport.capabilities?.conditionalPut === true,
114
+ rename: this.allowRename && options.transport.capabilities?.conditionalDelete === true
115
+ && (options.transport.capabilities?.conditionalCopy === true || options.transport.capabilities?.conditionalPut === true),
116
+ streamingAppend: options.transport.capabilities?.streamingWrite === true
117
+ && options.transport.capabilities?.conditionalPut === true && typeof options.transport.putObjectStream === "function",
118
+ randomAccessWrite: false,
107
119
  readOnly: options.readOnly ?? false,
108
120
  symlinks: false, hardlinks: false, permissions: false,
109
121
  timestamps: options.transport.capabilities?.conditionalCopy === true || options.transport.capabilities?.conditionalPut === true,
@@ -26,6 +26,7 @@ export interface WebDavFileSystemOptions {
26
26
  readonly maxResponseBytes?: number;
27
27
  readonly maxXmlBytes?: number;
28
28
  readonly maxEntries?: number;
29
+ /** Per-request and aggregate stat/write-preflight walk timeout; defaults to 30,000 ms. */
29
30
  readonly timeoutMs?: number;
30
31
  readonly overwritePolicy?: "lock" | "etag";
31
32
  readonly atomicEmptyDirectory?: WebDavAtomicEmptyDirectoryBinding;
@@ -33,6 +34,28 @@ export interface WebDavFileSystemOptions {
33
34
  }
34
35
  export declare class WebDavFileSystem implements FileSystem {
35
36
  readonly capabilities: Readonly<{
37
+ read: true;
38
+ stat: true;
39
+ readdir: true;
40
+ realpath: true;
41
+ access: true;
42
+ write: true;
43
+ append: true;
44
+ exclusiveCreate: true;
45
+ explicitDirectories: true;
46
+ implicitDirectories: false;
47
+ mkdir: true;
48
+ recursiveMkdir: true;
49
+ remove: true;
50
+ recursiveRemove: true;
51
+ rename: true;
52
+ copy: true;
53
+ exclusiveCopy: true;
54
+ readlink: false;
55
+ truncate: false;
56
+ randomAccessWrite: false;
57
+ removeDirectory: boolean;
58
+ streamingAppend: boolean;
36
59
  symlinks: false;
37
60
  hardlinks: false;
38
61
  permissions: false;
@@ -50,6 +73,7 @@ export declare class WebDavFileSystem implements FileSystem {
50
73
  private readonly maxXmlBytes;
51
74
  private readonly maxEntries;
52
75
  private readonly timeoutMs;
76
+ private readonly walkDeadlines;
53
77
  private readonly overwritePolicy;
54
78
  private readonly configuredComparison;
55
79
  private readonly atomicEmptyDirectory;
@@ -72,6 +96,7 @@ export declare class WebDavFileSystem implements FileSystem {
72
96
  private mutation;
73
97
  private unsupported;
74
98
  private maybeStat;
99
+ private withWalkDeadline;
75
100
  stat(path: string, options?: FsOptions): Promise<FileStat>;
76
101
  lstat(path: string, options?: FsOptions): Promise<FileStat>;
77
102
  readdir(path: string, options?: FsOptions): Promise<DirectoryEntry[]>;
@@ -87,7 +87,7 @@ function normalize(path) {
87
87
  }
88
88
  return `/${segments.join("/")}`;
89
89
  }
90
- function validateDirectoryAccessPath(path) {
90
+ function validateDirectoryWalkPath(path, syscall = "access") {
91
91
  if (typeof path !== "string")
92
92
  fail("EINVAL", "resolve", String(path), "invalid WebDAV path");
93
93
  let bytes = 0;
@@ -105,7 +105,7 @@ function validateDirectoryAccessPath(path) {
105
105
  if (point > 0xffff)
106
106
  offset++;
107
107
  if (bytes > 65_536 || components > 256) {
108
- fail("ENAMETOOLONG", "access", path, "directory access exceeds the 64KiB path or 256 component limit");
108
+ fail("ENAMETOOLONG", syscall, path, "directory access exceeds the 64KiB path or 256 component limit");
109
109
  }
110
110
  }
111
111
  }
@@ -140,6 +140,11 @@ function statusCode(element) {
140
140
  }
141
141
  export class WebDavFileSystem {
142
142
  capabilities = Object.freeze({
143
+ read: true, stat: true, readdir: true, realpath: true, access: true,
144
+ write: true, append: true, exclusiveCreate: true, explicitDirectories: true, implicitDirectories: false,
145
+ mkdir: true, recursiveMkdir: true, remove: true, recursiveRemove: true, rename: true, copy: true,
146
+ exclusiveCopy: true, readlink: false, truncate: false, randomAccessWrite: false,
147
+ removeDirectory: false, streamingAppend: false,
143
148
  symlinks: false, hardlinks: false, permissions: false, timestamps: true,
144
149
  atomicRename: false, streamingRead: true, streamingWrite: true,
145
150
  });
@@ -152,6 +157,7 @@ export class WebDavFileSystem {
152
157
  maxXmlBytes;
153
158
  maxEntries;
154
159
  timeoutMs;
160
+ walkDeadlines = new WeakMap();
155
161
  overwritePolicy;
156
162
  configuredComparison;
157
163
  atomicEmptyDirectory;
@@ -200,6 +206,10 @@ export class WebDavFileSystem {
200
206
  }
201
207
  this.transport = options.fetch === globalThis.fetch ? options.fetch.bind(globalThis) : options.fetch;
202
208
  this.requestStreamSupport = options.requestStreamSupport ?? (options.fetch === globalThis.fetch ? "native" : false);
209
+ this.capabilities = Object.freeze({ ...this.capabilities,
210
+ removeDirectory: this.atomicEmptyDirectory !== undefined,
211
+ streamingAppend: this.requestStreamSupport !== false,
212
+ });
203
213
  this.maxResponseBytes = positive(options.maxResponseBytes ?? 64 * 1024 * 1024, "maxResponseBytes");
204
214
  this.maxXmlBytes = positive(options.maxXmlBytes ?? 2 * 1024 * 1024, "maxXmlBytes");
205
215
  this.maxEntries = positive(options.maxEntries ?? 10_000, "maxEntries");
@@ -311,7 +321,10 @@ export class WebDavFileSystem {
311
321
  async *requestStream(method, path, options, init, consume, collection = false, received) {
312
322
  if (options.signal?.aborted)
313
323
  fail("ECANCELED", method, path);
314
- const deadline = createRequestTimeout(this.timeoutMs);
324
+ const walk = this.walkDeadlines.get(options);
325
+ const deadline = walk?.deadline ?? createRequestTimeout(this.timeoutMs);
326
+ if (walk)
327
+ walk.deadline = deadline;
315
328
  const timeout = deadline.signal;
316
329
  try {
317
330
  const headers = new Headers(this.headers);
@@ -400,7 +413,8 @@ export class WebDavFileSystem {
400
413
  }
401
414
  }
402
415
  finally {
403
- deadline.dispose();
416
+ if (!walk)
417
+ deadline.dispose();
404
418
  }
405
419
  }
406
420
  async bytes(response, limit, signal) {
@@ -670,27 +684,44 @@ export class WebDavFileSystem {
670
684
  throw error;
671
685
  }
672
686
  }
687
+ async withWalkDeadline(options, walk) {
688
+ if (this.walkDeadlines.has(options))
689
+ return walk(options);
690
+ const scopedOptions = { ...options };
691
+ const owned = {};
692
+ this.walkDeadlines.set(scopedOptions, owned);
693
+ try {
694
+ return await walk(scopedOptions);
695
+ }
696
+ finally {
697
+ owned.deadline?.dispose();
698
+ this.walkDeadlines.delete(scopedOptions);
699
+ }
700
+ }
673
701
  async stat(path, options = {}) {
702
+ validateDirectoryWalkPath(path, "stat");
674
703
  const normalized = normalize(path);
675
704
  const collection = requiresCollection(path);
676
- try {
677
- const stat = (await this.entries(normalized, "0", options, collection)).get(normalized);
678
- if (collection && stat.type !== "directory")
679
- fail("ENOTDIR", "stat", path);
680
- return stat;
681
- }
682
- catch (error) {
683
- if (!isFsError(error, "ENOENT"))
684
- throw error;
685
- let parent = "";
686
- for (const segment of normalized.slice(1).split("/").slice(0, -1)) {
687
- parent += `/${segment}`;
688
- const ancestor = (await this.entries(parent, "0", options)).get(parent);
689
- if (ancestor.type !== "directory")
705
+ return this.withWalkDeadline(options, async (scopedOptions) => {
706
+ try {
707
+ const stat = (await this.entries(normalized, "0", scopedOptions, collection)).get(normalized);
708
+ if (collection && stat.type !== "directory")
690
709
  fail("ENOTDIR", "stat", path);
710
+ return stat;
691
711
  }
692
- throw error;
693
- }
712
+ catch (error) {
713
+ if (!isFsError(error, "ENOENT"))
714
+ throw error;
715
+ let parent = "";
716
+ for (const segment of normalized.slice(1).split("/").slice(0, -1)) {
717
+ parent += `/${segment}`;
718
+ const ancestor = (await this.entries(parent, "0", scopedOptions)).get(parent);
719
+ if (ancestor.type !== "directory")
720
+ fail("ENOTDIR", "stat", path);
721
+ }
722
+ throw error;
723
+ }
724
+ });
694
725
  }
695
726
  async lstat(path, options = {}) {
696
727
  return this.stat(path, options);
@@ -742,6 +773,7 @@ export class WebDavFileSystem {
742
773
  }.bind(this));
743
774
  }
744
775
  async prepareWrite(path, options) {
776
+ validateDirectoryWalkPath(path, "writeFile");
745
777
  const normalized = normalize(path);
746
778
  if (options.mode !== undefined)
747
779
  this.unsupported("writeFile mode", path);
@@ -751,37 +783,39 @@ export class WebDavFileSystem {
751
783
  fail("ECANCELED", "writeFile", path);
752
784
  if (normalized === "/")
753
785
  fail("EISDIR", "writeFile", path);
754
- if (requiresCollection(path)) {
755
- await this.stat(path, options);
756
- fail("EISDIR", "writeFile", path);
757
- }
758
- let parent = "";
759
- for (const segment of normalized.slice(1).split("/").slice(0, -1)) {
760
- parent += `/${segment}`;
761
- await this.stat(`${parent}/`, options);
762
- }
763
- const exclusive = options.flag === "wx" || options.flag === "ax";
764
- const existing = exclusive ? undefined : await this.maybeStat(normalized, options);
765
- if (existing?.type === "directory")
766
- fail("EISDIR", "writeFile", path);
767
- let prefix = new Uint8Array();
768
- const headers = { "Content-Type": "application/octet-stream" };
769
- if (exclusive || (options.flag === "a" && !existing))
770
- headers["If-None-Match"] = "*";
771
- if (options.flag === "a" && existing) {
772
- const snapshot = await this.request("GET", normalized, options, { headers: { "Accept-Encoding": "identity" } }, async (response, signal) => {
773
- if (response.status !== 200)
774
- this.httpError(response.status, "GET", path);
775
- const etag = strongEtag(response.headers.get("ETag"), path);
776
- if (response.headers.get("Content-Encoding") && response.headers.get("Content-Encoding").toLowerCase() !== "identity") {
777
- fail("ENOTSUP", "appendFile", path, "conditional append requires an identity representation");
778
- }
779
- return { etag, data: await this.bytes(response, this.maxResponseBytes, signal) };
780
- });
781
- prefix = snapshot.data;
782
- headers["If-Match"] = snapshot.etag;
783
- }
784
- return { normalized, headers, prefix };
786
+ return this.withWalkDeadline(options, async (scopedOptions) => {
787
+ if (requiresCollection(path)) {
788
+ await this.stat(path, scopedOptions);
789
+ fail("EISDIR", "writeFile", path);
790
+ }
791
+ let parent = "";
792
+ for (const segment of normalized.slice(1).split("/").slice(0, -1)) {
793
+ parent += `/${segment}`;
794
+ await this.stat(`${parent}/`, scopedOptions);
795
+ }
796
+ const exclusive = options.flag === "wx" || options.flag === "ax";
797
+ const existing = exclusive ? undefined : await this.maybeStat(normalized, scopedOptions);
798
+ if (existing?.type === "directory")
799
+ fail("EISDIR", "writeFile", path);
800
+ let prefix = new Uint8Array();
801
+ const headers = { "Content-Type": "application/octet-stream" };
802
+ if (exclusive || (options.flag === "a" && !existing))
803
+ headers["If-None-Match"] = "*";
804
+ if (options.flag === "a" && existing) {
805
+ const snapshot = await this.request("GET", normalized, scopedOptions, { headers: { "Accept-Encoding": "identity" } }, async (response, signal) => {
806
+ if (response.status !== 200)
807
+ this.httpError(response.status, "GET", path);
808
+ const etag = strongEtag(response.headers.get("ETag"), path);
809
+ if (response.headers.get("Content-Encoding") && response.headers.get("Content-Encoding").toLowerCase() !== "identity") {
810
+ fail("ENOTSUP", "appendFile", path, "conditional append requires an identity representation");
811
+ }
812
+ return { etag, data: await this.bytes(response, this.maxResponseBytes, signal) };
813
+ });
814
+ prefix = snapshot.data;
815
+ headers["If-Match"] = snapshot.etag;
816
+ }
817
+ return { normalized, headers, prefix };
818
+ });
785
819
  }
786
820
  async writeFile(path, data, options = {}) {
787
821
  if (!(data instanceof Uint8Array))
@@ -1156,7 +1190,7 @@ export class WebDavFileSystem {
1156
1190
  if (mode & 2)
1157
1191
  this.unsupported("access write/execute permission checks", path);
1158
1192
  if (mode & 1)
1159
- validateDirectoryAccessPath(path);
1193
+ validateDirectoryWalkPath(path);
1160
1194
  const stat = await this.stat(path, options);
1161
1195
  if (options.signal?.aborted)
1162
1196
  fail("ECANCELED", "access", path);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poe-platform/safe-fs",
3
- "version": "0.1.53",
3
+ "version": "0.1.55",
4
4
  "description": "Composable filesystem with a portable core and explicit Node adapters",
5
5
  "type": "module",
6
6
  "license": "MIT",