@poe-platform/safe-fs 0.1.54 → 0.1.56

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,7 +96,9 @@ 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>;
101
+ private statWithAncestors;
98
102
  lstat(path: string, options?: FsOptions): Promise<FileStat>;
99
103
  readdir(path: string, options?: FsOptions): Promise<DirectoryEntry[]>;
100
104
  readFile(path: string, options?: ReadFileOptions): Promise<Uint8Array>;
@@ -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,47 @@ 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");
703
+ return this.statWithAncestors(path, options);
704
+ }
705
+ async statWithAncestors(path, options) {
683
706
  const normalized = normalize(path);
684
707
  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")
708
+ return this.withWalkDeadline(options, async (scopedOptions) => {
709
+ try {
710
+ const stat = (await this.entries(normalized, "0", scopedOptions, collection)).get(normalized);
711
+ if (collection && stat.type !== "directory")
699
712
  fail("ENOTDIR", "stat", path);
713
+ return stat;
700
714
  }
701
- throw error;
702
- }
715
+ catch (error) {
716
+ if (!isFsError(error, "ENOENT"))
717
+ throw error;
718
+ let parent = "";
719
+ for (const segment of normalized.slice(1).split("/").slice(0, -1)) {
720
+ parent += `/${segment}`;
721
+ const ancestor = (await this.entries(parent, "0", scopedOptions)).get(parent);
722
+ if (ancestor.type !== "directory")
723
+ fail("ENOTDIR", "stat", path);
724
+ }
725
+ throw error;
726
+ }
727
+ });
703
728
  }
704
729
  async lstat(path, options = {}) {
705
730
  return this.stat(path, options);
@@ -751,6 +776,7 @@ export class WebDavFileSystem {
751
776
  }.bind(this));
752
777
  }
753
778
  async prepareWrite(path, options) {
779
+ validateDirectoryWalkPath(path, "writeFile");
754
780
  const normalized = normalize(path);
755
781
  if (options.mode !== undefined)
756
782
  this.unsupported("writeFile mode", path);
@@ -760,37 +786,39 @@ export class WebDavFileSystem {
760
786
  fail("ECANCELED", "writeFile", path);
761
787
  if (normalized === "/")
762
788
  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 };
789
+ return this.withWalkDeadline(options, async (scopedOptions) => {
790
+ if (requiresCollection(path)) {
791
+ await this.stat(path, scopedOptions);
792
+ fail("EISDIR", "writeFile", path);
793
+ }
794
+ let parent = "";
795
+ for (const segment of normalized.slice(1).split("/").slice(0, -1)) {
796
+ parent += `/${segment}`;
797
+ await this.stat(`${parent}/`, scopedOptions);
798
+ }
799
+ const exclusive = options.flag === "wx" || options.flag === "ax";
800
+ const existing = exclusive ? undefined : await this.maybeStat(normalized, scopedOptions);
801
+ if (existing?.type === "directory")
802
+ fail("EISDIR", "writeFile", path);
803
+ let prefix = new Uint8Array();
804
+ const headers = { "Content-Type": "application/octet-stream" };
805
+ if (exclusive || (options.flag === "a" && !existing))
806
+ headers["If-None-Match"] = "*";
807
+ if (options.flag === "a" && existing) {
808
+ const snapshot = await this.request("GET", normalized, scopedOptions, { headers: { "Accept-Encoding": "identity" } }, async (response, signal) => {
809
+ if (response.status !== 200)
810
+ this.httpError(response.status, "GET", path);
811
+ const etag = strongEtag(response.headers.get("ETag"), path);
812
+ if (response.headers.get("Content-Encoding") && response.headers.get("Content-Encoding").toLowerCase() !== "identity") {
813
+ fail("ENOTSUP", "appendFile", path, "conditional append requires an identity representation");
814
+ }
815
+ return { etag, data: await this.bytes(response, this.maxResponseBytes, signal) };
816
+ });
817
+ prefix = snapshot.data;
818
+ headers["If-Match"] = snapshot.etag;
819
+ }
820
+ return { normalized, headers, prefix };
821
+ });
794
822
  }
795
823
  async writeFile(path, data, options = {}) {
796
824
  if (!(data instanceof Uint8Array))
@@ -1165,8 +1193,9 @@ export class WebDavFileSystem {
1165
1193
  if (mode & 2)
1166
1194
  this.unsupported("access write/execute permission checks", path);
1167
1195
  if (mode & 1)
1168
- validateDirectoryAccessPath(path);
1169
- const stat = await this.stat(path, options);
1196
+ validateDirectoryWalkPath(path);
1197
+ // Non-execute probes do not inherit stat's raw-path limits.
1198
+ const stat = await (mode & 1 ? this.stat(path, options) : this.statWithAncestors(path, options));
1170
1199
  if (options.signal?.aborted)
1171
1200
  fail("ECANCELED", "access", path);
1172
1201
  if ((mode & 1) && stat.type !== "directory")
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.56",
4
4
  "description": "Composable filesystem with a portable core and explicit Node adapters",
5
5
  "type": "module",
6
6
  "license": "MIT",