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