Skip to content

feat: support truncating digests #329

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Aug 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 75 additions & 8 deletions src/hashes/hasher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,43 @@ import type { MultihashHasher } from './interface.js'

type Await<T> = Promise<T> | T

export function from <Name extends string, Code extends number> ({ name, code, encode }: { name: Name, code: Code, encode(input: Uint8Array): Await<Uint8Array> }): Hasher<Name, Code> {
return new Hasher(name, code, encode)
const DEFAULT_MIN_DIGEST_LENGTH = 20

export interface HasherInit <Name extends string, Code extends number> {
name: Name
code: Code
encode(input: Uint8Array): Await<Uint8Array>

/**
* The minimum length a hash is allowed to be truncated to in bytes
*
* @default 20
*/
minDigestLength?: number

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Without any other customization will this break small identity multihashes?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't do, unless you identity.digest(fromString('test'), { truncate: 3 }), this limit doesn't get touched unless you ask to use the new truncate feature so this change should be entirely backward compatible and non-breaking

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Turns out the types wouldn't allow passing options to identity.digest as it doesn't implement the same interface as things returned from from (see comment) - I've fixed this up here, still non-breaking.


/**
* The maximum length a hash is allowed to be truncated to in bytes. If not
* specified it will be inferred from the length of the digest.
*/
maxDigestLength?: number
}

export function from <Name extends string, Code extends number> ({ name, code, encode, minDigestLength, maxDigestLength }: HasherInit<Name, Code>): Hasher<Name, Code> {
return new Hasher(name, code, encode, minDigestLength, maxDigestLength)
}

export interface DigestOptions {
Copy link
Contributor

@2color 2color Jul 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd add the unit type in the JS doc so it's clear truncate is in bytes.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added a doc comment, let me know if you think it's clear.

/**
* Truncate the returned digest to this number of bytes.
*
* This may cause the digest method to throw/reject if the passed value is
* greater than the digest length or below a threshold under which the risk of
* hash collisions is significant.
*
* The actual value of this threshold can depend on the hashing algorithm in
* use.
*/
truncate?: number
}

/**
Expand All @@ -15,23 +50,55 @@ export class Hasher<Name extends string, Code extends number> implements Multiha
readonly name: Name
readonly code: Code
readonly encode: (input: Uint8Array) => Await<Uint8Array>
readonly minDigestLength: number
readonly maxDigestLength?: number

constructor (name: Name, code: Code, encode: (input: Uint8Array) => Await<Uint8Array>) {
constructor (name: Name, code: Code, encode: (input: Uint8Array) => Await<Uint8Array>, minDigestLength?: number, maxDigestLength?: number) {
this.name = name
this.code = code
this.encode = encode
this.minDigestLength = minDigestLength ?? DEFAULT_MIN_DIGEST_LENGTH
this.maxDigestLength = maxDigestLength
}

digest (input: Uint8Array): Await<Digest.Digest<Code, number>> {
digest (input: Uint8Array, options?: DigestOptions): Await<Digest.Digest<Code, number>> {
if (options?.truncate != null) {
if (options.truncate < this.minDigestLength) {
throw new Error(`Invalid truncate option, must be greater than or equal to ${this.minDigestLength}`)
}

if (this.maxDigestLength != null && options.truncate > this.maxDigestLength) {
throw new Error(`Invalid truncate option, must be less than or equal to ${this.maxDigestLength}`)
}
}

if (input instanceof Uint8Array) {
const result = this.encode(input)
return result instanceof Uint8Array
? Digest.create(this.code, result)
/* c8 ignore next 1 */
: result.then(digest => Digest.create(this.code, digest))

if (result instanceof Uint8Array) {
return createDigest(result, this.code, options?.truncate)
}

return result.then(digest => createDigest(digest, this.code, options?.truncate))
} else {
throw Error('Unknown type, must be binary type')
/* c8 ignore next 1 */
}
}
}

/**
* Create a Digest from the passed uint8array and code, optionally truncating it
* first.
*/
function createDigest <Code extends number> (digest: Uint8Array, code: Code, truncate?: number): Digest.Digest<Code, number> {
if (truncate != null && truncate !== digest.byteLength) {
if (truncate > digest.byteLength) {
throw new Error(`Invalid truncate option, must be less than or equal to ${digest.byteLength}`)
}

digest = digest.subarray(0, truncate)
}

return Digest.create(code, digest)
}
11 changes: 10 additions & 1 deletion src/hashes/identity.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
import { coerce } from '../bytes.js'
import * as Digest from './digest.js'
import type { DigestOptions } from './hasher.js'

const code: 0x0 = 0x0
const name = 'identity'

const encode: (input: Uint8Array) => Uint8Array = coerce

