1use std::path::PathBuf;
2
3fn provider_group_display_name(name: &str) -> &str {
6 match name {
7 "digitalocean" => "DigitalOcean",
8 "vultr" => "Vultr",
9 "linode" => "Linode",
10 "hetzner" => "Hetzner",
11 "upcloud" => "UpCloud",
12 "proxmox" => "Proxmox VE",
13 "aws" => "AWS EC2",
14 "scaleway" => "Scaleway",
15 "gcp" => "GCP",
16 "azure" => "Azure",
17 "tailscale" => "Tailscale",
18 "oracle" => "Oracle Cloud",
19 other => other,
20 }
21}
22
23#[derive(Debug, Clone)]
26pub struct SshConfigFile {
27 pub elements: Vec<ConfigElement>,
28 pub path: PathBuf,
29 pub crlf: bool,
31 pub bom: bool,
33}
34
35#[derive(Debug, Clone)]
37#[allow(dead_code)]
38pub struct IncludeDirective {
39 pub raw_line: String,
40 pub pattern: String,
41 pub resolved_files: Vec<IncludedFile>,
42}
43
44#[derive(Debug, Clone)]
46pub struct IncludedFile {
47 pub path: PathBuf,
48 pub elements: Vec<ConfigElement>,
49}
50
51#[derive(Debug, Clone)]
53pub enum ConfigElement {
54 HostBlock(HostBlock),
56 GlobalLine(String),
58 Include(IncludeDirective),
60}
61
62#[derive(Debug, Clone)]
64pub struct HostBlock {
65 pub host_pattern: String,
67 pub raw_host_line: String,
69 pub directives: Vec<Directive>,
71}
72
73#[derive(Debug, Clone)]
75pub struct Directive {
76 pub key: String,
78 pub value: String,
80 pub raw_line: String,
82 pub is_non_directive: bool,
84}
85
86#[derive(Debug, Clone)]
88pub struct HostEntry {
89 pub alias: String,
90 pub hostname: String,
91 pub user: String,
92 pub port: u16,
93 pub identity_file: String,
94 pub proxy_jump: String,
95 pub source_file: Option<PathBuf>,
97 pub tags: Vec<String>,
99 pub provider_tags: Vec<String>,
101 pub has_provider_tags: bool,
103 pub provider: Option<String>,
105 pub tunnel_count: u16,
107 pub askpass: Option<String>,
109 pub provider_meta: Vec<(String, String)>,
111 pub stale: Option<u64>,
113}
114
115impl Default for HostEntry {
116 fn default() -> Self {
117 Self {
118 alias: String::new(),
119 hostname: String::new(),
120 user: String::new(),
121 port: 22,
122 identity_file: String::new(),
123 proxy_jump: String::new(),
124 source_file: None,
125 tags: Vec::new(),
126 provider_tags: Vec::new(),
127 has_provider_tags: false,
128 provider: None,
129 tunnel_count: 0,
130 askpass: None,
131 provider_meta: Vec::new(),
132 stale: None,
133 }
134 }
135}
136
137impl HostEntry {
138 pub fn ssh_command(&self, config_path: &std::path::Path) -> String {
143 let escaped = self.alias.replace('\'', "'\\''");
144 let default = dirs::home_dir()
145 .map(|h| h.join(".ssh/config"))
146 .unwrap_or_default();
147 if config_path == default {
148 format!("ssh -- '{}'", escaped)
149 } else {
150 let config_escaped = config_path.display().to_string().replace('\'', "'\\''");
151 format!("ssh -F '{}' -- '{}'", config_escaped, escaped)
152 }
153 }
154}
155
156#[derive(Debug, Clone, Default)]
158pub struct PatternEntry {
159 pub pattern: String,
160 pub hostname: String,
161 pub user: String,
162 pub port: u16,
163 pub identity_file: String,
164 pub proxy_jump: String,
165 pub tags: Vec<String>,
166 pub askpass: Option<String>,
167 pub source_file: Option<PathBuf>,
168 pub directives: Vec<(String, String)>,
170}
171
172pub fn is_host_pattern(pattern: &str) -> bool {
176 pattern.contains('*')
177 || pattern.contains('?')
178 || pattern.contains('[')
179 || pattern.starts_with('!')
180 || pattern.contains(' ')
181 || pattern.contains('\t')
182}
183
184pub fn ssh_pattern_match(pattern: &str, text: &str) -> bool {
188 if let Some(rest) = pattern.strip_prefix('!') {
189 return !match_glob(rest, text);
190 }
191 match_glob(pattern, text)
192}
193
194fn match_glob(pattern: &str, text: &str) -> bool {
197 if text.is_empty() {
198 return pattern.is_empty();
199 }
200 if pattern.is_empty() {
201 return false;
202 }
203 let pat: Vec<char> = pattern.chars().collect();
204 let txt: Vec<char> = text.chars().collect();
205 glob_match(&pat, &txt)
206}
207
208fn glob_match(pat: &[char], txt: &[char]) -> bool {
210 let mut pi = 0;
211 let mut ti = 0;
212 let mut star: Option<(usize, usize)> = None; while ti < txt.len() {
215 if pi < pat.len() && pat[pi] == '?' {
216 pi += 1;
217 ti += 1;
218 } else if pi < pat.len() && pat[pi] == '*' {
219 star = Some((pi + 1, ti));
220 pi += 1;
221 } else if pi < pat.len() && pat[pi] == '[' {
222 if let Some((matches, end)) = match_char_class(pat, pi, txt[ti]) {
223 if matches {
224 pi = end;
225 ti += 1;
226 } else if let Some((spi, sti)) = star {
227 let sti = sti + 1;
228 star = Some((spi, sti));
229 pi = spi;
230 ti = sti;
231 } else {
232 return false;
233 }
234 } else if let Some((spi, sti)) = star {
235 let sti = sti + 1;
237 star = Some((spi, sti));
238 pi = spi;
239 ti = sti;
240 } else {
241 return false;
242 }
243 } else if pi < pat.len() && pat[pi] == txt[ti] {
244 pi += 1;
245 ti += 1;
246 } else if let Some((spi, sti)) = star {
247 let sti = sti + 1;
248 star = Some((spi, sti));
249 pi = spi;
250 ti = sti;
251 } else {
252 return false;
253 }
254 }
255
256 while pi < pat.len() && pat[pi] == '*' {
257 pi += 1;
258 }
259 pi == pat.len()
260}
261
262fn match_char_class(pat: &[char], start: usize, ch: char) -> Option<(bool, usize)> {
266 let mut i = start + 1;
267 if i >= pat.len() {
268 return None;
269 }
270
271 let negate = pat[i] == '!' || pat[i] == '^';
272 if negate {
273 i += 1;
274 }
275
276 let mut matched = false;
277 while i < pat.len() && pat[i] != ']' {
278 if i + 2 < pat.len() && pat[i + 1] == '-' && pat[i + 2] != ']' {
279 let lo = pat[i];
280 let hi = pat[i + 2];
281 if ch >= lo && ch <= hi {
282 matched = true;
283 }
284 i += 3;
285 } else {
286 matched |= pat[i] == ch;
287 i += 1;
288 }
289 }
290
291 if i >= pat.len() {
292 return None;
293 }
294
295 let result = if negate { !matched } else { matched };
296 Some((result, i + 1))
297}
298
299pub fn host_pattern_matches(host_pattern: &str, alias: &str) -> bool {
303 let patterns: Vec<&str> = host_pattern.split_whitespace().collect();
304 if patterns.is_empty() {
305 return false;
306 }
307
308 let mut any_positive_match = false;
309 for pat in &patterns {
310 if let Some(neg) = pat.strip_prefix('!') {
311 if match_glob(neg, alias) {
312 return false;
313 }
314 } else if ssh_pattern_match(pat, alias) {
315 any_positive_match = true;
316 }
317 }
318
319 any_positive_match
320}
321
322impl HostBlock {
323 fn content_end(&self) -> usize {
325 let mut pos = self.directives.len();
326 while pos > 0 {
327 if self.directives[pos - 1].is_non_directive
328 && self.directives[pos - 1].raw_line.trim().is_empty()
329 {
330 pos -= 1;
331 } else {
332 break;
333 }
334 }
335 pos
336 }
337
338 fn pop_trailing_blanks(&mut self) -> Vec<Directive> {
340 let end = self.content_end();
341 self.directives.drain(end..).collect()
342 }
343
344 fn ensure_trailing_blank(&mut self) {
346 self.pop_trailing_blanks();
347 self.directives.push(Directive {
348 key: String::new(),
349 value: String::new(),
350 raw_line: String::new(),
351 is_non_directive: true,
352 });
353 }
354
355 fn detect_indent(&self) -> String {
357 for d in &self.directives {
358 if !d.is_non_directive && !d.raw_line.is_empty() {
359 let trimmed = d.raw_line.trim_start();
360 let indent_len = d.raw_line.len() - trimmed.len();
361 if indent_len > 0 {
362 return d.raw_line[..indent_len].to_string();
363 }
364 }
365 }
366 " ".to_string()
367 }
368
369 pub fn tags(&self) -> Vec<String> {
371 for d in &self.directives {
372 if d.is_non_directive {
373 let trimmed = d.raw_line.trim();
374 if let Some(rest) = trimmed.strip_prefix("# purple:tags ") {
375 return rest
376 .split(',')
377 .map(|t| t.trim().to_string())
378 .filter(|t| !t.is_empty())
379 .collect();
380 }
381 }
382 }
383 Vec::new()
384 }
385
386 pub fn provider_tags(&self) -> Vec<String> {
388 for d in &self.directives {
389 if d.is_non_directive {
390 let trimmed = d.raw_line.trim();
391 if let Some(rest) = trimmed.strip_prefix("# purple:provider_tags ") {
392 return rest
393 .split(',')
394 .map(|t| t.trim().to_string())
395 .filter(|t| !t.is_empty())
396 .collect();
397 }
398 }
399 }
400 Vec::new()
401 }
402
403 pub fn has_provider_tags_comment(&self) -> bool {
406 self.directives.iter().any(|d| {
407 d.is_non_directive && {
408 let t = d.raw_line.trim();
409 t == "# purple:provider_tags" || t.starts_with("# purple:provider_tags ")
410 }
411 })
412 }
413
414 pub fn provider(&self) -> Option<(String, String)> {
417 for d in &self.directives {
418 if d.is_non_directive {
419 let trimmed = d.raw_line.trim();
420 if let Some(rest) = trimmed.strip_prefix("# purple:provider ") {
421 if let Some((name, id)) = rest.split_once(':') {
422 return Some((name.trim().to_string(), id.trim().to_string()));
423 }
424 }
425 }
426 }
427 None
428 }
429
430 pub fn set_provider(&mut self, provider_name: &str, server_id: &str) {
432 let indent = self.detect_indent();
433 self.directives.retain(|d| {
434 !(d.is_non_directive && d.raw_line.trim().starts_with("# purple:provider "))
435 });
436 let pos = self.content_end();
437 self.directives.insert(
438 pos,
439 Directive {
440 key: String::new(),
441 value: String::new(),
442 raw_line: format!(
443 "{}# purple:provider {}:{}",
444 indent, provider_name, server_id
445 ),
446 is_non_directive: true,
447 },
448 );
449 }
450
451 pub fn askpass(&self) -> Option<String> {
453 for d in &self.directives {
454 if d.is_non_directive {
455 let trimmed = d.raw_line.trim();
456 if let Some(rest) = trimmed.strip_prefix("# purple:askpass ") {
457 let val = rest.trim();
458 if !val.is_empty() {
459 return Some(val.to_string());
460 }
461 }
462 }
463 }
464 None
465 }
466
467 pub fn set_askpass(&mut self, source: &str) {
470 let indent = self.detect_indent();
471 self.directives.retain(|d| {
472 !(d.is_non_directive && {
473 let t = d.raw_line.trim();
474 t == "# purple:askpass" || t.starts_with("# purple:askpass ")
475 })
476 });
477 if !source.is_empty() {
478 let pos = self.content_end();
479 self.directives.insert(
480 pos,
481 Directive {
482 key: String::new(),
483 value: String::new(),
484 raw_line: format!("{}# purple:askpass {}", indent, source),
485 is_non_directive: true,
486 },
487 );
488 }
489 }
490
491 pub fn meta(&self) -> Vec<(String, String)> {
494 for d in &self.directives {
495 if d.is_non_directive {
496 let trimmed = d.raw_line.trim();
497 if let Some(rest) = trimmed.strip_prefix("# purple:meta ") {
498 return rest
499 .split(',')
500 .filter_map(|pair| {
501 let (k, v) = pair.split_once('=')?;
502 let k = k.trim();
503 let v = v.trim();
504 if k.is_empty() {
505 None
506 } else {
507 Some((k.to_string(), v.to_string()))
508 }
509 })
510 .collect();
511 }
512 }
513 }
514 Vec::new()
515 }
516
517 pub fn set_meta(&mut self, meta: &[(String, String)]) {
520 let indent = self.detect_indent();
521 self.directives.retain(|d| {
522 !(d.is_non_directive && {
523 let t = d.raw_line.trim();
524 t == "# purple:meta" || t.starts_with("# purple:meta ")
525 })
526 });
527 if !meta.is_empty() {
528 let encoded: Vec<String> = meta
529 .iter()
530 .map(|(k, v)| {
531 let clean_k = Self::sanitize_tag(&k.replace([',', '='], ""));
532 let clean_v = Self::sanitize_tag(&v.replace(',', ""));
533 format!("{}={}", clean_k, clean_v)
534 })
535 .collect();
536 let pos = self.content_end();
537 self.directives.insert(
538 pos,
539 Directive {
540 key: String::new(),
541 value: String::new(),
542 raw_line: format!("{}# purple:meta {}", indent, encoded.join(",")),
543 is_non_directive: true,
544 },
545 );
546 }
547 }
548
549 pub fn stale(&self) -> Option<u64> {
552 for d in &self.directives {
553 if d.is_non_directive {
554 let trimmed = d.raw_line.trim();
555 if let Some(rest) = trimmed.strip_prefix("# purple:stale ") {
556 return rest.trim().parse::<u64>().ok();
557 }
558 }
559 }
560 None
561 }
562
563 pub fn set_stale(&mut self, timestamp: u64) {
566 let indent = self.detect_indent();
567 self.clear_stale();
568 let pos = self.content_end();
569 self.directives.insert(
570 pos,
571 Directive {
572 key: String::new(),
573 value: String::new(),
574 raw_line: format!("{}# purple:stale {}", indent, timestamp),
575 is_non_directive: true,
576 },
577 );
578 }
579
580 pub fn clear_stale(&mut self) {
582 self.directives.retain(|d| {
583 !(d.is_non_directive && {
584 let t = d.raw_line.trim();
585 t == "# purple:stale" || t.starts_with("# purple:stale ")
586 })
587 });
588 }
589
590 fn sanitize_tag(tag: &str) -> String {
593 tag.chars()
594 .filter(|c| {
595 !c.is_control()
596 && *c != ','
597 && !('\u{200B}'..='\u{200F}').contains(c) && !('\u{202A}'..='\u{202E}').contains(c) && !('\u{2066}'..='\u{2069}').contains(c) && *c != '\u{FEFF}' })
602 .take(128)
603 .collect()
604 }
605
606 pub fn set_tags(&mut self, tags: &[String]) {
608 let indent = self.detect_indent();
609 self.directives.retain(|d| {
610 !(d.is_non_directive && {
611 let t = d.raw_line.trim();
612 t == "# purple:tags" || t.starts_with("# purple:tags ")
613 })
614 });
615 let sanitized: Vec<String> = tags
616 .iter()
617 .map(|t| Self::sanitize_tag(t))
618 .filter(|t| !t.is_empty())
619 .collect();
620 if !sanitized.is_empty() {
621 let pos = self.content_end();
622 self.directives.insert(
623 pos,
624 Directive {
625 key: String::new(),
626 value: String::new(),
627 raw_line: format!("{}# purple:tags {}", indent, sanitized.join(",")),
628 is_non_directive: true,
629 },
630 );
631 }
632 }
633
634 pub fn set_provider_tags(&mut self, tags: &[String]) {
637 let indent = self.detect_indent();
638 self.directives.retain(|d| {
639 !(d.is_non_directive && {
640 let t = d.raw_line.trim();
641 t == "# purple:provider_tags" || t.starts_with("# purple:provider_tags ")
642 })
643 });
644 let sanitized: Vec<String> = tags
645 .iter()
646 .map(|t| Self::sanitize_tag(t))
647 .filter(|t| !t.is_empty())
648 .collect();
649 let raw = if sanitized.is_empty() {
650 format!("{}# purple:provider_tags", indent)
651 } else {
652 format!("{}# purple:provider_tags {}", indent, sanitized.join(","))
653 };
654 let pos = self.content_end();
655 self.directives.insert(
656 pos,
657 Directive {
658 key: String::new(),
659 value: String::new(),
660 raw_line: raw,
661 is_non_directive: true,
662 },
663 );
664 }
665
666 pub fn to_host_entry(&self) -> HostEntry {
668 let mut entry = HostEntry {
669 alias: self.host_pattern.clone(),
670 port: 22,
671 ..Default::default()
672 };
673 for d in &self.directives {
674 if d.is_non_directive {
675 continue;
676 }
677 if d.key.eq_ignore_ascii_case("hostname") {
678 entry.hostname = d.value.clone();
679 } else if d.key.eq_ignore_ascii_case("user") {
680 entry.user = d.value.clone();
681 } else if d.key.eq_ignore_ascii_case("port") {
682 entry.port = d.value.parse().unwrap_or(22);
683 } else if d.key.eq_ignore_ascii_case("identityfile") {
684 if entry.identity_file.is_empty() {
685 entry.identity_file = d.value.clone();
686 }
687 } else if d.key.eq_ignore_ascii_case("proxyjump") {
688 entry.proxy_jump = d.value.clone();
689 }
690 }
691 entry.tags = self.tags();
692 entry.provider_tags = self.provider_tags();
693 entry.has_provider_tags = self.has_provider_tags_comment();
694 entry.provider = self.provider().map(|(name, _)| name);
695 entry.tunnel_count = self.tunnel_count();
696 entry.askpass = self.askpass();
697 entry.provider_meta = self.meta();
698 entry.stale = self.stale();
699 entry
700 }
701
702 pub fn to_pattern_entry(&self) -> PatternEntry {
704 let mut entry = PatternEntry {
705 pattern: self.host_pattern.clone(),
706 hostname: String::new(),
707 user: String::new(),
708 port: 22,
709 identity_file: String::new(),
710 proxy_jump: String::new(),
711 tags: self.tags(),
712 askpass: self.askpass(),
713 source_file: None,
714 directives: Vec::new(),
715 };
716 for d in &self.directives {
717 if d.is_non_directive {
718 continue;
719 }
720 match d.key.to_ascii_lowercase().as_str() {
721 "hostname" => entry.hostname = d.value.clone(),
722 "user" => entry.user = d.value.clone(),
723 "port" => entry.port = d.value.parse().unwrap_or(22),
724 "identityfile" => {
725 if entry.identity_file.is_empty() {
726 entry.identity_file = d.value.clone();
727 }
728 }
729 "proxyjump" => entry.proxy_jump = d.value.clone(),
730 _ => {}
731 }
732 entry.directives.push((d.key.clone(), d.value.clone()));
733 }
734 entry
735 }
736
737 pub fn tunnel_count(&self) -> u16 {
739 let count = self
740 .directives
741 .iter()
742 .filter(|d| {
743 !d.is_non_directive
744 && (d.key.eq_ignore_ascii_case("localforward")
745 || d.key.eq_ignore_ascii_case("remoteforward")
746 || d.key.eq_ignore_ascii_case("dynamicforward"))
747 })
748 .count();
749 count.min(u16::MAX as usize) as u16
750 }
751
752 #[allow(dead_code)]
754 pub fn has_tunnels(&self) -> bool {
755 self.directives.iter().any(|d| {
756 !d.is_non_directive
757 && (d.key.eq_ignore_ascii_case("localforward")
758 || d.key.eq_ignore_ascii_case("remoteforward")
759 || d.key.eq_ignore_ascii_case("dynamicforward"))
760 })
761 }
762
763 pub fn tunnel_directives(&self) -> Vec<crate::tunnel::TunnelRule> {
765 self.directives
766 .iter()
767 .filter(|d| !d.is_non_directive)
768 .filter_map(|d| crate::tunnel::TunnelRule::parse_value(&d.key, &d.value))
769 .collect()
770 }
771}
772
773impl SshConfigFile {
774 pub fn host_entries(&self) -> Vec<HostEntry> {
776 let mut entries = Vec::new();
777 Self::collect_host_entries(&self.elements, &mut entries);
778 entries
779 }
780
781 pub fn pattern_entries(&self) -> Vec<PatternEntry> {
783 let mut entries = Vec::new();
784 Self::collect_pattern_entries(&self.elements, &mut entries);
785 entries
786 }
787
788 fn collect_pattern_entries(elements: &[ConfigElement], entries: &mut Vec<PatternEntry>) {
789 for e in elements {
790 match e {
791 ConfigElement::HostBlock(block) => {
792 if !is_host_pattern(&block.host_pattern) {
793 continue;
794 }
795 entries.push(block.to_pattern_entry());
796 }
797 ConfigElement::Include(include) => {
798 for file in &include.resolved_files {
799 let start = entries.len();
800 Self::collect_pattern_entries(&file.elements, entries);
801 for entry in &mut entries[start..] {
802 if entry.source_file.is_none() {
803 entry.source_file = Some(file.path.clone());
804 }
805 }
806 }
807 }
808 ConfigElement::GlobalLine(_) => {}
809 }
810 }
811 }
812
813 pub fn matching_patterns(&self, alias: &str) -> Vec<PatternEntry> {
816 let mut matches = Vec::new();
817 Self::collect_matching_patterns(&self.elements, alias, &mut matches);
818 matches
819 }
820
821 fn collect_matching_patterns(
822 elements: &[ConfigElement],
823 alias: &str,
824 matches: &mut Vec<PatternEntry>,
825 ) {
826 for e in elements {
827 match e {
828 ConfigElement::HostBlock(block) => {
829 if !is_host_pattern(&block.host_pattern) {
830 continue;
831 }
832 if host_pattern_matches(&block.host_pattern, alias) {
833 matches.push(block.to_pattern_entry());
834 }
835 }
836 ConfigElement::Include(include) => {
837 for file in &include.resolved_files {
838 let start = matches.len();
839 Self::collect_matching_patterns(&file.elements, alias, matches);
840 for entry in &mut matches[start..] {
841 if entry.source_file.is_none() {
842 entry.source_file = Some(file.path.clone());
843 }
844 }
845 }
846 }
847 ConfigElement::GlobalLine(_) => {}
848 }
849 }
850 }
851
852 pub fn include_paths(&self) -> Vec<PathBuf> {
854 let mut paths = Vec::new();
855 Self::collect_include_paths(&self.elements, &mut paths);
856 paths
857 }
858
859 fn collect_include_paths(elements: &[ConfigElement], paths: &mut Vec<PathBuf>) {
860 for e in elements {
861 if let ConfigElement::Include(include) = e {
862 for file in &include.resolved_files {
863 paths.push(file.path.clone());
864 Self::collect_include_paths(&file.elements, paths);
865 }
866 }
867 }
868 }
869
870 pub fn include_glob_dirs(&self) -> Vec<PathBuf> {
873 let config_dir = self.path.parent();
874 let mut seen = std::collections::HashSet::new();
875 let mut dirs = Vec::new();
876 Self::collect_include_glob_dirs(&self.elements, config_dir, &mut seen, &mut dirs);
877 dirs
878 }
879
880 fn collect_include_glob_dirs(
881 elements: &[ConfigElement],
882 config_dir: Option<&std::path::Path>,
883 seen: &mut std::collections::HashSet<PathBuf>,
884 dirs: &mut Vec<PathBuf>,
885 ) {
886 for e in elements {
887 if let ConfigElement::Include(include) = e {
888 for single in Self::split_include_patterns(&include.pattern) {
890 let expanded = Self::expand_env_vars(&Self::expand_tilde(single));
891 let resolved = if expanded.starts_with('/') {
892 PathBuf::from(&expanded)
893 } else if let Some(dir) = config_dir {
894 dir.join(&expanded)
895 } else {
896 continue;
897 };
898 if let Some(parent) = resolved.parent() {
899 let parent = parent.to_path_buf();
900 if seen.insert(parent.clone()) {
901 dirs.push(parent);
902 }
903 }
904 }
905 for file in &include.resolved_files {
907 Self::collect_include_glob_dirs(&file.elements, file.path.parent(), seen, dirs);
908 }
909 }
910 }
911 }
912
913 pub fn remove_all_orphaned_group_headers(&mut self) -> usize {
916 let active_providers: std::collections::HashSet<String> = self
918 .elements
919 .iter()
920 .filter_map(|e| {
921 if let ConfigElement::HostBlock(block) = e {
922 block
923 .provider()
924 .map(|(name, _)| provider_group_display_name(&name).to_string())
925 } else {
926 None
927 }
928 })
929 .collect();
930
931 let mut removed = 0;
932 self.elements.retain(|e| {
933 if let ConfigElement::GlobalLine(line) = e {
934 if let Some(rest) = line.trim().strip_prefix("# purple:group ") {
935 if !active_providers.contains(rest.trim()) {
936 removed += 1;
937 return false;
938 }
939 }
940 }
941 true
942 });
943 removed
944 }
945
946 pub fn repair_absorbed_group_comments(&mut self) -> usize {
950 let mut repaired = 0;
951 let mut idx = 0;
952 while idx < self.elements.len() {
953 let needs_repair = if let ConfigElement::HostBlock(block) = &self.elements[idx] {
954 block
955 .directives
956 .iter()
957 .any(|d| d.is_non_directive && d.raw_line.trim().starts_with("# purple:group "))
958 } else {
959 false
960 };
961
962 if !needs_repair {
963 idx += 1;
964 continue;
965 }
966
967 let block = if let ConfigElement::HostBlock(block) = &mut self.elements[idx] {
969 block
970 } else {
971 unreachable!()
972 };
973
974 let group_idx = block
975 .directives
976 .iter()
977 .position(|d| {
978 d.is_non_directive && d.raw_line.trim().starts_with("# purple:group ")
979 })
980 .unwrap();
981
982 let mut keep_end = group_idx;
984 while keep_end > 0
985 && block.directives[keep_end - 1].is_non_directive
986 && block.directives[keep_end - 1].raw_line.trim().is_empty()
987 {
988 keep_end -= 1;
989 }
990
991 let extracted: Vec<ConfigElement> = block
993 .directives
994 .drain(keep_end..)
995 .map(|d| ConfigElement::GlobalLine(d.raw_line))
996 .collect();
997
998 let insert_at = idx + 1;
1000 for (i, elem) in extracted.into_iter().enumerate() {
1001 self.elements.insert(insert_at + i, elem);
1002 }
1003
1004 repaired += 1;
1005 idx = insert_at;
1007 while idx < self.elements.len() {
1009 if let ConfigElement::HostBlock(_) = &self.elements[idx] {
1010 break;
1011 }
1012 idx += 1;
1013 }
1014 }
1015 repaired
1016 }
1017
1018 fn collect_host_entries(elements: &[ConfigElement], entries: &mut Vec<HostEntry>) {
1020 for e in elements {
1021 match e {
1022 ConfigElement::HostBlock(block) => {
1023 if is_host_pattern(&block.host_pattern) {
1024 continue;
1025 }
1026 entries.push(block.to_host_entry());
1027 }
1028 ConfigElement::Include(include) => {
1029 for file in &include.resolved_files {
1030 let start = entries.len();
1031 Self::collect_host_entries(&file.elements, entries);
1032 for entry in &mut entries[start..] {
1033 if entry.source_file.is_none() {
1034 entry.source_file = Some(file.path.clone());
1035 }
1036 }
1037 }
1038 }
1039 ConfigElement::GlobalLine(_) => {}
1040 }
1041 }
1042 }
1043
1044 pub fn has_host(&self, alias: &str) -> bool {
1047 Self::has_host_in_elements(&self.elements, alias)
1048 }
1049
1050 fn has_host_in_elements(elements: &[ConfigElement], alias: &str) -> bool {
1051 for e in elements {
1052 match e {
1053 ConfigElement::HostBlock(block) => {
1054 if block.host_pattern.split_whitespace().any(|p| p == alias) {
1055 return true;
1056 }
1057 }
1058 ConfigElement::Include(include) => {
1059 for file in &include.resolved_files {
1060 if Self::has_host_in_elements(&file.elements, alias) {
1061 return true;
1062 }
1063 }
1064 }
1065 ConfigElement::GlobalLine(_) => {}
1066 }
1067 }
1068 false
1069 }
1070
1071 pub fn is_included_host(&self, alias: &str) -> bool {
1074 for e in &self.elements {
1076 match e {
1077 ConfigElement::HostBlock(block) => {
1078 if block.host_pattern.split_whitespace().any(|p| p == alias) {
1079 return false;
1080 }
1081 }
1082 ConfigElement::Include(include) => {
1083 for file in &include.resolved_files {
1084 if Self::has_host_in_elements(&file.elements, alias) {
1085 return true;
1086 }
1087 }
1088 }
1089 ConfigElement::GlobalLine(_) => {}
1090 }
1091 }
1092 false
1093 }
1094
1095 pub fn add_host(&mut self, entry: &HostEntry) {
1100 let block = Self::entry_to_block(entry);
1101 let insert_pos = self.find_trailing_pattern_start();
1102
1103 if let Some(pos) = insert_pos {
1104 let needs_blank_before = pos > 0
1106 && !matches!(
1107 self.elements.get(pos - 1),
1108 Some(ConfigElement::GlobalLine(line)) if line.trim().is_empty()
1109 );
1110 let mut idx = pos;
1111 if needs_blank_before {
1112 self.elements
1113 .insert(idx, ConfigElement::GlobalLine(String::new()));
1114 idx += 1;
1115 }
1116 self.elements.insert(idx, ConfigElement::HostBlock(block));
1117 let after = idx + 1;
1119 if after < self.elements.len()
1120 && !matches!(
1121 self.elements.get(after),
1122 Some(ConfigElement::GlobalLine(line)) if line.trim().is_empty()
1123 )
1124 {
1125 self.elements
1126 .insert(after, ConfigElement::GlobalLine(String::new()));
1127 }
1128 } else {
1129 if !self.elements.is_empty() && !self.last_element_has_trailing_blank() {
1131 self.elements.push(ConfigElement::GlobalLine(String::new()));
1132 }
1133 self.elements.push(ConfigElement::HostBlock(block));
1134 }
1135 }
1136
1137 fn find_trailing_pattern_start(&self) -> Option<usize> {
1142 let mut first_pattern_pos = None;
1143 for i in (0..self.elements.len()).rev() {
1144 match &self.elements[i] {
1145 ConfigElement::HostBlock(block) => {
1146 if is_host_pattern(&block.host_pattern) {
1147 first_pattern_pos = Some(i);
1148 } else {
1149 break;
1151 }
1152 }
1153 ConfigElement::GlobalLine(_) => {
1154 if first_pattern_pos.is_some() {
1156 first_pattern_pos = Some(i);
1157 }
1158 }
1159 ConfigElement::Include(_) => break,
1160 }
1161 }
1162 first_pattern_pos.filter(|&pos| pos > 0)
1164 }
1165
1166 pub fn last_element_has_trailing_blank(&self) -> bool {
1168 match self.elements.last() {
1169 Some(ConfigElement::HostBlock(block)) => block
1170 .directives
1171 .last()
1172 .is_some_and(|d| d.is_non_directive && d.raw_line.trim().is_empty()),
1173 Some(ConfigElement::GlobalLine(line)) => line.trim().is_empty(),
1174 _ => false,
1175 }
1176 }
1177
1178 pub fn update_host(&mut self, old_alias: &str, entry: &HostEntry) {
1181 for element in &mut self.elements {
1182 if let ConfigElement::HostBlock(block) = element {
1183 if block.host_pattern == old_alias {
1184 if entry.alias != block.host_pattern {
1186 block.host_pattern = entry.alias.clone();
1187 block.raw_host_line = format!("Host {}", entry.alias);
1188 }
1189
1190 Self::upsert_directive(block, "HostName", &entry.hostname);
1192 Self::upsert_directive(block, "User", &entry.user);
1193 if entry.port != 22 {
1194 Self::upsert_directive(block, "Port", &entry.port.to_string());
1195 } else {
1196 block
1198 .directives
1199 .retain(|d| d.is_non_directive || !d.key.eq_ignore_ascii_case("port"));
1200 }
1201 Self::upsert_directive(block, "IdentityFile", &entry.identity_file);
1202 Self::upsert_directive(block, "ProxyJump", &entry.proxy_jump);
1203 return;
1204 }
1205 }
1206 }
1207 }
1208
1209 fn upsert_directive(block: &mut HostBlock, key: &str, value: &str) {
1211 if value.is_empty() {
1212 block
1213 .directives
1214 .retain(|d| d.is_non_directive || !d.key.eq_ignore_ascii_case(key));
1215 return;
1216 }
1217 let indent = block.detect_indent();
1218 for d in &mut block.directives {
1219 if !d.is_non_directive && d.key.eq_ignore_ascii_case(key) {
1220 if d.value != value {
1222 d.value = value.to_string();
1223 let trimmed = d.raw_line.trim_start();
1229 let after_key = &trimmed[d.key.len()..];
1230 let sep = if after_key.trim_start().starts_with('=') {
1231 let eq_pos = after_key.find('=').unwrap();
1232 let after_eq = &after_key[eq_pos + 1..];
1233 let trailing_ws = after_eq.len() - after_eq.trim_start().len();
1234 after_key[..eq_pos + 1 + trailing_ws].to_string()
1235 } else {
1236 " ".to_string()
1237 };
1238 let comment_suffix = Self::extract_inline_comment(&d.raw_line, &d.key);
1240 d.raw_line = format!("{}{}{}{}{}", indent, d.key, sep, value, comment_suffix);
1241 }
1242 return;
1243 }
1244 }
1245 let pos = block.content_end();
1247 block.directives.insert(
1248 pos,
1249 Directive {
1250 key: key.to_string(),
1251 value: value.to_string(),
1252 raw_line: format!("{}{} {}", indent, key, value),
1253 is_non_directive: false,
1254 },
1255 );
1256 }
1257
1258 fn extract_inline_comment(raw_line: &str, key: &str) -> String {
1262 let trimmed = raw_line.trim_start();
1263 if trimmed.len() <= key.len() {
1264 return String::new();
1265 }
1266 let after_key = &trimmed[key.len()..];
1268 let rest = after_key.trim_start();
1269 let rest = rest.strip_prefix('=').unwrap_or(rest).trim_start();
1270 let bytes = rest.as_bytes();
1272 let mut in_quote = false;
1273 for i in 0..bytes.len() {
1274 if bytes[i] == b'"' {
1275 in_quote = !in_quote;
1276 } else if !in_quote
1277 && bytes[i] == b'#'
1278 && i > 0
1279 && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t')
1280 {
1281 let clean_end = rest[..i].trim_end().len();
1283 return rest[clean_end..].to_string();
1284 }
1285 }
1286 String::new()
1287 }
1288
1289 pub fn set_host_provider(&mut self, alias: &str, provider_name: &str, server_id: &str) {
1291 for element in &mut self.elements {
1292 if let ConfigElement::HostBlock(block) = element {
1293 if block.host_pattern == alias {
1294 block.set_provider(provider_name, server_id);
1295 return;
1296 }
1297 }
1298 }
1299 }
1300
1301 pub fn find_hosts_by_provider(&self, provider_name: &str) -> Vec<(String, String)> {
1305 let mut results = Vec::new();
1306 Self::collect_provider_hosts(&self.elements, provider_name, &mut results);
1307 results
1308 }
1309
1310 fn collect_provider_hosts(
1311 elements: &[ConfigElement],
1312 provider_name: &str,
1313 results: &mut Vec<(String, String)>,
1314 ) {
1315 for element in elements {
1316 match element {
1317 ConfigElement::HostBlock(block) => {
1318 if let Some((name, id)) = block.provider() {
1319 if name == provider_name {
1320 results.push((block.host_pattern.clone(), id));
1321 }
1322 }
1323 }
1324 ConfigElement::Include(include) => {
1325 for file in &include.resolved_files {
1326 Self::collect_provider_hosts(&file.elements, provider_name, results);
1327 }
1328 }
1329 ConfigElement::GlobalLine(_) => {}
1330 }
1331 }
1332 }
1333
1334 fn values_match(a: &str, b: &str) -> bool {
1337 a.split_whitespace().eq(b.split_whitespace())
1338 }
1339
1340 pub fn add_forward(&mut self, alias: &str, directive_key: &str, value: &str) {
1344 for element in &mut self.elements {
1345 if let ConfigElement::HostBlock(block) = element {
1346 if block.host_pattern.split_whitespace().any(|p| p == alias) {
1347 let indent = block.detect_indent();
1348 let pos = block.content_end();
1349 block.directives.insert(
1350 pos,
1351 Directive {
1352 key: directive_key.to_string(),
1353 value: value.to_string(),
1354 raw_line: format!("{}{} {}", indent, directive_key, value),
1355 is_non_directive: false,
1356 },
1357 );
1358 return;
1359 }
1360 }
1361 }
1362 }
1363
1364 pub fn remove_forward(&mut self, alias: &str, directive_key: &str, value: &str) -> bool {
1369 for element in &mut self.elements {
1370 if let ConfigElement::HostBlock(block) = element {
1371 if block.host_pattern.split_whitespace().any(|p| p == alias) {
1372 if let Some(pos) = block.directives.iter().position(|d| {
1373 !d.is_non_directive
1374 && d.key.eq_ignore_ascii_case(directive_key)
1375 && Self::values_match(&d.value, value)
1376 }) {
1377 block.directives.remove(pos);
1378 return true;
1379 }
1380 return false;
1381 }
1382 }
1383 }
1384 false
1385 }
1386
1387 pub fn has_forward(&self, alias: &str, directive_key: &str, value: &str) -> bool {
1390 for element in &self.elements {
1391 if let ConfigElement::HostBlock(block) = element {
1392 if block.host_pattern.split_whitespace().any(|p| p == alias) {
1393 return block.directives.iter().any(|d| {
1394 !d.is_non_directive
1395 && d.key.eq_ignore_ascii_case(directive_key)
1396 && Self::values_match(&d.value, value)
1397 });
1398 }
1399 }
1400 }
1401 false
1402 }
1403
1404 pub fn find_tunnel_directives(&self, alias: &str) -> Vec<crate::tunnel::TunnelRule> {
1408 Self::find_tunnel_directives_in(&self.elements, alias)
1409 }
1410
1411 fn find_tunnel_directives_in(
1412 elements: &[ConfigElement],
1413 alias: &str,
1414 ) -> Vec<crate::tunnel::TunnelRule> {
1415 for element in elements {
1416 match element {
1417 ConfigElement::HostBlock(block) => {
1418 if block.host_pattern.split_whitespace().any(|p| p == alias) {
1419 return block.tunnel_directives();
1420 }
1421 }
1422 ConfigElement::Include(include) => {
1423 for file in &include.resolved_files {
1424 let rules = Self::find_tunnel_directives_in(&file.elements, alias);
1425 if !rules.is_empty() {
1426 return rules;
1427 }
1428 }
1429 }
1430 ConfigElement::GlobalLine(_) => {}
1431 }
1432 }
1433 Vec::new()
1434 }
1435
1436 pub fn deduplicate_alias(&self, base: &str) -> String {
1438 self.deduplicate_alias_excluding(base, None)
1439 }
1440
1441 pub fn deduplicate_alias_excluding(&self, base: &str, exclude: Option<&str>) -> String {
1444 let is_taken = |alias: &str| {
1445 if exclude == Some(alias) {
1446 return false;
1447 }
1448 self.has_host(alias)
1449 };
1450 if !is_taken(base) {
1451 return base.to_string();
1452 }
1453 for n in 2..=9999 {
1454 let candidate = format!("{}-{}", base, n);
1455 if !is_taken(&candidate) {
1456 return candidate;
1457 }
1458 }
1459 format!("{}-{}", base, std::process::id())
1461 }
1462
1463 pub fn set_host_tags(&mut self, alias: &str, tags: &[String]) {
1465 for element in &mut self.elements {
1466 if let ConfigElement::HostBlock(block) = element {
1467 if block.host_pattern == alias {
1468 block.set_tags(tags);
1469 return;
1470 }
1471 }
1472 }
1473 }
1474
1475 pub fn set_host_provider_tags(&mut self, alias: &str, tags: &[String]) {
1477 for element in &mut self.elements {
1478 if let ConfigElement::HostBlock(block) = element {
1479 if block.host_pattern == alias {
1480 block.set_provider_tags(tags);
1481 return;
1482 }
1483 }
1484 }
1485 }
1486
1487 pub fn set_host_askpass(&mut self, alias: &str, source: &str) {
1489 for element in &mut self.elements {
1490 if let ConfigElement::HostBlock(block) = element {
1491 if block.host_pattern == alias {
1492 block.set_askpass(source);
1493 return;
1494 }
1495 }
1496 }
1497 }
1498
1499 pub fn set_host_meta(&mut self, alias: &str, meta: &[(String, String)]) {
1501 for element in &mut self.elements {
1502 if let ConfigElement::HostBlock(block) = element {
1503 if block.host_pattern == alias {
1504 block.set_meta(meta);
1505 return;
1506 }
1507 }
1508 }
1509 }
1510
1511 pub fn set_host_stale(&mut self, alias: &str, timestamp: u64) {
1513 for element in &mut self.elements {
1514 if let ConfigElement::HostBlock(block) = element {
1515 if block.host_pattern == alias {
1516 block.set_stale(timestamp);
1517 return;
1518 }
1519 }
1520 }
1521 }
1522
1523 pub fn clear_host_stale(&mut self, alias: &str) {
1525 for element in &mut self.elements {
1526 if let ConfigElement::HostBlock(block) = element {
1527 if block.host_pattern == alias {
1528 block.clear_stale();
1529 return;
1530 }
1531 }
1532 }
1533 }
1534
1535 pub fn stale_hosts(&self) -> Vec<(String, u64)> {
1537 let mut result = Vec::new();
1538 for element in &self.elements {
1539 if let ConfigElement::HostBlock(block) = element {
1540 if let Some(ts) = block.stale() {
1541 result.push((block.host_pattern.clone(), ts));
1542 }
1543 }
1544 }
1545 result
1546 }
1547
1548 #[allow(dead_code)]
1550 pub fn delete_host(&mut self, alias: &str) {
1551 let provider_name = self.elements.iter().find_map(|e| {
1554 if let ConfigElement::HostBlock(b) = e {
1555 if b.host_pattern == alias {
1556 return b.provider().map(|(name, _)| name);
1557 }
1558 }
1559 None
1560 });
1561
1562 self.elements.retain(|e| match e {
1563 ConfigElement::HostBlock(block) => block.host_pattern != alias,
1564 _ => true,
1565 });
1566
1567 if let Some(name) = provider_name {
1569 self.remove_orphaned_group_header(&name);
1570 }
1571
1572 self.elements.dedup_by(|a, b| {
1574 matches!(
1575 (&*a, &*b),
1576 (ConfigElement::GlobalLine(x), ConfigElement::GlobalLine(y))
1577 if x.trim().is_empty() && y.trim().is_empty()
1578 )
1579 });
1580 }
1581
1582 pub fn delete_host_undoable(&mut self, alias: &str) -> Option<(ConfigElement, usize)> {
1587 let pos = self
1588 .elements
1589 .iter()
1590 .position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias))?;
1591 let element = self.elements.remove(pos);
1592 Some((element, pos))
1593 }
1594
1595 #[allow(dead_code)]
1597 fn find_group_header_position(&self, provider_name: &str) -> Option<usize> {
1598 let display = provider_group_display_name(provider_name);
1599 let header = format!("# purple:group {}", display);
1600 self.elements
1601 .iter()
1602 .position(|e| matches!(e, ConfigElement::GlobalLine(line) if *line == header))
1603 }
1604
1605 fn remove_orphaned_group_header(&mut self, provider_name: &str) {
1608 if self.find_hosts_by_provider(provider_name).is_empty() {
1609 let display = provider_group_display_name(provider_name);
1610 let header = format!("# purple:group {}", display);
1611 self.elements
1612 .retain(|e| !matches!(e, ConfigElement::GlobalLine(line) if *line == header));
1613 }
1614 }
1615
1616 pub fn insert_host_at(&mut self, element: ConfigElement, position: usize) {
1618 let pos = position.min(self.elements.len());
1619 self.elements.insert(pos, element);
1620 }
1621
1622 pub fn find_provider_insert_position(&self, provider_name: &str) -> Option<usize> {
1626 let mut last_pos = None;
1627 for (i, element) in self.elements.iter().enumerate() {
1628 if let ConfigElement::HostBlock(block) = element {
1629 if let Some((name, _)) = block.provider() {
1630 if name == provider_name {
1631 last_pos = Some(i);
1632 }
1633 }
1634 }
1635 }
1636 last_pos.map(|p| p + 1)
1638 }
1639
1640 #[allow(dead_code)]
1642 pub fn swap_hosts(&mut self, alias_a: &str, alias_b: &str) -> bool {
1643 let pos_a = self
1644 .elements
1645 .iter()
1646 .position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_a));
1647 let pos_b = self
1648 .elements
1649 .iter()
1650 .position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_b));
1651 if let (Some(a), Some(b)) = (pos_a, pos_b) {
1652 if a == b {
1653 return false;
1654 }
1655 let (first, second) = (a.min(b), a.max(b));
1656
1657 if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
1659 block.pop_trailing_blanks();
1660 }
1661 if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
1662 block.pop_trailing_blanks();
1663 }
1664
1665 self.elements.swap(first, second);
1667
1668 if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
1670 block.ensure_trailing_blank();
1671 }
1672
1673 if second < self.elements.len() - 1 {
1675 if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
1676 block.ensure_trailing_blank();
1677 }
1678 }
1679
1680 return true;
1681 }
1682 false
1683 }
1684
1685 pub(crate) fn entry_to_block(entry: &HostEntry) -> HostBlock {
1687 let mut directives = Vec::new();
1688
1689 if !entry.hostname.is_empty() {
1690 directives.push(Directive {
1691 key: "HostName".to_string(),
1692 value: entry.hostname.clone(),
1693 raw_line: format!(" HostName {}", entry.hostname),
1694 is_non_directive: false,
1695 });
1696 }
1697 if !entry.user.is_empty() {
1698 directives.push(Directive {
1699 key: "User".to_string(),
1700 value: entry.user.clone(),
1701 raw_line: format!(" User {}", entry.user),
1702 is_non_directive: false,
1703 });
1704 }
1705 if entry.port != 22 {
1706 directives.push(Directive {
1707 key: "Port".to_string(),
1708 value: entry.port.to_string(),
1709 raw_line: format!(" Port {}", entry.port),
1710 is_non_directive: false,
1711 });
1712 }
1713 if !entry.identity_file.is_empty() {
1714 directives.push(Directive {
1715 key: "IdentityFile".to_string(),
1716 value: entry.identity_file.clone(),
1717 raw_line: format!(" IdentityFile {}", entry.identity_file),
1718 is_non_directive: false,
1719 });
1720 }
1721 if !entry.proxy_jump.is_empty() {
1722 directives.push(Directive {
1723 key: "ProxyJump".to_string(),
1724 value: entry.proxy_jump.clone(),
1725 raw_line: format!(" ProxyJump {}", entry.proxy_jump),
1726 is_non_directive: false,
1727 });
1728 }
1729
1730 HostBlock {
1731 host_pattern: entry.alias.clone(),
1732 raw_host_line: format!("Host {}", entry.alias),
1733 directives,
1734 }
1735 }
1736}
1737
1738#[cfg(test)]
1739mod tests {
1740 use super::*;
1741
1742 fn parse_str(content: &str) -> SshConfigFile {
1743 SshConfigFile {
1744 elements: SshConfigFile::parse_content(content),
1745 path: PathBuf::from("/tmp/test_config"),
1746 crlf: false,
1747 bom: false,
1748 }
1749 }
1750
1751 #[test]
1752 fn tunnel_directives_extracts_forwards() {
1753 let config = parse_str(
1754 "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n RemoteForward 9090 localhost:3000\n DynamicForward 1080\n",
1755 );
1756 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1757 let rules = block.tunnel_directives();
1758 assert_eq!(rules.len(), 3);
1759 assert_eq!(rules[0].tunnel_type, crate::tunnel::TunnelType::Local);
1760 assert_eq!(rules[0].bind_port, 8080);
1761 assert_eq!(rules[1].tunnel_type, crate::tunnel::TunnelType::Remote);
1762 assert_eq!(rules[2].tunnel_type, crate::tunnel::TunnelType::Dynamic);
1763 } else {
1764 panic!("Expected HostBlock");
1765 }
1766 }
1767
1768 #[test]
1769 fn tunnel_count_counts_forwards() {
1770 let config = parse_str(
1771 "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n RemoteForward 9090 localhost:3000\n",
1772 );
1773 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1774 assert_eq!(block.tunnel_count(), 2);
1775 } else {
1776 panic!("Expected HostBlock");
1777 }
1778 }
1779
1780 #[test]
1781 fn tunnel_count_zero_for_no_forwards() {
1782 let config = parse_str("Host myserver\n HostName 10.0.0.1\n User admin\n");
1783 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1784 assert_eq!(block.tunnel_count(), 0);
1785 assert!(!block.has_tunnels());
1786 } else {
1787 panic!("Expected HostBlock");
1788 }
1789 }
1790
1791 #[test]
1792 fn has_tunnels_true_with_forward() {
1793 let config = parse_str("Host myserver\n HostName 10.0.0.1\n DynamicForward 1080\n");
1794 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1795 assert!(block.has_tunnels());
1796 } else {
1797 panic!("Expected HostBlock");
1798 }
1799 }
1800
1801 #[test]
1802 fn add_forward_inserts_directive() {
1803 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n User admin\n");
1804 config.add_forward("myserver", "LocalForward", "8080 localhost:80");
1805 let output = config.serialize();
1806 assert!(output.contains("LocalForward 8080 localhost:80"));
1807 assert!(output.contains("HostName 10.0.0.1"));
1809 assert!(output.contains("User admin"));
1810 }
1811
1812 #[test]
1813 fn add_forward_preserves_indentation() {
1814 let mut config = parse_str("Host myserver\n\tHostName 10.0.0.1\n");
1815 config.add_forward("myserver", "LocalForward", "8080 localhost:80");
1816 let output = config.serialize();
1817 assert!(output.contains("\tLocalForward 8080 localhost:80"));
1818 }
1819
1820 #[test]
1821 fn add_multiple_forwards_same_type() {
1822 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
1823 config.add_forward("myserver", "LocalForward", "8080 localhost:80");
1824 config.add_forward("myserver", "LocalForward", "9090 localhost:90");
1825 let output = config.serialize();
1826 assert!(output.contains("LocalForward 8080 localhost:80"));
1827 assert!(output.contains("LocalForward 9090 localhost:90"));
1828 }
1829
1830 #[test]
1831 fn remove_forward_removes_exact_match() {
1832 let mut config = parse_str(
1833 "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n LocalForward 9090 localhost:90\n",
1834 );
1835 config.remove_forward("myserver", "LocalForward", "8080 localhost:80");
1836 let output = config.serialize();
1837 assert!(!output.contains("8080 localhost:80"));
1838 assert!(output.contains("9090 localhost:90"));
1839 }
1840
1841 #[test]
1842 fn remove_forward_leaves_other_directives() {
1843 let mut config = parse_str(
1844 "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n User admin\n",
1845 );
1846 config.remove_forward("myserver", "LocalForward", "8080 localhost:80");
1847 let output = config.serialize();
1848 assert!(!output.contains("LocalForward"));
1849 assert!(output.contains("HostName 10.0.0.1"));
1850 assert!(output.contains("User admin"));
1851 }
1852
1853 #[test]
1854 fn remove_forward_no_match_is_noop() {
1855 let original = "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n";
1856 let mut config = parse_str(original);
1857 config.remove_forward("myserver", "LocalForward", "9999 localhost:99");
1858 assert_eq!(config.serialize(), original);
1859 }
1860
1861 #[test]
1862 fn host_entry_tunnel_count_populated() {
1863 let config = parse_str(
1864 "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n DynamicForward 1080\n",
1865 );
1866 let entries = config.host_entries();
1867 assert_eq!(entries.len(), 1);
1868 assert_eq!(entries[0].tunnel_count, 2);
1869 }
1870
1871 #[test]
1872 fn remove_forward_returns_true_on_match() {
1873 let mut config =
1874 parse_str("Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n");
1875 assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
1876 }
1877
1878 #[test]
1879 fn remove_forward_returns_false_on_no_match() {
1880 let mut config =
1881 parse_str("Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n");
1882 assert!(!config.remove_forward("myserver", "LocalForward", "9999 localhost:99"));
1883 }
1884
1885 #[test]
1886 fn remove_forward_returns_false_for_unknown_host() {
1887 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
1888 assert!(!config.remove_forward("nohost", "LocalForward", "8080 localhost:80"));
1889 }
1890
1891 #[test]
1892 fn has_forward_finds_match() {
1893 let config =
1894 parse_str("Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n");
1895 assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1896 }
1897
1898 #[test]
1899 fn has_forward_no_match() {
1900 let config =
1901 parse_str("Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n");
1902 assert!(!config.has_forward("myserver", "LocalForward", "9999 localhost:99"));
1903 assert!(!config.has_forward("nohost", "LocalForward", "8080 localhost:80"));
1904 }
1905
1906 #[test]
1907 fn has_forward_case_insensitive_key() {
1908 let config =
1909 parse_str("Host myserver\n HostName 10.0.0.1\n localforward 8080 localhost:80\n");
1910 assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1911 }
1912
1913 #[test]
1914 fn add_forward_to_empty_block() {
1915 let mut config = parse_str("Host myserver\n");
1916 config.add_forward("myserver", "LocalForward", "8080 localhost:80");
1917 let output = config.serialize();
1918 assert!(output.contains("LocalForward 8080 localhost:80"));
1919 }
1920
1921 #[test]
1922 fn remove_forward_case_insensitive_key_match() {
1923 let mut config =
1924 parse_str("Host myserver\n HostName 10.0.0.1\n localforward 8080 localhost:80\n");
1925 assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
1926 assert!(!config.serialize().contains("localforward"));
1927 }
1928
1929 #[test]
1930 fn tunnel_count_case_insensitive() {
1931 let config = parse_str(
1932 "Host myserver\n localforward 8080 localhost:80\n REMOTEFORWARD 9090 localhost:90\n dynamicforward 1080\n",
1933 );
1934 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1935 assert_eq!(block.tunnel_count(), 3);
1936 } else {
1937 panic!("Expected HostBlock");
1938 }
1939 }
1940
1941 #[test]
1942 fn tunnel_directives_extracts_all_types() {
1943 let config = parse_str(
1944 "Host myserver\n LocalForward 8080 localhost:80\n RemoteForward 9090 localhost:3000\n DynamicForward 1080\n",
1945 );
1946 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1947 let rules = block.tunnel_directives();
1948 assert_eq!(rules.len(), 3);
1949 assert_eq!(rules[0].tunnel_type, crate::tunnel::TunnelType::Local);
1950 assert_eq!(rules[1].tunnel_type, crate::tunnel::TunnelType::Remote);
1951 assert_eq!(rules[2].tunnel_type, crate::tunnel::TunnelType::Dynamic);
1952 } else {
1953 panic!("Expected HostBlock");
1954 }
1955 }
1956
1957 #[test]
1958 fn tunnel_directives_skips_malformed() {
1959 let config = parse_str("Host myserver\n LocalForward not_valid\n DynamicForward 1080\n");
1960 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1961 let rules = block.tunnel_directives();
1962 assert_eq!(rules.len(), 1);
1963 assert_eq!(rules[0].bind_port, 1080);
1964 } else {
1965 panic!("Expected HostBlock");
1966 }
1967 }
1968
1969 #[test]
1970 fn find_tunnel_directives_multi_pattern_host() {
1971 let config =
1972 parse_str("Host prod staging\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n");
1973 let rules = config.find_tunnel_directives("prod");
1974 assert_eq!(rules.len(), 1);
1975 assert_eq!(rules[0].bind_port, 8080);
1976 let rules2 = config.find_tunnel_directives("staging");
1977 assert_eq!(rules2.len(), 1);
1978 }
1979
1980 #[test]
1981 fn find_tunnel_directives_no_match() {
1982 let config = parse_str("Host myserver\n LocalForward 8080 localhost:80\n");
1983 let rules = config.find_tunnel_directives("nohost");
1984 assert!(rules.is_empty());
1985 }
1986
1987 #[test]
1988 fn has_forward_exact_match() {
1989 let config = parse_str("Host myserver\n LocalForward 8080 localhost:80\n");
1990 assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1991 assert!(!config.has_forward("myserver", "LocalForward", "9090 localhost:80"));
1992 assert!(!config.has_forward("myserver", "RemoteForward", "8080 localhost:80"));
1993 assert!(!config.has_forward("nohost", "LocalForward", "8080 localhost:80"));
1994 }
1995
1996 #[test]
1997 fn has_forward_whitespace_normalized() {
1998 let config = parse_str("Host myserver\n LocalForward 8080 localhost:80\n");
1999 assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
2001 }
2002
2003 #[test]
2004 fn has_forward_multi_pattern_host() {
2005 let config = parse_str("Host prod staging\n LocalForward 8080 localhost:80\n");
2006 assert!(config.has_forward("prod", "LocalForward", "8080 localhost:80"));
2007 assert!(config.has_forward("staging", "LocalForward", "8080 localhost:80"));
2008 }
2009
2010 #[test]
2011 fn add_forward_multi_pattern_host() {
2012 let mut config = parse_str("Host prod staging\n HostName 10.0.0.1\n");
2013 config.add_forward("prod", "LocalForward", "8080 localhost:80");
2014 assert!(config.has_forward("prod", "LocalForward", "8080 localhost:80"));
2015 assert!(config.has_forward("staging", "LocalForward", "8080 localhost:80"));
2016 }
2017
2018 #[test]
2019 fn remove_forward_multi_pattern_host() {
2020 let mut config = parse_str(
2021 "Host prod staging\n LocalForward 8080 localhost:80\n LocalForward 9090 localhost:90\n",
2022 );
2023 assert!(config.remove_forward("staging", "LocalForward", "8080 localhost:80"));
2024 assert!(!config.has_forward("staging", "LocalForward", "8080 localhost:80"));
2025 assert!(config.has_forward("staging", "LocalForward", "9090 localhost:90"));
2027 }
2028
2029 #[test]
2030 fn edit_tunnel_detects_duplicate_after_remove() {
2031 let mut config = parse_str(
2033 "Host myserver\n LocalForward 8080 localhost:80\n LocalForward 9090 localhost:90\n",
2034 );
2035 assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
2037 assert!(config.has_forward("myserver", "LocalForward", "9090 localhost:90"));
2039 }
2040
2041 #[test]
2042 fn has_forward_tab_whitespace_normalized() {
2043 let config = parse_str("Host myserver\n LocalForward 8080\tlocalhost:80\n");
2044 assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
2046 }
2047
2048 #[test]
2049 fn remove_forward_tab_whitespace_normalized() {
2050 let mut config = parse_str("Host myserver\n LocalForward 8080\tlocalhost:80\n");
2051 assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
2053 assert!(!config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
2054 }
2055
2056 #[test]
2057 fn upsert_preserves_space_separator_when_value_contains_equals() {
2058 let mut config = parse_str("Host myserver\n IdentityFile ~/.ssh/id=prod\n");
2059 let entry = HostEntry {
2060 alias: "myserver".to_string(),
2061 hostname: "10.0.0.1".to_string(),
2062 identity_file: "~/.ssh/id=staging".to_string(),
2063 port: 22,
2064 ..Default::default()
2065 };
2066 config.update_host("myserver", &entry);
2067 let output = config.serialize();
2068 assert!(
2070 output.contains(" IdentityFile ~/.ssh/id=staging"),
2071 "got: {}",
2072 output
2073 );
2074 assert!(!output.contains("IdentityFile="), "got: {}", output);
2075 }
2076
2077 #[test]
2078 fn upsert_preserves_equals_separator() {
2079 let mut config = parse_str("Host myserver\n IdentityFile=~/.ssh/id_rsa\n");
2080 let entry = HostEntry {
2081 alias: "myserver".to_string(),
2082 hostname: "10.0.0.1".to_string(),
2083 identity_file: "~/.ssh/id_ed25519".to_string(),
2084 port: 22,
2085 ..Default::default()
2086 };
2087 config.update_host("myserver", &entry);
2088 let output = config.serialize();
2089 assert!(
2090 output.contains("IdentityFile=~/.ssh/id_ed25519"),
2091 "got: {}",
2092 output
2093 );
2094 }
2095
2096 #[test]
2097 fn upsert_preserves_spaced_equals_separator() {
2098 let mut config = parse_str("Host myserver\n IdentityFile = ~/.ssh/id_rsa\n");
2099 let entry = HostEntry {
2100 alias: "myserver".to_string(),
2101 hostname: "10.0.0.1".to_string(),
2102 identity_file: "~/.ssh/id_ed25519".to_string(),
2103 port: 22,
2104 ..Default::default()
2105 };
2106 config.update_host("myserver", &entry);
2107 let output = config.serialize();
2108 assert!(
2109 output.contains("IdentityFile = ~/.ssh/id_ed25519"),
2110 "got: {}",
2111 output
2112 );
2113 }
2114
2115 #[test]
2116 fn is_included_host_false_for_main_config() {
2117 let config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2118 assert!(!config.is_included_host("myserver"));
2119 }
2120
2121 #[test]
2122 fn is_included_host_false_for_nonexistent() {
2123 let config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2124 assert!(!config.is_included_host("nohost"));
2125 }
2126
2127 #[test]
2128 fn is_included_host_multi_pattern_main_config() {
2129 let config = parse_str("Host prod staging\n HostName 10.0.0.1\n");
2130 assert!(!config.is_included_host("prod"));
2131 assert!(!config.is_included_host("staging"));
2132 }
2133
2134 fn first_block(config: &SshConfigFile) -> &HostBlock {
2139 match config.elements.first().unwrap() {
2140 ConfigElement::HostBlock(b) => b,
2141 _ => panic!("Expected HostBlock"),
2142 }
2143 }
2144
2145 fn first_block_mut(config: &mut SshConfigFile) -> &mut HostBlock {
2146 match config.elements.first_mut().unwrap() {
2147 ConfigElement::HostBlock(b) => b,
2148 _ => panic!("Expected HostBlock"),
2149 }
2150 }
2151
2152 fn block_by_index(config: &SshConfigFile, idx: usize) -> &HostBlock {
2153 let mut count = 0;
2154 for el in &config.elements {
2155 if let ConfigElement::HostBlock(b) = el {
2156 if count == idx {
2157 return b;
2158 }
2159 count += 1;
2160 }
2161 }
2162 panic!("No HostBlock at index {}", idx);
2163 }
2164
2165 #[test]
2166 fn askpass_returns_none_when_absent() {
2167 let config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2168 assert_eq!(first_block(&config).askpass(), None);
2169 }
2170
2171 #[test]
2172 fn askpass_returns_keychain() {
2173 let config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass keychain\n");
2174 assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
2175 }
2176
2177 #[test]
2178 fn askpass_returns_op_uri() {
2179 let config = parse_str(
2180 "Host myserver\n HostName 10.0.0.1\n # purple:askpass op://Vault/Item/field\n",
2181 );
2182 assert_eq!(
2183 first_block(&config).askpass(),
2184 Some("op://Vault/Item/field".to_string())
2185 );
2186 }
2187
2188 #[test]
2189 fn askpass_returns_vault_with_field() {
2190 let config = parse_str(
2191 "Host myserver\n HostName 10.0.0.1\n # purple:askpass vault:secret/ssh#password\n",
2192 );
2193 assert_eq!(
2194 first_block(&config).askpass(),
2195 Some("vault:secret/ssh#password".to_string())
2196 );
2197 }
2198
2199 #[test]
2200 fn askpass_returns_bw_source() {
2201 let config =
2202 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass bw:my-item\n");
2203 assert_eq!(
2204 first_block(&config).askpass(),
2205 Some("bw:my-item".to_string())
2206 );
2207 }
2208
2209 #[test]
2210 fn askpass_returns_pass_source() {
2211 let config =
2212 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass pass:ssh/prod\n");
2213 assert_eq!(
2214 first_block(&config).askpass(),
2215 Some("pass:ssh/prod".to_string())
2216 );
2217 }
2218
2219 #[test]
2220 fn askpass_returns_custom_command() {
2221 let config =
2222 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass get-pass %a %h\n");
2223 assert_eq!(
2224 first_block(&config).askpass(),
2225 Some("get-pass %a %h".to_string())
2226 );
2227 }
2228
2229 #[test]
2230 fn askpass_ignores_empty_value() {
2231 let config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass \n");
2232 assert_eq!(first_block(&config).askpass(), None);
2233 }
2234
2235 #[test]
2236 fn askpass_ignores_non_askpass_purple_comments() {
2237 let config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:tags prod\n");
2238 assert_eq!(first_block(&config).askpass(), None);
2239 }
2240
2241 #[test]
2242 fn set_askpass_adds_comment() {
2243 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2244 config.set_host_askpass("myserver", "keychain");
2245 assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
2246 }
2247
2248 #[test]
2249 fn set_askpass_replaces_existing() {
2250 let mut config =
2251 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass keychain\n");
2252 config.set_host_askpass("myserver", "op://V/I/p");
2253 assert_eq!(
2254 first_block(&config).askpass(),
2255 Some("op://V/I/p".to_string())
2256 );
2257 }
2258
2259 #[test]
2260 fn set_askpass_empty_removes_comment() {
2261 let mut config =
2262 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass keychain\n");
2263 config.set_host_askpass("myserver", "");
2264 assert_eq!(first_block(&config).askpass(), None);
2265 }
2266
2267 #[test]
2268 fn set_askpass_preserves_other_directives() {
2269 let mut config =
2270 parse_str("Host myserver\n HostName 10.0.0.1\n User admin\n # purple:tags prod\n");
2271 config.set_host_askpass("myserver", "vault:secret/ssh");
2272 assert_eq!(
2273 first_block(&config).askpass(),
2274 Some("vault:secret/ssh".to_string())
2275 );
2276 let entry = first_block(&config).to_host_entry();
2277 assert_eq!(entry.user, "admin");
2278 assert!(entry.tags.contains(&"prod".to_string()));
2279 }
2280
2281 #[test]
2282 fn set_askpass_preserves_indent() {
2283 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2284 config.set_host_askpass("myserver", "keychain");
2285 let raw = first_block(&config)
2286 .directives
2287 .iter()
2288 .find(|d| d.raw_line.contains("purple:askpass"))
2289 .unwrap();
2290 assert!(
2291 raw.raw_line.starts_with(" "),
2292 "Expected 4-space indent, got: {:?}",
2293 raw.raw_line
2294 );
2295 }
2296
2297 #[test]
2298 fn set_askpass_on_nonexistent_host() {
2299 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2300 config.set_host_askpass("nohost", "keychain");
2301 assert_eq!(first_block(&config).askpass(), None);
2302 }
2303
2304 #[test]
2305 fn to_entry_includes_askpass() {
2306 let config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass bw:item\n");
2307 let entries = config.host_entries();
2308 assert_eq!(entries.len(), 1);
2309 assert_eq!(entries[0].askpass, Some("bw:item".to_string()));
2310 }
2311
2312 #[test]
2313 fn to_entry_askpass_none_when_absent() {
2314 let config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2315 let entries = config.host_entries();
2316 assert_eq!(entries.len(), 1);
2317 assert_eq!(entries[0].askpass, None);
2318 }
2319
2320 #[test]
2321 fn set_askpass_vault_with_hash_field() {
2322 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2323 config.set_host_askpass("myserver", "vault:secret/data/team#api_key");
2324 assert_eq!(
2325 first_block(&config).askpass(),
2326 Some("vault:secret/data/team#api_key".to_string())
2327 );
2328 }
2329
2330 #[test]
2331 fn set_askpass_custom_command_with_percent() {
2332 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2333 config.set_host_askpass("myserver", "get-pass %a %h");
2334 assert_eq!(
2335 first_block(&config).askpass(),
2336 Some("get-pass %a %h".to_string())
2337 );
2338 }
2339
2340 #[test]
2341 fn multiple_hosts_independent_askpass() {
2342 let mut config = parse_str("Host alpha\n HostName a.com\n\nHost beta\n HostName b.com\n");
2343 config.set_host_askpass("alpha", "keychain");
2344 config.set_host_askpass("beta", "vault:secret/ssh");
2345 assert_eq!(
2346 block_by_index(&config, 0).askpass(),
2347 Some("keychain".to_string())
2348 );
2349 assert_eq!(
2350 block_by_index(&config, 1).askpass(),
2351 Some("vault:secret/ssh".to_string())
2352 );
2353 }
2354
2355 #[test]
2356 fn set_askpass_then_clear_then_set_again() {
2357 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2358 config.set_host_askpass("myserver", "keychain");
2359 assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
2360 config.set_host_askpass("myserver", "");
2361 assert_eq!(first_block(&config).askpass(), None);
2362 config.set_host_askpass("myserver", "op://V/I/p");
2363 assert_eq!(
2364 first_block(&config).askpass(),
2365 Some("op://V/I/p".to_string())
2366 );
2367 }
2368
2369 #[test]
2370 fn askpass_tab_indent_preserved() {
2371 let mut config = parse_str("Host myserver\n\tHostName 10.0.0.1\n");
2372 config.set_host_askpass("myserver", "pass:ssh/prod");
2373 let raw = first_block(&config)
2374 .directives
2375 .iter()
2376 .find(|d| d.raw_line.contains("purple:askpass"))
2377 .unwrap();
2378 assert!(
2379 raw.raw_line.starts_with("\t"),
2380 "Expected tab indent, got: {:?}",
2381 raw.raw_line
2382 );
2383 }
2384
2385 #[test]
2386 fn askpass_coexists_with_provider_comment() {
2387 let config = parse_str(
2388 "Host myserver\n HostName 10.0.0.1\n # purple:provider do:123\n # purple:askpass keychain\n",
2389 );
2390 let block = first_block(&config);
2391 assert_eq!(block.askpass(), Some("keychain".to_string()));
2392 assert!(block.provider().is_some());
2393 }
2394
2395 #[test]
2396 fn set_askpass_does_not_remove_tags() {
2397 let mut config =
2398 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:tags prod,staging\n");
2399 config.set_host_askpass("myserver", "keychain");
2400 let entry = first_block(&config).to_host_entry();
2401 assert_eq!(entry.askpass, Some("keychain".to_string()));
2402 assert!(entry.tags.contains(&"prod".to_string()));
2403 assert!(entry.tags.contains(&"staging".to_string()));
2404 }
2405
2406 #[test]
2407 fn askpass_idempotent_set_same_value() {
2408 let mut config =
2409 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass keychain\n");
2410 config.set_host_askpass("myserver", "keychain");
2411 assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
2412 let serialized = config.serialize();
2413 assert_eq!(
2414 serialized.matches("purple:askpass").count(),
2415 1,
2416 "Should have exactly one askpass comment"
2417 );
2418 }
2419
2420 #[test]
2421 fn askpass_with_value_containing_equals() {
2422 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2423 config.set_host_askpass("myserver", "cmd --opt=val %h");
2424 assert_eq!(
2425 first_block(&config).askpass(),
2426 Some("cmd --opt=val %h".to_string())
2427 );
2428 }
2429
2430 #[test]
2431 fn askpass_with_value_containing_hash() {
2432 let config =
2433 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass vault:a/b#c\n");
2434 assert_eq!(
2435 first_block(&config).askpass(),
2436 Some("vault:a/b#c".to_string())
2437 );
2438 }
2439
2440 #[test]
2441 fn askpass_with_long_op_uri() {
2442 let uri = "op://My Personal Vault/SSH Production Server/password";
2443 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2444 config.set_host_askpass("myserver", uri);
2445 assert_eq!(first_block(&config).askpass(), Some(uri.to_string()));
2446 }
2447
2448 #[test]
2449 fn askpass_does_not_interfere_with_host_matching() {
2450 let config = parse_str(
2452 "Host myserver\n HostName 10.0.0.1\n User root\n # purple:askpass keychain\n",
2453 );
2454 let entry = first_block(&config).to_host_entry();
2455 assert_eq!(entry.user, "root");
2456 assert_eq!(entry.hostname, "10.0.0.1");
2457 assert_eq!(entry.askpass, Some("keychain".to_string()));
2458 }
2459
2460 #[test]
2461 fn set_askpass_on_host_with_many_directives() {
2462 let config_str = "\
2463Host myserver
2464 HostName 10.0.0.1
2465 User admin
2466 Port 2222
2467 IdentityFile ~/.ssh/id_ed25519
2468 ProxyJump bastion
2469 # purple:tags prod,us-east
2470";
2471 let mut config = parse_str(config_str);
2472 config.set_host_askpass("myserver", "pass:ssh/prod");
2473 let entry = first_block(&config).to_host_entry();
2474 assert_eq!(entry.askpass, Some("pass:ssh/prod".to_string()));
2475 assert_eq!(entry.user, "admin");
2476 assert_eq!(entry.port, 2222);
2477 assert!(entry.tags.contains(&"prod".to_string()));
2478 }
2479
2480 #[test]
2481 fn askpass_with_crlf_line_endings() {
2482 let config =
2483 parse_str("Host myserver\r\n HostName 10.0.0.1\r\n # purple:askpass keychain\r\n");
2484 assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
2485 }
2486
2487 #[test]
2488 fn askpass_only_on_first_matching_host() {
2489 let config = parse_str(
2491 "Host dup\n HostName a.com\n # purple:askpass keychain\n\nHost dup\n HostName b.com\n # purple:askpass vault:x\n",
2492 );
2493 let entries = config.host_entries();
2494 assert_eq!(entries[0].askpass, Some("keychain".to_string()));
2496 }
2497
2498 #[test]
2499 fn set_askpass_preserves_other_non_directive_comments() {
2500 let config_str = "Host myserver\n HostName 10.0.0.1\n # This is a user comment\n # purple:askpass old\n # Another comment\n";
2501 let mut config = parse_str(config_str);
2502 config.set_host_askpass("myserver", "new-source");
2503 let serialized = config.serialize();
2504 assert!(serialized.contains("# This is a user comment"));
2505 assert!(serialized.contains("# Another comment"));
2506 assert!(serialized.contains("# purple:askpass new-source"));
2507 assert!(!serialized.contains("# purple:askpass old"));
2508 }
2509
2510 #[test]
2511 fn askpass_mixed_with_tunnel_directives() {
2512 let config_str = "\
2513Host myserver
2514 HostName 10.0.0.1
2515 LocalForward 8080 localhost:80
2516 # purple:askpass bw:item
2517 RemoteForward 9090 localhost:9090
2518";
2519 let config = parse_str(config_str);
2520 let entry = first_block(&config).to_host_entry();
2521 assert_eq!(entry.askpass, Some("bw:item".to_string()));
2522 assert_eq!(entry.tunnel_count, 2);
2523 }
2524
2525 #[test]
2530 fn set_askpass_idempotent_same_value() {
2531 let config_str = "Host myserver\n HostName 10.0.0.1\n # purple:askpass keychain\n";
2532 let mut config = parse_str(config_str);
2533 config.set_host_askpass("myserver", "keychain");
2534 let output = config.serialize();
2535 assert_eq!(output.matches("purple:askpass").count(), 1);
2537 assert!(output.contains("# purple:askpass keychain"));
2538 }
2539
2540 #[test]
2541 fn set_askpass_with_equals_in_value() {
2542 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2543 config.set_host_askpass("myserver", "cmd --opt=val");
2544 let entries = config.host_entries();
2545 assert_eq!(entries[0].askpass, Some("cmd --opt=val".to_string()));
2546 }
2547
2548 #[test]
2549 fn set_askpass_with_hash_in_value() {
2550 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2551 config.set_host_askpass("myserver", "vault:secret/data#field");
2552 let entries = config.host_entries();
2553 assert_eq!(
2554 entries[0].askpass,
2555 Some("vault:secret/data#field".to_string())
2556 );
2557 }
2558
2559 #[test]
2560 fn set_askpass_long_op_uri() {
2561 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2562 let long_uri = "op://My Personal Vault/SSH Production Server Key/password";
2563 config.set_host_askpass("myserver", long_uri);
2564 assert_eq!(config.host_entries()[0].askpass, Some(long_uri.to_string()));
2565 }
2566
2567 #[test]
2568 fn askpass_host_with_multi_pattern_is_skipped() {
2569 let config_str = "Host prod staging\n HostName 10.0.0.1\n";
2572 let mut config = parse_str(config_str);
2573 config.set_host_askpass("prod", "keychain");
2574 assert!(config.host_entries().is_empty());
2576 }
2577
2578 #[test]
2579 fn askpass_survives_directive_reorder() {
2580 let config_str = "\
2582Host myserver
2583 # purple:askpass op://V/I/p
2584 HostName 10.0.0.1
2585 User root
2586";
2587 let config = parse_str(config_str);
2588 let entry = first_block(&config).to_host_entry();
2589 assert_eq!(entry.askpass, Some("op://V/I/p".to_string()));
2590 assert_eq!(entry.hostname, "10.0.0.1");
2591 }
2592
2593 #[test]
2594 fn askpass_among_many_purple_comments() {
2595 let config_str = "\
2596Host myserver
2597 HostName 10.0.0.1
2598 # purple:tags prod,us-east
2599 # purple:provider do:12345
2600 # purple:askpass pass:ssh/prod
2601";
2602 let config = parse_str(config_str);
2603 let entry = first_block(&config).to_host_entry();
2604 assert_eq!(entry.askpass, Some("pass:ssh/prod".to_string()));
2605 assert!(entry.tags.contains(&"prod".to_string()));
2606 }
2607
2608 #[test]
2609 fn meta_empty_when_no_comment() {
2610 let config_str = "Host myhost\n HostName 1.2.3.4\n";
2611 let config = parse_str(config_str);
2612 let meta = first_block(&config).meta();
2613 assert!(meta.is_empty());
2614 }
2615
2616 #[test]
2617 fn meta_parses_key_value_pairs() {
2618 let config_str = "\
2619Host myhost
2620 HostName 1.2.3.4
2621 # purple:meta region=nyc3,plan=s-1vcpu-1gb
2622";
2623 let config = parse_str(config_str);
2624 let meta = first_block(&config).meta();
2625 assert_eq!(meta.len(), 2);
2626 assert_eq!(meta[0], ("region".to_string(), "nyc3".to_string()));
2627 assert_eq!(meta[1], ("plan".to_string(), "s-1vcpu-1gb".to_string()));
2628 }
2629
2630 #[test]
2631 fn meta_round_trip() {
2632 let config_str = "Host myhost\n HostName 1.2.3.4\n";
2633 let mut config = parse_str(config_str);
2634 let meta = vec![
2635 ("region".to_string(), "fra1".to_string()),
2636 ("plan".to_string(), "cx11".to_string()),
2637 ];
2638 config.set_host_meta("myhost", &meta);
2639 let output = config.serialize();
2640 assert!(output.contains("# purple:meta region=fra1,plan=cx11"));
2641
2642 let config2 = parse_str(&output);
2643 let parsed = first_block(&config2).meta();
2644 assert_eq!(parsed, meta);
2645 }
2646
2647 #[test]
2648 fn meta_replaces_existing() {
2649 let config_str = "\
2650Host myhost
2651 HostName 1.2.3.4
2652 # purple:meta region=old
2653";
2654 let mut config = parse_str(config_str);
2655 config.set_host_meta("myhost", &[("region".to_string(), "new".to_string())]);
2656 let output = config.serialize();
2657 assert!(!output.contains("region=old"));
2658 assert!(output.contains("region=new"));
2659 }
2660
2661 #[test]
2662 fn meta_removed_when_empty() {
2663 let config_str = "\
2664Host myhost
2665 HostName 1.2.3.4
2666 # purple:meta region=nyc3
2667";
2668 let mut config = parse_str(config_str);
2669 config.set_host_meta("myhost", &[]);
2670 let output = config.serialize();
2671 assert!(!output.contains("purple:meta"));
2672 }
2673
2674 #[test]
2675 fn meta_sanitizes_commas_in_values() {
2676 let config_str = "Host myhost\n HostName 1.2.3.4\n";
2677 let mut config = parse_str(config_str);
2678 let meta = vec![("plan".to_string(), "s-1vcpu,1gb".to_string())];
2679 config.set_host_meta("myhost", &meta);
2680 let output = config.serialize();
2681 assert!(output.contains("plan=s-1vcpu1gb"));
2683
2684 let config2 = parse_str(&output);
2685 let parsed = first_block(&config2).meta();
2686 assert_eq!(parsed[0].1, "s-1vcpu1gb");
2687 }
2688
2689 #[test]
2690 fn meta_in_host_entry() {
2691 let config_str = "\
2692Host myhost
2693 HostName 1.2.3.4
2694 # purple:meta region=nyc3,plan=s-1vcpu-1gb
2695";
2696 let config = parse_str(config_str);
2697 let entry = first_block(&config).to_host_entry();
2698 assert_eq!(entry.provider_meta.len(), 2);
2699 assert_eq!(entry.provider_meta[0].0, "region");
2700 assert_eq!(entry.provider_meta[1].0, "plan");
2701 }
2702
2703 #[test]
2704 fn repair_absorbed_group_comment() {
2705 let mut config = SshConfigFile {
2707 elements: vec![ConfigElement::HostBlock(HostBlock {
2708 host_pattern: "myserver".to_string(),
2709 raw_host_line: "Host myserver".to_string(),
2710 directives: vec![
2711 Directive {
2712 key: "HostName".to_string(),
2713 value: "10.0.0.1".to_string(),
2714 raw_line: " HostName 10.0.0.1".to_string(),
2715 is_non_directive: false,
2716 },
2717 Directive {
2718 key: String::new(),
2719 value: String::new(),
2720 raw_line: "# purple:group Production".to_string(),
2721 is_non_directive: true,
2722 },
2723 ],
2724 })],
2725 path: PathBuf::from("/tmp/test_config"),
2726 crlf: false,
2727 bom: false,
2728 };
2729 let count = config.repair_absorbed_group_comments();
2730 assert_eq!(count, 1);
2731 assert_eq!(config.elements.len(), 2);
2732 if let ConfigElement::HostBlock(block) = &config.elements[0] {
2734 assert_eq!(block.directives.len(), 1);
2735 assert_eq!(block.directives[0].key, "HostName");
2736 } else {
2737 panic!("Expected HostBlock");
2738 }
2739 if let ConfigElement::GlobalLine(line) = &config.elements[1] {
2741 assert_eq!(line, "# purple:group Production");
2742 } else {
2743 panic!("Expected GlobalLine for group comment");
2744 }
2745 }
2746
2747 #[test]
2748 fn repair_strips_trailing_blanks_before_group() {
2749 let mut config = SshConfigFile {
2750 elements: vec![ConfigElement::HostBlock(HostBlock {
2751 host_pattern: "myserver".to_string(),
2752 raw_host_line: "Host myserver".to_string(),
2753 directives: vec![
2754 Directive {
2755 key: "HostName".to_string(),
2756 value: "10.0.0.1".to_string(),
2757 raw_line: " HostName 10.0.0.1".to_string(),
2758 is_non_directive: false,
2759 },
2760 Directive {
2761 key: String::new(),
2762 value: String::new(),
2763 raw_line: "".to_string(),
2764 is_non_directive: true,
2765 },
2766 Directive {
2767 key: String::new(),
2768 value: String::new(),
2769 raw_line: "# purple:group Staging".to_string(),
2770 is_non_directive: true,
2771 },
2772 ],
2773 })],
2774 path: PathBuf::from("/tmp/test_config"),
2775 crlf: false,
2776 bom: false,
2777 };
2778 let count = config.repair_absorbed_group_comments();
2779 assert_eq!(count, 1);
2780 if let ConfigElement::HostBlock(block) = &config.elements[0] {
2782 assert_eq!(block.directives.len(), 1);
2783 } else {
2784 panic!("Expected HostBlock");
2785 }
2786 assert_eq!(config.elements.len(), 3);
2788 if let ConfigElement::GlobalLine(line) = &config.elements[1] {
2789 assert!(line.trim().is_empty());
2790 } else {
2791 panic!("Expected blank GlobalLine");
2792 }
2793 if let ConfigElement::GlobalLine(line) = &config.elements[2] {
2794 assert!(line.starts_with("# purple:group"));
2795 } else {
2796 panic!("Expected group GlobalLine");
2797 }
2798 }
2799
2800 #[test]
2801 fn repair_clean_config_returns_zero() {
2802 let mut config =
2803 parse_str("# purple:group Production\nHost myserver\n HostName 10.0.0.1\n");
2804 let count = config.repair_absorbed_group_comments();
2805 assert_eq!(count, 0);
2806 }
2807
2808 #[test]
2809 fn repair_roundtrip_serializes_correctly() {
2810 let mut config = SshConfigFile {
2812 elements: vec![
2813 ConfigElement::HostBlock(HostBlock {
2814 host_pattern: "server1".to_string(),
2815 raw_host_line: "Host server1".to_string(),
2816 directives: vec![
2817 Directive {
2818 key: "HostName".to_string(),
2819 value: "10.0.0.1".to_string(),
2820 raw_line: " HostName 10.0.0.1".to_string(),
2821 is_non_directive: false,
2822 },
2823 Directive {
2824 key: String::new(),
2825 value: String::new(),
2826 raw_line: "".to_string(),
2827 is_non_directive: true,
2828 },
2829 Directive {
2830 key: String::new(),
2831 value: String::new(),
2832 raw_line: "# purple:group Staging".to_string(),
2833 is_non_directive: true,
2834 },
2835 ],
2836 }),
2837 ConfigElement::HostBlock(HostBlock {
2838 host_pattern: "server2".to_string(),
2839 raw_host_line: "Host server2".to_string(),
2840 directives: vec![Directive {
2841 key: "HostName".to_string(),
2842 value: "10.0.0.2".to_string(),
2843 raw_line: " HostName 10.0.0.2".to_string(),
2844 is_non_directive: false,
2845 }],
2846 }),
2847 ],
2848 path: PathBuf::from("/tmp/test_config"),
2849 crlf: false,
2850 bom: false,
2851 };
2852 let count = config.repair_absorbed_group_comments();
2853 assert_eq!(count, 1);
2854 let output = config.serialize();
2855 let expected = "\
2857Host server1
2858 HostName 10.0.0.1
2859
2860# purple:group Staging
2861Host server2
2862 HostName 10.0.0.2
2863";
2864 assert_eq!(output, expected);
2865 }
2866
2867 #[test]
2872 fn delete_last_provider_host_removes_group_header() {
2873 let config_str = "\
2874# purple:group DigitalOcean
2875Host do-web
2876 HostName 1.2.3.4
2877 # purple:provider digitalocean:123
2878";
2879 let mut config = parse_str(config_str);
2880 config.delete_host("do-web");
2881 let has_header = config
2882 .elements
2883 .iter()
2884 .any(|e| matches!(e, ConfigElement::GlobalLine(l) if l.contains("purple:group")));
2885 assert!(
2886 !has_header,
2887 "Group header should be removed when last provider host is deleted"
2888 );
2889 }
2890
2891 #[test]
2892 fn delete_one_of_multiple_provider_hosts_preserves_group_header() {
2893 let config_str = "\
2894# purple:group DigitalOcean
2895Host do-web
2896 HostName 1.2.3.4
2897 # purple:provider digitalocean:123
2898
2899Host do-db
2900 HostName 5.6.7.8
2901 # purple:provider digitalocean:456
2902";
2903 let mut config = parse_str(config_str);
2904 config.delete_host("do-web");
2905 let has_header = config.elements.iter().any(|e| {
2906 matches!(e, ConfigElement::GlobalLine(l) if l.contains("purple:group DigitalOcean"))
2907 });
2908 assert!(
2909 has_header,
2910 "Group header should be preserved when other provider hosts remain"
2911 );
2912 assert_eq!(config.host_entries().len(), 1);
2913 }
2914
2915 #[test]
2916 fn delete_non_provider_host_leaves_group_headers() {
2917 let config_str = "\
2918Host personal
2919 HostName 10.0.0.1
2920
2921# purple:group DigitalOcean
2922Host do-web
2923 HostName 1.2.3.4
2924 # purple:provider digitalocean:123
2925";
2926 let mut config = parse_str(config_str);
2927 config.delete_host("personal");
2928 let has_header = config.elements.iter().any(|e| {
2929 matches!(e, ConfigElement::GlobalLine(l) if l.contains("purple:group DigitalOcean"))
2930 });
2931 assert!(
2932 has_header,
2933 "Group header should not be affected by deleting a non-provider host"
2934 );
2935 assert_eq!(config.host_entries().len(), 1);
2936 }
2937
2938 #[test]
2939 fn delete_host_undoable_keeps_group_header_for_undo() {
2940 let config_str = "\
2944# purple:group Vultr
2945Host vultr-web
2946 HostName 2.3.4.5
2947 # purple:provider vultr:789
2948";
2949 let mut config = parse_str(config_str);
2950 let result = config.delete_host_undoable("vultr-web");
2951 assert!(result.is_some());
2952 let has_header = config
2953 .elements
2954 .iter()
2955 .any(|e| matches!(e, ConfigElement::GlobalLine(l) if l.contains("purple:group")));
2956 assert!(has_header, "Group header should be kept for undo");
2957 }
2958
2959 #[test]
2960 fn delete_host_undoable_preserves_header_when_others_remain() {
2961 let config_str = "\
2962# purple:group AWS EC2
2963Host aws-web
2964 HostName 3.4.5.6
2965 # purple:provider aws:i-111
2966
2967Host aws-db
2968 HostName 7.8.9.0
2969 # purple:provider aws:i-222
2970";
2971 let mut config = parse_str(config_str);
2972 let result = config.delete_host_undoable("aws-web");
2973 assert!(result.is_some());
2974 let has_header = config.elements.iter().any(
2975 |e| matches!(e, ConfigElement::GlobalLine(l) if l.contains("purple:group AWS EC2")),
2976 );
2977 assert!(
2978 has_header,
2979 "Group header preserved when other provider hosts remain (undoable)"
2980 );
2981 }
2982
2983 #[test]
2984 fn delete_host_undoable_returns_original_position_for_undo() {
2985 let config_str = "\
2988# purple:group Vultr
2989Host vultr-web
2990 HostName 2.3.4.5
2991 # purple:provider vultr:789
2992
2993Host manual
2994 HostName 10.0.0.1
2995";
2996 let mut config = parse_str(config_str);
2997 let (element, pos) = config.delete_host_undoable("vultr-web").unwrap();
2998 assert_eq!(pos, 1, "Position should be the original host index");
3000 config.insert_host_at(element, pos);
3002 let output = config.serialize();
3004 assert!(
3005 output.contains("# purple:group Vultr"),
3006 "Group header should be present"
3007 );
3008 assert!(output.contains("Host vultr-web"), "Host should be restored");
3009 assert!(output.contains("Host manual"), "Manual host should survive");
3010 assert_eq!(config_str, output);
3011 }
3012
3013 #[test]
3018 fn add_host_inserts_before_trailing_wildcard() {
3019 let config_str = "\
3020Host existing
3021 HostName 10.0.0.1
3022
3023Host *
3024 ServerAliveInterval 60
3025";
3026 let mut config = parse_str(config_str);
3027 let entry = HostEntry {
3028 alias: "newhost".to_string(),
3029 hostname: "10.0.0.2".to_string(),
3030 port: 22,
3031 ..Default::default()
3032 };
3033 config.add_host(&entry);
3034 let output = config.serialize();
3035 let new_pos = output.find("Host newhost").unwrap();
3036 let wildcard_pos = output.find("Host *").unwrap();
3037 assert!(
3038 new_pos < wildcard_pos,
3039 "New host should appear before Host *: {}",
3040 output
3041 );
3042 let existing_pos = output.find("Host existing").unwrap();
3043 assert!(existing_pos < new_pos);
3044 }
3045
3046 #[test]
3047 fn add_host_appends_when_no_wildcards() {
3048 let config_str = "\
3049Host existing
3050 HostName 10.0.0.1
3051";
3052 let mut config = parse_str(config_str);
3053 let entry = HostEntry {
3054 alias: "newhost".to_string(),
3055 hostname: "10.0.0.2".to_string(),
3056 port: 22,
3057 ..Default::default()
3058 };
3059 config.add_host(&entry);
3060 let output = config.serialize();
3061 let existing_pos = output.find("Host existing").unwrap();
3062 let new_pos = output.find("Host newhost").unwrap();
3063 assert!(existing_pos < new_pos, "New host should be appended at end");
3064 }
3065
3066 #[test]
3067 fn add_host_appends_when_wildcard_at_beginning() {
3068 let config_str = "\
3070Host *
3071 ServerAliveInterval 60
3072
3073Host existing
3074 HostName 10.0.0.1
3075";
3076 let mut config = parse_str(config_str);
3077 let entry = HostEntry {
3078 alias: "newhost".to_string(),
3079 hostname: "10.0.0.2".to_string(),
3080 port: 22,
3081 ..Default::default()
3082 };
3083 config.add_host(&entry);
3084 let output = config.serialize();
3085 let existing_pos = output.find("Host existing").unwrap();
3086 let new_pos = output.find("Host newhost").unwrap();
3087 assert!(
3088 existing_pos < new_pos,
3089 "New host should be appended at end when wildcard is at top: {}",
3090 output
3091 );
3092 }
3093
3094 #[test]
3095 fn add_host_inserts_before_trailing_pattern_host() {
3096 let config_str = "\
3097Host existing
3098 HostName 10.0.0.1
3099
3100Host *.example.com
3101 ProxyJump bastion
3102";
3103 let mut config = parse_str(config_str);
3104 let entry = HostEntry {
3105 alias: "newhost".to_string(),
3106 hostname: "10.0.0.2".to_string(),
3107 port: 22,
3108 ..Default::default()
3109 };
3110 config.add_host(&entry);
3111 let output = config.serialize();
3112 let new_pos = output.find("Host newhost").unwrap();
3113 let pattern_pos = output.find("Host *.example.com").unwrap();
3114 assert!(
3115 new_pos < pattern_pos,
3116 "New host should appear before pattern host: {}",
3117 output
3118 );
3119 }
3120
3121 #[test]
3122 fn add_host_no_triple_blank_lines() {
3123 let config_str = "\
3124Host existing
3125 HostName 10.0.0.1
3126
3127Host *
3128 ServerAliveInterval 60
3129";
3130 let mut config = parse_str(config_str);
3131 let entry = HostEntry {
3132 alias: "newhost".to_string(),
3133 hostname: "10.0.0.2".to_string(),
3134 port: 22,
3135 ..Default::default()
3136 };
3137 config.add_host(&entry);
3138 let output = config.serialize();
3139 assert!(
3140 !output.contains("\n\n\n"),
3141 "Should not have triple blank lines: {}",
3142 output
3143 );
3144 }
3145
3146 #[test]
3147 fn provider_group_display_name_matches_providers_mod() {
3148 let providers = [
3153 "digitalocean",
3154 "vultr",
3155 "linode",
3156 "hetzner",
3157 "upcloud",
3158 "proxmox",
3159 "aws",
3160 "scaleway",
3161 "gcp",
3162 "azure",
3163 "tailscale",
3164 "oracle",
3165 ];
3166 for name in &providers {
3167 assert_eq!(
3168 provider_group_display_name(name),
3169 crate::providers::provider_display_name(name),
3170 "Display name mismatch for provider '{}': model.rs has '{}' but providers/mod.rs has '{}'",
3171 name,
3172 provider_group_display_name(name),
3173 crate::providers::provider_display_name(name),
3174 );
3175 }
3176 }
3177
3178 #[test]
3179 fn test_sanitize_tag_strips_control_chars() {
3180 assert_eq!(HostBlock::sanitize_tag("prod"), "prod");
3181 assert_eq!(HostBlock::sanitize_tag("prod\n"), "prod");
3182 assert_eq!(HostBlock::sanitize_tag("pr\x00od"), "prod");
3183 assert_eq!(HostBlock::sanitize_tag("\t\r\n"), "");
3184 }
3185
3186 #[test]
3187 fn test_sanitize_tag_strips_commas() {
3188 assert_eq!(HostBlock::sanitize_tag("prod,staging"), "prodstaging");
3189 assert_eq!(HostBlock::sanitize_tag(",,,"), "");
3190 }
3191
3192 #[test]
3193 fn test_sanitize_tag_strips_bidi() {
3194 assert_eq!(HostBlock::sanitize_tag("prod\u{202E}tset"), "prodtset");
3195 assert_eq!(HostBlock::sanitize_tag("\u{200B}zero\u{FEFF}"), "zero");
3196 }
3197
3198 #[test]
3199 fn test_sanitize_tag_truncates_long() {
3200 let long = "a".repeat(200);
3201 assert_eq!(HostBlock::sanitize_tag(&long).len(), 128);
3202 }
3203
3204 #[test]
3205 fn test_sanitize_tag_preserves_unicode() {
3206 assert_eq!(HostBlock::sanitize_tag("日本語"), "日本語");
3207 assert_eq!(HostBlock::sanitize_tag("café"), "café");
3208 }
3209
3210 #[test]
3215 fn test_provider_tags_parsing() {
3216 let config =
3217 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:provider_tags a,b,c\n");
3218 let entry = first_block(&config).to_host_entry();
3219 assert_eq!(entry.provider_tags, vec!["a", "b", "c"]);
3220 }
3221
3222 #[test]
3223 fn test_provider_tags_empty() {
3224 let config = parse_str("Host myserver\n HostName 10.0.0.1\n");
3225 let entry = first_block(&config).to_host_entry();
3226 assert!(entry.provider_tags.is_empty());
3227 }
3228
3229 #[test]
3230 fn test_has_provider_tags_comment_present() {
3231 let config =
3232 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:provider_tags prod\n");
3233 assert!(first_block(&config).has_provider_tags_comment());
3234 assert!(first_block(&config).to_host_entry().has_provider_tags);
3235 }
3236
3237 #[test]
3238 fn test_has_provider_tags_comment_sentinel() {
3239 let config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:provider_tags\n");
3241 assert!(first_block(&config).has_provider_tags_comment());
3242 assert!(first_block(&config).to_host_entry().has_provider_tags);
3243 assert!(
3244 first_block(&config)
3245 .to_host_entry()
3246 .provider_tags
3247 .is_empty()
3248 );
3249 }
3250
3251 #[test]
3252 fn test_has_provider_tags_comment_absent() {
3253 let config = parse_str("Host myserver\n HostName 10.0.0.1\n");
3254 assert!(!first_block(&config).has_provider_tags_comment());
3255 assert!(!first_block(&config).to_host_entry().has_provider_tags);
3256 }
3257
3258 #[test]
3259 fn test_set_tags_does_not_delete_provider_tags() {
3260 let mut config = parse_str(
3261 "Host myserver\n HostName 10.0.0.1\n # purple:tags user1\n # purple:provider_tags cloud1,cloud2\n",
3262 );
3263 config.set_host_tags("myserver", &["newuser".to_string()]);
3264 let entry = first_block(&config).to_host_entry();
3265 assert_eq!(entry.tags, vec!["newuser"]);
3266 assert_eq!(entry.provider_tags, vec!["cloud1", "cloud2"]);
3267 }
3268
3269 #[test]
3270 fn test_set_provider_tags_does_not_delete_user_tags() {
3271 let mut config = parse_str(
3272 "Host myserver\n HostName 10.0.0.1\n # purple:tags user1,user2\n # purple:provider_tags old\n",
3273 );
3274 config.set_host_provider_tags("myserver", &["new1".to_string(), "new2".to_string()]);
3275 let entry = first_block(&config).to_host_entry();
3276 assert_eq!(entry.tags, vec!["user1", "user2"]);
3277 assert_eq!(entry.provider_tags, vec!["new1", "new2"]);
3278 }
3279
3280 #[test]
3281 fn test_set_askpass_does_not_delete_similar_comments() {
3282 let mut config = parse_str(
3284 "Host myserver\n HostName 10.0.0.1\n # purple:askpass keychain\n # purple:askpass_backup test\n",
3285 );
3286 config.set_host_askpass("myserver", "op://vault/item/pass");
3287 let entry = first_block(&config).to_host_entry();
3288 assert_eq!(entry.askpass, Some("op://vault/item/pass".to_string()));
3289 let serialized = config.serialize();
3291 assert!(serialized.contains("purple:askpass_backup test"));
3292 }
3293
3294 #[test]
3295 fn test_set_meta_does_not_delete_similar_comments() {
3296 let mut config = parse_str(
3298 "Host myserver\n HostName 10.0.0.1\n # purple:meta region=us-east\n # purple:metadata foo\n",
3299 );
3300 config.set_host_meta("myserver", &[("region".to_string(), "eu-west".to_string())]);
3301 let serialized = config.serialize();
3302 assert!(serialized.contains("purple:meta region=eu-west"));
3303 assert!(serialized.contains("purple:metadata foo"));
3304 }
3305
3306 #[test]
3307 fn test_set_meta_sanitizes_control_chars() {
3308 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
3309 config.set_host_meta(
3310 "myserver",
3311 &[
3312 ("region".to_string(), "us\x00east".to_string()),
3313 ("zone".to_string(), "a\u{202E}b".to_string()),
3314 ],
3315 );
3316 let serialized = config.serialize();
3317 assert!(serialized.contains("region=useast"));
3319 assert!(serialized.contains("zone=ab"));
3320 assert!(!serialized.contains('\x00'));
3321 assert!(!serialized.contains('\u{202E}'));
3322 }
3323
3324 #[test]
3327 fn stale_returns_timestamp() {
3328 let config_str = "\
3329Host web
3330 HostName 1.2.3.4
3331 # purple:stale 1711900000
3332";
3333 let config = parse_str(config_str);
3334 assert_eq!(first_block(&config).stale(), Some(1711900000));
3335 }
3336
3337 #[test]
3338 fn stale_returns_none_when_absent() {
3339 let config_str = "Host web\n HostName 1.2.3.4\n";
3340 let config = parse_str(config_str);
3341 assert_eq!(first_block(&config).stale(), None);
3342 }
3343
3344 #[test]
3345 fn stale_returns_none_for_malformed() {
3346 for bad in &[
3347 "Host w\n HostName 1.2.3.4\n # purple:stale abc\n",
3348 "Host w\n HostName 1.2.3.4\n # purple:stale\n",
3349 "Host w\n HostName 1.2.3.4\n # purple:stale -1\n",
3350 ] {
3351 let config = parse_str(bad);
3352 assert_eq!(first_block(&config).stale(), None, "input: {bad}");
3353 }
3354 }
3355
3356 #[test]
3357 fn set_stale_adds_comment() {
3358 let config_str = "Host web\n HostName 1.2.3.4\n";
3359 let mut config = parse_str(config_str);
3360 first_block_mut(&mut config).set_stale(1711900000);
3361 assert_eq!(first_block(&config).stale(), Some(1711900000));
3362 assert!(config.serialize().contains("# purple:stale 1711900000"));
3363 }
3364
3365 #[test]
3366 fn set_stale_replaces_existing() {
3367 let config_str = "\
3368Host web
3369 HostName 1.2.3.4
3370 # purple:stale 1000
3371";
3372 let mut config = parse_str(config_str);
3373 first_block_mut(&mut config).set_stale(2000);
3374 assert_eq!(first_block(&config).stale(), Some(2000));
3375 let output = config.serialize();
3376 assert!(!output.contains("1000"));
3377 assert!(output.contains("# purple:stale 2000"));
3378 }
3379
3380 #[test]
3381 fn clear_stale_removes_comment() {
3382 let config_str = "\
3383Host web
3384 HostName 1.2.3.4
3385 # purple:stale 1711900000
3386";
3387 let mut config = parse_str(config_str);
3388 first_block_mut(&mut config).clear_stale();
3389 assert_eq!(first_block(&config).stale(), None);
3390 assert!(!config.serialize().contains("purple:stale"));
3391 }
3392
3393 #[test]
3394 fn clear_stale_when_absent_is_noop() {
3395 let config_str = "Host web\n HostName 1.2.3.4\n";
3396 let mut config = parse_str(config_str);
3397 let before = config.serialize();
3398 first_block_mut(&mut config).clear_stale();
3399 assert_eq!(config.serialize(), before);
3400 }
3401
3402 #[test]
3403 fn stale_roundtrip() {
3404 let config_str = "\
3405Host web
3406 HostName 1.2.3.4
3407 # purple:stale 1711900000
3408";
3409 let config = parse_str(config_str);
3410 let output = config.serialize();
3411 let config2 = parse_str(&output);
3412 assert_eq!(first_block(&config2).stale(), Some(1711900000));
3413 }
3414
3415 #[test]
3416 fn stale_in_host_entry() {
3417 let config_str = "\
3418Host web
3419 HostName 1.2.3.4
3420 # purple:stale 1711900000
3421";
3422 let config = parse_str(config_str);
3423 let entry = first_block(&config).to_host_entry();
3424 assert_eq!(entry.stale, Some(1711900000));
3425 }
3426
3427 #[test]
3428 fn stale_coexists_with_other_annotations() {
3429 let config_str = "\
3430Host web
3431 HostName 1.2.3.4
3432 # purple:tags prod
3433 # purple:provider do:12345
3434 # purple:askpass keychain
3435 # purple:meta region=nyc3
3436 # purple:stale 1711900000
3437";
3438 let config = parse_str(config_str);
3439 let entry = first_block(&config).to_host_entry();
3440 assert_eq!(entry.stale, Some(1711900000));
3441 assert!(entry.tags.contains(&"prod".to_string()));
3442 assert_eq!(entry.provider, Some("do".to_string()));
3443 assert_eq!(entry.askpass, Some("keychain".to_string()));
3444 assert_eq!(entry.provider_meta[0].0, "region");
3445 }
3446
3447 #[test]
3448 fn set_host_stale_delegates() {
3449 let config_str = "\
3450Host web
3451 HostName 1.2.3.4
3452
3453Host db
3454 HostName 5.6.7.8
3455";
3456 let mut config = parse_str(config_str);
3457 config.set_host_stale("db", 1234567890);
3458 assert_eq!(config.host_entries()[1].stale, Some(1234567890));
3459 assert_eq!(config.host_entries()[0].stale, None);
3460 }
3461
3462 #[test]
3463 fn clear_host_stale_delegates() {
3464 let config_str = "\
3465Host web
3466 HostName 1.2.3.4
3467 # purple:stale 1711900000
3468";
3469 let mut config = parse_str(config_str);
3470 config.clear_host_stale("web");
3471 assert_eq!(first_block(&config).stale(), None);
3472 }
3473
3474 #[test]
3475 fn stale_hosts_collects_all() {
3476 let config_str = "\
3477Host web
3478 HostName 1.2.3.4
3479 # purple:stale 1000
3480
3481Host db
3482 HostName 5.6.7.8
3483
3484Host app
3485 HostName 9.10.11.12
3486 # purple:stale 2000
3487";
3488 let config = parse_str(config_str);
3489 let stale = config.stale_hosts();
3490 assert_eq!(stale.len(), 2);
3491 assert_eq!(stale[0], ("web".to_string(), 1000));
3492 assert_eq!(stale[1], ("app".to_string(), 2000));
3493 }
3494
3495 #[test]
3496 fn set_stale_preserves_indent() {
3497 let config_str = "Host web\n\tHostName 1.2.3.4\n";
3498 let mut config = parse_str(config_str);
3499 first_block_mut(&mut config).set_stale(1711900000);
3500 assert!(config.serialize().contains("\t# purple:stale 1711900000"));
3501 }
3502
3503 #[test]
3504 fn stale_does_not_match_similar_comments() {
3505 let config_str = "\
3506Host web
3507 HostName 1.2.3.4
3508 # purple:stale_backup 999
3509";
3510 let config = parse_str(config_str);
3511 assert_eq!(first_block(&config).stale(), None);
3512 }
3513
3514 #[test]
3515 fn stale_with_whitespace_in_timestamp() {
3516 let config_str = "Host w\n HostName 1.2.3.4\n # purple:stale 1711900000 \n";
3517 let config = parse_str(config_str);
3518 assert_eq!(first_block(&config).stale(), Some(1711900000));
3519 }
3520
3521 #[test]
3522 fn stale_with_u64_max() {
3523 let ts = u64::MAX;
3524 let config_str = format!("Host w\n HostName 1.2.3.4\n # purple:stale {}\n", ts);
3525 let config = parse_str(&config_str);
3526 assert_eq!(first_block(&config).stale(), Some(ts));
3527 let output = config.serialize();
3529 let config2 = parse_str(&output);
3530 assert_eq!(first_block(&config2).stale(), Some(ts));
3531 }
3532
3533 #[test]
3534 fn stale_with_u64_overflow() {
3535 let config_str = "Host w\n HostName 1.2.3.4\n # purple:stale 18446744073709551616\n";
3536 let config = parse_str(config_str);
3537 assert_eq!(first_block(&config).stale(), None);
3538 }
3539
3540 #[test]
3541 fn stale_timestamp_zero() {
3542 let config_str = "Host w\n HostName 1.2.3.4\n # purple:stale 0\n";
3543 let config = parse_str(config_str);
3544 assert_eq!(first_block(&config).stale(), Some(0));
3545 }
3546
3547 #[test]
3548 fn set_host_stale_nonexistent_alias_is_noop() {
3549 let config_str = "Host web\n HostName 1.2.3.4\n";
3550 let mut config = parse_str(config_str);
3551 let before = config.serialize();
3552 config.set_host_stale("nonexistent", 12345);
3553 assert_eq!(config.serialize(), before);
3554 }
3555
3556 #[test]
3557 fn clear_host_stale_nonexistent_alias_is_noop() {
3558 let config_str = "Host web\n HostName 1.2.3.4\n";
3559 let mut config = parse_str(config_str);
3560 let before = config.serialize();
3561 config.clear_host_stale("nonexistent");
3562 assert_eq!(config.serialize(), before);
3563 }
3564
3565 #[test]
3566 fn stale_hosts_empty_config() {
3567 let config_str = "";
3568 let config = parse_str(config_str);
3569 assert!(config.stale_hosts().is_empty());
3570 }
3571
3572 #[test]
3573 fn stale_hosts_no_stale() {
3574 let config_str = "Host web\n HostName 1.2.3.4\n\nHost db\n HostName 5.6.7.8\n";
3575 let config = parse_str(config_str);
3576 assert!(config.stale_hosts().is_empty());
3577 }
3578
3579 #[test]
3580 fn clear_stale_preserves_other_purple_comments() {
3581 let config_str = "\
3582Host web
3583 HostName 1.2.3.4
3584 # purple:tags prod
3585 # purple:provider do:123
3586 # purple:askpass keychain
3587 # purple:meta region=nyc3
3588 # purple:stale 1711900000
3589";
3590 let mut config = parse_str(config_str);
3591 config.clear_host_stale("web");
3592 let entry = first_block(&config).to_host_entry();
3593 assert_eq!(entry.stale, None);
3594 assert!(entry.tags.contains(&"prod".to_string()));
3595 assert_eq!(entry.provider, Some("do".to_string()));
3596 assert_eq!(entry.askpass, Some("keychain".to_string()));
3597 assert_eq!(entry.provider_meta[0].0, "region");
3598 }
3599
3600 #[test]
3601 fn set_stale_preserves_other_purple_comments() {
3602 let config_str = "\
3603Host web
3604 HostName 1.2.3.4
3605 # purple:tags prod
3606 # purple:provider do:123
3607 # purple:askpass keychain
3608 # purple:meta region=nyc3
3609";
3610 let mut config = parse_str(config_str);
3611 config.set_host_stale("web", 1711900000);
3612 let entry = first_block(&config).to_host_entry();
3613 assert_eq!(entry.stale, Some(1711900000));
3614 assert!(entry.tags.contains(&"prod".to_string()));
3615 assert_eq!(entry.provider, Some("do".to_string()));
3616 assert_eq!(entry.askpass, Some("keychain".to_string()));
3617 assert_eq!(entry.provider_meta[0].0, "region");
3618 }
3619
3620 #[test]
3621 fn stale_multiple_comments_first_wins() {
3622 let config_str = "\
3623Host web
3624 HostName 1.2.3.4
3625 # purple:stale 1000
3626 # purple:stale 2000
3627";
3628 let config = parse_str(config_str);
3629 assert_eq!(first_block(&config).stale(), Some(1000));
3630 }
3631
3632 #[test]
3633 fn set_stale_removes_multiple_stale_comments() {
3634 let config_str = "\
3635Host web
3636 HostName 1.2.3.4
3637 # purple:stale 1000
3638 # purple:stale 2000
3639";
3640 let mut config = parse_str(config_str);
3641 first_block_mut(&mut config).set_stale(3000);
3642 assert_eq!(first_block(&config).stale(), Some(3000));
3643 let output = config.serialize();
3644 assert_eq!(output.matches("purple:stale").count(), 1);
3645 }
3646
3647 #[test]
3648 fn stale_absent_in_host_entry() {
3649 let config_str = "Host web\n HostName 1.2.3.4\n";
3650 let config = parse_str(config_str);
3651 assert_eq!(first_block(&config).to_host_entry().stale, None);
3652 }
3653
3654 #[test]
3655 fn set_stale_four_space_indent() {
3656 let config_str = "Host web\n HostName 1.2.3.4\n";
3657 let mut config = parse_str(config_str);
3658 first_block_mut(&mut config).set_stale(1711900000);
3659 assert!(config.serialize().contains(" # purple:stale 1711900000"));
3660 }
3661
3662 #[test]
3663 fn clear_stale_removes_bare_comment() {
3664 let config_str = "Host web\n HostName 1.2.3.4\n # purple:stale\n";
3665 let mut config = parse_str(config_str);
3666 first_block_mut(&mut config).clear_stale();
3667 assert!(!config.serialize().contains("purple:stale"));
3668 }
3669
3670 #[test]
3673 fn stale_preserves_blank_line_between_hosts() {
3674 let config_str = "\
3675Host web
3676 HostName 1.2.3.4
3677
3678Host db
3679 HostName 5.6.7.8
3680";
3681 let mut config = parse_str(config_str);
3682 config.set_host_stale("web", 1711900000);
3683 let output = config.serialize();
3684 assert!(
3686 output.contains("# purple:stale 1711900000\n\nHost db"),
3687 "blank line between hosts lost after set_stale:\n{}",
3688 output
3689 );
3690 }
3691
3692 #[test]
3693 fn stale_preserves_blank_line_before_group_header() {
3694 let config_str = "\
3695Host do-web
3696 HostName 1.2.3.4
3697 # purple:provider digitalocean:111
3698
3699# purple:group Hetzner
3700
3701Host hz-cache
3702 HostName 9.10.11.12
3703 # purple:provider hetzner:333
3704";
3705 let mut config = parse_str(config_str);
3706 config.set_host_stale("do-web", 1711900000);
3707 let output = config.serialize();
3708 assert!(
3710 output.contains("\n\n# purple:group Hetzner"),
3711 "blank line before group header lost after set_stale:\n{}",
3712 output
3713 );
3714 }
3715
3716 #[test]
3717 fn stale_set_and_clear_is_byte_identical() {
3718 let config_str = "\
3719Host manual
3720 HostName 10.0.0.1
3721 User admin
3722
3723# purple:group DigitalOcean
3724
3725Host do-web
3726 HostName 1.2.3.4
3727 User root
3728 # purple:provider digitalocean:111
3729 # purple:tags prod
3730
3731Host do-db
3732 HostName 5.6.7.8
3733 User root
3734 # purple:provider digitalocean:222
3735 # purple:meta region=nyc3
3736
3737# purple:group Hetzner
3738
3739Host hz-cache
3740 HostName 9.10.11.12
3741 User root
3742 # purple:provider hetzner:333
3743";
3744 let original = config_str.to_string();
3745 let mut config = parse_str(config_str);
3746
3747 config.set_host_stale("do-db", 1711900000);
3749 let after_stale = config.serialize();
3750 assert_ne!(after_stale, original, "stale should change the config");
3751
3752 config.clear_host_stale("do-db");
3754 let after_clear = config.serialize();
3755 assert_eq!(
3756 after_clear, original,
3757 "clearing stale must restore byte-identical config"
3758 );
3759 }
3760
3761 #[test]
3762 fn stale_does_not_accumulate_blank_lines() {
3763 let config_str = "Host web\n HostName 1.2.3.4\n\nHost db\n HostName 5.6.7.8\n";
3764 let mut config = parse_str(config_str);
3765
3766 for _ in 0..10 {
3768 config.set_host_stale("web", 1711900000);
3769 config.clear_host_stale("web");
3770 }
3771
3772 let output = config.serialize();
3773 assert_eq!(
3774 output, config_str,
3775 "repeated set/clear must not accumulate blank lines"
3776 );
3777 }
3778
3779 #[test]
3780 fn stale_preserves_all_directives_and_comments() {
3781 let config_str = "\
3782Host complex
3783 HostName 1.2.3.4
3784 User deploy
3785 Port 2222
3786 IdentityFile ~/.ssh/id_ed25519
3787 ProxyJump bastion
3788 LocalForward 8080 localhost:80
3789 # purple:provider digitalocean:999
3790 # purple:tags prod,us-east
3791 # purple:provider_tags web-tier
3792 # purple:askpass keychain
3793 # purple:meta region=nyc3,plan=s-1vcpu-1gb
3794 # This is a user comment
3795";
3796 let mut config = parse_str(config_str);
3797 let entry_before = first_block(&config).to_host_entry();
3798
3799 config.set_host_stale("complex", 1711900000);
3800 let entry_after = first_block(&config).to_host_entry();
3801
3802 assert_eq!(entry_after.hostname, entry_before.hostname);
3804 assert_eq!(entry_after.user, entry_before.user);
3805 assert_eq!(entry_after.port, entry_before.port);
3806 assert_eq!(entry_after.identity_file, entry_before.identity_file);
3807 assert_eq!(entry_after.proxy_jump, entry_before.proxy_jump);
3808 assert_eq!(entry_after.tags, entry_before.tags);
3809 assert_eq!(entry_after.provider_tags, entry_before.provider_tags);
3810 assert_eq!(entry_after.provider, entry_before.provider);
3811 assert_eq!(entry_after.askpass, entry_before.askpass);
3812 assert_eq!(entry_after.provider_meta, entry_before.provider_meta);
3813 assert_eq!(entry_after.tunnel_count, entry_before.tunnel_count);
3814 assert_eq!(entry_after.stale, Some(1711900000));
3815
3816 config.clear_host_stale("complex");
3818 let entry_cleared = first_block(&config).to_host_entry();
3819 assert_eq!(entry_cleared.stale, None);
3820 assert_eq!(entry_cleared.hostname, entry_before.hostname);
3821 assert_eq!(entry_cleared.tags, entry_before.tags);
3822 assert_eq!(entry_cleared.provider, entry_before.provider);
3823 assert_eq!(entry_cleared.askpass, entry_before.askpass);
3824 assert_eq!(entry_cleared.provider_meta, entry_before.provider_meta);
3825
3826 assert!(config.serialize().contains("# This is a user comment"));
3828 }
3829
3830 #[test]
3831 fn stale_on_last_host_preserves_trailing_newline() {
3832 let config_str = "Host web\n HostName 1.2.3.4\n";
3833 let mut config = parse_str(config_str);
3834 config.set_host_stale("web", 1711900000);
3835 let output = config.serialize();
3836 assert!(output.ends_with('\n'), "config must end with newline");
3837
3838 config.clear_host_stale("web");
3839 let output2 = config.serialize();
3840 assert_eq!(output2, config_str);
3841 }
3842
3843 #[test]
3844 fn stale_with_crlf_preserves_line_endings() {
3845 let config_str = "Host web\r\n HostName 1.2.3.4\r\n";
3846 let config = SshConfigFile {
3847 elements: SshConfigFile::parse_content(config_str),
3848 path: std::path::PathBuf::from("/tmp/test"),
3849 crlf: true,
3850 bom: false,
3851 };
3852 let mut config = config;
3853 config.set_host_stale("web", 1711900000);
3854 let output = config.serialize();
3855 for line in output.split('\n') {
3857 if !line.is_empty() {
3858 assert!(
3859 line.ends_with('\r'),
3860 "CRLF lost after set_stale. Line: {:?}",
3861 line
3862 );
3863 }
3864 }
3865
3866 config.clear_host_stale("web");
3867 assert_eq!(config.serialize(), config_str);
3868 }
3869
3870 #[test]
3871 fn pattern_match_star_wildcard() {
3872 assert!(ssh_pattern_match("*", "anything"));
3873 assert!(ssh_pattern_match("10.30.0.*", "10.30.0.5"));
3874 assert!(ssh_pattern_match("10.30.0.*", "10.30.0.100"));
3875 assert!(!ssh_pattern_match("10.30.0.*", "10.30.1.5"));
3876 assert!(ssh_pattern_match("*.example.com", "web.example.com"));
3877 assert!(!ssh_pattern_match("*.example.com", "example.com"));
3878 assert!(ssh_pattern_match("prod-*-web", "prod-us-web"));
3879 assert!(!ssh_pattern_match("prod-*-web", "prod-us-api"));
3880 }
3881
3882 #[test]
3883 fn pattern_match_question_mark() {
3884 assert!(ssh_pattern_match("server-?", "server-1"));
3885 assert!(ssh_pattern_match("server-?", "server-a"));
3886 assert!(!ssh_pattern_match("server-?", "server-10"));
3887 assert!(!ssh_pattern_match("server-?", "server-"));
3888 }
3889
3890 #[test]
3891 fn pattern_match_character_class() {
3892 assert!(ssh_pattern_match("server-[abc]", "server-a"));
3893 assert!(ssh_pattern_match("server-[abc]", "server-c"));
3894 assert!(!ssh_pattern_match("server-[abc]", "server-d"));
3895 assert!(ssh_pattern_match("server-[0-9]", "server-5"));
3896 assert!(!ssh_pattern_match("server-[0-9]", "server-a"));
3897 assert!(ssh_pattern_match("server-[!abc]", "server-d"));
3898 assert!(!ssh_pattern_match("server-[!abc]", "server-a"));
3899 assert!(ssh_pattern_match("server-[^abc]", "server-d"));
3900 assert!(!ssh_pattern_match("server-[^abc]", "server-a"));
3901 }
3902
3903 #[test]
3904 fn pattern_match_negation() {
3905 assert!(!ssh_pattern_match("!prod-*", "prod-web"));
3906 assert!(ssh_pattern_match("!prod-*", "staging-web"));
3907 }
3908
3909 #[test]
3910 fn pattern_match_exact() {
3911 assert!(ssh_pattern_match("myserver", "myserver"));
3912 assert!(!ssh_pattern_match("myserver", "myserver2"));
3913 assert!(!ssh_pattern_match("myserver", "other"));
3914 }
3915
3916 #[test]
3917 fn pattern_match_empty() {
3918 assert!(!ssh_pattern_match("", "anything"));
3919 assert!(!ssh_pattern_match("*", ""));
3920 assert!(ssh_pattern_match("", ""));
3921 }
3922
3923 #[test]
3924 fn host_pattern_matches_multi_pattern() {
3925 assert!(host_pattern_matches("prod staging", "prod"));
3926 assert!(host_pattern_matches("prod staging", "staging"));
3927 assert!(!host_pattern_matches("prod staging", "dev"));
3928 }
3929
3930 #[test]
3931 fn host_pattern_matches_with_negation() {
3932 assert!(host_pattern_matches(
3933 "*.example.com !internal.example.com",
3934 "web.example.com",
3935 ));
3936 assert!(!host_pattern_matches(
3937 "*.example.com !internal.example.com",
3938 "internal.example.com",
3939 ));
3940 }
3941
3942 #[test]
3943 fn host_pattern_matches_alias_only() {
3944 assert!(!host_pattern_matches("10.30.0.*", "production"));
3946 assert!(host_pattern_matches("prod*", "production"));
3947 assert!(!host_pattern_matches("staging*", "production"));
3948 }
3949
3950 #[test]
3951 fn pattern_entries_collects_wildcards() {
3952 let config = parse_str(
3953 "Host myserver\n Hostname 10.0.0.1\n\nHost 10.30.0.*\n User debian\n ProxyJump bastion\n\nHost *\n ServerAliveInterval 60\n",
3954 );
3955 let patterns = config.pattern_entries();
3956 assert_eq!(patterns.len(), 2);
3957 assert_eq!(patterns[0].pattern, "10.30.0.*");
3958 assert_eq!(patterns[0].user, "debian");
3959 assert_eq!(patterns[0].proxy_jump, "bastion");
3960 assert_eq!(patterns[1].pattern, "*");
3961 assert!(
3962 patterns[1]
3963 .directives
3964 .iter()
3965 .any(|(k, v)| k == "ServerAliveInterval" && v == "60")
3966 );
3967 }
3968
3969 #[test]
3970 fn pattern_entries_empty_when_no_patterns() {
3971 let config = parse_str("Host myserver\n Hostname 10.0.0.1\n");
3972 let patterns = config.pattern_entries();
3973 assert!(patterns.is_empty());
3974 }
3975
3976 #[test]
3977 fn matching_patterns_returns_in_config_order() {
3978 let config = parse_str(
3979 "Host 10.30.0.*\n User debian\n\nHost myserver\n Hostname 10.30.0.5\n\nHost *\n ServerAliveInterval 60\n",
3980 );
3981 let matches = config.matching_patterns("myserver");
3983 assert_eq!(matches.len(), 1);
3984 assert_eq!(matches[0].pattern, "*");
3985 }
3986
3987 #[test]
3988 fn matching_patterns_negation_excludes() {
3989 let config = parse_str(
3990 "Host * !bastion\n ServerAliveInterval 60\n\nHost bastion\n Hostname 10.0.0.1\n",
3991 );
3992 let matches = config.matching_patterns("bastion");
3993 assert!(matches.is_empty());
3994 }
3995
3996 #[test]
3997 fn pattern_entries_and_host_entries_are_disjoint() {
3998 let config = parse_str(
3999 "Host myserver\n Hostname 10.0.0.1\n\nHost 10.30.0.*\n User debian\n\nHost *\n ServerAliveInterval 60\n",
4000 );
4001 let hosts = config.host_entries();
4002 let patterns = config.pattern_entries();
4003 assert_eq!(hosts.len(), 1);
4004 assert_eq!(hosts[0].alias, "myserver");
4005 assert_eq!(patterns.len(), 2);
4006 assert_eq!(patterns[0].pattern, "10.30.0.*");
4007 assert_eq!(patterns[1].pattern, "*");
4008 }
4009
4010 #[test]
4011 fn pattern_crud_round_trip() {
4012 let mut config = parse_str("Host myserver\n Hostname 10.0.0.1\n");
4013 let entry = HostEntry {
4015 alias: "10.30.0.*".to_string(),
4016 user: "debian".to_string(),
4017 ..Default::default()
4018 };
4019 config.add_host(&entry);
4020 let output = config.serialize();
4021 assert!(output.contains("Host 10.30.0.*"));
4022 assert!(output.contains("User debian"));
4023 let reparsed = parse_str(&output);
4025 assert_eq!(reparsed.host_entries().len(), 1);
4026 assert_eq!(reparsed.pattern_entries().len(), 1);
4027 assert_eq!(reparsed.pattern_entries()[0].pattern, "10.30.0.*");
4028 }
4029
4030 #[test]
4031 fn matching_patterns_full_ssh_semantics() {
4032 let config = parse_str(
4033 "Host 10.30.0.*\n User debian\n IdentityFile ~/.ssh/id_bootstrap\n ProxyJump bastion\n\n\
4034 Host *.internal !secret.internal\n ForwardAgent yes\n\n\
4035 Host myserver\n Hostname 10.30.0.5\n\n\
4036 Host *\n ServerAliveInterval 60\n",
4037 );
4038 let matches = config.matching_patterns("myserver");
4040 assert_eq!(matches.len(), 1);
4041 assert_eq!(matches[0].pattern, "*");
4042 assert!(
4043 matches[0]
4044 .directives
4045 .iter()
4046 .any(|(k, v)| k == "ServerAliveInterval" && v == "60")
4047 );
4048 }
4049
4050 #[test]
4051 fn pattern_entries_preserve_all_directives() {
4052 let config = parse_str(
4053 "Host *.example.com\n User admin\n Port 2222\n IdentityFile ~/.ssh/id_example\n ProxyJump gateway\n ServerAliveInterval 30\n ForwardAgent yes\n",
4054 );
4055 let patterns = config.pattern_entries();
4056 assert_eq!(patterns.len(), 1);
4057 let p = &patterns[0];
4058 assert_eq!(p.pattern, "*.example.com");
4059 assert_eq!(p.user, "admin");
4060 assert_eq!(p.port, 2222);
4061 assert_eq!(p.identity_file, "~/.ssh/id_example");
4062 assert_eq!(p.proxy_jump, "gateway");
4063 assert_eq!(p.directives.len(), 6);
4065 assert!(
4066 p.directives
4067 .iter()
4068 .any(|(k, v)| k == "ForwardAgent" && v == "yes")
4069 );
4070 assert!(
4071 p.directives
4072 .iter()
4073 .any(|(k, v)| k == "ServerAliveInterval" && v == "30")
4074 );
4075 }
4076
4077 #[test]
4080 fn roundtrip_pattern_blocks_preserved() {
4081 let input = "Host myserver\n Hostname 10.0.0.1\n User root\n\nHost 10.30.0.*\n User debian\n IdentityFile ~/.ssh/id_bootstrap\n ProxyJump bastion\n\nHost *\n ServerAliveInterval 60\n AddKeysToAgent yes\n";
4082 let config = parse_str(input);
4083 let output = config.serialize();
4084 assert_eq!(
4085 input, output,
4086 "Pattern blocks must survive round-trip exactly"
4087 );
4088 }
4089
4090 #[test]
4091 fn add_pattern_preserves_existing_config() {
4092 let input = "Host myserver\n Hostname 10.0.0.1\n\nHost otherserver\n Hostname 10.0.0.2\n\nHost *\n ServerAliveInterval 60\n";
4093 let mut config = parse_str(input);
4094 let entry = HostEntry {
4095 alias: "10.30.0.*".to_string(),
4096 user: "debian".to_string(),
4097 ..Default::default()
4098 };
4099 config.add_host(&entry);
4100 let output = config.serialize();
4101 assert!(output.contains("Host myserver"));
4103 assert!(output.contains("Hostname 10.0.0.1"));
4104 assert!(output.contains("Host otherserver"));
4105 assert!(output.contains("Hostname 10.0.0.2"));
4106 assert!(output.contains("Host 10.30.0.*"));
4108 assert!(output.contains("User debian"));
4109 assert!(output.contains("Host *"));
4111 let new_pos = output.find("Host 10.30.0.*").unwrap();
4113 let star_pos = output.find("Host *").unwrap();
4114 assert!(new_pos < star_pos, "New pattern must be before Host *");
4115 let reparsed = parse_str(&output);
4117 assert_eq!(reparsed.host_entries().len(), 2);
4118 assert_eq!(reparsed.pattern_entries().len(), 2); }
4120
4121 #[test]
4122 fn update_pattern_preserves_other_blocks() {
4123 let input = "Host myserver\n Hostname 10.0.0.1\n\nHost 10.30.0.*\n User debian\n\nHost *\n ServerAliveInterval 60\n";
4124 let mut config = parse_str(input);
4125 let updated = HostEntry {
4126 alias: "10.30.0.*".to_string(),
4127 user: "admin".to_string(),
4128 ..Default::default()
4129 };
4130 config.update_host("10.30.0.*", &updated);
4131 let output = config.serialize();
4132 assert!(output.contains("User admin"));
4134 assert!(!output.contains("User debian"));
4135 assert!(output.contains("Host myserver"));
4137 assert!(output.contains("Hostname 10.0.0.1"));
4138 assert!(output.contains("Host *"));
4139 assert!(output.contains("ServerAliveInterval 60"));
4140 }
4141
4142 #[test]
4143 fn delete_pattern_preserves_other_blocks() {
4144 let input = "Host myserver\n Hostname 10.0.0.1\n\nHost 10.30.0.*\n User debian\n\nHost *\n ServerAliveInterval 60\n";
4145 let mut config = parse_str(input);
4146 config.delete_host("10.30.0.*");
4147 let output = config.serialize();
4148 assert!(!output.contains("Host 10.30.0.*"));
4149 assert!(!output.contains("User debian"));
4150 assert!(output.contains("Host myserver"));
4151 assert!(output.contains("Hostname 10.0.0.1"));
4152 assert!(output.contains("Host *"));
4153 assert!(output.contains("ServerAliveInterval 60"));
4154 let reparsed = parse_str(&output);
4155 assert_eq!(reparsed.host_entries().len(), 1);
4156 assert_eq!(reparsed.pattern_entries().len(), 1); }
4158
4159 #[test]
4160 fn update_pattern_rename() {
4161 let input = "Host *.example.com\n User admin\n\nHost myserver\n Hostname 10.0.0.1\n";
4162 let mut config = parse_str(input);
4163 let renamed = HostEntry {
4164 alias: "*.prod.example.com".to_string(),
4165 user: "admin".to_string(),
4166 ..Default::default()
4167 };
4168 config.update_host("*.example.com", &renamed);
4169 let output = config.serialize();
4170 assert!(
4171 !output.contains("Host *.example.com\n"),
4172 "Old pattern removed"
4173 );
4174 assert!(
4175 output.contains("Host *.prod.example.com"),
4176 "New pattern present"
4177 );
4178 assert!(output.contains("Host myserver"), "Other host preserved");
4179 }
4180
4181 #[test]
4182 fn config_with_only_patterns() {
4183 let input = "Host *.example.com\n User admin\n\nHost *\n ServerAliveInterval 60\n";
4184 let config = parse_str(input);
4185 assert!(config.host_entries().is_empty());
4186 assert_eq!(config.pattern_entries().len(), 2);
4187 let output = config.serialize();
4189 assert_eq!(input, output);
4190 }
4191
4192 #[test]
4193 fn host_pattern_matches_all_negative_returns_false() {
4194 assert!(!host_pattern_matches("!prod !staging", "anything"));
4195 assert!(!host_pattern_matches("!prod !staging", "dev"));
4196 }
4197
4198 #[test]
4199 fn host_pattern_matches_negation_only_checks_alias() {
4200 assert!(host_pattern_matches("* !10.0.0.1", "myserver"));
4202 assert!(!host_pattern_matches("* !myserver", "myserver"));
4203 }
4204
4205 #[test]
4206 fn pattern_match_malformed_char_class() {
4207 assert!(!ssh_pattern_match("[abc", "a"));
4209 assert!(!ssh_pattern_match("[", "a"));
4210 assert!(!ssh_pattern_match("[]", "a"));
4212 }
4213
4214 #[test]
4215 fn host_pattern_matches_whitespace_edge_cases() {
4216 assert!(host_pattern_matches("prod staging", "prod"));
4217 assert!(host_pattern_matches(" prod ", "prod"));
4218 assert!(host_pattern_matches("prod\tstaging", "prod"));
4219 assert!(!host_pattern_matches(" ", "anything"));
4220 assert!(!host_pattern_matches("", "anything"));
4221 }
4222
4223 #[test]
4224 fn pattern_with_metadata_roundtrip() {
4225 let input = "Host 10.30.0.*\n User debian\n # purple:tags internal,vpn\n # purple:askpass keychain\n\nHost myserver\n Hostname 10.0.0.1\n";
4226 let config = parse_str(input);
4227 let patterns = config.pattern_entries();
4228 assert_eq!(patterns.len(), 1);
4229 assert_eq!(patterns[0].tags, vec!["internal", "vpn"]);
4230 assert_eq!(patterns[0].askpass.as_deref(), Some("keychain"));
4231 let output = config.serialize();
4233 assert_eq!(input, output);
4234 }
4235
4236 #[test]
4237 fn matching_patterns_multiple_in_config_order() {
4238 let input = "Host my-*\n User fallback\n\nHost my-10*\n User team\n\nHost my-10-*\n User specific\n\nHost other\n Hostname 10.30.0.5\n\nHost *\n ServerAliveInterval 60\n";
4240 let config = parse_str(input);
4241 let matches = config.matching_patterns("my-10-server");
4242 assert_eq!(matches.len(), 4);
4243 assert_eq!(matches[0].pattern, "my-*");
4244 assert_eq!(matches[1].pattern, "my-10*");
4245 assert_eq!(matches[2].pattern, "my-10-*");
4246 assert_eq!(matches[3].pattern, "*");
4247 }
4248
4249 #[test]
4250 fn add_pattern_to_empty_config() {
4251 let mut config = parse_str("");
4252 let entry = HostEntry {
4253 alias: "*.example.com".to_string(),
4254 user: "admin".to_string(),
4255 ..Default::default()
4256 };
4257 config.add_host(&entry);
4258 let output = config.serialize();
4259 assert!(output.contains("Host *.example.com"));
4260 assert!(output.contains("User admin"));
4261 let reparsed = parse_str(&output);
4262 assert!(reparsed.host_entries().is_empty());
4263 assert_eq!(reparsed.pattern_entries().len(), 1);
4264 }
4265}