Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ All notable changes to OriginWeave are documented in this file. The format follo

### Added

- The session-ending command stack now retains the status-reply protections from its current parent. A reply from a replacement connection is rejected while the original pending status request remains recoverable; sending the end command still does not prove that the browser session ended.
- Typed outbound WebDriver BiDi `session.end` over the bounded client WebSocket stream: it serializes only the standards-defined method with empty params, rejects invalid frame deadlines before correlation registration, retires only the just-registered id when frame preflight proves no command bytes were emitted, preserves exact command-kind correlation across ambiguous writes, and does not treat frame-write success as proof that the browser session ended.
- Regression checks now exercise fragmented browser replies, interleaved control messages, and rejected replies without losing a pending request. These checks do not establish browser readiness or release acceptance.
- The typed browser-status response stack now includes its verified command and opening-exchange prerequisites, including the release-record check that previously did not execute; parsing remains bounded and does not grant browser authority or prove operational readiness.
- Bounded RFC 6455 WebDriver BiDi opening-response validation on the exact peer-verified stream: it admits only HTTP/1.1 `101`, case-insensitive `Upgrade`/`Connection` tokens, and the client-key-correlated `Sec-WebSocket-Accept` value within monotonic time and header-size ceilings; it restores blocking mode and still does not implement WebSocket frames or grant browser/Agent authority.
Expand Down Expand Up @@ -58,6 +60,7 @@ All notable changes to OriginWeave are documented in this file. The format follo

### Changed

- Carried the verified status-response prerequisites into the session-end sender, preserving its command behavior and making the inherited release-record check execute in the existing test suite.
- Kept the `session.status` frame-failure coverage contract focused on observable correlation state, avoiding assertion-internal uncovered branches without weakening preflight retirement or ambiguous-write retention checks.
- Made the command-correlation release-record check run in the existing CI test suite, preserving its exact bounds and authority exclusions; carried the verified message-parent fixture repairs into the correlation stack.
- Carried the verified parent fixture and release-check repairs into the session-status sender without changing command or correlation behavior.
Expand Down
12 changes: 8 additions & 4 deletions crates/originweave-network/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@
//! the RFC 6455 opening exchange, provides bounded masked client writes and
//! unmasked server-frame reads, assembles bounded WebDriver BiDi text messages,
//! classifies complete local-end JSON envelopes, tracks bounded command-response
//! correlation, sends one narrowly typed `session.status` command, and admits its
//! required readiness result through one command-specific correlated parser without
//! exposing generic JSON bodies or granting browser, TLS, policy, secret, or Agent
//! authority.
//! correlation, sends narrowly typed `session.status` and `session.end` commands,
//! and admits the required readiness result through one command-specific correlated
//! parser without exposing generic JSON bodies or granting browser, TLS, policy,
//! secret, or Agent authority.

#![forbid(unsafe_code)]
#![deny(missing_docs)]
Expand All @@ -21,6 +21,7 @@ mod webdriver_bidi_command_correlation;
mod webdriver_bidi_connection;
mod webdriver_bidi_json_envelope;
mod webdriver_bidi_received_message;
mod webdriver_bidi_session_end_command;
mod webdriver_bidi_session_status_command;
mod webdriver_bidi_session_status_response;
mod webdriver_bidi_websocket_frame;
Expand Down Expand Up @@ -53,6 +54,9 @@ pub use webdriver_bidi_received_message::{
WebDriverBiDiConnectionMessageRead, WebDriverBiDiConnectionMessageReadError,
WebDriverBiDiReceivedTextMessage, WebDriverBiDiWebSocketMessageReader,
};
pub use webdriver_bidi_session_end_command::{
WebDriverBiDiSessionEndCommand, WebDriverBiDiSessionEndCommandError,
};
pub use webdriver_bidi_session_status_command::{
WebDriverBiDiSessionStatusCommand, WebDriverBiDiSessionStatusCommandError,
};
Expand Down
241 changes: 241 additions & 0 deletions crates/originweave-network/src/webdriver_bidi_session_end_command.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
use std::{error::Error, fmt, time::Duration};

use crate::{
MAX_WEBDRIVER_BIDI_JS_UINT, MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiCommandCorrelation,
WebDriverBiDiCommandCorrelationError, WebDriverBiDiCommandKind,
WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketFrameError,
WebDriverBiDiWebSocketMaskKey,
};

