1use std::fs::File;
6use std::io::{BufRead, BufReader, Error as IoError};
7use std::path::{Path, PathBuf};
8use std::str::FromStr;
9use std::time::Duration;
10
11use bitflags::bitflags;
12use glob::{Pattern, glob};
13use thiserror::Error;
14
15use super::{Host, HostClause, HostParams, HostScope, SshConfig};
16use crate::params::AlgorithmsRule;
17use crate::{DefaultAlgorithms, RemoteForward, RemoteForwardDestination, RemoteForwardListen};
18
19mod field;
21use field::Field;
22
23pub type SshParserResult<T> = Result<T, SshParserError>;
24
25pub(crate) struct IncludeContext {
27 home: Option<PathBuf>,
29 stack: Vec<PathBuf>,
31}
32
33impl IncludeContext {
34 pub(crate) fn new(home: Option<PathBuf>) -> Self {
36 Self {
37 home,
38 stack: Vec::new(),
39 }
40 }
41}
42
43#[derive(Debug, Error)]
45pub enum SshParserError {
46 #[error("expected boolean value ('yes', 'no')")]
47 ExpectedBoolean,
48 #[error("expected port number")]
49 ExpectedPort,
50 #[error("expected unsigned value")]
51 ExpectedUnsigned,
52 #[error("expected algorithms")]
53 ExpectedAlgorithms,
54 #[error("expected path")]
55 ExpectedPath,
56 #[error("home directory not found for argument: {0}")]
60 HomeNotFound(String),
61 #[error("include cycle detected: {path}", path = .0.display())]
65 IncludeCycle(PathBuf),
66 #[error("IO error: {0}")]
67 Io(#[from] IoError),
68 #[error("glob error: {0}")]
69 Glob(#[from] glob::GlobError),
70 #[error("invalid quotes")]
71 InvalidQuotes,
72 #[error("invalid RemoteForward arguments: {0:?}")]
74 InvalidRemoteForward(Vec<String>),
75 #[error("missing argument")]
76 MissingArgument,
77 #[error("pattern error: {0}")]
78 PatternError(#[from] glob::PatternError),
79 #[error("unknown field: {0}")]
80 UnknownField(String, Vec<String>),
81 #[error("unknown field: {0}")]
82 UnsupportedField(String, Vec<String>),
83}
84
85bitflags! {
86 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
88 pub struct ParseRule: u8 {
89 const STRICT = 0b00000000;
91 const ALLOW_UNKNOWN_FIELDS = 0b00000001;
93 const ALLOW_UNSUPPORTED_FIELDS = 0b00000010;
95 }
96}
97
98pub(crate) struct SshConfigParser;
102
103impl SshConfigParser {
104 pub(crate) fn parse(
109 config: &mut SshConfig,
110 reader: &mut impl BufRead,
111 rules: ParseRule,
112 ignore_unknown: Option<Vec<String>>,
113 include: &mut IncludeContext,
114 ) -> SshParserResult<()> {
115 let mut default_params = HostParams::new(&config.default_algorithms);
121 default_params.ignore_unknown = ignore_unknown;
122 let mut current_host_index = config.append_host(
123 Host::new(
124 vec![HostClause::new(String::from("*"), false)],
125 default_params,
126 ),
127 Vec::new(),
128 );
129
130 let mut lines = reader.lines();
131 loop {
133 let line = match lines.next() {
134 None => break,
135 Some(Err(err)) => return Err(SshParserError::Io(err)),
136 Some(Ok(line)) => Self::strip_comments(line.trim()),
137 };
138 if line.is_empty() {
139 continue;
140 }
141 let (field, args) = match Self::tokenize_line(&line) {
143 Ok((field, args)) => (field, args),
144 Err(SshParserError::UnknownField(field, args))
145 if rules.intersects(ParseRule::ALLOW_UNKNOWN_FIELDS)
146 || config.hosts[current_host_index].params.ignored(&field) =>
147 {
148 config.hosts[current_host_index]
149 .params
150 .ignored_fields
151 .insert(field, args);
152 continue;
153 }
154 Err(SshParserError::UnknownField(field, args)) => {
155 return Err(SshParserError::UnknownField(field, args));
156 }
157 Err(err) => return Err(err),
158 };
159 if field == Field::Host {
161 let mut params = HostParams::new(&config.default_algorithms);
163 params.ignore_unknown = config.hosts[0].params.ignore_unknown.clone();
164 let pattern = Self::parse_host(args)?;
165 trace!("Adding new host: {pattern:?}",);
166
167 current_host_index = config.append_host(Host::new(pattern, params), Vec::new());
170 } else if field == Field::Include {
171 let (pattern, inherited_scope, ignore_unknown) = {
172 let host = &config.hosts[current_host_index];
173 (
174 host.pattern.clone(),
175 config
176 .host_scopes
177 .get(current_host_index)
178 .cloned()
179 .unwrap_or_default(),
180 host.params.ignore_unknown.clone(),
181 )
182 };
183 let new_hosts = Self::include_files(
184 args,
185 &pattern,
186 &inherited_scope,
187 rules,
188 &config.default_algorithms,
189 ignore_unknown.clone(),
190 include,
191 )?;
192 for (host, scope) in new_hosts {
193 config.append_host(host, scope);
194 }
195
196 let mut params = HostParams::new(&config.default_algorithms);
197 params.ignore_unknown = ignore_unknown;
198 current_host_index =
199 config.append_host(Host::new(pattern, params), inherited_scope);
200 } else {
201 Self::update_host(
203 field,
204 args,
205 &mut config.hosts[current_host_index],
206 include.home.as_deref(),
207 )
208 .or_else(|err| match err {
209 SshParserError::UnsupportedField(field, args)
211 if rules.intersects(ParseRule::ALLOW_UNSUPPORTED_FIELDS) =>
212 {
213 config.hosts[current_host_index]
214 .params
215 .unsupported_fields
216 .insert(field, args);
217 Ok(())
218 }
219 SshParserError::UnsupportedField(_, _) => Ok(()),
223 e => Err(e),
224 })?;
225 }
226 }
227
228 Ok(())
229 }
230
231 fn strip_comments(s: &str) -> String {
233 let mut in_quotes = false;
234 let mut result = String::new();
235
236 for c in s.chars() {
237 match c {
238 '"' => {
239 in_quotes = !in_quotes;
240 result.push(c);
241 }
242 '#' if !in_quotes => {
243 break;
245 }
246 _ => {
247 result.push(c);
248 }
249 }
250 }
251
252 result
253 }
254
255 fn split_args_respecting_quotes(s: &str) -> Vec<String> {
260 let mut result = Vec::new();
261 let mut current = String::new();
262 let mut has_token = false;
263 let mut in_quotes = false;
264 let mut chars = s.chars().peekable();
265 while let Some(c) = chars.next() {
266 if in_quotes {
267 current.push(c);
268 if c == '\\' {
269 if let Some(&nc) = chars.peek() {
270 current.push(nc);
271 chars.next();
272 }
273 } else if c == '"' {
274 in_quotes = false;
275 }
276 } else if c.is_whitespace() {
277 if has_token {
278 result.push(std::mem::take(&mut current));
279 has_token = false;
280 }
281 } else if c == '"' {
282 current.push(c);
283 in_quotes = true;
284 has_token = true;
285 } else {
286 current.push(c);
287 has_token = true;
288 }
289 }
290 if has_token {
291 result.push(current);
292 }
293 result
294 }
295
296 fn count_unescaped_quotes(s: &str) -> usize {
299 let mut count = 0;
300 let chars: Vec<char> = s.chars().collect();
301 let mut i = 0;
302 while i < chars.len() {
303 if chars[i] == '\\' && i + 1 < chars.len() {
304 i += 2;
306 } else if chars[i] == '"' {
307 count += 1;
308 i += 1;
309 } else {
310 i += 1;
311 }
312 }
313 count
314 }
315
316 fn ends_with_unescaped_quote(s: &str) -> bool {
318 if !s.ends_with('"') {
319 return false;
320 }
321 let chars: Vec<char> = s.chars().collect();
323 let mut backslash_count = 0;
324 for i in (0..chars.len() - 1).rev() {
325 if chars[i] == '\\' {
326 backslash_count += 1;
327 } else {
328 break;
329 }
330 }
331 backslash_count % 2 == 0
333 }
334
335 fn unescape_string(s: &str) -> String {
339 let mut result = String::with_capacity(s.len());
340 let chars: Vec<char> = s.chars().collect();
341 let mut i = 0;
342 while i < chars.len() {
343 if chars[i] == '\\' && i + 1 < chars.len() {
344 let next = chars[i + 1];
345 match next {
346 '"' | '\\' | '\'' => {
347 result.push(next);
349 i += 2;
350 }
351 _ => {
352 result.push(chars[i]);
354 i += 1;
355 }
356 }
357 } else {
358 result.push(chars[i]);
359 i += 1;
360 }
361 }
362 result
363 }
364
365 fn update_host(
367 field: Field,
368 args: Vec<String>,
369 host: &mut Host,
370 home: Option<&Path>,
371 ) -> SshParserResult<()> {
372 trace!("parsing field {field:?} with args {args:?}",);
373 let params = &mut host.params;
374 match field {
375 Field::AddKeysToAgent => {
376 let value = Self::parse_boolean(args)?;
377 trace!("add_keys_to_agent: {value}",);
378 params.add_keys_to_agent = Some(value);
379 }
380 Field::BindAddress => {
381 let value = Self::parse_string(args)?;
382 trace!("bind_address: {value}",);
383 params.bind_address = Some(value);
384 }
385 Field::BindInterface => {
386 let value = Self::parse_string(args)?;
387 trace!("bind_interface: {value}",);
388 params.bind_interface = Some(value);
389 }
390 Field::CaSignatureAlgorithms => {
391 let rule = Self::parse_algos(args)?;
392 trace!("ca_signature_algorithms: {rule:?}",);
393 params.ca_signature_algorithms.apply(rule);
394 }
395 Field::CertificateFile => {
396 let value = Self::parse_path(args, home)?;
397 trace!("certificate_file: {value:?}",);
398 params.certificate_file = Some(value);
399 }
400 Field::Ciphers => {
401 let rule = Self::parse_algos(args)?;
402 trace!("ciphers: {rule:?}",);
403 params.ciphers.apply(rule);
404 }
405 Field::Compression => {
406 let value = Self::parse_boolean(args)?;
407 trace!("compression: {value}",);
408 params.compression = Some(value);
409 }
410 Field::ConnectTimeout => {
411 let value = Self::parse_duration(args)?;
412 trace!("connect_timeout: {value:?}",);
413 params.connect_timeout = Some(value);
414 }
415 Field::ConnectionAttempts => {
416 let value = Self::parse_unsigned(args)?;
417 trace!("connection_attempts: {value}",);
418 params.connection_attempts = Some(value);
419 }
420 Field::ForwardAgent => {
421 let value = Self::parse_boolean(args)?;
422 trace!("forward_agent: {value}",);
423 params.forward_agent = Some(value);
424 }
425 Field::Host => { }
426 Field::HostKeyAlgorithms => {
427 let rule = Self::parse_algos(args)?;
428 trace!("host_key_algorithm: {rule:?}",);
429 params.host_key_algorithms.apply(rule);
430 }
431 Field::HostName => {
432 let value = Self::parse_string(args)?;
433 trace!("host_name: {value}",);
434 params.host_name = Some(value);
435 }
436 Field::Include => { }
437 Field::IdentityFile => {
438 let value = Self::parse_path_list(args, home)?;
439 trace!("identity_file: {value:?}",);
440 if let Some(existing) = &mut params.identity_file {
441 existing.extend(value);
442 } else {
443 params.identity_file = Some(value);
444 }
445 }
446 Field::IgnoreUnknown => {
447 let value = Self::parse_comma_separated_list(args)?;
448 trace!("ignore_unknown: {value:?}",);
449 params.ignore_unknown = Some(value);
450 }
451 Field::KexAlgorithms => {
452 let rule = Self::parse_algos(args)?;
453 trace!("kex_algorithms: {rule:?}",);
454 params.kex_algorithms.apply(rule);
455 }
456 Field::Mac => {
457 let rule = Self::parse_algos(args)?;
458 trace!("mac: {rule:?}",);
459 params.mac.apply(rule);
460 }
461 Field::Port => {
462 let value = Self::parse_port(args)?;
463 trace!("port: {value}",);
464 params.port = Some(value);
465 }
466 Field::ProxyJump => {
467 let rule = Self::parse_comma_separated_list(args)?;
468 trace!("proxy_jump: {rule:?}",);
469 params.proxy_jump = Some(rule);
470 }
471 Field::PubkeyAcceptedAlgorithms => {
472 let rule = Self::parse_algos(args)?;
473 trace!("pubkey_accepted_algorithms: {rule:?}",);
474 params.pubkey_accepted_algorithms.apply(rule);
475 }
476 Field::PubkeyAuthentication => {
477 let value = Self::parse_boolean(args)?;
478 trace!("pubkey_authentication: {value}",);
479 params.pubkey_authentication = Some(value);
480 }
481 Field::RemoteForward => {
482 let value = Self::parse_remote_forward(args)?;
483 trace!("remote_forward: {value}",);
484 params.remote_forward.push(value);
485 }
486 Field::ServerAliveInterval => {
487 let value = Self::parse_duration(args)?;
488 trace!("server_alive_interval: {value:?}",);
489 params.server_alive_interval = Some(value);
490 }
491 Field::TcpKeepAlive => {
492 let value = Self::parse_boolean(args)?;
493 trace!("tcp_keep_alive: {value}",);
494 params.tcp_keep_alive = Some(value);
495 }
496 #[cfg(target_os = "macos")]
497 Field::UseKeychain => {
498 let value = Self::parse_boolean(args)?;
499 trace!("use_keychain: {value}",);
500 params.use_keychain = Some(value);
501 }
502 Field::User => {
503 let value = Self::parse_string(args)?;
504 trace!("user: {value}",);
505 params.user = Some(value);
506 }
507 Field::AddressFamily
509 | Field::BatchMode
510 | Field::CanonicalDomains
511 | Field::CanonicalizeFallbackLock
512 | Field::CanonicalizeHostname
513 | Field::CanonicalizeMaxDots
514 | Field::CanonicalizePermittedCNAMEs
515 | Field::CheckHostIP
516 | Field::ClearAllForwardings
517 | Field::ControlMaster
518 | Field::ControlPath
519 | Field::ControlPersist
520 | Field::DynamicForward
521 | Field::EnableSSHKeysign
522 | Field::EscapeChar
523 | Field::ExitOnForwardFailure
524 | Field::FingerprintHash
525 | Field::ForkAfterAuthentication
526 | Field::ForwardX11
527 | Field::ForwardX11Timeout
528 | Field::ForwardX11Trusted
529 | Field::GatewayPorts
530 | Field::GlobalKnownHostsFile
531 | Field::GSSAPIAuthentication
532 | Field::GSSAPIDelegateCredentials
533 | Field::HashKnownHosts
534 | Field::HostbasedAcceptedAlgorithms
535 | Field::HostbasedAuthentication
536 | Field::HostKeyAlias
537 | Field::HostbasedKeyTypes
538 | Field::IdentitiesOnly
539 | Field::IdentityAgent
540 | Field::IPQoS
541 | Field::KbdInteractiveAuthentication
542 | Field::KbdInteractiveDevices
543 | Field::KnownHostsCommand
544 | Field::LocalCommand
545 | Field::LocalForward
546 | Field::LogLevel
547 | Field::LogVerbose
548 | Field::NoHostAuthenticationForLocalhost
549 | Field::NumberOfPasswordPrompts
550 | Field::PasswordAuthentication
551 | Field::PermitLocalCommand
552 | Field::PermitRemoteOpen
553 | Field::PKCS11Provider
554 | Field::PreferredAuthentications
555 | Field::ProxyCommand
556 | Field::ProxyUseFdpass
557 | Field::PubkeyAcceptedKeyTypes
558 | Field::RekeyLimit
559 | Field::RequestTTY
560 | Field::RevokedHostKeys
561 | Field::SecruityKeyProvider
562 | Field::SendEnv
563 | Field::ServerAliveCountMax
564 | Field::SessionType
565 | Field::SetEnv
566 | Field::StdinNull
567 | Field::StreamLocalBindMask
568 | Field::StrictHostKeyChecking
569 | Field::SyslogFacility
570 | Field::UpdateHostKeys
571 | Field::UserKnownHostsFile
572 | Field::VerifyHostKeyDNS
573 | Field::VisualHostKey
574 | Field::XAuthLocation => {
575 return Err(SshParserError::UnsupportedField(field.to_string(), args));
576 }
577 }
578 Ok(())
579 }
580
581 fn resolve_include_path(path_match: &str, home: Option<&Path>) -> SshParserResult<String> {
589 #[cfg(windows)]
590 const PATH_SEPARATOR: &str = "\\";
591 #[cfg(unix)]
592 const PATH_SEPARATOR: &str = "/";
593
594 let escaped_home =
595 |home: &Path| PathBuf::from(Pattern::escape(&home.display().to_string()));
596
597 if path_match.starts_with(PATH_SEPARATOR) {
598 Ok(path_match.to_string())
599 } else if let Some((home, rest)) = Self::split_home_prefix(path_match, home)? {
600 Ok(Self::join_home(&escaped_home(home), rest)
601 .display()
602 .to_string())
603 } else if path_match.starts_with('~') {
604 Ok(path_match.to_string())
605 } else {
606 let home = home.ok_or_else(|| SshParserError::HomeNotFound(path_match.to_string()))?;
607 Ok(escaped_home(home)
608 .join(".ssh")
609 .join(path_match)
610 .display()
611 .to_string())
612 }
613 }
614
615 fn include_files(
620 args: Vec<String>,
621 pattern: &[HostClause],
622 inherited_scope: &[Vec<HostClause>],
623 rules: ParseRule,
624 default_algos: &DefaultAlgorithms,
625 ignore_unknown: Option<Vec<String>>,
626 include: &mut IncludeContext,
627 ) -> SshParserResult<Vec<(Host, HostScope)>> {
628 if args.is_empty() {
629 return Err(SshParserError::MissingArgument);
630 }
631
632 let mut new_hosts = vec![];
633
634 for arg in args {
635 let path_match = Self::resolve_include_path(&arg, include.home.as_deref())?;
636
637 trace!("include files: {path_match}",);
638 let files = glob(&path_match)?;
639
640 for file in files {
641 let file = file?;
642 let canonical = file.canonicalize()?;
643 if include.stack.contains(&canonical) {
644 return Err(SshParserError::IncludeCycle(canonical));
645 }
646 trace!("including file: {}", file.display());
647 let mut reader = BufReader::new(File::open(file)?);
648 let mut sub_config = SshConfig::default().default_algorithms(default_algos.clone());
649 include.stack.push(canonical);
650 let parsed = Self::parse(
651 &mut sub_config,
652 &mut reader,
653 rules,
654 ignore_unknown.clone(),
655 include,
656 );
657 include.stack.pop();
658 parsed?;
659
660 let SshConfig {
661 hosts: sub_hosts,
662 host_scopes: sub_scopes,
663 ..
664 } = sub_config;
665 for (index, sub_host) in sub_hosts.into_iter().enumerate() {
666 trace!("adding sub-host: {sub_host:?}",);
667 let mut scope = inherited_scope.to_vec();
668 scope.push(pattern.to_vec());
669 if let Some(sub_scope) = sub_scopes.get(index) {
670 scope.extend(sub_scope.iter().cloned());
671 }
672 new_hosts.push((sub_host, scope));
673 }
674 }
675 }
676
677 Ok(new_hosts)
678 }
679
680 fn tokenize_line(line: &str) -> SshParserResult<(Field, Vec<String>)> {
693 let trimmed_line = line.trim();
695 let (field, other_tokens) = if trimmed_line.find('=').unwrap_or(usize::MAX)
697 < trimmed_line.find(char::is_whitespace).unwrap_or(usize::MAX)
698 {
699 trimmed_line
700 .split_once('=')
701 .ok_or(SshParserError::MissingArgument)?
702 } else {
703 trimmed_line
704 .split_once(char::is_whitespace)
705 .ok_or(SshParserError::MissingArgument)?
706 };
707
708 trace!("tokenized line '{line}' - field '{field}' with args '{other_tokens}'",);
709
710 let other_tokens = other_tokens.trim().trim_start_matches('=').trim();
712 trace!("other tokens trimmed: '{other_tokens}'",);
713
714 let unescaped_quote_count = Self::count_unescaped_quotes(other_tokens);
716 if unescaped_quote_count % 2 != 0 {
717 return Err(SshParserError::InvalidQuotes);
718 }
719
720 let raw_tokens = Self::split_args_respecting_quotes(other_tokens);
722
723 let args = if raw_tokens.len() == 1
725 && raw_tokens[0].starts_with('"')
726 && raw_tokens[0].len() >= 2
727 && Self::ends_with_unescaped_quote(&raw_tokens[0])
728 {
729 trace!("quoted args: '{}'", raw_tokens[0]);
730 let t = &raw_tokens[0];
731 let content = &t[1..t.len() - 1];
732 vec![Self::unescape_string(content)]
733 } else {
734 trace!("split args: {:?}", raw_tokens);
735 raw_tokens
736 };
737
738 match Field::from_str(field) {
739 Ok(field) => Ok((field, args)),
740 Err(_) => Err(SshParserError::UnknownField(field.to_string(), args)),
741 }
742 }
743
744 fn parse_boolean(args: Vec<String>) -> SshParserResult<bool> {
750 let value = args.first().ok_or(SshParserError::MissingArgument)?;
751
752 if value.eq_ignore_ascii_case("yes") || value.eq_ignore_ascii_case("true") {
753 Ok(true)
754 } else if value.eq_ignore_ascii_case("no") || value.eq_ignore_ascii_case("false") {
755 Ok(false)
756 } else {
757 Err(SshParserError::ExpectedBoolean)
758 }
759 }
760
761 fn parse_algos(args: Vec<String>) -> SshParserResult<AlgorithmsRule> {
763 let first = args.first().ok_or(SshParserError::MissingArgument)?;
764
765 AlgorithmsRule::from_str(first)
766 }
767
768 fn parse_comma_separated_list(args: Vec<String>) -> SshParserResult<Vec<String>> {
770 match args
771 .first()
772 .map(|x| x.split(',').map(|x| x.to_string()).collect())
773 {
774 Some(args) => Ok(args),
775 _ => Err(SshParserError::MissingArgument),
776 }
777 }
778
779 fn parse_duration(args: Vec<String>) -> SshParserResult<Duration> {
781 let value = Self::parse_unsigned(args)?;
782 Ok(Duration::from_secs(value as u64))
783 }
784
785 fn parse_host(args: Vec<String>) -> SshParserResult<Vec<HostClause>> {
789 if args.is_empty() {
790 return Err(SshParserError::MissingArgument);
791 }
792 Ok(args
794 .into_iter()
795 .map(|x| {
796 if let Some(pattern) = x.strip_prefix('!') {
797 HostClause::new(pattern.to_string(), true)
798 } else {
799 HostClause::new(x, false)
800 }
801 })
802 .collect())
803 }
804
805 fn parse_path_list(args: Vec<String>, home: Option<&Path>) -> SshParserResult<Vec<PathBuf>> {
807 if args.is_empty() {
808 return Err(SshParserError::MissingArgument);
809 }
810 args.iter()
811 .map(|x| Self::parse_path_arg(x.as_str(), home))
812 .collect()
813 }
814
815 fn parse_path(args: Vec<String>, home: Option<&Path>) -> SshParserResult<PathBuf> {
817 if let Some(s) = args.first() {
818 Self::parse_path_arg(s, home)
819 } else {
820 Err(SshParserError::MissingArgument)
821 }
822 }
823
824 fn parse_path_arg(s: &str, home: Option<&Path>) -> SshParserResult<PathBuf> {
828 Ok(match Self::split_home_prefix(s, home)? {
829 Some((home, rest)) => Self::join_home(home, rest),
830 None => PathBuf::from(s),
831 })
832 }
833
834 fn split_home_prefix<'a>(
839 s: &'a str,
840 home: Option<&'a Path>,
841 ) -> SshParserResult<Option<(&'a Path, &'a str)>> {
842 match Self::strip_home_prefix(s) {
843 Some(rest) => {
844 let home = home.ok_or_else(|| SshParserError::HomeNotFound(s.to_string()))?;
845 Ok(Some((home, rest)))
846 }
847 None => Ok(None),
848 }
849 }
850
851 fn join_home(home: &Path, rest: &str) -> PathBuf {
853 if rest.is_empty() {
854 home.to_path_buf()
855 } else {
856 home.join(rest)
857 }
858 }
859
860 fn strip_home_prefix(s: &str) -> Option<&str> {
865 let rest = s.strip_prefix('~')?;
866 if rest.is_empty() {
867 Some(rest)
868 } else if rest.starts_with(std::path::is_separator) {
869 Some(rest.trim_start_matches(std::path::is_separator))
870 } else {
871 None
872 }
873 }
874
875 fn parse_port(args: Vec<String>) -> SshParserResult<u16> {
877 match args.first().map(|x| u16::from_str(x)) {
878 Some(Ok(val)) => Ok(val),
879 Some(Err(_)) => Err(SshParserError::ExpectedPort),
880 None => Err(SshParserError::MissingArgument),
881 }
882 }
883
884 fn parse_remote_forward(args: Vec<String>) -> SshParserResult<RemoteForward> {
886 if !(1..=2).contains(&args.len()) {
887 return Err(SshParserError::InvalidRemoteForward(args));
888 }
889
890 let listen_argument = Self::normalize_forward_argument(&args[0]);
891 let listen = if listen_argument.contains('/') {
892 RemoteForwardListen::UnixSocket(PathBuf::from(listen_argument))
893 } else if let Ok(port) = u16::from_str(&listen_argument) {
894 RemoteForwardListen::Port(port)
895 } else if let Some((host, port)) = Self::parse_forward_host(&listen_argument, true) {
896 RemoteForwardListen::Host { host, port }
897 } else {
898 return Err(SshParserError::InvalidRemoteForward(args));
899 };
900
901 let destination = if let Some(argument) = args.get(1) {
902 let argument = Self::normalize_forward_argument(argument);
903 if argument.contains('/') {
904 Some(RemoteForwardDestination::UnixSocket(PathBuf::from(
905 argument,
906 )))
907 } else if let Some((host, port)) = Self::parse_forward_host(&argument, false) {
908 Some(RemoteForwardDestination::Host { host, port })
909 } else {
910 return Err(SshParserError::InvalidRemoteForward(args));
911 }
912 } else {
913 None
914 };
915
916 Ok(RemoteForward::new(listen, destination))
917 }
918
919 fn normalize_forward_argument(argument: &str) -> String {
921 if argument.starts_with('"')
922 && argument.len() >= 2
923 && Self::ends_with_unescaped_quote(argument)
924 {
925 Self::unescape_string(&argument[1..argument.len() - 1])
926 } else {
927 argument.to_string()
928 }
929 }
930
931 fn parse_forward_host(value: &str, allow_empty_host: bool) -> Option<(String, u16)> {
933 let (host, port) = if let Some(value) = value.strip_prefix('[') {
934 let (host, port) = value.split_once("]:")?;
935 if host.is_empty() || host.contains(['[', ']']) {
936 return None;
937 }
938 (host, port)
939 } else {
940 let (host, port) = value.rsplit_once(':')?;
941 if host.contains([':', '[', ']']) {
942 return None;
943 }
944 (host, port)
945 };
946
947 if !allow_empty_host && host.is_empty() {
948 return None;
949 }
950
951 Some((host.to_string(), u16::from_str(port).ok()?))
952 }
953
954 fn parse_string(args: Vec<String>) -> SshParserResult<String> {
956 if let Some(s) = args.into_iter().next() {
957 Ok(s)
958 } else {
959 Err(SshParserError::MissingArgument)
960 }
961 }
962
963 fn parse_unsigned(args: Vec<String>) -> SshParserResult<usize> {
965 match args.first().map(|x| usize::from_str(x)) {
966 Some(Ok(val)) => Ok(val),
967 Some(Err(_)) => Err(SshParserError::ExpectedUnsigned),
968 None => Err(SshParserError::MissingArgument),
969 }
970 }
971}
972
973#[cfg(test)]
974mod tests {
975
976 use std::fs::File;
977 use std::io::{BufReader, Write};
978 use std::path::{Path, PathBuf};
979
980 use pretty_assertions::assert_eq;
981 use tempfile::NamedTempFile;
982
983 use super::*;
984 use crate::{DefaultAlgorithms, RemoteForward, RemoteForwardDestination, RemoteForwardListen};
985
986 #[test]
987 fn should_parse_remote_forward_listeners() -> Result<(), SshParserError> {
988 let cases = [
989 (
990 vec!["8080"],
991 RemoteForward::new(RemoteForwardListen::Port(8080), None),
992 ),
993 (
994 vec!["localhost:8080"],
995 RemoteForward::new(
996 RemoteForwardListen::Host {
997 host: "localhost".to_string(),
998 port: 8080,
999 },
1000 None,
1001 ),
1002 ),
1003 (
1004 vec!["0.0.0.0:8080"],
1005 RemoteForward::new(
1006 RemoteForwardListen::Host {
1007 host: "0.0.0.0".to_string(),
1008 port: 8080,
1009 },
1010 None,
1011 ),
1012 ),
1013 (
1014 vec!["*:8080"],
1015 RemoteForward::new(
1016 RemoteForwardListen::Host {
1017 host: "*".to_string(),
1018 port: 8080,
1019 },
1020 None,
1021 ),
1022 ),
1023 (
1024 vec![":8080"],
1025 RemoteForward::new(
1026 RemoteForwardListen::Host {
1027 host: String::new(),
1028 port: 8080,
1029 },
1030 None,
1031 ),
1032 ),
1033 (
1034 vec!["[::1]:8080"],
1035 RemoteForward::new(
1036 RemoteForwardListen::Host {
1037 host: "::1".to_string(),
1038 port: 8080,
1039 },
1040 None,
1041 ),
1042 ),
1043 (
1044 vec!["/tmp/remote.sock"],
1045 RemoteForward::new(
1046 RemoteForwardListen::UnixSocket(PathBuf::from("/tmp/remote.sock")),
1047 None,
1048 ),
1049 ),
1050 ];
1051
1052 for (args, expected) in cases {
1053 let args = args.into_iter().map(str::to_string).collect();
1054 assert_eq!(SshConfigParser::parse_remote_forward(args)?, expected);
1055 }
1056 Ok(())
1057 }
1058
1059 #[test]
1060 fn should_parse_remote_forward_destinations() -> Result<(), SshParserError> {
1061 let cases = [
1062 (
1063 vec!["8080", "localhost:80"],
1064 RemoteForward::new(
1065 RemoteForwardListen::Port(8080),
1066 Some(RemoteForwardDestination::Host {
1067 host: "localhost".to_string(),
1068 port: 80,
1069 }),
1070 ),
1071 ),
1072 (
1073 vec!["/tmp/remote.sock", "/tmp/local.sock"],
1074 RemoteForward::new(
1075 RemoteForwardListen::UnixSocket(PathBuf::from("/tmp/remote.sock")),
1076 Some(RemoteForwardDestination::UnixSocket(PathBuf::from(
1077 "/tmp/local.sock",
1078 ))),
1079 ),
1080 ),
1081 (
1082 vec!["\"/tmp/remote socket\"", "[2001:db8::1]:443"],
1083 RemoteForward::new(
1084 RemoteForwardListen::UnixSocket(PathBuf::from("/tmp/remote socket")),
1085 Some(RemoteForwardDestination::Host {
1086 host: "2001:db8::1".to_string(),
1087 port: 443,
1088 }),
1089 ),
1090 ),
1091 ];
1092
1093 for (args, expected) in cases {
1094 let args = args.into_iter().map(str::to_string).collect();
1095 assert_eq!(SshConfigParser::parse_remote_forward(args)?, expected);
1096 }
1097 Ok(())
1098 }
1099
1100 #[test]
1101 fn should_reject_invalid_remote_forward() {
1102 for args in [
1103 vec![],
1104 vec!["localhost"],
1105 vec!["[::1:8080"],
1106 vec!["70000"],
1107 vec!["localhost:70000"],
1108 vec!["8080", "80"],
1109 vec!["8080", "localhost:80", "unexpected"],
1110 ] {
1111 let args = args.into_iter().map(str::to_string).collect();
1112 assert!(matches!(
1113 SshConfigParser::parse_remote_forward(args),
1114 Err(SshParserError::InvalidRemoteForward(_))
1115 ));
1116 }
1117 }
1118
1119 #[test]
1120 fn should_parse_configuration() -> Result<(), SshParserError> {
1121 crate::test_log();
1122 let temp = create_ssh_config();
1123 let file = File::open(temp.path()).expect("Failed to open tempfile");
1124 let mut reader = BufReader::new(file);
1125 let config = SshConfig::default()
1126 .default_algorithms(DefaultAlgorithms {
1127 ca_signature_algorithms: vec![],
1128 ciphers: vec![],
1129 host_key_algorithms: vec![],
1130 kex_algorithms: vec![],
1131 mac: vec![],
1132 pubkey_accepted_algorithms: vec!["omar-crypt".to_string()],
1133 })
1134 .parse(&mut reader, ParseRule::STRICT)?;
1135
1136 let params = config.query("*");
1139 assert_eq!(
1140 params.ignore_unknown.as_deref().unwrap(),
1141 &["Pippo", "Pluto"]
1142 );
1143 assert_eq!(params.compression.unwrap(), true);
1144 assert_eq!(params.connection_attempts.unwrap(), 10);
1145 assert_eq!(params.connect_timeout.unwrap(), Duration::from_secs(60));
1146 assert_eq!(
1147 params.server_alive_interval.unwrap(),
1148 Duration::from_secs(40)
1149 );
1150 assert_eq!(params.tcp_keep_alive.unwrap(), true);
1151 assert_eq!(params.ciphers.algorithms(), &["a-manella", "blowfish"]);
1152 assert_eq!(
1153 params.pubkey_accepted_algorithms.algorithms(),
1154 &["desu", "omar-crypt", "fast-omar-crypt"]
1155 );
1156
1157 assert_eq!(params.ca_signature_algorithms.algorithms(), &["random"]);
1159 assert_eq!(
1160 params.host_key_algorithms.algorithms(),
1161 &["luigi", "mario",]
1162 );
1163 assert_eq!(params.kex_algorithms.algorithms(), &["desu", "gigi",]);
1164 assert_eq!(params.mac.algorithms(), &["concorde"]);
1165 assert!(params.bind_address.is_none());
1166
1167 let params_172_26_104_4 = config.query("172.26.104.4");
1171
1172 assert_eq!(params_172_26_104_4.add_keys_to_agent.unwrap(), true);
1174 assert_eq!(params_172_26_104_4.compression.unwrap(), true);
1175 assert_eq!(params_172_26_104_4.connection_attempts.unwrap(), 10);
1176 assert_eq!(
1177 params_172_26_104_4.connect_timeout.unwrap(),
1178 Duration::from_secs(60)
1179 );
1180 assert_eq!(params_172_26_104_4.tcp_keep_alive.unwrap(), true);
1181
1182 assert_eq!(
1184 params_172_26_104_4.ca_signature_algorithms.algorithms(),
1185 &["random"]
1186 );
1187 assert_eq!(
1188 params_172_26_104_4.ciphers.algorithms(),
1189 &["a-manella", "blowfish",]
1190 );
1191 assert_eq!(params_172_26_104_4.mac.algorithms(), &["spyro", "deoxys"]); assert_eq!(
1193 params_172_26_104_4.proxy_jump.unwrap(),
1194 &["jump.example.com"]
1195 ); assert_eq!(
1197 params_172_26_104_4
1198 .pubkey_accepted_algorithms
1199 .algorithms()
1200 .is_empty(), true
1202 );
1203 assert_eq!(
1204 params_172_26_104_4.bind_address.as_deref().unwrap(),
1205 "10.8.0.10"
1206 );
1207 assert_eq!(
1208 params_172_26_104_4.bind_interface.as_deref().unwrap(),
1209 "tun0"
1210 );
1211 assert_eq!(params_172_26_104_4.port.unwrap(), 2222);
1212 assert_eq!(
1213 params_172_26_104_4.identity_file.as_deref().unwrap(),
1214 vec![
1215 Path::new("/home/root/.ssh/pippo.key"),
1216 Path::new("/home/root/.ssh/pluto.key")
1217 ]
1218 );
1219 assert_eq!(params_172_26_104_4.user.as_deref().unwrap(), "omar");
1220
1221 let params_tostapane = config.query("tostapane");
1223 assert_eq!(params_tostapane.compression.unwrap(), true); assert_eq!(params_tostapane.connection_attempts.unwrap(), 10);
1225 assert_eq!(
1226 params_tostapane.connect_timeout.unwrap(),
1227 Duration::from_secs(60)
1228 );
1229 assert_eq!(params_tostapane.tcp_keep_alive.unwrap(), true);
1230 assert_eq!(
1231 params_tostapane.remote_forward,
1232 vec![RemoteForward::new(RemoteForwardListen::Port(88), None)]
1233 );
1234 assert_eq!(params_tostapane.user.as_deref().unwrap(), "ciro-esposito");
1235
1236 assert_eq!(
1238 params_tostapane.ca_signature_algorithms.algorithms(),
1239 &["random"]
1240 );
1241 assert_eq!(
1242 params_tostapane.ciphers.algorithms(),
1243 &["a-manella", "blowfish",]
1244 );
1245 assert_eq!(
1246 params_tostapane.mac.algorithms(),
1247 vec!["spyro".to_string(), "deoxys".to_string(),]
1248 );
1249 assert_eq!(
1250 params_tostapane.proxy_jump.unwrap(),
1251 vec![
1252 "jump1.example.com".to_string(),
1253 "jump2.example.com".to_string(),
1254 ]
1255 );
1256 assert_eq!(
1257 params_tostapane.pubkey_accepted_algorithms.algorithms(),
1258 &["desu", "omar-crypt", "fast-omar-crypt"]
1259 );
1260
1261 let params_192_168_1_30 = config.query("192.168.1.30");
1263
1264 assert_eq!(params_192_168_1_30.user.as_deref().unwrap(), "nutellaro");
1266 assert_eq!(
1267 params_192_168_1_30.remote_forward,
1268 vec![RemoteForward::new(RemoteForwardListen::Port(123), None)]
1269 );
1270
1271 assert_eq!(params_192_168_1_30.compression.unwrap(), true);
1273 assert_eq!(params_192_168_1_30.connection_attempts.unwrap(), 10);
1274 assert_eq!(
1275 params_192_168_1_30.connect_timeout.unwrap(),
1276 Duration::from_secs(60)
1277 );
1278 assert_eq!(params_192_168_1_30.tcp_keep_alive.unwrap(), true);
1279
1280 assert_eq!(
1282 params_192_168_1_30.ca_signature_algorithms.algorithms(),
1283 &["random"]
1284 );
1285 assert_eq!(
1286 params_192_168_1_30.ciphers.algorithms(),
1287 &["a-manella", "blowfish"]
1288 );
1289 assert_eq!(params_192_168_1_30.mac.algorithms(), &["concorde"]);
1290 assert_eq!(
1291 params_192_168_1_30.pubkey_accepted_algorithms.algorithms(),
1292 &["desu", "omar-crypt", "fast-omar-crypt"]
1293 );
1294
1295 Ok(())
1296 }
1297
1298 #[test]
1299 fn should_allow_unknown_field() -> Result<(), SshParserError> {
1300 crate::test_log();
1301 let temp = create_ssh_config_with_unknown_fields();
1302 let file = File::open(temp.path()).expect("Failed to open tempfile");
1303 let mut reader = BufReader::new(file);
1304 let _config = SshConfig::default()
1305 .default_algorithms(DefaultAlgorithms::empty())
1306 .parse(&mut reader, ParseRule::ALLOW_UNKNOWN_FIELDS)?;
1307
1308 Ok(())
1309 }
1310
1311 #[test]
1312 fn should_not_allow_unknown_field() {
1313 crate::test_log();
1314 let temp = create_ssh_config_with_unknown_fields();
1315 let file = File::open(temp.path()).expect("Failed to open tempfile");
1316 let mut reader = BufReader::new(file);
1317 assert!(matches!(
1318 SshConfig::default()
1319 .default_algorithms(DefaultAlgorithms::empty())
1320 .parse(&mut reader, ParseRule::STRICT)
1321 .unwrap_err(),
1322 SshParserError::UnknownField(..)
1323 ));
1324 }
1325
1326 #[test]
1327 fn should_store_unknown_fields() {
1328 crate::test_log();
1329 let temp = create_ssh_config_with_unknown_fields();
1330 let file = File::open(temp.path()).expect("Failed to open tempfile");
1331 let mut reader = BufReader::new(file);
1332 let config = SshConfig::default()
1333 .default_algorithms(DefaultAlgorithms::empty())
1334 .parse(&mut reader, ParseRule::ALLOW_UNKNOWN_FIELDS)
1335 .unwrap();
1336
1337 let host = config.query("cross-platform");
1338 assert_eq!(
1339 host.ignored_fields.get("Piropero").unwrap(),
1340 &vec![String::from("yes")]
1341 );
1342 }
1343
1344 #[test]
1345 fn should_parse_inversed_ssh_config() {
1346 crate::test_log();
1347 let temp = create_inverted_ssh_config();
1348 let file = File::open(temp.path()).expect("Failed to open tempfile");
1349 let mut reader = BufReader::new(file);
1350 let config = SshConfig::default()
1351 .default_algorithms(DefaultAlgorithms::empty())
1352 .parse(&mut reader, ParseRule::STRICT)
1353 .unwrap();
1354
1355 let home_dir = dirs::home_dir()
1356 .expect("home directory must exist")
1357 .to_string_lossy()
1358 .to_string();
1359
1360 let remote_host = config.query("remote-host");
1361
1362 assert_eq!(
1364 remote_host.identity_file.unwrap()[0].as_path(),
1365 Path::new(format!("{home_dir}/.ssh/id_rsa_good").as_str()) );
1367
1368 assert_eq!(remote_host.host_name.unwrap(), "hostname.com");
1370 assert_eq!(remote_host.user.unwrap(), "user");
1371
1372 assert_eq!(
1374 remote_host.connect_timeout.unwrap(),
1375 Duration::from_secs(15)
1376 );
1377 }
1378
1379 #[test]
1380 fn should_parse_configuration_with_hosts() {
1381 crate::test_log();
1382 let temp = create_ssh_config_with_comments();
1383
1384 let file = File::open(temp.path()).expect("Failed to open tempfile");
1385 let mut reader = BufReader::new(file);
1386 let config = SshConfig::default()
1387 .default_algorithms(DefaultAlgorithms::empty())
1388 .parse(&mut reader, ParseRule::STRICT)
1389 .unwrap();
1390
1391 let hostname = config.query("cross-platform").host_name.unwrap();
1392 assert_eq!(&hostname, "hostname.com");
1393
1394 assert!(config.query("this").host_name.is_none());
1395 }
1396
1397 #[test]
1398 fn should_update_host_bind_address() -> Result<(), SshParserError> {
1399 crate::test_log();
1400 let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1401 SshConfigParser::update_host(
1402 Field::BindAddress,
1403 vec![String::from("127.0.0.1")],
1404 &mut host,
1405 None,
1406 )?;
1407 assert_eq!(host.params.bind_address.as_deref().unwrap(), "127.0.0.1");
1408 Ok(())
1409 }
1410
1411 #[test]
1412 fn should_update_host_bind_interface() -> Result<(), SshParserError> {
1413 crate::test_log();
1414 let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1415 SshConfigParser::update_host(
1416 Field::BindInterface,
1417 vec![String::from("aaa")],
1418 &mut host,
1419 None,
1420 )?;
1421 assert_eq!(host.params.bind_interface.as_deref().unwrap(), "aaa");
1422 Ok(())
1423 }
1424
1425 #[test]
1426 fn should_update_host_ca_signature_algos() -> Result<(), SshParserError> {
1427 crate::test_log();
1428 let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1429 SshConfigParser::update_host(
1430 Field::CaSignatureAlgorithms,
1431 vec![String::from("a,b,c")],
1432 &mut host,
1433 None,
1434 )?;
1435 assert_eq!(
1436 host.params.ca_signature_algorithms.algorithms(),
1437 &["a", "b", "c"]
1438 );
1439 Ok(())
1440 }
1441
1442 #[test]
1443 fn should_update_host_certificate_file() -> Result<(), SshParserError> {
1444 crate::test_log();
1445 let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1446 SshConfigParser::update_host(
1447 Field::CertificateFile,
1448 vec![String::from("/tmp/a.crt")],
1449 &mut host,
1450 None,
1451 )?;
1452 assert_eq!(
1453 host.params.certificate_file.as_deref().unwrap(),
1454 Path::new("/tmp/a.crt")
1455 );
1456 Ok(())
1457 }
1458
1459 #[test]
1460 fn should_update_host_ciphers() -> Result<(), SshParserError> {
1461 crate::test_log();
1462 let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1463 SshConfigParser::update_host(Field::Ciphers, vec![String::from("a,b,c")], &mut host, None)?;
1464 assert_eq!(host.params.ciphers.algorithms(), &["a", "b", "c"]);
1465 Ok(())
1466 }
1467
1468 #[test]
1469 fn should_update_host_compression() -> Result<(), SshParserError> {
1470 crate::test_log();
1471 let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1472 SshConfigParser::update_host(
1473 Field::Compression,
1474 vec![String::from("yes")],
1475 &mut host,
1476 None,
1477 )?;
1478 assert_eq!(host.params.compression.unwrap(), true);
1479 Ok(())
1480 }
1481
1482 #[test]
1483 fn should_update_host_connection_attempts() -> Result<(), SshParserError> {
1484 crate::test_log();
1485 let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1486 SshConfigParser::update_host(
1487 Field::ConnectionAttempts,
1488 vec![String::from("4")],
1489 &mut host,
1490 None,
1491 )?;
1492 assert_eq!(host.params.connection_attempts.unwrap(), 4);
1493 Ok(())
1494 }
1495
1496 #[test]
1497 fn should_update_host_connection_timeout() -> Result<(), SshParserError> {
1498 crate::test_log();
1499 let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1500 SshConfigParser::update_host(
1501 Field::ConnectTimeout,
1502 vec![String::from("10")],
1503 &mut host,
1504 None,
1505 )?;
1506 assert_eq!(
1507 host.params.connect_timeout.unwrap(),
1508 Duration::from_secs(10)
1509 );
1510 Ok(())
1511 }
1512
1513 #[test]
1514 fn should_update_host_key_algorithms() -> Result<(), SshParserError> {
1515 crate::test_log();
1516 let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1517 SshConfigParser::update_host(
1518 Field::HostKeyAlgorithms,
1519 vec![String::from("a,b,c")],
1520 &mut host,
1521 None,
1522 )?;
1523 assert_eq!(
1524 host.params.host_key_algorithms.algorithms(),
1525 &["a", "b", "c"]
1526 );
1527 Ok(())
1528 }
1529
1530 #[test]
1531 fn should_update_host_host_name() -> Result<(), SshParserError> {
1532 crate::test_log();
1533 let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1534 SshConfigParser::update_host(
1535 Field::HostName,
1536 vec![String::from("192.168.1.1")],
1537 &mut host,
1538 None,
1539 )?;
1540 assert_eq!(host.params.host_name.as_deref().unwrap(), "192.168.1.1");
1541 Ok(())
1542 }
1543
1544 #[test]
1545 fn should_update_host_ignore_unknown() -> Result<(), SshParserError> {
1546 crate::test_log();
1547 let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1548 SshConfigParser::update_host(
1549 Field::IgnoreUnknown,
1550 vec![String::from("a,b,c")],
1551 &mut host,
1552 None,
1553 )?;
1554 assert_eq!(
1555 host.params.ignore_unknown.as_deref().unwrap(),
1556 &["a", "b", "c"]
1557 );
1558 Ok(())
1559 }
1560
1561 #[test]
1562 fn should_update_kex_algorithms() -> Result<(), SshParserError> {
1563 crate::test_log();
1564 let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1565 SshConfigParser::update_host(
1566 Field::KexAlgorithms,
1567 vec![String::from("a,b,c")],
1568 &mut host,
1569 None,
1570 )?;
1571 assert_eq!(host.params.kex_algorithms.algorithms(), &["a", "b", "c"]);
1572 Ok(())
1573 }
1574
1575 #[test]
1576 fn should_update_host_mac() -> Result<(), SshParserError> {
1577 crate::test_log();
1578 let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1579 SshConfigParser::update_host(Field::Mac, vec![String::from("a,b,c")], &mut host, None)?;
1580 assert_eq!(host.params.mac.algorithms(), &["a", "b", "c"]);
1581 Ok(())
1582 }
1583
1584 #[test]
1585 fn should_update_host_port() -> Result<(), SshParserError> {
1586 crate::test_log();
1587 let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1588 SshConfigParser::update_host(Field::Port, vec![String::from("2222")], &mut host, None)?;
1589 assert_eq!(host.params.port.unwrap(), 2222);
1590 Ok(())
1591 }
1592
1593 #[test]
1594 fn should_update_host_pubkey_accepted_algos() -> Result<(), SshParserError> {
1595 crate::test_log();
1596 let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1597 SshConfigParser::update_host(
1598 Field::PubkeyAcceptedAlgorithms,
1599 vec![String::from("a,b,c")],
1600 &mut host,
1601 None,
1602 )?;
1603 assert_eq!(
1604 host.params.pubkey_accepted_algorithms.algorithms(),
1605 &["a", "b", "c"]
1606 );
1607 Ok(())
1608 }
1609
1610 #[test]
1611 fn should_update_host_pubkey_authentication() -> Result<(), SshParserError> {
1612 crate::test_log();
1613 let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1614 SshConfigParser::update_host(
1615 Field::PubkeyAuthentication,
1616 vec![String::from("yes")],
1617 &mut host,
1618 None,
1619 )?;
1620 assert_eq!(host.params.pubkey_authentication.unwrap(), true);
1621 Ok(())
1622 }
1623
1624 #[test]
1625 fn should_update_host_remote_forward() -> Result<(), SshParserError> {
1626 crate::test_log();
1627 let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1628 SshConfigParser::update_host(
1629 Field::RemoteForward,
1630 vec![String::from("3005"), String::from("localhost:80")],
1631 &mut host,
1632 None,
1633 )?;
1634 SshConfigParser::update_host(
1635 Field::RemoteForward,
1636 vec![String::from("/tmp/remote.sock")],
1637 &mut host,
1638 None,
1639 )?;
1640 assert_eq!(
1641 host.params.remote_forward,
1642 vec![
1643 RemoteForward::new(
1644 RemoteForwardListen::Port(3005),
1645 Some(RemoteForwardDestination::Host {
1646 host: "localhost".to_string(),
1647 port: 80,
1648 }),
1649 ),
1650 RemoteForward::new(
1651 RemoteForwardListen::UnixSocket(PathBuf::from("/tmp/remote.sock")),
1652 None,
1653 ),
1654 ]
1655 );
1656 Ok(())
1657 }
1658
1659 #[test]
1660 fn should_accumulate_remote_forwards_from_matching_hosts() -> Result<(), SshParserError> {
1661 let config = r#"
1662Host test
1663 RemoteForward 8080 localhost:80
1664
1665Host *
1666 RemoteForward "/tmp/remote socket" "/tmp/local socket"
1667"#;
1668 let mut reader = BufReader::new(config.as_bytes());
1669 let config = SshConfig::default()
1670 .default_algorithms(DefaultAlgorithms::empty())
1671 .parse(&mut reader, ParseRule::STRICT)?;
1672
1673 assert_eq!(
1674 config.query("test").remote_forward,
1675 vec![
1676 RemoteForward::new(
1677 RemoteForwardListen::Port(8080),
1678 Some(RemoteForwardDestination::Host {
1679 host: "localhost".to_string(),
1680 port: 80,
1681 }),
1682 ),
1683 RemoteForward::new(
1684 RemoteForwardListen::UnixSocket(PathBuf::from("/tmp/remote socket")),
1685 Some(RemoteForwardDestination::UnixSocket(PathBuf::from(
1686 "/tmp/local socket",
1687 ))),
1688 ),
1689 ]
1690 );
1691 Ok(())
1692 }
1693
1694 #[test]
1695 fn should_update_host_server_alive_interval() -> Result<(), SshParserError> {
1696 crate::test_log();
1697 let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1698 SshConfigParser::update_host(
1699 Field::ServerAliveInterval,
1700 vec![String::from("40")],
1701 &mut host,
1702 None,
1703 )?;
1704 assert_eq!(
1705 host.params.server_alive_interval.unwrap(),
1706 Duration::from_secs(40)
1707 );
1708 Ok(())
1709 }
1710
1711 #[test]
1712 fn should_update_host_tcp_keep_alive() -> Result<(), SshParserError> {
1713 crate::test_log();
1714 let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1715 SshConfigParser::update_host(
1716 Field::TcpKeepAlive,
1717 vec![String::from("no")],
1718 &mut host,
1719 None,
1720 )?;
1721 assert_eq!(host.params.tcp_keep_alive.unwrap(), false);
1722 Ok(())
1723 }
1724
1725 #[test]
1726 fn should_update_host_user() -> Result<(), SshParserError> {
1727 crate::test_log();
1728 let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1729 SshConfigParser::update_host(Field::User, vec![String::from("pippo")], &mut host, None)?;
1730 assert_eq!(host.params.user.as_deref().unwrap(), "pippo");
1731 Ok(())
1732 }
1733
1734 #[test]
1735 fn should_not_update_host_if_unknown() -> Result<(), SshParserError> {
1736 crate::test_log();
1737 let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1738 let result = SshConfigParser::update_host(
1739 Field::PasswordAuthentication,
1740 vec![String::from("yes")],
1741 &mut host,
1742 None,
1743 );
1744
1745 match result {
1746 Ok(_) | Err(SshParserError::UnsupportedField(_, _)) => Ok(()),
1747 Err(e) => Err(e),
1748 }?;
1749
1750 assert_eq!(host.params, HostParams::new(&DefaultAlgorithms::empty()));
1751 Ok(())
1752 }
1753
1754 #[test]
1755 fn should_update_host_if_unsupported() -> Result<(), SshParserError> {
1756 crate::test_log();
1757 let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1758 let result = SshConfigParser::update_host(
1759 Field::PasswordAuthentication,
1760 vec![String::from("yes")],
1761 &mut host,
1762 None,
1763 );
1764
1765 match result {
1766 Err(SshParserError::UnsupportedField(field, _)) => {
1767 assert_eq!(field, "passwordauthentication");
1768 Ok(())
1769 }
1770 Ok(_) => Ok(()),
1771 Err(e) => Err(e),
1772 }?;
1773
1774 assert_eq!(host.params, HostParams::new(&DefaultAlgorithms::empty()));
1775 Ok(())
1776 }
1777
1778 #[test]
1779 fn should_tokenize_line() -> Result<(), SshParserError> {
1780 crate::test_log();
1781 assert_eq!(
1782 SshConfigParser::tokenize_line("HostName 192.168.*.* 172.26.*.*")?,
1783 (
1784 Field::HostName,
1785 vec![String::from("192.168.*.*"), String::from("172.26.*.*")]
1786 )
1787 );
1788 assert_eq!(
1790 SshConfigParser::tokenize_line(
1791 " HostName 192.168.*.* 172.26.*.* "
1792 )?,
1793 (
1794 Field::HostName,
1795 vec![String::from("192.168.*.*"), String::from("172.26.*.*")]
1796 )
1797 );
1798 Ok(())
1799 }
1800
1801 #[test]
1802 fn should_not_tokenize_line() {
1803 crate::test_log();
1804 assert!(matches!(
1805 SshConfigParser::tokenize_line("Omar yes").unwrap_err(),
1806 SshParserError::UnknownField(..)
1807 ));
1808 }
1809
1810 #[test]
1811 fn should_fail_parsing_field() {
1812 crate::test_log();
1813
1814 assert!(matches!(
1815 SshConfigParser::tokenize_line(" ").unwrap_err(),
1816 SshParserError::MissingArgument
1817 ));
1818 }
1819
1820 #[test]
1821 fn should_fail_on_mismatched_quotes() {
1822 crate::test_log();
1823
1824 assert!(matches!(
1826 SshConfigParser::tokenize_line(r#"Hostname "example.com"#).unwrap_err(),
1827 SshParserError::InvalidQuotes
1828 ));
1829 assert!(matches!(
1831 SshConfigParser::tokenize_line(r#"Hostname example.com""#).unwrap_err(),
1832 SshParserError::InvalidQuotes
1833 ));
1834 assert!(matches!(
1836 SshConfigParser::tokenize_line(r#"Hostname foo "bar"#).unwrap_err(),
1837 SshParserError::InvalidQuotes
1838 ));
1839 }
1840
1841 #[test]
1842 fn should_parse_boolean() -> Result<(), SshParserError> {
1843 crate::test_log();
1844 assert_eq!(
1845 SshConfigParser::parse_boolean(vec![String::from("yes")])?,
1846 true
1847 );
1848 assert_eq!(
1849 SshConfigParser::parse_boolean(vec![String::from("no")])?,
1850 false
1851 );
1852 Ok(())
1853 }
1854
1855 #[test]
1856 fn should_parse_boolean_true_false_case_insensitive() -> Result<(), SshParserError> {
1857 crate::test_log();
1858 for value in ["true", "True", "YES", "Yes"] {
1859 assert!(SshConfigParser::parse_boolean(vec![value.to_string()])?);
1860 }
1861 for value in ["false", "False", "NO", "No"] {
1862 assert!(!SshConfigParser::parse_boolean(vec![value.to_string()])?);
1863 }
1864 Ok(())
1865 }
1866
1867 #[test]
1868 fn should_fail_parsing_boolean() {
1869 crate::test_log();
1870 assert!(matches!(
1871 SshConfigParser::parse_boolean(vec!["boh".to_string()]).unwrap_err(),
1872 SshParserError::ExpectedBoolean
1873 ));
1874 assert!(matches!(
1875 SshConfigParser::parse_boolean(vec![]).unwrap_err(),
1876 SshParserError::MissingArgument
1877 ));
1878 }
1879
1880 #[test]
1881 fn should_parse_algos() -> Result<(), SshParserError> {
1882 crate::test_log();
1883 assert_eq!(
1884 SshConfigParser::parse_algos(vec![String::from("a,b,c,d")])?,
1885 AlgorithmsRule::Set(vec![
1886 "a".to_string(),
1887 "b".to_string(),
1888 "c".to_string(),
1889 "d".to_string(),
1890 ])
1891 );
1892
1893 assert_eq!(
1894 SshConfigParser::parse_algos(vec![String::from("a")])?,
1895 AlgorithmsRule::Set(vec!["a".to_string()])
1896 );
1897
1898 assert_eq!(
1899 SshConfigParser::parse_algos(vec![String::from("+a,b")])?,
1900 AlgorithmsRule::Append(vec!["a".to_string(), "b".to_string()])
1901 );
1902
1903 Ok(())
1904 }
1905
1906 #[test]
1907 fn should_parse_comma_separated_list() -> Result<(), SshParserError> {
1908 crate::test_log();
1909 assert_eq!(
1910 SshConfigParser::parse_comma_separated_list(vec![String::from("a,b,c,d")])?,
1911 vec![
1912 "a".to_string(),
1913 "b".to_string(),
1914 "c".to_string(),
1915 "d".to_string(),
1916 ]
1917 );
1918 assert_eq!(
1919 SshConfigParser::parse_comma_separated_list(vec![String::from("a")])?,
1920 vec!["a".to_string()]
1921 );
1922 Ok(())
1923 }
1924
1925 #[test]
1926 fn should_fail_parsing_comma_separated_list() {
1927 crate::test_log();
1928 assert!(matches!(
1929 SshConfigParser::parse_comma_separated_list(vec![]).unwrap_err(),
1930 SshParserError::MissingArgument
1931 ));
1932 }
1933
1934 #[test]
1935 fn should_parse_duration() -> Result<(), SshParserError> {
1936 crate::test_log();
1937 assert_eq!(
1938 SshConfigParser::parse_duration(vec![String::from("60")])?,
1939 Duration::from_secs(60)
1940 );
1941 Ok(())
1942 }
1943
1944 #[test]
1945 fn should_fail_parsing_duration() {
1946 crate::test_log();
1947 assert!(matches!(
1948 SshConfigParser::parse_duration(vec![String::from("AAA")]).unwrap_err(),
1949 SshParserError::ExpectedUnsigned
1950 ));
1951 assert!(matches!(
1952 SshConfigParser::parse_duration(vec![]).unwrap_err(),
1953 SshParserError::MissingArgument
1954 ));
1955 }
1956
1957 #[test]
1958 fn should_parse_host() -> Result<(), SshParserError> {
1959 crate::test_log();
1960 assert_eq!(
1961 SshConfigParser::parse_host(vec![
1962 String::from("192.168.*.*"),
1963 String::from("!192.168.1.1"),
1964 String::from("172.26.104.*"),
1965 String::from("!172.26.104.10"),
1966 ])?,
1967 vec![
1968 HostClause::new(String::from("192.168.*.*"), false),
1969 HostClause::new(String::from("192.168.1.1"), true),
1970 HostClause::new(String::from("172.26.104.*"), false),
1971 HostClause::new(String::from("172.26.104.10"), true),
1972 ]
1973 );
1974 Ok(())
1975 }
1976
1977 #[test]
1978 fn should_fail_parsing_host() {
1979 crate::test_log();
1980 assert!(matches!(
1981 SshConfigParser::parse_host(vec![]).unwrap_err(),
1982 SshParserError::MissingArgument
1983 ));
1984 }
1985
1986 #[test]
1987 fn should_parse_path() -> Result<(), SshParserError> {
1988 crate::test_log();
1989 assert_eq!(
1990 SshConfigParser::parse_path(vec![String::from("/tmp/a.txt")], None)?,
1991 PathBuf::from("/tmp/a.txt")
1992 );
1993 Ok(())
1994 }
1995
1996 #[test]
1997 fn should_parse_path_list() -> Result<(), SshParserError> {
1998 crate::test_log();
1999 assert_eq!(
2000 SshConfigParser::parse_path_list(
2001 vec![String::from("/tmp/a.txt"), String::from("/tmp/b.txt")],
2002 None
2003 )?,
2004 vec![PathBuf::from("/tmp/a.txt"), PathBuf::from("/tmp/b.txt")]
2005 );
2006 Ok(())
2007 }
2008
2009 #[test]
2010 fn should_fail_parse_path_list() {
2011 crate::test_log();
2012 assert!(matches!(
2013 SshConfigParser::parse_path_list(vec![], None).unwrap_err(),
2014 SshParserError::MissingArgument
2015 ));
2016 }
2017
2018 #[test]
2019 fn should_fail_parsing_path() {
2020 crate::test_log();
2021 assert!(matches!(
2022 SshConfigParser::parse_path(vec![], None).unwrap_err(),
2023 SshParserError::MissingArgument
2024 ));
2025 }
2026
2027 #[test]
2028 fn should_parse_port() -> Result<(), SshParserError> {
2029 crate::test_log();
2030 assert_eq!(SshConfigParser::parse_port(vec![String::from("22")])?, 22);
2031 Ok(())
2032 }
2033
2034 #[test]
2035 fn should_fail_parsing_port() {
2036 crate::test_log();
2037 assert!(matches!(
2038 SshConfigParser::parse_port(vec![String::from("1234567")]).unwrap_err(),
2039 SshParserError::ExpectedPort
2040 ));
2041 assert!(matches!(
2042 SshConfigParser::parse_port(vec![]).unwrap_err(),
2043 SshParserError::MissingArgument
2044 ));
2045 }
2046
2047 #[test]
2048 fn should_parse_string() -> Result<(), SshParserError> {
2049 crate::test_log();
2050 assert_eq!(
2051 SshConfigParser::parse_string(vec![String::from("foobar")])?,
2052 String::from("foobar")
2053 );
2054 Ok(())
2055 }
2056
2057 #[test]
2058 fn should_fail_parsing_string() {
2059 crate::test_log();
2060 assert!(matches!(
2061 SshConfigParser::parse_string(vec![]).unwrap_err(),
2062 SshParserError::MissingArgument
2063 ));
2064 }
2065
2066 #[test]
2067 fn should_parse_unsigned() -> Result<(), SshParserError> {
2068 crate::test_log();
2069 assert_eq!(
2070 SshConfigParser::parse_unsigned(vec![String::from("43")])?,
2071 43
2072 );
2073 Ok(())
2074 }
2075
2076 #[test]
2077 fn should_fail_parsing_unsigned() {
2078 crate::test_log();
2079 assert!(matches!(
2080 SshConfigParser::parse_unsigned(vec![String::from("abc")]).unwrap_err(),
2081 SshParserError::ExpectedUnsigned
2082 ));
2083 assert!(matches!(
2084 SshConfigParser::parse_unsigned(vec![]).unwrap_err(),
2085 SshParserError::MissingArgument
2086 ));
2087 }
2088
2089 #[test]
2090 fn should_strip_comments() {
2091 crate::test_log();
2092
2093 assert_eq!(
2094 SshConfigParser::strip_comments("host my_host # this is my fav host").as_str(),
2095 "host my_host "
2096 );
2097 assert_eq!(
2098 SshConfigParser::strip_comments("# this is a comment").as_str(),
2099 ""
2100 );
2101 }
2102
2103 #[test]
2104 fn should_preserve_hash_inside_quoted_strings() {
2105 crate::test_log();
2106
2107 assert_eq!(
2109 SshConfigParser::strip_comments(r#"Ciphers "aes256-ctr # not a comment""#).as_str(),
2110 r#"Ciphers "aes256-ctr # not a comment""#
2111 );
2112 assert_eq!(
2114 SshConfigParser::strip_comments(r#"Ciphers "aes256-ctr" # this is a comment"#).as_str(),
2115 r#"Ciphers "aes256-ctr" "#
2116 );
2117 assert_eq!(
2119 SshConfigParser::strip_comments(r#"ProxyCommand "ssh # hop" -W "dest # host""#)
2120 .as_str(),
2121 r#"ProxyCommand "ssh # hop" -W "dest # host""#
2122 );
2123 assert_eq!(
2125 SshConfigParser::strip_comments(r#"Key "val1" "val2" # comment"#).as_str(),
2126 r#"Key "val1" "val2" "#
2127 );
2128 }
2129
2130 #[test]
2131 fn test_should_parse_config_with_quotes_and_eq() {
2132 crate::test_log();
2133
2134 let config = create_ssh_config_with_quotes_and_eq();
2135 let file = File::open(config.path()).expect("Failed to open tempfile");
2136 let mut reader = BufReader::new(file);
2137
2138 let config = SshConfig::default()
2139 .default_algorithms(DefaultAlgorithms::empty())
2140 .parse(&mut reader, ParseRule::STRICT)
2141 .expect("Failed to parse config");
2142
2143 let params = config.query("foo");
2144
2145 assert_eq!(
2147 params.connect_timeout.expect("unspec connect timeout"),
2148 Duration::from_secs(15)
2149 );
2150 assert_eq!(
2151 params
2152 .ignore_unknown
2153 .as_deref()
2154 .expect("unspec ignore unknown"),
2155 &["Pippo", "Pluto"]
2156 );
2157 assert_eq!(
2158 params
2159 .ciphers
2160 .algorithms()
2161 .iter()
2162 .map(|x| x.as_str())
2163 .collect::<Vec<&str>>(),
2164 &["Pepperoni Pizza", "Margherita Pizza", "Hawaiian Pizza"]
2165 );
2166 assert_eq!(
2167 params
2168 .mac
2169 .algorithms()
2170 .iter()
2171 .map(|x| x.as_str())
2172 .collect::<Vec<&str>>(),
2173 &["Pasta Carbonara", "Pasta con tonno"]
2174 );
2175 }
2176
2177 #[test]
2178 fn test_should_resolve_absolute_include_path() {
2179 crate::test_log();
2180
2181 let expected = PathBuf::from("/tmp/config.local");
2182
2183 let s = "/tmp/config.local";
2184 let resolved = PathBuf::from(
2185 SshConfigParser::resolve_include_path(s, None).expect("absolute path resolves"),
2186 );
2187 assert_eq!(resolved, expected);
2188 }
2189
2190 #[test]
2191 fn should_fail_resolving_home_relative_paths_without_home() {
2192 crate::test_log();
2193 type Resolve = fn(&str) -> SshParserResult<()>;
2194 let parse_path: Resolve = |s| SshConfigParser::parse_path_arg(s, None).map(drop);
2195 let resolve_include: Resolve = |s| SshConfigParser::resolve_include_path(s, None).map(drop);
2196 let cases: [(&str, &str, Resolve); 5] = [
2197 ("parse_path_arg", "~/.ssh/id_rsa", parse_path),
2198 ("parse_path_arg", "~", parse_path),
2199 (
2200 "resolve_include_path",
2201 "~/.ssh/config.local",
2202 resolve_include,
2203 ),
2204 ("resolve_include_path", "config.d/*", resolve_include),
2205 ("resolve_include_path", "~", resolve_include),
2206 ];
2207 for (name, input, resolve) in cases {
2208 assert!(
2209 matches!(resolve(input), Err(SshParserError::HomeNotFound(ref arg)) if arg == input),
2210 "{name}({input})"
2211 );
2212 }
2213 }
2214
2215 #[cfg(windows)]
2216 #[test]
2217 fn should_expand_tilde_followed_by_windows_separator() -> Result<(), SshParserError> {
2218 crate::test_log();
2219 let home = Path::new("/home/alice");
2220 assert_eq!(
2221 SshConfigParser::parse_path_arg("~\\.ssh\\id_rsa", Some(home))?,
2222 home.join(".ssh").join("id_rsa")
2223 );
2224 Ok(())
2225 }
2226
2227 #[test]
2228 fn should_resolve_path_arguments_against_home() {
2229 crate::test_log();
2230
2231 let home = Some(Path::new("/home/alice"));
2232 let cases: [(&str, &str, &str); 6] = [
2234 ("~", "/home/alice", "/home/alice"),
2235 ("~/", "/home/alice", "/home/alice"),
2236 ("~//x", "/home/alice/x", "/home/alice/x"),
2237 (
2238 "~/.ssh/id_dsa",
2239 "/home/alice/.ssh/id_dsa",
2240 "/home/alice/.ssh/id_dsa",
2241 ),
2242 (
2243 "config.local",
2244 "config.local",
2245 "/home/alice/.ssh/config.local",
2246 ),
2247 ("~bob/config", "~bob/config", "~bob/config"),
2248 ];
2249 for (input, expected_path, expected_include) in cases {
2250 assert_eq!(
2251 SshConfigParser::parse_path_arg(input, home).expect("path argument parses"),
2252 PathBuf::from(expected_path),
2253 "parse_path_arg({input})"
2254 );
2255 assert_eq!(
2256 PathBuf::from(
2257 SshConfigParser::resolve_include_path(input, home)
2258 .expect("include path resolves")
2259 ),
2260 PathBuf::from(expected_include),
2261 "resolve_include_path({input})"
2262 );
2263 }
2264 }
2265
2266 #[test]
2267 fn should_resolve_paths_against_configured_home() -> Result<(), SshParserError> {
2268 crate::test_log();
2269 let home = tempfile::tempdir()?;
2270 let ssh_dir = home.path().join(".ssh");
2271 std::fs::create_dir_all(ssh_dir.join("conf.d"))?;
2272 std::fs::write(
2273 ssh_dir.join("conf.d").join("a.conf"),
2274 "Include nested.conf\nHost foo\n IdentityFile ~/.ssh/foo_key\n",
2275 )?;
2276 std::fs::write(
2277 ssh_dir.join("nested.conf"),
2278 "Host bar\n CertificateFile ~/.ssh/bar-cert.pub\n",
2279 )?;
2280
2281 let mut reader = BufReader::new("Include conf.d/*.conf\n".as_bytes());
2282 let config = SshConfig::default()
2283 .default_algorithms(DefaultAlgorithms::empty())
2284 .home_dir(home.path())
2285 .parse(&mut reader, ParseRule::STRICT)?;
2286
2287 assert_eq!(
2288 config.query("foo").identity_file,
2289 Some(vec![ssh_dir.join("foo_key")])
2290 );
2291 assert_eq!(
2292 config.query("bar").certificate_file,
2293 Some(ssh_dir.join("bar-cert.pub"))
2294 );
2295 Ok(())
2296 }
2297
2298 #[test]
2299 fn should_escape_glob_metacharacters_in_home() -> Result<(), SshParserError> {
2300 crate::test_log();
2301 let dir = tempfile::tempdir()?;
2302 let home = dir.path().join("user[1]");
2303 let ssh_dir = home.join(".ssh");
2304 std::fs::create_dir_all(&ssh_dir)?;
2305 std::fs::write(ssh_dir.join("extra.conf"), "Host foo\n User bob\n")?;
2306
2307 for include in ["Include extra.conf\n", "Include ~/.ssh/extra.conf\n"] {
2308 let mut reader = BufReader::new(include.as_bytes());
2309 let config = SshConfig::default()
2310 .default_algorithms(DefaultAlgorithms::empty())
2311 .home_dir(&home)
2312 .parse(&mut reader, ParseRule::STRICT)?;
2313 assert_eq!(
2314 config.query("foo").user.as_deref(),
2315 Some("bob"),
2316 "{include}"
2317 );
2318 }
2319 Ok(())
2320 }
2321
2322 #[test]
2323 fn should_reject_include_cycle() -> Result<(), SshParserError> {
2324 crate::test_log();
2325 type IncludeFile = (&'static str, &'static str, &'static [&'static str]);
2327
2328 let cases: [(&[IncludeFile], &str, &str); 2] = [
2330 (
2331 &[("self.conf", "", &["self.conf"])],
2332 "self.conf",
2333 "self.conf",
2334 ),
2335 (
2336 &[
2337 ("a.conf", "Host a\n ", &["b.conf"]),
2338 ("b.conf", "", &["a.conf"]),
2339 ],
2340 "a.conf",
2341 "a.conf",
2342 ),
2343 ];
2344
2345 for (files, entry, expected) in cases {
2346 let dir = tempfile::tempdir()?;
2347 for (name, prefix, includes) in files {
2348 let includes = includes
2349 .iter()
2350 .map(|include| dir.path().join(include).display().to_string())
2351 .collect::<Vec<_>>()
2352 .join(" ");
2353 std::fs::write(
2354 dir.path().join(name),
2355 format!("{prefix}Include {includes}\n"),
2356 )?;
2357 }
2358
2359 let config = format!(
2360 "Include {entry}\n",
2361 entry = dir.path().join(entry).display()
2362 );
2363 let mut reader = BufReader::new(config.as_bytes());
2364 let err = SshConfig::default()
2365 .default_algorithms(DefaultAlgorithms::empty())
2366 .parse(&mut reader, ParseRule::STRICT)
2367 .unwrap_err();
2368
2369 let expected = dir.path().join(expected).canonicalize()?;
2370 assert_eq!(
2371 err.to_string(),
2372 format!("include cycle detected: {path}", path = expected.display())
2373 );
2374 assert!(matches!(err, SshParserError::IncludeCycle(p) if p == expected));
2375 }
2376 Ok(())
2377 }
2378
2379 #[cfg(unix)]
2380 #[test]
2381 fn should_reject_include_cycle_through_symlink() -> Result<(), SshParserError> {
2382 crate::test_log();
2383 let dir = tempfile::tempdir()?;
2384 let real = dir.path().join("real.conf");
2385 let link = dir.path().join("link.conf");
2386 std::os::unix::fs::symlink(&real, &link)?;
2387 std::fs::write(&real, format!("Include {link}\n", link = link.display()))?;
2388
2389 let config = format!("Include {real}\n", real = real.display());
2390 let mut reader = BufReader::new(config.as_bytes());
2391 let err = SshConfig::default()
2392 .default_algorithms(DefaultAlgorithms::empty())
2393 .parse(&mut reader, ParseRule::STRICT)
2394 .unwrap_err();
2395
2396 assert!(matches!(err, SshParserError::IncludeCycle(p) if p == real.canonicalize()?));
2397 Ok(())
2398 }
2399
2400 #[test]
2401 fn should_allow_non_cyclic_repeated_include() -> Result<(), SshParserError> {
2402 crate::test_log();
2403 let dir = tempfile::tempdir()?;
2404 let shared = dir.path().join("shared.conf");
2405 std::fs::write(&shared, "Host shared\n HostName shared.internal\n")?;
2406 let left = dir.path().join("left.conf");
2407 let right = dir.path().join("right.conf");
2408 std::fs::write(
2409 &left,
2410 format!("Include {shared}\n", shared = shared.display()),
2411 )?;
2412 std::fs::write(
2413 &right,
2414 format!("Include {shared}\n", shared = shared.display()),
2415 )?;
2416
2417 let config = format!(
2418 "Include {left} {right}\n",
2419 left = left.display(),
2420 right = right.display()
2421 );
2422 let mut reader = BufReader::new(config.as_bytes());
2423 let config = SshConfig::default()
2424 .default_algorithms(DefaultAlgorithms::empty())
2425 .parse(&mut reader, ParseRule::STRICT)?;
2426
2427 assert_eq!(
2428 config.query("shared").host_name.as_deref(),
2429 Some("shared.internal")
2430 );
2431 Ok(())
2432 }
2433
2434 #[test]
2435 fn should_fail_parsing_algos_missing_arg() {
2436 crate::test_log();
2437 assert!(matches!(
2438 SshConfigParser::parse_algos(vec![]).unwrap_err(),
2439 SshParserError::MissingArgument
2440 ));
2441 }
2442
2443 #[test]
2444 fn should_parse_duration_zero() {
2445 crate::test_log();
2446 assert_eq!(
2447 SshConfigParser::parse_duration(vec![String::from("0")]).unwrap(),
2448 Duration::from_secs(0)
2449 );
2450 }
2451
2452 #[test]
2453 fn should_parse_port_boundary() {
2454 crate::test_log();
2455 assert_eq!(
2457 SshConfigParser::parse_port(vec![String::from("1")]).unwrap(),
2458 1
2459 );
2460 assert_eq!(
2462 SshConfigParser::parse_port(vec![String::from("65535")]).unwrap(),
2463 65535
2464 );
2465 }
2466
2467 #[test]
2468 fn should_update_host_add_keys_to_agent() {
2469 crate::test_log();
2470 let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
2471 SshConfigParser::update_host(
2472 Field::AddKeysToAgent,
2473 vec![String::from("yes")],
2474 &mut host,
2475 None,
2476 )
2477 .unwrap();
2478 assert_eq!(host.params.add_keys_to_agent.unwrap(), true);
2479
2480 let mut host2 = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
2481 SshConfigParser::update_host(
2482 Field::AddKeysToAgent,
2483 vec![String::from("no")],
2484 &mut host2,
2485 None,
2486 )
2487 .unwrap();
2488 assert_eq!(host2.params.add_keys_to_agent.unwrap(), false);
2489 }
2490
2491 #[test]
2492 fn should_update_host_forward_agent() {
2493 crate::test_log();
2494 let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
2495 SshConfigParser::update_host(
2496 Field::ForwardAgent,
2497 vec![String::from("yes")],
2498 &mut host,
2499 None,
2500 )
2501 .unwrap();
2502 assert_eq!(host.params.forward_agent.unwrap(), true);
2503 }
2504
2505 #[test]
2506 fn should_update_host_proxy_jump() {
2507 crate::test_log();
2508 let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
2509 SshConfigParser::update_host(
2510 Field::ProxyJump,
2511 vec![String::from("jump1,jump2,jump3")],
2512 &mut host,
2513 None,
2514 )
2515 .unwrap();
2516 assert_eq!(
2517 host.params.proxy_jump.unwrap(),
2518 vec![
2519 "jump1".to_string(),
2520 "jump2".to_string(),
2521 "jump3".to_string()
2522 ]
2523 );
2524 }
2525
2526 #[test]
2527 fn should_update_host_identity_file() {
2528 crate::test_log();
2529 let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
2530 SshConfigParser::update_host(
2531 Field::IdentityFile,
2532 vec![String::from("/path/to/key1"), String::from("/path/to/key2")],
2533 &mut host,
2534 None,
2535 )
2536 .unwrap();
2537 assert_eq!(
2538 host.params.identity_file.unwrap(),
2539 vec![
2540 PathBuf::from("/path/to/key1"),
2541 PathBuf::from("/path/to/key2")
2542 ]
2543 );
2544 }
2545
2546 #[test]
2547 fn test_should_allow_and_append_multiple_identity_files_directives() {
2548 crate::test_log();
2549 let config = r##"
2550Host test
2551 IdentityFile /path/to/key1 /path/to/key2
2552 IdentityFile /path/to/key3
2553"##;
2554 let mut reader = BufReader::new(config.as_bytes());
2555 let config = SshConfig::default()
2556 .default_algorithms(DefaultAlgorithms::empty())
2557 .parse(&mut reader, ParseRule::STRICT)
2558 .expect("Failed to parse config");
2559
2560 let params = config.query("test");
2561 assert_eq!(
2562 params.identity_file.as_ref().unwrap(),
2563 &vec![
2564 PathBuf::from("/path/to/key1"),
2565 PathBuf::from("/path/to/key2"),
2566 PathBuf::from("/path/to/key3"),
2567 ]
2568 );
2569 }
2570
2571 #[test]
2572 fn test_should_accumulate_identity_files_across_host_blocks() {
2573 crate::test_log();
2574 let config = r##"
2575Host test
2576 IdentityFile /path/to/specific_key
2577
2578Host *
2579 IdentityFile /path/to/default_key
2580"##;
2581 let mut reader = BufReader::new(config.as_bytes());
2582 let config = SshConfig::default()
2583 .default_algorithms(DefaultAlgorithms::empty())
2584 .parse(&mut reader, ParseRule::STRICT)
2585 .expect("Failed to parse config");
2586
2587 let params = config.query("test");
2588 assert_eq!(
2590 params.identity_file.as_ref().unwrap(),
2591 &vec![
2592 PathBuf::from("/path/to/specific_key"),
2593 PathBuf::from("/path/to/default_key"),
2594 ]
2595 );
2596 }
2597
2598 #[test]
2599 fn should_store_unsupported_fields_when_allowed() {
2600 crate::test_log();
2601
2602 let config = r##"
2603Host test
2604 PasswordAuthentication yes
2605"##;
2606 let mut reader = BufReader::new(config.as_bytes());
2607 let config = SshConfig::default()
2608 .default_algorithms(DefaultAlgorithms::empty())
2609 .parse(&mut reader, ParseRule::ALLOW_UNSUPPORTED_FIELDS)
2610 .unwrap();
2611
2612 let params = config.query("test");
2613 assert!(
2614 params
2615 .unsupported_fields
2616 .contains_key("passwordauthentication")
2617 );
2618 }
2619
2620 #[test]
2621 fn should_tokenize_line_with_equals_separator() {
2622 crate::test_log();
2623 let (field, args) = SshConfigParser::tokenize_line("HostName=example.com").unwrap();
2624 assert_eq!(field, Field::HostName);
2625 assert_eq!(args, vec!["example.com".to_string()]);
2626 }
2627
2628 #[test]
2629 fn should_tokenize_line_with_quoted_args() {
2630 crate::test_log();
2631 let (field, args) =
2632 SshConfigParser::tokenize_line("Ciphers \"aes256-ctr,aes128-ctr\"").unwrap();
2633 assert_eq!(field, Field::Ciphers);
2634 assert_eq!(args, vec!["aes256-ctr,aes128-ctr".to_string()]);
2635 }
2636
2637 #[test]
2638 fn should_tokenize_line_with_equals_and_quoted_args() {
2639 crate::test_log();
2640 let (field, args) =
2641 SshConfigParser::tokenize_line("Ciphers=\"aes256-ctr,aes128-ctr\"").unwrap();
2642 assert_eq!(field, Field::Ciphers);
2643 assert_eq!(args, vec!["aes256-ctr,aes128-ctr".to_string()]);
2644 }
2645
2646 #[test]
2647 fn should_unescape_quoted_args() {
2648 crate::test_log();
2649
2650 let (field, args) =
2652 SshConfigParser::tokenize_line(r#"HostName "gateway\"server""#).unwrap();
2653 assert_eq!(field, Field::HostName);
2654 assert_eq!(args, vec![r#"gateway"server"#.to_string()]);
2655
2656 let (field, args) = SshConfigParser::tokenize_line(r#"HostName "path\\to\\host""#).unwrap();
2658 assert_eq!(field, Field::HostName);
2659 assert_eq!(args, vec![r#"path\to\host"#.to_string()]);
2660
2661 let (field, args) = SshConfigParser::tokenize_line(r#"HostName "it\'s a test""#).unwrap();
2663 assert_eq!(field, Field::HostName);
2664 assert_eq!(args, vec!["it's a test".to_string()]);
2665
2666 let (field, args) =
2668 SshConfigParser::tokenize_line(r#"HostName "say \"hello\" and \\go""#).unwrap();
2669 assert_eq!(field, Field::HostName);
2670 assert_eq!(args, vec![r#"say "hello" and \go"#.to_string()]);
2671
2672 let (field, args) = SshConfigParser::tokenize_line(r#"HostName "test\nvalue""#).unwrap();
2674 assert_eq!(field, Field::HostName);
2675 assert_eq!(args, vec![r#"test\nvalue"#.to_string()]);
2676 }
2677
2678 #[test]
2679 fn should_tokenize_line_setenv() -> Result<(), SshParserError> {
2680 crate::test_log();
2681 assert_eq!(
2682 SshConfigParser::tokenize_line(
2683 r#"SetEnv TEST_1=Test1 TEST_2="Test 2" TEST_3="Test \"3\"" TEST_4=Test"4""#
2684 )?,
2685 (
2686 Field::SetEnv,
2687 vec![
2688 r#"TEST_1=Test1"#.to_owned(),
2689 r#"TEST_2="Test 2""#.to_owned(),
2690 r#"TEST_3="Test \"3\"""#.to_owned(),
2691 r#"TEST_4=Test"4""#.to_owned(),
2692 ]
2693 )
2694 );
2695 Ok(())
2696 }
2697
2698 #[test]
2699 fn should_count_unescaped_quotes() {
2700 crate::test_log();
2701
2702 assert_eq!(SshConfigParser::count_unescaped_quotes("hello"), 0);
2704
2705 assert_eq!(SshConfigParser::count_unescaped_quotes(r#""hello""#), 2);
2707
2708 assert_eq!(SshConfigParser::count_unescaped_quotes(r#"\"hello\""#), 0);
2710
2711 assert_eq!(
2713 SshConfigParser::count_unescaped_quotes(r#""hello\"world""#),
2714 2
2715 );
2716
2717 assert_eq!(SshConfigParser::count_unescaped_quotes(r#"\\""#), 1);
2719
2720 assert_eq!(SshConfigParser::count_unescaped_quotes(""), 0);
2722
2723 assert_eq!(SshConfigParser::count_unescaped_quotes(r#"\""#), 0);
2725 }
2726
2727 #[test]
2728 fn should_detect_ends_with_unescaped_quote() {
2729 crate::test_log();
2730
2731 assert!(SshConfigParser::ends_with_unescaped_quote(r#""hello""#));
2733
2734 assert!(!SshConfigParser::ends_with_unescaped_quote(r#""hello\""#));
2736
2737 assert!(SshConfigParser::ends_with_unescaped_quote(r#""hello\\""#));
2739
2740 assert!(!SshConfigParser::ends_with_unescaped_quote(r#""hello\\\""#));
2742
2743 assert!(!SshConfigParser::ends_with_unescaped_quote("hello"));
2745
2746 assert!(SshConfigParser::ends_with_unescaped_quote(r#"""#));
2748
2749 assert!(!SshConfigParser::ends_with_unescaped_quote(r#"\""#));
2751 }
2752
2753 #[test]
2754 fn should_unescape_string() {
2755 crate::test_log();
2756
2757 assert_eq!(
2759 SshConfigParser::unescape_string(r#"hello\"world"#),
2760 r#"hello"world"#
2761 );
2762
2763 assert_eq!(
2765 SshConfigParser::unescape_string(r#"path\\to\\file"#),
2766 r#"path\to\file"#
2767 );
2768
2769 assert_eq!(SshConfigParser::unescape_string(r#"it\'s"#), "it's");
2771
2772 assert_eq!(
2774 SshConfigParser::unescape_string(r#"say \"hi\" and \\go"#),
2775 r#"say "hi" and \go"#
2776 );
2777
2778 assert_eq!(
2780 SshConfigParser::unescape_string(r#"test\nvalue"#),
2781 r#"test\nvalue"#
2782 );
2783
2784 assert_eq!(SshConfigParser::unescape_string("plain text"), "plain text");
2786
2787 assert_eq!(SshConfigParser::unescape_string(""), "");
2789
2790 assert_eq!(SshConfigParser::unescape_string(r#"test\"#), r#"test\"#);
2792
2793 assert_eq!(SshConfigParser::unescape_string(r#"\\\\"#), r#"\\"#);
2795 }
2796
2797 #[test]
2798 fn should_parse_host_with_single_pattern() {
2799 crate::test_log();
2800 let result = SshConfigParser::parse_host(vec![String::from("example.com")]).unwrap();
2801 assert_eq!(result.len(), 1);
2802 assert_eq!(result[0].pattern, "example.com");
2803 assert!(!result[0].negated);
2804 }
2805
2806 #[test]
2807 fn should_parse_host_with_exclamation_in_pattern() {
2808 crate::test_log();
2809
2810 let result = SshConfigParser::parse_host(vec![String::from("host!name")]).unwrap();
2812 assert_eq!(result.len(), 1);
2813 assert_eq!(result[0].pattern, "host!name");
2814 assert!(!result[0].negated);
2815
2816 let result = SshConfigParser::parse_host(vec![String::from("!host!name")]).unwrap();
2818 assert_eq!(result.len(), 1);
2819 assert_eq!(result[0].pattern, "host!name");
2820 assert!(result[0].negated);
2821
2822 let result = SshConfigParser::parse_host(vec![String::from("!a!b!c")]).unwrap();
2824 assert_eq!(result.len(), 1);
2825 assert_eq!(result[0].pattern, "a!b!c");
2826 assert!(result[0].negated);
2827
2828 let result = SshConfigParser::parse_host(vec![String::from("a!b")]).unwrap();
2830 assert_eq!(result.len(), 1);
2831 assert_eq!(result[0].pattern, "a!b");
2832 assert!(!result[0].negated);
2833 }
2834
2835 #[cfg(target_os = "macos")]
2836 #[test]
2837 fn should_update_host_use_keychain() {
2838 crate::test_log();
2839 let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
2840 SshConfigParser::update_host(
2841 Field::UseKeychain,
2842 vec![String::from("yes")],
2843 &mut host,
2844 None,
2845 )
2846 .unwrap();
2847 assert_eq!(host.params.use_keychain.unwrap(), true);
2848 }
2849
2850 fn create_ssh_config_with_quotes_and_eq() -> NamedTempFile {
2851 let mut tmpfile: tempfile::NamedTempFile =
2852 tempfile::NamedTempFile::new().expect("Failed to create tempfile");
2853 let config = r##"
2854# ssh config
2855# written by veeso
2856
2857
2858# I put a comment here just to annoy
2859
2860IgnoreUnknown=Pippo,Pluto
2861ConnectTimeout = 15
2862Ciphers "Pepperoni Pizza,Margherita Pizza,Hawaiian Pizza"
2863Macs="Pasta Carbonara,Pasta con tonno"
2864"##;
2865 tmpfile.write_all(config.as_bytes()).unwrap();
2866 tmpfile
2867 }
2868
2869 fn create_ssh_config() -> NamedTempFile {
2870 let mut tmpfile: tempfile::NamedTempFile =
2871 tempfile::NamedTempFile::new().expect("Failed to create tempfile");
2872 let config = r##"
2873# ssh config
2874# written by veeso
2875
2876
2877 # I put a comment here just to annoy
2878
2879IgnoreUnknown Pippo,Pluto
2880
2881Compression yes
2882ConnectionAttempts 10
2883ConnectTimeout 60
2884ServerAliveInterval 40
2885TcpKeepAlive yes
2886Ciphers +a-manella,blowfish
2887
2888# Let's start defining some hosts
2889
2890Host 192.168.*.* 172.26.*.* !192.168.1.30
2891 User omar
2892 # ForwardX11 is actually not supported; I just want to see that it wont' fail parsing
2893 ForwardX11 yes
2894 BindAddress 10.8.0.10
2895 BindInterface tun0
2896 AddKeysToAgent yes
2897 Ciphers +coi-piedi,cazdecan,triestin-stretto
2898 IdentityFile /home/root/.ssh/pippo.key /home/root/.ssh/pluto.key
2899 Macs spyro,deoxys
2900 Port 2222
2901 PubkeyAcceptedAlgorithms -omar-crypt
2902 ProxyJump jump.example.com
2903
2904Host tostapane
2905 User ciro-esposito
2906 HostName 192.168.24.32
2907 RemoteForward 88
2908 Compression no
2909 Pippo yes
2910 Pluto 56
2911 ProxyJump jump1.example.com,jump2.example.com
2912 Macs +spyro,deoxys
2913
2914Host 192.168.1.30
2915 User nutellaro
2916 RemoteForward 123
2917
2918Host *
2919 CaSignatureAlgorithms random
2920 HostKeyAlgorithms luigi,mario
2921 KexAlgorithms desu,gigi
2922 Macs concorde
2923 PubkeyAcceptedAlgorithms desu,omar-crypt,fast-omar-crypt
2924"##;
2925 tmpfile.write_all(config.as_bytes()).unwrap();
2926 tmpfile
2927 }
2928
2929 fn create_inverted_ssh_config() -> NamedTempFile {
2930 let mut tmpfile: tempfile::NamedTempFile =
2931 tempfile::NamedTempFile::new().expect("Failed to create tempfile");
2932 let config = r##"
2933Host *-host
2934 IdentityFile ~/.ssh/id_rsa_good
2935
2936Host remote-*
2937 HostName hostname.com
2938 User user
2939 IdentityFile ~/.ssh/id_rsa_bad
2940
2941Host *
2942 ConnectTimeout 15
2943 IdentityFile ~/.ssh/id_rsa_ugly
2944 "##;
2945 tmpfile.write_all(config.as_bytes()).unwrap();
2946 tmpfile
2947 }
2948
2949 fn create_ssh_config_with_comments() -> NamedTempFile {
2950 let mut tmpfile: tempfile::NamedTempFile =
2951 tempfile::NamedTempFile::new().expect("Failed to create tempfile");
2952 let config = r##"
2953Host cross-platform # this is my fav host
2954 HostName hostname.com
2955 User user
2956 IdentityFile ~/.ssh/id_rsa_good
2957
2958Host *
2959 AddKeysToAgent yes
2960 IdentityFile ~/.ssh/id_rsa_bad
2961 "##;
2962 tmpfile.write_all(config.as_bytes()).unwrap();
2963 tmpfile
2964 }
2965
2966 fn create_ssh_config_with_unknown_fields() -> NamedTempFile {
2967 let mut tmpfile: tempfile::NamedTempFile =
2968 tempfile::NamedTempFile::new().expect("Failed to create tempfile");
2969 let config = r##"
2970Host cross-platform # this is my fav host
2971 HostName hostname.com
2972 User user
2973 IdentityFile ~/.ssh/id_rsa_good
2974 Piropero yes
2975
2976Host *
2977 AddKeysToAgent yes
2978 IdentityFile ~/.ssh/id_rsa_bad
2979 "##;
2980 tmpfile.write_all(config.as_bytes()).unwrap();
2981 tmpfile
2982 }
2983
2984 #[test]
2985 fn test_should_parse_config_with_include() {
2986 crate::test_log();
2987
2988 let config = create_include_config();
2989 let file = File::open(config.config.path()).expect("Failed to open tempfile");
2990 let mut reader = BufReader::new(file);
2991
2992 let config = SshConfig::default()
2993 .default_algorithms(DefaultAlgorithms::empty())
2994 .parse(&mut reader, ParseRule::STRICT)
2995 .expect("Failed to parse config");
2996
2997 let default_params = config.query("unknown-host");
2998 assert_eq!(
3000 default_params.connect_timeout.unwrap(),
3001 Duration::from_secs(60) );
3003 assert_eq!(
3004 default_params.server_alive_interval.unwrap(),
3005 Duration::from_secs(40) );
3007 assert_eq!(default_params.tcp_keep_alive.unwrap(), true);
3008 assert_eq!(default_params.ciphers.algorithms().is_empty(), true);
3009 assert_eq!(
3010 default_params.ignore_unknown.as_deref().unwrap(),
3011 &["Pippo", "Pluto"]
3012 );
3013 assert_eq!(default_params.compression.unwrap(), true);
3014 assert_eq!(default_params.connection_attempts.unwrap(), 10);
3015 assert_eq!(default_params.port, Some(345));
3016
3017 let glob_params = config.query("192.168.1.1");
3019 assert_eq!(
3020 glob_params.connect_timeout.unwrap(),
3021 Duration::from_secs(60)
3022 );
3023 assert_eq!(
3024 glob_params.server_alive_interval.unwrap(),
3025 Duration::from_secs(40) );
3027 assert_eq!(glob_params.tcp_keep_alive.unwrap(), true);
3028 assert_eq!(glob_params.ciphers.algorithms().is_empty(), true);
3029
3030 let tostapane_params = config.query("tostapane");
3032 assert_eq!(
3033 tostapane_params.connect_timeout.unwrap(),
3034 Duration::from_secs(60) );
3036 assert_eq!(
3037 tostapane_params.server_alive_interval.unwrap(),
3038 Duration::from_secs(40) );
3040 assert_eq!(tostapane_params.tcp_keep_alive.unwrap(), true);
3041 assert_eq!(
3043 tostapane_params.ciphers.algorithms(),
3044 &[
3045 "a-manella",
3046 "blowfish",
3047 "coi-piedi",
3048 "cazdecan",
3049 "triestin-stretto"
3050 ]
3051 );
3052
3053 let microwave_params = config.query("microwave");
3055 assert_eq!(
3056 microwave_params.connect_timeout.unwrap(),
3057 Duration::from_secs(60) );
3059 assert_eq!(
3060 microwave_params.server_alive_interval.unwrap(),
3061 Duration::from_secs(40) );
3063 assert_eq!(
3064 microwave_params.port.unwrap(),
3065 345 );
3067 assert_eq!(microwave_params.tcp_keep_alive.unwrap(), true);
3068 assert_eq!(microwave_params.ciphers.algorithms().is_empty(), true);
3069 assert_eq!(microwave_params.user.as_deref().unwrap(), "mario-rossi");
3070 assert_eq!(
3071 microwave_params.host_name.as_deref().unwrap(),
3072 "192.168.24.33"
3073 );
3074 assert_eq!(
3075 microwave_params.remote_forward,
3076 vec![RemoteForward::new(RemoteForwardListen::Port(88), None)]
3077 );
3078 assert_eq!(microwave_params.compression.unwrap(), true);
3079
3080 let fridge_params = config.query("fridge");
3082 assert_eq!(
3083 fridge_params.connect_timeout.unwrap(),
3084 Duration::from_secs(60)
3085 ); assert_eq!(
3087 fridge_params.server_alive_interval.unwrap(),
3088 Duration::from_secs(40)
3089 ); assert_eq!(fridge_params.tcp_keep_alive.unwrap(), true);
3091 assert_eq!(fridge_params.ciphers.algorithms().is_empty(), true);
3092 assert_eq!(fridge_params.user.as_deref().unwrap(), "luigi-verdi");
3093 assert_eq!(fridge_params.host_name.as_deref().unwrap(), "192.168.24.34");
3094 }
3095
3096 #[test]
3097 fn should_restore_parent_scope_after_include() -> Result<(), SshParserError> {
3098 let mut included = NamedTempFile::new().expect("Failed to create included file");
3099 included.write_all(b"Host foo\n HostName foo.example\n")?;
3100
3101 let config = format!(
3102 "Include {included}\nUser alice\n",
3103 included = included.path().display(),
3104 );
3105 let mut reader = BufReader::new(config.as_bytes());
3106 let config = SshConfig::default()
3107 .default_algorithms(DefaultAlgorithms::empty())
3108 .parse(&mut reader, ParseRule::STRICT)?;
3109
3110 let default = config.query("github.com");
3111 assert_eq!(default.user.as_deref(), Some("alice"));
3112 assert!(default.host_name.is_none());
3113
3114 let foo = config.query("foo");
3115 assert_eq!(foo.user.as_deref(), Some("alice"));
3116 assert_eq!(foo.host_name.as_deref(), Some("foo.example"));
3117
3118 Ok(())
3119 }
3120
3121 #[test]
3122 fn should_keep_included_values_before_parent_fallbacks() -> Result<(), SshParserError> {
3123 let mut included = NamedTempFile::new().expect("Failed to create included file");
3124 included.write_all(b"Host foo\n User bob\n")?;
3125
3126 let config = format!(
3127 "Include {included}\nUser alice\n",
3128 included = included.path().display(),
3129 );
3130 let mut reader = BufReader::new(config.as_bytes());
3131 let config = SshConfig::default()
3132 .default_algorithms(DefaultAlgorithms::empty())
3133 .parse(&mut reader, ParseRule::STRICT)?;
3134
3135 assert_eq!(config.query("foo").user.as_deref(), Some("bob"));
3136 assert_eq!(config.query("github.com").user.as_deref(), Some("alice"));
3137
3138 Ok(())
3139 }
3140
3141 #[test]
3142 fn should_require_enclosing_and_included_host_scopes() -> Result<(), SshParserError> {
3143 let mut included = NamedTempFile::new().expect("Failed to create included file");
3144 included.write_all(b"Host app*\n HostName included.internal\n")?;
3145
3146 let config = format!(
3147 concat!(
3148 "Host *.example.com !app-blocked.example.com\n",
3149 " Include {included}\n",
3150 " Port 2222\n",
3151 "Host *\n",
3152 " User fallback\n",
3153 ),
3154 included = included.path().display(),
3155 );
3156 let mut reader = BufReader::new(config.as_bytes());
3157 let config = SshConfig::default()
3158 .default_algorithms(DefaultAlgorithms::empty())
3159 .parse(&mut reader, ParseRule::STRICT)?;
3160
3161 let allowed = config.query("app1.example.com");
3162 assert_eq!(allowed.host_name.as_deref(), Some("included.internal"));
3163 assert_eq!(allowed.port, Some(2222));
3164 assert_eq!(allowed.user.as_deref(), Some("fallback"));
3165
3166 let negated = config.query("app-blocked.example.com");
3167 assert!(negated.host_name.is_none());
3168 assert!(negated.port.is_none());
3169 assert_eq!(negated.user.as_deref(), Some("fallback"));
3170
3171 let outside = config.query("app1.example.net");
3172 assert!(outside.host_name.is_none());
3173 assert!(outside.port.is_none());
3174 assert_eq!(outside.user.as_deref(), Some("fallback"));
3175
3176 assert!(
3177 config
3178 .intersecting_hosts("app-blocked.example.com")
3179 .all(|host| host.params.host_name.as_deref() != Some("included.internal"))
3180 );
3181
3182 Ok(())
3183 }
3184
3185 #[test]
3186 fn should_restore_scope_between_consecutive_includes() -> Result<(), SshParserError> {
3187 let mut first = NamedTempFile::new().expect("Failed to create first include");
3188 first.write_all(b"Host bar\n HostName bar.internal\n")?;
3189 let mut second = NamedTempFile::new().expect("Failed to create second include");
3190 second.write_all(b"Port 2222\n")?;
3191
3192 let config = format!(
3193 concat!(
3194 "Host foo\n",
3195 " Include {first}\n",
3196 " Include {second}\n",
3197 " User alice\n",
3198 ),
3199 first = first.path().display(),
3200 second = second.path().display(),
3201 );
3202 let mut reader = BufReader::new(config.as_bytes());
3203 let config = SshConfig::default()
3204 .default_algorithms(DefaultAlgorithms::empty())
3205 .parse(&mut reader, ParseRule::STRICT)?;
3206
3207 let foo = config.query("foo");
3208 assert_eq!(foo.port, Some(2222));
3209 assert_eq!(foo.user.as_deref(), Some("alice"));
3210
3211 let bar = config.query("bar");
3212 assert!(bar.host_name.is_none());
3213 assert!(bar.port.is_none());
3214 assert!(bar.user.is_none());
3215
3216 Ok(())
3217 }
3218
3219 #[test]
3220 fn should_preserve_all_nested_include_scopes() -> Result<(), SshParserError> {
3221 let mut inner = NamedTempFile::new().expect("Failed to create inner include");
3222 inner.write_all(b"User nested\n")?;
3223
3224 let mut outer = NamedTempFile::new().expect("Failed to create outer include");
3225 writeln!(
3226 outer,
3227 "Host app*\n Include {inner}",
3228 inner = inner.path().display(),
3229 )?;
3230
3231 let config = format!(
3232 "Host *.example.com\n Include {outer}\n",
3233 outer = outer.path().display(),
3234 );
3235 let mut reader = BufReader::new(config.as_bytes());
3236 let config = SshConfig::default()
3237 .default_algorithms(DefaultAlgorithms::empty())
3238 .parse(&mut reader, ParseRule::STRICT)?;
3239
3240 assert_eq!(
3241 config.query("app1.example.com").user.as_deref(),
3242 Some("nested")
3243 );
3244 assert!(config.query("app1.example.net").user.is_none());
3245 assert!(config.query("other.example.com").user.is_none());
3246
3247 Ok(())
3248 }
3249
3250 #[test]
3251 fn should_include_every_path_argument() -> Result<(), SshParserError> {
3252 let mut first = NamedTempFile::new().expect("Failed to create first include");
3253 first.write_all(b"Host foo\n HostName foo.internal\n")?;
3254 let mut second = NamedTempFile::new().expect("Failed to create second include");
3255 second.write_all(b"Host bar\n HostName bar.internal\n")?;
3256
3257 let config = format!(
3258 "Include {first} {second}\n",
3259 first = first.path().display(),
3260 second = second.path().display(),
3261 );
3262 let mut reader = BufReader::new(config.as_bytes());
3263 let config = SshConfig::default()
3264 .default_algorithms(DefaultAlgorithms::empty())
3265 .parse(&mut reader, ParseRule::STRICT)?;
3266
3267 assert_eq!(
3268 config.query("foo").host_name.as_deref(),
3269 Some("foo.internal")
3270 );
3271 assert_eq!(
3272 config.query("bar").host_name.as_deref(),
3273 Some("bar.internal")
3274 );
3275
3276 Ok(())
3277 }
3278
3279 #[test]
3280 fn should_take_first_obtained_value_across_include_arguments() -> Result<(), SshParserError> {
3281 let mut first = NamedTempFile::new().expect("Failed to create first include");
3282 first.write_all(b"Host foo\n User first\n")?;
3283 let mut second = NamedTempFile::new().expect("Failed to create second include");
3284 second.write_all(b"Host foo\n User second\n Port 2222\n")?;
3285
3286 let config = format!(
3287 "Include {first} {second}\n",
3288 first = first.path().display(),
3289 second = second.path().display(),
3290 );
3291 let mut reader = BufReader::new(config.as_bytes());
3292 let config = SshConfig::default()
3293 .default_algorithms(DefaultAlgorithms::empty())
3294 .parse(&mut reader, ParseRule::STRICT)?;
3295
3296 assert_eq!(config.query("foo").user.as_deref(), Some("first"));
3297 assert_eq!(config.query("foo").port, Some(2222));
3298
3299 let config = format!(
3300 "Include {second} {first}\n",
3301 first = first.path().display(),
3302 second = second.path().display(),
3303 );
3304 let mut reader = BufReader::new(config.as_bytes());
3305 let config = SshConfig::default()
3306 .default_algorithms(DefaultAlgorithms::empty())
3307 .parse(&mut reader, ParseRule::STRICT)?;
3308
3309 assert_eq!(config.query("foo").user.as_deref(), Some("second"));
3310
3311 Ok(())
3312 }
3313
3314 #[test]
3315 fn should_expand_glob_in_each_include_argument() -> Result<(), SshParserError> {
3316 let conf_dir = tempfile::tempdir()?;
3317 std::fs::write(
3318 conf_dir.path().join("10-a.conf"),
3319 "Host foo\n User glob-a\n",
3320 )?;
3321 std::fs::write(
3322 conf_dir.path().join("20-b.conf"),
3323 "Host foo\n User glob-b\n HostName b.internal\n",
3324 )?;
3325 std::fs::write(
3326 conf_dir.path().join("other.txt"),
3327 "Host foo\n Port 9999\n",
3328 )?;
3329 let extra_dir = tempfile::tempdir()?;
3330 std::fs::write(
3331 extra_dir.path().join("x.conf"),
3332 "Host bar\n HostName bar.internal\n",
3333 )?;
3334
3335 let config = format!(
3336 "Include {conf}/*.conf {extra}/*.conf\n",
3337 conf = conf_dir.path().display(),
3338 extra = extra_dir.path().display(),
3339 );
3340 let mut reader = BufReader::new(config.as_bytes());
3341 let config = SshConfig::default()
3342 .default_algorithms(DefaultAlgorithms::empty())
3343 .parse(&mut reader, ParseRule::STRICT)?;
3344
3345 assert_eq!(config.query("foo").user.as_deref(), Some("glob-a"));
3346 assert_eq!(config.query("foo").host_name.as_deref(), Some("b.internal"));
3347 assert!(config.query("foo").port.is_none());
3348 assert_eq!(
3349 config.query("bar").host_name.as_deref(),
3350 Some("bar.internal")
3351 );
3352
3353 Ok(())
3354 }
3355
3356 #[test]
3357 fn should_skip_unmatched_include_arguments() -> Result<(), SshParserError> {
3358 let missing_dir = tempfile::tempdir()?;
3359 let missing = missing_dir.path().join("absent").join("*.conf");
3360 let mut present = NamedTempFile::new().expect("Failed to create include");
3361 present.write_all(b"Host foo\n HostName foo.internal\n")?;
3362
3363 let config = format!(
3364 "Include {missing} {present} {missing}\n",
3365 missing = missing.display(),
3366 present = present.path().display(),
3367 );
3368 let mut reader = BufReader::new(config.as_bytes());
3369 let config = SshConfig::default()
3370 .default_algorithms(DefaultAlgorithms::empty())
3371 .parse(&mut reader, ParseRule::STRICT)?;
3372
3373 assert_eq!(
3374 config.query("foo").host_name.as_deref(),
3375 Some("foo.internal")
3376 );
3377
3378 Ok(())
3379 }
3380
3381 #[test]
3382 fn should_scope_every_include_argument_to_enclosing_host() -> Result<(), SshParserError> {
3383 let mut first = NamedTempFile::new().expect("Failed to create first include");
3384 first.write_all(b"Host app*\n HostName first.internal\n")?;
3385 let mut second = NamedTempFile::new().expect("Failed to create second include");
3386 second.write_all(b"Port 2222\nHost db*\n HostName second.internal\n")?;
3387
3388 let config = format!(
3389 "Host *.example.com\n Include {first} {second}\n User alice\nHost *\n User fallback\n",
3390 first = first.path().display(),
3391 second = second.path().display(),
3392 );
3393 let mut reader = BufReader::new(config.as_bytes());
3394 let config = SshConfig::default()
3395 .default_algorithms(DefaultAlgorithms::empty())
3396 .parse(&mut reader, ParseRule::STRICT)?;
3397
3398 let app = config.query("app1.example.com");
3399 assert_eq!(app.host_name.as_deref(), Some("first.internal"));
3400 assert_eq!(app.port, Some(2222));
3401 assert_eq!(app.user.as_deref(), Some("alice"));
3402
3403 let db = config.query("db1.example.com");
3404 assert_eq!(db.host_name.as_deref(), Some("second.internal"));
3405 assert_eq!(db.port, Some(2222));
3406 assert_eq!(db.user.as_deref(), Some("alice"));
3407
3408 let outside = config.query("db1.example.net");
3409 assert!(outside.host_name.is_none());
3410 assert!(outside.port.is_none());
3411 assert_eq!(outside.user.as_deref(), Some("fallback"));
3412
3413 Ok(())
3414 }
3415
3416 #[allow(dead_code)]
3417 struct ConfigWithInclude {
3418 config: NamedTempFile,
3419 inc1: NamedTempFile,
3420 inc2: NamedTempFile,
3421 inc3: NamedTempFile,
3422 inc4: NamedTempFile,
3423 }
3424
3425 fn create_include_config() -> ConfigWithInclude {
3426 let mut config_file: tempfile::NamedTempFile =
3427 tempfile::NamedTempFile::new().expect("Failed to create tempfile");
3428 let mut inc1_file: tempfile::NamedTempFile =
3429 tempfile::NamedTempFile::new().expect("Failed to create tempfile");
3430 let mut inc2_file: tempfile::NamedTempFile =
3431 tempfile::NamedTempFile::new().expect("Failed to create tempfile");
3432 let mut inc3_file: tempfile::NamedTempFile =
3433 tempfile::NamedTempFile::new().expect("Failed to create tempfile");
3434 let mut inc4_file: tempfile::NamedTempFile =
3435 tempfile::NamedTempFile::new().expect("Failed to create tempfile");
3436
3437 let config = format!(
3438 r##"
3439# ssh config
3440# written by veeso
3441
3442
3443 # I put a comment here just to annoy
3444
3445IgnoreUnknown Pippo,Pluto
3446
3447Compression yes
3448ConnectionAttempts 10
3449ConnectTimeout 60
3450ServerAliveInterval 40
3451Include {inc1}
3452
3453# Let's start defining some hosts
3454
3455Host tostapane
3456 User ciro-esposito
3457 HostName 192.168.24.32
3458 RemoteForward 88
3459 Compression no
3460 # Ignore unknown fields should be inherited from the global section
3461 Pippo yes
3462 Pluto 56
3463 Include {inc2}
3464
3465Host *
3466Include {inc3}
3467Include {inc4}
3468"##,
3469 inc1 = inc1_file.path().display(),
3470 inc2 = inc2_file.path().display(),
3471 inc3 = inc3_file.path().display(),
3472 inc4 = inc4_file.path().display(),
3473 );
3474 config_file.write_all(config.as_bytes()).unwrap();
3475
3476 let inc1 = r##"
3478 ConnectTimeout 60
3479 ServerAliveInterval 60
3480 TcpKeepAlive yes
3481 "##;
3482 inc1_file.write_all(inc1.as_bytes()).unwrap();
3483
3484 let inc2 = r##"
3486 ConnectTimeout 180
3487 ServerAliveInterval 180
3488 Ciphers +a-manella,blowfish,coi-piedi,cazdecan,triestin-stretto
3489 "##;
3490 inc2_file.write_all(inc2.as_bytes()).unwrap();
3491
3492 let inc3 = r##"
3494Host microwave
3495 User mario-rossi
3496 HostName 192.168.24.33
3497 RemoteForward 88
3498 Compression no
3499 # Ignore unknown fields should be inherited from the global section
3500 Pippo yes
3501 Pluto 56
3502"##;
3503 inc3_file.write_all(inc3.as_bytes()).unwrap();
3504
3505 let inc4 = r##"
3507 # Update microwave
3508 ServerAliveInterval 30
3509 Port 345
3510
3511# Force microwave update (it won't work)
3512Host microwave
3513 ConnectTimeout 30
3514
3515Host fridge
3516 User luigi-verdi
3517 HostName 192.168.24.34
3518 RemoteForward 88
3519 Compression no
3520"##;
3521 inc4_file.write_all(inc4.as_bytes()).unwrap();
3522
3523 ConfigWithInclude {
3524 config: config_file,
3525 inc1: inc1_file,
3526 inc2: inc2_file,
3527 inc3: inc3_file,
3528 inc4: inc4_file,
3529 }
3530 }
3531}