use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use hmac::{Hmac, Mac};
use sha2::Sha256;
use subtle::ConstantTimeEq;
use crate::error::Violation;
pub const MARKER_OSC_CODE: u32 = 8487;
pub const MARKER_OSC_PREFIX: &str = "twm;";
const BEL: &str = "\x07";
const ST: &str = "\x1b\\";
pub const MARKER_MAC_BYTES: usize = 16;
const MARKER_MAC_CHARS: usize = 22;
pub const MAX_SAFE_INTEGER: i64 = (1i64 << 53) - 1;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenderMarker {
pub revision: i64,
pub mac: String,
}
pub fn compute_mac(token: &str, session_id: &str, revision: i64) -> String {
let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(token.as_bytes())
.expect("HMAC accepts keys of any length");
mac.update(format!("{session_id}:{revision}").as_bytes());
let digest = mac.finalize().into_bytes();
URL_SAFE_NO_PAD.encode(&digest[..MARKER_MAC_BYTES])
}
pub fn encode_marker(token: &str, session_id: &str, revision: i64) -> Result<String, Violation> {
if token.is_empty() {
return Err(Violation::new("marker-argument", "token must not be empty"));
}
if session_id.is_empty() {
return Err(Violation::new(
"marker-argument",
"sessionId must not be empty",
));
}
if revision <= 0 || revision > MAX_SAFE_INTEGER {
return Err(Violation::new(
"marker-argument",
"revision must be a positive safe integer",
));
}
Ok(format!(
"\x1b]{MARKER_OSC_CODE};{MARKER_OSC_PREFIX}{revision};{}{BEL}",
compute_mac(token, session_id, revision)
))
}
pub fn verify_marker_payload(payload: &str, token: &str, session_id: &str) -> Option<RenderMarker> {
if token.is_empty() || session_id.is_empty() {
return None;
}
let text = payload
.strip_suffix(BEL)
.or_else(|| payload.strip_suffix(ST))
.unwrap_or(payload);
let body = text.strip_prefix(MARKER_OSC_PREFIX)?;
let (revision_text, mac) = body.split_once(';')?;
if !canonical_revision(revision_text) || !canonical_mac(mac) {
return None;
}
let revision: i64 = revision_text.parse().ok()?;
if revision <= 0 || revision > MAX_SAFE_INTEGER {
return None;
}
let expected = compute_mac(token, session_id, revision);
if expected.as_bytes().ct_eq(mac.as_bytes()).unwrap_u8() != 1 {
return None;
}
Some(RenderMarker {
revision,
mac: mac.to_owned(),
})
}
fn canonical_revision(text: &str) -> bool {
let bytes = text.as_bytes();
if bytes.is_empty() || bytes.len() > 16 || !(b'1'..=b'9').contains(&bytes[0]) {
return false;
}
bytes[1..].iter().all(u8::is_ascii_digit)
}
fn canonical_mac(mac: &str) -> bool {
mac.len() == MARKER_MAC_CHARS
&& mac
.bytes()
.all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_')
}