235 lines
7.7 KiB
Rust
235 lines
7.7 KiB
Rust
use base64::{
|
|
Engine,
|
|
alphabet::STANDARD,
|
|
engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig},
|
|
};
|
|
|
|
/// What a program says it is doing, OSC 7501.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum State {
|
|
Idle,
|
|
Working,
|
|
Done,
|
|
Blocked,
|
|
Error,
|
|
}
|
|
|
|
/// What a blocked program waits for.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum Kind {
|
|
Permission,
|
|
Question,
|
|
Auth,
|
|
}
|
|
|
|
/// A record, as the last report for its id left it.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct Record {
|
|
pub state: State,
|
|
pub kind: Option<Kind>,
|
|
pub progress: Option<u8>,
|
|
pub app: Option<String>,
|
|
pub title: Option<String>,
|
|
pub msg: Option<String>,
|
|
}
|
|
|
|
/// An OSC 7501 sequence, checked whole; an id is empty for the root record.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub enum Status {
|
|
Report(String, Record),
|
|
/// Drops the record at the id and those under it; with no id, all.
|
|
Clear(Option<String>),
|
|
/// Asks which states and kinds the terminal takes.
|
|
Query,
|
|
}
|
|
|
|
/// The answer to the query: every state and kind this terminal takes.
|
|
pub const REPLY: &str =
|
|
"\x1b]7501;?:states=idle,working,done,blocked,error:kinds=permission,question,auth\x1b\\";
|
|
|
|
/// The spec's limits: a whole sequence, a key, an app, an id, its segments and levels.
|
|
const SEQUENCE: usize = 4096;
|
|
const KEY: usize = 16;
|
|
const APP: usize = 32;
|
|
const ID: usize = 128;
|
|
const SEGMENT: usize = 32;
|
|
const LEVELS: usize = 8;
|
|
|
|
/// Records a terminal keeps; past them, the one updated longest ago goes.
|
|
pub const RECORDS: usize = 256;
|
|
|
|
/// Standard base64, its padding optional.
|
|
const BASE64: GeneralPurpose = GeneralPurpose::new(
|
|
&STANDARD,
|
|
GeneralPurposeConfig::new().with_decode_padding_mode(DecodePaddingMode::Indifferent),
|
|
);
|
|
|
|
/// The sequence in an OSC 7501 body past `7501;`, `length` bytes from OSC through ST; none for one the spec drops, logged with why.
|
|
pub fn read(body: &[u8], length: usize) -> Option<Status> {
|
|
parse(body, length)
|
|
.inspect_err(|why| log::warn!("pty: OSC 7501 dropped: {why}"))
|
|
.ok()
|
|
}
|
|
|
|
/// A value's bytes, as the spec allows them.
|
|
fn valued(byte: u8) -> bool {
|
|
byte.is_ascii_alphanumeric() || b"_.,+/=-".contains(&byte)
|
|
}
|
|
|
|
/// An id's or an app's bytes.
|
|
fn named(byte: u8) -> bool {
|
|
byte.is_ascii_alphanumeric() || b"_.+-".contains(&byte)
|
|
}
|
|
|
|
fn parse(body: &[u8], length: usize) -> Result<Status, String> {
|
|
if length > SEQUENCE {
|
|
return Err(format!("{length} bytes, over {SEQUENCE}"));
|
|
}
|
|
if body.trim_ascii() == b"?" {
|
|
return Ok(Status::Query);
|
|
}
|
|
// Every pair is checked before the last of each key wins.
|
|
let (mut state, mut id, mut kind, mut progress, mut app, mut title, mut msg) =
|
|
(None, None, None, None, None, None, None);
|
|
for pair in body.split(|&byte| byte == b':') {
|
|
// A malformed pair is skipped: no `=`, no key, or a byte outside the set.
|
|
let Some(at) = pair.iter().position(|&byte| byte == b'=') else {
|
|
continue;
|
|
};
|
|
let (key, value) = (pair[..at].trim_ascii(), pair[at + 1..].trim_ascii());
|
|
if key.is_empty()
|
|
|| !key.iter().all(u8::is_ascii_lowercase)
|
|
|| !value.iter().all(|&byte| valued(byte))
|
|
{
|
|
continue;
|
|
}
|
|
if key.len() > KEY {
|
|
return Err(format!("a key over {KEY} bytes"));
|
|
}
|
|
let value = String::from_utf8(value.to_vec()).expect("ASCII");
|
|
match key {
|
|
b"state" => state = Some(value),
|
|
b"id" => id = Some(self::id(&value)?),
|
|
b"kind" => kind = Some(value),
|
|
b"progress" => progress = Some(value),
|
|
b"app" if value.len() > APP => return Err(format!("an app over {APP} bytes")),
|
|
b"app" => app = Some(value),
|
|
b"title" => title = Some(text(&value, "title", 256, 192)?),
|
|
b"msg" => msg = Some(text(&value, "msg", 2732, 2048)?),
|
|
_ => {}
|
|
}
|
|
}
|
|
let state = match state.as_deref().ok_or("no state")? {
|
|
"idle" => State::Idle,
|
|
"working" => State::Working,
|
|
"done" => State::Done,
|
|
"blocked" => State::Blocked,
|
|
"error" => State::Error,
|
|
"clear" => return Ok(Status::Clear(id)),
|
|
other => return Err(format!("state {other} is not the spec's")),
|
|
};
|
|
let kind = match (state, kind.as_deref()) {
|
|
(State::Blocked, Some("permission")) => Some(Kind::Permission),
|
|
(State::Blocked, Some("question")) => Some(Kind::Question),
|
|
(State::Blocked, Some("auth")) => Some(Kind::Auth),
|
|
_ => None,
|
|
};
|
|
let progress = match state {
|
|
State::Working | State::Blocked => progress
|
|
.filter(|value| value.bytes().all(|byte| byte.is_ascii_digit()))
|
|
.and_then(|value| value.parse::<u8>().ok())
|
|
.filter(|&value| value <= 100),
|
|
_ => None,
|
|
};
|
|
let app = app.filter(|app| !app.is_empty() && app.bytes().all(named));
|
|
let record = Record {
|
|
state,
|
|
kind,
|
|
progress,
|
|
app,
|
|
title,
|
|
msg,
|
|
};
|
|
Ok(Status::Report(id.unwrap_or_default(), record))
|
|
}
|
|
|
|
/// An id that keeps to the spec's grammar and limits.
|
|
fn id(value: &str) -> Result<String, String> {
|
|
let segments: Vec<&str> = value.split('/').collect();
|
|
let fits = value.len() <= ID
|
|
&& segments.len() <= LEVELS
|
|
&& segments
|
|
.iter()
|
|
.all(|segment| (1..=SEGMENT).contains(&segment.len()) && segment.bytes().all(named));
|
|
if fits {
|
|
Ok(value.to_string())
|
|
} else {
|
|
Err(format!("id {value} is not the spec's"))
|
|
}
|
|
}
|
|
|
|
/// The text a base64 value names, one line, within `encoded` and `decoded` bytes.
|
|
fn text(value: &str, key: &str, encoded: usize, decoded: usize) -> Result<String, String> {
|
|
if value.len() > encoded {
|
|
return Err(format!("a {key} over {encoded} bytes"));
|
|
}
|
|
let bytes = BASE64
|
|
.decode(value)
|
|
.map_err(|error| format!("{key} is no base64: {error}"))?;
|
|
if bytes.len() > decoded {
|
|
return Err(format!("a {key} over {decoded} bytes decoded"));
|
|
}
|
|
let text = String::from_utf8(bytes).map_err(|_| format!("{key} is not UTF-8"))?;
|
|
if text.chars().any(char::is_control) {
|
|
return Err(format!("{key} holds a control character"));
|
|
}
|
|
Ok(text)
|
|
}
|
|
|
|
/// The records one terminal keeps, by id, each with when it was last reported.
|
|
#[derive(Default)]
|
|
pub struct Records {
|
|
list: Vec<(String, Record, u64)>,
|
|
clock: u64,
|
|
}
|
|
|
|
impl Records {
|
|
/// Replaces the record at `id` whole.
|
|
pub fn report(&mut self, id: String, record: Record) {
|
|
self.clock += 1;
|
|
if let Some(entry) = self.list.iter_mut().find(|(at, ..)| *at == id) {
|
|
*entry = (id, record, self.clock);
|
|
return;
|
|
}
|
|
if self.list.len() == RECORDS {
|
|
let oldest = (0..self.list.len())
|
|
.min_by_key(|&at| self.list[at].2)
|
|
.expect("records");
|
|
let (gone, ..) = self.list.remove(oldest);
|
|
log::debug!("status: record {gone:?} goes, the one updated longest ago");
|
|
}
|
|
self.list.push((id, record, self.clock));
|
|
}
|
|
|
|
/// Drops the record at `id` and every one under it; with none, all.
|
|
pub fn clear(&mut self, id: Option<&str>) {
|
|
match id {
|
|
None => self.list.clear(),
|
|
Some(id) => {
|
|
let under = format!("{id}/");
|
|
self.list
|
|
.retain(|(at, ..)| at != id && !at.starts_with(&under));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A new prompt: working, blocked and idle records go, done and error stay.
|
|
pub fn prompt(&mut self) {
|
|
self.list
|
|
.retain(|(_, record, _)| matches!(record.state, State::Done | State::Error));
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|