@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
|
@@ -1,22 +1,34 @@
|
|
|
1
|
-
|
|
1
|
+
// Exact snapshot delta construction, inspection, serialization, and application.
|
|
2
2
|
import * as ByteSize from "effect/ByteSize";
|
|
3
3
|
import * as Crypto from "effect/Crypto";
|
|
4
4
|
import * as Effect from "effect/Effect";
|
|
5
5
|
import * as Encoding from "effect/Encoding";
|
|
6
|
-
import * as
|
|
6
|
+
import * as Fn from "effect/Function";
|
|
7
|
+
import * as Match from "effect/Match";
|
|
8
|
+
import * as Predicate from "effect/Predicate";
|
|
7
9
|
import * as Result from "effect/Result";
|
|
8
10
|
import * as Schema from "effect/Schema";
|
|
9
|
-
import { make as makeBytePath } from "../BytePath.js";
|
|
10
11
|
import { ImageError } from "../Snapshot.js";
|
|
11
|
-
import {
|
|
12
|
-
import
|
|
12
|
+
import { SnapshotDeltaError, SnapshotDifference, SnapshotNodeKind } from "../SnapshotDelta.js";
|
|
13
|
+
import { make as makeBytePath } from "./bytePath.js";
|
|
14
|
+
import { bytesOrder, sameBytes } from "./bytes.js";
|
|
15
|
+
import { CanonicalBase64 } from "./canonicalBase64.js";
|
|
13
16
|
import * as Image from "./image.js";
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const
|
|
17
|
+
import { WireStoredMetadata } from "./metadata.js";
|
|
18
|
+
import * as SnapshotDeltaModel from "./snapshotDeltaModel.js";
|
|
19
|
+
const FORMAT = "effect-vfs-delta";
|
|
20
|
+
const ALGORITHM = "effect-vfs-semantic-sha256-v1";
|
|
21
|
+
const SLASH_BYTE = 47;
|
|
22
|
+
const DOT_BYTE = 46;
|
|
23
|
+
const MAX_NAME_BYTES = 255;
|
|
24
|
+
const NUL_BYTE = 0;
|
|
25
|
+
const SHA256_BYTES = 32;
|
|
26
|
+
const ROOT_PATH = new Uint8Array([SLASH_BYTE]);
|
|
27
|
+
const Path = CanonicalBase64.Encoded;
|
|
28
|
+
const InlinePayload = Schema.TaggedStruct("Inline", { bytes: CanonicalBase64.Encoded });
|
|
29
|
+
const BasePayload = Schema.TaggedStruct("Base", { path: Path });
|
|
30
|
+
const Payload = Schema.Union([InlinePayload, BasePayload]);
|
|
31
|
+
const DeltaRecord = Schema.Union([
|
|
20
32
|
Schema.Struct({ kind: Schema.Literal("directory"), paths: Schema.Array(Path), metadata: Image.StoredMetadata }),
|
|
21
33
|
Schema.Struct({
|
|
22
34
|
kind: Schema.Literal("file"),
|
|
@@ -31,241 +43,256 @@ const Record = Schema.Union([
|
|
|
31
43
|
payload: Payload
|
|
32
44
|
})
|
|
33
45
|
]);
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
"birthtimeNs"
|
|
46
|
+
const AddedChange = Schema.TaggedStruct("Added", { path: Path, kind: SnapshotNodeKind });
|
|
47
|
+
const RemovedChange = Schema.TaggedStruct("Removed", { path: Path, kind: SnapshotNodeKind });
|
|
48
|
+
const UpdatedChange = Schema.TaggedStruct("Updated", {
|
|
49
|
+
path: Path,
|
|
50
|
+
beforeKind: SnapshotNodeKind,
|
|
51
|
+
afterKind: SnapshotNodeKind,
|
|
52
|
+
differences: Schema.Array(SnapshotDifference).check(Schema.isMinLength(1))
|
|
53
|
+
});
|
|
54
|
+
const Change = Schema.Union([AddedChange, RemovedChange, UpdatedChange]);
|
|
55
|
+
const WirePayload = Schema.Union([
|
|
56
|
+
Schema.TaggedStruct("Inline", { bytes: Schema.String }),
|
|
57
|
+
Schema.TaggedStruct("Base", { path: Schema.String })
|
|
47
58
|
]);
|
|
48
|
-
const
|
|
49
|
-
Schema.
|
|
50
|
-
|
|
59
|
+
const WireDeltaRecord = Schema.Union([
|
|
60
|
+
Schema.Struct({
|
|
61
|
+
kind: Schema.Literal("directory"),
|
|
62
|
+
paths: Schema.Array(Schema.String),
|
|
63
|
+
metadata: WireStoredMetadata
|
|
64
|
+
}),
|
|
65
|
+
Schema.Struct({
|
|
66
|
+
kind: Schema.Literal("file"),
|
|
67
|
+
paths: Schema.Array(Schema.String),
|
|
68
|
+
metadata: WireStoredMetadata,
|
|
69
|
+
payload: WirePayload
|
|
70
|
+
}),
|
|
71
|
+
Schema.Struct({
|
|
72
|
+
kind: Schema.Literal("symlink"),
|
|
73
|
+
paths: Schema.Array(Schema.String),
|
|
74
|
+
metadata: WireStoredMetadata,
|
|
75
|
+
payload: WirePayload
|
|
76
|
+
})
|
|
77
|
+
]);
|
|
78
|
+
const WireChange = Schema.Union([
|
|
79
|
+
Schema.TaggedStruct("Added", { path: Schema.String, kind: SnapshotNodeKind }),
|
|
80
|
+
Schema.TaggedStruct("Removed", { path: Schema.String, kind: SnapshotNodeKind }),
|
|
51
81
|
Schema.TaggedStruct("Updated", {
|
|
52
|
-
path:
|
|
53
|
-
beforeKind:
|
|
54
|
-
afterKind:
|
|
55
|
-
differences: Schema.Array(
|
|
82
|
+
path: Schema.String,
|
|
83
|
+
beforeKind: SnapshotNodeKind,
|
|
84
|
+
afterKind: SnapshotNodeKind,
|
|
85
|
+
differences: Schema.Array(SnapshotDifference).check(Schema.isMinLength(1))
|
|
56
86
|
})
|
|
57
87
|
]);
|
|
88
|
+
const sameChanges = Schema.toEquivalence(Schema.Array(Change));
|
|
89
|
+
const VersionProbe = Schema.Struct({
|
|
90
|
+
format: Schema.Literal(FORMAT),
|
|
91
|
+
version: Schema.Unknown
|
|
92
|
+
});
|
|
58
93
|
const Document = Schema.Struct({
|
|
59
|
-
format: Schema.Literal(
|
|
94
|
+
format: Schema.Literal(FORMAT),
|
|
60
95
|
version: Schema.Literal(1),
|
|
61
|
-
base: Schema.Struct({ algorithm: Schema.Literal(
|
|
62
|
-
records: Schema.Array(
|
|
96
|
+
base: Schema.Struct({ algorithm: Schema.Literal(ALGORITHM), digest: CanonicalBase64.Encoded }),
|
|
97
|
+
records: Schema.Array(DeltaRecord),
|
|
63
98
|
changes: Schema.Array(Change)
|
|
64
99
|
});
|
|
100
|
+
const WireDocument = Schema.Struct({
|
|
101
|
+
format: Schema.Literal(FORMAT),
|
|
102
|
+
version: Schema.Literal(1),
|
|
103
|
+
base: Schema.Struct({ algorithm: Schema.Literal(ALGORITHM), digest: Schema.String }),
|
|
104
|
+
records: Schema.Array(WireDeltaRecord),
|
|
105
|
+
changes: Schema.Array(WireChange)
|
|
106
|
+
});
|
|
65
107
|
const timestampFields = new Set(["atimeNs", "mtimeNs", "ctimeNs", "birthtimeNs"]);
|
|
66
|
-
const differenceOrder =
|
|
67
|
-
"kind",
|
|
68
|
-
"content",
|
|
69
|
-
"target",
|
|
70
|
-
"hardLinks",
|
|
71
|
-
"mode",
|
|
72
|
-
"uid",
|
|
73
|
-
"gid",
|
|
74
|
-
"atimeNs",
|
|
75
|
-
"mtimeNs",
|
|
76
|
-
"ctimeNs",
|
|
77
|
-
"birthtimeNs"
|
|
78
|
-
];
|
|
108
|
+
const differenceOrder = SnapshotDifference.literals;
|
|
79
109
|
const encoder = new TextEncoder();
|
|
80
110
|
const Json = Schema.fromJsonString(Schema.Unknown);
|
|
81
|
-
const
|
|
82
|
-
const sameBytes = (a, b) => Equal.equals(a, b);
|
|
83
|
-
const compareBytes = (a, b) => {
|
|
84
|
-
for (let i = 0; i < Math.min(a.length, b.length); i++) {
|
|
85
|
-
const d = a[i] - b[i];
|
|
86
|
-
if (d !== 0)
|
|
87
|
-
return d;
|
|
88
|
-
}
|
|
89
|
-
return a.length - b.length;
|
|
90
|
-
};
|
|
111
|
+
const JsonDocument = Schema.fromJsonString(Document);
|
|
91
112
|
const key = Encoding.encodeHex;
|
|
92
113
|
const join = (parent, name) => {
|
|
93
114
|
const out = new Uint8Array(parent.length + (parent.length === 1 ? 0 : 1) + name.length);
|
|
94
115
|
out.set(parent);
|
|
95
116
|
let offset = parent.length;
|
|
96
117
|
if (parent.length !== 1)
|
|
97
|
-
out[offset++] =
|
|
118
|
+
out[offset++] = SLASH_BYTE;
|
|
98
119
|
out.set(name, offset);
|
|
99
120
|
return out;
|
|
100
121
|
};
|
|
101
122
|
const parentKey = (path) => {
|
|
102
123
|
let slash = path.length - 1;
|
|
103
|
-
while (slash > 0 && path[slash] !==
|
|
124
|
+
while (slash > 0 && path[slash] !== SLASH_BYTE)
|
|
104
125
|
slash--;
|
|
105
|
-
return key(slash === 0 ?
|
|
126
|
+
return key(slash === 0 ? ROOT_PATH : path.subarray(0, slash));
|
|
106
127
|
};
|
|
107
128
|
const basename = (path) => {
|
|
108
129
|
let slash = path.length - 1;
|
|
109
|
-
while (slash > 0 && path[slash] !==
|
|
130
|
+
while (slash > 0 && path[slash] !== SLASH_BYTE)
|
|
110
131
|
slash--;
|
|
111
132
|
return path.subarray(slash + 1);
|
|
112
133
|
};
|
|
113
|
-
const decode64 = (s, field) => CanonicalBase64.isCanonical(s) ? Effect.succeed(Image.bytes(s)) : Effect.fail(failure("InvalidEncoding", field));
|
|
114
134
|
const safeAdd = (a, b) => {
|
|
115
135
|
const n = a + b;
|
|
116
136
|
return Number.isSafeInteger(n) ? n : undefined;
|
|
117
137
|
};
|
|
118
|
-
const
|
|
119
|
-
const exceedsByteLimit = (value, limit) => BigInt(value) > BigInt(limit);
|
|
138
|
+
const exceeds = (value, limit) => ByteSize.isGreaterThan(ByteSize.bytes(value), limit);
|
|
120
139
|
const validPath = (path) => {
|
|
121
|
-
if (path.length === 0 || path[0] !==
|
|
140
|
+
if (path.length === 0 || path[0] !== SLASH_BYTE || path.includes(NUL_BYTE) ||
|
|
141
|
+
(path.length > 1 && path.at(-1) === SLASH_BYTE))
|
|
122
142
|
return false;
|
|
123
143
|
if (path.length === 1)
|
|
124
144
|
return true;
|
|
125
145
|
let start = 1;
|
|
126
146
|
for (let i = 1; i <= path.length; i++) {
|
|
127
|
-
if (i === path.length || path[i] ===
|
|
147
|
+
if (i === path.length || path[i] === SLASH_BYTE) {
|
|
128
148
|
const length = i - start;
|
|
129
|
-
if (length < 1 || length >
|
|
130
|
-
(length === 2 && path[start] ===
|
|
149
|
+
if (length < 1 || length > MAX_NAME_BYTES || (length === 1 && path[start] === DOT_BYTE) ||
|
|
150
|
+
(length === 2 && path[start] === DOT_BYTE && path[start + 1] === DOT_BYTE))
|
|
131
151
|
return false;
|
|
132
152
|
start = i + 1;
|
|
133
153
|
}
|
|
134
154
|
}
|
|
135
155
|
return true;
|
|
136
156
|
};
|
|
157
|
+
const roleBudget = (limits, role) => role === "base"
|
|
158
|
+
? {
|
|
159
|
+
records: limits.maxBaseRecords,
|
|
160
|
+
recordsField: "baseRecords",
|
|
161
|
+
pathBytes: limits.maxIdentityBytes,
|
|
162
|
+
pathBytesField: "identityBytes",
|
|
163
|
+
payloadBytes: limits.maxIdentityBytes,
|
|
164
|
+
payloadBytesField: "identityBytes"
|
|
165
|
+
}
|
|
166
|
+
: {
|
|
167
|
+
records: limits.maxTargetRecords,
|
|
168
|
+
recordsField: "targetRecords",
|
|
169
|
+
pathBytes: limits.maxDecodedDeltaBytes,
|
|
170
|
+
pathBytesField: "decodedDeltaBytes",
|
|
171
|
+
payloadBytes: limits.maxOutputBytes,
|
|
172
|
+
payloadBytesField: "outputBytes"
|
|
173
|
+
};
|
|
174
|
+
const encodedPayload = (record) => Match.value(record).pipe(Match.tag("directory", () => undefined), Match.tag("file", (record) => record.data), Match.tag("symlink", (record) => record.target), Match.exhaustive);
|
|
137
175
|
const normalize = Effect.fnUntraced(function* (snapshot, limits, role) {
|
|
138
176
|
const doc = yield* Image.inspect(snapshot);
|
|
139
|
-
|
|
140
|
-
|
|
177
|
+
const budget = roleBudget(limits, role);
|
|
178
|
+
if (doc.records.length > budget.records) {
|
|
179
|
+
return yield* new ImageError({ code: "LimitExceeded", field: budget.recordsField });
|
|
141
180
|
}
|
|
142
181
|
const records = new Map(doc.records.map((record) => [record.id, record]));
|
|
143
182
|
const pathsById = new Map();
|
|
144
|
-
const pending = [[doc.root,
|
|
183
|
+
const pending = [[doc.root, ROOT_PATH]];
|
|
145
184
|
let entries = 0;
|
|
146
|
-
let pathBytes =
|
|
185
|
+
let pathBytes = ByteSize.bytes(ROOT_PATH.length);
|
|
186
|
+
// Index loop: `pending` grows while it is being walked.
|
|
147
187
|
for (let i = 0; i < pending.length; i++) {
|
|
148
188
|
const [recordId, path] = pending[i];
|
|
149
189
|
const record = records.get(recordId);
|
|
150
190
|
if (record === undefined)
|
|
151
|
-
return yield*
|
|
191
|
+
return yield* new ImageError({ code: "InvalidStructure", field: role });
|
|
152
192
|
const paths = pathsById.get(recordId);
|
|
153
193
|
if (paths === undefined)
|
|
154
194
|
pathsById.set(recordId, [path]);
|
|
155
195
|
else
|
|
156
196
|
paths.push(path);
|
|
157
|
-
if (
|
|
197
|
+
if (Image.Record.guards.directory(record)) {
|
|
158
198
|
for (const entry of record.entries) {
|
|
159
199
|
if (++entries > limits.maxEntries)
|
|
160
|
-
return yield*
|
|
161
|
-
const
|
|
162
|
-
const pathLength =
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
return yield* failure("LimitExceeded", role === "base" ? "identityBytes" : "decodedDeltaBytes");
|
|
200
|
+
return yield* new ImageError({ code: "LimitExceeded", field: "entries" });
|
|
201
|
+
const separator = path.length === ROOT_PATH.length ? 0 : 1;
|
|
202
|
+
const pathLength = path.length + separator + CanonicalBase64.decodedLength(entry.name);
|
|
203
|
+
pathBytes = ByteSize.sum(pathBytes, ByteSize.bytes(pathLength));
|
|
204
|
+
if (ByteSize.isGreaterThan(pathBytes, budget.pathBytes)) {
|
|
205
|
+
return yield* new ImageError({ code: "LimitExceeded", field: budget.pathBytesField });
|
|
167
206
|
}
|
|
168
|
-
|
|
169
|
-
pending.push([entry.target, join(path, Image.bytes(entry.name))]);
|
|
207
|
+
pending.push([entry.target, join(path, yield* CanonicalBase64.decode(entry.name))]);
|
|
170
208
|
}
|
|
171
209
|
}
|
|
172
210
|
}
|
|
173
211
|
const objects = [];
|
|
174
212
|
const byPath = new Map();
|
|
175
|
-
let payloadBytes =
|
|
176
|
-
let basePayloadBytes = 0;
|
|
213
|
+
let payloadBytes = ByteSize.zero;
|
|
177
214
|
for (const record of doc.records) {
|
|
178
215
|
const paths = pathsById.get(record.id);
|
|
179
216
|
if (paths === undefined)
|
|
180
|
-
return yield*
|
|
181
|
-
paths.sort(
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
:
|
|
187
|
-
if (encodedPayload !== undefined) {
|
|
188
|
-
const payloadLength = encodedPayloadLength(encodedPayload);
|
|
189
|
-
const next = safeAdd(role === "base" ? basePayloadBytes : payloadBytes, payloadLength);
|
|
190
|
-
const limit = role === "base" ? limits.maxIdentityBytes : limits.maxOutputBytes;
|
|
191
|
-
if (next === undefined || exceedsByteLimit(next, limit)) {
|
|
192
|
-
return yield* failure("LimitExceeded", role === "base" ? "identityBytes" : "outputBytes");
|
|
217
|
+
return yield* new ImageError({ code: "InvalidStructure", field: role });
|
|
218
|
+
paths.sort(bytesOrder);
|
|
219
|
+
const encoded = encodedPayload(record);
|
|
220
|
+
if (encoded !== undefined) {
|
|
221
|
+
payloadBytes = ByteSize.sum(payloadBytes, ByteSize.bytes(CanonicalBase64.decodedLength(encoded)));
|
|
222
|
+
if (ByteSize.isGreaterThan(payloadBytes, budget.payloadBytes)) {
|
|
223
|
+
return yield* new ImageError({ code: "LimitExceeded", field: budget.payloadBytesField });
|
|
193
224
|
}
|
|
194
|
-
if (role === "base")
|
|
195
|
-
basePayloadBytes = next;
|
|
196
|
-
else
|
|
197
|
-
payloadBytes = next;
|
|
198
225
|
}
|
|
199
|
-
const payload = encodedPayload === undefined ? undefined : Image.bytes(encodedPayload);
|
|
200
226
|
const object = {
|
|
201
|
-
kind: record.
|
|
227
|
+
kind: record._tag,
|
|
202
228
|
metadata: record.metadata,
|
|
203
229
|
paths,
|
|
204
230
|
pathIdentity: paths.map(key).join("/"),
|
|
205
|
-
payload
|
|
231
|
+
payload: encoded === undefined ? undefined : yield* CanonicalBase64.decode(encoded)
|
|
206
232
|
};
|
|
207
233
|
objects.push(object);
|
|
208
234
|
for (const path of paths)
|
|
209
235
|
byPath.set(key(path), { object, path });
|
|
210
236
|
}
|
|
211
|
-
objects.sort((a, b) =>
|
|
237
|
+
objects.sort((a, b) => bytesOrder(a.paths[0], b.paths[0]));
|
|
212
238
|
return { objects, byPath };
|
|
213
239
|
});
|
|
240
|
+
const U64_BYTES = 8;
|
|
241
|
+
const EMPTY = new Uint8Array();
|
|
242
|
+
const IDENTITY_PREFIX = encoder.encode(`${ALGORITHM}\0`);
|
|
243
|
+
const KIND_BYTE = { directory: 0, file: 1, symlink: 2 };
|
|
214
244
|
const u64 = (n) => {
|
|
215
|
-
const out = new Uint8Array(
|
|
245
|
+
const out = new Uint8Array(U64_BYTES);
|
|
216
246
|
new DataView(out.buffer).setBigUint64(0, BigInt(n), false);
|
|
217
247
|
return out;
|
|
218
248
|
};
|
|
249
|
+
const framedLength = (bytes) => U64_BYTES + bytes.length;
|
|
250
|
+
const timestampBytes = (metadata) => [metadata.atimeNs, metadata.mtimeNs, metadata.ctimeNs, metadata.birthtimeNs].map((value) => encoder.encode(String(value)));
|
|
219
251
|
// This is the versioned semantic identity encoding, not a generic byte builder.
|
|
220
252
|
// Field framing, ordering, and the domain prefix are part of the persisted delta contract.
|
|
221
|
-
const identityBytes =
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
output.set(bytes, length);
|
|
241
|
-
length = next;
|
|
242
|
-
return Effect.void;
|
|
253
|
+
const identityBytes = (view, limits) => {
|
|
254
|
+
const timestamps = view.objects.map((object) => timestampBytes(object.metadata));
|
|
255
|
+
let total = ByteSize.bytes(IDENTITY_PREFIX.length + U64_BYTES);
|
|
256
|
+
for (const [index, object] of view.objects.entries()) {
|
|
257
|
+
const objectBytes = U64_BYTES +
|
|
258
|
+
object.paths.reduce((sum, path) => sum + framedLength(path), 0) +
|
|
259
|
+
1 + 3 * U64_BYTES +
|
|
260
|
+
timestamps[index].reduce((sum, stamp) => sum + framedLength(stamp), 0) +
|
|
261
|
+
framedLength(object.payload ?? EMPTY);
|
|
262
|
+
total = ByteSize.sum(total, ByteSize.bytes(objectBytes));
|
|
263
|
+
}
|
|
264
|
+
if (ByteSize.isGreaterThan(total, limits.maxIdentityBytes)) {
|
|
265
|
+
return Result.fail(new ImageError({ code: "LimitExceeded", field: "identityBytes" }));
|
|
266
|
+
}
|
|
267
|
+
const output = new Uint8Array(Number(ByteSize.toBigInt(total)));
|
|
268
|
+
let offset = 0;
|
|
269
|
+
const put = (bytes) => {
|
|
270
|
+
output.set(bytes, offset);
|
|
271
|
+
offset += bytes.length;
|
|
243
272
|
};
|
|
244
|
-
const frame =
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
for (const object of view.objects) {
|
|
251
|
-
|
|
273
|
+
const frame = (bytes) => {
|
|
274
|
+
put(u64(bytes.length));
|
|
275
|
+
put(bytes);
|
|
276
|
+
};
|
|
277
|
+
put(IDENTITY_PREFIX);
|
|
278
|
+
put(u64(view.objects.length));
|
|
279
|
+
for (const [index, object] of view.objects.entries()) {
|
|
280
|
+
put(u64(object.paths.length));
|
|
252
281
|
for (const path of object.paths)
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
yield* frame(encoder.encode(object.metadata.birthtimeNs));
|
|
262
|
-
yield* frame(object.payload ?? new Uint8Array());
|
|
282
|
+
frame(path);
|
|
283
|
+
put(new Uint8Array([KIND_BYTE[object.kind]]));
|
|
284
|
+
put(u64(object.metadata.uid));
|
|
285
|
+
put(u64(object.metadata.gid));
|
|
286
|
+
put(u64(object.metadata.mode));
|
|
287
|
+
for (const stamp of timestamps[index])
|
|
288
|
+
frame(stamp);
|
|
289
|
+
frame(object.payload ?? EMPTY);
|
|
263
290
|
}
|
|
264
|
-
return
|
|
265
|
-
}
|
|
291
|
+
return Result.succeed(output);
|
|
292
|
+
};
|
|
266
293
|
const digest = Effect.fnUntraced(function* (view, limits) {
|
|
267
294
|
const crypto = yield* Crypto.Crypto;
|
|
268
|
-
return yield* crypto.digest("SHA-256", yield* identityBytes(view, limits));
|
|
295
|
+
return yield* crypto.digest("SHA-256", yield* Effect.fromResult(identityBytes(view, limits)));
|
|
269
296
|
});
|
|
270
297
|
const samePaths = (a, b) => a.pathIdentity === b.pathIdentity;
|
|
271
298
|
const differences = (before, after) => differenceOrder.filter((field) => {
|
|
@@ -284,264 +311,265 @@ const differences = (before, after) => differenceOrder.filter((field) => {
|
|
|
284
311
|
const compare = (base, target) => {
|
|
285
312
|
const changes = [];
|
|
286
313
|
const add = (change, path) => changes.push({ change, path });
|
|
287
|
-
|
|
288
|
-
const cachedDifferences = (before
|
|
289
|
-
let byAfter = differencesByPair.get(before);
|
|
290
|
-
if (byAfter === undefined) {
|
|
291
|
-
byAfter = new WeakMap();
|
|
292
|
-
differencesByPair.set(before, byAfter);
|
|
293
|
-
}
|
|
294
|
-
const cached = byAfter.get(after);
|
|
295
|
-
if (cached !== undefined)
|
|
296
|
-
return cached;
|
|
297
|
-
const value = differences(before, after);
|
|
298
|
-
byAfter.set(after, value);
|
|
299
|
-
return value;
|
|
300
|
-
};
|
|
314
|
+
// Memoized per object pair so hard-link groups compare each pair once.
|
|
315
|
+
const cachedDifferences = Fn.memoize((before) => Fn.memoize((after) => differences(before, after)));
|
|
301
316
|
for (const [pathKey, before] of base.byPath) {
|
|
302
317
|
const after = target.byPath.get(pathKey);
|
|
303
318
|
if (after === undefined) {
|
|
304
|
-
add({
|
|
319
|
+
add(RemovedChange.make({ path: CanonicalBase64.encode(before.path), kind: before.object.kind }), before.path);
|
|
305
320
|
}
|
|
306
321
|
else {
|
|
307
|
-
const diff = cachedDifferences(before.object
|
|
322
|
+
const diff = cachedDifferences(before.object)(after.object);
|
|
308
323
|
if (diff.length > 0) {
|
|
309
|
-
add({
|
|
310
|
-
|
|
311
|
-
path: Image.base64(before.path),
|
|
324
|
+
add(UpdatedChange.make({
|
|
325
|
+
path: CanonicalBase64.encode(before.path),
|
|
312
326
|
beforeKind: before.object.kind,
|
|
313
327
|
afterKind: after.object.kind,
|
|
314
328
|
differences: diff
|
|
315
|
-
}, before.path);
|
|
329
|
+
}), before.path);
|
|
316
330
|
}
|
|
317
331
|
}
|
|
318
332
|
}
|
|
319
333
|
for (const [pathKey, after] of target.byPath) {
|
|
320
334
|
if (!base.byPath.has(pathKey)) {
|
|
321
|
-
add({
|
|
335
|
+
add(AddedChange.make({ path: CanonicalBase64.encode(after.path), kind: after.object.kind }), after.path);
|
|
322
336
|
}
|
|
323
337
|
}
|
|
324
|
-
changes.sort((a, b) =>
|
|
338
|
+
changes.sort((a, b) => bytesOrder(a.path, b.path));
|
|
325
339
|
return changes.map(({ change }) => change);
|
|
326
340
|
};
|
|
327
341
|
const getDocument = (delta) => Effect.suspend(() => {
|
|
328
|
-
const value =
|
|
342
|
+
const value = SnapshotDeltaModel.value(delta);
|
|
329
343
|
return value !== undefined && Schema.is(Document)(value)
|
|
330
344
|
? Effect.succeed(value)
|
|
331
|
-
: Effect.fail(
|
|
345
|
+
: Effect.fail(new ImageError({ code: "InvalidStructure", field: "delta" }));
|
|
346
|
+
});
|
|
347
|
+
const byEntryName = (a, b) => bytesOrder(a.name, b.name);
|
|
348
|
+
// Every recorded path must be the root directory or the child of a recorded directory.
|
|
349
|
+
// Together with `validPath` and the per-record path rules in `validate`, this subsumes the
|
|
350
|
+
// structural checks `Image.capture` would otherwise perform on the applied image.
|
|
351
|
+
const linkTree = (paths) => {
|
|
352
|
+
const rootKey = key(ROOT_PATH);
|
|
353
|
+
const root = paths.get(rootKey);
|
|
354
|
+
if (root?.kind !== "directory")
|
|
355
|
+
return Result.fail(new ImageError({ code: "InvalidStructure", field: "root" }));
|
|
356
|
+
const entriesById = new Map();
|
|
357
|
+
for (const entry of paths.values()) {
|
|
358
|
+
if (entry.kind === "directory")
|
|
359
|
+
entriesById.set(entry.id, []);
|
|
360
|
+
}
|
|
361
|
+
for (const [pathKey, child] of paths) {
|
|
362
|
+
if (pathKey === rootKey)
|
|
363
|
+
continue;
|
|
364
|
+
const parent = paths.get(parentKey(child.bytes));
|
|
365
|
+
const entries = parent === undefined ? undefined : entriesById.get(parent.id);
|
|
366
|
+
if (entries === undefined)
|
|
367
|
+
return Result.fail(new ImageError({ code: "InvalidStructure", field: "parent" }));
|
|
368
|
+
entries.push({ name: basename(child.bytes), target: child.id });
|
|
369
|
+
}
|
|
370
|
+
for (const entries of entriesById.values())
|
|
371
|
+
entries.sort(byEntryName);
|
|
372
|
+
return Result.succeed({ root: root.id, entriesById });
|
|
373
|
+
};
|
|
374
|
+
const inheritedPayload = Effect.fnUntraced(function* (base, kind, encodedPath) {
|
|
375
|
+
const source = base.byPath.get(key(yield* CanonicalBase64.decode(encodedPath)))?.object;
|
|
376
|
+
return source?.kind === kind ? source.payload : undefined;
|
|
332
377
|
});
|
|
378
|
+
// Expects a document that already passed `validate`.
|
|
333
379
|
const buildImage = Effect.fnUntraced(function* (document, base, limits) {
|
|
334
380
|
const paths = new Map();
|
|
335
|
-
const records
|
|
336
|
-
const entriesById = new Map();
|
|
337
|
-
let bytes = 0;
|
|
338
|
-
for (let i = 0; i < document.records.length; i++) {
|
|
339
|
-
const record = document.records[i];
|
|
340
|
-
const recordId = String(i);
|
|
381
|
+
for (const [index, record] of document.records.entries()) {
|
|
341
382
|
for (const encodedPath of record.paths) {
|
|
342
|
-
const
|
|
343
|
-
paths.set(key(
|
|
383
|
+
const bytes = yield* CanonicalBase64.decode(encodedPath);
|
|
384
|
+
paths.set(key(bytes), { id: String(index), kind: record.kind, bytes });
|
|
344
385
|
}
|
|
386
|
+
}
|
|
387
|
+
const tree = yield* Effect.fromResult(linkTree(paths));
|
|
388
|
+
const records = [];
|
|
389
|
+
let outputBytes = ByteSize.zero;
|
|
390
|
+
for (const [index, record] of document.records.entries()) {
|
|
391
|
+
const id = String(index);
|
|
345
392
|
if (record.kind === "directory") {
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
393
|
+
records.push(Image.Record.cases.directory.make({
|
|
394
|
+
id,
|
|
395
|
+
metadata: record.metadata,
|
|
396
|
+
entries: (tree.entriesById.get(id) ?? []).map((entry) => ({
|
|
397
|
+
name: CanonicalBase64.encode(entry.name),
|
|
398
|
+
target: entry.target
|
|
399
|
+
}))
|
|
400
|
+
}));
|
|
349
401
|
continue;
|
|
350
402
|
}
|
|
351
|
-
const
|
|
352
|
-
?
|
|
353
|
-
:
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
: inherited?.kind === record.kind
|
|
360
|
-
? inherited.payload?.length
|
|
361
|
-
: undefined);
|
|
362
|
-
const next = payloadLength === undefined ? undefined : safeAdd(bytes, payloadLength);
|
|
363
|
-
if (next !== undefined && exceedsByteLimit(next, limits.maxOutputBytes)) {
|
|
364
|
-
return yield* failure("LimitExceeded", "outputBytes");
|
|
403
|
+
const payload = Predicate.isTagged("Inline")(record.payload)
|
|
404
|
+
? yield* CanonicalBase64.decode(record.payload.bytes)
|
|
405
|
+
: yield* inheritedPayload(base, record.kind, record.payload.path);
|
|
406
|
+
if (payload === undefined)
|
|
407
|
+
return yield* new ImageError({ code: "InvalidStructure", field: "baseReference" });
|
|
408
|
+
outputBytes = ByteSize.sum(outputBytes, ByteSize.bytes(payload.length));
|
|
409
|
+
if (ByteSize.isGreaterThan(outputBytes, limits.maxOutputBytes)) {
|
|
410
|
+
return yield* new ImageError({ code: "LimitExceeded", field: "outputBytes" });
|
|
365
411
|
}
|
|
366
|
-
const payload = record.payload._tag === "Inline"
|
|
367
|
-
? yield* decode64(record.payload.bytes, "payload")
|
|
368
|
-
: inherited?.kind === record.kind
|
|
369
|
-
? inherited.payload
|
|
370
|
-
: undefined;
|
|
371
|
-
if (payload === undefined && base !== undefined)
|
|
372
|
-
return yield* failure("InvalidStructure", "baseReference");
|
|
373
|
-
const actual = payload ?? new Uint8Array();
|
|
374
|
-
if (next === undefined)
|
|
375
|
-
return yield* failure("LimitExceeded", "outputBytes");
|
|
376
|
-
bytes = next;
|
|
377
412
|
records.push(record.kind === "file"
|
|
378
|
-
? { id
|
|
379
|
-
:
|
|
413
|
+
? Image.Record.cases.file.make({ id, metadata: record.metadata, data: CanonicalBase64.encode(payload) })
|
|
414
|
+
: Image.Record.cases.symlink.make({
|
|
415
|
+
id,
|
|
416
|
+
metadata: record.metadata,
|
|
417
|
+
target: CanonicalBase64.encode(payload)
|
|
418
|
+
}));
|
|
380
419
|
}
|
|
381
|
-
|
|
382
|
-
if (root?.kind !== "directory")
|
|
383
|
-
return yield* failure("InvalidStructure", "root");
|
|
384
|
-
for (const [pathKey, child] of paths) {
|
|
385
|
-
if (pathKey !== key(new Uint8Array([47]))) {
|
|
386
|
-
const parent = paths.get(parentKey(child.bytes));
|
|
387
|
-
const entries = parent === undefined ? undefined : entriesById.get(parent.id);
|
|
388
|
-
if (entries === undefined)
|
|
389
|
-
return yield* failure("InvalidStructure", "parent");
|
|
390
|
-
entries.push({ name: Image.base64(basename(child.bytes)), target: child.id });
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
for (const entries of entriesById.values())
|
|
394
|
-
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
395
|
-
return yield* Image.capture({ format: "effect-vfs", version: 1, root: root.id, records });
|
|
420
|
+
return yield* Image.capture({ format: "effect-vfs", version: 1, root: tree.root, records }, undefined, true);
|
|
396
421
|
});
|
|
397
422
|
const validate = Effect.fnUntraced(function* (document, limits) {
|
|
398
423
|
const deltaRecords = safeAdd(document.records.length, document.changes.length);
|
|
399
424
|
if (deltaRecords === undefined || deltaRecords > limits.maxDeltaRecords) {
|
|
400
|
-
return yield*
|
|
425
|
+
return yield* new ImageError({ code: "LimitExceeded", field: "deltaRecords" });
|
|
426
|
+
}
|
|
427
|
+
if (document.records.length > limits.maxOutputRecords) {
|
|
428
|
+
return yield* new ImageError({ code: "LimitExceeded", field: "outputRecords" });
|
|
401
429
|
}
|
|
402
|
-
if (document.
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
return yield* failure("InvalidEncoding", "digest");
|
|
430
|
+
if (!CanonicalBase64.is(document.base.digest) ||
|
|
431
|
+
CanonicalBase64.decodedLength(document.base.digest) !== SHA256_BYTES) {
|
|
432
|
+
return yield* new ImageError({ code: "InvalidEncoding", field: "digest" });
|
|
406
433
|
}
|
|
407
|
-
let decoded =
|
|
434
|
+
let decoded = ByteSize.bytes(SHA256_BYTES);
|
|
435
|
+
let outputBytes = ByteSize.zero;
|
|
408
436
|
let entries = -1;
|
|
409
437
|
let inherited = 0;
|
|
410
|
-
const
|
|
411
|
-
|
|
438
|
+
const paths = new Map();
|
|
439
|
+
const charge = (encoded) => {
|
|
440
|
+
decoded = ByteSize.sum(decoded, ByteSize.bytes(CanonicalBase64.decodedLength(encoded)));
|
|
441
|
+
return ByteSize.isGreaterThan(decoded, limits.maxDecodedDeltaBytes)
|
|
442
|
+
? Effect.fail(new ImageError({ code: "LimitExceeded", field: "decodedDeltaBytes" }))
|
|
443
|
+
: Effect.void;
|
|
444
|
+
};
|
|
445
|
+
for (const [index, record] of document.records.entries()) {
|
|
412
446
|
if (record.paths.length < 1 || (record.kind === "directory" && record.paths.length !== 1)) {
|
|
413
|
-
return yield*
|
|
447
|
+
return yield* new ImageError({ code: "InvalidStructure", field: "paths" });
|
|
414
448
|
}
|
|
415
449
|
for (const encodedPath of record.paths) {
|
|
416
|
-
if (!CanonicalBase64.
|
|
417
|
-
return yield*
|
|
418
|
-
decoded = safeAdd(decoded, CanonicalBase64.decodedLength(encodedPath)) ?? Number.POSITIVE_INFINITY;
|
|
419
|
-
if (BigInt(decoded) > ByteSize.toBigInt(limits.maxDecodedDeltaBytes)) {
|
|
420
|
-
return yield* failure("LimitExceeded", "decodedDeltaBytes");
|
|
450
|
+
if (!CanonicalBase64.is(encodedPath)) {
|
|
451
|
+
return yield* new ImageError({ code: "InvalidEncoding", field: "path" });
|
|
421
452
|
}
|
|
453
|
+
yield* charge(encodedPath);
|
|
422
454
|
entries++;
|
|
423
455
|
if (entries > limits.maxEntries)
|
|
424
|
-
return yield*
|
|
425
|
-
const
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
456
|
+
return yield* new ImageError({ code: "LimitExceeded", field: "entries" });
|
|
457
|
+
const bytes = yield* CanonicalBase64.decode(encodedPath);
|
|
458
|
+
const pathKey = key(bytes);
|
|
459
|
+
if (!validPath(bytes) || paths.has(pathKey)) {
|
|
460
|
+
return yield* new ImageError({ code: "InvalidStructure", field: "path" });
|
|
461
|
+
}
|
|
462
|
+
paths.set(pathKey, { id: String(index), kind: record.kind, bytes });
|
|
429
463
|
}
|
|
430
|
-
if (record.kind
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
if (BigInt(decoded) > ByteSize.toBigInt(limits.maxDecodedDeltaBytes)) {
|
|
436
|
-
return yield* failure("LimitExceeded", "decodedDeltaBytes");
|
|
437
|
-
}
|
|
464
|
+
if (record.kind === "directory")
|
|
465
|
+
continue;
|
|
466
|
+
if (Predicate.isTagged("Inline")(record.payload)) {
|
|
467
|
+
if (!CanonicalBase64.is(record.payload.bytes)) {
|
|
468
|
+
return yield* new ImageError({ code: "InvalidEncoding", field: "payload" });
|
|
438
469
|
}
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
}
|
|
470
|
+
yield* charge(record.payload.bytes);
|
|
471
|
+
outputBytes = ByteSize.sum(outputBytes, ByteSize.bytes(CanonicalBase64.decodedLength(record.payload.bytes)));
|
|
472
|
+
}
|
|
473
|
+
else {
|
|
474
|
+
if (!CanonicalBase64.is(record.payload.path)) {
|
|
475
|
+
return yield* new ImageError({ code: "InvalidEncoding", field: "basePath" });
|
|
476
|
+
}
|
|
477
|
+
yield* charge(record.payload.path);
|
|
478
|
+
inherited++;
|
|
479
|
+
if (inherited > limits.maxInheritedRecords) {
|
|
480
|
+
return yield* new ImageError({ code: "LimitExceeded", field: "inheritedRecords" });
|
|
481
|
+
}
|
|
482
|
+
if (!validPath(yield* CanonicalBase64.decode(record.payload.path)) || !record.paths.includes(record.payload.path)) {
|
|
483
|
+
return yield* new ImageError({ code: "InvalidStructure", field: "basePath" });
|
|
454
484
|
}
|
|
455
485
|
}
|
|
456
486
|
}
|
|
457
487
|
let previous;
|
|
458
488
|
for (const change of document.changes) {
|
|
459
|
-
if (!CanonicalBase64.
|
|
460
|
-
return yield*
|
|
461
|
-
decoded = safeAdd(decoded, CanonicalBase64.decodedLength(change.path)) ?? Number.POSITIVE_INFINITY;
|
|
462
|
-
if (BigInt(decoded) > ByteSize.toBigInt(limits.maxDecodedDeltaBytes)) {
|
|
463
|
-
return yield* failure("LimitExceeded", "decodedDeltaBytes");
|
|
489
|
+
if (!CanonicalBase64.is(change.path)) {
|
|
490
|
+
return yield* new ImageError({ code: "InvalidEncoding", field: "changePath" });
|
|
464
491
|
}
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
492
|
+
yield* charge(change.path);
|
|
493
|
+
const path = yield* CanonicalBase64.decode(change.path);
|
|
494
|
+
if (!validPath(path) || (previous !== undefined && bytesOrder(previous, path) >= 0)) {
|
|
495
|
+
return yield* new ImageError({ code: "InvalidStructure", field: "changes" });
|
|
468
496
|
}
|
|
469
497
|
previous = path;
|
|
470
498
|
}
|
|
471
|
-
|
|
499
|
+
if (ByteSize.isGreaterThan(outputBytes, limits.maxOutputBytes)) {
|
|
500
|
+
return yield* new ImageError({ code: "LimitExceeded", field: "outputBytes" });
|
|
501
|
+
}
|
|
502
|
+
yield* Effect.fromResult(linkTree(paths));
|
|
503
|
+
// Inline symlink targets are the only applied payloads a base snapshot has not already vetted.
|
|
504
|
+
for (const record of document.records) {
|
|
505
|
+
if (record.kind !== "symlink" || !Predicate.isTagged("Inline")(record.payload))
|
|
506
|
+
continue;
|
|
507
|
+
if (!CanonicalBase64.is(record.payload.bytes)) {
|
|
508
|
+
return yield* new ImageError({ code: "InvalidEncoding", field: "payload" });
|
|
509
|
+
}
|
|
510
|
+
if ((yield* CanonicalBase64.decode(record.payload.bytes)).includes(NUL_BYTE)) {
|
|
511
|
+
return yield* new ImageError({ code: "InvalidStructure", field: "symlink" });
|
|
512
|
+
}
|
|
513
|
+
}
|
|
472
514
|
});
|
|
473
515
|
/** @internal */
|
|
474
|
-
export const diffSnapshots = Effect.
|
|
516
|
+
export const diffSnapshots = Effect.fnUntraced(function* (base, target, limits) {
|
|
475
517
|
const before = yield* normalize(base, limits, "base");
|
|
476
518
|
const after = yield* normalize(target, limits, "target");
|
|
477
519
|
const changes = [...compare(before, after)];
|
|
478
520
|
const deltaRecords = safeAdd(after.objects.length, changes.length);
|
|
479
521
|
if (deltaRecords === undefined || deltaRecords > limits.maxDeltaRecords) {
|
|
480
|
-
return yield*
|
|
522
|
+
return yield* new ImageError({ code: "LimitExceeded", field: "deltaRecords" });
|
|
481
523
|
}
|
|
482
|
-
const
|
|
483
|
-
const samePayload = (source, target) => {
|
|
484
|
-
let byTarget = payloadEquality.get(source);
|
|
485
|
-
if (byTarget === undefined) {
|
|
486
|
-
byTarget = new WeakMap();
|
|
487
|
-
payloadEquality.set(source, byTarget);
|
|
488
|
-
}
|
|
489
|
-
const cached = byTarget.get(target);
|
|
490
|
-
if (cached !== undefined)
|
|
491
|
-
return cached;
|
|
492
|
-
const value = sameBytes(source.payload, target.payload);
|
|
493
|
-
byTarget.set(target, value);
|
|
494
|
-
return value;
|
|
495
|
-
};
|
|
524
|
+
const samePayload = Fn.memoize((source) => Fn.memoize((candidate) => sameBytes(source.payload, candidate.payload)));
|
|
496
525
|
const records = after.objects.map((object) => {
|
|
497
|
-
const paths = object.paths.map(
|
|
526
|
+
const paths = object.paths.map(CanonicalBase64.encode);
|
|
498
527
|
if (object.kind === "directory")
|
|
499
528
|
return { kind: object.kind, paths, metadata: object.metadata };
|
|
500
529
|
const inherited = object.paths.find((path) => {
|
|
501
530
|
const source = before.byPath.get(key(path));
|
|
502
|
-
return source?.object.kind === object.kind && samePayload(source.object
|
|
531
|
+
return source?.object.kind === object.kind && samePayload(source.object)(object);
|
|
503
532
|
});
|
|
504
533
|
return {
|
|
505
534
|
kind: object.kind,
|
|
506
535
|
paths,
|
|
507
536
|
metadata: object.metadata,
|
|
508
537
|
payload: inherited === undefined
|
|
509
|
-
? {
|
|
510
|
-
: {
|
|
538
|
+
? InlinePayload.make({ bytes: CanonicalBase64.encode(object.payload ?? new Uint8Array()) })
|
|
539
|
+
: BasePayload.make({ path: CanonicalBase64.encode(inherited) })
|
|
511
540
|
};
|
|
512
541
|
});
|
|
513
542
|
const document = {
|
|
514
|
-
format:
|
|
543
|
+
format: FORMAT,
|
|
515
544
|
version: 1,
|
|
516
|
-
base: { algorithm:
|
|
545
|
+
base: { algorithm: ALGORITHM, digest: CanonicalBase64.encode(yield* digest(before, limits)) },
|
|
517
546
|
records,
|
|
518
547
|
changes
|
|
519
548
|
};
|
|
520
549
|
yield* validate(document, limits);
|
|
521
|
-
return
|
|
550
|
+
return SnapshotDeltaModel.make(document);
|
|
522
551
|
});
|
|
523
552
|
/** @internal */
|
|
524
553
|
const verify = Effect.fnUntraced(function* (base, document, limits) {
|
|
525
554
|
yield* validate(document, limits);
|
|
526
555
|
const before = yield* normalize(base, limits, "base");
|
|
527
|
-
if (!sameBytes(yield* digest(before, limits),
|
|
556
|
+
if (!sameBytes(yield* digest(before, limits), yield* CanonicalBase64.decode(document.base.digest))) {
|
|
528
557
|
return yield* new SnapshotDeltaError({ code: "BaseMismatch" });
|
|
529
558
|
}
|
|
530
559
|
const target = yield* buildImage(document, before, limits);
|
|
531
560
|
const changes = compare(before, yield* normalize(target, limits, "target"));
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
return yield* failure("InvalidStructure", "changes");
|
|
561
|
+
if (!sameChanges(changes, document.changes)) {
|
|
562
|
+
return yield* new ImageError({ code: "InvalidStructure", field: "changes" });
|
|
563
|
+
}
|
|
536
564
|
return { changes, target };
|
|
537
565
|
});
|
|
538
566
|
/** @internal */
|
|
539
|
-
export const inspectSnapshotDelta = Effect.
|
|
567
|
+
export const inspectSnapshotDelta = Effect.fnUntraced(function* (base, delta, options, limits) {
|
|
540
568
|
const output = [];
|
|
541
569
|
const { changes } = yield* verify(base, yield* getDocument(delta), limits);
|
|
542
570
|
for (const change of changes) {
|
|
543
|
-
const path = makeBytePath(
|
|
544
|
-
if (
|
|
571
|
+
const path = makeBytePath(yield* CanonicalBase64.decode(change.path));
|
|
572
|
+
if (!Predicate.isTagged("Updated")(change))
|
|
545
573
|
output.push(Object.freeze({ ...change, path }));
|
|
546
574
|
else {
|
|
547
575
|
const fields = change.differences.filter((field) => options.includeTimestamps === true || !timestampFields.has(field));
|
|
@@ -555,35 +583,40 @@ export const inspectSnapshotDelta = Effect.fn("VirtualFileSystem.inspectSnapshot
|
|
|
555
583
|
export const encodeSnapshotDelta = Effect.fnUntraced(function* (delta, limits) {
|
|
556
584
|
const document = yield* getDocument(delta);
|
|
557
585
|
yield* validate(document, limits);
|
|
558
|
-
const text = yield* Schema.encodeEffect(
|
|
586
|
+
const text = yield* Schema.encodeEffect(JsonDocument)(document).pipe(Effect.mapError(() => new ImageError({ code: "InvalidStructure" })));
|
|
559
587
|
const bytes = encoder.encode(text);
|
|
560
|
-
if (
|
|
561
|
-
return yield*
|
|
588
|
+
if (exceeds(bytes.length, limits.maxEncodedBytes)) {
|
|
589
|
+
return yield* new ImageError({ code: "LimitExceeded", field: "encodedBytes" });
|
|
562
590
|
}
|
|
563
591
|
return bytes;
|
|
564
592
|
});
|
|
565
593
|
/** @internal */
|
|
566
594
|
export const decodeSnapshotDelta = Effect.fnUntraced(function* (input, limits) {
|
|
567
|
-
if (!(input instanceof Uint8Array) || !(input.buffer instanceof ArrayBuffer))
|
|
568
|
-
return yield*
|
|
569
|
-
|
|
570
|
-
|
|
595
|
+
if (!(input instanceof Uint8Array) || !(input.buffer instanceof ArrayBuffer)) {
|
|
596
|
+
return yield* new ImageError({ code: "InvalidEncoding" });
|
|
597
|
+
}
|
|
598
|
+
if (exceeds(input.byteLength, limits.maxEncodedBytes)) {
|
|
599
|
+
return yield* new ImageError({ code: "LimitExceeded", field: "encodedBytes" });
|
|
571
600
|
}
|
|
572
601
|
const text = yield* Effect.try({
|
|
573
602
|
try: () => new TextDecoder("utf-8", { fatal: true }).decode(new Uint8Array(input)),
|
|
574
|
-
catch: () =>
|
|
603
|
+
catch: () => new ImageError({ code: "InvalidEncoding" })
|
|
575
604
|
});
|
|
576
|
-
const value = yield* Schema.decodeEffect(Json)(text).pipe(Effect.mapError(() =>
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
return yield*
|
|
605
|
+
const value = yield* Schema.decodeEffect(Json)(text).pipe(Effect.mapError(() => new ImageError({ code: "InvalidEncoding" })));
|
|
606
|
+
const version = Schema.decodeUnknownResult(VersionProbe)(value);
|
|
607
|
+
if (Result.isSuccess(version) && version.success.version !== 1) {
|
|
608
|
+
return yield* new ImageError({ code: "UnsupportedVersion" });
|
|
609
|
+
}
|
|
610
|
+
const wire = Schema.decodeUnknownResult(WireDocument, { onExcessProperty: "error" })(value);
|
|
611
|
+
if (Result.isFailure(wire))
|
|
612
|
+
return yield* new ImageError({ code: "InvalidStructure" });
|
|
613
|
+
yield* validate(wire.success, limits);
|
|
580
614
|
const parsed = Schema.decodeUnknownResult(Document, { onExcessProperty: "error" })(value);
|
|
581
615
|
if (Result.isFailure(parsed))
|
|
582
|
-
return yield*
|
|
583
|
-
|
|
584
|
-
return makeSnapshotDelta(parsed.success);
|
|
616
|
+
return yield* new ImageError({ code: "InvalidEncoding" });
|
|
617
|
+
return SnapshotDeltaModel.make(parsed.success);
|
|
585
618
|
});
|
|
586
619
|
/** @internal */
|
|
587
|
-
export const applySnapshotDelta = Effect.
|
|
620
|
+
export const applySnapshotDelta = Effect.fnUntraced(function* (base, delta, limits) {
|
|
588
621
|
return (yield* verify(base, yield* getDocument(delta), limits)).target;
|
|
589
622
|
});
|