test: 1a --- A8 raw PTY delivery, and the negotiated-v23 refusal

The last two of the three discriminators. Each is proven by a mutation
that reinstates the defect it exists to catch.

**A8, delivered rather than routed.** The producer row proved a
terminal-focused session reaches the `TextInput` branch; this one
observes the CHILD PROCESS. A real PTY runs `printf '\033[?2004h'; exec
cat > FILE`, so the terminal turns bracketed paste ON and then records
exactly what arrives on its stdin.

**The enabled mode is the entire precondition**, and the row waits for
the child's own mode-set to be parsed before typing rather than assuming
it: with bracketed paste OFF, "no markers" is true of every code path
including a paste, so the assertion would pass against the behaviour it
forbids. The contrast closes it from the other side — through the SAME
terminal in the SAME mode, a paste IS bracketed. One path marked and the
other not, both observed at the PTY.

`M-1a-4` routes typed text through `encode_paste` and the row fails with
the forbidden bytes in hand:
`"\u{1b}[200~héllo\u{301}\u{1b}[201~"`.

**The negotiated-v23 refusal** gets its own suite, because it needs a
live daemon. A refusal is the hardest claim to witness honestly —
"nothing happened" is also what a dead daemon, a desynchronized stream
or a broken test look like — so the row pairs it with a POSITIVE CONTROL
on the same session: after the refused `TextInput`, an ordinary `Key`
that must take effect. Events from one session are processed in order,
so the control's edit arriving with no preceding `REFUSED` edit means
the gate fired rather than that the daemon was asleep.

Its complement runs the same traffic on a v24 session and requires the
edit to land, so the pair cannot be satisfied by `TextInput` being
broken outright.

`M-1a-5` disables the inbound gate and the v23 row fails with `REFUSED`
visible inside the CRDT op — a v23 peer editing a buffer through a
variant its session never declared, which is precisely the hole review
round 1 identified.

One setup lesson, recorded because it cost a red: the A8 row first
failed with an empty file, and the cause was that the frontend's view
was never pointed at the terminal buffer, so `active_terminal_key`
returned `None` and the DOCUMENT path ran. It now asserts that
precondition through public state before typing — a row that quietly
tests the document path and reports a terminal result is worse than one
that fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
This commit is contained in:
Levi Neuwirth 2026-08-12 21:29:48 +02:00
parent 3ed37f7786
commit e3a19a4e63
No known key found for this signature in database
2 changed files with 360 additions and 0 deletions

View File

