@dbx-tools/shared-fs 0.6.10
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/README.md +56 -0
- package/index.ts +14 -0
- package/lib/index.d.ts +10 -0
- package/lib/index.js +10 -0
- package/lib/src/base-fs.d.ts +268 -0
- package/lib/src/base-fs.js +622 -0
- package/lib/src/fs.d.ts +110 -0
- package/lib/src/fs.js +2 -0
- package/lib/src/memory-fs.d.ts +46 -0
- package/lib/src/memory-fs.js +131 -0
- package/lib/src/posix-path.d.ts +80 -0
- package/lib/src/posix-path.js +208 -0
- package/lib/tsconfig.tsbuildinfo +1 -0
- package/package.json +53 -0
- package/src/base-fs.ts +882 -0
- package/src/fs.ts +142 -0
- package/src/memory-fs.ts +174 -0
- package/src/posix-path.ts +219 -0
package/src/base-fs.ts
ADDED
|
@@ -0,0 +1,882 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Abstract {@link FileSystem} base: lifecycle, rooted POSIX paths, encoding, and
|
|
3
|
+
* portable fallbacks so a concrete backend only implements low-level I/O.
|
|
4
|
+
*
|
|
5
|
+
* A new adapter typically overrides:
|
|
6
|
+
* - {@link onInit} / {@link onClose} (optional)
|
|
7
|
+
* - {@link createRootDirectory} when {@link BaseFileSystemOptions.createRoot} is set
|
|
8
|
+
* - {@link toBackendPath} when the backend needs non-POSIX separators
|
|
9
|
+
* - {@link preparePath} for post-resolve checks (e.g. symlink containment)
|
|
10
|
+
* - the `*At` primitives and {@link isNotFoundError}
|
|
11
|
+
* - optional `try*` hooks for native append / copy / move
|
|
12
|
+
*
|
|
13
|
+
* @module
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { error, functionModule, hash, object, type OneOrMany } from "@dbx-tools/shared-core";
|
|
17
|
+
import type {
|
|
18
|
+
CopyOptions,
|
|
19
|
+
FileContent,
|
|
20
|
+
FileEntry,
|
|
21
|
+
FileStat,
|
|
22
|
+
FileSystem,
|
|
23
|
+
ListOptions,
|
|
24
|
+
MakeDirectoryOptions,
|
|
25
|
+
ReadFileOptions,
|
|
26
|
+
RemoveOptions,
|
|
27
|
+
WriteFileOptions,
|
|
28
|
+
} from "./fs.ts";
|
|
29
|
+
import * as posixPath from "./posix-path.ts";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* One root path segment. Strings are split on `/` and sanitized; numbers /
|
|
33
|
+
* booleans / bigints stringify then sanitize; objects and arrays are FNV-hashed
|
|
34
|
+
* as a single segment.
|
|
35
|
+
*/
|
|
36
|
+
export type FileSystemRootSegment = string | number | boolean | bigint | object;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A single {@link FileSystemRootSegment} or a non-empty list of them. Nested
|
|
40
|
+
* arrays/objects inside the list are one hashed segment each (not flattened).
|
|
41
|
+
*/
|
|
42
|
+
export type FileSystemRootInput = FileSystemRootSegment | OneOrMany<FileSystemRootSegment>;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Characters no backend accepts inside a single path component: a separator
|
|
46
|
+
* (which would silently deepen the path) or a NUL / control character.
|
|
47
|
+
*
|
|
48
|
+
* This is a DENY-list on purpose. Spaces, `@`, `&`, `#`, parentheses and
|
|
49
|
+
* non-ASCII are all legal directory names on POSIX and in a Databricks
|
|
50
|
+
* workspace, and replacing one with a hash points the filesystem at a
|
|
51
|
+
* directory that does not exist - a failure that is silent and very hard to
|
|
52
|
+
* trace back. Only reject what genuinely cannot be a component.
|
|
53
|
+
*/
|
|
54
|
+
const UNSAFE_PATH_SEGMENT = /[\\/\u0000-\u001F\u007F]/;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Turn {@link root} into a POSIX filesystem root:
|
|
58
|
+
*
|
|
59
|
+
* 1. Expand one-or-many input segments
|
|
60
|
+
* 2. Strings split on `/` (and `\`); objects/arrays FNV-hash as one piece
|
|
61
|
+
* 3. Each resulting component that cannot be a path component - see
|
|
62
|
+
* {@link UNSAFE_PATH_SEGMENT} - is replaced with {@link hash.fnvHash}
|
|
63
|
+
* 4. Join with `/` and run {@link posixPath.normalizeRoot}
|
|
64
|
+
*
|
|
65
|
+
* Defaults to `/`. A leading `/` on the first string segment is preserved.
|
|
66
|
+
*
|
|
67
|
+
* @example
|
|
68
|
+
* normalizeFileSystemRoot("/cool/wow"); // "/cool/wow"
|
|
69
|
+
* normalizeFileSystemRoot("/Users/me@corp.com/My Notes"); // unchanged
|
|
70
|
+
* normalizeFileSystemRoot(["/path", { user: 1 }, true]); // "/path/<hash>/true"
|
|
71
|
+
*/
|
|
72
|
+
export function normalizeFileSystemRoot(root?: FileSystemRootInput): string {
|
|
73
|
+
if (root === undefined) return "/";
|
|
74
|
+
const segments = object.toOneOrMany(root);
|
|
75
|
+
const absolute = isAbsoluteRootStart(segments[0]);
|
|
76
|
+
const parts: string[] = [];
|
|
77
|
+
for (const segment of segments) {
|
|
78
|
+
if (segment === null || segment === undefined) {
|
|
79
|
+
throw new TypeError("Filesystem root segments must be non-null");
|
|
80
|
+
}
|
|
81
|
+
appendRootParts(parts, segment);
|
|
82
|
+
}
|
|
83
|
+
if (parts.length === 0) return "/";
|
|
84
|
+
const joined = parts.join("/");
|
|
85
|
+
return posixPath.normalizeRoot(absolute ? `/${joined}` : joined);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isAbsoluteRootStart(segment: FileSystemRootSegment): boolean {
|
|
89
|
+
return typeof segment === "string" && posixPath.isAbsolute(segment.trim());
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function appendRootParts(parts: string[], segment: FileSystemRootSegment): void {
|
|
93
|
+
switch (typeof segment) {
|
|
94
|
+
case "string":
|
|
95
|
+
for (const piece of splitPathPieces(segment)) {
|
|
96
|
+
parts.push(sanitizePathSegment(piece));
|
|
97
|
+
}
|
|
98
|
+
return;
|
|
99
|
+
case "number":
|
|
100
|
+
case "boolean":
|
|
101
|
+
case "bigint":
|
|
102
|
+
parts.push(sanitizePathSegment(String(segment)));
|
|
103
|
+
return;
|
|
104
|
+
default:
|
|
105
|
+
parts.push(hash.fnvHash(segment));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Split on `/` (after `\` → `/`); drop empty pieces from
|
|
111
|
+
* leading/trailing/double slashes and no-op `.` pieces.
|
|
112
|
+
*/
|
|
113
|
+
function splitPathPieces(input: string): string[] {
|
|
114
|
+
return posixPath
|
|
115
|
+
.toPosix(input.trim())
|
|
116
|
+
.split("/")
|
|
117
|
+
.filter((piece) => piece.length > 0 && piece !== ".");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Keep a usable path component verbatim; FNV-hash one that cannot be used.
|
|
122
|
+
*
|
|
123
|
+
* `..` is hashed rather than dropped so a root can never traverse above
|
|
124
|
+
* itself while the offending segment stays visible in the resolved root.
|
|
125
|
+
*/
|
|
126
|
+
function sanitizePathSegment(segment: string): string {
|
|
127
|
+
if (segment === ".." || UNSAFE_PATH_SEGMENT.test(segment)) {
|
|
128
|
+
return hash.fnvHash(segment);
|
|
129
|
+
}
|
|
130
|
+
return segment;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export type FileSystemErrorCode =
|
|
134
|
+
| "NOT_FOUND"
|
|
135
|
+
| "ALREADY_EXISTS"
|
|
136
|
+
| "NOT_DIRECTORY"
|
|
137
|
+
| "IS_DIRECTORY"
|
|
138
|
+
| "DIRECTORY_NOT_EMPTY"
|
|
139
|
+
| "PERMISSION_DENIED"
|
|
140
|
+
| "READ_ONLY"
|
|
141
|
+
| "INVALID_PATH"
|
|
142
|
+
| "NOT_SUPPORTED"
|
|
143
|
+
| "IO_ERROR";
|
|
144
|
+
|
|
145
|
+
export class FileSystemError extends Error {
|
|
146
|
+
readonly name = "FileSystemError";
|
|
147
|
+
|
|
148
|
+
constructor(
|
|
149
|
+
readonly code: FileSystemErrorCode,
|
|
150
|
+
message: string,
|
|
151
|
+
readonly path?: string,
|
|
152
|
+
options?: { cause?: unknown },
|
|
153
|
+
) {
|
|
154
|
+
super(
|
|
155
|
+
message,
|
|
156
|
+
options?.cause !== undefined ? { cause: error.toError(options.cause) } : undefined,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Infer a {@link FileSystemErrorCode} from HTTP status / message tokens on an
|
|
163
|
+
* unknown thrown value (via {@link error.errorContext}).
|
|
164
|
+
*
|
|
165
|
+
* Covers common SDK / REST wording so adapters do not reimplement the same
|
|
166
|
+
* "not found" / "already exists" checks. Returns undefined when nothing matches.
|
|
167
|
+
*/
|
|
168
|
+
export function inferFileSystemErrorCode(err: unknown): FileSystemErrorCode | undefined {
|
|
169
|
+
const ctx = error.errorContext(err);
|
|
170
|
+
if (ctx.hasStatusCode(404) || ctx.hasMessage("not", "found") || ctx.hasMessage("not", "exist")) {
|
|
171
|
+
return "NOT_FOUND";
|
|
172
|
+
}
|
|
173
|
+
if (ctx.hasStatusCode(409) || ctx.hasMessage("already", "exists")) {
|
|
174
|
+
return "ALREADY_EXISTS";
|
|
175
|
+
}
|
|
176
|
+
// Before "not"+"directory": "directory not empty" contains both of those tokens.
|
|
177
|
+
if (ctx.hasMessage("not", "empty")) {
|
|
178
|
+
return "DIRECTORY_NOT_EMPTY";
|
|
179
|
+
}
|
|
180
|
+
if (ctx.hasMessage("not", "directory")) {
|
|
181
|
+
return "NOT_DIRECTORY";
|
|
182
|
+
}
|
|
183
|
+
if (ctx.hasMessage("is", "directory") || ctx.hasMessage("not", "file")) {
|
|
184
|
+
return "IS_DIRECTORY";
|
|
185
|
+
}
|
|
186
|
+
if (
|
|
187
|
+
ctx.hasStatusCode(401, 403) ||
|
|
188
|
+
ctx.hasMessage("permission", "denied") ||
|
|
189
|
+
ctx.hasMessage("access", "denied")
|
|
190
|
+
) {
|
|
191
|
+
return "PERMISSION_DENIED";
|
|
192
|
+
}
|
|
193
|
+
if (ctx.hasMessage("read", "only")) {
|
|
194
|
+
return "READ_ONLY";
|
|
195
|
+
}
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Map an unknown backend failure into a {@link FileSystemError}.
|
|
201
|
+
*
|
|
202
|
+
* Prefer a backend-specific {@link codeOf} classifier (errno, SDK code). When
|
|
203
|
+
* it returns undefined, falls back to {@link inferFileSystemErrorCode}. Message
|
|
204
|
+
* and cause always go through `@dbx-tools/shared-core` {@link error} helpers.
|
|
205
|
+
*/
|
|
206
|
+
export function mapFileSystemError(
|
|
207
|
+
err: unknown,
|
|
208
|
+
filePath: string,
|
|
209
|
+
codeOf?: (err: unknown) => FileSystemErrorCode | undefined,
|
|
210
|
+
): FileSystemError {
|
|
211
|
+
if (err instanceof FileSystemError) return err;
|
|
212
|
+
const message = error.errorMessage(err);
|
|
213
|
+
return new FileSystemError(
|
|
214
|
+
codeOf?.(err) ?? inferFileSystemErrorCode(err) ?? "IO_ERROR",
|
|
215
|
+
message || `Filesystem operation failed: ${filePath}`,
|
|
216
|
+
filePath,
|
|
217
|
+
{ cause: error.toError(err) },
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export interface BaseFileSystemOptions<TBackend extends string = string> {
|
|
222
|
+
id: string;
|
|
223
|
+
backend: TBackend;
|
|
224
|
+
/**
|
|
225
|
+
* Filesystem root. One segment or a list of segments ({@link FileSystemRootInput}):
|
|
226
|
+
* primitives stringify, objects/arrays are FNV-hashed, then joined with `/`
|
|
227
|
+
* and normalized via {@link posixPath.normalizeRoot}. Defaults to `/`.
|
|
228
|
+
*/
|
|
229
|
+
root?: FileSystemRootInput;
|
|
230
|
+
readOnly?: boolean;
|
|
231
|
+
/**
|
|
232
|
+
* Ensure {@link root} exists during init by calling {@link createRootDirectory}.
|
|
233
|
+
* Defaults to false (remote roots usually already exist). Local disk adapters
|
|
234
|
+
* typically pass true and override {@link createRootDirectory}.
|
|
235
|
+
*/
|
|
236
|
+
createRoot?: boolean;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Base implementation for local, remote, and virtual filesystems.
|
|
241
|
+
*
|
|
242
|
+
* Subclasses implement the low-level `*At` primitives. This class provides:
|
|
243
|
+
*
|
|
244
|
+
* - Memoized {@link _init} (so an explicit {@link init} call is optional)
|
|
245
|
+
* - Optional root creation via {@link createRootDirectory}
|
|
246
|
+
* - POSIX-only rooted path normalization and traversal protection
|
|
247
|
+
* - {@link toBackendPath} for host/separator conversion at the boundary
|
|
248
|
+
* - Text encoding and decoding
|
|
249
|
+
* - {@link exists}
|
|
250
|
+
* - Parent-directory creation on write / append / copy / move
|
|
251
|
+
* - Append / copy / move fallbacks (override `try*` for native ops)
|
|
252
|
+
* - Recursive mkdir, readdir, and rmdir
|
|
253
|
+
* - Extension filtering for {@link readdir}
|
|
254
|
+
*
|
|
255
|
+
* Namespace paths always use `/`. Host adapters convert with
|
|
256
|
+
* {@link posixPath.toPosix} / {@link posixPath.toHost} in {@link toBackendPath}.
|
|
257
|
+
*/
|
|
258
|
+
export abstract class BaseFileSystem<
|
|
259
|
+
TBackend extends string = string,
|
|
260
|
+
> implements FileSystem<TBackend> {
|
|
261
|
+
readonly id: string;
|
|
262
|
+
readonly backend: TBackend;
|
|
263
|
+
/** POSIX-normalized root (see {@link posixPath.normalizeRoot}). */
|
|
264
|
+
readonly root: string;
|
|
265
|
+
readonly readOnly: boolean;
|
|
266
|
+
protected readonly createRoot: boolean;
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Memoized initialization. Every operation that needs a ready backend awaits
|
|
270
|
+
* this, so callers (e.g. Mastra) may call {@link init} every time or never;
|
|
271
|
+
* both are fine.
|
|
272
|
+
*/
|
|
273
|
+
protected _init: () => Promise<void>;
|
|
274
|
+
|
|
275
|
+
private initStarted = false;
|
|
276
|
+
|
|
277
|
+
protected constructor(options: BaseFileSystemOptions<TBackend>) {
|
|
278
|
+
this.id = options.id;
|
|
279
|
+
this.backend = options.backend;
|
|
280
|
+
this.root = normalizeFileSystemRoot(options.root);
|
|
281
|
+
this.readOnly = options.readOnly ?? false;
|
|
282
|
+
this.createRoot = options.createRoot ?? false;
|
|
283
|
+
this._init = this.createInit();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/* ------------------------------------------------------------------ */
|
|
287
|
+
/* Lifecycle */
|
|
288
|
+
/* ------------------------------------------------------------------ */
|
|
289
|
+
|
|
290
|
+
private createInit(): () => Promise<void> {
|
|
291
|
+
return functionModule.memoize(async () => {
|
|
292
|
+
this.initStarted = true;
|
|
293
|
+
if (this.createRoot) {
|
|
294
|
+
await this.guard(this.root, () => this.createRootDirectory());
|
|
295
|
+
}
|
|
296
|
+
await this.onInit();
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async init(): Promise<void> {
|
|
301
|
+
await this._init();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async close(): Promise<void> {
|
|
305
|
+
if (!this.initStarted) return;
|
|
306
|
+
try {
|
|
307
|
+
await this._init();
|
|
308
|
+
} catch {
|
|
309
|
+
this.initStarted = false;
|
|
310
|
+
this._init = this.createInit();
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
await this.onClose();
|
|
314
|
+
this.initStarted = false;
|
|
315
|
+
this._init = this.createInit();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Ensure {@link root} exists when {@link createRoot} is true.
|
|
320
|
+
*
|
|
321
|
+
* Default is a no-op. Local adapters typically `mkdir -p`; remote adapters
|
|
322
|
+
* leave the default when the root is provisioned out of band.
|
|
323
|
+
*/
|
|
324
|
+
protected async createRootDirectory(): Promise<void> {}
|
|
325
|
+
|
|
326
|
+
/** Override when the backend requires connection or validation work. */
|
|
327
|
+
protected async onInit(): Promise<void> {}
|
|
328
|
+
|
|
329
|
+
/** Override when the backend owns connections or other resources. */
|
|
330
|
+
protected async onClose(): Promise<void> {}
|
|
331
|
+
|
|
332
|
+
protected assertWritable(operation: string): void {
|
|
333
|
+
if (this.readOnly) {
|
|
334
|
+
throw new FileSystemError("READ_ONLY", `Cannot ${operation}: filesystem is read-only`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/* ------------------------------------------------------------------ */
|
|
339
|
+
/* Paths (POSIX only) */
|
|
340
|
+
/* ------------------------------------------------------------------ */
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Normalize an input path into an absolute POSIX path inside the virtual
|
|
344
|
+
* filesystem namespace (`/a/b`). Backslashes are converted; `..` escaping
|
|
345
|
+
* the root throws {@link FileSystemError} `PERMISSION_DENIED`.
|
|
346
|
+
*/
|
|
347
|
+
protected normalizePath(inputPath: string): string {
|
|
348
|
+
if (inputPath.includes("\0")) {
|
|
349
|
+
throw new FileSystemError("INVALID_PATH", "Paths cannot contain null characters", inputPath);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const result = posixPath.normalize(inputPath);
|
|
353
|
+
if (!result.ok) {
|
|
354
|
+
throw new FileSystemError("PERMISSION_DENIED", "Path escapes the filesystem root", inputPath);
|
|
355
|
+
}
|
|
356
|
+
return result.path;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Convert a POSIX backend path (under {@link root}) into the form the
|
|
361
|
+
* underlying API expects.
|
|
362
|
+
*
|
|
363
|
+
* Default is identity. Local disk overrides with {@link posixPath.toHost}.
|
|
364
|
+
* Databricks / object-store adapters usually leave the default.
|
|
365
|
+
*/
|
|
366
|
+
protected toBackendPath(posixBackendPath: string): string {
|
|
367
|
+
return posixBackendPath;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Convert a normalized namespace path (`/a/b`) into a backend path.
|
|
372
|
+
*
|
|
373
|
+
* Joins {@link root} with the namespace using POSIX `/`, then applies
|
|
374
|
+
* {@link toBackendPath}. Override {@link toBackendPath} instead of this
|
|
375
|
+
* method unless the join itself must change.
|
|
376
|
+
*/
|
|
377
|
+
protected resolveBackendPath(namespacePath: string): string {
|
|
378
|
+
const posix =
|
|
379
|
+
namespacePath === "/" ? this.root : posixPath.join(this.root, namespacePath.slice(1));
|
|
380
|
+
return this.toBackendPath(posix);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
resolvePath(inputPath: string): string {
|
|
384
|
+
return this.resolveBackendPath(this.normalizePath(inputPath));
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Resolve {@link inputPath}, ensure init, and run {@link preparePath}.
|
|
389
|
+
*/
|
|
390
|
+
protected async resolveFor(
|
|
391
|
+
inputPath: string,
|
|
392
|
+
options?: { allowMissing?: boolean },
|
|
393
|
+
): Promise<string> {
|
|
394
|
+
await this._init();
|
|
395
|
+
return this.resolveNamespaceFor(this.normalizePath(inputPath), options);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* {@link resolveFor} for a path that is ALREADY a normalized namespace path
|
|
400
|
+
* (`/a/b`). The single spelling for "namespace path to prepared backend
|
|
401
|
+
* path", so no call site has to re-derive the chain by hand.
|
|
402
|
+
*/
|
|
403
|
+
private resolveNamespaceFor(
|
|
404
|
+
namespacePath: string,
|
|
405
|
+
options?: { allowMissing?: boolean },
|
|
406
|
+
): Promise<string> {
|
|
407
|
+
return this.preparePath(this.resolveBackendPath(namespacePath), options);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Hook after lexical resolution. Override for realpath containment or
|
|
412
|
+
* similar backend-specific checks. Default is a no-op.
|
|
413
|
+
*/
|
|
414
|
+
protected async preparePath(
|
|
415
|
+
resolvedPath: string,
|
|
416
|
+
_options?: { allowMissing?: boolean },
|
|
417
|
+
): Promise<string> {
|
|
418
|
+
return resolvedPath;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
protected joinNamespace(parent: string, child: string): string {
|
|
422
|
+
return this.normalizePath(posixPath.join(parent, child));
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Namespace path without a leading slash (`.` for the root). */
|
|
426
|
+
protected toRelativePath(namespacePath: string): string {
|
|
427
|
+
return namespacePath === "/" ? "." : namespacePath.slice(1);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
protected toBytes(content: FileContent): Uint8Array {
|
|
431
|
+
return typeof content === "string" ? new TextEncoder().encode(content) : content;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** Create parent directories for {@link inputPath} when it is nested. */
|
|
435
|
+
protected async ensureParentDirectory(inputPath: string): Promise<void> {
|
|
436
|
+
const namespacePath = this.normalizePath(inputPath);
|
|
437
|
+
const parent = posixPath.dirname(namespacePath);
|
|
438
|
+
if (parent === "/" || parent === namespacePath) return;
|
|
439
|
+
await this.mkdir(parent, { recursive: true });
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/* ------------------------------------------------------------------ */
|
|
443
|
+
/* Backend primitives */
|
|
444
|
+
/* ------------------------------------------------------------------ */
|
|
445
|
+
|
|
446
|
+
protected abstract readBytesAt(resolvedPath: string): Promise<Uint8Array>;
|
|
447
|
+
|
|
448
|
+
protected abstract writeBytesAt(
|
|
449
|
+
resolvedPath: string,
|
|
450
|
+
content: Uint8Array,
|
|
451
|
+
options: Required<WriteFileOptions>,
|
|
452
|
+
): Promise<void>;
|
|
453
|
+
|
|
454
|
+
protected abstract deleteFileAt(resolvedPath: string): Promise<void>;
|
|
455
|
+
|
|
456
|
+
protected abstract createDirectoryAt(resolvedPath: string): Promise<void>;
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Remove an empty directory.
|
|
460
|
+
*
|
|
461
|
+
* Recursive deletion is implemented by {@link BaseFileSystem}.
|
|
462
|
+
*/
|
|
463
|
+
protected abstract removeDirectoryAt(resolvedPath: string): Promise<void>;
|
|
464
|
+
|
|
465
|
+
/** Return only the direct children of a directory (`name` is the basename). */
|
|
466
|
+
protected abstract listDirectoryAt(resolvedPath: string): Promise<FileEntry[]>;
|
|
467
|
+
|
|
468
|
+
protected abstract statAt(resolvedPath: string): Promise<Omit<FileStat, "path">>;
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Recognize the backend's not-found error.
|
|
472
|
+
*
|
|
473
|
+
* Default accepts {@link FileSystemError} `NOT_FOUND` plus common SDK / HTTP
|
|
474
|
+
* "not found" shapes via {@link inferFileSystemErrorCode}. Override for
|
|
475
|
+
* backend-specific codes (e.g. Node `ENOENT`) that do not carry a message.
|
|
476
|
+
*/
|
|
477
|
+
protected isNotFoundError(err: unknown): boolean {
|
|
478
|
+
if (err instanceof FileSystemError) return err.code === "NOT_FOUND";
|
|
479
|
+
return inferFileSystemErrorCode(err) === "NOT_FOUND";
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Normalize a backend failure into a {@link FileSystemError}.
|
|
484
|
+
*
|
|
485
|
+
* Every `*At` / `try*` primitive is invoked through {@link guard}, so an
|
|
486
|
+
* adapter never writes its own try/catch and cannot forget to normalize.
|
|
487
|
+
* Override only to classify backend-specific codes (e.g. Node errno).
|
|
488
|
+
*/
|
|
489
|
+
protected mapError(err: unknown, filePath: string): FileSystemError {
|
|
490
|
+
return mapFileSystemError(err, filePath);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** Run a backend primitive, routing any failure through {@link mapError}. */
|
|
494
|
+
private async guard<T>(resolvedPath: string, operation: () => Promise<T>): Promise<T> {
|
|
495
|
+
try {
|
|
496
|
+
return await operation();
|
|
497
|
+
} catch (err) {
|
|
498
|
+
throw this.mapError(err, resolvedPath);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/* ------------------------------------------------------------------ */
|
|
503
|
+
/* Optional native-operation hooks */
|
|
504
|
+
/* ------------------------------------------------------------------ */
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Override when the backend supports native append.
|
|
508
|
+
*
|
|
509
|
+
* Parent directories are already created by {@link appendFile}. Return true
|
|
510
|
+
* when the operation was performed. The default causes {@link BaseFileSystem}
|
|
511
|
+
* to use read-concatenate-write.
|
|
512
|
+
*/
|
|
513
|
+
protected async tryAppendFileAt(_resolvedPath: string, _content: Uint8Array): Promise<boolean> {
|
|
514
|
+
return false;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Override when the backend supports native server-side copying.
|
|
519
|
+
*
|
|
520
|
+
* Parent directories of the destination are already created by {@link copyFile}.
|
|
521
|
+
*/
|
|
522
|
+
protected async tryCopyFileAt(
|
|
523
|
+
_sourcePath: string,
|
|
524
|
+
_destinationPath: string,
|
|
525
|
+
_options: Required<CopyOptions>,
|
|
526
|
+
): Promise<boolean> {
|
|
527
|
+
return false;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Override for native rename or move support.
|
|
532
|
+
*
|
|
533
|
+
* Parent directories of the destination are already created by {@link moveFile}.
|
|
534
|
+
*/
|
|
535
|
+
protected async tryMoveFileAt(
|
|
536
|
+
_sourcePath: string,
|
|
537
|
+
_destinationPath: string,
|
|
538
|
+
_options: Required<CopyOptions>,
|
|
539
|
+
): Promise<boolean> {
|
|
540
|
+
return false;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/* ------------------------------------------------------------------ */
|
|
544
|
+
/* File operations */
|
|
545
|
+
/* ------------------------------------------------------------------ */
|
|
546
|
+
|
|
547
|
+
async readFile(inputPath: string): Promise<Uint8Array>;
|
|
548
|
+
async readFile(
|
|
549
|
+
inputPath: string,
|
|
550
|
+
options: ReadFileOptions & { encoding: string },
|
|
551
|
+
): Promise<string>;
|
|
552
|
+
async readFile(inputPath: string, options?: ReadFileOptions): Promise<string | Uint8Array> {
|
|
553
|
+
const resolvedPath = await this.resolveFor(inputPath);
|
|
554
|
+
const content = await this.guard(resolvedPath, () => this.readBytesAt(resolvedPath));
|
|
555
|
+
if (options?.encoding) {
|
|
556
|
+
return new TextDecoder(options.encoding).decode(content);
|
|
557
|
+
}
|
|
558
|
+
return content;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
async writeFile(
|
|
562
|
+
inputPath: string,
|
|
563
|
+
content: FileContent,
|
|
564
|
+
options: WriteFileOptions = {},
|
|
565
|
+
): Promise<void> {
|
|
566
|
+
await this._init();
|
|
567
|
+
this.assertWritable("write file");
|
|
568
|
+
|
|
569
|
+
const overwrite = await this.resolveOverwrite(inputPath, options, "File");
|
|
570
|
+
await this.ensureParentDirectory(inputPath);
|
|
571
|
+
const resolvedPath = await this.resolveFor(inputPath, { allowMissing: true });
|
|
572
|
+
await this.guard(resolvedPath, () =>
|
|
573
|
+
this.writeBytesAt(resolvedPath, this.toBytes(content), { overwrite }),
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
async appendFile(inputPath: string, content: FileContent): Promise<void> {
|
|
578
|
+
await this._init();
|
|
579
|
+
this.assertWritable("append file");
|
|
580
|
+
|
|
581
|
+
const bytes = this.toBytes(content);
|
|
582
|
+
await this.ensureParentDirectory(inputPath);
|
|
583
|
+
const resolvedPath = await this.resolveFor(inputPath, { allowMissing: true });
|
|
584
|
+
|
|
585
|
+
if (await this.guard(resolvedPath, () => this.tryAppendFileAt(resolvedPath, bytes))) {
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const existing = (await this.exists(inputPath))
|
|
590
|
+
? await this.readFile(inputPath)
|
|
591
|
+
: new Uint8Array();
|
|
592
|
+
const combined = new Uint8Array(existing.byteLength + bytes.byteLength);
|
|
593
|
+
combined.set(existing);
|
|
594
|
+
combined.set(bytes, existing.byteLength);
|
|
595
|
+
await this.writeFile(inputPath, combined, { overwrite: true });
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
async deleteFile(inputPath: string, options: RemoveOptions = {}): Promise<void> {
|
|
599
|
+
await this._init();
|
|
600
|
+
this.assertWritable("delete file");
|
|
601
|
+
|
|
602
|
+
await this.ignoringMissing(options, async () => {
|
|
603
|
+
const entry = await this.stat(inputPath);
|
|
604
|
+
if (entry.type === "directory") {
|
|
605
|
+
throw new FileSystemError("IS_DIRECTORY", `Path is a directory: ${inputPath}`, inputPath);
|
|
606
|
+
}
|
|
607
|
+
const resolvedPath = await this.resolveFor(inputPath);
|
|
608
|
+
await this.guard(resolvedPath, () => this.deleteFileAt(resolvedPath));
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
async copyFile(
|
|
613
|
+
sourcePath: string,
|
|
614
|
+
destinationPath: string,
|
|
615
|
+
options: CopyOptions = {},
|
|
616
|
+
): Promise<void> {
|
|
617
|
+
const { source, destination, resolved } = await this.prepareTransfer(
|
|
618
|
+
"copy file",
|
|
619
|
+
sourcePath,
|
|
620
|
+
destinationPath,
|
|
621
|
+
options,
|
|
622
|
+
);
|
|
623
|
+
|
|
624
|
+
if (await this.guard(destination, () => this.tryCopyFileAt(source, destination, resolved))) {
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
await this.writeFile(destinationPath, await this.readFile(sourcePath), resolved);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
async moveFile(
|
|
632
|
+
sourcePath: string,
|
|
633
|
+
destinationPath: string,
|
|
634
|
+
options: CopyOptions = {},
|
|
635
|
+
): Promise<void> {
|
|
636
|
+
const { source, destination, resolved } = await this.prepareTransfer(
|
|
637
|
+
"move file",
|
|
638
|
+
sourcePath,
|
|
639
|
+
destinationPath,
|
|
640
|
+
options,
|
|
641
|
+
);
|
|
642
|
+
|
|
643
|
+
if (await this.guard(destination, () => this.tryMoveFileAt(source, destination, resolved))) {
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
await this.copyFile(sourcePath, destinationPath, resolved);
|
|
648
|
+
const sourceStat = await this.stat(sourcePath);
|
|
649
|
+
if (sourceStat.type === "directory") {
|
|
650
|
+
await this.rmdir(sourcePath, { recursive: true });
|
|
651
|
+
} else {
|
|
652
|
+
await this.deleteFile(sourcePath);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Resolve the effective `overwrite` flag, rejecting when the target exists
|
|
658
|
+
* and overwriting was refused. Shared by write / copy / move so the three
|
|
659
|
+
* cannot disagree about what `overwrite: false` means.
|
|
660
|
+
*/
|
|
661
|
+
private async resolveOverwrite(
|
|
662
|
+
inputPath: string,
|
|
663
|
+
options: { overwrite?: boolean },
|
|
664
|
+
label: string,
|
|
665
|
+
): Promise<boolean> {
|
|
666
|
+
const overwrite = options.overwrite ?? true;
|
|
667
|
+
if (!overwrite && (await this.exists(inputPath))) {
|
|
668
|
+
throw new FileSystemError(
|
|
669
|
+
"ALREADY_EXISTS",
|
|
670
|
+
`${label} already exists: ${inputPath}`,
|
|
671
|
+
inputPath,
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
return overwrite;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/** Shared copy / move prologue: writability, overwrite policy, both ends resolved. */
|
|
678
|
+
private async prepareTransfer(
|
|
679
|
+
operation: string,
|
|
680
|
+
sourcePath: string,
|
|
681
|
+
destinationPath: string,
|
|
682
|
+
options: CopyOptions,
|
|
683
|
+
): Promise<{ source: string; destination: string; resolved: Required<CopyOptions> }> {
|
|
684
|
+
await this._init();
|
|
685
|
+
this.assertWritable(operation);
|
|
686
|
+
|
|
687
|
+
const overwrite = await this.resolveOverwrite(destinationPath, options, "Destination");
|
|
688
|
+
await this.ensureParentDirectory(destinationPath);
|
|
689
|
+
|
|
690
|
+
return {
|
|
691
|
+
source: await this.resolveFor(sourcePath),
|
|
692
|
+
destination: await this.resolveFor(destinationPath, { allowMissing: true }),
|
|
693
|
+
resolved: { overwrite },
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** Run a removal, swallowing a not-found failure when `force` is set. */
|
|
698
|
+
private async ignoringMissing(
|
|
699
|
+
options: RemoveOptions,
|
|
700
|
+
operation: () => Promise<void>,
|
|
701
|
+
): Promise<void> {
|
|
702
|
+
try {
|
|
703
|
+
await operation();
|
|
704
|
+
} catch (err) {
|
|
705
|
+
if (options.force && this.isNotFoundError(err)) return;
|
|
706
|
+
throw err;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/* ------------------------------------------------------------------ */
|
|
711
|
+
/* Directory operations */
|
|
712
|
+
/* ------------------------------------------------------------------ */
|
|
713
|
+
|
|
714
|
+
async mkdir(inputPath: string, options: MakeDirectoryOptions = {}): Promise<void> {
|
|
715
|
+
await this._init();
|
|
716
|
+
this.assertWritable("create directory");
|
|
717
|
+
|
|
718
|
+
const namespacePath = this.normalizePath(inputPath);
|
|
719
|
+
|
|
720
|
+
if (!options.recursive) {
|
|
721
|
+
await this.createDirectory(namespacePath);
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
let currentPath = "";
|
|
726
|
+
for (const segment of namespacePath.split("/").filter(Boolean)) {
|
|
727
|
+
currentPath = `${currentPath}/${segment}`;
|
|
728
|
+
try {
|
|
729
|
+
const entry = await this.stat(currentPath);
|
|
730
|
+
if (entry.type !== "directory") {
|
|
731
|
+
throw new FileSystemError(
|
|
732
|
+
"NOT_DIRECTORY",
|
|
733
|
+
`Path component is not a directory: ${currentPath}`,
|
|
734
|
+
currentPath,
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
} catch (error) {
|
|
738
|
+
if (!this.isNotFoundError(error)) throw error;
|
|
739
|
+
await this.createDirectory(currentPath);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
async rmdir(inputPath: string, options: RemoveOptions = {}): Promise<void> {
|
|
745
|
+
await this._init();
|
|
746
|
+
this.assertWritable("remove directory");
|
|
747
|
+
|
|
748
|
+
await this.ignoringMissing(options, async () => {
|
|
749
|
+
const entry = await this.stat(inputPath);
|
|
750
|
+
if (entry.type !== "directory") {
|
|
751
|
+
throw new FileSystemError(
|
|
752
|
+
"NOT_DIRECTORY",
|
|
753
|
+
`Path is not a directory: ${inputPath}`,
|
|
754
|
+
inputPath,
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
const namespacePath = this.normalizePath(inputPath);
|
|
759
|
+
if (options.recursive) {
|
|
760
|
+
await this.removeDirectoryContents(namespacePath);
|
|
761
|
+
}
|
|
762
|
+
await this.removeDirectory(namespacePath);
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
private async removeDirectoryContents(namespacePath: string): Promise<void> {
|
|
767
|
+
for (const entry of await this.listDirectory(namespacePath)) {
|
|
768
|
+
const childPath = this.joinNamespace(namespacePath, entry.name);
|
|
769
|
+
if (entry.type === "directory") {
|
|
770
|
+
await this.removeDirectoryContents(childPath);
|
|
771
|
+
await this.removeDirectory(childPath);
|
|
772
|
+
} else {
|
|
773
|
+
const resolvedPath = await this.resolveNamespaceFor(childPath);
|
|
774
|
+
await this.guard(resolvedPath, () => this.deleteFileAt(resolvedPath));
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/** {@link createDirectoryAt} for a namespace path, resolved and guarded. */
|
|
780
|
+
private async createDirectory(namespacePath: string): Promise<void> {
|
|
781
|
+
const resolvedPath = await this.resolveNamespaceFor(namespacePath, { allowMissing: true });
|
|
782
|
+
await this.guard(resolvedPath, () => this.createDirectoryAt(resolvedPath));
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/** {@link removeDirectoryAt} for a namespace path, resolved and guarded. */
|
|
786
|
+
private async removeDirectory(namespacePath: string): Promise<void> {
|
|
787
|
+
const resolvedPath = await this.resolveNamespaceFor(namespacePath);
|
|
788
|
+
await this.guard(resolvedPath, () => this.removeDirectoryAt(resolvedPath));
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/** {@link listDirectoryAt} for a namespace path, resolved and guarded. */
|
|
792
|
+
private async listDirectory(namespacePath: string): Promise<FileEntry[]> {
|
|
793
|
+
const resolvedPath = await this.resolveNamespaceFor(namespacePath);
|
|
794
|
+
return this.guard(resolvedPath, () => this.listDirectoryAt(resolvedPath));
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
async readdir(inputPath: string, options: ListOptions = {}): Promise<FileEntry[]> {
|
|
798
|
+
await this._init();
|
|
799
|
+
|
|
800
|
+
const namespacePath = this.normalizePath(inputPath);
|
|
801
|
+
|
|
802
|
+
if (!options.recursive) {
|
|
803
|
+
return this.filterEntries(await this.listDirectory(namespacePath), options);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
return this.listDirectoryRecursive(namespacePath, options, 0, "");
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
private async listDirectoryRecursive(
|
|
810
|
+
namespacePath: string,
|
|
811
|
+
options: ListOptions,
|
|
812
|
+
depth: number,
|
|
813
|
+
prefix: string,
|
|
814
|
+
): Promise<FileEntry[]> {
|
|
815
|
+
const maximumDepth = options.maxDepth ?? Number.POSITIVE_INFINITY;
|
|
816
|
+
const entries = this.filterEntries(await this.listDirectory(namespacePath), options);
|
|
817
|
+
|
|
818
|
+
const results: FileEntry[] = [];
|
|
819
|
+
|
|
820
|
+
for (const entry of entries) {
|
|
821
|
+
const relativeName = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
822
|
+
results.push({ ...entry, name: relativeName });
|
|
823
|
+
|
|
824
|
+
if (entry.type === "directory" && depth < maximumDepth) {
|
|
825
|
+
results.push(
|
|
826
|
+
...(await this.listDirectoryRecursive(
|
|
827
|
+
this.joinNamespace(namespacePath, entry.name),
|
|
828
|
+
options,
|
|
829
|
+
depth + 1,
|
|
830
|
+
relativeName,
|
|
831
|
+
)),
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
return results;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
private filterEntries(entries: FileEntry[], options: ListOptions): FileEntry[] {
|
|
840
|
+
if (!options.extension) {
|
|
841
|
+
return entries;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
const extensions = (
|
|
845
|
+
Array.isArray(options.extension) ? options.extension : [options.extension]
|
|
846
|
+
).map((extension) => {
|
|
847
|
+
const normalized = extension.toLowerCase();
|
|
848
|
+
return normalized.startsWith(".") ? normalized : `.${normalized}`;
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
return entries.filter((entry) => {
|
|
852
|
+
if (entry.type !== "file") return true;
|
|
853
|
+
const lower = entry.name.toLowerCase();
|
|
854
|
+
return extensions.some((ext) => lower.endsWith(ext));
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
async exists(inputPath: string): Promise<boolean> {
|
|
859
|
+
await this._init();
|
|
860
|
+
try {
|
|
861
|
+
await this.stat(inputPath);
|
|
862
|
+
return true;
|
|
863
|
+
} catch (error) {
|
|
864
|
+
if (this.isNotFoundError(error)) return false;
|
|
865
|
+
if (error instanceof FileSystemError && error.code === "PERMISSION_DENIED") {
|
|
866
|
+
return false;
|
|
867
|
+
}
|
|
868
|
+
throw error;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
async stat(inputPath: string): Promise<FileStat> {
|
|
873
|
+
await this._init();
|
|
874
|
+
const namespacePath = this.normalizePath(inputPath);
|
|
875
|
+
const resolvedPath = await this.resolveNamespaceFor(namespacePath);
|
|
876
|
+
const entry = await this.guard(resolvedPath, () => this.statAt(resolvedPath));
|
|
877
|
+
return {
|
|
878
|
+
...entry,
|
|
879
|
+
path: this.toRelativePath(namespacePath),
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
}
|