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