@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.
@@ -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
@@ -0,0 +1,7 @@
1
+ export const assert: {
2
+ (condition: boolean, message: string): asserts condition;
3
+ } = (condition, message) => {
4
+ if (!condition) {
5
+ throw new Error(message);
6
+ }
7
+ };
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
+ }