Skip to content

fix(native): reject typed arrays other than Uint8Array in feed - #49

Merged
ThomasK33 merged 2 commits into
mainfrom
fix/feed-typed-array-check
Oct 30, 2026
Merged

ThomasK33 merged 2 commits into
mainfrom
fix/feed-typed-array-check

Conversation

@ThomasK33

Copy link
Copy Markdown
Member

Why

The goal is to handle the items still open after the #12 follow-ups and land each on main as its own PR. This one fixes the raw addon's feed.

native/README.md says the addon's feed throws TypeError("feed typed array must be Uint8Array") for a typed array that is not a Uint8Array. It throws TypeError("feed data must be a string, Buffer, or Uint8Array") for any other value. The typed-array check in TerminalWrap::Feed never ran. The IsBuffer() branch before it is true for every ArrayBufferView in Node, because napi_is_buffer accepts any view. So a Uint16Array or Float32Array was written as raw bytes, and a DataView failed with Error: Invalid argument.

What changed

  • native/terminal.cc: I deleted the IsBuffer() branch in TerminalWrap::Feed. A Buffer is a Uint8Array, so the typed-array branch now handles it.
  • test/terminal.test.ts: new raw-addon tests. Uint16Array, Float32Array and Uint8ClampedArray throw the typed-array TypeError. A DataView throws the generic TypeError. Buffer, Uint8Array and a Buffer slice with a byte offset still write "AB".
  • native/README.md: the Verification section no longer lists the typed-array errors as untested.

Scope

Only the raw addon's feed argument check changes. src/index.ts already rejects these values before native code runs, so the public API keeps its behavior.

Blast Radius

Code that calls the raw addon directly with a non-Uint8Array typed array or a DataView now gets a TypeError. Before, it got raw bytes written or Error: Invalid argument. Package users who go through createTerminal from the package see no change.

Verification

The test commit (1727d01) fails on the unfixed code:

     × rejects a Uint16Array in the addon feed with a TypeError 5ms
     × rejects a Float32Array in the addon feed with a TypeError 1ms
     × rejects a Uint8ClampedArray in the addon feed with a TypeError 0ms
     × rejects a DataView in the addon feed with a TypeError 1ms
     ✓ writes a Buffer in the addon feed 0ms
     ✓ writes a Uint8Array in the addon feed 0ms
     ✓ writes a Buffer slice with a byte offset in the addon feed 0ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 4 ⎯⎯⎯⎯⎯⎯⎯
 FAIL  test/terminal.test.ts > GhosttyVtTerminal > rejects a Uint16Array in the addon feed with a TypeError
 FAIL  test/terminal.test.ts > GhosttyVtTerminal > rejects a Float32Array in the addon feed with a TypeError
 FAIL  test/terminal.test.ts > GhosttyVtTerminal > rejects a Uint8ClampedArray in the addon feed with a TypeError
AssertionError: expected undefined to be an instance of TypeError
 FAIL  test/terminal.test.ts > GhosttyVtTerminal > rejects a DataView in the addon feed with a TypeError
AssertionError: expected Error: Invalid argument to be an instance of TypeError
      Tests  4 failed | 3 passed | 33 skipped (40)

With the fix (fabeeaf):

 ✓ test/terminal.test.ts (40 tests | 33 skipped) 5ms
      Tests  7 passed | 33 skipped (40)

Repro script (node repro-feed.mjs after npm run build), after the fix. On main the raw Uint16Array, Float32Array and Uint8ClampedArray cases returned "ok", and DataView threw Error: Invalid argument.

