diff --git a/.changeset/add-endo-ascii.md b/.changeset/add-endo-ascii.md index 07dbb75ecd..0b667d534c 100644 --- a/.changeset/add-endo-ascii.md +++ b/.changeset/add-endo-ascii.md @@ -1,15 +1,23 @@ --- -'@endo/ascii': minor -'@endo/sha256': patch +'@endo/ascii': major --- -Add `@endo/ascii`, a platform-neutral encoder that turns ASCII text into bytes, -one byte per code unit, and asserts every code unit is in the admitted 7-bit -range `0x00`–`0x7f`, hard-failing on the first that is not. It is pure -JavaScript — no `TextEncoder`, no `node:` imports, no host globals — so it runs -under XS exactly as under Node.js and browsers, and it is the XS-floor -replacement for the ad-hoc `Uint8Array.from(text, ch => ch.charCodeAt(0))` -helper that truncates rather than rejects non-ASCII code units. +Add `@endo/ascii`, a platform-neutral transcoder between ASCII text and bytes, +one byte per code unit. Its `encodeAscii` and `decodeAscii` functions assert +every value is in the admitted 7-bit range `0x00`–`0x7f`, hard-failing on the +first that is not. Both functions are available from the package entry, and +from the `./encode.js` and `./decode.js` subpaths respectively. +`decodeAscii` accepts both genuine and emulated frozen `Uint8Array` values. -`@endo/sha256`'s XS spot check now encodes its vectors with `@endo/ascii` -instead of a local copy of that helper. +This initial release is intentionally `major`: it establishes the stable +public API for the package rather than publishing an intermediate pre-1.0 +surface. + +The package is pure JavaScript, with no `TextEncoder`, `TextDecoder`, `node:` +imports, or host globals, so it runs under XS exactly as under Node.js and +browsers. `encodeAscii` replaces ad hoc encoders that truncate rather than +reject non-ASCII code units. `decodeAscii` is the strict counterpart the +`TextDecoder` label `'ascii'` is not: the +[WHATWG Encoding Standard](https://encoding.spec.whatwg.org/#names-and-labels) +aliases that label to `windows-1252`, so `fatal: true` never fires on bytes +`0x80`–`0xff`. diff --git a/.changeset/ocapn-adopt-ascii.md b/.changeset/ocapn-adopt-ascii.md new file mode 100644 index 0000000000..9a5c47ed2d --- /dev/null +++ b/.changeset/ocapn-adopt-ascii.md @@ -0,0 +1,28 @@ +--- +'@endo/ocapn': major +--- + +Swissnums represented as strings must now be 7-bit ASCII. Pass a `Uint8Array` +or immutable bytes instead when a secret contains arbitrary bytes; raw-byte +swissnums still ride the wire verbatim. + +This is a `major` release because it rejects previously accepted string inputs +and changes the error contract of a public client helper. + +- The hub API (`publish`/`publishHeld`/`unpublish`) previously accepted any + string swissnum and silently UTF-8-encoded it; a non-ASCII string swissnum now + throws a `RangeError`. To revoke a publication persisted under the old + behavior, pass the UTF-8 bytes of its former string swissnum to `unpublish`. +- `decodeSwissnum` now rejects wire bytes `0x80`–`0xff` instead of silently + decoding them as `windows-1252` characters. +- Sturdyref readers preserve a non-ASCII secret as raw bytes instead of + mis-decoding it as `windows-1252` text, so arbitrary-byte secrets can reach + byte-keyed locators unchanged. +- The client-side `encodeSwissnum` already rejected non-ASCII input; its thrown + error changes type and message (generic `Error` -> `RangeError`), and a + non-string argument now throws `TypeError` instead of being coerced. +- `publish`, `publishHeld`, and `unpublish` now declare their existing support + for immutable `ArrayBufferLike` swissnums in addition to `Uint8Array`. + +Handoff session keys continue to accept the full Unicode permitted in peer +locations; the new swissnum validation does not apply to those location keys. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a30cc75980..2805112961 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -163,7 +163,10 @@ jobs: strategy: fail-fast: false matrix: - node-version: [22.x, 24.x] + # Temporarily pin Node 24: Node 24.19 triggers the better-sqlite3@11.10.0 + # legacy node::ObjectWrap cleanup abort; see + # https://github.com/nodejs/node/pull/65042. + node-version: [22.x, 24.18.0] platform: [ubuntu-latest, macos-15] # windows-latest exhibited flakey tests that are not yet worth the # trouble to investigate, and blocked us from upgrading yarn from 1 to @@ -329,7 +332,10 @@ jobs: strategy: fail-fast: false matrix: - node-version: [22.x, 24.x] + # Temporarily pin Node 24: Node 24.19 triggers the better-sqlite3@11.10.0 + # legacy node::ObjectWrap cleanup abort; see + # https://github.com/nodejs/node/pull/65042. + node-version: [22.x, 24.18.0] platform: [ubuntu-latest] steps: @@ -371,7 +377,10 @@ jobs: strategy: fail-fast: false matrix: - node-version: [22.x, 24.x] + # Temporarily pin Node 24: Node 24.19 triggers the better-sqlite3@11.10.0 + # legacy node::ObjectWrap cleanup abort; see + # https://github.com/nodejs/node/pull/65042. + node-version: [22.x, 24.18.0] platform: [ubuntu-latest] steps: @@ -477,7 +486,10 @@ jobs: strategy: fail-fast: false matrix: - node-version: [22.x, 24.x] + # Temporarily pin Node 24: Node 24.19 triggers the better-sqlite3@11.10.0 + # legacy node::ObjectWrap cleanup abort; see + # https://github.com/nodejs/node/pull/65042. + node-version: [22.x, 24.18.0] platform: [ubuntu-latest] steps: diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index c3e4b8c8fe..7ac79193ee 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -41,7 +41,7 @@ jobs: sudo apt-get install -y build-essential pkg-config - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # v1 with: toolchain: stable diff --git a/packages/ascii/CHANGELOG.md b/packages/ascii/CHANGELOG.md new file mode 100644 index 0000000000..e4d87c4d45 --- /dev/null +++ b/packages/ascii/CHANGELOG.md @@ -0,0 +1,4 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. diff --git a/packages/ascii/README.md b/packages/ascii/README.md index 9972e0137b..91fe5e4bf6 100644 --- a/packages/ascii/README.md +++ b/packages/ascii/README.md @@ -1,18 +1,29 @@ # @endo/ascii -`@endo/ascii` encodes ASCII text to bytes, one byte per code unit, and asserts -that every code unit is in the admitted 7-bit range `0x00`–`0x7f`. +`@endo/ascii` transcodes between ASCII text and bytes, one byte per code unit, +and asserts in both directions that every value is in the admitted 7-bit range +`0x00`–`0x7f`. ```js -import { encodeAscii } from '@endo/ascii'; +import { encodeAscii, decodeAscii } from '@endo/ascii'; const bytes = encodeAscii('abc'); // Uint8Array [ 0x61, 0x62, 0x63 ] encodeAscii('café'); // throws RangeError: the é is 0xe9 + +decodeAscii(Uint8Array.of(0x61, 0x62, 0x63)); // 'abc' +decodeAscii(Uint8Array.of(0x80)); // throws RangeError: 0x80 is past 0x7f ``` -It is pure JavaScript — no `TextEncoder`, no `node:` imports, no host globals — -so it imports and runs under XS (`xst`) exactly as it does under Node.js and -browsers. That makes it the XS-floor replacement for the ad-hoc +`decodeAscii` is the strict inverse of `encodeAscii`, and the counterpart the +`TextDecoder` label `'ascii'` is not: per the +[WHATWG Encoding Standard](https://encoding.spec.whatwg.org/#names-and-labels) +that label is an alias for `windows-1252`, so `new TextDecoder('ascii', { +fatal: true })` silently maps bytes `0x80`–`0xff` to Latin-1/windows-1252 +characters rather than throwing. + +It is pure JavaScript — no `TextEncoder`, no `TextDecoder`, no `node:` imports, +and no host globals — so it imports and runs under XS (`xst`) exactly as it does +under Node.js and browsers. That makes it the XS-floor replacement for the ad-hoc `Uint8Array.from(text, ch => ch.charCodeAt(0))` helper that XS bundles reach for because XS lacks `TextEncoder`: unlike that helper, `encodeAscii` **rejects** a non-ASCII code unit rather than silently truncating it to its low byte. @@ -20,7 +31,10 @@ non-ASCII code unit rather than silently truncating it to its low byte. ## Scope This is the narrow primitive for protocol text that is ASCII by construction, -where a stray non-ASCII code unit is a bug to surface rather than to mangle. It -does not decode, and it is deliberately not a general Unicode transcoder: -callers that need to encode arbitrary text as UTF-8 want a different tool. The -optional second argument names the string in the thrown diagnostic. +where a stray non-ASCII value is a bug to surface rather than to mangle. It is +deliberately not a general Unicode transcoder: callers that need to encode or +decode arbitrary text as UTF-8 want a different tool. The optional second +argument names the string or bytes in the thrown diagnostic. + +The package entry exports both functions. The `./encode.js` and `./decode.js` +subpaths expose the individual directions. diff --git a/packages/ascii/decode.js b/packages/ascii/decode.js new file mode 100644 index 0000000000..38dc8d9d8f --- /dev/null +++ b/packages/ascii/decode.js @@ -0,0 +1 @@ +export { decodeAscii } from './src/decode.js'; diff --git a/packages/ascii/index.js b/packages/ascii/index.js index 4df1b071f5..22998fae82 100644 --- a/packages/ascii/index.js +++ b/packages/ascii/index.js @@ -1 +1,2 @@ export { encodeAscii } from './src/encode.js'; +export { decodeAscii } from './src/decode.js'; diff --git a/packages/ascii/package.json b/packages/ascii/package.json index c43ba21233..4179ce8579 100644 --- a/packages/ascii/package.json +++ b/packages/ascii/package.json @@ -1,10 +1,12 @@ { "name": "@endo/ascii", "version": "0.1.0", - "description": "Encodes ASCII text to bytes, asserting each code unit is 7-bit", + "description": "Transcodes between ASCII text and bytes, asserting every value is 7-bit", "keywords": [ "ascii", + "decode", "endo", + "encode", "ses" ], "author": "Endo contributors", @@ -23,6 +25,7 @@ "exports": { ".": "./index.js", "./encode.js": "./encode.js", + "./decode.js": "./decode.js", "./src/*": { "test-endo-ascii": "./src/*" }, diff --git a/packages/ascii/src/decode.js b/packages/ascii/src/decode.js new file mode 100644 index 0000000000..9a6a58d1f1 --- /dev/null +++ b/packages/ascii/src/decode.js @@ -0,0 +1,96 @@ +// @ts-check + +import harden from '@endo/harden'; + +const TypedArrayPrototype = Object.getPrototypeOf(Uint8Array.prototype); +const { apply } = Reflect; +const { fromCharCode } = String; +const isView = /** @type {(value: unknown) => boolean} */ (ArrayBuffer.isView); +const { fill: typedArrayFill } = TypedArrayPrototype; +const { slice: typedArraySlice } = TypedArrayPrototype; +const { get: typedArrayLength } = /** @type {PropertyDescriptor} */ ( + Object.getOwnPropertyDescriptor(TypedArrayPrototype, 'length') +); +const { get: typedArrayTag } = /** @type {PropertyDescriptor} */ ( + Object.getOwnPropertyDescriptor(TypedArrayPrototype, Symbol.toStringTag) +); + +const CODE_UNIT_CHUNK_SIZE = 4096; + +/** + * Decodes bytes to ASCII text, one UTF-16 code unit per byte, asserting that + * every byte is in the admitted 7-bit range `0x00`–`0x7f` and hard-failing on + * the first byte that is not. It is the exact inverse of `encodeAscii`: what + * `encodeAscii` admits, `decodeAscii` round-trips, and what `encodeAscii` + * rejects, `decodeAscii` refuses to have produced. + * + * Pure JavaScript with no `TextDecoder`, no `node:` imports, and no host + * globals, so it imports and runs under XS (`xst`) exactly as it does under + * Node.js and browsers. It is also the strict counterpart the `TextDecoder` + * label `'ascii'` is not: per the [WHATWG Encoding + * Standard](https://encoding.spec.whatwg.org/#names-and-labels) that label is + * an alias for `windows-1252`, so `new TextDecoder('ascii', { fatal: true })` + * silently maps bytes `0x80`–`0xff` to Latin-1/windows-1252 characters instead + * of throwing — the exact trap this primitive avoids. + * + * @param {Uint8Array} bytes + * @param {string} [name] Name of the bytes, for error diagnostics. + * @returns {string} + */ +export const decodeAscii = (bytes, name = '') => { + /** @type {Uint8Array} */ + let genuineBytes; + try { + if (isView(bytes)) { + if ( + apply( + /** @type {(this: unknown) => string | undefined} */ (typedArrayTag), + bytes, + [], + ) !== 'Uint8Array' + ) { + throw TypeError('not a Uint8Array'); + } + // A zero-length intrinsic fill performs ValidateTypedArray, including the + // detached/out-of-bounds check, without invoking a subclass species + // constructor or reading through a caller-controlled Proxy. + apply(typedArrayFill, bytes, [0, 0, 0]); + genuineBytes = bytes; + } else { + // The freezable-TypedArray shim represents a Uint8Array over an emulated + // immutable ArrayBuffer with a non-exotic wrapper. Its shimmed `slice` + // amplifies the wrapper and copies its bytes into a genuine Uint8Array, + // matching the compatibility path used by @endo/bytes and @endo/utf8. + genuineBytes = apply(typedArraySlice, bytes, []); + } + } catch (cause) { + throw TypeError(`ascii: expected bytes ${name} to be a Uint8Array`, { + cause, + }); + } + const length = apply( + /** @type {(this: unknown) => number} */ (typedArrayLength), + genuineBytes, + [], + ); + + /** @type {string[]} */ + const chunks = []; + for (let offset = 0; offset < length; offset += CODE_UNIT_CHUNK_SIZE) { + const chunkLength = Math.min(CODE_UNIT_CHUNK_SIZE, length - offset); + const codeUnits = new Array(chunkLength); + for (let index = 0; index < chunkLength; index += 1) { + const byteOffset = offset + index; + const byte = genuineBytes[byteOffset]; + if (!(byte >= 0 && byte <= 0x7f)) { + throw RangeError( + `Non-ASCII byte 0x${byte.toString(16)} at offset ${byteOffset} of bytes ${name}`, + ); + } + codeUnits[index] = byte; + } + chunks.push(apply(fromCharCode, undefined, codeUnits)); + } + return chunks.join(''); +}; +harden(decodeAscii); diff --git a/packages/ascii/test/decode.test.js b/packages/ascii/test/decode.test.js new file mode 100644 index 0000000000..405512664a --- /dev/null +++ b/packages/ascii/test/decode.test.js @@ -0,0 +1,114 @@ +// @ts-nocheck + +import test from 'ava'; + +import { decodeAscii, encodeAscii } from '../index.js'; +import { decodeAscii as decodeAsciiFromSubpath } from '../decode.js'; + +test('decodes code-unit bytes to their ASCII text', t => { + t.is(decodeAscii(new Uint8Array()), ''); + t.is(decodeAscii(Uint8Array.of(0x61, 0x62, 0x63)), 'abc'); + t.is( + decodeAscii( + Uint8Array.of( + 0x48, + 0x65, + 0x6c, + 0x6c, + 0x6f, + 0x2c, + 0x20, + 0x77, + 0x6f, + 0x72, + 0x6c, + 0x64, + 0x21, + ), + ), + 'Hello, world!', + ); +}); + +test('admits the full 7-bit range, including NUL and DEL', t => { + const bytes = new Uint8Array(0x80); + for (let i = 0; i < 0x80; i += 1) { + bytes[i] = i; + } + const text = decodeAscii(bytes); + t.is(text.length, 0x80); + for (let i = 0; i < 0x80; i += 1) { + t.is(text.charCodeAt(i), i); + } + // The boundary bytes specifically. + t.is(decodeAscii(Uint8Array.of(0x00)), '\x00'); + t.is(decodeAscii(Uint8Array.of(0x7f)), '\x7f'); +}); + +test('rejects a byte at the 0x80 boundary, reporting the offset', t => { + const error = t.throws(() => decodeAscii(Uint8Array.of(0x61, 0x80)), { + instanceOf: RangeError, + }); + t.regex(error.message, /0x80/); + t.regex(error.message, /offset 1/); +}); + +test('rejects every non-ASCII byte 0x80-0xff', t => { + for (let byte = 0x80; byte <= 0xff; byte += 1) { + t.throws(() => decodeAscii(Uint8Array.of(byte)), { + instanceOf: RangeError, + }); + } +}); + +test('names the bytes in the diagnostic when asked', t => { + const error = t.throws(() => decodeAscii(Uint8Array.of(0xff), 'greeting'), { + instanceOf: RangeError, + }); + t.regex(error.message, /bytes greeting/); +}); + +test('rejects a non-Uint8Array input', t => { + t.throws(() => decodeAscii('abc'), { instanceOf: TypeError }); + t.throws(() => decodeAscii([0x41]), { instanceOf: TypeError }); +}); + +test('reads the intrinsic Uint8Array length and rejects proxies', t => { + class MisleadingLength extends Uint8Array { + // eslint-disable-next-line class-methods-use-this + get length() { + return 4; + } + } + + t.is(decodeAscii(new MisleadingLength([0x41])), 'A'); + t.throws(() => decodeAscii(new Proxy(Uint8Array.of(0x41), {})), { + instanceOf: TypeError, + }); +}); + +test('rejects a Uint8Array over a detached buffer', t => { + const bytes = Uint8Array.of(0x41); + structuredClone(bytes.buffer, { transfer: [bytes.buffer] }); + t.throws(() => decodeAscii(bytes), { instanceOf: TypeError }); +}); + +test('round-trips with encodeAscii across the full admitted range', t => { + let text = ''; + for (let i = 0; i < 0x80; i += 1) { + text += String.fromCharCode(i); + } + t.is(decodeAscii(encodeAscii(text)), text); +}); + +test('round-trips inputs longer than the code-unit chunk size', t => { + let text = ''; + for (let index = 0; index < 4096 * 2 + 1; index += 1) { + text += String.fromCharCode(index % 0x80); + } + t.is(decodeAscii(encodeAscii(text)), text); +}); + +test('the package entry and the decode.js subpath export the same function', t => { + t.is(decodeAscii, decodeAsciiFromSubpath); +}); diff --git a/packages/ascii/test/emulated-decode.test.js b/packages/ascii/test/emulated-decode.test.js new file mode 100644 index 0000000000..b3fa7a804b --- /dev/null +++ b/packages/ascii/test/emulated-decode.test.js @@ -0,0 +1,28 @@ +// @ts-nocheck + +import test from 'ava'; + +test.serial('decodes an emulated frozen Uint8Array', async t => { + const typedArrayPrototype = Object.getPrototypeOf(Uint8Array.prototype); + const nativeTypedArraySlice = typedArrayPrototype.slice; + const emulatedBytesToGenuineBytes = new WeakMap(); + const emulatedBytes = Object.freeze(Object.create(Uint8Array.prototype)); + emulatedBytesToGenuineBytes.set( + emulatedBytes, + Uint8Array.of(0x41, 0x42, 0x43), + ); + + // Model the freezable-TypedArray shim's amplification of an emulated wrapper + // through %TypedArrayPrototype%.slice. Import the decoder while the shim is + // installed so it captures the same intrinsic the real shim provides. + typedArrayPrototype.slice = function slice(...args) { + const genuineBytes = emulatedBytesToGenuineBytes.get(this); + return Reflect.apply(nativeTypedArraySlice, genuineBytes || this, args); + }; + const { decodeAscii } = await import('../src/decode.js?emulated-test'); + typedArrayPrototype.slice = nativeTypedArraySlice; + + t.false(ArrayBuffer.isView(emulatedBytes)); + t.is(emulatedBytes[0], undefined); + t.is(decodeAscii(emulatedBytes), 'ABC'); +}); diff --git a/packages/goblin-chat/package.json b/packages/goblin-chat/package.json index 4a23398d33..06d3a95728 100644 --- a/packages/goblin-chat/package.json +++ b/packages/goblin-chat/package.json @@ -48,6 +48,7 @@ "test:xs": "exit 0" }, "dependencies": { + "@endo/ascii": "workspace:^", "@endo/base64": "workspace:^", "@endo/eventual-send": "workspace:^", "@endo/exo": "workspace:^", diff --git a/packages/goblin-chat/src/use-goblin-chat.js b/packages/goblin-chat/src/use-goblin-chat.js index 04c9ccb75b..382b5b9dd8 100644 --- a/packages/goblin-chat/src/use-goblin-chat.js +++ b/packages/goblin-chat/src/use-goblin-chat.js @@ -34,6 +34,7 @@ */ import { useCallback, useEffect, useReducer, useRef } from 'react'; +import { decodeAscii } from '@endo/ascii'; import { E } from '@endo/eventual-send'; import { Far } from '@endo/marshal'; @@ -45,8 +46,6 @@ import { parseLocator } from './uri-parse.js'; import { encodeBase64Url } from './base64url.js'; import { initialState, reducer, formatError } from './chat-state.js'; -const ASCII_DECODER = new TextDecoder('ascii'); - /** * Render a swissnum for log display. * @@ -60,13 +59,9 @@ const ASCII_DECODER = new TextDecoder('ascii'); * that appear in `ocapn://…/s/` URIs. Render in the * same canonical base64url form they appeared on the wire. * - * NOTE: this used to delegate to `decodeSwissnum` and rely on its - * `TextDecoder('ascii', { fatal: true })` to throw on non-ASCII. That - * doesn't work — per the WHATWG encoding spec the `'ascii'` label is - * aliased to `'windows-1252'`, every byte 0–255 is "valid" in - * windows-1252, so `fatal` never fires and 32 random bytes come back - * as Latin-1 garbage like `ôr¤\`RB…`. We do the printable-ASCII check - * ourselves now. + * `decodeSwissnum` enforces 7-bit ASCII, but log display is narrower: + * control characters are not printable and should use the canonical + * base64url form too. Check the printable range here before decoding. * * @param {SwissNum} swissNum * @returns {string} @@ -85,7 +80,7 @@ const formatSwissnumForLog = swissNum => { } } if (allPrintable) { - return ASCII_DECODER.decode(bytes); + return decodeAscii(bytes, 'swissnum'); } return encodeBase64Url(bytes); }; diff --git a/packages/goblin-chat/tsconfig.composite.json b/packages/goblin-chat/tsconfig.composite.json index 51634069c5..64cc0ca176 100644 --- a/packages/goblin-chat/tsconfig.composite.json +++ b/packages/goblin-chat/tsconfig.composite.json @@ -5,6 +5,9 @@ "composite": true }, "references": [ + { + "path": "../ascii/tsconfig.composite.json" + }, { "path": "../base64/tsconfig.composite.json" }, diff --git a/packages/ocapn/package.json b/packages/ocapn/package.json index 6395bac332..03a49efacf 100644 --- a/packages/ocapn/package.json +++ b/packages/ocapn/package.json @@ -49,6 +49,7 @@ "test:update-snapshots": "ses-ava --update-snapshots" }, "dependencies": { + "@endo/ascii": "workspace:^", "@endo/bytes": "workspace:^", "@endo/eventual-send": "workspace:^", "@endo/harden": "workspace:^", diff --git a/packages/ocapn/src/client/ref-kit.js b/packages/ocapn/src/client/ref-kit.js index 1055e2dd40..7d37c31f29 100644 --- a/packages/ocapn/src/client/ref-kit.js +++ b/packages/ocapn/src/client/ref-kit.js @@ -71,7 +71,7 @@ import { makeSlot, parseSlot } from '../captp/pairwise.js'; * @property {(remotePromise: Promise) => object} makeLocalResolverForRemotePromise * @property {(answerPosition: bigint, promise: Promise) => Promise} makeLocalAnswerPromiseAndFulfill * @property {(position: bigint) => Promise} getLocalAnswerValue - * @property {(location: OcapnLocation, secret: string) => SturdyRef} makeSturdyRef + * @property {(location: OcapnLocation, secret: string | Uint8Array) => SturdyRef} makeSturdyRef * @property {(signedGive: HandoffGiveSigEnvelope) => Promise} provideHandoff * @property {(signedGive: HandoffGiveDetails) => HandoffGiveSigEnvelope} sendHandoff * @property {(value: object) => ValInfo} getInfoForVal diff --git a/packages/ocapn/src/client/sturdyrefs.js b/packages/ocapn/src/client/sturdyrefs.js index 568dff4069..4c25a4183f 100644 --- a/packages/ocapn/src/client/sturdyrefs.js +++ b/packages/ocapn/src/client/sturdyrefs.js @@ -5,8 +5,11 @@ * @import { InternalSession } from './types.js' */ -import harden from '@endo/harden'; +import { decodeAscii } from '@endo/ascii'; +import { bytesFromImmutable } from '@endo/bytes/from-immutable.js'; +import { bytesToImmutable } from '@endo/bytes/to-immutable.js'; import { E } from '@endo/eventual-send'; +import harden from '@endo/harden'; import { makeTagged } from '@endo/pass-style'; import { encodeSwissnum, swissnumFromBytes } from './util.js'; @@ -28,14 +31,33 @@ import { encodeSwissnum, swissnumFromBytes } from './util.js'; * @property {string | Uint8Array} secret */ -/** @type {WeakMap} */ +/** + * @typedef {object} InternalSturdyRefDetails + * @property {OcapnLocation} location + * @property {string | ArrayBuffer} secret + */ + +/** @type {WeakMap} */ const sturdyRefDetails = new WeakMap(); /** @param {any} value */ export const isSturdyRef = value => sturdyRefDetails.has(value); -/** @param {SturdyRef} sturdyRef */ -export const getSturdyRefDetails = sturdyRef => sturdyRefDetails.get(sturdyRef); +/** + * @param {SturdyRef} sturdyRef + * @returns {SturdyRefDetails | undefined} + */ +export const getSturdyRefDetails = sturdyRef => { + const details = sturdyRefDetails.get(sturdyRef); + if (!details) { + return undefined; + } + const { location, secret } = details; + return harden({ + location, + secret: typeof secret === 'string' ? secret : bytesFromImmutable(secret), + }); +}; /** * Mint a `SturdyRef` value for `(location, secret)`. Sturdyrefs are @@ -48,7 +70,9 @@ export const getSturdyRefDetails = sturdyRef => sturdyRefDetails.get(sturdyRef); */ export const makeSturdyRef = (location, secret) => { const sturdyRef = makeTagged('ocapn-sturdyref', undefined); - sturdyRefDetails.set(sturdyRef, { location, secret }); + const durableSecret = + typeof secret === 'string' ? secret : bytesToImmutable(secret); + sturdyRefDetails.set(sturdyRef, harden({ location, secret: durableSecret })); return harden(sturdyRef); }; @@ -69,7 +93,7 @@ export const enlivenSturdyRef = async ( isSelfLocation, locator, ) => { - const details = sturdyRefDetails.get(sturdyRef); + const details = getSturdyRefDetails(sturdyRef); if (!details) { throw Error('SturdyRef details not found'); } @@ -113,7 +137,6 @@ export const enlivenSturdyRef = async ( * @returns {SturdyRefTracker} */ export const makeSturdyRefTracker = locator => { - const textDecoder = new TextDecoder('ascii', { fatal: true }); return harden({ makeSturdyRef: (location, secret) => makeSturdyRef(location, secret), lookup: async secretBytes => { @@ -126,12 +149,16 @@ export const makeSturdyRefTracker = locator => { // a Spritely-style random 24-byte secret), fall back to passing // the raw bytes through; locators that index by bytes can match // those, locators that don't will simply return undefined. + let secret; try { - const secret = textDecoder.decode(view); - return locator.get(secret); - } catch { - return locator.get(view); + secret = decodeAscii(view, 'sturdyref secret'); + } catch (error) { + if (!(error instanceof RangeError)) { + throw error; + } + secret = view; } + return locator.get(secret); }, }); }; diff --git a/packages/ocapn/src/client/util.js b/packages/ocapn/src/client/util.js index 1166cb1f98..2e8378e1ab 100644 --- a/packages/ocapn/src/client/util.js +++ b/packages/ocapn/src/client/util.js @@ -5,6 +5,7 @@ * @import { LocationId, SwissNum } from './types.js' */ +import { decodeAscii, encodeAscii } from '@endo/ascii'; import { bytesFromImmutable } from '@endo/bytes/from-immutable.js'; import { bytesToImmutable } from '@endo/bytes/to-immutable.js'; import { encodeHex } from '@endo/hex'; @@ -48,15 +49,22 @@ export const locationToLocationId = location => { return uri; }; -const swissnumDecoder = new TextDecoder('ascii', { fatal: true }); -const swissnumEncoder = new TextEncoder(); - /** + * Decode a swissnum's wire bytes back to its string form, rejecting any byte + * outside the 7-bit ASCII range. This is the strict inverse of + * `encodeSwissnum`: `TextDecoder('ascii')` cannot enforce this, because per the + * [WHATWG Encoding Standard](https://encoding.spec.whatwg.org/#names-and-labels) + * the `'ascii'` label is an alias for `windows-1252`, so `fatal: true` never + * fires on bytes `0x80`–`0xff` and they decode to Latin-1/windows-1252 + * characters instead of throwing. Raw-bytes swissnums that carry non-ASCII + * bytes on the wire are represented as bytes, not decoded to a string, so they + * are unaffected. + * * @param {ArrayBufferLike} value * @returns {string} */ export const decodeSwissnum = value => { - return swissnumDecoder.decode(bytesFromImmutable(value)); + return decodeAscii(bytesFromImmutable(value), 'swissnum'); }; /** @@ -64,17 +72,8 @@ export const decodeSwissnum = value => { * @returns {SwissNum} */ export const encodeSwissnum = value => { - // Validate the value is strictly valid ASCII - for (let i = 0; i < value.length; i += 1) { - const code = value.charCodeAt(i); - if (code > 127) { - throw new Error( - `Invalid ASCII character in swissnum at position ${i}: ${value[i]}`, - ); - } - } // @ts-expect-error - Branded type: SwissNum is ArrayBufferLike at runtime - return bytesToImmutable(swissnumEncoder.encode(value)); + return bytesToImmutable(encodeAscii(value, 'swissnum')); }; /** diff --git a/packages/ocapn/src/codecs/descriptors.js b/packages/ocapn/src/codecs/descriptors.js index d6f2b64629..d44ae5bc06 100644 --- a/packages/ocapn/src/codecs/descriptors.js +++ b/packages/ocapn/src/codecs/descriptors.js @@ -10,8 +10,9 @@ * @import { SessionId, PublicKeyId } from '../client/types.js' */ -import harden from '@endo/harden'; +import { decodeAscii } from '@endo/ascii'; import { bytesToImmutable } from '@endo/bytes/to-immutable.js'; +import harden from '@endo/harden'; import { makeCodec, makeRecordUnionCodec } from '../syrup/codec.js'; import { @@ -321,12 +322,20 @@ export const makeDescCodecs = referenceKit => { syrupReader => { const node = OcapnPeerCodec.read(syrupReader); const swissNum = syrupReader.readBytestring(); - const textDecoder = new TextDecoder('ascii', { fatal: true }); const secretBytes = swissNum instanceof Uint8Array ? swissNum : new Uint8Array(/** @type {ArrayBuffer} */ (swissNum.slice())); - const secret = textDecoder.decode(secretBytes); + /** @type {string | Uint8Array} */ + let secret; + try { + secret = decodeAscii(secretBytes, 'sturdyref secret'); + } catch (error) { + if (!(error instanceof RangeError)) { + throw error; + } + secret = new Uint8Array(secretBytes); + } const value = referenceKit.makeSturdyRef(node, secret); return value; }, diff --git a/packages/ocapn/src/cryptography.js b/packages/ocapn/src/cryptography.js index 48b9165b71..75e5ee6176 100644 --- a/packages/ocapn/src/cryptography.js +++ b/packages/ocapn/src/cryptography.js @@ -1,5 +1,6 @@ // @ts-check /* global crypto */ +import { encodeAscii } from '@endo/ascii'; import harden from '@endo/harden'; import { bytesToImmutable } from '@endo/bytes/to-immutable.js'; import { bytesFromImmutable } from '@endo/bytes/from-immutable.js'; @@ -28,9 +29,7 @@ import { * @import { OcapnCodec } from './codec-interface.js' */ -const textEncoder = new TextEncoder(); - -const sessionIdHashPrefixBytes = textEncoder.encode('prot0'); +const sessionIdHashPrefixBytes = encodeAscii('prot0'); /** * @typedef {object} OcapnPublicKey @@ -238,12 +237,7 @@ export const makeCryptography = codec => { // Includes a length-prefixed channel-binding value (the Noise // handshake hash on the np netlayer; an empty buffer where no // session-bound binding is available, e.g. tcp-testing-only). - const LOCATION_SIG_DOMAIN = (() => { - const text = 'ocapn-location-v1\0'; - const bytes = new Uint8Array(text.length); - for (let i = 0; i < text.length; i += 1) bytes[i] = text.charCodeAt(i); - return bytes; - })(); + const LOCATION_SIG_DOMAIN = encodeAscii('ocapn-location-v1\0'); /** * @param {OcapnLocation} location diff --git a/packages/ocapn/src/hub/hub.js b/packages/ocapn/src/hub/hub.js index 97bf56681b..4903c53ff0 100644 --- a/packages/ocapn/src/hub/hub.js +++ b/packages/ocapn/src/hub/hub.js @@ -1,4 +1,5 @@ // @ts-check +import { encodeAscii } from '@endo/ascii'; import harden from '@endo/harden'; import { bytesFromImmutable } from '@endo/bytes/from-immutable.js'; import { Far } from '@endo/marshal'; @@ -14,6 +15,8 @@ import { makeOcapnOperationsCodecs } from '../codecs/operations.js'; import { getSelectorName, makeSelector } from '../selector.js'; import { makeSturdyRef } from '../client/sturdyrefs.js'; +/** @import { OcapnLocation } from '../codecs/components.js' */ + /** * An OCapN hub: a comms-vat-style forwarding node that is NOT a * client. The hub holds no presences, no promises, no locator of live @@ -130,9 +133,26 @@ const bytesFromHex = hex => { */ const swissnumHex = swissnum => typeof swissnum === 'string' - ? hexFromBytes(new TextEncoder().encode(swissnum)) + ? hexFromBytes(encodeAscii(swissnum, 'swissnum')) : hexFromBytes(swissnum); +/** + * Derive a durable session key for the exporter named by an inbound handoff. + * Its UTF-8 representation is intentionally preserved for compatibility with + * durable sessions created before string swissnums became ASCII-only. + * + * @param {OcapnLocation} exporterLocation + * @returns {string} + */ +export const makeHandoffSessionKey = exporterLocation => { + const json = JSON.stringify(exporterLocation); + if (json === undefined) { + throw TypeError('handoff exporter location must be JSON-serializable'); + } + return `handoff:${hexFromBytes(new TextEncoder().encode(json))}`; +}; +harden(makeHandoffSessionKey); + const makeMemoryHubStore = () => { /** @type {any} */ let state; @@ -781,9 +801,7 @@ export const makeOcapnHub = ({ ); } const { exporterLocation } = signedGive.object; - const outKey = `handoff:${swissnumHex( - JSON.stringify(exporterLocation), - )}`; + const outKey = makeHandoffSessionKey(exporterLocation); const outSession = provideSessionState(outKey); // Frames toward the exporter queue until the dial completes; // the location persists so a successor process (or a later @@ -2014,7 +2032,7 @@ export const makeOcapnHub = ({ * Publish a reference row under a swissnum: `(origin session, * position there, flavor)`. Position 0 is the origin's bootstrap. * - * @param {string | Uint8Array} swissnum + * @param {string | Uint8Array | ArrayBufferLike} swissnum * @param {{ session: string, position: bigint, flavor?: 'object' | 'promise' }} at */ publish: (swissnum, { session, position, flavor = 'object' }) => { @@ -2046,7 +2064,7 @@ export const makeOcapnHub = ({ * positions toward it — the natural form for an embedder endpoint * that knows its own import positions. * - * @param {string | Uint8Array} swissnum + * @param {string | Uint8Array | ArrayBufferLike} swissnum * @param {{ session: string, position: bigint }} at */ publishHeld: (swissnum, { session, position }) => { @@ -2120,7 +2138,7 @@ export const makeOcapnHub = ({ } return harden(dials); }, - /** @param {string | Uint8Array} swissnum */ + /** @param {string | Uint8Array | ArrayBufferLike} swissnum */ unpublish: swissnum => { const key = swissnumHex(swissnum); const refId = publications.get(key); diff --git a/packages/ocapn/test/ascii.test.js b/packages/ocapn/test/ascii.test.js new file mode 100644 index 0000000000..cbc775369a --- /dev/null +++ b/packages/ocapn/test/ascii.test.js @@ -0,0 +1,111 @@ +// @ts-check + +import { bytesFromImmutable } from '@endo/bytes/from-immutable.js'; +import { bytesToImmutable } from '@endo/bytes/to-immutable.js'; +import harden from '@endo/harden'; +import test from '@endo/ses-ava/test.js'; + +import { + decodeSwissnum, + encodeSwissnum, + swissnumFromBytes, +} from '../src/client/util.js'; +import { makeSturdyRefTracker } from '../src/client/sturdyrefs.js'; +import { makeHandoffSessionKey, makeOcapnHub } from '../src/hub/hub.js'; +import { syrupCodec } from '../src/syrup/index.js'; + +/** @import { OcapnLocation } from '../src/codecs/components.js' */ + +test('encodeSwissnum preserves every ASCII byte', t => { + let asciiText = ''; + const expectedBytes = new Uint8Array(0x80); + for (let codeUnit = 0; codeUnit < 0x80; codeUnit += 1) { + asciiText += String.fromCharCode(codeUnit); + expectedBytes[codeUnit] = codeUnit; + } + + const swissnum = encodeSwissnum(asciiText); + t.true(Object.isFrozen(swissnum)); + t.deepEqual(bytesFromImmutable(swissnum), expectedBytes); +}); + +test('encodeSwissnum rejects U+0080', t => { + t.throws(() => encodeSwissnum('\x80'), { + instanceOf: RangeError, + message: /Non-ASCII code unit 0x80 at offset 0 of string swissnum/, + }); +}); + +test('decodeSwissnum round-trips every ASCII byte', t => { + let asciiText = ''; + for (let codeUnit = 0; codeUnit < 0x80; codeUnit += 1) { + asciiText += String.fromCharCode(codeUnit); + } + + t.is(decodeSwissnum(encodeSwissnum(asciiText)), asciiText); +}); + +test('swissnum wrappers round-trip beyond the ASCII decoder chunk size', t => { + let asciiText = ''; + for (let index = 0; index < 4096 * 2 + 1; index += 1) { + asciiText += String.fromCharCode(index % 0x80); + } + + t.is(decodeSwissnum(encodeSwissnum(asciiText)), asciiText); +}); + +test('decodeSwissnum rejects a non-ASCII wire byte', t => { + // A raw-bytes swissnum carrying 0x80 must not silently decode to a + // windows-1252 character (the trap `TextDecoder('ascii')` falls into); + // the string form of a swissnum is 7-bit ASCII by construction. + t.throws(() => decodeSwissnum(swissnumFromBytes(Uint8Array.of(0x80))), { + instanceOf: RangeError, + message: /Non-ASCII byte 0x80 at offset 0 of bytes swissnum/, + }); +}); + +test('hub string swissnums reject U+0080 without restricting bytes', t => { + const hub = makeOcapnHub({ codec: syrupCodec }); + + t.throws(() => hub.unpublish('\x80'), { + instanceOf: RangeError, + message: /Non-ASCII code unit 0x80 at offset 0 of string swissnum/, + }); + hub.publish(Uint8Array.of(0x80), { + session: 'byte-swissnum-origin', + position: 0n, + }); + t.deepEqual(hub.inspect().publishedOrigins, ['byte-swissnum-origin']); + hub.unpublish(bytesToImmutable(Uint8Array.of(0x80))); + t.deepEqual(hub.inspect().publishedOrigins, []); +}); + +test('sturdyref lookup falls back to raw non-ASCII bytes', async t => { + /** @type {unknown} */ + let lookedUp; + const tracker = makeSturdyRefTracker({ + get: secret => { + lookedUp = secret; + return undefined; + }, + }); + + await tracker.lookup(Uint8Array.of(0x80).buffer); + t.deepEqual(lookedUp, Uint8Array.of(0x80)); +}); + +test('handoff session keys admit Unicode exporter locations', t => { + /** @type {OcapnLocation} */ + const unicodeLocation = harden({ + type: 'ocapn-peer', + transport: 'tcp-test-only', + designator: 'caf\u00e9', + hints: false, + }); + const key = makeHandoffSessionKey(unicodeLocation); + t.is( + key, + 'handoff:7b2274797065223a226f6361706e2d70656572222c227472616e73706f7274223a227463702d746573742d6f6e6c79222c2264657369676e61746f72223a22636166c3a9222c2268696e7473223a66616c73657d', + ); + t.not(key, makeHandoffSessionKey({ ...unicodeLocation, designator: 'cafe' })); +}); diff --git a/packages/ocapn/test/codecs/passable.test.js b/packages/ocapn/test/codecs/passable.test.js index a1f48eb04b..18688a835d 100644 --- a/packages/ocapn/test/codecs/passable.test.js +++ b/packages/ocapn/test/codecs/passable.test.js @@ -152,6 +152,22 @@ const table = [ t.is(details.secret, '123'); }, }, + { + name: 'sturdyRef with raw non-ASCII secret', + makeValue: testKit => + testKit.sturdyRefTracker.makeSturdyRef( + exporterLocation, + Uint8Array.of(0x00, 0x80, 0xff), + ), + customAssert: (t, actual) => { + const details = getSturdyRefDetails(actual); + if (!details) { + throw Error('SturdyRef has no details'); + } + t.deepEqual(details.location, exporterLocation); + t.deepEqual(details.secret, Uint8Array.of(0x00, 0x80, 0xff)); + }, + }, { name: 'sturdyRef in list', makeValue: testKit => diff --git a/packages/ocapn/test/codecs/snapshots/passable.test.js.md b/packages/ocapn/test/codecs/snapshots/passable.test.js.md index ede79f6492..9404bb5d29 100644 Binary files a/packages/ocapn/test/codecs/snapshots/passable.test.js.md and b/packages/ocapn/test/codecs/snapshots/passable.test.js.md differ diff --git a/packages/ocapn/test/codecs/snapshots/passable.test.js.snap b/packages/ocapn/test/codecs/snapshots/passable.test.js.snap index c618623384..5d93f5adc8 100644 Binary files a/packages/ocapn/test/codecs/snapshots/passable.test.js.snap and b/packages/ocapn/test/codecs/snapshots/passable.test.js.snap differ diff --git a/packages/ocapn/test/cryptography.test.js b/packages/ocapn/test/cryptography.test.js index 44b2445439..3d4ab74bf5 100644 --- a/packages/ocapn/test/cryptography.test.js +++ b/packages/ocapn/test/cryptography.test.js @@ -2,8 +2,10 @@ import test from '@endo/ses-ava/test.js'; +import { bytesFromImmutable } from '@endo/bytes/from-immutable.js'; import { bytesToImmutable } from '@endo/bytes/to-immutable.js'; import { bytesFromText } from '@endo/bytes/from-string.js'; +import harden from '@endo/harden'; import { makeCryptography, makeSessionId } from '../src/cryptography.js'; import { syrupCodec } from '../src/syrup/index.js'; import { @@ -13,14 +15,25 @@ import { makeHandoffReceiveSigEnvelope, } from '../src/codecs/descriptors.js'; +/** @import { OcapnLocation } from '../src/codecs/components.js' */ + const { makeOcapnKeyPair, + makeOcapnKeyPairFromPrivateKey, + signLocation, signHandoffGive, signHandoffReceive, assertHandoffGiveSignatureValid, assertHandoffReceiveSignatureValid, } = makeCryptography(syrupCodec); +/** @param {ArrayBufferLike | Uint8Array} bytes */ +const toHex = bytes => + Array.from( + bytes instanceof Uint8Array ? bytes : bytesFromImmutable(bytes), + byte => byte.toString(16).padStart(2, '0'), + ).join(''); + const makeSessionKeys = () => { const key1 = makeOcapnKeyPair(); const key2 = makeOcapnKeyPair(); @@ -117,3 +130,35 @@ test('makeOcapnKeyPair', t => { t.is(key.publicKey.bytes.byteLength, 32); t.is(key.publicKey.id.byteLength, 32); }); + +test('protocol domain constants match their wire goldens', t => { + const peerIdOne = Uint8Array.from({ length: 32 }, (_, index) => index); + const peerIdTwo = Uint8Array.from({ length: 32 }, (_, index) => 0xff - index); + t.is( + toHex(makeSessionId(peerIdOne.buffer, peerIdTwo.buffer)), + '6e862c41ed70e923d1da2ac2544f64651812b57cd3f699d461b98b5011647477', + ); + + const privateKey = Uint8Array.from({ length: 32 }, (_, index) => index + 1); + const keyPair = makeOcapnKeyPairFromPrivateKey(privateKey); + /** @type {OcapnLocation} */ + const location = harden({ + type: 'ocapn-peer', + transport: 'tcp-test-only', + designator: 'golden', + hints: false, + }); + const signature = signLocation( + location, + keyPair, + Uint8Array.of(0xde, 0xad, 0xbe, 0xef).buffer, + ); + t.is( + toHex(signature.r), + '610011d441793f1a210aa607cee4c70624a9878200cad05fb333340f53d65eb6', + ); + t.is( + toHex(signature.s), + 'dad39d603bf910537bb3d59d9df9b392c912c3d31ebf15317accef8838497000', + ); +}); diff --git a/packages/ocapn/test/sturdyref.test.js b/packages/ocapn/test/sturdyref.test.js index 84e6094d86..5e1de9c749 100644 --- a/packages/ocapn/test/sturdyref.test.js +++ b/packages/ocapn/test/sturdyref.test.js @@ -4,7 +4,12 @@ import { E } from '@endo/eventual-send'; import { Far } from '@endo/marshal'; import { passStyleOf } from '@endo/pass-style'; import { test, testWithErrorUnwrapping, makeTestClient } from './_util.js'; -import { isSturdyRef, getSturdyRefDetails } from '../src/client/sturdyrefs.js'; +import { + getSturdyRefDetails, + isSturdyRef, + makeSturdyRef, + makeSturdyRefTracker, +} from '../src/client/sturdyrefs.js'; import { ocapnPassStyleOf } from '../src/codecs/ocapn-pass-style.js'; testWithErrorUnwrapping('SturdyRef is a tagged type', async t => { @@ -106,6 +111,62 @@ testWithErrorUnwrapping( }, ); +test('SturdyRef snapshots and hardens raw-byte secrets', t => { + const secret = Uint8Array.of(0x00, 0x80, 0xff); + const sturdyRef = makeSturdyRef( + { + type: 'ocapn-peer', + transport: 'tcp-test-only', + designator: 'example.test', + hints: false, + }, + secret, + ); + + secret[1] = 0x01; + const details = getSturdyRefDetails(sturdyRef); + t.truthy(details); + if (details && typeof details.secret !== 'string') { + t.deepEqual(details.secret, Uint8Array.of(0x00, 0x80, 0xff)); + details.secret[2] = 0x02; + const freshDetails = getSturdyRefDetails(sturdyRef); + t.deepEqual(freshDetails?.secret, Uint8Array.of(0x00, 0x80, 0xff)); + t.true(Object.isFrozen(details)); + } +}); + +test('SturdyRef tracker does not retry locator failures', async t => { + let calls = 0; + const tracker = makeSturdyRefTracker({ + get: () => { + calls += 1; + throw TypeError('locator failure'); + }, + }); + + await t.throwsAsync(tracker.lookup(Uint8Array.of(0x41).buffer), { + instanceOf: TypeError, + message: 'locator failure', + }); + t.is(calls, 1); +}); + +test('SturdyRef tracker propagates byte validation failures', async t => { + let calls = 0; + const tracker = makeSturdyRefTracker({ + get: () => { + calls += 1; + return undefined; + }, + }); + const proxy = new Proxy(Uint8Array.of(0x41), {}); + + await t.throwsAsync(tracker.lookup(/** @type {any} */ (proxy)), { + instanceOf: TypeError, + }); + t.is(calls, 0); +}); + test('client.enlivenSturdyRef() returns promise for fetched value', async t => { const testObjectTable = new Map(); const testObject = Far('TestObject', { diff --git a/packages/ocapn/tsconfig.composite.json b/packages/ocapn/tsconfig.composite.json index ffd9dae10f..8449167c95 100644 --- a/packages/ocapn/tsconfig.composite.json +++ b/packages/ocapn/tsconfig.composite.json @@ -5,6 +5,9 @@ "composite": true }, "references": [ + { + "path": "../ascii/tsconfig.composite.json" + }, { "path": "../bytes/tsconfig.composite.json" }, diff --git a/yarn.lock b/yarn.lock index 626f368a20..e94b76eeee 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1399,7 +1399,7 @@ __metadata: languageName: unknown linkType: soft -"@endo/ascii@workspace:packages/ascii": +"@endo/ascii@workspace:^, @endo/ascii@workspace:packages/ascii": version: 0.0.0-use.local resolution: "@endo/ascii@workspace:packages/ascii" dependencies: @@ -2378,6 +2378,7 @@ __metadata: version: 0.0.0-use.local resolution: "@endo/goblin-chat@workspace:packages/goblin-chat" dependencies: + "@endo/ascii": "workspace:^" "@endo/base64": "workspace:^" "@endo/eventual-send": "workspace:^" "@endo/exo": "workspace:^" @@ -2824,6 +2825,7 @@ __metadata: version: 0.0.0-use.local resolution: "@endo/ocapn@workspace:packages/ocapn" dependencies: + "@endo/ascii": "workspace:^" "@endo/bytes": "workspace:^" "@endo/chacha12": "workspace:^" "@endo/eventual-send": "workspace:^"