@openlfcp/shared-objects 0.2.0-beta.1 → 0.2.0-beta.3
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/admission/canonical.d.ts +7 -0
- package/dist/admission/canonical.js +34 -1
- package/dist/admission/framing.d.ts +17 -8
- package/dist/admission/framing.js +23 -12
- package/dist/admission/sequence.js +14 -0
- package/dist/sections/replica.js +1 -1
- package/dist/sections/tree.js +14 -2
- package/package.json +3 -3
|
@@ -30,6 +30,13 @@ export interface ParsedChange {
|
|
|
30
30
|
/** Hex other actors, ascending: actor index i is otherActors[i - 1]. */
|
|
31
31
|
readonly otherActors: readonly string[];
|
|
32
32
|
readonly ops: readonly ParsedOp[];
|
|
33
|
+
/**
|
|
34
|
+
* §14.1: the extra bytes after the columns (which rule 4 leaves free) begin
|
|
35
|
+
* with an Automerge author — an unsigned LEB128 1, a length L, then at least
|
|
36
|
+
* L bytes. automerge 0.12 reads it and asserts the change's sequence number
|
|
37
|
+
* is 1, so a later change carrying one is refused (finding D5).
|
|
38
|
+
*/
|
|
39
|
+
readonly beginsWithAuthor: boolean;
|
|
33
40
|
}
|
|
34
41
|
/**
|
|
35
42
|
* §11.3: checks that `bytes` (one change chunk within the §11.1 limits) is
|
|
@@ -349,6 +349,36 @@ function checkValue(type, bytes) {
|
|
|
349
349
|
if (type === 6)
|
|
350
350
|
utf8(bytes, "a string value");
|
|
351
351
|
}
|
|
352
|
+
/**
|
|
353
|
+
* §14.1: whether the extra bytes after a change's columns begin with an
|
|
354
|
+
* Automerge author (finding D5). Rule 4 leaves these bytes free, but
|
|
355
|
+
* automerge 0.12 reads an author there — an unsigned LEB128 1, a length L,
|
|
356
|
+
* then L bytes — and asserts the change's sequence number is 1. The LEB128 is
|
|
357
|
+
* read as automerge reads it: up to ten bytes, under 2^64, in any encoding
|
|
358
|
+
* (not the shortest-form `Reader` the rest of the walk uses).
|
|
359
|
+
*/
|
|
360
|
+
function beginsWithAuthor(extra) {
|
|
361
|
+
let pos = 0;
|
|
362
|
+
const leb = () => {
|
|
363
|
+
let value = 0n;
|
|
364
|
+
let shift = 0n;
|
|
365
|
+
for (;;) {
|
|
366
|
+
if (pos >= extra.length)
|
|
367
|
+
return null;
|
|
368
|
+
const b = extra[pos++];
|
|
369
|
+
if (shift === 63n && b !== 0 && b !== 1)
|
|
370
|
+
return null;
|
|
371
|
+
value |= BigInt(b & 0x7f) << shift;
|
|
372
|
+
if ((b & 0x80) === 0)
|
|
373
|
+
return value;
|
|
374
|
+
shift += 7n;
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
if (leb() !== 1n)
|
|
378
|
+
return false;
|
|
379
|
+
const length = leb();
|
|
380
|
+
return length !== null && BigInt(extra.length - pos) >= length;
|
|
381
|
+
}
|
|
352
382
|
/**
|
|
353
383
|
* §11.3: checks that `bytes` (one change chunk within the §11.1 limits) is
|
|
354
384
|
* the canonical encoding of its change, and returns the change's operations
|
|
@@ -403,7 +433,9 @@ export function checkCanonicalChange(bytes) {
|
|
|
403
433
|
const data = new Map();
|
|
404
434
|
for (const m of metas)
|
|
405
435
|
data.set(m.spec, r.take(m.length));
|
|
406
|
-
// What follows is the change's extra bytes.
|
|
436
|
+
// What follows is the change's extra bytes (§11.3 rule 4), free except that
|
|
437
|
+
// automerge reads an author from their start (§14.1, finding D5).
|
|
438
|
+
const authored = beginsWithAuthor(bytes.subarray(r.pos));
|
|
407
439
|
const rle = (spec) => {
|
|
408
440
|
const d = data.get(spec);
|
|
409
441
|
return d === undefined ? undefined : column(spec, d);
|
|
@@ -615,6 +647,7 @@ export function checkCanonicalChange(bytes) {
|
|
|
615
647
|
deps: Object.freeze(deps.map(hex)),
|
|
616
648
|
otherActors: Object.freeze(others.map(hex)),
|
|
617
649
|
ops: Object.freeze(ops),
|
|
650
|
+
beginsWithAuthor: authored,
|
|
618
651
|
});
|
|
619
652
|
}
|
|
620
653
|
//# sourceMappingURL=canonical.js.map
|
|
@@ -12,6 +12,12 @@ export interface CheckedChange {
|
|
|
12
12
|
readonly deps: readonly string[];
|
|
13
13
|
/** Hex IDs of the other actors the change refers to (§11.1: each must be known to the document). */
|
|
14
14
|
readonly otherActors: readonly string[];
|
|
15
|
+
/**
|
|
16
|
+
* §14.1: the change's extra bytes begin with an Automerge author, allowed
|
|
17
|
+
* only in an actor's first change (finding D5). See
|
|
18
|
+
* {@link ParsedChange.beginsWithAuthor}.
|
|
19
|
+
*/
|
|
20
|
+
readonly beginsWithAuthor: boolean;
|
|
15
21
|
}
|
|
16
22
|
/**
|
|
17
23
|
* Checks that `bytes` is exactly one valid Automerge change (§11, §12) and
|
|
@@ -23,15 +29,18 @@ export interface CheckedChange {
|
|
|
23
29
|
*/
|
|
24
30
|
export declare function checkChange(bytes: Uint8Array): CheckedChange;
|
|
25
31
|
/**
|
|
26
|
-
* The change hash of bytes checkChange refused, read without decoding
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
32
|
+
* The change hash of bytes checkChange refused, read without decoding them:
|
|
33
|
+
* the SHA-256 of the chunk from its type byte on (the Automerge change hash).
|
|
34
|
+
* §14.1 names a refused change by this hash whenever the bytes are one change
|
|
35
|
+
* chunk of type 1 whose length field covers them exactly and whose checksum
|
|
36
|
+
* matches — the inner header is never read, so a change above a §11.1 limit
|
|
37
|
+
* (named without being expanded) and one whose header holds a length running
|
|
38
|
+
* past its end (finding C3) are both named. Undefined when the bytes are not
|
|
39
|
+
* such a chunk: no magic bytes, another chunk type (a compressed chunk
|
|
40
|
+
* included), a length that does not cover them exactly, or a wrong checksum
|
|
41
|
+
* (whose name would be its intact copy's, finding C1).
|
|
33
42
|
*/
|
|
34
|
-
export declare function refusedChangeHash(bytes: Uint8Array
|
|
43
|
+
export declare function refusedChangeHash(bytes: Uint8Array): string | undefined;
|
|
35
44
|
/** The change as its canonical bytes give it (§11.3), reusing the walk of checkChange. */
|
|
36
45
|
export declare function parsedOf(change: CheckedChange): ParsedChange;
|
|
37
46
|
/** The decoded change, reusing the decode of checkChange when there was one. */
|
|
@@ -3,7 +3,7 @@ import { LfcpError, toHex } from "@openlfcp/core";
|
|
|
3
3
|
import { sha256 } from "@openlfcp/crypto";
|
|
4
4
|
import { ProfileInvalidError } from "../profile-invalid.js";
|
|
5
5
|
import { checkCanonicalChange } from "./canonical.js";
|
|
6
|
-
import { checkChangeExpansion
|
|
6
|
+
import { checkChangeExpansion } from "./limits.js";
|
|
7
7
|
/**
|
|
8
8
|
* Automerge byte checks of the SHARED-OBJECTS-PROFILE-01 payloads (LFCP-031):
|
|
9
9
|
* a Data Unit plaintext carries exactly one Automerge change (§11, §12), a
|
|
@@ -63,23 +63,25 @@ export function checkChange(bytes) {
|
|
|
63
63
|
seq: decoded.seq,
|
|
64
64
|
deps: Object.freeze([...decoded.deps]),
|
|
65
65
|
otherActors: expansion.otherActors,
|
|
66
|
+
beginsWithAuthor: parsed.beginsWithAuthor,
|
|
66
67
|
});
|
|
67
68
|
DECODED.set(checked, decoded);
|
|
68
69
|
PARSED.set(checked, parsed);
|
|
69
70
|
return checked;
|
|
70
71
|
}
|
|
71
72
|
/**
|
|
72
|
-
* The change hash of bytes checkChange refused, read without decoding
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
73
|
+
* The change hash of bytes checkChange refused, read without decoding them:
|
|
74
|
+
* the SHA-256 of the chunk from its type byte on (the Automerge change hash).
|
|
75
|
+
* §14.1 names a refused change by this hash whenever the bytes are one change
|
|
76
|
+
* chunk of type 1 whose length field covers them exactly and whose checksum
|
|
77
|
+
* matches — the inner header is never read, so a change above a §11.1 limit
|
|
78
|
+
* (named without being expanded) and one whose header holds a length running
|
|
79
|
+
* past its end (finding C3) are both named. Undefined when the bytes are not
|
|
80
|
+
* such a chunk: no magic bytes, another chunk type (a compressed chunk
|
|
81
|
+
* included), a length that does not cover them exactly, or a wrong checksum
|
|
82
|
+
* (whose name would be its intact copy's, finding C1).
|
|
79
83
|
*/
|
|
80
|
-
export function refusedChangeHash(bytes
|
|
81
|
-
if (error instanceof MalformedChunkError)
|
|
82
|
-
return undefined;
|
|
84
|
+
export function refusedChangeHash(bytes) {
|
|
83
85
|
if (bytes.length < HEADER || MAGIC.some((b, i) => bytes[i] !== b))
|
|
84
86
|
return undefined;
|
|
85
87
|
if (bytes[8] !== CHUNK_CHANGE)
|
|
@@ -99,7 +101,16 @@ export function refusedChangeHash(bytes, error) {
|
|
|
99
101
|
}
|
|
100
102
|
if (pos + length !== bytes.length)
|
|
101
103
|
return undefined;
|
|
102
|
-
|
|
104
|
+
// §14.1 (baseline.7, finding C1): name the bytes only when their checksum
|
|
105
|
+
// matches. The name is the SHA-256 of the chunk from its type byte on, and
|
|
106
|
+
// the checksum (bytes 4 to 8) is the first four bytes of that same hash; a
|
|
107
|
+
// chunk with a wrong checksum would be named by its intact copy's hash, so
|
|
108
|
+
// it is left unnamed and the intact copy is checked as any change is.
|
|
109
|
+
const hash = sha256(bytes.subarray(8));
|
|
110
|
+
for (let i = 0; i < 4; i++)
|
|
111
|
+
if (bytes[4 + i] !== hash[i])
|
|
112
|
+
return undefined;
|
|
113
|
+
return toHex(hash);
|
|
103
114
|
}
|
|
104
115
|
/** The operations checkChange read from the bytes (§11.3), for the references of §11.4. */
|
|
105
116
|
const PARSED = new WeakMap();
|
|
@@ -69,6 +69,14 @@ export function admitBatch(changes, doc, accept = () => undefined) {
|
|
|
69
69
|
});
|
|
70
70
|
continue;
|
|
71
71
|
}
|
|
72
|
+
else if (c.beginsWithAuthor && c.seq !== 1) {
|
|
73
|
+
refused.push({
|
|
74
|
+
change: c,
|
|
75
|
+
error: new ProfileInvalidError("INVALID_AUTOMERGE_BYTES", `the change's extra bytes begin with an author but its sequence number is ${c.seq}, not 1 (§14.1)`),
|
|
76
|
+
held: false,
|
|
77
|
+
});
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
72
80
|
else {
|
|
73
81
|
const references = referenceRefusal(c, doc);
|
|
74
82
|
if (references !== null) {
|
|
@@ -124,6 +132,12 @@ export function admitChange(change, doc) {
|
|
|
124
132
|
kind: "invalid",
|
|
125
133
|
error: new ProfileInvalidError("INVALID_AUTOMERGE_BYTES", `actor ${change.actor} sequence ${change.seq} skips sequence ${latest + 1} (§14.1)`),
|
|
126
134
|
};
|
|
135
|
+
// §14.1: an author belongs only in an actor's first change (finding D5).
|
|
136
|
+
if (change.beginsWithAuthor && change.seq !== 1)
|
|
137
|
+
return {
|
|
138
|
+
kind: "invalid",
|
|
139
|
+
error: new ProfileInvalidError("INVALID_AUTOMERGE_BYTES", `the change's extra bytes begin with an author but its sequence number is ${change.seq}, not 1 (§14.1)`),
|
|
140
|
+
};
|
|
127
141
|
// §11.4: checked only; the caller adds the change once the engine applied it.
|
|
128
142
|
const references = referenceRefusal(change, doc);
|
|
129
143
|
if (references !== null)
|
package/dist/sections/replica.js
CHANGED
|
@@ -292,7 +292,7 @@ export class SectionReplica {
|
|
|
292
292
|
}
|
|
293
293
|
catch (e) {
|
|
294
294
|
// Never decoded: a change refused by the checks is named from its bytes only.
|
|
295
|
-
refused.push(refusal(index, refusedChangeHash(unit.bytes
|
|
295
|
+
refused.push(refusal(index, refusedChangeHash(unit.bytes), e, false));
|
|
296
296
|
return;
|
|
297
297
|
}
|
|
298
298
|
if (unit.signer !== undefined &&
|
package/dist/sections/tree.js
CHANGED
|
@@ -189,8 +189,9 @@ export function deriveTree(doc, validation = validateSection(doc)) {
|
|
|
189
189
|
* the node or of an ancestor, from the change history. A change deletes
|
|
190
190
|
* when the last lifecycle value it writes to an owner (a Task node's Task,
|
|
191
191
|
* another node itself) is "deleted"; content is a node's creation, an
|
|
192
|
-
* operation on its Text,
|
|
193
|
-
*
|
|
192
|
+
* operation on its Text, a Task's title or status, or a move of the node
|
|
193
|
+
* (a write to its `placement` register — node.move or node.resolve_placement,
|
|
194
|
+
* §8). Two changes are concurrent when neither is in the other's dependencies.
|
|
194
195
|
*/
|
|
195
196
|
function retainedEdits(doc, hidden, parentOf, sectionId) {
|
|
196
197
|
const root = doc;
|
|
@@ -201,8 +202,13 @@ function retainedEdits(doc, hidden, parentOf, sectionId) {
|
|
|
201
202
|
const textOf = new Map();
|
|
202
203
|
const ownerOf = new Map();
|
|
203
204
|
const taskOf = new Map();
|
|
205
|
+
// The object ID of each node's own map, for a move written to its `placement`.
|
|
206
|
+
const nodeMapOf = new Map();
|
|
204
207
|
for (const id of Object.keys(nodes)) {
|
|
205
208
|
const n = nodes[id];
|
|
209
|
+
const nodeMap = A.getObjectId(n);
|
|
210
|
+
if (nodeMap !== null)
|
|
211
|
+
nodeMapOf.set(nodeMap, id);
|
|
206
212
|
const text = A.getObjectId(n, "text");
|
|
207
213
|
if (text !== null)
|
|
208
214
|
textOf.set(text, id);
|
|
@@ -236,6 +242,12 @@ function retainedEdits(doc, hidden, parentOf, sectionId) {
|
|
|
236
242
|
const task = taskOf.get(op.obj);
|
|
237
243
|
if (task !== undefined && hidden.has(task) && (key === "title" || key === "status"))
|
|
238
244
|
touched.add(task);
|
|
245
|
+
// §8, §9: a move writes the node's `placement` register (node.move or
|
|
246
|
+
// node.resolve_placement). A creation also writes it and is already
|
|
247
|
+
// counted above; what this adds is a move of an existing node.
|
|
248
|
+
const moved = nodeMapOf.get(op.obj);
|
|
249
|
+
if (moved !== undefined && key === "placement" && hidden.has(moved))
|
|
250
|
+
touched.add(moved);
|
|
239
251
|
const owner = ownerOf.get(op.obj);
|
|
240
252
|
if (owner !== undefined && key === "lifecycle")
|
|
241
253
|
lastLifecycle.set(owner, value);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openlfcp/shared-objects",
|
|
3
|
-
"version": "0.2.0-beta.
|
|
3
|
+
"version": "0.2.0-beta.3",
|
|
4
4
|
"description": "SHARED-OBJECTS-PROFILE-01 (org.openlfcp.shared-objects.v1) implementation.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"openlfcp",
|
|
@@ -53,8 +53,8 @@
|
|
|
53
53
|
"dependencies": {
|
|
54
54
|
"@automerge/automerge": "3.5.0",
|
|
55
55
|
"fflate": "0.8.3",
|
|
56
|
-
"@openlfcp/core": "^0.2.0-beta.
|
|
57
|
-
"@openlfcp/crypto": "^0.2.0-beta.
|
|
56
|
+
"@openlfcp/core": "^0.2.0-beta.3",
|
|
57
|
+
"@openlfcp/crypto": "^0.2.0-beta.3"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"fast-check": "4.10.2"
|