@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.
Files changed (53) hide show
  1. package/dist/BytePath.d.ts +33 -2
  2. package/dist/BytePath.d.ts.map +1 -1
  3. package/dist/BytePath.js +1 -1
  4. package/dist/Snapshot.d.ts +58 -2
  5. package/dist/Snapshot.d.ts.map +1 -1
  6. package/dist/Snapshot.js +57 -1
  7. package/dist/SnapshotDelta.d.ts +113 -3
  8. package/dist/SnapshotDelta.d.ts.map +1 -1
  9. package/dist/SnapshotDelta.js +112 -2
  10. package/dist/VirtualFileSystem.d.ts +919 -10
  11. package/dist/VirtualFileSystem.d.ts.map +1 -1
  12. package/dist/VirtualFileSystem.js +575 -7
  13. package/dist/index.d.ts +1 -1
  14. package/dist/index.js +1 -1
  15. package/dist/internal/bytePath.d.ts.map +1 -1
  16. package/dist/internal/bytePath.js +5 -10
  17. package/dist/internal/bytes.d.ts +6 -0
  18. package/dist/internal/bytes.d.ts.map +1 -0
  19. package/dist/internal/bytes.js +17 -0
  20. package/dist/internal/canonicalBase64.d.ts +13 -5
  21. package/dist/internal/canonicalBase64.d.ts.map +1 -1
  22. package/dist/internal/canonicalBase64.js +28 -6
  23. package/dist/internal/image.d.ts +76 -90
  24. package/dist/internal/image.d.ts.map +1 -1
  25. package/dist/internal/image.js +98 -89
  26. package/dist/internal/metadata.d.ts +28 -0
  27. package/dist/internal/metadata.d.ts.map +1 -0
  28. package/dist/internal/metadata.js +29 -0
  29. package/dist/internal/snapshotDelta.d.ts.map +1 -1
  30. package/dist/internal/snapshotDelta.js +346 -300
  31. package/dist/internal/snapshotDeltaModel.d.ts +2 -2
  32. package/dist/internal/snapshotDeltaModel.d.ts.map +1 -1
  33. package/dist/internal/snapshotDeltaModel.js +3 -1
  34. package/dist/internal/virtualFileSystem/errors.d.ts +5 -0
  35. package/dist/internal/virtualFileSystem/errors.d.ts.map +1 -1
  36. package/dist/internal/virtualFileSystem/errors.js +19 -0
  37. package/dist/internal/virtualFileSystem/fixture.d.ts.map +1 -1
  38. package/dist/internal/virtualFileSystem/fixture.js +74 -74
  39. package/dist/internal/virtualFileSystem/overlayContent.d.ts.map +1 -1
  40. package/dist/internal/virtualFileSystem/overlayContent.js +9 -2
  41. package/dist/internal/virtualFileSystem/overlayDiff.d.ts +2 -11
  42. package/dist/internal/virtualFileSystem/overlayDiff.d.ts.map +1 -1
  43. package/dist/internal/virtualFileSystem/overlayDiff.js +45 -71
  44. package/dist/internal/virtualFileSystem/path.d.ts +15 -14
  45. package/dist/internal/virtualFileSystem/path.d.ts.map +1 -1
  46. package/dist/internal/virtualFileSystem/path.js +57 -54
  47. package/dist/internal/virtualFileSystem/testHooks.d.ts.map +1 -1
  48. package/dist/internal/virtualFileSystem/watchHub.d.ts.map +1 -1
  49. package/dist/internal/virtualFileSystem/watchHub.js +13 -13
  50. package/dist/internal/virtualFileSystem.d.ts +91 -15
  51. package/dist/internal/virtualFileSystem.d.ts.map +1 -1
  52. package/dist/internal/virtualFileSystem.js +470 -392
  53. 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 { getBytes as getBytePathBytes } from "./bytePath.js";
14
+ import { CanonicalBase64 } from "./canonicalBase64.js";
13
15
  import * as Image from "./image.js";