const SESSION_END_METHOD: &str = "session.end";

/// One bounded WebDriver BiDi `session.end` command.
///
/// The command is deliberately concrete rather than a generic JSON or arbitrary-method escape
/// hatch. It carries only a WebDriver BiDi `js-uint` correlation identifier and always serializes
/// the standards-defined empty parameter map. Successfully writing the frame does not claim that
/// the remote session ended; callers must wait for a separately validated correlated response.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct WebDriverBiDiSessionEndCommand {
command_id: u64,
}

impl WebDriverBiDiSessionEndCommand {
/// Construct one `session.end` command with a JavaScript-safe correlation identifier.
pub fn new(command_id: u64) -> Result<Self, WebDriverBiDiSessionEndCommandError> {
if command_id > MAX_WEBDRIVER_BIDI_JS_UINT {
return Err(WebDriverBiDiSessionEndCommandError::CommandIdOutOfRange {
command_id,
maximum_command_id: MAX_WEBDRIVER_BIDI_JS_UINT,
});
}
Ok(Self { command_id })
}

/// Return the exact local correlation identifier serialized for this command.
#[must_use]
pub const fn command_id(&self) -> u64 {
self.command_id
}

/// Register and write this exact command on an already established verified BiDi stream.
///
/// Locally invalid frame deadlines fail before correlation registration and before any remote
/// side effect. Correlation then registers the command before the first possible frame write.
/// A frame-owner preflight rejection that proves no write began retires this exact command
/// again. Once frame emission can have begun, a later failure leaves the identifier outstanding
/// because partial or full emission is ambiguous. A successful write also leaves the identifier
/// outstanding until a later correlated response proves completion.
pub fn send(
self,
established: WebDriverBiDiWebSocketEstablished,
correlation: &mut WebDriverBiDiCommandCorrelation,
masking_key: WebDriverBiDiWebSocketMaskKey,
frame_timeout: Duration,
) -> Result<WebDriverBiDiWebSocketEstablished, WebDriverBiDiSessionEndCommandError> {
if frame_timeout.is_zero() || frame_timeout > MAX_WEBSOCKET_FRAME_TIMEOUT {
return Err(WebDriverBiDiSessionEndCommandError::FrameWrite {
source: WebDriverBiDiWebSocketFrameError::InvalidFrameTimeout {
frame_timeout,
maximum_timeout: MAX_WEBSOCKET_FRAME_TIMEOUT,
},
});
}
correlation
.register_command_for(self.command_id, WebDriverBiDiCommandKind::SessionEnd)
.map_err(|source| WebDriverBiDiSessionEndCommandError::Correlation { source })?;
let message = self.serialized();
match established.write_text_frame(&message, masking_key, frame_timeout) {
Ok(established) => Ok(established),
Err(source) => Err(map_frame_failure(correlation, self.command_id, source)),
}
}

fn serialized(self) -> String {
format!(
"{{\"id\":{},\"method\":\"{SESSION_END_METHOD}\",\"params\":{{}}}}",
self.command_id
)
}
}

fn map_frame_failure(
correlation: &mut WebDriverBiDiCommandCorrelation,
command_id: u64,
source: WebDriverBiDiWebSocketFrameError,
) -> WebDriverBiDiSessionEndCommandError {
if matches!(
source,
WebDriverBiDiWebSocketFrameError::MalformedFrame { .. }
) {
let _retirement =
correlation.retire_command_for(command_id, WebDriverBiDiCommandKind::SessionEnd);
}
WebDriverBiDiSessionEndCommandError::FrameWrite { source }
}

/// Fail-closed errors while constructing or sending one typed `session.end` command.
#[derive(Debug)]
pub enum WebDriverBiDiSessionEndCommandError {
/// The requested command identifier is outside WebDriver BiDi's `js-uint` range.
CommandIdOutOfRange {
/// Rejected command identifier.
command_id: u64,
/// Largest JavaScript-safe identifier admitted by this boundary.
maximum_command_id: u64,
},
/// The bounded local correlation registry rejected the command before network I/O.
Correlation {
/// Exact typed correlation failure.
source: WebDriverBiDiCommandCorrelationError,
},
/// Frame preflight validation or a later write operation failed.
FrameWrite {
/// Exact typed bounded WebSocket frame validation/write failure.
source: WebDriverBiDiWebSocketFrameError,
},
}

