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 3 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
75 changes: 67 additions & 8 deletions src/hashes/hasher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,35 @@ 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
const DEFAULT_MAX_DIGEST_LENGTH = 128

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 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 in bytes
*
* @default 128
*/
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?: number
}

/**
Expand All @@ -15,23 +42,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 ?? DEFAULT_MAX_DIGEST_LENGTH
}

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 (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)
}
3 changes: 2 additions & 1 deletion src/hashes/sha1-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ const sha = (name: AlgorithmIdentifier) =>
export const sha1 = from({
name: 'sha-1',
code: 0x11,
encode: sha('SHA-1')
encode: sha('SHA-1'),
maxDigestLength: 20
})
3 changes: 2 additions & 1 deletion src/hashes/sha1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ import { from } from './hasher.js'
export const sha1 = from({
name: 'sha-1',
code: 0x11,
encode: (input) => coerce(crypto.createHash('sha1').update(input).digest())
encode: (input) => coerce(crypto.createHash('sha1').update(input).digest()),
maxDigestLength: 20
})
6 changes: 4 additions & 2 deletions src/hashes/sha2-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ function sha (name: AlgorithmIdentifier): (data: Uint8Array) => Promise<Uint8Arr
export const sha256 = from({
name: 'sha2-256',
code: 0x12,
encode: sha('SHA-256')
encode: sha('SHA-256'),
maxDigestLength: 32
})

export const sha512 = from({
name: 'sha2-512',
code: 0x13,
encode: sha('SHA-512')
encode: sha('SHA-512'),
maxDigestLength: 64
})
6 changes: 4 additions & 2 deletions src/hashes/sha2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import { from } from './hasher.js'
export const sha256 = from({
name: 'sha2-256',
code: 0x12,
encode: (input) => coerce(crypto.createHash('sha256').update(input).digest())
encode: (input) => coerce(crypto.createHash('sha256').update(input).digest()),
maxDigestLength: 32
})

export const sha512 = from({
name: 'sha2-512',
code: 0x13,
encode: input => coerce(crypto.createHash('sha512').update(input).digest())
encode: input => coerce(crypto.createHash('sha512').update(input).digest()),
maxDigestLength: 64
})
52 changes: 52 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 () => {
assert.throws(() => {
void sha256.digest(fromString('test'), {
truncate: 10
})
}, /Invalid truncate option/)

assert.throws(() => {
void 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 () => {
assert.throws(() => {
void sha512.digest(fromString('test'), {
truncate: 10
})
}, /Invalid truncate option/)

assert.throws(() => {
void 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 Down
Loading