@atcute/repo 0.1.0
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/LICENSE +14 -0
- package/README.md +58 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/reader.d.ts +3 -0
- package/dist/reader.d.ts.map +1 -0
- package/dist/reader.js +67 -0
- package/dist/reader.js.map +1 -0
- package/dist/streamed-reader.d.ts +25 -0
- package/dist/streamed-reader.d.ts.map +1 -0
- package/dist/streamed-reader.js +143 -0
- package/dist/streamed-reader.js.map +1 -0
- package/dist/types.d.ts +37 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +54 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/queue.d.ts +42 -0
- package/dist/utils/queue.d.ts.map +1 -0
- package/dist/utils/queue.js +121 -0
- package/dist/utils/queue.js.map +1 -0
- package/dist/utils.d.ts +4 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +6 -0
- package/dist/utils.js.map +1 -0
- package/dist/verify.d.ts +17 -0
- package/dist/verify.d.ts.map +1 -0
- package/dist/verify.js +166 -0
- package/dist/verify.js.map +1 -0
- package/lib/index.ts +5 -0
- package/lib/reader.ts +98 -0
- package/lib/streamed-reader.ts +208 -0
- package/lib/types.ts +64 -0
- package/lib/utils/queue.ts +148 -0
- package/lib/utils.ts +7 -0
- package/lib/verify.ts +244 -0
- package/package.json +44 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
interface Node<T> {
|
|
2
|
+
value: T;
|
|
3
|
+
next: Node<T> | undefined;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const createNode = <T>(value: T, next: Node<T> | undefined): Node<T> => {
|
|
7
|
+
return { value, next };
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/** a queue data structure (fifo) */
|
|
11
|
+
class Queue<T> implements Iterable<T> {
|
|
12
|
+
#head: Node<T> | undefined;
|
|
13
|
+
#tail: Node<T> | undefined;
|
|
14
|
+
#size: number = 0;
|
|
15
|
+
|
|
16
|
+
/** size of the queue */
|
|
17
|
+
get size(): number {
|
|
18
|
+
return this.#size;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* clear the queue
|
|
23
|
+
*/
|
|
24
|
+
clear(): void {
|
|
25
|
+
this.#head = undefined;
|
|
26
|
+
this.#tail = undefined;
|
|
27
|
+
this.#size = 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* adds a value to the end of the queue
|
|
32
|
+
* @param value value to add
|
|
33
|
+
* @returns the queue instance
|
|
34
|
+
*/
|
|
35
|
+
enqueue(value: T): this {
|
|
36
|
+
const tail = this.#tail;
|
|
37
|
+
const node = createNode(value, undefined);
|
|
38
|
+
|
|
39
|
+
if (tail !== undefined) {
|
|
40
|
+
tail.next = node;
|
|
41
|
+
} else {
|
|
42
|
+
this.#head = node;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
this.#tail = node;
|
|
46
|
+
this.#size++;
|
|
47
|
+
return this;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* adds a value to the front of the queue
|
|
52
|
+
* @param value value to add
|
|
53
|
+
* @returns the queue instance
|
|
54
|
+
*/
|
|
55
|
+
enqueueFront(value: T): this {
|
|
56
|
+
const head = this.#head;
|
|
57
|
+
const node = createNode(value, head);
|
|
58
|
+
|
|
59
|
+
if (head === undefined) {
|
|
60
|
+
this.#tail = node;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
this.#head = node;
|
|
64
|
+
this.#size++;
|
|
65
|
+
return this;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* removes the first value from the queue
|
|
70
|
+
* @returns first queued value, or undefined if empty
|
|
71
|
+
*/
|
|
72
|
+
dequeue(): T | undefined {
|
|
73
|
+
const head = this.#head;
|
|
74
|
+
if (!head) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const next = head.next;
|
|
79
|
+
|
|
80
|
+
this.#head = next;
|
|
81
|
+
if (next === undefined) {
|
|
82
|
+
this.#tail = undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
this.#size--;
|
|
86
|
+
return head.value;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* get the first value without removing from queue
|
|
91
|
+
* @returns first queued value, or undefined if empty
|
|
92
|
+
*/
|
|
93
|
+
peek(): T | undefined {
|
|
94
|
+
return this.#head?.value;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* returns an iterator that drains all values from the queue
|
|
99
|
+
*/
|
|
100
|
+
drain(): IterableIterator<T, undefined, undefined> {
|
|
101
|
+
// deno-lint-ignore no-this-alias
|
|
102
|
+
const self = this;
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
next() {
|
|
106
|
+
const head = self.#head;
|
|
107
|
+
if (!head) {
|
|
108
|
+
return { done: true, value: undefined };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const next = head.next;
|
|
112
|
+
|
|
113
|
+
self.#head = next;
|
|
114
|
+
if (next === undefined) {
|
|
115
|
+
self.#tail = undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
self.#size--;
|
|
119
|
+
return { done: false, value: head.value };
|
|
120
|
+
},
|
|
121
|
+
[Symbol.iterator]() {
|
|
122
|
+
return this;
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* iterates over the queue without draining
|
|
129
|
+
*/
|
|
130
|
+
[Symbol.iterator](): Iterator<T, undefined, undefined> {
|
|
131
|
+
let current = this.#head;
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
next() {
|
|
135
|
+
if (current === undefined) {
|
|
136
|
+
return { done: true, value: undefined };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const value = current.value;
|
|
140
|
+
current = current.next;
|
|
141
|
+
|
|
142
|
+
return { done: false, value: value };
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export default Queue;
|
package/lib/utils.ts
ADDED
package/lib/verify.ts
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import * as CAR from '@atcute/car';
|
|
2
|
+
import * as CBOR from '@atcute/cbor';
|
|
3
|
+
import * as CID from '@atcute/cid';
|
|
4
|
+
import type { PublicKey } from '@atcute/crypto';
|
|
5
|
+
import type { AtprotoDid } from '@atcute/lexicons/syntax';
|
|
6
|
+
import { isNodeData, type NodeData } from '@atcute/mst';
|
|
7
|
+
import { decodeUtf8From, encodeUtf8, toSha256 } from '@atcute/uint8array';
|
|
8
|
+
|
|
9
|
+
import { isCommit, type Commit } from './types.js';
|
|
10
|
+
|
|
11
|
+
type BlockMap = Map<string, Uint8Array>;
|
|
12
|
+
|
|
13
|
+
export interface VerifiedRecord {
|
|
14
|
+
/** CID of the record */
|
|
15
|
+
cid: string;
|
|
16
|
+
/** Record data */
|
|
17
|
+
record: unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface VerifyRecordOptions {
|
|
21
|
+
did?: AtprotoDid;
|
|
22
|
+
collection: string;
|
|
23
|
+
rkey: string;
|
|
24
|
+
publicKey?: PublicKey;
|
|
25
|
+
carBytes: Uint8Array;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const verifyRecord = async ({
|
|
29
|
+
did,
|
|
30
|
+
collection,
|
|
31
|
+
rkey,
|
|
32
|
+
publicKey,
|
|
33
|
+
carBytes,
|
|
34
|
+
}: VerifyRecordOptions): Promise<VerifiedRecord> => {
|
|
35
|
+
// read the car
|
|
36
|
+
let blockmap: BlockMap;
|
|
37
|
+
let commit: Commit;
|
|
38
|
+
{
|
|
39
|
+
const reader = CAR.fromUint8Array(carBytes);
|
|
40
|
+
if (reader.header.data.roots.length !== 1) {
|
|
41
|
+
throw new Error(`car must have exactly one root`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
blockmap = new Map();
|
|
45
|
+
for (const entry of reader) {
|
|
46
|
+
const cidString = CID.toString(entry.cid);
|
|
47
|
+
|
|
48
|
+
// Verify that `bytes` matches its associated CID
|
|
49
|
+
const expectedCid = CID.toString(await CID.create(entry.cid.codec as 85 | 113, entry.bytes));
|
|
50
|
+
if (cidString !== expectedCid) {
|
|
51
|
+
throw new Error(`cid does not match bytes`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
blockmap.set(cidString, entry.bytes);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (blockmap.size === 0) {
|
|
58
|
+
throw new Error(`car must have at least one block`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
commit = readBlock(blockmap, reader.header.data.roots[0].$link, isCommit);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// verify did in commit matches the did
|
|
65
|
+
if (did !== undefined && commit.did !== did) {
|
|
66
|
+
throw new Error(`did in commit does not match expected did`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// verify signature contained in commit is valid (if publicKey provided)
|
|
70
|
+
if (publicKey) {
|
|
71
|
+
const { sig, ...unsigned } = commit;
|
|
72
|
+
|
|
73
|
+
const data = CBOR.encode(unsigned);
|
|
74
|
+
const valid = await publicKey.verify(
|
|
75
|
+
CBOR.fromBytes(sig) as Uint8Array<ArrayBuffer>,
|
|
76
|
+
data as Uint8Array<ArrayBuffer>,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
if (!valid) {
|
|
80
|
+
throw new Error(`signature verification failed`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// find and verify the record in the commit
|
|
85
|
+
const targetKey = `${collection}/${rkey}`;
|
|
86
|
+
const { found } = await dfs(blockmap, commit.data.$link, targetKey);
|
|
87
|
+
if (!found) {
|
|
88
|
+
throw new Error(`could not find record in car`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
cid: found.cid,
|
|
93
|
+
record: found.record,
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const readBlock = <T>(blockmap: BlockMap, cid: string, validate: (value: unknown) => value is T): T => {
|
|
98
|
+
const bytes = blockmap.get(cid);
|
|
99
|
+
if (!bytes) {
|
|
100
|
+
throw new Error(`cid not found in blockmap; cid=${cid}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const decoded = CBOR.decode(bytes);
|
|
104
|
+
if (!validate(decoded)) {
|
|
105
|
+
throw new Error(`validation failed for cid=${cid}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return decoded;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
interface DfsResult {
|
|
112
|
+
found: false | { cid: string; record: unknown };
|
|
113
|
+
min?: string;
|
|
114
|
+
max?: string;
|
|
115
|
+
depth?: number;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const dfs = async (
|
|
119
|
+
blockmap: BlockMap,
|
|
120
|
+
from: string | undefined,
|
|
121
|
+
targetKey: string,
|
|
122
|
+
visited = new Set<string>(),
|
|
123
|
+
): Promise<DfsResult> => {
|
|
124
|
+
// If there's no starting point, return empty state
|
|
125
|
+
if (from == null) {
|
|
126
|
+
return { found: false };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Check for cycles
|
|
130
|
+
{
|
|
131
|
+
if (visited.has(from)) {
|
|
132
|
+
throw new Error(`cycle detected; cid=${from}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
visited.add(from);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Get the block data
|
|
139
|
+
let node: NodeData;
|
|
140
|
+
{
|
|
141
|
+
const bytes = blockmap.get(from);
|
|
142
|
+
if (!bytes) {
|
|
143
|
+
return { found: false };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const decoded = CBOR.decode(bytes);
|
|
147
|
+
if (!isNodeData(decoded)) {
|
|
148
|
+
throw new Error(`invalid mst node; cid=${from}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
node = decoded;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Recursively process the left child
|
|
155
|
+
const left = await dfs(blockmap, node.l?.$link, targetKey, visited);
|
|
156
|
+
|
|
157
|
+
let key = '';
|
|
158
|
+
let found = left.found;
|
|
159
|
+
let depth: number | undefined;
|
|
160
|
+
let firstKey: string | undefined;
|
|
161
|
+
let lastKey: string | undefined;
|
|
162
|
+
|
|
163
|
+
// Process all entries in this node
|
|
164
|
+
for (const entry of node.e) {
|
|
165
|
+
// Construct the key by truncating and appending
|
|
166
|
+
key = key.substring(0, entry.p) + decodeUtf8From(CBOR.fromBytes(entry.k));
|
|
167
|
+
|
|
168
|
+
// Check if this is our target key
|
|
169
|
+
if (key === targetKey) {
|
|
170
|
+
const recordBytes = blockmap.get(entry.v.$link);
|
|
171
|
+
if (recordBytes) {
|
|
172
|
+
const record = CBOR.decode(recordBytes);
|
|
173
|
+
found = { cid: entry.v.$link, record };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Calculate depth based on leading zeros in the hash
|
|
178
|
+
const keyDigest = await toSha256(encodeUtf8(key));
|
|
179
|
+
let zeroCount = 0;
|
|
180
|
+
|
|
181
|
+
outerLoop: for (const byte of keyDigest) {
|
|
182
|
+
for (let bit = 7; bit >= 0; bit--) {
|
|
183
|
+
if (((byte >> bit) & 1) !== 0) {
|
|
184
|
+
break outerLoop;
|
|
185
|
+
}
|
|
186
|
+
zeroCount++;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const thisDepth = Math.floor(zeroCount / 2);
|
|
191
|
+
|
|
192
|
+
// Ensure consistent depth
|
|
193
|
+
if (depth === undefined) {
|
|
194
|
+
depth = thisDepth;
|
|
195
|
+
} else if (depth !== thisDepth) {
|
|
196
|
+
throw new Error(`node has entries with different depths; cid=${from}`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Track first and last keys
|
|
200
|
+
if (lastKey === undefined) {
|
|
201
|
+
firstKey = key;
|
|
202
|
+
lastKey = key;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Check key ordering
|
|
206
|
+
if (lastKey > key) {
|
|
207
|
+
throw new Error(`entries are out of order; cid=${from}`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Process right child
|
|
211
|
+
const right = await dfs(blockmap, entry.t?.$link, targetKey, visited);
|
|
212
|
+
|
|
213
|
+
// Check ordering with right subtree
|
|
214
|
+
if (right.min && right.min < lastKey) {
|
|
215
|
+
throw new Error(`entries are out of order; cid=${from}`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
found = found || right.found;
|
|
219
|
+
|
|
220
|
+
// Check depth ordering
|
|
221
|
+
if (left.depth !== undefined && left.depth >= thisDepth) {
|
|
222
|
+
throw new Error(`depths are out of order; cid=${from}`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (right.depth !== undefined && right.depth >= thisDepth) {
|
|
226
|
+
throw new Error(`depths are out of order; cid=${from}`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Update last key based on right subtree
|
|
230
|
+
lastKey = right.max ?? key;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Check ordering with left subtree
|
|
234
|
+
if (left.max && firstKey && left.max > firstKey) {
|
|
235
|
+
throw new Error(`entries are out of order; cid=${from}`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
found,
|
|
240
|
+
min: firstKey,
|
|
241
|
+
max: lastKey,
|
|
242
|
+
depth,
|
|
243
|
+
};
|
|
244
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"type": "module",
|
|
3
|
+
"name": "@atcute/repo",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "AT Protocol repository decoder for AT Protocol.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"atproto",
|
|
8
|
+
"repo"
|
|
9
|
+
],
|
|
10
|
+
"license": "0BSD",
|
|
11
|
+
"repository": {
|
|
12
|
+
"url": "https://github.com/mary-ext/atcute",
|
|
13
|
+
"directory": "packages/utilities/repo"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist/",
|
|
17
|
+
"lib/",
|
|
18
|
+
"!lib/**/*.bench.ts",
|
|
19
|
+
"!lib/**/*.test.ts"
|
|
20
|
+
],
|
|
21
|
+
"exports": {
|
|
22
|
+
".": "./dist/index.js"
|
|
23
|
+
},
|
|
24
|
+
"sideEffects": false,
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@vitest/coverage-v8": "^3.2.4",
|
|
27
|
+
"vitest": "^3.2.4",
|
|
28
|
+
"@atcute/multibase": "^1.1.6"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@atcute/car": "^5.0.0",
|
|
32
|
+
"@atcute/cbor": "^2.2.7",
|
|
33
|
+
"@atcute/cid": "^2.2.6",
|
|
34
|
+
"@atcute/crypto": "^2.2.5",
|
|
35
|
+
"@atcute/lexicons": "^1.2.2",
|
|
36
|
+
"@atcute/mst": "^0.1.0",
|
|
37
|
+
"@atcute/uint8array": "^1.0.5"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "tsc --project tsconfig.build.json",
|
|
41
|
+
"test": "vitest run --coverage",
|
|
42
|
+
"prepublish": "rm -rf dist; pnpm run build"
|
|
43
|
+
}
|
|
44
|
+
}
|