fix: local-bridge robustness, scope-gate order, sentinel contract; packaging/docs closeout (P8-P11, P2/P3/P15)
Closes review #003 (prepublish review for v0.1.0). - P8: StdinSink::poll_shutdown parks an inflight reserve+send on a full channel (waker registered) — a stdin blast followed by EOF delivers the EOF instead of stranding it - P9: five poisoned-lock .expect() sites -> unwrap_or_else(into_inner) - P10: three thread-spawn .expect() sites -> TtyError::AllocFailed - P5: tty:open scope gate runs before carriage/cmd/backend-lookup checks (no backend-name enumeration differential for unscoped ids) - P11: recv_stdout terminates on the zero-length drained sentinel; the sentinel is no longer yielded as an item (doc was already the contract); stderr has no sentinel (doc noted) - P2: exclude AGENTS.md + docs/plans/, drop dead Cargo.lock and docs/research/ entries (package list: 42 files, 659.2KiB) - P3: AGENTS.md phase status (all five landed), ADR range 001..009 (+ alktty-native ADR-009 in the mapping), alkcall guidance corrected to v0.4.x / pin "0.4.0"; architecture README ADR-009 row + landed-phase status - P15: backend.rs doc typo; redundant tokio-stream dev-dep removed; NegotiationError::Io arm logs; set_identity failure logs; input_pump.abort() at session end; TtySessionError::Open carries the accept_bi StreamError (no io::Error flattening); borrowing deserialize in open_via_channels (no params.clone()); error_response_bytes guards an "error" key in fields; trivial inline comments promoted/removed; plan-doc test counts + doc front-matter refreshed; session tests that raced session teardown under the abort change use a GatedBackend (exit held until released) Verification: cargo test 104 lib / --all-features 147; clippy (all-targets + wasm32) -D warnings; fmt; wasm check; doc 0 warnings; publish dry-run OK.
This commit is contained in:
1 parent
7b0bac0671
commit
3baa993edb
14 files changed
+372
-105
No files matched your search
+24
-14
@@ -130,7 +130,11 @@ impl ProtocolHandler for TtyAdapter {
|
||||
|
||||
async fn handle(&self, connection: Connection, auth: &AuthContext) -> Result<(), HandlerError> {
|
||||
if let Some(identity) = auth.identity.clone() {
|
||||
let _ = connection.set_identity(identity);
|
||||
// "Already set" is benign (one identity per connection by
|
||||
// construction); anything else is a real wiring bug.
|
||||
if let Err(e) = connection.set_identity(identity) {
|
||||
debug!("tty: set_identity: {e}");
|
||||
}
|
||||
}
|
||||
loop {
|
||||
let stream = match connection.accept_bi().await {
|
||||
@@ -143,11 +147,6 @@ impl ProtocolHandler for TtyAdapter {
|
||||
let ownership = self.ownership.clone();
|
||||
let identity = auth.identity.clone();
|
||||
tokio::spawn(async move {
|
||||
// `stream` is a `BiStream` (ADR-092) — `AsyncRead + AsyncWrite
|
||||
// + Send + Unpin`. Split into halves for `drive_session`
|
||||
// (which takes separate `AsyncWrite` + `AsyncRead` args). The
|
||||
// split is the stdlib idiom for `TcpStream`-style duplex
|
||||
// streams; no per-handler wrapper.
|
||||
let (client_read, client_write) = tokio::io::split(stream);
|
||||
let _ =
|
||||
drive_session(client_write, client_read, backends, ownership, identity).await;
|
||||
@@ -265,7 +264,12 @@ pub async fn drive_session_pre_negotiated(
|
||||
/// `enforce_scope` gates the `tty:open` scope check: `true` on the direct
|
||||
/// path (the adapter is the only gate), `false` on the channels path (the
|
||||
/// registry's `AccessControl` enforced the scope at open time — see
|
||||
/// [`drive_session_pre_negotiated`]).
|
||||
/// [`drive_session_pre_negotiated`]). The scope gate runs *first* (review
|
||||
/// #003 P5): checking it after the backend lookup would let an
|
||||
/// authenticated-but-unscoped identity distinguish registered backend
|
||||
/// names (`unknown_backend`) from unregistered ones (`forbidden`) — a
|
||||
/// name-enumeration differential. Gated first, every unscoped request
|
||||
/// gets the same `forbidden` regardless of the request body.
|
||||
#[allow(clippy::type_complexity)]
|
||||
async fn validate_and_allocate<W>(
|
||||
neg_writer: NegotiationWriter<W>,
|
||||
@@ -278,6 +282,11 @@ async fn validate_and_allocate<W>(
|
||||
where
|
||||
W: AsyncWrite + Send + Unpin + 'static,
|
||||
{
|
||||
if enforce_scope && !has_scope(identity, TTY_OPEN_SCOPE) {
|
||||
send_negotiation_error(neg_writer, "forbidden", &[]).await;
|
||||
return Err(());
|
||||
}
|
||||
|
||||
if req.carriage != "raw" {
|
||||
send_negotiation_error(
|
||||
neg_writer,
|
||||
@@ -306,11 +315,6 @@ where
|
||||
}
|
||||
};
|
||||
|
||||
if enforce_scope && !has_scope(identity, TTY_OPEN_SCOPE) {
|
||||
send_negotiation_error(neg_writer, "forbidden", &[]).await;
|
||||
return Err(());
|
||||
}
|
||||
|
||||
let params = crate::backend::TtyParams::from(req);
|
||||
|
||||
if let Some(provider) = ownership {
|
||||
@@ -359,7 +363,10 @@ where
|
||||
let frame = match neg_reader.read_frame().await {
|
||||
Ok(f) => f,
|
||||
Err(NegotiationError::ConnectionClosed) => return Ok(()),
|
||||
Err(NegotiationError::Io(_)) => return Ok(()),
|
||||
Err(NegotiationError::Io(e)) => {
|
||||
debug!("tty: negotiation read io error: {e}");
|
||||
return Ok(());
|
||||
}
|
||||
Err(NegotiationError::FrameTooLarge(_)) => {
|
||||
send_negotiation_error(
|
||||
neg_writer,
|
||||
@@ -485,7 +492,10 @@ where
|
||||
}
|
||||
|
||||
drop(writer_tx);
|
||||
drop(input_pump);
|
||||
// The client→backend pump outlives the session data plane only until
|
||||
// the client disconnects; aborting it at session end is tighter — no
|
||||
// lingering task holding the backend stdin half after the exit chunk.
|
||||
input_pump.abort();
|
||||
|
||||
let _ = drainer.await;
|
||||
debug!("tty: session complete");
|
||||
|
||||
Reference in new issue
Block a user