Skip to main content

badterm_core/
dialer.rs

1//! The dialing directory: saved BBS entries, stored as TOML, with import and
2//! export in SyncTERM `.lst`, CSV, JSON and TOML formats.
3
4use std::fmt;
5use std::fs;
6use std::path::Path;
7use std::time::{SystemTime, UNIX_EPOCH};
8
9use anyhow::{Context, Result, bail};
10use serde::{Deserialize, Serialize};
11
12use crate::ansi::Encoding;
13
14#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "lowercase")]
16pub enum Protocol {
17    #[default]
18    Telnet,
19    /// Telnet over TLS.
20    #[serde(rename = "telnets")]
21    TelnetS,
22    /// Plain TCP with no telnet processing.
23    Raw,
24    Ssh,
25    Rlogin,
26}
27
28impl Protocol {
29    pub const ALL: [Protocol; 5] =
30        [Protocol::Telnet, Protocol::TelnetS, Protocol::Raw, Protocol::Ssh, Protocol::Rlogin];
31
32    pub fn default_port(self) -> u16 {
33        match self {
34            Protocol::Telnet | Protocol::Raw => 23,
35            Protocol::TelnetS => 992,
36            Protocol::Ssh => 22,
37            Protocol::Rlogin => 513,
38        }
39    }
40
41    /// SyncTERM's names for them, too.
42    pub fn name(self) -> &'static str {
43        match self {
44            Protocol::Telnet => "Telnet",
45            Protocol::TelnetS => "TelnetS",
46            Protocol::Raw => "Raw",
47            Protocol::Ssh => "SSH",
48            Protocol::Rlogin => "RLogin",
49        }
50    }
51
52    /// Whether the connection speaks the telnet protocol.
53    pub fn is_telnet(self) -> bool {
54        matches!(self, Protocol::Telnet | Protocol::TelnetS)
55    }
56
57    fn from_name(s: &str) -> Option<Protocol> {
58        match s.trim().to_ascii_lowercase().as_str() {
59            "telnet" => Some(Protocol::Telnet),
60            "telnets" => Some(Protocol::TelnetS),
61            "raw" => Some(Protocol::Raw),
62            "ssh" | "sshna" => Some(Protocol::Ssh),
63            "rlogin" | "rloginreversed" => Some(Protocol::Rlogin),
64            _ => None,
65        }
66    }
67}
68
69impl fmt::Display for Protocol {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        f.write_str(self.name())
72    }
73}
74
75#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(default)]
77pub struct Entry {
78    pub name: String,
79    pub host: String,
80    pub port: u16,
81    pub protocol: Protocol,
82    pub encoding: Encoding,
83    pub username: String,
84    /// Stored in plain text, as in other BBS dialers.
85    pub password: String,
86    /// Answer the BBS's login prompts with `username` and `password`.
87    #[serde(skip_serializing_if = "is_true")]
88    pub auto_login: bool,
89    pub term_type: String,
90    /// Screen size: one of [`SCREEN_MODES`].
91    #[serde(skip_serializing_if = "is_80")]
92    pub cols: u16,
93    #[serde(skip_serializing_if = "is_25")]
94    pub rows: u16,
95    /// The font, as a CTerm font ID: 0 for CP437, or an Amiga font for
96    /// Amiga BBSes.
97    #[serde(skip_serializing_if = "is_zero")]
98    pub font: u8,
99    /// iCE colors (the blink bit as a bright background) from the start:
100    /// None leaves it to the BBS (`CSI ?33h`), as before.
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub ice: Option<bool>,
103    /// Baud rate emulation: show what the BBS sends at this speed, as over
104    /// a modem (0: as fast as it arrives).
105    #[serde(skip_serializing_if = "is_zero_u32")]
106    pub baud: u32,
107    /// Phosphor mode for this system; None follows the global setting.
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub crt: Option<CrtMode>,
110    /// SSH private key to try first; otherwise the SSH agent and the usual
111    /// `~/.ssh/id_*` files are tried.
112    #[serde(skip_serializing_if = "String::is_empty")]
113    pub key_file: String,
114    pub comment: String,
115    /// The password is kept in the OS keychain rather than in `password`.
116    #[serde(skip_serializing_if = "is_false")]
117    pub keychain: bool,
118    /// Pinned entries sort ahead of the rest.
119    #[serde(skip_serializing_if = "is_false")]
120    pub pinned: bool,
121    /// Unix timestamps.
122    pub added: Option<u64>,
123    pub last_connected: Option<u64>,
124    pub calls: u32,
125}
126
127impl Default for Entry {
128    fn default() -> Self {
129        Entry {
130            name: String::new(),
131            host: String::new(),
132            port: 23,
133            protocol: Protocol::Telnet,
134            encoding: Encoding::Cp437,
135            username: String::new(),
136            password: String::new(),
137            auto_login: true,
138            term_type: "ANSI".into(),
139            cols: 80,
140            rows: 25,
141            font: 0,
142            ice: None,
143            baud: 0,
144            crt: None,
145            key_file: String::new(),
146            comment: String::new(),
147            keychain: false,
148            pinned: false,
149            added: None,
150            last_connected: None,
151            calls: 0,
152        }
153    }
154}
155
156impl Entry {
157    /// The screen size, (columns, rows): the entry's, if it is one BadTerm
158    /// has, or 80x25.
159    pub fn screen_mode(&self) -> (u16, u16) {
160        let mode = (self.cols, self.rows);
161        if SCREEN_MODES.contains(&mode) { mode } else { SCREEN_MODES[0] }
162    }
163
164    /// Parse `telnet://host:port`, `ssh://user@host`, `host:port` or `host`
165    /// into an entry.
166    pub fn from_address(addr: &str) -> Option<Entry> {
167        let addr = addr.trim();
168        let (protocol, rest) = match addr.split_once("://") {
169            Some((scheme, rest)) => (Protocol::from_name(scheme)?, rest),
170            None => (Protocol::Telnet, addr),
171        };
172        let rest = rest.trim_end_matches('/');
173        let (username, rest) = match rest.rsplit_once('@') {
174            Some((user, host)) => (user.to_string(), host),
175            None => (String::new(), rest),
176        };
177        let (host, port) = split_host_port(rest)?;
178        let port = port.unwrap_or(protocol.default_port());
179        Some(Entry { name: host.clone(), host, port, protocol, username, added: Some(now()), ..Entry::default() })
180    }
181
182    pub fn address(&self) -> String {
183        if self.port == self.protocol.default_port() {
184            self.host.clone()
185        } else if self.host.contains(':') {
186            format!("[{}]:{}", self.host, self.port)
187        } else {
188            format!("{}:{}", self.host, self.port)
189        }
190    }
191
192    fn same_system(&self, other: &Entry) -> bool {
193        self.host.eq_ignore_ascii_case(&other.host) && self.port == other.port && self.protocol == other.protocol
194    }
195
196    /// Identifies the system (protocol, host and port), e.g. for remembering
197    /// which default entries were already offered.
198    pub fn system_id(&self) -> String {
199        format!("{}://{}:{}", self.protocol.name().to_ascii_lowercase(), self.host.to_ascii_lowercase(), self.port)
200    }
201}
202
203fn is_false(b: &bool) -> bool {
204    !*b
205}
206
207fn is_true(b: &bool) -> bool {
208    *b
209}
210
211fn is_zero(n: &u8) -> bool {
212    *n == 0
213}
214
215fn is_zero_u32(n: &u32) -> bool {
216    *n == 0
217}
218
219/// The baud rates an entry can emulate; 0 is no limit.
220pub const BAUD_RATES: [u32; 11] = [0, 300, 1200, 2400, 9600, 14400, 19200, 28800, 33600, 57600, 115200];
221
222fn is_80(n: &u16) -> bool {
223    *n == 80
224}
225
226fn is_25(n: &u16) -> bool {
227    *n == 25
228}
229
230/// The screen sizes (columns, rows) an entry can ask for. Modes of 43 rows
231/// or more use the 8x8 font.
232pub const SCREEN_MODES: [(u16, u16); 6] = [(80, 25), (80, 43), (80, 50), (132, 25), (132, 37), (132, 52)];
233
234/// "Phosphor mode": the look of a CRT, drawn by the front end.
235#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
236#[serde(rename_all = "lowercase")]
237pub enum CrtMode {
238    #[default]
239    Off,
240    /// A color CRT.
241    Color,
242    /// A green-badterm monochrome monitor (P1).
243    Green,
244    /// An amber monochrome monitor (P3).
245    Amber,
246}
247
248impl CrtMode {
249    pub const ALL: [CrtMode; 4] = [CrtMode::Off, CrtMode::Color, CrtMode::Green, CrtMode::Amber];
250
251    pub fn next(self) -> CrtMode {
252        let i = CrtMode::ALL.iter().position(|&m| m == self).unwrap_or(0);
253        CrtMode::ALL[(i + 1) % CrtMode::ALL.len()]
254    }
255
256    pub fn name(self) -> &'static str {
257        match self {
258            CrtMode::Off => "off",
259            CrtMode::Color => "color CRT",
260            CrtMode::Green => "green screen",
261            CrtMode::Amber => "amber screen",
262        }
263    }
264}
265
266/// The entries a new directory starts with, first among them the project's
267/// own BBS. More can be added here over time: existing directories pick up
268/// new ones once (see [`Directory::offer_defaults`]).
269pub fn default_entries() -> Vec<Entry> {
270    vec![Entry {
271        name: "The Vortex BBS".into(),
272        host: "vortexbbs.com".into(),
273        port: 23,
274        protocol: Protocol::Telnet,
275        comment: "Home BBS of the BadTerm project".into(),
276        pinned: true,
277        ..Entry::default()
278    }]
279}
280
281fn split_host_port(s: &str) -> Option<(String, Option<u16>)> {
282    if s.is_empty() {
283        return None;
284    }
285    if let Some(rest) = s.strip_prefix('[') {
286        let (host, after) = rest.split_once(']')?;
287        let port = match after.strip_prefix(':') {
288            Some(p) => Some(p.parse().ok()?),
289            None => None,
290        };
291        return Some((host.to_string(), port));
292    }
293    match s.rsplit_once(':') {
294        // A bare IPv6 address has several colons and no port.
295        Some((h, p)) if !h.contains(':') => Some((h.to_string(), Some(p.parse().ok()?))),
296        _ => Some((s.to_string(), None)),
297    }
298}
299
300pub fn now() -> u64 {
301    SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
302}
303
304#[derive(Clone, Copy, Debug, PartialEq, Eq)]
305pub enum Format {
306    Toml,
307    Json,
308    /// Ours, or a list such as the Telnet BBS Guide's `bbslist.csv`.
309    Csv,
310    SyncTerm,
311    /// MagiTerm's `magiterm.ini`: SyncTERM's layout, but every entry SSH.
312    /// Import only.
313    MagiTerm,
314    /// EtherTerm's `dialdirectory.xml`. Import only.
315    EtherTerm,
316    /// NetRunner's binary `netrunner.phn`. Import only.
317    NetRunner,
318}
319
320impl Format {
321    pub fn from_path(path: &Path) -> Option<Format> {
322        let ext = path.extension()?.to_str()?.to_ascii_lowercase();
323        match ext.as_str() {
324            "toml" => Some(Format::Toml),
325            "json" => Some(Format::Json),
326            "csv" => Some(Format::Csv),
327            "ini" if path.file_stem().is_some_and(|s| s.eq_ignore_ascii_case("magiterm")) => Some(Format::MagiTerm),
328            "lst" | "ini" => Some(Format::SyncTerm),
329            "xml" => Some(Format::EtherTerm),
330            "phn" => Some(Format::NetRunner),
331            _ => None,
332        }
333    }
334}
335
336/// How the dialing directory is listed. Pinned entries always come first.
337#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
338#[serde(rename_all = "snake_case")]
339pub enum SortOrder {
340    #[default]
341    Name,
342    /// Most recently called first.
343    LastCalled,
344    /// Most called first.
345    Calls,
346}
347
348impl SortOrder {
349    pub fn name(self) -> &'static str {
350        match self {
351            SortOrder::Name => "name",
352            SortOrder::LastCalled => "last called",
353            SortOrder::Calls => "calls",
354        }
355    }
356
357    pub fn next(self) -> SortOrder {
358        match self {
359            SortOrder::Name => SortOrder::LastCalled,
360            SortOrder::LastCalled => SortOrder::Calls,
361            SortOrder::Calls => SortOrder::Name,
362        }
363    }
364
365    fn is_default(&self) -> bool {
366        *self == SortOrder::Name
367    }
368}
369
370#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
371pub struct Directory {
372    /// How the list is shown.
373    #[serde(default, skip_serializing_if = "SortOrder::is_default")]
374    pub sort: SortOrder,
375    /// Default entries already offered to this directory (by
376    /// [`Entry::system_id`]), so one the user deleted does not come back.
377    #[serde(default, skip_serializing_if = "Vec::is_empty")]
378    pub defaults_offered: Vec<String>,
379    #[serde(default, rename = "entry")]
380    pub entries: Vec<Entry>,
381}
382
383impl Directory {
384    /// Load the directory, returning an empty one if the file does not exist.
385    pub fn load(path: &Path) -> Result<Directory> {
386        match fs::read_to_string(path) {
387            Ok(s) => toml::from_str(&s).with_context(|| format!("parsing {}", path.display())),
388            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Directory::default()),
389            Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
390        }
391    }
392
393    /// Save atomically (write to a temporary file, then rename).
394    pub fn save(&self, path: &Path) -> Result<()> {
395        if let Some(dir) = path.parent() {
396            fs::create_dir_all(dir)?;
397        }
398        let tmp = path.with_extension("toml.tmp");
399        fs::write(&tmp, toml::to_string_pretty(self)?)?;
400        fs::rename(&tmp, path).with_context(|| format!("writing {}", path.display()))
401    }
402
403    /// Pinned entries first, then by name.
404    pub fn sort(&mut self) {
405        self.entries.sort_by_key(|e| (!e.pinned, e.name.to_lowercase()));
406    }
407
408    /// Indices of the entries to list, in `self.sort` order, keeping those
409    /// whose name, address or comment contains `filter` (ignoring case).
410    pub fn view(&self, filter: &str) -> Vec<usize> {
411        let filter = filter.trim().to_lowercase();
412        let mut v: Vec<usize> = (0..self.entries.len())
413            .filter(|&i| {
414                let e = &self.entries[i];
415                filter.is_empty()
416                    || [&e.name, &e.host, &e.comment].iter().any(|f| f.to_lowercase().contains(&filter))
417                    || e.address().to_lowercase().contains(&filter)
418            })
419            .collect();
420        let name = |i: &usize| self.entries[*i].name.to_lowercase();
421        match self.sort {
422            SortOrder::Name => v.sort_by_key(|i| (!self.entries[*i].pinned, name(i))),
423            SortOrder::LastCalled => v.sort_by_key(|i| {
424                let e = &self.entries[*i];
425                (!e.pinned, std::cmp::Reverse(e.last_connected.unwrap_or(0)), name(i))
426            }),
427            SortOrder::Calls => v.sort_by_key(|i| {
428                let e = &self.entries[*i];
429                (!e.pinned, std::cmp::Reverse(e.calls), name(i))
430            }),
431        }
432        v
433    }
434
435    /// Add any default entries this directory has not been offered before.
436    /// Returns true if the directory changed and should be saved.
437    pub fn offer_defaults(&mut self) -> bool {
438        let mut changed = false;
439        for e in default_entries() {
440            let id = e.system_id();
441            if self.defaults_offered.contains(&id) {
442                continue;
443            }
444            if !self.entries.iter().any(|x| x.same_system(&e)) {
445                self.entries.push(Entry { added: Some(now()), ..e });
446            }
447            self.defaults_offered.push(id);
448            changed = true;
449        }
450        if changed {
451            self.sort();
452        }
453        changed
454    }
455
456    /// Add entries that are not already present (same host, port and
457    /// protocol). Returns how many were added.
458    pub fn merge(&mut self, new: Vec<Entry>) -> usize {
459        let mut added = 0;
460        for e in new {
461            if !self.entries.iter().any(|x| x.same_system(&e)) {
462                self.entries.push(e);
463                added += 1;
464            }
465        }
466        self.sort();
467        added
468    }
469}
470
471/// Read entries from a file, choosing the format by extension (falling back
472/// to sniffing the content).
473pub fn import(path: &Path) -> Result<Vec<Entry>> {
474    let bytes = fs::read(path).with_context(|| format!("reading {}", path.display()))?;
475    // NetRunner's phone book is binary.
476    if bytes.starts_with(NETRUNNER_MAGIC) || Format::from_path(path) == Some(Format::NetRunner) {
477        return Ok(finish_import(from_netrunner(&bytes)?));
478    }
479    // SyncTERM lists from Windows may be in a legacy code page.
480    let text = match String::from_utf8(bytes) {
481        Ok(s) => s,
482        Err(e) => crate::cp437::decode(e.as_bytes()),
483    };
484    let text = text.trim_start_matches('\u{feff}');
485    let format = Format::from_path(path).or_else(|| sniff(text));
486    let entries = match format {
487        Some(Format::Toml) => toml::from_str::<Directory>(text)?.entries,
488        Some(Format::Json) => from_json(text)?,
489        Some(Format::Csv) => from_csv(text)?,
490        Some(Format::SyncTerm) => from_syncterm(text),
491        Some(Format::MagiTerm) => from_syncterm(text)
492            .into_iter()
493            .map(|mut e| {
494                if e.protocol == Protocol::Telnet {
495                    e.protocol = Protocol::Ssh;
496                    if e.port == Protocol::Telnet.default_port() {
497                        e.port = Protocol::Ssh.default_port();
498                    }
499                }
500                e
501            })
502            .collect(),
503        Some(Format::EtherTerm) => from_etherterm(text)?,
504        // Read above, as bytes.
505        Some(Format::NetRunner) => unreachable!("NetRunner phone books are read before decoding"),
506        None => bail!("unrecognized dialing directory format: {}", path.display()),
507    };
508    Ok(finish_import(entries))
509}
510
511/// Write entries to a file in the format implied by its extension.
512pub fn export(path: &Path, entries: &[Entry]) -> Result<()> {
513    let Some(format) = Format::from_path(path) else {
514        bail!("use a .lst, .csv, .json or .toml file name to choose the export format");
515    };
516    let text = match format {
517        Format::Toml => toml::to_string_pretty(&Directory { entries: entries.to_vec(), ..Directory::default() })?,
518        Format::Json => serde_json::to_string_pretty(entries)?,
519        Format::Csv => to_csv(entries)?,
520        Format::SyncTerm => to_syncterm(entries),
521        Format::MagiTerm | Format::EtherTerm | Format::NetRunner => {
522            bail!("that format can be imported, not exported")
523        }
524    };
525    fs::write(path, text).with_context(|| format!("writing {}", path.display()))
526}
527
528fn sniff(text: &str) -> Option<Format> {
529    let t = text.trim_start();
530    if t.starts_with('{') || t.starts_with('[') && t[1..].trim_start().starts_with('{') {
531        Some(Format::Json)
532    } else if t.starts_with("<?xml") || t.starts_with("<EtherTerm") {
533        Some(Format::EtherTerm)
534    } else if t.starts_with("[[entry]]") {
535        Some(Format::Toml)
536    } else if t.starts_with('[') {
537        Some(Format::SyncTerm)
538    } else if t.lines().next().is_some_and(|l| l.contains(',')) {
539        Some(Format::Csv)
540    } else {
541        None
542    }
543}
544
545fn from_json(text: &str) -> Result<Vec<Entry>> {
546    // Accept either a bare array or our directory object.
547    match serde_json::from_str::<Vec<Entry>>(text) {
548        Ok(v) => Ok(v),
549        Err(_) => Ok(serde_json::from_str::<Directory>(text)?.entries),
550    }
551}
552
553const CSV_HEADER: [&str; 8] = ["name", "host", "port", "protocol", "encoding", "username", "term_type", "comment"];
554
555fn to_csv(entries: &[Entry]) -> Result<String> {
556    let mut w = csv::Writer::from_writer(Vec::new());
557    w.write_record(CSV_HEADER)?;
558    for e in entries {
559        w.write_record([
560            e.name.as_str(),
561            &e.host,
562            &e.port.to_string(),
563            e.protocol.name(),
564            e.encoding.name(),
565            &e.username,
566            &e.term_type,
567            &e.comment,
568        ])?;
569    }
570    Ok(String::from_utf8(w.into_inner()?)?)
571}
572
573/// Parse CSV with a header row. Columns are matched by name, so files from
574/// spreadsheets or BBS lists with extra or reordered columns still import.
575fn from_csv(text: &str) -> Result<Vec<Entry>> {
576    let mut r = csv::ReaderBuilder::new().flexible(true).trim(csv::Trim::All).from_reader(text.as_bytes());
577    let headers: Vec<String> = r.headers()?.iter().map(|h| h.to_ascii_lowercase()).collect();
578    let col = |names: &[&str]| headers.iter().position(|h| names.contains(&h.as_str()));
579    let c_name = col(&["name", "bbs", "bbs name", "system", "bbsname"]);
580    let c_host = col(&["host", "address", "hostname", "telnet", "telnetaddress"]);
581    let c_port = col(&["port", "bbsport"]);
582    // The Telnet BBS Guide's list: an SSH port, the new-user login, and
583    // details worth keeping as the comment.
584    let c_ssh_port = col(&["sshport"]);
585    let c_new_login = col(&["newlogin"]);
586    let c_details: Vec<usize> = ["software", "location", "bbssysop"].iter().filter_map(|n| col(&[n])).collect();
587    let c_proto = col(&["protocol", "type", "connectiontype"]);
588    let c_enc = col(&["encoding", "charset"]);
589    let c_user = col(&["username", "user"]);
590    let c_tt = col(&["term_type", "terminal"]);
591    let c_comment = col(&["comment", "comments", "description", "notes"]);
592    let Some(c_host) = c_host else { bail!("CSV needs a 'host' or 'address' column") };
593
594    let mut out = Vec::new();
595    for rec in r.records() {
596        let rec = rec?;
597        let get = |c: Option<usize>| c.and_then(|c| rec.get(c)).unwrap_or("").to_string();
598        let mut e = match Entry::from_address(&get(Some(c_host))) {
599            Some(e) => e,
600            None => continue,
601        };
602        if let Some(p) = c_proto.and_then(|c| rec.get(c)).and_then(Protocol::from_name) {
603            if e.port == e.protocol.default_port() {
604                e.port = p.default_port();
605            }
606            e.protocol = p;
607        }
608        if let Ok(port) = get(c_port).parse() {
609            e.port = port;
610        } else if let Ok(port) = get(c_ssh_port).parse() {
611            // Only SSH: log in as the BBS's new-user name, as it says.
612            e.protocol = Protocol::Ssh;
613            e.port = port;
614            e.username = get(c_new_login);
615        }
616        let name = get(c_name);
617        if !name.is_empty() {
618            e.name = name;
619        }
620        if get(c_enc).to_ascii_lowercase().replace('-', "") == "utf8" {
621            e.encoding = Encoding::Utf8;
622        }
623        if c_user.is_some() {
624            e.username = get(c_user);
625        }
626        let tt = get(c_tt);
627        if !tt.is_empty() {
628            e.term_type = tt;
629        }
630        e.comment = get(c_comment);
631        if e.comment.is_empty() {
632            let details: Vec<String> = c_details.iter().map(|&c| get(Some(c))).filter(|d| !d.is_empty()).collect();
633            e.comment = details.join(", ");
634        }
635        out.push(e);
636    }
637    Ok(out)
638}
639
640/// Name imported entries that have none, and date them.
641fn finish_import(entries: Vec<Entry>) -> Vec<Entry> {
642    let stamp = now();
643    entries
644        .into_iter()
645        .filter(|e| !e.host.is_empty())
646        .map(|mut e| {
647            if e.name.is_empty() {
648                e.name = e.host.clone();
649            }
650            e.added.get_or_insert(stamp);
651            e
652        })
653        .collect()
654}
655
656/// The start of a NetRunner phone book: "NetRunner Phone Book" as a Pascal
657/// string.
658const NETRUNNER_MAGIC: &[u8] = b"\x14NetRunner Phone Book";
659
660/// Parse NetRunner's `netrunner.phn`: a header, then 200 fixed records of
661/// 362 bytes, made of Pascal strings (a length byte, then up to 40
662/// characters): the name, the address (`host` or `host:port`), user name
663/// and password, then the sysop and BBS software, kept as the comment.
664/// Unused records have no name or address.
665fn from_netrunner(data: &[u8]) -> Result<Vec<Entry>> {
666    const START: usize = 48;
667    const RECORD: usize = 362;
668    if !data.starts_with(NETRUNNER_MAGIC) {
669        bail!("not a NetRunner phone book");
670    }
671    let text = |r: &[u8], at: usize| -> String {
672        let len = (r[at] as usize).min(40);
673        crate::cp437::decode(&r[at + 1..at + 1 + len]).trim().to_string()
674    };
675    let mut out = Vec::new();
676    for r in data.get(START..).unwrap_or(&[]).as_chunks::<RECORD>().0 {
677        let (name, address) = (text(r, 0), text(r, 41));
678        let Some(mut e) = Entry::from_address(&address) else { continue };
679        if !name.is_empty() {
680            e.name = name;
681        }
682        e.username = text(r, 82);
683        e.password = text(r, 123);
684        let details: Vec<String> = [text(r, 320), text(r, 279)].into_iter().filter(|d| !d.is_empty()).collect();
685        e.comment = details.join(", ");
686        out.push(e);
687    }
688    Ok(out)
689}
690
691/// Parse EtherTerm's `dialdirectory.xml`: `<BBS name=".." ip=".."
692/// port=".." protocol="TELNET|ssh" login=".." password=".." font=".." />`.
693fn from_etherterm(text: &str) -> Result<Vec<Entry>> {
694    if !text.contains("<Phonebook") && !text.contains("<BBS") {
695        bail!("not an EtherTerm dialing directory");
696    }
697    let mut out = Vec::new();
698    for tag in text.split("<BBS").skip(1) {
699        let Some(end) = tag.find('>') else { continue };
700        let attrs = xml_attributes(&tag[..end]);
701        let get =
702            |k: &str| attrs.iter().find(|(n, _)| n.eq_ignore_ascii_case(k)).map(|(_, v)| v.as_str()).unwrap_or("");
703        let Some(mut e) = Entry::from_address(get("ip")) else { continue };
704        if let Some(p) = Protocol::from_name(get("protocol")) {
705            e.protocol = p;
706            e.port = p.default_port();
707        }
708        if let Ok(port) = get("port").parse() {
709            e.port = port;
710        }
711        e.name = get("name").trim().to_string();
712        e.username = get("login").to_string();
713        e.password = get("password").to_string();
714        let font = get("font").to_ascii_lowercase();
715        e.font = match () {
716            _ if font.contains("topaz") && font.contains("plus") => 40,
717            _ if font.contains("topaz") => 42,
718            _ if font.contains("microknight") && font.contains("plus") => 39,
719            _ if font.contains("microknight") => 41,
720            _ if font.contains("noodle") => 37,
721            _ if font.contains("sooul") || font.contains("mosoul") => 38,
722            _ => 0,
723        };
724        out.push(e);
725    }
726    Ok(out)
727}
728
729/// The `name="value"` pairs of an XML tag, with entities decoded.
730fn xml_attributes(tag: &str) -> Vec<(String, String)> {
731    let mut out = Vec::new();
732    let mut rest = tag;
733    while let Some(eq) = rest.find('=') {
734        let name = rest[..eq].trim().rsplit(char::is_whitespace).next().unwrap_or("").to_string();
735        let after = rest[eq + 1..].trim_start();
736        let Some(quote) = after.chars().next().filter(|c| *c == '"' || *c == '\'') else { break };
737        let Some(close) = after[1..].find(quote) else { break };
738        out.push((name, xml_unescape(&after[1..1 + close])));
739        rest = &after[close + 2..];
740    }
741    out
742}
743
744fn xml_unescape(s: &str) -> String {
745    let mut out = String::with_capacity(s.len());
746    let mut rest = s;
747    while let Some(amp) = rest.find('&') {
748        out.push_str(&rest[..amp]);
749        let Some(semi) = rest[amp..].find(';') else { break };
750        let entity = &rest[amp + 1..amp + semi];
751        let decoded = match entity {
752            "amp" => Some('&'),
753            "lt" => Some('<'),
754            "gt" => Some('>'),
755            "quot" => Some('"'),
756            "apos" => Some('\''),
757            _ => entity
758                .strip_prefix("#x")
759                .and_then(|h| u32::from_str_radix(h, 16).ok())
760                .or_else(|| entity.strip_prefix('#').and_then(|d| d.parse().ok()))
761                .and_then(char::from_u32),
762        };
763        match decoded {
764            Some(c) => out.push(c),
765            None => out.push_str(&rest[amp..amp + semi + 1]),
766        }
767        rest = &rest[amp + semi + 1..];
768    }
769    out.push_str(rest);
770    out
771}
772
773/// Parse a SyncTERM dialing directory (`syncterm.lst`): an INI file with one
774/// section per BBS.
775fn from_syncterm(text: &str) -> Vec<Entry> {
776    let mut out = Vec::new();
777    let mut cur: Option<Entry> = None;
778    for line in text.lines() {
779        let line = line.trim();
780        if line.is_empty() || line.starts_with(';') || line.starts_with('#') {
781            continue;
782        }
783        if let Some(name) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
784            out.extend(cur.take());
785            cur = Some(Entry { name: name.to_string(), port: 0, ..Entry::default() });
786            continue;
787        }
788        let (Some(e), Some((key, value))) = (cur.as_mut(), line.split_once('=')) else { continue };
789        let value = value.trim();
790        match key.trim().to_ascii_lowercase().as_str() {
791            "address" => e.host = value.to_string(),
792            "port" => e.port = value.parse().unwrap_or(0),
793            "connectiontype" => match Protocol::from_name(value) {
794                Some(p) => e.protocol = p,
795                None => log::warn!("{}: unsupported connection type {value}", e.name),
796            },
797            "username" => e.username = value.to_string(),
798            "password" => e.password = value.to_string(),
799            "comment" => e.comment = value.to_string(),
800            "totalcalls" => e.calls = value.parse().unwrap_or(0),
801            "added" => e.added = value.parse().ok(),
802            "lastconnected" => e.last_connected = value.parse().ok().filter(|&t| t != 0),
803            _ => {}
804        }
805    }
806    out.extend(cur);
807    for e in &mut out {
808        // SyncTERM addresses may carry the port (host:port) or a URL scheme.
809        if let Some(parsed) = Entry::from_address(&e.host) {
810            if e.port == 0 && parsed.port != parsed.protocol.default_port() {
811                e.port = parsed.port;
812            }
813            e.host = parsed.host;
814        }
815        if e.port == 0 {
816            e.port = e.protocol.default_port();
817        }
818    }
819    out
820}
821
822fn to_syncterm(entries: &[Entry]) -> String {
823    let mut s = String::new();
824    for e in entries {
825        // Section names cannot contain brackets.
826        let name: String = e.name.chars().map(|c| if c == '[' || c == ']' { '_' } else { c }).collect();
827        s.push_str(&format!("[{name}]\n"));
828        s.push_str(&format!("\tAddress={}\n", e.host));
829        s.push_str(&format!("\tPort={}\n", e.port));
830        s.push_str(&format!("\tConnectionType={}\n", e.protocol.name()));
831        if let Some(t) = e.added {
832            s.push_str(&format!("\tAdded={t}\n"));
833        }
834        if let Some(t) = e.last_connected {
835            s.push_str(&format!("\tLastConnected={t}\n"));
836        }
837        s.push_str(&format!("\tTotalCalls={}\n", e.calls));
838        if !e.username.is_empty() {
839            s.push_str(&format!("\tUserName={}\n", e.username));
840        }
841        if !e.password.is_empty() {
842            s.push_str(&format!("\tPassword={}\n", e.password));
843        }
844        if !e.comment.is_empty() {
845            s.push_str(&format!("\tComment={}\n", e.comment));
846        }
847        s.push('\n');
848    }
849    s
850}
851
852#[cfg(test)]
853mod tests {
854    use super::*;
855
856    fn sample() -> Vec<Entry> {
857        vec![
858            Entry {
859                name: "Alpha BBS".into(),
860                host: "alpha.example.org".into(),
861                comment: "Has, commas".into(),
862                ..Entry::default()
863            },
864            Entry {
865                name: "Beta".into(),
866                host: "beta.example.org".into(),
867                port: 2323,
868                crt: Some(CrtMode::Amber),
869                ..Entry::default()
870            },
871        ]
872    }
873
874    #[test]
875    fn parse_addresses() {
876        let e = Entry::from_address("telnet://bbs.example.com:2323").unwrap();
877        assert_eq!((e.host.as_str(), e.port), ("bbs.example.com", 2323));
878        let e = Entry::from_address("bbs.example.com").unwrap();
879        assert_eq!(e.port, 23);
880        let e = Entry::from_address("[::1]:23").unwrap();
881        assert_eq!(e.host, "::1");
882        let e = Entry::from_address("ssh://bbs.example.com").unwrap();
883        assert_eq!((e.protocol, e.port), (Protocol::Ssh, 22));
884        let e = Entry::from_address("ssh://guest@bbs.example.com:2222").unwrap();
885        assert_eq!((e.username.as_str(), e.host.as_str(), e.port), ("guest", "bbs.example.com", 2222));
886        assert!(Entry::from_address("host:notaport").is_none());
887    }
888
889    #[test]
890    fn syncterm_round_trip() {
891        let text = to_syncterm(&sample());
892        let back = from_syncterm(&text);
893        assert_eq!(back.len(), 2);
894        assert_eq!(back[1].port, 2323);
895        assert_eq!(back[0].comment, "Has, commas");
896    }
897
898    #[test]
899    fn syncterm_real_world() {
900        let text = "[Some BBS]\n\tAddress=bbs.example.net:2300\n\tConnectionType=Telnet\n\tScreenMode=80x25\n\
901                    [Secure]\nAddress=s.example.net\nConnectionType=SSH\nPort=2222\n";
902        let v = from_syncterm(text);
903        assert_eq!((v[0].host.as_str(), v[0].port), ("bbs.example.net", 2300));
904        assert_eq!((v[1].protocol, v[1].port), (Protocol::Ssh, 2222));
905    }
906
907    #[test]
908    fn csv_round_trip() {
909        let text = to_csv(&sample()).unwrap();
910        let back = from_csv(&text).unwrap();
911        assert_eq!(back[0].name, "Alpha BBS");
912        assert_eq!(back[0].comment, "Has, commas");
913        assert_eq!(back[1].port, 2323);
914    }
915
916    #[test]
917    fn csv_with_foreign_columns() {
918        let text = "BBS Name,Address,Sysop\nFoo,foo.example.com:2023,Bob\n";
919        let v = from_csv(text).unwrap();
920        assert_eq!((v[0].name.as_str(), v[0].port), ("Foo", 2023));
921    }
922
923    #[test]
924    fn toml_and_json_round_trip() {
925        let d = Directory { entries: sample(), defaults_offered: vec!["telnet://x:23".into()], ..Directory::default() };
926        let t = toml::to_string_pretty(&d).unwrap();
927        assert_eq!(toml::from_str::<Directory>(&t).unwrap(), d);
928        let j = serde_json::to_string(&d.entries).unwrap();
929        assert_eq!(from_json(&j).unwrap(), d.entries);
930    }
931
932    #[test]
933    fn merge_skips_duplicates() {
934        let mut d = Directory { entries: sample(), ..Directory::default() };
935        let added = d.merge(sample());
936        assert_eq!(added, 0);
937        assert_eq!(d.entries.len(), 2);
938    }
939
940    #[test]
941    fn defaults_are_offered_once() {
942        let mut d = Directory { entries: sample(), ..Directory::default() };
943        assert!(d.offer_defaults());
944        // Pinned, so first despite the name.
945        assert_eq!(d.entries[0].host, "vortexbbs.com");
946        assert_eq!(d.entries.len(), 3);
947        assert!(!d.offer_defaults());
948
949        // Deleting it sticks, including across a save and load.
950        d.entries.remove(0);
951        let d: Directory = toml::from_str(&toml::to_string_pretty(&d).unwrap()).unwrap();
952        let mut d = d;
953        assert!(!d.offer_defaults());
954        assert_eq!(d.entries.len(), 2);
955    }
956
957    #[test]
958    fn new_fields_are_optional_in_toml() {
959        let d: Directory = toml::from_str("[[entry]]\nname = \"A\"\nhost = \"a.example\"\n").unwrap();
960        assert!(!d.entries[0].pinned && !d.entries[0].keychain && d.entries[0].key_file.is_empty());
961        let t = toml::to_string_pretty(&d).unwrap();
962        assert!(!t.contains("pinned") && !t.contains("keychain") && !t.contains("key_file"), "{t}");
963        assert!(d.entries[0].crt.is_none() && !t.contains("crt"), "{t}");
964    }
965
966    #[test]
967    fn crt_mode_in_toml() {
968        let d: Directory = toml::from_str("[[entry]]\nhost = \"a.example\"\ncrt = \"green\"\n").unwrap();
969        assert_eq!(d.entries[0].crt, Some(CrtMode::Green));
970        assert_eq!(CrtMode::Amber.next(), CrtMode::Off);
971    }
972
973    #[test]
974    fn view_sorts_and_filters() {
975        let mut d = Directory { entries: sample(), ..Directory::default() };
976        d.entries[0].calls = 1;
977        d.entries[1].calls = 5;
978        d.entries[0].last_connected = Some(200);
979        d.entries[1].last_connected = Some(100);
980        assert_eq!(d.view(""), [0, 1]);
981        d.sort = SortOrder::Calls;
982        assert_eq!(d.view(""), [1, 0]);
983        d.sort = SortOrder::LastCalled;
984        assert_eq!(d.view(""), [0, 1]);
985        // Pinned entries stay on top whatever the order.
986        d.entries[1].pinned = true;
987        assert_eq!(d.view(""), [1, 0]);
988
989        assert_eq!(d.view("ALPHA"), [0]);
990        assert_eq!(d.view("commas"), [0]);
991        assert_eq!(d.view(":2323"), [1]);
992        assert!(d.view("nothing like it").is_empty());
993
994        let t = toml::to_string_pretty(&d).unwrap();
995        assert!(t.contains("sort = \"last_called\""), "{t}");
996        assert_eq!(toml::from_str::<Directory>(&t).unwrap().sort, SortOrder::LastCalled);
997    }
998
999    #[test]
1000    fn telnet_bbs_guide_csv() {
1001        let text = "bbsName, bbsSysop, newLogin, TelnetAddress, bbsPort, sshPort, WebAddress, location, Modem, software\n\
1002                    13th Floor BBS,Hoy Brothers,new,13th.hoyvision.com,6400,,,\"Oak Ridge, TN, USA\",,\"Image BBS\"\n\
1003                    3D Realms,,NEW,172.0.79.226,,,,\"Cotati, CA, USA\",,\"Synchronet\"\n\
1004                    4 Wheel Ham BBS,w7jpj,NEW,bbs.4wheelham.com,,2222,,\"Littleton, CO, USA\",,\"Synchronet\"\n";
1005        let v = from_csv(text).unwrap();
1006        assert_eq!((v[0].name.as_str(), v[0].host.as_str(), v[0].port), ("13th Floor BBS", "13th.hoyvision.com", 6400));
1007        assert_eq!(v[0].comment, "Image BBS, Oak Ridge, TN, USA, Hoy Brothers");
1008        assert_eq!((v[1].protocol, v[1].port), (Protocol::Telnet, 23));
1009        assert_eq!((v[2].protocol, v[2].port, v[2].username.as_str()), (Protocol::Ssh, 2222, "NEW"));
1010    }
1011
1012    #[test]
1013    fn etherterm_and_magiterm() {
1014        let xml = r#"<?xml version="1.0" ?>
1015<EtherTerm>
1016    <Phonebook version="1.0">
1017        <BBS name="Tom &amp; Jerry&#39;s" ip="bbs.example.org" port="6523" protocol="TELNET" login="" password="" font="vga8x16.bmp" keyMap="ANSI" />
1018        <BBS name="Amiga Land (ssh)" ip="amiga.example.org" port="2222" protocol="ssh" login="guest" password="pw" font="topaz-a1200.bmp" keyMap="ANSI" />
1019    </Phonebook>
1020</EtherTerm>"#;
1021        assert_eq!(sniff(xml), Some(Format::EtherTerm));
1022        let v = from_etherterm(xml).unwrap();
1023        assert_eq!(v[0].name, "Tom & Jerry's");
1024        assert_eq!((v[0].protocol, v[0].port), (Protocol::Telnet, 6523));
1025        assert_eq!((v[1].protocol, v[1].port, v[1].username.as_str(), v[1].font), (Protocol::Ssh, 2222, "guest", 42));
1026
1027        let dir = std::env::temp_dir().join(format!("badterm-magiterm-{}", std::process::id()));
1028        std::fs::create_dir_all(&dir).unwrap();
1029        let path = dir.join("magiterm.ini");
1030        std::fs::write(
1031            &path,
1032            "[A-Net Online]\naddress = mystic-anet.online \nport = 22 \n\n[After Hours]\naddress = ah-bbs.com\n",
1033        )
1034        .unwrap();
1035        let v = import(&path).unwrap();
1036        assert_eq!((v[0].protocol, v[0].port), (Protocol::Ssh, 22));
1037        assert_eq!((v[1].protocol, v[1].port), (Protocol::Ssh, 22));
1038        assert!(export(&dir.join("out.xml"), &v).is_err());
1039        std::fs::remove_dir_all(&dir).unwrap();
1040    }
1041
1042    #[test]
1043    fn netrunner_phone_book() {
1044        let book = include_bytes!("../tests/data/netrunner.phn");
1045        let v = from_netrunner(book).unwrap();
1046        assert_eq!(v.len(), 2);
1047        assert_eq!((v[0].name.as_str(), v[0].host.as_str(), v[0].port), ("The Vortex BBS", "vortexbbs.com", 23));
1048        assert_eq!((v[0].username.as_str(), v[0].password.as_str()), ("User Here", "Password Here"));
1049        assert_eq!(v[0].comment, "Enigma 1/2, Sysop Name Here");
1050        assert_eq!((v[1].name.as_str(), v[1].host.as_str()), ("Another System", "another.com"));
1051        assert!(from_netrunner(b"not a phone book").is_err());
1052
1053        // host:port in the address, as NetRunner takes it.
1054        let mut edited = book.to_vec();
1055        let at = 48 + 41;
1056        let addr = b"bbs.example.org:2323";
1057        edited[at] = addr.len() as u8;
1058        edited[at + 1..at + 1 + addr.len()].copy_from_slice(addr);
1059        let v = from_netrunner(&edited).unwrap();
1060        assert_eq!((v[0].host.as_str(), v[0].port), ("bbs.example.org", 2323));
1061    }
1062}