1use std::path::{Path, PathBuf};
13
14use data_encoding::{BASE64, BASE64_NOPAD};
15
16use crate::screen::Screen;
17use crate::sixel::{Image, solid};
18
19const MAX_SIDE: usize = 4096;
21const MAX_CACHE: u64 = 256 << 20;
23
24#[derive(Clone, Debug, Default)]
26struct Mask {
27 width: usize,
28 height: usize,
29 bits: Vec<bool>,
30}
31
32impl Mask {
33 fn get(&self, x: usize, y: usize) -> bool {
34 x < self.width && y < self.height && self.bits[y * self.width + x]
35 }
36}
37
38#[derive(Default)]
39pub struct SyncTerm {
40 pub cache_dir: Option<PathBuf>,
42 buffers: [Option<Image>; 2],
44 mask: Option<Mask>,
46 pub audio: crate::audio::Audio,
48 pub audio_ops: Vec<crate::audio::AudioOp>,
50 pub hold: Option<std::time::Instant>,
52}
53
54struct Args<'a> {
56 fields: Vec<&'a str>,
57}
58
59impl<'a> Args<'a> {
60 fn num(&self, name: &str) -> Option<i64> {
62 self.text(name).and_then(|v| v.trim().parse().ok())
63 }
64
65 fn text(&self, name: &str) -> Option<&'a str> {
66 self.fields.iter().find_map(|f| f.split_once('=').filter(|(k, _)| *k == name).map(|(_, v)| v))
67 }
68
69 fn flag(&self, name: &str) -> bool {
71 self.fields.contains(&name)
72 }
73}
74
75pub(crate) fn decode_b64(data: &str) -> Option<Vec<u8>> {
76 let data: String = data.chars().filter(|c| !c.is_whitespace()).collect();
77 BASE64.decode(data.as_bytes()).or_else(|_| BASE64_NOPAD.decode(data.trim_end_matches('=').as_bytes())).ok()
78}
79
80pub(crate) fn safe_name(name: &str) -> Option<&str> {
82 let ok = !name.is_empty()
83 && name.len() <= 255
84 && !name.starts_with('.')
85 && !name.chars().any(|c| matches!(c, '/' | '\\' | ':' | '\0') || c.is_control());
86 ok.then_some(name)
87}
88
89fn glob(pattern: &[u8], name: &[u8]) -> bool {
91 match (pattern.first(), name.first()) {
92 (None, None) => true,
93 (Some(b'*'), _) => glob(&pattern[1..], name) || !name.is_empty() && glob(pattern, &name[1..]),
94 (Some(b'?'), Some(_)) => glob(&pattern[1..], &name[1..]),
95 (Some(p), Some(n)) if p == n => glob(&pattern[1..], &name[1..]),
96 _ => false,
97 }
98}
99
100fn pnm_header(data: &[u8], count: usize) -> Option<(Vec<usize>, usize)> {
103 let mut i = 2;
104 let mut nums = Vec::new();
105 while nums.len() < count {
106 while i < data.len() && (data[i].is_ascii_whitespace() || data[i] == b'#') {
107 if data[i] == b'#' {
108 while i < data.len() && data[i] != b'\n' {
109 i += 1;
110 }
111 } else {
112 i += 1;
113 }
114 }
115 let start = i;
116 while i < data.len() && data[i].is_ascii_digit() {
117 i += 1;
118 }
119 nums.push(std::str::from_utf8(&data[start..i]).ok()?.parse().ok()?);
120 }
121 Some((nums, i + 1))
123}
124
125fn plain_numbers(data: &[u8]) -> impl Iterator<Item = usize> + '_ {
127 data.split(|b| b.is_ascii_whitespace())
128 .filter(|t| !t.is_empty())
129 .filter_map(|t| std::str::from_utf8(t).ok()?.parse().ok())
130}
131
132fn decode_ppm(data: &[u8]) -> Option<Image> {
134 let raw = match data.get(..2)? {
135 b"P6" => true,
136 b"P3" => false,
137 _ => return None,
138 };
139 let (h, start) = pnm_header(data, 3)?;
140 let (width, height, max) = (h[0], h[1], h[2].clamp(1, 255));
141 if width == 0 || height == 0 || width > MAX_SIDE || height > MAX_SIDE {
142 return None;
143 }
144 let scale = |v: usize| (v.min(max) * 255 / max) as u32;
145 let n = width * height;
146 let samples: Vec<usize> = if raw {
147 data.get(start..start + n * 3)?.iter().map(|&b| b as usize).collect()
148 } else {
149 plain_numbers(data.get(start - 1..)?).take(n * 3).collect()
150 };
151 if samples.len() < n * 3 {
152 return None;
153 }
154 let pixels = samples.chunks(3).map(|c| solid(scale(c[0]) << 16 | scale(c[1]) << 8 | scale(c[2]))).collect();
155 Some(Image { width, height, pixels })
156}
157
158fn decode_pbm(data: &[u8]) -> Option<Mask> {
160 let raw = match data.get(..2)? {
161 b"P4" => true,
162 b"P1" => false,
163 _ => return None,
164 };
165 let (h, start) = pnm_header(data, 2)?;
166 let (width, height) = (h[0], h[1]);
167 if width == 0 || height == 0 || width > MAX_SIDE || height > MAX_SIDE {
168 return None;
169 }
170 let bits = if raw {
171 let row = width.div_ceil(8);
173 let d = data.get(start..start + row * height)?;
174 (0..height).flat_map(|y| (0..width).map(move |x| d[y * row + x / 8] & (0x80 >> (x % 8)) != 0)).collect()
175 } else {
176 let bits: Vec<bool> = data
177 .get(start - 1..)?
178 .iter()
179 .filter(|b| matches!(b, b'0' | b'1'))
180 .take(width * height)
181 .map(|&b| b == b'1')
182 .collect();
183 if bits.len() < width * height {
184 return None;
185 }
186 bits
187 };
188 Some(Mask { width, height, bits })
189}
190
191fn decode_jxl(data: &[u8]) -> Option<Image> {
193 use jxl_oxide::{EnumColourEncoding, JxlImage, RenderingIntent};
194 let mut image = JxlImage::builder().read(std::io::Cursor::new(data)).ok()?;
195 let (width, height) = (image.width() as usize, image.height() as usize);
196 if width == 0 || height == 0 || width > MAX_SIDE || height > MAX_SIDE {
197 return None;
198 }
199 image.request_color_encoding(EnumColourEncoding::srgb(RenderingIntent::Relative));
200 let render = image.render_frame(0).ok()?;
201 let mut stream = render.stream_no_alpha();
202 let (w, h, ch) = (stream.width() as usize, stream.height() as usize, stream.channels() as usize);
203 let mut buf = vec![0u8; w * h * ch];
204 stream.write_to_buffer(&mut buf);
205 let pixels = buf
206 .chunks(ch.max(1))
207 .map(|c| match *c {
208 [r, g, b, ..] => solid((r as u32) << 16 | (g as u32) << 8 | b as u32),
209 [v, ..] => solid((v as u32) * 0x010101),
210 [] => solid(0),
211 })
212 .collect();
213 Some(Image { width: w, height: h, pixels })
214}
215
216impl SyncTerm {
217 pub fn apc(&mut self, data: &[u8], screen: &mut Screen, replies: &mut Vec<u8>) -> bool {
220 let Ok(text) = std::str::from_utf8(data) else { return false };
221 let Some(rest) = text.strip_prefix("SyncTERM:") else { return false };
222 let mut fields: Vec<&str> = rest.split(';').collect();
223 let kind = fields.remove(0);
224 let verb = if fields.is_empty() { "" } else { fields.remove(0) };
225 match (kind, verb) {
226 ("C", "S") => self.store(&fields),
227 ("C", "L") => self.list(fields.first().copied().unwrap_or("*"), replies),
228 ("C", "DrawPPM" | "DrawPPMBlob" | "DrawJXL" | "DrawJXLBlob") => {
229 let Some((&last, opts)) = fields.split_last() else { return true };
230 let args = Args { fields: opts.to_vec() };
231 if let Some(img) = self.load(verb, last) {
232 self.draw(&img, &args, screen);
233 }
234 }
235 ("C", "LoadPPM" | "LoadPPMBlob" | "LoadJXL" | "LoadJXLBlob") => {
236 let Some((&last, opts)) = fields.split_last() else { return true };
237 let args = Args { fields: opts.to_vec() };
238 let b = args.num("B").unwrap_or(0).clamp(0, 1) as usize;
239 self.buffers[b] = self.load(verb, last);
240 }
241 ("C", "SetFont") => {
243 let (Some(slot), Some(name)) = (fields.first(), fields.get(1)) else { return true };
244 if let (Ok(slot), Some(data)) = (slot.trim().parse::<u8>(), self.read_cached(name))
245 && !screen.look.load_font(slot, &data)
246 {
247 log::debug!("not loading {name} ({} bytes) into font slot {slot}", data.len());
248 }
249 }
250 ("C", "LoadPBM" | "LoadPBMBlob") => {
251 let Some(&last) = fields.last() else { return true };
252 let bytes = if verb == "LoadPBMBlob" { decode_b64(last) } else { self.read_cached(last) };
253 self.mask = bytes.and_then(|b| decode_pbm(&b));
254 }
255 ("P", "Copy") => {
256 let args = Args { fields };
257 let b = args.num("B").unwrap_or(0).clamp(0, 1) as usize;
258 self.buffers[b] = Some(copy(screen, &args));
259 }
260 ("P", "Paste") => {
261 let args = Args { fields };
262 let b = args.num("B").unwrap_or(0).clamp(0, 1) as usize;
263 if let Some(img) = self.buffers[b].take() {
264 self.draw(&img, &args, screen);
265 self.buffers[b] = Some(img);
266 }
267 }
268 ("Q", "JXL") => replies.extend_from_slice(b"\x1b[=1;1-n"),
270 ("A", _) => {
271 let mut all = vec![verb];
272 all.extend(&fields);
273 if let Some(until) = self.audio.apc(&all, self.cache_dir.as_ref(), &mut self.audio_ops) {
274 self.hold = Some(until.min(std::time::Instant::now() + std::time::Duration::from_secs(60)));
276 }
277 }
278 ("Q", "libsndfile") => replies.extend_from_slice(b"\x1b[=7;100;1n"),
280 ("Q", "libsndfileFormat") => {
281 let id = |i: usize| crate::audio::parse_id(fields.get(i).copied().unwrap_or("0"));
282 let (pm, ps) = (id(0), id(1));
283 let ok = crate::audio::format_supported(pm, ps) as u8;
284 replies.extend_from_slice(format!("\x1b[=7;101;{pm};{ps};{ok}n").as_bytes());
285 }
286 _ => log::debug!("unhandled SyncTERM APC {kind};{verb}"),
287 }
288 true
289 }
290
291 fn load(&self, verb: &str, arg: &str) -> Option<Image> {
293 let bytes = if verb.ends_with("Blob") { decode_b64(arg)? } else { self.read_cached(arg)? };
294 if verb.contains("JXL") { decode_jxl(&bytes) } else { decode_ppm(&bytes) }
295 }
296
297 fn cache_path(&self, name: &str) -> Option<PathBuf> {
298 Some(self.cache_dir.as_ref()?.join(safe_name(name)?))
299 }
300
301 fn read_cached(&self, name: &str) -> Option<Vec<u8>> {
302 std::fs::read(self.cache_path(name)?).ok()
303 }
304
305 fn store(&self, fields: &[&str]) {
307 let (Some(name), Some(data)) = (fields.first(), fields.get(1)) else { return };
308 let (Some(path), Some(bytes)) = (self.cache_path(name), decode_b64(data)) else {
309 return log::warn!("not caching {name:?}: a bad name or data");
310 };
311 let dir = path.parent().unwrap_or(Path::new("."));
312 let used: u64 = std::fs::read_dir(dir)
313 .map(|d| d.filter_map(|e| e.ok()?.metadata().ok()).map(|m| m.len()).sum())
314 .unwrap_or(0);
315 if used + bytes.len() as u64 > MAX_CACHE {
316 return log::warn!("not caching {name}: the cache for this BBS is full");
317 }
318 if let Err(e) = std::fs::create_dir_all(dir).and_then(|_| std::fs::write(&path, &bytes)) {
319 log::warn!("caching {}: {e}", path.display());
320 }
321 }
322
323 fn list(&self, pattern: &str, replies: &mut Vec<u8>) {
325 let mut out = b"\x1b_SyncTERM:C;L\n".to_vec();
326 if let Some(dir) = &self.cache_dir {
327 let mut names: Vec<(String, PathBuf)> = std::fs::read_dir(dir)
328 .into_iter()
329 .flatten()
330 .filter_map(|e| e.ok())
331 .filter(|e| e.file_type().is_ok_and(|t| t.is_file()))
332 .filter_map(|e| Some((e.file_name().into_string().ok()?, e.path())))
333 .filter(|(n, _)| glob(pattern.as_bytes(), n.as_bytes()))
334 .collect();
335 names.sort();
336 for (name, path) in names {
337 if let Ok(bytes) = std::fs::read(&path) {
338 out.extend_from_slice(format!("{name}\t{:x}\n", md5::compute(&bytes)).as_bytes());
339 }
340 }
341 }
342 out.extend_from_slice(b"\x1b\\");
343 replies.extend_from_slice(&out);
344 }
345
346 fn draw(&self, img: &Image, args: &Args, screen: &mut Screen) {
348 let num = |n: &str, d: i64| args.num(n).unwrap_or(d);
349 let sx = num("SX", 0).clamp(0, img.width as i64) as usize;
350 let sy = num("SY", 0).clamp(0, img.height as i64) as usize;
351 let sw = num("SW", (img.width - sx) as i64).clamp(0, (img.width - sx) as i64) as usize;
352 let sh = num("SH", (img.height - sy) as i64).clamp(0, (img.height - sy) as i64) as usize;
353 let (zx, zy) = (num("ZX", 1).clamp(1, 64) as usize, num("ZY", 1).clamp(1, 64) as usize);
354 let (fx, fy) = (args.flag("FX"), args.flag("FY"));
355 let (dx, dy) = (num("DX", 0) as isize, num("DY", 0) as isize);
356 let (mx, my) = (num("MX", 0).max(0) as usize, num("MY", 0).max(0) as usize);
357 let mask = if args.flag("MBUF") {
358 self.mask.clone()
359 } else if let Some(file) = args.text("MFILE") {
360 self.read_cached(file).and_then(|b| decode_pbm(&b))
361 } else if let Some(bits) = args.text("MASK") {
362 let (w, h) = (num("MW", 0).max(0) as usize, num("MH", 0).max(0) as usize);
363 decode_b64(bits).filter(|_| w > 0 && h > 0 && w <= MAX_SIDE && h <= MAX_SIDE).map(|b| Mask {
364 width: w,
365 height: h,
366 bits: (0..w * h).map(|i| b.get(i / 8).is_some_and(|byte| byte & (0x80 >> (i % 8)) != 0)).collect(),
367 })
368 } else {
369 None
370 };
371 if sw == 0 || sh == 0 {
372 return;
373 }
374 screen.draw_pixels((dx, dy, sw * zx, sh * zy), |x, y| {
375 let (i, j) = ((x - dx) as usize / zx, (y - dy) as usize / zy);
376 let (i, j) = (if fx { sw - 1 - i } else { i }, if fy { sh - 1 - j } else { j });
377 if mask.as_ref().is_some_and(|m| !m.get(mx + i, my + j)) {
378 return 0;
379 }
380 img.pixels[(sy + j) * img.width + sx + i]
381 });
382 }
383}
384
385fn copy(screen: &Screen, args: &Args) -> Image {
387 let (w, h) = screen.size_px();
388 let x = args.num("X").unwrap_or(0).clamp(0, w as i64) as usize;
389 let y = args.num("Y").unwrap_or(0).clamp(0, h as i64) as usize;
390 let cw = args.num("W").unwrap_or((w - x) as i64).clamp(0, (w - x) as i64) as usize;
391 let ch = args.num("H").unwrap_or((h - y) as i64).clamp(0, (h - y) as i64) as usize;
392 let mut pixels = Vec::with_capacity(cw * ch);
393 for yy in y..y + ch {
394 for xx in x..x + cw {
395 pixels.push(solid(screen.pixel_at(xx, yy)));
396 }
397 }
398 Image { width: cw, height: ch, pixels }
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404
405 fn apc(st: &mut SyncTerm, screen: &mut Screen, s: &str) -> Vec<u8> {
406 let mut replies = Vec::new();
407 assert!(st.apc(s.as_bytes(), screen, &mut replies));
408 replies
409 }
410
411 const PPM: &[u8] = b"P6\n# a comment\n2 2\n255\n\xff\x00\x00\x00\xff\x00\x00\x00\xff\xff\xff\xff";
413
414 #[test]
415 fn pictures_decode() {
416 let img = decode_ppm(PPM).unwrap();
417 assert_eq!((img.width, img.height), (2, 2));
418 assert_eq!(img.pixels, [solid(0xFF0000), solid(0x00FF00), solid(0x0000FF), solid(0xFFFFFF)]);
419 let plain = decode_ppm(b"P3 2 1 15 15 0 0 0 0 15").unwrap();
420 assert_eq!(plain.pixels, [solid(0xFF0000), solid(0x0000FF)]);
421 let m = decode_pbm(b"P4\n10 2\n\xc0\x40\x00\x00").unwrap();
422 assert_eq!((m.width, m.height), (10, 2));
423 assert!(m.get(0, 0) && m.get(1, 0) && !m.get(2, 0) && m.get(9, 0) && !m.get(0, 1));
424 let m = decode_pbm(b"P1 3 1 1 0 1").unwrap();
425 assert_eq!(m.bits, [true, false, true]);
426 assert!(decode_ppm(b"P6 99999 1 255 x").is_none());
427 }
428
429 #[test]
430 fn cache_draw_zoom_flip_and_mask() {
431 let dir = std::env::temp_dir().join(format!("badterm-cache-test-{}", std::process::id()));
432 let _ = std::fs::remove_dir_all(&dir);
433 let mut st = SyncTerm { cache_dir: Some(dir.clone()), ..Default::default() };
434 let mut s = Screen::new(80, 25, 0);
435 let b64 = BASE64.encode(PPM);
436 apc(&mut st, &mut s, &format!("SyncTERM:C;S;pic.ppm;{b64}"));
437 apc(&mut st, &mut s, &format!("SyncTERM:C;S;../evil;{b64}"));
439 let list = String::from_utf8(apc(&mut st, &mut s, "SyncTERM:C;L;*.ppm")).unwrap();
440 assert_eq!(list, format!("\x1b_SyncTERM:C;L\npic.ppm\t{:x}\n\x1b\\", md5::compute(PPM)));
441 assert!(!dir.parent().unwrap().join("evil").exists());
442
443 apc(&mut st, &mut s, "SyncTERM:C;DrawPPM;DX=10;DY=20;ZX=2;ZY=2;FX;pic.ppm");
445 assert_eq!(s.pixel_at(10, 20), 0x00FF00);
446 assert_eq!(s.pixel_at(11, 21), 0x00FF00);
447 assert_eq!(s.pixel_at(12, 20), 0xFF0000);
448 assert_eq!(s.pixel_at(10, 22), 0xFFFFFF);
449 assert_eq!(s.pixel_at(14, 20), 0, "the background beyond");
450
451 let mask = BASE64.encode(&[0b0001_0000]);
453 apc(&mut st, &mut s, &format!("SyncTERM:C;DrawPPMBlob;DX=-1;DY=-1;MW=2;MH=2;MASK={mask};{b64}"));
454 assert_eq!(s.pixel_at(0, 0), 0xFFFFFF);
455 assert_eq!(s.pixel_at(1, 0), 0);
456
457 apc(&mut st, &mut s, "SyncTERM:P;Copy;B=1;X=10;Y=20;W=4;H=4");
459 apc(&mut st, &mut s, "SyncTERM:P;Paste;B=1;DX=100;DY=100");
460 assert_eq!(s.pixel_at(100, 100), 0x00FF00);
461 assert_eq!(s.pixel_at(103, 103), 0x0000FF);
462
463 s.move_to(1, 1);
465 s.put(b'x');
466 assert_eq!(s.pixel_at(10, 20), 0);
467
468 assert_eq!(apc(&mut st, &mut s, "SyncTERM:Q;JXL"), b"\x1b[=1;1-n");
469 apc(&mut st, &mut s, &format!("SyncTERM:C;S;my.fnt;{}", BASE64.encode(&[0x3Cu8; 2048])));
471 apc(&mut st, &mut s, "SyncTERM:C;SetFont;50;my.fnt");
472 assert_eq!(s.look.loaded_font(50).map(|f| f.height), Some(8));
473 let _ = std::fs::remove_dir_all(&dir);
474 }
475
476 #[test]
477 fn globbing() {
478 assert!(glob(b"*", b"a.ppm") && glob(b"*.ppm", b"a.ppm") && glob(b"a?ppm", b"a.ppm"));
479 assert!(!glob(b"*.jxl", b"a.ppm") && !glob(b"a", b"ab"));
480 }
481
482 #[test]
484 #[ignore]
485 fn jxl_decodes() {
486 let data = std::fs::read(std::env::var("BADTERM_JXL").expect("set BADTERM_JXL")).unwrap();
487 let img = decode_jxl(&data).expect("decodes");
488 eprintln!("{}x{}", img.width, img.height);
489 assert!(img.pixels.iter().any(|&p| p != solid(0)));
490 }
491}