diff --git a/package.json b/package.json index cbbb9d6..cc86a73 100644 --- a/package.json +++ b/package.json @@ -184,6 +184,7 @@ "clean": "aegir clean", "lint": "aegir lint", "build": "aegir build", + "build:validator": "npx @ipld/schema to-js src/header.ipldsch > src/header-validator.js", "release": "aegir release", "test": "npm run lint && aegir test && npm run test:examples", "test:node": "aegir test -t node --cov", @@ -227,5 +228,8 @@ "ignore": [ "dist" ] - } + }, + "eslintIgnore": [ + "src/header-validator.js" + ] } diff --git a/src/buffer-decoder.js b/src/buffer-decoder.js index 7dd7fa2..8164a83 100644 --- a/src/buffer-decoder.js +++ b/src/buffer-decoder.js @@ -2,7 +2,7 @@ import { decode as decodeDagCbor } from '@ipld/dag-cbor' import { CID } from 'multiformats/cid' import * as Digest from 'multiformats/hashes/digest' import { CIDV0_BYTES, decodeV2Header, decodeVarint, getMultihashLength, V2_HEADER_LENGTH } from './decoder-common.js' -import { CarHeader as headerValidator } from './header-validator.js' +import { CarV1HeaderOrV2Pragma } from './header-validator.js' /** * @typedef {import('./api').Block} Block @@ -30,22 +30,23 @@ export function readHeader (reader, strictVersion) { } const header = reader.exactly(length, true) const block = decodeDagCbor(header) - if (!headerValidator(block)) { + if (CarV1HeaderOrV2Pragma.toTyped(block) === undefined) { throw new Error('Invalid CAR header format') } if ((block.version !== 1 && block.version !== 2) || (strictVersion !== undefined && block.version !== strictVersion)) { throw new Error(`Invalid CAR version: ${block.version}${strictVersion !== undefined ? ` (expected ${strictVersion})` : ''}`) } - // we've made 'roots' optional in the schema so we can do the version check - // before rejecting the block as invalid if there is no version - const hasRoots = Array.isArray(block.roots) - if ((block.version === 1 && !hasRoots) || (block.version === 2 && hasRoots)) { - throw new Error('Invalid CAR header format') - } if (block.version === 1) { + // CarV1HeaderOrV2Pragma makes roots optional, let's make it mandatory + if (!Array.isArray(block.roots)) { + throw new Error('Invalid CAR header format') + } return block } // version 2 + if (block.roots !== undefined) { + throw new Error('Invalid CAR header format') + } const v2Header = decodeV2Header(reader.exactly(V2_HEADER_LENGTH, true)) reader.seek(v2Header.dataOffset - reader.pos) const v1Header = readHeader(reader, 1) diff --git a/src/decoder.js b/src/decoder.js index 7dbcfd1..fe6b06e 100644 --- a/src/decoder.js +++ b/src/decoder.js @@ -2,7 +2,7 @@ import { decode as decodeDagCbor } from '@ipld/dag-cbor' import { CID } from 'multiformats/cid' import * as Digest from 'multiformats/hashes/digest' import { CIDV0_BYTES, decodeV2Header, decodeVarint, getMultihashLength, V2_HEADER_LENGTH } from './decoder-common.js' -import { CarHeader as headerValidator } from './header-validator.js' +import { CarV1HeaderOrV2Pragma } from './header-validator.js' /** * @typedef {import('./api').Block} Block @@ -31,22 +31,23 @@ export async function readHeader (reader, strictVersion) { } const header = await reader.exactly(length, true) const block = decodeDagCbor(header) - if (!headerValidator(block)) { + if (CarV1HeaderOrV2Pragma.toTyped(block) === undefined) { throw new Error('Invalid CAR header format') } if ((block.version !== 1 && block.version !== 2) || (strictVersion !== undefined && block.version !== strictVersion)) { throw new Error(`Invalid CAR version: ${block.version}${strictVersion !== undefined ? ` (expected ${strictVersion})` : ''}`) } - // we've made 'roots' optional in the schema so we can do the version check - // before rejecting the block as invalid if there is no version - const hasRoots = Array.isArray(block.roots) - if ((block.version === 1 && !hasRoots) || (block.version === 2 && hasRoots)) { - throw new Error('Invalid CAR header format') - } if (block.version === 1) { + // CarV1HeaderOrV2Pragma makes roots optional, let's make it mandatory + if (!Array.isArray(block.roots)) { + throw new Error('Invalid CAR header format') + } return block } // version 2 + if (block.roots !== undefined) { + throw new Error('Invalid CAR header format') + } const v2Header = decodeV2Header(await reader.exactly(V2_HEADER_LENGTH, true)) reader.seek(v2Header.dataOffset - reader.pos) const v1Header = await readHeader(reader, 1) diff --git a/src/header-validator.js b/src/header-validator.js index d666537..5fd8a1e 100644 --- a/src/header-validator.js +++ b/src/header-validator.js @@ -1,74 +1,214 @@ -/* eslint-disable jsdoc/check-indentation */ - -/** Auto-generated with ipld-schema-validator@0.0.0-dev at Thu Jun 17 2021 from IPLD Schema: +/** Auto-generated with @ipld/schema@v4.2.0 at Thu Sep 14 2023 from IPLD Schema: + * + * # CarV1HeaderOrV2Pragma is a more relaxed form, and can parse {version:x} where + * # roots are optional. This is typically useful for the {verison:2} CARv2 + * # pragma. * - * type CarHeader struct { - * version Int - * roots optional [&Any] - * # roots is _not_ optional for CarV1 but we defer that check within code to - * # gracefully handle the >V1 case where it's just {version:X} + * type CarV1HeaderOrV2Pragma struct { + * roots optional [&Any] + * # roots is _not_ optional for CarV1 but we defer that check within code to + * # gracefully handle the V2 case where it's just {version:X} + * version Int * } * + * # CarV1Header is the strict form of the header, and requires roots to be + * # present. This is compatible with the CARv1 specification. + * + * # type CarV1Header struct { + * # roots [&Any] + * # version Int + * # } + * */ const Kinds = { - Null: /** - * @param {any} obj - * @returns {boolean} - */ (/** @type {any} */ obj) => obj === null, - Int: /** - * @param {any} obj - * @returns {boolean} - */ (/** @type {any} */ obj) => Number.isInteger(obj), - Float: /** - * @param {any} obj - * @returns {boolean} - */ (/** @type {any} */ obj) => typeof obj === 'number' && Number.isFinite(obj), - String: /** - * @param {any} obj - * @returns {boolean} - */ (/** @type {any} */ obj) => typeof obj === 'string', - Bool: /** - * @param {any} obj - * @returns {boolean} - */ (/** @type {any} */ obj) => typeof obj === 'boolean', - Bytes: /** - * @param {any} obj - * @returns {boolean} - */ (/** @type {any} */ obj) => obj instanceof Uint8Array, - Link: /** - * @param {any} obj - * @returns {boolean} - */ (/** @type {any} */ obj) => !Kinds.Null(obj) && typeof obj === 'object' && obj.asCID === obj, - List: /** - * @param {any} obj - * @returns {boolean} - */ (/** @type {any} */ obj) => Array.isArray(obj), - Map: /** - * @param {any} obj - * @returns {boolean} - */ (/** @type {any} */ obj) => !Kinds.Null(obj) && typeof obj === 'object' && obj.asCID !== obj && !Kinds.List(obj) && !Kinds.Bytes(obj) + Null: /** @returns {undefined|null} */ (/** @type {any} */ obj) => obj === null ? obj : undefined, + Int: /** @returns {undefined|number} */ (/** @type {any} */ obj) => Number.isInteger(obj) ? obj : undefined, + Float: /** @returns {undefined|number} */ (/** @type {any} */ obj) => typeof obj === 'number' && Number.isFinite(obj) ? obj : undefined, + String: /** @returns {undefined|string} */ (/** @type {any} */ obj) => typeof obj === 'string' ? obj : undefined, + Bool: /** @returns {undefined|boolean} */ (/** @type {any} */ obj) => typeof obj === 'boolean' ? obj : undefined, + Bytes: /** @returns {undefined|Uint8Array} */ (/** @type {any} */ obj) => obj instanceof Uint8Array ? obj : undefined, + Link: /** @returns {undefined|object} */ (/** @type {any} */ obj) => obj !== null && typeof obj === 'object' && obj.asCID === obj ? obj : undefined, + List: /** @returns {undefined|Array} */ (/** @type {any} */ obj) => Array.isArray(obj) ? obj : undefined, + Map: /** @returns {undefined|object} */ (/** @type {any} */ obj) => obj !== null && typeof obj === 'object' && obj.asCID !== obj && !Array.isArray(obj) && !(obj instanceof Uint8Array) ? obj : undefined } -/** @type {{ [k in string]: (obj:any)=>boolean}} */ +/** @type {{ [k in string]: (obj:any)=>undefined|any}} */ const Types = { + 'CarV1HeaderOrV2Pragma > roots (anon) > valueType (anon)': Kinds.Link, + 'CarV1HeaderOrV2Pragma > roots (anon)': /** @returns {undefined|any} */ (/** @type {any} */ obj) => { + if (Kinds.List(obj) === undefined) { + return undefined + } + for (let i = 0; i < obj.length; i++) { + let v = obj[i] + v = Types['CarV1HeaderOrV2Pragma > roots (anon) > valueType (anon)'](v) + if (v === undefined) { + return undefined + } + if (v !== obj[i]) { + const ret = obj.slice(0, i) + for (let j = i; j < obj.length; j++) { + let v = obj[j] + v = Types['CarV1HeaderOrV2Pragma > roots (anon) > valueType (anon)'](v) + if (v === undefined) { + return undefined + } + ret.push(v) + } + return ret + } + } + return obj + }, Int: Kinds.Int, - 'CarHeader > version': /** - * @param {any} obj - * @returns {boolean} - */ (/** @type {any} */ obj) => Types.Int(obj), - 'CarHeader > roots (anon) > valueType (anon)': Kinds.Link, - 'CarHeader > roots (anon)': /** - * @param {any} obj - * @returns {boolean} - */ (/** @type {any} */ obj) => Kinds.List(obj) && Array.prototype.every.call(obj, Types['CarHeader > roots (anon) > valueType (anon)']), - 'CarHeader > roots': /** - * @param {any} obj - * @returns {boolean} - */ (/** @type {any} */ obj) => Types['CarHeader > roots (anon)'](obj), - CarHeader: /** - * @param {any} obj - * @returns {boolean} - */ (/** @type {any} */ obj) => { const keys = obj && Object.keys(obj); return Kinds.Map(obj) && ['version'].every((k) => keys.includes(k)) && Object.entries(obj).every(([name, value]) => Types['CarHeader > ' + name] && Types['CarHeader > ' + name](value)) } + CarV1HeaderOrV2Pragma: /** @returns {undefined|any} */ (/** @type {any} */ obj) => { + if (Kinds.Map(obj) === undefined) { + return undefined + } + const entries = Object.entries(obj) + /** @type {{[k in string]: any}} */ + let ret = obj + let requiredCount = 1 + for (let i = 0; i < entries.length; i++) { + const [key, value] = entries[i] + switch (key) { + case 'roots': + { + const v = Types['CarV1HeaderOrV2Pragma > roots (anon)'](obj[key]) + if (v === undefined) { + return undefined + } + if (v !== value || ret !== obj) { + if (ret === obj) { + /** @type {{[k in string]: any}} */ + ret = {} + for (let j = 0; j < i; j++) { + ret[entries[j][0]] = entries[j][1] + } + } + ret.roots = v + } + } + break + case 'version': + { + requiredCount-- + const v = Types.Int(obj[key]) + if (v === undefined) { + return undefined + } + if (v !== value || ret !== obj) { + if (ret === obj) { + /** @type {{[k in string]: any}} */ + ret = {} + for (let j = 0; j < i; j++) { + ret[entries[j][0]] = entries[j][1] + } + } + ret.version = v + } + } + break + default: + return undefined + } + } + + if (requiredCount > 0) { + return undefined + } + return ret + } +} +/** @type {{ [k in string]: (obj:any)=>undefined|any}} */ +const Reprs = { + 'CarV1HeaderOrV2Pragma > roots (anon) > valueType (anon)': Kinds.Link, + 'CarV1HeaderOrV2Pragma > roots (anon)': /** @returns {undefined|any} */ (/** @type {any} */ obj) => { + if (Kinds.List(obj) === undefined) { + return undefined + } + for (let i = 0; i < obj.length; i++) { + let v = obj[i] + v = Reprs['CarV1HeaderOrV2Pragma > roots (anon) > valueType (anon)'](v) + if (v === undefined) { + return undefined + } + if (v !== obj[i]) { + const ret = obj.slice(0, i) + for (let j = i; j < obj.length; j++) { + let v = obj[j] + v = Reprs['CarV1HeaderOrV2Pragma > roots (anon) > valueType (anon)'](v) + if (v === undefined) { + return undefined + } + ret.push(v) + } + return ret + } + } + return obj + }, + Int: Kinds.Int, + CarV1HeaderOrV2Pragma: /** @returns {undefined|any} */ (/** @type {any} */ obj) => { + if (Kinds.Map(obj) === undefined) { + return undefined + } + const entries = Object.entries(obj) + /** @type {{[k in string]: any}} */ + let ret = obj + let requiredCount = 1 + for (let i = 0; i < entries.length; i++) { + const [key, value] = entries[i] + switch (key) { + case 'roots': + { + const v = Reprs['CarV1HeaderOrV2Pragma > roots (anon)'](value) + if (v === undefined) { + return undefined + } + if (v !== value || ret !== obj) { + if (ret === obj) { + /** @type {{[k in string]: any}} */ + ret = {} + for (let j = 0; j < i; j++) { + ret[entries[j][0]] = entries[j][1] + } + } + ret.roots = v + } + } + break + case 'version': + { + requiredCount-- + const v = Reprs.Int(value) + if (v === undefined) { + return undefined + } + if (v !== value || ret !== obj) { + if (ret === obj) { + /** @type {{[k in string]: any}} */ + ret = {} + for (let j = 0; j < i; j++) { + ret[entries[j][0]] = entries[j][1] + } + } + ret.version = v + } + } + break + default: + return undefined + } + } + if (requiredCount > 0) { + return undefined + } + return ret + } } -export const CarHeader = Types.CarHeader +export const CarV1HeaderOrV2Pragma = { + toTyped: Types.CarV1HeaderOrV2Pragma, + toRepresentation: Reprs.CarV1HeaderOrV2Pragma +} diff --git a/src/header.ipldsch b/src/header.ipldsch index 0f10a23..59bcfd7 100644 --- a/src/header.ipldsch +++ b/src/header.ipldsch @@ -1,6 +1,18 @@ -type CarHeader struct { - version Int - roots optional [&Any] - # roots is _not_ optional for CarV1 but we defer that check within code to - # gracefully handle the >V1 case where it's just {version:X} +# CarV1HeaderOrV2Pragma is a more relaxed form, and can parse {version:x} where +# roots are optional. This is typically useful for the {verison:2} CARv2 +# pragma. + +type CarV1HeaderOrV2Pragma struct { + roots optional [&Any] + # roots is _not_ optional for CarV1 but we defer that check within code to + # gracefully handle the V2 case where it's just {version:X} + version Int } + +# CarV1Header is the strict form of the header, and requires roots to be +# present. This is compatible with the CARv1 specification. + +# type CarV1Header struct { +# roots [&Any] +# version Int +# }