1use std::fmt;
17use std::io::{self, Read, Write};
18use std::net::{Shutdown, TcpStream, ToSocketAddrs};
19use std::path::{Path, PathBuf};
20use std::sync::{Arc, Mutex};
21use std::thread;
22use std::time::Duration;
23
24use anyhow::{Context, Result, anyhow, bail};
25use russh::MethodKind;
26use russh::client::{self, AuthResult, KeyboardInteractiveAuthResponse};
27use russh::keys::agent::AgentIdentity;
28use russh::keys::agent::client::{AgentClient, AgentStream};
29use russh::keys::known_hosts::{check_known_hosts_path, learn_known_hosts_path};
30use russh::keys::{HashAlg, PrivateKey, PrivateKeyWithHashAlg, PublicKey, PublicKeyOrCertificate};
31use russh::{ChannelMsg, Disconnect};
32use tokio::sync::{mpsc, oneshot};
33
34use crate::dialer::{Entry, Protocol};
35
36pub(crate) const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
37const MAX_KBD_INTERACTIVE_ROUNDS: usize = 5;
39const MAX_PROMPTED_ATTEMPTS: usize = 3;
41const DEFAULT_KEY_FILES: [&str; 3] = ["id_ed25519", "id_ecdsa", "id_rsa"];
43const LOGIN_CANCELLED: &str = "Login cancelled";
44
45#[derive(Debug)]
47pub enum NetEvent {
48 Connected(Writer),
50 Data(Vec<u8>),
51 Notice(String),
53 Prompt(AuthPrompt),
55 Closed(Option<String>),
57}
58
59#[derive(Clone, Copy, Debug, PartialEq, Eq)]
61pub enum PromptKind {
62 Password,
64 Passphrase,
66 Interactive,
69}
70
71pub struct AuthPrompt {
74 pub kind: PromptKind,
75 pub title: String,
77 pub instructions: String,
79 pub prompt: String,
80 pub echo: bool,
82 reply: oneshot::Sender<Option<String>>,
83}
84
85impl AuthPrompt {
86 pub fn answer(self, answer: Option<String>) {
88 let _ = self.reply.send(answer);
89 }
90}
91
92impl fmt::Debug for AuthPrompt {
93 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94 f.debug_struct("AuthPrompt")
95 .field("kind", &self.kind)
96 .field("title", &self.title)
97 .field("instructions", &self.instructions)
98 .field("prompt", &self.prompt)
99 .field("echo", &self.echo)
100 .finish()
101 }
102}
103
104#[derive(Clone, Debug)]
106pub struct ConnectOptions {
107 pub cols: u16,
108 pub rows: u16,
109 pub known_hosts: PathBuf,
111 pub ssh_agent: bool,
113 pub ssh_key_dir: Option<PathBuf>,
115}
116
117pub(crate) enum Cmd {
119 Data(Vec<u8>),
120 Close,
121}
122
123pub struct Writer(pub(crate) WriterKind);
125
126pub(crate) enum WriterKind {
127 Tcp(TcpStream),
128 Channel(mpsc::UnboundedSender<Cmd>),
130}
131
132impl fmt::Debug for Writer {
133 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134 f.write_str(match self.0 {
135 WriterKind::Tcp(_) => "Writer(TCP)",
136 WriterKind::Channel(_) => "Writer(channel)",
137 })
138 }
139}
140
141impl Writer {
142 pub fn write_all(&mut self, data: &[u8]) -> io::Result<()> {
143 match &mut self.0 {
144 WriterKind::Tcp(s) => s.write_all(data),
145 WriterKind::Channel(tx) => tx
146 .send(Cmd::Data(data.to_vec()))
147 .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "connection closed")),
148 }
149 }
150
151 pub fn shutdown(&self) {
153 match &self.0 {
154 WriterKind::Tcp(s) => {
155 let _ = s.shutdown(Shutdown::Both);
156 }
157 WriterKind::Channel(tx) => {
158 let _ = tx.send(Cmd::Close);
159 }
160 }
161 }
162}
163
164pub fn spawn_connection<F>(entry: &Entry, opts: &ConnectOptions, sink: F)
169where
170 F: Fn(NetEvent) + Send + 'static,
171{
172 match entry.protocol {
173 Protocol::Telnet | Protocol::Raw => spawn_tcp(entry.host.clone(), entry.port, None, sink),
174 Protocol::Rlogin => spawn_tcp(entry.host.clone(), entry.port, Some(rlogin_handshake(entry)), sink),
175 Protocol::TelnetS => {
176 crate::tls::spawn_tls(entry.host.clone(), entry.port, tls_pins_path(&opts.known_hosts), sink)
177 }
178 Protocol::Ssh => {
179 let params = SshParams {
180 host: entry.host.clone(),
181 port: entry.port,
182 user: entry.username.clone(),
183 password: entry.password.clone(),
184 key_file: (!entry.key_file.trim().is_empty()).then(|| expand_home(entry.key_file.trim())),
185 agent: opts.ssh_agent,
186 key_dir: opts.ssh_key_dir.clone(),
187 term: if entry.term_type.is_empty() { "ansi".into() } else { entry.term_type.clone() },
188 cols: opts.cols,
189 rows: opts.rows,
190 known_hosts: opts.known_hosts.clone(),
191 };
192 spawn_ssh(params, sink);
193 }
194 }
195}
196
197pub fn tls_pins_path(known_hosts: &Path) -> PathBuf {
199 known_hosts.with_file_name("tls_known_hosts")
200}
201
202pub fn rlogin_handshake(entry: &Entry) -> Vec<u8> {
207 let term = if entry.term_type.is_empty() { "ansi-bbs".to_string() } else { entry.term_type.to_ascii_lowercase() };
208 let mut out = vec![0];
209 for field in [entry.password.as_str(), entry.username.as_str(), &format!("{term}/115200")] {
210 out.extend_from_slice(field.as_bytes());
211 out.push(0);
212 }
213 out
214}
215
216fn spawn_tcp<F>(host: String, port: u16, handshake: Option<Vec<u8>>, sink: F)
219where
220 F: Fn(NetEvent) + Send + 'static,
221{
222 thread::spawn(move || {
223 let mut stream = match connect_tcp(&host, port) {
224 Ok(s) => s,
225 Err(e) => return sink(NetEvent::Closed(Some(format!("{e:#}")))),
226 };
227 let mut expect_ack = false;
228 if let Some(h) = &handshake {
229 if let Err(e) = stream.write_all(h) {
230 return sink(NetEvent::Closed(Some(format!("sending the RLogin login: {e}"))));
231 }
232 expect_ack = true;
233 }
234 match stream.try_clone() {
235 Ok(w) => sink(NetEvent::Connected(Writer(WriterKind::Tcp(w)))),
236 Err(e) => return sink(NetEvent::Closed(Some(e.to_string()))),
237 }
238 let mut buf = vec![0u8; 16 * 1024];
239 loop {
240 match stream.read(&mut buf) {
241 Ok(0) => return sink(NetEvent::Closed(None)),
242 Ok(n) => {
243 let mut data = &buf[..n];
244 if expect_ack {
245 expect_ack = false;
246 data = data.strip_prefix(&[0]).unwrap_or(data);
247 }
248 if !data.is_empty() {
249 sink(NetEvent::Data(data.to_vec()));
250 }
251 }
252 Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
253 Err(e) => return sink(NetEvent::Closed(Some(e.to_string()))),
254 }
255 }
256 });
257}
258
259fn connect_tcp(host: &str, port: u16) -> Result<TcpStream> {
260 let addrs: Vec<_> = (host, port).to_socket_addrs().with_context(|| format!("resolving {host}"))?.collect();
261 let mut last_err = None;
262 for addr in addrs {
263 match TcpStream::connect_timeout(&addr, CONNECT_TIMEOUT) {
264 Ok(s) => {
265 let _ = s.set_nodelay(true);
266 return Ok(s);
267 }
268 Err(e) => last_err = Some(e),
269 }
270 }
271 match last_err {
272 Some(e) => Err(e).with_context(|| format!("connecting to {host}:{port}")),
273 None => bail!("{host} has no addresses"),
274 }
275}
276
277struct SshParams {
278 host: String,
279 port: u16,
280 user: String,
281 password: String,
282 key_file: Option<PathBuf>,
283 agent: bool,
284 key_dir: Option<PathBuf>,
285 term: String,
286 cols: u16,
287 rows: u16,
288 known_hosts: PathBuf,
289}
290
291#[derive(Clone)]
294pub(crate) struct Emitter(Arc<dyn Fn(NetEvent) + Send + Sync>);
295
296impl Emitter {
297 pub(crate) fn new<F: Fn(NetEvent) + Send + 'static>(sink: F) -> Emitter {
299 let sink = Mutex::new(sink);
300 Emitter(Arc::new(move |ev| (sink.lock().unwrap_or_else(|e| e.into_inner()))(ev)))
301 }
302
303 pub(crate) fn emit(&self, ev: NetEvent) {
304 (self.0)(ev)
305 }
306}
307
308fn spawn_ssh<F>(params: SshParams, sink: F)
309where
310 F: Fn(NetEvent) + Send + 'static,
311{
312 thread::spawn(move || {
313 let emitter = Emitter::new(sink);
314 let rt = match tokio::runtime::Builder::new_current_thread().enable_all().build() {
315 Ok(rt) => rt,
316 Err(e) => return emitter.emit(NetEvent::Closed(Some(format!("starting SSH runtime: {e}")))),
317 };
318 let result = rt.block_on(run_ssh(params, emitter.clone()));
319 emitter.emit(NetEvent::Closed(result.err().map(|e| format!("{e:#}"))));
320 });
321}
322
323struct HostKeyCheck {
324 host: String,
325 port: u16,
326 known_hosts: PathBuf,
327 emitter: Emitter,
328 problem: Arc<Mutex<Option<String>>>,
330}
331
332impl client::Handler for HostKeyCheck {
333 type Error = russh::Error;
334
335 async fn check_server_key(&mut self, key: &PublicKeyOrCertificate) -> Result<bool, Self::Error> {
336 let key = match key {
337 PublicKeyOrCertificate::PublicKey { key, .. } => key.clone(),
338 PublicKeyOrCertificate::Certificate(cert) => PublicKey::from(cert.public_key().clone()),
339 };
340 match verify_host_key(&self.host, self.port, &key, &self.known_hosts) {
341 Ok(notice) => {
342 if let Some(n) = notice {
343 self.emitter.emit(NetEvent::Notice(n));
344 }
345 Ok(true)
346 }
347 Err(msg) => {
348 *self.problem.lock().unwrap() = Some(msg);
349 Ok(false)
350 }
351 }
352 }
353}
354
355pub fn verify_host_key(host: &str, port: u16, key: &PublicKey, known_hosts: &Path) -> Result<Option<String>, String> {
358 let fingerprint = key.fingerprint(HashAlg::Sha256);
359 match check_known_hosts_path(host, port, key, known_hosts) {
360 Ok(true) => Ok(None),
361 Ok(false) => {
362 learn_known_hosts_path(host, port, key, known_hosts)
363 .map_err(|e| format!("saving host key to {}: {e}", known_hosts.display()))?;
364 Ok(Some(format!("Trusted new SSH host key for {host}: {fingerprint}")))
365 }
366 Err(russh::keys::Error::KeyChanged { line }) => Err(format!(
367 "The SSH host key for {host} has CHANGED (now {fingerprint}). Someone may be intercepting the \
368 connection. If the BBS really changed its key, delete line {line} of {}.",
369 known_hosts.display()
370 )),
371 Err(e) => Err(format!("reading {}: {e}", known_hosts.display())),
372 }
373}
374
375async fn run_ssh(p: SshParams, emitter: Emitter) -> Result<()> {
376 if p.user.is_empty() {
377 bail!("SSH needs a user name: edit the entry and fill in \"User name\"");
378 }
379 let config = Arc::new(client::Config { keepalive_interval: Some(Duration::from_secs(30)), ..Default::default() });
380 let problem = Arc::new(Mutex::new(None));
381 let handler = HostKeyCheck {
382 host: p.host.clone(),
383 port: p.port,
384 known_hosts: p.known_hosts.clone(),
385 emitter: emitter.clone(),
386 problem: problem.clone(),
387 };
388 let connect = client::connect(config, (p.host.as_str(), p.port), handler);
389 let mut session = match tokio::time::timeout(CONNECT_TIMEOUT, connect).await {
390 Err(_) => bail!("connecting to {}:{}: timed out", p.host, p.port),
391 Ok(Err(e)) => {
392 if let Some(msg) = problem.lock().unwrap().take() {
393 bail!(msg);
394 }
395 return Err(anyhow!(e)).with_context(|| format!("connecting to {}:{}", p.host, p.port));
396 }
397 Ok(Ok(s)) => s,
398 };
399
400 Login { session: &mut session, p: &p, emitter: &emitter }.run().await?;
401
402 let channel = session.channel_open_session().await.context("opening SSH channel")?;
403 channel
404 .request_pty(false, &p.term, p.cols.into(), p.rows.into(), 0, 0, &[])
405 .await
406 .context("requesting a terminal")?;
407 channel.request_shell(false).await.context("starting the shell")?;
408
409 let (tx, mut rx) = mpsc::unbounded_channel();
410 emitter.emit(NetEvent::Connected(Writer(WriterKind::Channel(tx))));
411
412 let (mut read, write) = channel.split();
413 loop {
414 tokio::select! {
415 msg = read.wait() => match msg {
416 Some(ChannelMsg::Data { data }) => emitter.emit(NetEvent::Data(data.to_vec())),
417 Some(ChannelMsg::ExtendedData { data, .. }) => emitter.emit(NetEvent::Data(data.to_vec())),
418 Some(ChannelMsg::Eof | ChannelMsg::Close) | None => break,
419 Some(_) => {}
420 },
421 cmd = rx.recv() => match cmd {
422 Some(Cmd::Data(d)) => write.data(&d[..]).await.context("sending data")?,
423 Some(Cmd::Close) | None => break,
424 },
425 }
426 }
427 let _ = session.disconnect(Disconnect::ByApplication, "", "en").await;
428 Ok(())
429}
430
431fn expand_home(p: &str) -> PathBuf {
433 match (p.strip_prefix("~/").or_else(|| p.strip_prefix("~\\")), std::env::home_dir()) {
434 (Some(rest), Some(home)) => home.join(rest),
435 _ => PathBuf::from(p),
436 }
437}
438
439enum Attempt {
441 Success,
442 Failed(Vec<MethodKind>),
444}
445
446impl From<AuthResult> for Attempt {
447 fn from(r: AuthResult) -> Attempt {
448 match r {
449 AuthResult::Success => Attempt::Success,
450 AuthResult::Failure { remaining_methods, .. } => Attempt::Failed(remaining_methods.to_vec()),
451 }
452 }
453}
454
455struct Login<'a, H: client::Handler> {
457 session: &'a mut client::Handle<H>,
458 p: &'a SshParams,
459 emitter: &'a Emitter,
460}
461
462impl<H: client::Handler> Login<'_, H> {
463 async fn run(&mut self) -> Result<()> {
467 let user = self.p.user.clone();
468 let mut methods = match self.session.authenticate_none(&user).await?.into() {
469 Attempt::Success => return Ok(()),
470 Attempt::Failed(m) => m,
471 };
472 if methods.contains(&MethodKind::PublicKey) {
473 match self.public_keys().await? {
474 Some(Attempt::Success) => return Ok(()),
475 Some(Attempt::Failed(m)) => methods = m,
476 None => {}
477 }
478 }
479 let mut asked = false;
483 if methods.contains(&MethodKind::KeyboardInteractive) {
484 let (attempt, a) = self.keyboard_interactive().await?;
485 asked = a;
486 match attempt {
487 Attempt::Success => return Ok(()),
488 Attempt::Failed(m) => methods = m,
489 }
490 }
491 if !asked && methods.contains(&MethodKind::Password) {
492 match self.password().await? {
493 Attempt::Success => return Ok(()),
494 Attempt::Failed(m) => methods = m,
495 }
496 }
497 let offered: Vec<&str> = methods.iter().map(<&str>::from).collect();
498 log::debug!("SSH login refused; server still offers {offered:?}");
499 bail!("SSH login as \"{user}\" was refused: check the user name and password")
500 }
501
502 async fn ask(&self, kind: PromptKind, instructions: String, prompt: String, echo: bool) -> Option<String> {
503 let (reply, answer) = oneshot::channel();
504 let title = format!("SSH login to {}", self.p.host);
505 self.emitter.emit(NetEvent::Prompt(AuthPrompt { kind, title, instructions, prompt, echo, reply }));
506 answer.await.ok().flatten()
507 }
508
509 async fn ask_password(&self, retry: bool) -> Result<String> {
510 let why = if retry { "That password was not accepted." } else { "" };
511 let prompt = format!("Password for {}:", self.p.user);
512 self.ask(PromptKind::Password, why.into(), prompt, false).await.context(LOGIN_CANCELLED)
513 }
514
515 async fn password(&mut self) -> Result<Attempt> {
518 let mut stored = (!self.p.password.is_empty()).then(|| self.p.password.clone());
519 let (mut retry, mut asked) = (false, 0);
520 let mut last = Attempt::Failed(vec![MethodKind::Password]);
521 while asked < MAX_PROMPTED_ATTEMPTS {
522 let pw = match stored.take() {
523 Some(pw) => pw,
524 None => {
525 asked += 1;
526 self.ask_password(retry).await?
527 }
528 };
529 last = self.session.authenticate_password(&self.p.user, pw).await?.into();
530 match &last {
531 Attempt::Failed(m) if m.contains(&MethodKind::Password) => retry = true,
532 _ => break,
533 }
534 }
535 Ok(last)
536 }
537
538 async fn keyboard_interactive(&mut self) -> Result<(Attempt, bool)> {
542 let user = self.p.user.clone();
543 let mut stored = (!self.p.password.is_empty()).then(|| self.p.password.clone());
544 let (mut retry, mut asked) = (false, false);
545 let mut last = Attempt::Failed(vec![MethodKind::KeyboardInteractive]);
546 let attempts = MAX_PROMPTED_ATTEMPTS + usize::from(stored.is_some());
548 'attempts: for _ in 0..attempts {
549 let mut resp = self.session.authenticate_keyboard_interactive_start(&user, None).await?;
550 for _ in 0..MAX_KBD_INTERACTIVE_ROUNDS {
551 let (name, instructions, prompts) = match resp {
552 KeyboardInteractiveAuthResponse::Success => return Ok((Attempt::Success, asked)),
553 KeyboardInteractiveAuthResponse::Failure { remaining_methods, .. } => {
554 last = Attempt::Failed(remaining_methods.to_vec());
555 let used_stored = stored.is_none() && !self.p.password.is_empty();
558 if !remaining_methods.contains(&MethodKind::KeyboardInteractive) || !asked && !used_stored {
559 break 'attempts;
560 }
561 retry = true;
562 continue 'attempts;
563 }
564 KeyboardInteractiveAuthResponse::InfoRequest { name, instructions, prompts } => {
565 (name, instructions, prompts)
566 }
567 };
568 let mut answers = Vec::with_capacity(prompts.len());
569 if let ([only], Some(pw)) = (prompts.as_slice(), stored.as_ref())
570 && !only.echo
571 {
572 answers.push(pw.clone());
573 stored = None;
574 } else {
575 let mut info = [name.trim(), instructions.trim()]
576 .into_iter()
577 .filter(|s| !s.is_empty())
578 .collect::<Vec<_>>()
579 .join("\n");
580 if retry && info.is_empty() {
581 info = "That answer was not accepted.".into();
582 }
583 for q in &prompts {
584 let kind = if q.echo { PromptKind::Interactive } else { PromptKind::Password };
585 let text = if q.prompt.trim().is_empty() { "Password:" } else { q.prompt.trim() };
586 asked = true;
587 let answer = self.ask(kind, info.clone(), text.into(), q.echo).await;
588 answers.push(answer.context(LOGIN_CANCELLED)?);
589 }
590 }
591 resp = self.session.authenticate_keyboard_interactive_respond(answers).await?;
592 }
593 break;
594 }
595 Ok((last, asked))
596 }
597
598 async fn public_keys(&mut self) -> Result<Option<Attempt>> {
601 let rsa_hash = self.session.best_supported_rsa_hash().await.ok().flatten().flatten();
602 let mut offered: Vec<PublicKey> = Vec::new();
603 let mut last = None;
604
605 if let Some(path) = self.p.key_file.clone()
606 && let Some(key) = self.load_key(&path, true).await?
607 {
608 offered.push(key.public_key().clone());
609 let r = self.offer_key(key, rsa_hash).await?;
610 if matches!(r, Attempt::Success) {
611 return Ok(Some(r));
612 }
613 last = Some(r);
614 }
615
616 if self.p.agent
617 && let Some(mut agent) = connect_agent().await
618 {
619 let identities = agent.request_identities().await.unwrap_or_else(|e| {
620 log::debug!("listing SSH agent keys: {e}");
621 Vec::new()
622 });
623 for id in identities {
624 let AgentIdentity::PublicKey { key, .. } = id else { continue };
625 if offered.contains(&key) {
626 continue;
627 }
628 offered.push(key.clone());
629 let hash = if key.algorithm().is_rsa() { rsa_hash } else { None };
630 match self.session.authenticate_publickey_with(&self.p.user, key, hash, &mut agent).await {
631 Ok(r) => match Attempt::from(r) {
632 Attempt::Success => return Ok(Some(Attempt::Success)),
633 failed => last = Some(failed),
634 },
635 Err(e) => log::debug!("SSH agent signing failed: {e}"),
636 }
637 }
638 }
639
640 if let Some(dir) = self.p.key_dir.clone() {
641 for name in DEFAULT_KEY_FILES {
642 let path = dir.join(name);
643 if Some(&path) == self.p.key_file.as_ref() || !path.exists() {
644 continue;
645 }
646 let Some(key) = self.load_key(&path, false).await? else { continue };
650 if offered.contains(key.public_key()) {
651 continue;
652 }
653 offered.push(key.public_key().clone());
654 match self.offer_key(key, rsa_hash).await? {
655 Attempt::Success => return Ok(Some(Attempt::Success)),
656 failed => last = Some(failed),
657 }
658 }
659 }
660 Ok(last)
661 }
662
663 async fn offer_key(&mut self, key: PrivateKey, rsa_hash: Option<HashAlg>) -> Result<Attempt> {
664 let key = PrivateKeyWithHashAlg::new(Arc::new(key), rsa_hash);
665 Ok(self.session.authenticate_publickey(&self.p.user, key).await?.into())
666 }
667
668 async fn load_key(&self, path: &Path, ask: bool) -> Result<Option<PrivateKey>> {
672 let shown = path.display().to_string();
673 let mut why = String::new();
674 let mut passphrase: Option<String> = None;
675 for _ in 0..=MAX_PROMPTED_ATTEMPTS {
676 match russh::keys::load_secret_key(path, passphrase.as_deref()) {
677 Ok(key) => return Ok(Some(key)),
678 Err(e) if !ask => {
679 log::debug!("skipping SSH key {shown}: {e}");
680 return Ok(None);
681 }
682 Err(russh::keys::Error::KeyIsEncrypted) => {}
683 Err(russh::keys::Error::SshKey(_)) if passphrase.is_some() => {
685 why = "That passphrase was not accepted.".into();
686 }
687 Err(e) => {
688 self.emitter.emit(NetEvent::Notice(format!("Cannot use SSH key {shown}: {e}")));
689 return Ok(None);
690 }
691 }
692 let prompt = format!("Passphrase for {shown}:");
693 match self.ask(PromptKind::Passphrase, why.clone(), prompt, false).await {
694 Some(p) => passphrase = Some(p),
695 None => return Ok(None),
697 }
698 }
699 Ok(None)
700 }
701}
702
703async fn connect_agent() -> Option<AgentClient<Box<dyn AgentStream + Send + Unpin>>> {
705 #[cfg(unix)]
706 {
707 match AgentClient::connect_env().await {
708 Ok(a) => Some(a.dynamic()),
709 Err(e) => {
710 log::debug!("no SSH agent: {e}");
711 None
712 }
713 }
714 }
715 #[cfg(windows)]
716 {
717 if let Ok(a) = AgentClient::connect_named_pipe(r"\\.\pipe\openssh-ssh-agent").await {
719 return Some(a.dynamic());
720 }
721 match AgentClient::connect_pageant().await {
722 Ok(a) => Some(a.dynamic()),
723 Err(e) => {
724 log::debug!("no SSH agent: {e}");
725 None
726 }
727 }
728 }
729 #[cfg(not(any(unix, windows)))]
730 None
731}