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