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