14
- import { ConfigurationError, FsCode as FsCodeSchema, FsError } from "./virtualFileSystem/errors.js";
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 { attachedBuffer, decodeConfiguration, failure, nameBytes, ownedPath, preparePath, strictString, wellFormed } from "./virtualFileSystem/path.js";
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
- /** @internal */
33
- export const Mode = Schema.Natural.check(Schema.isLessThanOrEqualTo(0o7777));
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
- // Match snapshot v1's canonical signed decimal timestamp domain.
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
- /** @internal */
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(Schema.Union([
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: String(metadata.atimeNs),
200
- mtimeNs: String(metadata.mtimeNs),
201
- ctimeNs: String(metadata.ctimeNs),
202
- birthtimeNs: String(metadata.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 = source._tag === "Empty" ? undefined : source.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 (!Schema.is(Timestamp)(initialTime)) {
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 Schema.is(Timestamp)(now) ? Effect.succeed(now) : Effect.fail(failure("InvalidArgument", operation));
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 (record.kind === "directory")
278
+ if (Image.Record.guards.directory(record))
247
279
  count += record.entries.length;
248
280
  else {
249
- const length = Image.decodedLength(record.kind === "file" ? record.data : record.target);
250
- if (record.kind === "file" && length > maxFileBytes) {
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 = source._tag === "Overlay" ? Content.forOverlay(source.base, image) : undefined;
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.kind,
296
+ kind: record._tag,
265
297
  ino: record.id === image.root ? 1n : nextInode++,
266
- nlink: record.kind === "directory" ? 2 : 0,
298
+ nlink: Image.Record.guards.directory(record) ? 2 : 0,
267
299
  size: 0n,
268
- atimeNs: BigInt(record.metadata.atimeNs),
269
- mtimeNs: BigInt(record.metadata.mtimeNs),
270
- ctimeNs: BigInt(record.metadata.ctimeNs),
271
- birthtimeNs: BigInt(record.metadata.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 (record.kind === "directory") {
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 (record.kind === "file") {
289
- const data = baseContents?.get(record.id) ?? Content.make(Image.bytes(record.data));
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 = Image.bytes(record.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 (record.kind !== "directory")
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(Image.bytes(entry.name)), node);
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 "2f" + names.reverse().join("2f");
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 === "2f" ? "" : "2f") + name)) };
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
- if (target === root) {
358
- changes.push({ _tag: "Update", path: ownedPath(new Uint8Array([47])) });
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({ _tag: "Update", path: ownedPath(nameBytes(path)) });
409
+ changes.push(UpdateChange.make({ path: ownedPath(nameBytes(path)) }));
370
410
  }
371
411
  if (node.kind === "directory")
372
- pending.push([node, path + "2f"]);
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: Image.base64(nameBytes(name)), target });
441
+ children.push({ name: CanonicalBase64.encode(nameBytes(name)), target });
400
442
  }
401
- records.push({ id, kind: "directory", metadata, entries: children });
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, kind: "file", metadata, data: Image.base64(node.data.bytes) });
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([47])]];
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++] = 47;
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 = (directory, identity, bits, operation, path) => {
516
+ const authorize = (node, identity, bits, operation, path) => {
472
517
  if (identity.privileged)
473
518
  return Effect.void;
474
- const metadata = directory.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(failure("AccessDenied", operation, path));
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
- const resize = Effect.fnUntraced(function* (file, length, operation) {
518
- if (typeof length !== "bigint" || length < 0n)
519
- return yield* failure("InvalidArgument", operation);
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 & ~0o6000,
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(failure("InvalidHandle", operation))
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 (!Schema.is(Schema.Natural)(maximum))
550
- return yield* failure("InvalidArgument", "read");
613
+ if (!isNatural(maximum))
614
+ return yield* new FsError({ code: "InvalidArgument", operation: "read" });
551
615
  const offset = position ?? ref.offset;
552
- if (typeof offset !== "bigint" || offset < 0n || offset > 0x7fffffffffffffffn) {
553
- return yield* failure("InvalidArgument", "read");
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 instanceof Uint8Array) || !(input.buffer instanceof ArrayBuffer) || !attachedBuffer(input)) {
566
- return yield* failure("InvalidArgument", "write");
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 (typeof offset !== "bigint" || offset < 0n || offset > 0x7fffffffffffffffn) {
573
- return yield* failure("InvalidArgument", "write");
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* failure("FileTooLarge", "write");
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* failure("NoSpace", "write");
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
- usedBytes += BigInt(size - file.data.bytes.length);
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 (typeof offset !== "bigint" || !Schema.is(SeekMode)(mode)) {
630
- return yield* failure("InvalidArgument", "seek");
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 > 0x7fffffffffffffffn)
634
- return yield* failure("InvalidArgument", "seek");
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* failure("NoData", "seek");
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* failure("ClosedCaller", operation);
666
- if (typeof target !== "object" || target === null)
667
- return yield* failure("InvalidReference", operation);
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* failure("InvalidReference", operation);
671
- if (state.volume !== volumeIdentity)
672
- return yield* failure("ForeignReference", operation);
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* failure("StaleReference", operation);
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* failure("ClosedCaller", operation, path.input);
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* failure("InvalidHandle", operation, path.input);
686
- if (target.volume !== volumeIdentity)
687
- return yield* failure("ForeignHandle", operation, path.input);
688
- if (target.directory === undefined)
689
- return yield* failure("InvalidHandle", operation, path.input);
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, 1, operation, path.input);
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* failure("NotDirectory", operation, path.input);
702
- yield* authorize(current, identity, 1, operation, path.input);
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 === "2e")
767
+ if (component === DOT_HEX)
707
768
  continue;
708
- if (component === "2e2e") {
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* failure("NotFound", operation, path.input);
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* failure("NotFound", operation, path.input);
726
- if (++traversals > 40)
727
- return yield* failure("SymlinkLoop", operation, path.input);
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* failure("PathTooLong", operation, path.input);
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
- work = yield* Effect.fromResult(preparePath(ownedPath(expansion), operation, settings.maxPathBytes));
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* failure("NotDirectory", operation, path.input);
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* failure("NotFound", operation, path.input);
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* failure("NotFound", operation, path.input);
760
- if (node.kind !== "directory")
761
- return yield* failure("NotDirectory", operation, path.input);
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, 1, operation, input);
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, 4, "readDirectory", input);
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* failure("InvalidArgument", "readLink", input);
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* failure("NotFound", "realPath", input);
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("2f" + components.reverse().join("2f"));
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* failure("ClosedCaller", operation);
827
- if (typeof target === "object" && target !== null && (FileHandleId in target || DirectoryHandleId in target)) {
828
- const ref = FileHandleId in target ? files.get(target) : handles.get(target);
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* failure("InvalidHandle", operation);
831
- if (ref.volume !== volumeIdentity)
832
- return yield* failure("ForeignHandle", operation);
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* failure("InvalidHandle", operation);
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(failure("AccessDenied", operation, path));
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 (!Schema.is(Mode)(mode))
852
- return yield* failure("InvalidArgument", "chmod");
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 = Schema.decodeResult(OwnerUpdate, { onExcessProperty: "error" })(owner);
944
+ const decoded = decodeOwnerUpdate(owner);
868
945
  if (Result.isFailure(decoded))
869
- return yield* failure("InvalidArgument", "chown");
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* failure("AccessDenied", "chown");
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 & ~0o6000 : 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 = Schema.decodeResult(Times, { onExcessProperty: "error" })(times);
970
+ const decoded = decodeTimes(times);
894
971
  if (Result.isFailure(decoded))
895
- return yield* failure("InvalidArgument", "utimes");
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
- return yield* failure("AccessDenied", "utimes");
906
- yield* authorize(node, identity, 2, "utimes", "/");
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 & 0o1000) !== 0 && !identity.privileged &&
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(failure("AccessDenied", operation, input))
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* failure("ClosedCaller", "rootReference");
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 instanceof Uint8Array) || !(name.buffer instanceof ArrayBuffer) || !attachedBuffer(name) ||
940
- name.length === 0 || name.length > 255 || name.includes(0) || name.includes(47))
941
- return yield* failure("InvalidArgument", "lookupReference");
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 === "2e" || key === "2e2e")
944
- return yield* failure("InvalidArgument", "lookupReference");
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* failure("NotDirectory", "lookupReference");
949
- yield* authorize(directory, identity, 1, "lookupReference", "/");
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* failure("NotFound", "lookupReference");
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* failure("NotDirectory", "parentReference");
961
- yield* authorize(directory, identity, 1, "parentReference", "/");
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* failure("NotDirectory", "observeDirectory");
976
- yield* authorize(directory, identity, 4, "observeDirectory", "/");
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* failure("InvalidArgument", "readLinkReference");
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* failure("IsDirectory", "openReference");
1005
- if (node.metadata.nlink === 0)
1006
- return yield* failure("StaleReference", "openReference");
1007
- yield* authorize(node, identity, 4, "openReference", "/");
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* failure("IsDirectory", "readFile", input);
1020
- yield* authorize(node, identity, 4, "readFile", input);
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 instanceof Uint8Array) || !(bytes.buffer instanceof ArrayBuffer) || !attachedBuffer(bytes)) {
1029
- return yield* failure("InvalidArgument", "writeFile", input);
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 = Schema.decodeResult(WriteFileSettings, { onExcessProperty: "error" })(raw);
1034
- if (Result.isFailure(decoded))
1035
- return yield* failure("InvalidArgument", "writeFile", input);
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* failure("AlreadyExists", "writeFile", input);
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* failure("IsDirectory", "writeFile", input);
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* failure("SymlinkLoop", "writeFile", input);
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, 3, "writeFile", input);
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 && settings.maxEntries !== undefined && entries >= settings.maxEntries) {
1066
- return yield* failure("NoSpace", "writeFile", input);
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" ? 6 : 2, "writeFile", input);
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* failure("FileTooLarge", "writeFile", input);
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* failure("NoSpace", "writeFile", input);
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
- data: Content.make(data),
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 & ~0o6000,
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.entries.set(name, node);
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 > 7)
1154
- return yield* failure("InvalidArgument", "access", input);
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 & 1) !== 0 && (node.metadata.mode & 0o111) === 0) {
1158
- return yield* failure("AccessDenied", "access", input);
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* failure("IsDirectory", "truncate", input);
1170
- yield* authorize(node, identity, 2, "truncate", input);
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* failure("IsDirectory", "link", source);
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, 3, "link", destination);
1295
+ yield* authorize(parent, identity, WRITE | EXECUTE, "link", destination);
1199
1296
  const name = path.components.at(-1);
