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", "LoadPBM" | "LoadPBMBlob") => {
242 let Some(&last) = fields.last() else { return true };
243 let bytes = if verb == "LoadPBMBlob" { decode_b64(last) } else { self.read_cached(last) };
244 self.mask = bytes.and_then(|b| decode_pbm(&b));
245 }
246 ("P", "Copy") => {
247 let args = Args { fields };
248 let b = args.num("B").unwrap_or(0).clamp(0, 1) as usize;
249 self.buffers[b] = Some(copy(screen, &args));
250 }
251 ("P", "Paste") => {
252 let args = Args { fields };
253 let b = args.num("B").unwrap_or(0).clamp(0, 1) as usize;
254 if let Some(img) = self.buffers[b].take() {
255 self.draw(&img, &args, screen);
256 self.buffers[b] = Some(img);
257 }
258 }
259 ("Q", "JXL") => replies.extend_from_slice(b"\x1b[=1;1-n"),
261 ("A", _) => {
262 let mut all = vec![verb];
263 all.extend(&fields);
264 if let Some(until) = self.audio.apc(&all, self.cache_dir.as_ref(), &mut self.audio_ops) {
265 self.hold = Some(until.min(std::time::Instant::now() + std::time::Duration::from_secs(60)));
267 }
268 }
269 ("Q", "libsndfile") => replies.extend_from_slice(b"\x1b[=7;100;1n"),
271 ("Q", "libsndfileFormat") => {
272 let id = |i: usize| crate::audio::parse_id(fields.get(i).copied().unwrap_or("0"));
273 let (pm, ps) = (id(0), id(1));
274 let ok = crate::audio::format_supported(pm, ps) as u8;
275 replies.extend_from_slice(format!("\x1b[=7;101;{pm};{ps};{ok}n").as_bytes());
276 }
277 _ => log::debug!("unhandled SyncTERM APC {kind};{verb}"),
278 }
279 true
280 }
281
282 fn load(&self, verb: &str, arg: &str) -> Option<Image> {
284 let bytes = if verb.ends_with("Blob") { decode_b64(arg)? } else { self.read_cached(arg)? };
285 if verb.contains("JXL") { decode_jxl(&bytes) } else { decode_ppm(&bytes) }
286 }
287
288 fn cache_path(&self, name: &str) -> Option<PathBuf> {
289 Some(self.cache_dir.as_ref()?.join(safe_name(name)?))
290 }
291
292 fn read_cached(&self, name: &str) -> Option<Vec<u8>> {
293 std::fs::read(self.cache_path(name)?).ok()
294 }
295
296 fn store(&self, fields: &[&str]) {
298 let (Some(name), Some(data)) = (fields.first(), fields.get(1)) else { return };
299 let (Some(path), Some(bytes)) = (self.cache_path(name), decode_b64(data)) else {
300 return log::warn!("not caching {name:?}: a bad name or data");
301 };
302 let dir = path.parent().unwrap_or(Path::new("."));
303 let used: u64 = std::fs::read_dir(dir)
304 .map(|d| d.filter_map(|e| e.ok()?.metadata().ok()).map(|m| m.len()).sum())
305 .unwrap_or(0);
306 if used + bytes.len() as u64 > MAX_CACHE {
307 return log::warn!("not caching {name}: the cache for this BBS is full");
308 }
309 if let Err(e) = std::fs::create_dir_all(dir).and_then(|_| std::fs::write(&path, &bytes)) {
310 log::warn!("caching {}: {e}", path.display());
311 }
312 }
313
314 fn list(&self, pattern: &str, replies: &mut Vec<u8>) {
316 let mut out = b"\x1b_SyncTERM:C;L\n".to_vec();
317 if let Some(dir) = &self.cache_dir {
318 let mut names: Vec<(String, PathBuf)> = std::fs::read_dir(dir)
319 .into_iter()
320 .flatten()
321 .filter_map(|e| e.ok())
322 .filter(|e| e.file_type().is_ok_and(|t| t.is_file()))
323 .filter_map(|e| Some((e.file_name().into_string().ok()?, e.path())))
324 .filter(|(n, _)| glob(pattern.as_bytes(), n.as_bytes()))
325 .collect();
326 names.sort();
327 for (name, path) in names {
328 if let Ok(bytes) = std::fs::read(&path) {
329 out.extend_from_slice(format!("{name}\t{:x}\n", md5::compute(&bytes)).as_bytes());
330 }
331 }
332 }
333 out.extend_from_slice(b"\x1b\\");
334 replies.extend_from_slice(&out);
335 }
336
337 fn draw(&self, img: &Image, args: &Args, screen: &mut Screen) {
339 let num = |n: &str, d: i64| args.num(n).unwrap_or(d);
340 let sx = num("SX", 0).clamp(0, img.width as i64) as usize;
341 let sy = num("SY", 0).clamp(0, img.height as i64) as usize;
342 let sw = num("SW", (img.width - sx) as i64).clamp(0, (img.width - sx) as i64) as usize;
343 let sh = num("SH", (img.height - sy) as i64).clamp(0, (img.height - sy) as i64) as usize;
344 let (zx, zy) = (num("ZX", 1).clamp(1, 64) as usize, num("ZY", 1).clamp(1, 64) as usize);
345 let (fx, fy) = (args.flag("FX"), args.flag("FY"));
346 let (dx, dy) = (num("DX", 0) as isize, num("DY", 0) as isize);
347 let (mx, my) = (num("MX", 0).max(0) as usize, num("MY", 0).max(0) as usize);
348 let mask = if args.flag("MBUF") {
349 self.mask.clone()
350 } else if let Some(file) = args.text("MFILE") {
351 self.read_cached(file).and_then(|b| decode_pbm(&b))
352 } else if let Some(bits) = args.text("MASK") {
353 let (w, h) = (num("MW", 0).max(0) as usize, num("MH", 0).max(0) as usize);
354 decode_b64(bits).filter(|_| w > 0 && h > 0 && w <= MAX_SIDE && h <= MAX_SIDE).map(|b| Mask {
355 width: w,
356 height: h,
357 bits: (0..w * h).map(|i| b.get(i / 8).is_some_and(|byte| byte & (0x80 >> (i % 8)) != 0)).collect(),
358 })
359 } else {
360 None
361 };
362 if sw == 0 || sh == 0 {
363 return;
364 }
365 screen.draw_pixels((dx, dy, sw * zx, sh * zy), |x, y| {
366 let (i, j) = ((x - dx) as usize / zx, (y - dy) as usize / zy);
367 let (i, j) = (if fx { sw - 1 - i } else { i }, if fy { sh - 1 - j } else { j });
368 if mask.as_ref().is_some_and(|m| !m.get(mx + i, my + j)) {
369 return 0;
370 }
371 img.pixels[(sy + j) * img.width + sx + i]
372 });
373 }
374}
375
376fn copy(screen: &Screen, args: &Args) -> Image {
378 let (w, h) = screen.size_px();
379 let x = args.num("X").unwrap_or(0).clamp(0, w as i64) as usize;
380 let y = args.num("Y").unwrap_or(0).clamp(0, h as i64) as usize;
381 let cw = args.num("W").unwrap_or((w - x) as i64).clamp(0, (w - x) as i64) as usize;
382 let ch = args.num("H").unwrap_or((h - y) as i64).clamp(0, (h - y) as i64) as usize;
383 let mut pixels = Vec::with_capacity(cw * ch);
384 for yy in y..y + ch {
385 for xx in x..x + cw {
386 pixels.push(solid(screen.pixel_at(xx, yy)));
387 }
388 }
389 Image { width: cw, height: ch, pixels }
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395
396 fn apc(st: &mut SyncTerm, screen: &mut Screen, s: &str) -> Vec<u8> {
397 let mut replies = Vec::new();
398 assert!(st.apc(s.as_bytes(), screen, &mut replies));
399 replies
400 }
401
402 const PPM: &[u8] = b"P6\n# a comment\n2 2\n255\n\xff\x00\x00\x00\xff\x00\x00\x00\xff\xff\xff\xff";
404
405 #[test]
406 fn pictures_decode() {
407 let img = decode_ppm(PPM).unwrap();
408 assert_eq!((img.width, img.height), (2, 2));
409 assert_eq!(img.pixels, [solid(0xFF0000), solid(0x00FF00), solid(0x0000FF), solid(0xFFFFFF)]);
410 let plain = decode_ppm(b"P3 2 1 15 15 0 0 0 0 15").unwrap();
411 assert_eq!(plain.pixels, [solid(0xFF0000), solid(0x0000FF)]);
412 let m = decode_pbm(b"P4\n10 2\n\xc0\x40\x00\x00").unwrap();
413 assert_eq!((m.width, m.height), (10, 2));
414 assert!(m.get(0, 0) && m.get(1, 0) && !m.get(2, 0) && m.get(9, 0) && !m.get(0, 1));
415 let m = decode_pbm(b"P1 3 1 1 0 1").unwrap();
416 assert_eq!(m.bits, [true, false, true]);
417 assert!(decode_ppm(b"P6 99999 1 255 x").is_none());
418 }
419
420 #[test]
421 fn cache_draw_zoom_flip_and_mask() {
422 let dir = std::env::temp_dir().join(format!("badterm-cache-test-{}", std::process::id()));
423 let _ = std::fs::remove_dir_all(&dir);
424 let mut st = SyncTerm { cache_dir: Some(dir.clone()), ..Default::default() };
425 let mut s = Screen::new(80, 25, 0);
426 let b64 = BASE64.encode(PPM);
427 apc(&mut st, &mut s, &format!("SyncTERM:C;S;pic.ppm;{b64}"));
428 apc(&mut st, &mut s, &format!("SyncTERM:C;S;../evil;{b64}"));
430 let list = String::from_utf8(apc(&mut st, &mut s, "SyncTERM:C;L;*.ppm")).unwrap();
431 assert_eq!(list, format!("\x1b_SyncTERM:C;L\npic.ppm\t{:x}\n\x1b\\", md5::compute(PPM)));
432 assert!(!dir.parent().unwrap().join("evil").exists());
433
434 apc(&mut st, &mut s, "SyncTERM:C;DrawPPM;DX=10;DY=20;ZX=2;ZY=2;FX;pic.ppm");
436 assert_eq!(s.pixel_at(10, 20), 0x00FF00);
437 assert_eq!(s.pixel_at(11, 21), 0x00FF00);
438 assert_eq!(s.pixel_at(12, 20), 0xFF0000);
439 assert_eq!(s.pixel_at(10, 22), 0xFFFFFF);
440 assert_eq!(s.pixel_at(14, 20), 0, "the background beyond");
441
442 let mask = BASE64.encode(&[0b0001_0000]);
444 apc(&mut st, &mut s, &format!("SyncTERM:C;DrawPPMBlob;DX=-1;DY=-1;MW=2;MH=2;MASK={mask};{b64}"));
445 assert_eq!(s.pixel_at(0, 0), 0xFFFFFF);
446 assert_eq!(s.pixel_at(1, 0), 0);
447
448 apc(&mut st, &mut s, "SyncTERM:P;Copy;B=1;X=10;Y=20;W=4;H=4");
450 apc(&mut st, &mut s, "SyncTERM:P;Paste;B=1;DX=100;DY=100");
451 assert_eq!(s.pixel_at(100, 100), 0x00FF00);
452 assert_eq!(s.pixel_at(103, 103), 0x0000FF);
453
454 s.move_to(1, 1);
456 s.put(b'x');
457 assert_eq!(s.pixel_at(10, 20), 0);
458
459 assert_eq!(apc(&mut st, &mut s, "SyncTERM:Q;JXL"), b"\x1b[=1;1-n");
460 let _ = std::fs::remove_dir_all(&dir);
461 }
462
463 #[test]
464 fn globbing() {
465 assert!(glob(b"*", b"a.ppm") && glob(b"*.ppm", b"a.ppm") && glob(b"a?ppm", b"a.ppm"));
466 assert!(!glob(b"*.jxl", b"a.ppm") && !glob(b"a", b"ab"));
467 }
468
469 #[test]
471 #[ignore]
472 fn jxl_decodes() {
473 let data = std::fs::read(std::env::var("BADTERM_JXL").expect("set BADTERM_JXL")).unwrap();
474 let img = decode_jxl(&data).expect("decodes");
475 eprintln!("{}x{}", img.width, img.height);
476 assert!(img.pixels.iter().any(|&p| p != solid(0)));
477 }
478}