@poe-platform/safe-fs 0.1.145 → 0.1.146
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.
- package/dist/safe-fs/contracts/filesystem.d.ts +1 -0
- package/dist/safe-fs/fs/memory/index.d.ts +1 -0
- package/dist/safe-fs/fs/memory/index.js +17 -0
- package/dist/safe-fs/fs/memory/missing-target.d.ts +14 -0
- package/dist/safe-fs/fs/memory/missing-target.js +117 -0
- package/dist/safe-fs/fs/quota/index.d.ts +2 -0
- package/dist/safe-fs/fs/quota/index.js +38 -8
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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")
|