1use 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 #[serde(rename = "telnets")]
21 TelnetS,
22 Raw,
24 Ssh,
25 Rlogin,
26 Modem,
29}
30
31impl Protocol {
32 pub const ALL: [Protocol; 6] =
33 [Protocol::Telnet, Protocol::TelnetS, Protocol::Raw, Protocol::Ssh, Protocol::Rlogin, Protocol::Modem];
34
35 pub fn default_port(self) -> u16 {
37 match self {
38 Protocol::Telnet | Protocol::Raw => 23,
39 Protocol::TelnetS => 992,
40 Protocol::Ssh => 22,
41 Protocol::Rlogin => 513,
42 Protocol::Modem => 0,
43 }
44 }
45
46 pub fn name(self) -> &'static str {
48 match self {
49 Protocol::Telnet => "Telnet",
50 Protocol::TelnetS => "TelnetS",
51 Protocol::Raw => "Raw",
52 Protocol::Ssh => "SSH",
53 Protocol::Rlogin => "RLogin",
54 Protocol::Modem => "Modem",
55 }
56 }
57
58 pub fn is_telnet(self) -> bool {
60 matches!(self, Protocol::Telnet | Protocol::TelnetS)
61 }
62
63 fn from_name(s: &str) -> Option<Protocol> {
64 match s.trim().to_ascii_lowercase().as_str() {
65 "telnet" => Some(Protocol::Telnet),
66 "telnets" => Some(Protocol::TelnetS),
67 "raw" => Some(Protocol::Raw),
68 "ssh" | "sshna" => Some(Protocol::Ssh),
69 "rlogin" | "rloginreversed" => Some(Protocol::Rlogin),
70 "modem" | "dialup" | "serial" => Some(Protocol::Modem),
71 _ => None,
72 }
73 }
74}
75
76impl fmt::Display for Protocol {
77 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78 f.write_str(self.name())
79 }
80}
81
82#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(default)]
84pub struct Entry {
85 pub name: String,
86 pub host: String,
87 pub port: u16,
88 pub protocol: Protocol,
89 pub encoding: Encoding,
90 pub username: String,
91 pub password: String,
93 #[serde(skip_serializing_if = "is_true")]
95 pub auto_login: bool,
96 pub term_type: String,
97 #[serde(skip_serializing_if = "is_80")]
99 pub cols: u16,
100 #[serde(skip_serializing_if = "is_25")]
101 pub rows: u16,
102 #[serde(skip_serializing_if = "is_zero")]
105 pub font: u8,
106 #[serde(skip_serializing_if = "Option::is_none")]
109 pub ice: Option<bool>,
110 #[serde(skip_serializing_if = "is_zero_u32")]
113 pub baud: u32,
114 #[serde(skip_serializing_if = "Option::is_none")]
116 pub crt: Option<CrtMode>,
117 #[serde(skip_serializing_if = "Option::is_none")]
120 pub bell: Option<BellMode>,
121 #[serde(skip_serializing_if = "is_false")]
123 pub capture: bool,
124 #[serde(skip_serializing_if = "String::is_empty")]
129 pub capture_file: String,
130 #[serde(skip_serializing_if = "String::is_empty")]
133 pub key_file: String,
134 #[serde(skip_serializing_if = "String::is_empty")]
137 pub download_dir: String,
138 #[serde(skip_serializing_if = "String::is_empty")]
139 pub upload_dir: String,
140 #[serde(skip_serializing_if = "is_false")]
142 pub hide_status: bool,
143 #[serde(skip_serializing_if = "String::is_empty")]
145 pub group: String,
146 pub comment: String,
147 #[serde(skip_serializing_if = "is_false")]
149 pub keychain: bool,
150 #[serde(skip_serializing_if = "is_false")]
152 pub pinned: bool,
153 pub added: Option<u64>,
155 pub last_connected: Option<u64>,
156 pub calls: u32,
157}
158
159impl Default for Entry {
160 fn default() -> Self {
161 Entry {
162 name: String::new(),
163 host: String::new(),
164 port: 23,
165 protocol: Protocol::Telnet,
166 encoding: Encoding::Cp437,
167 username: String::new(),
168 password: String::new(),
169 auto_login: true,
170 term_type: "ANSI".into(),
171 cols: 80,
172 rows: 25,
173 font: 0,
174 ice: None,
175 baud: 0,
176 crt: None,
177 bell: None,
178 capture: false,
179 capture_file: String::new(),
180 key_file: String::new(),
181 download_dir: String::new(),
182 upload_dir: String::new(),
183 hide_status: false,
184 group: String::new(),
185 comment: String::new(),
186 keychain: false,
187 pinned: false,
188 added: None,
189 last_connected: None,
190 calls: 0,
191 }
192 }
193}
194
195impl Entry {
196 pub fn screen_mode(&self) -> (u16, u16) {
199 let mode = (self.cols, self.rows);
200 if SCREEN_MODES.contains(&mode) { mode } else { SCREEN_MODES[0] }
201 }
202
203 pub fn from_address(addr: &str) -> Option<Entry> {
206 let addr = addr.trim();
207 let (protocol, rest) = match addr.split_once("://") {
208 Some((scheme, rest)) => (Protocol::from_name(scheme)?, rest),
209 None => (Protocol::Telnet, addr),
210 };
211 let rest = rest.trim_end_matches('/');
212 let (username, rest) = match rest.rsplit_once('@') {
213 Some((user, host)) => (user.to_string(), host),
214 None => (String::new(), rest),
215 };
216 let (host, port) = split_host_port(rest)?;
217 let port = port.unwrap_or(protocol.default_port());
218 Some(Entry { name: host.clone(), host, port, protocol, username, added: Some(now()), ..Entry::default() })
219 }
220
221 pub fn address(&self) -> String {
222 if self.port == self.protocol.default_port() || self.protocol == Protocol::Modem {
223 self.host.clone()
224 } else if self.host.contains(':') {
225 format!("[{}]:{}", self.host, self.port)
226 } else {
227 format!("{}:{}", self.host, self.port)
228 }
229 }
230
231 fn same_system(&self, other: &Entry) -> bool {
232 self.host.eq_ignore_ascii_case(&other.host) && self.port == other.port && self.protocol == other.protocol
233 }
234
235 pub fn system_id(&self) -> String {
238 format!("{}://{}:{}", self.protocol.name().to_ascii_lowercase(), self.host.to_ascii_lowercase(), self.port)
239 }
240}
241
242fn is_false(b: &bool) -> bool {
243 !*b
244}
245
246fn is_true(b: &bool) -> bool {
247 *b
248}
249
250fn is_zero(n: &u8) -> bool {
251 *n == 0
252}
253
254fn is_zero_u32(n: &u32) -> bool {
255 *n == 0
256}
257
258pub const BAUD_RATES: [u32; 11] = [0, 300, 1200, 2400, 9600, 14400, 19200, 28800, 33600, 57600, 115200];
260
261fn is_80(n: &u16) -> bool {
262 *n == 80
263}
264
265fn is_25(n: &u16) -> bool {
266 *n == 25
267}
268
269pub const SCREEN_MODES: [(u16, u16); 6] = [(80, 25), (80, 43), (80, 50), (132, 25), (132, 37), (132, 52)];
272
273#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
275#[serde(rename_all = "lowercase")]
276pub enum BellMode {
277 #[default]
279 Sound,
280 Visual,
282 Both,
283 Off,
284}
285
286impl BellMode {
287 pub const ALL: [BellMode; 4] = [BellMode::Sound, BellMode::Visual, BellMode::Both, BellMode::Off];
288
289 pub fn name(self) -> &'static str {
290 match self {
291 BellMode::Sound => "Beep",
292 BellMode::Visual => "Flash",
293 BellMode::Both => "Beep and flash",
294 BellMode::Off => "Off",
295 }
296 }
297
298 pub fn sound(self) -> bool {
299 matches!(self, BellMode::Sound | BellMode::Both)
300 }
301
302 pub fn visual(self) -> bool {
303 matches!(self, BellMode::Visual | BellMode::Both)
304 }
305}
306
307#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
309#[serde(rename_all = "lowercase")]
310pub enum CrtMode {
311 #[default]
312 Off,
313 Color,
315 Green,
317 Amber,
319}
320
321impl CrtMode {
322 pub const ALL: [CrtMode; 4] = [CrtMode::Off, CrtMode::Color, CrtMode::Green, CrtMode::Amber];
323
324 pub fn next(self) -> CrtMode {
325 let i = CrtMode::ALL.iter().position(|&m| m == self).unwrap_or(0);
326 CrtMode::ALL[(i + 1) % CrtMode::ALL.len()]
327 }
328
329 pub fn name(self) -> &'static str {
330 match self {
331 CrtMode::Off => "Off",
332 CrtMode::Color => "Color CRT",
333 CrtMode::Green => "Green screen",
334 CrtMode::Amber => "Amber screen",
335 }
336 }
337}
338
339pub fn default_entries() -> Vec<Entry> {
343 vec![Entry {
344 name: "The Vortex BBS".into(),
345 host: "vortexbbs.com".into(),
346 port: 23,
347 protocol: Protocol::Telnet,
348 comment: "Home BBS of the BadTerm project".into(),
349 pinned: true,
350 ..Entry::default()
351 }]
352}
353
354fn split_host_port(s: &str) -> Option<(String, Option<u16>)> {
355 if s.is_empty() {
356 return None;
357 }
358 if let Some(rest) = s.strip_prefix('[') {
359 let (host, after) = rest.split_once(']')?;
360 let port = match after.strip_prefix(':') {
361 Some(p) => Some(p.parse().ok()?),
362 None => None,
363 };
364 return Some((host.to_string(), port));
365 }
366 match s.rsplit_once(':') {
367 Some((h, p)) if !h.contains(':') => Some((h.to_string(), Some(p.parse().ok()?))),
369 _ => Some((s.to_string(), None)),
370 }
371}
372
373pub fn now() -> u64 {
374 SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
375}
376
377#[derive(Clone, Copy, Debug, PartialEq, Eq)]
378pub enum Format {
379 Toml,
380 Json,
381 Csv,
383 SyncTerm,
384 MagiTerm,
387 EtherTerm,
389 NetRunner,
391 Qodem,
393}
394
395impl Format {
396 pub fn from_path(path: &Path) -> Option<Format> {
397 let ext = path.extension()?.to_str()?.to_ascii_lowercase();
398 match ext.as_str() {
399 "toml" => Some(Format::Toml),
400 "json" => Some(Format::Json),
401 "csv" => Some(Format::Csv),
402 "ini" if path.file_stem().is_some_and(|s| s.eq_ignore_ascii_case("magiterm")) => Some(Format::MagiTerm),
403 "lst" | "ini" => Some(Format::SyncTerm),
404 "xml" => Some(Format::EtherTerm),
405 "phn" => Some(Format::NetRunner),
406 "txt" if path.file_stem().is_some_and(|s| s.eq_ignore_ascii_case("fonebook")) => Some(Format::Qodem),
407 _ => None,
408 }
409 }
410}
411
412#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
414#[serde(rename_all = "snake_case")]
415pub enum SortOrder {
416 #[default]
417 Name,
418 LastCalled,
420 Calls,
422}
423
424impl SortOrder {
425 pub fn name(self) -> &'static str {
426 match self {
427 SortOrder::Name => "Name",
428 SortOrder::LastCalled => "Last called",
429 SortOrder::Calls => "Calls",
430 }
431 }
432
433 pub fn next(self) -> SortOrder {
434 match self {
435 SortOrder::Name => SortOrder::LastCalled,
436 SortOrder::LastCalled => SortOrder::Calls,
437 SortOrder::Calls => SortOrder::Name,
438 }
439 }
440
441 fn is_default(&self) -> bool {
442 *self == SortOrder::Name
443 }
444}
445
446#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
447pub struct Directory {
448 #[serde(default, skip_serializing_if = "SortOrder::is_default")]
450 pub sort: SortOrder,
451 #[serde(default, skip_serializing_if = "Vec::is_empty")]
454 pub defaults_offered: Vec<String>,
455 #[serde(default, rename = "entry")]
456 pub entries: Vec<Entry>,
457}
458
459impl Directory {
460 pub fn groups(&self) -> Vec<String> {
462 let mut groups: Vec<String> = Vec::new();
463 for e in &self.entries {
464 let g = e.group.trim();
465 if !g.is_empty() && !groups.iter().any(|h| h.eq_ignore_ascii_case(g)) {
466 groups.push(g.to_string());
467 }
468 }
469 groups.sort_by_key(|g| g.to_lowercase());
470 groups
471 }
472
473 pub fn load(path: &Path) -> Result<Directory> {
475 match fs::read_to_string(path) {
476 Ok(s) => toml::from_str(&s).with_context(|| format!("parsing {}", path.display())),
477 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Directory::default()),
478 Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
479 }
480 }
481
482 pub fn save(&self, path: &Path) -> Result<()> {
484 if let Some(dir) = path.parent() {
485 fs::create_dir_all(dir)?;
486 }
487 let tmp = path.with_extension("toml.tmp");
488 fs::write(&tmp, toml::to_string_pretty(self)?)?;
489 fs::rename(&tmp, path).with_context(|| format!("writing {}", path.display()))
490 }
491
492 pub fn sort(&mut self) {
494 self.entries.sort_by_key(|e| (!e.pinned, e.name.to_lowercase()));
495 }
496
497 pub fn view(&self, filter: &str) -> Vec<usize> {
500 self.view_group(filter, None)
501 }
502
503 pub fn view_group(&self, filter: &str, group: Option<&str>) -> Vec<usize> {
505 let filter = filter.trim().to_lowercase();
506 let mut v: Vec<usize> = (0..self.entries.len())
507 .filter(|&i| {
508 let e = &self.entries[i];
509 group.is_none_or(|g| e.group.trim().eq_ignore_ascii_case(g))
510 && (filter.is_empty()
511 || [&e.name, &e.host, &e.comment, &e.group].iter().any(|f| f.to_lowercase().contains(&filter))
512 || e.address().to_lowercase().contains(&filter))
513 })
514 .collect();
515 let name = |i: &usize| self.entries[*i].name.to_lowercase();
516 match self.sort {
517 SortOrder::Name => v.sort_by_key(|i| (!self.entries[*i].pinned, name(i))),
518 SortOrder::LastCalled => v.sort_by_key(|i| {
519 let e = &self.entries[*i];
520 (!e.pinned, std::cmp::Reverse(e.last_connected.unwrap_or(0)), name(i))
521 }),
522 SortOrder::Calls => v.sort_by_key(|i| {
523 let e = &self.entries[*i];
524 (!e.pinned, std::cmp::Reverse(e.calls), name(i))
525 }),
526 }
527 v
528 }
529
530 pub fn offer_defaults(&mut self) -> bool {
533 let mut changed = false;
534 for e in default_entries() {
535 let id = e.system_id();
536 if self.defaults_offered.contains(&id) {
537 continue;
538 }
539 if !self.entries.iter().any(|x| x.same_system(&e)) {
540 self.entries.push(Entry { added: Some(now()), ..e });
541 }
542 self.defaults_offered.push(id);
543 changed = true;
544 }
545 if changed {
546 self.sort();
547 }
548 changed
549 }
550
551 pub fn merge(&mut self, new: Vec<Entry>) -> usize {
554 let mut added = 0;
555 for e in new {
556 if !self.entries.iter().any(|x| x.same_system(&e)) {
557 self.entries.push(e);
558 added += 1;
559 }
560 }
561 self.sort();
562 added
563 }
564}
565
566pub fn import(path: &Path) -> Result<Vec<Entry>> {
569 let bytes = fs::read(path).with_context(|| format!("reading {}", path.display()))?;
570 if bytes.starts_with(NETRUNNER_MAGIC) || Format::from_path(path) == Some(Format::NetRunner) {
572 return Ok(finish_import(from_netrunner(&bytes)?));
573 }
574 let text = match String::from_utf8(bytes) {
576 Ok(s) => s,
577 Err(e) => crate::cp437::decode(e.as_bytes()),
578 };
579 let text = text.trim_start_matches('\u{feff}');
580 let format = Format::from_path(path).or_else(|| sniff(text));
581 let entries = match format {
582 Some(Format::Toml) => toml::from_str::<Directory>(text)?.entries,
583 Some(Format::Json) => from_json(text)?,
584 Some(Format::Csv) => from_csv(text)?,
585 Some(Format::SyncTerm) => from_syncterm(text),
586 Some(Format::MagiTerm) => from_syncterm(text)
587 .into_iter()
588 .map(|mut e| {
589 if e.protocol == Protocol::Telnet {
590 e.protocol = Protocol::Ssh;
591 if e.port == Protocol::Telnet.default_port() {
592 e.port = Protocol::Ssh.default_port();
593 }
594 }
595 e
596 })
597 .collect(),
598 Some(Format::EtherTerm) => from_etherterm(text)?,
599 Some(Format::Qodem) => from_qodem(text),
600 Some(Format::NetRunner) => unreachable!("NetRunner phone books are read before decoding"),
602 None => bail!("unrecognized dialing directory format: {}", path.display()),
603 };
604 Ok(finish_import(entries))
605}
606
607pub fn export(path: &Path, entries: &[Entry]) -> Result<()> {
609 let Some(format) = Format::from_path(path) else {
610 bail!("use a .lst, .csv, .json or .toml file name to choose the export format");
611 };
612 let text = match format {
613 Format::Toml => toml::to_string_pretty(&Directory { entries: entries.to_vec(), ..Directory::default() })?,
614 Format::Json => serde_json::to_string_pretty(entries)?,
615 Format::Csv => to_csv(entries)?,
616 Format::SyncTerm => to_syncterm(entries),
617 Format::MagiTerm | Format::EtherTerm | Format::NetRunner | Format::Qodem => {
618 bail!("that format can be imported, not exported")
619 }
620 };
621 fs::write(path, text).with_context(|| format!("writing {}", path.display()))
622}
623
624fn sniff(text: &str) -> Option<Format> {
625 let t = text.trim_start();
626 if is_qodem(t) {
627 Some(Format::Qodem)
628 } else if t.starts_with('{') || t.starts_with('[') && t[1..].trim_start().starts_with('{') {
629 Some(Format::Json)
630 } else if t.starts_with("<?xml") || t.starts_with("<EtherTerm") {
631 Some(Format::EtherTerm)
632 } else if t.starts_with("[[entry]]") {
633 Some(Format::Toml)
634 } else if t.starts_with('[') {
635 Some(Format::SyncTerm)
636 } else if t.lines().next().is_some_and(|l| l.contains(',')) {
637 Some(Format::Csv)
638 } else {
639 None
640 }
641}
642
643fn from_json(text: &str) -> Result<Vec<Entry>> {
644 match serde_json::from_str::<Vec<Entry>>(text) {
646 Ok(v) => Ok(v),
647 Err(_) => Ok(serde_json::from_str::<Directory>(text)?.entries),
648 }
649}
650
651const CSV_HEADER: [&str; 8] = ["name", "host", "port", "protocol", "encoding", "username", "term_type", "comment"];
652
653fn to_csv(entries: &[Entry]) -> Result<String> {
654 let mut w = csv::Writer::from_writer(Vec::new());
655 w.write_record(CSV_HEADER)?;
656 for e in entries {
657 w.write_record([
658 e.name.as_str(),
659 &e.host,
660 &e.port.to_string(),
661 e.protocol.name(),
662 e.encoding.name(),
663 &e.username,
664 &e.term_type,
665 &e.comment,
666 ])?;
667 }
668 Ok(String::from_utf8(w.into_inner()?)?)
669}
670
671fn from_csv(text: &str) -> Result<Vec<Entry>> {
674 let mut r = csv::ReaderBuilder::new().flexible(true).trim(csv::Trim::All).from_reader(text.as_bytes());
675 let headers: Vec<String> = r.headers()?.iter().map(|h| h.to_ascii_lowercase()).collect();
676 let col = |names: &[&str]| headers.iter().position(|h| names.contains(&h.as_str()));
677 let c_name = col(&["name", "bbs", "bbs name", "system", "bbsname"]);
678 let c_host = col(&["host", "address", "hostname", "telnet", "telnetaddress"]);
679 let c_port = col(&["port", "bbsport"]);
680 let c_ssh_port = col(&["sshport"]);
683 let c_new_login = col(&["newlogin"]);
684 let c_details: Vec<usize> = ["software", "location", "bbssysop"].iter().filter_map(|n| col(&[n])).collect();
685 let c_proto = col(&["protocol", "type", "connectiontype"]);
686 let c_enc = col(&["encoding", "charset"]);
687 let c_user = col(&["username", "user"]);
688 let c_tt = col(&["term_type", "terminal"]);
689 let c_comment = col(&["comment", "comments", "description", "notes"]);
690 let Some(c_host) = c_host else { bail!("CSV needs a 'host' or 'address' column") };
691
692 let mut out = Vec::new();
693 for rec in r.records() {
694 let rec = rec?;
695 let get = |c: Option<usize>| c.and_then(|c| rec.get(c)).unwrap_or("").to_string();
696 let mut e = match Entry::from_address(&get(Some(c_host))) {
697 Some(e) => e,
698 None => continue,
699 };
700 if let Some(p) = c_proto.and_then(|c| rec.get(c)).and_then(Protocol::from_name) {
701 if e.port == e.protocol.default_port() {
702 e.port = p.default_port();
703 }
704 e.protocol = p;
705 }
706 if let Ok(port) = get(c_port).parse() {
707 e.port = port;
708 } else if let Ok(port) = get(c_ssh_port).parse() {
709 e.protocol = Protocol::Ssh;
711 e.port = port;
712 e.username = get(c_new_login);
713 }
714 let name = get(c_name);
715 if !name.is_empty() {
716 e.name = name;
717 }
718 if get(c_enc).to_ascii_lowercase().replace('-', "") == "utf8" {
719 e.encoding = Encoding::Utf8;
720 }
721 if c_user.is_some() {
722 e.username = get(c_user);
723 }
724 let tt = get(c_tt);
725 if !tt.is_empty() {
726 e.term_type = tt;
727 }
728 e.comment = get(c_comment);
729 if e.comment.is_empty() {
730 let details: Vec<String> = c_details.iter().map(|&c| get(Some(c))).filter(|d| !d.is_empty()).collect();
731 e.comment = details.join(", ");
732 }
733 out.push(e);
734 }
735 Ok(out)
736}
737
738fn finish_import(entries: Vec<Entry>) -> Vec<Entry> {
740 let stamp = now();
741 entries
742 .into_iter()
743 .filter(|e| !e.host.is_empty())
744 .map(|mut e| {
745 if e.name.is_empty() {
746 e.name = e.host.clone();
747 }
748 e.added.get_or_insert(stamp);
749 e
750 })
751 .collect()
752}
753
754const NETRUNNER_MAGIC: &[u8] = b"\x14NetRunner Phone Book";
757
758fn from_netrunner(data: &[u8]) -> Result<Vec<Entry>> {
764 const START: usize = 48;
765 const RECORD: usize = 362;
766 if !data.starts_with(NETRUNNER_MAGIC) {
767 bail!("not a NetRunner phone book");
768 }
769 let text = |r: &[u8], at: usize| -> String {
770 let len = (r[at] as usize).min(40);
771 crate::cp437::decode(&r[at + 1..at + 1 + len]).trim().to_string()
772 };
773 let mut out = Vec::new();
774 for r in data.get(START..).unwrap_or(&[]).as_chunks::<RECORD>().0 {
775 let (name, address) = (text(r, 0), text(r, 41));
776 let Some(mut e) = Entry::from_address(&address) else { continue };
777 if !name.is_empty() {
778 e.name = name;
779 }
780 e.username = text(r, 82);
781 e.password = text(r, 123);
782 let details: Vec<String> = [text(r, 320), text(r, 279)].into_iter().filter(|d| !d.is_empty()).collect();
783 e.comment = details.join(", ");
784 out.push(e);
785 }
786 Ok(out)
787}
788
789fn from_etherterm(text: &str) -> Result<Vec<Entry>> {
792 if !text.contains("<Phonebook") && !text.contains("<BBS") {
793 bail!("not an EtherTerm dialing directory");
794 }
795 let mut out = Vec::new();
796 for tag in text.split("<BBS").skip(1) {
797 let Some(end) = tag.find('>') else { continue };
798 let attrs = xml_attributes(&tag[..end]);
799 let get =
800 |k: &str| attrs.iter().find(|(n, _)| n.eq_ignore_ascii_case(k)).map(|(_, v)| v.as_str()).unwrap_or("");
801 let Some(mut e) = Entry::from_address(get("ip")) else { continue };
802 if let Some(p) = Protocol::from_name(get("protocol")) {
803 e.protocol = p;
804 e.port = p.default_port();
805 }
806 if let Ok(port) = get("port").parse() {
807 e.port = port;
808 }
809 e.name = get("name").trim().to_string();
810 e.username = get("login").to_string();
811 e.password = get("password").to_string();
812 let font = get("font").to_ascii_lowercase();
813 e.font = match () {
814 _ if font.contains("topaz") && font.contains("plus") => 40,
815 _ if font.contains("topaz") => 42,
816 _ if font.contains("microknight") && font.contains("plus") => 39,
817 _ if font.contains("microknight") => 41,
818 _ if font.contains("noodle") => 37,
819 _ if font.contains("sooul") || font.contains("mosoul") => 38,
820 _ => 0,
821 };
822 out.push(e);
823 }
824 Ok(out)
825}
826
827fn xml_attributes(tag: &str) -> Vec<(String, String)> {
829 let mut out = Vec::new();
830 let mut rest = tag;
831 while let Some(eq) = rest.find('=') {
832 let name = rest[..eq].trim().rsplit(char::is_whitespace).next().unwrap_or("").to_string();
833 let after = rest[eq + 1..].trim_start();
834 let Some(quote) = after.chars().next().filter(|c| *c == '"' || *c == '\'') else { break };
835 let Some(close) = after[1..].find(quote) else { break };
836 out.push((name, xml_unescape(&after[1..1 + close])));
837 rest = &after[close + 2..];
838 }
839 out
840}
841
842fn xml_unescape(s: &str) -> String {
843 let mut out = String::with_capacity(s.len());
844 let mut rest = s;
845 while let Some(amp) = rest.find('&') {
846 out.push_str(&rest[..amp]);
847 let Some(semi) = rest[amp..].find(';') else { break };
848 let entity = &rest[amp + 1..amp + semi];
849 let decoded = match entity {
850 "amp" => Some('&'),
851 "lt" => Some('<'),
852 "gt" => Some('>'),
853 "quot" => Some('"'),
854 "apos" => Some('\''),
855 _ => entity
856 .strip_prefix("#x")
857 .and_then(|h| u32::from_str_radix(h, 16).ok())
858 .or_else(|| entity.strip_prefix('#').and_then(|d| d.parse().ok()))
859 .and_then(char::from_u32),
860 };
861 match decoded {
862 Some(c) => out.push(c),
863 None => out.push_str(&rest[amp..amp + semi + 1]),
864 }
865 rest = &rest[amp + semi + 1..];
866 }
867 out.push_str(rest);
868 out
869}
870
871fn is_qodem(text: &str) -> bool {
876 text.starts_with("# Qodem") || text.lines().take(20).any(|l| l.trim() == "[entry]") && text.contains("\nmethod=")
877}
878
879fn from_qodem(text: &str) -> Vec<Entry> {
884 let mut out = Vec::new();
885 let mut cur: Option<Entry> = None;
886 let mut skip = false;
887 let mut notes: Option<Vec<String>> = None;
888 let finish = |e: Option<Entry>, skip: bool, out: &mut Vec<Entry>| {
889 if let Some(mut e) = e.filter(|_| !skip) {
890 if e.port == 0 {
891 e.port = e.protocol.default_port();
892 }
893 out.push(e);
894 }
895 };
896 for line in text.lines() {
897 if let Some(n) = notes.as_mut() {
898 if line == "END" {
899 let text = notes.take().unwrap_or_default().join(" ");
900 if let Some(e) = cur.as_mut() {
901 e.comment = text.trim().to_string();
902 }
903 } else {
904 n.push(line.trim().to_string());
905 }
906 continue;
907 }
908 let line = line.trim();
909 if line == "[entry]" {
910 finish(cur.take(), skip, &mut out);
911 cur = Some(Entry { port: 0, ..Entry::default() });
912 skip = false;
913 continue;
914 }
915 let (Some(e), Some((key, value))) = (cur.as_mut(), line.split_once('=')) else { continue };
916 let value = value.trim();
917 match key {
918 "name" => e.name = value.to_string(),
919 "address" => e.host = value.to_string(),
920 "port" => e.port = value.parse().unwrap_or(0),
921 "username" => e.username = value.to_string(),
922 "password" => e.password = value.to_string(),
923 "method" => match value {
924 "TELNET" => e.protocol = Protocol::Telnet,
925 "SSH" => e.protocol = Protocol::Ssh,
926 "RLOGIN" => e.protocol = Protocol::Rlogin,
927 "SOCKET" => e.protocol = Protocol::Raw,
928 "MODEM" => e.protocol = Protocol::Modem,
929 _ => skip = true,
930 },
931 "emulation" => match value {
932 "AVATAR" => e.encoding = Encoding::Avatar,
933 "PETSCII" => e.encoding = Encoding::Petscii,
934 "ATASCII" => e.encoding = Encoding::Atascii,
935 "PRESTEL" => e.encoding = Encoding::Viewdata,
936 "BEEB" => e.encoding = Encoding::Mode7,
937 "L_UTF8" | "X_UTF8" => e.encoding = Encoding::Utf8,
938 _ => {}
939 },
940 "codepage" if value.eq_ignore_ascii_case("utf-8") => e.encoding = Encoding::Utf8,
941 "times_on" => e.calls = value.parse().unwrap_or(0),
942 "last_call" => e.last_connected = value.parse().ok().filter(|&t| t != 0),
943 "capture_filename" if !value.is_empty() => e.capture_file = value.to_string(),
944 "notes" if value == "<<<END" => notes = Some(Vec::new()),
945 _ => {}
946 }
947 }
948 finish(cur.take(), skip, &mut out);
949 out
950}
951
952fn from_syncterm(text: &str) -> Vec<Entry> {
953 let mut out = Vec::new();
954 let mut cur: Option<Entry> = None;
955 for line in text.lines() {
956 let line = line.trim();
957 if line.is_empty() || line.starts_with(';') || line.starts_with('#') {
958 continue;
959 }
960 if let Some(name) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
961 out.extend(cur.take());
962 cur = Some(Entry { name: name.to_string(), port: 0, ..Entry::default() });
963 continue;
964 }
965 let (Some(e), Some((key, value))) = (cur.as_mut(), line.split_once('=')) else { continue };
966 let value = value.trim();
967 match key.trim().to_ascii_lowercase().as_str() {
968 "address" => e.host = value.to_string(),
969 "port" => e.port = value.parse().unwrap_or(0),
970 "connectiontype" => match Protocol::from_name(value) {
971 Some(p) => e.protocol = p,
972 None => log::warn!("{}: unsupported connection type {value}", e.name),
973 },
974 "username" => e.username = value.to_string(),
975 "password" => e.password = value.to_string(),
976 "comment" => e.comment = value.to_string(),
977 "totalcalls" => e.calls = value.parse().unwrap_or(0),
978 "added" => e.added = value.parse().ok(),
979 "lastconnected" => e.last_connected = value.parse().ok().filter(|&t| t != 0),
980 _ => {}
981 }
982 }
983 out.extend(cur);
984 for e in &mut out {
985 if let Some(parsed) = Entry::from_address(&e.host) {
987 if e.port == 0 && parsed.port != parsed.protocol.default_port() {
988 e.port = parsed.port;
989 }
990 e.host = parsed.host;
991 }
992 if e.port == 0 {
993 e.port = e.protocol.default_port();
994 }
995 }
996 out
997}
998
999fn to_syncterm(entries: &[Entry]) -> String {
1000 let mut s = String::new();
1001 for e in entries {
1002 let name: String = e.name.chars().map(|c| if c == '[' || c == ']' { '_' } else { c }).collect();
1004 s.push_str(&format!("[{name}]\n"));
1005 s.push_str(&format!("\tAddress={}\n", e.host));
1006 s.push_str(&format!("\tPort={}\n", e.port));
1007 s.push_str(&format!("\tConnectionType={}\n", e.protocol.name()));
1008 if let Some(t) = e.added {
1009 s.push_str(&format!("\tAdded={t}\n"));
1010 }
1011 if let Some(t) = e.last_connected {
1012 s.push_str(&format!("\tLastConnected={t}\n"));
1013 }
1014 s.push_str(&format!("\tTotalCalls={}\n", e.calls));
1015 if !e.username.is_empty() {
1016 s.push_str(&format!("\tUserName={}\n", e.username));
1017 }
1018 if !e.password.is_empty() {
1019 s.push_str(&format!("\tPassword={}\n", e.password));
1020 }
1021 if !e.comment.is_empty() {
1022 s.push_str(&format!("\tComment={}\n", e.comment));
1023 }
1024 s.push('\n');
1025 }
1026 s
1027}
1028
1029#[cfg(test)]
1030mod tests {
1031 use super::*;
1032
1033 fn sample() -> Vec<Entry> {
1034 vec![
1035 Entry {
1036 name: "Alpha BBS".into(),
1037 host: "alpha.example.org".into(),
1038 comment: "Has, commas".into(),
1039 ..Entry::default()
1040 },
1041 Entry {
1042 name: "Beta".into(),
1043 host: "beta.example.org".into(),
1044 port: 2323,
1045 crt: Some(CrtMode::Amber),
1046 ..Entry::default()
1047 },
1048 ]
1049 }
1050
1051 #[test]
1052 fn parse_addresses() {
1053 let e = Entry::from_address("telnet://bbs.example.com:2323").unwrap();
1054 assert_eq!((e.host.as_str(), e.port), ("bbs.example.com", 2323));
1055 let e = Entry::from_address("bbs.example.com").unwrap();
1056 assert_eq!(e.port, 23);
1057 let e = Entry::from_address("[::1]:23").unwrap();
1058 assert_eq!(e.host, "::1");
1059 let e = Entry::from_address("ssh://bbs.example.com").unwrap();
1060 assert_eq!((e.protocol, e.port), (Protocol::Ssh, 22));
1061 let e = Entry::from_address("ssh://guest@bbs.example.com:2222").unwrap();
1062 assert_eq!((e.username.as_str(), e.host.as_str(), e.port), ("guest", "bbs.example.com", 2222));
1063 assert!(Entry::from_address("host:notaport").is_none());
1064 }
1065
1066 #[test]
1067 fn syncterm_round_trip() {
1068 let text = to_syncterm(&sample());
1069 let back = from_syncterm(&text);
1070 assert_eq!(back.len(), 2);
1071 assert_eq!(back[1].port, 2323);
1072 assert_eq!(back[0].comment, "Has, commas");
1073 }
1074
1075 #[test]
1076 fn syncterm_real_world() {
1077 let text = "[Some BBS]\n\tAddress=bbs.example.net:2300\n\tConnectionType=Telnet\n\tScreenMode=80x25\n\
1078 [Secure]\nAddress=s.example.net\nConnectionType=SSH\nPort=2222\n";
1079 let v = from_syncterm(text);
1080 assert_eq!((v[0].host.as_str(), v[0].port), ("bbs.example.net", 2300));
1081 assert_eq!((v[1].protocol, v[1].port), (Protocol::Ssh, 2222));
1082 }
1083
1084 #[test]
1085 fn csv_round_trip() {
1086 let text = to_csv(&sample()).unwrap();
1087 let back = from_csv(&text).unwrap();
1088 assert_eq!(back[0].name, "Alpha BBS");
1089 assert_eq!(back[0].comment, "Has, commas");
1090 assert_eq!(back[1].port, 2323);
1091 }
1092
1093 #[test]
1094 fn csv_with_foreign_columns() {
1095 let text = "BBS Name,Address,Sysop\nFoo,foo.example.com:2023,Bob\n";
1096 let v = from_csv(text).unwrap();
1097 assert_eq!((v[0].name.as_str(), v[0].port), ("Foo", 2023));
1098 }
1099
1100 #[test]
1101 fn toml_and_json_round_trip() {
1102 let d = Directory { entries: sample(), defaults_offered: vec!["telnet://x:23".into()], ..Directory::default() };
1103 let t = toml::to_string_pretty(&d).unwrap();
1104 assert_eq!(toml::from_str::<Directory>(&t).unwrap(), d);
1105 let j = serde_json::to_string(&d.entries).unwrap();
1106 assert_eq!(from_json(&j).unwrap(), d.entries);
1107 }
1108
1109 #[test]
1110 fn merge_skips_duplicates() {
1111 let mut d = Directory { entries: sample(), ..Directory::default() };
1112 let added = d.merge(sample());
1113 assert_eq!(added, 0);
1114 assert_eq!(d.entries.len(), 2);
1115 }
1116
1117 #[test]
1118 fn defaults_are_offered_once() {
1119 let mut d = Directory { entries: sample(), ..Directory::default() };
1120 assert!(d.offer_defaults());
1121 assert_eq!(d.entries[0].host, "vortexbbs.com");
1123 assert_eq!(d.entries.len(), 3);
1124 assert!(!d.offer_defaults());
1125
1126 d.entries.remove(0);
1128 let d: Directory = toml::from_str(&toml::to_string_pretty(&d).unwrap()).unwrap();
1129 let mut d = d;
1130 assert!(!d.offer_defaults());
1131 assert_eq!(d.entries.len(), 2);
1132 }
1133
1134 #[test]
1135 fn new_fields_are_optional_in_toml() {
1136 let d: Directory = toml::from_str("[[entry]]\nname = \"A\"\nhost = \"a.example\"\n").unwrap();
1137 assert!(!d.entries[0].pinned && !d.entries[0].keychain && d.entries[0].key_file.is_empty());
1138 let t = toml::to_string_pretty(&d).unwrap();
1139 assert!(!t.contains("pinned") && !t.contains("keychain") && !t.contains("key_file"), "{t}");
1140 assert!(d.entries[0].crt.is_none() && !t.contains("crt"), "{t}");
1141 }
1142
1143 #[test]
1144 fn crt_mode_in_toml() {
1145 let d: Directory = toml::from_str("[[entry]]\nhost = \"a.example\"\ncrt = \"green\"\n").unwrap();
1146 assert_eq!(d.entries[0].crt, Some(CrtMode::Green));
1147 assert_eq!(CrtMode::Amber.next(), CrtMode::Off);
1148 }
1149
1150 #[test]
1151 fn view_sorts_and_filters() {
1152 let mut d = Directory { entries: sample(), ..Directory::default() };
1153 d.entries[0].calls = 1;
1154 d.entries[1].calls = 5;
1155 d.entries[0].last_connected = Some(200);
1156 d.entries[1].last_connected = Some(100);
1157 assert_eq!(d.view(""), [0, 1]);
1158 d.sort = SortOrder::Calls;
1159 assert_eq!(d.view(""), [1, 0]);
1160 d.sort = SortOrder::LastCalled;
1161 assert_eq!(d.view(""), [0, 1]);
1162 d.entries[1].pinned = true;
1164 assert_eq!(d.view(""), [1, 0]);
1165
1166 assert_eq!(d.view("ALPHA"), [0]);
1167 d.entries[0].group = "Retro".into();
1169 d.entries[1].group = "retro ".into();
1170 assert_eq!(d.groups(), ["Retro"]);
1171 d.entries[1].group = "Art".into();
1172 assert_eq!(d.groups(), ["Art", "Retro"]);
1173 assert_eq!(d.view_group("", Some("retro")), [0]);
1174 assert_eq!(d.view_group("", Some("Art")), [1]);
1175 assert_eq!(d.view("art"), [1]);
1176 assert_eq!(d.view("commas"), [0]);
1177 assert_eq!(d.view(":2323"), [1]);
1178 assert!(d.view("nothing like it").is_empty());
1179
1180 let t = toml::to_string_pretty(&d).unwrap();
1181 assert!(t.contains("sort = \"last_called\""), "{t}");
1182 assert_eq!(toml::from_str::<Directory>(&t).unwrap().sort, SortOrder::LastCalled);
1183 }
1184
1185 #[test]
1186 fn telnet_bbs_guide_csv() {
1187 let text = "bbsName, bbsSysop, newLogin, TelnetAddress, bbsPort, sshPort, WebAddress, location, Modem, software\n\
1188 13th Floor BBS,Hoy Brothers,new,13th.hoyvision.com,6400,,,\"Oak Ridge, TN, USA\",,\"Image BBS\"\n\
1189 3D Realms,,NEW,172.0.79.226,,,,\"Cotati, CA, USA\",,\"Synchronet\"\n\
1190 4 Wheel Ham BBS,w7jpj,NEW,bbs.4wheelham.com,,2222,,\"Littleton, CO, USA\",,\"Synchronet\"\n";
1191 let v = from_csv(text).unwrap();
1192 assert_eq!((v[0].name.as_str(), v[0].host.as_str(), v[0].port), ("13th Floor BBS", "13th.hoyvision.com", 6400));
1193 assert_eq!(v[0].comment, "Image BBS, Oak Ridge, TN, USA, Hoy Brothers");
1194 assert_eq!((v[1].protocol, v[1].port), (Protocol::Telnet, 23));
1195 assert_eq!((v[2].protocol, v[2].port, v[2].username.as_str()), (Protocol::Ssh, 2222, "NEW"));
1196 }
1197
1198 #[test]
1199 fn etherterm_and_magiterm() {
1200 let xml = r#"<?xml version="1.0" ?>
1201<EtherTerm>
1202 <Phonebook version="1.0">
1203 <BBS name="Tom & Jerry's" ip="bbs.example.org" port="6523" protocol="TELNET" login="" password="" font="vga8x16.bmp" keyMap="ANSI" />
1204 <BBS name="Amiga Land (ssh)" ip="amiga.example.org" port="2222" protocol="ssh" login="guest" password="pw" font="topaz-a1200.bmp" keyMap="ANSI" />
1205 </Phonebook>
1206</EtherTerm>"#;
1207 assert_eq!(sniff(xml), Some(Format::EtherTerm));
1208 let v = from_etherterm(xml).unwrap();
1209 assert_eq!(v[0].name, "Tom & Jerry's");
1210 assert_eq!((v[0].protocol, v[0].port), (Protocol::Telnet, 6523));
1211 assert_eq!((v[1].protocol, v[1].port, v[1].username.as_str(), v[1].font), (Protocol::Ssh, 2222, "guest", 42));
1212
1213 let dir = std::env::temp_dir().join(format!("badterm-magiterm-{}", std::process::id()));
1214 std::fs::create_dir_all(&dir).unwrap();
1215 let path = dir.join("magiterm.ini");
1216 std::fs::write(
1217 &path,
1218 "[A-Net Online]\naddress = mystic-anet.online \nport = 22 \n\n[After Hours]\naddress = ah-bbs.com\n",
1219 )
1220 .unwrap();
1221 let v = import(&path).unwrap();
1222 assert_eq!((v[0].protocol, v[0].port), (Protocol::Ssh, 22));
1223 assert_eq!((v[1].protocol, v[1].port), (Protocol::Ssh, 22));
1224 assert!(export(&dir.join("out.xml"), &v).is_err());
1225 std::fs::remove_dir_all(&dir).unwrap();
1226 }
1227
1228 #[test]
1229 fn netrunner_phone_book() {
1230 let book = include_bytes!("../tests/data/netrunner.phn");
1231 let v = from_netrunner(book).unwrap();
1232 assert_eq!(v.len(), 2);
1233 assert_eq!((v[0].name.as_str(), v[0].host.as_str(), v[0].port), ("The Vortex BBS", "vortexbbs.com", 23));
1234 assert_eq!((v[0].username.as_str(), v[0].password.as_str()), ("User Here", "Password Here"));
1235 assert_eq!(v[0].comment, "Enigma 1/2, Sysop Name Here");
1236 assert_eq!((v[1].name.as_str(), v[1].host.as_str()), ("Another System", "another.com"));
1237 assert!(from_netrunner(b"not a phone book").is_err());
1238
1239 let mut edited = book.to_vec();
1241 let at = 48 + 41;
1242 let addr = b"bbs.example.org:2323";
1243 edited[at] = addr.len() as u8;
1244 edited[at + 1..at + 1 + addr.len()].copy_from_slice(addr);
1245 let v = from_netrunner(&edited).unwrap();
1246 assert_eq!((v[0].host.as_str(), v[0].port), ("bbs.example.org", 2323));
1247 }
1248
1249 #[test]
1250 fn imports_qodem() {
1251 let text = "# Qodem Phonebook\n#\n\n[entry]\nname=The Vortex\naddress=vortexbbs.com\nport=\n\
1252 username=guest\npassword=pw\nmethod=TELNET\nemulation=AVATAR\ncodepage=CP437\ntimes_on=7\n\
1253 last_call=1700000000\nnotes=<<<END\nBest BBS\nin town\nEND\ncapture_filename=vortex.txt\n\n\
1254 [entry]\nname=Local shell\naddress=\nmethod=LOCAL\n\n\
1255 [entry]\nname=Dial-up\naddress=555-1234\nport=\nmethod=MODEM\nemulation=ANSI\n\n\
1256 [entry]\nname=Secure\naddress=ssh.example.org\nport=2222\nmethod=SSH\nemulation=X_UTF8\n";
1257 let dir = std::env::temp_dir().join(format!("badterm-qodem-{}", std::process::id()));
1258 fs::create_dir_all(&dir).unwrap();
1259 for name in ["fonebook.txt", "phonebook"] {
1261 fs::write(dir.join(name), text).unwrap();
1262 let v = import(&dir.join(name)).unwrap();
1263 assert_eq!(v.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(), ["The Vortex", "Dial-up", "Secure"]);
1264 let e = &v[0];
1265 assert_eq!(
1266 (e.host.as_str(), e.port, e.protocol, e.encoding),
1267 ("vortexbbs.com", 23, Protocol::Telnet, Encoding::Avatar)
1268 );
1269 assert_eq!((e.username.as_str(), e.password.as_str(), e.calls), ("guest", "pw", 7));
1270 assert_eq!((e.comment.as_str(), e.capture_file.as_str()), ("Best BBS in town", "vortex.txt"));
1271 assert_eq!(
1272 (v[1].protocol, v[1].host.as_str(), v[1].address()),
1273 (Protocol::Modem, "555-1234", "555-1234".into())
1274 );
1275 assert_eq!((v[2].protocol, v[2].port, v[2].encoding), (Protocol::Ssh, 2222, Encoding::Utf8));
1276 }
1277 let _ = fs::remove_dir_all(&dir);
1278 }
1279}