@poe-platform/safe-fs 0.1.54 → 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.
@@ -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;
@@ -72,6 +73,7 @@ export declare class WebDavFileSystem implements FileSystem {
72
73
  private readonly maxXmlBytes;
73
74
  private readonly maxEntries;
74
75
  private readonly timeoutMs;
76
+ private readonly walkDeadlines;
75
77
  private readonly overwritePolicy;
76
78
  private readonly configuredComparison;
77
79
  private readonly atomicEmptyDirectory;
@@ -94,6 +96,7 @@ export declare class WebDavFileSystem implements FileSystem {
94
96
  private mutation;
95
97
  private unsupported;
96
98
  private maybeStat;
99
+ private withWalkDeadline;
97
100
  stat(path: string, options?: FsOptions): Promise<FileStat>;
98
101
  lstat(path: string, options?: FsOptions): Promise<FileStat>;
99
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
  }
@@ -157,6 +157,7 @@ export class WebDavFileSystem {
157
157
  maxXmlBytes;
158
158
  maxEntries;
159
159
  timeoutMs;
160
+ walkDeadlines = new WeakMap();
160
161
  overwritePolicy;
161
162
  configuredComparison;
162
163
  atomicEmptyDirectory;
@@ -320,7 +321,10 @@ export class WebDavFileSystem {
320
321
  async *requestStream(method, path, options, init, consume, collection = false, received) {
321
322
  if (options.signal?.aborted)
322
323
  fail("ECANCELED", method, path);
323
- 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;
324
328
  const timeout = deadline.signal;
325
329
  try {
326
330
  const headers = new Headers(this.headers);
@@ -409,7 +413,8 @@ export class WebDavFileSystem {
409
413
  }
410
414
  }
411
415
  finally {
412
- deadline.dispose();
416
+ if (!walk)
417
+ deadline.dispose();
413
418
  }
414
419
  }
415
420
  async bytes(response, limit, signal) {
@@ -679,27 +684,44 @@ export class WebDavFileSystem {
679
684
  throw error;
680
685
  }
681
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
+ }
682
701
  async stat(path, options = {}) {
702
+ validateDirectoryWalkPath(path, "stat");
683
703
  const normalized = normalize(path);
684
704
  const collection = requiresCollection(path);
685
- try {
686
- const stat = (await this.entries(normalized, "0", options, collection)).get(normalized);
687
- if (collection && stat.type !== "directory")
688
- fail("ENOTDIR", "stat", path);
689
- return stat;
690
- }
691
- catch (error) {
692
- if (!isFsError(error, "ENOENT"))
693
- throw error;
694
- let parent = "";
695
- for (const segment of normalized.slice(1).split("/").slice(0, -1)) {
696
- parent += `/${segment}`;
697
- const ancestor = (await this.entries(parent, "0", options)).get(parent);
698
- 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")
699
709
  fail("ENOTDIR", "stat", path);
710
+ return stat;
700
711
  }
701
- throw error;
702
- }
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
+ });
703
725
  }
704
726
  async lstat(path, options = {}) {
705
727
  return this.stat(path, options);
@@ -751,6 +773,7 @@ export class WebDavFileSystem {
751
773
  }.bind(this));
752
774
  }
753
775
  async prepareWrite(path, options) {
776
+ validateDirectoryWalkPath(path, "writeFile");
754
777
  const normalized = normalize(path);
755
778
  if (options.mode !== undefined)
756
779
  this.unsupported("writeFile mode", path);
@@ -760,37 +783,39 @@ export class WebDavFileSystem {
760
783
  fail("ECANCELED", "writeFile", path);
761
784
  if (normalized === "/")
762
785
  fail("EISDIR", "writeFile", path);
763
- if (requiresCollection(path)) {
764
- await this.stat(path, options);
765
- fail("EISDIR", "writeFile", path);
766
- }
767
- let parent = "";
768
- for (const segment of normalized.slice(1).split("/").slice(0, -1)) {
769
- parent += `/${segment}`;
770
- await this.stat(`${parent}/`, options);
771
- }
772
- const exclusive = options.flag === "wx" || options.flag === "ax";
773
- const existing = exclusive ? undefined : await this.maybeStat(normalized, options);
774
- if (existing?.type === "directory")
775
- fail("EISDIR", "writeFile", path);
776
- let prefix = new Uint8Array();
777
- const headers = { "Content-Type": "application/octet-stream" };
778
- if (exclusive || (options.flag === "a" && !existing))
779
- headers["If-None-Match"] = "*";
780
- if (options.flag === "a" && existing) {
781
- const snapshot = await this.request("GET", normalized, options, { headers: { "Accept-Encoding": "identity" } }, async (response, signal) => {
782
- if (response.status !== 200)
783
- this.httpError(response.status, "GET", path);
784
- const etag = strongEtag(response.headers.get("ETag"), path);
785
- if (response.headers.get("Content-Encoding") && response.headers.get("Content-Encoding").toLowerCase() !== "identity") {
786
- fail("ENOTSUP", "appendFile", path, "conditional append requires an identity representation");
787
- }
788
- return { etag, data: await this.bytes(response, this.maxResponseBytes, signal) };
789
- });
790
- prefix = snapshot.data;
791
- headers["If-Match"] = snapshot.etag;
792
- }
793
- 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
+ });
794
819
  }
795
820
  async writeFile(path, data, options = {}) {
796
821
  if (!(data instanceof Uint8Array))
@@ -1165,7 +1190,7 @@ export class WebDavFileSystem {
1165
1190
  if (mode & 2)
1166
1191
  this.unsupported("access write/execute permission checks", path);
1167
1192
  if (mode & 1)
1168
- validateDirectoryAccessPath(path);
1193
+ validateDirectoryWalkPath(path);
1169
1194
  const stat = await this.stat(path, options);
1170
1195
  if (options.signal?.aborted)
1171
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.54",
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",