@effect-vfs/core 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/BytePath.d.ts +49 -5
- package/dist/BytePath.d.ts.map +1 -1
- package/dist/BytePath.js +9 -9
- package/dist/Snapshot.d.ts +61 -5
- package/dist/Snapshot.d.ts.map +1 -1
- package/dist/Snapshot.js +60 -5
- package/dist/SnapshotDelta.d.ts +208 -31
- package/dist/SnapshotDelta.d.ts.map +1 -1
- package/dist/SnapshotDelta.js +162 -25
- package/dist/VirtualFileSystem.d.ts +1035 -348
- package/dist/VirtualFileSystem.d.ts.map +1 -1
- package/dist/VirtualFileSystem.js +620 -1772
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/internal/bytePath.d.ts +10 -0
- package/dist/internal/bytePath.d.ts.map +1 -0
- package/dist/internal/bytePath.js +41 -0
- package/dist/internal/bytes.d.ts +6 -0
- package/dist/internal/bytes.d.ts.map +1 -0
- package/dist/internal/bytes.js +17 -0
- package/dist/internal/canonicalBase64.d.ts +14 -6
- package/dist/internal/canonicalBase64.d.ts.map +1 -1
- package/dist/internal/canonicalBase64.js +29 -7
- package/dist/internal/image.d.ts +85 -104
- package/dist/internal/image.d.ts.map +1 -1
- package/dist/internal/image.js +99 -95
- package/dist/internal/metadata.d.ts +28 -0
- package/dist/internal/metadata.d.ts.map +1 -0
- package/dist/internal/metadata.js +29 -0
- package/dist/internal/snapshotDelta.d.ts +0 -1
- package/dist/internal/snapshotDelta.d.ts.map +1 -1
- package/dist/internal/snapshotDelta.js +358 -325
- package/dist/internal/snapshotDeltaModel.d.ts +10 -0
- package/dist/internal/snapshotDeltaModel.d.ts.map +1 -0
- package/dist/internal/snapshotDeltaModel.js +15 -0
- package/dist/internal/virtualFileSystem/errors.d.ts +31 -0
- package/dist/internal/virtualFileSystem/errors.d.ts.map +1 -0
- package/dist/internal/virtualFileSystem/errors.js +51 -0
- package/dist/internal/virtualFileSystem/fixture.d.ts +63 -0
- package/dist/internal/virtualFileSystem/fixture.d.ts.map +1 -0
- package/dist/internal/virtualFileSystem/fixture.js +144 -0
- package/dist/internal/{content.d.ts → virtualFileSystem/overlayContent.d.ts} +4 -14
- package/dist/internal/virtualFileSystem/overlayContent.d.ts.map +1 -0
- package/dist/internal/virtualFileSystem/overlayContent.js +29 -0
- package/dist/internal/{overlayChanges.d.ts → virtualFileSystem/overlayDiff.d.ts} +3 -12
- package/dist/internal/virtualFileSystem/overlayDiff.d.ts.map +1 -0
- package/dist/internal/virtualFileSystem/overlayDiff.js +120 -0
- package/dist/internal/virtualFileSystem/path.d.ts +44 -0
- package/dist/internal/virtualFileSystem/path.d.ts.map +1 -0
- package/dist/internal/virtualFileSystem/path.js +127 -0
- package/dist/internal/virtualFileSystem/testHooks.d.ts +20 -0
- package/dist/internal/virtualFileSystem/testHooks.d.ts.map +1 -0
- package/dist/internal/virtualFileSystem/testHooks.js +23 -0
- package/dist/internal/virtualFileSystem/watchHub.d.ts +16 -0
- package/dist/internal/virtualFileSystem/watchHub.d.ts.map +1 -0
- package/dist/internal/virtualFileSystem/watchHub.js +33 -0
- package/dist/internal/virtualFileSystem.d.ts +366 -0
- package/dist/internal/virtualFileSystem.d.ts.map +1 -0
- package/dist/internal/virtualFileSystem.js +1755 -0
- package/package.json +15 -5
- package/dist/internal/content.d.ts.map +0 -1
- package/dist/internal/content.js +0 -26
- package/dist/internal/overlayChanges.d.ts.map +0 -1
- package/dist/internal/overlayChanges.js +0 -146
- package/dist/internal/overlayTesting.d.ts +0 -12
- package/dist/internal/overlayTesting.d.ts.map +0 -1
- package/dist/internal/overlayTesting.js +0 -12
|
@@ -0,0 +1,1755 @@
|
|
|
1
|
+
// Runtime definitions and cohesive live virtual filesystem engine.
|
|
2
|
+
import * as ByteSize from "effect/ByteSize";
|
|
3
|
+
import * as Clock from "effect/Clock";
|
|
4
|
+
import * as Context from "effect/Context";
|
|
5
|
+
import * as Data from "effect/Data";
|
|
6
|
+
import * as Effect from "effect/Effect";
|
|
7
|
+
import * as Encoding from "effect/Encoding";
|
|
8
|
+
import * as Predicate from "effect/Predicate";
|
|
9
|
+
import * as Result from "effect/Result";
|
|
10
|
+
import * as Schema from "effect/Schema";
|
|
11
|
+
import * as Semaphore from "effect/Semaphore";
|
|
12
|
+
import { BytePath } from "../BytePath.js";
|
|
13
|
+
import { ImageError } from "../Snapshot.js";
|
|
14
|
+
import { CanonicalBase64 } from "./canonicalBase64.js";
|
|
15
|
+
import * as Image from "./image.js";
|
|
16
|
+
import * as MetadataDomain from "./metadata.js";
|
|
17
|
+
import { ConfigurationError, decodeConfiguration, FsCode as FsCodeSchema, FsError } from "./virtualFileSystem/errors.js";
|
|
18
|
+
import * as Content from "./virtualFileSystem/overlayContent.js";
|
|
19
|
+
import { compareOverlay } from "./virtualFileSystem/overlayDiff.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";
|
|
21
|
+
import * as TestHooks from "./virtualFileSystem/testHooks.js";
|
|
22
|
+
import * as WatchHub from "./virtualFileSystem/watchHub.js";
|
|
23
|
+
/** @internal */
|
|
24
|
+
export const VolumeId = Symbol("@effect-vfs/core/Volume");
|
|
25
|
+
/** @internal */
|
|
26
|
+
export const CallerId = Symbol("@effect-vfs/core/Caller");
|
|
27
|
+
/** @internal */
|
|
28
|
+
export const FileHandleId = Symbol("@effect-vfs/core/FileHandle");
|
|
29
|
+
/** @internal */
|
|
30
|
+
export const DirectoryHandleId = Symbol("@effect-vfs/core/DirectoryHandle");
|
|
31
|
+
/** @internal */
|
|
32
|
+
export const ObjectReferenceId = Symbol("@effect-vfs/core/ObjectReference");
|
|
33
|
+
/** @internal */
|
|
34
|
+
export { ConfigurationError, FsCodeSchema as FsCode, FsError };
|
|
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);
|
|
51
|
+
/** @internal */
|
|
52
|
+
export const Identity = Schema.Struct({
|
|
53
|
+
uid: Schema.Natural,
|
|
54
|
+
gid: Schema.Natural,
|
|
55
|
+
groups: Schema.Array(Schema.Natural),
|
|
56
|
+
privileged: Schema.Boolean
|
|
57
|
+
});
|
|
58
|
+
/** @internal */
|
|
59
|
+
export const RootCallerOptions = Schema.Struct({
|
|
60
|
+
identity: Schema.optionalKey(Identity),
|
|
61
|
+
umask: Schema.optionalKey(Schema.Natural.check(Schema.isLessThanOrEqualTo(0o777)))
|
|
62
|
+
});
|
|
63
|
+
/** @internal */
|
|
64
|
+
export const VolumeOptions = Schema.Struct({
|
|
65
|
+
maxEntries: Schema.optionalKey(Schema.Natural),
|
|
66
|
+
maxBytes: Schema.optionalKey(Schema.ByteSize),
|
|
67
|
+
maxFileBytes: Schema.optionalKey(Schema.ByteSize.check(Schema.makeFilter((size) => ByteSize.isLessThanOrEqualTo(size, ByteSize.bytes(0xffffffff)) ? undefined : "must be at most 4294967295 bytes"))),
|
|
68
|
+
maxPathBytes: Schema.optionalKey(Schema.ByteSize.check(Schema.makeFilter((size) => ByteSize.isGreaterThanOrEqualTo(size, ByteSize.bytes(1)) ? undefined : "must be at least 1 byte")))
|
|
69
|
+
});
|
|
70
|
+
const isTimestamp = Schema.is(MetadataDomain.Timestamp);
|
|
71
|
+
/** @internal */
|
|
72
|
+
export const Metadata = Schema.Struct({
|
|
73
|
+
kind: Schema.Literals(["directory", "file", "symlink"]),
|
|
74
|
+
ino: Schema.BigInt,
|
|
75
|
+
nlink: Schema.Natural,
|
|
76
|
+
size: Schema.BigInt,
|
|
77
|
+
uid: Schema.Natural,
|
|
78
|
+
gid: Schema.Natural,
|
|
79
|
+
mode: Mode,
|
|
80
|
+
atimeNs: MetadataDomain.Timestamp,
|
|
81
|
+
mtimeNs: MetadataDomain.Timestamp,
|
|
82
|
+
ctimeNs: MetadataDomain.Timestamp,
|
|
83
|
+
birthtimeNs: MetadataDomain.Timestamp
|
|
84
|
+
});
|
|
85
|
+
/** @internal */
|
|
86
|
+
export const OwnerUpdate = Schema.Struct({
|
|
87
|
+
uid: Schema.optionalKey(Schema.Natural),
|
|
88
|
+
gid: Schema.optionalKey(Schema.Natural)
|
|
89
|
+
});
|
|
90
|
+
/** @internal */
|
|
91
|
+
export const TimeUpdate = Schema.Union([
|
|
92
|
+
Schema.Struct({ kind: Schema.Literal("now") }),
|
|
93
|
+
Schema.Struct({ kind: Schema.Literal("omit") }),
|
|
94
|
+
Schema.Struct({ kind: Schema.Literal("value"), nanoseconds: MetadataDomain.Timestamp })
|
|
95
|
+
]);
|
|
96
|
+
/** @internal */
|
|
97
|
+
export const Times = Schema.Struct({ access: TimeUpdate, modification: TimeUpdate });
|
|
98
|
+
/** @internal */
|
|
99
|
+
export const SeekMode = Schema.Literals(["start", "current", "end", "data", "hole"]);
|
|
100
|
+
const isSeekMode = Schema.is(SeekMode);
|
|
101
|
+
/** @internal */
|
|
102
|
+
export const OpenSettings = Schema.Struct({
|
|
103
|
+
access: Schema.Literals(["read", "write", "readWrite"]),
|
|
104
|
+
create: Schema.optionalKey(Schema.Literals(["never", "ifMissing", "exclusive"])),
|
|
105
|
+
mode: Schema.optionalKey(Mode),
|
|
106
|
+
append: Schema.optionalKey(Schema.Boolean),
|
|
107
|
+
truncate: Schema.optionalKey(Schema.Boolean),
|
|
108
|
+
followFinalSymlink: Schema.optionalKey(Schema.Boolean)
|
|
109
|
+
});
|
|
110
|
+
const WriteFileSettings = Schema.Struct({
|
|
111
|
+
...OpenSettings.fields,
|
|
112
|
+
replaceFinalSymlink: Schema.optionalKey(Schema.Boolean),
|
|
113
|
+
finalMode: Schema.optionalKey(Mode)
|
|
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" });
|
|
119
|
+
/** @internal */
|
|
120
|
+
export const OverlayNodeKind = Schema.Literals(["directory", "file", "symlink"]);
|
|
121
|
+
/** @internal */
|
|
122
|
+
export const OverlayDifference = Schema.Literals([
|
|
123
|
+
"content",
|
|
124
|
+
"mode",
|
|
125
|
+
"uid",
|
|
126
|
+
"gid",
|
|
127
|
+
"atimeNs",
|
|
128
|
+
"mtimeNs",
|
|
129
|
+
"ctimeNs",
|
|
130
|
+
"birthtimeNs"
|
|
131
|
+
]);
|
|
132
|
+
const OverlayDifferences = Schema.Array(OverlayDifference);
|
|
133
|
+
const NonEmptyOverlayDifferences = OverlayDifferences.check(Schema.isMinLength(1));
|
|
134
|
+
/** @internal */
|
|
135
|
+
export const OverlayChange = Schema.Union([
|
|
136
|
+
Schema.TaggedStruct("Added", { path: BytePath, kind: OverlayNodeKind }),
|
|
137
|
+
Schema.TaggedStruct("Removed", { path: BytePath, kind: OverlayNodeKind }),
|
|
138
|
+
Schema.TaggedStruct("Replaced", {
|
|
139
|
+
path: BytePath,
|
|
140
|
+
beforeKind: OverlayNodeKind,
|
|
141
|
+
afterKind: OverlayNodeKind,
|
|
142
|
+
differences: OverlayDifferences
|
|
143
|
+
}),
|
|
144
|
+
Schema.TaggedStruct("Renamed", {
|
|
145
|
+
from: BytePath,
|
|
146
|
+
to: BytePath,
|
|
147
|
+
kind: OverlayNodeKind,
|
|
148
|
+
differences: OverlayDifferences
|
|
149
|
+
}),
|
|
150
|
+
Schema.TaggedStruct("Updated", { path: BytePath, kind: OverlayNodeKind, differences: NonEmptyOverlayDifferences })
|
|
151
|
+
]);
|
|
152
|
+
/** @internal */
|
|
153
|
+
export const OverlayChangesOptions = Schema.Struct({
|
|
154
|
+
includeTimestamps: Schema.optionalKey(Schema.Boolean)
|
|
155
|
+
});
|
|
156
|
+
/** @internal */
|
|
157
|
+
export class CurrentFileSystem extends Context.Service()("@effect-vfs/core/CurrentFileSystem") {
|
|
158
|
+
}
|
|
159
|
+
/** @internal */
|
|
160
|
+
export const FixtureMetadata = Schema.Struct({
|
|
161
|
+
uid: Schema.optionalKey(Schema.Natural),
|
|
162
|
+
gid: Schema.optionalKey(Schema.Natural),
|
|
163
|
+
mode: Schema.optionalKey(Mode),
|
|
164
|
+
atimeNs: Schema.optionalKey(MetadataDomain.Timestamp),
|
|
165
|
+
mtimeNs: Schema.optionalKey(MetadataDomain.Timestamp),
|
|
166
|
+
ctimeNs: Schema.optionalKey(MetadataDomain.Timestamp),
|
|
167
|
+
birthtimeNs: Schema.optionalKey(MetadataDomain.Timestamp)
|
|
168
|
+
});
|
|
169
|
+
const FixturePath = Schema.Union([
|
|
170
|
+
Schema.String,
|
|
171
|
+
BytePath
|
|
172
|
+
]);
|
|
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 */
|
|
195
|
+
export const Fixture = Schema.Struct({
|
|
196
|
+
rootMetadata: Schema.optionalKey(FixtureMetadata),
|
|
197
|
+
entries: Schema.Array(FixtureEntry)
|
|
198
|
+
});
|
|
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);
|
|
204
|
+
const files = new WeakMap();
|
|
205
|
+
const handles = new WeakMap();
|
|
206
|
+
const directoryMetadata = (ino, uid, gid, mode, now) => ({
|
|
207
|
+
kind: "directory",
|
|
208
|
+
ino,
|
|
209
|
+
uid,
|
|
210
|
+
gid,
|
|
211
|
+
mode,
|
|
212
|
+
nlink: 2,
|
|
213
|
+
size: 0n,
|
|
214
|
+
atimeNs: now,
|
|
215
|
+
mtimeNs: now,
|
|
216
|
+
ctimeNs: now,
|
|
217
|
+
birthtimeNs: now
|
|
218
|
+
});
|
|
219
|
+
const storedMetadata = (metadata) => ({
|
|
220
|
+
uid: metadata.uid,
|
|
221
|
+
gid: metadata.gid,
|
|
222
|
+
mode: metadata.mode,
|
|
223
|
+
atimeNs: metadata.atimeNs,
|
|
224
|
+
mtimeNs: metadata.mtimeNs,
|
|
225
|
+
ctimeNs: metadata.ctimeNs,
|
|
226
|
+
birthtimeNs: metadata.birthtimeNs
|
|
227
|
+
});
|
|
228
|
+
/** @internal */
|
|
229
|
+
export const VolumeSource = Data.taggedEnum();
|
|
230
|
+
const UpdateChange = Schema.TaggedStruct("Update", { path: BytePath });
|
|
231
|
+
// Each execution constructs a fresh volume and captures its Clock.
|
|
232
|
+
/** @internal */
|
|
233
|
+
export const makeVolume = Effect.fnUntraced(function* (source, options) {
|
|
234
|
+
const image = "image" in source ? source.image : undefined;
|
|
235
|
+
const decoded = decodeConfiguration(VolumeOptions, options === undefined ? {} : options);
|
|
236
|
+
if (Result.isFailure(decoded))
|
|
237
|
+
return yield* decoded.failure;
|
|
238
|
+
const settings = { ...decoded.success };
|
|
239
|
+
const clock = yield* Clock.clockWith(Effect.succeed);
|
|
240
|
+
const initialTime = clock.currentTimeNanosUnsafe();
|
|
241
|
+
if (!isTimestamp(initialTime)) {
|
|
242
|
+
return yield* new ConfigurationError({ field: "clock.currentTimeNanos" });
|
|
243
|
+
}
|
|
244
|
+
const timestamp = (operation) => Effect.suspend(() => {
|
|
245
|
+
const now = clock.currentTimeNanosUnsafe();
|
|
246
|
+
return isTimestamp(now)
|
|
247
|
+
? Effect.succeed(now)
|
|
248
|
+
: Effect.fail(new FsError({ code: "InvalidArgument", operation: operation }));
|
|
249
|
+
});
|
|
250
|
+
const volumeIdentity = Symbol();
|
|
251
|
+
const gate = Semaphore.makeUnsafe(1);
|
|
252
|
+
let revisionCounter = 0n;
|
|
253
|
+
const nextRevision = () => ++revisionCounter;
|
|
254
|
+
const root = {
|
|
255
|
+
kind: "directory",
|
|
256
|
+
lineage: image?.root,
|
|
257
|
+
parent: undefined,
|
|
258
|
+
entries: new Map(),
|
|
259
|
+
metadata: directoryMetadata(1n, 0, 0, 0o755, initialTime),
|
|
260
|
+
revision: nextRevision(),
|
|
261
|
+
objectReference: undefined
|
|
262
|
+
};
|
|
263
|
+
let nextInode = 2n;
|
|
264
|
+
let entries = 0;
|
|
265
|
+
let usedBytes = 0n;
|
|
266
|
+
// The schema caps this value at uint32, so this boundary conversion is exact.
|
|
267
|
+
const maxFileBytes = Number(ByteSize.toBigInt(settings.maxFileBytes ?? ByteSize.bytes(0xffffffff)));
|
|
268
|
+
// Permit waits stay interruptible. State transitions and resource registration do not.
|
|
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);
|
|
273
|
+
if (image !== undefined) {
|
|
274
|
+
const incoming = new Map();
|
|
275
|
+
let content = 0n;
|
|
276
|
+
let count = 0;
|
|
277
|
+
for (const record of image.records) {
|
|
278
|
+
if (Image.Record.guards.directory(record))
|
|
279
|
+
count += record.entries.length;
|
|
280
|
+
else {
|
|
281
|
+
const length = CanonicalBase64.decodedLength(Image.Record.guards.file(record) ? record.data : record.target);
|
|
282
|
+
if (Image.Record.guards.file(record) && length > maxFileBytes) {
|
|
283
|
+
return yield* new ImageError({ code: "LimitExceeded", field: "maxFileBytes" });
|
|
284
|
+
}
|
|
285
|
+
content += BigInt(length);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if ((settings.maxEntries !== undefined && count > settings.maxEntries) ||
|
|
289
|
+
(settings.maxBytes !== undefined && content > ByteSize.toBigInt(settings.maxBytes))) {
|
|
290
|
+
return yield* new ImageError({ code: "LimitExceeded", field: "volume" });
|
|
291
|
+
}
|
|
292
|
+
const baseContents = Predicate.isTagged("Overlay")(source) ? Content.forOverlay(source.base, image) : undefined;
|
|
293
|
+
for (const record of image.records) {
|
|
294
|
+
const metadata = {
|
|
295
|
+
...record.metadata,
|
|
296
|
+
kind: record._tag,
|
|
297
|
+
ino: record.id === image.root ? 1n : nextInode++,
|
|
298
|
+
nlink: Image.Record.guards.directory(record) ? 2 : 0,
|
|
299
|
+
size: 0n,
|
|
300
|
+
atimeNs: record.metadata.atimeNs,
|
|
301
|
+
mtimeNs: record.metadata.mtimeNs,
|
|
302
|
+
ctimeNs: record.metadata.ctimeNs,
|
|
303
|
+
birthtimeNs: record.metadata.birthtimeNs
|
|
304
|
+
};
|
|
305
|
+
if (Image.Record.guards.directory(record)) {
|
|
306
|
+
const node = record.id === image.root
|
|
307
|
+
? root
|
|
308
|
+
: {
|
|
309
|
+
kind: "directory",
|
|
310
|
+
lineage: record.id,
|
|
311
|
+
parent: undefined,
|
|
312
|
+
entries: new Map(),
|
|
313
|
+
metadata,
|
|
314
|
+
revision: nextRevision(),
|
|
315
|
+
objectReference: undefined
|
|
316
|
+
};
|
|
317
|
+
node.metadata = metadata;
|
|
318
|
+
incoming.set(record.id, node);
|
|
319
|
+
}
|
|
320
|
+
else if (Image.Record.guards.file(record)) {
|
|
321
|
+
const data = baseContents?.get(record.id) ?? Content.make(yield* CanonicalBase64.decode(record.data));
|
|
322
|
+
incoming.set(record.id, {
|
|
323
|
+
kind: "file",
|
|
324
|
+
lineage: record.id,
|
|
325
|
+
data,
|
|
326
|
+
openCount: 0,
|
|
327
|
+
metadata: { ...metadata, size: BigInt(data.bytes.length) },
|
|
328
|
+
revision: nextRevision(),
|
|
329
|
+
objectReference: undefined
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
else {
|
|
333
|
+
const target = yield* CanonicalBase64.decode(record.target);
|
|
334
|
+
incoming.set(record.id, {
|
|
335
|
+
kind: "symlink",
|
|
336
|
+
lineage: record.id,
|
|
337
|
+
target,
|
|
338
|
+
metadata: { ...metadata, size: BigInt(target.length) },
|
|
339
|
+
revision: nextRevision(),
|
|
340
|
+
objectReference: undefined
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
for (const record of image.records) {
|
|
345
|
+
if (!Image.Record.guards.directory(record))
|
|
346
|
+
continue;
|
|
347
|
+
const parent = incoming.get(record.id);
|
|
348
|
+
if (parent?.kind !== "directory")
|
|
349
|
+
return yield* new ImageError({ code: "InvalidStructure" });
|
|
350
|
+
for (const entry of record.entries) {
|
|
351
|
+
const node = incoming.get(entry.target);
|
|
352
|
+
if (node === undefined)
|
|
353
|
+
return yield* new ImageError({ code: "InvalidStructure" });
|
|
354
|
+
parent.entries.set(Encoding.encodeHex(yield* CanonicalBase64.decode(entry.name)), node);
|
|
355
|
+
if (node.kind === "directory") {
|
|
356
|
+
node.parent = parent;
|
|
357
|
+
parent.metadata = { ...parent.metadata, nlink: parent.metadata.nlink + 1 };
|
|
358
|
+
}
|
|
359
|
+
else
|
|
360
|
+
node.metadata = { ...node.metadata, nlink: node.metadata.nlink + 1 };
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
entries = count;
|
|
364
|
+
usedBytes = content;
|
|
365
|
+
}
|
|
366
|
+
const watchHub = yield* WatchHub.make(coordinated);
|
|
367
|
+
const directoryHex = (directory) => {
|
|
368
|
+
const names = [];
|
|
369
|
+
let current = directory;
|
|
370
|
+
while (current.parent !== undefined) {
|
|
371
|
+
const parent = current.parent;
|
|
372
|
+
const found = [...parent.entries].find(([, node]) => node === current);
|
|
373
|
+
if (found === undefined)
|
|
374
|
+
break;
|
|
375
|
+
names.push(found[0]);
|
|
376
|
+
current = parent;
|
|
377
|
+
}
|
|
378
|
+
return SLASH_HEX + names.reverse().join(SLASH_HEX);
|
|
379
|
+
};
|
|
380
|
+
const publishEntry = (_tag, parent, name) => {
|
|
381
|
+
watchHub.publishUnsafe(() => {
|
|
382
|
+
const prefix = directoryHex(parent);
|
|
383
|
+
return { _tag, path: ownedPath(nameBytes(prefix + (prefix === SLASH_HEX ? "" : SLASH_HEX) + name)) };
|
|
384
|
+
});
|
|
385
|
+
};
|
|
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
|
+
}
|
|
397
|
+
watchHub.publishManyUnsafe(() => {
|
|
398
|
+
const changes = [];
|
|
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) {
|
|
402
|
+
const next = pending.pop();
|
|
403
|
+
if (next === undefined)
|
|
404
|
+
break;
|
|
405
|
+
const [directory, prefix] = next;
|
|
406
|
+
for (const [name, node] of directory.entries) {
|
|
407
|
+
const path = prefix + name;
|
|
408
|
+
if (node === target) {
|
|
409
|
+
changes.push(UpdateChange.make({ path: ownedPath(nameBytes(path)) }));
|
|
410
|
+
}
|
|
411
|
+
if (node.kind === "directory")
|
|
412
|
+
pending.push([node, path + SLASH_HEX]);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return changes;
|
|
416
|
+
});
|
|
417
|
+
};
|
|
418
|
+
const captureSnapshot = Effect.fnUntraced(function* () {
|
|
419
|
+
const ids = new Map([[root, "0"]]);
|
|
420
|
+
const pending = [root];
|
|
421
|
+
const records = [];
|
|
422
|
+
for (let index = 0; index < pending.length; index++) {
|
|
423
|
+
if (index % WALK_YIELD_INTERVAL === 0)
|
|
424
|
+
yield* Effect.yieldNow;
|
|
425
|
+
const node = pending[index];
|
|
426
|
+
if (node === undefined)
|
|
427
|
+
continue;
|
|
428
|
+
const id = ids.get(node);
|
|
429
|
+
if (id === undefined)
|
|
430
|
+
return yield* new ImageError({ code: "InvalidStructure" });
|
|
431
|
+
const metadata = storedMetadata(node.metadata);
|
|
432
|
+
if (node.kind === "directory") {
|
|
433
|
+
const children = [];
|
|
434
|
+
for (const [name, child] of node.entries) {
|
|
435
|
+
let target = ids.get(child);
|
|
436
|
+
if (target === undefined) {
|
|
437
|
+
target = String(ids.size);
|
|
438
|
+
ids.set(child, target);
|
|
439
|
+
pending.push(child);
|
|
440
|
+
}
|
|
441
|
+
children.push({ name: CanonicalBase64.encode(nameBytes(name)), target });
|
|
442
|
+
}
|
|
443
|
+
records.push(Image.Record.cases.directory.make({ id, metadata, entries: children }));
|
|
444
|
+
}
|
|
445
|
+
else if (node.kind === "file") {
|
|
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) }));
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return yield* Image.capture({ format: "effect-vfs", version: 1, root: "0", records }, undefined, true);
|
|
453
|
+
});
|
|
454
|
+
const observeChanges = Effect.fnUntraced(function* () {
|
|
455
|
+
const observation = [];
|
|
456
|
+
const paths = [[root, new Uint8Array([SLASH_BYTE])]];
|
|
457
|
+
for (let index = 0; index < paths.length; index++) {
|
|
458
|
+
if (index % WALK_YIELD_INTERVAL === 0)
|
|
459
|
+
yield* Effect.yieldNow;
|
|
460
|
+
const current = paths[index];
|
|
461
|
+
if (current === undefined)
|
|
462
|
+
continue;
|
|
463
|
+
const [node, path] = current;
|
|
464
|
+
observation.push({
|
|
465
|
+
path: new Uint8Array(path),
|
|
466
|
+
lineage: node.lineage,
|
|
467
|
+
kind: node.kind,
|
|
468
|
+
content: node.kind === "file" ? node.data.bytes : node.kind === "symlink" ? node.target : undefined,
|
|
469
|
+
metadata: storedMetadata(node.metadata)
|
|
470
|
+
});
|
|
471
|
+
if (node.kind !== "directory")
|
|
472
|
+
continue;
|
|
473
|
+
for (const [name, child] of node.entries) {
|
|
474
|
+
const bytes = nameBytes(name);
|
|
475
|
+
const childPath = new Uint8Array(path.length + (path.length === 1 ? 0 : 1) + bytes.length);
|
|
476
|
+
childPath.set(path);
|
|
477
|
+
let offset = path.length;
|
|
478
|
+
if (path.length !== 1)
|
|
479
|
+
childPath[offset++] = SLASH_BYTE;
|
|
480
|
+
childPath.set(bytes, offset);
|
|
481
|
+
paths.push([child, childPath]);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
return observation;
|
|
485
|
+
});
|
|
486
|
+
const captureState = Effect.fnUntraced(function* (hook) {
|
|
487
|
+
const snapshot = yield* captureSnapshot();
|
|
488
|
+
if (hook !== undefined)
|
|
489
|
+
yield* hook.betweenSnapshotAndSummary;
|
|
490
|
+
return { snapshot, observation: yield* observeChanges() };
|
|
491
|
+
});
|
|
492
|
+
const advanceRevision = (node) => {
|
|
493
|
+
node.revision = nextRevision();
|
|
494
|
+
};
|
|
495
|
+
const referenceFor = (node) => {
|
|
496
|
+
if (node.objectReference !== undefined)
|
|
497
|
+
return node.objectReference;
|
|
498
|
+
const reference = Object.freeze({ [ObjectReferenceId]: true });
|
|
499
|
+
objectReferences.set(reference, { volume: volumeIdentity, node });
|
|
500
|
+
node.objectReference = reference;
|
|
501
|
+
return reference;
|
|
502
|
+
};
|
|
503
|
+
const invalidateReference = (node) => {
|
|
504
|
+
const reference = node.objectReference;
|
|
505
|
+
if (reference === undefined)
|
|
506
|
+
return;
|
|
507
|
+
const state = objectReferences.get(reference);
|
|
508
|
+
if (state !== undefined)
|
|
509
|
+
state.node = undefined;
|
|
510
|
+
node.objectReference = undefined;
|
|
511
|
+
};
|
|
512
|
+
const release = (reference) => coordinated(Effect.sync(() => {
|
|
513
|
+
reference.directory = undefined;
|
|
514
|
+
reference.closed = true;
|
|
515
|
+
}));
|
|
516
|
+
const authorize = (node, identity, bits, operation, path) => {
|
|
517
|
+
if (identity.privileged)
|
|
518
|
+
return Effect.void;
|
|
519
|
+
const metadata = node.metadata;
|
|
520
|
+
const shift = metadata.uid === identity.uid ?
|
|
521
|
+
6
|
|
522
|
+
: metadata.gid === identity.gid || identity.groups.includes(metadata.gid)
|
|
523
|
+
? 3
|
|
524
|
+
: 0;
|
|
525
|
+
return ((metadata.mode >> shift) & bits) === bits
|
|
526
|
+
? Effect.void
|
|
527
|
+
: Effect.fail(path === undefined
|
|
528
|
+
? new FsError({ code: "AccessDenied", operation })
|
|
529
|
+
: new FsError({ code: "AccessDenied", operation, path }));
|
|
530
|
+
};
|
|
531
|
+
const reclaim = (file) => {
|
|
532
|
+
if (file.metadata.nlink === 0 && file.openCount === 0) {
|
|
533
|
+
usedBytes -= BigInt(file.data.bytes.length);
|
|
534
|
+
file.data = Content.empty();
|
|
535
|
+
invalidateReference(file);
|
|
536
|
+
}
|
|
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
|
+
};
|
|
551
|
+
const detach = (node, now) => {
|
|
552
|
+
if (node.kind === "directory") {
|
|
553
|
+
node.parent = undefined;
|
|
554
|
+
node.metadata = { ...node.metadata, nlink: 0, ctimeNs: now };
|
|
555
|
+
advanceRevision(node);
|
|
556
|
+
invalidateReference(node);
|
|
557
|
+
}
|
|
558
|
+
else {
|
|
559
|
+
node.metadata = { ...node.metadata, nlink: node.metadata.nlink - 1, ctimeNs: now };
|
|
560
|
+
advanceRevision(node);
|
|
561
|
+
if (node.kind === "file")
|
|
562
|
+
reclaim(node);
|
|
563
|
+
else if (node.metadata.nlink === 0) {
|
|
564
|
+
usedBytes -= BigInt(node.target.length);
|
|
565
|
+
invalidateReference(node);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
};
|
|
569
|
+
const releaseFile = (ref) => {
|
|
570
|
+
if (ref.file !== undefined) {
|
|
571
|
+
ref.file.openCount -= 1;
|
|
572
|
+
reclaim(ref.file);
|
|
573
|
+
ref.file = undefined;
|
|
574
|
+
}
|
|
575
|
+
ref.closed = true;
|
|
576
|
+
};
|
|
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);
|
|
580
|
+
file.data = Content.make(data);
|
|
581
|
+
file.metadata = {
|
|
582
|
+
...file.metadata,
|
|
583
|
+
size: BigInt(data.length),
|
|
584
|
+
mode: file.metadata.mode & ~SET_ID_BITS,
|
|
585
|
+
mtimeNs: now,
|
|
586
|
+
ctimeNs: now
|
|
587
|
+
};
|
|
588
|
+
advanceRevision(file);
|
|
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));
|
|
605
|
+
});
|
|
606
|
+
const fileHandle = (ref) => {
|
|
607
|
+
const get = (operation, access) => ref.file === undefined || (access === "read" && ref.access === "write") ||
|
|
608
|
+
(access === "write" && ref.access === "read")
|
|
609
|
+
? Effect.fail(new FsError({ code: "InvalidHandle", operation: operation }))
|
|
610
|
+
: Effect.succeed(ref.file);
|
|
611
|
+
const read = Effect.fnUntraced(function* (maximum, position) {
|
|
612
|
+
const file = yield* get(position === undefined ? "read" : "pread", "read");
|
|
613
|
+
if (!isNatural(maximum))
|
|
614
|
+
return yield* new FsError({ code: "InvalidArgument", operation: "read" });
|
|
615
|
+
const offset = position ?? ref.offset;
|
|
616
|
+
if (!Predicate.isBigInt(offset) || offset < 0n || offset > MAX_FILE_OFFSET) {
|
|
617
|
+
return yield* new FsError({ code: "InvalidArgument", operation: "read" });
|
|
618
|
+
}
|
|
619
|
+
const start = Number(offset > file.metadata.size ? file.metadata.size : offset);
|
|
620
|
+
const data = file.data.bytes.slice(start, start + Math.min(maximum, file.data.bytes.length - start));
|
|
621
|
+
if (maximum > 0) {
|
|
622
|
+
file.metadata = { ...file.metadata, atimeNs: (yield* timestamp("read")) };
|
|
623
|
+
}
|
|
624
|
+
if (position === undefined)
|
|
625
|
+
ref.offset += BigInt(data.length);
|
|
626
|
+
return data;
|
|
627
|
+
}, coordinated);
|
|
628
|
+
const write = Effect.fnUntraced(function* (input, position) {
|
|
629
|
+
if (!isAttachedBytes(input))
|
|
630
|
+
return yield* new FsError({ code: "InvalidArgument", operation: "write" });
|
|
631
|
+
const bytes = new Uint8Array(input);
|
|
632
|
+
return yield* coordinated(Effect.gen(function* () {
|
|
633
|
+
const file = yield* get(position === undefined ? "write" : "pwrite", "write");
|
|
634
|
+
const offset = position ?? (ref.append ? file.metadata.size : ref.offset);
|
|
635
|
+
if (!Predicate.isBigInt(offset) || offset < 0n || offset > MAX_FILE_OFFSET) {
|
|
636
|
+
return yield* new FsError({ code: "InvalidArgument", operation: "write" });
|
|
637
|
+
}
|
|
638
|
+
if (bytes.length === 0) {
|
|
639
|
+
return 0;
|
|
640
|
+
}
|
|
641
|
+
if (offset >= BigInt(maxFileBytes))
|
|
642
|
+
return yield* new FsError({ code: "FileTooLarge", operation: "write" });
|
|
643
|
+
const start = Number(offset);
|
|
644
|
+
const free = settings.maxBytes === undefined
|
|
645
|
+
? BigInt(maxFileBytes)
|
|
646
|
+
: ByteSize.toBigInt(settings.maxBytes) - usedBytes;
|
|
647
|
+
const maximumEnd = BigInt(file.data.bytes.length) + free;
|
|
648
|
+
const end = Number(BigInt(maxFileBytes) < maximumEnd ? BigInt(maxFileBytes) : maximumEnd);
|
|
649
|
+
const count = Math.min(bytes.length, Math.max(0, end - start));
|
|
650
|
+
if (count === 0)
|
|
651
|
+
return yield* new FsError({ code: "NoSpace", operation: "write" });
|
|
652
|
+
const size = Math.max(file.data.bytes.length, start + count);
|
|
653
|
+
// Always detach before mutation. A same-sized write is the critical
|
|
654
|
+
// case: the current payload may belong to the base or a prior capture.
|
|
655
|
+
const data = new Uint8Array(size);
|
|
656
|
+
data.set(file.data.bytes);
|
|
657
|
+
const now = yield* timestamp("write");
|
|
658
|
+
data.set(bytes.subarray(0, count), start);
|
|
659
|
+
replaceContent(file, data, now);
|
|
660
|
+
if (position === undefined)
|
|
661
|
+
ref.offset = offset + BigInt(count);
|
|
662
|
+
return count;
|
|
663
|
+
}));
|
|
664
|
+
});
|
|
665
|
+
const handle = Object.freeze({
|
|
666
|
+
[FileHandleId]: true,
|
|
667
|
+
read: Effect.fn("FileHandle.read")(function* (maximum) {
|
|
668
|
+
return yield* read(maximum);
|
|
669
|
+
}),
|
|
670
|
+
pread: Effect.fn("FileHandle.pread")(function* (maximum, offset) {
|
|
671
|
+
return yield* read(maximum, offset);
|
|
672
|
+
}),
|
|
673
|
+
write: Effect.fn("FileHandle.write")(function* (bytes) {
|
|
674
|
+
return yield* write(bytes);
|
|
675
|
+
}),
|
|
676
|
+
pwrite: Effect.fn("FileHandle.pwrite")(function* (bytes, offset) {
|
|
677
|
+
return yield* write(bytes, offset);
|
|
678
|
+
}),
|
|
679
|
+
seek: Effect.fn("FileHandle.seek")(function* (offset, mode) {
|
|
680
|
+
return yield* coordinated(Effect.gen(function* () {
|
|
681
|
+
const file = yield* get("seek");
|
|
682
|
+
if (!Predicate.isBigInt(offset) || !isSeekMode(mode)) {
|
|
683
|
+
return yield* new FsError({ code: "InvalidArgument", operation: "seek" });
|
|
684
|
+
}
|
|
685
|
+
let next = mode === "current" ? ref.offset + offset : mode === "end" ? file.metadata.size + offset : offset;
|
|
686
|
+
if (next < 0n || next > MAX_FILE_OFFSET) {
|
|
687
|
+
return yield* new FsError({ code: "InvalidArgument", operation: "seek" });
|
|
688
|
+
}
|
|
689
|
+
if (mode === "data" || mode === "hole") {
|
|
690
|
+
if (offset >= file.metadata.size)
|
|
691
|
+
return yield* new FsError({ code: "NoData", operation: "seek" });
|
|
692
|
+
if (mode === "hole")
|
|
693
|
+
next = file.metadata.size;
|
|
694
|
+
}
|
|
695
|
+
ref.offset = next;
|
|
696
|
+
return next;
|
|
697
|
+
}));
|
|
698
|
+
}),
|
|
699
|
+
truncate: Effect.fn("FileHandle.truncate")(function* (length) {
|
|
700
|
+
return yield* coordinated(Effect.gen(function* () {
|
|
701
|
+
yield* resize(yield* get("truncate", "write"), length, "truncate");
|
|
702
|
+
}));
|
|
703
|
+
}),
|
|
704
|
+
stat: coordinated(Effect.gen(function* () {
|
|
705
|
+
return { ...(yield* get("stat")).metadata };
|
|
706
|
+
})).pipe(Effect.withSpan("FileHandle.stat")),
|
|
707
|
+
sync: coordinated(Effect.suspend(() => Effect.asVoid(get("sync")))).pipe(Effect.withSpan("FileHandle.sync")),
|
|
708
|
+
close: coordinated(Effect.gen(function* () {
|
|
709
|
+
yield* get("close");
|
|
710
|
+
releaseFile(ref);
|
|
711
|
+
})).pipe(Effect.withSpan("FileHandle.close"))
|
|
712
|
+
});
|
|
713
|
+
files.set(handle, ref);
|
|
714
|
+
return handle;
|
|
715
|
+
};
|
|
716
|
+
const createCaller = (reference, identity, umask) => {
|
|
717
|
+
const referencedNode = Effect.fnUntraced(function* (target, operation) {
|
|
718
|
+
if (reference.directory === undefined)
|
|
719
|
+
return yield* new FsError({ code: "ClosedCaller", operation: operation });
|
|
720
|
+
if (!Predicate.isObject(target))
|
|
721
|
+
return yield* new FsError({ code: "InvalidReference", operation: operation });
|
|
722
|
+
const state = objectReferences.get(target);
|
|
723
|
+
if (state === undefined)
|
|
724
|
+
return yield* new FsError({ code: "InvalidReference", operation: operation });
|
|
725
|
+
if (state.volume !== volumeIdentity) {
|
|
726
|
+
return yield* new FsError({ code: "ForeignReference", operation: operation });
|
|
727
|
+
}
|
|
728
|
+
if (state.node === undefined)
|
|
729
|
+
return yield* new FsError({ code: "StaleReference", operation: operation });
|
|
730
|
+
return state.node;
|
|
731
|
+
});
|
|
732
|
+
const lookup = Effect.fnUntraced(function* (path, base, operation, options = {}) {
|
|
733
|
+
const { followFinalSymlink = true, allowMissing = false, parentOnly = false } = options;
|
|
734
|
+
if (reference.directory === undefined) {
|
|
735
|
+
return yield* new FsError({ code: "ClosedCaller", operation: operation, path: path.input });
|
|
736
|
+
}
|
|
737
|
+
let current = path.absolute ? root : reference.directory;
|
|
738
|
+
if (!path.absolute && base !== undefined) {
|
|
739
|
+
const target = handles.get(base);
|
|
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
|
+
}
|
|
749
|
+
current = target.directory;
|
|
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 });
|
|
754
|
+
}
|
|
755
|
+
let work = path;
|
|
756
|
+
let parent;
|
|
757
|
+
let name;
|
|
758
|
+
let traversals = 0;
|
|
759
|
+
for (let index = 0; index < work.components.length - (parentOnly ? 1 : 0); index++) {
|
|
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);
|
|
764
|
+
const component = work.components[index];
|
|
765
|
+
if (component === undefined)
|
|
766
|
+
break;
|
|
767
|
+
if (component === DOT_HEX)
|
|
768
|
+
continue;
|
|
769
|
+
if (component === DOT_DOT_HEX) {
|
|
770
|
+
current = current.parent ?? current;
|
|
771
|
+
parent = undefined;
|
|
772
|
+
name = undefined;
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
775
|
+
parent = current;
|
|
776
|
+
name = component;
|
|
777
|
+
const child = current.entries.get(component);
|
|
778
|
+
if (child === undefined) {
|
|
779
|
+
if (allowMissing && index === work.components.length - 1 && !work.trailingSlash) {
|
|
780
|
+
return { node: undefined, parent, name };
|
|
781
|
+
}
|
|
782
|
+
return yield* new FsError({ code: "NotFound", operation: operation, path: path.input });
|
|
783
|
+
}
|
|
784
|
+
if (child.kind === "symlink" && (followFinalSymlink || index < work.components.length - 1 || work.trailingSlash)) {
|
|
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
|
+
}
|
|
791
|
+
const suffix = work.suffixes[index] ?? new Uint8Array(0);
|
|
792
|
+
if (settings.maxPathBytes !== undefined &&
|
|
793
|
+
ByteSize.isGreaterThan(ByteSize.bytes(child.target.length + suffix.length), settings.maxPathBytes)) {
|
|
794
|
+
return yield* new FsError({ code: "PathTooLong", operation: operation, path: path.input });
|
|
795
|
+
}
|
|
796
|
+
const expansion = new Uint8Array(child.target.length + suffix.length);
|
|
797
|
+
expansion.set(child.target);
|
|
798
|
+
expansion.set(suffix, child.target.length);
|
|
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;
|
|
806
|
+
if (work.absolute)
|
|
807
|
+
current = root;
|
|
808
|
+
index = -1;
|
|
809
|
+
}
|
|
810
|
+
else
|
|
811
|
+
current = child;
|
|
812
|
+
}
|
|
813
|
+
if (!parentOnly && work.trailingSlash && current.kind !== "directory") {
|
|
814
|
+
return yield* new FsError({ code: "NotDirectory", operation: operation, path: path.input });
|
|
815
|
+
}
|
|
816
|
+
return { node: current, parent, name };
|
|
817
|
+
});
|
|
818
|
+
const resolveNode = Effect.fnUntraced(function* (path, base, operation, options) {
|
|
819
|
+
const result = yield* lookup(path, base, operation, options);
|
|
820
|
+
if (result.node === undefined) {
|
|
821
|
+
return yield* new FsError({ code: "NotFound", operation: operation, path: path.input });
|
|
822
|
+
}
|
|
823
|
+
return result.node;
|
|
824
|
+
});
|
|
825
|
+
const locate = Effect.fnUntraced(function* (path, base, operation, options) {
|
|
826
|
+
const result = yield* lookup(path, base, operation, options);
|
|
827
|
+
const node = result.node;
|
|
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
|
+
}
|
|
834
|
+
return node;
|
|
835
|
+
});
|
|
836
|
+
const acquireDirectory = Effect.fnUntraced(function* (input, options, operation) {
|
|
837
|
+
const prepared = preparePath(input, operation, settings.maxPathBytes);
|
|
838
|
+
const base = options?.relativeTo;
|
|
839
|
+
const acquired = { volume: volumeIdentity, directory: undefined, closed: false };
|
|
840
|
+
// Register before retaining a directory. Closed scopes can run this immediately,
|
|
841
|
+
// so registration must not happen while holding the volume permit.
|
|
842
|
+
yield* Effect.addFinalizer(() => release(acquired));
|
|
843
|
+
return yield* coordinated(Effect.gen(function* () {
|
|
844
|
+
if (acquired.closed)
|
|
845
|
+
return yield* Effect.interrupt;
|
|
846
|
+
const path = yield* Effect.fromResult(prepared);
|
|
847
|
+
const directory = yield* locate(path, base, operation);
|
|
848
|
+
yield* authorize(directory, identity, EXECUTE, operation, input);
|
|
849
|
+
acquired.directory = directory;
|
|
850
|
+
return acquired;
|
|
851
|
+
}));
|
|
852
|
+
});
|
|
853
|
+
const list = Effect.fnUntraced(function* (input, options) {
|
|
854
|
+
const prepared = preparePath(input, "readDirectory", settings.maxPathBytes);
|
|
855
|
+
const base = options?.relativeTo;
|
|
856
|
+
return yield* coordinated(Effect.gen(function* () {
|
|
857
|
+
const directory = yield* locate(yield* Effect.fromResult(prepared), base, "readDirectory");
|
|
858
|
+
yield* authorize(directory, identity, READ, "readDirectory", input);
|
|
859
|
+
const result = [...directory.entries.keys()].map(nameBytes);
|
|
860
|
+
directory.metadata = { ...directory.metadata, atimeNs: (yield* timestamp("readDirectory")) };
|
|
861
|
+
return result;
|
|
862
|
+
}));
|
|
863
|
+
});
|
|
864
|
+
const readTarget = Effect.fnUntraced(function* (input, options) {
|
|
865
|
+
const prepared = preparePath(input, "readLink", settings.maxPathBytes);
|
|
866
|
+
const base = options?.relativeTo;
|
|
867
|
+
return yield* coordinated(Effect.gen(function* () {
|
|
868
|
+
const node = yield* resolveNode(yield* Effect.fromResult(prepared), base, "readLink", {
|
|
869
|
+
followFinalSymlink: false
|
|
870
|
+
});
|
|
871
|
+
if (node.kind !== "symlink") {
|
|
872
|
+
return yield* new FsError({ code: "InvalidArgument", operation: "readLink", path: input });
|
|
873
|
+
}
|
|
874
|
+
return new Uint8Array(node.target);
|
|
875
|
+
}));
|
|
876
|
+
});
|
|
877
|
+
const canonical = Effect.fnUntraced(function* (input, options) {
|
|
878
|
+
const prepared = preparePath(input, "realPath", settings.maxPathBytes);
|
|
879
|
+
const base = options?.relativeTo;
|
|
880
|
+
return yield* coordinated(Effect.gen(function* () {
|
|
881
|
+
const result = yield* lookup(yield* Effect.fromResult(prepared), base, "realPath");
|
|
882
|
+
const components = [];
|
|
883
|
+
if (result.node?.kind !== "directory" && result.name !== undefined)
|
|
884
|
+
components.push(result.name);
|
|
885
|
+
let directory = result.node?.kind === "directory" ? result.node : result.parent;
|
|
886
|
+
while (directory !== undefined && directory.parent !== undefined) {
|
|
887
|
+
const parent = directory.parent;
|
|
888
|
+
const entry = [...parent.entries].find(([, child]) => child === directory);
|
|
889
|
+
if (entry === undefined)
|
|
890
|
+
return yield* new FsError({ code: "NotFound", operation: "realPath", path: input });
|
|
891
|
+
components.push(entry[0]);
|
|
892
|
+
directory = parent;
|
|
893
|
+
}
|
|
894
|
+
return nameBytes(SLASH_HEX + components.reverse().join(SLASH_HEX));
|
|
895
|
+
}));
|
|
896
|
+
});
|
|
897
|
+
const metadataNode = Effect.fnUntraced(function* (target, options, operation) {
|
|
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);
|
|
903
|
+
if (ref === undefined)
|
|
904
|
+
return yield* new FsError({ code: "InvalidHandle", operation: operation });
|
|
905
|
+
if (ref.volume !== volumeIdentity) {
|
|
906
|
+
return yield* new FsError({ code: "ForeignHandle", operation: operation });
|
|
907
|
+
}
|
|
908
|
+
const node = "file" in ref ? ref.file : ref.directory;
|
|
909
|
+
if (node === undefined)
|
|
910
|
+
return yield* new FsError({ code: "InvalidHandle", operation: operation });
|
|
911
|
+
return node;
|
|
912
|
+
}
|
|
913
|
+
const path = yield* Effect.fromResult(preparePath(target, operation, settings.maxPathBytes));
|
|
914
|
+
return yield* resolveNode(path, options?.relativeTo, operation, {
|
|
915
|
+
followFinalSymlink: options?.followFinalSymlink !== false
|
|
916
|
+
});
|
|
917
|
+
});
|
|
918
|
+
const permittedMode = (metadata, mode, operation, path) => {
|
|
919
|
+
if (!identity.privileged && identity.uid !== metadata.uid) {
|
|
920
|
+
return Effect.fail(path === undefined
|
|
921
|
+
? new FsError({ code: "AccessDenied", operation })
|
|
922
|
+
: new FsError({ code: "AccessDenied", operation, path }));
|
|
923
|
+
}
|
|
924
|
+
const group = identity.gid === metadata.gid || identity.groups.includes(metadata.gid);
|
|
925
|
+
return Effect.succeed(!identity.privileged && metadata.kind === "file" && !group ? mode & ~0o2000 : mode);
|
|
926
|
+
};
|
|
927
|
+
const changeMode = Effect.fnUntraced(function* (target, mode, options) {
|
|
928
|
+
if (!isMode(mode))
|
|
929
|
+
return yield* new FsError({ code: "InvalidArgument", operation: "chmod" });
|
|
930
|
+
const chosen = options === undefined ? undefined : { ...options };
|
|
931
|
+
return yield* coordinated(Effect.gen(function* () {
|
|
932
|
+
const node = yield* metadataNode(target, chosen, "chmod");
|
|
933
|
+
const permitted = yield* permittedMode(node.metadata, mode, "chmod");
|
|
934
|
+
node.metadata = {
|
|
935
|
+
...node.metadata,
|
|
936
|
+
mode: permitted,
|
|
937
|
+
ctimeNs: (yield* timestamp("chmod"))
|
|
938
|
+
};
|
|
939
|
+
advanceRevision(node);
|
|
940
|
+
publishNode(node);
|
|
941
|
+
}));
|
|
942
|
+
});
|
|
943
|
+
const changeOwner = Effect.fnUntraced(function* (target, owner, options) {
|
|
944
|
+
const decoded = decodeOwnerUpdate(owner);
|
|
945
|
+
if (Result.isFailure(decoded))
|
|
946
|
+
return yield* new FsError({ code: "InvalidArgument", operation: "chown" });
|
|
947
|
+
const update = { ...decoded.success };
|
|
948
|
+
const chosen = options === undefined ? undefined : { ...options };
|
|
949
|
+
return yield* coordinated(Effect.gen(function* () {
|
|
950
|
+
const node = yield* metadataNode(target, chosen, "chown");
|
|
951
|
+
if (!identity.privileged && (identity.uid !== node.metadata.uid ||
|
|
952
|
+
(update.uid !== undefined && update.uid !== node.metadata.uid) ||
|
|
953
|
+
(update.gid !== undefined && update.gid !== identity.gid && !identity.groups.includes(update.gid)))) {
|
|
954
|
+
return yield* new FsError({ code: "AccessDenied", operation: "chown" });
|
|
955
|
+
}
|
|
956
|
+
if (update.uid === undefined && update.gid === undefined)
|
|
957
|
+
return;
|
|
958
|
+
node.metadata = {
|
|
959
|
+
...node.metadata,
|
|
960
|
+
uid: update.uid ?? node.metadata.uid,
|
|
961
|
+
gid: update.gid ?? node.metadata.gid,
|
|
962
|
+
mode: node.kind === "file" ? node.metadata.mode & ~SET_ID_BITS : node.metadata.mode,
|
|
963
|
+
ctimeNs: (yield* timestamp("chown"))
|
|
964
|
+
};
|
|
965
|
+
advanceRevision(node);
|
|
966
|
+
publishNode(node);
|
|
967
|
+
}));
|
|
968
|
+
});
|
|
969
|
+
const changeTimes = Effect.fnUntraced(function* (target, times, options) {
|
|
970
|
+
const decoded = decodeTimes(times);
|
|
971
|
+
if (Result.isFailure(decoded))
|
|
972
|
+
return yield* new FsError({ code: "InvalidArgument", operation: "utimes" });
|
|
973
|
+
const access = { ...decoded.success.access };
|
|
974
|
+
const modification = { ...decoded.success.modification };
|
|
975
|
+
const chosen = options === undefined ? undefined : { ...options };
|
|
976
|
+
return yield* coordinated(Effect.gen(function* () {
|
|
977
|
+
const node = yield* metadataNode(target, chosen, "utimes");
|
|
978
|
+
if (access.kind === "omit" && modification.kind === "omit")
|
|
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.
|
|
982
|
+
if (!identity.privileged && identity.uid !== node.metadata.uid) {
|
|
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));
|
|
990
|
+
}
|
|
991
|
+
const now = yield* timestamp("utimes");
|
|
992
|
+
node.metadata = {
|
|
993
|
+
...node.metadata,
|
|
994
|
+
atimeNs: access.kind === "omit"
|
|
995
|
+
? node.metadata.atimeNs
|
|
996
|
+
: access.kind === "now"
|
|
997
|
+
? now
|
|
998
|
+
: access.nanoseconds,
|
|
999
|
+
mtimeNs: modification.kind === "omit"
|
|
1000
|
+
? node.metadata.mtimeNs
|
|
1001
|
+
: modification.kind === "now"
|
|
1002
|
+
? now
|
|
1003
|
+
: modification.nanoseconds,
|
|
1004
|
+
ctimeNs: now
|
|
1005
|
+
};
|
|
1006
|
+
advanceRevision(node);
|
|
1007
|
+
publishNode(node);
|
|
1008
|
+
}));
|
|
1009
|
+
});
|
|
1010
|
+
const authorizeRemoval = (parent, child, operation, input) => (parent.metadata.mode & STICKY_BIT) !== 0 && !identity.privileged &&
|
|
1011
|
+
identity.uid !== parent.metadata.uid && identity.uid !== child.metadata.uid
|
|
1012
|
+
? Effect.fail(new FsError({ code: "AccessDenied", operation: operation, path: input }))
|
|
1013
|
+
: Effect.void;
|
|
1014
|
+
return Object.freeze({
|
|
1015
|
+
[CallerId]: true,
|
|
1016
|
+
rootReference: coordinated(Effect.gen(function* () {
|
|
1017
|
+
if (reference.directory === undefined) {
|
|
1018
|
+
return yield* new FsError({ code: "ClosedCaller", operation: "rootReference" });
|
|
1019
|
+
}
|
|
1020
|
+
return referenceFor(root);
|
|
1021
|
+
})).pipe(Effect.withSpan("Caller.rootReference")),
|
|
1022
|
+
lookupReference: Effect.fn("Caller.lookupReference")(function* (directoryReference, name) {
|
|
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" });
|
|
1026
|
+
const key = Encoding.encodeHex(new Uint8Array(name));
|
|
1027
|
+
if (isDotComponent(key))
|
|
1028
|
+
return yield* new FsError({ code: "InvalidArgument", operation: "lookupReference" });
|
|
1029
|
+
return yield* coordinated(Effect.gen(function* () {
|
|
1030
|
+
const directory = yield* referencedNode(directoryReference, "lookupReference");
|
|
1031
|
+
if (directory.kind !== "directory") {
|
|
1032
|
+
return yield* new FsError({ code: "NotDirectory", operation: "lookupReference" });
|
|
1033
|
+
}
|
|
1034
|
+
yield* authorize(directory, identity, EXECUTE, "lookupReference", "/");
|
|
1035
|
+
const child = directory.entries.get(key);
|
|
1036
|
+
if (child === undefined)
|
|
1037
|
+
return yield* new FsError({ code: "NotFound", operation: "lookupReference" });
|
|
1038
|
+
return referenceFor(child);
|
|
1039
|
+
}));
|
|
1040
|
+
}),
|
|
1041
|
+
parentReference: Effect.fn("Caller.parentReference")(function* (directoryReference) {
|
|
1042
|
+
return yield* coordinated(Effect.gen(function* () {
|
|
1043
|
+
const directory = yield* referencedNode(directoryReference, "parentReference");
|
|
1044
|
+
if (directory.kind !== "directory") {
|
|
1045
|
+
return yield* new FsError({ code: "NotDirectory", operation: "parentReference" });
|
|
1046
|
+
}
|
|
1047
|
+
yield* authorize(directory, identity, EXECUTE, "parentReference", "/");
|
|
1048
|
+
return referenceFor(directory.parent ?? directory);
|
|
1049
|
+
}));
|
|
1050
|
+
}),
|
|
1051
|
+
observeMetadata: Effect.fn("Caller.observeMetadata")(function* (objectReference) {
|
|
1052
|
+
return yield* coordinated(Effect.gen(function* () {
|
|
1053
|
+
const node = yield* referencedNode(objectReference, "observeMetadata");
|
|
1054
|
+
return Object.freeze({ value: Object.freeze({ ...node.metadata }), revision: node.revision });
|
|
1055
|
+
}));
|
|
1056
|
+
}),
|
|
1057
|
+
observeDirectory: Effect.fn("Caller.observeDirectory")(function* (directoryReference) {
|
|
1058
|
+
return yield* coordinated(Effect.gen(function* () {
|
|
1059
|
+
const directory = yield* referencedNode(directoryReference, "observeDirectory");
|
|
1060
|
+
if (directory.kind !== "directory") {
|
|
1061
|
+
return yield* new FsError({ code: "NotDirectory", operation: "observeDirectory" });
|
|
1062
|
+
}
|
|
1063
|
+
yield* authorize(directory, identity, READ, "observeDirectory", "/");
|
|
1064
|
+
const value = Object.freeze([...directory.entries].map(([name, node]) => Object.freeze({ name: nameBytes(name), reference: referenceFor(node) })));
|
|
1065
|
+
return Object.freeze({ value, revision: directory.revision });
|
|
1066
|
+
}));
|
|
1067
|
+
}),
|
|
1068
|
+
readLinkReference: Effect.fn("Caller.readLinkReference")(function* (objectReference) {
|
|
1069
|
+
return yield* coordinated(Effect.gen(function* () {
|
|
1070
|
+
const node = yield* referencedNode(objectReference, "readLinkReference");
|
|
1071
|
+
if (node.kind !== "symlink") {
|
|
1072
|
+
return yield* new FsError({ code: "InvalidArgument", operation: "readLinkReference" });
|
|
1073
|
+
}
|
|
1074
|
+
return new Uint8Array(node.target);
|
|
1075
|
+
}));
|
|
1076
|
+
}),
|
|
1077
|
+
openReference: Effect.fn("Caller.openReference")(function* (objectReference) {
|
|
1078
|
+
const acquired = {
|
|
1079
|
+
volume: volumeIdentity,
|
|
1080
|
+
file: undefined,
|
|
1081
|
+
closed: false,
|
|
1082
|
+
offset: 0n,
|
|
1083
|
+
access: "read",
|
|
1084
|
+
append: false
|
|
1085
|
+
};
|
|
1086
|
+
yield* Effect.addFinalizer(() => coordinated(Effect.sync(() => releaseFile(acquired))));
|
|
1087
|
+
return yield* coordinated(Effect.gen(function* () {
|
|
1088
|
+
if (acquired.closed)
|
|
1089
|
+
return yield* Effect.interrupt;
|
|
1090
|
+
const node = yield* referencedNode(objectReference, "openReference");
|
|
1091
|
+
if (node.kind !== "file")
|
|
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", "/");
|
|
1097
|
+
node.openCount += 1;
|
|
1098
|
+
acquired.file = node;
|
|
1099
|
+
return fileHandle(acquired);
|
|
1100
|
+
}));
|
|
1101
|
+
}),
|
|
1102
|
+
readFile: Effect.fn("Caller.readFile")(function* (input, options) {
|
|
1103
|
+
const prepared = preparePath(input, "readFile", settings.maxPathBytes);
|
|
1104
|
+
const base = options?.relativeTo;
|
|
1105
|
+
return yield* coordinated(Effect.gen(function* () {
|
|
1106
|
+
const node = yield* resolveNode(yield* Effect.fromResult(prepared), base, "readFile");
|
|
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);
|
|
1111
|
+
const data = new Uint8Array(node.data.bytes);
|
|
1112
|
+
node.metadata = { ...node.metadata, atimeNs: (yield* timestamp("readFile")) };
|
|
1113
|
+
return data;
|
|
1114
|
+
}));
|
|
1115
|
+
}),
|
|
1116
|
+
writeFile: Effect.fn("Caller.writeFile")(function* (input, bytes, options) {
|
|
1117
|
+
const prepared = preparePath(input, "writeFile", settings.maxPathBytes);
|
|
1118
|
+
if (!isAttachedBytes(bytes)) {
|
|
1119
|
+
return yield* new FsError({ code: "InvalidArgument", operation: "writeFile", path: input });
|
|
1120
|
+
}
|
|
1121
|
+
const captured = new Uint8Array(bytes);
|
|
1122
|
+
const { relativeTo: base, ...raw } = options;
|
|
1123
|
+
const decoded = decodeWriteFileSettings(raw);
|
|
1124
|
+
if (Result.isFailure(decoded)) {
|
|
1125
|
+
return yield* new FsError({ code: "InvalidArgument", operation: "writeFile", path: input });
|
|
1126
|
+
}
|
|
1127
|
+
const chosen = decoded.success;
|
|
1128
|
+
return yield* coordinated(Effect.gen(function* () {
|
|
1129
|
+
const path = yield* Effect.fromResult(prepared);
|
|
1130
|
+
if (chosen.create === "exclusive") {
|
|
1131
|
+
const exists = yield* Effect.result(lookup(path, base, "writeFile", { followFinalSymlink: false }));
|
|
1132
|
+
if (Result.isSuccess(exists)) {
|
|
1133
|
+
return yield* new FsError({ code: "AlreadyExists", operation: "writeFile", path: input });
|
|
1134
|
+
}
|
|
1135
|
+
if (exists.failure.code !== "NotFound")
|
|
1136
|
+
return yield* exists.failure;
|
|
1137
|
+
}
|
|
1138
|
+
const resolved = yield* lookup(path, base, "writeFile", {
|
|
1139
|
+
followFinalSymlink: chosen.replaceFinalSymlink !== true && chosen.followFinalSymlink !== false,
|
|
1140
|
+
allowMissing: chosen.create === "ifMissing" || chosen.create === "exclusive"
|
|
1141
|
+
});
|
|
1142
|
+
const { name, parent } = resolved;
|
|
1143
|
+
if (parent === undefined || name === undefined || resolved.node?.kind === "directory") {
|
|
1144
|
+
return yield* new FsError({ code: "IsDirectory", operation: "writeFile", path: input });
|
|
1145
|
+
}
|
|
1146
|
+
const replaced = resolved.node?.kind === "symlink" ? resolved.node : undefined;
|
|
1147
|
+
if (replaced !== undefined && !chosen.replaceFinalSymlink) {
|
|
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 });
|
|
1152
|
+
}
|
|
1153
|
+
const file = resolved.node?.kind === "file" ? resolved.node : undefined;
|
|
1154
|
+
if (file === undefined) {
|
|
1155
|
+
yield* authorize(parent, identity, WRITE | EXECUTE, "writeFile", input);
|
|
1156
|
+
if (replaced !== undefined)
|
|
1157
|
+
yield* authorizeRemoval(parent, replaced, "writeFile", input);
|
|
1158
|
+
if (replaced === undefined && atEntryLimit()) {
|
|
1159
|
+
return yield* new FsError({ code: "NoSpace", operation: "writeFile", path: input });
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
else {
|
|
1163
|
+
yield* authorize(file, identity, chosen.access === "readWrite" ? READ | WRITE : WRITE, "writeFile", input);
|
|
1164
|
+
}
|
|
1165
|
+
const finalMode = chosen.finalMode === undefined ? undefined : yield* permittedMode(file?.metadata ?? { kind: "file", uid: identity.uid, gid: parent.metadata.gid }, chosen.finalMode, "writeFile", input);
|
|
1166
|
+
const previous = file?.data.bytes.length ?? 0;
|
|
1167
|
+
const initial = chosen.truncate ? 0 : previous;
|
|
1168
|
+
const position = chosen.append ? initial : 0;
|
|
1169
|
+
const size = Math.max(initial, position + captured.length);
|
|
1170
|
+
if (size > maxFileBytes) {
|
|
1171
|
+
return yield* new FsError({ code: "FileTooLarge", operation: "writeFile", path: input });
|
|
1172
|
+
}
|
|
1173
|
+
const reclaimed = replaced !== undefined && replaced.metadata.nlink === 1 ? replaced.target.length : 0;
|
|
1174
|
+
if (settings.maxBytes !== undefined &&
|
|
1175
|
+
BigInt(size - previous) > ByteSize.toBigInt(settings.maxBytes) - usedBytes + BigInt(reclaimed)) {
|
|
1176
|
+
return yield* new FsError({ code: "NoSpace", operation: "writeFile", path: input });
|
|
1177
|
+
}
|
|
1178
|
+
if (file !== undefined && !chosen.truncate && captured.length === 0 && chosen.finalMode === undefined) {
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
let data = captured;
|
|
1182
|
+
if (position !== 0 || size !== captured.length) {
|
|
1183
|
+
data = new Uint8Array(size);
|
|
1184
|
+
if (file !== undefined && !chosen.truncate)
|
|
1185
|
+
data.set(file.data.bytes);
|
|
1186
|
+
data.set(captured, position);
|
|
1187
|
+
}
|
|
1188
|
+
const now = yield* timestamp("writeFile");
|
|
1189
|
+
const node = file ??
|
|
1190
|
+
{
|
|
1191
|
+
kind: "file",
|
|
1192
|
+
lineage: undefined,
|
|
1193
|
+
// Assigned below on the shared path that also covers an existing file.
|
|
1194
|
+
data: Content.empty(),
|
|
1195
|
+
openCount: 0,
|
|
1196
|
+
metadata: {
|
|
1197
|
+
...directoryMetadata(nextInode++, identity.uid, parent.metadata.gid, (chosen.mode ?? 0o666) & 0o777 & ~umask, now),
|
|
1198
|
+
kind: "file",
|
|
1199
|
+
nlink: 1
|
|
1200
|
+
},
|
|
1201
|
+
revision: nextRevision(),
|
|
1202
|
+
objectReference: undefined
|
|
1203
|
+
};
|
|
1204
|
+
node.data = Content.make(data);
|
|
1205
|
+
node.metadata = {
|
|
1206
|
+
...node.metadata,
|
|
1207
|
+
mode: finalMode ?? node.metadata.mode & ~SET_ID_BITS,
|
|
1208
|
+
size: BigInt(size),
|
|
1209
|
+
mtimeNs: now,
|
|
1210
|
+
ctimeNs: now
|
|
1211
|
+
};
|
|
1212
|
+
advanceRevision(node);
|
|
1213
|
+
usedBytes += BigInt(size - previous);
|
|
1214
|
+
if (file === undefined) {
|
|
1215
|
+
if (replaced !== undefined)
|
|
1216
|
+
detach(replaced, now);
|
|
1217
|
+
attach(parent, name, node, now);
|
|
1218
|
+
if (replaced === undefined)
|
|
1219
|
+
entries += 1;
|
|
1220
|
+
publishEntry(replaced === undefined ? "Create" : "Update", parent, name);
|
|
1221
|
+
}
|
|
1222
|
+
else
|
|
1223
|
+
publishNode(node);
|
|
1224
|
+
}));
|
|
1225
|
+
}),
|
|
1226
|
+
chmod: Effect.fn("Caller.chmod")(function* (path, mode, options) {
|
|
1227
|
+
yield* changeMode(path, mode, options);
|
|
1228
|
+
}),
|
|
1229
|
+
chmodHandle: Effect.fn("Caller.chmodHandle")(function* (handle, mode) {
|
|
1230
|
+
yield* changeMode(handle, mode);
|
|
1231
|
+
}),
|
|
1232
|
+
chown: Effect.fn("Caller.chown")(function* (path, owner, options) {
|
|
1233
|
+
yield* changeOwner(path, owner, options);
|
|
1234
|
+
}),
|
|
1235
|
+
chownHandle: Effect.fn("Caller.chownHandle")(function* (handle, owner) {
|
|
1236
|
+
yield* changeOwner(handle, owner);
|
|
1237
|
+
}),
|
|
1238
|
+
utimes: Effect.fn("Caller.utimes")(function* (path, times, options) {
|
|
1239
|
+
yield* changeTimes(path, times, options);
|
|
1240
|
+
}),
|
|
1241
|
+
utimesHandle: Effect.fn("Caller.utimesHandle")(function* (handle, times) {
|
|
1242
|
+
yield* changeTimes(handle, times);
|
|
1243
|
+
}),
|
|
1244
|
+
access: Effect.fn("Caller.access")(function* (input, bits = 0, options) {
|
|
1245
|
+
const prepared = preparePath(input, "access", settings.maxPathBytes);
|
|
1246
|
+
const base = options?.relativeTo;
|
|
1247
|
+
if (!Number.isInteger(bits) || bits < 0 || bits > (READ | WRITE | EXECUTE)) {
|
|
1248
|
+
return yield* new FsError({ code: "InvalidArgument", operation: "access", path: input });
|
|
1249
|
+
}
|
|
1250
|
+
return yield* coordinated(Effect.gen(function* () {
|
|
1251
|
+
const node = yield* resolveNode(yield* Effect.fromResult(prepared), base, "access");
|
|
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 });
|
|
1254
|
+
}
|
|
1255
|
+
yield* authorize(node, identity, bits, "access", input);
|
|
1256
|
+
}));
|
|
1257
|
+
}),
|
|
1258
|
+
truncate: Effect.fn("Caller.truncate")(function* (input, length, options) {
|
|
1259
|
+
const prepared = preparePath(input, "truncate", settings.maxPathBytes);
|
|
1260
|
+
const base = options?.relativeTo;
|
|
1261
|
+
return yield* coordinated(Effect.gen(function* () {
|
|
1262
|
+
const node = yield* resolveNode(yield* Effect.fromResult(prepared), base, "truncate");
|
|
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);
|
|
1267
|
+
yield* resize(node, length, "truncate");
|
|
1268
|
+
}));
|
|
1269
|
+
}),
|
|
1270
|
+
lstat: Effect.fn("Caller.lstat")(function* (input, options) {
|
|
1271
|
+
const prepared = preparePath(input, "lstat", settings.maxPathBytes);
|
|
1272
|
+
const base = options?.relativeTo;
|
|
1273
|
+
return yield* coordinated(Effect.gen(function* () {
|
|
1274
|
+
const node = yield* resolveNode(yield* Effect.fromResult(prepared), base, "lstat", {
|
|
1275
|
+
followFinalSymlink: false
|
|
1276
|
+
});
|
|
1277
|
+
return { ...node.metadata };
|
|
1278
|
+
}));
|
|
1279
|
+
}),
|
|
1280
|
+
link: Effect.fn("Caller.link")(function* (source, destination, options) {
|
|
1281
|
+
const a = preparePath(source, "link", settings.maxPathBytes);
|
|
1282
|
+
const b = preparePath(destination, "link", settings.maxPathBytes);
|
|
1283
|
+
const sourceBase = options?.sourceRelativeTo;
|
|
1284
|
+
const destinationBase = options?.destinationRelativeTo;
|
|
1285
|
+
const follow = options?.followSourceSymlink ?? false;
|
|
1286
|
+
return yield* coordinated(Effect.gen(function* () {
|
|
1287
|
+
const node = yield* resolveNode(yield* Effect.fromResult(a), sourceBase, "link", {
|
|
1288
|
+
followFinalSymlink: follow
|
|
1289
|
+
});
|
|
1290
|
+
if (node.kind === "directory") {
|
|
1291
|
+
return yield* new FsError({ code: "IsDirectory", operation: "link", path: source });
|
|
1292
|
+
}
|
|
1293
|
+
const path = yield* Effect.fromResult(b);
|
|
1294
|
+
const parent = yield* locate(path, destinationBase, "link", { parentOnly: true });
|
|
1295
|
+
yield* authorize(parent, identity, WRITE | EXECUTE, "link", destination);
|
|
1296
|
+
const name = path.components.at(-1);
|
|
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 });
|
|
1302
|
+
}
|
|
1303
|
+
if (atEntryLimit()) {
|
|
1304
|
+
return yield* new FsError({ code: "NoSpace", operation: "link", path: destination });
|
|
1305
|
+
}
|
|
1306
|
+
const now = yield* timestamp("link");
|
|
1307
|
+
attach(parent, name, node, now);
|
|
1308
|
+
node.metadata = { ...node.metadata, nlink: node.metadata.nlink + 1, ctimeNs: now };
|
|
1309
|
+
advanceRevision(node);
|
|
1310
|
+
entries += 1;
|
|
1311
|
+
publishEntry("Create", parent, name);
|
|
1312
|
+
}));
|
|
1313
|
+
}),
|
|
1314
|
+
symlink: Effect.fn("Caller.symlink")(function* (target, input, options) {
|
|
1315
|
+
const prepared = preparePath(input, "symlink", settings.maxPathBytes);
|
|
1316
|
+
const rawTarget = inputBytes(target);
|
|
1317
|
+
if (Result.isFailure(rawTarget)) {
|
|
1318
|
+
return yield* new FsError({ code: rawTarget.failure, operation: "symlink", path: target });
|
|
1319
|
+
}
|
|
1320
|
+
if (rawTarget.success.includes(0)) {
|
|
1321
|
+
return yield* new FsError({ code: "InvalidArgument", operation: "symlink", path: target });
|
|
1322
|
+
}
|
|
1323
|
+
const targetBytes = new Uint8Array(rawTarget.success);
|
|
1324
|
+
const base = options?.relativeTo;
|
|
1325
|
+
return yield* coordinated(Effect.gen(function* () {
|
|
1326
|
+
const path = yield* Effect.fromResult(prepared);
|
|
1327
|
+
const bytes = targetBytes;
|
|
1328
|
+
const parent = yield* locate(path, base, "symlink", { parentOnly: true });
|
|
1329
|
+
yield* authorize(parent, identity, WRITE | EXECUTE, "symlink", input);
|
|
1330
|
+
const name = path.components.at(-1);
|
|
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 });
|
|
1336
|
+
}
|
|
1337
|
+
if (atEntryLimit() ||
|
|
1338
|
+
(settings.maxBytes !== undefined &&
|
|
1339
|
+
BigInt(bytes.length) > ByteSize.toBigInt(settings.maxBytes) - usedBytes))
|
|
1340
|
+
return yield* new FsError({ code: "NoSpace", operation: "symlink", path: input });
|
|
1341
|
+
const now = yield* timestamp("symlink");
|
|
1342
|
+
const node = {
|
|
1343
|
+
kind: "symlink",
|
|
1344
|
+
lineage: undefined,
|
|
1345
|
+
target: new Uint8Array(bytes),
|
|
1346
|
+
metadata: {
|
|
1347
|
+
...directoryMetadata(nextInode, identity.uid, parent.metadata.gid, 0o777, now),
|
|
1348
|
+
kind: "symlink",
|
|
1349
|
+
nlink: 1,
|
|
1350
|
+
size: BigInt(bytes.length)
|
|
1351
|
+
},
|
|
1352
|
+
revision: nextRevision(),
|
|
1353
|
+
objectReference: undefined
|
|
1354
|
+
};
|
|
1355
|
+
attach(parent, name, node, now);
|
|
1356
|
+
nextInode += 1n;
|
|
1357
|
+
entries += 1;
|
|
1358
|
+
usedBytes += BigInt(bytes.length);
|
|
1359
|
+
publishEntry("Create", parent, name);
|
|
1360
|
+
}));
|
|
1361
|
+
}),
|
|
1362
|
+
readDirectoryBytes: Effect.fn("Caller.readDirectoryBytes")(function* (input, options) {
|
|
1363
|
+
return yield* list(input, options);
|
|
1364
|
+
}),
|
|
1365
|
+
readDirectory: Effect.fn("Caller.readDirectory")(function* (input, options) {
|
|
1366
|
+
return yield* Effect.forEach(yield* list(input, options), (bytes) => strictString(bytes, "readDirectory"));
|
|
1367
|
+
}),
|
|
1368
|
+
readLinkBytes: Effect.fn("Caller.readLinkBytes")(function* (input, options) {
|
|
1369
|
+
return yield* readTarget(input, options);
|
|
1370
|
+
}),
|
|
1371
|
+
readLink: Effect.fn("Caller.readLink")(function* (input, options) {
|
|
1372
|
+
return yield* strictString(yield* readTarget(input, options), "readLink");
|
|
1373
|
+
}),
|
|
1374
|
+
realPathBytes: Effect.fn("Caller.realPathBytes")(function* (input, options) {
|
|
1375
|
+
return ownedPath(yield* canonical(input, options));
|
|
1376
|
+
}),
|
|
1377
|
+
realPath: Effect.fn("Caller.realPath")(function* (input, options) {
|
|
1378
|
+
return yield* strictString(yield* canonical(input, options), "realPath");
|
|
1379
|
+
}),
|
|
1380
|
+
open: Effect.fn("Caller.open")(function* (input, options) {
|
|
1381
|
+
const prepared = preparePath(input, "open", settings.maxPathBytes);
|
|
1382
|
+
const { relativeTo: base, ...raw } = options;
|
|
1383
|
+
const decoded = decodeOpenSettings(raw);
|
|
1384
|
+
if (Result.isFailure(decoded)) {
|
|
1385
|
+
return yield* new FsError({ code: "InvalidArgument", operation: "open", path: input });
|
|
1386
|
+
}
|
|
1387
|
+
const chosen = { ...decoded.success };
|
|
1388
|
+
if (chosen.access === "read" && (chosen.append || chosen.truncate)) {
|
|
1389
|
+
return yield* new FsError({ code: "InvalidArgument", operation: "open", path: input });
|
|
1390
|
+
}
|
|
1391
|
+
if (chosen.mode !== undefined && (chosen.create === undefined || chosen.create === "never")) {
|
|
1392
|
+
return yield* new FsError({ code: "InvalidArgument", operation: "open", path: input });
|
|
1393
|
+
}
|
|
1394
|
+
const acquired = {
|
|
1395
|
+
volume: volumeIdentity,
|
|
1396
|
+
file: undefined,
|
|
1397
|
+
closed: false,
|
|
1398
|
+
offset: 0n,
|
|
1399
|
+
access: chosen.access,
|
|
1400
|
+
append: chosen.append ?? false
|
|
1401
|
+
};
|
|
1402
|
+
yield* Effect.addFinalizer(() => coordinated(Effect.sync(() => releaseFile(acquired))));
|
|
1403
|
+
return yield* coordinated(Effect.gen(function* () {
|
|
1404
|
+
if (acquired.closed)
|
|
1405
|
+
return yield* Effect.interrupt;
|
|
1406
|
+
const path = yield* Effect.fromResult(prepared);
|
|
1407
|
+
if (chosen.create === "exclusive") {
|
|
1408
|
+
const existing = yield* Effect.result(lookup(path, base, "open", { followFinalSymlink: false }));
|
|
1409
|
+
if (Result.isSuccess(existing)) {
|
|
1410
|
+
return yield* new FsError({ code: "AlreadyExists", operation: "open", path: input });
|
|
1411
|
+
}
|
|
1412
|
+
if (existing.failure.code !== "NotFound")
|
|
1413
|
+
return yield* existing.failure;
|
|
1414
|
+
}
|
|
1415
|
+
const resolved = yield* lookup(path, base, "open", {
|
|
1416
|
+
followFinalSymlink: chosen.followFinalSymlink !== false,
|
|
1417
|
+
allowMissing: chosen.create === "ifMissing" || chosen.create === "exclusive"
|
|
1418
|
+
});
|
|
1419
|
+
const parent = resolved.parent;
|
|
1420
|
+
if (parent === undefined)
|
|
1421
|
+
return yield* new FsError({ code: "IsDirectory", operation: "open", path: input });
|
|
1422
|
+
const name = resolved.name;
|
|
1423
|
+
if (isDotComponent(name)) {
|
|
1424
|
+
return yield* new FsError({ code: "IsDirectory", operation: "open", path: input });
|
|
1425
|
+
}
|
|
1426
|
+
yield* authorize(parent, identity, EXECUTE, "open", input);
|
|
1427
|
+
let file = resolved.node;
|
|
1428
|
+
if (file !== undefined && chosen.create === "exclusive") {
|
|
1429
|
+
return yield* new FsError({ code: "AlreadyExists", operation: "open", path: input });
|
|
1430
|
+
}
|
|
1431
|
+
if (file === undefined) {
|
|
1432
|
+
if (chosen.create === undefined || chosen.create === "never" || path.trailingSlash) {
|
|
1433
|
+
return yield* new FsError({ code: "NotFound", operation: "open", path: input });
|
|
1434
|
+
}
|
|
1435
|
+
yield* authorize(parent, identity, WRITE | EXECUTE, "open", input);
|
|
1436
|
+
if (atEntryLimit()) {
|
|
1437
|
+
return yield* new FsError({ code: "NoSpace", operation: "open", path: input });
|
|
1438
|
+
}
|
|
1439
|
+
const now = yield* timestamp("open");
|
|
1440
|
+
file = {
|
|
1441
|
+
kind: "file",
|
|
1442
|
+
lineage: undefined,
|
|
1443
|
+
data: Content.empty(),
|
|
1444
|
+
openCount: 0,
|
|
1445
|
+
metadata: {
|
|
1446
|
+
...directoryMetadata(nextInode, identity.uid, parent.metadata.gid, (chosen.mode ?? 0o666) & 0o777 & ~umask, now),
|
|
1447
|
+
kind: "file",
|
|
1448
|
+
nlink: 1
|
|
1449
|
+
},
|
|
1450
|
+
revision: nextRevision(),
|
|
1451
|
+
objectReference: undefined
|
|
1452
|
+
};
|
|
1453
|
+
attach(parent, name, file, now);
|
|
1454
|
+
entries += 1;
|
|
1455
|
+
nextInode += 1n;
|
|
1456
|
+
publishEntry("Create", parent, name);
|
|
1457
|
+
}
|
|
1458
|
+
else {
|
|
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);
|
|
1469
|
+
if (chosen.truncate)
|
|
1470
|
+
yield* resize(file, 0n, "open");
|
|
1471
|
+
}
|
|
1472
|
+
file.openCount += 1;
|
|
1473
|
+
acquired.file = file;
|
|
1474
|
+
return fileHandle(acquired);
|
|
1475
|
+
}));
|
|
1476
|
+
}),
|
|
1477
|
+
unlink: Effect.fn("Caller.unlink")(function* (input, options) {
|
|
1478
|
+
const prepared = preparePath(input, "unlink", settings.maxPathBytes);
|
|
1479
|
+
const base = options?.relativeTo;
|
|
1480
|
+
return yield* coordinated(Effect.gen(function* () {
|
|
1481
|
+
const path = yield* Effect.fromResult(prepared);
|
|
1482
|
+
const parent = yield* locate(path, base, "unlink", { parentOnly: true });
|
|
1483
|
+
yield* authorize(parent, identity, WRITE | EXECUTE, "unlink", input);
|
|
1484
|
+
const name = path.components.at(-1);
|
|
1485
|
+
if (isDotComponent(name)) {
|
|
1486
|
+
return yield* new FsError({ code: "IsDirectory", operation: "unlink", path: input });
|
|
1487
|
+
}
|
|
1488
|
+
const child = parent.entries.get(name);
|
|
1489
|
+
if (child === undefined)
|
|
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
|
+
}
|
|
1497
|
+
yield* authorizeRemoval(parent, child, "unlink", input);
|
|
1498
|
+
const now = yield* timestamp("unlink");
|
|
1499
|
+
parent.entries.delete(name);
|
|
1500
|
+
parent.metadata = { ...parent.metadata, mtimeNs: now, ctimeNs: now };
|
|
1501
|
+
advanceRevision(parent);
|
|
1502
|
+
detach(child, now);
|
|
1503
|
+
entries -= 1;
|
|
1504
|
+
publishEntry("Remove", parent, name);
|
|
1505
|
+
}));
|
|
1506
|
+
}),
|
|
1507
|
+
rename: Effect.fn("Caller.rename")(function* (source, destination, options) {
|
|
1508
|
+
const oldPrepared = preparePath(source, "rename", settings.maxPathBytes);
|
|
1509
|
+
const newPrepared = preparePath(destination, "rename", settings.maxPathBytes);
|
|
1510
|
+
const oldBase = options?.sourceRelativeTo;
|
|
1511
|
+
const newBase = options?.destinationRelativeTo;
|
|
1512
|
+
return yield* coordinated(Effect.gen(function* () {
|
|
1513
|
+
const oldPath = yield* Effect.fromResult(oldPrepared);
|
|
1514
|
+
const newPath = yield* Effect.fromResult(newPrepared);
|
|
1515
|
+
const oldParent = yield* locate(oldPath, oldBase, "rename", { parentOnly: true });
|
|
1516
|
+
const newParent = yield* locate(newPath, newBase, "rename", { parentOnly: true });
|
|
1517
|
+
yield* authorize(oldParent, identity, WRITE | EXECUTE, "rename", source);
|
|
1518
|
+
yield* authorize(newParent, identity, WRITE | EXECUTE, "rename", destination);
|
|
1519
|
+
const oldName = oldPath.components.at(-1);
|
|
1520
|
+
const newName = newPath.components.at(-1);
|
|
1521
|
+
if (isDotComponent(oldName) || isDotComponent(newName)) {
|
|
1522
|
+
return yield* new FsError({ code: "InvalidArgument", operation: "rename", path: source });
|
|
1523
|
+
}
|
|
1524
|
+
const child = oldParent.entries.get(oldName);
|
|
1525
|
+
if (child === undefined)
|
|
1526
|
+
return yield* new FsError({ code: "NotFound", operation: "rename", path: source });
|
|
1527
|
+
const replaced = newParent.entries.get(newName);
|
|
1528
|
+
if (newPath.trailingSlash && replaced === undefined) {
|
|
1529
|
+
return yield* new FsError({ code: "NotFound", operation: "rename", path: destination });
|
|
1530
|
+
}
|
|
1531
|
+
if (oldPath.trailingSlash && child.kind !== "directory") {
|
|
1532
|
+
return yield* new FsError({ code: "NotDirectory", operation: "rename", path: source });
|
|
1533
|
+
}
|
|
1534
|
+
if (newPath.trailingSlash && replaced?.kind !== "directory") {
|
|
1535
|
+
return yield* new FsError({ code: "NotDirectory", operation: "rename", path: destination });
|
|
1536
|
+
}
|
|
1537
|
+
if (child === replaced)
|
|
1538
|
+
return;
|
|
1539
|
+
yield* authorizeRemoval(oldParent, child, "rename", source);
|
|
1540
|
+
if (replaced !== undefined) {
|
|
1541
|
+
yield* authorizeRemoval(newParent, replaced, "rename", destination);
|
|
1542
|
+
if (child.kind === "directory" && replaced.kind !== "directory") {
|
|
1543
|
+
return yield* new FsError({ code: "NotDirectory", operation: "rename", path: destination });
|
|
1544
|
+
}
|
|
1545
|
+
if (child.kind !== "directory" && replaced.kind === "directory") {
|
|
1546
|
+
return yield* new FsError({ code: "IsDirectory", operation: "rename", path: destination });
|
|
1547
|
+
}
|
|
1548
|
+
if (replaced.kind === "directory" && replaced.entries.size > 0) {
|
|
1549
|
+
return yield* new FsError({ code: "NotEmpty", operation: "rename", path: destination });
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
for (let ancestor = newParent; ancestor !== undefined; ancestor = ancestor.parent) {
|
|
1553
|
+
if (ancestor === child) {
|
|
1554
|
+
return yield* new FsError({ code: "InvalidArgument", operation: "rename", path: destination });
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
const now = yield* timestamp("rename");
|
|
1558
|
+
// All rejection checks precede namespace, ancestry, quota, and metadata publication.
|
|
1559
|
+
const oldEvent = () => ownedPath(nameBytes(directoryHex(oldParent) + (oldParent === root ? "" : SLASH_HEX) + oldName));
|
|
1560
|
+
oldParent.entries.delete(oldName);
|
|
1561
|
+
newParent.entries.set(newName, child);
|
|
1562
|
+
if (child.kind === "directory")
|
|
1563
|
+
child.parent = newParent;
|
|
1564
|
+
oldParent.metadata = {
|
|
1565
|
+
...oldParent.metadata,
|
|
1566
|
+
nlink: oldParent.metadata.nlink - (child.kind === "directory" ? 1 : 0),
|
|
1567
|
+
mtimeNs: now,
|
|
1568
|
+
ctimeNs: now
|
|
1569
|
+
};
|
|
1570
|
+
newParent.metadata = {
|
|
1571
|
+
...newParent.metadata,
|
|
1572
|
+
nlink: newParent.metadata.nlink + (child.kind === "directory" && replaced === undefined ? 1 : 0),
|
|
1573
|
+
mtimeNs: now,
|
|
1574
|
+
ctimeNs: now
|
|
1575
|
+
};
|
|
1576
|
+
child.metadata = { ...child.metadata, ctimeNs: now };
|
|
1577
|
+
advanceRevision(oldParent);
|
|
1578
|
+
if (newParent !== oldParent)
|
|
1579
|
+
advanceRevision(newParent);
|
|
1580
|
+
advanceRevision(child);
|
|
1581
|
+
if (replaced !== undefined) {
|
|
1582
|
+
detach(replaced, now);
|
|
1583
|
+
entries -= 1;
|
|
1584
|
+
}
|
|
1585
|
+
watchHub.publishUnsafe(() => ({ _tag: "Remove", path: oldEvent() }));
|
|
1586
|
+
publishEntry("Create", newParent, newName);
|
|
1587
|
+
}));
|
|
1588
|
+
}),
|
|
1589
|
+
rmdir: Effect.fn("Caller.rmdir")(function* (input, options) {
|
|
1590
|
+
const prepared = preparePath(input, "rmdir", settings.maxPathBytes);
|
|
1591
|
+
const base = options?.relativeTo;
|
|
1592
|
+
return yield* coordinated(Effect.gen(function* () {
|
|
1593
|
+
const path = yield* Effect.fromResult(prepared);
|
|
1594
|
+
const parent = yield* locate(path, base, "rmdir", { parentOnly: true });
|
|
1595
|
+
yield* authorize(parent, identity, WRITE | EXECUTE, "rmdir", input);
|
|
1596
|
+
const name = path.components.at(-1);
|
|
1597
|
+
if (isDotComponent(name)) {
|
|
1598
|
+
return yield* new FsError({ code: "InvalidArgument", operation: "rmdir", path: input });
|
|
1599
|
+
}
|
|
1600
|
+
const child = parent.entries.get(name);
|
|
1601
|
+
if (child === undefined)
|
|
1602
|
+
return yield* new FsError({ code: "NotFound", operation: "rmdir", path: input });
|
|
1603
|
+
yield* authorizeRemoval(parent, child, "rmdir", input);
|
|
1604
|
+
if (child.kind !== "directory") {
|
|
1605
|
+
return yield* new FsError({ code: "NotDirectory", operation: "rmdir", path: input });
|
|
1606
|
+
}
|
|
1607
|
+
if (child.entries.size > 0)
|
|
1608
|
+
return yield* new FsError({ code: "NotEmpty", operation: "rmdir", path: input });
|
|
1609
|
+
const now = yield* timestamp("rmdir");
|
|
1610
|
+
parent.entries.delete(name);
|
|
1611
|
+
parent.metadata = { ...parent.metadata, nlink: parent.metadata.nlink - 1, mtimeNs: now, ctimeNs: now };
|
|
1612
|
+
child.parent = undefined;
|
|
1613
|
+
child.metadata = { ...child.metadata, nlink: 0, ctimeNs: now };
|
|
1614
|
+
advanceRevision(parent);
|
|
1615
|
+
advanceRevision(child);
|
|
1616
|
+
invalidateReference(child);
|
|
1617
|
+
entries -= 1;
|
|
1618
|
+
publishEntry("Remove", parent, name);
|
|
1619
|
+
}));
|
|
1620
|
+
}),
|
|
1621
|
+
stat: Effect.fn("Caller.stat")(function* (input, options) {
|
|
1622
|
+
const prepared = preparePath(input, "stat", settings.maxPathBytes);
|
|
1623
|
+
const base = options?.relativeTo;
|
|
1624
|
+
return yield* coordinated(Effect.gen(function* () {
|
|
1625
|
+
const path = yield* Effect.fromResult(prepared);
|
|
1626
|
+
const directory = yield* resolveNode(path, base, "stat");
|
|
1627
|
+
return { ...directory.metadata };
|
|
1628
|
+
}));
|
|
1629
|
+
}),
|
|
1630
|
+
mkdir: Effect.fn("Caller.mkdir")(function* (input, options) {
|
|
1631
|
+
const prepared = preparePath(input, "mkdir", settings.maxPathBytes);
|
|
1632
|
+
const base = options?.relativeTo;
|
|
1633
|
+
const mode = options?.mode === undefined ? 0o777 : options.mode;
|
|
1634
|
+
if (!isMode(mode))
|
|
1635
|
+
return yield* new FsError({ code: "InvalidArgument", operation: "mkdir", path: input });
|
|
1636
|
+
return yield* coordinated(Effect.gen(function* () {
|
|
1637
|
+
const path = yield* Effect.fromResult(prepared);
|
|
1638
|
+
const parent = yield* locate(path, base, "mkdir", { parentOnly: true });
|
|
1639
|
+
yield* authorize(parent, identity, WRITE | EXECUTE, "mkdir", input);
|
|
1640
|
+
const name = path.components.at(-1);
|
|
1641
|
+
if (isDotComponent(name) || parent.entries.has(name)) {
|
|
1642
|
+
return yield* new FsError({ code: "AlreadyExists", operation: "mkdir", path: input });
|
|
1643
|
+
}
|
|
1644
|
+
if (atEntryLimit()) {
|
|
1645
|
+
return yield* new FsError({ code: "NoSpace", operation: "mkdir", path: input });
|
|
1646
|
+
}
|
|
1647
|
+
const now = yield* timestamp("mkdir");
|
|
1648
|
+
const child = {
|
|
1649
|
+
kind: "directory",
|
|
1650
|
+
lineage: undefined,
|
|
1651
|
+
parent,
|
|
1652
|
+
entries: new Map(),
|
|
1653
|
+
metadata: directoryMetadata(nextInode, identity.uid, parent.metadata.gid, (mode & 0o777 & ~umask) | (mode & STICKY_BIT), now),
|
|
1654
|
+
revision: nextRevision(),
|
|
1655
|
+
objectReference: undefined
|
|
1656
|
+
};
|
|
1657
|
+
// No Effect yield or expected failure between these publication writes.
|
|
1658
|
+
attach(parent, name, child, now);
|
|
1659
|
+
nextInode += 1n;
|
|
1660
|
+
entries += 1;
|
|
1661
|
+
publishEntry("Create", parent, name);
|
|
1662
|
+
}));
|
|
1663
|
+
}),
|
|
1664
|
+
withDirectory: Effect.fn("Caller.withDirectory")(function* (input, options) {
|
|
1665
|
+
const acquired = yield* acquireDirectory(input, options, "withDirectory");
|
|
1666
|
+
return createCaller(acquired, identity, umask);
|
|
1667
|
+
}),
|
|
1668
|
+
openDirectory: Effect.fn("Caller.openDirectory")(function* (input, options) {
|
|
1669
|
+
const acquired = yield* acquireDirectory(input, options, "openDirectory");
|
|
1670
|
+
const handle = Object.freeze({
|
|
1671
|
+
[DirectoryHandleId]: true,
|
|
1672
|
+
stat: coordinated(Effect.suspend(() => acquired.directory === undefined
|
|
1673
|
+
? Effect.fail(new FsError({ code: "InvalidHandle", operation: "stat" }))
|
|
1674
|
+
: Effect.succeed({ ...acquired.directory.metadata }))).pipe(Effect.withSpan("DirectoryHandle.stat")),
|
|
1675
|
+
close: coordinated(Effect.suspend(() => {
|
|
1676
|
+
if (acquired.directory === undefined) {
|
|
1677
|
+
return Effect.fail(new FsError({ code: "InvalidHandle", operation: "close" }));
|
|
1678
|
+
}
|
|
1679
|
+
acquired.directory = undefined;
|
|
1680
|
+
acquired.closed = true;
|
|
1681
|
+
return Effect.void;
|
|
1682
|
+
})).pipe(Effect.withSpan("DirectoryHandle.close"))
|
|
1683
|
+
});
|
|
1684
|
+
handles.set(handle, acquired);
|
|
1685
|
+
return handle;
|
|
1686
|
+
})
|
|
1687
|
+
});
|
|
1688
|
+
};
|
|
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) });
|
|
1693
|
+
const publicChanges = (changes) => Object.freeze(changes.map(publicChange));
|
|
1694
|
+
const changeOptions = (options) => {
|
|
1695
|
+
const decoded = decodeConfiguration(OverlayChangesOptions, options === undefined ? {} : options);
|
|
1696
|
+
return Result.isFailure(decoded) ? Effect.fail(decoded.failure) : Effect.succeed(decoded.success);
|
|
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;
|
|
1701
|
+
const volume = Object.freeze({
|
|
1702
|
+
[VolumeId]: true,
|
|
1703
|
+
watch: Effect.gen(function* () {
|
|
1704
|
+
const hook = TestHooks.getRegistrationHook(surface);
|
|
1705
|
+
return yield* watchHub.subscribe(hook?.afterSubscribe);
|
|
1706
|
+
}).pipe(Effect.withSpan("Volume.watch")),
|
|
1707
|
+
snapshot: coordinatedRead(captureSnapshot()).pipe(Effect.withSpan("Volume.snapshot")),
|
|
1708
|
+
caller: Effect.fn("Volume.caller")(function* (options) {
|
|
1709
|
+
const decoded = decodeConfiguration(RootCallerOptions, options === undefined ? {} : options);
|
|
1710
|
+
if (Result.isFailure(decoded))
|
|
1711
|
+
return yield* decoded.failure;
|
|
1712
|
+
const chosen = decoded.success.identity ?? { uid: 0, gid: 0, groups: [], privileged: true };
|
|
1713
|
+
const identity = Object.freeze({ ...chosen, groups: Object.freeze([...chosen.groups]) });
|
|
1714
|
+
return createCaller({ volume: volumeIdentity, directory: root, closed: false }, identity, decoded.success.umask ?? 0o022);
|
|
1715
|
+
})
|
|
1716
|
+
});
|
|
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;
|
|
1721
|
+
}
|
|
1722
|
+
const hook = TestHooks.getObservationHook(source.base);
|
|
1723
|
+
const overlay = Object.freeze({
|
|
1724
|
+
...volume,
|
|
1725
|
+
changes: Effect.fn("OverlayVolume.changes")(function* (options) {
|
|
1726
|
+
const selected = yield* changeOptions(options);
|
|
1727
|
+
const current = yield* coordinatedRead(observeChanges());
|
|
1728
|
+
return publicChanges(compareOverlay(baseObservation, current, selected.includeTimestamps ?? false));
|
|
1729
|
+
}),
|
|
1730
|
+
capture: Effect.fn("OverlayVolume.capture")(function* (options) {
|
|
1731
|
+
const selected = yield* changeOptions(options);
|
|
1732
|
+
const current = yield* coordinatedRead(captureState(hook));
|
|
1733
|
+
return Object.freeze({
|
|
1734
|
+
snapshot: current.snapshot,
|
|
1735
|
+
changes: publicChanges(compareOverlay(baseObservation, current.observation, selected.includeTimestamps ?? false))
|
|
1736
|
+
});
|
|
1737
|
+
})
|
|
1738
|
+
});
|
|
1739
|
+
surface = overlay;
|
|
1740
|
+
return overlay;
|
|
1741
|
+
});
|
|
1742
|
+
/** @internal */
|
|
1743
|
+
export const make = Effect.fn("VirtualFileSystem.make")(function* (options) {
|
|
1744
|
+
return yield* makeVolume(VolumeSource.Empty(), options).pipe(Effect.catchTag("ImageError", Effect.die));
|
|
1745
|
+
});
|
|
1746
|
+
/** @internal */
|
|
1747
|
+
export const fromSnapshot = Effect.fn("VirtualFileSystem.fromSnapshot")(function* (snapshot, options) {
|
|
1748
|
+
const image = yield* Image.inspect(snapshot);
|
|
1749
|
+
return yield* makeVolume(VolumeSource.Snapshot({ image }), options);
|
|
1750
|
+
});
|
|
1751
|
+
/** @internal */
|
|
1752
|
+
export const makeOverlay = Effect.fn("VirtualFileSystem.makeOverlay")(function* (base, options) {
|
|
1753
|
+
const image = yield* Image.inspect(base);
|
|
1754
|
+
return yield* makeVolume(VolumeSource.Overlay({ base, image }), options);
|
|
1755
|
+
});
|