@effect-vfs/core 0.3.0 → 0.3.1
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/BytePath.d.ts +33 -2
- package/dist/BytePath.d.ts.map +1 -1
- package/dist/BytePath.js +1 -1
- package/dist/Snapshot.d.ts +58 -2
- package/dist/Snapshot.d.ts.map +1 -1
- package/dist/Snapshot.js +57 -1
- package/dist/SnapshotDelta.d.ts +113 -3
- package/dist/SnapshotDelta.d.ts.map +1 -1
- package/dist/SnapshotDelta.js +112 -2
- package/dist/VirtualFileSystem.d.ts +919 -10
- package/dist/VirtualFileSystem.d.ts.map +1 -1
- package/dist/VirtualFileSystem.js +575 -7
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/internal/bytePath.d.ts.map +1 -1
- package/dist/internal/bytePath.js +5 -10
- package/dist/internal/bytes.d.ts +6 -0
- package/dist/internal/bytes.d.ts.map +1 -0
- package/dist/internal/bytes.js +17 -0
- package/dist/internal/canonicalBase64.d.ts +13 -5
- package/dist/internal/canonicalBase64.d.ts.map +1 -1
- package/dist/internal/canonicalBase64.js +28 -6
- package/dist/internal/image.d.ts +76 -90
- package/dist/internal/image.d.ts.map +1 -1
- package/dist/internal/image.js +98 -89
- package/dist/internal/metadata.d.ts +28 -0
- package/dist/internal/metadata.d.ts.map +1 -0
- package/dist/internal/metadata.js +29 -0
- package/dist/internal/snapshotDelta.d.ts.map +1 -1
- package/dist/internal/snapshotDelta.js +346 -300
- package/dist/internal/snapshotDeltaModel.d.ts +2 -2
- package/dist/internal/snapshotDeltaModel.d.ts.map +1 -1
- package/dist/internal/snapshotDeltaModel.js +3 -1
- package/dist/internal/virtualFileSystem/errors.d.ts +5 -0
- package/dist/internal/virtualFileSystem/errors.d.ts.map +1 -1
- package/dist/internal/virtualFileSystem/errors.js +19 -0
- package/dist/internal/virtualFileSystem/fixture.d.ts.map +1 -1
- package/dist/internal/virtualFileSystem/fixture.js +74 -74
- package/dist/internal/virtualFileSystem/overlayContent.d.ts.map +1 -1
- package/dist/internal/virtualFileSystem/overlayContent.js +9 -2
- package/dist/internal/virtualFileSystem/overlayDiff.d.ts +2 -11
- package/dist/internal/virtualFileSystem/overlayDiff.d.ts.map +1 -1
- package/dist/internal/virtualFileSystem/overlayDiff.js +45 -71
- package/dist/internal/virtualFileSystem/path.d.ts +15 -14
- package/dist/internal/virtualFileSystem/path.d.ts.map +1 -1
- package/dist/internal/virtualFileSystem/path.js +57 -54
- package/dist/internal/virtualFileSystem/testHooks.d.ts.map +1 -1
- package/dist/internal/virtualFileSystem/watchHub.d.ts.map +1 -1
- package/dist/internal/virtualFileSystem/watchHub.js +13 -13
- package/dist/internal/virtualFileSystem.d.ts +91 -15
- package/dist/internal/virtualFileSystem.d.ts.map +1 -1
- package/dist/internal/virtualFileSystem.js +470 -392
- package/package.json +15 -5
|
@@ -2,19 +2,22 @@
|
|
|
2
2
|
import * as ByteSize from "effect/ByteSize";
|
|
3
3
|
import * as Clock from "effect/Clock";
|
|
4
4
|
import * as Context from "effect/Context";
|
|
5
|
+
import * as Data from "effect/Data";
|
|
5
6
|
import * as Effect from "effect/Effect";
|
|
6
7
|
import * as Encoding from "effect/Encoding";
|
|
8
|
+
import * as Predicate from "effect/Predicate";
|
|
7
9
|
import * as Result from "effect/Result";
|
|
8
10
|
import * as Schema from "effect/Schema";
|
|
9
11
|
import * as Semaphore from "effect/Semaphore";
|
|
10
12
|
import { BytePath } from "../BytePath.js";
|
|
11
13
|
import { ImageError } from "../Snapshot.js";
|
|
12
|
-
import {
|
|
14
|
+
import { CanonicalBase64 } from "./canonicalBase64.js";
|
|
13
15
|
import * as Image from "./image.js";
|
|
14
|
-
import
|
|
16
|
+
import * as MetadataDomain from "./metadata.js";
|
|
17
|
+
import { ConfigurationError, decodeConfiguration, FsCode as FsCodeSchema, FsError } from "./virtualFileSystem/errors.js";
|
|
15
18
|
import * as Content from "./virtualFileSystem/overlayContent.js";
|
|
16
19
|
import { compareOverlay } from "./virtualFileSystem/overlayDiff.js";
|
|
17
|
-
import {
|
|
20
|
+
import { DOT_DOT_HEX, DOT_HEX, inputBytes, isAttachedBytes, isDotComponent, MAX_NAME_BYTES, nameBytes, ownedPath, preparePath, SLASH_BYTE, SLASH_HEX, strictString } from "./virtualFileSystem/path.js";
|
|
18
21
|
import * as TestHooks from "./virtualFileSystem/testHooks.js";
|
|
19
22
|
import * as WatchHub from "./virtualFileSystem/watchHub.js";
|
|
20
23
|
/** @internal */
|
|
@@ -29,8 +32,22 @@ export const DirectoryHandleId = Symbol("@effect-vfs/core/DirectoryHandle");
|
|
|
29
32
|
export const ObjectReferenceId = Symbol("@effect-vfs/core/ObjectReference");
|
|
30
33
|
/** @internal */
|
|
31
34
|
export { ConfigurationError, FsCodeSchema as FsCode, FsError };
|
|
32
|
-
|
|
33
|
-
|
|
35
|
+
// POSIX permission bits, masked against mode once it is shifted to the caller's class.
|
|
36
|
+
const EXECUTE = 0o1;
|
|
37
|
+
const WRITE = 0o2;
|
|
38
|
+
const READ = 0o4;
|
|
39
|
+
// Execute for every class; a file with none is not executable by anyone.
|
|
40
|
+
const ANY_EXECUTE = 0o111;
|
|
41
|
+
// setuid and setgid, cleared whenever a file's contents or ownership change.
|
|
42
|
+
const SET_ID_BITS = 0o6000;
|
|
43
|
+
// Sticky: only the owner of an entry or of its directory may remove it.
|
|
44
|
+
const STICKY_BIT = 0o1000;
|
|
45
|
+
const WALK_YIELD_INTERVAL = 128;
|
|
46
|
+
// Largest signed 64-bit file offset, as POSIX off_t.
|
|
47
|
+
const MAX_FILE_OFFSET = 0x7fffffffffffffffn;
|
|
48
|
+
const Mode = Schema.Natural.check(Schema.isLessThanOrEqualTo(0o7777));
|
|
49
|
+
const isMode = Schema.is(Mode);
|
|
50
|
+
const isNatural = Schema.is(Schema.Natural);
|
|
34
51
|
/** @internal */
|
|
35
52
|
export const Identity = Schema.Struct({
|
|
36
53
|
uid: Schema.Natural,
|
|
@@ -50,10 +67,7 @@ export const VolumeOptions = Schema.Struct({
|
|
|
50
67
|
maxFileBytes: Schema.optionalKey(Schema.ByteSize.check(Schema.makeFilter((size) => ByteSize.isLessThanOrEqualTo(size, ByteSize.bytes(0xffffffff)) ? undefined : "must be at most 4294967295 bytes"))),
|
|
51
68
|
maxPathBytes: Schema.optionalKey(Schema.ByteSize.check(Schema.makeFilter((size) => ByteSize.isGreaterThanOrEqualTo(size, ByteSize.bytes(1)) ? undefined : "must be at least 1 byte")))
|
|
52
69
|
});
|
|
53
|
-
|
|
54
|
-
const timestampLimit = 10n ** 128n - 1n;
|
|
55
|
-
/** @internal */
|
|
56
|
-
export const Timestamp = Schema.BigInt.check(Schema.isGreaterThanOrEqualToBigInt(-timestampLimit), Schema.isLessThanOrEqualToBigInt(timestampLimit));
|
|
70
|
+
const isTimestamp = Schema.is(MetadataDomain.Timestamp);
|
|
57
71
|
/** @internal */
|
|
58
72
|
export const Metadata = Schema.Struct({
|
|
59
73
|
kind: Schema.Literals(["directory", "file", "symlink"]),
|
|
@@ -63,10 +77,10 @@ export const Metadata = Schema.Struct({
|
|
|
63
77
|
uid: Schema.Natural,
|
|
64
78
|
gid: Schema.Natural,
|
|
65
79
|
mode: Mode,
|
|
66
|
-
atimeNs: Timestamp,
|
|
67
|
-
mtimeNs: Timestamp,
|
|
68
|
-
ctimeNs: Timestamp,
|
|
69
|
-
birthtimeNs: Timestamp
|
|
80
|
+
atimeNs: MetadataDomain.Timestamp,
|
|
81
|
+
mtimeNs: MetadataDomain.Timestamp,
|
|
82
|
+
ctimeNs: MetadataDomain.Timestamp,
|
|
83
|
+
birthtimeNs: MetadataDomain.Timestamp
|
|
70
84
|
});
|
|
71
85
|
/** @internal */
|
|
72
86
|
export const OwnerUpdate = Schema.Struct({
|
|
@@ -77,12 +91,13 @@ export const OwnerUpdate = Schema.Struct({
|
|
|
77
91
|
export const TimeUpdate = Schema.Union([
|
|
78
92
|
Schema.Struct({ kind: Schema.Literal("now") }),
|
|
79
93
|
Schema.Struct({ kind: Schema.Literal("omit") }),
|
|
80
|
-
Schema.Struct({ kind: Schema.Literal("value"), nanoseconds: Timestamp })
|
|
94
|
+
Schema.Struct({ kind: Schema.Literal("value"), nanoseconds: MetadataDomain.Timestamp })
|
|
81
95
|
]);
|
|
82
96
|
/** @internal */
|
|
83
97
|
export const Times = Schema.Struct({ access: TimeUpdate, modification: TimeUpdate });
|
|
84
98
|
/** @internal */
|
|
85
99
|
export const SeekMode = Schema.Literals(["start", "current", "end", "data", "hole"]);
|
|
100
|
+
const isSeekMode = Schema.is(SeekMode);
|
|
86
101
|
/** @internal */
|
|
87
102
|
export const OpenSettings = Schema.Struct({
|
|
88
103
|
access: Schema.Literals(["read", "write", "readWrite"]),
|
|
@@ -92,12 +107,15 @@ export const OpenSettings = Schema.Struct({
|
|
|
92
107
|
truncate: Schema.optionalKey(Schema.Boolean),
|
|
93
108
|
followFinalSymlink: Schema.optionalKey(Schema.Boolean)
|
|
94
109
|
});
|
|
95
|
-
|
|
96
|
-
export const WriteFileSettings = Schema.Struct({
|
|
110
|
+
const WriteFileSettings = Schema.Struct({
|
|
97
111
|
...OpenSettings.fields,
|
|
98
112
|
replaceFinalSymlink: Schema.optionalKey(Schema.Boolean),
|
|
99
113
|
finalMode: Schema.optionalKey(Mode)
|
|
100
114
|
});
|
|
115
|
+
const decodeOwnerUpdate = Schema.decodeResult(OwnerUpdate, { onExcessProperty: "error" });
|
|
116
|
+
const decodeTimes = Schema.decodeResult(Times, { onExcessProperty: "error" });
|
|
117
|
+
const decodeWriteFileSettings = Schema.decodeResult(WriteFileSettings, { onExcessProperty: "error" });
|
|
118
|
+
const decodeOpenSettings = Schema.decodeResult(OpenSettings, { onExcessProperty: "error" });
|
|
101
119
|
/** @internal */
|
|
102
120
|
export const OverlayNodeKind = Schema.Literals(["directory", "file", "symlink"]);
|
|
103
121
|
/** @internal */
|
|
@@ -143,40 +161,46 @@ export const FixtureMetadata = Schema.Struct({
|
|
|
143
161
|
uid: Schema.optionalKey(Schema.Natural),
|
|
144
162
|
gid: Schema.optionalKey(Schema.Natural),
|
|
145
163
|
mode: Schema.optionalKey(Mode),
|
|
146
|
-
atimeNs: Schema.optionalKey(Timestamp),
|
|
147
|
-
mtimeNs: Schema.optionalKey(Timestamp),
|
|
148
|
-
ctimeNs: Schema.optionalKey(Timestamp),
|
|
149
|
-
birthtimeNs: Schema.optionalKey(Timestamp)
|
|
164
|
+
atimeNs: Schema.optionalKey(MetadataDomain.Timestamp),
|
|
165
|
+
mtimeNs: Schema.optionalKey(MetadataDomain.Timestamp),
|
|
166
|
+
ctimeNs: Schema.optionalKey(MetadataDomain.Timestamp),
|
|
167
|
+
birthtimeNs: Schema.optionalKey(MetadataDomain.Timestamp)
|
|
150
168
|
});
|
|
151
169
|
const FixturePath = Schema.Union([
|
|
152
170
|
Schema.String,
|
|
153
171
|
BytePath
|
|
154
172
|
]);
|
|
155
173
|
/** @internal */
|
|
174
|
+
export const FixtureEntry = Schema.Union([
|
|
175
|
+
Schema.Struct({
|
|
176
|
+
kind: Schema.Literal("directory"),
|
|
177
|
+
path: FixturePath,
|
|
178
|
+
metadata: Schema.optionalKey(FixtureMetadata)
|
|
179
|
+
}),
|
|
180
|
+
Schema.Struct({
|
|
181
|
+
kind: Schema.Literal("file"),
|
|
182
|
+
path: FixturePath,
|
|
183
|
+
bytes: Schema.Uint8Array,
|
|
184
|
+
metadata: Schema.optionalKey(FixtureMetadata)
|
|
185
|
+
}),
|
|
186
|
+
Schema.Struct({
|
|
187
|
+
kind: Schema.Literal("symlink"),
|
|
188
|
+
path: FixturePath,
|
|
189
|
+
target: FixturePath,
|
|
190
|
+
metadata: Schema.optionalKey(FixtureMetadata)
|
|
191
|
+
}),
|
|
192
|
+
Schema.Struct({ kind: Schema.Literal("hardLink"), path: FixturePath, target: FixturePath })
|
|
193
|
+
]).pipe(Schema.toTaggedUnion("kind"));
|
|
194
|
+
/** @internal */
|
|
156
195
|
export const Fixture = Schema.Struct({
|
|
157
196
|
rootMetadata: Schema.optionalKey(FixtureMetadata),
|
|
158
|
-
entries: Schema.Array(
|
|
159
|
-
Schema.Struct({
|
|
160
|
-
kind: Schema.Literal("directory"),
|
|
161
|
-
path: FixturePath,
|
|
162
|
-
metadata: Schema.optionalKey(FixtureMetadata)
|
|
163
|
-
}),
|
|
164
|
-
Schema.Struct({
|
|
165
|
-
kind: Schema.Literal("file"),
|
|
166
|
-
path: FixturePath,
|
|
167
|
-
bytes: Schema.Uint8Array,
|
|
168
|
-
metadata: Schema.optionalKey(FixtureMetadata)
|
|
169
|
-
}),
|
|
170
|
-
Schema.Struct({
|
|
171
|
-
kind: Schema.Literal("symlink"),
|
|
172
|
-
path: FixturePath,
|
|
173
|
-
target: FixturePath,
|
|
174
|
-
metadata: Schema.optionalKey(FixtureMetadata)
|
|
175
|
-
}),
|
|
176
|
-
Schema.Struct({ kind: Schema.Literal("hardLink"), path: FixturePath, target: FixturePath })
|
|
177
|
-
]))
|
|
197
|
+
entries: Schema.Array(FixtureEntry)
|
|
178
198
|
});
|
|
179
199
|
const objectReferences = new WeakMap();
|
|
200
|
+
const isFileHandle = (value) => Predicate.hasProperty(FileHandleId)(value);
|
|
201
|
+
// Only a PathInput target names a path; a handle-based call has none to report in its error.
|
|
202
|
+
const pathOf = (target) => isFileHandle(target) || isDirectoryHandle(target) ? undefined : target;
|
|
203
|
+
const isDirectoryHandle = (value) => Predicate.hasProperty(DirectoryHandleId)(value);
|
|
180
204
|
const files = new WeakMap();
|
|
181
205
|
const handles = new WeakMap();
|
|
182
206
|
const directoryMetadata = (ino, uid, gid, mode, now) => ({
|
|
@@ -196,27 +220,32 @@ const storedMetadata = (metadata) => ({
|
|
|
196
220
|
uid: metadata.uid,
|
|
197
221
|
gid: metadata.gid,
|
|
198
222
|
mode: metadata.mode,
|
|
199
|
-
atimeNs:
|
|
200
|
-
mtimeNs:
|
|
201
|
-
ctimeNs:
|
|
202
|
-
birthtimeNs:
|
|
223
|
+
atimeNs: metadata.atimeNs,
|
|
224
|
+
mtimeNs: metadata.mtimeNs,
|
|
225
|
+
ctimeNs: metadata.ctimeNs,
|
|
226
|
+
birthtimeNs: metadata.birthtimeNs
|
|
203
227
|
});
|
|
228
|
+
/** @internal */
|
|
229
|
+
export const VolumeSource = Data.taggedEnum();
|
|
230
|
+
const UpdateChange = Schema.TaggedStruct("Update", { path: BytePath });
|
|
204
231
|
// Each execution constructs a fresh volume and captures its Clock.
|
|
205
232
|
/** @internal */
|
|
206
233
|
export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
207
|
-
const image =
|
|
234
|
+
const image = "image" in source ? source.image : undefined;
|
|
208
235
|
const decoded = decodeConfiguration(VolumeOptions, options === undefined ? {} : options);
|
|
209
236
|
if (Result.isFailure(decoded))
|
|
210
237
|
return yield* decoded.failure;
|
|
211
238
|
const settings = { ...decoded.success };
|
|
212
239
|
const clock = yield* Clock.clockWith(Effect.succeed);
|
|
213
240
|
const initialTime = clock.currentTimeNanosUnsafe();
|
|
214
|
-
if (!
|
|
241
|
+
if (!isTimestamp(initialTime)) {
|
|
215
242
|
return yield* new ConfigurationError({ field: "clock.currentTimeNanos" });
|
|
216
243
|
}
|
|
217
244
|
const timestamp = (operation) => Effect.suspend(() => {
|
|
218
245
|
const now = clock.currentTimeNanosUnsafe();
|
|
219
|
-
return
|
|
246
|
+
return isTimestamp(now)
|
|
247
|
+
? Effect.succeed(now)
|
|
248
|
+
: Effect.fail(new FsError({ code: "InvalidArgument", operation: operation }));
|
|
220
249
|
});
|
|
221
250
|
const volumeIdentity = Symbol();
|
|
222
251
|
const gate = Semaphore.makeUnsafe(1);
|
|
@@ -238,16 +267,19 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
238
267
|
const maxFileBytes = Number(ByteSize.toBigInt(settings.maxFileBytes ?? ByteSize.bytes(0xffffffff)));
|
|
239
268
|
// Permit waits stay interruptible. State transitions and resource registration do not.
|
|
240
269
|
const coordinated = (effect) => gate.withPermit(Effect.uninterruptible(effect));
|
|
270
|
+
// Read-only walks hold the permit but mutate nothing, so abandoning one midway loses only
|
|
271
|
+
// local state. Interruptible so a cancelled snapshot actually stops.
|
|
272
|
+
const coordinatedRead = (effect) => gate.withPermit(effect);
|
|
241
273
|
if (image !== undefined) {
|
|
242
274
|
const incoming = new Map();
|
|
243
275
|
let content = 0n;
|
|
244
276
|
let count = 0;
|
|
245
277
|
for (const record of image.records) {
|
|
246
|
-
if (
|
|
278
|
+
if (Image.Record.guards.directory(record))
|
|
247
279
|
count += record.entries.length;
|
|
248
280
|
else {
|
|
249
|
-
const length =
|
|
250
|
-
if (
|
|
281
|
+
const length = CanonicalBase64.decodedLength(Image.Record.guards.file(record) ? record.data : record.target);
|
|
282
|
+
if (Image.Record.guards.file(record) && length > maxFileBytes) {
|
|
251
283
|
return yield* new ImageError({ code: "LimitExceeded", field: "maxFileBytes" });
|
|
252
284
|
}
|
|
253
285
|
content += BigInt(length);
|
|
@@ -257,20 +289,20 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
257
289
|
(settings.maxBytes !== undefined && content > ByteSize.toBigInt(settings.maxBytes))) {
|
|
258
290
|
return yield* new ImageError({ code: "LimitExceeded", field: "volume" });
|
|
259
291
|
}
|
|
260
|
-
const baseContents =
|
|
292
|
+
const baseContents = Predicate.isTagged("Overlay")(source) ? Content.forOverlay(source.base, image) : undefined;
|
|
261
293
|
for (const record of image.records) {
|
|
262
294
|
const metadata = {
|
|
263
295
|
...record.metadata,
|
|
264
|
-
kind: record.
|
|
296
|
+
kind: record._tag,
|
|
265
297
|
ino: record.id === image.root ? 1n : nextInode++,
|
|
266
|
-
nlink:
|
|
298
|
+
nlink: Image.Record.guards.directory(record) ? 2 : 0,
|
|
267
299
|
size: 0n,
|
|
268
|
-
atimeNs:
|
|
269
|
-
mtimeNs:
|
|
270
|
-
ctimeNs:
|
|
271
|
-
birthtimeNs:
|
|
300
|
+
atimeNs: record.metadata.atimeNs,
|
|
301
|
+
mtimeNs: record.metadata.mtimeNs,
|
|
302
|
+
ctimeNs: record.metadata.ctimeNs,
|
|
303
|
+
birthtimeNs: record.metadata.birthtimeNs
|
|
272
304
|
};
|
|
273
|
-
if (
|
|
305
|
+
if (Image.Record.guards.directory(record)) {
|
|
274
306
|
const node = record.id === image.root
|
|
275
307
|
? root
|
|
276
308
|
: {
|
|
@@ -285,8 +317,8 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
285
317
|
node.metadata = metadata;
|
|
286
318
|
incoming.set(record.id, node);
|
|
287
319
|
}
|
|
288
|
-
else if (
|
|
289
|
-
const data = baseContents?.get(record.id) ?? Content.make(
|
|
320
|
+
else if (Image.Record.guards.file(record)) {
|
|
321
|
+
const data = baseContents?.get(record.id) ?? Content.make(yield* CanonicalBase64.decode(record.data));
|
|
290
322
|
incoming.set(record.id, {
|
|
291
323
|
kind: "file",
|
|
292
324
|
lineage: record.id,
|
|
@@ -298,7 +330,7 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
298
330
|
});
|
|
299
331
|
}
|
|
300
332
|
else {
|
|
301
|
-
const target =
|
|
333
|
+
const target = yield* CanonicalBase64.decode(record.target);
|
|
302
334
|
incoming.set(record.id, {
|
|
303
335
|
kind: "symlink",
|
|
304
336
|
lineage: record.id,
|
|
@@ -310,7 +342,7 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
310
342
|
}
|
|
311
343
|
}
|
|
312
344
|
for (const record of image.records) {
|
|
313
|
-
if (
|
|
345
|
+
if (!Image.Record.guards.directory(record))
|
|
314
346
|
continue;
|
|
315
347
|
const parent = incoming.get(record.id);
|
|
316
348
|
if (parent?.kind !== "directory")
|
|
@@ -319,7 +351,7 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
319
351
|
const node = incoming.get(entry.target);
|
|
320
352
|
if (node === undefined)
|
|
321
353
|
return yield* new ImageError({ code: "InvalidStructure" });
|
|
322
|
-
parent.entries.set(Encoding.encodeHex(
|
|
354
|
+
parent.entries.set(Encoding.encodeHex(yield* CanonicalBase64.decode(entry.name)), node);
|
|
323
355
|
if (node.kind === "directory") {
|
|
324
356
|
node.parent = parent;
|
|
325
357
|
parent.metadata = { ...parent.metadata, nlink: parent.metadata.nlink + 1 };
|
|
@@ -343,22 +375,30 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
343
375
|
names.push(found[0]);
|
|
344
376
|
current = parent;
|
|
345
377
|
}
|
|
346
|
-
return
|
|
378
|
+
return SLASH_HEX + names.reverse().join(SLASH_HEX);
|
|
347
379
|
};
|
|
348
380
|
const publishEntry = (_tag, parent, name) => {
|
|
349
381
|
watchHub.publishUnsafe(() => {
|
|
350
382
|
const prefix = directoryHex(parent);
|
|
351
|
-
return { _tag, path: ownedPath(nameBytes(prefix + (prefix ===
|
|
383
|
+
return { _tag, path: ownedPath(nameBytes(prefix + (prefix === SLASH_HEX ? "" : SLASH_HEX) + name)) };
|
|
352
384
|
});
|
|
353
385
|
};
|
|
354
386
|
const publishNode = (target) => {
|
|
387
|
+
// Directories are never hard linked, so one name reaches them and the parent chain resolves it.
|
|
388
|
+
if (target.kind === "directory") {
|
|
389
|
+
// Removal and rename displacement drop the link count while open handles keep reaching the node.
|
|
390
|
+
// No name resolves it any more, so its empty parent chain would otherwise read as the root path.
|
|
391
|
+
// The file scan below stops on the same signal, and `lookup` already reads it as NotFound.
|
|
392
|
+
if (target.metadata.nlink === 0)
|
|
393
|
+
return;
|
|
394
|
+
watchHub.publishUnsafe(() => UpdateChange.make({ path: ownedPath(nameBytes(directoryHex(target))) }));
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
355
397
|
watchHub.publishManyUnsafe(() => {
|
|
356
398
|
const changes = [];
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
const pending = [[root, "2f"]];
|
|
361
|
-
while (pending.length > 0) {
|
|
399
|
+
const pending = [[root, SLASH_HEX]];
|
|
400
|
+
// nlink counts the names bound to this node, so the scan stops once it has found them all.
|
|
401
|
+
while (pending.length > 0 && changes.length < target.metadata.nlink) {
|
|
362
402
|
const next = pending.pop();
|
|
363
403
|
if (next === undefined)
|
|
364
404
|
break;
|
|
@@ -366,10 +406,10 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
366
406
|
for (const [name, node] of directory.entries) {
|
|
367
407
|
const path = prefix + name;
|
|
368
408
|
if (node === target) {
|
|
369
|
-
changes.push({
|
|
409
|
+
changes.push(UpdateChange.make({ path: ownedPath(nameBytes(path)) }));
|
|
370
410
|
}
|
|
371
411
|
if (node.kind === "directory")
|
|
372
|
-
pending.push([node, path +
|
|
412
|
+
pending.push([node, path + SLASH_HEX]);
|
|
373
413
|
}
|
|
374
414
|
}
|
|
375
415
|
return changes;
|
|
@@ -380,6 +420,8 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
380
420
|
const pending = [root];
|
|
381
421
|
const records = [];
|
|
382
422
|
for (let index = 0; index < pending.length; index++) {
|
|
423
|
+
if (index % WALK_YIELD_INTERVAL === 0)
|
|
424
|
+
yield* Effect.yieldNow;
|
|
383
425
|
const node = pending[index];
|
|
384
426
|
if (node === undefined)
|
|
385
427
|
continue;
|
|
@@ -396,22 +438,25 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
396
438
|
ids.set(child, target);
|
|
397
439
|
pending.push(child);
|
|
398
440
|
}
|
|
399
|
-
children.push({ name:
|
|
441
|
+
children.push({ name: CanonicalBase64.encode(nameBytes(name)), target });
|
|
400
442
|
}
|
|
401
|
-
records.push({ id,
|
|
443
|
+
records.push(Image.Record.cases.directory.make({ id, metadata, entries: children }));
|
|
402
444
|
}
|
|
403
445
|
else if (node.kind === "file") {
|
|
404
|
-
records.push({ id,
|
|
446
|
+
records.push(Image.Record.cases.file.make({ id, metadata, data: CanonicalBase64.encode(node.data.bytes) }));
|
|
447
|
+
}
|
|
448
|
+
else {
|
|
449
|
+
records.push(Image.Record.cases.symlink.make({ id, metadata, target: CanonicalBase64.encode(node.target) }));
|
|
405
450
|
}
|
|
406
|
-
else
|
|
407
|
-
records.push({ id, kind: "symlink", metadata, target: Image.base64(node.target) });
|
|
408
451
|
}
|
|
409
|
-
return yield* Image.capture({ format: "effect-vfs", version: 1, root: "0", records });
|
|
452
|
+
return yield* Image.capture({ format: "effect-vfs", version: 1, root: "0", records }, undefined, true);
|
|
410
453
|
});
|
|
411
|
-
const observeChanges = ()
|
|
454
|
+
const observeChanges = Effect.fnUntraced(function* () {
|
|
412
455
|
const observation = [];
|
|
413
|
-
const paths = [[root, new Uint8Array([
|
|
456
|
+
const paths = [[root, new Uint8Array([SLASH_BYTE])]];
|
|
414
457
|
for (let index = 0; index < paths.length; index++) {
|
|
458
|
+
if (index % WALK_YIELD_INTERVAL === 0)
|
|
459
|
+
yield* Effect.yieldNow;
|
|
415
460
|
const current = paths[index];
|
|
416
461
|
if (current === undefined)
|
|
417
462
|
continue;
|
|
@@ -431,18 +476,18 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
431
476
|
childPath.set(path);
|
|
432
477
|
let offset = path.length;
|
|
433
478
|
if (path.length !== 1)
|
|
434
|
-
childPath[offset++] =
|
|
479
|
+
childPath[offset++] = SLASH_BYTE;
|
|
435
480
|
childPath.set(bytes, offset);
|
|
436
481
|
paths.push([child, childPath]);
|
|
437
482
|
}
|
|
438
483
|
}
|
|
439
484
|
return observation;
|
|
440
|
-
};
|
|
485
|
+
});
|
|
441
486
|
const captureState = Effect.fnUntraced(function* (hook) {
|
|
442
487
|
const snapshot = yield* captureSnapshot();
|
|
443
488
|
if (hook !== undefined)
|
|
444
489
|
yield* hook.betweenSnapshotAndSummary;
|
|
445
|
-
return { snapshot, observation: observeChanges() };
|
|
490
|
+
return { snapshot, observation: yield* observeChanges() };
|
|
446
491
|
});
|
|
447
492
|
const advanceRevision = (node) => {
|
|
448
493
|
node.revision = nextRevision();
|
|
@@ -468,10 +513,10 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
468
513
|
reference.directory = undefined;
|
|
469
514
|
reference.closed = true;
|
|
470
515
|
}));
|
|
471
|
-
const authorize = (
|
|
516
|
+
const authorize = (node, identity, bits, operation, path) => {
|
|
472
517
|
if (identity.privileged)
|
|
473
518
|
return Effect.void;
|
|
474
|
-
const metadata =
|
|
519
|
+
const metadata = node.metadata;
|
|
475
520
|
const shift = metadata.uid === identity.uid ?
|
|
476
521
|
6
|
|
477
522
|
: metadata.gid === identity.gid || identity.groups.includes(metadata.gid)
|
|
@@ -479,7 +524,9 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
479
524
|
: 0;
|
|
480
525
|
return ((metadata.mode >> shift) & bits) === bits
|
|
481
526
|
? Effect.void
|
|
482
|
-
: Effect.fail(
|
|
527
|
+
: Effect.fail(path === undefined
|
|
528
|
+
? new FsError({ code: "AccessDenied", operation })
|
|
529
|
+
: new FsError({ code: "AccessDenied", operation, path }));
|
|
483
530
|
};
|
|
484
531
|
const reclaim = (file) => {
|
|
485
532
|
if (file.metadata.nlink === 0 && file.openCount === 0) {
|
|
@@ -488,6 +535,19 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
488
535
|
invalidateReference(file);
|
|
489
536
|
}
|
|
490
537
|
};
|
|
538
|
+
// Whether the volume's entry quota leaves room for one more name.
|
|
539
|
+
const atEntryLimit = () => settings.maxEntries !== undefined && entries >= settings.maxEntries;
|
|
540
|
+
// A new subdirectory's ".." entry is a second link to the parent; other node kinds add none.
|
|
541
|
+
const attach = (parent, name, node, now) => {
|
|
542
|
+
parent.entries.set(name, node);
|
|
543
|
+
parent.metadata = {
|
|
544
|
+
...parent.metadata,
|
|
545
|
+
nlink: parent.metadata.nlink + (node.kind === "directory" ? 1 : 0),
|
|
546
|
+
mtimeNs: now,
|
|
547
|
+
ctimeNs: now
|
|
548
|
+
};
|
|
549
|
+
advanceRevision(parent);
|
|
550
|
+
};
|
|
491
551
|
const detach = (node, now) => {
|
|
492
552
|
if (node.kind === "directory") {
|
|
493
553
|
node.parent = undefined;
|
|
@@ -514,43 +574,47 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
514
574
|
}
|
|
515
575
|
ref.closed = true;
|
|
516
576
|
};
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
if (length > BigInt(maxFileBytes))
|
|
521
|
-
return yield* failure("FileTooLarge", operation);
|
|
522
|
-
const size = Number(length);
|
|
523
|
-
if (settings.maxBytes !== undefined &&
|
|
524
|
-
BigInt(size - file.data.bytes.length) > ByteSize.toBigInt(settings.maxBytes) - usedBytes) {
|
|
525
|
-
return yield* failure("NoSpace", operation);
|
|
526
|
-
}
|
|
527
|
-
const data = new Uint8Array(size);
|
|
528
|
-
data.set(file.data.bytes.subarray(0, size));
|
|
529
|
-
const now = yield* timestamp(operation);
|
|
530
|
-
usedBytes += BigInt(size - file.data.bytes.length);
|
|
577
|
+
// Replacing a payload clears setuid and setgid, and charges the volume for the size delta.
|
|
578
|
+
const replaceContent = (file, data, now) => {
|
|
579
|
+
usedBytes += BigInt(data.length - file.data.bytes.length);
|
|
531
580
|
file.data = Content.make(data);
|
|
532
581
|
file.metadata = {
|
|
533
582
|
...file.metadata,
|
|
534
|
-
size: length,
|
|
535
|
-
mode: file.metadata.mode & ~
|
|
583
|
+
size: BigInt(data.length),
|
|
584
|
+
mode: file.metadata.mode & ~SET_ID_BITS,
|
|
536
585
|
mtimeNs: now,
|
|
537
586
|
ctimeNs: now
|
|
538
587
|
};
|
|
539
588
|
advanceRevision(file);
|
|
540
589
|
publishNode(file);
|
|
590
|
+
};
|
|
591
|
+
const resize = Effect.fnUntraced(function* (file, length, operation) {
|
|
592
|
+
if (!Predicate.isBigInt(length) || length < 0n) {
|
|
593
|
+
return yield* new FsError({ code: "InvalidArgument", operation: operation });
|
|
594
|
+
}
|
|
595
|
+
if (length > BigInt(maxFileBytes))
|
|
596
|
+
return yield* new FsError({ code: "FileTooLarge", operation: operation });
|
|
597
|
+
const size = Number(length);
|
|
598
|
+
if (settings.maxBytes !== undefined &&
|
|
599
|
+
BigInt(size - file.data.bytes.length) > ByteSize.toBigInt(settings.maxBytes) - usedBytes) {
|
|
600
|
+
return yield* new FsError({ code: "NoSpace", operation: operation });
|
|
601
|
+
}
|
|
602
|
+
const data = new Uint8Array(size);
|
|
603
|
+
data.set(file.data.bytes.subarray(0, size));
|
|
604
|
+
replaceContent(file, data, yield* timestamp(operation));
|
|
541
605
|
});
|
|
542
606
|
const fileHandle = (ref) => {
|
|
543
607
|
const get = (operation, access) => ref.file === undefined || (access === "read" && ref.access === "write") ||
|
|
544
608
|
(access === "write" && ref.access === "read")
|
|
545
|
-
? Effect.fail(
|
|
609
|
+
? Effect.fail(new FsError({ code: "InvalidHandle", operation: operation }))
|
|
546
610
|
: Effect.succeed(ref.file);
|
|
547
611
|
const read = Effect.fnUntraced(function* (maximum, position) {
|
|
548
612
|
const file = yield* get(position === undefined ? "read" : "pread", "read");
|
|
549
|
-
if (!
|
|
550
|
-
return yield*
|
|
613
|
+
if (!isNatural(maximum))
|
|
614
|
+
return yield* new FsError({ code: "InvalidArgument", operation: "read" });
|
|
551
615
|
const offset = position ?? ref.offset;
|
|
552
|
-
if (
|
|
553
|
-
return yield*
|
|
616
|
+
if (!Predicate.isBigInt(offset) || offset < 0n || offset > MAX_FILE_OFFSET) {
|
|
617
|
+
return yield* new FsError({ code: "InvalidArgument", operation: "read" });
|
|
554
618
|
}
|
|
555
619
|
const start = Number(offset > file.metadata.size ? file.metadata.size : offset);
|
|
556
620
|
const data = file.data.bytes.slice(start, start + Math.min(maximum, file.data.bytes.length - start));
|
|
@@ -562,21 +626,20 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
562
626
|
return data;
|
|
563
627
|
}, coordinated);
|
|
564
628
|
const write = Effect.fnUntraced(function* (input, position) {
|
|
565
|
-
if (!(input
|
|
566
|
-
return yield*
|
|
567
|
-
}
|
|
629
|
+
if (!isAttachedBytes(input))
|
|
630
|
+
return yield* new FsError({ code: "InvalidArgument", operation: "write" });
|
|
568
631
|
const bytes = new Uint8Array(input);
|
|
569
632
|
return yield* coordinated(Effect.gen(function* () {
|
|
570
633
|
const file = yield* get(position === undefined ? "write" : "pwrite", "write");
|
|
571
634
|
const offset = position ?? (ref.append ? file.metadata.size : ref.offset);
|
|
572
|
-
if (
|
|
573
|
-
return yield*
|
|
635
|
+
if (!Predicate.isBigInt(offset) || offset < 0n || offset > MAX_FILE_OFFSET) {
|
|
636
|
+
return yield* new FsError({ code: "InvalidArgument", operation: "write" });
|
|
574
637
|
}
|
|
575
638
|
if (bytes.length === 0) {
|
|
576
639
|
return 0;
|
|
577
640
|
}
|
|
578
641
|
if (offset >= BigInt(maxFileBytes))
|
|
579
|
-
return yield*
|
|
642
|
+
return yield* new FsError({ code: "FileTooLarge", operation: "write" });
|
|
580
643
|
const start = Number(offset);
|
|
581
644
|
const free = settings.maxBytes === undefined
|
|
582
645
|
? BigInt(maxFileBytes)
|
|
@@ -585,7 +648,7 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
585
648
|
const end = Number(BigInt(maxFileBytes) < maximumEnd ? BigInt(maxFileBytes) : maximumEnd);
|
|
586
649
|
const count = Math.min(bytes.length, Math.max(0, end - start));
|
|
587
650
|
if (count === 0)
|
|
588
|
-
return yield*
|
|
651
|
+
return yield* new FsError({ code: "NoSpace", operation: "write" });
|
|
589
652
|
const size = Math.max(file.data.bytes.length, start + count);
|
|
590
653
|
// Always detach before mutation. A same-sized write is the critical
|
|
591
654
|
// case: the current payload may belong to the base or a prior capture.
|
|
@@ -593,17 +656,7 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
593
656
|
data.set(file.data.bytes);
|
|
594
657
|
const now = yield* timestamp("write");
|
|
595
658
|
data.set(bytes.subarray(0, count), start);
|
|
596
|
-
|
|
597
|
-
file.data = Content.make(data);
|
|
598
|
-
file.metadata = {
|
|
599
|
-
...file.metadata,
|
|
600
|
-
size: BigInt(size),
|
|
601
|
-
mode: file.metadata.mode & ~0o6000,
|
|
602
|
-
mtimeNs: now,
|
|
603
|
-
ctimeNs: now
|
|
604
|
-
};
|
|
605
|
-
advanceRevision(file);
|
|
606
|
-
publishNode(file);
|
|
659
|
+
replaceContent(file, data, now);
|
|
607
660
|
if (position === undefined)
|
|
608
661
|
ref.offset = offset + BigInt(count);
|
|
609
662
|
return count;
|
|
@@ -626,15 +679,16 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
626
679
|
seek: Effect.fn("FileHandle.seek")(function* (offset, mode) {
|
|
627
680
|
return yield* coordinated(Effect.gen(function* () {
|
|
628
681
|
const file = yield* get("seek");
|
|
629
|
-
if (
|
|
630
|
-
return yield*
|
|
682
|
+
if (!Predicate.isBigInt(offset) || !isSeekMode(mode)) {
|
|
683
|
+
return yield* new FsError({ code: "InvalidArgument", operation: "seek" });
|
|
631
684
|
}
|
|
632
685
|
let next = mode === "current" ? ref.offset + offset : mode === "end" ? file.metadata.size + offset : offset;
|
|
633
|
-
if (next < 0n || next >
|
|
634
|
-
return yield*
|
|
686
|
+
if (next < 0n || next > MAX_FILE_OFFSET) {
|
|
687
|
+
return yield* new FsError({ code: "InvalidArgument", operation: "seek" });
|
|
688
|
+
}
|
|
635
689
|
if (mode === "data" || mode === "hole") {
|
|
636
690
|
if (offset >= file.metadata.size)
|
|
637
|
-
return yield*
|
|
691
|
+
return yield* new FsError({ code: "NoData", operation: "seek" });
|
|
638
692
|
if (mode === "hole")
|
|
639
693
|
next = file.metadata.size;
|
|
640
694
|
}
|
|
@@ -662,50 +716,57 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
662
716
|
const createCaller = (reference, identity, umask) => {
|
|
663
717
|
const referencedNode = Effect.fnUntraced(function* (target, operation) {
|
|
664
718
|
if (reference.directory === undefined)
|
|
665
|
-
return yield*
|
|
666
|
-
if (
|
|
667
|
-
return yield*
|
|
719
|
+
return yield* new FsError({ code: "ClosedCaller", operation: operation });
|
|
720
|
+
if (!Predicate.isObject(target))
|
|
721
|
+
return yield* new FsError({ code: "InvalidReference", operation: operation });
|
|
668
722
|
const state = objectReferences.get(target);
|
|
669
723
|
if (state === undefined)
|
|
670
|
-
return yield*
|
|
671
|
-
if (state.volume !== volumeIdentity)
|
|
672
|
-
return yield*
|
|
724
|
+
return yield* new FsError({ code: "InvalidReference", operation: operation });
|
|
725
|
+
if (state.volume !== volumeIdentity) {
|
|
726
|
+
return yield* new FsError({ code: "ForeignReference", operation: operation });
|
|
727
|
+
}
|
|
673
728
|
if (state.node === undefined)
|
|
674
|
-
return yield*
|
|
729
|
+
return yield* new FsError({ code: "StaleReference", operation: operation });
|
|
675
730
|
return state.node;
|
|
676
731
|
});
|
|
677
732
|
const lookup = Effect.fnUntraced(function* (path, base, operation, options = {}) {
|
|
678
733
|
const { followFinalSymlink = true, allowMissing = false, parentOnly = false } = options;
|
|
679
|
-
if (reference.directory === undefined)
|
|
680
|
-
return yield*
|
|
734
|
+
if (reference.directory === undefined) {
|
|
735
|
+
return yield* new FsError({ code: "ClosedCaller", operation: operation, path: path.input });
|
|
736
|
+
}
|
|
681
737
|
let current = path.absolute ? root : reference.directory;
|
|
682
738
|
if (!path.absolute && base !== undefined) {
|
|
683
739
|
const target = handles.get(base);
|
|
684
|
-
if (target === undefined)
|
|
685
|
-
return yield*
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
740
|
+
if (target === undefined) {
|
|
741
|
+
return yield* new FsError({ code: "InvalidHandle", operation: operation, path: path.input });
|
|
742
|
+
}
|
|
743
|
+
if (target.volume !== volumeIdentity) {
|
|
744
|
+
return yield* new FsError({ code: "ForeignHandle", operation: operation, path: path.input });
|
|
745
|
+
}
|
|
746
|
+
if (target.directory === undefined) {
|
|
747
|
+
return yield* new FsError({ code: "InvalidHandle", operation: operation, path: path.input });
|
|
748
|
+
}
|
|
690
749
|
current = target.directory;
|
|
691
|
-
yield* authorize(current, identity,
|
|
750
|
+
yield* authorize(current, identity, EXECUTE, operation, path.input);
|
|
751
|
+
}
|
|
752
|
+
if (current.metadata.nlink === 0) {
|
|
753
|
+
return yield* new FsError({ code: "NotFound", operation: operation, path: path.input });
|
|
692
754
|
}
|
|
693
|
-
if (current.metadata.nlink === 0)
|
|
694
|
-
return yield* failure("NotFound", operation, path.input);
|
|
695
755
|
let work = path;
|
|
696
756
|
let parent;
|
|
697
757
|
let name;
|
|
698
758
|
let traversals = 0;
|
|
699
759
|
for (let index = 0; index < work.components.length - (parentOnly ? 1 : 0); index++) {
|
|
700
|
-
if (current.kind !== "directory")
|
|
701
|
-
return yield*
|
|
702
|
-
|
|
760
|
+
if (current.kind !== "directory") {
|
|
761
|
+
return yield* new FsError({ code: "NotDirectory", operation: operation, path: path.input });
|
|
762
|
+
}
|
|
763
|
+
yield* authorize(current, identity, EXECUTE, operation, path.input);
|
|
703
764
|
const component = work.components[index];
|
|
704
765
|
if (component === undefined)
|
|
705
766
|
break;
|
|
706
|
-
if (component ===
|
|
767
|
+
if (component === DOT_HEX)
|
|
707
768
|
continue;
|
|
708
|
-
if (component ===
|
|
769
|
+
if (component === DOT_DOT_HEX) {
|
|
709
770
|
current = current.parent ?? current;
|
|
710
771
|
parent = undefined;
|
|
711
772
|
name = undefined;
|
|
@@ -718,22 +779,30 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
718
779
|
if (allowMissing && index === work.components.length - 1 && !work.trailingSlash) {
|
|
719
780
|
return { node: undefined, parent, name };
|
|
720
781
|
}
|
|
721
|
-
return yield*
|
|
782
|
+
return yield* new FsError({ code: "NotFound", operation: operation, path: path.input });
|
|
722
783
|
}
|
|
723
784
|
if (child.kind === "symlink" && (followFinalSymlink || index < work.components.length - 1 || work.trailingSlash)) {
|
|
724
|
-
if (child.target.length === 0)
|
|
725
|
-
return yield*
|
|
726
|
-
|
|
727
|
-
|
|
785
|
+
if (child.target.length === 0) {
|
|
786
|
+
return yield* new FsError({ code: "NotFound", operation: operation, path: path.input });
|
|
787
|
+
}
|
|
788
|
+
if (++traversals > 40) {
|
|
789
|
+
return yield* new FsError({ code: "SymlinkLoop", operation: operation, path: path.input });
|
|
790
|
+
}
|
|
728
791
|
const suffix = work.suffixes[index] ?? new Uint8Array(0);
|
|
729
792
|
if (settings.maxPathBytes !== undefined &&
|
|
730
793
|
ByteSize.isGreaterThan(ByteSize.bytes(child.target.length + suffix.length), settings.maxPathBytes)) {
|
|
731
|
-
return yield*
|
|
794
|
+
return yield* new FsError({ code: "PathTooLong", operation: operation, path: path.input });
|
|
732
795
|
}
|
|
733
796
|
const expansion = new Uint8Array(child.target.length + suffix.length);
|
|
734
797
|
expansion.set(child.target);
|
|
735
798
|
expansion.set(suffix, child.target.length);
|
|
736
|
-
|
|
799
|
+
const expanded = preparePath(ownedPath(expansion), operation, settings.maxPathBytes);
|
|
800
|
+
// The expansion is synthetic: its per-component limits are the caller's to hear about,
|
|
801
|
+
// but the path in the error has to be the one the caller passed in.
|
|
802
|
+
if (Result.isFailure(expanded)) {
|
|
803
|
+
return yield* new FsError({ code: expanded.failure.code, operation: operation, path: path.input });
|
|
804
|
+
}
|
|
805
|
+
work = expanded.success;
|
|
737
806
|
if (work.absolute)
|
|
738
807
|
current = root;
|
|
739
808
|
index = -1;
|
|
@@ -742,23 +811,26 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
742
811
|
current = child;
|
|
743
812
|
}
|
|
744
813
|
if (!parentOnly && work.trailingSlash && current.kind !== "directory") {
|
|
745
|
-
return yield*
|
|
814
|
+
return yield* new FsError({ code: "NotDirectory", operation: operation, path: path.input });
|
|
746
815
|
}
|
|
747
816
|
return { node: current, parent, name };
|
|
748
817
|
});
|
|
749
818
|
const resolveNode = Effect.fnUntraced(function* (path, base, operation, options) {
|
|
750
819
|
const result = yield* lookup(path, base, operation, options);
|
|
751
|
-
if (result.node === undefined)
|
|
752
|
-
return yield*
|
|
820
|
+
if (result.node === undefined) {
|
|
821
|
+
return yield* new FsError({ code: "NotFound", operation: operation, path: path.input });
|
|
822
|
+
}
|
|
753
823
|
return result.node;
|
|
754
824
|
});
|
|
755
825
|
const locate = Effect.fnUntraced(function* (path, base, operation, options) {
|
|
756
826
|
const result = yield* lookup(path, base, operation, options);
|
|
757
827
|
const node = result.node;
|
|
758
|
-
if (node === undefined)
|
|
759
|
-
return yield*
|
|
760
|
-
|
|
761
|
-
|
|
828
|
+
if (node === undefined) {
|
|
829
|
+
return yield* new FsError({ code: "NotFound", operation: operation, path: path.input });
|
|
830
|
+
}
|
|
831
|
+
if (node.kind !== "directory") {
|
|
832
|
+
return yield* new FsError({ code: "NotDirectory", operation: operation, path: path.input });
|
|
833
|
+
}
|
|
762
834
|
return node;
|
|
763
835
|
});
|
|
764
836
|
const acquireDirectory = Effect.fnUntraced(function* (input, options, operation) {
|
|
@@ -773,7 +845,7 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
773
845
|
return yield* Effect.interrupt;
|
|
774
846
|
const path = yield* Effect.fromResult(prepared);
|
|
775
847
|
const directory = yield* locate(path, base, operation);
|
|
776
|
-
yield* authorize(directory, identity,
|
|
848
|
+
yield* authorize(directory, identity, EXECUTE, operation, input);
|
|
777
849
|
acquired.directory = directory;
|
|
778
850
|
return acquired;
|
|
779
851
|
}));
|
|
@@ -783,7 +855,7 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
783
855
|
const base = options?.relativeTo;
|
|
784
856
|
return yield* coordinated(Effect.gen(function* () {
|
|
785
857
|
const directory = yield* locate(yield* Effect.fromResult(prepared), base, "readDirectory");
|
|
786
|
-
yield* authorize(directory, identity,
|
|
858
|
+
yield* authorize(directory, identity, READ, "readDirectory", input);
|
|
787
859
|
const result = [...directory.entries.keys()].map(nameBytes);
|
|
788
860
|
directory.metadata = { ...directory.metadata, atimeNs: (yield* timestamp("readDirectory")) };
|
|
789
861
|
return result;
|
|
@@ -796,8 +868,9 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
796
868
|
const node = yield* resolveNode(yield* Effect.fromResult(prepared), base, "readLink", {
|
|
797
869
|
followFinalSymlink: false
|
|
798
870
|
});
|
|
799
|
-
if (node.kind !== "symlink")
|
|
800
|
-
return yield*
|
|
871
|
+
if (node.kind !== "symlink") {
|
|
872
|
+
return yield* new FsError({ code: "InvalidArgument", operation: "readLink", path: input });
|
|
873
|
+
}
|
|
801
874
|
return new Uint8Array(node.target);
|
|
802
875
|
}));
|
|
803
876
|
});
|
|
@@ -814,25 +887,27 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
814
887
|
const parent = directory.parent;
|
|
815
888
|
const entry = [...parent.entries].find(([, child]) => child === directory);
|
|
816
889
|
if (entry === undefined)
|
|
817
|
-
return yield*
|
|
890
|
+
return yield* new FsError({ code: "NotFound", operation: "realPath", path: input });
|
|
818
891
|
components.push(entry[0]);
|
|
819
892
|
directory = parent;
|
|
820
893
|
}
|
|
821
|
-
return nameBytes(
|
|
894
|
+
return nameBytes(SLASH_HEX + components.reverse().join(SLASH_HEX));
|
|
822
895
|
}));
|
|
823
896
|
});
|
|
824
897
|
const metadataNode = Effect.fnUntraced(function* (target, options, operation) {
|
|
825
|
-
if (reference.directory === undefined)
|
|
826
|
-
return yield*
|
|
827
|
-
|
|
828
|
-
|
|
898
|
+
if (reference.directory === undefined) {
|
|
899
|
+
return yield* new FsError({ code: "ClosedCaller", operation: operation });
|
|
900
|
+
}
|
|
901
|
+
if (isFileHandle(target) || isDirectoryHandle(target)) {
|
|
902
|
+
const ref = isFileHandle(target) ? files.get(target) : handles.get(target);
|
|
829
903
|
if (ref === undefined)
|
|
830
|
-
return yield*
|
|
831
|
-
if (ref.volume !== volumeIdentity)
|
|
832
|
-
return yield*
|
|
904
|
+
return yield* new FsError({ code: "InvalidHandle", operation: operation });
|
|
905
|
+
if (ref.volume !== volumeIdentity) {
|
|
906
|
+
return yield* new FsError({ code: "ForeignHandle", operation: operation });
|
|
907
|
+
}
|
|
833
908
|
const node = "file" in ref ? ref.file : ref.directory;
|
|
834
909
|
if (node === undefined)
|
|
835
|
-
return yield*
|
|
910
|
+
return yield* new FsError({ code: "InvalidHandle", operation: operation });
|
|
836
911
|
return node;
|
|
837
912
|
}
|
|
838
913
|
const path = yield* Effect.fromResult(preparePath(target, operation, settings.maxPathBytes));
|
|
@@ -842,14 +917,16 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
842
917
|
});
|
|
843
918
|
const permittedMode = (metadata, mode, operation, path) => {
|
|
844
919
|
if (!identity.privileged && identity.uid !== metadata.uid) {
|
|
845
|
-
return Effect.fail(
|
|
920
|
+
return Effect.fail(path === undefined
|
|
921
|
+
? new FsError({ code: "AccessDenied", operation })
|
|
922
|
+
: new FsError({ code: "AccessDenied", operation, path }));
|
|
846
923
|
}
|
|
847
924
|
const group = identity.gid === metadata.gid || identity.groups.includes(metadata.gid);
|
|
848
925
|
return Effect.succeed(!identity.privileged && metadata.kind === "file" && !group ? mode & ~0o2000 : mode);
|
|
849
926
|
};
|
|
850
927
|
const changeMode = Effect.fnUntraced(function* (target, mode, options) {
|
|
851
|
-
if (!
|
|
852
|
-
return yield*
|
|
928
|
+
if (!isMode(mode))
|
|
929
|
+
return yield* new FsError({ code: "InvalidArgument", operation: "chmod" });
|
|
853
930
|
const chosen = options === undefined ? undefined : { ...options };
|
|
854
931
|
return yield* coordinated(Effect.gen(function* () {
|
|
855
932
|
const node = yield* metadataNode(target, chosen, "chmod");
|
|
@@ -864,9 +941,9 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
864
941
|
}));
|
|
865
942
|
});
|
|
866
943
|
const changeOwner = Effect.fnUntraced(function* (target, owner, options) {
|
|
867
|
-
const decoded =
|
|
944
|
+
const decoded = decodeOwnerUpdate(owner);
|
|
868
945
|
if (Result.isFailure(decoded))
|
|
869
|
-
return yield*
|
|
946
|
+
return yield* new FsError({ code: "InvalidArgument", operation: "chown" });
|
|
870
947
|
const update = { ...decoded.success };
|
|
871
948
|
const chosen = options === undefined ? undefined : { ...options };
|
|
872
949
|
return yield* coordinated(Effect.gen(function* () {
|
|
@@ -874,7 +951,7 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
874
951
|
if (!identity.privileged && (identity.uid !== node.metadata.uid ||
|
|
875
952
|
(update.uid !== undefined && update.uid !== node.metadata.uid) ||
|
|
876
953
|
(update.gid !== undefined && update.gid !== identity.gid && !identity.groups.includes(update.gid)))) {
|
|
877
|
-
return yield*
|
|
954
|
+
return yield* new FsError({ code: "AccessDenied", operation: "chown" });
|
|
878
955
|
}
|
|
879
956
|
if (update.uid === undefined && update.gid === undefined)
|
|
880
957
|
return;
|
|
@@ -882,7 +959,7 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
882
959
|
...node.metadata,
|
|
883
960
|
uid: update.uid ?? node.metadata.uid,
|
|
884
961
|
gid: update.gid ?? node.metadata.gid,
|
|
885
|
-
mode: node.kind === "file" ? node.metadata.mode & ~
|
|
962
|
+
mode: node.kind === "file" ? node.metadata.mode & ~SET_ID_BITS : node.metadata.mode,
|
|
886
963
|
ctimeNs: (yield* timestamp("chown"))
|
|
887
964
|
};
|
|
888
965
|
advanceRevision(node);
|
|
@@ -890,9 +967,9 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
890
967
|
}));
|
|
891
968
|
});
|
|
892
969
|
const changeTimes = Effect.fnUntraced(function* (target, times, options) {
|
|
893
|
-
const decoded =
|
|
970
|
+
const decoded = decodeTimes(times);
|
|
894
971
|
if (Result.isFailure(decoded))
|
|
895
|
-
return yield*
|
|
972
|
+
return yield* new FsError({ code: "InvalidArgument", operation: "utimes" });
|
|
896
973
|
const access = { ...decoded.success.access };
|
|
897
974
|
const modification = { ...decoded.success.modification };
|
|
898
975
|
const chosen = options === undefined ? undefined : { ...options };
|
|
@@ -900,10 +977,16 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
900
977
|
const node = yield* metadataNode(target, chosen, "utimes");
|
|
901
978
|
if (access.kind === "omit" && modification.kind === "omit")
|
|
902
979
|
return;
|
|
980
|
+
// POSIX grants write access only when both times are UTIME_NOW; both UTIME_OMIT
|
|
981
|
+
// returned above. Every other combination, mixed ones included, needs ownership.
|
|
903
982
|
if (!identity.privileged && identity.uid !== node.metadata.uid) {
|
|
904
|
-
if (access.kind !== "now" || modification.kind !== "now")
|
|
905
|
-
|
|
906
|
-
|
|
983
|
+
if (access.kind !== "now" || modification.kind !== "now") {
|
|
984
|
+
const path = pathOf(target);
|
|
985
|
+
return yield* path === undefined
|
|
986
|
+
? new FsError({ code: "AccessDenied", operation: "utimes" })
|
|
987
|
+
: new FsError({ code: "AccessDenied", operation: "utimes", path });
|
|
988
|
+
}
|
|
989
|
+
yield* authorize(node, identity, WRITE, "utimes", pathOf(target));
|
|
907
990
|
}
|
|
908
991
|
const now = yield* timestamp("utimes");
|
|
909
992
|
node.metadata = {
|
|
@@ -924,41 +1007,44 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
924
1007
|
publishNode(node);
|
|
925
1008
|
}));
|
|
926
1009
|
});
|
|
927
|
-
const authorizeRemoval = (parent, child, operation, input) => (parent.metadata.mode &
|
|
1010
|
+
const authorizeRemoval = (parent, child, operation, input) => (parent.metadata.mode & STICKY_BIT) !== 0 && !identity.privileged &&
|
|
928
1011
|
identity.uid !== parent.metadata.uid && identity.uid !== child.metadata.uid
|
|
929
|
-
? Effect.fail(
|
|
1012
|
+
? Effect.fail(new FsError({ code: "AccessDenied", operation: operation, path: input }))
|
|
930
1013
|
: Effect.void;
|
|
931
1014
|
return Object.freeze({
|
|
932
1015
|
[CallerId]: true,
|
|
933
1016
|
rootReference: coordinated(Effect.gen(function* () {
|
|
934
|
-
if (reference.directory === undefined)
|
|
935
|
-
return yield*
|
|
1017
|
+
if (reference.directory === undefined) {
|
|
1018
|
+
return yield* new FsError({ code: "ClosedCaller", operation: "rootReference" });
|
|
1019
|
+
}
|
|
936
1020
|
return referenceFor(root);
|
|
937
1021
|
})).pipe(Effect.withSpan("Caller.rootReference")),
|
|
938
1022
|
lookupReference: Effect.fn("Caller.lookupReference")(function* (directoryReference, name) {
|
|
939
|
-
if (!(name
|
|
940
|
-
name.
|
|
941
|
-
return yield*
|
|
1023
|
+
if (!isAttachedBytes(name) || name.length === 0 || name.length > MAX_NAME_BYTES || name.includes(0) ||
|
|
1024
|
+
name.includes(SLASH_BYTE))
|
|
1025
|
+
return yield* new FsError({ code: "InvalidArgument", operation: "lookupReference" });
|
|
942
1026
|
const key = Encoding.encodeHex(new Uint8Array(name));
|
|
943
|
-
if (key
|
|
944
|
-
return yield*
|
|
1027
|
+
if (isDotComponent(key))
|
|
1028
|
+
return yield* new FsError({ code: "InvalidArgument", operation: "lookupReference" });
|
|
945
1029
|
return yield* coordinated(Effect.gen(function* () {
|
|
946
1030
|
const directory = yield* referencedNode(directoryReference, "lookupReference");
|
|
947
|
-
if (directory.kind !== "directory")
|
|
948
|
-
return yield*
|
|
949
|
-
|
|
1031
|
+
if (directory.kind !== "directory") {
|
|
1032
|
+
return yield* new FsError({ code: "NotDirectory", operation: "lookupReference" });
|
|
1033
|
+
}
|
|
1034
|
+
yield* authorize(directory, identity, EXECUTE, "lookupReference", "/");
|
|
950
1035
|
const child = directory.entries.get(key);
|
|
951
1036
|
if (child === undefined)
|
|
952
|
-
return yield*
|
|
1037
|
+
return yield* new FsError({ code: "NotFound", operation: "lookupReference" });
|
|
953
1038
|
return referenceFor(child);
|
|
954
1039
|
}));
|
|
955
1040
|
}),
|
|
956
1041
|
parentReference: Effect.fn("Caller.parentReference")(function* (directoryReference) {
|
|
957
1042
|
return yield* coordinated(Effect.gen(function* () {
|
|
958
1043
|
const directory = yield* referencedNode(directoryReference, "parentReference");
|
|
959
|
-
if (directory.kind !== "directory")
|
|
960
|
-
return yield*
|
|
961
|
-
|
|
1044
|
+
if (directory.kind !== "directory") {
|
|
1045
|
+
return yield* new FsError({ code: "NotDirectory", operation: "parentReference" });
|
|
1046
|
+
}
|
|
1047
|
+
yield* authorize(directory, identity, EXECUTE, "parentReference", "/");
|
|
962
1048
|
return referenceFor(directory.parent ?? directory);
|
|
963
1049
|
}));
|
|
964
1050
|
}),
|
|
@@ -971,9 +1057,10 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
971
1057
|
observeDirectory: Effect.fn("Caller.observeDirectory")(function* (directoryReference) {
|
|
972
1058
|
return yield* coordinated(Effect.gen(function* () {
|
|
973
1059
|
const directory = yield* referencedNode(directoryReference, "observeDirectory");
|
|
974
|
-
if (directory.kind !== "directory")
|
|
975
|
-
return yield*
|
|
976
|
-
|
|
1060
|
+
if (directory.kind !== "directory") {
|
|
1061
|
+
return yield* new FsError({ code: "NotDirectory", operation: "observeDirectory" });
|
|
1062
|
+
}
|
|
1063
|
+
yield* authorize(directory, identity, READ, "observeDirectory", "/");
|
|
977
1064
|
const value = Object.freeze([...directory.entries].map(([name, node]) => Object.freeze({ name: nameBytes(name), reference: referenceFor(node) })));
|
|
978
1065
|
return Object.freeze({ value, revision: directory.revision });
|
|
979
1066
|
}));
|
|
@@ -981,8 +1068,9 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
981
1068
|
readLinkReference: Effect.fn("Caller.readLinkReference")(function* (objectReference) {
|
|
982
1069
|
return yield* coordinated(Effect.gen(function* () {
|
|
983
1070
|
const node = yield* referencedNode(objectReference, "readLinkReference");
|
|
984
|
-
if (node.kind !== "symlink")
|
|
985
|
-
return yield*
|
|
1071
|
+
if (node.kind !== "symlink") {
|
|
1072
|
+
return yield* new FsError({ code: "InvalidArgument", operation: "readLinkReference" });
|
|
1073
|
+
}
|
|
986
1074
|
return new Uint8Array(node.target);
|
|
987
1075
|
}));
|
|
988
1076
|
}),
|
|
@@ -1001,10 +1089,11 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
1001
1089
|
return yield* Effect.interrupt;
|
|
1002
1090
|
const node = yield* referencedNode(objectReference, "openReference");
|
|
1003
1091
|
if (node.kind !== "file")
|
|
1004
|
-
return yield*
|
|
1005
|
-
if (node.metadata.nlink === 0)
|
|
1006
|
-
return yield*
|
|
1007
|
-
|
|
1092
|
+
return yield* new FsError({ code: "IsDirectory", operation: "openReference" });
|
|
1093
|
+
if (node.metadata.nlink === 0) {
|
|
1094
|
+
return yield* new FsError({ code: "StaleReference", operation: "openReference" });
|
|
1095
|
+
}
|
|
1096
|
+
yield* authorize(node, identity, READ, "openReference", "/");
|
|
1008
1097
|
node.openCount += 1;
|
|
1009
1098
|
acquired.file = node;
|
|
1010
1099
|
return fileHandle(acquired);
|
|
@@ -1015,9 +1104,10 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
1015
1104
|
const base = options?.relativeTo;
|
|
1016
1105
|
return yield* coordinated(Effect.gen(function* () {
|
|
1017
1106
|
const node = yield* resolveNode(yield* Effect.fromResult(prepared), base, "readFile");
|
|
1018
|
-
if (node.kind !== "file")
|
|
1019
|
-
return yield*
|
|
1020
|
-
|
|
1107
|
+
if (node.kind !== "file") {
|
|
1108
|
+
return yield* new FsError({ code: "IsDirectory", operation: "readFile", path: input });
|
|
1109
|
+
}
|
|
1110
|
+
yield* authorize(node, identity, READ, "readFile", input);
|
|
1021
1111
|
const data = new Uint8Array(node.data.bytes);
|
|
1022
1112
|
node.metadata = { ...node.metadata, atimeNs: (yield* timestamp("readFile")) };
|
|
1023
1113
|
return data;
|
|
@@ -1025,21 +1115,23 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
1025
1115
|
}),
|
|
1026
1116
|
writeFile: Effect.fn("Caller.writeFile")(function* (input, bytes, options) {
|
|
1027
1117
|
const prepared = preparePath(input, "writeFile", settings.maxPathBytes);
|
|
1028
|
-
if (!(bytes
|
|
1029
|
-
return yield*
|
|
1118
|
+
if (!isAttachedBytes(bytes)) {
|
|
1119
|
+
return yield* new FsError({ code: "InvalidArgument", operation: "writeFile", path: input });
|
|
1030
1120
|
}
|
|
1031
1121
|
const captured = new Uint8Array(bytes);
|
|
1032
1122
|
const { relativeTo: base, ...raw } = options;
|
|
1033
|
-
const decoded =
|
|
1034
|
-
if (Result.isFailure(decoded))
|
|
1035
|
-
return yield*
|
|
1123
|
+
const decoded = decodeWriteFileSettings(raw);
|
|
1124
|
+
if (Result.isFailure(decoded)) {
|
|
1125
|
+
return yield* new FsError({ code: "InvalidArgument", operation: "writeFile", path: input });
|
|
1126
|
+
}
|
|
1036
1127
|
const chosen = decoded.success;
|
|
1037
1128
|
return yield* coordinated(Effect.gen(function* () {
|
|
1038
1129
|
const path = yield* Effect.fromResult(prepared);
|
|
1039
1130
|
if (chosen.create === "exclusive") {
|
|
1040
1131
|
const exists = yield* Effect.result(lookup(path, base, "writeFile", { followFinalSymlink: false }));
|
|
1041
|
-
if (Result.isSuccess(exists))
|
|
1042
|
-
return yield*
|
|
1132
|
+
if (Result.isSuccess(exists)) {
|
|
1133
|
+
return yield* new FsError({ code: "AlreadyExists", operation: "writeFile", path: input });
|
|
1134
|
+
}
|
|
1043
1135
|
if (exists.failure.code !== "NotFound")
|
|
1044
1136
|
return yield* exists.failure;
|
|
1045
1137
|
}
|
|
@@ -1049,36 +1141,39 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
1049
1141
|
});
|
|
1050
1142
|
const { name, parent } = resolved;
|
|
1051
1143
|
if (parent === undefined || name === undefined || resolved.node?.kind === "directory") {
|
|
1052
|
-
return yield*
|
|
1144
|
+
return yield* new FsError({ code: "IsDirectory", operation: "writeFile", path: input });
|
|
1053
1145
|
}
|
|
1054
1146
|
const replaced = resolved.node?.kind === "symlink" ? resolved.node : undefined;
|
|
1055
1147
|
if (replaced !== undefined && !chosen.replaceFinalSymlink) {
|
|
1056
|
-
return yield*
|
|
1148
|
+
return yield* new FsError({ code: "SymlinkLoop", operation: "writeFile", path: input });
|
|
1149
|
+
}
|
|
1150
|
+
if (chosen.access === "read") {
|
|
1151
|
+
return yield* new FsError({ code: "InvalidHandle", operation: "writeFile", path: input });
|
|
1057
1152
|
}
|
|
1058
|
-
if (chosen.access === "read")
|
|
1059
|
-
return yield* failure("InvalidHandle", "writeFile", input);
|
|
1060
1153
|
const file = resolved.node?.kind === "file" ? resolved.node : undefined;
|
|
1061
1154
|
if (file === undefined) {
|
|
1062
|
-
yield* authorize(parent, identity,
|
|
1155
|
+
yield* authorize(parent, identity, WRITE | EXECUTE, "writeFile", input);
|
|
1063
1156
|
if (replaced !== undefined)
|
|
1064
1157
|
yield* authorizeRemoval(parent, replaced, "writeFile", input);
|
|
1065
|
-
if (replaced === undefined &&
|
|
1066
|
-
return yield*
|
|
1158
|
+
if (replaced === undefined && atEntryLimit()) {
|
|
1159
|
+
return yield* new FsError({ code: "NoSpace", operation: "writeFile", path: input });
|
|
1067
1160
|
}
|
|
1068
1161
|
}
|
|
1069
|
-
else
|
|
1070
|
-
yield* authorize(file, identity, chosen.access === "readWrite" ?
|
|
1162
|
+
else {
|
|
1163
|
+
yield* authorize(file, identity, chosen.access === "readWrite" ? READ | WRITE : WRITE, "writeFile", input);
|
|
1164
|
+
}
|
|
1071
1165
|
const finalMode = chosen.finalMode === undefined ? undefined : yield* permittedMode(file?.metadata ?? { kind: "file", uid: identity.uid, gid: parent.metadata.gid }, chosen.finalMode, "writeFile", input);
|
|
1072
1166
|
const previous = file?.data.bytes.length ?? 0;
|
|
1073
1167
|
const initial = chosen.truncate ? 0 : previous;
|
|
1074
1168
|
const position = chosen.append ? initial : 0;
|
|
1075
1169
|
const size = Math.max(initial, position + captured.length);
|
|
1076
|
-
if (size > maxFileBytes)
|
|
1077
|
-
return yield*
|
|
1170
|
+
if (size > maxFileBytes) {
|
|
1171
|
+
return yield* new FsError({ code: "FileTooLarge", operation: "writeFile", path: input });
|
|
1172
|
+
}
|
|
1078
1173
|
const reclaimed = replaced !== undefined && replaced.metadata.nlink === 1 ? replaced.target.length : 0;
|
|
1079
1174
|
if (settings.maxBytes !== undefined &&
|
|
1080
1175
|
BigInt(size - previous) > ByteSize.toBigInt(settings.maxBytes) - usedBytes + BigInt(reclaimed)) {
|
|
1081
|
-
return yield*
|
|
1176
|
+
return yield* new FsError({ code: "NoSpace", operation: "writeFile", path: input });
|
|
1082
1177
|
}
|
|
1083
1178
|
if (file !== undefined && !chosen.truncate && captured.length === 0 && chosen.finalMode === undefined) {
|
|
1084
1179
|
return;
|
|
@@ -1095,7 +1190,8 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
1095
1190
|
{
|
|
1096
1191
|
kind: "file",
|
|
1097
1192
|
lineage: undefined,
|
|
1098
|
-
|
|
1193
|
+
// Assigned below on the shared path that also covers an existing file.
|
|
1194
|
+
data: Content.empty(),
|
|
1099
1195
|
openCount: 0,
|
|
1100
1196
|
metadata: {
|
|
1101
1197
|
...directoryMetadata(nextInode++, identity.uid, parent.metadata.gid, (chosen.mode ?? 0o666) & 0o777 & ~umask, now),
|
|
@@ -1108,7 +1204,7 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
1108
1204
|
node.data = Content.make(data);
|
|
1109
1205
|
node.metadata = {
|
|
1110
1206
|
...node.metadata,
|
|
1111
|
-
mode: finalMode ?? node.metadata.mode & ~
|
|
1207
|
+
mode: finalMode ?? node.metadata.mode & ~SET_ID_BITS,
|
|
1112
1208
|
size: BigInt(size),
|
|
1113
1209
|
mtimeNs: now,
|
|
1114
1210
|
ctimeNs: now
|
|
@@ -1118,9 +1214,7 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
1118
1214
|
if (file === undefined) {
|
|
1119
1215
|
if (replaced !== undefined)
|
|
1120
1216
|
detach(replaced, now);
|
|
1121
|
-
parent
|
|
1122
|
-
parent.metadata = { ...parent.metadata, mtimeNs: now, ctimeNs: now };
|
|
1123
|
-
advanceRevision(parent);
|
|
1217
|
+
attach(parent, name, node, now);
|
|
1124
1218
|
if (replaced === undefined)
|
|
1125
1219
|
entries += 1;
|
|
1126
1220
|
publishEntry(replaced === undefined ? "Create" : "Update", parent, name);
|
|
@@ -1150,12 +1244,13 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
1150
1244
|
access: Effect.fn("Caller.access")(function* (input, bits = 0, options) {
|
|
1151
1245
|
const prepared = preparePath(input, "access", settings.maxPathBytes);
|
|
1152
1246
|
const base = options?.relativeTo;
|
|
1153
|
-
if (!Number.isInteger(bits) || bits < 0 || bits >
|
|
1154
|
-
return yield*
|
|
1247
|
+
if (!Number.isInteger(bits) || bits < 0 || bits > (READ | WRITE | EXECUTE)) {
|
|
1248
|
+
return yield* new FsError({ code: "InvalidArgument", operation: "access", path: input });
|
|
1249
|
+
}
|
|
1155
1250
|
return yield* coordinated(Effect.gen(function* () {
|
|
1156
1251
|
const node = yield* resolveNode(yield* Effect.fromResult(prepared), base, "access");
|
|
1157
|
-
if (node.kind === "file" && (bits &
|
|
1158
|
-
return yield*
|
|
1252
|
+
if (node.kind === "file" && (bits & EXECUTE) !== 0 && (node.metadata.mode & ANY_EXECUTE) === 0) {
|
|
1253
|
+
return yield* new FsError({ code: "AccessDenied", operation: "access", path: input });
|
|
1159
1254
|
}
|
|
1160
1255
|
yield* authorize(node, identity, bits, "access", input);
|
|
1161
1256
|
}));
|
|
@@ -1165,9 +1260,10 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
1165
1260
|
const base = options?.relativeTo;
|
|
1166
1261
|
return yield* coordinated(Effect.gen(function* () {
|
|
1167
1262
|
const node = yield* resolveNode(yield* Effect.fromResult(prepared), base, "truncate");
|
|
1168
|
-
if (node.kind !== "file")
|
|
1169
|
-
return yield*
|
|
1170
|
-
|
|
1263
|
+
if (node.kind !== "file") {
|
|
1264
|
+
return yield* new FsError({ code: "IsDirectory", operation: "truncate", path: input });
|
|
1265
|
+
}
|
|
1266
|
+
yield* authorize(node, identity, WRITE, "truncate", input);
|
|
1171
1267
|
yield* resize(node, length, "truncate");
|
|
1172
1268
|
}));
|
|
1173
1269
|
}),
|
|
@@ -1191,25 +1287,25 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
1191
1287
|
const node = yield* resolveNode(yield* Effect.fromResult(a), sourceBase, "link", {
|
|
1192
1288
|
followFinalSymlink: follow
|
|
1193
1289
|
});
|
|
1194
|
-
if (node.kind === "directory")
|
|
1195
|
-
return yield*
|
|
1290
|
+
if (node.kind === "directory") {
|
|
1291
|
+
return yield* new FsError({ code: "IsDirectory", operation: "link", path: source });
|
|
1292
|
+
}
|
|
1196
1293
|
const path = yield* Effect.fromResult(b);
|
|
1197
1294
|
const parent = yield* locate(path, destinationBase, "link", { parentOnly: true });
|
|
1198
|
-
yield* authorize(parent, identity,
|
|
1295
|
+
yield* authorize(parent, identity, WRITE | EXECUTE, "link", destination);
|
|
1199
1296
|
const name = path.components.at(-1);
|
|
1200
|
-
if (name
|
|
1201
|
-
return yield*
|
|
1297
|
+
if (isDotComponent(name) || parent.entries.has(name)) {
|
|
1298
|
+
return yield* new FsError({ code: "AlreadyExists", operation: "link", path: destination });
|
|
1299
|
+
}
|
|
1300
|
+
if (path.trailingSlash) {
|
|
1301
|
+
return yield* new FsError({ code: "NotDirectory", operation: "link", path: destination });
|
|
1202
1302
|
}
|
|
1203
|
-
if (
|
|
1204
|
-
return yield*
|
|
1205
|
-
if (settings.maxEntries !== undefined && entries >= settings.maxEntries) {
|
|
1206
|
-
return yield* failure("NoSpace", "link", destination);
|
|
1303
|
+
if (atEntryLimit()) {
|
|
1304
|
+
return yield* new FsError({ code: "NoSpace", operation: "link", path: destination });
|
|
1207
1305
|
}
|
|
1208
1306
|
const now = yield* timestamp("link");
|
|
1209
|
-
parent
|
|
1210
|
-
parent.metadata = { ...parent.metadata, mtimeNs: now, ctimeNs: now };
|
|
1307
|
+
attach(parent, name, node, now);
|
|
1211
1308
|
node.metadata = { ...node.metadata, nlink: node.metadata.nlink + 1, ctimeNs: now };
|
|
1212
|
-
advanceRevision(parent);
|
|
1213
1309
|
advanceRevision(node);
|
|
1214
1310
|
entries += 1;
|
|
1215
1311
|
publishEntry("Create", parent, name);
|
|
@@ -1217,30 +1313,31 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
1217
1313
|
}),
|
|
1218
1314
|
symlink: Effect.fn("Caller.symlink")(function* (target, input, options) {
|
|
1219
1315
|
const prepared = preparePath(input, "symlink", settings.maxPathBytes);
|
|
1220
|
-
|
|
1221
|
-
|
|
1316
|
+
const rawTarget = inputBytes(target);
|
|
1317
|
+
if (Result.isFailure(rawTarget)) {
|
|
1318
|
+
return yield* new FsError({ code: rawTarget.failure, operation: "symlink", path: target });
|
|
1222
1319
|
}
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
return yield* failure("InvalidArgument", "symlink", target);
|
|
1320
|
+
if (rawTarget.success.includes(0)) {
|
|
1321
|
+
return yield* new FsError({ code: "InvalidArgument", operation: "symlink", path: target });
|
|
1226
1322
|
}
|
|
1227
|
-
const targetBytes = new Uint8Array(rawTarget);
|
|
1323
|
+
const targetBytes = new Uint8Array(rawTarget.success);
|
|
1228
1324
|
const base = options?.relativeTo;
|
|
1229
1325
|
return yield* coordinated(Effect.gen(function* () {
|
|
1230
1326
|
const path = yield* Effect.fromResult(prepared);
|
|
1231
1327
|
const bytes = targetBytes;
|
|
1232
1328
|
const parent = yield* locate(path, base, "symlink", { parentOnly: true });
|
|
1233
|
-
yield* authorize(parent, identity,
|
|
1329
|
+
yield* authorize(parent, identity, WRITE | EXECUTE, "symlink", input);
|
|
1234
1330
|
const name = path.components.at(-1);
|
|
1235
|
-
if (name
|
|
1236
|
-
return yield*
|
|
1331
|
+
if (isDotComponent(name) || parent.entries.has(name)) {
|
|
1332
|
+
return yield* new FsError({ code: "AlreadyExists", operation: "symlink", path: input });
|
|
1333
|
+
}
|
|
1334
|
+
if (path.trailingSlash) {
|
|
1335
|
+
return yield* new FsError({ code: "NotDirectory", operation: "symlink", path: input });
|
|
1237
1336
|
}
|
|
1238
|
-
if (
|
|
1239
|
-
return yield* failure("NotDirectory", "symlink", input);
|
|
1240
|
-
if ((settings.maxEntries !== undefined && entries >= settings.maxEntries) ||
|
|
1337
|
+
if (atEntryLimit() ||
|
|
1241
1338
|
(settings.maxBytes !== undefined &&
|
|
1242
1339
|
BigInt(bytes.length) > ByteSize.toBigInt(settings.maxBytes) - usedBytes))
|
|
1243
|
-
return yield*
|
|
1340
|
+
return yield* new FsError({ code: "NoSpace", operation: "symlink", path: input });
|
|
1244
1341
|
const now = yield* timestamp("symlink");
|
|
1245
1342
|
const node = {
|
|
1246
1343
|
kind: "symlink",
|
|
@@ -1255,9 +1352,7 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
1255
1352
|
revision: nextRevision(),
|
|
1256
1353
|
objectReference: undefined
|
|
1257
1354
|
};
|
|
1258
|
-
parent
|
|
1259
|
-
parent.metadata = { ...parent.metadata, mtimeNs: now, ctimeNs: now };
|
|
1260
|
-
advanceRevision(parent);
|
|
1355
|
+
attach(parent, name, node, now);
|
|
1261
1356
|
nextInode += 1n;
|
|
1262
1357
|
entries += 1;
|
|
1263
1358
|
usedBytes += BigInt(bytes.length);
|
|
@@ -1285,15 +1380,16 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
1285
1380
|
open: Effect.fn("Caller.open")(function* (input, options) {
|
|
1286
1381
|
const prepared = preparePath(input, "open", settings.maxPathBytes);
|
|
1287
1382
|
const { relativeTo: base, ...raw } = options;
|
|
1288
|
-
const decoded =
|
|
1289
|
-
if (Result.isFailure(decoded))
|
|
1290
|
-
return yield*
|
|
1383
|
+
const decoded = decodeOpenSettings(raw);
|
|
1384
|
+
if (Result.isFailure(decoded)) {
|
|
1385
|
+
return yield* new FsError({ code: "InvalidArgument", operation: "open", path: input });
|
|
1386
|
+
}
|
|
1291
1387
|
const chosen = { ...decoded.success };
|
|
1292
1388
|
if (chosen.access === "read" && (chosen.append || chosen.truncate)) {
|
|
1293
|
-
return yield*
|
|
1389
|
+
return yield* new FsError({ code: "InvalidArgument", operation: "open", path: input });
|
|
1294
1390
|
}
|
|
1295
1391
|
if (chosen.mode !== undefined && (chosen.create === undefined || chosen.create === "never")) {
|
|
1296
|
-
return yield*
|
|
1392
|
+
return yield* new FsError({ code: "InvalidArgument", operation: "open", path: input });
|
|
1297
1393
|
}
|
|
1298
1394
|
const acquired = {
|
|
1299
1395
|
volume: volumeIdentity,
|
|
@@ -1310,8 +1406,9 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
1310
1406
|
const path = yield* Effect.fromResult(prepared);
|
|
1311
1407
|
if (chosen.create === "exclusive") {
|
|
1312
1408
|
const existing = yield* Effect.result(lookup(path, base, "open", { followFinalSymlink: false }));
|
|
1313
|
-
if (Result.isSuccess(existing))
|
|
1314
|
-
return yield*
|
|
1409
|
+
if (Result.isSuccess(existing)) {
|
|
1410
|
+
return yield* new FsError({ code: "AlreadyExists", operation: "open", path: input });
|
|
1411
|
+
}
|
|
1315
1412
|
if (existing.failure.code !== "NotFound")
|
|
1316
1413
|
return yield* existing.failure;
|
|
1317
1414
|
}
|
|
@@ -1321,23 +1418,23 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
1321
1418
|
});
|
|
1322
1419
|
const parent = resolved.parent;
|
|
1323
1420
|
if (parent === undefined)
|
|
1324
|
-
return yield*
|
|
1421
|
+
return yield* new FsError({ code: "IsDirectory", operation: "open", path: input });
|
|
1325
1422
|
const name = resolved.name;
|
|
1326
|
-
if (name
|
|
1327
|
-
return yield*
|
|
1423
|
+
if (isDotComponent(name)) {
|
|
1424
|
+
return yield* new FsError({ code: "IsDirectory", operation: "open", path: input });
|
|
1328
1425
|
}
|
|
1329
|
-
yield* authorize(parent, identity,
|
|
1426
|
+
yield* authorize(parent, identity, EXECUTE, "open", input);
|
|
1330
1427
|
let file = resolved.node;
|
|
1331
1428
|
if (file !== undefined && chosen.create === "exclusive") {
|
|
1332
|
-
return yield*
|
|
1429
|
+
return yield* new FsError({ code: "AlreadyExists", operation: "open", path: input });
|
|
1333
1430
|
}
|
|
1334
1431
|
if (file === undefined) {
|
|
1335
1432
|
if (chosen.create === undefined || chosen.create === "never" || path.trailingSlash) {
|
|
1336
|
-
return yield*
|
|
1433
|
+
return yield* new FsError({ code: "NotFound", operation: "open", path: input });
|
|
1337
1434
|
}
|
|
1338
|
-
yield* authorize(parent, identity,
|
|
1339
|
-
if (
|
|
1340
|
-
return yield*
|
|
1435
|
+
yield* authorize(parent, identity, WRITE | EXECUTE, "open", input);
|
|
1436
|
+
if (atEntryLimit()) {
|
|
1437
|
+
return yield* new FsError({ code: "NoSpace", operation: "open", path: input });
|
|
1341
1438
|
}
|
|
1342
1439
|
const now = yield* timestamp("open");
|
|
1343
1440
|
file = {
|
|
@@ -1353,21 +1450,22 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
1353
1450
|
revision: nextRevision(),
|
|
1354
1451
|
objectReference: undefined
|
|
1355
1452
|
};
|
|
1356
|
-
parent
|
|
1357
|
-
parent.metadata = { ...parent.metadata, mtimeNs: now, ctimeNs: now };
|
|
1358
|
-
advanceRevision(parent);
|
|
1453
|
+
attach(parent, name, file, now);
|
|
1359
1454
|
entries += 1;
|
|
1360
1455
|
nextInode += 1n;
|
|
1361
1456
|
publishEntry("Create", parent, name);
|
|
1362
1457
|
}
|
|
1363
1458
|
else {
|
|
1364
|
-
if (file.kind === "symlink")
|
|
1365
|
-
return yield*
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1459
|
+
if (file.kind === "symlink") {
|
|
1460
|
+
return yield* new FsError({ code: "SymlinkLoop", operation: "open", path: input });
|
|
1461
|
+
}
|
|
1462
|
+
if (file.kind !== "file") {
|
|
1463
|
+
return yield* new FsError({ code: "IsDirectory", operation: "open", path: input });
|
|
1464
|
+
}
|
|
1465
|
+
if (path.trailingSlash) {
|
|
1466
|
+
return yield* new FsError({ code: "NotDirectory", operation: "open", path: input });
|
|
1467
|
+
}
|
|
1468
|
+
yield* authorize(file, identity, chosen.access === "read" ? READ : chosen.access === "write" ? WRITE : READ | WRITE, "open", input);
|
|
1371
1469
|
if (chosen.truncate)
|
|
1372
1470
|
yield* resize(file, 0n, "open");
|
|
1373
1471
|
}
|
|
@@ -1382,18 +1480,20 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
1382
1480
|
return yield* coordinated(Effect.gen(function* () {
|
|
1383
1481
|
const path = yield* Effect.fromResult(prepared);
|
|
1384
1482
|
const parent = yield* locate(path, base, "unlink", { parentOnly: true });
|
|
1385
|
-
yield* authorize(parent, identity,
|
|
1483
|
+
yield* authorize(parent, identity, WRITE | EXECUTE, "unlink", input);
|
|
1386
1484
|
const name = path.components.at(-1);
|
|
1387
|
-
if (name
|
|
1388
|
-
return yield*
|
|
1485
|
+
if (isDotComponent(name)) {
|
|
1486
|
+
return yield* new FsError({ code: "IsDirectory", operation: "unlink", path: input });
|
|
1389
1487
|
}
|
|
1390
1488
|
const child = parent.entries.get(name);
|
|
1391
1489
|
if (child === undefined)
|
|
1392
|
-
return yield*
|
|
1393
|
-
if (child.kind === "directory")
|
|
1394
|
-
return yield*
|
|
1395
|
-
|
|
1396
|
-
|
|
1490
|
+
return yield* new FsError({ code: "NotFound", operation: "unlink", path: input });
|
|
1491
|
+
if (child.kind === "directory") {
|
|
1492
|
+
return yield* new FsError({ code: "IsDirectory", operation: "unlink", path: input });
|
|
1493
|
+
}
|
|
1494
|
+
if (path.trailingSlash) {
|
|
1495
|
+
return yield* new FsError({ code: "NotDirectory", operation: "unlink", path: input });
|
|
1496
|
+
}
|
|
1397
1497
|
yield* authorizeRemoval(parent, child, "unlink", input);
|
|
1398
1498
|
const now = yield* timestamp("unlink");
|
|
1399
1499
|
parent.entries.delete(name);
|
|
@@ -1414,26 +1514,25 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
1414
1514
|
const newPath = yield* Effect.fromResult(newPrepared);
|
|
1415
1515
|
const oldParent = yield* locate(oldPath, oldBase, "rename", { parentOnly: true });
|
|
1416
1516
|
const newParent = yield* locate(newPath, newBase, "rename", { parentOnly: true });
|
|
1417
|
-
yield* authorize(oldParent, identity,
|
|
1418
|
-
yield* authorize(newParent, identity,
|
|
1517
|
+
yield* authorize(oldParent, identity, WRITE | EXECUTE, "rename", source);
|
|
1518
|
+
yield* authorize(newParent, identity, WRITE | EXECUTE, "rename", destination);
|
|
1419
1519
|
const oldName = oldPath.components.at(-1);
|
|
1420
1520
|
const newName = newPath.components.at(-1);
|
|
1421
|
-
if (oldName
|
|
1422
|
-
|
|
1423
|
-
return yield* failure("InvalidArgument", "rename", source);
|
|
1521
|
+
if (isDotComponent(oldName) || isDotComponent(newName)) {
|
|
1522
|
+
return yield* new FsError({ code: "InvalidArgument", operation: "rename", path: source });
|
|
1424
1523
|
}
|
|
1425
1524
|
const child = oldParent.entries.get(oldName);
|
|
1426
1525
|
if (child === undefined)
|
|
1427
|
-
return yield*
|
|
1526
|
+
return yield* new FsError({ code: "NotFound", operation: "rename", path: source });
|
|
1428
1527
|
const replaced = newParent.entries.get(newName);
|
|
1429
1528
|
if (newPath.trailingSlash && replaced === undefined) {
|
|
1430
|
-
return yield*
|
|
1529
|
+
return yield* new FsError({ code: "NotFound", operation: "rename", path: destination });
|
|
1431
1530
|
}
|
|
1432
1531
|
if (oldPath.trailingSlash && child.kind !== "directory") {
|
|
1433
|
-
return yield*
|
|
1532
|
+
return yield* new FsError({ code: "NotDirectory", operation: "rename", path: source });
|
|
1434
1533
|
}
|
|
1435
1534
|
if (newPath.trailingSlash && replaced?.kind !== "directory") {
|
|
1436
|
-
return yield*
|
|
1535
|
+
return yield* new FsError({ code: "NotDirectory", operation: "rename", path: destination });
|
|
1437
1536
|
}
|
|
1438
1537
|
if (child === replaced)
|
|
1439
1538
|
return;
|
|
@@ -1441,22 +1540,23 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
1441
1540
|
if (replaced !== undefined) {
|
|
1442
1541
|
yield* authorizeRemoval(newParent, replaced, "rename", destination);
|
|
1443
1542
|
if (child.kind === "directory" && replaced.kind !== "directory") {
|
|
1444
|
-
return yield*
|
|
1543
|
+
return yield* new FsError({ code: "NotDirectory", operation: "rename", path: destination });
|
|
1445
1544
|
}
|
|
1446
1545
|
if (child.kind !== "directory" && replaced.kind === "directory") {
|
|
1447
|
-
return yield*
|
|
1546
|
+
return yield* new FsError({ code: "IsDirectory", operation: "rename", path: destination });
|
|
1448
1547
|
}
|
|
1449
1548
|
if (replaced.kind === "directory" && replaced.entries.size > 0) {
|
|
1450
|
-
return yield*
|
|
1549
|
+
return yield* new FsError({ code: "NotEmpty", operation: "rename", path: destination });
|
|
1451
1550
|
}
|
|
1452
1551
|
}
|
|
1453
1552
|
for (let ancestor = newParent; ancestor !== undefined; ancestor = ancestor.parent) {
|
|
1454
|
-
if (ancestor === child)
|
|
1455
|
-
return yield*
|
|
1553
|
+
if (ancestor === child) {
|
|
1554
|
+
return yield* new FsError({ code: "InvalidArgument", operation: "rename", path: destination });
|
|
1555
|
+
}
|
|
1456
1556
|
}
|
|
1457
1557
|
const now = yield* timestamp("rename");
|
|
1458
1558
|
// All rejection checks precede namespace, ancestry, quota, and metadata publication.
|
|
1459
|
-
const oldEvent = () => ownedPath(nameBytes(directoryHex(oldParent) + (oldParent === root ? "" :
|
|
1559
|
+
const oldEvent = () => ownedPath(nameBytes(directoryHex(oldParent) + (oldParent === root ? "" : SLASH_HEX) + oldName));
|
|
1460
1560
|
oldParent.entries.delete(oldName);
|
|
1461
1561
|
newParent.entries.set(newName, child);
|
|
1462
1562
|
if (child.kind === "directory")
|
|
@@ -1492,19 +1592,20 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
1492
1592
|
return yield* coordinated(Effect.gen(function* () {
|
|
1493
1593
|
const path = yield* Effect.fromResult(prepared);
|
|
1494
1594
|
const parent = yield* locate(path, base, "rmdir", { parentOnly: true });
|
|
1495
|
-
yield* authorize(parent, identity,
|
|
1595
|
+
yield* authorize(parent, identity, WRITE | EXECUTE, "rmdir", input);
|
|
1496
1596
|
const name = path.components.at(-1);
|
|
1497
|
-
if (name
|
|
1498
|
-
return yield*
|
|
1597
|
+
if (isDotComponent(name)) {
|
|
1598
|
+
return yield* new FsError({ code: "InvalidArgument", operation: "rmdir", path: input });
|
|
1499
1599
|
}
|
|
1500
1600
|
const child = parent.entries.get(name);
|
|
1501
1601
|
if (child === undefined)
|
|
1502
|
-
return yield*
|
|
1602
|
+
return yield* new FsError({ code: "NotFound", operation: "rmdir", path: input });
|
|
1503
1603
|
yield* authorizeRemoval(parent, child, "rmdir", input);
|
|
1504
|
-
if (child.kind !== "directory")
|
|
1505
|
-
return yield*
|
|
1604
|
+
if (child.kind !== "directory") {
|
|
1605
|
+
return yield* new FsError({ code: "NotDirectory", operation: "rmdir", path: input });
|
|
1606
|
+
}
|
|
1506
1607
|
if (child.entries.size > 0)
|
|
1507
|
-
return yield*
|
|
1608
|
+
return yield* new FsError({ code: "NotEmpty", operation: "rmdir", path: input });
|
|
1508
1609
|
const now = yield* timestamp("rmdir");
|
|
1509
1610
|
parent.entries.delete(name);
|
|
1510
1611
|
parent.metadata = { ...parent.metadata, nlink: parent.metadata.nlink - 1, mtimeNs: now, ctimeNs: now };
|
|
@@ -1530,18 +1631,18 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
1530
1631
|
const prepared = preparePath(input, "mkdir", settings.maxPathBytes);
|
|
1531
1632
|
const base = options?.relativeTo;
|
|
1532
1633
|
const mode = options?.mode === undefined ? 0o777 : options.mode;
|
|
1533
|
-
if (!
|
|
1534
|
-
return yield*
|
|
1634
|
+
if (!isMode(mode))
|
|
1635
|
+
return yield* new FsError({ code: "InvalidArgument", operation: "mkdir", path: input });
|
|
1535
1636
|
return yield* coordinated(Effect.gen(function* () {
|
|
1536
1637
|
const path = yield* Effect.fromResult(prepared);
|
|
1537
1638
|
const parent = yield* locate(path, base, "mkdir", { parentOnly: true });
|
|
1538
|
-
yield* authorize(parent, identity,
|
|
1639
|
+
yield* authorize(parent, identity, WRITE | EXECUTE, "mkdir", input);
|
|
1539
1640
|
const name = path.components.at(-1);
|
|
1540
|
-
if (name
|
|
1541
|
-
return yield*
|
|
1641
|
+
if (isDotComponent(name) || parent.entries.has(name)) {
|
|
1642
|
+
return yield* new FsError({ code: "AlreadyExists", operation: "mkdir", path: input });
|
|
1542
1643
|
}
|
|
1543
|
-
if (
|
|
1544
|
-
return yield*
|
|
1644
|
+
if (atEntryLimit()) {
|
|
1645
|
+
return yield* new FsError({ code: "NoSpace", operation: "mkdir", path: input });
|
|
1545
1646
|
}
|
|
1546
1647
|
const now = yield* timestamp("mkdir");
|
|
1547
1648
|
const child = {
|
|
@@ -1549,20 +1650,12 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
1549
1650
|
lineage: undefined,
|
|
1550
1651
|
parent,
|
|
1551
1652
|
entries: new Map(),
|
|
1552
|
-
metadata: directoryMetadata(nextInode, identity.uid, parent.metadata.gid, (mode & 0o777 & ~umask) | (mode &
|
|
1653
|
+
metadata: directoryMetadata(nextInode, identity.uid, parent.metadata.gid, (mode & 0o777 & ~umask) | (mode & STICKY_BIT), now),
|
|
1553
1654
|
revision: nextRevision(),
|
|
1554
1655
|
objectReference: undefined
|
|
1555
1656
|
};
|
|
1556
|
-
const parentMetadata = {
|
|
1557
|
-
...parent.metadata,
|
|
1558
|
-
nlink: parent.metadata.nlink + 1,
|
|
1559
|
-
mtimeNs: now,
|
|
1560
|
-
ctimeNs: now
|
|
1561
|
-
};
|
|
1562
1657
|
// No Effect yield or expected failure between these publication writes.
|
|
1563
|
-
parent
|
|
1564
|
-
parent.metadata = parentMetadata;
|
|
1565
|
-
advanceRevision(parent);
|
|
1658
|
+
attach(parent, name, child, now);
|
|
1566
1659
|
nextInode += 1n;
|
|
1567
1660
|
entries += 1;
|
|
1568
1661
|
publishEntry("Create", parent, name);
|
|
@@ -1577,11 +1670,12 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
1577
1670
|
const handle = Object.freeze({
|
|
1578
1671
|
[DirectoryHandleId]: true,
|
|
1579
1672
|
stat: coordinated(Effect.suspend(() => acquired.directory === undefined
|
|
1580
|
-
? Effect.fail(
|
|
1673
|
+
? Effect.fail(new FsError({ code: "InvalidHandle", operation: "stat" }))
|
|
1581
1674
|
: Effect.succeed({ ...acquired.directory.metadata }))).pipe(Effect.withSpan("DirectoryHandle.stat")),
|
|
1582
1675
|
close: coordinated(Effect.suspend(() => {
|
|
1583
|
-
if (acquired.directory === undefined)
|
|
1584
|
-
return Effect.fail(
|
|
1676
|
+
if (acquired.directory === undefined) {
|
|
1677
|
+
return Effect.fail(new FsError({ code: "InvalidHandle", operation: "close" }));
|
|
1678
|
+
}
|
|
1585
1679
|
acquired.directory = undefined;
|
|
1586
1680
|
acquired.closed = true;
|
|
1587
1681
|
return Effect.void;
|
|
@@ -1592,35 +1686,25 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
1592
1686
|
})
|
|
1593
1687
|
});
|
|
1594
1688
|
};
|
|
1595
|
-
const baseObservation =
|
|
1596
|
-
const publicChange = (change) =>
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
case "Removed":
|
|
1600
|
-
case "Updated":
|
|
1601
|
-
case "Replaced":
|
|
1602
|
-
return Object.freeze({ ...change, path: ownedPath(new Uint8Array(change.path)) });
|
|
1603
|
-
case "Renamed":
|
|
1604
|
-
return Object.freeze({
|
|
1605
|
-
...change,
|
|
1606
|
-
from: ownedPath(new Uint8Array(change.from)),
|
|
1607
|
-
to: ownedPath(new Uint8Array(change.to))
|
|
1608
|
-
});
|
|
1609
|
-
}
|
|
1610
|
-
throw new Error("Unknown internal overlay change");
|
|
1611
|
-
};
|
|
1689
|
+
const baseObservation = Predicate.isTagged("Overlay")(source) ? yield* observeChanges() : undefined;
|
|
1690
|
+
const publicChange = (change) => Predicate.isTagged("Renamed")(change)
|
|
1691
|
+
? OverlayChange.make({ ...change, from: ownedPath(change.from), to: ownedPath(change.to) })
|
|
1692
|
+
: OverlayChange.make({ ...change, path: ownedPath(change.path) });
|
|
1612
1693
|
const publicChanges = (changes) => Object.freeze(changes.map(publicChange));
|
|
1613
1694
|
const changeOptions = (options) => {
|
|
1614
1695
|
const decoded = decodeConfiguration(OverlayChangesOptions, options === undefined ? {} : options);
|
|
1615
1696
|
return Result.isFailure(decoded) ? Effect.fail(decoded.failure) : Effect.succeed(decoded.success);
|
|
1616
1697
|
};
|
|
1698
|
+
// An overlay is a spread copy of `volume`, so the object the caller holds is not always the one
|
|
1699
|
+
// built here. `watch` runs lazily, so it reads whichever surface was actually handed out.
|
|
1700
|
+
let surface;
|
|
1617
1701
|
const volume = Object.freeze({
|
|
1618
1702
|
[VolumeId]: true,
|
|
1619
1703
|
watch: Effect.gen(function* () {
|
|
1620
|
-
const hook = TestHooks.getRegistrationHook(
|
|
1704
|
+
const hook = TestHooks.getRegistrationHook(surface);
|
|
1621
1705
|
return yield* watchHub.subscribe(hook?.afterSubscribe);
|
|
1622
1706
|
}).pipe(Effect.withSpan("Volume.watch")),
|
|
1623
|
-
snapshot:
|
|
1707
|
+
snapshot: coordinatedRead(captureSnapshot()).pipe(Effect.withSpan("Volume.snapshot")),
|
|
1624
1708
|
caller: Effect.fn("Volume.caller")(function* (options) {
|
|
1625
1709
|
const decoded = decodeConfiguration(RootCallerOptions, options === undefined ? {} : options);
|
|
1626
1710
|
if (Result.isFailure(decoded))
|
|
@@ -1630,48 +1714,42 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
|
1630
1714
|
return createCaller({ volume: volumeIdentity, directory: root, closed: false }, identity, decoded.success.umask ?? 0o022);
|
|
1631
1715
|
})
|
|
1632
1716
|
});
|
|
1633
|
-
|
|
1634
|
-
|
|
1717
|
+
surface = volume;
|
|
1718
|
+
if (!Predicate.isTagged("Overlay")(source) || baseObservation === undefined) {
|
|
1719
|
+
// SAFETY: Overlay sources return below, so S is non-Overlay here and VolumeFor<S> is Volume.
|
|
1720
|
+
return volume;
|
|
1635
1721
|
}
|
|
1636
1722
|
const hook = TestHooks.getObservationHook(source.base);
|
|
1637
1723
|
const overlay = Object.freeze({
|
|
1638
1724
|
...volume,
|
|
1639
1725
|
changes: Effect.fn("OverlayVolume.changes")(function* (options) {
|
|
1640
1726
|
const selected = yield* changeOptions(options);
|
|
1641
|
-
const current = yield*
|
|
1727
|
+
const current = yield* coordinatedRead(observeChanges());
|
|
1642
1728
|
return publicChanges(compareOverlay(baseObservation, current, selected.includeTimestamps ?? false));
|
|
1643
1729
|
}),
|
|
1644
1730
|
capture: Effect.fn("OverlayVolume.capture")(function* (options) {
|
|
1645
1731
|
const selected = yield* changeOptions(options);
|
|
1646
|
-
const current = yield*
|
|
1732
|
+
const current = yield* coordinatedRead(captureState(hook));
|
|
1647
1733
|
return Object.freeze({
|
|
1648
1734
|
snapshot: current.snapshot,
|
|
1649
1735
|
changes: publicChanges(compareOverlay(baseObservation, current.observation, selected.includeTimestamps ?? false))
|
|
1650
1736
|
});
|
|
1651
1737
|
})
|
|
1652
1738
|
});
|
|
1653
|
-
|
|
1739
|
+
surface = overlay;
|
|
1740
|
+
return overlay;
|
|
1654
1741
|
});
|
|
1655
1742
|
/** @internal */
|
|
1656
1743
|
export const make = Effect.fn("VirtualFileSystem.make")(function* (options) {
|
|
1657
|
-
|
|
1658
|
-
if (result._tag === "Overlay")
|
|
1659
|
-
return yield* Effect.die(new Error("empty volume constructed as overlay"));
|
|
1660
|
-
return result.volume;
|
|
1744
|
+
return yield* makeVolume(VolumeSource.Empty(), options).pipe(Effect.catchTag("ImageError", Effect.die));
|
|
1661
1745
|
});
|
|
1662
1746
|
/** @internal */
|
|
1663
1747
|
export const fromSnapshot = Effect.fn("VirtualFileSystem.fromSnapshot")(function* (snapshot, options) {
|
|
1664
1748
|
const image = yield* Image.inspect(snapshot);
|
|
1665
|
-
|
|
1666
|
-
if (result._tag === "Overlay")
|
|
1667
|
-
return yield* new ImageError({ code: "InvalidStructure" });
|
|
1668
|
-
return result.volume;
|
|
1749
|
+
return yield* makeVolume(VolumeSource.Snapshot({ image }), options);
|
|
1669
1750
|
});
|
|
1670
1751
|
/** @internal */
|
|
1671
1752
|
export const makeOverlay = Effect.fn("VirtualFileSystem.makeOverlay")(function* (base, options) {
|
|
1672
1753
|
const image = yield* Image.inspect(base);
|
|
1673
|
-
|
|
1674
|
-
if (result._tag === "Volume")
|
|
1675
|
-
return yield* new ImageError({ code: "InvalidStructure" });
|
|
1676
|
-
return result.volume;
|
|
1754
|
+
return yield* makeVolume(VolumeSource.Overlay({ base, image }), options);
|
|
1677
1755
|
});
|