diff --git a/src/backend/tests/utils/strings.test.ts b/src/backend/tests/utils/strings.test.ts index 2b108a78..2aae77bb 100644 --- a/src/backend/tests/utils/strings.test.ts +++ b/src/backend/tests/utils/strings.test.ts @@ -4,6 +4,8 @@ import { compareNormalizedStrings } from "../../../core/StringUtils.ts"; import { normalizeStr } from "../../../core/StringUtils.ts"; import { replaceInterpolatedValues } from "../../utils/DataUtils.ts"; import { splitByFirstFound } from '../../../core/StringUtils.ts'; +import { hashObject as coreHashObject } from '../../../core/StringUtils.ts'; +import { hashObject } from '../../utils/StringUtils.ts'; import { noCasePropObj } from '../../utils/DataUtils.ts'; import { isrcNoHyphens, isrcWithHyphens, REGEX_ISRC_HYPHENS, REGEX_ISRC_NO_HYPHENS } from '../../../core/PlayUtils.ts'; @@ -254,4 +256,23 @@ describe('ISRC Parsing', function() { }); }); }); -}); \ No newline at end of file +}); + +describe('Object Hashing', function () { + + const track = { name: 'Foo', metadata: [{ service: 'musicbrainz', id: '1' }] }; + + it('core hash ignores property order', function () { + expect(coreHashObject(track)).to.eq(coreHashObject({ metadata: [{ id: '1', service: 'musicbrainz' }], name: 'Foo' })); + }); + + it('core hash differs when nested data differs', function () { + expect(coreHashObject(track)).to.not.eq(coreHashObject({ ...track, metadata: [{ service: 'musicbrainz', id: '2' }] })); + expect(coreHashObject(track)).to.not.eq(coreHashObject({ name: 'Foo' })); + }); + + it('backend hash is still sha256', function () { + expect(hashObject(track)).to.match(/^[a-f0-9]{64}$/); + expect(hashObject(track)).to.not.eq(coreHashObject(track)); + }); +}); diff --git a/src/backend/utils/StringUtils.ts b/src/backend/utils/StringUtils.ts index 0b0a0e9d..101611db 100644 --- a/src/backend/utils/StringUtils.ts +++ b/src/backend/utils/StringUtils.ts @@ -6,7 +6,7 @@ import { DELIMITERS_NO_AMP } from '../../core/Atomic.ts'; import { DELIMITERS } from '../../core/Atomic.ts'; import { getPlatformIdFromData, parseBoolStrict } from "../utils.ts"; import { genGroupIdStr } from '../../core/PlayUtils.ts'; -import { compareNormalizedStrings, normalizeStr } from "../../core/StringUtils.ts"; +import { compareNormalizedStrings, normalizeStr, hashObject as coreHashObject, type HashFunction } from "../../core/StringUtils.ts"; import { buildTrackString } from "../../core/MusicMetadata.ts"; import { parseRegexSingle } from "@foxxmd/regex-buddy-core"; @@ -370,10 +370,10 @@ export const normalizeListenbrainzUrl = (urlVal: string): string | undefined => return undefined; } -type HashFunction = (obj: object) => string; const defaultHasher = hasher(); const defaultHashFunc: HashFunction = (obj) => defaultHasher.hash(obj); -export const hashObject = (obj: object, h: HashFunction = defaultHashFunc): string => h(obj); +// same as core hashObject but defaults to sha256, these hashes are persisted +export const hashObject = (obj: object, h: HashFunction = defaultHashFunc): string => coreHashObject(obj, h); const NON_ALPHANUMWHITESPACE_CHARS: RegExp = new RegExp(/[^a-zA-Z\d\s]/); export const hasNonAlphanumericChars = (str: string): boolean => { diff --git a/src/client/components/play/PlayEdit.tsx b/src/client/components/play/PlayEdit.tsx index ddfb5c84..5a055228 100644 --- a/src/client/components/play/PlayEdit.tsx +++ b/src/client/components/play/PlayEdit.tsx @@ -9,6 +9,7 @@ import { useState } from "react"; import { AlbumSearch } from "./AlbumSearch.js"; import { DurationSepEditable } from "./DurationEditable.js"; import { Tooltip } from "../ToggleTip.js"; +import { hashObject } from "../../../core/StringUtils.js"; export interface PlayEditProps { initialPlay?: PlayObjectMinimal @@ -94,7 +95,10 @@ export const PlayEdit = (props: PlayEditProps) => { Track (Title) - { // selected credits replace existing ones entirely so ids/images from a previous selection are not kept field.form.setFieldValue('data', { ...field.form.state.values.data, ...val }); @@ -168,7 +172,7 @@ export const PlayEdit = (props: PlayEditProps) => { { field.form.setFieldValue('data', { ...field.form.state.values.data, ...val }); diff --git a/src/core/StringUtils.ts b/src/core/StringUtils.ts index c3bfb1cd..1dbf00dd 100644 --- a/src/core/StringUtils.ts +++ b/src/core/StringUtils.ts @@ -5,6 +5,8 @@ import isBetween from "dayjs/plugin/isBetween.js"; import relativeTime from "dayjs/plugin/relativeTime.js"; import timezone from "dayjs/plugin/timezone.js"; import utc from "dayjs/plugin/utc.js"; +// deep import so the browser bundle does not pull in the package entrypoint, which requires node crypto +import { objectSorter } from "node-object-hash/dist/objectSorter.js"; import { type AmbPlayObject, SCROBBLE_TS_SOC_START, @@ -307,3 +309,35 @@ export const compareNormalizedStrings = (existing: string, candidate: string): S }); }; +const sortObject = objectSorter(); + +/** + * Fast non-cryptographic 53-bit string hash (cyrb53). + * + * Not for security or for hashes that are persisted/compared with backend hashes. + * + * This is used solely for synchronous hashing in the browser on things like React keys + * derived from plain objects in non-critical functionality. + * Can't use browser built-in crypto since its async and would be a headache to get it to work + * with React rendering just for a single hash + */ +export const cheapHash = (str: string, seed = 0): string => { + let h1 = 0xdeadbeef ^ seed, h2 = 0x41c6ce57 ^ seed; + for (let i = 0; i < str.length; i++) { + const ch = str.charCodeAt(i); + h1 = Math.imul(h1 ^ ch, 2654435761); + h2 = Math.imul(h2 ^ ch, 1597334677); + } + h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909); + h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909); + return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(16); +}; + +export type HashFunction = (obj: object) => string; +const cheapHashFunc: HashFunction = (obj) => cheapHash(sortObject(obj)); +/** + * Hash an object, independent of property order. + * + * Browser-safe. Defaults to a cheap non-cryptographic hash, backend code should use hashObject from backend StringUtils (sha256). + */ +export const hashObject = (obj: object, h: HashFunction = cheapHashFunc): string => h(obj);