1use std::io::{self, Write};
14
15use super::clipboard::base64_encode;
16
17pub const REQUEST_PROGRAM_STATUS: &[u8] = b"\x1b]7501;?\x1b\\";
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
35pub struct ProgramStatusState<'a>(&'a str);
36
37impl<'a> ProgramStatusState<'a> {
38 pub const IDLE: Self = Self("idle");
40 pub const WORKING: Self = Self("working");
42 pub const DONE: Self = Self("done");
44 pub const BLOCKED: Self = Self("blocked");
46 pub const ERROR: Self = Self("error");
48 pub const CLEAR: Self = Self("clear");
50
51 pub const fn new(name: &'a str) -> Self {
56 Self(name)
57 }
58
59 pub const fn as_str(self) -> &'a str {
61 self.0
62 }
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
71pub struct ProgramStatusKind<'a>(&'a str);
72
73impl<'a> ProgramStatusKind<'a> {
74 pub const PERMISSION: Self = Self("permission");
76 pub const QUESTION: Self = Self("question");
78 pub const AUTH: Self = Self("auth");
80
81 pub const fn new(name: &'a str) -> Self {
86 Self(name)
87 }
88
89 pub const fn as_str(self) -> &'a str {
91 self.0
92 }
93}
94
95#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct ProgramStatus<'a> {
103 pub state: ProgramStatusState<'a>,
105 pub id: Option<&'a str>,
108 pub kind: Option<ProgramStatusKind<'a>>,
110 pub progress: Option<u8>,
113 pub app: Option<&'a str>,
115 pub title: Option<&'a str>,
118 pub message: Option<&'a str>,
121}
122
123impl<'a> ProgramStatus<'a> {
124 pub fn new(state: ProgramStatusState<'a>) -> Self {
126 Self {
127 state,
128 id: None,
129 kind: None,
130 progress: None,
131 app: None,
132 title: None,
133 message: None,
134 }
135 }
136}
137
138pub(crate) fn valid_value(value: &str) -> bool {
139 value
140 .bytes()
141 .all(|b| b.is_ascii_alphanumeric() || b"_.,+/=-".contains(&b))
142}
143
144fn valid_segment(value: &str) -> bool {
145 (1..=32).contains(&value.len())
146 && value
147 .bytes()
148 .all(|b| b.is_ascii_alphanumeric() || b"_.+-".contains(&b))
149}
150
151fn validate_text(value: Option<&str>, limit: usize, field: &str) -> io::Result<()> {
152 if value.is_some_and(|s| s.len() > limit || s.chars().any(char::is_control)) {
153 return Err(io::Error::new(
154 io::ErrorKind::InvalidInput,
155 format!(
156 "program status {field} exceeds {limit} UTF-8 bytes or contains a control character"
157 ),
158 ));
159 }
160 Ok(())
161}
162
163pub fn write_program_status<W: Write>(w: &mut W, report: &ProgramStatus<'_>) -> io::Result<()> {
188 for (field, value) in [
189 ("state", Some(report.state.as_str())),
190 ("kind", report.kind.map(ProgramStatusKind::as_str)),
191 ] {
192 if value.is_some_and(|name| name.is_empty() || name.len() > 4096 || !valid_value(name)) {
193 return Err(io::Error::new(
194 io::ErrorKind::InvalidInput,
195 format!("invalid program status {field} name"),
196 ));
197 }
198 }
199 if report.id.is_some_and(|id| {
200 id.len() > 128 || id.split('/').count() > 8 || !id.split('/').all(valid_segment)
201 }) {
202 return Err(io::Error::new(
203 io::ErrorKind::InvalidInput,
204 "invalid program status id",
205 ));
206 }
207 if report.app.is_some_and(|app| !valid_segment(app)) {
208 return Err(io::Error::new(
209 io::ErrorKind::InvalidInput,
210 "invalid program status app",
211 ));
212 }
213 if report.progress.is_some_and(|progress| progress > 100) {
214 return Err(io::Error::new(
215 io::ErrorKind::InvalidInput,
216 "program status progress exceeds 100",
217 ));
218 }
219 validate_text(report.title, 192, "title")?;
220 validate_text(report.message, 2048, "message")?;
221
222 let mut sequence = String::from("\x1b]7501;state=");
223 sequence.push_str(report.state.as_str());
224 if let Some(id) = report.id {
225 sequence.push_str(":id=");
226 sequence.push_str(id);
227 }
228 if let Some(kind) = report.kind {
229 sequence.push_str(":kind=");
230 sequence.push_str(kind.as_str());
231 }
232 if let Some(progress) = report.progress {
233 sequence.push_str(":progress=");
234 sequence.push_str(&progress.to_string());
235 }
236 if let Some(app) = report.app {
237 sequence.push_str(":app=");
238 sequence.push_str(app);
239 }
240 if let Some(title) = report.title {
241 sequence.push_str(":title=");
242 sequence.push_str(&base64_encode(title.as_bytes()));
243 }
244 if let Some(message) = report.message {
245 sequence.push_str(":msg=");
246 sequence.push_str(&base64_encode(message.as_bytes()));
247 }
248 sequence.push_str("\x1b\\");
249 if sequence.len() > 4096 {
250 return Err(io::Error::new(
251 io::ErrorKind::InvalidInput,
252 "program status sequence exceeds 4096 bytes",
253 ));
254 }
255 w.write_all(sequence.as_bytes())
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261
262 #[test]
263 fn program_status_names_have_value_equality() {
264 use std::collections::HashSet;
265
266 for state in [
267 ProgramStatusState::IDLE,
268 ProgramStatusState::WORKING,
269 ProgramStatusState::DONE,
270 ProgramStatusState::BLOCKED,
271 ProgramStatusState::ERROR,
272 ProgramStatusState::CLEAR,
273 ] {
274 let name = state.as_str().to_owned();
275 let constructed = ProgramStatusState::new(&name);
276 assert_eq!(state, constructed);
277 assert_eq!(HashSet::from([state, constructed]).len(), 1);
278 }
279 for kind in [
280 ProgramStatusKind::PERMISSION,
281 ProgramStatusKind::QUESTION,
282 ProgramStatusKind::AUTH,
283 ] {
284 let name = kind.as_str().to_owned();
285 let constructed = ProgramStatusKind::new(&name);
286 assert_eq!(kind, constructed);
287 assert_eq!(HashSet::from([kind, constructed]).len(), 1);
288 }
289 assert_ne!(
290 ProgramStatusState::new("paused"),
291 ProgramStatusState::WORKING
292 );
293 assert_ne!(ProgramStatusKind::new("confirm"), ProgramStatusKind::AUTH);
294 }
295
296 #[test]
297 fn program_status_states_and_clear_records() {
298 for (state, value) in [
299 (ProgramStatusState::IDLE, "idle"),
300 (ProgramStatusState::WORKING, "working"),
301 (ProgramStatusState::DONE, "done"),
302 (ProgramStatusState::BLOCKED, "blocked"),
303 (ProgramStatusState::ERROR, "error"),
304 (ProgramStatusState::CLEAR, "clear"),
305 ] {
306 let mut report = ProgramStatus::new(state);
307 for id in [None, Some("build/test")] {
308 report.id = id;
309 let mut bytes = Vec::new();
310 write_program_status(&mut bytes, &report).unwrap();
311 let suffix = if id.is_some() { ":id=build/test" } else { "" };
312 assert_eq!(
313 bytes,
314 format!("\x1b]7501;state={value}{suffix}\x1b\\").as_bytes()
315 );
316 }
317 }
318 }
319
320 #[test]
321 fn program_status_encodes_all_fields_and_blocked_kinds() {
322 for (kind, value) in [
323 (ProgramStatusKind::PERMISSION, "permission"),
324 (ProgramStatusKind::QUESTION, "question"),
325 (ProgramStatusKind::AUTH, "auth"),
326 ] {
327 let report = ProgramStatus {
328 state: ProgramStatusState::BLOCKED,
329 id: Some("build/test"),
330 kind: Some(kind),
331 progress: Some(42),
332 app: Some("cargo"),
333 title: Some("Test"),
334 message: Some("Ready \u{2713}: yes?"),
335 };
336 let original = report.clone();
337 let mut bytes = Vec::new();
338 write_program_status(&mut bytes, &report).unwrap();
339 assert_eq!(
340 bytes,
341 format!("\x1b]7501;state=blocked:id=build/test:kind={value}:progress=42:app=cargo:title=VGVzdA==:msg=UmVhZHkg4pyTOiB5ZXM/\x1b\\").as_bytes(),
342 );
343 assert_eq!(report, original);
344 }
345 }
346
347 fn invalid(report: &ProgramStatus<'_>) {
348 let mut bytes = Vec::new();
349 let error = write_program_status(&mut bytes, report).unwrap_err();
350 assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
351 assert!(bytes.is_empty(), "invalid reports must emit nothing");
352 }
353
354 #[test]
355 fn program_status_accepts_borrowed_extension_names() {
356 let state_name = String::from("paused");
357 let kind_name = String::from("confirm");
358 let mut report = ProgramStatus::new(ProgramStatusState::new(&state_name));
359 let mut bytes = Vec::new();
360 write_program_status(&mut bytes, &report).unwrap();
361 assert_eq!(bytes, b"\x1b]7501;state=paused\x1b\\");
362 assert_eq!(report.state.as_str(), state_name);
363
364 report.state = ProgramStatusState::BLOCKED;
365 report.kind = Some(ProgramStatusKind::new(&kind_name));
366 let original = report.clone();
367 bytes.clear();
368 write_program_status(&mut bytes, &report).unwrap();
369 assert_eq!(bytes, b"\x1b]7501;state=blocked:kind=confirm\x1b\\");
370 assert_eq!(report.kind.unwrap().as_str(), kind_name);
371 assert_eq!(report, original);
372
373 report.state = ProgramStatusState::new("AZaz09_.,+/=-");
374 report.kind = Some(ProgramStatusKind::new("AZaz09_.,+/=-"));
375 bytes.clear();
376 write_program_status(&mut bytes, &report).unwrap();
377 assert_eq!(
378 bytes,
379 b"\x1b]7501;state=AZaz09_.,+/=-:kind=AZaz09_.,+/=-\x1b\\"
380 );
381 }
382
383 #[test]
384 fn program_status_preserves_commas_in_report_values() {
385 for name in [",", ",paused", "paused,", "paused,done"] {
386 let mut report = ProgramStatus::new(ProgramStatusState::new(name));
387 let mut bytes = Vec::new();
388 write_program_status(&mut bytes, &report).unwrap();
389 assert_eq!(bytes, format!("\x1b]7501;state={name}\x1b\\").as_bytes());
390
391 report.state = ProgramStatusState::BLOCKED;
392 report.kind = Some(ProgramStatusKind::new(name));
393 bytes.clear();
394 write_program_status(&mut bytes, &report).unwrap();
395 assert_eq!(
396 bytes,
397 format!("\x1b]7501;state=blocked:kind={name}\x1b\\").as_bytes()
398 );
399 }
400 }
401
402 #[test]
403 fn program_status_rejects_invalid_extension_names_before_output() {
404 for name in [
405 "", "a:b", "a;b", "a b", "a\tb", "a\nb", "\x1b\\", "\u{e9}", "\u{9c}",
406 ] {
407 invalid(&ProgramStatus::new(ProgramStatusState::new(name)));
408 let mut report = ProgramStatus::new(ProgramStatusState::BLOCKED);
409 report.kind = Some(ProgramStatusKind::new(name));
410 invalid(&report);
411 }
412 for code in (0..=0x1f).chain(0x7f..=0x9f) {
413 let name = format!("a{}b", char::from_u32(code).unwrap());
414 invalid(&ProgramStatus::new(ProgramStatusState::new(&name)));
415 let mut report = ProgramStatus::new(ProgramStatusState::BLOCKED);
416 report.kind = Some(ProgramStatusKind::new(&name));
417 invalid(&report);
418 }
419 }
420
421 #[test]
422 fn program_status_extension_names_respect_complete_sequence_limit() {
423 for (kind, overhead) in [
424 (false, b"\x1b]7501;state=\x1b\\".len()),
425 (true, b"\x1b]7501;state=blocked:kind=\x1b\\".len()),
426 ] {
427 for extra in [0, 1, overhead + 1] {
428 let name = "a".repeat(4096 - overhead + extra);
429 let mut report = ProgramStatus::new(if kind {
430 ProgramStatusState::BLOCKED
431 } else {
432 ProgramStatusState::new(&name)
433 });
434 if kind {
435 report.kind = Some(ProgramStatusKind::new(&name));
436 }
437 if extra == 0 {
438 let mut bytes = Vec::new();
439 write_program_status(&mut bytes, &report).unwrap();
440 assert_eq!(bytes.len(), 4096);
441 report.title = Some("x");
442 invalid(&report);
443 } else {
444 invalid(&report);
445 }
446 }
447 }
448
449 let name = "a".repeat(2100);
450 let mut report = ProgramStatus::new(ProgramStatusState::new(&name));
451 report.kind = Some(ProgramStatusKind::new(&name));
452 invalid(&report);
453 }
454
455 #[test]
456 fn program_status_rejects_invalid_ids_apps_and_progress() {
457 let mut report = ProgramStatus::new(ProgramStatusState::WORKING);
458 for id in ["", "/a", "a/", "a//b", "a:b", "a;b", "a=b", "a b", "\u{e9}"] {
459 report.id = Some(id);
460 invalid(&report);
461 }
462 let segment = "a".repeat(33);
463 let levels = ["a"; 9].join("/");
464 let long_id = vec!["a".repeat(25); 5].join("/");
465 for id in [&segment, &levels, &long_id] {
466 report.id = Some(id);
467 invalid(&report);
468 }
469 report.id = None;
470 for app in ["", "a/b", "a b", "a:b", "a=b", "\u{e9}", &segment] {
471 report.app = Some(app);
472 invalid(&report);
473 }
474 report.app = None;
475 for progress in [101, 255] {
476 report.progress = Some(progress);
477 invalid(&report);
478 }
479 }
480
481 #[test]
482 fn program_status_rejects_controls_and_text_over_limits() {
483 for code in (0..=0x1f).chain(0x7f..=0x9f) {
484 let text = format!("before{}after", char::from_u32(code).unwrap());
485 let mut report = ProgramStatus::new(ProgramStatusState::DONE);
486 report.title = Some(&text);
487 invalid(&report);
488 report.title = None;
489 report.message = Some(&text);
490 invalid(&report);
491 report.message = None;
492 }
493 for title in ["a".repeat(193), "\u{e9}".repeat(97)] {
494 let mut report = ProgramStatus::new(ProgramStatusState::DONE);
495 report.title = Some(&title);
496 invalid(&report);
497 }
498 for message in ["a".repeat(2049), "\u{e9}".repeat(1025)] {
499 let mut report = ProgramStatus::new(ProgramStatusState::DONE);
500 report.message = Some(&message);
501 invalid(&report);
502 }
503 }
504
505 #[test]
506 fn program_status_accepts_limits_and_empty_text() {
507 let id = format!(
508 "{}/{}/{}/{}",
509 "a".repeat(32),
510 "b".repeat(32),
511 "c".repeat(32),
512 "d".repeat(29)
513 );
514 assert_eq!(id.len(), 128);
515 let app = "a".repeat(32);
516 let title = "\u{e9}".repeat(96);
517 let message = "\u{e9}".repeat(1024);
518 let mut report = ProgramStatus {
519 state: ProgramStatusState::BLOCKED,
520 id: Some(&id),
521 kind: Some(ProgramStatusKind::PERMISSION),
522 progress: Some(100),
523 app: Some(&app),
524 title: Some(&title),
525 message: Some(&message),
526 };
527 let mut bytes = Vec::new();
528 write_program_status(&mut bytes, &report).unwrap();
529 assert!(bytes.len() < 4096);
530 let sequence = String::from_utf8(bytes).unwrap();
531 assert_eq!(
532 sequence
533 .split(":title=")
534 .nth(1)
535 .unwrap()
536 .split(':')
537 .next()
538 .unwrap()
539 .len(),
540 256
541 );
542 assert_eq!(
543 sequence
544 .split(":msg=")
545 .nth(1)
546 .unwrap()
547 .trim_end_matches("\x1b\\")
548 .len(),
549 2732
550 );
551
552 report.id = Some("a/b/c/d/e/f/g/h");
553 report.app = Some("AZaz09_.+-");
554 report.title = Some("");
555 report.message = Some("");
556 report.progress = Some(0);
557 let mut bytes = Vec::new();
558 write_program_status(&mut bytes, &report).unwrap();
559 assert!(bytes.ends_with(b":title=:msg=\x1b\\"));
560 }
561
562 #[test]
563 fn program_status_propagates_write_errors() {
564 let mut empty = [];
565 let error = write_program_status(
566 &mut empty.as_mut_slice(),
567 &ProgramStatus::new(ProgramStatusState::IDLE),
568 )
569 .unwrap_err();
570 assert_eq!(error.kind(), io::ErrorKind::WriteZero);
571 }
572}