function digest (input: Uint8Array): Digest.Digest<typeof code, number> {
function digest (input: Uint8Array, options?: DigestOptions): Digest.Digest<typeof code, number> {
if (options?.truncate != null && options.truncate !== input.byteLength) {
if (options.truncate < 0 || options.truncate > input.byteLength) {
throw new Error(`Invalid truncate option, must be less than or equal to ${input.byteLength}`)
}

input = input.subarray(0, options.truncate)
}

return Digest.create(code, encode(input))
}

Expand Down
6 changes: 4 additions & 2 deletions src/hashes/interface.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// # Multihash

import type { DigestOptions } from './hasher.js'

/**
* Represents a multihash digest which carries information about the
* hashing algorithm and an actual hash digest.
Expand Down Expand Up @@ -42,7 +44,7 @@ export interface MultihashHasher<Code extends number = number> {
* while performance critical code may asses return value to decide whether
* await is needed.
*/
digest(input: Uint8Array): Promise<MultihashDigest<Code>> | MultihashDigest<Code>
digest(input: Uint8Array, options?: DigestOptions): Promise<MultihashDigest<Code>> | MultihashDigest<Code>

/**
* Name of the multihash
Expand All @@ -66,5 +68,5 @@ export interface MultihashHasher<Code extends number = number> {
* impractical e.g. implementation of Hash Array Mapped Trie (HAMT).
*/
export interface SyncMultihashHasher<Code extends number = number> extends MultihashHasher<Code> {
digest(input: Uint8Array): MultihashDigest<Code>
digest(input: Uint8Array, options?: DigestOptions): MultihashDigest<Code>
}
78 changes: 78 additions & 0 deletions test/test-multihash.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,32 @@ describe('multihash', () => {
assert.deepStrictEqual(hash2.bytes, hash.bytes)
})

it('hash sha2-256 truncated', async () => {
const hash = await sha256.digest(fromString('test'), {
truncate: 24
})
assert.deepStrictEqual(hash.code, sha256.code)
assert.deepStrictEqual(hash.digest.byteLength, 24)

const hash2 = decodeDigest(hash.bytes)
assert.deepStrictEqual(hash2.code, sha256.code)
assert.deepStrictEqual(hash2.bytes, hash.bytes)
})

it('hash sha2-256 truncated (invalid option)', async () => {
await assert.isRejected((async () => {
await sha256.digest(fromString('test'), {
truncate: 10
})
})(), /Invalid truncate option/)

await assert.isRejected((async () => {
await sha256.digest(fromString('test'), {
truncate: 64
})
})(), /Invalid truncate option/)
})

if (typeof navigator === 'undefined') {
it('sync sha-256', () => {
const hash = sha256.digest(fromString('test'))
Expand All @@ -97,6 +123,32 @@ describe('multihash', () => {
assert.deepStrictEqual(hash2.bytes, hash.bytes)
})

it('hash sha2-512 truncated', async () => {
const hash = await sha512.digest(fromString('test'), {
truncate: 32
})
assert.deepStrictEqual(hash.code, sha512.code)
assert.deepStrictEqual(hash.digest.byteLength, 32)

const hash2 = decodeDigest(hash.bytes)
assert.deepStrictEqual(hash2.code, sha512.code)
assert.deepStrictEqual(hash2.bytes, hash.bytes)
})

it('hash sha2-512 truncated (invalid option)', async () => {
await assert.isRejected((async () => {
await sha512.digest(fromString('test'), {
truncate: 10
})
})(), /Invalid truncate option/)

await assert.isRejected((async () => {
await sha512.digest(fromString('test'), {
truncate: 100
})
})(), /Invalid truncate option/)
})

it('hash identity async', async () => {
// eslint-disable-next-line @typescript-eslint/await-thenable
const hash = await identity.digest(fromString('test'))
Expand All @@ -119,6 +171,32 @@ describe('multihash', () => {
assert.deepStrictEqual(hash2.code, identity.code)
assert.deepStrictEqual(hash2.bytes, hash.bytes)
})

it('hash identity truncated', async () => {
const hash = identity.digest(fromString('test'), {
truncate: 2
})
assert.deepStrictEqual(hash.code, identity.code)
assert.deepStrictEqual(hash.digest.byteLength, 2)

const hash2 = decodeDigest(hash.bytes)
assert.deepStrictEqual(hash2.code, identity.code)
assert.deepStrictEqual(hash2.bytes, hash.bytes)
})

it('hash identity truncated (invalid option)', async () => {
assert.throws(() => {
identity.digest(fromString('test'), {
truncate: -1
})
}, /Invalid truncate option/)

assert.throws(() => {
identity.digest(fromString('test'), {
truncate: 100
})
}, /Invalid truncate option/)
})
})
describe('decode', () => {
for (const { encoding, hex, size } of valid) {
Expand Down
Loading