Skip to content

Commit 9b74edb

Browse files
committed
adds incremental verification to CAR files.
1 parent c48b835 commit 9b74edb

12 files changed

+1004
-21
lines changed

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
node_modules/
1+
node_modules/

.npmignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
node_modules/
22
test/
33
.*/
4-
*.config.*js
4+
*.config.*js

package-lock.json

Lines changed: 761 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
"@ipld/dag-json": "^8.0.11",
2727
"@ipld/dag-pb": "^2.1.18",
2828
"@multiformats/blake2": "^1.0.11",
29+
"browser-readablestream-to-it": "^2.0.4",
30+
"ipfs-unixfs-exporter": "^13.1.7",
2931
"multiformats": "^9.9.0"
3032
},
3133
"devDependencies": {
@@ -42,5 +44,8 @@
4244
"ecmaVersion": "latest",
4345
"sourceType": "module"
4446
}
47+
},
48+
"imports": {
49+
"#src/*": "./src/*"
4550
}
4651
}

src/utils/car-block-getter.js

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { CarBlockIterator } from '@ipld/car/iterator'
2+
import toIterable from 'browser-readablestream-to-it'
3+
4+
import { verifyBlock } from './car.js'
5+
import { promiseTimeout } from './timers.js'
6+
import { TimeoutError, VerificationError } from './errors.js'
7+
8+
// Assumptions
9+
// * client and server are both using DFS traversal.
10+
// * Server is sending CARs with duplicate blocks.
11+
export class CarBlockGetter {
12+
constructor (carItr, opts = {}) {
13+
this.carItr = carItr
14+
this.getBlockTimeout = opts.getBlockTimeout ?? 1_000 * 10
15+
}
16+
17+
static async fromStream (carStream) {
18+
const iterable = await CarBlockIterator.fromIterable(
19+
asAsyncIterable(carStream)
20+
)
21+
const carItr = iterable[Symbol.asyncIterator]()
22+
return new CarBlockGetter(carItr)
23+
}
24+
25+
async get (cid, options) {
26+
const { value, done } = await promiseTimeout(
27+
this.carItr.next(),
28+
this.getBlockTimeout,
29+
new TimeoutError(`get block ${cid} timed out`)
30+
)
31+
32+
if (!value && done) {
33+
throw new VerificationError('CAR file has no more blocks.')
34+
}
35+
36+
const { cid: blockCid, bytes } = value
37+
await verifyBlock(blockCid, bytes)
38+
39+
if (!cid.equals(blockCid)) {
40+
throw new VerificationError(
41+
`received block with cid ${blockCid}, expected ${cid}`
42+
)
43+
}
44+
45+
return bytes
46+
}
47+
}
48+
49+
function asAsyncIterable (readable) {
50+
return Symbol.asyncIterator in readable ? readable : toIterable(readable)
51+
}

src/utils/car.js

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ import * as json from 'multiformats/codecs/json'
88
import { sha256 } from 'multiformats/hashes/sha2'
99
import { from as hasher } from 'multiformats/hashes/hasher'
1010
import { blake2b256 } from '@multiformats/blake2/blake2b'
11+
import { recursive } from 'ipfs-unixfs-exporter'
12+
13+
import { CarBlockGetter } from './car-block-getter.js'
14+
import { VerificationError } from './errors.js'
1115

1216
const { toHex } = bytes
1317

