diff --git a/README.md b/README.md index 0560def..f266019 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ An implementation of the [DAG-PB spec](https://github.com/ipld/specs/blob/master ## Example ```js -import CID from 'multiformats/cid' +import { CID } from 'multiformats/cid' import { sha256 } from 'multiformats/hashes/sha2' import * as dagPB from '@ipld/dag-pb' @@ -47,7 +47,7 @@ The DAG-PB encoding is very strict about the Data Model forms that are passed in Due to this strictness, a `prepare()` function is made available which simplifies construction and allows for more flexible input forms. Prior to encoding objects, call `prepare()` to receive a new object that strictly conforms to the schema. ```js -import CID from 'multiformats/cid' +import { CID } from 'multiformats/cid' import { prepare } from '@ipld/dag-pb' console.log(prepare({ Data: 'some data' })) @@ -66,6 +66,61 @@ Some features of `prepare()`: * `Links` array is always present, even if empty * `Links` array is properly sorted +## `createNode()` & `createLink()` + +These utility exports are available to make transition from the older [ipld-dag-pb](https://github.com/ipld/js-ipld-dag-pb) library which used `DAGNode` and `DAGLink` objects with constructors. `createNode()` mirrors the `new DAGNode()` API while `createLink()` mirrors `new DAGLink()` API. + +* `createNode(data: Uint8Array, links: PBLink[]|void): PBNode`: create a correctly formed `PBNode` object from a `Uint8Array` and an optional array of correctly formed `PBLink` objects. The returned object will be suitable for passing to `encode()` and using `prepare()` on it should result in a noop. +* `createLink(name: string, size: number, cid: CID): PBLink`: create a correctly formed `PBLink` object from a name, size and CID. The returned object will be suitable for attaching to a `PBNode`'s `Links` array, or in an array for the second argument to `createNode()`. + +```js +import { CID, bytes } from 'multiformats' +import * as Block from 'multiformats/block' +import { sha256 as hasher } from 'multiformats/hashes/sha2' +import * as codec from '@ipld/dag-pb' + +const { createLink, createNode } = codec + +async function run () { + const cid1 = CID.parse('QmWDtUQj38YLW8v3q4A6LwPn4vYKEbuKWpgSm6bjKW6Xfe') + const cid2 = CID.parse('bafyreifepiu23okq5zuyvyhsoiazv2icw2van3s7ko6d3ixl5jx2yj2yhu') + + const links = [createLink('link1', 100, cid1), createLink('link2', 200, cid2)] + const value = createNode(Uint8Array.from([0, 1, 2, 3, 4]), links) + console.log(value) + + const block = await Block.encode({ value, codec, hasher }) + console.log(block.cid) + console.log(`Encoded: ${bytes.toHex(block.bytes).replace(/(.{80})/g, '$1\n ')}`) +} + +run().catch((err) => console.error(err)) +``` + +Results in: + +``` +{ + Data: Uint8Array(5) [ 0, 1, 2, 3, 4 ], + Links: [ + { + Hash: CID(QmWDtUQj38YLW8v3q4A6LwPn4vYKEbuKWpgSm6bjKW6Xfe), + Name: 'link1', + Tsize: 100 + }, + { + Hash: CID(bafyreifepiu23okq5zuyvyhsoiazv2icw2van3s7ko6d3ixl5jx2yj2yhu), + Name: 'link2', + Tsize: 200 + } + ] +} +CID(bafybeihsp53wkzsaif76mjv564cawzqyjwianosamlvf6sht2m25ttyxiy) +Encoded: 122d0a2212207521fe19c374a97759226dc5c0c8e674e73950e81b211f7dd3b6b30883a08a511205 + 6c696e6b31186412300a2401711220a47a29adb950ee698ae0f272019ae902b6aa06ee5f53bc3da2 + ebea6fac27583d12056c696e6b3218c8010a050001020304 +``` + ## License Licensed under either of diff --git a/src/index.js b/src/index.js index c7f735e..c3d07ee 100644 --- a/src/index.js +++ b/src/index.js @@ -1,6 +1,7 @@ import { CID } from 'multiformats/cid' import { decodeNode } from './pb-decode.js' import { encodeNode } from './pb-encode.js' +import { prepare, validate, createNode, createLink } from './util.js' /** * @template T @@ -15,201 +16,6 @@ import { encodeNode } from './pb-encode.js' export const name = 'dag-pb' export const code = 0x70 -const pbNodeProperties = ['Data', 'Links'] -const pbLinkProperties = ['Hash', 'Name', 'Tsize'] - -const textEncoder = new TextEncoder() - -/** - * @param {PBLink} a - * @param {PBLink} b - * @returns {number} - */ -function linkComparator (a, b) { - if (a === b) { - return 0 - } - - const abuf = a.Name ? textEncoder.encode(a.Name) : [] - const bbuf = b.Name ? textEncoder.encode(b.Name) : [] - - let x = abuf.length - let y = bbuf.length - - for (let i = 0, len = Math.min(x, y); i < len; ++i) { - if (abuf[i] !== bbuf[i]) { - x = abuf[i] - y = bbuf[i] - break - } - } - - return x < y ? -1 : y < x ? 1 : 0 -} - -/** - * @param {any} node - * @param {string[]} properties - * @returns {boolean} - */ -function hasOnlyProperties (node, properties) { - return !Object.keys(node).some((p) => !properties.includes(p)) -} - -/** - * Converts a CID, or a PBLink-like object to a PBLink - * - * @param {any} link - * @returns {PBLink} - */ -function asLink (link) { - if (typeof link.asCID === 'object') { - const Hash = CID.asCID(link) - if (!Hash) { - throw new TypeError('Invalid DAG-PB form') - } - return { Hash } - } - - if (typeof link !== 'object' || Array.isArray(link)) { - throw new TypeError('Invalid DAG-PB form') - } - - const pbl = {} - - if (link.Hash) { - let cid = CID.asCID(link.Hash) - try { - if (!cid) { - if (typeof link.Hash === 'string') { - cid = CID.parse(link.Hash) - } else if (link.Hash instanceof Uint8Array) { - cid = CID.decode(link.Hash) - } - } - } catch (e) { - throw new TypeError(`Invalid DAG-PB form: ${e.message}`) - } - - if (cid) { - pbl.Hash = cid - } - } - - if (!pbl.Hash) { - throw new TypeError('Invalid DAG-PB form') - } - - if (typeof link.Name === 'string') { - pbl.Name = link.Name - } - - if (typeof link.Tsize === 'number') { - pbl.Tsize = link.Tsize - } - - return pbl -} - -/** - * @param {any} node - * @returns {PBNode} - */ -export function prepare (node) { - if (node instanceof Uint8Array || typeof node === 'string') { - node = { Data: node } - } - - if (typeof node !== 'object' || Array.isArray(node)) { - throw new TypeError('Invalid DAG-PB form') - } - - /** @type {PBNode} */ - const pbn = {} - - if (node.Data) { - if (typeof node.Data === 'string') { - pbn.Data = textEncoder.encode(node.Data) - } else if (node.Data instanceof Uint8Array) { - pbn.Data = node.Data - } - } - - if (node.Links && Array.isArray(node.Links) && node.Links.length) { - pbn.Links = node.Links.map(asLink) - pbn.Links.sort(linkComparator) - } else { - pbn.Links = [] - } - - return pbn -} - -/** - * @param {PBNode} node - */ -export function validate (node) { - /* - type PBLink struct { - Hash optional Link - Name optional String - Tsize optional Int - } - - type PBNode struct { - Links [PBLink] - Data optional Bytes - } - */ - if (!node || typeof node !== 'object' || Array.isArray(node)) { - throw new TypeError('Invalid DAG-PB form') - } - - if (!hasOnlyProperties(node, pbNodeProperties)) { - throw new TypeError('Invalid DAG-PB form (extraneous properties)') - } - - if (node.Data !== undefined && !(node.Data instanceof Uint8Array)) { - throw new TypeError('Invalid DAG-PB form (Data must be a Uint8Array)') - } - - if (!Array.isArray(node.Links)) { - throw new TypeError('Invalid DAG-PB form (Links must be an array)') - } - - for (let i = 0; i < node.Links.length; i++) { - const link = node.Links[i] - if (!link || typeof link !== 'object' || Array.isArray(link)) { - throw new TypeError('Invalid DAG-PB form (bad link object)') - } - - if (!hasOnlyProperties(link, pbLinkProperties)) { - throw new TypeError('Invalid DAG-PB form (extraneous properties on link object)') - } - - if (!link.Hash) { - throw new TypeError('Invalid DAG-PB form (link must have a Hash)') - } - - // @ts-ignore private property for TS - if (link.Hash.asCID !== link.Hash) { - throw new TypeError('Invalid DAG-PB form (link Hash must be a CID)') - } - - if (link.Name !== undefined && typeof link.Name !== 'string') { - throw new TypeError('Invalid DAG-PB form (link Name must be a string)') - } - - if (link.Tsize !== undefined && (typeof link.Tsize !== 'number' || link.Tsize % 1 !== 0)) { - throw new TypeError('Invalid DAG-PB form (link Tsize must be an integer)') - } - - if (i > 0 && linkComparator(link, node.Links[i - 1]) === -1) { - throw new TypeError('Invalid DAG-PB form (links must be sorted by Name bytes)') - } - } -} - /** * @param {PBNode} node * @returns {ByteView} @@ -274,3 +80,5 @@ export function decode (bytes) { return node } + +export { prepare, validate, createNode, createLink } diff --git a/src/util.js b/src/util.js new file mode 100644 index 0000000..c21c36e --- /dev/null +++ b/src/util.js @@ -0,0 +1,226 @@ +import { CID } from 'multiformats/cid' + +/** + * @typedef {import('./interface').PBLink} PBLink + * @typedef {import('./interface').PBNode} PBNode + */ + +const pbNodeProperties = ['Data', 'Links'] +const pbLinkProperties = ['Hash', 'Name', 'Tsize'] + +const textEncoder = new TextEncoder() + +/** + * @param {PBLink} a + * @param {PBLink} b + * @returns {number} + */ +function linkComparator (a, b) { + if (a === b) { + return 0 + } + + const abuf = a.Name ? textEncoder.encode(a.Name) : [] + const bbuf = b.Name ? textEncoder.encode(b.Name) : [] + + let x = abuf.length + let y = bbuf.length + + for (let i = 0, len = Math.min(x, y); i < len; ++i) { + if (abuf[i] !== bbuf[i]) { + x = abuf[i] + y = bbuf[i] + break + } + } + + return x < y ? -1 : y < x ? 1 : 0 +} + +/** + * @param {any} node + * @param {string[]} properties + * @returns {boolean} + */ +function hasOnlyProperties (node, properties) { + return !Object.keys(node).some((p) => !properties.includes(p)) +} + +/** + * Converts a CID, or a PBLink-like object to a PBLink + * + * @param {any} link + * @returns {PBLink} + */ +function asLink (link) { + if (typeof link.asCID === 'object') { + const Hash = CID.asCID(link) + if (!Hash) { + throw new TypeError('Invalid DAG-PB form') + } + return { Hash } + } + + if (typeof link !== 'object' || Array.isArray(link)) { + throw new TypeError('Invalid DAG-PB form') + } + + const pbl = {} + + if (link.Hash) { + let cid = CID.asCID(link.Hash) + try { + if (!cid) { + if (typeof link.Hash === 'string') { + cid = CID.parse(link.Hash) + } else if (link.Hash instanceof Uint8Array) { + cid = CID.decode(link.Hash) + } + } + } catch (e) { + throw new TypeError(`Invalid DAG-PB form: ${e.message}`) + } + + if (cid) { + pbl.Hash = cid + } + } + + if (!pbl.Hash) { + throw new TypeError('Invalid DAG-PB form') + } + + if (typeof link.Name === 'string') { + pbl.Name = link.Name + } + + if (typeof link.Tsize === 'number') { + pbl.Tsize = link.Tsize + } + + return pbl +} + +/** + * @param {any} node + * @returns {PBNode} + */ +export function prepare (node) { + if (node instanceof Uint8Array || typeof node === 'string') { + node = { Data: node } + } + + if (typeof node !== 'object' || Array.isArray(node)) { + throw new TypeError('Invalid DAG-PB form') + } + + /** @type {PBNode} */ + const pbn = {} + + if (node.Data !== undefined) { + if (typeof node.Data === 'string') { + pbn.Data = textEncoder.encode(node.Data) + } else if (node.Data instanceof Uint8Array) { + pbn.Data = node.Data + } else { + throw new TypeError('Invalid DAG-PB form') + } + } + + if (node.Links !== undefined) { + if (Array.isArray(node.Links)) { + pbn.Links = node.Links.map(asLink) + pbn.Links.sort(linkComparator) + } else { + throw new TypeError('Invalid DAG-PB form') + } + } else { + pbn.Links = [] + } + + return pbn +} + +/** + * @param {PBNode} node + */ +export function validate (node) { + /* + type PBLink struct { + Hash optional Link + Name optional String + Tsize optional Int + } + + type PBNode struct { + Links [PBLink] + Data optional Bytes + } + */ + if (!node || typeof node !== 'object' || Array.isArray(node)) { + throw new TypeError('Invalid DAG-PB form') + } + + if (!hasOnlyProperties(node, pbNodeProperties)) { + throw new TypeError('Invalid DAG-PB form (extraneous properties)') + } + + if (node.Data !== undefined && !(node.Data instanceof Uint8Array)) { + throw new TypeError('Invalid DAG-PB form (Data must be a Uint8Array)') + } + + if (!Array.isArray(node.Links)) { + throw new TypeError('Invalid DAG-PB form (Links must be an array)') + } + + for (let i = 0; i < node.Links.length; i++) { + const link = node.Links[i] + if (!link || typeof link !== 'object' || Array.isArray(link)) { + throw new TypeError('Invalid DAG-PB form (bad link object)') + } + + if (!hasOnlyProperties(link, pbLinkProperties)) { + throw new TypeError('Invalid DAG-PB form (extraneous properties on link object)') + } + + if (!link.Hash) { + throw new TypeError('Invalid DAG-PB form (link must have a Hash)') + } + + // @ts-ignore private property for TS + if (link.Hash.asCID !== link.Hash) { + throw new TypeError('Invalid DAG-PB form (link Hash must be a CID)') + } + + if (link.Name !== undefined && typeof link.Name !== 'string') { + throw new TypeError('Invalid DAG-PB form (link Name must be a string)') + } + + if (link.Tsize !== undefined && (typeof link.Tsize !== 'number' || link.Tsize % 1 !== 0)) { + throw new TypeError('Invalid DAG-PB form (link Tsize must be an integer)') + } + + if (i > 0 && linkComparator(link, node.Links[i - 1]) === -1) { + throw new TypeError('Invalid DAG-PB form (links must be sorted by Name bytes)') + } + } +} + +/** + * @param {Uint8Array} data + * @param {PBLink[]} [links=[]] + * @returns {PBNode} + */ +export function createNode (data, links = []) { + return prepare({ Data: data, Links: links }) +} + +/** + * @param {string} name + * @param {number} size + * @param {CID} cid + * @returns {PBLink} + */ +export function createLink (name, size, cid) { + return asLink({ Hash: cid, Name: name, Tsize: size }) +} diff --git a/test/test-basics.js b/test/test-basics.js index f6314a4..afcc1e5 100644 --- a/test/test-basics.js +++ b/test/test-basics.js @@ -6,7 +6,7 @@ import chaiSubset from 'chai-subset' import { bytes } from 'multiformats' import { CID } from 'multiformats/cid' import { sha256 } from 'multiformats/hashes/sha2' -import { encode, decode, code, prepare } from '@ipld/dag-pb' +import { encode, decode, code, prepare, createNode, createLink } from '@ipld/dag-pb' chai.use(chaiSubset) const { assert } = chai @@ -250,7 +250,7 @@ describe('Basics', () => { [], true, 100, - () => {}, + () => { }, Symbol.for('nope') ] @@ -264,13 +264,16 @@ describe('Basics', () => { [], true, 100, - () => {}, + () => { }, Symbol.for('nope'), { asCID: {} } ] for (const invalid of invalids) { assert.throws(() => encode(prepare({ Links: [invalid] })), 'Invalid DAG-PB form') + if (!Array.isArray(invalid)) { + assert.throws(() => encode(prepare({ Links: invalid })), 'Invalid DAG-PB form') + } } }) @@ -373,7 +376,6 @@ describe('Basics', () => { const outOfOrderNodeHex = '0a040802180612240a221220cf92fdefcdc34cac009c8b05eb662be0618db9de55ecd42785e9ec6712f8df6512240a221220cf92fdefcdc34cac009c8b05eb662be0618db9de55ecd42785e9ec6712f8df65' const outOfOrderNode = bytes.fromHex(outOfOrderNodeHex) const node = decode(outOfOrderNode) // should not throw - console.log('OOO', node) const reencoded = encode(node) // we only care that it's different, i.e. this won't round-trip assert.notStrictEqual(bytes.toHex(reencoded), outOfOrderNodeHex) @@ -425,3 +427,82 @@ describe('Basics', () => { assert.throws(() => prepare(node), 'Invalid DAG-PB form') }) }) + +describe('create*() utility functions', () => { + const data = Uint8Array.from([0, 1, 2, 3, 4]) + const aCid = CID.parse('QmWDtUQj38YLW8v3q4A6LwPn4vYKEbuKWpgSm6bjKW6Xfe') + const links = [ + { + Name: 'foo', + Hash: CID.parse('QmUGhP2X8xo9dsj45vqx1H6i5WqPqLqmLQsHTTxd3ke8mp'), + Tsize: 262158 + }, { + Name: 'boo', + Hash: CID.parse('QmP7SrR76KHK9A916RbHG1ufy2TzNABZgiE23PjZDMzZXy'), + Tsize: 262158 + }, { + Name: 'yep', + Hash: CID.parse('QmQg1v4o9xdT3Q14wh4S7dxZkDjyZ9ssFzFzyep1YrVJBY'), + Tsize: 262158 + } + ] + const linksSorted = [links[1], links[0], links[2]] + + it('createNode()', () => { + assert.deepStrictEqual(createNode(data), { Data: data, Links: [] }) + assert.deepStrictEqual(createNode(data, []), { Data: data, Links: [] }) + assert.deepStrictEqual(createNode(data, [links[0]]), { Data: data, Links: [links[0]] }) + assert.deepStrictEqual(createNode(data, links), { Data: data, Links: linksSorted }) + // @ts-ignore + assert.deepStrictEqual(createNode(), { Links: [] }) + }) + + it('createNode() errors', () => { + const invalids = [ + [], + true, + 100, + () => { }, + Symbol.for('nope') + ] + for (const invalid of invalids) { + // @ts-ignore + assert.throws(() => createNode(invalid)) + } + }) + + it('createLink()', () => { + assert.deepStrictEqual(createLink('foo', 100, aCid), { Hash: aCid, Name: 'foo', Tsize: 100 }) + for (const l of links) { + assert.deepStrictEqual(createLink(l.Name, l.Tsize, l.Hash), l) + } + // Tsize isn't mandatory + // @ts-ignore + assert.deepStrictEqual(createLink('foo', undefined, aCid), { Hash: aCid, Name: 'foo' }) + // neither is Name + // @ts-ignore + assert.deepStrictEqual(createLink(undefined, undefined, aCid), { Hash: aCid }) + // but that's not really what this API is for ... + }) + + it('createNode() errors', () => { + const invalids = [ + undefined, + null, + [], + true, + 100, + () => { }, + Symbol.for('nope'), + {} + ] + for (const invalid1 of invalids) { + for (const invalid2 of invalids) { + for (const invalid3 of invalids) { + // @ts-ignore + assert.throws(() => createLink(invalid1, invalid2, invalid3)) + } + } + } + }) +})