1200
- if (name === undefined || name === "2e" || name === "2e2e" || parent.entries.has(name)) {
1201
- return yield* failure("AlreadyExists", "link", destination);
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 (path.trailingSlash)
1204
- return yield* failure("NotDirectory", "link", destination);
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.entries.set(name, node);
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
- if (typeof target === "string" && !wellFormed(target)) {
1221
- return yield* failure("InvalidPathEncoding", "symlink", target);
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
- const rawTarget = typeof target === "string" ? new TextEncoder().encode(target) : getBytePathBytes(target);
1224
- if (rawTarget === undefined || rawTarget.includes(0)) {
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, 3, "symlink", input);
1329
+ yield* authorize(parent, identity, WRITE | EXECUTE, "symlink", input);
1234
1330
  const name = path.components.at(-1);
1235
- if (name === undefined || name === "2e" || name === "2e2e" || parent.entries.has(name)) {
1236
- return yield* failure("AlreadyExists", "symlink", input);
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 (path.trailingSlash)
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* failure("NoSpace", "symlink", input);
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.entries.set(name, node);
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 = Schema.decodeResult(OpenSettings, { onExcessProperty: "error" })(raw);
1289
- if (Result.isFailure(decoded))
1290
- return yield* failure("InvalidArgument", "open", input);
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* failure("InvalidArgument", "open", input);
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* failure("InvalidArgument", "open", input);
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* failure("AlreadyExists", "open", input);
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* failure("IsDirectory", "open", input);
1421
+ return yield* new FsError({ code: "IsDirectory", operation: "open", path: input });
1325
1422
  const name = resolved.name;
1326
- if (name === undefined || name === "2e" || name === "2e2e") {
1327
- return yield* failure("IsDirectory", "open", input);
1423
+ if (isDotComponent(name)) {
1424
+ return yield* new FsError({ code: "IsDirectory", operation: "open", path: input });
1328
1425
  }
1329
- yield* authorize(parent, identity, 1, "open", input);
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* failure("AlreadyExists", "open", input);
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* failure("NotFound", "open", input);
1433
+ return yield* new FsError({ code: "NotFound", operation: "open", path: input });
1337
1434
  }
1338
- yield* authorize(parent, identity, 3, "open", input);
1339
- if (settings.maxEntries !== undefined && entries >= settings.maxEntries) {
1340
- return yield* failure("NoSpace", "open", input);
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.entries.set(name, file);
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* failure("SymlinkLoop", "open", input);
1366
- if (file.kind !== "file")
1367
- return yield* failure("IsDirectory", "open", input);
1368
- if (path.trailingSlash)
1369
- return yield* failure("NotDirectory", "open", input);
1370
- yield* authorize(file, identity, chosen.access === "read" ? 4 : chosen.access === "write" ? 2 : 6, "open", input);
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, 3, "unlink", input);
1483
+ yield* authorize(parent, identity, WRITE | EXECUTE, "unlink", input);
1386
1484
  const name = path.components.at(-1);
1387
- if (name === undefined || name === "2e" || name === "2e2e") {
1388
- return yield* failure("IsDirectory", "unlink", input);
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* failure("NotFound", "unlink", input);
1393
- if (child.kind === "directory")
1394
- return yield* failure("IsDirectory", "unlink", input);
1395
- if (path.trailingSlash)
1396
- return yield* failure("NotDirectory", "unlink", input);
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, 3, "rename", source);
1418
- yield* authorize(newParent, identity, 3, "rename", destination);
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 === undefined || newName === undefined || oldName === "2e" || oldName === "2e2e" ||
1422
- newName === "2e" || newName === "2e2e") {
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* failure("NotFound", "rename", source);
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* failure("NotFound", "rename", destination);
1529
+ return yield* new FsError({ code: "NotFound", operation: "rename", path: destination });
1431
1530
  }
1432
1531
  if (oldPath.trailingSlash && child.kind !== "directory") {
1433
- return yield* failure("NotDirectory", "rename", source);
1532
+ return yield* new FsError({ code: "NotDirectory", operation: "rename", path: source });
1434
1533
  }
1435
1534
  if (newPath.trailingSlash && replaced?.kind !== "directory") {
1436
- return yield* failure("NotDirectory", "rename", destination);
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* failure("NotDirectory", "rename", destination);
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* failure("IsDirectory", "rename", destination);
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* failure("NotEmpty", "rename", destination);
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* failure("InvalidArgument", "rename", destination);
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 ? "" : "2f") + oldName));
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, 3, "rmdir", input);
1595
+ yield* authorize(parent, identity, WRITE | EXECUTE, "rmdir", input);
1496
1596
  const name = path.components.at(-1);