@@ -33,20 +37,49 @@ const hashes = {
3337
export async function validateBody (body) {
3438
const carBlockIterator = await CarBlockIterator.fromIterable(body)
3539
for await (const { cid, bytes } of carBlockIterator) {
36-
if (!codecs[cid.code]) {
37-
throw new Error(`Unexpected codec: 0x${cid.code.toString(16)}`)
38-
}
39-
if (!hashes[cid.multihash.code]) {
40-
throw new Error(`Unexpected multihash code: 0x${cid.multihash.code.toString(16)}`)
41-
}
42-
43-
// Verify step 2: if we hash the bytes, do we get the same digest as reported by the CID?
44-
// Note that this step is sufficient if you just want to safely verify the CAR's reported CIDs
45-
const hash = await hashes[cid.multihash.code].digest(bytes)
46-
if (toHex(hash.digest) !== toHex(cid.multihash.digest)) {
47-
throw new Error('Hash mismatch')
48-
}
40+
await verifyBlock(cid, bytes)
4941
}
5042

5143
return true
5244
}
45+
46+
/**
47+
* Verifies a block
48+
*
49+
* @param {CID} cid
50+
* @param {Uint8Array} bytes
51+
*/
52+
export async function verifyBlock (cid, bytes) {
53+
// Verify step 1: is this a CID we know how to deal with?
54+
if (!codecs[cid.code]) {
55+
throw new VerificationError(`Unexpected codec: 0x${cid.code.toString(16)}`)
56+
}
57+
if (!hashes[cid.multihash.code]) {
58+
throw new VerificationError(`Unexpected multihash code: 0x${cid.multihash.code.toString(16)}`)
59+
}
60+
61+
// Verify step 2: if we hash the bytes, do we get the same digest as
62+
// reported by the CID? Note that this step is sufficient if you just
63+
// want to safely verify the CAR's reported CIDs
64+
const hash = await hashes[cid.multihash.code].digest(bytes)
65+
if (toHex(hash.digest) !== toHex(cid.multihash.digest)) {
66+
throw new VerificationError(
67+
`Mismatch: digest of bytes (${toHex(hash)}) does not match digest in CID (${toHex(cid.multihash.digest)})`)
68+
}
69+
}
70+
71+
/**
72+
* Verifies and extracts the raw content from a CAR stream.
73+
*
74+
* @param {string} cidPath
75+
* @param {ReadableStream|AsyncIterable} carStream
76+
*/
77+
export async function * extractVerifiedContent (cidPath, carStream) {
78+
const getter = await CarBlockGetter.fromStream(carStream)
79+
80+
for await (const child of recursive(cidPath, getter)) {
81+
for await (const chunk of child.content()) {
82+
yield chunk
83+
}
84+
}
85+
}

src/utils/errors.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export class VerificationError extends Error {
2+
constructor (message) {
3+
super(message)
4+
this.name = 'VerificationError'
5+
}
6+
}
7+
8+
export class TimeoutError extends Error {
9+
constructor (message) {
10+
super(message)
11+
this.name = 'TimeoutError'
12+
}
13+
}

src/utils/timers.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,15 @@ export const setTimeoutPromise = async ms => {
33
setTimeout(resolve, ms)
44
})
55
}
6+
7+
export function promiseTimeout (promise, ms, timeoutErr) {
8+
let id
9+
const timeout = new Promise((resolve, reject) => {
10+
id = setTimeout(() => {
11+
const err = new Error('Promise timed out')
12+
reject(timeoutErr || err)
13+
}, ms)
14+
})
15+
16+
return Promise.race([promise, timeout]).finally(() => clearTimeout(id))
17+
}

