diff --git a/.changeset/js-id-package.md b/.changeset/js-id-package.md new file mode 100644 index 0000000000..5375cbdfef --- /dev/null +++ b/.changeset/js-id-package.md @@ -0,0 +1,5 @@ +--- +'@e2b/id': minor +--- + +Initial release: prefixed, human-legible IDs for E2B resources (`prj_…`, `wrk_…`, `sec_…`, …) in the TypeID encoding the Go services use, as a dependency-free package that runs in Node, Bun, Deno, Workers and browsers. diff --git a/.github/scripts/build_release_itinerary.cjs b/.github/scripts/build_release_itinerary.cjs index ae6cb4e2f8..2bb5f2a6d9 100644 --- a/.github/scripts/build_release_itinerary.cjs +++ b/.github/scripts/build_release_itinerary.cjs @@ -13,6 +13,7 @@ const labels = { '@e2b/cli': 'CLI (@e2b/cli)', '@e2b/dockerfile-utils': 'Dockerfile Utils JS (@e2b/dockerfile-utils)', '@e2b/dockerfile-utils-python': 'Dockerfile Utils Python (e2b-dockerfile-utils)', + '@e2b/id': 'ID JS (@e2b/id)', } const statusFile = process.argv[2] 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/pkg_artifacts.yml b/.github/workflows/pkg_artifacts.yml index cec5de45f0..887dd650eb 100644 --- a/.github/workflows/pkg_artifacts.yml +++ b/.github/workflows/pkg_artifacts.yml @@ -120,6 +120,21 @@ jobs: name: e2b-dockerfile-utils-js path: packages/dockerfile-utils-js/*.tgz + - name: Pack ID JS + if: ${{ !cancelled() }} + working-directory: packages/js-id + run: | + pnpm run build + pnpm version prerelease --preid=${{ env.BRANCH_ID }} --no-git-tag-version + pnpm pack + + - name: Upload ID JS artifact + if: ${{ !cancelled() }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: e2b-id-js + path: packages/js-id/*.tgz + # Independent of the CLI steps above: a CLI failure should still leave # reviewers a usable SDK tarball, as it did before the CLI moved ahead of it. - name: Pack JS SDK @@ -226,6 +241,8 @@ jobs: CODE_INTERPRETER_JS_TGZ=$(artifact_basename packages/code-interpreter-js/*.tgz) DESKTOP_JS_VERSION=$(node -p "require('./packages/desktop-js/package.json').version") DESKTOP_JS_TGZ=$(artifact_basename packages/desktop-js/*.tgz) + ID_JS_VERSION=$(node -p "require('./packages/js-id/package.json').version") + ID_JS_TGZ=$(artifact_basename packages/js-id/*.tgz) PY_VERSION=$(grep '^version' packages/python-sdk/pyproject.toml | head -1 | sed 's/.*"\(.*\)"/\1/') PY_WHL=$(artifact_basename packages/python-sdk/dist/*.whl) DOCKERFILE_UTILS_PY_WHL=$(artifact_basename packages/dockerfile-utils-python/dist/*.whl) @@ -253,6 +270,10 @@ jobs: BODY+='```sh'$'\n' BODY+="npm install ./${DESKTOP_JS_TGZ}"$'\n' BODY+='```'$'\n\n' + BODY+="**ID JS** (\`@e2b/id@${ID_JS_VERSION}\`):"$'\n' + BODY+='```sh'$'\n' + BODY+="npm install ./${ID_JS_TGZ}"$'\n' + BODY+='```'$'\n\n' BODY+="**Python SDK** (\`e2b==${PY_VERSION}\`, with \`e2b-dockerfile-utils==${DOCKERFILE_UTILS_PY_VERSION}\`):"$'\n' BODY+='```sh'$'\n' BODY+="pip install ./${DOCKERFILE_UTILS_PY_WHL} ./${PY_WHL}"$'\n' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a95098d819..ef4254cf9e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,6 +31,7 @@ jobs: desktop_python: ${{ steps.desktop_python.outputs.release }} dockerfile_utils_js: ${{ steps.dockerfile_utils_js.outputs.release }} dockerfile_utils_python: ${{ steps.dockerfile_utils_python.outputs.release }} + id_js: ${{ steps.id_js.outputs.release }} itinerary: ${{ steps.itinerary.outputs.itinerary }} steps: - name: Check the ref @@ -148,6 +149,13 @@ jobs: IS_RELEASE=$(./.github/scripts/is_release_for_package.sh "@e2b/dockerfile-utils-python" "$RUNNER_TEMP/release-plan.json") echo "release=$IS_RELEASE" >> "$GITHUB_OUTPUT" + - name: Check ID JS Release + id: id_js + if: steps.version.outputs.release == 'true' + run: | + IS_RELEASE=$(./.github/scripts/is_release_for_package.sh "@e2b/id" "$RUNNER_TEMP/release-plan.json") + echo "release=$IS_RELEASE" >> "$GITHUB_OUTPUT" + - name: Build release itinerary id: itinerary if: steps.version.outputs.release == 'true' @@ -223,6 +231,12 @@ jobs: if: needs.preflight.outputs.dockerfile_utils_python == 'true' uses: ./.github/workflows/dockerfile_utils_python_tests.yml + id-tests: + name: ID Package Tests + needs: [preflight] + if: needs.preflight.outputs.id_js == 'true' + uses: ./.github/workflows/id_tests.yml + publish: name: Publish needs: @@ -236,6 +250,7 @@ jobs: - desktop-python-tests - dockerfile-utils-js-tests - dockerfile-utils-python-tests + - id-tests if: (!cancelled()) && !contains(needs.*.result, 'failure') && needs.preflight.outputs.release == 'true' uses: ./.github/workflows/publish_packages.yml with: @@ -275,6 +290,7 @@ jobs: - desktop-python-tests - dockerfile-utils-js-tests - dockerfile-utils-python-tests + - id-tests - publish if: failure() name: Release Failed - Slack Notification diff --git a/.github/workflows/sdk_tests.yml b/.github/workflows/sdk_tests.yml index a99a9ba5b1..cf06732875 100644 --- a/.github/workflows/sdk_tests.yml +++ b/.github/workflows/sdk_tests.yml @@ -35,6 +35,7 @@ jobs: desktop_python: ${{ steps.filter.outputs.desktop_python }} dockerfile_utils_js: ${{ steps.filter.outputs.dockerfile_utils_js }} dockerfile_utils_python: ${{ steps.filter.outputs.dockerfile_utils_python }} + id: ${{ steps.filter.outputs.id }} steps: - name: Filter changed paths # On workflow_dispatch there is no PR diff and paths-filter would fall @@ -111,6 +112,10 @@ jobs: - *shared - '.github/workflows/dockerfile_utils_python_tests.yml' - 'packages/dockerfile-utils-python/**/!(*.md)' + id: + - *shared + - '.github/workflows/id_tests.yml' + - 'packages/js-id/**/!(*.md)' js-tests: name: JS SDK Tests @@ -136,6 +141,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 + code-interpreter-js-tests: name: Code Interpreter JS SDK Tests needs: changes @@ -195,6 +208,7 @@ jobs: - desktop-python-tests - dockerfile-utils-js-tests - dockerfile-utils-python-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..a6e6efd786 --- /dev/null +++ b/packages/js-id/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 FoundryLabs, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/js-id/README.md b/packages/js-id/README.md new file mode 100644 index 0000000000..007ea35f22 --- /dev/null +++ b/packages/js-id/README.md @@ -0,0 +1,153 @@ +# `@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_01kyjhkfvse96rg4d2qzd9enft' + +encodeId('volume', '019fa519-bfc5-784d-9386-a5d7a93a692a') +// 'vol_01kyjhkfy5f16s71n5tymkmt9a' + +decodeId('volume', 'vol_01kyjhkfy5f16s71n5tymkmt9a') +// '019fa519-bfc5-784d-9386-a5d7a93a692a' + +parseId('vol_01kyjhkfy5f16s71n5tymkmt9a') +// { kind: 'volume', uuid: '019fa519-bfc5-784d-9386-a5d7a93a692a' } + +isId('project', 'vol_01kyjhkfy5f16s71n5tymkmt9a') +// false +``` + +The format is deliberately portable: any TypeID library reads it, and so does +any language with integers. 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_7mfb0gpp6c8dsaasre0asc7n3s +wrk_7mfb0gpp6c8dsaasre0asc7n3s +sec_01kyjhkfvse96rg4d2qzd9enft +vol_01kyjhkfy5f16s71n5tymkmt9a +sbx_01kyjhkfzbf7samagavqtvkg6s +usr_01kyjhkg0hfj7a0eeb7emcn30p +grp_2n1t201rmv87aae5j4csam8000 +``` + +| kind | prefix | +| ----------- | ------ | +| `project` | `prj` | +| `workspace` | `wrk` | +| `secret` | `sec` | +| `volume` | `vol` | +| `sandbox` | `sbx` | +| `user` | `usr` | +| `group` | `grp` | + +The encoding is [TypeID](https://github.com/jetify-com/typeid)'s: the UUID's +128 bits written in base 32 with the Crockford alphabet (`0-9a-z` minus `i`, +`l`, `o` and `u`), lowercase, most significant digit first, left-padded to 26 +digits. 26 digits hold 130 bits, so the first character carries two zero bits +and is always `0` to `7`. + +This is the format E2B's services emit and accept — the Go side is +[`packages/shared/pkg/id`](https://github.com/e2b-dev/infra/tree/main/packages/shared/pkg/id) +in e2b-dev/infra, built on `github.com/sumup/typeid`, and its test vectors are +pinned in this package's suite. `project`, `workspace` and `secret` are the +kinds it defines, with the same prefixes; the other four are ahead of it. Any +TypeID library reads these IDs, and so does any language with integers: + +```py +ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz" + +def encode(b: bytes) -> str: + n = int.from_bytes(b, "big") + return "".join(ALPHABET[(n >> (5 * i)) & 31] for i in range(25, -1, -1)) + +def decode(s: str) -> bytes: + n = 0 + for c in s: + n = n * 32 + ALPHABET.index(c) + return n.to_bytes(16, "big") +``` + +The digits are the bits in order, so IDs sort the way their UUIDs do. A UUIDv7 +leads with a millisecond timestamp, and so its IDs are ordered by mint time — +which also means IDs minted together share a long prefix. The tail is the +distinctive part to read. + +### One spelling per ID + +A leading character above `7` spells a value no 16 bytes can hold, so every +UUID has four strings a careless 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 — exactly what the Go +side rejects. + +### UUIDs, the way Go reads and mints them + +`encodeId` takes a UUID in any of the four spellings `google/uuid`'s `Parse` +accepts, and only those: hyphenated `8-4-4-4-12`, the same after `urn:uuid:` or +in braces, or 32 bare hex digits, in either case. Nothing checks the version or +variant bits, because Go does not. Everything this package returns is the +lowercase hyphenated form, as Go's `String` is. + +`createUuid` mints a UUIDv7 the way `NewV7` does: a millisecond timestamp, then +a 12-bit sequence that makes every UUID this process mints strictly greater +than the last, even within a millisecond and even if the clock steps back, then +62 random bits. So `createId` output sorts in call order. + +### 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, monotonic per process like `google/uuid`'s | +| `encodeBytes(bytes)` / `decodeBytes(encoded)` | the prefix-free codec, over any 16 bytes | +| `uuidToBytes(uuid)` / `bytesToUuid(bytes)` | Go's four UUID spellings to bytes, and back to the hyphenated one | +| `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 (the Go suite's among them), a +seeded 1306-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 +snippet above. diff --git a/packages/js-id/package.json b/packages/js-id/package.json new file mode 100644 index 0000000000..b1421cd217 --- /dev/null +++ b/packages/js-id/package.json @@ -0,0 +1,67 @@ +{ + "name": "@e2b/id", + "version": "0.0.0", + "description": "Prefixed, human-legible IDs for E2B resources", + "homepage": "https://e2b.dev", + "license": "Apache-2.0", + "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": "catalog:", + "@typescript/native": "catalog:", + "prettier": "^3.6.2", + "tsdown": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "files": [ + "dist", + "README.md", + "LICENSE", + "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..801089ec4c --- /dev/null +++ b/packages/js-id/src/codec.ts @@ -0,0 +1,224 @@ +/** + * The TypeID codec: 16 bytes as 26 lowercase Crockford base32 characters. + * + * The 128-bit value is written in base 32, most significant digit first, with + * the Crockford alphabet (`0-9a-z` minus `i`, `l`, `o` and `u`), left-padded + * to 26 digits. 26 digits carry 130 bits, so the leading digit holds the top + * three bits of the value under two bits that are always zero: every encoding + * starts with a character from `0` to `7`. + * + * This is the encoding of the TypeID specification and of + * `github.com/sumup/typeid`, which is what E2B's Go services use + * (`packages/shared/pkg/id` in e2b-dev/infra). Agreeing with it is the whole + * requirement: an ID minted here must decode there to the same UUID, and the + * other way round. The Go suite's vectors are pinned in `tests/vectors.ts`. + * + * The digits are the bits in order and the alphabet is in ASCII order, so the + * string sorts the way the bytes do. A UUIDv7 leads with its 48-bit millisecond + * timestamp, and so does its ID: IDs minted together share a long prefix, and + * lexical order is mint order to the millisecond. That is what makes a TypeID + * k-sortable — and why the front of an ID is the least distinctive part to + * read; look at the tail. + * + * Reading one in any language takes the alphabet and arithmetic: + * + * ```py + * ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz" + * + * def encode(b: bytes) -> str: + * n = int.from_bytes(b, "big") + * return "".join(ALPHABET[(n >> (5 * i)) & 31] for i in range(25, -1, -1)) + * + * def decode(s: str) -> bytes: + * n = 0 + * for c in s: + * n = n * 32 + ALPHABET.index(c) + * return n.to_bytes(16, "big") + * ``` + * + * Because 128 is not a multiple of 5, a leading digit of 8 or more spells a + * value no 16 bytes can hold. A decoder that masked those bits away would map + * four strings to every value; {@link decodeBytes} accepts only the one + * {@link encodeBytes} produces and rejects the other three as `overflow`, + * which is also what the Go side does. + */ + +import { InvalidIdError } from './errors' + +/** + * Crockford base32, lowercased: the digits, then the letters with `i`, `l`, + * `o` and `u` left out so nothing reads as `1` or `0`. The index of a + * character is its digit value, and the characters are in ASCII order, which + * is what makes encodings sort like the bytes they carry. + */ +export const ALPHABET = '0123456789abcdefghjkmnpqrstvwxyz' + +/** 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 + +/** + * `26 * 5 - 128`: the bits the width has spare. They are the two most + * significant bits of the leading digit, and they are always zero. + */ +export const SLACK_BITS = ENCODED_LENGTH * 5 - DECODED_LENGTH * 8 + +/** + * The largest digit value the leading character may hold: the three bits + * under the slack, so `7`. Anything above it spells a 129- or 130-bit value. + */ +export const MAX_LEADING_DIGIT = (1 << (5 - SLACK_BITS)) - 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 +} + +/** + * Encodes 16 bytes as 26 lowercase Crockford base32 characters, most + * significant first. + * + * 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 + // The slack bits go in front, as zeros already in the buffer: 2 + 128 is + // exactly 26 digits, so the loop flushes the last digit itself and nothing + // is left over to pad. + let bits = SLACK_BITS + for (const byte of bytes) { + buffer = (buffer << 8) | byte + bits += 8 + while (bits >= 5) { + bits -= 5 + out += ALPHABET[(buffer >>> bits) & 31] + } + } + + return 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' | 'overflow'; 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 bits are checked before decoding because the decode loop cannot + // see them: it keeps the low 128 of the 130 bits, so all four spellings of a + // value decode identically and the difference is only visible here. + if (digitAt(encoded, 0) > MAX_LEADING_DIGIT) { + return { reason: 'overflow', index: 0 } + } + + 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 Crockford base32 characters "${ALPHABET}".` + ) + case 'overflow': + return new InvalidIdError( + 'overflow', + `"${encoded}" starts with ${JSON.stringify(encoded[fault.index])}, which spells a value above ${DECODED_LENGTH * 8} bits: the first character must be "${ALPHABET[0]}" to "${ALPHABET[MAX_LEADING_DIGIT]}".` + ) + } +} + +/** + * Decodes what {@link encodeBytes} produced, and only that: 26 characters, + * lowercase Crockford base32, with a leading character from `0` to `7`. + * + * @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 leads with a character above `7`. + */ +export function decodeBytes(encoded: string): Uint8Array { + const fault = faultInEncoded(encoded) + if (fault) throw invalidEncoding(encoded, fault) + + const bytes = new Uint8Array(DECODED_LENGTH) + let buffer = 0 + // The mirror of encoding: the first two bits read are the slack, already + // known to be zero, so the count starts below zero and the first byte is + // complete after exactly two digits. + let bits = -SLACK_BITS + let i = 0 + for (let k = 0; k < ENCODED_LENGTH; k++) { + buffer = (buffer << 5) | digitAt(encoded, 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..52141e9bd6 --- /dev/null +++ b/packages/js-id/src/errors.ts @@ -0,0 +1,44 @@ +import type { IdKind } from './id' + +/** + * Why a string was rejected. Callers that need to react differently per cause — + * hint at the right kind, count overflowing 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, or is not a string. + * - `length`: the encoding is not {@link ENCODED_LENGTH} characters. + * - `alphabet`: it holds a character outside the lowercase Crockford alphabet. + * - `overflow`: its leading character is above `7`, so it spells a value no 16 + * bytes can hold — one of the three non-canonical spellings of a value. + * - `uuid`: the UUID given was not in one of the four forms `google/uuid` + * accepts. + */ +export type InvalidIdReason = + 'kind' | 'prefix' | 'length' | 'alphabet' | 'overflow' | '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..9c8a618c0e --- /dev/null +++ b/packages/js-id/src/id.ts @@ -0,0 +1,304 @@ +/** + * Prefixed, human-legible IDs for E2B resources. + * + * An ID is a three-character kind prefix, an underscore, and the 26-character + * TypeID encoding of a UUID: + * + * ```text + * prj_01kyjhkfvse96rg4d2qzd9enft + * ^^^ ^ + * | 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. `project`, `workspace` and `secret` are the kinds the Go + * side defines (`packages/shared/pkg/id` in e2b-dev/infra), with the same + * prefixes; the rest are ahead of it. + */ +export const ID_PREFIXES = { + project: 'prj', + workspace: 'wrk', + secret: 'sec', + 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(', ') +} + +/** + * The error for an `id` that is not a string at all — a `null` or a number out + * of a JSON payload typed `any`. Callers that turn {@link InvalidIdError} into a + * 400 would otherwise get a TypeError, and a 500. The value is described by its + * type rather than interpolated: a symbol would throw from a template literal. + */ +function notAString(id: unknown): InvalidIdError { + return new InvalidIdError( + 'prefix', + `Expected an ID string, got ${id === null ? 'null' : typeof id}.` + ) +} + +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. Own properties only: + // `ID_PREFIXES['toString']` would otherwise answer with a function inherited + // from `Object.prototype` and mint `function toString() { … }_…`. + const prefix = Object.hasOwn(ID_PREFIXES, kind) + ? (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 any of the four string forms + * `google/uuid` accepts: hyphenated 8-4-4-4-12, the same with a `urn:uuid:` + * prefix or in braces, or 32 bare hex digits. + * @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_01kyjhkfvse96rg4d2qzd9enft' + * ``` + */ +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_01kyjhkfvse96rg4d2qzd9enft') + * // '019fa519-bf79-724d-8811-a2bfda9755fa' + * ``` + */ +export function decodeId(kind: K, id: string): string { + const prefix = prefixOf(kind) + if (typeof id !== 'string') throw notAString(id) + if (!id.startsWith(`${prefix}${SEPARATOR}`)) { + // With no separator there is no prefix to name: slicing to `indexOf`'s -1 + // would drop the last character and could land on a real one ("prjx"). + const separator = id.indexOf(SEPARATOR) + const named = + separator < 0 ? undefined : KINDS_BY_PREFIX.get(id.slice(0, 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_01kyjhkfzbf7samagavqtvkg6s' + * ``` + */ +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_01kyjhkfy5f16s71n5tymkmt9a') + * // { kind: 'volume', uuid: '019fa519-bfc5-784d-9386-a5d7a93a692a' } + * ``` + */ +export function parseId(id: string): ParsedId { + if (typeof id !== 'string') throw notAString(id) + 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 leading + * character. + * + * 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 + + if (!Object.hasOwn(ID_PREFIXES, kind)) return false + const prefix: string = ID_PREFIXES[kind] + 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..1ff46e45f0 --- /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_01kyjhkfvse96rg4d2qzd9enft' + * 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 TypeID encoding of a UUID — the + * same format E2B's Go services emit. See `id.ts` for what the prefix buys and + * `codec.ts` for the encoding. + */ + +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..2b0b0b7b9c --- /dev/null +++ b/packages/js-id/src/uuid.ts @@ -0,0 +1,209 @@ +/** + * The hex form of a UUID and its 16 bytes, plus a UUIDv7 mint — both mirroring + * `github.com/google/uuid`, which is what E2B's Go services parse and mint + * UUIDs with, so that a UUID string Go accepts is one this package accepts, and + * the UUIDs minted here have the shape of the ones minted there. + * + * UUIDs cross this package's boundary as strings + * (`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 hyphenated 8-4-4-4-12 form, in either case. */ +const HYPHENATED = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +/** 32 bare hex digits, in either case. */ +const BARE = /^[0-9a-f]{32}$/i + +/** The prefix of the URN form, matched case-insensitively like Go does. */ +const URN_PREFIX = 'urn:uuid:' + +/** 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') +) + +/** + * The 32 hex digits of a UUID string, or `undefined` if it is not one. + * + * Exactly the four spellings `google/uuid`'s `Parse` takes, chosen by length + * as it does: hyphenated, `urn:uuid:`-prefixed, braced, and bare. Nothing is + * checked about the version or variant bits, as nothing is there — a value + * that is 128 bits but not an RFC 9562 UUID still parses, and encodes. + * + * Go does not look at the braces themselves: it drops the first character and + * never reads the last, so `[…]` and `x…y` parse there. That is an oversight + * rather than a contract, and it is not mirrored — the braces have to be + * braces here. + */ +function hexDigitsOf(uuid: string): string | undefined { + switch (uuid.length) { + case 36: + return HYPHENATED.test(uuid) ? uuid.split('-').join('') : undefined + case 36 + URN_PREFIX.length: + return uuid.slice(0, URN_PREFIX.length).toLowerCase() === URN_PREFIX + ? hexDigitsOf(uuid.slice(URN_PREFIX.length)) + : undefined + case 36 + 2: + return uuid[0] === '{' && uuid[37] === '}' + ? hexDigitsOf(uuid.slice(1, -1)) + : undefined + case 32: + return BARE.test(uuid) ? uuid : undefined + default: + return undefined + } +} + +/** + * Parses a UUID string into its 16 bytes. + * + * @param uuid a UUID in any of the four forms `google/uuid` accepts: hyphenated + * 8-4-4-4-12, the same with a `urn:uuid:` prefix or in braces, or 32 bare hex + * digits; upper or lower case. + * @returns the 16 bytes, most significant first. + * @throws {InvalidIdError} if `uuid` is not in one of those forms. + */ +export function uuidToBytes(uuid: string): Uint8Array { + if (typeof uuid !== 'string') { + // Named by its type: a symbol would throw from a template literal and + // replace this error with a TypeError. + throw new InvalidIdError( + 'uuid', + `Expected a UUID string, got ${uuid === null ? 'null' : typeof uuid}.` + ) + } + const hex = hexDigitsOf(uuid) + if (hex === undefined) { + throw new InvalidIdError( + 'uuid', + `"${uuid}" is not a UUID: expected 32 hex digits, hyphenated 8-4-4-4-12 like "019fa519-bf79-724d-8811-a2bfda9755fa" (optionally in braces or after "urn:uuid:"), or unhyphenated.` + ) + } + + 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: the one + * `google/uuid`'s `String` produces, and the one this package always returns. + * + * @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 a `Uint8Array` of 16 bytes. + */ +export function bytesToUuid(bytes: Uint8Array): string { + // By tag rather than `instanceof`, so a Uint8Array from another realm (an + // iframe, a vm context) and a Buffer both pass. Anything else would format + // values above 255 as "undefined", or be a TypeError for null. + if (Object.prototype.toString.call(bytes) !== '[object Uint8Array]') { + throw new InvalidIdError( + 'uuid', + `Cannot format ${bytes === null ? 'null' : `a ${typeof bytes}`} as a UUID: expected a Uint8Array of exactly ${DECODED_LENGTH} bytes.` + ) + } + 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 +} + +/** The width of the sequence field: the 12 bits of `rand_a`. */ +const SEQUENCE_BITS = 12 +const SEQUENCE_MAX = (1 << SEQUENCE_BITS) - 1 + +/** + * The last timestamp and sequence handed out, so the next pair is always + * greater: `google/uuid` keeps the same two numbers behind a mutex. Module + * state, so it is per process, like Go's. + */ +let lastMs = -1 +let lastSequence = 0 + +/** + * The timestamp and sequence for the next UUIDv7, strictly greater than the + * previous pair even if the clock stalls or steps back. + * + * `google/uuid` fills the sequence from the sub-millisecond part of the clock + * (nanoseconds divided by 256) and only bumps when that fails to move. The + * platform clock here is `Date.now`, whole milliseconds, so the sequence is + * all bump: it counts up through a millisecond and carries into the next when + * it overflows, which is also what Go does once its clock reading is behind. + * Same shape, same guarantee; only the cross-process tie-break Go's + * sub-millisecond bits give is missing. + */ +function nextV7Time(): [ms: number, sequence: number] { + let ms = Date.now() + let sequence = 0 + if (ms < lastMs || (ms === lastMs && sequence <= lastSequence)) { + ms = lastMs + sequence = lastSequence + 1 + if (sequence > SEQUENCE_MAX) { + ms += 1 + sequence = 0 + } + } + lastMs = ms + lastSequence = sequence + return [ms, sequence] +} + +/** + * Mints a UUIDv7 the way `google/uuid`'s `NewV7` does: a 48-bit big-endian + * millisecond timestamp, the version nibble, a 12-bit sequence that makes + * every UUID minted by this process strictly greater than the one before, the + * variant bits, and 62 random bits. + * + * IDs are minted from v7 rather than v4 so that the bytes a database stores + * sort chronologically — and, since {@link encodeId} keeps the bits in order, + * so do the IDs, down to the order of two calls in the same millisecond. The + * Go side mints project and workspace UUIDs as v4; the codec does not care + * either way. + * + * @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, sequence] = nextV7Time() + // 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] = 0x70 | ((sequence >>> 8) & 0x0f) // version 7, sequence high + bytes[7] = sequence & 0xff // sequence low + 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..c226a67234 --- /dev/null +++ b/packages/js-id/tests/codec.test.ts @@ -0,0 +1,258 @@ +import { describe, expect, test } from 'vitest' + +import { + ALPHABET, + DECODED_LENGTH, + decodeBytes, + encodeBytes, + ENCODED_LENGTH, + MAX_LEADING_DIGIT, + SLACK_BITS, +} 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, + SLACK_STEP, + v7, +} from './vectors' + +describe('the format', () => { + // Pinned as literals, not as their own definitions: `expect(SLACK_BITS) + // .toBe(ENCODED_LENGTH * 5 - DECODED_LENGTH * 8)` restates the line that + // computes it and so can never fail. 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('0123456789abcdefghjkmnpqrstvwxyz') + expect(DECODED_LENGTH).toBe(16) + expect(ENCODED_LENGTH).toBe(26) + expect(SLACK_BITS).toBe(2) + expect(MAX_LEADING_DIGIT).toBe(7) + }) + + // 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(MAX_LEADING_DIGIT).toBe((1 << (5 - SLACK_BITS)) - 1) + expect(SLACK_STEP).toBe(MAX_LEADING_DIGIT + 1) + expect(ALPHABET).toHaveLength(32) + expect(new Set(ALPHABET).size).toBe(32) + // Crockford: no i, l, o or u, so nothing reads as a 1 or a 0. + expect(ALPHABET).not.toMatch(/[ilou]/) + // In ASCII order, which is what lets encoded order follow byte order. + expect([...ALPHABET].sort().join('')).toBe(ALPHABET) + }) + + 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, + // most significant first. Checked against refEncode, which computes it a + // different way. + test('the string is the bits, in order', () => { + 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 31 smallest nonzero values fit in the last digit, so they are one + // character from the encoding of zero; 32 is the first to spill into the + // next one. That pins where the low bits live and that no shift happened. + test.each([1, 2, 7, 8, 31])( + 'the value %i is one character from zero, at the end', + (value) => { + const bytes = bytesFrom(BigInt(value)) + const want = ALPHABET[0].repeat(ENCODED_LENGTH - 1) + ALPHABET[value] + + expect(encodeBytes(bytes)).toBe(want) + expect(decodeBytes(want)).toStrictEqual(bytes) + } + ) + + // And the top bit lands in the first digit, under the two slack bits: bit + // 127 is the leading digit's value 4, so the largest leading digit is 7. + test('the top bit is the leading digit', () => { + expect(encodeBytes(bytesFrom(1n << 127n))).toBe( + `${ALPHABET[4]}${ALPHABET[0].repeat(ENCODED_LENGTH - 1)}` + ) + expect(encodeBytes(bytesFrom((1n << 128n) - 1n))[0]).toBe( + ALPHABET[MAX_LEADING_DIGIT] + ) + }) +}) + +describe('the leading digit', () => { + // The cost of 128 not dividing by 5. Every value has exactly four strings a + // permissive decoder maps to it, differing only in the two high bits of the + // leading digit. Exactly one — the one with those bits zero — is accepted. + 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[0]) + expect(digit, encoded).toBeLessThanOrEqual(MAX_LEADING_DIGIT) + + let accepted = 0 + for (let slack = 0; slack < 1 << SLACK_BITS; slack++) { + const alternative = + ALPHABET[digit | (slack * SLACK_STEP)] + encoded.slice(1) + + // This is what makes the check necessary rather than incidental: a + // decoder that keeps the low 128 bits maps all four spellings to the + // same 16 bytes and reports nothing. Checked against refDecode, which + // unpacks without consulting the leading 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(/above 128 bits/) + } + expect(accepted, encoded).toBe(1) + } + }) + + // The same fact from the outside: only 8 of the 32 characters can ever + // appear first, and over enough values every one of them does. + test('only 0 to 7 appear first', () => { + const allowed = new Set(ALPHABET.slice(0, MAX_LEADING_DIGIT + 1)) + expect(allowed).toStrictEqual(new Set('01234567')) + + const seen = new Set() + for (const bytes of corpus()) { + const digit = encodeBytes(bytes)[0] + expect(allowed, `${hex(bytes)} starts with ${digit}`).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}0`], + ['base32 padding', `${valid.slice(0, -1)}=`], + // Nothing here ever emits uppercase, so accepting it would give every + // value millions of spellings. Go rejects it too. + ['uppercase', valid.toUpperCase()], + [ + 'a single uppercase letter', + (() => { + // The first characters are digits, which have 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) + ) + })(), + ], + // The four letters Crockford leaves out. Go's "bad alphabet" case is "i". + ['"i", which is not in the alphabet', `${valid.slice(0, -1)}i`], + ['"l", which is not in the alphabet', `${valid.slice(0, -1)}l`], + ['"o", which is not in the alphabet', `${valid.slice(0, -1)}o`], + ['"u", which is not in the alphabet', `${valid.slice(0, -1)}u`], + ['"-", which is not a digit', `-${valid.slice(1)}`], + ['a non-ascii character', `${valid.slice(0, -1)}é`], + ['whitespace', ` ${valid.slice(1)}`], + ['a leading "8"', `8${valid.slice(1)}`], + ['a leading "z"', `z${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('0'.repeat(ENCODED_LENGTH))).toStrictEqual( + new Uint8Array(16) + ) + expect( + decodeBytes(encodeBytes(bytesFrom((1n << 128n) - 1n))) + ).toStrictEqual(bytesFrom((1n << 128n) - 1n)) + }) +}) + +describe('what the encoding keeps', () => { + // Two v7s minted in the same millisecond share their first 52 bits + // (timestamp plus version nibble), which is their first 10 digits. The + // encoding keeps them where they are, so the fronts match. + test('the timestamp reads out from the front', () => { + const source = new Lcg(7) + + 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 + + expect(encodeBytes(a).slice(0, 10)).toBe(encodeBytes(b).slice(0, 10)) + } + }) + + // The same point as the user sees it: a batch minted together shares a long + // prefix, so the tail is the part to read. + test('ids minted together 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)).slice(0, 8)) + } + + // 200 consecutive milliseconds move only the last of the first 10 digits + // or so; the first 8 are constant over the batch. + expect(fronts.size).toBe(1) + }) + + // What the user gets for that: encoded order is byte order, so a v7's + // chronology survives into the string and an index on it sorts by time. + test('and so sort order survives', () => { + const source = new Lcg(15) + const values = Array.from({ length: 5000 }, () => v7(source)) + + 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] + expect(encodeBytes(low) < encodeBytes(high), hex(low)).toBe(true) + } + }) +}) diff --git a/packages/js-id/tests/id.test.ts b/packages/js-id/tests/id.test.ts new file mode 100644 index 0000000000..84d3640b51 --- /dev/null +++ b/packages/js-id/tests/id.test.ts @@ -0,0 +1,500 @@ +import { afterEach, describe, expect, test, vi } from 'vitest' + +import { ALPHABET, 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', + 'secret', + '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) + }) + + // The four spellings google/uuid's Parse takes, so a UUID string the Go + // side accepts is one this package accepts, and both encode it the same. + test.each([ + ['hyphenated', '019fa519-bf79-724d-8811-a2bfda9755fa'], + ['urn-prefixed', 'urn:uuid:019fa519-bf79-724d-8811-a2bfda9755fa'], + [ + 'urn-prefixed in upper case', + 'URN:UUID:019FA519-BF79-724D-8811-A2BFDA9755FA', + ], + ['braced', '{019fa519-bf79-724d-8811-a2bfda9755fa}'], + ['bare', '019fa519bf79724d8811a2bfda9755fa'], + ])('a uuid that is %s is accepted', (_, uuid) => { + expect(encodeId('project', uuid)).toBe('prj_01kyjhkfvse96rg4d2qzd9enft') + expect(uuidToBytes(uuid)).toStrictEqual( + uuidToBytes('019fa519-bf79-724d-8811-a2bfda9755fa') + ) + }) + + // Nothing reads the version or variant: Go does not, so a 128-bit value + // that is not an RFC 9562 UUID still encodes, and to the same string. + test('a non-rfc value still parses', () => { + expect(uuidToBytes('26064700-4700-0000-0000-000000001111')).toStrictEqual( + uuidToBytes('26064700470000000000000000001111') + ) + }) +}) + +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, + // and so do the IDs made from them. 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) + }) + + test('and so do the ids minted from them', () => { + const fronts = Array.from({ length: 1000 }, () => + createId('sandbox').slice(0, 4 + 10) + ) + expect([...fronts].sort()).toStrictEqual(fronts) + }) +}) + +describe('minting the way google/uuid does', () => { + afterEach(() => vi.restoreAllMocks()) + + // The first 8 bytes are the timestamp, the version and the sequence; the + // sequence is what makes two calls in the same millisecond ordered, so the + // whole UUID strictly increases call by call, not just its timestamp. + test('every uuid is greater than the one before', () => { + const minted = Array.from({ length: 5000 }, () => createUuid()) + for (let i = 1; i < minted.length; i++) { + expect( + minted[i] > minted[i - 1], + `${minted[i - 1]} then ${minted[i]}` + ).toBe(true) + } + }) + + // The mocked clocks are in 2100, past anything already handed out: the + // state is per process and never goes backwards, so a mock in the past + // would just be another stalled clock. + test('even when the clock stalls', () => { + vi.spyOn(Date, 'now').mockReturnValue(4102444800000) + + // More than the 4096 the sequence holds: it has to carry into the + // timestamp, which Go's getV7Time does by adding one to the packed value. + const minted = Array.from({ length: 5000 }, () => createUuid()) + expect(new Set(minted).size).toBe(minted.length) + for (let i = 1; i < minted.length; i++) { + expect(minted[i] > minted[i - 1]).toBe(true) + } + + const timestamps = new Set(minted.map((uuid) => uuid.slice(0, 13))) + expect(timestamps.size).toBe(2) + const sequences = minted + .slice(0, 4096) + .map((uuid) => Number.parseInt(uuid.slice(15, 18), 16)) + expect(sequences).toStrictEqual(sequences.map((_, i) => i)) + }) + + test('and when it steps back', () => { + const now = vi.spyOn(Date, 'now') + now.mockReturnValue(4102444800000) + const before = createUuid() + now.mockReturnValue(4102444700000) + const after = createUuid() + expect(after > before).toBe(true) + expect(after.slice(0, 13)).toBe(before.slice(0, 13)) + }) + + test('the layout is a v7 with the sequence in rand_a', () => { + vi.spyOn(Date, 'now').mockReturnValue(0x03c0a519bf79) + const uuid = createUuid() + expect(uuid).toMatch( + /^03c0a519-bf79-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + ) + }) +}) + +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)}i`], + ['surrounding whitespace', ` ${projectId} `], + ['an overflowing leading character', `prj_z${projectId.slice(5)}`], + ])('%s', (_, value) => { + expect(isId('project', value)).toBe(false) + expect(() => decodeId('project', value)).toThrow(InvalidIdError) + }) + + test('the overflow case is really the only bits flipped', () => { + // Index 4 of the id is the leading digit of the encoding. Setting its two + // high bits leaves a string that a decoder keeping the low 128 bits would + // happily map to the same UUID; the Go side rejects it as this one does. + const digit = ALPHABET.indexOf(projectId[4]) + const overflowing = `prj_${ALPHABET[digit | 24]}${projectId.slice(5)}` + expect(overflowing).toHaveLength(ID_LENGTH) + expect(overflowing).not.toBe(projectId) + expect(() => decodeId('project', overflowing)).toThrow(/above 128 bits/) + }) + + test.each([ + ['not hex', 'not-a-uuid'], + ['one digit short', '019fa519-bf79-724d-8811-a2bfda9755f'], + ['one digit long', '019fa519-bf79-724d-8811-a2bfda9755faa'], + ['empty', ''], + // Hyphens anywhere but 8-4-4-4-12: Go checks the four positions. + ['hyphenated in the wrong places', '019fa519bf79-724d-8811-a2bf-da9755fa'], + ['bare but with one hyphen', '019fa519-bf79724d8811a2bfda9755fa'], + // The prefix has to be the URN one, and the braces have to be braces — + // Go would take "[…]" since it never looks at them, but that is not a + // spelling anyone writes on purpose. + [ + 'prefixed with something else', + 'uuid:uuid:019fa519-bf79-724d-8811-a2bfda9755fa', + ], + ['bracketed', '[019fa519-bf79-724d-8811-a2bfda9755fa]'], + ['half braced', '{019fa519-bf79-724d-8811-a2bfda9755fa '], + [ + 'braced and urn-prefixed', + '{urn:uuid:019fa519-bf79-724d-8811-a2bfda9755fa}', + ], + ['surrounded by whitespace', ' 019fa519-bf79-724d-8811-a2bfda9755fa '], + ])('encodeId rejects a uuid that is %s', (_, uuid) => { + expect(() => encodeId('project', uuid)).toThrow(InvalidIdError) + expect(() => encodeId('project', uuid)).toThrow( + expect.objectContaining({ reason: 'uuid' }) + ) + }) +}) + +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) + } + ) + + // The same payload handed to decodeId or parseId has to be an InvalidIdError, + // which callers turn into a 400, not a TypeError, which becomes a 500. + test.each([null, undefined, 123, ['x'], {}, Symbol('x')])( + 'decodeId and parseId reject %s with an InvalidIdError', + (value) => { + for (const call of [ + () => decodeId('project', value as unknown as string), + () => parseId(value as unknown as string), + ]) { + expect(call).toThrow(InvalidIdError) + expect(call).toThrow(expect.objectContaining({ reason: 'prefix' })) + } + } + ) + + // A plain-object lookup of the kind would answer these with a function + // inherited from Object.prototype and mint `function toString() { … }_…`. + test.each([ + 'toString', + 'valueOf', + 'constructor', + 'hasOwnProperty', + '__proto__', + ])('the kind %s does not reach Object.prototype', (kind) => { + const unknown = kind as IdKind + for (const call of [ + () => encodeId(unknown, GOLDEN_IDS[0][1]), + () => createId(unknown), + () => decodeId(unknown, projectId), + ]) { + expect(call).toThrow(expect.objectContaining({ reason: 'kind' })) + } + expect(isId(unknown, projectId)).toBe(false) + }) + + // Slicing up to indexOf's -1 would drop the last character, so "prjx" would + // be blamed on the project kind. + test.each(['prjx', 'volx', 'prj'])( + 'an id with no separator, %s, names no kind', + (value) => { + try { + decodeId('volume', value) + expect.fail(`decodeId should have rejected ${value}`) + } catch (error) { + expect(error).toBeInstanceOf(InvalidIdError) + expect((error as InvalidIdError).reason).toBe('prefix') + expect((error as InvalidIdError).actualKind).toBeUndefined() + expect((error as InvalidIdError).message).toMatch( + /expected it to start with "vol_"/ + ) + } + } + ) + + 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)}i`], + ['overflow', 'project' as IdKind, `prj_z${projectId.slice(5)}`], + ])('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..d95a963a53 --- /dev/null +++ b/packages/js-id/tests/interop.test.ts @@ -0,0 +1,99 @@ +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 arithmetic 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: the alphabet and integer arithmetic, no bit + * shuffling. If this passes, the format really is "the 128-bit value in 26 + * Crockford base32 digits, most significant first", so any port tested the + * same way against the same corpus decodes what this package emits. + * `int.to_bytes(16)` raising on 129 bits is also Python's own statement of the + * leading-digit rule. + */ +const SNIPPET = ` +import sys + +ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz" + +def encode(b: bytes) -> str: + n = int.from_bytes(b, "big") + return "".join(ALPHABET[(n >> (5 * i)) & 31] for i in range(25, -1, -1)) + +def decode(s: str) -> bytes: + n = 0 + for c in s: + n = n * 32 + ALPHABET.index(c) + return n.to_bytes(16, "big") + +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')( + 'an independent python encoder 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/uuid.test.ts b/packages/js-id/tests/uuid.test.ts new file mode 100644 index 0000000000..7ce4467d21 --- /dev/null +++ b/packages/js-id/tests/uuid.test.ts @@ -0,0 +1,250 @@ +import { runInNewContext } from 'node:vm' + +import { afterEach, describe, expect, test, vi } from 'vitest' + +import { DECODED_LENGTH } from '../src/codec' +import { InvalidIdError } from '../src/errors' +import { bytesToUuid, uuidToBytes } from '../src/uuid' +import { corpus, hex } from './vectors' + +/** + * The module under test, loaded fresh: `createUuid` keeps the last timestamp and + * sequence in module state, so each clock test gets its own copy rather than + * inheriting whatever an earlier test (or a mocked year 2100) left behind. + */ +async function freshUuidModule() { + vi.resetModules() + return import('../src/uuid') +} + +/** An independent formatter: `toString(16)` and a regex, no lookup table. */ +function referenceUuid(bytes: Uint8Array): string { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')) + .join('') + .replace(/^(.{8})(.{4})(.{4})(.{4})(.{12})$/, '$1-$2-$3-$4-$5') +} + +/** The 48-bit millisecond timestamp at the front of a v7. */ +function timestampOf(uuid: string): number { + return Number.parseInt(uuid.replaceAll('-', '').slice(0, 12), 16) +} + +/** The 12-bit sequence in `rand_a`, after the version nibble. */ +function sequenceOf(uuid: string): number { + return Number.parseInt(uuid.slice(15, 18), 16) +} + +function expectUuidError(call: () => unknown) { + expect(call).toThrow(InvalidIdError) + expect(call).toThrow(expect.objectContaining({ reason: 'uuid' })) +} + +describe('uuidToBytes', () => { + test('reads the digits most significant first', () => { + expect( + Array.from(uuidToBytes('019fa519-bf79-724d-8811-a2bfda9755fa')) + ).toStrictEqual([ + 0x01, 0x9f, 0xa5, 0x19, 0xbf, 0x79, 0x72, 0x4d, 0x88, 0x11, 0xa2, 0xbf, + 0xda, 0x97, 0x55, 0xfa, + ]) + }) + + test('reads every hex digit in either case', () => { + expect( + Array.from(uuidToBytes('0123456789abcdefABCDEF0123456789')) + ).toStrictEqual([ + 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0xab, 0xcd, 0xef, 0x01, + 0x23, 0x45, 0x67, 0x89, + ]) + }) + + test('agrees with the reference formatter over the corpus', () => { + for (const bytes of corpus()) { + const uuid = referenceUuid(bytes) + expect(uuidToBytes(uuid), hex(bytes)).toStrictEqual(bytes) + expect(uuidToBytes(uuid.toUpperCase()), hex(bytes)).toStrictEqual(bytes) + expect(uuidToBytes(uuid.replaceAll('-', '')), hex(bytes)).toStrictEqual( + bytes + ) + } + }) + + // google/uuid's Parse dispatches on length, so each form is probed one + // character either side of its length and with one bad character inside it. + const uuid = '019fa519-bf79-724d-8811-a2bfda9755fa' + test.each([ + ['hyphenated, one short', uuid.slice(0, -1)], + ['hyphenated, one long', `${uuid}0`], + ['hyphenated, a non-hex digit', `${uuid.slice(0, -1)}g`], + ['hyphenated, a hyphen where a digit goes', `${uuid.slice(0, -1)}-`], + ['urn, one short', `urn:uuid:${uuid.slice(0, -1)}`], + ['urn, one long', `urn:uuid:${uuid}0`], + ['urn, the wrong prefix', `urn:uid::${uuid}`], + ['urn, a non-hex digit', `urn:uuid:${uuid.slice(0, -1)}g`], + ['braced, one short', `{${uuid.slice(0, -1)}}`], + ['braced, one long', `{${uuid}0}`], + ['braced, the wrong closing brace', `{${uuid})`], + ['braced, a non-hex digit', `{${uuid.slice(0, -1)}g}`], + ['bare, one short', uuid.replaceAll('-', '').slice(0, -1)], + ['bare, one long', `${uuid.replaceAll('-', '')}0`], + ['bare, a non-hex digit', `${uuid.replaceAll('-', '').slice(0, -1)}g`], + ])('rejects %s', (_, value) => { + expectUuidError(() => uuidToBytes(value)) + }) + + // A UUID column read out of JSON can be anything; the answer still has to be + // an InvalidIdError, and a symbol must not throw from the message itself. + test.each([null, undefined, 123, {}, [], Symbol('x'), new Uint8Array(16)])( + 'rejects %s, which is not a string', + (value) => { + expectUuidError(() => uuidToBytes(value as unknown as string)) + } + ) +}) + +describe('bytesToUuid', () => { + test('formats the canonical lowercase hyphenated form', () => { + expect( + bytesToUuid( + new Uint8Array([ + 0x01, 0x9f, 0xa5, 0x19, 0xbf, 0x79, 0x72, 0x4d, 0x88, 0x11, 0xa2, + 0xbf, 0xda, 0x97, 0x55, 0xfa, + ]) + ) + ).toBe('019fa519-bf79-724d-8811-a2bfda9755fa') + }) + + test('formats every byte value as two lowercase digits', () => { + for (let byte = 0; byte < 256; byte++) { + const bytes = new Uint8Array(DECODED_LENGTH).fill(byte) + expect(bytesToUuid(bytes)).toBe(referenceUuid(bytes)) + } + }) + + test('agrees with the reference formatter over the corpus', () => { + for (const bytes of corpus()) { + expect(bytesToUuid(bytes), hex(bytes)).toBe(referenceUuid(bytes)) + expect(uuidToBytes(bytesToUuid(bytes)), hex(bytes)).toStrictEqual(bytes) + } + }) + + test('takes a Buffer, which is a Uint8Array', () => { + const bytes = new Uint8Array(DECODED_LENGTH).fill(0xab) + expect(bytesToUuid(Buffer.from(bytes))).toBe(bytesToUuid(bytes)) + }) + + test('takes a Uint8Array from another realm', () => { + const Foreign = runInNewContext('Uint8Array') as Uint8ArrayConstructor + const bytes = new Foreign(DECODED_LENGTH).fill(0xab) + expect(bytes instanceof Uint8Array).toBe(false) + expect(bytesToUuid(bytes)).toBe('abababab-abab-abab-abab-abababababab') + }) + + test.each([0, 1, 15, 17, 32])('rejects %i bytes', (length) => { + expectUuidError(() => bytesToUuid(new Uint8Array(length))) + }) + + // Without the check a plain array of 16 numbers above 255 formatted as + // "undefinedundefined…", and null was a TypeError. + test.each([ + ['null', null], + ['undefined', undefined], + ['a string of 16 characters', '0123456789abcdef'], + ['a plain array', Array.from({ length: 16 }, () => 256)], + ['a Uint16Array', new Uint16Array(16).fill(0xffff)], + ])('rejects %s', (_, value) => { + expectUuidError(() => bytesToUuid(value as unknown as Uint8Array)) + }) +}) + +describe('createUuid', () => { + afterEach(() => vi.restoreAllMocks()) + + test('every uuid is a v7 with the RFC 9562 variant', async () => { + const { createUuid } = await freshUuidModule() + // The variant overwrites two random bits, so it takes many draws to be + // sure no random value leaks through. + for (let i = 0; i < 2000; i++) { + const bytes = uuidToBytes(createUuid()) + expect(bytes[6] >> 4).toBe(7) + expect(bytes[8] & 0xc0).toBe(0x80) + } + }) + + // The 48-bit timestamp is split across division and 32-bit shifts, so the + // values either side of 2^32 and the top of the range are the ones to pin. + test.each([0, 1, 0xffffffff, 0x100000000, 0x03c0a519bf79, 2 ** 48 - 1])( + 'writes the timestamp %i big-endian into the first six bytes', + async (ms) => { + const { createUuid } = await freshUuidModule() + vi.spyOn(Date, 'now').mockReturnValue(ms) + const uuid = createUuid() + expect(timestampOf(uuid)).toBe(ms) + expect(sequenceOf(uuid)).toBe(0) + } + ) + + // Random bits only fill rand_b and the low bits of the variant byte: with a + // generator of all ones or all zeros the timestamp, version and sequence are + // unchanged and the variant byte is exactly 0xbf or 0x80. + test.each([ + [0xff, 'bf', 'ffffffffffffff'], + [0x00, '80', '00000000000000'], + ])('random byte %i only reaches rand_b', async (fill, variant, rest) => { + const { createUuid } = await freshUuidModule() + vi.spyOn(Date, 'now').mockReturnValue(0x03c0a519bf79) + vi.spyOn(crypto, 'getRandomValues').mockImplementation((array) => { + ;(array as Uint8Array).fill(fill) + return array + }) + expect(createUuid()).toBe( + `03c0a519-bf79-7000-${variant}${rest.slice(0, 2)}-${rest.slice(2)}` + ) + }) + + test('the sequence counts up within a millisecond and resets on the next', async () => { + const { createUuid } = await freshUuidModule() + const now = vi.spyOn(Date, 'now').mockReturnValue(1_000_000) + expect( + [createUuid(), createUuid(), createUuid()].map(sequenceOf) + ).toStrictEqual([0, 1, 2]) + + now.mockReturnValue(1_000_001) + const next = createUuid() + expect(timestampOf(next)).toBe(1_000_001) + expect(sequenceOf(next)).toBe(0) + }) + + test('a full sequence carries into the next millisecond', async () => { + const { createUuid } = await freshUuidModule() + const now = vi.spyOn(Date, 'now').mockReturnValue(1_000_000) + const minted = Array.from({ length: 4096 }, () => createUuid()) + expect(sequenceOf(minted[4095])).toBe(4095) + + const carried = createUuid() + expect(timestampOf(carried)).toBe(1_000_001) + expect(sequenceOf(carried)).toBe(0) + + // The clock still reads the old millisecond, which is now behind: the + // carried one keeps counting rather than going back. + expect(sequenceOf(createUuid())).toBe(1) + + // Once the clock catches up with the carried millisecond it is a stall + // there too, and once it passes it the sequence starts over. + now.mockReturnValue(1_000_001) + expect(sequenceOf(createUuid())).toBe(2) + now.mockReturnValue(1_000_002) + expect(sequenceOf(createUuid())).toBe(0) + }) + + test('a clock that steps back keeps the last millisecond', async () => { + const { createUuid } = await freshUuidModule() + const now = vi.spyOn(Date, 'now').mockReturnValue(1_000_000) + const before = createUuid() + now.mockReturnValue(500_000) + const after = createUuid() + expect(timestampOf(after)).toBe(1_000_000) + expect(sequenceOf(after)).toBe(sequenceOf(before) + 1) + expect(after > before).toBe(true) + }) +}) diff --git a/packages/js-id/tests/vectors.ts b/packages/js-id/tests/vectors.ts new file mode 100644 index 0000000000..a400b5e469 --- /dev/null +++ b/packages/js-id/tests/vectors.ts @@ -0,0 +1,257 @@ +/** + * 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, SLACK_BITS } from '../src/codec' +import type { IdKind } from '../src/id' + +/** + * UUIDs and the encodings they must produce. The first four are copied from + * the Go suite (`packages/shared/pkg/id/object_test.go` in e2b-dev/infra), so + * they are the compatibility contract with the services rather than a record + * of this package; the rest were computed with the arithmetic snippet in + * `src/codec.ts`. Change the alphabet, the width or the bit order and they stop + * matching. + * + * The five v7s were minted 37ms apart, so note what the encoding keeps: they + * share "01kyjhkf" at the front, which is the common timestamp showing + * through, and differ from there. + */ +export const GOLDEN: ReadonlyArray = [ + // From the Go suite: a v4, the team UUID, a v7 secret, and the "legacy body" + // test — a string in the old rotated format that happens to be a well-formed + // TypeID too, which Go pins as reading to this UUID and nothing else. + ['f47ac10b-58cc-4372-a567-0e02b2c3d479', '7mfb0gpp6c8dsaasre0asc7n3s'], + ['550e8400-e29b-41d4-a716-446655440000', '2n1t201rmv87aae5j4csam8000'], + ['019fa519-bf79-724d-8811-a2bfda9755fa', '01kyjhkfvse96rg4d2qzd9enft'], + ['df8aa05e-b25e-7932-a6aa-33a2cddc3eef', '6zhag5xcjyf4sadahkmb6xrfqf'], + // v7, minted 37ms after the one above and after each other. + ['019fa519-bf9f-762a-a916-431479cc7171', '01kyjhkfwzernaj5j32hwwrwbh'], + ['019fa519-bfc5-784d-9386-a5d7a93a692a', '01kyjhkfy5f16s71n5tymkmt9a'], + ['019fa519-bfeb-79f2-aa2a-0addf5b9c0d9', '01kyjhkfzbf7samagavqtvkg6s'], + ['019fa519-c011-7c8e-a039-cb3ba8ca8c16', '01kyjhkg0hfj7a0eeb7emcn30p'], + // 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', '53d4nq93fd6cmtymyc4etdfq17'], + ['fbe1337a-dac0-53d8-805c-905bca106f3e', '7vw4sqnpp0afc80q4gbf510vsy'], + // The extremes: the leading digit tops out at 7 because it holds only three + // bits of the value, and the lowest bit lands in the last character. + ['00000000-0000-0000-0000-000000000000', '00000000000000000000000000'], + ['00000000-0000-0000-0000-000000000001', '00000000000000000000000001'], + ['80000000-0000-0000-0000-000000000000', '40000000000000000000000000'], + ['ffffffff-ffff-ffff-ffff-ffffffffffff', '7zzzzzzzzzzzzzzzzzzzzzzzzz'], + // 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', '160s3g0hr0000000000000048h'], // 2606:4700:4700::1111 + ['20010db8-85a3-0000-0000-8a2e03707334', '10046vh1d30000004a5r1q0wsm'], // 2001:db8:85a3::8a2e:370:7334 +] + +/** + * The same contract for the prefixed form, one per kind. The project, + * workspace and secret IDs are the Go suite's own, prefix and all. + */ +export const GOLDEN_IDS: ReadonlyArray< + readonly [kind: IdKind, uuid: string, id: string] +> = [ + [ + 'project', + 'f47ac10b-58cc-4372-a567-0e02b2c3d479', + 'prj_7mfb0gpp6c8dsaasre0asc7n3s', + ], + [ + 'workspace', + 'f47ac10b-58cc-4372-a567-0e02b2c3d479', + 'wrk_7mfb0gpp6c8dsaasre0asc7n3s', + ], + [ + 'secret', + '019fa519-bf79-724d-8811-a2bfda9755fa', + 'sec_01kyjhkfvse96rg4d2qzd9enft', + ], + [ + 'volume', + '019fa519-bfc5-784d-9386-a5d7a93a692a', + 'vol_01kyjhkfy5f16s71n5tymkmt9a', + ], + [ + 'sandbox', + '019fa519-bfeb-79f2-aa2a-0addf5b9c0d9', + 'sbx_01kyjhkfzbf7samagavqtvkg6s', + ], + [ + 'user', + '019fa519-c011-7c8e-a039-cb3ba8ca8c16', + 'usr_01kyjhkg0hfj7a0eeb7emcn30p', + ], + [ + 'group', + '550e8400-e29b-41d4-a716-446655440000', + 'grp_2n1t201rmv87aae5j4csam8000', + ], +] + +/** + * 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 = + 'e58522dfc30f44687edca477cc58f37015d7c9e1c46311a354654db07b2f6bdc' + +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 written in 26 base32 digits, most + * significant first. `encodeBytes` is checked against this rather than against + * itself. + */ +export function refEncode(bytes: Uint8Array): string { + let value = bigintFrom(bytes) + + const digits: string[] = [] + for (let i = 0; i < ENCODED_LENGTH; i++) { + digits.unshift(ALPHABET[Number(value & 31n)]) + value >>= 5n + } + + return digits.join('') +} + +/** + * The permissive counterpart to {@link refEncode}: unpack 26 digits into 130 + * bits and keep the low 128. It does not look at the slack bits, which is the + * point — it is what a decoder that forgets the leading-digit rule does, so the + * tests can show that all four spellings of a value reach it. + */ +export function refDecode(encoded: string): Uint8Array { + let value = 0n + for (const character of encoded) { + value = (value << 5n) | BigInt(ALPHABET.indexOf(character)) + } + return bytesFrom(value & MASK_128) +} + +/** + * The slack bits, as they sit in the leading digit: the high {@link SLACK_BITS} + * of its five. OR-ing any nonzero multiple of this into the first digit's value + * gives one of the three overflowing spellings. + */ +export const SLACK_STEP = 1 << (5 - 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 6e328110ae..b2f3c30274 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -259,6 +259,27 @@ importers: packages/dockerfile-utils-python: {} + packages/js-id: + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 22.20.5 + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 + prettier: + specifier: ^3.6.2 + version: 3.9.9 + tsdown: + specifier: 'catalog:' + version: 0.22.14(@typescript/typescript6@6.0.2)(oxc-resolver@11.24.2)(tsx@4.23.15) + typescript: + specifier: 'catalog:' + version: '@typescript/typescript6@6.0.2' + vitest: + specifier: 'catalog:' + version: 4.1.12(@types/node@22.20.5)(@vitest/browser-playwright@4.1.12)(@vitest/coverage-v8@4.1.12)(msw@2.15.0(@types/node@22.20.5)(@typescript/typescript6@6.0.2))(vite@6.4.5(@types/node@22.20.5)(jiti@2.7.0)(terser@5.46.0)(tsx@4.23.15)(yaml@2.9.1)) + packages/js-sdk: dependencies: '@bufbuild/protobuf':