Skip to main content

badterm_core/
transfer.rs

1//! File transfers: ZMODEM here, XMODEM and YMODEM in [`crate::xmodem`], and
2//! Kermit in [`crate::kermit`].
3//!
4//! ZMODEM transfers drive the caller-owned state machines of the
5//! `zmodem2` crate with file I/O, progress tracking and timeouts.
6//!
7//! A [`Transfer`] is fed raw (post-telnet) bytes from the remote and appends
8//! the bytes it wants sent to an output buffer, so it works the same over
9//! telnet, raw TCP and SSH.
10
11use std::collections::VecDeque;
12use std::fs::{self, File};
13use std::io::{BufWriter, Read, Seek, SeekFrom, Write};
14use std::path::{Path, PathBuf};
15use std::time::{Duration, Instant, UNIX_EPOCH};
16
17use anyhow::{Context, Result, bail};
18use zmodem2::{Action, Event, FileInfo, Position, Receiver, Sender};
19
20pub use crate::kermit::Kermit;
21pub use crate::xmodem::{XyProtocol, XyReceive, XySend};
22
23/// `ZPAD ZPAD ZDLE 'B' "00"`: the hex ZRQINIT header a sender (`sz`) opens with.
24pub const ZRQINIT: &[u8] = b"**\x18B00";
25/// `ZPAD ZPAD ZDLE 'B' "01"`: the hex ZRINIT header a waiting receiver (`rz`) sends.
26pub const ZRINIT: &[u8] = b"**\x18B01";
27/// Ten CANs then ten backspaces: makes the other end abandon a session.
28pub const CANCEL: &[u8] = b"\x18\x18\x18\x18\x18\x18\x18\x18\x18\x18\x08\x08\x08\x08\x08\x08\x08\x08\x08\x08";
29
30/// Wait this long for input before nudging the other side.
31pub(crate) const TIMEOUT: Duration = Duration::from_secs(10);
32/// Give up after this many consecutive timeouts.
33pub(crate) const MAX_TIMEOUTS: u32 = 6;
34pub(crate) const MAX_FILE_SIZE: u64 = u32::MAX as u64;
35
36#[derive(Clone, Copy, Debug, PartialEq, Eq)]
37pub enum Direction {
38    Download,
39    Upload,
40}
41
42#[derive(Clone, Debug, PartialEq, Eq)]
43pub enum Outcome {
44    /// Finished; lists the files saved (download) or sent (upload).
45    Completed(Vec<PathBuf>),
46    Cancelled,
47    Failed(String),
48}
49
50#[derive(Clone, Debug)]
51pub struct Progress {
52    pub direction: Direction,
53    pub file_name: String,
54    pub file_bytes: u64,
55    pub file_size: Option<u64>,
56    pub files_done: usize,
57    /// Files still waiting to be sent (uploads only).
58    pub files_queued: usize,
59    pub file_started: Instant,
60    /// Where a resumed download picked up, if it was resumed.
61    pub resumed_at: Option<u64>,
62}
63
64impl Progress {
65    pub(crate) fn new(direction: Direction) -> Progress {
66        Progress {
67            direction,
68            file_name: String::new(),
69            file_bytes: 0,
70            file_size: None,
71            files_done: 0,
72            files_queued: 0,
73            file_started: Instant::now(),
74            resumed_at: None,
75        }
76    }
77
78    /// Average speed of the current file in bytes per second (of what
79    /// came this time, for a resumed download).
80    pub fn rate(&self) -> f64 {
81        let secs = self.file_started.elapsed().as_secs_f64();
82        let bytes = self.file_bytes.saturating_sub(self.resumed_at.unwrap_or(0));
83        if secs > 0.0 { bytes as f64 / secs } else { 0.0 }
84    }
85}
86
87/// State shared by both directions.
88pub(crate) struct Common {
89    pub(crate) pending: Vec<u8>,
90    pub(crate) progress: Progress,
91    pub(crate) outcome: Option<Outcome>,
92    pub(crate) last_input: Instant,
93    pub(crate) timeouts: u32,
94    pub(crate) cancelled: bool,
95}
96
97impl Common {
98    pub(crate) fn new(direction: Direction) -> Common {
99        Common {
100            pending: Vec::new(),
101            progress: Progress::new(direction),
102            outcome: None,
103            last_input: Instant::now(),
104            timeouts: 0,
105            cancelled: false,
106        }
107    }
108
109    pub(crate) fn input(&mut self, data: &[u8]) {
110        self.pending.extend_from_slice(data);
111        self.last_input = Instant::now();
112        self.timeouts = 0;
113    }
114
115    /// True when the timeout elapsed; sets a failure once too many have.
116    fn timed_out(&mut self, now: Instant) -> bool {
117        if self.outcome.is_some() || now.duration_since(self.last_input) < TIMEOUT {
118            return false;
119        }
120        self.last_input = now;
121        self.timeouts += 1;
122        if self.timeouts >= MAX_TIMEOUTS {
123            self.outcome = Some(Outcome::Failed("the other side stopped responding".into()));
124        }
125        true
126    }
127
128    pub(crate) fn aborted(&mut self) {
129        let outcome = if self.cancelled { Outcome::Cancelled } else { Outcome::Failed("cancelled by the BBS".into()) };
130        self.outcome.get_or_insert(outcome);
131    }
132
133    /// Bytes left after the session ended, to hand back to the terminal.
134    fn leftover(&mut self) -> Vec<u8> {
135        std::mem::take(&mut self.pending)
136    }
137}
138
139/// Downloads are written under this suffix until they are complete, so an
140/// interrupted one can be resumed when the BBS sends the file again.
141pub const PARTIAL_SUFFIX: &str = ".part";
142
143pub struct Download {
144    rx: Receiver,
145    dir: PathBuf,
146    /// The file being written (`name.part`), and its name once complete.
147    file: Option<(BufWriter<File>, PathBuf, String)>,
148    /// Where to ask the sender to start the file just announced, once the
149    /// receiver has room to say so.
150    accept: Option<u32>,
151    saved: Vec<PathBuf>,
152    c: Common,
153}
154
155impl Download {
156    pub fn new(dir: PathBuf) -> Result<Download> {
157        // TCP and SSH are reliable and flow-controlled, so let the sender
158        // stream without per-buffer acknowledgements.
159        let mut rx = Receiver::with_flow_control(0, true).map_err(|e| anyhow::anyhow!("{e}"))?;
160        // Decide where each file starts: a partial one is resumed.
161        rx.set_manual_file_accept(true);
162        Ok(Download { rx, dir, file: None, accept: None, saved: Vec::new(), c: Common::new(Direction::Download) })
163    }
164
165    fn fail(&mut self, msg: String) {
166        self.c.outcome.get_or_insert(Outcome::Failed(msg));
167        let _ = self.rx.abort();
168    }
169
170    /// Open the file the sender announced, as `name.part`: appending to
171    /// one left by an interrupted download of it (when its size says there
172    /// is more to come), or new. Returns where the sender should start.
173    fn open(&mut self, wire_name: &[u8], size: Option<u64>) -> Option<u32> {
174        self.close();
175        let name = sanitize_file_name(wire_name);
176        let part = self.dir.join(format!("{name}{PARTIAL_SUFFIX}"));
177        let have = fs::metadata(&part).map(|m| m.len()).unwrap_or(0);
178        let resume = size.is_some_and(|s| have > 0 && have < s && have <= u32::MAX as u64);
179        let result = fs::create_dir_all(&self.dir)
180            .and_then(|()| if resume { fs::OpenOptions::new().append(true).open(&part) } else { File::create(&part) });
181        match result {
182            Ok(f) => {
183                let offset = if resume { have } else { 0 };
184                self.file = Some((BufWriter::new(f), part, name.clone()));
185                let p = &mut self.c.progress;
186                p.file_name = name;
187                p.file_size = size;
188                p.file_bytes = offset;
189                p.resumed_at = resume.then_some(offset);
190                p.file_started = Instant::now();
191                Some(offset as u32)
192            }
193            Err(e) => {
194                self.fail(format!("creating {} in {}: {e}", part.display(), self.dir.display()));
195                None
196            }
197        }
198    }
199
200    /// Finish writing the current file. A complete one gets its own name
201    /// (never replacing a file); an incomplete one stays as `name.part`.
202    fn close_file(&mut self, complete: bool) -> Option<PathBuf> {
203        let (mut f, part, name) = self.file.take()?;
204        if let Err(e) = f.flush() {
205            self.fail(format!("writing {}: {e}", part.display()));
206            return None;
207        }
208        drop(f);
209        if !complete {
210            return None;
211        }
212        match unique_path(&self.dir, &name).and_then(|dest| fs::rename(&part, &dest).map(|()| dest)) {
213            Ok(dest) => Some(dest),
214            Err(e) => {
215                self.fail(format!("saving {name}: {e}"));
216                None
217            }
218        }
219    }
220
221    fn close(&mut self) {
222        self.close_file(false);
223    }
224
225    fn pump(&mut self, out: &mut Vec<u8>) {
226        loop {
227            match self.rx.poll() {
228                Action::WriteWire(b) => {
229                    out.extend_from_slice(b);
230                    let n = b.len();
231                    self.rx.wire_written(n);
232                }
233                Action::WriteFile(b) => {
234                    let n = b.len();
235                    let written = match &mut self.file {
236                        Some((f, _, _)) => f.write_all(b),
237                        None => Ok(()),
238                    };
239                    if let Err(e) = written {
240                        self.fail(format!("writing {}: {e}", self.c.progress.file_name));
241                        continue;
242                    }
243                    self.c.progress.file_bytes += n as u64;
244                    if let Err(e) = self.rx.file_written(n) {
245                        log::debug!("zmodem file_written: {e}");
246                    }
247                }
248                Action::Event(Event::FileStarted(info)) => {
249                    let name = info.name.to_vec();
250                    let size = info.size.map(|s| u64::from(s.get()));
251                    self.accept = self.open(&name, size);
252                }
253                Action::Event(Event::FileCompleted) => {
254                    let path = self.close_file(true);
255                    self.saved.extend(path);
256                    self.c.progress.files_done += 1;
257                }
258                Action::Event(Event::SessionCompleted) => {
259                    self.close();
260                    self.c.outcome.get_or_insert(Outcome::Completed(self.saved.clone()));
261                }
262                Action::Event(Event::Aborted) => {
263                    self.close();
264                    self.c.aborted();
265                }
266                Action::Idle => {
267                    if let Some(offset) = self.accept {
268                        match self.rx.accept_file_at(offset) {
269                            Ok(()) => {
270                                self.accept = None;
271                                continue;
272                            }
273                            // Outgoing bytes first.
274                            Err(zmodem2::Error::Backpressure) => {}
275                            Err(e) => {
276                                self.accept = None;
277                                self.fail(format!("starting {}: {e}", self.c.progress.file_name));
278                            }
279                        }
280                    }
281                    if self.c.outcome.is_some() || self.c.pending.is_empty() {
282                        break;
283                    }
284                    match self.rx.submit_wire(&self.c.pending) {
285                        Ok(0) => break,
286                        Ok(n) => {
287                            self.c.pending.drain(..n);
288                        }
289                        Err(e) => {
290                            // The receiver has asked for a resend; drop the damaged input.
291                            log::debug!("zmodem receive error: {e}");
292                            self.c.pending.clear();
293                        }
294                    }
295                }
296                _ => break,
297            }
298        }
299    }
300}
301
302pub struct Upload {
303    tx: Sender,
304    queue: VecDeque<PathBuf>,
305    file: Option<(File, PathBuf)>,
306    sent: Vec<PathBuf>,
307    /// Start the next file (or finish) once the outgoing buffer drains.
308    advance: bool,
309    finishing: bool,
310    buf: Vec<u8>,
311    c: Common,
312}
313
314impl Upload {
315    pub fn new(paths: Vec<PathBuf>) -> Result<Upload> {
316        if paths.is_empty() {
317            bail!("no files to upload");
318        }
319        for p in &paths {
320            check_upload(p)?;
321        }
322        let mut tx = Sender::new().map_err(|e| anyhow::anyhow!("{e}"))?;
323        tx.set_streaming_window(usize::MAX);
324        let mut up = Upload {
325            tx,
326            queue: paths.into(),
327            file: None,
328            sent: Vec::new(),
329            advance: false,
330            finishing: false,
331            buf: Vec::new(),
332            c: Common::new(Direction::Upload),
333        };
334        up.start_next();
335        Ok(up)
336    }
337
338    /// Add a file to the batch. Fails once the last file has been sent.
339    pub fn enqueue(&mut self, path: PathBuf) -> Result<()> {
340        if self.finishing || self.c.outcome.is_some() {
341            bail!("the upload is already finishing");
342        }
343        check_upload(&path)?;
344        self.queue.push_back(path);
345        self.c.progress.files_queued = self.queue.len();
346        Ok(())
347    }
348
349    fn fail(&mut self, msg: String) {
350        self.c.outcome.get_or_insert(Outcome::Failed(msg));
351        self.tx.abort();
352    }
353
354    fn start_next(&mut self) {
355        let Some(path) = self.queue.pop_front() else {
356            self.finishing = true;
357            if let Err(e) = self.tx.finish() {
358                self.fail(format!("finishing upload: {e}"));
359            }
360            return;
361        };
362        self.c.progress.files_queued = self.queue.len();
363        let opened = File::open(&path).and_then(|f| f.metadata().map(|m| (f, m)));
364        let (f, meta) = match opened {
365            Ok(v) => v,
366            Err(e) => return self.fail(format!("opening {}: {e}", path.display())),
367        };
368        let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "file".into());
369        let size = meta.len();
370        let mut info = FileInfo::new(name.as_bytes(), Some(Position::new(size as u32)));
371        if let Some(t) = meta.modified().ok().and_then(|t| t.duration_since(UNIX_EPOCH).ok()) {
372            info = info.with_modified(t.as_secs() as u32);
373        }
374        if let Err(e) = self.tx.start_file(info) {
375            return self.fail(format!("starting {name}: {e}"));
376        }
377        let p = &mut self.c.progress;
378        p.file_name = name;
379        p.file_size = Some(size);
380        p.file_bytes = 0;
381        p.file_started = Instant::now();
382        self.file = Some((f, path));
383    }
384
385    fn read_chunk(&mut self, offset: u64, max_len: usize) -> std::io::Result<usize> {
386        let Some((f, _)) = &mut self.file else { return Ok(0) };
387        self.buf.resize(max_len, 0);
388        f.seek(SeekFrom::Start(offset))?;
389        f.read(&mut self.buf)
390    }
391
392    fn pump(&mut self, out: &mut Vec<u8>) {
393        loop {
394            match self.tx.poll() {
395                Action::WriteWire(b) => {
396                    out.extend_from_slice(b);
397                    let n = b.len();
398                    self.tx.wire_written(n);
399                }
400                Action::ReadFile { offset, max_len } => {
401                    let offset = u64::from(offset.get());
402                    match self.read_chunk(offset, max_len) {
403                        Ok(0) => self.fail(format!("{} got shorter while sending", self.c.progress.file_name)),
404                        Ok(n) => {
405                            if let Err(e) = self.tx.submit_file(&self.buf[..n]) {
406                                log::debug!("zmodem submit_file: {e}");
407                            }
408                            self.c.progress.file_bytes = offset + n as u64;
409                        }
410                        Err(e) => self.fail(format!("reading {}: {e}", self.c.progress.file_name)),
411                    }
412                }
413                Action::Event(Event::FileCompleted) => {
414                    if let Some((_, path)) = self.file.take() {
415                        self.sent.push(path);
416                    }
417                    self.c.progress.files_done += 1;
418                    self.advance = true;
419                }
420                Action::Event(Event::SessionCompleted) => {
421                    self.c.outcome.get_or_insert(Outcome::Completed(self.sent.clone()));
422                }
423                Action::Event(Event::Aborted) => self.c.aborted(),
424                Action::Event(_) => {}
425                Action::Idle => {
426                    if self.advance && self.c.outcome.is_none() {
427                        self.advance = false;
428                        self.start_next();
429                        continue;
430                    }
431                    if self.c.outcome.is_some() || self.c.pending.is_empty() {
432                        break;
433                    }
434                    match self.tx.submit_wire(&self.c.pending) {
435                        Ok(0) => break,
436                        Ok(n) => {
437                            self.c.pending.drain(..n);
438                        }
439                        Err(e) => {
440                            log::debug!("zmodem send error: {e}");
441                            self.c.pending.clear();
442                        }
443                    }
444                }
445                _ => break,
446            }
447        }
448    }
449}
450
451pub(crate) fn check_upload(p: &Path) -> Result<()> {
452    let meta = fs::metadata(p).with_context(|| format!("{}", p.display()))?;
453    if !meta.is_file() {
454        bail!("{} is not a file", p.display());
455    }
456    if meta.len() > MAX_FILE_SIZE {
457        bail!("{} is larger than the 4 GiB limit", p.display());
458    }
459    Ok(())
460}
461
462pub enum Transfer {
463    Download(Download),
464    Upload(Upload),
465    /// XMODEM or YMODEM.
466    XyDownload(XyReceive),
467    XyUpload(XySend),
468    /// Either way.
469    Kermit(Kermit),
470}
471
472impl Transfer {
473    fn parts(&mut self) -> &mut Common {
474        match self {
475            Transfer::Download(d) => &mut d.c,
476            Transfer::Upload(u) => &mut u.c,
477            Transfer::XyDownload(d) => &mut d.c,
478            Transfer::XyUpload(u) => &mut u.c,
479            Transfer::Kermit(k) => &mut k.c,
480        }
481    }
482
483    fn pump(&mut self, out: &mut Vec<u8>) {
484        match self {
485            Transfer::Download(d) => d.pump(out),
486            Transfer::Upload(u) => u.pump(out),
487            Transfer::XyDownload(d) => d.pump(out),
488            Transfer::XyUpload(u) => u.pump(out),
489            Transfer::Kermit(k) => k.pump(out),
490        }
491    }
492
493    /// The protocol's name, for the progress window.
494    pub fn name(&self) -> &'static str {
495        match self {
496            Transfer::Download(_) | Transfer::Upload(_) => "ZMODEM",
497            Transfer::XyDownload(d) => d.protocol().name(),
498            Transfer::XyUpload(u) => u.protocol().name(),
499            Transfer::Kermit(_) => "Kermit",
500        }
501    }
502
503    /// Start a side that speaks first (a Kermit sender): call once.
504    pub fn start(&mut self, out: &mut Vec<u8>) {
505        if let Transfer::Kermit(k) = self {
506            k.start(out);
507        }
508    }
509
510    /// Feed bytes received from the remote; bytes to send are appended to `out`.
511    pub fn feed(&mut self, data: &[u8], out: &mut Vec<u8>) {
512        self.parts().input(data);
513        self.pump(out);
514    }
515
516    /// Call periodically to drive timeouts and retries.
517    pub fn tick(&mut self, now: Instant, out: &mut Vec<u8>) {
518        // X/YMODEM keep their own time: a receiver asks every few seconds.
519        match self {
520            Transfer::XyDownload(d) => {
521                d.tick(now, out);
522                return self.pump(out);
523            }
524            Transfer::XyUpload(u) => {
525                u.tick(now, out);
526                return self.pump(out);
527            }
528            Transfer::Kermit(k) => {
529                k.tick(now, out);
530                return self.pump(out);
531            }
532            _ => {}
533        }
534        if !self.parts().timed_out(now) {
535            return;
536        }
537        let gave_up = self.parts().outcome.is_some();
538        match self {
539            Transfer::Download(d) if gave_up => {
540                let _ = d.rx.abort();
541            }
542            Transfer::Download(d) => {
543                let _ = d.rx.timeout();
544            }
545            Transfer::Upload(u) if gave_up => u.tx.abort(),
546            Transfer::Upload(u) => {
547                let _ = u.tx.timeout();
548            }
549            Transfer::XyDownload(_) | Transfer::XyUpload(_) | Transfer::Kermit(_) => {}
550        }
551        self.pump(out);
552    }
553
554    /// Abort the transfer, telling the other side to stop.
555    pub fn cancel(&mut self, out: &mut Vec<u8>) {
556        self.parts().cancelled = true;
557        match self {
558            Transfer::Download(d) => {
559                let _ = d.rx.abort();
560            }
561            Transfer::Upload(u) => u.tx.abort(),
562            Transfer::XyDownload(d) => return d.cancel(out),
563            Transfer::XyUpload(u) => return u.cancel(out),
564            Transfer::Kermit(k) => return k.cancel(out),
565        }
566        self.pump(out);
567        // The abort is final whether or not the other side acknowledges it.
568        self.parts().outcome.get_or_insert(Outcome::Cancelled);
569    }
570
571    pub fn progress(&self) -> &Progress {
572        match self {
573            Transfer::Download(d) => &d.c.progress,
574            Transfer::Upload(u) => &u.c.progress,
575            Transfer::XyDownload(d) => d.progress(),
576            Transfer::XyUpload(u) => u.progress(),
577            Transfer::Kermit(k) => k.progress(),
578        }
579    }
580
581    pub fn is_finished(&self) -> bool {
582        match self {
583            Transfer::Download(d) => d.c.outcome.is_some(),
584            Transfer::Upload(u) => u.c.outcome.is_some(),
585            Transfer::XyDownload(d) => d.c.outcome.is_some(),
586            Transfer::XyUpload(u) => u.c.outcome.is_some(),
587            Transfer::Kermit(k) => k.c.outcome.is_some(),
588        }
589    }
590
591    /// The result and any bytes received after the session ended, which
592    /// belong to the terminal.
593    pub fn finish(mut self) -> (Outcome, Vec<u8>) {
594        let c = self.parts();
595        let leftover = c.leftover();
596        let outcome = c.outcome.take().unwrap_or_else(|| Outcome::Failed("interrupted".into()));
597        if let (Transfer::Download(d), Outcome::Failed(_) | Outcome::Cancelled) = (&mut self, &outcome) {
598            d.close();
599        }
600        (outcome, leftover)
601    }
602
603    pub fn enqueue(&mut self, path: PathBuf) -> Result<()> {
604        match self {
605            Transfer::Upload(u) => u.enqueue(path),
606            Transfer::XyUpload(u) => u.enqueue(path),
607            Transfer::Kermit(k) => k.enqueue(path),
608            Transfer::Download(_) | Transfer::XyDownload(_) => bail!("a download is in progress"),
609        }
610    }
611}
612
613/// Reduce a file name from the wire to a safe base name: no directories, no
614/// control characters, never empty, "." or "..".
615pub fn sanitize_file_name(wire: &[u8]) -> String {
616    let s = String::from_utf8_lossy(wire);
617    let base = s.rsplit(['/', '\\']).next().unwrap_or("");
618    let cleaned: String = base
619        .chars()
620        .map(|c| if c.is_control() || matches!(c, ':' | '*' | '?' | '"' | '<' | '>' | '|') { '_' } else { c })
621        .collect();
622    let cleaned = cleaned.trim().trim_start_matches('.').to_string();
623    if cleaned.is_empty() { "download".to_string() } else { cleaned }
624}
625
626/// `dir/name`, or `dir/name (1).ext`, ... if that already exists.
627pub(crate) fn unique_path(dir: &Path, name: &str) -> std::io::Result<PathBuf> {
628    let first = dir.join(name);
629    if !first.exists() {
630        return Ok(first);
631    }
632    let (stem, ext) = match name.rsplit_once('.') {
633        Some((s, e)) if !s.is_empty() => (s, format!(".{e}")),
634        _ => (name, String::new()),
635    };
636    for i in 1..10_000 {
637        let p = dir.join(format!("{stem} ({i}){ext}"));
638        if !p.exists() {
639            return Ok(p);
640        }
641    }
642    Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, format!("too many copies of {name}")))
643}
644
645/// Strips the tail end of a ZMODEM session from data that follows it: the
646/// CR LF (often with the high bit set) and XON closing the last hex header,
647/// and the "OO" ("over and out") that `sz` sends after its final ZFIN. These
648/// can arrive in later packets than the end of the session, so the filter
649/// stays armed across calls until real data shows up or it expires.
650#[derive(Debug)]
651pub struct TrailerFilter {
652    until: Instant,
653    o_left: u8,
654}
655
656impl TrailerFilter {
657    const WINDOW: Duration = Duration::from_secs(2);
658
659    pub fn new() -> TrailerFilter {
660        TrailerFilter { until: Instant::now() + Self::WINDOW, o_left: 2 }
661    }
662
663    /// Remove trailer bytes from the start of `data`. Returns false once the
664    /// filter is done and can be dropped.
665    pub fn strip(&mut self, data: &mut Vec<u8>) -> bool {
666        if Instant::now() > self.until {
667            return false;
668        }
669        // Where the trailer ends, if it ends within this chunk.
670        let mut end = None;
671        for (n, &b) in data.iter().enumerate() {
672            match b {
673                b'\r' | b'\n' | 0x8D | 0x8A | 0x11 | 0x13 => {}
674                b'O' if self.o_left > 0 => {
675                    self.o_left -= 1;
676                    if self.o_left == 0 {
677                        // "OO" is the very last thing a sender writes.
678                        end = Some(n + 1);
679                        break;
680                    }
681                }
682                _ => {
683                    end = Some(n);
684                    break;
685                }
686            }
687        }
688        match end {
689            Some(cut) => {
690                data.drain(..cut);
691                false
692            }
693            None => {
694                data.clear();
695                true
696            }
697        }
698    }
699}
700
701impl Default for TrailerFilter {
702    fn default() -> Self {
703        Self::new()
704    }
705}
706
707/// Find the first occurrence of `needle` in `hay`.
708pub fn find(hay: &[u8], needle: &[u8]) -> Option<usize> {
709    hay.windows(needle.len()).position(|w| w == needle)
710}
711
712#[cfg(test)]
713mod tests {
714    use super::*;
715
716    fn temp_dir(name: &str) -> PathBuf {
717        let d = std::env::temp_dir().join(format!("badterm-test-{name}-{}", std::process::id()));
718        let _ = fs::remove_dir_all(&d);
719        fs::create_dir_all(&d).unwrap();
720        d
721    }
722
723    /// Run an upload against a download in memory until both finish.
724    fn run(up: &mut Transfer, down: &mut Transfer) {
725        let (mut to_down, mut to_up) = (Vec::new(), Vec::new());
726        up.feed(&[], &mut to_down);
727        for _ in 0..100_000 {
728            if up.is_finished() && down.is_finished() {
729                return;
730            }
731            let data = std::mem::take(&mut to_down);
732            down.feed(&data, &mut to_up);
733            let data = std::mem::take(&mut to_up);
734            up.feed(&data, &mut to_down);
735        }
736        panic!("transfer did not finish");
737    }
738
739    #[test]
740    fn upload_to_download_round_trip() {
741        let src = temp_dir("src");
742        let dst = temp_dir("dst");
743        let mut contents = Vec::new();
744        for i in 0..300_000u32 {
745            contents.push((i.wrapping_mul(2_654_435_761) >> 13) as u8);
746        }
747        // Include bytes that need escaping on the wire.
748        contents.extend_from_slice(&[0x18, 0x11, 0x13, 0xFF, b'\r', 0]);
749        fs::write(src.join("big.bin"), &contents).unwrap();
750        fs::write(src.join("empty.txt"), b"").unwrap();
751        fs::write(dst.join("big.bin"), b"already here").unwrap();
752
753        let mut up = Transfer::Upload(Upload::new(vec![src.join("big.bin"), src.join("empty.txt")]).unwrap());
754        let mut down = Transfer::Download(Download::new(dst.clone()).unwrap());
755        run(&mut up, &mut down);
756
757        let (up_outcome, _) = up.finish();
758        let (down_outcome, _) = down.finish();
759        assert_eq!(up_outcome, Outcome::Completed(vec![src.join("big.bin"), src.join("empty.txt")]));
760        // The existing file was not overwritten.
761        assert_eq!(down_outcome, Outcome::Completed(vec![dst.join("big (1).bin"), dst.join("empty.txt")]));
762        assert_eq!(fs::read(dst.join("big (1).bin")).unwrap(), contents);
763        assert_eq!(fs::read(dst.join("big.bin")).unwrap(), b"already here");
764        assert_eq!(fs::read(dst.join("empty.txt")).unwrap(), b"");
765    }
766
767    #[test]
768    fn cancel_sends_abort() {
769        let dst = temp_dir("cancel");
770        let mut down = Transfer::Download(Download::new(dst).unwrap());
771        let mut out = Vec::new();
772        down.cancel(&mut out);
773        assert!(find(&out, &CANCEL[..10]).is_some());
774        assert_eq!(down.finish().0, Outcome::Cancelled);
775    }
776
777    #[test]
778    fn trailer_filter_spans_chunks() {
779        let mut f = TrailerFilter::new();
780        let mut a = b"\r\x8a".to_vec();
781        assert!(f.strip(&mut a));
782        assert!(a.is_empty());
783        // The BBS's own line break after "OO" is kept.
784        let mut b = b"OO\r\nMenu: ".to_vec();
785        assert!(!f.strip(&mut b));
786        assert_eq!(b, b"\r\nMenu: ");
787        // Only two Os are ever eaten.
788        let mut f = TrailerFilter::new();
789        let mut c = b"OOOps".to_vec();
790        f.strip(&mut c);
791        assert_eq!(c, b"Ops");
792    }
793
794    #[test]
795    fn sanitizes_names() {
796        assert_eq!(sanitize_file_name(b"../../etc/passwd"), "passwd");
797        assert_eq!(sanitize_file_name(b"C:\\DOS\\GAME.ZIP"), "GAME.ZIP");
798        assert_eq!(sanitize_file_name(b".."), "download");
799        assert_eq!(sanitize_file_name(b".hidden"), "hidden");
800        assert_eq!(sanitize_file_name(b"a\x1bb?.txt"), "a_b_.txt");
801    }
802}