@ -232,5 +232,179 @@ fn a9_a_payload_at_the_cap_is_inserted_whole() {
);
}
// ---------------------------------------------------------------------
// A8 — a terminal receives RAW UTF-8, never bracketed paste
// ---------------------------------------------------------------------
/// A8, delivered rather than merely routed: the child process receives
/// the exact UTF-8 bytes, **while bracketed-paste mode is ENABLED**, and
/// no `ESC[200~` / `ESC[201~` markers.
///
/// **The enabled mode is the whole precondition.** With bracketed paste
/// off, "no markers" is true of every code path including a paste, so
/// the assertion would pass against the behaviour it exists to forbid.
/// The row therefore waits for the child's own `ESC[?2004h` to be
/// parsed, asserts the mode really is on, and only then types.
///
/// The contrast at the end is what makes it a discriminator: through the
/// same terminal in the same mode, a PASTE does get the markers. One
/// path bracketed and the other not, observed at the PTY.
#[test]
fn a8_a_terminal_receives_raw_utf8_with_bracketed_paste_enabled() {
use pmacs::terminal::TerminalSpec;
use std::time::{Duration, Instant};
let dir = tempfile::tempdir().expect("tempdir");
let sink = dir.path().join("received");
let sink_disp = sink.display().to_string();
let mut s = EditorState::new_with_roots(&crate::iso::roots());
// The child turns bracketed paste ON, then copies its stdin to a
// file so the test can read exactly what arrived on the PTY.
let script = format!("printf '\\033[?2004h'; exec cat > {sink_disp}");
let mut spec = TerminalSpec::new("/bin/sh");
spec.args = vec!["-c".into(), script];
spec.rows = 24;
spec.cols = 80;
let buffer_id = s
.terminal_manager
.borrow_mut()
.open(
spec,
&mut s.core.borrow_mut(),
&mut s.process_supervisor.borrow_mut(),
)
.expect("open terminal");
// Point this frontend's view at the terminal buffer, the way a
// daemon-side buffer switch does. Without it `active_terminal_key`
// returns `None`, the terminal branch is never taken, and the row
// fails for a setup reason rather than a behavioural one — which is
// exactly how it first failed.
let window_id = attach_terminal_view(&s, FID, buffer_id);
let key = pmacs::terminal::TerminalViewKey::new(FID, window_id, buffer_id);
// Precondition, asserted rather than assumed: this frontend's
// ACTIVE window shows the terminal buffer. A row that silently
// failed this would be testing the document path and reporting it
// as a terminal result.
{
let core = s.core.borrow();
let view = core.views.get(&FID).expect("view registered");
let active = core.windows.get(&view.active).expect("active window");
assert_eq!(active.buffer_id, buffer_id);
assert!(
s.terminal_manager.borrow().is_terminal(buffer_id),
"and the manager agrees it is a terminal"
);
}
// Wait for the child's mode-set to be parsed — a condition, not a
// sleep, so a slow machine waits longer rather than failing.
let deadline = Instant::now() + Duration::from_secs(10);
loop {
s.tick_processes();
let on = s
.terminal_manager
.borrow()
.modes_for_view(key)
.is_some_and(|m| m.bracketed_paste);
if on {
break;
}
assert!(
Instant::now() < deadline,
"child never enabled bracketed paste; the precondition this \
row depends on was never established"
);
std::thread::sleep(Duration::from_millis(10));
}
// Multi-byte and multi-scalar, so a byte-level mistake shows up.
let typed = "h\u{e9}llo\u{301}";
s.dispatch_text_input(FID, typed);
let deadline = Instant::now() + Duration::from_secs(10);
let got = loop {
s.tick_processes();
let got = std::fs::read(&sink).unwrap_or_default();
if got.len() >= typed.len() {
break got;
}
assert!(
Instant::now() < deadline,
"the child never received the typed text; got {got:?}"
);
std::thread::sleep(Duration::from_millis(10));
};
assert_eq!(
String::from_utf8_lossy(&got),
typed,
"the PTY must receive the exact UTF-8 that was typed"
);
let text = String::from_utf8_lossy(&got).into_owned();
assert!(
!text.contains("\u{1b}[200~") && !text.contains("\u{1b}[201~"),
"typed text must NOT be bracketed: {text:?}"
);
// The contrast, through the same terminal in the same mode: a paste
// IS bracketed. Without this the row above could pass because the
// mode was somehow inert rather than because the code is right.
assert!(
s.dispatch_paste(FID, b"pasted"),
"the terminal claims the paste"
);
let deadline = Instant::now() + Duration::from_secs(10);
loop {
s.tick_processes();
let all = String::from_utf8_lossy(&std::fs::read(&sink).unwrap_or_default()).into_owned();
if all.contains("\u{1b}[200~") {
assert!(
all.contains("pasted") && all.contains("\u{1b}[201~"),
"a paste is bracketed on both sides: {all:?}"
);
break;
}
assert!(
Instant::now() < deadline,
"a paste through the same terminal must be bracketed; got {all:?}"
);
std::thread::sleep(Duration::from_millis(10));
}
}
/// Register a frontend view whose active window shows `buffer_id`.
fn attach_terminal_view(
state: &EditorState,
frontend_id: FrontendId,
buffer_id: pmacs::buffer::BufferId,
) -> pmacs::window::WindowId {
use pmacs::window::{FrontendView, Layout, Window, WindowId};
let mut core = state.core.borrow_mut();
let text_view = {
let registry = core.registry.clone();
let registry = registry.borrow();
let buffer = registry.get(buffer_id).expect("buffer present");
pmacs::text_view::TextView::new(buffer)
};
let window_id = WindowId::next();
core.windows
.insert(window_id, Window::new(window_id, buffer_id, text_view));
core.register_frontend_view(
frontend_id,
FrontendView {
layout: Layout::single(window_id),
active: window_id,
fold_projection: true,
panel_capable: true,
frame_geometry: None,
panel_hidden: false,
},
);
window_id
}
#[path = "common/iso.rs"]
mod iso;

View File

