1use super::model::{Directive, HostBlock, HostEntry, PatternEntry};
9
10impl HostBlock {
11 pub(super) fn content_end(&self) -> usize {
13 let mut pos = self.directives.len();
14 while pos > 0 {
15 if self.directives[pos - 1].is_non_directive
16 && self.directives[pos - 1].raw_line.trim().is_empty()
17 {
18 pos -= 1;
19 } else {
20 break;
21 }
22 }
23 pos
24 }
25
26 #[allow(dead_code)]
28 pub(super) fn pop_trailing_blanks(&mut self) -> Vec<Directive> {
29 let end = self.content_end();
30 self.directives.drain(end..).collect()
31 }
32
33 #[allow(dead_code)]
35 pub(super) fn ensure_trailing_blank(&mut self) {
36 self.pop_trailing_blanks();
37 self.directives.push(Directive {
38 key: String::new(),
39 value: String::new(),
40 raw_line: String::new(),
41 is_non_directive: true,
42 });
43 }
44
45 pub(super) fn detect_indent(&self) -> String {
47 for d in &self.directives {
48 if !d.is_non_directive && !d.raw_line.is_empty() {
49 let trimmed = d.raw_line.trim_start();
50 let indent_len = d.raw_line.len() - trimmed.len();
51 if indent_len > 0 {
52 return d.raw_line[..indent_len].to_string();
53 }
54 }
55 }
56 " ".to_string()
57 }
58
59 pub fn tags(&self) -> Vec<String> {
61 for d in &self.directives {
62 if d.is_non_directive {
63 let trimmed = d.raw_line.trim();
64 if let Some(rest) = trimmed.strip_prefix("# purple:tags ") {
65 return rest
66 .split(',')
67 .map(|t| t.trim().to_string())
68 .filter(|t| !t.is_empty())
69 .collect();
70 }
71 }
72 }
73 Vec::new()
74 }
75
76 pub fn provider_tags(&self) -> Vec<String> {
78 for d in &self.directives {
79 if d.is_non_directive {
80 let trimmed = d.raw_line.trim();
81 if let Some(rest) = trimmed.strip_prefix("# purple:provider_tags ") {
82 return rest
83 .split(',')
84 .map(|t| t.trim().to_string())
85 .filter(|t| !t.is_empty())
86 .collect();
87 }
88 }
89 }
90 Vec::new()
91 }
92
93 pub fn has_provider_tags_comment(&self) -> bool {
96 self.directives.iter().any(|d| {
97 d.is_non_directive && {
98 let t = d.raw_line.trim();
99 t == "# purple:provider_tags" || t.starts_with("# purple:provider_tags ")
100 }
101 })
102 }
103
104 pub fn provider_user(&self) -> Option<String> {
108 self.single_value_marker("# purple:provider_user")
109 }
110
111 pub fn provider_key(&self) -> Option<String> {
114 self.single_value_marker("# purple:provider_key")
115 }
116
117 fn single_value_marker(&self, prefix: &str) -> Option<String> {
120 let with_space = format!("{} ", prefix);
121 for d in &self.directives {
122 if !d.is_non_directive {
123 continue;
124 }
125 let trimmed = d.raw_line.trim();
126 if let Some(rest) = trimmed.strip_prefix(with_space.as_str()) {
127 let value = rest.trim();
128 if !value.is_empty() {
129 return Some(value.to_string());
130 }
131 }
132 }
133 None
134 }
135
136 pub fn provider(&self) -> Option<(String, String)> {
140 self.provider_id()
141 .map(|(id, server_id)| (id.provider, server_id))
142 }
143
144 pub fn provider_raw(&self) -> Option<(String, String)> {
154 for d in &self.directives {
155 if !d.is_non_directive {
156 continue;
157 }
158 let trimmed = d.raw_line.trim();
159 let rest = match trimmed.strip_prefix("# purple:provider ") {
160 Some(r) => r.trim(),
161 None => continue,
162 };
163 let (provider, server_id) = rest.split_once(':')?;
164 let provider = provider.trim();
165 let server_id = server_id.trim();
166 if provider.is_empty() || server_id.is_empty() {
167 return None;
168 }
169 return Some((provider.to_string(), server_id.to_string()));
170 }
171 None
172 }
173
174 pub fn provider_id(&self) -> Option<(crate::providers::config::ProviderConfigId, String)> {
178 for d in &self.directives {
179 if !d.is_non_directive {
180 continue;
181 }
182 let trimmed = d.raw_line.trim();
183 let rest = match trimmed.strip_prefix("# purple:provider ") {
184 Some(r) => r.trim(),
185 None => continue,
186 };
187 let parts: Vec<&str> = rest.splitn(3, ':').collect();
190 return match parts.as_slice() {
191 [provider, server_id] => {
192 let provider = provider.trim();
193 let server_id = server_id.trim();
194 if provider.is_empty() || server_id.is_empty() {
195 return None;
196 }
197 Some((
198 crate::providers::config::ProviderConfigId::bare(provider),
199 server_id.to_string(),
200 ))
201 }
202 [provider, label, server_id] => {
203 let label = label.trim();
204 let provider = provider.trim();
205 let server_id = server_id.trim();
206 if provider.is_empty() || server_id.is_empty() {
211 return None;
212 }
213 if crate::providers::config::validate_label(label).is_ok() {
214 Some((
216 crate::providers::config::ProviderConfigId::labeled(provider, label),
217 server_id.to_string(),
218 ))
219 } else if label.is_empty() {
220 None
224 } else {
225 Some((
229 crate::providers::config::ProviderConfigId::bare(provider),
230 format!("{}:{}", label, server_id),
231 ))
232 }
233 }
234 _ => None,
235 };
236 }
237 None
238 }
239
240 pub fn set_provider_id(
243 &mut self,
244 id: &crate::providers::config::ProviderConfigId,
245 server_id: &str,
246 ) {
247 let server_id = Self::sanitize_raw_line_value(server_id);
251 let indent = self.detect_indent();
252 self.directives.retain(|d| {
253 !(d.is_non_directive && d.raw_line.trim().starts_with("# purple:provider "))
254 });
255 let pos = self.content_end();
256 self.directives.insert(
257 pos,
258 Directive {
259 key: String::new(),
260 value: String::new(),
261 raw_line: format!("{}# purple:provider {}:{}", indent, id, server_id),
262 is_non_directive: true,
263 },
264 );
265 }
266
267 pub fn askpass(&self) -> Option<String> {
269 for d in &self.directives {
270 if d.is_non_directive {
271 let trimmed = d.raw_line.trim();
272 if let Some(rest) = trimmed.strip_prefix("# purple:askpass ") {
273 let val = rest.trim();
274 if !val.is_empty() {
275 return Some(val.to_string());
276 }
277 }
278 }
279 }
280 None
281 }
282
283 pub fn vault_ssh(&self) -> Option<String> {
285 for d in &self.directives {
286 if d.is_non_directive {
287 let trimmed = d.raw_line.trim();
288 if let Some(rest) = trimmed.strip_prefix("# purple:vault-ssh ") {
289 let val = rest.trim();
290 if !val.is_empty() && crate::vault_ssh::is_valid_role(val) {
291 return Some(val.to_string());
292 }
293 }
294 }
295 }
296 None
297 }
298
299 pub fn set_vault_ssh(&mut self, role: &str) {
301 let role = Self::sanitize_raw_line_value(role);
302 let indent = self.detect_indent();
303 self.directives.retain(|d| {
304 !(d.is_non_directive && {
305 let t = d.raw_line.trim();
306 t == "# purple:vault-ssh" || t.starts_with("# purple:vault-ssh ")
307 })
308 });
309 if !role.is_empty() {
310 let pos = self.content_end();
311 self.directives.insert(
312 pos,
313 Directive {
314 key: String::new(),
315 value: String::new(),
316 raw_line: format!("{}# purple:vault-ssh {}", indent, role),
317 is_non_directive: true,
318 },
319 );
320 }
321 }
322
323 pub fn vault_addr(&self) -> Option<String> {
329 for d in &self.directives {
330 if d.is_non_directive {
331 let trimmed = d.raw_line.trim();
332 if let Some(rest) = trimmed.strip_prefix("# purple:vault-addr ") {
333 let val = rest.trim();
334 if !val.is_empty() && crate::vault_ssh::is_valid_vault_addr(val) {
335 return Some(val.to_string());
336 }
337 }
338 }
339 }
340 None
341 }
342
343 pub fn set_vault_addr(&mut self, url: &str) {
347 let url = Self::sanitize_raw_line_value(url);
348 let indent = self.detect_indent();
349 self.directives.retain(|d| {
350 !(d.is_non_directive && {
351 let t = d.raw_line.trim();
352 t == "# purple:vault-addr" || t.starts_with("# purple:vault-addr ")
353 })
354 });
355 if !url.is_empty() {
356 let pos = self.content_end();
357 self.directives.insert(
358 pos,
359 Directive {
360 key: String::new(),
361 value: String::new(),
362 raw_line: format!("{}# purple:vault-addr {}", indent, url),
363 is_non_directive: true,
364 },
365 );
366 }
367 }
368
369 pub fn set_askpass(&mut self, source: &str) {
372 let source = Self::sanitize_raw_line_value(source);
373 let indent = self.detect_indent();
374 self.directives.retain(|d| {
375 !(d.is_non_directive && {
376 let t = d.raw_line.trim();
377 t == "# purple:askpass" || t.starts_with("# purple:askpass ")
378 })
379 });
380 if !source.is_empty() {
381 let pos = self.content_end();
382 self.directives.insert(
383 pos,
384 Directive {
385 key: String::new(),
386 value: String::new(),
387 raw_line: format!("{}# purple:askpass {}", indent, source),
388 is_non_directive: true,
389 },
390 );
391 }
392 }
393
394 pub fn meta(&self) -> Vec<(String, String)> {
397 for d in &self.directives {
398 if d.is_non_directive {
399 let trimmed = d.raw_line.trim();
400 if let Some(rest) = trimmed.strip_prefix("# purple:meta ") {
401 return rest
402 .split(',')
403 .filter_map(|pair| {
404 let (k, v) = pair.split_once('=')?;
405 let k = k.trim();
406 let v = v.trim();
407 if k.is_empty() {
408 None
409 } else {
410 Some((k.to_string(), v.to_string()))
411 }
412 })
413 .collect();
414 }
415 }
416 }
417 Vec::new()
418 }
419
420 pub fn set_meta(&mut self, meta: &[(String, String)]) {
423 let indent = self.detect_indent();
424 self.directives.retain(|d| {
425 !(d.is_non_directive && {
426 let t = d.raw_line.trim();
427 t == "# purple:meta" || t.starts_with("# purple:meta ")
428 })
429 });
430 if !meta.is_empty() {
431 let encoded: Vec<String> = meta
432 .iter()
433 .map(|(k, v)| {
434 let clean_k = Self::sanitize_tag(&k.replace([',', '='], ""));
435 let clean_v = Self::sanitize_tag(&v.replace(',', ""));
436 format!("{}={}", clean_k, clean_v)
437 })
438 .collect();
439 let pos = self.content_end();
440 self.directives.insert(
441 pos,
442 Directive {
443 key: String::new(),
444 value: String::new(),
445 raw_line: format!("{}# purple:meta {}", indent, encoded.join(",")),
446 is_non_directive: true,
447 },
448 );
449 }
450 }
451
452 pub fn stale(&self) -> Option<u64> {
455 for d in &self.directives {
456 if d.is_non_directive {
457 let trimmed = d.raw_line.trim();
458 if let Some(rest) = trimmed.strip_prefix("# purple:stale ") {
459 return rest.trim().parse::<u64>().ok();
460 }
461 }
462 }
463 None
464 }
465
466 pub fn set_stale(&mut self, timestamp: u64) {
469 let indent = self.detect_indent();
470 self.clear_stale();
471 let pos = self.content_end();
472 self.directives.insert(
473 pos,
474 Directive {
475 key: String::new(),
476 value: String::new(),
477 raw_line: format!("{}# purple:stale {}", indent, timestamp),
478 is_non_directive: true,
479 },
480 );
481 }
482
483 pub fn clear_stale(&mut self) {
485 self.directives.retain(|d| {
486 !(d.is_non_directive && {
487 let t = d.raw_line.trim();
488 t == "# purple:stale" || t.starts_with("# purple:stale ")
489 })
490 });
491 }
492
493 pub(super) fn sanitize_tag(tag: &str) -> String {
496 tag.chars()
497 .filter(|c| {
498 !c.is_control()
499 && *c != ','
500 && !('\u{200B}'..='\u{200F}').contains(c) && !('\u{202A}'..='\u{202E}').contains(c) && !('\u{2066}'..='\u{2069}').contains(c) && *c != '\u{FEFF}' })
505 .take(128)
506 .collect()
507 }
508
509 pub(super) fn sanitize_raw_line_value(s: &str) -> std::borrow::Cow<'_, str> {
521 if !s.contains(['\n', '\r', '\0']) {
522 return std::borrow::Cow::Borrowed(s);
523 }
524 log::warn!(
525 "[purple] sanitized line-breaking characters from value before writing to ssh_config"
526 );
527 std::borrow::Cow::Owned(s.replace(['\n', '\r', '\0'], " "))
528 }
529
530 pub(super) fn render_value(value: &str) -> std::borrow::Cow<'_, str> {
543 if value.chars().any(char::is_whitespace) || value.contains('"') {
544 let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
545 std::borrow::Cow::Owned(format!("\"{escaped}\""))
546 } else {
547 std::borrow::Cow::Borrowed(value)
548 }
549 }
550
551 pub fn set_tags(&mut self, tags: &[String]) {
553 let indent = self.detect_indent();
554 self.directives.retain(|d| {
555 !(d.is_non_directive && {
556 let t = d.raw_line.trim();
557 t == "# purple:tags" || t.starts_with("# purple:tags ")
558 })
559 });
560 let sanitized: Vec<String> = tags
561 .iter()
562 .map(|t| Self::sanitize_tag(t))
563 .filter(|t| !t.is_empty())
564 .collect();
565 if !sanitized.is_empty() {
566 let pos = self.content_end();
567 self.directives.insert(
568 pos,
569 Directive {
570 key: String::new(),
571 value: String::new(),
572 raw_line: format!("{}# purple:tags {}", indent, sanitized.join(",")),
573 is_non_directive: true,
574 },
575 );
576 }
577 }
578
579 pub fn set_provider_tags(&mut self, tags: &[String]) {
582 let indent = self.detect_indent();
583 self.directives.retain(|d| {
584 !(d.is_non_directive && {
585 let t = d.raw_line.trim();
586 t == "# purple:provider_tags" || t.starts_with("# purple:provider_tags ")
587 })
588 });
589 let sanitized: Vec<String> = tags
590 .iter()
591 .map(|t| Self::sanitize_tag(t))
592 .filter(|t| !t.is_empty())
593 .collect();
594 let raw = if sanitized.is_empty() {
595 format!("{}# purple:provider_tags", indent)
596 } else {
597 format!("{}# purple:provider_tags {}", indent, sanitized.join(","))
598 };
599 let pos = self.content_end();
600 self.directives.insert(
601 pos,
602 Directive {
603 key: String::new(),
604 value: String::new(),
605 raw_line: raw,
606 is_non_directive: true,
607 },
608 );
609 }
610
611 pub fn set_provider_user(&mut self, user: &str) {
613 self.set_single_value_marker("# purple:provider_user", user);
614 }
615
616 pub fn set_provider_key(&mut self, key: &str) {
618 self.set_single_value_marker("# purple:provider_key", key);
619 }
620
621 fn set_single_value_marker(&mut self, prefix: &str, value: &str) {
625 let indent = self.detect_indent();
626 let with_space = format!("{} ", prefix);
627 self.directives.retain(|d| {
628 !(d.is_non_directive && {
629 let t = d.raw_line.trim();
630 t == prefix || t.starts_with(with_space.as_str())
631 })
632 });
633 let value = Self::sanitize_raw_line_value(value.trim());
634 if !value.is_empty() {
635 let pos = self.content_end();
636 self.directives.insert(
637 pos,
638 Directive {
639 key: String::new(),
640 value: String::new(),
641 raw_line: format!("{}{} {}", indent, prefix, value),
642 is_non_directive: true,
643 },
644 );
645 }
646 }
647
648 pub fn to_host_entry(&self) -> HostEntry {
654 let mut entry = HostEntry {
655 alias: self.host_pattern.clone(),
656 port: 22,
657 ..Default::default()
658 };
659 let mut port_seen = false;
660 let mut proxy_command_seen = false;
661 for d in &self.directives {
662 if d.is_non_directive {
663 continue;
664 }
665 if d.key.eq_ignore_ascii_case("hostname") {
666 if entry.hostname.is_empty() {
667 entry.hostname = d.value.clone();
668 }
669 } else if d.key.eq_ignore_ascii_case("user") {
670 if entry.user.is_empty() {
671 entry.user = d.value.clone();
672 }
673 } else if d.key.eq_ignore_ascii_case("port") {
674 if !port_seen {
675 entry.port = d.value.parse().unwrap_or(22);
676 port_seen = true;
677 }
678 } else if d.key.eq_ignore_ascii_case("identityfile") {
679 if entry.identity_file.is_empty() {
680 entry.identity_file = d.value.clone();
681 }
682 } else if d.key.eq_ignore_ascii_case("proxyjump") {
683 if entry.proxy_jump.is_empty() {
684 entry.proxy_jump = d.value.clone();
685 }
686 } else if d.key.eq_ignore_ascii_case("proxycommand") {
687 if !proxy_command_seen {
688 entry.has_proxy_command = !d.value.trim().eq_ignore_ascii_case("none");
689 proxy_command_seen = true;
690 }
691 } else if d.key.eq_ignore_ascii_case("certificatefile")
692 && entry.certificate_file.is_empty()
693 {
694 entry.certificate_file = d.value.clone();
695 }
696 }
697 entry.tags = self.tags();
698 entry.provider_tags = self.provider_tags();
699 entry.has_provider_tags = self.has_provider_tags_comment();
700 if let Some((id, _)) = self.provider_id() {
701 entry.provider = Some(id.provider);
702 entry.provider_label = id.label;
703 }
704 entry.tunnel_count = self.tunnel_count();
705 entry.askpass = self.askpass();
706 entry.vault_ssh = self.vault_ssh();
707 entry.vault_addr = self.vault_addr();
708 entry.provider_meta = self.meta();
709 entry.stale = self.stale();
710 entry.provider_user = self.provider_user();
711 entry.provider_key = self.provider_key();
712 entry
713 }
714
715 pub fn to_pattern_entry(&self) -> PatternEntry {
717 let mut entry = PatternEntry {
718 pattern: self.host_pattern.clone(),
719 hostname: String::new(),
720 user: String::new(),
721 port: 22,
722 identity_file: String::new(),
723 proxy_jump: String::new(),
724 tags: self.tags(),
725 askpass: self.askpass(),
726 source_file: None,
727 directives: Vec::new(),
728 };
729 let mut port_seen = false;
730 for d in &self.directives {
731 if d.is_non_directive {
732 continue;
733 }
734 match d.key.to_ascii_lowercase().as_str() {
735 "hostname" if entry.hostname.is_empty() => entry.hostname = d.value.clone(),
736 "user" if entry.user.is_empty() => entry.user = d.value.clone(),
737 "port" if !port_seen => {
738 entry.port = d.value.parse().unwrap_or(22);
739 port_seen = true;
740 }
741 "identityfile" if entry.identity_file.is_empty() => {
742 entry.identity_file = d.value.clone();
743 }
744 "proxyjump" if entry.proxy_jump.is_empty() => entry.proxy_jump = d.value.clone(),
745 _ => {}
746 }
747 entry.directives.push((d.key.clone(), d.value.clone()));
748 }
749 entry
750 }
751
752 pub fn tunnel_count(&self) -> u16 {
754 let count = self
755 .directives
756 .iter()
757 .filter(|d| {
758 !d.is_non_directive
759 && (d.key.eq_ignore_ascii_case("localforward")
760 || d.key.eq_ignore_ascii_case("remoteforward")
761 || d.key.eq_ignore_ascii_case("dynamicforward"))
762 })
763 .count();
764 count.min(u16::MAX as usize) as u16
765 }
766
767 #[allow(dead_code)]
769 pub fn has_tunnels(&self) -> bool {
770 self.directives.iter().any(|d| {
771 !d.is_non_directive
772 && (d.key.eq_ignore_ascii_case("localforward")
773 || d.key.eq_ignore_ascii_case("remoteforward")
774 || d.key.eq_ignore_ascii_case("dynamicforward"))
775 })
776 }
777
778 pub fn tunnel_directives(&self) -> Vec<crate::tunnel::TunnelRule> {
780 self.directives
781 .iter()
782 .filter(|d| !d.is_non_directive)
783 .filter_map(|d| crate::tunnel::TunnelRule::parse_value(&d.key, &d.value))
784 .collect()
785 }
786}