From 20439759a85de5666e010705687a7e8f3fdf84ba Mon Sep 17 00:00:00 2001 From: achingbrain Date: Sat, 15 Oct 2022 13:56:39 +0100 Subject: [PATCH] feat!: CID as an interface This is an attempt at implementing #203, converting the CID class into a minimal interface and having factory functions to return objects that conform to the CID interface. I've preserved all the generics the `Link` interface introduced and there are functions in `link.js` that add the extra methods to turn something that conforms to `CID` into something that can be used as a `Link` so existing code using the `Link` API should not have to change. Notably there was no need to update any of the `Link` tests. The static methods on CID have been exported as individual functions - the names remain the same (`decode`, `parse`, etc) in an attempt to be less disruptive. Code using these methods should mostly just need to change: ```js import { CID } from 'multiformats/cid' ``` to: ```js import * as CID from 'multiformats/cid ``` Types can be imported as: ```ts import type { CID } from 'multiformats/interface' ``` or as before: ```ts import type { CID } from 'multiformats/cid' ``` BREAKING CHANGE: the CID class is now an interface --- README.md | 2 +- src/block.js | 26 +- src/block/interface.ts | 4 +- src/cid.js | 675 ++++++++++++++++++------------------ src/cid/interface.js | 1 + src/cid/interface.ts | 24 ++ src/index.js | 2 +- src/interface.ts | 1 + src/link.js | 60 +++- src/link/interface.ts | 3 - src/traversal.js | 21 +- test/test-cid.spec.js | 175 ++++------ test/test-link.spec.js | 47 +++ test/test-traversal.spec.js | 2 +- 14 files changed, 573 insertions(+), 470 deletions(-) create mode 100644 src/cid/interface.js create mode 100644 src/cid/interface.ts diff --git a/README.md b/README.md index 5515e280..a824b3f2 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ This library defines common interfaces and low level building blocks for various This library provides implementations for most basics and many others can be found in linked repositories. ```js -import { CID } from 'multiformats/cid' +import * as CID from 'multiformats/cid' import * as json from 'multiformats/codecs/json' import { sha256 } from 'multiformats/hashes/sha2' diff --git a/src/block.js b/src/block.js index 2e1a7dbf..e4d2cf51 100644 --- a/src/block.js +++ b/src/block.js @@ -2,6 +2,13 @@ import { bytes as binary, CID } from './index.js' // Linter can see that API is used in types. // eslint-disable-next-line import * as API from './interface.js' +// Linter can see that API is used in types. +// eslint-disable-next-line +import * as CIDAPI from './cid/interface.js' +// Linter can see that API is used in types. +// eslint-disable-next-line +import * as LinkAPI from './link/interface.js' +import { asLink } from './link.js' function readonly ({ enumerable = true, configurable = false } = {}) { return { enumerable, configurable, writable: false } @@ -10,7 +17,7 @@ function readonly ({ enumerable = true, configurable = false } = {}) { /** * @param {[string|number, string]} path * @param {any} value - * @returns {Iterable<[string, CID]>} + * @returns {Iterable<[string, CIDAPI.CID]>} */ function * linksWithin (path, value) { if (value != null && typeof value === 'object') { @@ -39,7 +46,7 @@ function * linksWithin (path, value) { * @template T * @param {T} source * @param {Array} base - * @returns {Iterable<[string, CID]>} + * @returns {Iterable<[string, CIDAPI.CID]>} */ function * links (source, base) { if (source == null || source instanceof Uint8Array) { @@ -121,7 +128,7 @@ function get (source, path) { class Block { /** * @param {object} options - * @param {CID} options.cid + * @param {LinkAPI.Link} options.cid * @param {API.ByteView} options.bytes * @param {T} options.value */ @@ -176,14 +183,14 @@ async function encode ({ value, codec, hasher }) { const bytes = codec.encode(value) const hash = await hasher.digest(bytes) - /** @type {CID} */ + /** @type {CIDAPI.CID} */ const cid = CID.create( 1, codec.code, hash ) - return new Block({ value, bytes, cid }) + return new Block({ value, bytes, cid: asLink(cid) }) } /** @@ -194,7 +201,7 @@ async function encode ({ value, codec, hasher }) { * @param {API.ByteView} options.bytes * @param {API.BlockDecoder} options.codec * @param {API.MultihashHasher} options.hasher - * @returns {Promise>} + * @returns {Promise>} */ async function decode ({ bytes, codec, hasher }) { if (!bytes) throw new Error('Missing required argument "bytes"') @@ -202,10 +209,10 @@ async function decode ({ bytes, codec, hasher }) { const value = codec.decode(bytes) const hash = await hasher.digest(bytes) - /** @type {CID} */ + /** @type {CIDAPI.CID} */ const cid = CID.create(1, codec.code, hash) - return new Block({ value, bytes, cid }) + return new Block({ value, bytes, cid: asLink(cid) }) } /** @@ -229,8 +236,7 @@ function createUnsafe ({ bytes, cid, value: maybeValue, codec }) { if (value === undefined) throw new Error('Missing required argument, must either provide "value" or "codec"') return new Block({ - // eslint-disable-next-line object-shorthand - cid: /** @type {CID} */ (cid), + cid, bytes, value }) diff --git a/src/block/interface.ts b/src/block/interface.ts index c87ff74f..c47e2774 100644 --- a/src/block/interface.ts +++ b/src/block/interface.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-unnecessary-type-constraint */ /* eslint-disable no-use-before-define */ import { Link, Version } from '../link/interface.js' -import { CID } from '../cid.js' +import { CID } from '../cid/interface.js' /** * A byte-encoded representation of some type of `Data`. @@ -64,7 +64,7 @@ export interface BlockView< A extends number = number, V extends Version = 1 > extends Block { - cid: CID + cid: Link value: T links: () => Iterable<[string, CID]> diff --git a/src/cid.js b/src/cid.js index 4736898f..771672e1 100644 --- a/src/cid.js +++ b/src/cid.js @@ -5,13 +5,17 @@ import { base32 } from './bases/base32.js' import { coerce } from './bytes.js' // Linter can see that API is used in types. // eslint-disable-next-line -import * as API from "./link/interface.js" +import * as API from './link/interface.js' +// Linter can see that API is used in types. +// eslint-disable-next-line +import * as CID from './cid/interface.js' // This way TS will also expose all the types from module export * from './link/interface.js' +export * from './cid/interface.js' /** - * @template {API.Link} T + * @template {CID.CID} T * @template {string} Prefix * @param {T} link * @param {API.MultibaseEncoder} [base] @@ -35,11 +39,11 @@ export const format = (link, base) => { } } -/** @type {WeakMap>} */ +/** @type {WeakMap>} */ const cache = new WeakMap() /** - * @param {API.UnknownLink} cid + * @param {CID} cid * @returns {Map} */ const baseCache = cid => { @@ -56,17 +60,16 @@ const baseCache = cid => { * @template {unknown} [Data=unknown] * @template {number} [Format=number] * @template {number} [Alg=number] - * @template {API.Version} [Version=API.Version] - * @implements {API.Link} + * @template {API.Version} [Ver=API.Version] + * @implements {CID.CID} */ -export class CID { +class DefaultCID { /** - * @param {Version} version - Version of the CID + * @param {Ver} version - Version of the CID * @param {Format} code - Code of the codec content is encoded in, see https://github.com/multiformats/multicodec/blob/master/table.csv * @param {API.MultihashDigest} multihash - (Multi)hash of the of the content. * @param {Uint8Array} bytes - * */ constructor (version, code, multihash, bytes) { /** @readonly */ @@ -78,126 +81,29 @@ export class CID { /** @readonly */ this.bytes = bytes - // ArrayBufferView - /** @readonly */ - this.byteOffset = bytes.byteOffset - /** @readonly */ - this.byteLength = bytes.byteLength - // Circular reference /** @readonly */ this.asCID = this } - /** - * @returns {CID} - */ - toV0 () { - switch (this.version) { - case 0: { - return /** @type {CID} */ (this) - } - case 1: { - const { code, multihash } = this - - if (code !== DAG_PB_CODE) { - throw new Error('Cannot convert a non dag-pb CID to CIDv0') - } - - // sha2-256 - if (multihash.code !== SHA_256_CODE) { - throw new Error('Cannot convert non sha2-256 multihash CID to CIDv0') - } - - return /** @type {CID} */ ( - CID.createV0( - /** @type {API.MultihashDigest} */ (multihash) - ) - ) - } - default: { - throw Error( - `Can not convert CID version ${this.version} to version 0. This is a bug please report` - ) - } - } - } - - /** - * @returns {CID} - */ - toV1 () { - switch (this.version) { - case 0: { - const { code, digest } = this.multihash - const multihash = Digest.create(code, digest) - return /** @type {CID} */ ( - CID.createV1(this.code, multihash) - ) - } - case 1: { - return /** @type {CID} */ (this) - } - default: { - throw Error( - `Can not convert CID version ${this.version} to version 1. This is a bug please report` - ) - } - } - } - /** * @param {unknown} other - * @returns {other is CID} + * @returns {other is CID.CID} */ equals (other) { - return CID.equals(this, other) - } - - /** - * @template {unknown} Data - * @template {number} Format - * @template {number} Alg - * @template {API.Version} Version - * @param {API.Link} self - * @param {unknown} other - * @returns {other is CID} - */ - static equals (self, other) { - const unknown = - /** @type {{code?:unknown, version?:unknown, multihash?:unknown}} */ ( - other - ) - return ( - unknown && - self.code === unknown.code && - self.version === unknown.version && - Digest.equals(self.multihash, unknown.multihash) - ) + return equals(this, other) } - /** - * @param {API.MultibaseEncoder} [base] - * @returns {string} - */ - toString (base) { - return format(this, base) + get [Symbol.toStringTag] () { + return 'CID' } - toJSON () { - return { - code: this.code, - version: this.version, - hash: this.multihash.bytes + toString () { + if (this.version === 1) { + return format(this, base32) } - } - - link () { - return this - } - get [Symbol.toStringTag] () { - return 'CID' + return format(this, base58btc) } // Legacy @@ -205,255 +111,349 @@ export class CID { [Symbol.for('nodejs.util.inspect.custom')] () { return `CID(${this.toString()})` } +} - /** - * Takes any input `value` and returns a `CID` instance if it was - * a `CID` otherwise returns `null`. If `value` is instanceof `CID` - * it will return value back. If `value` is not instance of this CID - * class, but is compatible CID it will return new instance of this - * `CID` class. Otherwise returs null. - * - * This allows two different incompatible versions of CID library to - * co-exist and interop as long as binary interface is compatible. - * - * @template {unknown} Data - * @template {number} Format - * @template {number} Alg - * @template {API.Version} Version - * @template {unknown} U - * @param {API.Link|U} input - * @returns {CID|null} - */ - static asCID (input) { - const value = /** @type {any} */ (input) - if (value instanceof CID) { - // If value is instance of CID then we're all set. - return value - } else if (value != null && value.asCID === value) { - // If value isn't instance of this CID class but `this.asCID === this` is - // true it is CID instance coming from a different implementation (diff - // version or duplicate). In that case we rebase it to this `CID` - // implementation so caller is guaranteed to get instance with expected - // API. - const { version, code, multihash, bytes } = value - return new CID( - version, - code, - /** @type {API.MultihashDigest} */ (multihash), - bytes || encodeCID(version, code, multihash.bytes) - ) - } else if (value != null && value[cidSymbol] === true) { - // If value is a CID from older implementation that used to be tagged via - // symbol we still rebase it to the this `CID` implementation by - // delegating that to a constructor. - const { version, multihash, code } = value - const digest = - /** @type {API.MultihashDigest} */ - (Digest.decode(multihash)) - return CID.create(version, code, digest) - } else { - // Otherwise value is not a CID (or an incompatible version of it) in - // which case we return `null`. - return null - } - } - - /** - * - * @template {unknown} Data - * @template {number} Format - * @template {number} Alg - * @template {API.Version} Version - * @param {Version} version - Version of the CID - * @param {Format} code - Code of the codec content is encoded in, see https://github.com/multiformats/multicodec/blob/master/table.csv - * @param {API.MultihashDigest} digest - (Multi)hash of the of the content. - * @returns {CID} - */ - static create (version, code, digest) { - if (typeof code !== 'number') { - throw new Error('String codecs are no longer supported') +/** + * @template {unknown} [Data=unknown] + * @template {number} [Format=number] + * @template {number} [Alg=number] + * @template {API.Version} [Ver=API.Version] + * @param {CID.CID} cid + * @returns {CID.CID} + */ +export function toV0 (cid) { + switch (cid.version) { + case 0: { + return /** @type {CID.CID} */ (cid) } + case 1: { + const { code, multihash } = cid - switch (version) { - case 0: { - if (code !== DAG_PB_CODE) { - throw new Error( - `Version 0 CID must use dag-pb (code: ${DAG_PB_CODE}) block encoding` - ) - } else { - return new CID(version, code, digest, digest.bytes) - } - } - case 1: { - const bytes = encodeCID(version, code, digest.bytes) - return new CID(version, code, digest, bytes) + if (code !== DAG_PB_CODE) { + throw new Error('Cannot convert a non dag-pb CID to CIDv0') } - default: { - throw new Error('Invalid version') + + // sha2-256 + if (multihash.code !== SHA_256_CODE) { + throw new Error('Cannot convert non sha2-256 multihash CID to CIDv0') } + + return /** @type {CID.CID} */ ( + createV0( + /** @type {API.MultihashDigest} */ (multihash) + ) + ) + } + default: { + throw Error( + `Can not convert CID version ${cid.version} to version 0. This is a bug please report` + ) } } +} - /** - * Simplified version of `create` for CIDv0. - * - * @template {unknown} [T=unknown] - * @param {API.MultihashDigest} digest - Multihash. - * @returns {CID} - */ - static createV0 (digest) { - return CID.create(0, DAG_PB_CODE, digest) +/** + * @template {unknown} [Data=unknown] + * @template {number} [Format=number] + * @template {number} [Alg=number] + * @template {API.Version} [Ver=API.Version] + * @param {CID.CID} cid + * @returns {CID.CID} + */ +export function toV1 (cid) { + switch (cid.version) { + case 0: { + const { code, digest } = cid.multihash + const multihash = Digest.create(code, digest) + return createV1(cid.code, multihash) + } + case 1: { + return /** @type {CID.CID} */ ( + cid + ) + } + default: { + throw Error( + `Can not convert CID version ${cid.version} to version 1. This is a bug please report` + ) + } } +} - /** - * Simplified version of `create` for CIDv1. - * - * @template {unknown} Data - * @template {number} Code - * @template {number} Alg - * @param {Code} code - Content encoding format code. - * @param {API.MultihashDigest} digest - Miltihash of the content. - * @returns {CID} - */ - static createV1 (code, digest) { - return CID.create(1, code, digest) +/** + * @template {unknown} Data + * @template {number} Format + * @template {number} Alg + * @template {API.Version} Ver + * @param {CID.CID} self + * @param {unknown} other + * @returns {other is CID.CID} + */ +export function equals (self, other) { + const unknown = + /** @type {{code?:unknown, version?:unknown, multihash?:unknown}} */ ( + other + ) + return ( + unknown && + self.code === unknown.code && + self.version === unknown.version && + Digest.equals(self.multihash, unknown.multihash) + ) +} + +/** + * Takes any input `value` and returns a `CID` instance if it was + * a `CID` otherwise returns `null`. If `value` is instanceof `CID` + * it will return value back. If `value` is not instance of this CID + * class, but is compatible CID it will return new instance of this + * `CID` class. Otherwise returs null. + * + * This allows two different incompatible versions of CID library to + * co-exist and interop as long as binary interface is compatible. + * + * @template {unknown} Data + * @template {number} Format + * @template {number} Alg + * @template {API.Version} Version + * @template {unknown} U + * @param {API.Link|U} input + * @returns {CID.CID|null} + */ +export function asCID (input) { + const value = /** @type {any} */ (input) + if (value instanceof DefaultCID) { + // If value is instance of CID then we're all set. + return value + } else if (value != null && value.asCID === value) { + // If value isn't instance of this CID class but `this.asCID === this` is + // true it is CID instance coming from a different implementation (diff + // version or duplicate). In that case we rebase it to this `CID` + // implementation so caller is guaranteed to get instance with expected + // API. + const { version, code, multihash, bytes } = value + return new DefaultCID( + version, + code, + /** @type {API.MultihashDigest} */ (multihash), + bytes || encodeCID(version, code, multihash.bytes) + ) + } else if (value != null && value[cidSymbol] === true) { + // If value is a CID from older implementation that used to be tagged via + // symbol we still rebase it to the this `CID` implementation by + // delegating that to a constructor. + const { version, multihash, code } = value + const digest = + /** @type {API.MultihashDigest} */ + (Digest.decode(multihash)) + return create(version, code, digest) + } else { + // Otherwise value is not a CID (or an incompatible version of it) in + // which case we return `null`. + return null } +} - /** - * Decoded a CID from its binary representation. The byte array must contain - * only the CID with no additional bytes. - * - * An error will be thrown if the bytes provided do not contain a valid - * binary representation of a CID. - * - * @template {unknown} Data - * @template {number} Code - * @template {number} Alg - * @template {API.Version} Ver - * @param {API.ByteView>} bytes - * @returns {CID} - */ - static decode (bytes) { - const [cid, remainder] = CID.decodeFirst(bytes) - if (remainder.length) { - throw new Error('Incorrect length') - } - return cid +/** + * + * @template {unknown} Data + * @template {number} Format + * @template {number} Alg + * @template {API.Version} Version + * @param {Version} version - Version of the CID + * @param {Format} code - Code of the codec content is encoded in, see https://github.com/multiformats/multicodec/blob/master/table.csv + * @param {API.MultihashDigest} digest - (Multi)hash of the of the content. + * @returns {CID.CID} + */ +export function create (version, code, digest) { + if (typeof code !== 'number') { + throw new Error('String codecs are no longer supported') } - /** - * Decoded a CID from its binary representation at the beginning of a byte - * array. - * - * Returns an array with the first element containing the CID and the second - * element containing the remainder of the original byte array. The remainder - * will be a zero-length byte array if the provided bytes only contained a - * binary CID representation. - * - * @template {unknown} T - * @template {number} C - * @template {number} A - * @template {API.Version} V - * @param {API.ByteView>} bytes - * @returns {[CID, Uint8Array]} - */ - static decodeFirst (bytes) { - const specs = CID.inspectBytes(bytes) - const prefixSize = specs.size - specs.multihashSize - const multihashBytes = coerce( - bytes.subarray(prefixSize, prefixSize + specs.multihashSize) - ) - if (multihashBytes.byteLength !== specs.multihashSize) { - throw new Error('Incorrect length') - } - const digestBytes = multihashBytes.subarray( - specs.multihashSize - specs.digestSize - ) - const digest = new Digest.Digest( - specs.multihashCode, - specs.digestSize, - digestBytes, - multihashBytes - ) - const cid = - specs.version === 0 - ? CID.createV0(/** @type {API.MultihashDigest} */ (digest)) - : CID.createV1(specs.codec, digest) - return [/** @type {CID} */(cid), bytes.subarray(specs.size)] + if (digest.bytes == null) { + throw new Error('Invalid digest') } - /** - * Inspect the initial bytes of a CID to determine its properties. - * - * Involves decoding up to 4 varints. Typically this will require only 4 to 6 - * bytes but for larger multicodec code values and larger multihash digest - * lengths these varints can be quite large. It is recommended that at least - * 10 bytes be made available in the `initialBytes` argument for a complete - * inspection. - * - * @template {unknown} T - * @template {number} C - * @template {number} A - * @template {API.Version} V - * @param {API.ByteView>} initialBytes - * @returns {{ version:V, codec:C, multihashCode:A, digestSize:number, multihashSize:number, size:number }} - */ - static inspectBytes (initialBytes) { - let offset = 0 - const next = () => { - const [i, length] = varint.decode(initialBytes.subarray(offset)) - offset += length - return i + switch (version) { + case 0: { + if (code !== DAG_PB_CODE) { + throw new Error( + `Version 0 CID must use dag-pb (code: ${DAG_PB_CODE}) block encoding` + ) + } else { + return new DefaultCID(version, code, digest, digest.bytes) + } } - - let version = /** @type {V} */ (next()) - let codec = /** @type {C} */ (DAG_PB_CODE) - if (/** @type {number} */(version) === 18) { - // CIDv0 - version = /** @type {V} */ (0) - offset = 0 - } else { - codec = /** @type {C} */ (next()) + case 1: { + const bytes = encodeCID(version, code, digest.bytes) + return new DefaultCID(version, code, digest, bytes) } - - if (version !== 0 && version !== 1) { - throw new RangeError(`Invalid CID version ${version}`) + default: { + throw new Error('Invalid version') } + } +} - const prefixSize = offset - const multihashCode = /** @type {A} */ (next()) // multihash code - const digestSize = next() // multihash length - const size = offset + digestSize - const multihashSize = size - prefixSize +/** + * Simplified version of `create` for CIDv0. + * + * @template {unknown} [T=unknown] + * @param {API.MultihashDigest} digest - Multihash. + * @returns {CID.CID} + */ +export function createV0 (digest) { + return create(0, DAG_PB_CODE, digest) +} - return { version, codec, multihashCode, digestSize, multihashSize, size } +/** + * Simplified version of `create` for CIDv1. + * + * @template {unknown} Data + * @template {number} Code + * @template {number} Alg + * + * @param {Code} code - Content encoding format code. + * @param {API.MultihashDigest} digest - Miltihash of the content. + * @returns {CID.CID} + */ +export function createV1 (code, digest) { + return create(1, code, digest) +} + +/** + * Decoded a CID from its binary representation. The byte array must contain + * only the CID with no additional bytes. + * + * An error will be thrown if the bytes provided do not contain a valid + * binary representation of a CID. + * + * @template {unknown} Data + * @template {number} Code + * @template {number} Alg + * @template {API.Version} Ver + * @param {API.ByteView>} bytes + * @returns {CID.CID} + */ +export function decode (bytes) { + const [cid, remainder] = decodeFirst(bytes) + if (remainder.length) { + throw new Error('Incorrect length') } + return cid +} - /** - * Takes cid in a string representation and creates an instance. If `base` - * decoder is not provided will use a default from the configuration. It will - * throw an error if encoding of the CID is not compatible with supplied (or - * a default decoder). - * - * @template {string} Prefix - * @template {unknown} Data - * @template {number} Code - * @template {number} Alg - * @template {API.Version} Ver - * @param {API.ToString, Prefix>} source - * @param {API.MultibaseDecoder} [base] - * @returns {CID} - */ - static parse (source, base) { - const [prefix, bytes] = parseCIDtoBytes(source, base) +/** + * Decoded a CID from its binary representation at the beginning of a byte + * array. + * + * Returns an array with the first element containing the CID and the second + * element containing the remainder of the original byte array. The remainder + * will be a zero-length byte array if the provided bytes only contained a + * binary CID representation. + * + * @template {unknown} T + * @template {number} C + * @template {number} A + * @template {API.Version} V + * @param {API.ByteView>} bytes + * @returns {[CID.CID, Uint8Array]} + */ +export function decodeFirst (bytes) { + const specs = inspectBytes(bytes) + const prefixSize = specs.size - specs.multihashSize + const multihashBytes = coerce( + bytes.subarray(prefixSize, prefixSize + specs.multihashSize) + ) + if (multihashBytes.byteLength !== specs.multihashSize) { + throw new Error('Incorrect length') + } + const digestBytes = multihashBytes.subarray( + specs.multihashSize - specs.digestSize + ) + const digest = new Digest.Digest( + specs.multihashCode, + specs.digestSize, + digestBytes, + multihashBytes + ) + const cid = + specs.version === 0 + ? createV0(/** @type {API.MultihashDigest} */ (digest)) + : createV1(specs.codec, digest) + return [/** @type {CID.CID} */(cid), bytes.subarray(specs.size)] +} - const cid = CID.decode(bytes) +/** + * Inspect the initial bytes of a CID to determine its properties. + * + * Involves decoding up to 4 varints. Typically this will require only 4 to 6 + * bytes but for larger multicodec code values and larger multihash digest + * lengths these varints can be quite large. It is recommended that at least + * 10 bytes be made available in the `initialBytes` argument for a complete + * inspection. + * + * @template {unknown} T + * @template {number} C + * @template {number} A + * @template {API.Version} V + * @param {API.ByteView>} initialBytes + * @returns {{ version:V, codec:C, multihashCode:A, digestSize:number, multihashSize:number, size:number }} + */ +export function inspectBytes (initialBytes) { + let offset = 0 + const next = () => { + const [i, length] = varint.decode(initialBytes.subarray(offset)) + offset += length + return i + } - // Cache string representation to avoid computing it on `this.toString()` - baseCache(cid).set(prefix, source) + let version = /** @type {V} */ (next()) + let codec = /** @type {C} */ (DAG_PB_CODE) + if (/** @type {number} */(version) === 18) { + // CIDv0 + version = /** @type {V} */ (0) + offset = 0 + } else { + codec = /** @type {C} */ (next()) + } - return cid + if (version !== 0 && version !== 1) { + throw new RangeError(`Invalid CID version ${version}`) } + + const prefixSize = offset + const multihashCode = /** @type {A} */ (next()) // multihash code + const digestSize = next() // multihash length + const size = offset + digestSize + const multihashSize = size - prefixSize + + return { version, codec, multihashCode, digestSize, multihashSize, size } +} + +/** + * Takes cid in a string representation and creates an instance. If `base` + * decoder is not provided will use a default from the configuration. It will + * throw an error if encoding of the CID is not compatible with supplied (or + * a default decoder). + * + * @template {string} Prefix + * @template {unknown} Data + * @template {number} Code + * @template {number} Alg + * @template {API.Version} Ver + * @param {API.ToString, Prefix>} source + * @param {API.MultibaseDecoder} [base] + * @returns {CID.CID} + */ +export function parse (source, base) { + const [prefix, bytes] = parseCIDtoBytes(source, base) + + const cid = decode(bytes) + + // Cache string representation to avoid computing it on `this.toString()` + baseCache(cid).set(prefix, source) + + return cid } /** @@ -496,10 +496,10 @@ const parseCIDtoBytes = (source, base) => { } /** - * * @param {Uint8Array} bytes * @param {Map} cache * @param {API.MultibaseEncoder<'z'>} base + * @returns {string} */ const toStringV0 = (bytes, cache, base) => { const { prefix } = base @@ -522,6 +522,7 @@ const toStringV0 = (bytes, cache, base) => { * @param {Uint8Array} bytes * @param {Map} cache * @param {API.MultibaseEncoder} base + * @returns {string} */ const toStringV1 = (bytes, cache, base) => { const { prefix } = base diff --git a/src/cid/interface.js b/src/cid/interface.js new file mode 100644 index 00000000..d9b3f4f7 --- /dev/null +++ b/src/cid/interface.js @@ -0,0 +1 @@ +// this is dummy module overlayed by interface.ts diff --git a/src/cid/interface.ts b/src/cid/interface.ts new file mode 100644 index 00000000..ca47897a --- /dev/null +++ b/src/cid/interface.ts @@ -0,0 +1,24 @@ +import { ByteView, Link, MultihashDigest, Version } from '../link/interface.js' + +/** + * A content identifier that contains a version, a codec and a + * multihash. + * + * @template Data - Logical type of the data being linked to. + * @template Format - multicodec code corresponding to a codec linked data is encoded with + * @template Alg - multicodec code corresponding to the hashing algorithm of the CID + * @template Ver - CID version + */ +export interface CID< +Data extends unknown = unknown, +Format extends number = number, +Alg extends number = number, +Ver extends Version = Version +> { + version: Ver + code: Format + multihash: MultihashDigest + bytes: ByteView> + + equals: (other: unknown) => other is CID +} diff --git a/src/index.js b/src/index.js index 377a9771..f3cc8e34 100644 --- a/src/index.js +++ b/src/index.js @@ -1,4 +1,4 @@ -import { CID } from './cid.js' +import * as CID from './cid.js' import * as varint from './varint.js' import * as bytes from './bytes.js' import * as hasher from './hashes/hasher.js' diff --git a/src/interface.ts b/src/interface.ts index e2a56c67..eefaf3ff 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -3,3 +3,4 @@ export * from './hashes/interface.js' export * from './codecs/interface.js' export * from './link/interface.js' export * from './block/interface.js' +export * from './cid/interface.js' diff --git a/src/link.js b/src/link.js index 71793099..dbe012b5 100644 --- a/src/link.js +++ b/src/link.js @@ -1,21 +1,67 @@ // Linter can see that API is used in types. // eslint-disable-next-line import * as API from "./link/interface.js" -import { CID, format } from './cid.js' +import * as CID from './cid.js' // This way TS will also expose all the types from module export * from './link/interface.js' +// Linter can see that API is used in types. +// eslint-disable-next-line +import * as CIDAPI from './cid/interface.js' -const DAG_PB_CODE = 0x70 // eslint-disable-next-line const SHA_256_CODE = 0x12 +/** + * @template {unknown} Data + * @template {number} Code + * @template {number} Alg + * @template {API.Version} Ver + * @param {CIDAPI.CID} cid + * @returns {API.Link} + */ +export function asLink (cid) { + /** @type {API.Link} */ + const link = { + ...cid, + toJSON () { + return { + code: cid.code, + version: cid.version, + hash: cid.multihash.bytes + } + }, + link () { + return link + }, + toV1 () { + return asLink(CID.toV1(cid)) + }, + /** + * @param {unknown} other + * @returns {other is API.Link} + */ + equals (other) { + return cid.equals(other) + }, + toString (base) { + if (cid.version === 0 && base != null && base.name !== 'base58btc') { + throw new Error(`Cannot string encode V0 in ${base.name} encoding`) + } + + return CID.format(cid, base).toString() + } + } + + return link +} + /** * Simplified version of `create` for CIDv0. * * @param {API.MultihashDigest} digest - Multihash. * @returns {API.LegacyLink} */ -export const createLegacy = digest => CID.create(0, DAG_PB_CODE, digest) +export const createLegacy = digest => asLink(CID.createV0(digest)) /** * Simplified version of `create` for CIDv1. @@ -27,7 +73,7 @@ export const createLegacy = digest => CID.create(0, DAG_PB_CODE, digest) * @param {API.MultihashDigest} digest - Miltihash of the content. * @returns {API.Link} */ -export const create = (code, digest) => CID.create(1, code, digest) +export const create = (code, digest) => asLink(CID.createV1(code, digest)) /** * Type predicate returns true if value is the link. @@ -54,9 +100,9 @@ export const isLink = value => * @param {API.MultibaseDecoder} [base] * @returns {API.Link} */ -export const parse = (source, base) => CID.parse(source, base) +export const parse = (source, base) => asLink(CID.parse(source, base)) -export { format } +export const format = CID.format /** * Decoded a CID from its binary representation. The byte array must contain @@ -72,4 +118,4 @@ export { format } * @param {API.ByteView>} bytes * @returns {API.Link} */ -export const decode = bytes => CID.decode(bytes) +export const decode = bytes => asLink(CID.decode(bytes)) diff --git a/src/link/interface.ts b/src/link/interface.ts index af8b7e47..791ad8e7 100644 --- a/src/link/interface.ts +++ b/src/link/interface.ts @@ -27,9 +27,6 @@ export interface Link< readonly version: V readonly code: Format readonly multihash: MultihashDigest - - readonly byteOffset: number - readonly byteLength: number readonly bytes: ByteView> equals: (other: unknown) => other is Link diff --git a/src/traversal.js b/src/traversal.js index 0b46b086..e2c95cb3 100644 --- a/src/traversal.js +++ b/src/traversal.js @@ -1,17 +1,20 @@ import { base58btc } from './bases/base58.js' +// Linter can see that API is used in types. +// eslint-disable-next-line +import * as API from './link/interface.js' /** - * @template [C=number] - multicodec code corresponding to codec used to encode the block - * @template [A=number] - multicodec code corresponding to the hashing algorithm used in CID creation. - * @template [V=0|1] - CID version - * @typedef {import('./cid').CID} CID + * @template {number} [C=number] - multicodec code corresponding to codec used to encode the block + * @template {number} [A=number] - multicodec code corresponding to the hashing algorithm used in CID creation. + * @template {API.Version} [V=API.Version] - CID version + * @typedef {import('./cid/interface.js').CID} CID */ /** - * @template [T=unknown] - Logical type of the data encoded in the block - * @template [C=number] - multicodec code corresponding to codec used to encode the block - * @template [A=number] - multicodec code corresponding to the hashing algorithm used in CID creation. - * @template [V=0|1] - CID version + * @template {unknown} [T=unknown] - Logical type of the data encoded in the block + * @template {number} [C=number] - multicodec code corresponding to codec used to encode the block + * @template {number} [A=number] - multicodec code corresponding to the hashing algorithm used in CID creation. + * @template {API.Version} [V=API.Version] - CID version * @typedef {import('./block/interface.js').BlockView} BlockView */ @@ -23,7 +26,7 @@ import { base58btc } from './bases/base58.js' */ const walk = async ({ cid, load, seen }) => { seen = seen || new Set() - const b58Cid = cid.toString(base58btc) + const b58Cid = base58btc.encode(cid.bytes) if (seen.has(b58Cid)) { return } diff --git a/test/test-cid.spec.js b/test/test-cid.spec.js index 6de0f6c5..f211a840 100644 --- a/test/test-cid.spec.js +++ b/test/test-cid.spec.js @@ -12,7 +12,7 @@ import chai from 'chai' import chaiAsPromised from 'chai-as-promised' // Linter can see that API is used in types. // eslint-disable-next-line -import * as API from 'multiformats' +import * as CIDAPI from '../src/cid/interface.js' chai.use(chaiAsPromised) const { assert } = chai @@ -82,13 +82,6 @@ describe('CID', () => { assert.throws(() => CID.create(0, 113, hash), msg) }) - it('throws on trying to base encode CIDv0 in other base than base58btc', async () => { - const mhStr = 'QmdfTbBqBPQ7VNxZEYEj14VmRuZBkqFbiwReogJgS1zR1n' - const cid = CID.parse(mhStr) - const msg = 'Cannot string encode V0 in base32 encoding' - assert.throws(() => cid.toString(base32), msg) - }) - it('.bytes', async () => { const hash = await sha256.digest(textEncoder.encode('abc')) const codec = 112 @@ -105,7 +98,7 @@ describe('CID', () => { it('should construct from an old CID', () => { const cidStr = 'QmdfTbBqBPQ7VNxZEYEj14VmRuZBkqFbiwReogJgS1zR1n' const oldCid = CID.parse(cidStr) - const newCid = /** @type {CID} */ (CID.asCID(oldCid)) + const newCid = /** @type {CIDAPI.CID} */ (CID.asCID(oldCid)) assert.deepStrictEqual(newCid.toString(), cidStr) }) @@ -226,14 +219,9 @@ describe('CID', () => { const cidStr = 'bafybeidskjjd4zmr7oh6ku6wp72vvbxyibcli2r6if3ocdcy7jjjusvl2u' const oldCid = CID.parse(cidStr) - const newCid = /** @type {CID} */ (CID.asCID(oldCid)) + const newCid = /** @type {CIDAPI.CID} */ (CID.asCID(oldCid)) assert.deepStrictEqual(newCid.toString(), cidStr) }) - - it('.link() should return this CID', () => { - const cid = CID.parse('bafybeidskjjd4zmr7oh6ku6wp72vvbxyibcli2r6if3ocdcy7jjjusvl2u') - assert.equal(cid, cid.link()) - }) }) describe('utilities', () => { @@ -260,7 +248,7 @@ describe('CID', () => { it('.equals v0 to v1 and vice versa', () => { const cidV1 = CID.parse(h3) - const cidV0 = cidV1.toV0() + const cidV0 = CID.toV0(cidV1) assert.deepStrictEqual(cidV0.equals(cidV1), false) assert.deepStrictEqual(cidV1.equals(cidV0), false) @@ -344,13 +332,13 @@ describe('CID', () => { describe('conversion v0 <-> v1', () => { it('should convert v0 to v1', async () => { const hash = await sha256.digest(textEncoder.encode(`TEST${Date.now()}`)) - const cid = CID.create(0, 112, hash).toV1() + const cid = CID.toV1(CID.create(0, 112, hash)) assert.deepStrictEqual(cid.version, 1) }) it('should convert v1 to v0', async () => { const hash = await sha256.digest(textEncoder.encode(`TEST${Date.now()}`)) - const cid = CID.create(1, 112, hash).toV0() + const cid = CID.toV0(CID.create(1, 112, hash)) assert.deepStrictEqual(cid.version, 0) }) @@ -358,7 +346,7 @@ describe('CID', () => { const hash = await sha256.digest(textEncoder.encode(`TEST${Date.now()}`)) const cid = CID.create(1, 0x71, hash) assert.throws( - () => cid.toV0(), + () => CID.toV0(cid), 'Cannot convert a non dag-pb CID to CIDv0' ) }) @@ -367,7 +355,7 @@ describe('CID', () => { const hash = await sha512.digest(textEncoder.encode(`TEST${Date.now()}`)) const cid = CID.create(1, 112, hash) assert.throws( - () => cid.toV0(), + () => CID.toV0(cid), 'Cannot convert non sha2-256 multihash CID to CIDv0' ) }) @@ -376,13 +364,13 @@ describe('CID', () => { const hash = await sha512.digest(textEncoder.encode(`TEST${Date.now()}`)) const cid = CID.create(1, 112, hash) - assert.deepStrictEqual(cid.toV1() === cid, true) + assert.deepStrictEqual(CID.toV1(cid) === cid, true) }) it('should return assert.deepStrictEqual instance when converting v0 to v0', async () => { const hash = await sha256.digest(textEncoder.encode(`TEST${Date.now()}`)) const cid = CID.create(0, 112, hash) - assert.deepStrictEqual(cid.toV0() === cid, true) + assert.deepStrictEqual(CID.toV0(cid) === cid, true) }) it('should fail to convert unknown version', async () => { @@ -396,16 +384,16 @@ describe('CID', () => { version: 3 }) - assert.deepStrictEqual(cid1.toV0().version, 0) - assert.deepStrictEqual(cid1.toV1().version, 1) + assert.deepStrictEqual(CID.toV0(cid1).version, 0) + assert.deepStrictEqual(CID.toV1(cid1).version, 1) assert.equal(cid2.version, 3) assert.throws( - () => cid2.toV1(), + () => CID.toV1(cid2), /Can not convert CID version 3 to version 1/ ) assert.throws( - () => cid2.toV0(), + () => CID.toV0(cid2), /Can not convert CID version 3 to version 0/ ) }) @@ -419,80 +407,44 @@ describe('CID', () => { assert.deepStrictEqual(cid.bytes, cid.bytes) }) - it('should cache string representation when it matches the multibaseName it was constructed with', async () => { + it('should cache string representations', async () => { const hash = await sha256.digest(textEncoder.encode('abc')) const cid = CID.create(1, 112, hash) + let callCount = 0 - const b32 = { - ...base32, - callCount: 0, - /** - * @param {Uint8Array} bytes - */ - encode (bytes) { - this.callCount += 1 - return base32.encode(bytes) + '!' - } - } + const encode = base32.encode - const b64 = { - ...base64, - callCount: 0, - /** - * @param {Uint8Array} bytes - */ - encode (bytes) { - this.callCount += 1 - return base64.encode(bytes) - } + base32.encode = (bytes) => { + callCount++ + return encode.call(base32, bytes) } - const base32String = - 'bafybeif2pall7dybz7vecqka3zo24irdwabwdi4wc55jznaq75q7eaavvu' - assert.deepEqual(cid.toString(b32), `${base32String}!`) - assert.deepEqual(b32.callCount, 1) - assert.deepEqual(cid.toString(), `${base32String}!`) - assert.deepEqual(b32.callCount, 1) + assert.equal(callCount, 0) + const base32String = 'bafybeif2pall7dybz7vecqka3zo24irdwabwdi4wc55jznaq75q7eaavvu' + assert.equal(cid.toString(), base32String) + assert.equal(callCount, 1) + assert.equal(cid.toString(), base32String) + assert.equal(callCount, 1) - assert.deepStrictEqual( - cid.toString(b64), - 'mAXASILp4Fr+PAc/qQUFA3l2uIiOwA2Gjlhd6nLQQ/2HyABWt' - ) - assert.equal(b64.callCount, 1) - assert.deepStrictEqual( - cid.toString(b64), - 'mAXASILp4Fr+PAc/qQUFA3l2uIiOwA2Gjlhd6nLQQ/2HyABWt' - ) - assert.equal(b64.callCount, 1) + base32.encode = encode }) it('should cache string representation when constructed with one', () => { - const base32String = - 'bafybeif2pall7dybz7vecqka3zo24irdwabwdi4wc55jznaq75q7eaavvu' - const cid = CID.parse(base32String) + let callCount = 0 + const encode = base32.encode - assert.deepStrictEqual( - cid.toString({ - ...base32, - encode () { - throw Error('Should not call decode') - } - }), - base32String - ) - }) - }) + base32.encode = (bytes) => { + callCount++ + return encode.call(base32, bytes) + } - it('toJSON()', async () => { - const hash = await sha256.digest(textEncoder.encode('abc')) - const cid = CID.create(1, 112, hash) - const json = cid.toJSON() + const base32String = 'bafybeif2pall7dybz7vecqka3zo24irdwabwdi4wc55jznaq75q7eaavvu' + const cid = CID.parse(base32String) + assert.equal(cid.toString(), base32String) + assert.equal(callCount, 0) - assert.deepStrictEqual( - { ...json, hash: null }, - { code: 112, version: 1, hash: null } - ) - assert.ok(equals(json.hash, hash.bytes)) + base32.encode = encode + }) }) it('asCID', async () => { @@ -523,8 +475,7 @@ describe('CID', () => { // @ts-expect-error - no such method assert.strictEqual(typeof incompatibleCID.toV0, 'undefined') - const cid1 = /** @type {CID} */ (CID.asCID(incompatibleCID)) - assert.ok(cid1 instanceof CID) + const cid1 = /** @type {CIDAPI.CID} */ (CID.asCID(incompatibleCID)) assert.strictEqual(cid1.code, code) assert.strictEqual(cid1.version, version) assert.ok(equals(cid1.multihash.bytes, hash.bytes)) @@ -535,8 +486,7 @@ describe('CID', () => { const duckCID = { version, code, multihash: hash } // @ts-expect-error - no such property duckCID.asCID = duckCID - const cid3 = /** @type {CID} */ (CID.asCID(duckCID)) - assert.ok(cid3 instanceof CID) + const cid3 = /** @type {CIDAPI.CID} */ (CID.asCID(duckCID)) assert.strictEqual(cid3.code, code) assert.strictEqual(cid3.version, version) assert.ok(equals(cid3.multihash.bytes, hash.bytes)) @@ -544,18 +494,17 @@ describe('CID', () => { const cid4 = CID.asCID(cid3) assert.strictEqual(cid3, cid4) - const cid5 = /** @type {CID} */ ( + const cid5 = /** @type {CIDAPI.CID} */ ( CID.asCID(new OLDCID(1, 'raw', Uint8Array.from(hash.bytes))) ) - assert.ok(cid5 instanceof CID) assert.strictEqual(cid5.version, 1) assert.ok(equals(cid5.multihash.bytes, hash.bytes)) assert.strictEqual(cid5.code, 85) }) /** - * @param {API.CID} x - * @param {API.CID} y + * @param {CIDAPI.CID} x + * @param {CIDAPI.CID} y */ const digestsame = (x, y) => { // @ts-ignore - not sure what this supposed to be @@ -592,7 +541,7 @@ describe('CID', () => { const hash = await sha256.digest(textEncoder.encode('abc')) const cid = CID.create(1, 112, hash) - const parsed = CID.parse(cid.toString(base58btc)) + const parsed = CID.parse(base58btc.encode(cid.bytes)) digestsame(cid, parsed) }) @@ -610,14 +559,14 @@ describe('CID', () => { const msg = 'To parse non base32 or base58btc encoded CID multibase decoder must be provided' - assert.throws(() => CID.parse(cid.toString(base64)), msg) + assert.throws(() => CID.parse(base64.encode(cid.bytes)), msg) }) it('parses base64 encoded CIDv1 if base64 is provided', async () => { const hash = await sha256.digest(textEncoder.encode('abc')) const cid = CID.create(1, 112, hash) - const parsed = CID.parse(cid.toString(base64), base64) + const parsed = CID.parse(base64.encode(cid.bytes), base64) digestsame(cid, parsed) }) }) @@ -668,7 +617,7 @@ describe('CID', () => { it('new CID from old CID', async () => { const hash = await sha256.digest(textEncoder.encode('abc')) - const cid = /** @type {CID} */ ( + const cid = /** @type {CIDAPI.CID} */ ( CID.asCID(new OLDCID(1, 'raw', Uint8Array.from(hash.bytes))) ) assert.deepStrictEqual(cid.version, 1) @@ -708,4 +657,32 @@ describe('CID', () => { sender.close() receiver.close() }) + + describe('decode', () => { + const tests = { + v0: 'QmTFHZL5CkgNz19MdPnSuyLAi6AVq9fFp81zmPpaL2amED', + v1: 'bafybeif2pall7dybz7vecqka3zo24irdwabwdi4wc55jznaq75q7eaavvu' + } + + Object.entries(tests).forEach(([version, cidString]) => { + it(`decode ${version} from bytes`, () => { + const cid1 = CID.parse(cidString) + const cid2 = CID.decode(cid1.bytes) + + assert.deepStrictEqual(cid1, cid2) + }) + + it(`decode ${version} from subarray`, () => { + const cid1 = CID.parse(cidString) + // a byte array with an extra byte at the start and end + const bytes = new Uint8Array(cid1.bytes.length + 2) + bytes.set(cid1.bytes, 1) + // slice the cid bytes out of the middle to have a subarray with a non-zero .byteOffset + const subarray = bytes.subarray(1, cid1.bytes.length + 1) + const cid2 = CID.decode(subarray) + + assert.deepStrictEqual(cid1, cid2) + }) + }) + }) }) diff --git a/test/test-link.spec.js b/test/test-link.spec.js index 67f320fa..6be91cb3 100644 --- a/test/test-link.spec.js +++ b/test/test-link.spec.js @@ -4,6 +4,9 @@ import * as Link from '../src/link.js' import chai from 'chai' import chaiAsPromised from 'chai-as-promised' import { sha256 } from '../src/hashes/sha2.js' +import { base32 } from '../src/bases/base32.js' +import { base64 } from '../src/bases/base64.js' +import { base58btc } from '../src/bases/base58.js' chai.use(chaiAsPromised) const { assert } = chai @@ -92,6 +95,50 @@ describe('Link', () => { assert.ok(t2) }) }) + + describe('toString', () => { + it('throws on trying to base encode legacy links in other base than base58btc', async () => { + const hash = await sha256.digest(utf8.encode('abc')) + const link = Link.createLegacy(hash) + + const msg = 'Cannot string encode V0 in base32 encoding' + assert.throws(() => link.toString(base32), msg) + }) + + it('encode legacy links in base base58btc', async () => { + const hash = await sha256.digest(utf8.encode('abc')) + const link = Link.createLegacy(hash) + + assert.equal(link.toString(), 'QmatYkNGZnELf8cAGdyJpUca2PyY4szai3RHyyWofNY1pY') + assert.equal(link.toString(base58btc), 'QmatYkNGZnELf8cAGdyJpUca2PyY4szai3RHyyWofNY1pY') + }) + + it('encode v1 links', async () => { + const hash = await sha256.digest(utf8.encode('abc')) + const link = Link.create(0x71, hash) + + assert.equal(link.toString(), 'bafyreif2pall7dybz7vecqka3zo24irdwabwdi4wc55jznaq75q7eaavvu') + assert.equal(link.toString(base58btc), 'zdpuAxyLXBdHyrzwJpctQJpxH6cnuEAQwbf8VSWJ5NL5JPEjN') + assert.equal(link.toString(base32), 'bafyreif2pall7dybz7vecqka3zo24irdwabwdi4wc55jznaq75q7eaavvu') + assert.equal(link.toString(base64), 'mAXESILp4Fr+PAc/qQUFA3l2uIiOwA2Gjlhd6nLQQ/2HyABWt') + }) + }) + + describe('link', () => { + it('.link() should return this v0 Link', async () => { + const hash = await sha256.digest(utf8.encode('abc')) + const link = Link.createLegacy(hash) + + assert.equal(link, link.link()) + }) + + it('.link() should return this v1 Link', async () => { + const hash = await sha256.digest(utf8.encode('abc')) + const link = Link.create(0x71, hash) + + assert.equal(link, link.link()) + }) + }) }) describe('decode', () => { diff --git a/test/test-traversal.spec.js b/test/test-traversal.spec.js index 62f9c2bd..500b0c95 100644 --- a/test/test-traversal.spec.js +++ b/test/test-traversal.spec.js @@ -6,7 +6,7 @@ import { walk } from '../src/traversal.js' import { fromString } from '../src/bytes.js' import { assert } from 'chai' -/** @typedef {import('../src/cid.js').CID} CID */ +/** @typedef {import('../src/cid/interface.js').CID} CID */ // from dag-pb, simplified /**