diff --git a/package-lock.json b/package-lock.json index 80c3b0d..bd6afff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "crypto-js": "^4.2.0", "figures": "^6.1.0", "fs-extra": "^11.2.0", + "hi-base32": "^0.5.1", "ignore": "^5.3.2", "inquirer": "^10.2.2", "lodash": "^4.17.21", @@ -37,7 +38,9 @@ "proper-lockfile": "^4.1.2", "superagent": "^10.1.0", "redis": "^4.7.0", - "unzipper": "^0.12.3" + "superagent": "^10.0.0", + "unzipper": "^0.12.3", + "urns": "^0.6.0" }, "devDependencies": { "@types/archiver": "^6.0.2", @@ -3139,6 +3142,11 @@ "node": ">=8" } }, + "node_modules/hi-base32": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/hi-base32/-/hi-base32-0.5.1.tgz", + "integrity": "sha512-EmBBpvdYh/4XxsnUybsPag6VikPYnN30td+vQk+GI3qpahVEG9+gTkG0aXVxTjBqQ5T6ijbWIu77O+C5WFWsnA==" + }, "node_modules/hosted-git-info": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", @@ -5461,6 +5469,11 @@ "node-int64": "^0.4.0" } }, + "node_modules/urns": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/urns/-/urns-0.6.0.tgz", + "integrity": "sha512-KqXGkRiq76KDvw+wHusJL0fSVltnF3Teqf1BK4f1xK3p1u1NAYYBQRsP89nw5CV/y+egjehITVPLh6upfqFdLg==" + }, "node_modules/utf8": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/utf8/-/utf8-3.0.0.tgz", diff --git a/package.json b/package.json index 66d1b48..6bcac98 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "crypto-js": "^4.2.0", "figures": "^6.1.0", "fs-extra": "^11.2.0", + "hi-base32": "^0.5.1", "ignore": "^5.3.2", "inquirer": "^10.2.2", "lodash": "^4.17.21", @@ -53,7 +54,9 @@ "proper-lockfile": "^4.1.2", "superagent": "^10.1.0", "redis": "^4.7.0", - "unzipper": "^0.12.3" + "superagent": "^10.0.0", + "unzipper": "^0.12.3", + "urns": "^0.6.0" }, "devDependencies": { "@types/archiver": "^6.0.2", diff --git a/src/utils/Udi.ts b/src/utils/Udi.ts new file mode 100644 index 0000000..cf662a0 --- /dev/null +++ b/src/utils/Udi.ts @@ -0,0 +1,141 @@ +import * as urns from 'urns'; +import { createHash } from 'crypto'; +import { encode as base32Encode, decode as base32Decode } from 'hi-base32'; + +// +// This class encapsulates the concept of a Universal Data Identifier (UDI) which is a +// standardized way to identify resources across the distributed DIG mesh network. +// The UDI is formatted as follows: +// urn:dig:chainName:storeId:rootHash/resourceKey +// The UDI can be used to uniquely identify resources across the DIG network. +// +class Udi { + readonly chainName: string; + readonly storeId: Buffer; + readonly rootHash: Buffer | null; + readonly resourceKey: string | null; + static readonly nid: string = "dig"; + static readonly namespace: string = `urn:${Udi.nid}`; + + constructor( + chainName: string, + storeId: string | Buffer, + rootHash: string | Buffer | null = null, + resourceKey: string | null = null + ) { + if (!storeId) { + throw new Error("storeId cannot be empty"); + } + this.chainName = chainName || "chia"; + this.storeId = Udi.convertToBuffer(storeId); + this.rootHash = rootHash ? Udi.convertToBuffer(rootHash) : null; + this.resourceKey = resourceKey; + } + + static convertToBuffer(input: string | Buffer): Buffer { + if (Buffer.isBuffer(input)) { + return input; + } + + if (Udi.isHex(input)) { + return Buffer.from(input, 'hex'); + } + + if (Udi.isBase32(input)) { + return Buffer.from(base32Decode(input, false)); // Decode as UTF-8 + } + + throw new Error("Invalid input encoding. Must be 32-byte hex or Base32 string."); + } + + static isHex(input: string): boolean { + return /^[a-fA-F0-9]{64}$/.test(input); + } + + static isBase32(input: string): boolean { + return /^[a-z2-7]{52}$/.test(input.toLowerCase()); + } + + withRootHash(rootHash: string | Buffer | null): Udi { + return new Udi(this.chainName, this.storeId, rootHash, this.resourceKey); + } + + withResourceKey(resourceKey: string | null): Udi { + return new Udi(this.chainName, this.storeId, this.rootHash, resourceKey); + } + + static fromUrn(urn: string): Udi { + const parsedUrn = urns.parseURN(urn); + if (parsedUrn.nid.toLowerCase() !== Udi.nid) { + throw new Error(`Invalid nid: ${parsedUrn.nid}`); + } + + const parts = parsedUrn.nss.split(':'); + if (parts.length < 2) { + throw new Error(`Invalid UDI format: ${parsedUrn.nss}`); + } + + const chainName = parts[0]; + const storeId = parts[1].split('/')[0]; + + let rootHash: string | null = null; + if (parts.length > 2) { + rootHash = parts[2].split('/')[0]; + } + + const pathParts = parsedUrn.nss.split('/'); + let resourceKey: string | null = null; + if (pathParts.length > 1) { + resourceKey = pathParts.slice(1).join('/'); + } + + return new Udi(chainName, storeId, rootHash, resourceKey); + } + + toUrn(encoding: 'hex' | 'base32' = 'hex'): string { + const storeIdStr = this.bufferToString(this.storeId, encoding); + let urn = `${Udi.namespace}:${this.chainName}:${storeIdStr}`; + + if (this.rootHash) { + const rootHashStr = this.bufferToString(this.rootHash, encoding); + urn += `:${rootHashStr}`; + } + + if (this.resourceKey) { + urn += `/${this.resourceKey}`; + } + + return urn; + } + + bufferToString(buffer: Buffer, encoding: 'hex' | 'base32'): string { + return encoding === 'hex' + ? buffer.toString('hex') + : base32Encode(buffer).toLowerCase().replace(/=+$/, ''); + } + + equals(other: Udi): boolean { + return ( + this.storeId.equals(other.storeId) && + this.chainName === other.chainName && + (this.rootHash && other.rootHash ? this.rootHash.equals(other.rootHash) : this.rootHash === other.rootHash) && + this.resourceKey === other.resourceKey + ); + } + + toString(): string { + return this.toUrn(); + } + + clone(): Udi { + return new Udi(this.chainName, this.storeId, this.rootHash, this.resourceKey); + } + + hashCode(): string { + const hash = createHash('sha256'); + hash.update(this.toUrn()); + return hash.digest('hex'); + } +} + +export { Udi }; diff --git a/tests/udi.tests.ts b/tests/udi.tests.ts new file mode 100644 index 0000000..1e5f42c --- /dev/null +++ b/tests/udi.tests.ts @@ -0,0 +1,68 @@ +import { expect } from 'chai'; +import { Udi } from '../src/utils/Udi'; + +describe('Udi', () => { + it('should initialize correctly with all parameters', () => { + const udi = new Udi('chia', 'store1', 'rootHash1', 'resourceKey1'); + expect(udi.chainName).to.equal('chia'); + expect(udi.storeId).to.equal('store1'); + expect(udi.rootHash).to.equal('rootHash1'); + expect(udi.resourceKey).to.equal('resourceKey1'); + }); + + it('should initialize correctly with optional parameters', () => { + const udi = new Udi('chia', 'store1'); + expect(udi.chainName).to.equal('chia'); + expect(udi.storeId).to.equal('store1'); + expect(udi.rootHash).to.be.null; + expect(udi.resourceKey).to.be.null; + }); + + it('should create a new Udi with a different rootHash using withRootHash', () => { + const udi = new Udi('chia', 'store1', 'rootHash1', 'resourceKey1'); + const newUdi = udi.withRootHash('newRootHash'); + expect(newUdi.rootHash).to.equal('newRootHash'); + expect(newUdi.resourceKey).to.equal('resourceKey1'); + }); + + it('should create a new Udi with a different resourceKey using withResourceKey', () => { + const udi = new Udi('chia', 'store1', 'rootHash1', 'resourceKey1'); + const newUdi = udi.withResourceKey('newResourceKey'); + expect(newUdi.resourceKey).to.equal('newResourceKey'); + expect(newUdi.rootHash).to.equal('rootHash1'); + }); + + it('should create a Udi from a valid URN', () => { + const urn = 'urn:dig:chia:store1:rootHash1/resourceKey1'; + const udi = Udi.fromUrn(urn); + expect(udi.chainName).to.equal('chia'); + expect(udi.storeId).to.equal('store1'); + expect(udi.rootHash).to.equal('rootHash1'); + expect(udi.resourceKey).to.equal('resourceKey1'); + }); + + it('should throw an error for an invalid URN namespace', () => { + const urn = 'urn:invalid:chia:store1:rootHash1/resourceKey1'; + expect(() => Udi.fromUrn(urn)).to.throw('Invalid namespace: invalid'); + }); + + it('should convert a Udi to a URN string', () => { + const udi = new Udi('chia', 'store1', 'rootHash1', 'resourceKey1'); + const urn = udi.toUrn(); + expect(urn).to.equal('urn:dig:chia:store1:rootHash1/resourceKey1'); + }); + + it('should correctly compare two Udi instances using equals', () => { + const udi1 = new Udi('chia', 'store1', 'rootHash1', 'resourceKey1'); + const udi2 = new Udi('chia', 'store1', 'rootHash1', 'resourceKey1'); + const udi3 = new Udi('chia', 'store2', 'rootHash1', 'resourceKey1'); + expect(udi1.equals(udi2)).to.be.true; + expect(udi1.equals(udi3)).to.be.false; + }); + + it('should convert a Udi to a string using toString', () => { + const udi = new Udi('chia', 'store1', 'rootHash1', 'resourceKey1'); + const str = udi.toString(); + expect(str).to.equal('urn:dig:chia:store1:rootHash1/resourceKey1'); + }); +}); \ No newline at end of file