From 816170faf594cda5449045fc23ca420cac1aa12a Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:10:34 +0200 Subject: [PATCH 1/3] Add @e2b/id: prefixed IDs for E2B resources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A three-character kind prefix, an underscore, and the 26-character TypeID encoding of a UUID: prj_7mfb0gpp6c8dsaasre0asc7n3s sbx_01kyjhkfzbf7samagavqtvkg6s The encoding is the one E2B's Go services use (packages/shared/pkg/id in e2b-dev/infra, built on github.com/sumup/typeid): the 128 bits in base 32 with the lowercase Crockford alphabet, most significant digit first, so every ID starts with 0-7 and sorts the way its UUID does. The Go suite's own vectors are pinned here, prefix and all, so an ID minted on either side decodes on the other to the same UUID. A leading character above 7 is rejected as overflow rather than masked, exactly as Go rejects it. Kinds are project, workspace and secret — the three Go defines, with the same prefixes — plus volume, sandbox, user and group ahead of it. UUIDs are read and minted the way github.com/google/uuid does it, so the two sides agree there too: encodeId takes exactly the four spellings Go's Parse takes (hyphenated, urn:uuid:-prefixed, braced, bare) and checks no version bits, and createUuid is NewV7 — a millisecond timestamp and a 12-bit sequence bumped so every UUID a process mints is strictly greater than the last, then 62 random bits — so IDs sort in call order even within a millisecond. No dependencies, and no runtime-specific API: a test asserts src reaches for nothing but platform globals, so the package works in Workers, Deno, Bun and browsers with no compat flags. The interop suite drives python3 over the whole corpus with an arithmetic encoder stated independently of this code. Wired into the release pipeline: public, versioned from 0.0.0 with an "Initial release" changeset so the next release mints 0.1.0, gated on the id_tests workflow in release.yml, and published by `changeset publish` over npm's OIDC trusted publishing like every other package here. The @e2b/id name already exists on npm as a 0.0.0 placeholder with the trusted publisher attached, so 0.1.0 is the first real version and publishing needs no manual step. Co-Authored-By: Claude Co-Authored-By: Claude Fable 5.1 --- .changeset/js-id-package.md | 5 + .github/scripts/build_release_itinerary.cjs | 1 + .github/workflows/id_tests.yml | 66 +++ .github/workflows/pkg_artifacts.yml | 21 + .github/workflows/release.yml | 16 + .github/workflows/sdk_tests.yml | 14 + packages/js-id/.gitignore | 11 + packages/js-id/LICENSE | 201 +++++++++ packages/js-id/README.md | 153 +++++++ packages/js-id/package.json | 67 +++ packages/js-id/src/codec.ts | 224 ++++++++++ packages/js-id/src/errors.ts | 44 ++ packages/js-id/src/id.ts | 281 ++++++++++++ packages/js-id/src/index.ts | 38 ++ packages/js-id/src/uuid.ts | 192 +++++++++ packages/js-id/tests/codec.test.ts | 258 +++++++++++ packages/js-id/tests/id.test.ts | 446 ++++++++++++++++++++ packages/js-id/tests/interop.test.ts | 99 +++++ packages/js-id/tests/runtime.test.ts | 48 +++ packages/js-id/tests/vectors.ts | 257 +++++++++++ packages/js-id/tsconfig.json | 36 ++ packages/js-id/tsdown.config.ts | 14 + packages/js-id/vitest.config.mts | 9 + pnpm-lock.yaml | 21 + 24 files changed, 2522 insertions(+) create mode 100644 .changeset/js-id-package.md create mode 100644 .github/workflows/id_tests.yml create mode 100644 packages/js-id/.gitignore create mode 100644 packages/js-id/LICENSE create mode 100644 packages/js-id/README.md create mode 100644 packages/js-id/package.json create mode 100644 packages/js-id/src/codec.ts create mode 100644 packages/js-id/src/errors.ts create mode 100644 packages/js-id/src/id.ts create mode 100644 packages/js-id/src/index.ts create mode 100644 packages/js-id/src/uuid.ts create mode 100644 packages/js-id/tests/codec.test.ts create mode 100644 packages/js-id/tests/id.test.ts create mode 100644 packages/js-id/tests/interop.test.ts create mode 100644 packages/js-id/tests/runtime.test.ts create mode 100644 packages/js-id/tests/vectors.ts create mode 100644 packages/js-id/tsconfig.json create mode 100644 packages/js-id/tsdown.config.ts create mode 100644 packages/js-id/vitest.config.mts 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..db0e99048f --- /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. + * - `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..2aea31f8de --- /dev/null +++ b/packages/js-id/src/id.ts @@ -0,0 +1,281 @@ +/** + * 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(', ') +} + +function prefixOf(kind: K): IdPrefix { + // Cast so the undefined branch stays reachable to the compiler: an unknown + // kind cannot come from TypeScript, but it can come from JavaScript or from + // a cast, and the message it gets should be this one. + const prefix = ID_PREFIXES[kind] as IdPrefix | undefined + if (prefix === undefined) { + throw new InvalidIdError( + 'kind', + `'${kind}' is not a resource kind that has IDs. Expected one of ${knownKinds()}.` + ) + } + return prefix +} + +/** + * Encodes a UUID as an ID of the given kind. + * + * @param kind the resource the UUID belongs to. + * @param uuid the resource's UUID, in 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 (!id.startsWith(`${prefix}${SEPARATOR}`)) { + const named = KINDS_BY_PREFIX.get(id.slice(0, id.indexOf(SEPARATOR))) + throw named === undefined + ? new InvalidIdError( + 'prefix', + `"${id}" is not a ${kind} ID: expected it to start with "${prefix}${SEPARATOR}".` + ) + : new InvalidIdError( + 'prefix', + `"${id}" is a ${named} ID, not a ${kind} ID.`, + named + ) + } + return bytesToUuid(decodeBytes(id.slice(prefix.length + SEPARATOR.length))) +} + +/** + * Mints an ID for a new resource of the given kind, from a fresh + * {@link createUuid} UUIDv7. + * + * @param kind the resource being created. + * @returns the ID. + * @throws {InvalidIdError} if `kind` is not a known kind. + * @example + * ```ts + * createId('sandbox') // 'sbx_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 { + 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 + + const prefix = ID_PREFIXES[kind] as string | undefined + if (prefix === undefined) return false + if (!value.startsWith(prefix + SEPARATOR)) return false + + return ( + faultInEncoded(value.slice(prefix.length + SEPARATOR.length)) === undefined + ) +} diff --git a/packages/js-id/src/index.ts b/packages/js-id/src/index.ts new file mode 100644 index 0000000000..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..390104ea00 --- /dev/null +++ b/packages/js-id/src/uuid.ts @@ -0,0 +1,192 @@ +/** + * 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 { + const hex = typeof uuid === 'string' ? hexDigitsOf(uuid) : undefined + 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 16 bytes long. + */ +export function bytesToUuid(bytes: Uint8Array): string { + if (bytes.length !== DECODED_LENGTH) { + throw new InvalidIdError( + 'uuid', + `Cannot format ${bytes.length} bytes as a UUID: expected exactly ${DECODED_LENGTH}.` + ) + } + + let uuid = '' + let offset = 0 + for (const group of GROUPS) { + if (offset > 0) uuid += '-' + for (let i = 0; i < group; i++) uuid += HEX[bytes[offset + i]] + offset += group + } + return uuid +} + +/** 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..1dcb6212f1 --- /dev/null +++ b/packages/js-id/tests/id.test.ts @@ -0,0 +1,446 @@ +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) + } + ) + + 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/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': From 7824d5d6e5995746935f2813a58205a464a9acb2 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Fri, 13 Nov 2026 15:59:39 +0100 Subject: [PATCH 2/3] Reject non-strings, inherited kinds and separator-less IDs cleanly in @e2b/id - decodeId and parseId throw InvalidIdError('prefix') for a non-string id (null, a number, ...) instead of a TypeError, so callers that map InvalidIdError to a 400 don't turn a bad JSON payload into a 500. - Kind lookups in prefixOf and isId use own properties only, so encodeId('toString', ...) throws InvalidIdError('kind') instead of minting "function toString() { [native code] }_...". - decodeId no longer slices to indexOf's -1 when the id has no separator, which named the wrong kind for inputs like "prjx". Co-Authored-By: Claude Opus 5.5 --- packages/js-id/src/errors.ts | 2 +- packages/js-id/src/id.ts | 33 +++++++++++++++++--- packages/js-id/tests/id.test.ts | 54 +++++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 6 deletions(-) diff --git a/packages/js-id/src/errors.ts b/packages/js-id/src/errors.ts index db0e99048f..52141e9bd6 100644 --- a/packages/js-id/src/errors.ts +++ b/packages/js-id/src/errors.ts @@ -6,7 +6,7 @@ import type { IdKind } from './id' * 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. + * - `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 diff --git a/packages/js-id/src/id.ts b/packages/js-id/src/id.ts index 2aea31f8de..9c8a618c0e 100644 --- a/packages/js-id/src/id.ts +++ b/packages/js-id/src/id.ts @@ -118,11 +118,28 @@ function knownKinds(): string { .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. - const prefix = ID_PREFIXES[kind] as IdPrefix | undefined + // 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', @@ -172,8 +189,13 @@ export function encodeId(kind: K, uuid: string): Id { */ export function decodeId(kind: K, id: string): string { const prefix = prefixOf(kind) + if (typeof id !== 'string') throw notAString(id) if (!id.startsWith(`${prefix}${SEPARATOR}`)) { - const named = KINDS_BY_PREFIX.get(id.slice(0, id.indexOf(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', @@ -218,6 +240,7 @@ export function createId(kind: K): Id { * ``` */ export function parseId(id: string): ParsedId { + if (typeof id !== 'string') throw notAString(id) const separator = id.indexOf(SEPARATOR) if (separator < 0) { throw new InvalidIdError( @@ -271,8 +294,8 @@ export function parseId(id: string): ParsedId { export function isId(kind: K, value: string): value is Id { if (typeof value !== 'string') return false - const prefix = ID_PREFIXES[kind] as string | undefined - if (prefix === undefined) return false + if (!Object.hasOwn(ID_PREFIXES, kind)) return false + const prefix: string = ID_PREFIXES[kind] if (!value.startsWith(prefix + SEPARATOR)) return false return ( diff --git a/packages/js-id/tests/id.test.ts b/packages/js-id/tests/id.test.ts index 1dcb6212f1..84d3640b51 100644 --- a/packages/js-id/tests/id.test.ts +++ b/packages/js-id/tests/id.test.ts @@ -364,6 +364,60 @@ describe('the fixes the review found', () => { } ) + // 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) }) From 4d95431e9ed7ec560b701c3aea709d36f5a8852d Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Fri, 13 Nov 2026 16:29:06 +0100 Subject: [PATCH 3/3] Test @e2b/id's UUID module directly, and reject non-UUID input cleanly Adds tests/uuid.test.ts, which exercises uuid.ts on its own instead of through encodeId/createId: - uuidToBytes: exact bytes, every hex digit in both cases, agreement with an independent formatter over the shared corpus, and each of Go's four forms probed one character short, one long and with a bad digit. - bytesToUuid: all 256 byte values, the corpus, Buffers and Uint8Arrays from another realm, and wrong lengths. - createUuid, on a fresh module per test so the monotonic state never leaks between them: version and variant on every mint, the 48-bit timestamp at 0, 2^32 +/- 1 and 2^48 - 1, random bits confined to rand_b, the sequence resetting on a new millisecond, and carry on overflow. The tests caught two input-validation bugs, both now InvalidIdError('uuid'): uuidToBytes threw a TypeError for a Symbol (from interpolating it into the message), and bytesToUuid threw a TypeError for null and formatted plain arrays and Uint16Arrays with values above 255 as "undefined". Co-Authored-By: Claude Opus 5.5 --- packages/js-id/src/uuid.ts | 21 ++- packages/js-id/tests/uuid.test.ts | 250 ++++++++++++++++++++++++++++++ 2 files changed, 269 insertions(+), 2 deletions(-) create mode 100644 packages/js-id/tests/uuid.test.ts diff --git a/packages/js-id/src/uuid.ts b/packages/js-id/src/uuid.ts index 390104ea00..2b0b0b7b9c 100644 --- a/packages/js-id/src/uuid.ts +++ b/packages/js-id/src/uuid.ts @@ -71,7 +71,15 @@ function hexDigitsOf(uuid: string): string | undefined { * @throws {InvalidIdError} if `uuid` is not in one of those forms. */ export function uuidToBytes(uuid: string): Uint8Array { - const hex = typeof uuid === 'string' ? hexDigitsOf(uuid) : undefined + 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', @@ -92,9 +100,18 @@ export function uuidToBytes(uuid: string): Uint8Array { * * @param bytes exactly 16 bytes, most significant first. * @returns the UUID in 8-4-4-4-12 hex form. - * @throws {InvalidIdError} if `bytes` is not 16 bytes long. + * @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', 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) + }) +})