Skip to main content

badterm_core/
xmodem.rs

1//! XMODEM (checksum, CRC and 1K) and YMODEM (batch, and the streaming
2//! YMODEM-G) file transfers, for the BBSes and doors that offer them.
3//!
4//! Like ZMODEM in [`crate::transfer`], each side is a state machine fed the
5//! bytes from the remote, appending what it wants sent. Neither protocol
6//! announces itself, so the user starts them: a download asks the sender to
7//! begin (with 'C', NAK or 'G'), an upload waits for the receiver to.
8
9use std::collections::VecDeque;
10use std::fs::{self, File};
11use std::io::{BufWriter, Read, Write};
12use std::path::PathBuf;
13use std::time::{Duration, Instant, UNIX_EPOCH};
14
15use anyhow::{Result, bail};
16
17use crate::transfer::{self, Common, Direction, Outcome, Progress};
18
19const SOH: u8 = 0x01;
20const STX: u8 = 0x02;
21const EOT: u8 = 0x04;
22const ACK: u8 = 0x06;
23const NAK: u8 = 0x15;
24const CAN: u8 = 0x18;
25/// XMODEM pads the last block with this.
26const SUB: u8 = 0x1A;
27
28/// How often a receiver asks the sender to start, and how many times.
29const START_EVERY: Duration = Duration::from_secs(3);
30const START_TRIES: u32 = 20;
31/// Retries of one block (or EOT) before giving up.
32const MAX_RETRIES: u32 = 10;
33/// Blocks sent per call while streaming (YMODEM-G).
34const STREAM_BURST: usize = 64;
35
36#[derive(Clone, Copy, Debug, PartialEq, Eq)]
37pub enum XyProtocol {
38    Ymodem,
39    /// YMODEM without acknowledgements, for error-free links.
40    YmodemG,
41    Xmodem1k,
42    XmodemCrc,
43    /// The original: 128-byte blocks with a checksum.
44    Xmodem,
45}
46
47impl XyProtocol {
48    pub const ALL: [XyProtocol; 5] =
49        [XyProtocol::Ymodem, XyProtocol::YmodemG, XyProtocol::Xmodem1k, XyProtocol::XmodemCrc, XyProtocol::Xmodem];
50
51    pub fn name(self) -> &'static str {
52        match self {
53            XyProtocol::Ymodem => "YMODEM",
54            XyProtocol::YmodemG => "YMODEM-G",
55            XyProtocol::Xmodem1k => "XMODEM-1K",
56            XyProtocol::XmodemCrc => "XMODEM-CRC",
57            XyProtocol::Xmodem => "XMODEM",
58        }
59    }
60
61    /// YMODEM sends file names and sizes, and several files at once.
62    pub fn batch(self) -> bool {
63        matches!(self, XyProtocol::Ymodem | XyProtocol::YmodemG)
64    }
65
66    fn crc(self) -> bool {
67        self != XyProtocol::Xmodem
68    }
69
70    fn streaming(self) -> bool {
71        self == XyProtocol::YmodemG
72    }
73
74    fn block_size(self) -> usize {
75        match self {
76            XyProtocol::Xmodem | XyProtocol::XmodemCrc => 128,
77            _ => 1024,
78        }
79    }
80
81    /// What a receiver sends to start the sender.
82    fn start_char(self) -> u8 {
83        match self {
84            XyProtocol::Xmodem => NAK,
85            XyProtocol::YmodemG => b'G',
86            _ => b'C',
87        }
88    }
89}
90
91/// CRC-16/XMODEM: polynomial 0x1021, starting at 0.
92pub fn crc16(data: &[u8]) -> u16 {
93    let mut crc: u16 = 0;
94    for &b in data {
95        crc ^= (b as u16) << 8;
96        for _ in 0..8 {
97            crc = if crc & 0x8000 != 0 { (crc << 1) ^ 0x1021 } else { crc << 1 };
98        }
99    }
100    crc
101}
102
103/// A block on the wire: SOH (128 bytes) or STX (1024), its number twice,
104/// the data, and a CRC or checksum.
105fn block(num: u8, data: &[u8], crc: bool) -> Vec<u8> {
106    let mut out = Vec::with_capacity(data.len() + 5);
107    out.push(if data.len() == 1024 { STX } else { SOH });
108    out.push(num);
109    out.push(!num);
110    out.extend_from_slice(data);
111    if crc {
112        out.extend_from_slice(&crc16(data).to_be_bytes());
113    } else {
114        out.push(data.iter().fold(0u8, |a, &b| a.wrapping_add(b)));
115    }
116    out
117}
118
119#[derive(Clone, Copy, Debug, PartialEq, Eq)]
120enum RState {
121    /// Waiting for YMODEM's block 0: a file's name and size, or the end.
122    Header,
123    Data,
124    Done,
125}
126
127/// Receiving files.
128pub struct XyReceive {
129    proto: XyProtocol,
130    dir: PathBuf,
131    /// XMODEM sends no name; this is what the file is saved as.
132    name: String,
133    state: RState,
134    expected: u8,
135    /// No block of the current file yet: keep asking the sender to start.
136    starting: bool,
137    last_ask: Option<Instant>,
138    asks: u32,
139    retries: u32,
140    file: Option<(BufWriter<File>, PathBuf)>,
141    /// YMODEM's file size, to cut the padding off the last block.
142    remaining: Option<u64>,
143    /// XMODEM's last block, held back until EOT so its padding can go.
144    held: Vec<u8>,
145    eots: u32,
146    saved: Vec<PathBuf>,
147    pub(crate) c: Common,
148}
149
150impl XyReceive {
151    pub fn new(proto: XyProtocol, dir: PathBuf, name: Option<String>) -> XyReceive {
152        let name =
153            name.map(|n| transfer::sanitize_file_name(n.as_bytes())).unwrap_or_else(|| "xmodem-download".to_string());
154        XyReceive {
155            proto,
156            dir,
157            name,
158            state: if proto.batch() { RState::Header } else { RState::Data },
159            expected: 1,
160            starting: true,
161            last_ask: None,
162            asks: 0,
163            retries: 0,
164            file: None,
165            remaining: None,
166            held: Vec::new(),
167            eots: 0,
168            saved: Vec::new(),
169            c: Common::new(Direction::Download),
170        }
171    }
172
173    pub(crate) fn progress(&self) -> &Progress {
174        &self.c.progress
175    }
176
177    pub fn protocol(&self) -> XyProtocol {
178        self.proto
179    }
180
181    fn fail(&mut self, msg: impl Into<String>, out: &mut Vec<u8>) {
182        if self.c.outcome.is_none() {
183            out.extend_from_slice(transfer::CANCEL);
184            self.c.outcome = Some(Outcome::Failed(msg.into()));
185        }
186        self.drop_file();
187    }
188
189    /// A partly received file is removed.
190    fn drop_file(&mut self) {
191        if let Some((_, path)) = self.file.take() {
192            let _ = fs::remove_file(path);
193        }
194    }
195
196    fn ask(&mut self, out: &mut Vec<u8>) {
197        out.push(self.proto.start_char());
198        self.last_ask = Some(Instant::now());
199        self.starting = true;
200    }
201
202    /// Drive timeouts: ask the sender to start (again), or to resend.
203    pub(crate) fn tick(&mut self, now: Instant, out: &mut Vec<u8>) {
204        if self.c.outcome.is_some() || self.state == RState::Done {
205            return;
206        }
207        if self.starting {
208            if self.last_ask.is_none_or(|t| now.duration_since(t) >= START_EVERY) {
209                self.asks += 1;
210                if self.asks > START_TRIES {
211                    return self.fail("the sender never started", out);
212                }
213                self.ask(out);
214            }
215            return;
216        }
217        if now.duration_since(self.c.last_input) >= transfer::TIMEOUT {
218            self.c.last_input = now;
219            self.retries += 1;
220            if self.retries > MAX_RETRIES || self.proto.streaming() {
221                return self.fail("the sender stopped", out);
222            }
223            out.push(NAK);
224        }
225    }
226
227    pub(crate) fn pump(&mut self, out: &mut Vec<u8>) {
228        if self.state == RState::Done && self.c.outcome.is_none() {
229            self.c.outcome = Some(Outcome::Completed(self.saved.clone()));
230        }
231        while self.c.outcome.is_none() && !self.c.pending.is_empty() {
232            let first = self.c.pending[0];
233            match first {
234                SOH | STX => {
235                    let size = if first == SOH { 128 } else { 1024 };
236                    let need = 3 + size + if self.proto.crc() { 2 } else { 1 };
237                    if self.c.pending.len() < need {
238                        return;
239                    }
240                    let raw: Vec<u8> = self.c.pending.drain(..need).collect();
241                    let (num, inv, data) = (raw[1], raw[2], &raw[3..3 + size]);
242                    let good = num == !inv
243                        && if self.proto.crc() {
244                            raw[3 + size..] == crc16(data).to_be_bytes()
245                        } else {
246                            raw[3 + size] == data.iter().fold(0u8, |a, &b| a.wrapping_add(b))
247                        };
248                    if !good {
249                        if self.proto.streaming() {
250                            return self.fail("a damaged block (YMODEM-G cannot resend)", out);
251                        }
252                        out.push(NAK);
253                        continue;
254                    }
255                    self.retries = 0;
256                    self.block(num, data.to_vec(), out);
257                }
258                EOT => {
259                    self.c.pending.remove(0);
260                    self.eot(out);
261                }
262                CAN => {
263                    if self.c.pending.len() < 2 {
264                        return;
265                    }
266                    if self.c.pending[1] == CAN {
267                        self.drop_file();
268                        self.c.aborted();
269                        return;
270                    }
271                    self.c.pending.remove(0);
272                }
273                // Noise between blocks.
274                _ => {
275                    self.c.pending.remove(0);
276                }
277            }
278        }
279        if self.state == RState::Done && self.c.outcome.is_none() {
280            self.c.outcome = Some(Outcome::Completed(self.saved.clone()));
281        }
282    }
283
284    fn block(&mut self, num: u8, data: Vec<u8>, out: &mut Vec<u8>) {
285        let streaming = self.proto.streaming();
286        match self.state {
287            RState::Header if num == 0 => {
288                self.starting = false;
289                out.push(ACK);
290                // The end of the batch: an empty name.
291                if data[0] == 0 {
292                    self.state = RState::Done;
293                    return;
294                }
295                let name_end = data.iter().position(|&b| b == 0).unwrap_or(data.len());
296                let name = transfer::sanitize_file_name(&data[..name_end]);
297                let rest = &data[(name_end + 1).min(data.len())..];
298                let info = String::from_utf8_lossy(&rest[..rest.iter().position(|&b| b == 0).unwrap_or(rest.len())])
299                    .into_owned();
300                let size = info.split_whitespace().next().and_then(|s| s.parse::<u64>().ok());
301                if let Err(e) = self.open(&name, size) {
302                    return self.fail(e.to_string(), out);
303                }
304                self.state = RState::Data;
305                self.expected = 1;
306                // Now for the data.
307                self.ask(out);
308            }
309            RState::Header => {
310                if !streaming {
311                    out.push(NAK);
312                }
313            }
314            // The sender missed our ACK of the header and sent it again.
315            RState::Data if num == 0 && self.proto.batch() && self.expected == 1 => {
316                out.push(ACK);
317                self.ask(out);
318            }
319            RState::Data if num == self.expected => {
320                if self.starting && !self.proto.batch() {
321                    let name = self.name.clone();
322                    if let Err(e) = self.open(&name, None) {
323                        return self.fail(e.to_string(), out);
324                    }
325                }
326                self.starting = false;
327                self.expected = self.expected.wrapping_add(1);
328                if let Err(e) = self.write(data) {
329                    return self.fail(e, out);
330                }
331                if !streaming {
332                    out.push(ACK);
333                }
334            }
335            RState::Data if num == self.expected.wrapping_sub(1) => {
336                // A repeat of the last block: our ACK was lost.
337                if !streaming {
338                    out.push(ACK);
339                }
340            }
341            RState::Data => self.fail(format!("blocks out of order (got {num}, wanted {})", self.expected), out),
342            RState::Done => {}
343        }
344    }
345
346    fn open(&mut self, name: &str, size: Option<u64>) -> std::io::Result<()> {
347        fs::create_dir_all(&self.dir)?;
348        let path = transfer::unique_path(&self.dir, name)?;
349        let f = File::create(&path)?;
350        self.file = Some((BufWriter::new(f), path));
351        self.remaining = size;
352        self.held.clear();
353        self.eots = 0;
354        let p = &mut self.c.progress;
355        p.file_name = name.to_string();
356        p.file_size = size;
357        p.file_bytes = 0;
358        p.file_started = Instant::now();
359        Ok(())
360    }
361
362    fn write(&mut self, mut data: Vec<u8>) -> Result<(), String> {
363        if let Some(left) = &mut self.remaining {
364            data.truncate((*left).min(data.len() as u64) as usize);
365            *left -= data.len() as u64;
366        } else {
367            // XMODEM: write the block before this one; this one may be the
368            // last, with padding to cut.
369            std::mem::swap(&mut self.held, &mut data);
370        }
371        self.c.progress.file_bytes += data.len() as u64;
372        match &mut self.file {
373            Some((f, path)) => f.write_all(&data).map_err(|e| format!("writing {}: {e}", path.display())),
374            None => Ok(()),
375        }
376    }
377
378    fn eot(&mut self, out: &mut Vec<u8>) {
379        if self.state != RState::Data || self.file.is_none() && self.proto.batch() {
380            // No file under way (an empty YMODEM file has one): just
381            // acknowledge it.
382            out.push(ACK);
383            return;
384        }
385        if self.file.is_none() {
386            // An empty XMODEM file is just an EOT.
387            let name = self.name.clone();
388            if let Err(e) = self.open(&name, None) {
389                return self.fail(e.to_string(), out);
390            }
391            self.starting = false;
392        }
393        // NAK the first EOT, as YMODEM asks, in case it was line noise.
394        if !self.proto.streaming() && self.eots == 0 {
395            self.eots = 1;
396            out.push(NAK);
397            return;
398        }
399        out.push(ACK);
400        if let Err(e) = self.finish_file() {
401            return self.fail(e, out);
402        }
403        if self.proto.batch() {
404            self.state = RState::Header;
405            self.ask(out);
406        } else {
407            self.state = RState::Done;
408        }
409    }
410
411    fn finish_file(&mut self) -> Result<(), String> {
412        let mut last = std::mem::take(&mut self.held);
413        while last.last() == Some(&SUB) {
414            last.pop();
415        }
416        self.c.progress.file_bytes += last.len() as u64;
417        if let Some((mut f, path)) = self.file.take() {
418            f.write_all(&last).and_then(|()| f.flush()).map_err(|e| format!("writing {}: {e}", path.display()))?;
419            self.saved.push(path);
420        }
421        self.c.progress.files_done += 1;
422        Ok(())
423    }
424
425    pub(crate) fn cancel(&mut self, out: &mut Vec<u8>) {
426        self.c.cancelled = true;
427        out.extend_from_slice(transfer::CANCEL);
428        self.drop_file();
429        self.c.outcome.get_or_insert(Outcome::Cancelled);
430    }
431}
432
433#[derive(Clone, Copy, Debug, PartialEq, Eq)]
434enum SState {
435    /// Waiting for the receiver's 'C', NAK or 'G' to send a header (YMODEM)
436    /// or the first block (XMODEM).
437    Start,
438    HeaderAck,
439    /// The header was acknowledged; waiting for the go-ahead for data.
440    DataStart,
441    /// A block is out; waiting for its ACK.
442    BlockAck,
443    Streaming,
444    EotAck,
445    /// YMODEM: waiting to send the empty header that ends the batch.
446    EndStart,
447    EndAck,
448    Done,
449}
450
451/// Sending files.
452pub struct XySend {
453    proto: XyProtocol,
454    queue: VecDeque<PathBuf>,
455    file: Option<(File, PathBuf)>,
456    size: u64,
457    sent_bytes: u64,
458    sent: Vec<PathBuf>,
459    state: SState,
460    num: u8,
461    /// The last block (or EOT) sent, for resending.
462    last: Vec<u8>,
463    crc: bool,
464    streaming: bool,
465    retries: u32,
466    pub(crate) c: Common,
467}
468
469impl XySend {
470    pub fn new(proto: XyProtocol, paths: Vec<PathBuf>) -> Result<XySend> {
471        if paths.is_empty() {
472            bail!("no files to upload");
473        }
474        if !proto.batch() && paths.len() > 1 {
475            bail!("{} sends one file at a time; YMODEM sends several", proto.name());
476        }
477        for p in &paths {
478            transfer::check_upload(p)?;
479        }
480        let mut c = Common::new(Direction::Upload);
481        c.progress.files_queued = paths.len();
482        Ok(XySend {
483            proto,
484            queue: paths.into(),
485            file: None,
486            size: 0,
487            sent_bytes: 0,
488            sent: Vec::new(),
489            state: SState::Start,
490            num: 0,
491            last: Vec::new(),
492            crc: proto.crc(),
493            streaming: false,
494            retries: 0,
495            c,
496        })
497    }
498
499    pub(crate) fn progress(&self) -> &Progress {
500        &self.c.progress
501    }
502
503    pub fn protocol(&self) -> XyProtocol {
504        self.proto
505    }
506
507    pub fn enqueue(&mut self, path: PathBuf) -> Result<()> {
508        if !self.proto.batch() {
509            bail!("{} sends one file at a time", self.proto.name());
510        }
511        if matches!(self.state, SState::EndStart | SState::EndAck | SState::Done) {
512            bail!("the upload is already finishing");
513        }
514        transfer::check_upload(&path)?;
515        self.queue.push_back(path);
516        self.c.progress.files_queued = self.queue.len();
517        Ok(())
518    }
519
520    fn fail(&mut self, msg: impl Into<String>, out: &mut Vec<u8>) {
521        if self.c.outcome.is_none() {
522            out.extend_from_slice(transfer::CANCEL);
523            self.c.outcome = Some(Outcome::Failed(msg.into()));
524        }
525    }
526
527    fn send(&mut self, bytes: Vec<u8>, out: &mut Vec<u8>) {
528        out.extend_from_slice(&bytes);
529        self.last = bytes;
530    }
531
532    /// Open the next file, if any.
533    fn next_file(&mut self) -> Result<bool, String> {
534        let Some(path) = self.queue.pop_front() else { return Ok(false) };
535        self.c.progress.files_queued = self.queue.len();
536        let f = File::open(&path).map_err(|e| format!("opening {}: {e}", path.display()))?;
537        self.size = f.metadata().map(|m| m.len()).unwrap_or(0);
538        self.sent_bytes = 0;
539        let p = &mut self.c.progress;
540        p.file_name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
541        p.file_size = Some(self.size);
542        p.file_bytes = 0;
543        p.file_started = Instant::now();
544        self.file = Some((f, path));
545        Ok(true)
546    }
547
548    /// YMODEM's block 0: name, size, modification time.
549    fn header(&self) -> Vec<u8> {
550        let (f, path) = self.file.as_ref().expect("a file is open");
551        let mut data = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default().into_bytes();
552        data.push(0);
553        let mtime = f
554            .metadata()
555            .ok()
556            .and_then(|m| m.modified().ok())
557            .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
558            .map_or(0, |d| d.as_secs());
559        data.extend_from_slice(format!("{} {mtime:o} 0", self.size).as_bytes());
560        data.push(0);
561        let len = if data.len() <= 128 { 128 } else { 1024 };
562        data.resize(len, 0);
563        block(0, &data, true)
564    }
565
566    /// The next data block, or None at the end of the file.
567    fn data_block(&mut self) -> Result<Option<Vec<u8>>, String> {
568        let left = self.size.saturating_sub(self.sent_bytes);
569        if left == 0 {
570            return Ok(None);
571        }
572        // 1K blocks need CRC; a short end goes in a 128-byte block.
573        let size = if self.crc && self.proto.block_size() == 1024 && left > 128 { 1024 } else { 128 };
574        let mut data = vec![0; size];
575        let (f, path) = self.file.as_mut().expect("a file is open");
576        let mut n = 0;
577        while n < size {
578            match f.read(&mut data[n..]) {
579                Ok(0) => break,
580                Ok(k) => n += k,
581                Err(e) => return Err(format!("reading {}: {e}", path.display())),
582            }
583        }
584        if n == 0 {
585            return Err(format!("{} got shorter while sending", path.display()));
586        }
587        data[n..].fill(SUB);
588        self.sent_bytes += n as u64;
589        self.c.progress.file_bytes = self.sent_bytes;
590        self.num = self.num.wrapping_add(1);
591        Ok(Some(block(self.num, &data, self.crc)))
592    }
593
594    /// Send the next block, or EOT once the file is done.
595    fn next_block(&mut self, out: &mut Vec<u8>) {
596        match self.data_block() {
597            Ok(Some(b)) => {
598                self.send(b, out);
599                self.state = if self.streaming { SState::Streaming } else { SState::BlockAck };
600            }
601            Ok(None) => {
602                self.send(vec![EOT], out);
603                self.state = SState::EotAck;
604            }
605            Err(e) => self.fail(e, out),
606        }
607    }
608
609    /// The receiver asked to start: with CRC ('C'), a checksum (NAK) or
610    /// streaming ('G').
611    fn started_with(&mut self, b: u8) {
612        self.crc = b != NAK;
613        self.streaming = b == b'G';
614    }
615
616    pub(crate) fn pump(&mut self, out: &mut Vec<u8>) {
617        if self.state == SState::Start && self.file.is_none() && self.c.outcome.is_none() {
618            match self.next_file() {
619                Ok(true) => {}
620                Ok(false) => self.state = SState::EndStart,
621                Err(e) => return self.fail(e, out),
622            }
623        }
624        while self.c.outcome.is_none() {
625            if self.state == SState::Streaming {
626                // Stream a burst, unless the receiver has something to say.
627                if !self.c.pending.is_empty() && self.c.pending.contains(&CAN) {
628                    // Fall through to read it.
629                } else {
630                    for _ in 0..STREAM_BURST {
631                        self.next_block(out);
632                        if self.state != SState::Streaming {
633                            break;
634                        }
635                    }
636                    if self.state == SState::Streaming {
637                        return;
638                    }
639                    continue;
640                }
641            }
642            if self.c.pending.is_empty() {
643                return;
644            }
645            let b = self.c.pending.remove(0);
646            if b == CAN {
647                if self.c.pending.first() == Some(&CAN) {
648                    self.c.aborted();
649                    return;
650                }
651                continue;
652            }
653            let go = matches!(b, b'C' | b'G' | NAK);
654            match self.state {
655                SState::Start if go => {
656                    self.started_with(b);
657                    self.num = 0;
658                    if self.proto.batch() {
659                        let h = self.header();
660                        self.send(h, out);
661                        self.state = SState::HeaderAck;
662                    } else {
663                        self.next_block(out);
664                    }
665                }
666                SState::HeaderAck if b == ACK => self.state = SState::DataStart,
667                // Some receivers go straight on without the ACK (YMODEM-G).
668                SState::HeaderAck | SState::DataStart if b == b'C' || b == b'G' => {
669                    self.started_with(b);
670                    self.next_block(out);
671                }
672                SState::HeaderAck if b == NAK => self.resend(out),
673                SState::BlockAck if b == ACK => {
674                    self.retries = 0;
675                    self.next_block(out);
676                }
677                SState::BlockAck | SState::EotAck if b == NAK => self.resend(out),
678                SState::EotAck if b == ACK => {
679                    self.retries = 0;
680                    if let Some((_, path)) = self.file.take() {
681                        self.sent.push(path);
682                    }
683                    self.c.progress.files_done += 1;
684                    if self.proto.batch() {
685                        self.state = SState::Start;
686                        match self.next_file() {
687                            Ok(true) => {}
688                            Ok(false) => self.state = SState::EndStart,
689                            Err(e) => return self.fail(e, out),
690                        }
691                    } else {
692                        self.state = SState::Done;
693                        self.c.outcome = Some(Outcome::Completed(self.sent.clone()));
694                    }
695                }
696                SState::EndStart if go => {
697                    self.send(block(0, &[0; 128], true), out);
698                    self.state = SState::EndAck;
699                }
700                SState::EndAck if b == ACK => {
701                    self.state = SState::Done;
702                    self.c.outcome = Some(Outcome::Completed(self.sent.clone()));
703                }
704                SState::EndAck if b == NAK => self.resend(out),
705                _ => {}
706            }
707        }
708    }
709
710    fn resend(&mut self, out: &mut Vec<u8>) {
711        self.retries += 1;
712        if self.retries > MAX_RETRIES {
713            return self.fail("too many errors", out);
714        }
715        out.extend_from_slice(&self.last);
716    }
717
718    pub(crate) fn tick(&mut self, now: Instant, out: &mut Vec<u8>) {
719        if self.c.outcome.is_some() || now.duration_since(self.c.last_input) < transfer::TIMEOUT {
720            return;
721        }
722        self.c.last_input = now;
723        match self.state {
724            SState::Start | SState::EndStart => {
725                self.c.timeouts += 1;
726                if self.c.timeouts >= transfer::MAX_TIMEOUTS {
727                    self.fail("the receiver never started", out);
728                }
729            }
730            SState::Streaming => {}
731            _ => self.resend(out),
732        }
733    }
734
735    pub(crate) fn cancel(&mut self, out: &mut Vec<u8>) {
736        self.c.cancelled = true;
737        out.extend_from_slice(transfer::CANCEL);
738        self.c.outcome.get_or_insert(Outcome::Cancelled);
739    }
740}
741
742#[cfg(test)]
743mod tests {
744    use super::*;
745
746    fn temp_dir(name: &str) -> PathBuf {
747        let d = std::env::temp_dir().join(format!("badterm-xy-{name}-{}", std::process::id()));
748        let _ = fs::remove_dir_all(&d);
749        fs::create_dir_all(&d).unwrap();
750        d
751    }
752
753    /// Shuttle bytes between a sender and a receiver until both finish.
754    fn run(tx: &mut XySend, rx: &mut XyReceive) {
755        let mut to_rx: Vec<u8> = Vec::new();
756        let mut to_tx: Vec<u8> = Vec::new();
757        rx.tick(Instant::now(), &mut to_tx);
758        for _ in 0..100_000 {
759            if tx.c.outcome.is_some() && rx.c.outcome.is_some() {
760                return;
761            }
762            let mut out = Vec::new();
763            tx.c.input(&std::mem::take(&mut to_tx));
764            tx.pump(&mut out);
765            to_rx.extend(out);
766            let mut out = Vec::new();
767            rx.c.input(&std::mem::take(&mut to_rx));
768            rx.pump(&mut out);
769            to_tx.extend(out);
770        }
771        panic!("stuck: tx {:?} {:?}, rx {:?} {:?}", tx.state, tx.c.outcome, rx.state, rx.c.outcome);
772    }
773
774    #[test]
775    fn crc16_check_value() {
776        assert_eq!(crc16(b"123456789"), 0x31C3);
777    }
778
779    #[test]
780    fn every_protocol_round_trips() {
781        let src = temp_dir("src");
782        // Sizes on and around the block boundaries, including a file ending
783        // in SUB bytes, which only YMODEM (knowing the size) keeps.
784        let files: Vec<(PathBuf, Vec<u8>)> = [(0usize, 0u8), (1, 7), (128, 9), (1025, 3), (5000, SUB)]
785            .iter()
786            .enumerate()
787            .map(|(i, &(len, fill))| {
788                let mut data: Vec<u8> = (0..len as u32).map(|j| (j * 31 + i as u32) as u8).collect();
789                if fill == SUB && len > 0 {
790                    data[len - 3..].fill(SUB);
791                }
792                let p = src.join(format!("file{i}.bin"));
793                fs::write(&p, &data).unwrap();
794                (p, data)
795            })
796            .collect();
797        for proto in XyProtocol::ALL {
798            let dst = temp_dir(proto.name());
799            let one = if proto == XyProtocol::Xmodem { 0 } else { 3 };
800            let paths: Vec<PathBuf> =
801                if proto.batch() { files.iter().map(|(p, _)| p.clone()).collect() } else { vec![files[one].0.clone()] };
802            let mut tx = XySend::new(proto, paths.clone()).unwrap();
803            let mut rx = XyReceive::new(proto, dst.clone(), Some("got.bin".into()));
804            run(&mut tx, &mut rx);
805            assert_eq!(tx.c.outcome, Some(Outcome::Completed(paths.clone())), "{}", proto.name());
806            let Some(Outcome::Completed(saved)) = rx.c.outcome.clone() else { panic!("{}", proto.name()) };
807            assert_eq!(saved.len(), paths.len(), "{}", proto.name());
808            for (path, (_, data)) in saved.iter().zip(files.iter().skip(if proto.batch() { 0 } else { one })) {
809                let got = fs::read(path).unwrap();
810                assert_eq!(&got, data, "{} {}", proto.name(), path.display());
811            }
812            if !proto.batch() {
813                assert_eq!(saved[0].file_name().unwrap(), "got.bin");
814            }
815        }
816    }
817
818    #[test]
819    fn damaged_blocks_are_resent() {
820        let src = temp_dir("noise");
821        let data: Vec<u8> = (0..3000u32).map(|i| (i * 7) as u8).collect();
822        fs::write(src.join("a.bin"), &data).unwrap();
823        let dst = temp_dir("noise-out");
824        let mut tx = XySend::new(XyProtocol::Ymodem, vec![src.join("a.bin")]).unwrap();
825        let mut rx = XyReceive::new(XyProtocol::Ymodem, dst, None);
826        let mut to_rx: Vec<u8> = Vec::new();
827        let mut to_tx = Vec::new();
828        rx.tick(Instant::now(), &mut to_tx);
829        let mut flipped = false;
830        for _ in 0..10_000 {
831            let mut out = Vec::new();
832            tx.c.input(&std::mem::take(&mut to_tx));
833            tx.pump(&mut out);
834            // Damage the first data block once.
835            if !flipped && out.len() > 1000 && out[0] == STX && out[1] == 1 {
836                out[500] ^= 0xFF;
837                flipped = true;
838            }
839            to_rx.extend(out);
840            let mut out = Vec::new();
841            rx.c.input(&std::mem::take(&mut to_rx));
842            rx.pump(&mut out);
843            to_tx.extend(out);
844            if tx.c.outcome.is_some() && rx.c.outcome.is_some() {
845                break;
846            }
847        }
848        assert!(flipped);
849        let Some(Outcome::Completed(saved)) = rx.c.outcome.clone() else { panic!("{:?}", rx.c.outcome) };
850        assert_eq!(fs::read(&saved[0]).unwrap(), data);
851    }
852
853    #[test]
854    fn xmodem_takes_one_file_and_cancel_is_heard() {
855        let src = temp_dir("one");
856        fs::write(src.join("a"), b"a").unwrap();
857        fs::write(src.join("b"), b"b").unwrap();
858        assert!(XySend::new(XyProtocol::Xmodem, vec![src.join("a"), src.join("b")]).is_err());
859        let mut rx = XyReceive::new(XyProtocol::XmodemCrc, temp_dir("cancel"), None);
860        let mut out = Vec::new();
861        rx.c.input(&[CAN, CAN, CAN]);
862        rx.pump(&mut out);
863        assert!(matches!(rx.c.outcome, Some(Outcome::Failed(_))));
864    }
865}