1use std::path::PathBuf;
2
3#[derive(Debug, Clone)]
6pub struct SshConfigFile {
7 pub elements: Vec<ConfigElement>,
8 pub path: PathBuf,
9 pub crlf: bool,
11}
12
13#[derive(Debug, Clone)]
15#[allow(dead_code)]
16pub struct IncludeDirective {
17 pub raw_line: String,
18 pub pattern: String,
19 pub resolved_files: Vec<IncludedFile>,
20}
21
22#[derive(Debug, Clone)]
24pub struct IncludedFile {
25 pub path: PathBuf,
26 pub elements: Vec<ConfigElement>,
27}
28
29#[derive(Debug, Clone)]
31pub enum ConfigElement {
32 HostBlock(HostBlock),
34 GlobalLine(String),
36 Include(IncludeDirective),
38}
39
40#[derive(Debug, Clone)]
42pub struct HostBlock {
43 pub host_pattern: String,
45 pub raw_host_line: String,
47 pub directives: Vec<Directive>,
49}
50
51#[derive(Debug, Clone)]
53pub struct Directive {
54 pub key: String,
56 pub value: String,
58 pub raw_line: String,
60 pub is_non_directive: bool,
62}
63
64#[derive(Debug, Clone)]
66pub struct HostEntry {
67 pub alias: String,
68 pub hostname: String,
69 pub user: String,
70 pub port: u16,
71 pub identity_file: String,
72 pub proxy_jump: String,
73 pub source_file: Option<PathBuf>,
75 pub tags: Vec<String>,
77 pub provider: Option<String>,
79 pub tunnel_count: u16,
81 pub askpass: Option<String>,
83 pub provider_meta: Vec<(String, String)>,
85}
86
87impl Default for HostEntry {
88 fn default() -> Self {
89 Self {
90 alias: String::new(),
91 hostname: String::new(),
92 user: String::new(),
93 port: 22,
94 identity_file: String::new(),
95 proxy_jump: String::new(),
96 source_file: None,
97 tags: Vec::new(),
98 provider: None,
99 tunnel_count: 0,
100 askpass: None,
101 provider_meta: Vec::new(),
102 }
103 }
104}
105
106impl HostEntry {
107 pub fn ssh_command(&self) -> String {
110 let escaped = self.alias.replace('\'', "'\\''");
111 format!("ssh -- '{}'", escaped)
112 }
113}
114
115pub fn is_host_pattern(pattern: &str) -> bool {
119 pattern.contains('*')
120 || pattern.contains('?')
121 || pattern.contains('[')
122 || pattern.starts_with('!')
123 || pattern.contains(' ')
124 || pattern.contains('\t')
125}
126
127impl HostBlock {
128 fn content_end(&self) -> usize {
130 let mut pos = self.directives.len();
131 while pos > 0 {
132 if self.directives[pos - 1].is_non_directive
133 && self.directives[pos - 1].raw_line.trim().is_empty()
134 {
135 pos -= 1;
136 } else {
137 break;
138 }
139 }
140 pos
141 }
142
143 fn pop_trailing_blanks(&mut self) -> Vec<Directive> {
145 let end = self.content_end();
146 self.directives.drain(end..).collect()
147 }
148
149 fn ensure_trailing_blank(&mut self) {
151 self.pop_trailing_blanks();
152 self.directives.push(Directive {
153 key: String::new(),
154 value: String::new(),
155 raw_line: String::new(),
156 is_non_directive: true,
157 });
158 }
159
160 fn detect_indent(&self) -> String {
162 for d in &self.directives {
163 if !d.is_non_directive && !d.raw_line.is_empty() {
164 let trimmed = d.raw_line.trim_start();
165 let indent_len = d.raw_line.len() - trimmed.len();
166 if indent_len > 0 {
167 return d.raw_line[..indent_len].to_string();
168 }
169 }
170 }
171 " ".to_string()
172 }
173
174 pub fn tags(&self) -> Vec<String> {
176 for d in &self.directives {
177 if d.is_non_directive {
178 let trimmed = d.raw_line.trim();
179 if let Some(rest) = trimmed.strip_prefix("# purple:tags ") {
180 return rest
181 .split(',')
182 .map(|t| t.trim().to_string())
183 .filter(|t| !t.is_empty())
184 .collect();
185 }
186 }
187 }
188 Vec::new()
189 }
190
191 pub fn provider(&self) -> Option<(String, String)> {
194 for d in &self.directives {
195 if d.is_non_directive {
196 let trimmed = d.raw_line.trim();
197 if let Some(rest) = trimmed.strip_prefix("# purple:provider ") {
198 if let Some((name, id)) = rest.split_once(':') {
199 return Some((name.trim().to_string(), id.trim().to_string()));
200 }
201 }
202 }
203 }
204 None
205 }
206
207 pub fn set_provider(&mut self, provider_name: &str, server_id: &str) {
209 let indent = self.detect_indent();
210 self.directives.retain(|d| {
211 !(d.is_non_directive && d.raw_line.trim().starts_with("# purple:provider"))
212 });
213 let pos = self.content_end();
214 self.directives.insert(
215 pos,
216 Directive {
217 key: String::new(),
218 value: String::new(),
219 raw_line: format!("{}# purple:provider {}:{}", indent, provider_name, server_id),
220 is_non_directive: true,
221 },
222 );
223 }
224
225 pub fn askpass(&self) -> Option<String> {
227 for d in &self.directives {
228 if d.is_non_directive {
229 let trimmed = d.raw_line.trim();
230 if let Some(rest) = trimmed.strip_prefix("# purple:askpass ") {
231 let val = rest.trim();
232 if !val.is_empty() {
233 return Some(val.to_string());
234 }
235 }
236 }
237 }
238 None
239 }
240
241 pub fn set_askpass(&mut self, source: &str) {
244 let indent = self.detect_indent();
245 self.directives.retain(|d| {
246 !(d.is_non_directive && d.raw_line.trim().starts_with("# purple:askpass"))
247 });
248 if !source.is_empty() {
249 let pos = self.content_end();
250 self.directives.insert(
251 pos,
252 Directive {
253 key: String::new(),
254 value: String::new(),
255 raw_line: format!("{}# purple:askpass {}", indent, source),
256 is_non_directive: true,
257 },
258 );
259 }
260 }
261
262 pub fn meta(&self) -> Vec<(String, String)> {
265 for d in &self.directives {
266 if d.is_non_directive {
267 let trimmed = d.raw_line.trim();
268 if let Some(rest) = trimmed.strip_prefix("# purple:meta ") {
269 return rest
270 .split(',')
271 .filter_map(|pair| {
272 let (k, v) = pair.split_once('=')?;
273 let k = k.trim();
274 let v = v.trim();
275 if k.is_empty() {
276 None
277 } else {
278 Some((k.to_string(), v.to_string()))
279 }
280 })
281 .collect();
282 }
283 }
284 }
285 Vec::new()
286 }
287
288 pub fn set_meta(&mut self, meta: &[(String, String)]) {
291 let indent = self.detect_indent();
292 self.directives.retain(|d| {
293 !(d.is_non_directive && d.raw_line.trim().starts_with("# purple:meta"))
294 });
295 if !meta.is_empty() {
296 let encoded: Vec<String> = meta
297 .iter()
298 .map(|(k, v)| {
299 let clean_k = k.replace([',', '='], "");
300 let clean_v = v.replace(',', "");
301 format!("{}={}", clean_k, clean_v)
302 })
303 .collect();
304 let pos = self.content_end();
305 self.directives.insert(
306 pos,
307 Directive {
308 key: String::new(),
309 value: String::new(),
310 raw_line: format!("{}# purple:meta {}", indent, encoded.join(",")),
311 is_non_directive: true,
312 },
313 );
314 }
315 }
316
317 pub fn set_tags(&mut self, tags: &[String]) {
319 let indent = self.detect_indent();
320 self.directives.retain(|d| {
321 !(d.is_non_directive && d.raw_line.trim().starts_with("# purple:tags"))
322 });
323 if !tags.is_empty() {
324 let pos = self.content_end();
325 self.directives.insert(
326 pos,
327 Directive {
328 key: String::new(),
329 value: String::new(),
330 raw_line: format!("{}# purple:tags {}", indent, tags.join(",")),
331 is_non_directive: true,
332 },
333 );
334 }
335 }
336
337 pub fn to_host_entry(&self) -> HostEntry {
339 let mut entry = HostEntry {
340 alias: self.host_pattern.clone(),
341 port: 22,
342 ..Default::default()
343 };
344 for d in &self.directives {
345 if d.is_non_directive {
346 continue;
347 }
348 if d.key.eq_ignore_ascii_case("hostname") {
349 entry.hostname = d.value.clone();
350 } else if d.key.eq_ignore_ascii_case("user") {
351 entry.user = d.value.clone();
352 } else if d.key.eq_ignore_ascii_case("port") {
353 entry.port = d.value.parse().unwrap_or(22);
354 } else if d.key.eq_ignore_ascii_case("identityfile") {
355 if entry.identity_file.is_empty() {
356 entry.identity_file = d.value.clone();
357 }
358 } else if d.key.eq_ignore_ascii_case("proxyjump") {
359 entry.proxy_jump = d.value.clone();
360 }
361 }
362 entry.tags = self.tags();
363 entry.provider = self.provider().map(|(name, _)| name);
364 entry.tunnel_count = self.tunnel_count();
365 entry.askpass = self.askpass();
366 entry.provider_meta = self.meta();
367 entry
368 }
369
370 pub fn tunnel_count(&self) -> u16 {
372 let count = self
373 .directives
374 .iter()
375 .filter(|d| {
376 !d.is_non_directive
377 && (d.key.eq_ignore_ascii_case("localforward")
378 || d.key.eq_ignore_ascii_case("remoteforward")
379 || d.key.eq_ignore_ascii_case("dynamicforward"))
380 })
381 .count();
382 count.min(u16::MAX as usize) as u16
383 }
384
385 #[allow(dead_code)]
387 pub fn has_tunnels(&self) -> bool {
388 self.directives.iter().any(|d| {
389 !d.is_non_directive
390 && (d.key.eq_ignore_ascii_case("localforward")
391 || d.key.eq_ignore_ascii_case("remoteforward")
392 || d.key.eq_ignore_ascii_case("dynamicforward"))
393 })
394 }
395
396 pub fn tunnel_directives(&self) -> Vec<crate::tunnel::TunnelRule> {
398 self.directives
399 .iter()
400 .filter(|d| !d.is_non_directive)
401 .filter_map(|d| crate::tunnel::TunnelRule::parse_value(&d.key, &d.value))
402 .collect()
403 }
404}
405
406impl SshConfigFile {
407 pub fn host_entries(&self) -> Vec<HostEntry> {
409 let mut entries = Vec::new();
410 Self::collect_host_entries(&self.elements, &mut entries);
411 entries
412 }
413
414 pub fn include_paths(&self) -> Vec<PathBuf> {
416 let mut paths = Vec::new();
417 Self::collect_include_paths(&self.elements, &mut paths);
418 paths
419 }
420
421 fn collect_include_paths(elements: &[ConfigElement], paths: &mut Vec<PathBuf>) {
422 for e in elements {
423 if let ConfigElement::Include(include) = e {
424 for file in &include.resolved_files {
425 paths.push(file.path.clone());
426 Self::collect_include_paths(&file.elements, paths);
427 }
428 }
429 }
430 }
431
432 pub fn include_glob_dirs(&self) -> Vec<PathBuf> {
435 let config_dir = self.path.parent();
436 let mut seen = std::collections::HashSet::new();
437 let mut dirs = Vec::new();
438 Self::collect_include_glob_dirs(&self.elements, config_dir, &mut seen, &mut dirs);
439 dirs
440 }
441
442 fn collect_include_glob_dirs(
443 elements: &[ConfigElement],
444 config_dir: Option<&std::path::Path>,
445 seen: &mut std::collections::HashSet<PathBuf>,
446 dirs: &mut Vec<PathBuf>,
447 ) {
448 for e in elements {
449 if let ConfigElement::Include(include) = e {
450 for single in include.pattern.split_whitespace() {
453 let expanded = Self::expand_tilde(single);
454 let resolved = if expanded.starts_with('/') {
455 PathBuf::from(&expanded)
456 } else if let Some(dir) = config_dir {
457 dir.join(&expanded)
458 } else {
459 continue;
460 };
461 if let Some(parent) = resolved.parent() {
462 let parent = parent.to_path_buf();
463 if seen.insert(parent.clone()) {
464 dirs.push(parent);
465 }
466 }
467 }
468 for file in &include.resolved_files {
470 Self::collect_include_glob_dirs(
471 &file.elements,
472 file.path.parent(),
473 seen,
474 dirs,
475 );
476 }
477 }
478 }
479 }
480
481
482 fn collect_host_entries(elements: &[ConfigElement], entries: &mut Vec<HostEntry>) {
484 for e in elements {
485 match e {
486 ConfigElement::HostBlock(block) => {
487 if is_host_pattern(&block.host_pattern) {
488 continue;
489 }
490 entries.push(block.to_host_entry());
491 }
492 ConfigElement::Include(include) => {
493 for file in &include.resolved_files {
494 let start = entries.len();
495 Self::collect_host_entries(&file.elements, entries);
496 for entry in &mut entries[start..] {
497 if entry.source_file.is_none() {
498 entry.source_file = Some(file.path.clone());
499 }
500 }
501 }
502 }
503 ConfigElement::GlobalLine(_) => {}
504 }
505 }
506 }
507
508 pub fn has_host(&self, alias: &str) -> bool {
511 Self::has_host_in_elements(&self.elements, alias)
512 }
513
514 fn has_host_in_elements(elements: &[ConfigElement], alias: &str) -> bool {
515 for e in elements {
516 match e {
517 ConfigElement::HostBlock(block) => {
518 if block.host_pattern.split_whitespace().any(|p| p == alias) {
519 return true;
520 }
521 }
522 ConfigElement::Include(include) => {
523 for file in &include.resolved_files {
524 if Self::has_host_in_elements(&file.elements, alias) {
525 return true;
526 }
527 }
528 }
529 ConfigElement::GlobalLine(_) => {}
530 }
531 }
532 false
533 }
534
535 pub fn is_included_host(&self, alias: &str) -> bool {
538 for e in &self.elements {
540 match e {
541 ConfigElement::HostBlock(block) => {
542 if block.host_pattern.split_whitespace().any(|p| p == alias) {
543 return false;
544 }
545 }
546 ConfigElement::Include(include) => {
547 for file in &include.resolved_files {
548 if Self::has_host_in_elements(&file.elements, alias) {
549 return true;
550 }
551 }
552 }
553 ConfigElement::GlobalLine(_) => {}
554 }
555 }
556 false
557 }
558
559 pub fn add_host(&mut self, entry: &HostEntry) {
561 let block = Self::entry_to_block(entry);
562 if !self.elements.is_empty() && !self.last_element_has_trailing_blank() {
564 self.elements
565 .push(ConfigElement::GlobalLine(String::new()));
566 }
567 self.elements.push(ConfigElement::HostBlock(block));
568 }
569
570 pub fn last_element_has_trailing_blank(&self) -> bool {
572 match self.elements.last() {
573 Some(ConfigElement::HostBlock(block)) => block
574 .directives
575 .last()
576 .is_some_and(|d| d.is_non_directive && d.raw_line.trim().is_empty()),
577 Some(ConfigElement::GlobalLine(line)) => line.trim().is_empty(),
578 _ => false,
579 }
580 }
581
582 pub fn update_host(&mut self, old_alias: &str, entry: &HostEntry) {
585 for element in &mut self.elements {
586 if let ConfigElement::HostBlock(block) = element {
587 if block.host_pattern == old_alias {
588 if entry.alias != block.host_pattern {
590 block.host_pattern = entry.alias.clone();
591 block.raw_host_line = format!("Host {}", entry.alias);
592 }
593
594 Self::upsert_directive(block, "HostName", &entry.hostname);
596 Self::upsert_directive(block, "User", &entry.user);
597 if entry.port != 22 {
598 Self::upsert_directive(block, "Port", &entry.port.to_string());
599 } else {
600 block
602 .directives
603 .retain(|d| d.is_non_directive || !d.key.eq_ignore_ascii_case("port"));
604 }
605 Self::upsert_directive(block, "IdentityFile", &entry.identity_file);
606 Self::upsert_directive(block, "ProxyJump", &entry.proxy_jump);
607 return;
608 }
609 }
610 }
611 }
612
613 fn upsert_directive(block: &mut HostBlock, key: &str, value: &str) {
615 if value.is_empty() {
616 block
617 .directives
618 .retain(|d| d.is_non_directive || !d.key.eq_ignore_ascii_case(key));
619 return;
620 }
621 let indent = block.detect_indent();
622 for d in &mut block.directives {
623 if !d.is_non_directive && d.key.eq_ignore_ascii_case(key) {
624 if d.value != value {
626 d.value = value.to_string();
627 let trimmed = d.raw_line.trim_start();
633 let after_key = &trimmed[d.key.len()..];
634 let sep = if after_key.trim_start().starts_with('=') {
635 let eq_pos = after_key.find('=').unwrap();
636 let after_eq = &after_key[eq_pos + 1..];
637 let trailing_ws = after_eq.len() - after_eq.trim_start().len();
638 after_key[..eq_pos + 1 + trailing_ws].to_string()
639 } else {
640 " ".to_string()
641 };
642 d.raw_line = format!("{}{}{}{}", indent, d.key, sep, value);
643 }
644 return;
645 }
646 }
647 let pos = block.content_end();
649 block.directives.insert(
650 pos,
651 Directive {
652 key: key.to_string(),
653 value: value.to_string(),
654 raw_line: format!("{}{} {}", indent, key, value),
655 is_non_directive: false,
656 },
657 );
658 }
659
660 pub fn set_host_provider(&mut self, alias: &str, provider_name: &str, server_id: &str) {
662 for element in &mut self.elements {
663 if let ConfigElement::HostBlock(block) = element {
664 if block.host_pattern == alias {
665 block.set_provider(provider_name, server_id);
666 return;
667 }
668 }
669 }
670 }
671
672 pub fn find_hosts_by_provider(&self, provider_name: &str) -> Vec<(String, String)> {
676 let mut results = Vec::new();
677 Self::collect_provider_hosts(&self.elements, provider_name, &mut results);
678 results
679 }
680
681 fn collect_provider_hosts(
682 elements: &[ConfigElement],
683 provider_name: &str,
684 results: &mut Vec<(String, String)>,
685 ) {
686 for element in elements {
687 match element {
688 ConfigElement::HostBlock(block) => {
689 if let Some((name, id)) = block.provider() {
690 if name == provider_name {
691 results.push((block.host_pattern.clone(), id));
692 }
693 }
694 }
695 ConfigElement::Include(include) => {
696 for file in &include.resolved_files {
697 Self::collect_provider_hosts(&file.elements, provider_name, results);
698 }
699 }
700 ConfigElement::GlobalLine(_) => {}
701 }
702 }
703 }
704
705 fn values_match(a: &str, b: &str) -> bool {
708 a.split_whitespace().eq(b.split_whitespace())
709 }
710
711 pub fn add_forward(&mut self, alias: &str, directive_key: &str, value: &str) {
715 for element in &mut self.elements {
716 if let ConfigElement::HostBlock(block) = element {
717 if block.host_pattern.split_whitespace().any(|p| p == alias) {
718 let indent = block.detect_indent();
719 let pos = block.content_end();
720 block.directives.insert(
721 pos,
722 Directive {
723 key: directive_key.to_string(),
724 value: value.to_string(),
725 raw_line: format!("{}{} {}", indent, directive_key, value),
726 is_non_directive: false,
727 },
728 );
729 return;
730 }
731 }
732 }
733 }
734
735 pub fn remove_forward(&mut self, alias: &str, directive_key: &str, value: &str) -> bool {
740 for element in &mut self.elements {
741 if let ConfigElement::HostBlock(block) = element {
742 if block.host_pattern.split_whitespace().any(|p| p == alias) {
743 if let Some(pos) = block.directives.iter().position(|d| {
744 !d.is_non_directive
745 && d.key.eq_ignore_ascii_case(directive_key)
746 && Self::values_match(&d.value, value)
747 }) {
748 block.directives.remove(pos);
749 return true;
750 }
751 return false;
752 }
753 }
754 }
755 false
756 }
757
758 pub fn has_forward(&self, alias: &str, directive_key: &str, value: &str) -> bool {
761 for element in &self.elements {
762 if let ConfigElement::HostBlock(block) = element {
763 if block.host_pattern.split_whitespace().any(|p| p == alias) {
764 return block.directives.iter().any(|d| {
765 !d.is_non_directive
766 && d.key.eq_ignore_ascii_case(directive_key)
767 && Self::values_match(&d.value, value)
768 });
769 }
770 }
771 }
772 false
773 }
774
775 pub fn find_tunnel_directives(&self, alias: &str) -> Vec<crate::tunnel::TunnelRule> {
779 Self::find_tunnel_directives_in(&self.elements, alias)
780 }
781
782 fn find_tunnel_directives_in(
783 elements: &[ConfigElement],
784 alias: &str,
785 ) -> Vec<crate::tunnel::TunnelRule> {
786 for element in elements {
787 match element {
788 ConfigElement::HostBlock(block) => {
789 if block.host_pattern.split_whitespace().any(|p| p == alias) {
790 return block.tunnel_directives();
791 }
792 }
793 ConfigElement::Include(include) => {
794 for file in &include.resolved_files {
795 let rules = Self::find_tunnel_directives_in(&file.elements, alias);
796 if !rules.is_empty() {
797 return rules;
798 }
799 }
800 }
801 ConfigElement::GlobalLine(_) => {}
802 }
803 }
804 Vec::new()
805 }
806
807 pub fn deduplicate_alias(&self, base: &str) -> String {
809 self.deduplicate_alias_excluding(base, None)
810 }
811
812 pub fn deduplicate_alias_excluding(&self, base: &str, exclude: Option<&str>) -> String {
815 let is_taken = |alias: &str| {
816 if exclude == Some(alias) {
817 return false;
818 }
819 self.has_host(alias)
820 };
821 if !is_taken(base) {
822 return base.to_string();
823 }
824 for n in 2..=9999 {
825 let candidate = format!("{}-{}", base, n);
826 if !is_taken(&candidate) {
827 return candidate;
828 }
829 }
830 format!("{}-{}", base, std::process::id())
832 }
833
834 pub fn set_host_tags(&mut self, alias: &str, tags: &[String]) {
836 for element in &mut self.elements {
837 if let ConfigElement::HostBlock(block) = element {
838 if block.host_pattern == alias {
839 block.set_tags(tags);
840 return;
841 }
842 }
843 }
844 }
845
846 pub fn set_host_askpass(&mut self, alias: &str, source: &str) {
848 for element in &mut self.elements {
849 if let ConfigElement::HostBlock(block) = element {
850 if block.host_pattern == alias {
851 block.set_askpass(source);
852 return;
853 }
854 }
855 }
856 }
857
858 pub fn set_host_meta(&mut self, alias: &str, meta: &[(String, String)]) {
860 for element in &mut self.elements {
861 if let ConfigElement::HostBlock(block) = element {
862 if block.host_pattern == alias {
863 block.set_meta(meta);
864 return;
865 }
866 }
867 }
868 }
869
870 #[allow(dead_code)]
872 pub fn delete_host(&mut self, alias: &str) {
873 self.elements.retain(|e| match e {
874 ConfigElement::HostBlock(block) => block.host_pattern != alias,
875 _ => true,
876 });
877 self.elements.dedup_by(|a, b| {
879 matches!(
880 (&*a, &*b),
881 (ConfigElement::GlobalLine(x), ConfigElement::GlobalLine(y))
882 if x.trim().is_empty() && y.trim().is_empty()
883 )
884 });
885 }
886
887 pub fn delete_host_undoable(&mut self, alias: &str) -> Option<(ConfigElement, usize)> {
890 let pos = self.elements.iter().position(|e| {
891 matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias)
892 })?;
893 let element = self.elements.remove(pos);
894 Some((element, pos))
895 }
896
897 pub fn insert_host_at(&mut self, element: ConfigElement, position: usize) {
899 let pos = position.min(self.elements.len());
900 self.elements.insert(pos, element);
901 }
902
903 #[allow(dead_code)]
905 pub fn swap_hosts(&mut self, alias_a: &str, alias_b: &str) -> bool {
906 let pos_a = self.elements.iter().position(|e| {
907 matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_a)
908 });
909 let pos_b = self.elements.iter().position(|e| {
910 matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_b)
911 });
912 if let (Some(a), Some(b)) = (pos_a, pos_b) {
913 if a == b {
914 return false;
915 }
916 let (first, second) = (a.min(b), a.max(b));
917
918 if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
920 block.pop_trailing_blanks();
921 }
922 if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
923 block.pop_trailing_blanks();
924 }
925
926 self.elements.swap(first, second);
928
929 if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
931 block.ensure_trailing_blank();
932 }
933
934 if second < self.elements.len() - 1 {
936 if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
937 block.ensure_trailing_blank();
938 }
939 }
940
941 return true;
942 }
943 false
944 }
945
946 pub(crate) fn entry_to_block(entry: &HostEntry) -> HostBlock {
948 let mut directives = Vec::new();
949
950 if !entry.hostname.is_empty() {
951 directives.push(Directive {
952 key: "HostName".to_string(),
953 value: entry.hostname.clone(),
954 raw_line: format!(" HostName {}", entry.hostname),
955 is_non_directive: false,
956 });
957 }
958 if !entry.user.is_empty() {
959 directives.push(Directive {
960 key: "User".to_string(),
961 value: entry.user.clone(),
962 raw_line: format!(" User {}", entry.user),
963 is_non_directive: false,
964 });
965 }
966 if entry.port != 22 {
967 directives.push(Directive {
968 key: "Port".to_string(),
969 value: entry.port.to_string(),
970 raw_line: format!(" Port {}", entry.port),
971 is_non_directive: false,
972 });
973 }
974 if !entry.identity_file.is_empty() {
975 directives.push(Directive {
976 key: "IdentityFile".to_string(),
977 value: entry.identity_file.clone(),
978 raw_line: format!(" IdentityFile {}", entry.identity_file),
979 is_non_directive: false,
980 });
981 }
982 if !entry.proxy_jump.is_empty() {
983 directives.push(Directive {
984 key: "ProxyJump".to_string(),
985 value: entry.proxy_jump.clone(),
986 raw_line: format!(" ProxyJump {}", entry.proxy_jump),
987 is_non_directive: false,
988 });
989 }
990
991 HostBlock {
992 host_pattern: entry.alias.clone(),
993 raw_host_line: format!("Host {}", entry.alias),
994 directives,
995 }
996 }
997}
998
999#[cfg(test)]
1000mod tests {
1001 use super::*;
1002
1003 fn parse_str(content: &str) -> SshConfigFile {
1004 SshConfigFile {
1005 elements: SshConfigFile::parse_content(content),
1006 path: PathBuf::from("/tmp/test_config"),
1007 crlf: false,
1008 }
1009 }
1010
1011 #[test]
1012 fn tunnel_directives_extracts_forwards() {
1013 let config = parse_str(
1014 "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n RemoteForward 9090 localhost:3000\n DynamicForward 1080\n",
1015 );
1016 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1017 let rules = block.tunnel_directives();
1018 assert_eq!(rules.len(), 3);
1019 assert_eq!(rules[0].tunnel_type, crate::tunnel::TunnelType::Local);
1020 assert_eq!(rules[0].bind_port, 8080);
1021 assert_eq!(rules[1].tunnel_type, crate::tunnel::TunnelType::Remote);
1022 assert_eq!(rules[2].tunnel_type, crate::tunnel::TunnelType::Dynamic);
1023 } else {
1024 panic!("Expected HostBlock");
1025 }
1026 }
1027
1028 #[test]
1029 fn tunnel_count_counts_forwards() {
1030 let config = parse_str(
1031 "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n RemoteForward 9090 localhost:3000\n",
1032 );
1033 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1034 assert_eq!(block.tunnel_count(), 2);
1035 } else {
1036 panic!("Expected HostBlock");
1037 }
1038 }
1039
1040 #[test]
1041 fn tunnel_count_zero_for_no_forwards() {
1042 let config = parse_str("Host myserver\n HostName 10.0.0.1\n User admin\n");
1043 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1044 assert_eq!(block.tunnel_count(), 0);
1045 assert!(!block.has_tunnels());
1046 } else {
1047 panic!("Expected HostBlock");
1048 }
1049 }
1050
1051 #[test]
1052 fn has_tunnels_true_with_forward() {
1053 let config = parse_str("Host myserver\n HostName 10.0.0.1\n DynamicForward 1080\n");
1054 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1055 assert!(block.has_tunnels());
1056 } else {
1057 panic!("Expected HostBlock");
1058 }
1059 }
1060
1061 #[test]
1062 fn add_forward_inserts_directive() {
1063 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n User admin\n");
1064 config.add_forward("myserver", "LocalForward", "8080 localhost:80");
1065 let output = config.serialize();
1066 assert!(output.contains("LocalForward 8080 localhost:80"));
1067 assert!(output.contains("HostName 10.0.0.1"));
1069 assert!(output.contains("User admin"));
1070 }
1071
1072 #[test]
1073 fn add_forward_preserves_indentation() {
1074 let mut config = parse_str("Host myserver\n\tHostName 10.0.0.1\n");
1075 config.add_forward("myserver", "LocalForward", "8080 localhost:80");
1076 let output = config.serialize();
1077 assert!(output.contains("\tLocalForward 8080 localhost:80"));
1078 }
1079
1080 #[test]
1081 fn add_multiple_forwards_same_type() {
1082 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
1083 config.add_forward("myserver", "LocalForward", "8080 localhost:80");
1084 config.add_forward("myserver", "LocalForward", "9090 localhost:90");
1085 let output = config.serialize();
1086 assert!(output.contains("LocalForward 8080 localhost:80"));
1087 assert!(output.contains("LocalForward 9090 localhost:90"));
1088 }
1089
1090 #[test]
1091 fn remove_forward_removes_exact_match() {
1092 let mut config = parse_str(
1093 "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n LocalForward 9090 localhost:90\n",
1094 );
1095 config.remove_forward("myserver", "LocalForward", "8080 localhost:80");
1096 let output = config.serialize();
1097 assert!(!output.contains("8080 localhost:80"));
1098 assert!(output.contains("9090 localhost:90"));
1099 }
1100
1101 #[test]
1102 fn remove_forward_leaves_other_directives() {
1103 let mut config = parse_str(
1104 "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n User admin\n",
1105 );
1106 config.remove_forward("myserver", "LocalForward", "8080 localhost:80");
1107 let output = config.serialize();
1108 assert!(!output.contains("LocalForward"));
1109 assert!(output.contains("HostName 10.0.0.1"));
1110 assert!(output.contains("User admin"));
1111 }
1112
1113 #[test]
1114 fn remove_forward_no_match_is_noop() {
1115 let original = "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n";
1116 let mut config = parse_str(original);
1117 config.remove_forward("myserver", "LocalForward", "9999 localhost:99");
1118 assert_eq!(config.serialize(), original);
1119 }
1120
1121 #[test]
1122 fn host_entry_tunnel_count_populated() {
1123 let config = parse_str(
1124 "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n DynamicForward 1080\n",
1125 );
1126 let entries = config.host_entries();
1127 assert_eq!(entries.len(), 1);
1128 assert_eq!(entries[0].tunnel_count, 2);
1129 }
1130
1131 #[test]
1132 fn remove_forward_returns_true_on_match() {
1133 let mut config = parse_str(
1134 "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n",
1135 );
1136 assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
1137 }
1138
1139 #[test]
1140 fn remove_forward_returns_false_on_no_match() {
1141 let mut config = parse_str(
1142 "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n",
1143 );
1144 assert!(!config.remove_forward("myserver", "LocalForward", "9999 localhost:99"));
1145 }
1146
1147 #[test]
1148 fn remove_forward_returns_false_for_unknown_host() {
1149 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
1150 assert!(!config.remove_forward("nohost", "LocalForward", "8080 localhost:80"));
1151 }
1152
1153 #[test]
1154 fn has_forward_finds_match() {
1155 let config = parse_str(
1156 "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n",
1157 );
1158 assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1159 }
1160
1161 #[test]
1162 fn has_forward_no_match() {
1163 let config = parse_str(
1164 "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n",
1165 );
1166 assert!(!config.has_forward("myserver", "LocalForward", "9999 localhost:99"));
1167 assert!(!config.has_forward("nohost", "LocalForward", "8080 localhost:80"));
1168 }
1169
1170 #[test]
1171 fn has_forward_case_insensitive_key() {
1172 let config = parse_str(
1173 "Host myserver\n HostName 10.0.0.1\n localforward 8080 localhost:80\n",
1174 );
1175 assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1176 }
1177
1178 #[test]
1179 fn add_forward_to_empty_block() {
1180 let mut config = parse_str("Host myserver\n");
1181 config.add_forward("myserver", "LocalForward", "8080 localhost:80");
1182 let output = config.serialize();
1183 assert!(output.contains("LocalForward 8080 localhost:80"));
1184 }
1185
1186 #[test]
1187 fn remove_forward_case_insensitive_key_match() {
1188 let mut config = parse_str(
1189 "Host myserver\n HostName 10.0.0.1\n localforward 8080 localhost:80\n",
1190 );
1191 assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
1192 assert!(!config.serialize().contains("localforward"));
1193 }
1194
1195 #[test]
1196 fn tunnel_count_case_insensitive() {
1197 let config = parse_str(
1198 "Host myserver\n localforward 8080 localhost:80\n REMOTEFORWARD 9090 localhost:90\n dynamicforward 1080\n",
1199 );
1200 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1201 assert_eq!(block.tunnel_count(), 3);
1202 } else {
1203 panic!("Expected HostBlock");
1204 }
1205 }
1206
1207 #[test]
1208 fn tunnel_directives_extracts_all_types() {
1209 let config = parse_str(
1210 "Host myserver\n LocalForward 8080 localhost:80\n RemoteForward 9090 localhost:3000\n DynamicForward 1080\n",
1211 );
1212 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1213 let rules = block.tunnel_directives();
1214 assert_eq!(rules.len(), 3);
1215 assert_eq!(rules[0].tunnel_type, crate::tunnel::TunnelType::Local);
1216 assert_eq!(rules[1].tunnel_type, crate::tunnel::TunnelType::Remote);
1217 assert_eq!(rules[2].tunnel_type, crate::tunnel::TunnelType::Dynamic);
1218 } else {
1219 panic!("Expected HostBlock");
1220 }
1221 }
1222
1223 #[test]
1224 fn tunnel_directives_skips_malformed() {
1225 let config = parse_str(
1226 "Host myserver\n LocalForward not_valid\n DynamicForward 1080\n",
1227 );
1228 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1229 let rules = block.tunnel_directives();
1230 assert_eq!(rules.len(), 1);
1231 assert_eq!(rules[0].bind_port, 1080);
1232 } else {
1233 panic!("Expected HostBlock");
1234 }
1235 }
1236
1237 #[test]
1238 fn find_tunnel_directives_multi_pattern_host() {
1239 let config = parse_str(
1240 "Host prod staging\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n",
1241 );
1242 let rules = config.find_tunnel_directives("prod");
1243 assert_eq!(rules.len(), 1);
1244 assert_eq!(rules[0].bind_port, 8080);
1245 let rules2 = config.find_tunnel_directives("staging");
1246 assert_eq!(rules2.len(), 1);
1247 }
1248
1249 #[test]
1250 fn find_tunnel_directives_no_match() {
1251 let config = parse_str(
1252 "Host myserver\n LocalForward 8080 localhost:80\n",
1253 );
1254 let rules = config.find_tunnel_directives("nohost");
1255 assert!(rules.is_empty());
1256 }
1257
1258 #[test]
1259 fn has_forward_exact_match() {
1260 let config = parse_str(
1261 "Host myserver\n LocalForward 8080 localhost:80\n",
1262 );
1263 assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1264 assert!(!config.has_forward("myserver", "LocalForward", "9090 localhost:80"));
1265 assert!(!config.has_forward("myserver", "RemoteForward", "8080 localhost:80"));
1266 assert!(!config.has_forward("nohost", "LocalForward", "8080 localhost:80"));
1267 }
1268
1269 #[test]
1270 fn has_forward_whitespace_normalized() {
1271 let config = parse_str(
1272 "Host myserver\n LocalForward 8080 localhost:80\n",
1273 );
1274 assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1276 }
1277
1278 #[test]
1279 fn has_forward_multi_pattern_host() {
1280 let config = parse_str(
1281 "Host prod staging\n LocalForward 8080 localhost:80\n",
1282 );
1283 assert!(config.has_forward("prod", "LocalForward", "8080 localhost:80"));
1284 assert!(config.has_forward("staging", "LocalForward", "8080 localhost:80"));
1285 }
1286
1287 #[test]
1288 fn add_forward_multi_pattern_host() {
1289 let mut config = parse_str(
1290 "Host prod staging\n HostName 10.0.0.1\n",
1291 );
1292 config.add_forward("prod", "LocalForward", "8080 localhost:80");
1293 assert!(config.has_forward("prod", "LocalForward", "8080 localhost:80"));
1294 assert!(config.has_forward("staging", "LocalForward", "8080 localhost:80"));
1295 }
1296
1297 #[test]
1298 fn remove_forward_multi_pattern_host() {
1299 let mut config = parse_str(
1300 "Host prod staging\n LocalForward 8080 localhost:80\n LocalForward 9090 localhost:90\n",
1301 );
1302 assert!(config.remove_forward("staging", "LocalForward", "8080 localhost:80"));
1303 assert!(!config.has_forward("staging", "LocalForward", "8080 localhost:80"));
1304 assert!(config.has_forward("staging", "LocalForward", "9090 localhost:90"));
1306 }
1307
1308 #[test]
1309 fn edit_tunnel_detects_duplicate_after_remove() {
1310 let mut config = parse_str(
1312 "Host myserver\n LocalForward 8080 localhost:80\n LocalForward 9090 localhost:90\n",
1313 );
1314 assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
1316 assert!(config.has_forward("myserver", "LocalForward", "9090 localhost:90"));
1318 }
1319
1320 #[test]
1321 fn has_forward_tab_whitespace_normalized() {
1322 let config = parse_str(
1323 "Host myserver\n LocalForward 8080\tlocalhost:80\n",
1324 );
1325 assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1327 }
1328
1329 #[test]
1330 fn remove_forward_tab_whitespace_normalized() {
1331 let mut config = parse_str(
1332 "Host myserver\n LocalForward 8080\tlocalhost:80\n",
1333 );
1334 assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
1336 assert!(!config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1337 }
1338
1339 #[test]
1340 fn upsert_preserves_space_separator_when_value_contains_equals() {
1341 let mut config = parse_str(
1342 "Host myserver\n IdentityFile ~/.ssh/id=prod\n",
1343 );
1344 let entry = HostEntry {
1345 alias: "myserver".to_string(),
1346 hostname: "10.0.0.1".to_string(),
1347 identity_file: "~/.ssh/id=staging".to_string(),
1348 port: 22,
1349 ..Default::default()
1350 };
1351 config.update_host("myserver", &entry);
1352 let output = config.serialize();
1353 assert!(output.contains(" IdentityFile ~/.ssh/id=staging"), "got: {}", output);
1355 assert!(!output.contains("IdentityFile="), "got: {}", output);
1356 }
1357
1358 #[test]
1359 fn upsert_preserves_equals_separator() {
1360 let mut config = parse_str(
1361 "Host myserver\n IdentityFile=~/.ssh/id_rsa\n",
1362 );
1363 let entry = HostEntry {
1364 alias: "myserver".to_string(),
1365 hostname: "10.0.0.1".to_string(),
1366 identity_file: "~/.ssh/id_ed25519".to_string(),
1367 port: 22,
1368 ..Default::default()
1369 };
1370 config.update_host("myserver", &entry);
1371 let output = config.serialize();
1372 assert!(output.contains("IdentityFile=~/.ssh/id_ed25519"), "got: {}", output);
1373 }
1374
1375 #[test]
1376 fn upsert_preserves_spaced_equals_separator() {
1377 let mut config = parse_str(
1378 "Host myserver\n IdentityFile = ~/.ssh/id_rsa\n",
1379 );
1380 let entry = HostEntry {
1381 alias: "myserver".to_string(),
1382 hostname: "10.0.0.1".to_string(),
1383 identity_file: "~/.ssh/id_ed25519".to_string(),
1384 port: 22,
1385 ..Default::default()
1386 };
1387 config.update_host("myserver", &entry);
1388 let output = config.serialize();
1389 assert!(output.contains("IdentityFile = ~/.ssh/id_ed25519"), "got: {}", output);
1390 }
1391
1392 #[test]
1393 fn is_included_host_false_for_main_config() {
1394 let config = parse_str(
1395 "Host myserver\n HostName 10.0.0.1\n",
1396 );
1397 assert!(!config.is_included_host("myserver"));
1398 }
1399
1400 #[test]
1401 fn is_included_host_false_for_nonexistent() {
1402 let config = parse_str(
1403 "Host myserver\n HostName 10.0.0.1\n",
1404 );
1405 assert!(!config.is_included_host("nohost"));
1406 }
1407
1408 #[test]
1409 fn is_included_host_multi_pattern_main_config() {
1410 let config = parse_str(
1411 "Host prod staging\n HostName 10.0.0.1\n",
1412 );
1413 assert!(!config.is_included_host("prod"));
1414 assert!(!config.is_included_host("staging"));
1415 }
1416
1417 fn first_block(config: &SshConfigFile) -> &HostBlock {
1422 match config.elements.first().unwrap() {
1423 ConfigElement::HostBlock(b) => b,
1424 _ => panic!("Expected HostBlock"),
1425 }
1426 }
1427
1428 fn block_by_index(config: &SshConfigFile, idx: usize) -> &HostBlock {
1429 let mut count = 0;
1430 for el in &config.elements {
1431 if let ConfigElement::HostBlock(b) = el {
1432 if count == idx {
1433 return b;
1434 }
1435 count += 1;
1436 }
1437 }
1438 panic!("No HostBlock at index {}", idx);
1439 }
1440
1441 #[test]
1442 fn askpass_returns_none_when_absent() {
1443 let config = parse_str("Host myserver\n HostName 10.0.0.1\n");
1444 assert_eq!(first_block(&config).askpass(), None);
1445 }
1446
1447 #[test]
1448 fn askpass_returns_keychain() {
1449 let config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass keychain\n");
1450 assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
1451 }
1452
1453 #[test]
1454 fn askpass_returns_op_uri() {
1455 let config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass op://Vault/Item/field\n");
1456 assert_eq!(first_block(&config).askpass(), Some("op://Vault/Item/field".to_string()));
1457 }
1458
1459 #[test]
1460 fn askpass_returns_vault_with_field() {
1461 let config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass vault:secret/ssh#password\n");
1462 assert_eq!(first_block(&config).askpass(), Some("vault:secret/ssh#password".to_string()));
1463 }
1464
1465 #[test]
1466 fn askpass_returns_bw_source() {
1467 let config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass bw:my-item\n");
1468 assert_eq!(first_block(&config).askpass(), Some("bw:my-item".to_string()));
1469 }
1470
1471 #[test]
1472 fn askpass_returns_pass_source() {
1473 let config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass pass:ssh/prod\n");
1474 assert_eq!(first_block(&config).askpass(), Some("pass:ssh/prod".to_string()));
1475 }
1476
1477 #[test]
1478 fn askpass_returns_custom_command() {
1479 let config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass get-pass %a %h\n");
1480 assert_eq!(first_block(&config).askpass(), Some("get-pass %a %h".to_string()));
1481 }
1482
1483 #[test]
1484 fn askpass_ignores_empty_value() {
1485 let config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass \n");
1486 assert_eq!(first_block(&config).askpass(), None);
1487 }
1488
1489 #[test]
1490 fn askpass_ignores_non_askpass_purple_comments() {
1491 let config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:tags prod\n");
1492 assert_eq!(first_block(&config).askpass(), None);
1493 }
1494
1495 #[test]
1496 fn set_askpass_adds_comment() {
1497 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
1498 config.set_host_askpass("myserver", "keychain");
1499 assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
1500 }
1501
1502 #[test]
1503 fn set_askpass_replaces_existing() {
1504 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass keychain\n");
1505 config.set_host_askpass("myserver", "op://V/I/p");
1506 assert_eq!(first_block(&config).askpass(), Some("op://V/I/p".to_string()));
1507 }
1508
1509 #[test]
1510 fn set_askpass_empty_removes_comment() {
1511 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass keychain\n");
1512 config.set_host_askpass("myserver", "");
1513 assert_eq!(first_block(&config).askpass(), None);
1514 }
1515
1516 #[test]
1517 fn set_askpass_preserves_other_directives() {
1518 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n User admin\n # purple:tags prod\n");
1519 config.set_host_askpass("myserver", "vault:secret/ssh");
1520 assert_eq!(first_block(&config).askpass(), Some("vault:secret/ssh".to_string()));
1521 let entry = first_block(&config).to_host_entry();
1522 assert_eq!(entry.user, "admin");
1523 assert!(entry.tags.contains(&"prod".to_string()));
1524 }
1525
1526 #[test]
1527 fn set_askpass_preserves_indent() {
1528 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
1529 config.set_host_askpass("myserver", "keychain");
1530 let raw = first_block(&config).directives.iter()
1531 .find(|d| d.raw_line.contains("purple:askpass"))
1532 .unwrap();
1533 assert!(raw.raw_line.starts_with(" "), "Expected 4-space indent, got: {:?}", raw.raw_line);
1534 }
1535
1536 #[test]
1537 fn set_askpass_on_nonexistent_host() {
1538 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
1539 config.set_host_askpass("nohost", "keychain");
1540 assert_eq!(first_block(&config).askpass(), None);
1541 }
1542
1543 #[test]
1544 fn to_entry_includes_askpass() {
1545 let config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass bw:item\n");
1546 let entries = config.host_entries();
1547 assert_eq!(entries.len(), 1);
1548 assert_eq!(entries[0].askpass, Some("bw:item".to_string()));
1549 }
1550
1551 #[test]
1552 fn to_entry_askpass_none_when_absent() {
1553 let config = parse_str("Host myserver\n HostName 10.0.0.1\n");
1554 let entries = config.host_entries();
1555 assert_eq!(entries.len(), 1);
1556 assert_eq!(entries[0].askpass, None);
1557 }
1558
1559 #[test]
1560 fn set_askpass_vault_with_hash_field() {
1561 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
1562 config.set_host_askpass("myserver", "vault:secret/data/team#api_key");
1563 assert_eq!(first_block(&config).askpass(), Some("vault:secret/data/team#api_key".to_string()));
1564 }
1565
1566 #[test]
1567 fn set_askpass_custom_command_with_percent() {
1568 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
1569 config.set_host_askpass("myserver", "get-pass %a %h");
1570 assert_eq!(first_block(&config).askpass(), Some("get-pass %a %h".to_string()));
1571 }
1572
1573 #[test]
1574 fn multiple_hosts_independent_askpass() {
1575 let mut config = parse_str("Host alpha\n HostName a.com\n\nHost beta\n HostName b.com\n");
1576 config.set_host_askpass("alpha", "keychain");
1577 config.set_host_askpass("beta", "vault:secret/ssh");
1578 assert_eq!(block_by_index(&config, 0).askpass(), Some("keychain".to_string()));
1579 assert_eq!(block_by_index(&config, 1).askpass(), Some("vault:secret/ssh".to_string()));
1580 }
1581
1582 #[test]
1583 fn set_askpass_then_clear_then_set_again() {
1584 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
1585 config.set_host_askpass("myserver", "keychain");
1586 assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
1587 config.set_host_askpass("myserver", "");
1588 assert_eq!(first_block(&config).askpass(), None);
1589 config.set_host_askpass("myserver", "op://V/I/p");
1590 assert_eq!(first_block(&config).askpass(), Some("op://V/I/p".to_string()));
1591 }
1592
1593 #[test]
1594 fn askpass_tab_indent_preserved() {
1595 let mut config = parse_str("Host myserver\n\tHostName 10.0.0.1\n");
1596 config.set_host_askpass("myserver", "pass:ssh/prod");
1597 let raw = first_block(&config).directives.iter()
1598 .find(|d| d.raw_line.contains("purple:askpass"))
1599 .unwrap();
1600 assert!(raw.raw_line.starts_with("\t"), "Expected tab indent, got: {:?}", raw.raw_line);
1601 }
1602
1603 #[test]
1604 fn askpass_coexists_with_provider_comment() {
1605 let config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:provider do:123\n # purple:askpass keychain\n");
1606 let block = first_block(&config);
1607 assert_eq!(block.askpass(), Some("keychain".to_string()));
1608 assert!(block.provider().is_some());
1609 }
1610
1611 #[test]
1612 fn set_askpass_does_not_remove_tags() {
1613 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:tags prod,staging\n");
1614 config.set_host_askpass("myserver", "keychain");
1615 let entry = first_block(&config).to_host_entry();
1616 assert_eq!(entry.askpass, Some("keychain".to_string()));
1617 assert!(entry.tags.contains(&"prod".to_string()));
1618 assert!(entry.tags.contains(&"staging".to_string()));
1619 }
1620
1621 #[test]
1622 fn askpass_idempotent_set_same_value() {
1623 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass keychain\n");
1624 config.set_host_askpass("myserver", "keychain");
1625 assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
1626 let serialized = config.serialize();
1627 assert_eq!(serialized.matches("purple:askpass").count(), 1, "Should have exactly one askpass comment");
1628 }
1629
1630 #[test]
1631 fn askpass_with_value_containing_equals() {
1632 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
1633 config.set_host_askpass("myserver", "cmd --opt=val %h");
1634 assert_eq!(first_block(&config).askpass(), Some("cmd --opt=val %h".to_string()));
1635 }
1636
1637 #[test]
1638 fn askpass_with_value_containing_hash() {
1639 let config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass vault:a/b#c\n");
1640 assert_eq!(first_block(&config).askpass(), Some("vault:a/b#c".to_string()));
1641 }
1642
1643 #[test]
1644 fn askpass_with_long_op_uri() {
1645 let uri = "op://My Personal Vault/SSH Production Server/password";
1646 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
1647 config.set_host_askpass("myserver", uri);
1648 assert_eq!(first_block(&config).askpass(), Some(uri.to_string()));
1649 }
1650
1651 #[test]
1652 fn askpass_does_not_interfere_with_host_matching() {
1653 let config = parse_str("Host myserver\n HostName 10.0.0.1\n User root\n # purple:askpass keychain\n");
1655 let entry = first_block(&config).to_host_entry();
1656 assert_eq!(entry.user, "root");
1657 assert_eq!(entry.hostname, "10.0.0.1");
1658 assert_eq!(entry.askpass, Some("keychain".to_string()));
1659 }
1660
1661 #[test]
1662 fn set_askpass_on_host_with_many_directives() {
1663 let config_str = "\
1664Host myserver
1665 HostName 10.0.0.1
1666 User admin
1667 Port 2222
1668 IdentityFile ~/.ssh/id_ed25519
1669 ProxyJump bastion
1670 # purple:tags prod,us-east
1671";
1672 let mut config = parse_str(config_str);
1673 config.set_host_askpass("myserver", "pass:ssh/prod");
1674 let entry = first_block(&config).to_host_entry();
1675 assert_eq!(entry.askpass, Some("pass:ssh/prod".to_string()));
1676 assert_eq!(entry.user, "admin");
1677 assert_eq!(entry.port, 2222);
1678 assert!(entry.tags.contains(&"prod".to_string()));
1679 }
1680
1681 #[test]
1682 fn askpass_with_crlf_line_endings() {
1683 let config = parse_str("Host myserver\r\n HostName 10.0.0.1\r\n # purple:askpass keychain\r\n");
1684 assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
1685 }
1686
1687 #[test]
1688 fn askpass_only_on_first_matching_host() {
1689 let config = parse_str("Host dup\n HostName a.com\n # purple:askpass keychain\n\nHost dup\n HostName b.com\n # purple:askpass vault:x\n");
1691 let entries = config.host_entries();
1692 assert_eq!(entries[0].askpass, Some("keychain".to_string()));
1694 }
1695
1696 #[test]
1697 fn set_askpass_preserves_other_non_directive_comments() {
1698 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";
1699 let mut config = parse_str(config_str);
1700 config.set_host_askpass("myserver", "new-source");
1701 let serialized = config.serialize();
1702 assert!(serialized.contains("# This is a user comment"));
1703 assert!(serialized.contains("# Another comment"));
1704 assert!(serialized.contains("# purple:askpass new-source"));
1705 assert!(!serialized.contains("# purple:askpass old"));
1706 }
1707
1708 #[test]
1709 fn askpass_mixed_with_tunnel_directives() {
1710 let config_str = "\
1711Host myserver
1712 HostName 10.0.0.1
1713 LocalForward 8080 localhost:80
1714 # purple:askpass bw:item
1715 RemoteForward 9090 localhost:9090
1716";
1717 let config = parse_str(config_str);
1718 let entry = first_block(&config).to_host_entry();
1719 assert_eq!(entry.askpass, Some("bw:item".to_string()));
1720 assert_eq!(entry.tunnel_count, 2);
1721 }
1722
1723 #[test]
1728 fn set_askpass_idempotent_same_value() {
1729 let config_str = "Host myserver\n HostName 10.0.0.1\n # purple:askpass keychain\n";
1730 let mut config = parse_str(config_str);
1731 config.set_host_askpass("myserver", "keychain");
1732 let output = config.serialize();
1733 assert_eq!(output.matches("purple:askpass").count(), 1);
1735 assert!(output.contains("# purple:askpass keychain"));
1736 }
1737
1738 #[test]
1739 fn set_askpass_with_equals_in_value() {
1740 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
1741 config.set_host_askpass("myserver", "cmd --opt=val");
1742 let entries = config.host_entries();
1743 assert_eq!(entries[0].askpass, Some("cmd --opt=val".to_string()));
1744 }
1745
1746 #[test]
1747 fn set_askpass_with_hash_in_value() {
1748 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
1749 config.set_host_askpass("myserver", "vault:secret/data#field");
1750 let entries = config.host_entries();
1751 assert_eq!(entries[0].askpass, Some("vault:secret/data#field".to_string()));
1752 }
1753
1754 #[test]
1755 fn set_askpass_long_op_uri() {
1756 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
1757 let long_uri = "op://My Personal Vault/SSH Production Server Key/password";
1758 config.set_host_askpass("myserver", long_uri);
1759 assert_eq!(config.host_entries()[0].askpass, Some(long_uri.to_string()));
1760 }
1761
1762 #[test]
1763 fn askpass_host_with_multi_pattern_is_skipped() {
1764 let config_str = "Host prod staging\n HostName 10.0.0.1\n";
1767 let mut config = parse_str(config_str);
1768 config.set_host_askpass("prod", "keychain");
1769 assert!(config.host_entries().is_empty());
1771 }
1772
1773 #[test]
1774 fn askpass_survives_directive_reorder() {
1775 let config_str = "\
1777Host myserver
1778 # purple:askpass op://V/I/p
1779 HostName 10.0.0.1
1780 User root
1781";
1782 let config = parse_str(config_str);
1783 let entry = first_block(&config).to_host_entry();
1784 assert_eq!(entry.askpass, Some("op://V/I/p".to_string()));
1785 assert_eq!(entry.hostname, "10.0.0.1");
1786 }
1787
1788 #[test]
1789 fn askpass_among_many_purple_comments() {
1790 let config_str = "\
1791Host myserver
1792 HostName 10.0.0.1
1793 # purple:tags prod,us-east
1794 # purple:provider do:12345
1795 # purple:askpass pass:ssh/prod
1796";
1797 let config = parse_str(config_str);
1798 let entry = first_block(&config).to_host_entry();
1799 assert_eq!(entry.askpass, Some("pass:ssh/prod".to_string()));
1800 assert!(entry.tags.contains(&"prod".to_string()));
1801 }
1802
1803 #[test]
1804 fn meta_empty_when_no_comment() {
1805 let config_str = "Host myhost\n HostName 1.2.3.4\n";
1806 let config = parse_str(config_str);
1807 let meta = first_block(&config).meta();
1808 assert!(meta.is_empty());
1809 }
1810
1811 #[test]
1812 fn meta_parses_key_value_pairs() {
1813 let config_str = "\
1814Host myhost
1815 HostName 1.2.3.4
1816 # purple:meta region=nyc3,plan=s-1vcpu-1gb
1817";
1818 let config = parse_str(config_str);
1819 let meta = first_block(&config).meta();
1820 assert_eq!(meta.len(), 2);
1821 assert_eq!(meta[0], ("region".to_string(), "nyc3".to_string()));
1822 assert_eq!(meta[1], ("plan".to_string(), "s-1vcpu-1gb".to_string()));
1823 }
1824
1825 #[test]
1826 fn meta_round_trip() {
1827 let config_str = "Host myhost\n HostName 1.2.3.4\n";
1828 let mut config = parse_str(config_str);
1829 let meta = vec![
1830 ("region".to_string(), "fra1".to_string()),
1831 ("plan".to_string(), "cx11".to_string()),
1832 ];
1833 config.set_host_meta("myhost", &meta);
1834 let output = config.serialize();
1835 assert!(output.contains("# purple:meta region=fra1,plan=cx11"));
1836
1837 let config2 = parse_str(&output);
1838 let parsed = first_block(&config2).meta();
1839 assert_eq!(parsed, meta);
1840 }
1841
1842 #[test]
1843 fn meta_replaces_existing() {
1844 let config_str = "\
1845Host myhost
1846 HostName 1.2.3.4
1847 # purple:meta region=old
1848";
1849 let mut config = parse_str(config_str);
1850 config.set_host_meta(
1851 "myhost",
1852 &[("region".to_string(), "new".to_string())],
1853 );
1854 let output = config.serialize();
1855 assert!(!output.contains("region=old"));
1856 assert!(output.contains("region=new"));
1857 }
1858
1859 #[test]
1860 fn meta_removed_when_empty() {
1861 let config_str = "\
1862Host myhost
1863 HostName 1.2.3.4
1864 # purple:meta region=nyc3
1865";
1866 let mut config = parse_str(config_str);
1867 config.set_host_meta("myhost", &[]);
1868 let output = config.serialize();
1869 assert!(!output.contains("purple:meta"));
1870 }
1871
1872 #[test]
1873 fn meta_sanitizes_commas_in_values() {
1874 let config_str = "Host myhost\n HostName 1.2.3.4\n";
1875 let mut config = parse_str(config_str);
1876 let meta = vec![("plan".to_string(), "s-1vcpu,1gb".to_string())];
1877 config.set_host_meta("myhost", &meta);
1878 let output = config.serialize();
1879 assert!(output.contains("plan=s-1vcpu1gb"));
1881
1882 let config2 = parse_str(&output);
1883 let parsed = first_block(&config2).meta();
1884 assert_eq!(parsed[0].1, "s-1vcpu1gb");
1885 }
1886
1887 #[test]
1888 fn meta_in_host_entry() {
1889 let config_str = "\
1890Host myhost
1891 HostName 1.2.3.4
1892 # purple:meta region=nyc3,plan=s-1vcpu-1gb
1893";
1894 let config = parse_str(config_str);
1895 let entry = first_block(&config).to_host_entry();
1896 assert_eq!(entry.provider_meta.len(), 2);
1897 assert_eq!(entry.provider_meta[0].0, "region");
1898 assert_eq!(entry.provider_meta[1].0, "plan");
1899 }
1900}