raw feed(Buffer 'AB'): ok, text "AB"
raw feed(Uint8Array [65,66]): ok, text "AB"
raw feed(Uint16Array [0x4241]): TypeError: feed typed array must be Uint8Array
raw feed(Float32Array [1]): TypeError: feed typed array must be Uint8Array
raw feed(Uint8ClampedArray [65,66]): TypeError: feed typed array must be Uint8Array
raw feed(DataView(2 bytes 'AB')): TypeError: feed data must be a string, Buffer, or Uint8Array
public feed(Buffer 'AB'): ok, text "AB"
public feed(Uint8Array [65,66]): ok, text "AB"
public feed(Uint16Array [0x4241]): TypeError: feed data must be a string, Buffer, or Uint8Array
public feed(Float32Array [1]): TypeError: feed data must be a string, Buffer, or Uint8Array
public feed(Uint8ClampedArray [65,66]): TypeError: feed data must be a string, Buffer, or Uint8Array
public feed(DataView(2 bytes 'AB')): TypeError: feed data must be a string, Buffer, or Uint8Array

Gates, each exit 0:

  1. npm ci && LIBGHOSTTY_VT_PREFIX=<ReleaseFast> npm run build:native && npm run build && npm run verify: 84 tests passed.
  2. The same with the ReleaseSafe prefix: 84 tests passed.
  3. UBSan: rm -rf dist && LIBGHOSTTY_VT_PREFIX=<ReleaseFast> CXXFLAGS="-fsanitize=undefined,float-cast-overflow -fno-sanitize-recover=all" LDFLAGS="-fsanitize=undefined,float-cast-overflow" npm run build:native && npm test && npm run smoke: 84 tests passed, smoke passed.
  4. .agents/skills/verify-native-lifecycle/run.sh: OK: every result matches EXPECT.

Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: medium

The non-Uint8Array cases fail: napi_is_buffer accepts every
ArrayBufferView, so the Buffer branch takes them first.

Signed-off-by: Thomas Kosiewski <tk@coder.com>

---
_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `medium`_

Change-Id: Iae93d5917e6918dd8161be33ba7cf7ba3d8c173c
napi_is_buffer is true for every ArrayBufferView, so the IsBuffer
branch read Uint16Array, Float32Array, Uint8ClampedArray and DataView
as bytes before the Uint8Array check could run. A Buffer is a
Uint8Array, so the typed-array branch covers it.

Signed-off-by: Thomas Kosiewski <tk@coder.com>

---
_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `medium`_

Change-Id: Ib6eeb52941e0da75444187109d01e5b0192d1727
@ThomasK33

Copy link
Copy Markdown
Member Author

Verdict: PASS
Head: fabeeaf

Parent: 9f8ec06 (merge base with main). Node v26.12.1 inside npm scripts (Path unset, nodedir 26.12.1), prebuilt libghostty-vt at Ghostty 83fb956.

Scenario 1: repro script. Command: node repro-feed.mjs from the checkout root after npm run build (ReleaseFast).

  • Parent: raw Uint16Array gave ok, text "AB", raw Float32Array gave ok, text "\ufffd?", raw Uint8ClampedArray gave ok, text "AB", raw DataView gave Error: Invalid argument.
  • Head: the three raw typed arrays give TypeError: feed typed array must be Uint8Array. Raw DataView gives TypeError: feed data must be a string, Buffer, or Uint8Array.
  • diff parent vs head shows only those 4 raw lines. The raw Buffer and Uint8Array lines (ok, text "AB") and all 6 public lines are identical.

Scenario 2: byte input, raw addon and public API. A local probe ran 34 inputs through both surfaces (68 lines) and printed the visible text, the cursor, or the error. diff parent vs head shows 2 lines, both rejected typed arrays:

< raw feed(BigUint64Array [1n]): ok text="\n" cursor=0,0
< raw feed(Int8Array [65,66]): ok text="AB\n" cursor=0,2
> raw feed(BigUint64Array [1n]): TypeError: feed typed array must be Uint8Array
> raw feed(Int8Array [65,66]): TypeError: feed typed array must be Uint8Array
  • Identical at parent and head on both surfaces: Buffer.alloc(4,'ABCD'), Buffer.from (ASCII, UTF-8 with an emoji, SGR, CRLF), 4 pooled Buffer.allocUnsafe(5) (byteOffset 15072 to 15096 in a 65600-byte pool), subarray at offsets 1, 3 and 5, a Uint8Array subarray, Buffer.concat, and strings.
  • Empty Buffer.alloc(0), empty Uint8Array(0), an empty subarray(1,1) and a view over a detached buffer, each fed after "AB": text "AB\n", cursor 0,2, so no write.
  • 1 MiB Buffer of printable text at 80x24: visible text equals an independently computed wrap of the last 1856 bytes (24 rows) and equals the string feed, at parent and head. The same holds for a 1 MiB Uint8Array at byte offset 3.
  • new Uint8Array(arrayBuffer, 2, 5) next to a Uint16Array over the same buffer gives HELLO. new Uint8Array(sharedArrayBuffer, 3, 6) gives SHARED.
  • Non-views keep their error: ArrayBuffer, [65,66], 65, null, undefined and {length:2} throw TypeError: feed data must be a string, Buffer, or Uint8Array.
  • The same probe and the repro on the UBSan build at head print the same output as the ReleaseFast head.

Gates at head (each exit 0):

  • ReleaseFast: npm ci && LIBGHOSTTY_VT_PREFIX=<fast> npm run build:native && npm run build && npm run verify: 6 files, 84 passed, smoke passed.
  • ReleaseSafe (addon contains reached unreachable code: 1): same command, 84 passed, smoke passed.
  • UBSan: rm -rf dist && LIBGHOSTTY_VT_PREFIX=<fast> CXXFLAGS="-fsanitize=undefined,float-cast-overflow -fno-sanitize-recover=all" LDFLAGS="-fsanitize=undefined,float-cast-overflow" npm run build:native && npm test && npm run smoke: 84 passed, smoke passed. nm -u shows __ubsan_handle_*_abort refs in terminal.o (7), addon.o (4) and libghostty_vt_shim.o (3).
  • .agents/skills/verify-native-lifecycle/run.sh: OK: every result matches EXPECT.

Diff review (gh pr diff 49):

  • 3 files. native/terminal.cc deletes only the IsBuffer() branch. test/terminal.test.ts adds 7 raw-addon cases. native/README.md drops "the typed-array errors" from the untested list. No other change.
  • Revert test: at the head, with native/terminal.cc from the parent and the addon rebuilt, npx vitest run test/terminal.test.ts -t "addon feed" gives 4 failed | 3 passed | 33 skipped (40). The failures are the Uint16Array, Float32Array and Uint8ClampedArray cases (expected undefined to be an instance of TypeError) and DataView (expected Error: Invalid argument to be an instance of TypeError). With the head file restored: 7 passed | 33 skipped (40). The 3 "writes" cases guard Buffer input after the branch removal, so they pass at both by design.
  • native/README.md line 52 (the feed error contract) now matches what the head does.

Observation (same at parent and head, not caused by this PR): a Uint8Array from another vm realm writes through the raw addon, but public feed rejects it, because src/index.ts:assertFeedData uses instanceof Uint8Array.


Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: xhigh