1497
- if (name === undefined || name === "2e" || name === "2e2e") {
1498
- return yield* failure("InvalidArgument", "rmdir", input);
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* failure("NotFound", "rmdir", input);
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* failure("NotDirectory", "rmdir", input);
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* failure("NotEmpty", "rmdir", input);
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 (!Schema.is(Mode)(mode))
1534
- return yield* failure("InvalidArgument", "mkdir", input);
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, 3, "mkdir", input);
1639
+ yield* authorize(parent, identity, WRITE | EXECUTE, "mkdir", input);
1539
1640
  const name = path.components.at(-1);
1540
- if (name === undefined || name === "2e" || name === "2e2e" || parent.entries.has(name)) {
1541
- return yield* failure("AlreadyExists", "mkdir", input);
1641
+ if (isDotComponent(name) || parent.entries.has(name)) {
1642
+ return yield* new FsError({ code: "AlreadyExists", operation: "mkdir", path: input });
1542
1643
  }
1543
- if (settings.maxEntries !== undefined && entries >= settings.maxEntries) {
1544
- return yield* failure("NoSpace", "mkdir", input);
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 & 0o1000), now),
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.entries.set(name, child);
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(failure("InvalidHandle", "stat"))
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(failure("InvalidHandle", "close"));
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 = source._tag === "Overlay" ? observeChanges() : undefined;
1596
- const publicChange = (change) => {
1597
- switch (change._tag) {
1598
- case "Added":
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(volume);
1704
+ const hook = TestHooks.getRegistrationHook(surface);
1621
1705
  return yield* watchHub.subscribe(hook?.afterSubscribe);
1622
1706
  }).pipe(Effect.withSpan("Volume.watch")),
1623
- snapshot: coordinated(captureSnapshot()).pipe(Effect.withSpan("Volume.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
- if (source._tag !== "Overlay" || baseObservation === undefined) {
1634
- return { _tag: "Volume", volume };
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* coordinated(Effect.sync(observeChanges));
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* coordinated(captureState(hook));
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
- return { _tag: "Overlay", volume: overlay };
1739
+ surface = overlay;
1740
+ return overlay;
1654
1741
  });
1655
1742
  /** @internal */
1656
1743
  export const make = Effect.fn("VirtualFileSystem.make")(function* (options) {
1657
- const result = yield* makeVolume({ _tag: "Empty" }, options).pipe(Effect.catchTag("ImageError", Effect.die));
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
- const result = yield* makeVolume({ _tag: "Snapshot", image }, options);
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
- const result = yield* makeVolume({ _tag: "Overlay", base, image }, options);
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
  });