impl fmt::Display for WebDriverBiDiSessionEndCommandError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::CommandIdOutOfRange { .. } => formatter
.write_str("WebDriver BiDi session.end command id is outside the js-uint range"),
Self::Correlation { .. } => {
formatter.write_str("WebDriver BiDi session.end command correlation was rejected")
}
Self::FrameWrite { .. } => {
formatter.write_str("WebDriver BiDi session.end command frame write failed")
}
}
}
}

impl Error for WebDriverBiDiSessionEndCommandError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::CommandIdOutOfRange { .. } => None,
Self::Correlation { source } => Some(source),
Self::FrameWrite { source } => Some(source),
}
}
}

#[cfg(test)]
mod tests {
use std::io;

use super::*;

#[test]
fn constructor_enforces_the_webdriver_bidi_js_uint_range() {
let accepted = WebDriverBiDiSessionEndCommand::new(MAX_WEBDRIVER_BIDI_JS_UINT);
assert_eq!(
accepted.ok().map(|command| command.command_id()),
Some(MAX_WEBDRIVER_BIDI_JS_UINT)
);

let rejected = WebDriverBiDiSessionEndCommand::new(MAX_WEBDRIVER_BIDI_JS_UINT + 1);
assert_eq!(
rejected.err().map(|error| error.to_string()).as_deref(),
Some("WebDriver BiDi session.end command id is outside the js-uint range")
);
}

#[test]
fn command_serialization_is_static_and_exact() {
let command = WebDriverBiDiSessionEndCommand { command_id: 42 };
assert_eq!(command.command_id(), 42);
assert_eq!(
command.serialized(),
r#"{"id":42,"method":"session.end","params":{}}"#
);
}

#[test]
fn command_errors_have_stable_messages_and_typed_sources() {
let range = WebDriverBiDiSessionEndCommandError::CommandIdOutOfRange {
command_id: MAX_WEBDRIVER_BIDI_JS_UINT + 1,
maximum_command_id: MAX_WEBDRIVER_BIDI_JS_UINT,
};
assert_eq!(
range.to_string(),
"WebDriver BiDi session.end command id is outside the js-uint range"
);
assert!(range.source().is_none());

let correlation = WebDriverBiDiSessionEndCommandError::Correlation {
source: WebDriverBiDiCommandCorrelationError::CommandAlreadyOutstanding,
};
assert_eq!(
correlation.to_string(),
"WebDriver BiDi session.end command correlation was rejected"
);
assert!(correlation.source().is_some());

let frame = WebDriverBiDiSessionEndCommandError::FrameWrite {
source: WebDriverBiDiWebSocketFrameError::FrameWriteFailed {
bytes_written: 0,
source: io::Error::other("test frame failure"),
},
};
assert_eq!(
frame.to_string(),
"WebDriver BiDi session.end command frame write failed"
);
assert!(frame.source().is_some());
}

#[test]
fn only_frame_preflight_malformed_errors_retire_registered_correlation() {
let mut correlation = WebDriverBiDiCommandCorrelation::new();
assert!(
correlation
.register_command_for(1, WebDriverBiDiCommandKind::SessionEnd)
.is_ok()
);
let preflight = WebDriverBiDiWebSocketFrameError::MalformedFrame {
reason: "test preflight rejection",
};
assert_eq!(
map_frame_failure(&mut correlation, 1, preflight).to_string(),
"WebDriver BiDi session.end command frame write failed"
);
assert_eq!(correlation.outstanding_count(), 0);

assert!(
correlation
.register_command_for(2, WebDriverBiDiCommandKind::SessionEnd)
.is_ok()
);
let ambiguous = WebDriverBiDiWebSocketFrameError::FrameWriteFailed {
bytes_written: 1,
source: io::Error::other("test ambiguous write failure"),
};
assert_eq!(
map_frame_failure(&mut correlation, 2, ambiguous).to_string(),
"WebDriver BiDi session.end command frame write failed"
);
assert_eq!(correlation.outstanding_count(), 1);
}
}
Loading
Loading