test/car.js

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import assert from 'node:assert/strict'
2+
import fs from 'node:fs'
3+
import { describe, it } from 'node:test'
4+
5+
import { CarReader, CarWriter } from '@ipld/car'
6+
import { CID } from 'multiformats/cid'
7+
8+
import { extractVerifiedContent } from '#src/utils/car.js'
9+
10+
async function concatChunks (itr) {
11+
const arr = []
12+
for await (const chunk of itr) {
13+
arr.push(chunk)
14+
}
15+
return new Uint8Array(...arr)
16+
}
17+
18+
describe('CAR Verification', () => {
19+
it('should extract content from a valid CAR', async () => {
20+
const cidPath =
21+
'bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4'
22+
const filepath = './fixtures/hello.car'
23+
const carStream = fs.createReadStream(filepath)
24+
25+
const contentItr = await extractVerifiedContent(cidPath, carStream)
26+
const buffer = await concatChunks(contentItr)
27+
const actualContent = String.fromCharCode(...buffer)
28+
const expectedContent = 'hello world\n'
29+
30+
assert.strictEqual(actualContent, expectedContent)
31+
})
32+
33+
it('should verify intermediate path segments', async () => {
34+
const cidPath =
35+
'bafybeigeqgfwhivuuxgmuvcrrwvs4j3yfzgljssvnuqzokm6uby4fpmwsa/subdir/hello.txt'
36+
const filepath = './fixtures/subdir.car'
37+
const carStream = fs.createReadStream(filepath)
38+
39+
const contentItr = await extractVerifiedContent(cidPath, carStream)
40+
const buffer = await concatChunks(contentItr)
41+
const actualContent = String.fromCharCode(...buffer)
42+
const expectedContent = 'hello world\n'
43+
44+
assert.strictEqual(actualContent, expectedContent)
45+
})
46+
47+
it('should error if CAR is missing blocks', async () => {
48+
const cidPath = 'bafybeigeqgfwhivuuxgmuvcrrwvs4j3yfzgljssvnuqzokm6uby4fpmwsa'
49+
const filepath = './fixtures/subdir.car'
50+
const carStream = fs.createReadStream(filepath)
51+
52+
// Create an invalid CAR that only has 1 block but should have 3
53+
const outCid = CID.parse(cidPath)
54+
const { writer, out } = await CarWriter.create([outCid]);
55+
(async () => {
56+
// need wrapping IIFE to avoid node exiting early
57+
const reader = await CarReader.fromIterable(carStream)
58+
await writer.put(await reader.get(cidPath))
59+
await writer.close()
60+
})()
61+
62+
await assert.rejects(
63+
async () => {
64+
for await (const _ of extractVerifiedContent(cidPath, out)) {}
65+
},
66+
{
67+
name: 'VerificationError',
68+
message: 'CAR file has no more blocks.'
69+
}
70+
)
71+
})
72+
73+
it('should error if CAR blocks are in the wrong traversal order', async () => {
74+
const cidPath = 'bafybeigeqgfwhivuuxgmuvcrrwvs4j3yfzgljssvnuqzokm6uby4fpmwsa'
75+
const filepath = './fixtures/subdir.car'
76+
const carStream = fs.createReadStream(filepath)
77+
78+
// Create an invalid CAR that has blocks in the wrong order
79+
const outCid = CID.parse(cidPath)
80+
const { writer, out } = await CarWriter.create([outCid]);
81+
(async () => {
82+
// need wrapping IIFE to avoid node exiting early
83+
const reader = await CarReader.fromIterable(carStream)
84+
85+
const blocks = []
86+
for await (const block of reader.blocks()) {
87+
blocks.push(block)
88+
}
89+
90+
const temp = blocks[0]
91+
blocks[0] = blocks[1]
92+
blocks[1] = temp
93+
94+
for (const block of blocks) {
95+
await writer.put(block)
96+
}
97+
await writer.close()
98+
})()
99+
100+
await assert.rejects(
101+
async () => {
102+
for await (const _ of extractVerifiedContent(cidPath, out)) {
103+
}
104+
},
105+
{
106+
name: 'VerificationError',
107+
message:
108+
'received block with cid bafybeidhkumeonuwkebh2i4fc7o7lguehauradvlk57gzake6ggjsy372a, expected bafybeigeqgfwhivuuxgmuvcrrwvs4j3yfzgljssvnuqzokm6uby4fpmwsa'
109+
}
110+
)
111+
})
112+
})

test/fixtures/hello.car

108 Bytes
Binary file not shown.

test/fixtures/subdir.car

293 Bytes
Binary file not shown.

test/test.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'
22
import { randomUUID } from 'node:crypto'
33
import { describe, it } from 'node:test'
44

5-
import Saturn from '../src/index.js'
5+
import Saturn from '#src/index.js'
66

77
describe('Saturn client', () => {
88
describe('constructor', () => {
@@ -53,4 +53,4 @@ describe('Saturn client', () => {
5353
assert.rejects(client.fetchCID('QmXjYBY478Cno4jzdCcPy4NcJYFrwHZ51xaCP8vUwN9MGm', { downloadTimeout: 0 }))
5454
})
5555
})
56-
})
56+
})

0 commit comments

Comments
 (0)