Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 19 additions & 11 deletions .changeset/add-endo-ascii.md
Original file line number Diff line number Diff line change
@@ -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`.
28 changes: 28 additions & 0 deletions .changeset/ocapn-adopt-ascii.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 16 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions packages/ascii/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 24 additions & 10 deletions packages/ascii/README.md
Original file line number Diff line number Diff line change
@@ -1,26 +1,40 @@
# @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.

## 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.
1 change: 1 addition & 0 deletions packages/ascii/decode.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { decodeAscii } from './src/decode.js';
1 change: 1 addition & 0 deletions packages/ascii/index.js
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { encodeAscii } from './src/encode.js';
export { decodeAscii } from './src/decode.js';
5 changes: 4 additions & 1 deletion packages/ascii/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -23,6 +25,7 @@
"exports": {
".": "./index.js",
"./encode.js": "./encode.js",
"./decode.js": "./decode.js",
"./src/*": {
"test-endo-ascii": "./src/*"
},
Expand Down
96 changes: 96 additions & 0 deletions packages/ascii/src/decode.js
Original file line number Diff line number Diff line change
@@ -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 = '<unknown>') => {
/** @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);
Loading
Loading