@ThomasK33
ThomasK33 merged commit c54051e into main Oct 30, 2026
5 checks passed
@ThomasK33
ThomasK33 deleted the fix/feed-typed-array-check branch October 30, 2026 07:42
ThomasK33 added a commit that referenced this pull request Oct 30, 2026
## Why
Ship the `snapshot()` crash fix (#46) to npm. Every earlier version, `0.1.0-beta.2` included, crashes Node when a JS accessor on `Object.prototype` disposes the terminal while `snapshot()` builds its result.

## What changed
- `package.json` and `package-lock.json` go from `0.1.0-beta.2` to `0.1.0-beta.3`. The branch name matches the version, so `publish.yml` creates `v0.1.0-beta.3` after merge.
- The `release-changelog` workflow adds the CHANGELOG entry to this branch.
- beta.3 also ships #48 (tests only) and #49 (the raw native addon's `feed` now rejects typed arrays other than `Uint8Array`). The package exports only `createTerminal`'s module, which already rejected those values in JS, so neither gets a CHANGELOG line.
- The beta.2 `## Fixed` heading, which the changelog bot had joined to the previous bullet, is restored.

## Blast Radius
The merge publishes `0.1.0-beta.3` to npm under the `beta` dist-tag. `latest` stays on `0.1.0-beta.0`. Snapshot output is byte-identical to beta.2, as #46's verifier checked.

## Verification
- CI (linux-x64, linux-arm64, linux-x64-ubsan, macos-arm64) passes on head `3365375`. Codex code and security reviews are clean on that head.
- `prepare-release-changelog.mjs` reports `changelog-entry-exists`. `resolve-npm-publish-tag.mjs` prints `beta`. The remote has no `v0.1.0-beta.3` tag.
- An independent verifier built prebuilds, ran `verify:prebuilds` (loads `prebuilds/linux-x64`), packed, and installed into a scratch consumer: `0.1.0-beta.3` loads from `prebuilds/`, and 8 accessor cases that crash beta.2 exit 0. It passed the CHANGELOG at `da96287`.

---
_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `high`_
ThomasK33 added a commit that referenced this pull request Oct 30, 2026
## Why
The `verify-native-lifecycle` skill keeps a sync commit. At that commit, its TLA+ model describes the covered native code. The sync commit was still `7366b75` (#12). Since then, #40 to #43, #46, and #49 changed covered code. #46 promised this follow-up.

## What changed
- The sync commit moves to `c54051e`, the current main. It includes #48 and #49.
- The coverage rows now name `ghostty_terminal_set` (scrollback byte limit) in the constructor and its failure path, `Resize`'s second `ghostty_terminal_resize` call, and the `GHOSTTY_TERMINAL_DATA_MODE` read in `EncodeMouse`. Ghostty v1.4.0 (#43) brought these.
- The "no JS inside native methods" assumption now says how each method reads its arguments:
  1. `Snapshot` reads its options before `RequireTerminal`, and it builds its result with `DefineValue` and `DefineIndex`.
  2. `Feed` and `Resize` call `RequireTerminal` first. After that, they only test types and read strings, bytes, and numbers, which runs no JS.
  3. `GetVisibleText`, `FormatPlain`, and `FormatHtml` read no arguments.
  4. The assumption was false for `snapshot` until #46.
- The bullet that lists where caller JS can run now names public `feed`: `assertFeedData` runs `data instanceof Uint8Array`, which runs a Proxy's `getPrototypeOf` trap after `#assertUsable`. The native null check covers it, as it does for `snapshot`.
- `AddonEnvLifecycle.tla` changes in comments only. The `CreateFails` comment now names the `ghostty_terminal_set` failure too.

The model itself does not change. #48 changes only a test. #49 changes only `Feed`'s argument checks, so the `Use(t)` step still describes `feed`.

## Verification
- I checked each row against `native/terminal.cc` and `src/index.ts` at `c54051e`.
- `git diff 7366b75 c54051e` on the covered paths adds no new static state, `Napi::Reference`, or new place that frees `terminal_` or `mouse_encoder_`.
- `run.sh`: all 35 rows match EXPECT (`OK: every result matches EXPECT.`). The current config holds all 5 rules. Each of the 6 mutants breaks the rule it guards.

The verifier's two notes on `0ff0941` (the `feed` window and the `CreateFails` comment) are fixed in `584c561`. `run.sh` still passes all 35 rows.

Workspace 36beaed175 drafted the first version of this commit. I corrected the assumption bullet: it said that every method reads its arguments before `RequireTerminal`, but `Feed` and `Resize` do not.

---
_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant