Skip to main content

termwright_protocol/
marker.rs

1//! Render-commit marker.
2//!
3//! The adapter writes this OSC sequence to stdout *after* the last byte of the
4//! render belonging to revision N. It commits a frame; it never carries data.
5//!
6//! ```text
7//! OSC 8487 ; twm;<revision>;<mac> BEL
8//! ```
9//!
10//! The legacy frame-based inbox ConPTY dropped DCS, APC and OSC 8 while private
11//! OSC survived. Termwright's pinned passthrough ConPTY now forwards those
12//! families, but OSC 8487 remains the one encoding certified across every host.
13//!
14//! with `mac = base64url(HMAC-SHA256(token, "{session_id}:{revision}"))[..16]`,
15//! unpadded. The token is an opaque UTF-8 string end to end: whatever arrives
16//! in `TERMWRIGHT_TOKEN` is used as key bytes, never decoded first.
17
18use base64::engine::general_purpose::URL_SAFE_NO_PAD;
19use base64::Engine as _;
20use hmac::{Hmac, Mac};
21use sha2::Sha256;
22use subtle::ConstantTimeEq;
23
24use crate::error::Violation;
25
26/// The private OSC number carrying render-commit markers. Chosen clear of
27/// everything in use (xterm's allocations, OSC 8, 9, 99, 133, 633, 697, 777+):
28/// 84 and 87 are the ASCII codes of `T` and `W`, for termwright.
29pub const MARKER_OSC_CODE: u32 = 8487;
30
31/// The tag opening a marker payload, immediately after `OSC 8487;`. A
32/// self-identifying guard: if anything ever claims 8487, a marker still says
33/// what it is rather than being mistaken for that feature's payload.
34pub const MARKER_OSC_PREFIX: &str = "twm;";
35
36/// The terminator this implementation emits — the one ConPTY was observed to
37/// forward most reliably.
38const BEL: &str = "\x07";
39
40/// The terminator a receiver must also accept.
41const ST: &str = "\x1b\\";
42
43/// How much of the HMAC-SHA256 output the marker retains.
44pub const MARKER_MAC_BYTES: usize = 16;
45
46/// Length of the unpadded base64url MAC.
47const MARKER_MAC_CHARS: usize = 22;
48
49/// Largest revision that survives a round trip through the JavaScript
50/// reference implementation unchanged.
51pub const MAX_SAFE_INTEGER: i64 = (1i64 << 53) - 1;
52
53/// A verified marker: the revision it commits, and the MAC that proved it.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct RenderMarker {
56    /// The committed revision.
57    pub revision: i64,
58    /// The MAC exactly as it appeared on the wire.
59    pub mac: String,
60}
61
62/// Compute the marker MAC for a session and revision.
63pub fn compute_mac(token: &str, session_id: &str, revision: i64) -> String {
64    let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(token.as_bytes())
65        .expect("HMAC accepts keys of any length");
66    mac.update(format!("{session_id}:{revision}").as_bytes());
67    let digest = mac.finalize().into_bytes();
68    URL_SAFE_NO_PAD.encode(&digest[..MARKER_MAC_BYTES])
69}
70
71/// Build the full escape sequence committing `revision`.
72///
73/// # Errors
74/// Returns a [`Violation`] on an empty token or session id, or a revision that
75/// is not a positive safe integer.
76pub fn encode_marker(token: &str, session_id: &str, revision: i64) -> Result<String, Violation> {
77    if token.is_empty() {
78        return Err(Violation::new("marker-argument", "token must not be empty"));
79    }
80    if session_id.is_empty() {
81        return Err(Violation::new(
82            "marker-argument",
83            "sessionId must not be empty",
84        ));
85    }
86    if revision <= 0 || revision > MAX_SAFE_INTEGER {
87        return Err(Violation::new(
88            "marker-argument",
89            "revision must be a positive safe integer",
90        ));
91    }
92    Ok(format!(
93        "\x1b]{MARKER_OSC_CODE};{MARKER_OSC_PREFIX}{revision};{}{BEL}",
94        compute_mac(token, session_id, revision)
95    ))
96}
97
98/// Parse and verify an OSC payload — everything after `OSC 8487;`.
99///
100/// Total function: hostile payloads yield `None`, never an error to interpret.
101/// Only canonically formatted revisions are accepted, so `1` and `01` cannot
102/// both authenticate the same commit, and the MAC compare is constant time.
103///
104/// A trailing BEL or ST is tolerated: a VT parser consumes the terminator
105/// before dispatching, so a handler normally passes a payload without one,
106/// while a caller scanning raw output with a regex keeps it. Both must work.
107pub fn verify_marker_payload(payload: &str, token: &str, session_id: &str) -> Option<RenderMarker> {
108    if token.is_empty() || session_id.is_empty() {
109        return None;
110    }
111    let text = payload
112        .strip_suffix(BEL)
113        .or_else(|| payload.strip_suffix(ST))
114        .unwrap_or(payload);
115    let body = text.strip_prefix(MARKER_OSC_PREFIX)?;
116    let (revision_text, mac) = body.split_once(';')?;
117    if !canonical_revision(revision_text) || !canonical_mac(mac) {
118        return None;
119    }
120    let revision: i64 = revision_text.parse().ok()?;
121    if revision <= 0 || revision > MAX_SAFE_INTEGER {
122        return None;
123    }
124    let expected = compute_mac(token, session_id, revision);
125    if expected.as_bytes().ct_eq(mac.as_bytes()).unwrap_u8() != 1 {
126        return None;
127    }
128    Some(RenderMarker {
129        revision,
130        mac: mac.to_owned(),
131    })
132}
133
134/// Accepts `^[1-9][0-9]{0,15}$`: no sign, no leading zero, no whitespace.
135fn canonical_revision(text: &str) -> bool {
136    let bytes = text.as_bytes();
137    if bytes.is_empty() || bytes.len() > 16 || !(b'1'..=b'9').contains(&bytes[0]) {
138        return false;
139    }
140    bytes[1..].iter().all(u8::is_ascii_digit)
141}
142
143/// Accepts exactly [`MARKER_MAC_CHARS`] base64url characters.
144fn canonical_mac(mac: &str) -> bool {
145    mac.len() == MARKER_MAC_CHARS
146        && mac
147            .bytes()
148            .all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_')
149}