@poe-platform/safe-fs 0.1.145 → 0.1.147

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.
@@ -96,6 +96,7 @@ export interface ReadStreamOptions extends FsOptions {
96
96
  }
97
97
  export interface FileSystem {
98
98
  readonly capabilities: FileSystemCapabilities;
99
+ canonicalizeMissingTarget?(path: string, options?: FsOptions): string | undefined;
99
100
  capabilitiesFor?(path: string, options?: FsOptions): Promise<FileSystemCapabilities>;
100
101
  readFile(path: string, options?: ReadFileOptions): Promise<Uint8Array>;
101
102
  writeFile(path: string, data: Uint8Array, options?: WriteFileOptions): Promise<void>;
@@ -38,6 +38,7 @@ export declare class MemoryFileSystem implements FileSystem {
38
38
  private readonly root;
39
39
  constructor();
40
40
  compareEntry(path: string, peer: FileSystem, peerPath: string, options?: FsOptions): Promise<EntryComparison>;
41
+ canonicalizeMissingTarget(path: string, options?: FsOptions): string | undefined;
41
42
  private metadata;
42
43
  private directory;
43
44
  private fail;
@@ -3,6 +3,7 @@ import { assertCallbackAuthorityAllowed, compareEntries, registerEntryAuthority
3
3
  import { getOwnedS3Entry } from "../s3/registry.js";
4
4
  import { getOwnedWebDavEntry } from "../webdav/resource-id.js";
5
5
  import { admitDirectoryEntries, directoryEntryLimit } from "../directory-admission.js";
6
+ import { resolveMissingTarget } from "./missing-target.js";
6
7
  const typeModes = { file: 0o100000, directory: 0o040000, symlink: 0o120000 };
7
8
  const ownedStats = new WeakMap();
8
9
  const ownedStores = new WeakMap();
@@ -80,6 +81,22 @@ export class MemoryFileSystem {
80
81
  compareEntry(path, peer, peerPath, options = {}) {
81
82
  return compareEntries(this, path, peer, peerPath, options);
82
83
  }
84
+ canonicalizeMissingTarget(path, options = {}) {
85
+ options.signal?.throwIfAborted();
86
+ const owner = ownedStores.get(this);
87
+ if (!owner || Object.getPrototypeOf(this) !== MemoryFileSystem.prototype
88
+ || Object.getOwnPropertyDescriptor(this, "root")?.value !== owner.root)
89
+ return undefined;
90
+ for (const name of ["realpath", "lstat", "resolve", "permission", "validatePath", "fail", "snapshot"]) {
91
+ const descriptor = Object.getOwnPropertyDescriptor(this, name)
92
+ ?? Object.getOwnPropertyDescriptor(MemoryFileSystem.prototype, name);
93
+ if (!descriptor || !("value" in descriptor) || descriptor.value !== memoryImplementation[name]?.value)
94
+ return undefined;
95
+ }
96
+ if (path !== "")
97
+ this.validatePath(path, "realpath");
98
+ return resolveMissingTarget(owner.root, path || ".", options.signal);
99
+ }
83
100
  metadata(mode) {
84
101
  const now = Date.now();
85
102
  return {
@@ -0,0 +1,14 @@
1
+ type Node = {
2
+ type: "file";
3
+ mode: number;
4
+ } | {
5
+ type: "symlink";
6
+ mode: number;
7
+ target: string;
8
+ } | {
9
+ type: "directory";
10
+ mode: number;
11
+ entries: ReadonlyMap<string, Node>;
12
+ };
13
+ export declare function resolveMissingTarget(root: Node, path: string, signal?: AbortSignal): string;
14
+ export {};
@@ -0,0 +1,117 @@
1
+ import { FsError } from "../../contracts/errors.js";
2
+ function components(path, signal) {
3
+ const result = [];
4
+ let start = 0;
5
+ for (let offset = 0; offset <= path.length; offset++) {
6
+ signal?.throwIfAborted();
7
+ if (offset !== path.length && path.charCodeAt(offset) !== 47)
8
+ continue;
9
+ if (offset > start)
10
+ result.push({ name: path.slice(start, offset), start });
11
+ start = offset + 1;
12
+ }
13
+ return result;
14
+ }
15
+ export function resolveMissingTarget(root, path, signal) {
16
+ const fail = (code, failedPath = path) => {
17
+ throw new FsError(code, { syscall: "realpath", path: failedPath });
18
+ };
19
+ const origin = components(path, signal);
20
+ const initial = { node: root, name: "" };
21
+ let position = initial;
22
+ let links = 0;
23
+ let missing = origin.length;
24
+ let missingLink = false;
25
+ for (let index = 0; index <= origin.length; index++) {
26
+ const token = origin[index];
27
+ if (!token && path.length > 0 && !path.endsWith("/"))
28
+ break;
29
+ const before = position;
30
+ const frames = [{ names: [token ?? { name: ".", start: path.length }], next: 0 }];
31
+ let first = true;
32
+ let originLink = false;
33
+ let found = true;
34
+ while (frames.length > 0) {
35
+ signal?.throwIfAborted();
36
+ const frame = frames.at(-1);
37
+ if (!frame)
38
+ break;
39
+ const component = frame.names[frame.next++];
40
+ if (!component) {
41
+ frames.pop();
42
+ continue;
43
+ }
44
+ const current = position.node;
45
+ if (current.type !== "directory")
46
+ return fail("ENOTDIR");
47
+ if (((current.mode >> 6) & 1) !== 1)
48
+ return fail("EACCES");
49
+ const name = component.name;
50
+ if (name === ".") {
51
+ first = false;
52
+ continue;
53
+ }
54
+ if (name === "..") {
55
+ position = position.parent ?? initial;
56
+ first = false;
57
+ continue;
58
+ }
59
+ if (new TextEncoder().encode(name).byteLength > 255)
60
+ return fail("ENAMETOOLONG");
61
+ const node = current.entries.get(name);
62
+ if (!node) {
63
+ found = false;
64
+ break;
65
+ }
66
+ if (first)
67
+ originLink = node.type === "symlink";
68
+ first = false;
69
+ if (node.type === "symlink") {
70
+ if (++links > 40)
71
+ return fail("ELOOP");
72
+ const names = components(node.target, signal);
73
+ if (node.target.endsWith("/"))
74
+ names.push({ name: ".", start: node.target.length });
75
+ if (node.target.startsWith("/"))
76
+ position = initial;
77
+ frames.push({ names, next: 0 });
78
+ }
79
+ else
80
+ position = { node, name, parent: position };
81
+ }
82
+ if (!found) {
83
+ position = before;
84
+ missing = index;
85
+ missingLink = originLink;
86
+ break;
87
+ }
88
+ }
89
+ if (missingLink) {
90
+ let end = path.length;
91
+ for (let count = origin.length; count > missing; count--) {
92
+ signal?.throwIfAborted();
93
+ if (count === missing + 1 && path[end - 1] !== "/")
94
+ return fail("ENOENT", path.slice(0, end));
95
+ const last = origin[count - 1];
96
+ if (!last)
97
+ break;
98
+ const boundary = last.start - 1;
99
+ end = boundary <= 0 ? 1 : boundary === 1 && path.startsWith("/") ? 2 : boundary;
100
+ }
101
+ }
102
+ const names = [];
103
+ for (let current = position; current?.parent; current = current.parent) {
104
+ signal?.throwIfAborted();
105
+ names.push(current.name);
106
+ }
107
+ names.reverse();
108
+ for (let index = missing; index < origin.length; index++) {
109
+ signal?.throwIfAborted();
110
+ const name = origin[index]?.name;
111
+ if (name === "..")
112
+ names.pop();
113
+ else if (name && name !== ".")
114
+ names.push(name);
115
+ }
116
+ return `/${names.join("/")}`;
117
+ }
@@ -1,6 +1,8 @@
1
1
  import type { FileSystem } from "../../contracts/filesystem.js";
2
2
  export interface FileSystemQuotaOptions {
3
3
  readonly maxBytes: number;
4
+ readonly maxScanEntries?: number;
5
+ readonly maxScanDepth?: number;
4
6
  }
5
7
  export declare class FileSystemQuotaError extends Error {
6
8
  readonly maxBytes: number;
@@ -1,5 +1,6 @@
1
1
  import { FsError } from "../../contracts/errors.js";
2
2
  import { quotaCapabilities } from "../capabilities.js";
3
+ import { admitDirectoryEntries } from "../directory-admission.js";
3
4
  export class FileSystemQuotaError extends Error {
4
5
  maxBytes;
5
6
  constructor(maxBytes) {
@@ -8,18 +9,37 @@ export class FileSystemQuotaError extends Error {
8
9
  this.name = "FileSystemQuotaError";
9
10
  }
10
11
  }
11
- async function usedBytes(fs, options, change) {
12
+ async function usedBytes(fs, limits, options, change) {
12
13
  let total = 0;
13
14
  let possibleAliases = 0;
14
- const pending = ["/"];
15
+ let remaining = limits.maxScanEntries;
16
+ const pending = [{ path: "/", depth: 0 }];
15
17
  while (pending.length) {
16
18
  options?.signal?.throwIfAborted();
17
19
  const directory = pending.pop();
18
- for (const entry of await fs.readdir(directory, options)) {
20
+ if (!directory)
21
+ break;
22
+ const signal = options?.signal;
23
+ const entries = await fs.readdir(directory.path, { ...options, ...(signal ? { signal } : {}), maxEntries: remaining });
24
+ options?.signal?.throwIfAborted();
25
+ const count = entries.length;
26
+ admitDirectoryEntries(count, remaining, directory.path);
27
+ remaining -= count;
28
+ let processed = 0;
29
+ for (const entry of entries) {
19
30
  options?.signal?.throwIfAborted();
20
- const path = `${directory === "/" ? "" : directory}/${entry.name}`;
21
- if (entry.type === "directory")
22
- pending.push(path);
31
+ if (processed >= count) {
32
+ admitDirectoryEntries(1, remaining, directory.path);
33
+ remaining--;
34
+ }
35
+ processed++;
36
+ const path = `${directory.path === "/" ? "" : directory.path}/${entry.name}`;
37
+ if (entry.type === "directory") {
38
+ const depth = directory.depth + 1;
39
+ if (depth > limits.maxScanDepth)
40
+ throw new FsError("EFBIG", { syscall: "readdir", path, message: "quota scan depth limit exceeded" });
41
+ pending.push({ path, depth });
42
+ }
23
43
  else {
24
44
  const stat = await fs.lstat(path, options);
25
45
  total += stat.size;
@@ -64,6 +84,14 @@ async function existingBytes(fs, path, options) {
64
84
  export function withFileSystemQuota(fs, options) {
65
85
  if (!Number.isSafeInteger(options.maxBytes) || options.maxBytes < 0)
66
86
  throw new RangeError("maxBytes must be a nonnegative safe integer");
87
+ const scanLimits = {
88
+ maxScanEntries: options.maxScanEntries === undefined ? 4096 : options.maxScanEntries,
89
+ maxScanDepth: options.maxScanDepth === undefined ? 64 : options.maxScanDepth,
90
+ };
91
+ for (const [name, value] of Object.entries(scanLimits)) {
92
+ if (!Number.isSafeInteger(value) || value < 0)
93
+ throw new RangeError(`${name} must be a nonnegative safe integer`);
94
+ }
67
95
  let queue = Promise.resolve();
68
96
  const mutate = (operation) => {
69
97
  const result = queue.then(operation);
@@ -81,8 +109,8 @@ export function withFileSystemQuota(fs, options) {
81
109
  throw error;
82
110
  }
83
111
  const projected = current?.type === "file" && nextBytes > current.size
84
- ? await usedBytes(fs, fsOptions, { path, stat: current, delta: nextBytes - current.size })
85
- : await usedBytes(fs, fsOptions) - (current?.type === "directory" ? 0 : current?.size ?? 0) + nextBytes;
112
+ ? await usedBytes(fs, scanLimits, fsOptions, { path, stat: current, delta: nextBytes - current.size })
113
+ : await usedBytes(fs, scanLimits, fsOptions) - (current?.type === "directory" ? 0 : current?.size ?? 0) + nextBytes;
86
114
  fsOptions?.signal?.throwIfAborted();
87
115
  if (projected > options.maxBytes)
88
116
  throw new FileSystemQuotaError(options.maxBytes);
@@ -142,6 +170,8 @@ export function withFileSystemQuota(fs, options) {
142
170
  // capabilities and methods without violating invariants on own properties.
143
171
  return new Proxy(Object.create(fs), {
144
172
  get(_target, property) {
173
+ if (property === "canonicalizeMissingTarget")
174
+ return undefined;
145
175
  if (property === "capabilities")
146
176
  return quotaCapabilities(fs.capabilities);
147
177
  if (property === "capabilitiesFor")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poe-platform/safe-fs",
3
- "version": "0.1.145",
3
+ "version": "0.1.147",
4
4
  "description": "Composable filesystem with a portable core and explicit Node adapters",
5
5
  "type": "module",
6
6
  "license": "MIT",