diff --git a/.github/workflows/id_tests.yml b/.github/workflows/id_tests.yml new file mode 100644 index 0000000000..0aa803120d --- /dev/null +++ b/.github/workflows/id_tests.yml @@ -0,0 +1,66 @@ +name: Test ID Packages + +# `@e2b/id` is a pure codec with no network and no API key, so unlike the SDK +# suites this one needs no secrets and no staging leg. +on: + workflow_call: + +permissions: + contents: read + +jobs: + test: + name: ID - Build and test (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, windows-latest] + runs-on: ${{ matrix.os }} + defaults: + run: + shell: bash + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Parse .tool-versions + uses: wistia/parse-tool-versions@v2.1.1 + with: + filename: '.tool-versions' + uppercase: 'true' + prefix: 'tool_version_' + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: '${{ env.TOOL_VERSION_PNPM }}' + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: '${{ env.TOOL_VERSION_NODEJS }}' + cache: pnpm + + - name: Configure pnpm + run: | + pnpm config set auto-install-peers true + pnpm config set exclude-links-from-lockfile true + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # The interop suite drives python3 to prove the format really is + # "b32encode, lowercase, strip padding, rotate", so an interpreter has to + # be on PATH even though the package itself has no Python component. + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '${{ env.TOOL_VERSION_PYTHON }}' + + - name: Test build (JS) + working-directory: packages/js-id + run: pnpm build + + - name: Run tests (JS) + working-directory: packages/js-id + run: pnpm test diff --git a/.github/workflows/sdk_tests.yml b/.github/workflows/sdk_tests.yml index f342f58366..35577b0b63 100644 --- a/.github/workflows/sdk_tests.yml +++ b/.github/workflows/sdk_tests.yml @@ -20,6 +20,7 @@ jobs: js: ${{ steps.filter.outputs.js }} python: ${{ steps.filter.outputs.python }} cli: ${{ steps.filter.outputs.cli }} + id: ${{ steps.filter.outputs.id }} steps: - name: Filter changed paths # On workflow_dispatch there is no PR diff and paths-filter would fall @@ -51,6 +52,10 @@ jobs: - '.github/workflows/cli_tests.yml' - 'packages/cli/**/!(*.md)' - 'packages/js-sdk/**/!(*.md)' + id: + - *shared + - '.github/workflows/id_tests.yml' + - 'packages/js-id/**/!(*.md)' js-tests: name: Production / JS SDK Tests @@ -76,6 +81,14 @@ jobs: secrets: E2B_API_KEY: ${{ secrets.E2B_API_KEY }} + id-tests: + name: ID Package Tests + needs: changes + if: ${{ needs.changes.outputs.id == 'true' || github.event_name == 'workflow_dispatch' }} + # No secrets and no staging leg: the ID packages are pure codecs that never + # reach the backend, so there is nothing environment-specific to re-check. + uses: ./.github/workflows/id_tests.yml + js-tests-staging: name: Staging / JS SDK Tests needs: changes @@ -118,6 +131,7 @@ jobs: - js-tests - python-tests - cli-tests + - id-tests if: ${{ always() }} runs-on: ubuntu-latest steps: diff --git a/packages/js-id/.gitignore b/packages/js-id/.gitignore new file mode 100644 index 0000000000..19583a8a46 --- /dev/null +++ b/packages/js-id/.gitignore @@ -0,0 +1,11 @@ +# dependencies +/node_modules + +# build output +dist + +# TypeScript cache +*.tsbuildinfo + +# Output of 'npm pack' +*.tgz diff --git a/packages/js-id/LICENSE b/packages/js-id/LICENSE new file mode 100644 index 0000000000..d7beaa9178 --- /dev/null +++ b/packages/js-id/LICENSE @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2025 FOUNDRYLABS, INC. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/packages/js-id/README.md b/packages/js-id/README.md new file mode 100644 index 0000000000..5de708fc16 --- /dev/null +++ b/packages/js-id/README.md @@ -0,0 +1,132 @@ +# `@e2b/id` + +Prefixed, human-legible IDs for E2B resources. No dependencies. + +```sh +npm install @e2b/id +``` + +```ts +import { createId, decodeId, encodeId, isId, parseId } from '@e2b/id' + +createId('project') +// 'prj_uk75vf2v7iagp2kgn7pfze3car' + +encodeId('volume', '019fa519-bfc5-784d-9386-a5d7a93a692a') +// 'vol_uxl2sotjfiagp2kgn7yv4e3e4g' + +decodeId('volume', 'vol_uxl2sotjfiagp2kgn7yv4e3e4g') +// '019fa519-bfc5-784d-9386-a5d7a93a692a' + +parseId('vol_uxl2sotjfiagp2kgn7yv4e3e4g') +// { kind: 'volume', uuid: '019fa519-bfc5-784d-9386-a5d7a93a692a' } + +isId('project', 'vol_uxl2sotjfiagp2kgn7yv4e3e4g') +// false +``` + +The format is deliberately portable: any language can read these IDs with its +standard library. A Python port lands alongside this one. + +## The format + +An ID is a three-character kind prefix, an underscore, and 26 characters of +encoded UUID — 30 characters in all: + +```text +prj_uk75vf2v7iagp2kgn7pfze3car +wrk_imkhttdroeagp2kgn7t53cvkiw +vol_uxl2sotjfiagp2kgn7yv4e3e4g +sbx_blo7looa3eagp2kgn75n47fkrk +usr_zm52rsumcyagp2kgoacf6i5ibz +grp_byblfq6upe6r5mcc2yzrbxfjlh +``` + +| kind | prefix | +| ----------- | ------ | +| `project` | `prj` | +| `workspace` | `wrk` | +| `volume` | `vol` | +| `sandbox` | `sbx` | +| `user` | `usr` | +| `group` | `grp` | + +The UUID's 16 bytes are base32-encoded with the RFC 4648 section 6 alphabet +(`a-z2-7`), lowercased, unpadded — 26 characters — and then **rotated left by +16**, so what was the first character ends up 11th. + +The rotation is the whole trick. A UUIDv7 leads with a 48-bit millisecond +timestamp, so unrotated encodings of IDs minted around the same time share a +long common prefix, and the leading characters barely move for months. Rotating +puts 10 characters of random bits in front, the timestamp from index 10, and the +rest of the random bits behind it. The trade is that encoded order no longer +follows time — don't build an index expecting it to. + +The alphabet is exactly the one Python's `base64.b32encode` uses, so any +language can read these IDs with its standard library and no tables: + +```py +import base64 + +def encode(b: bytes) -> str: + s = base64.b32encode(b).decode().rstrip("=").lower() + return s[16:] + s[:16] + +def decode(s: str) -> bytes: + s = s[10:] + s[:10] + return base64.b32decode(s.upper() + "======") +``` + +### One spelling per ID + +26 base32 digits carry 130 bits and a UUID has 128, so two bits of the encoding +are always zero and every UUID has four strings a permissive decoder maps to it. +`decodeId` accepts only the one `encodeId` produces and throws `InvalidIdError` +for the other three, along with uppercase, padding and anything outside the +alphabet. UUID arguments are held to the canonical 8-4-4-4-12 hex form for the +same reason. + +### Types + +`Id` is a template literal type, so the prefix is checked at compile time and +`isId` narrows to it: + +```ts +import type { Id } from '@e2b/id' + +function open(volume: Id<'volume'>) {} + +open(createId('volume')) // fine +open(createId('project')) // Argument of type 'prj_${string}' is not assignable + +declare const input: string +if (isId('volume', input)) open(input) // narrowed to Id<'volume'> +``` + +## API + +| | | +| --- | --- | +| `createId(kind)` | mint an ID for a new resource, from a fresh UUIDv7 | +| `encodeId(kind, uuid)` | encode a UUID you already have | +| `decodeId(kind, id)` | the UUID an ID carries; throws if the kind is wrong | +| `parseId(id)` | `{ kind, uuid }`, when the kind is what you want to find out | +| `isId(kind, value)` | `decodeId` without the throw, and a type guard; `false` for non-strings | +| `createUuid()` | mint a UUIDv7 | +| `encodeBytes(bytes)` / `decodeBytes(encoded)` | the prefix-free codec, over any 16 bytes | +| `uuidToBytes(uuid)` / `bytesToUuid(bytes)` | the canonical hex form and back | +| `ID_PREFIXES`, `ID_LENGTH`, `ENCODED_LENGTH`, `DECODED_LENGTH`, `ALPHABET` | the constants above | +| `Id`, `IdKind`, `IdPrefix`, `ParsedId`, `InvalidIdError`, `InvalidIdReason` | the types | + +## Development + +```sh +pnpm build +pnpm test +pnpm lint && pnpm typecheck +``` + +`tests/vectors.ts` holds the golden encodings, a seeded 1303-value corpus and a +`CORPUS_DIGEST` over it. Every port of this format pins the same three, so a +change to the format on one side alone cannot pass on the other. `pnpm test` also +pipes the whole corpus through `python3` to check the six-line snippet above. diff --git a/packages/js-id/package.json b/packages/js-id/package.json new file mode 100644 index 0000000000..f7219fc588 --- /dev/null +++ b/packages/js-id/package.json @@ -0,0 +1,67 @@ +{ + "name": "@e2b/id", + "version": "0.1.0", + "private": true, + "description": "Prefixed, human-legible IDs for E2B resources", + "homepage": "https://e2b.dev", + "license": "MIT", + "author": { + "name": "FoundryLabs, Inc.", + "email": "hello@e2b.dev", + "url": "https://e2b.dev" + }, + "bugs": "https://github.com/e2b-dev/e2b/issues", + "repository": { + "type": "git", + "url": "https://github.com/e2b-dev/e2b", + "directory": "packages/js-id" + }, + "publishConfig": { + "access": "public" + }, + "sideEffects": false, + "main": "dist/index.js", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + } + }, + "scripts": { + "prepublishOnly": "pnpm build", + "build": "tsc --noEmit && tsdown", + "dev": "tsdown --watch", + "test": "vitest run", + "typecheck": "tsc --noEmit", + "lint": "oxlint --config ../../.oxlintrc.json src tests", + "format": "prettier --write src/ tests/" + }, + "devDependencies": { + "@types/node": "^20.19.19", + "@typescript/native": "npm:typescript@^7.0.2", + "prettier": "^3.6.2", + "tsdown": "^0.22.3", + "typescript": "npm:@typescript/typescript6@^6.0.2", + "vitest": "^4.1.10" + }, + "files": [ + "dist", + "README.md", + "package.json" + ], + "keywords": [ + "e2b", + "id", + "uuid", + "uuidv7", + "base32", + "identifier", + "typescript" + ], + "engines": { + "node": ">=20.18.1 <21 || >=22" + } +} diff --git a/packages/js-id/src/codec.ts b/packages/js-id/src/codec.ts new file mode 100644 index 0000000000..6d072ff90c --- /dev/null +++ b/packages/js-id/src/codec.ts @@ -0,0 +1,261 @@ +/** + * The rotated base32 codec: 16 bytes as 26 lowercase characters. + * + * The 16 bytes are base32-encoded with the RFC 4648 section 6 alphabet + * ("A-Z2-7"), lowercased, unpadded: 26 characters. The string is then rotated + * left by {@link ROTATION}, so what was the first character is now the 11th. + * + * Rotation is the whole trick. A UUIDv7 leads with a 48-bit big-endian + * millisecond timestamp, so unrotated encodings of IDs minted together share a + * long common prefix and the leading characters are nearly constant for + * months. Rotating moves those characters inward: the string now leads with 10 + * characters of random bits, the timestamp reads out from {@link + * TIMESTAMP_INDEX}, and its 9.6 digits are followed by the remaining random + * bits. + * + * Decoding undoes it by rotating left by the other 10. The two amounts differ, + * so unlike a half-length rotation this one is not its own inverse; rotate and + * unrotate are separate functions and the tests hold them together. + * + * The alphabet is the one Python's `base64.b32encode` uses, so an + * implementation on the other side needs no tables, only the rotation: + * + * ```py + * import base64 + * + * def encode(b: bytes) -> str: + * s = base64.b32encode(b).decode().rstrip("=").lower() + * return s[16:] + s[:16] + * + * def decode(s: str) -> bytes: + * s = s[10:] + s[:10] + * return base64.b32decode(s.upper() + "======") + * ``` + * + * 26 base32 digits carry 130 bits and 16 bytes are 128, so the final digit of + * the unrotated string holds 2 bits that are always zero. Most base32 decoders + * discard those bits without looking, so every value has four spellings that + * decode to it. {@link decodeBytes} accepts only the one {@link encodeBytes} + * produces and rejects the other three. After rotation that digit sits at + * {@link SLACK_INDEX}, not at the end. + */ + +import { InvalidIdError } from './errors' + +/** + * RFC 4648 section 6, lowercased: what Python's `base64.b32encode` produces + * once `.lower()` is applied. The index of a character is its digit value. + */ +export const ALPHABET = 'abcdefghijklmnopqrstuvwxyz234567' + +/** The number of bytes every encoded value carries: a UUID's worth. */ +export const DECODED_LENGTH = 16 + +/** + * `ceil(128 / 5)`: the digits needed for 16 bytes. Every encoding is exactly + * this wide; there is no padding to add or strip. + */ +export const ENCODED_LENGTH = 26 + +/** + * How far {@link encodeBytes} rotates the string left, chosen so a UUIDv7's + * timestamp starts at {@link TIMESTAMP_INDEX}: far enough in that the leading + * characters are random, near enough the front that the timestamp begins in the + * first half of the string. Decoding rotates left by the remainder. + * + * The timestamp's ~10 digits then run from index 10 to 19, so most of it in + * fact lands in the second half — what the rotation buys is that none of it + * lands at the *front*, which is the part people read, sort by and truncate. + */ +export const ROTATION = 16 + +/** + * Where the unrotated string's first character lands: byte 0 of the value, and + * so bit 0 of a UUIDv7's timestamp, reads out here. + */ +export const TIMESTAMP_INDEX = ENCODED_LENGTH - ROTATION + +/** + * `26 * 5 - 128`: the always-zero bits in the final unrotated digit, and the + * mask that selects them out of that digit's value. + */ +export const SLACK_BITS = ENCODED_LENGTH * 5 - DECODED_LENGTH * 8 +export const SLACK_MASK = (1 << SLACK_BITS) - 1 + +/** + * Where that digit lands after rotation: the character a decoder must check + * for canonical form is inside the string, just before the timestamp, not at + * the end. + */ +export const SLACK_INDEX = TIMESTAMP_INDEX - 1 + +/** + * Character code to digit value, `-1` for anything outside the alphabet. + * + * A typed array rather than an object: it cannot inherit a value from + * `Object.prototype` (`VALUES['constructor']` would not be `undefined`), and + * `-1` rather than `undefined` means the decode loop below cannot silently OR a + * missing digit into the buffer — `| -1` would corrupt loudly, where + * `| undefined` corrupts as zero. + */ +const VALUES = (() => { + const table = new Int8Array(128).fill(-1) + for (let value = 0; value < ALPHABET.length; value++) { + table[ALPHABET.charCodeAt(value)] = value + } + return table +})() + +/** The digit value of a character, or `-1` if it is not in the alphabet. */ +function digitAt(s: string, index: number): number { + const code = s.charCodeAt(index) + return code < 128 ? VALUES[code] : -1 +} + +/** Rotate a 26-character string left by {@link ROTATION}. */ +function rotate(s: string): string { + return s.slice(ROTATION) + s.slice(0, ROTATION) +} + +/** Rotate left by the remainder, which undoes {@link rotate}. */ +function unrotate(s: string): string { + return ( + s.slice(ENCODED_LENGTH - ROTATION) + s.slice(0, ENCODED_LENGTH - ROTATION) + ) +} + +/** + * Encodes 16 bytes as 26 lowercase base32 characters, rotated so the leading + * bytes read out from the middle of the string. + * + * It cannot fail for any 16 bytes: nothing here reads a UUID's version or + * variant, so an arbitrary 128-bit value encodes just as well as a UUID. + * + * @param bytes exactly 16 bytes. + * @returns the 26-character encoding. + * @throws {InvalidIdError} if `bytes` is not 16 bytes long. + */ +export function encodeBytes(bytes: Uint8Array): string { + if (bytes.length !== DECODED_LENGTH) { + throw new InvalidIdError( + 'length', + `Cannot encode ${bytes.length} bytes: expected exactly ${DECODED_LENGTH}.` + ) + } + + let out = '' + let buffer = 0 + let bits = 0 + for (const byte of bytes) { + buffer = (buffer << 8) | byte + bits += 8 + while (bits >= 5) { + bits -= 5 + out += ALPHABET[(buffer >>> bits) & 31] + } + } + // The leftover bits are shifted up into a final digit, which is where the + // slack lives: 128 bits fill 25 digits and 3 bits of the 26th. SLACK_BITS + // being nonzero is what guarantees there are leftovers to flush — were the + // width ever a multiple of 40 bits this would append a spurious digit, so the + // relationship is asserted rather than assumed. + if (SLACK_BITS === 0) { + throw new InvalidIdError( + 'length', + `${DECODED_LENGTH} bytes pack evenly into base32 digits, so this codec's final-digit flush does not apply.` + ) + } + out += ALPHABET[(buffer << (5 - bits)) & 31] + + return rotate(out) +} + +/** + * Why {@link decodeBytes} would reject a string, or `undefined` if it would + * accept it. Reported without building an error, so {@link isId} can answer a + * boolean without paying for a message and a stack trace. + */ +export function faultInEncoded( + encoded: string +): { reason: 'length' | 'alphabet' | 'canonical'; index: number } | undefined { + if (encoded.length !== ENCODED_LENGTH) { + return { reason: 'length', index: -1 } + } + + // Membership is checked in its own pass so the message can point at the + // offending character. Case is part of it: uppercase is simply not in the + // alphabet, and accepting it would give every value millions of spellings. + for (let i = 0; i < ENCODED_LENGTH; i++) { + if (digitAt(encoded, i) < 0) return { reason: 'alphabet', index: i } + } + + // The slack digit is checked before decoding because the decode loop cannot + // see it: it drops the low bits of the final digit unread, so all four + // spellings of a value decode identically and the difference is only visible + // here. That digit is the last one of the unrotated string, which rotation + // has moved to SLACK_INDEX. + if (digitAt(encoded, SLACK_INDEX) & SLACK_MASK) { + return { reason: 'canonical', index: SLACK_INDEX } + } + + return undefined +} + +/** + * Builds the error for a fault. Separate from {@link faultInEncoded} so that the + * message — the expensive part — is only interpolated when something is about to + * be thrown. + */ +export function invalidEncoding( + encoded: string, + fault: NonNullable> +): InvalidIdError { + switch (fault.reason) { + case 'length': + return new InvalidIdError( + 'length', + `"${encoded}" is ${encoded.length} characters long: expected exactly ${ENCODED_LENGTH}.` + ) + case 'alphabet': + return new InvalidIdError( + 'alphabet', + `"${encoded}" holds ${JSON.stringify(encoded[fault.index])} at index ${fault.index}, which is not one of the lowercase base32 characters "${ALPHABET}".` + ) + case 'canonical': + return new InvalidIdError( + 'canonical', + `"${encoded}" is not the canonical spelling of its value: ${JSON.stringify(encoded[fault.index])} at index ${fault.index} sets ${SLACK_BITS} bits no 128-bit value reaches.` + ) + } +} + +/** + * Decodes what {@link encodeBytes} produced, and only that: 26 characters, + * lowercase RFC 4648 base32, rotated, with the two slack bits zero. + * + * @param encoded the 26-character encoding. + * @returns the 16 bytes it carries. + * @throws {InvalidIdError} if `encoded` is the wrong length, holds a character + * outside the lowercase alphabet, or is one of the three non-canonical + * spellings of its value. + */ +export function decodeBytes(encoded: string): Uint8Array { + const fault = faultInEncoded(encoded) + if (fault) throw invalidEncoding(encoded, fault) + + const raw = unrotate(encoded) + const bytes = new Uint8Array(DECODED_LENGTH) + let buffer = 0 + let bits = 0 + let i = 0 + for (let k = 0; k < ENCODED_LENGTH; k++) { + buffer = (buffer << 5) | digitAt(raw, k) + bits += 5 + if (bits >= 8) { + bits -= 8 + bytes[i++] = (buffer >>> bits) & 0xff + } + } + + return bytes +} diff --git a/packages/js-id/src/errors.ts b/packages/js-id/src/errors.ts new file mode 100644 index 0000000000..eb279ca96a --- /dev/null +++ b/packages/js-id/src/errors.ts @@ -0,0 +1,47 @@ +import type { IdKind } from './id' + +/** + * Why a string was rejected. Callers that need to react differently per cause — + * hint at the right kind, count non-canonical spellings, plain-400 the rest — + * branch on this rather than on the message, which is prose and may be reworded. + * + * - `kind`: the kind asked for is not one this package knows. + * - `prefix`: the ID names a different kind, or none. + * - `length`: the encoding is not {@link ENCODED_LENGTH} characters. + * - `alphabet`: it holds a character outside the lowercase base32 alphabet. + * - `canonical`: it decodes, but is one of the three non-canonical spellings. + * - `uuid`: the UUID given was not in canonical hex form. + */ +export type InvalidIdReason = + | 'kind' + | 'prefix' + | 'length' + | 'alphabet' + | 'canonical' + | 'uuid' + +/** + * Thrown when a string is not a well-formed ID, encoding or UUID. + * + * Every failure mode of this package is one of these; {@link reason} says which. + * Use {@link isId} when a boolean is what you want — it does not construct an + * error at all. + */ +export class InvalidIdError extends Error { + /** Why the string was rejected. */ + readonly reason: InvalidIdReason + + /** + * The kind the ID's prefix actually names, when it names a known one — set + * only for `reason: 'prefix'`, so `expected X, got Y` is available without + * parsing the message. + */ + readonly actualKind?: IdKind + + constructor(reason: InvalidIdReason, message: string, actualKind?: IdKind) { + super(message) + this.name = 'InvalidIdError' + this.reason = reason + this.actualKind = actualKind + } +} diff --git a/packages/js-id/src/id.ts b/packages/js-id/src/id.ts new file mode 100644 index 0000000000..25de4e3c70 --- /dev/null +++ b/packages/js-id/src/id.ts @@ -0,0 +1,276 @@ +/** + * Prefixed, human-legible IDs for E2B resources. + * + * An ID is a three-character kind prefix, an underscore, and the 26-character + * rotated base32 encoding of a UUID: + * + * ```text + * prj_uk75vf2v7iagp2kgn7pfze3car + * ^^^ ^ + * | the encoding of the resource's UUID + * the kind + * ``` + * + * The prefix is what makes an ID readable in a log line, a URL or a support + * ticket: you can tell at a glance which resource it points at, and a project + * ID pasted where a volume ID belongs fails loudly instead of looking up a row + * that happens to exist. + */ + +import { + decodeBytes, + encodeBytes, + ENCODED_LENGTH, + faultInEncoded, +} from './codec' +import { InvalidIdError } from './errors' +import { bytesToUuid, createUuid, uuidToBytes } from './uuid' + +/** + * The E2B resources that have IDs, and the prefix each one carries. + * + * This map is the single source of truth for the prefixes; adding a kind is + * adding a line here. + */ +export const ID_PREFIXES = { + project: 'prj', + workspace: 'wrk', + volume: 'vol', + sandbox: 'sbx', + user: 'usr', + group: 'grp', +} as const + +/** What separates the prefix from the encoding. */ +const SEPARATOR = '_' + +/** A resource kind that has IDs, e.g. `'project'`. */ +export type IdKind = keyof typeof ID_PREFIXES + +/** The prefix a kind carries, e.g. `'prj'`. */ +export type IdPrefix = (typeof ID_PREFIXES)[K] + +/** + * An ID of a given kind, e.g. `Id<'project'>` is `` `prj_${string}` ``. With + * no kind it is an ID of any kind. + * + * The type only pins the prefix — a string of the right shape still has to go + * through {@link isId} or {@link decodeId} to be known good — but it is enough + * for the compiler to catch a project ID passed where a volume ID belongs. + */ +export type Id = `${IdPrefix}_${string}` + +/** + * A parsed ID: which kind it names, and the UUID it carries. + */ +export interface ParsedId { + /** The resource kind its prefix names. */ + kind: IdKind + /** The UUID it encodes, in canonical hex form. */ + uuid: string +} + +/** + * How wide every prefix is. The parsing paths do not depend on it — they find + * the separator and slice by the matched prefix's own length — but IDs are a + * fixed width only as long as this holds, so it is checked against + * {@link ID_PREFIXES} rather than assumed. + */ +export const PREFIX_LENGTH = 3 + +for (const prefix of Object.values(ID_PREFIXES)) { + if (prefix.length !== PREFIX_LENGTH) { + throw new Error( + `@e2b/id: the prefix "${prefix}" is ${prefix.length} characters, not ${PREFIX_LENGTH}, so ID_LENGTH would be wrong for its kind.` + ) + } +} + +/** + * The width of every ID: a three-character prefix, an underscore and 26 + * characters of encoding. + * + * Useful for sizing a database column or aligning a log. It is not a validator + * — a string of this length can still be nonsense — so reach for {@link isId} + * when the question is whether an ID is well formed. + */ +export const ID_LENGTH = PREFIX_LENGTH + SEPARATOR.length + ENCODED_LENGTH + +/** + * Prefix to kind, for reading an ID's prefix back. + * + * A `Map`, not an object: prefixes come from untrusted strings, and a plain + * object would answer `KINDS_BY_PREFIX['toString']` with a function inherited + * from `Object.prototype`, putting `[native code]` in an error message. + */ +const KINDS_BY_PREFIX: ReadonlyMap = new Map( + Object.entries(ID_PREFIXES).map(([kind, prefix]) => [prefix, kind as IdKind]) +) + +/** `'group', 'project', …` — for error messages that list what is accepted. */ +function knownKinds(): string { + return Object.keys(ID_PREFIXES) + .sort() + .map((kind) => `'${kind}'`) + .join(', ') +} + +function prefixOf(kind: K): IdPrefix { + // Cast so the undefined branch stays reachable to the compiler: an unknown + // kind cannot come from TypeScript, but it can come from JavaScript or from + // a cast, and the message it gets should be this one. + const prefix = ID_PREFIXES[kind] as IdPrefix | undefined + if (prefix === undefined) { + throw new InvalidIdError( + 'kind', + `'${kind}' is not a resource kind that has IDs. Expected one of ${knownKinds()}.` + ) + } + return prefix +} + +/** + * Encodes a UUID as an ID of the given kind. + * + * @param kind the resource the UUID belongs to. + * @param uuid the resource's UUID, in canonical hex form. + * @returns the ID. + * @throws {InvalidIdError} if `kind` is not a known kind, or `uuid` is not a + * UUID. + * @example + * ```ts + * encodeId('project', '019fa519-bf79-724d-8811-a2bfda9755fa') + * // 'prj_uk75vf2v7iagp2kgn7pfze3car' + * ``` + */ +export function encodeId(kind: K, uuid: string): Id { + return `${prefixOf(kind)}${SEPARATOR}${encodeBytes(uuidToBytes(uuid))}` as Id +} + +/** + * Decodes an ID of the given kind back to its UUID. + * + * The kind is required rather than inferred so that a mismatch is an error: + * this is the check that stops one resource's ID from being used as another's. + * Use {@link parseId} when the kind is what you are trying to find out. + * + * @param kind the resource the ID must name. + * @param id the ID. + * @returns the UUID it carries, in canonical hex form. + * @throws {InvalidIdError} if `id` names a different kind, or is not a + * well-formed ID. + * @example + * ```ts + * decodeId('project', 'prj_uk75vf2v7iagp2kgn7pfze3car') + * // '019fa519-bf79-724d-8811-a2bfda9755fa' + * ``` + */ +export function decodeId(kind: K, id: string): string { + const prefix = prefixOf(kind) + if (!id.startsWith(`${prefix}${SEPARATOR}`)) { + const named = KINDS_BY_PREFIX.get(id.slice(0, id.indexOf(SEPARATOR))) + throw named === undefined + ? new InvalidIdError( + 'prefix', + `"${id}" is not a ${kind} ID: expected it to start with "${prefix}${SEPARATOR}".` + ) + : new InvalidIdError( + 'prefix', + `"${id}" is a ${named} ID, not a ${kind} ID.`, + named + ) + } + return bytesToUuid(decodeBytes(id.slice(prefix.length + SEPARATOR.length))) +} + +/** + * Mints an ID for a new resource of the given kind, from a fresh + * {@link createUuid} UUIDv7. + * + * @param kind the resource being created. + * @returns the ID. + * @throws {InvalidIdError} if `kind` is not a known kind. + * @example + * ```ts + * createId('sandbox') // 'sbx_blo7looa3eagp2kgn75n47fkrk' + * ``` + */ +export function createId(kind: K): Id { + return encodeId(kind, createUuid()) +} + +/** + * Reads an ID without knowing its kind up front. + * + * @param id the ID. + * @returns the kind its prefix names and the UUID it carries. + * @throws {InvalidIdError} if `id` carries no known prefix, or the rest of it + * is not a well-formed encoding. + * @example + * ```ts + * parseId('vol_uxl2sotjfiagp2kgn7yv4e3e4g') + * // { kind: 'volume', uuid: '019fa519-bfc5-784d-9386-a5d7a93a692a' } + * ``` + */ +export function parseId(id: string): ParsedId { + const separator = id.indexOf(SEPARATOR) + if (separator < 0) { + throw new InvalidIdError( + 'prefix', + `"${id}" is not an ID: expected a kind prefix and an underscore, like "prj${SEPARATOR}".` + ) + } + + const kind = KINDS_BY_PREFIX.get(id.slice(0, separator)) + if (kind === undefined) { + throw new InvalidIdError( + 'prefix', + `"${id}" carries the unknown prefix "${id.slice(0, separator)}". Expected one of ${Object.values( + ID_PREFIXES + ) + .sort() + .map((prefix) => `"${prefix}"`) + .join(', ')}.` + ) + } + + return { kind, uuid: decodeId(kind, id) } +} + +/** + * Whether a string is a well-formed ID of the given kind. + * + * This is {@link decodeId} without the throw, for validating input you did not + * mint: it checks the prefix, the width, the alphabet and the canonical + * spelling. + * + * It answers without constructing an error or decoding the UUID, so it is cheap + * enough to run over a whole request — which matters, because rejection is the + * common case for untrusted input and building an `Error` (message plus stack + * capture) costs orders of magnitude more than the check itself. + * + * Anything that is not a string is simply not an ID, so `null`, `undefined` and + * a stray number all return `false` rather than throwing — this is usually the + * first thing a handler calls on a JSON payload. + * + * @param kind the resource the ID must name. + * @param value the string to check. + * @returns whether `value` is an ID of that kind. + * @example + * ```ts + * if (!isId('project', input)) { + * throw new Error(`${input} is not a project ID`) + * } + * ``` + */ +export function isId(kind: K, value: string): value is Id { + if (typeof value !== 'string') return false + + const prefix = ID_PREFIXES[kind] as string | undefined + if (prefix === undefined) return false + if (!value.startsWith(prefix + SEPARATOR)) return false + + return ( + faultInEncoded(value.slice(prefix.length + SEPARATOR.length)) === undefined + ) +} diff --git a/packages/js-id/src/index.ts b/packages/js-id/src/index.ts new file mode 100644 index 0000000000..7835ee0aee --- /dev/null +++ b/packages/js-id/src/index.ts @@ -0,0 +1,38 @@ +/** + * `@e2b/id` — prefixed, human-legible IDs for E2B resources. + * + * ```ts + * import { createId, decodeId, isId, parseId } from '@e2b/id' + * + * const id = createId('project') // 'prj_uk75vf2v7iagp2kgn7pfze3car' + * decodeId('project', id) // '019fa519-bf79-724d-8811-a2bfda9755fa' + * isId('volume', id) // false + * parseId(id) // { kind: 'project', uuid: '019fa519-…' } + * ``` + * + * An ID is a kind prefix and the 26-character rotated base32 encoding of a + * UUID. See `id.ts` for what the prefix buys and `codec.ts` for what the + * rotation buys. + */ + +export { + ALPHABET, + DECODED_LENGTH, + decodeBytes, + encodeBytes, + ENCODED_LENGTH, +} from './codec' +export { InvalidIdError } from './errors' +export type { InvalidIdReason } from './errors' +export { + createId, + decodeId, + encodeId, + ID_LENGTH, + ID_PREFIXES, + PREFIX_LENGTH, + isId, + parseId, +} from './id' +export type { Id, IdKind, IdPrefix, ParsedId } from './id' +export { bytesToUuid, createUuid, uuidToBytes } from './uuid' diff --git a/packages/js-id/src/uuid.ts b/packages/js-id/src/uuid.ts new file mode 100644 index 0000000000..882bf4c5d2 --- /dev/null +++ b/packages/js-id/src/uuid.ts @@ -0,0 +1,105 @@ +/** + * The canonical hex form of a UUID and its 16 bytes, plus a UUIDv7 mint. + * + * UUIDs cross this package's boundary as their canonical 36-character string + * (`019fa519-bf79-724d-8811-a2bfda9755fa`) because that is what a database row + * or a JSON payload holds. The bytes are the internal currency. + */ + +import { DECODED_LENGTH } from './codec' +import { InvalidIdError } from './errors' + +/** The canonical 8-4-4-4-12 hex form, accepted in either case. */ +const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +/** Where the hyphens go when 32 hex digits are formatted back into a UUID. */ +const GROUPS = [4, 2, 2, 2, 6] + +const HEX = Array.from({ length: 256 }, (_, byte) => + byte.toString(16).padStart(2, '0') +) + +/** + * Parses the canonical hex form of a UUID into its 16 bytes. + * + * @param uuid a UUID in 8-4-4-4-12 hex form, upper or lower case. + * @returns the 16 bytes, most significant first. + * @throws {InvalidIdError} if `uuid` is not in that form. + */ +export function uuidToBytes(uuid: string): Uint8Array { + if (typeof uuid !== 'string' || !UUID_PATTERN.test(uuid)) { + throw new InvalidIdError( + 'uuid', + `"${uuid}" is not a UUID: expected 32 hex digits grouped 8-4-4-4-12, like "019fa519-bf79-724d-8811-a2bfda9755fa".` + ) + } + + // `split().join()` rather than `replaceAll`, which is ES2021: the build + // targets es2017 and rolldown lowers syntax, not library methods. + const hex = uuid.split('-').join('') + const bytes = new Uint8Array(DECODED_LENGTH) + for (let i = 0; i < DECODED_LENGTH; i++) { + bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16) + } + return bytes +} + +/** + * Formats 16 bytes as the canonical lowercase hex form of a UUID. + * + * @param bytes exactly 16 bytes, most significant first. + * @returns the UUID in 8-4-4-4-12 hex form. + * @throws {InvalidIdError} if `bytes` is not 16 bytes long. + */ +export function bytesToUuid(bytes: Uint8Array): string { + if (bytes.length !== DECODED_LENGTH) { + throw new InvalidIdError( + 'uuid', + `Cannot format ${bytes.length} bytes as a UUID: expected exactly ${DECODED_LENGTH}.` + ) + } + + let uuid = '' + let offset = 0 + for (const group of GROUPS) { + if (offset > 0) uuid += '-' + for (let i = 0; i < group; i++) uuid += HEX[bytes[offset + i]] + offset += group + } + return uuid +} + +/** + * Mints a UUIDv7: a 48-bit big-endian millisecond timestamp followed by 74 + * random bits, with the version and variant nibbles set per RFC 9562. + * + * IDs are minted from v7 rather than v4 so that the bytes a database stores + * sort chronologically, even though {@link encodeId} deliberately hides that + * order in the string it produces. + * + * @returns the UUID in canonical hex form. + * @example + * ```ts + * const uuid = createUuid() // '019fa519-bf79-724d-8811-a2bfda9755fa' + * ``` + */ +export function createUuid(): string { + const bytes = new Uint8Array(DECODED_LENGTH) + crypto.getRandomValues(bytes) + + const ms = Date.now() + // Bit arithmetic tops out at 32 bits, so the 48-bit timestamp is split: + // division for the high two bytes, shifts for the low four. + bytes[0] = Math.floor(ms / 2 ** 40) & 0xff + bytes[1] = Math.floor(ms / 2 ** 32) & 0xff + bytes[2] = (ms >>> 24) & 0xff + bytes[3] = (ms >>> 16) & 0xff + bytes[4] = (ms >>> 8) & 0xff + bytes[5] = ms & 0xff + + bytes[6] = (bytes[6] & 0x0f) | 0x70 // version 7 + bytes[8] = (bytes[8] & 0x3f) | 0x80 // variant 10 + + return bytesToUuid(bytes) +} diff --git a/packages/js-id/tests/codec.test.ts b/packages/js-id/tests/codec.test.ts new file mode 100644 index 0000000000..5666d0a655 --- /dev/null +++ b/packages/js-id/tests/codec.test.ts @@ -0,0 +1,278 @@ +import { describe, expect, test } from 'vitest' + +import { + ALPHABET, + DECODED_LENGTH, + decodeBytes, + encodeBytes, + ENCODED_LENGTH, + ROTATION, + SLACK_BITS, + SLACK_INDEX, + SLACK_MASK, + TIMESTAMP_INDEX, +} from '../src/codec' +import { InvalidIdError } from '../src/errors' +import { bytesToUuid, uuidToBytes } from '../src/uuid' +import { + bigintFrom, + bytesFrom, + corpus, + CORPUS_DIGEST, + corpusDigest, + GOLDEN, + hex, + Lcg, + refDecode, + refEncode, + v7, +} from './vectors' + +describe('the format', () => { + // Pinned as literals, not as their own definitions: `expect(TIMESTAMP_INDEX) + // .toBe(ENCODED_LENGTH - ROTATION)` restates the line that computes it and so + // can never fail — it would stay green with ROTATION changed to 20, which is a + // different wire format. These numbers ARE the format; every ID ever minted + // depends on them, so changing one has to break a test. + test('the format is these exact numbers', () => { + expect(ALPHABET).toBe('abcdefghijklmnopqrstuvwxyz234567') + expect(DECODED_LENGTH).toBe(16) + expect(ENCODED_LENGTH).toBe(26) + expect(ROTATION).toBe(16) + expect(TIMESTAMP_INDEX).toBe(10) + expect(SLACK_INDEX).toBe(9) + expect(SLACK_BITS).toBe(2) + expect(SLACK_MASK).toBe(3) + }) + + // The relations that make those numbers the right ones: 25 digits cannot hold + // 128 bits, 26 hold 130, and the slack is the difference. Unlike the pins + // above these are derivations, so they are stated against the arithmetic. + test('and those numbers are the ones the width forces', () => { + expect(ENCODED_LENGTH * 5).toBeGreaterThanOrEqual(DECODED_LENGTH * 8) + expect((ENCODED_LENGTH - 1) * 5).toBeLessThan(DECODED_LENGTH * 8) + expect(SLACK_BITS).toBe(ENCODED_LENGTH * 5 - DECODED_LENGTH * 8) + expect(SLACK_MASK).toBe((1 << SLACK_BITS) - 1) + expect(TIMESTAMP_INDEX).toBe(ENCODED_LENGTH - ROTATION) + expect(SLACK_INDEX).toBe(TIMESTAMP_INDEX - 1) + expect(ALPHABET).toHaveLength(32) + expect(new Set(ALPHABET).size).toBe(32) + // The front must be random, which needs the rotation past a v7's 10 + // non-random leading digits (48 timestamp bits plus 2 of the version nibble). + expect(ROTATION).toBeGreaterThanOrEqual(14) + expect(ROTATION).toBeLessThan(ENCODED_LENGTH) // not a full turn + }) + + test.each(GOLDEN)('%s encodes to %s', (uuid, encoded) => { + expect(encodeBytes(uuidToBytes(uuid))).toBe(encoded) + expect(bytesToUuid(decodeBytes(encoded))).toBe(uuid) + }) + + // The claim of the format: the string is the value's bits in 5-bit groups, + // rotated. Checked against refEncode, which computes it a different way. + test('the string is the bits, rotated', () => { + for (const bytes of corpus()) { + expect(encodeBytes(bytes), hex(bytes)).toBe(refEncode(bytes)) + } + }) + + // The one constant every port holds: same corpus, same format, same digest. + test('the corpus digest is the cross-implementation contract', () => { + expect(corpusDigest(encodeBytes)).toBe(CORPUS_DIGEST) + }) + + test('every encoding round trips', () => { + for (const bytes of corpus()) { + const encoded = encodeBytes(bytes) + expect(encoded, hex(bytes)).toHaveLength(ENCODED_LENGTH) + expect(encoded).toBe(encoded.toLowerCase()) + expect(decodeBytes(encoded)).toStrictEqual(bytes) + } + }) + + // The seven smallest nonzero values fit entirely in the slack digit: + // encoding shifts a value up by the two slack bits, so 1 through 7 become + // digit values 4 through 28, always a multiple of 4 and so always canonical. + // Seven strings differing from the encoding of zero in exactly one character + // say where the low bits live, that the shift happened, and where the + // rotation put it. Value 8 is the first to spill into a second digit. + test.each([1, 2, 3, 4, 5, 6, 7])( + 'the value %i is one character from zero', + (value) => { + const bytes = bytesFrom(BigInt(value)) + const want = + 'a'.repeat(SLACK_INDEX) + + ALPHABET[value << SLACK_BITS] + + 'a'.repeat(ENCODED_LENGTH - SLACK_INDEX - 1) + + expect(encodeBytes(bytes)).toBe(want) + expect(decodeBytes(want)).toStrictEqual(bytes) + } + ) +}) + +describe('canonical form', () => { + // The cost of 128 not dividing by 5. Every value has exactly four strings a + // permissive base32 decoder maps to it, differing only in the slack digit, + // which rotation has moved to SLACK_INDEX. Exactly one is canonical. + test('three of the four spellings of every value are rejected', () => { + for (const bytes of corpus().slice(0, 200)) { + const encoded = encodeBytes(bytes) + const digit = ALPHABET.indexOf(encoded[SLACK_INDEX]) + expect(digit & SLACK_MASK, encoded).toBe(0) + + let accepted = 0 + for (let slack = 0; slack <= SLACK_MASK; slack++) { + const alternative = + encoded.slice(0, SLACK_INDEX) + + ALPHABET[digit | slack] + + encoded.slice(SLACK_INDEX + 1) + + // This is what makes the canonical check necessary rather than + // incidental: a permissive decoder — including the six-line b32decode + // snippet this package advertises — maps all four spellings to the same + // 16 bytes and reports nothing. Checked against refDecode, which + // unrotates and unpacks without consulting the slack digit at all. + expect(refDecode(alternative), alternative).toStrictEqual(bytes) + + if (slack === 0) { + expect(decodeBytes(alternative)).toStrictEqual(bytes) + accepted++ + continue + } + expect(() => decodeBytes(alternative)).toThrow(InvalidIdError) + expect(() => decodeBytes(alternative)).toThrow(/canonical/) + } + expect(accepted, encoded).toBe(1) + } + }) + + // The same fact from the outside: only 8 of the 32 characters can ever + // appear at SLACK_INDEX, and over enough values every one of them does. + test('only multiples of four appear in the slack digit', () => { + const allowed = new Set( + Array.from( + { length: 32 / (SLACK_MASK + 1) }, + (_, i) => ALPHABET[i * (SLACK_MASK + 1)] + ) + ) + + const seen = new Set() + for (const bytes of corpus()) { + const digit = encodeBytes(bytes)[SLACK_INDEX] + expect( + allowed, + `${hex(bytes)} has ${digit} at index ${SLACK_INDEX}` + ).toContain(digit) + seen.add(digit) + } + expect(seen).toStrictEqual(allowed) + }) +}) + +describe('decoding rejects', () => { + const valid = encodeBytes(uuidToBytes('019fa41f-41cc-761e-8868-daa906581007')) + + test.each([ + ['nothing at all', ''], + ['one character short', valid.slice(0, -1)], + ['one character long', `${valid}a`], + ['base32 padding', `${valid.slice(0, -1)}=`], + // Nothing here ever emits uppercase, so accepting it would give every + // value millions of spellings. + ['uppercase', valid.toUpperCase()], + [ + 'a single uppercase letter', + (() => { + // The first character may be a digit, which has no upper case, so the + // one to raise is the first letter. + const at = valid.search(/[a-z]/) + return ( + valid.slice(0, at) + valid[at].toUpperCase() + valid.slice(at + 1) + ) + })(), + ], + ['"0", which is not in the alphabet', `0${valid.slice(1)}`], + ['"1", which is not in the alphabet', `1${valid.slice(1)}`], + ['"8", which is not in the alphabet', `8${valid.slice(1)}`], + ['"9", which is not in the alphabet', `9${valid.slice(1)}`], + ['"-", which is not a digit', `-${valid.slice(1)}`], + ['a non-ascii character', `${valid.slice(0, -1)}é`], + ['whitespace', ` ${valid.slice(1)}`], + ])('%s', (_, encoded) => { + expect(() => decodeBytes(encoded)).toThrow(InvalidIdError) + }) + + test('anything that is not 16 bytes', () => { + expect(() => encodeBytes(new Uint8Array(15))).toThrow(InvalidIdError) + expect(() => encodeBytes(new Uint8Array(17))).toThrow(InvalidIdError) + }) + + // All zeros decodes to the zero value rather than failing. + test('but not the extremes', () => { + expect(decodeBytes('a'.repeat(ENCODED_LENGTH))).toStrictEqual( + new Uint8Array(16) + ) + expect( + decodeBytes(encodeBytes(bytesFrom((1n << 128n) - 1n))) + ).toStrictEqual(bytesFrom((1n << 128n) - 1n)) + }) +}) + +describe('what the rotation buys', () => { + // Two v7s minted in the same millisecond share their first 52 bits + // (timestamp plus version nibble), which is their first 10 digits; rotation + // moves those to indices 10 through 19. So the insides must match and, over + // enough samples, the fronts must not. + test('the timestamp reads out from the middle, the front stays random', () => { + const source = new Lcg(7) + + let sameFront = 0 + for (let i = 0; i < 1000; i++) { + const a = v7(source) + const b = v7(source) + b.set(a.subarray(0, 7), 0) // same millisecond, same version nibble + + const [encodedA, encodedB] = [encodeBytes(a), encodeBytes(b)] + expect(encodedA.slice(TIMESTAMP_INDEX, TIMESTAMP_INDEX + 10)).toBe( + encodedB.slice(TIMESTAMP_INDEX, TIMESTAMP_INDEX + 10) + ) + if (encodedA.slice(0, 4) === encodedB.slice(0, 4)) sameFront++ + } + + // The first 4 characters are 20 random bits; collisions are ~1e-6. + expect(sameFront).toBeLessThanOrEqual(2) + }) + + // The same point as the user sees it: a batch minted together would have + // shared a long prefix unrotated, and must not now. + test('ids minted together do not share a prefix', () => { + const source = new Lcg(11) + const at = 1758000000000 + + const fronts = new Set() + for (let i = 0; i < 200; i++) fronts.add(encodeBytes(v7(source, at + i))[0]) + + // 200 draws over 32 first characters: fewer than 10 distinct would be + // wildly improbable for uniform bits. + expect(fronts.size).toBeGreaterThanOrEqual(10) + }) + + // The trade, stated plainly so no one builds an index on these strings + // expecting v7's chronology to survive: the front is random, so encoded + // order and timestamp order are unrelated. + test('and what it costs: sort order is gone', () => { + const source = new Lcg(15) + const values = Array.from({ length: 5000 }, () => v7(source)) + + let inversions = 0 + for (let i = 1; i < values.length; i++) { + let [low, high] = [values[i - 1], values[i]] + if (bigintFrom(low) > bigintFrom(high)) [low, high] = [high, low] + if (encodeBytes(low) > encodeBytes(high)) inversions++ + } + + // Random fronts mean about half of all pairs invert. + expect(inversions).toBeGreaterThan(values.length / 5) + }) +}) diff --git a/packages/js-id/tests/id.test.ts b/packages/js-id/tests/id.test.ts new file mode 100644 index 0000000000..a088b0eb65 --- /dev/null +++ b/packages/js-id/tests/id.test.ts @@ -0,0 +1,342 @@ +import { describe, expect, test } from 'vitest' + +import { ENCODED_LENGTH } from '../src/codec' +import { InvalidIdError } from '../src/errors' +import { + createId, + decodeId, + encodeId, + type Id, + ID_LENGTH, + ID_PREFIXES, + type IdKind, + isId, + parseId, + PREFIX_LENGTH, +} from '../src/id' +import { bytesToUuid, createUuid, uuidToBytes } from '../src/uuid' +import { corpus, GOLDEN, GOLDEN_IDS, hex } from './vectors' + +const KINDS = Object.keys(ID_PREFIXES) as IdKind[] + +describe('the prefixes', () => { + test('every kind has a distinct three-character prefix', () => { + const prefixes = Object.values(ID_PREFIXES) + expect(new Set(prefixes).size).toBe(prefixes.length) + for (const prefix of prefixes) { + expect(prefix).toMatch(/^[a-z]{3}$/) + } + expect(ID_LENGTH).toBe(3 + 1 + ENCODED_LENGTH) + }) + + test('every kind is covered here', () => { + expect(KINDS).toStrictEqual([ + 'project', + 'workspace', + 'volume', + 'sandbox', + 'user', + 'group', + ]) + expect(GOLDEN_IDS.map(([kind]) => kind).sort()).toStrictEqual( + [...KINDS].sort() + ) + }) +}) + +describe('encoding and decoding ids', () => { + test.each(GOLDEN_IDS)('a %s id for %s is %s', (kind, uuid, id) => { + expect(encodeId(kind, uuid)).toBe(id) + expect(decodeId(kind, id)).toBe(uuid) + expect(parseId(id)).toStrictEqual({ kind, uuid }) + expect(isId(kind, id)).toBe(true) + }) + + test('every id is ID_LENGTH characters wide', () => { + for (const kind of KINDS) { + for (const [uuid] of GOLDEN) { + expect(encodeId(kind, uuid)).toHaveLength(ID_LENGTH) + } + } + }) + + test('every value round trips through every kind', () => { + for (const bytes of corpus()) { + const uuid = bytesToUuid(bytes) + for (const kind of KINDS) { + const id = encodeId(kind, uuid) + expect(id.startsWith(`${ID_PREFIXES[kind]}_`), id).toBe(true) + expect(decodeId(kind, id), hex(bytes)).toBe(uuid) + expect(parseId(id)).toStrictEqual({ kind, uuid }) + } + } + }) + + // `Id` is a template literal type, so the prefix is a compile-time fact + // too: this test would fail to typecheck if it stopped being one. + test('the id type carries the kind', () => { + const value: string = GOLDEN_IDS[0][2] + + if (!isId('project', value)) throw new Error('golden id did not validate') + const narrowed: Id<'project'> = value + expect(narrowed).toBe(value) + + // @ts-expect-error a project id is not a volume id + const mismatched: Id<'volume'> = createId('project') + expect(mismatched).toHaveLength(ID_LENGTH) + }) + + test('uuids are accepted in either case and returned in lowercase', () => { + const uuid = '019fa519-bf79-724d-8811-a2bfda9755fa' + expect(encodeId('project', uuid.toUpperCase())).toBe( + encodeId('project', uuid) + ) + expect(decodeId('project', encodeId('project', uuid))).toBe(uuid) + }) +}) + +describe('minting ids', () => { + test('createId produces an id of the kind asked for', () => { + for (const kind of KINDS) { + const id = createId(kind) + expect(id).toHaveLength(ID_LENGTH) + expect(isId(kind, id)).toBe(true) + expect(parseId(id).kind).toBe(kind) + } + }) + + test('createUuid mints v7s that carry the current time', () => { + const before = Date.now() + const uuid = createUuid() + const after = Date.now() + + expect(uuid).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + ) + + const bytes = uuidToBytes(uuid) + let ms = 0 + for (const byte of bytes.subarray(0, 6)) ms = ms * 256 + byte + expect(ms).toBeGreaterThanOrEqual(before) + expect(ms).toBeLessThanOrEqual(after) + }) + + test('createUuid does not repeat itself', () => { + const minted = new Set(Array.from({ length: 1000 }, () => createUuid())) + expect(minted.size).toBe(1000) + }) + + // The point of minting from v7: the bytes a database stores sort by time. + // Only to the millisecond — within one, the rest of the UUID is random and + // nothing here adds a counter — so the timestamp field is what is ordered. + test('uuids minted in order carry timestamps in order', () => { + const timestamps = Array.from({ length: 1000 }, () => + createUuid().slice(0, 13) + ) + expect([...timestamps].sort()).toStrictEqual(timestamps) + }) +}) + +describe('a mismatched kind is an error', () => { + const [, uuid, projectId] = GOLDEN_IDS[0] + + test('decodeId names both kinds', () => { + expect(() => decodeId('volume', projectId)).toThrow(InvalidIdError) + expect(() => decodeId('volume', projectId)).toThrow( + /is a project ID, not a volume ID/ + ) + }) + + test('isId is false for every other kind', () => { + for (const kind of KINDS) { + expect(isId(kind, projectId)).toBe(kind === 'project') + } + }) + + test('an unknown prefix says what is accepted', () => { + const id = `tpl_${projectId.slice(4)}` + expect(() => parseId(id)).toThrow(/unknown prefix "tpl"/) + expect(() => decodeId('project', id)).toThrow( + /expected it to start with "prj_"/ + ) + expect(isId('project', id)).toBe(false) + }) + + test('so does an unknown kind', () => { + // Reachable from JavaScript, and from TypeScript through a cast, so the + // check has to be a runtime one. + expect(() => encodeId('template' as IdKind, uuid)).toThrow( + /'template' is not a resource kind/ + ) + }) +}) + +describe('malformed ids are rejected', () => { + const projectId = GOLDEN_IDS[0][2] + + test.each([ + ['nothing at all', ''], + ['a bare encoding with no prefix', projectId.slice(4)], + ['a prefix with no encoding', 'prj_'], + ['no separator', projectId.replace('_', '')], + ['a hyphen instead of an underscore', projectId.replace('_', '-')], + ['one character short', projectId.slice(0, -1)], + ['one character long', `${projectId}a`], + ['uppercase', projectId.toUpperCase()], + ['an uppercase prefix', `PRJ_${projectId.slice(4)}`], + ['a character outside the alphabet', `${projectId.slice(0, -1)}0`], + ['surrounding whitespace', ` ${projectId} `], + [ + 'a non-canonical spelling', + `${projectId.slice(0, 13)}j${projectId.slice(14)}`, + ], + ])('%s', (_, value) => { + expect(isId('project', value)).toBe(false) + expect(() => decodeId('project', value)).toThrow(InvalidIdError) + }) + + test('the non-canonical case is really the only bit flipped', () => { + // Index 13 of the id is index 9 of the encoding, the slack digit. Setting + // its low bits leaves a string that a permissive base32 decoder would + // happily map to the same UUID. + const nonCanonical = `${projectId.slice(0, 13)}j${projectId.slice(14)}` + expect(nonCanonical).toHaveLength(ID_LENGTH) + expect(nonCanonical).not.toBe(projectId) + expect(() => decodeId('project', nonCanonical)).toThrow(/canonical/) + }) + + test.each([ + ['not hex', 'not-a-uuid'], + ['unhyphenated', '019fa519bf79724d8811a2bfda9755fa'], + ['braced', '{019fa519-bf79-724d-8811-a2bfda9755fa}'], + ['urn-prefixed', 'urn:uuid:019fa519-bf79-724d-8811-a2bfda9755fa'], + ['one digit short', '019fa519-bf79-724d-8811-a2bfda9755f'], + ['empty', ''], + ])('encodeId rejects a uuid that is %s', (_, uuid) => { + expect(() => encodeId('project', uuid)).toThrow(InvalidIdError) + }) +}) + +describe('the fixes the review found', () => { + const projectId = GOLDEN_IDS[0][2] + + // A plain object would answer `KINDS_BY_PREFIX['toString']` with an inherited + // function, putting `[native code]` into a user-facing message — and Python, + // whose dict.get does not inherit, would report something else for the same + // input. + test.each(['toString', 'valueOf', 'constructor', 'hasOwnProperty'])( + 'the prefix %s does not reach Object.prototype', + (prefix) => { + const id = `${prefix}_${projectId.slice(4)}` + expect(() => parseId(id)).toThrow( + new RegExp(`carries the unknown prefix "${prefix}"`) + ) + expect(() => decodeId('project', id)).toThrow( + /expected it to start with "prj_"/ + ) + expect(isId('project', id)).toBe(false) + } + ) + + // `__proto__` cannot reach the lookup at all — it leads with the separator, so + // the prefix parses as empty — but it is worth pinning that it is rejected + // rather than doing anything exotic. + test('a leading underscore parses as an empty prefix', () => { + const id = `__proto___${projectId.slice(4)}` + expect(() => parseId(id)).toThrow(/carries the unknown prefix ""/) + expect(isId('project', id)).toBe(false) + }) + + // isId is the first thing a handler calls on a JSON payload, so a null or a + // number has to be `false`, not a TypeError. + test.each([null, undefined, 123, ['x'], {}, Symbol('x')])( + 'isId(%s) is false rather than a throw', + (value) => { + expect(isId('project', value as unknown as string)).toBe(false) + } + ) + + test('an unknown kind is false rather than a throw', () => { + expect(isId('template' as IdKind, projectId)).toBe(false) + }) + + // Callers branch on `reason`, never on the message, so rewording a message + // stays a cosmetic change. + test.each([ + ['prefix', 'volume' as IdKind, projectId], + ['prefix', 'project' as IdKind, `tpl_${projectId.slice(4)}`], + ['prefix', 'project' as IdKind, projectId.slice(4)], + ['length', 'project' as IdKind, projectId.slice(0, -1)], + ['alphabet', 'project' as IdKind, `${projectId.slice(0, -1)}0`], + [ + 'canonical', + 'project' as IdKind, + `${projectId.slice(0, 13)}j${projectId.slice(14)}`, + ], + ])('the reason for a %s failure', (reason, kind, value) => { + try { + decodeId(kind, value) + expect.fail(`decodeId should have rejected ${value}`) + } catch (error) { + expect(error).toBeInstanceOf(InvalidIdError) + expect((error as InvalidIdError).reason).toBe(reason) + } + }) + + test('the reason for a bad uuid and an unknown kind', () => { + expect(() => encodeId('project', 'not-a-uuid')).toThrow( + expect.objectContaining({ reason: 'uuid' }) + ) + expect(() => encodeId('template' as IdKind, GOLDEN_IDS[0][1])).toThrow( + expect.objectContaining({ reason: 'kind' }) + ) + }) + + test('a kind mismatch reports the kind it actually names', () => { + try { + decodeId('volume', projectId) + expect.fail('should have thrown') + } catch (error) { + expect((error as InvalidIdError).actualKind).toBe('project') + } + }) + + test('every prefix is PREFIX_LENGTH wide, which is what ID_LENGTH assumes', () => { + for (const prefix of Object.values(ID_PREFIXES)) { + expect(prefix).toHaveLength(PREFIX_LENGTH) + } + expect(ID_LENGTH).toBe(PREFIX_LENGTH + 1 + ENCODED_LENGTH) + }) +}) + +// Nothing else imports the barrel, so without this a typo in a re-export ships. +describe('the public entry point', () => { + test('re-exports every name the README documents', async () => { + const entry = await import('../src/index') + + for (const name of [ + 'ALPHABET', + 'DECODED_LENGTH', + 'ENCODED_LENGTH', + 'ID_LENGTH', + 'ID_PREFIXES', + 'PREFIX_LENGTH', + 'InvalidIdError', + 'bytesToUuid', + 'createId', + 'createUuid', + 'decodeBytes', + 'decodeId', + 'encodeBytes', + 'encodeId', + 'isId', + 'parseId', + 'uuidToBytes', + ]) { + expect(entry, name).toHaveProperty(name) + } + + expect(entry.createId('project')).toHaveLength(entry.ID_LENGTH) + expect(entry.PREFIX_LENGTH).toBe(3) + }) +}) diff --git a/packages/js-id/tests/interop.test.ts b/packages/js-id/tests/interop.test.ts new file mode 100644 index 0000000000..c759dd54ff --- /dev/null +++ b/packages/js-id/tests/interop.test.ts @@ -0,0 +1,93 @@ +import { execFileSync } from 'node:child_process' +import { describe, expect, test } from 'vitest' + +import { encodeBytes } from '../src/codec' +import { bytesToUuid } from '../src/uuid' +import { corpus } from './vectors' + +/** + * The interop contract, checked against another language's standard library + * rather than against another copy of our own code. + * + * The snippet below is the one in `src/codec.ts`'s documentation and in + * `@e2b/id`'s README, verbatim: six lines of `base64` and no tables. If this + * passes, the format really is "b32encode, lowercase, strip padding, rotate", + * so any port tested the same way against the same corpus decodes what this + * package emits. + */ +const SNIPPET = ` +import base64, sys + +def encode(b: bytes) -> str: + s = base64.b32encode(b).decode().rstrip("=").lower() + return s[16:] + s[:16] + +def decode(s: str) -> bytes: + s = s[10:] + s[:10] + return base64.b32decode(s.upper() + "======") + +n = 0 +for line in sys.stdin: + hex_bytes, encoded = line.split() + raw = bytes.fromhex(hex_bytes) + assert encode(raw) == encoded, (hex_bytes, encoded, encode(raw)) + assert decode(encoded) == raw, (hex_bytes, encoded) + n += 1 +print(f"ok {n}") +` + +/** + * The interpreter to drive. Windows runners ship `python`, not `python3`, and + * a `skipIf` keyed on `python3` alone would turn that leg into a silent no-op + * reporting green — the one failure mode a cross-language check must not have. + * `E2B_ID_SKIP_INTEROP=1` is the deliberate opt-out for a machine with no + * Python at all. + */ +function findPython(): string | undefined { + for (const candidate of ['python3', 'python']) { + try { + // Windows ships a `python3` App Execution Alias that exists on PATH and + // exits nonzero, so the version has to actually run. + const version = execFileSync(candidate, ['--version'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }) + if (version.startsWith('Python 3')) return candidate + } catch { + continue + } + } + return undefined +} + +const python = findPython() + +describe.skipIf(process.env.E2B_ID_SKIP_INTEROP === '1')( + "python's standard library agrees", + () => { + test('in both directions, over the whole corpus', () => { + // Not a skip: on CI this suite is the only thing standing between a + // format change and a silent fork from the other ports, so a missing + // interpreter has to be loud. + expect( + python, + 'no python3 on PATH; set E2B_ID_SKIP_INTEROP=1 to skip this suite deliberately' + ).toBeDefined() + + const values = corpus() + const input = values + .map( + (bytes) => + `${bytesToUuid(bytes).replaceAll('-', '')} ${encodeBytes(bytes)}` + ) + .join('\n') + + const output = execFileSync(python!, ['-c', SNIPPET], { + input, + encoding: 'utf8', + }) + + expect(output.trim()).toBe(`ok ${values.length}`) + }) + } +) diff --git a/packages/js-id/tests/runtime.test.ts b/packages/js-id/tests/runtime.test.ts new file mode 100644 index 0000000000..e8bc4e2734 --- /dev/null +++ b/packages/js-id/tests/runtime.test.ts @@ -0,0 +1,48 @@ +import { readdirSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { expect, test } from 'vitest' + +const SRC = join(import.meta.dirname, '..', 'src') + +/** + * The package runs anywhere: browsers, Cloudflare Workers, Deno, Bun and Node, + * with no compatibility flags. That holds only as long as `src` reaches for + * nothing but platform globals — `crypto.getRandomValues` and `Date.now`. + * + * A single `node:*` import anywhere in the graph is enough to break it, and it + * breaks at import time rather than at the call, so tree-shaking does not save + * you (see js-sdk and e2b-dev/e2b#1579, where an eager `createRequire` shim + * crashed workerd on import). This test is cheaper than finding out again. + */ +test('the source reaches for no runtime-specific api', () => { + const offenders: string[] = [] + + // Recursive: a `src/internal/*.ts` added later is just as fatal, and a + // non-recursive scan would skip the subdirectory without a word. + const files = readdirSync(SRC, { recursive: true }) as string[] + expect(files.length).toBeGreaterThan(0) + + for (const file of files) { + if (!file.endsWith('.ts')) continue + const source = readFileSync(join(SRC, file), 'utf8') + + // Comments carry `node:` in prose and `require` in explanations, so only + // real code counts: strip block comments and line comments first. + const code = source + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/^\s*\/\/.*$/gm, '') + + for (const pattern of [ + /\bfrom\s+['"]node:/, + /\brequire\s*\(/, + /\bprocess\./, + /\bBuffer\b/, + /\b__dirname\b/, + ]) { + const match = code.match(pattern) + if (match) offenders.push(`${file}: ${match[0]}`) + } + } + + expect(offenders).toStrictEqual([]) +}) diff --git a/packages/js-id/tests/vectors.ts b/packages/js-id/tests/vectors.ts new file mode 100644 index 0000000000..5873228831 --- /dev/null +++ b/packages/js-id/tests/vectors.ts @@ -0,0 +1,241 @@ +/** + * The shared test material: golden encodings, and a deterministic corpus. + * + * Every port of this format carries this file value for value — the same + * goldens, the same LCG, the same corpus in the same order. That is what keeps + * implementations from drifting: each checks the same inputs against the same + * fixed outputs and against its own independent reference encoder, so agreeing + * with this file means agreeing with each other. + */ + +import { createHash } from 'node:crypto' + +import { ALPHABET, ENCODED_LENGTH, ROTATION, SLACK_BITS } from '../src/codec' +import type { IdKind } from '../src/id' + +/** + * UUIDs and the encodings they must produce. These strings were computed with + * Python's `base64.b32encode` rather than captured from this package, so they + * check the implementation rather than record it, and they are the + * compatibility contract: change the alphabet, the width or the rotation and + * they stop matching. + * + * The five v7s were minted 37ms apart, so note what the rotation did: they + * share "agp2kg" from index 10, which is the common timestamp showing through, + * and their fronts differ. + */ +export const GOLDEN: ReadonlyArray = [ + // v7, minted 37ms apart. + ['019fa519-bf79-724d-8811-a2bfda9755fa', 'uk75vf2v7iagp2kgn7pfze3car'], + ['019fa519-bf9f-762a-a916-431479cc7171', 'imkhttdroeagp2kgn7t53cvkiw'], + ['019fa519-bfc5-784d-9386-a5d7a93a692a', 'uxl2sotjfiagp2kgn7yv4e3e4g'], + ['019fa519-bfeb-79f2-aa2a-0addf5b9c0d9', 'blo7looa3eagp2kgn75n47fkrk'], + ['019fa519-c011-7c8e-a039-cb3ba8ca8c16', 'zm52rsumcyagp2kgoacf6i5ibz'], + // v3 and v5 of "e2b.dev" in the DNS namespace: the only versions that are + // deterministic, and so the only ones that can be pinned by name. + ['a3692b74-8ded-3329-af53-cc23b4d7dc27', 'zqr3jv64e4unusw5en5uzstl2t'], + ['fbe1337a-dac0-53d8-805c-905bca106f3e', 'sbn4uedphy7pqtg6w2ybj5rac4'], + // A real v4. + ['f47ac10b-58cc-4372-a567-0e02b2c3d479', 'byblfq6upe6r5mcc2yzrbxfjlh'], + // The extremes, and the value whose only set bit is the lowest one: it + // lands in the slack digit, which rotation has moved to index 9. + ['00000000-0000-0000-0000-000000000000', 'aaaaaaaaaaaaaaaaaaaaaaaaaa'], + ['00000000-0000-0000-0000-000000000001', 'aaaaaaaaaeaaaaaaaaaaaaaaaa'], + ['ffffffff-ffff-ffff-ffff-ffffffffffff', '77777777747777777777777777'], + // Not UUIDs at all: two IPv6 addresses, which are the same 16 bytes and so + // encode in the same column. Nothing in the codec reads version bits, and + // these have none to read. + ['26064700-4700-0000-0000-000000001111', 'aaaaaaarceeydeoachaaaaaaaa'], // 2606:4700:4700::1111 + ['20010db8-85a3-0000-0000-8a2e03707334', 'rixag4dtgqeaaq3oefumaaaaaa'], // 2001:db8:85a3::8a2e:370:7334 +] + +/** The same contract for the prefixed form, one per kind. */ +export const GOLDEN_IDS: ReadonlyArray< + readonly [kind: IdKind, uuid: string, id: string] +> = [ + [ + 'project', + '019fa519-bf79-724d-8811-a2bfda9755fa', + 'prj_uk75vf2v7iagp2kgn7pfze3car', + ], + [ + 'workspace', + '019fa519-bf9f-762a-a916-431479cc7171', + 'wrk_imkhttdroeagp2kgn7t53cvkiw', + ], + [ + 'volume', + '019fa519-bfc5-784d-9386-a5d7a93a692a', + 'vol_uxl2sotjfiagp2kgn7yv4e3e4g', + ], + [ + 'sandbox', + '019fa519-bfeb-79f2-aa2a-0addf5b9c0d9', + 'sbx_blo7looa3eagp2kgn75n47fkrk', + ], + [ + 'user', + '019fa519-c011-7c8e-a039-cb3ba8ca8c16', + 'usr_zm52rsumcyagp2kgoacf6i5ibz', + ], + [ + 'group', + 'f47ac10b-58cc-4372-a567-0e02b2c3d479', + 'grp_byblfq6upe6r5mcc2yzrbxfjlh', + ], +] + +/** + * sha256 of every encoding in {@link corpus}, newline-joined, in order. The same + * constant is pinned by every port; if two implementations ever disagree about + * the corpus or the format, one of them fails on it. + */ +export const CORPUS_DIGEST = + 'a83c455e0a4ffd39e51c69d832c544b6809bf836f6459e2a9797005dddc71ec0' + +const MASK_64 = (1n << 64n) - 1n +const MASK_128 = (1n << 128n) - 1n + +/** + * A 64-bit linear congruential generator, so that the random half of the + * corpus is reproducible and identical in every implementation. The + * multiplier and increment are PCG's; the output byte is the top one, since + * an LCG's low bits are the weak ones. + */ +export class Lcg { + private state: bigint + + constructor(seed: number) { + this.state = BigInt(seed) & MASK_64 + } + + byte(): number { + this.state = + (this.state * 6364136223846793005n + 1442695040888963407n) & MASK_64 + return Number((this.state >> 56n) & 0xffn) + } + + bytes(count: number): Uint8Array { + return Uint8Array.from({ length: count }, () => this.byte()) + } +} + +/** 16 bytes with a v4's version and variant nibbles. */ +function v4(lcg: Lcg): Uint8Array { + const bytes = lcg.bytes(16) + bytes[6] = (bytes[6] & 0x0f) | 0x40 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + return bytes +} + +/** + * 16 bytes shaped like a v7: a 48-bit millisecond timestamp somewhere between + * 2020 and 2089, then the version and variant nibbles. + */ +export function v7(source: Lcg, ms?: number): Uint8Array { + // Exactly 16 bytes are drawn either way, so the corpus does not shift when a + // caller pins the timestamp. + const bytes = source.bytes(16) + const at = BigInt( + ms ?? 1577836800000 + Number(bigintFrom(bytes.subarray(0, 6)) % (1n << 41n)) + ) + for (let i = 0; i < 6; i++) { + bytes[i] = Number((at >> BigInt(8 * (5 - i))) & 0xffn) + } + bytes[6] = (bytes[6] & 0x0f) | 0x70 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + return bytes +} + +export function bigintFrom(bytes: Uint8Array): bigint { + let value = 0n + for (const byte of bytes) value = (value << 8n) | BigInt(byte) + return value +} + +export function bytesFrom(value: bigint): Uint8Array { + const bytes = new Uint8Array(16) + let rest = value & MASK_128 + for (let i = 15; i >= 0; i--) { + bytes[i] = Number(rest & 0xffn) + rest >>= 8n + } + return bytes +} + +/** + * The shared input: the extremes, the smallest values, every power of two + * either side — a digit boundary falls every 5 bits and that is where a + * packing mistake shows — the goldens, and a reproducible random tail of v4s, + * v7s and values that are neither. + */ +export function corpus(): Uint8Array[] { + const values: Uint8Array[] = [bytesFrom(0n), bytesFrom(MASK_128)] + + for (let value = 1n; value <= 7n; value++) values.push(bytesFrom(value)) + + for (let bit = 1n; bit < 128n; bit++) { + const power = 1n << bit + for (const delta of [-1n, 0n, 1n]) values.push(bytesFrom(power + delta)) + } + + for (const [uuid] of GOLDEN) { + values.push(bytesFrom(BigInt(`0x${uuid.replaceAll('-', '')}`))) + } + + const random = new Lcg(1) + for (let i = 0; i < 300; i++) { + values.push(v4(random), v7(random), random.bytes(16)) + } + + return values +} + +/** + * An independent implementation of the format, stated as arithmetic rather + * than as bit shuffling: the 128-bit value, moved up by the two slack bits, + * written in 26 base32 digits, then rotated. `encodeBytes` is checked against + * this rather than against itself. + */ +export function refEncode(bytes: Uint8Array): string { + let value = bigintFrom(bytes) << BigInt(SLACK_BITS) + + const digits: string[] = [] + for (let i = 0; i < ENCODED_LENGTH; i++) { + digits.unshift(ALPHABET[Number(value & 31n)]) + value >>= 5n + } + + const raw = digits.join('') + return raw.slice(ROTATION) + raw.slice(0, ROTATION) +} + +/** + * The permissive counterpart to {@link refEncode}: unrotate, unpack 26 digits + * into 130 bits, keep the top 128. It does not look at the slack digit, which is + * the point — it is what a third-party decoder built from the README snippet + * does, so the tests can show that all four spellings of a value reach it. + */ +export function refDecode(encoded: string): Uint8Array { + const raw = + encoded.slice(ENCODED_LENGTH - ROTATION) + + encoded.slice(0, ENCODED_LENGTH - ROTATION) + + let value = 0n + for (const character of raw) { + value = (value << 5n) | BigInt(ALPHABET.indexOf(character)) + } + return bytesFrom(value >> BigInt(SLACK_BITS)) +} + +/** The digest {@link CORPUS_DIGEST} pins. */ +export function corpusDigest(encode: (bytes: Uint8Array) => string): string { + return createHash('sha256') + .update(corpus().map(encode).join('\n')) + .digest('hex') +} + +/** 32 hex digits, for naming the offending value in a failure message. */ +export function hex(bytes: Uint8Array): string { + return Buffer.from(bytes).toString('hex') +} diff --git a/packages/js-id/tsconfig.json b/packages/js-id/tsconfig.json new file mode 100644 index 0000000000..2eee231f1b --- /dev/null +++ b/packages/js-id/tsconfig.json @@ -0,0 +1,36 @@ +{ + "compilerOptions": { + "outDir": "dist", + "target": "es2022", + "module": "esnext", + "lib": [ + "dom", + "es2022" + ], + "sourceMap": true, + "skipLibCheck": true, + "esModuleInterop": true, + "strict": true, + "noUncheckedIndexedAccess": false, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "moduleResolution": "bundler", + "isolatedModules": true, + "declaration": true, + // TypeScript 7's native tsc does not auto-include node_modules/@types, so + // the Node globals the tests use (`node:*` modules) have to be requested + // explicitly. + "types": [ + "node" + ] + }, + "include": [ + "src", + "tests" + ], + "exclude": [ + "dist", + "node_modules", + "tsdown.config.ts" + ] +} diff --git a/packages/js-id/tsdown.config.ts b/packages/js-id/tsdown.config.ts new file mode 100644 index 0000000000..1b8a645a95 --- /dev/null +++ b/packages/js-id/tsdown.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig({ + entry: { index: './src/index.ts' }, + // Matches the `engines` floor. Not es2017 like js-sdk: rolldown lowers syntax + // but not library methods, so an es2017 claim would be a lie the moment the + // source touches anything newer (`Object.fromEntries` is ES2019). + target: 'es2022', + format: ['esm', 'cjs'], + fixedExtension: false, + sourcemap: true, + dts: true, + clean: true, +}) diff --git a/packages/js-id/vitest.config.mts b/packages/js-id/vitest.config.mts new file mode 100644 index 0000000000..c2c24a2496 --- /dev/null +++ b/packages/js-id/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['tests/**/*.test.ts'], + environment: 'node', + globals: false, + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e61d5ffc10..1e0aea807b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -131,6 +131,27 @@ importers: specifier: ^4.1.10 version: 4.1.10(@types/node@20.19.43)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(msw@2.12.14(@types/node@20.19.43)(@typescript/typescript6@6.0.2))(vite@6.4.3(@types/node@20.19.43)(jiti@2.4.2)(terser@5.46.0)) + packages/js-id: + devDependencies: + '@types/node': + specifier: ^20.19.19 + version: 20.19.43 + '@typescript/native': + specifier: npm:typescript@^7.0.2 + version: typescript@7.0.2 + prettier: + specifier: ^3.6.2 + version: 3.6.2 + tsdown: + specifier: ^0.22.3 + version: 0.22.4(@typescript/typescript6@6.0.2) + typescript: + specifier: npm:@typescript/typescript6@^6.0.2 + version: '@typescript/typescript6@6.0.2' + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@20.19.43)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(msw@2.12.14(@types/node@20.19.43)(@typescript/typescript6@6.0.2))(vite@6.4.3(@types/node@20.19.43)(jiti@2.4.2)(terser@5.46.0)) + packages/js-sdk: dependencies: '@bufbuild/protobuf':