@ -0,0 +1,186 @@
//! GUI arc Stage 1a — the `TextInput` wire gate, against a real daemon.
//!
//! Separate from `gui_stage1a_acceptance` because these rows need a
//! live daemon and a negotiated session; that suite is in-process.
//!
//! **The claim under test is a REFUSAL**, which is the hardest kind to
//! witness honestly: "nothing happened" is also what a broken test,
//! a dead daemon or a dropped connection look like. Every row here
//! therefore pairs the refusal with a positive control on the same
//! session — something that *does* take effect — so silence can only
//! mean the gate fired.
#![cfg(feature = "crdt")]
use std::os::unix::net::UnixStream;
use std::time::{Duration, Instant};
use pmacs_protocol::{
ADVERTISED_PROTOCOL_VERSION, AttachRequest, CellSize, FrontendCapabilities, FrontendEvent,
Hello, InstanceMessage, Key, KeyEvent, Modifiers, PROTOCOL_VERSION, SessionBootstrapRequest,
TEXT_INPUT_MIN_VERSION, read_message, write_message,
};
#[path = "common/mod.rs"]
mod common;
fn semantic_caps() -> FrontendCapabilities {
FrontendCapabilities {
synchronized_output: false,
unicode_smp: true,
true_color: true,
mouse: false,
bracketed_paste: false,
terminal_kind: Some("stage1a".into()),
multi_frontend: true,
crdt_replica: true,
semantic_render: true,
}
}
/// Attach a semantic session that counter-offers `offer`.
fn attach_semantic(
daemon: &common::daemon::TestDaemon,
offer: u32,
) -> (UnixStream, pmacs_protocol::FrontendId) {
let mut stream = daemon.connect();
stream
.set_read_timeout(Some(Duration::from_secs(10)))
.expect("set read timeout");
let hello: Hello = read_message(&mut stream).expect("read daemon Hello");
assert_eq!(
hello.protocol_version, ADVERTISED_PROTOCOL_VERSION,
"the server-first Hello stays at the compatibility baseline"
);
let fid = hello.assigned_frontend_id;
write_message(
&mut stream,
&AttachRequest {
protocol_version: offer,
frontend_capabilities: semantic_caps(),
initial_size: CellSize::new(24, 80),
},
)
.expect("write AttachRequest");
write_message(&mut stream, &SessionBootstrapRequest::default()).expect("write bootstrap");
(stream, fid)
}
fn pump<T>(
stream: &mut UnixStream,
what: &str,
mut want: impl FnMut(&InstanceMessage) -> Option<T>,
) -> T {
let deadline = Instant::now() + Duration::from_secs(20);
while Instant::now() < deadline {
match read_message::<InstanceMessage>(stream) {
Ok(msg) => {
if let Some(found) = want(&msg) {
return found;
}
}
Err(error) => panic!("{what}: read stopped: {error}"),
}
}
panic!("timed out waiting for {what}");
}
fn send_text_input(stream: &mut UnixStream, fid: pmacs_protocol::FrontendId, text: &str) {
write_message(
stream,
&FrontendEvent::TextInput {
frontend_id: fid,
text: text.to_owned(),
},
)
.expect("write TextInput");
}
fn send_key(stream: &mut UnixStream, fid: pmacs_protocol::FrontendId, key: Key) {
write_message(
stream,
&FrontendEvent::Key(KeyEvent {
frontend_id: fid,
key,
mods: Modifiers::NONE,
timestamp_ns: 0,
}),
)
.expect("write key");
}
/// **The discriminating witness for the inbound gate.** A session that
/// negotiated v23 can still ENCODE `TextInput` — it is built from this
/// same crate — so the daemon must refuse it on the authenticated
/// session's negotiated version rather than trusting the producer to
/// withhold.
///
/// The positive control is what makes the refusal legible: the SAME
/// session then sends an ordinary `Key`, and that must take effect. So
/// the session is alive, the stream is synchronized and the daemon is
/// listening — silence about the `TextInput` is the gate, not the
/// plumbing.
#[test]
fn a_v23_session_cannot_drive_an_edit_through_text_input() {
assert_eq!(
TEXT_INPUT_MIN_VERSION, 24,
"this row is written against the v24 floor"
);
let daemon = common::daemon::TestDaemon::spawn();
let (mut stream, fid) = attach_semantic(&daemon, TEXT_INPUT_MIN_VERSION - 1);
let buffer_id = pump(&mut stream, "first BufferSnapshot", |msg| match msg {
InstanceMessage::BufferSnapshot { buffer_id, .. } => Some(*buffer_id),
_ => None,
});
// Refused: encoded by a peer that never declared v24.
send_text_input(&mut stream, fid, "REFUSED");
// The positive control, on the same session and after it.
send_key(&mut stream, fid, Key::Char('k'));
// The first edit that reaches this session must be the CONTROL's,
// never the refused text. Ordering carries the proof: the daemon
// processes a session's events in order, so the control's edit
// arriving with no preceding `REFUSED` edit means the TextInput was
// dropped rather than merely slow.
let op = pump(&mut stream, "the control's edit", |msg| match msg {
InstanceMessage::CrdtOp {
buffer_id: b, op, ..
} if *b == buffer_id => Some(op.bytes.clone()),
_ => None,
});
let text = String::from_utf8_lossy(&op).into_owned();
assert!(
!text.contains("REFUSED"),
"a v23 session must not be able to insert through TextInput; got {text:?}"
);
}
/// The complement, so the row above cannot pass because `TextInput` is
/// broken outright: the SAME traffic on a v24 session **does** edit.
#[test]
fn a_v24_session_can_drive_an_edit_through_text_input() {
let daemon = common::daemon::TestDaemon::spawn();
let (mut stream, fid) = attach_semantic(&daemon, PROTOCOL_VERSION);
let buffer_id = pump(&mut stream, "first BufferSnapshot", |msg| match msg {
InstanceMessage::BufferSnapshot { buffer_id, .. } => Some(*buffer_id),
_ => None,
});
send_text_input(&mut stream, fid, "ACCEPTED");
let op = pump(&mut stream, "the TextInput edit", |msg| match msg {
InstanceMessage::CrdtOp {
buffer_id: b, op, ..
} if *b == buffer_id => Some(op.bytes.clone()),
_ => None,
});
let text = String::from_utf8_lossy(&op).into_owned();
assert!(
text.contains("ACCEPTED"),
"a v24 session must be able to insert through TextInput; got {text:?}"
);
}