@stowage/adapter-fs 0.1.0 → 0.2.0
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 +19 -12
- package/dist/index.js +252 -95
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -31,30 +31,37 @@ The bundle measures 5.7 kB minified and gzipped, `@stowage/core` included.
|
|
|
31
31
|
## Limits
|
|
32
32
|
|
|
33
33
|
- `keyBytesPreserved` is not declared: a key comes back Unicode-equivalent to what was written
|
|
34
|
-
([spec 4.9](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.
|
|
34
|
+
([spec 4.9](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.2.0/docs/spec.md#49-capabilities)).
|
|
35
35
|
- `presignedUrls` is not declared: `FsStorage` has neither `presignGet` nor `presignPut`
|
|
36
|
-
([spec 4.9](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.
|
|
36
|
+
([spec 4.9](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.2.0/docs/spec.md#49-capabilities)).
|
|
37
37
|
- `userMetadata` is not declared: `put` with a non-empty `userMetadata` is `Unsupported`, and reads
|
|
38
38
|
return `{}`
|
|
39
|
-
([spec 4.9](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.
|
|
39
|
+
([spec 4.9](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.2.0/docs/spec.md#49-capabilities)).
|
|
40
|
+
- `userMetadataTokenKeys` is not declared: without `userMetadata`, a key beyond identifiers is
|
|
41
|
+
`Unsupported` naming `userMetadata` like any other
|
|
42
|
+
([spec 4.9](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.2.0/docs/spec.md#49-capabilities)).
|
|
40
43
|
- A key segment longer than 255 bytes is `InvalidKey`, and so is a key whose whole path is longer
|
|
41
44
|
than the file system holds. macOS bounds one path at 1024 bytes with the root counted in
|
|
42
|
-
([spec 6](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.
|
|
45
|
+
([spec 6](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.2.0/docs/spec.md#6-stowageadapter-fs)).
|
|
46
|
+
- A name the file system refuses to create is `InvalidKey` on `put` and on the destination of
|
|
47
|
+
`copy` and `move`. APFS refuses every noncharacter, so a key holding `U+FFFE` is written on
|
|
48
|
+
Linux and refused on macOS
|
|
49
|
+
([spec 6](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.2.0/docs/spec.md#6-stowageadapter-fs)).
|
|
43
50
|
- APFS keeps a name in the Unicode form it was written in, and folds the forms when it looks a name
|
|
44
51
|
up, so a key in NFD reaches the object its NFC form wrote. A case-insensitive file system collides
|
|
45
52
|
keys that differ in case alone. Nothing repairs either
|
|
46
|
-
([spec 6](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.
|
|
53
|
+
([spec 6](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.2.0/docs/spec.md#6-stowageadapter-fs)).
|
|
47
54
|
- The content type is derived from the key's extension, and is `application/octet-stream` where the
|
|
48
55
|
extension is unknown or absent. The `contentType` handed to `put` is not stored, so `stat` may
|
|
49
56
|
report another one
|
|
50
|
-
([spec 6](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.
|
|
57
|
+
([spec 6](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.2.0/docs/spec.md#6-stowageadapter-fs)).
|
|
51
58
|
- No `etag` is set
|
|
52
|
-
([spec 4.4](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.
|
|
59
|
+
([spec 4.4](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.2.0/docs/spec.md#44-object-descriptions)).
|
|
53
60
|
|
|
54
61
|
## Notes
|
|
55
62
|
|
|
56
63
|
`put` takes no `Blob`. A caller holding one passes its stream
|
|
57
|
-
([spec 4.2](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.
|
|
64
|
+
([spec 4.2](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.2.0/docs/spec.md#42-bodies)):
|
|
58
65
|
|
|
59
66
|
```ts
|
|
60
67
|
import { fsStorage } from "@stowage/adapter-fs";
|
|
@@ -66,7 +73,7 @@ await storage.put("reports/2026/q4.csv", blob.stream());
|
|
|
66
73
|
```
|
|
67
74
|
|
|
68
75
|
stowage reports no progress
|
|
69
|
-
([spec
|
|
76
|
+
([spec 12](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.2.0/docs/spec.md#12-non-goals)).
|
|
70
77
|
A caller who wants it counts the bytes on their way into `put`:
|
|
71
78
|
|
|
72
79
|
```ts
|
|
@@ -95,10 +102,10 @@ await storage.put("videos/intro.mp4", response.body.pipeThrough(countBytes(conso
|
|
|
95
102
|
|
|
96
103
|
## Specification
|
|
97
104
|
|
|
98
|
-
[`docs/spec.md` at `@stowage/adapter-fs@0.
|
|
105
|
+
[`docs/spec.md` at `@stowage/adapter-fs@0.2.0`](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.2.0/docs/spec.md#6-stowageadapter-fs)
|
|
99
106
|
is the contract: a caller may rely on what it states and on nothing else this package happens to
|
|
100
|
-
export. The [terms it uses](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.
|
|
101
|
-
and the [decisions behind it](https://github.com/stowage-js/stowage/tree/@stowage/adapter-fs@0.
|
|
107
|
+
export. The [terms it uses](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.2.0/CONTEXT.md)
|
|
108
|
+
and the [decisions behind it](https://github.com/stowage-js/stowage/tree/@stowage/adapter-fs@0.2.0/docs/adr)
|
|
102
109
|
are at the same tag.
|
|
103
110
|
|
|
104
111
|
## License
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { copyFile, lstat, mkdir, open, readdir, realpath, rename, rmdir, stat, unlink } from "node:fs/promises";
|
|
2
2
|
import { basename, dirname, isAbsolute, join, sep } from "node:path";
|
|
3
|
-
import { StorageError, invalidKeyReason, isStorageError } from "@stowage/core";
|
|
3
|
+
import { StorageError, invalidKeyReason, isStorageError, lastByteOf, rangeBoundsRefusal, rangeStartRefusal } from "@stowage/core";
|
|
4
4
|
//#region src/body.ts
|
|
5
|
-
const utf8$
|
|
5
|
+
const utf8$2 = new TextEncoder();
|
|
6
6
|
/**
|
|
7
7
|
* Writes the body into the open file. A stream goes chunk by chunk, so that an upload of
|
|
8
8
|
* any size costs one chunk of memory rather than the object.
|
|
@@ -15,7 +15,7 @@ async function writeBody(handle, body, signal) {
|
|
|
15
15
|
return;
|
|
16
16
|
}
|
|
17
17
|
signal?.throwIfAborted();
|
|
18
|
-
await writeAll(handle, typeof body === "string" ? utf8$
|
|
18
|
+
await writeAll(handle, typeof body === "string" ? utf8$2.encode(body) : body);
|
|
19
19
|
}
|
|
20
20
|
/** Spec 4.2 leaves a stream at its end or canceled once `put` settled, read or not. */
|
|
21
21
|
async function cancelBody(body, reason) {
|
|
@@ -82,6 +82,41 @@ function contentTypeOf(key) {
|
|
|
82
82
|
return contentTypes.get(name.slice(dot + 1).toLowerCase()) ?? "application/octet-stream";
|
|
83
83
|
}
|
|
84
84
|
//#endregion
|
|
85
|
+
//#region src/directory-lock.ts
|
|
86
|
+
const directoryMutationLocks = /* @__PURE__ */ new Map();
|
|
87
|
+
const doNothing = () => {};
|
|
88
|
+
async function holdDirectoryMutations(realRoot) {
|
|
89
|
+
const lock = directoryMutationLocks.get(realRoot) ?? {
|
|
90
|
+
tail: Promise.resolve(),
|
|
91
|
+
leases: 0
|
|
92
|
+
};
|
|
93
|
+
const previous = lock.tail;
|
|
94
|
+
let releaseNext = doNothing;
|
|
95
|
+
lock.tail = new Promise((resolve) => {
|
|
96
|
+
releaseNext = resolve;
|
|
97
|
+
});
|
|
98
|
+
lock.leases += 1;
|
|
99
|
+
directoryMutationLocks.set(realRoot, lock);
|
|
100
|
+
await previous;
|
|
101
|
+
let held = true;
|
|
102
|
+
return () => {
|
|
103
|
+
if (!held) return;
|
|
104
|
+
held = false;
|
|
105
|
+
lock.leases -= 1;
|
|
106
|
+
releaseNext();
|
|
107
|
+
if (lock.leases === 0 && directoryMutationLocks.get(realRoot) === lock) directoryMutationLocks.delete(realRoot);
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/** Runs the step with no directory below the root created or removed by anyone else. */
|
|
111
|
+
async function withDirectoryMutations(realRoot, step) {
|
|
112
|
+
const release = await holdDirectoryMutations(realRoot);
|
|
113
|
+
try {
|
|
114
|
+
return await step();
|
|
115
|
+
} finally {
|
|
116
|
+
release();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
//#endregion
|
|
85
120
|
//#region src/cursor.ts
|
|
86
121
|
const cursorTag = "stowage-fs-1:";
|
|
87
122
|
const unitDigits = 4;
|
|
@@ -136,7 +171,7 @@ function isTemporaryName(name) {
|
|
|
136
171
|
//#region src/key.ts
|
|
137
172
|
/** What a file system takes as one name, which spec 6 has the adapter refuse beyond. */
|
|
138
173
|
const segmentByteLimit = 255;
|
|
139
|
-
const utf8 = new TextEncoder();
|
|
174
|
+
const utf8$1 = new TextEncoder();
|
|
140
175
|
function requireKey(root, key, rule, operation) {
|
|
141
176
|
const reason = invalidKeyReason(key, rule) ?? temporaryNameReason(key, rule) ?? longSegmentReason(key);
|
|
142
177
|
if (reason === void 0) return;
|
|
@@ -154,7 +189,7 @@ function temporaryNameReason(key, rule) {
|
|
|
154
189
|
}
|
|
155
190
|
function longSegmentReason(key) {
|
|
156
191
|
for (const segment of key.split("/")) {
|
|
157
|
-
const bytes = utf8.encode(segment).length;
|
|
192
|
+
const bytes = utf8$1.encode(segment).length;
|
|
158
193
|
if (bytes > segmentByteLimit) return `holds a segment of ${bytes} UTF-8 bytes, above the limit of ${segmentByteLimit}`;
|
|
159
194
|
}
|
|
160
195
|
}
|
|
@@ -290,6 +325,10 @@ const errnoMappings = /* @__PURE__ */ new Map([
|
|
|
290
325
|
["EACCES", { code: "AccessDenied" }],
|
|
291
326
|
["EPERM", { code: "AccessDenied" }],
|
|
292
327
|
["ENAMETOOLONG", { code: "InvalidKey" }],
|
|
328
|
+
["EILSEQ", {
|
|
329
|
+
code: "NotFound",
|
|
330
|
+
onWrite: "InvalidKey"
|
|
331
|
+
}],
|
|
293
332
|
["EMFILE", {
|
|
294
333
|
code: "ProviderError",
|
|
295
334
|
retryable: true
|
|
@@ -329,10 +368,17 @@ function errnoOf(thrown) {
|
|
|
329
368
|
const code = Reflect.get(thrown, "code");
|
|
330
369
|
return typeof code === "string" ? code : void 0;
|
|
331
370
|
}
|
|
332
|
-
/** The
|
|
371
|
+
/** The file system refuses to create the name, as APFS refuses a noncharacter (spec 6). */
|
|
372
|
+
function refusesName(thrown) {
|
|
373
|
+
return errnoOf(thrown) === "EILSEQ";
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* The key names nothing this storage holds, which is what an absent file amounts to, and
|
|
377
|
+
* so is a name a file system refuses on a lookup: no file can be held under it.
|
|
378
|
+
*/
|
|
333
379
|
function isAbsence(thrown) {
|
|
334
380
|
const errno = errnoOf(thrown);
|
|
335
|
-
return errno === "ENOENT" || errno === "ENOTDIR";
|
|
381
|
+
return errno === "ENOENT" || errno === "ENOTDIR" || errno === "EILSEQ";
|
|
336
382
|
}
|
|
337
383
|
function messageOf(thrown) {
|
|
338
384
|
return thrown instanceof Error ? thrown.message : String(thrown);
|
|
@@ -415,15 +461,33 @@ async function resolveObject(context) {
|
|
|
415
461
|
/**
|
|
416
462
|
* The path a write lands under, with the directories above it in place. The nearest
|
|
417
463
|
* existing directory is resolved before anything is created, so a link leaving the root
|
|
418
|
-
* cannot cause this storage to create directories outside it.
|
|
464
|
+
* cannot cause this storage to create directories outside it. The caller holds the
|
|
465
|
+
* directory lock until the write's file is in place, since a prune may otherwise remove a
|
|
466
|
+
* directory this found in place before anything lies in it.
|
|
419
467
|
*/
|
|
420
468
|
async function prepareWrite(context) {
|
|
421
469
|
const path = pathOf(context.realRoot, context.key);
|
|
422
470
|
if (path === void 0) throw absent(context, 0);
|
|
423
|
-
const directory = await makeDirectory(dirname(path), context);
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
|
|
471
|
+
const { directory, created } = await makeDirectory(dirname(path), context);
|
|
472
|
+
const abandon = async () => await removeCreated(created);
|
|
473
|
+
try {
|
|
474
|
+
const target = join(directory, basename(path));
|
|
475
|
+
if (!await resolvesWithin(context, target)) throw absent(context, 1);
|
|
476
|
+
return {
|
|
477
|
+
path: target,
|
|
478
|
+
abandon
|
|
479
|
+
};
|
|
480
|
+
} catch (thrown) {
|
|
481
|
+
await abandon();
|
|
482
|
+
throw thrown;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
async function removeCreated(created) {
|
|
486
|
+
for (const directory of created.toReversed()) try {
|
|
487
|
+
await rmdir(directory);
|
|
488
|
+
} catch {
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
427
491
|
}
|
|
428
492
|
async function resolvesWithin(context, path) {
|
|
429
493
|
try {
|
|
@@ -451,32 +515,40 @@ async function makeDirectory(directory, context) {
|
|
|
451
515
|
existing = dirname(existing);
|
|
452
516
|
}
|
|
453
517
|
if (!within(context.realRoot, existing)) throw absent(context, 1);
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
518
|
+
const created = [];
|
|
519
|
+
try {
|
|
520
|
+
for (const segment of missing) {
|
|
521
|
+
const path = join(existing, segment);
|
|
522
|
+
try {
|
|
523
|
+
await mkdir(path);
|
|
524
|
+
created.push(path);
|
|
525
|
+
} catch (thrown) {
|
|
526
|
+
if (errnoOf(thrown) !== "EEXIST") throw fsErrorFrom(thrown, {
|
|
527
|
+
...context,
|
|
528
|
+
access: "write"
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
try {
|
|
532
|
+
existing = await realpath(path);
|
|
533
|
+
} catch (thrown) {
|
|
534
|
+
throw fsErrorFrom(thrown, {
|
|
535
|
+
...context,
|
|
536
|
+
access: "write"
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
if (!within(context.realRoot, existing)) throw absent(context, 1);
|
|
471
540
|
}
|
|
472
|
-
|
|
541
|
+
} catch (thrown) {
|
|
542
|
+
await removeCreated(created);
|
|
543
|
+
throw thrown;
|
|
473
544
|
}
|
|
474
|
-
return
|
|
545
|
+
return {
|
|
546
|
+
directory: existing,
|
|
547
|
+
created
|
|
548
|
+
};
|
|
475
549
|
}
|
|
476
550
|
//#endregion
|
|
477
551
|
//#region src/object-file.ts
|
|
478
|
-
const directoryMutationLocks = /* @__PURE__ */ new Map();
|
|
479
|
-
const doNothing = () => {};
|
|
480
552
|
/**
|
|
481
553
|
* The file below the key, or `undefined` where the key names no object of this storage:
|
|
482
554
|
* nothing at all, a directory, or a path that leaves the root (spec 6). What a listing
|
|
@@ -538,43 +610,21 @@ async function removeObjectFile(context, file) {
|
|
|
538
610
|
* Moves the file to the path, which is the copy and the delete of spec 4.11 as one file
|
|
539
611
|
* system performs them, and removes what the source left empty behind it.
|
|
540
612
|
*/
|
|
541
|
-
async function renameObjectFile(context, file,
|
|
613
|
+
async function renameObjectFile(context, file, destination) {
|
|
614
|
+
try {
|
|
615
|
+
await rename(file.path, destination.path);
|
|
616
|
+
} catch (thrown) {
|
|
617
|
+
throw fsErrorFrom(thrown, {
|
|
618
|
+
...refusesName(thrown) ? destination.context : context,
|
|
619
|
+
access: "write"
|
|
620
|
+
});
|
|
621
|
+
}
|
|
542
622
|
try {
|
|
543
|
-
try {
|
|
544
|
-
await rename(file.path, path);
|
|
545
|
-
} catch (thrown) {
|
|
546
|
-
throw fsErrorFrom(thrown, {
|
|
547
|
-
...context,
|
|
548
|
-
access: "write"
|
|
549
|
-
});
|
|
550
|
-
}
|
|
551
623
|
await pruneEmptyDirectories(context.realRoot, dirname(file.path));
|
|
552
624
|
} finally {
|
|
553
625
|
file.release();
|
|
554
626
|
}
|
|
555
627
|
}
|
|
556
|
-
async function holdDirectoryMutations(realRoot) {
|
|
557
|
-
const lock = directoryMutationLocks.get(realRoot) ?? {
|
|
558
|
-
tail: Promise.resolve(),
|
|
559
|
-
leases: 0
|
|
560
|
-
};
|
|
561
|
-
const previous = lock.tail;
|
|
562
|
-
let releaseNext = doNothing;
|
|
563
|
-
lock.tail = new Promise((resolve) => {
|
|
564
|
-
releaseNext = resolve;
|
|
565
|
-
});
|
|
566
|
-
lock.leases += 1;
|
|
567
|
-
directoryMutationLocks.set(realRoot, lock);
|
|
568
|
-
await previous;
|
|
569
|
-
let held = true;
|
|
570
|
-
return () => {
|
|
571
|
-
if (!held) return;
|
|
572
|
-
held = false;
|
|
573
|
-
lock.leases -= 1;
|
|
574
|
-
releaseNext();
|
|
575
|
-
if (lock.leases === 0 && directoryMutationLocks.get(realRoot) === lock) directoryMutationLocks.delete(realRoot);
|
|
576
|
-
};
|
|
577
|
-
}
|
|
578
628
|
async function pruneEmptyDirectories(realRoot, directory) {
|
|
579
629
|
for (let current = directory; current !== realRoot && within(realRoot, current); current = dirname(current)) try {
|
|
580
630
|
await rmdir(current);
|
|
@@ -610,24 +660,24 @@ async function lookUp(context, read) {
|
|
|
610
660
|
//#region src/range.ts
|
|
611
661
|
/** Refuses bounds the spec does not allow, before the file is opened (spec 4.3). */
|
|
612
662
|
function requireRange(root, range) {
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
663
|
+
const refusal = rangeBoundsRefusal(range);
|
|
664
|
+
if (refusal !== void 0) throw fsError(root, {
|
|
665
|
+
...refusal,
|
|
666
|
+
operation: "get",
|
|
667
|
+
attempts: 0
|
|
668
|
+
});
|
|
616
669
|
}
|
|
617
670
|
/** The last byte the range names, both ends inclusive, clipped to what the file holds. */
|
|
618
|
-
function
|
|
671
|
+
function lastByteToRead(root, range, size, key) {
|
|
619
672
|
if (range === void 0) return size - 1;
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
673
|
+
const refusal = rangeStartRefusal(range, size, key);
|
|
674
|
+
if (refusal !== void 0) throw fsError(root, {
|
|
675
|
+
...refusal,
|
|
623
676
|
operation: "get",
|
|
624
677
|
key,
|
|
625
678
|
attempts: 1
|
|
626
679
|
});
|
|
627
|
-
return
|
|
628
|
-
}
|
|
629
|
-
function isOffset(value) {
|
|
630
|
-
return Number.isInteger(value) && value >= 0;
|
|
680
|
+
return lastByteOf(range, size);
|
|
631
681
|
}
|
|
632
682
|
//#endregion
|
|
633
683
|
//#region src/stored-object.ts
|
|
@@ -742,6 +792,8 @@ async function close(handle) {
|
|
|
742
792
|
}
|
|
743
793
|
//#endregion
|
|
744
794
|
//#region src/walk.ts
|
|
795
|
+
const utf8 = new TextEncoder();
|
|
796
|
+
const strictUtf8 = new TextDecoder("utf-8", { fatal: true });
|
|
745
797
|
/**
|
|
746
798
|
* Every object below the prefix, sorted by key. A listing pages through one order, so
|
|
747
799
|
* the snapshot it reads is sorted; the order itself is not promised (spec 4.6).
|
|
@@ -749,25 +801,34 @@ async function close(handle) {
|
|
|
749
801
|
async function walkObjects(context, prefix) {
|
|
750
802
|
const entries = [];
|
|
751
803
|
const directory = prefix.slice(0, prefix.lastIndexOf("/") + 1);
|
|
752
|
-
await collect(
|
|
804
|
+
await collect({
|
|
805
|
+
context,
|
|
806
|
+
entries,
|
|
807
|
+
prefix: utf8.encode(prefix)
|
|
808
|
+
}, join(context.realRoot, ...directory.split("/")), directory);
|
|
753
809
|
return entries.filter((entry) => entry.key.startsWith(prefix)).toSorted(byKey);
|
|
754
810
|
}
|
|
755
|
-
async function collect(
|
|
811
|
+
async function collect(walk, directory, keyPrefix) {
|
|
812
|
+
const { context, entries } = walk;
|
|
756
813
|
let resolved;
|
|
757
814
|
let held;
|
|
758
815
|
try {
|
|
759
816
|
resolved = await realpath(directory);
|
|
760
817
|
if (!within(context.realRoot, resolved)) return;
|
|
761
|
-
held = await
|
|
818
|
+
held = await entriesOf(resolved);
|
|
762
819
|
} catch (thrown) {
|
|
763
820
|
if (isAbsence(thrown)) return;
|
|
764
821
|
throw failure(context, thrown);
|
|
765
822
|
}
|
|
766
823
|
for (const entry of held) {
|
|
824
|
+
if ("bytes" in entry) {
|
|
825
|
+
refuseUndecodableName(walk, keyPrefix, entry.bytes);
|
|
826
|
+
continue;
|
|
827
|
+
}
|
|
767
828
|
const path = join(resolved, entry.name);
|
|
768
829
|
const key = `${keyPrefix}${entry.name}`;
|
|
769
|
-
if (entry.
|
|
770
|
-
await collect(
|
|
830
|
+
if (entry.directory) {
|
|
831
|
+
await collect(walk, path, `${key}/`);
|
|
771
832
|
continue;
|
|
772
833
|
}
|
|
773
834
|
if (isTemporaryName(entry.name)) continue;
|
|
@@ -780,6 +841,71 @@ async function collect(context, entries, directory, keyPrefix) {
|
|
|
780
841
|
}
|
|
781
842
|
}
|
|
782
843
|
/**
|
|
844
|
+
* The names of one level. The runtime decodes a name that is no UTF-8 with U+FFFD in
|
|
845
|
+
* place of what it cannot read, so a level holding that character reads its names again
|
|
846
|
+
* as bytes to tell such a name from one that holds U+FFFD as written.
|
|
847
|
+
*/
|
|
848
|
+
async function entriesOf(directory) {
|
|
849
|
+
const held = await readdir(directory, { withFileTypes: true });
|
|
850
|
+
if (!held.some((entry) => entry.name.includes("�"))) return held.map((entry) => ({
|
|
851
|
+
name: entry.name,
|
|
852
|
+
directory: entry.isDirectory()
|
|
853
|
+
}));
|
|
854
|
+
const names = await readdir(directory, { encoding: "buffer" });
|
|
855
|
+
const entries = [];
|
|
856
|
+
for (const bytes of names) {
|
|
857
|
+
const name = utf8NameOf(bytes);
|
|
858
|
+
if (name === void 0) {
|
|
859
|
+
entries.push({ bytes });
|
|
860
|
+
continue;
|
|
861
|
+
}
|
|
862
|
+
try {
|
|
863
|
+
entries.push({
|
|
864
|
+
name,
|
|
865
|
+
directory: (await lstat(join(directory, name))).isDirectory()
|
|
866
|
+
});
|
|
867
|
+
} catch (thrown) {
|
|
868
|
+
if (isAbsence(thrown)) continue;
|
|
869
|
+
throw thrown;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
return entries;
|
|
873
|
+
}
|
|
874
|
+
function utf8NameOf(bytes) {
|
|
875
|
+
try {
|
|
876
|
+
return strictUtf8.decode(bytes);
|
|
877
|
+
} catch {
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
/**
|
|
882
|
+
* A name that is no UTF-8 has no key, and a key with U+FFFD in its place would reach
|
|
883
|
+
* another object or none. Passing over it would let a move of the prefix lose the file
|
|
884
|
+
* in silence, so a listing it falls below fails as one whose entry arrived without a key
|
|
885
|
+
* (spec 4.6); a listing beside it goes on.
|
|
886
|
+
*/
|
|
887
|
+
function refuseUndecodableName(walk, keyPrefix, bytes) {
|
|
888
|
+
const path = new Uint8Array([...utf8.encode(keyPrefix), ...bytes]);
|
|
889
|
+
if (!startsWith(path, walk.prefix)) return;
|
|
890
|
+
throw fsError(walk.context.root, {
|
|
891
|
+
code: "ProviderError",
|
|
892
|
+
message: `The name ${escapedBytesOf(path)} is no UTF-8, so no key names it`,
|
|
893
|
+
operation: walk.context.operation,
|
|
894
|
+
attempts: 1,
|
|
895
|
+
retryable: false
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
function startsWith(bytes, start) {
|
|
899
|
+
return start.length <= bytes.length && start.every((byte, index) => bytes[index] === byte);
|
|
900
|
+
}
|
|
901
|
+
function escapedBytesOf(bytes) {
|
|
902
|
+
return `"${Array.from(bytes, (byte) => standsAsItIs(byte) ? String.fromCharCode(byte) : `\\x${byte.toString(16).toUpperCase().padStart(2, "0")}`).join("")}"`;
|
|
903
|
+
}
|
|
904
|
+
/** Printable ASCII but for the quote and the backslash, which would read as an escape. */
|
|
905
|
+
function standsAsItIs(byte) {
|
|
906
|
+
return byte >= 32 && byte <= 126 && byte !== 34 && byte !== 92;
|
|
907
|
+
}
|
|
908
|
+
/**
|
|
783
909
|
* What the entry holds, or `undefined` for everything that is no object: a link leaving
|
|
784
910
|
* the root, a link to a directory, a socket, and a file another writer removed between
|
|
785
911
|
* the listing of the level and the reading of it.
|
|
@@ -837,12 +963,30 @@ var FileSystemStorage = class {
|
|
|
837
963
|
this.#requireNoUserMetadata(options?.userMetadata, key);
|
|
838
964
|
options?.signal?.throwIfAborted();
|
|
839
965
|
const context = await this.#context(key, "put", "write");
|
|
840
|
-
const
|
|
841
|
-
|
|
966
|
+
const reserved = await withDirectoryMutations(context.realRoot, async () => await this.#reserve(context));
|
|
967
|
+
try {
|
|
968
|
+
return await this.#write(context, reserved, body, options?.signal);
|
|
969
|
+
} catch (thrown) {
|
|
970
|
+
await withDirectoryMutations(context.realRoot, reserved.prepared.abandon);
|
|
971
|
+
throw thrown;
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* The directories a write needs and the file its bytes land in, created while the caller
|
|
976
|
+
* holds the directory lock: once that file is there, no prune removes the directory it
|
|
977
|
+
* lies in, so the body streams in without the lock.
|
|
978
|
+
*/
|
|
979
|
+
async #reserve(context) {
|
|
980
|
+
const prepared = await prepareWrite(context);
|
|
981
|
+
const temporary = temporaryPathIn(dirname(prepared.path));
|
|
982
|
+
return {
|
|
983
|
+
prepared,
|
|
984
|
+
temporary,
|
|
985
|
+
handle: await abandoningOnFailure(prepared, async () => await this.#openTemporary(context, temporary))
|
|
986
|
+
};
|
|
842
987
|
}
|
|
843
|
-
async #write(context,
|
|
844
|
-
return await this.#land(context, path, async (
|
|
845
|
-
const handle = await this.#openTemporary(context, temporary);
|
|
988
|
+
async #write(context, { prepared, temporary, handle }, body, signal) {
|
|
989
|
+
return await this.#land(context, prepared.path, temporary, async () => {
|
|
846
990
|
try {
|
|
847
991
|
await writeBody(handle, body, signal);
|
|
848
992
|
signal?.throwIfAborted();
|
|
@@ -860,10 +1004,9 @@ var FileSystemStorage = class {
|
|
|
860
1004
|
* reader sees the object as it was or as it now is and never half of a write. A write
|
|
861
1005
|
* that broke takes its file with it, so that no listing names what never became one.
|
|
862
1006
|
*/
|
|
863
|
-
async #land(context, path, fill, signal) {
|
|
864
|
-
const temporary = temporaryPathIn(dirname(path));
|
|
1007
|
+
async #land(context, path, temporary, fill, signal) {
|
|
865
1008
|
try {
|
|
866
|
-
const written = await fill(
|
|
1009
|
+
const written = await fill();
|
|
867
1010
|
signal?.throwIfAborted();
|
|
868
1011
|
await rename(temporary, path);
|
|
869
1012
|
return describe(context.key, written.size, written.mtime);
|
|
@@ -885,7 +1028,7 @@ var FileSystemStorage = class {
|
|
|
885
1028
|
return createStoredObject(context, found.description, {
|
|
886
1029
|
path: found.path,
|
|
887
1030
|
start: options?.range?.start ?? 0,
|
|
888
|
-
end:
|
|
1031
|
+
end: lastByteToRead(this.#root, options?.range, found.description.size, key)
|
|
889
1032
|
});
|
|
890
1033
|
}
|
|
891
1034
|
async stat(key, options) {
|
|
@@ -972,11 +1115,12 @@ var FileSystemStorage = class {
|
|
|
972
1115
|
const ends = await this.#endpoints(from, to, "copy", options);
|
|
973
1116
|
const source = await this.#requireFile(ends.from);
|
|
974
1117
|
try {
|
|
975
|
-
const
|
|
976
|
-
|
|
1118
|
+
const prepared = await prepareWrite(ends.to);
|
|
1119
|
+
const temporary = temporaryPathIn(dirname(prepared.path));
|
|
1120
|
+
return await abandoningOnFailure(prepared, async () => await this.#land(ends.to, prepared.path, temporary, async () => {
|
|
977
1121
|
await this.#read(ends.from, source.path, temporary);
|
|
978
1122
|
return await stat(temporary);
|
|
979
|
-
}, options?.signal);
|
|
1123
|
+
}, options?.signal));
|
|
980
1124
|
} finally {
|
|
981
1125
|
releaseObjectFile(source);
|
|
982
1126
|
}
|
|
@@ -996,9 +1140,14 @@ var FileSystemStorage = class {
|
|
|
996
1140
|
const ends = await this.#endpoints(from, to, "move", options);
|
|
997
1141
|
const source = await this.#requireFile(ends.from);
|
|
998
1142
|
try {
|
|
999
|
-
const
|
|
1000
|
-
|
|
1001
|
-
|
|
1143
|
+
const prepared = await prepareWrite(ends.to);
|
|
1144
|
+
await abandoningOnFailure(prepared, async () => {
|
|
1145
|
+
options?.signal?.throwIfAborted();
|
|
1146
|
+
await renameObjectFile(ends.from, source, {
|
|
1147
|
+
context: ends.to,
|
|
1148
|
+
path: prepared.path
|
|
1149
|
+
});
|
|
1150
|
+
});
|
|
1002
1151
|
return describe(to, source.stats.size, source.stats.mtime);
|
|
1003
1152
|
} finally {
|
|
1004
1153
|
releaseObjectFile(source);
|
|
@@ -1107,5 +1256,13 @@ function describe(key, size, lastModified) {
|
|
|
1107
1256
|
userMetadata: noUserMetadata
|
|
1108
1257
|
};
|
|
1109
1258
|
}
|
|
1259
|
+
async function abandoningOnFailure(prepared, write) {
|
|
1260
|
+
try {
|
|
1261
|
+
return await write();
|
|
1262
|
+
} catch (thrown) {
|
|
1263
|
+
await prepared.abandon();
|
|
1264
|
+
throw thrown;
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1110
1267
|
//#endregion
|
|
1111
1268
|
export { fsStorage };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stowage/adapter-fs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "stowage adapter rooting a storage in one directory of the local file system.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"adapter",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"provenance": true
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@stowage/core": "^0.
|
|
36
|
+
"@stowage/core": "^0.2.0"
|
|
37
37
|
},
|
|
38
38
|
"engines": {
|
|
39
39
|
"node": ">=24"
|