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(&self) -> Option<(String, String)> {
108 self.provider_id()
109 .map(|(id, server_id)| (id.provider, server_id))
110 }
111
112 pub fn provider_raw(&self) -> Option<(String, String)> {
122 for d in &self.directives {
123 if !d.is_non_directive {
124 continue;
125 }
126 let trimmed = d.raw_line.trim();
127 let rest = match trimmed.strip_prefix("# purple:provider ") {
128 Some(r) => r.trim(),
129 None => continue,
130 };
131 let (provider, server_id) = rest.split_once(':')?;
132 let provider = provider.trim();
133 let server_id = server_id.trim();
134 if provider.is_empty() || server_id.is_empty() {
135 return None;
136 }
137 return Some((provider.to_string(), server_id.to_string()));
138 }
139 None
140 }
141
142 pub fn provider_id(&self) -> Option<(crate::providers::config::ProviderConfigId, String)> {
146 for d in &self.directives {
147 if !d.is_non_directive {
148 continue;
149 }
150 let trimmed = d.raw_line.trim();
151 let rest = match trimmed.strip_prefix("# purple:provider ") {
152 Some(r) => r.trim(),
153 None => continue,
154 };
155 let parts: Vec<&str> = rest.splitn(3, ':').collect();
158 return match parts.as_slice() {
159 [provider, server_id] => {
160 let provider = provider.trim();
161 let server_id = server_id.trim();
162 if provider.is_empty() || server_id.is_empty() {
163 return None;
164 }
165 Some((
166 crate::providers::config::ProviderConfigId::bare(provider),
167 server_id.to_string(),
168 ))
169 }
170 [provider, label, server_id] => {
171 let label = label.trim();
172 let provider = provider.trim();
173 let server_id = server_id.trim();
174 if provider.is_empty() || server_id.is_empty() {
179 return None;
180 }
181 if crate::providers::config::validate_label(label).is_ok() {
182 Some((
184 crate::providers::config::ProviderConfigId::labeled(provider, label),
185 server_id.to_string(),
186 ))
187 } else if label.is_empty() {
188 None
192 } else {
193 Some((
197 crate::providers::config::ProviderConfigId::bare(provider),
198 format!("{}:{}", label, server_id),
199 ))
200 }
201 }
202 _ => None,
203 };
204 }
205 None
206 }
207
208 pub fn set_provider_id(
211 &mut self,
212 id: &crate::providers::config::ProviderConfigId,
213 server_id: &str,
214 ) {
215 let server_id = Self::sanitize_raw_line_value(server_id);
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, id, 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 vault_ssh(&self) -> Option<String> {
253 for d in &self.directives {
254 if d.is_non_directive {
255 let trimmed = d.raw_line.trim();
256 if let Some(rest) = trimmed.strip_prefix("# purple:vault-ssh ") {
257 let val = rest.trim();
258 if !val.is_empty() && crate::vault_ssh::is_valid_role(val) {
259 return Some(val.to_string());
260 }
261 }
262 }
263 }
264 None
265 }
266
267 pub fn set_vault_ssh(&mut self, role: &str) {
269 let role = Self::sanitize_raw_line_value(role);
270 let indent = self.detect_indent();
271 self.directives.retain(|d| {
272 !(d.is_non_directive && {
273 let t = d.raw_line.trim();
274 t == "# purple:vault-ssh" || t.starts_with("# purple:vault-ssh ")
275 })
276 });
277 if !role.is_empty() {
278 let pos = self.content_end();
279 self.directives.insert(
280 pos,
281 Directive {
282 key: String::new(),
283 value: String::new(),
284 raw_line: format!("{}# purple:vault-ssh {}", indent, role),
285 is_non_directive: true,
286 },
287 );
288 }
289 }
290
291 pub fn vault_addr(&self) -> Option<String> {
297 for d in &self.directives {
298 if d.is_non_directive {
299 let trimmed = d.raw_line.trim();
300 if let Some(rest) = trimmed.strip_prefix("# purple:vault-addr ") {
301 let val = rest.trim();
302 if !val.is_empty() && crate::vault_ssh::is_valid_vault_addr(val) {
303 return Some(val.to_string());
304 }
305 }
306 }
307 }
308 None
309 }
310
311 pub fn set_vault_addr(&mut self, url: &str) {
315 let url = Self::sanitize_raw_line_value(url);
316 let indent = self.detect_indent();
317 self.directives.retain(|d| {
318 !(d.is_non_directive && {
319 let t = d.raw_line.trim();
320 t == "# purple:vault-addr" || t.starts_with("# purple:vault-addr ")
321 })
322 });
323 if !url.is_empty() {
324 let pos = self.content_end();
325 self.directives.insert(
326 pos,
327 Directive {
328 key: String::new(),
329 value: String::new(),
330 raw_line: format!("{}# purple:vault-addr {}", indent, url),
331 is_non_directive: true,
332 },
333 );
334 }
335 }
336
337 pub fn set_askpass(&mut self, source: &str) {
340 let source = Self::sanitize_raw_line_value(source);
341 let indent = self.detect_indent();
342 self.directives.retain(|d| {
343 !(d.is_non_directive && {
344 let t = d.raw_line.trim();
345 t == "# purple:askpass" || t.starts_with("# purple:askpass ")
346 })
347 });
348 if !source.is_empty() {
349 let pos = self.content_end();
350 self.directives.insert(
351 pos,
352 Directive {
353 key: String::new(),
354 value: String::new(),
355 raw_line: format!("{}# purple:askpass {}", indent, source),
356 is_non_directive: true,
357 },
358 );
359 }
360 }
361
362 pub fn meta(&self) -> Vec<(String, String)> {
365 for d in &self.directives {
366 if d.is_non_directive {
367 let trimmed = d.raw_line.trim();
368 if let Some(rest) = trimmed.strip_prefix("# purple:meta ") {
369 return rest
370 .split(',')
371 .filter_map(|pair| {
372 let (k, v) = pair.split_once('=')?;
373 let k = k.trim();
374 let v = v.trim();
375 if k.is_empty() {
376 None
377 } else {
378 Some((k.to_string(), v.to_string()))
379 }
380 })
381 .collect();
382 }
383 }
384 }
385 Vec::new()
386 }
387
388 pub fn set_meta(&mut self, meta: &[(String, String)]) {
391 let indent = self.detect_indent();
392 self.directives.retain(|d| {
393 !(d.is_non_directive && {
394 let t = d.raw_line.trim();
395 t == "# purple:meta" || t.starts_with("# purple:meta ")
396 })
397 });
398 if !meta.is_empty() {
399 let encoded: Vec<String> = meta
400 .iter()
401 .map(|(k, v)| {
402 let clean_k = Self::sanitize_tag(&k.replace([',', '='], ""));
403 let clean_v = Self::sanitize_tag(&v.replace(',', ""));
404 format!("{}={}", clean_k, clean_v)
405 })
406 .collect();
407 let pos = self.content_end();
408 self.directives.insert(
409 pos,
410 Directive {
411 key: String::new(),
412 value: String::new(),
413 raw_line: format!("{}# purple:meta {}", indent, encoded.join(",")),
414 is_non_directive: true,
415 },
416 );
417 }
418 }
419
420 pub fn stale(&self) -> Option<u64> {
423 for d in &self.directives {
424 if d.is_non_directive {
425 let trimmed = d.raw_line.trim();
426 if let Some(rest) = trimmed.strip_prefix("# purple:stale ") {
427 return rest.trim().parse::<u64>().ok();
428 }
429 }
430 }
431 None
432 }
433
434 pub fn set_stale(&mut self, timestamp: u64) {
437 let indent = self.detect_indent();
438 self.clear_stale();
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:stale {}", indent, timestamp),
446 is_non_directive: true,
447 },
448 );
449 }
450
451 pub fn clear_stale(&mut self) {
453 self.directives.retain(|d| {
454 !(d.is_non_directive && {
455 let t = d.raw_line.trim();
456 t == "# purple:stale" || t.starts_with("# purple:stale ")
457 })
458 });
459 }
460
461 pub(super) fn sanitize_tag(tag: &str) -> String {
464 tag.chars()
465 .filter(|c| {
466 !c.is_control()
467 && *c != ','
468 && !('\u{200B}'..='\u{200F}').contains(c) && !('\u{202A}'..='\u{202E}').contains(c) && !('\u{2066}'..='\u{2069}').contains(c) && *c != '\u{FEFF}' })
473 .take(128)
474 .collect()
475 }
476
477 pub(super) fn sanitize_raw_line_value(s: &str) -> std::borrow::Cow<'_, str> {
489 if !s.contains(['\n', '\r', '\0']) {
490 return std::borrow::Cow::Borrowed(s);
491 }
492 log::warn!(
493 "[purple] sanitized line-breaking characters from value before writing to ssh_config"
494 );
495 std::borrow::Cow::Owned(s.replace(['\n', '\r', '\0'], " "))
496 }
497
498 pub(super) fn render_value(value: &str) -> std::borrow::Cow<'_, str> {
511 if value.chars().any(char::is_whitespace) || value.contains('"') {
512 let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
513 std::borrow::Cow::Owned(format!("\"{escaped}\""))
514 } else {
515 std::borrow::Cow::Borrowed(value)
516 }
517 }
518
519 pub fn set_tags(&mut self, tags: &[String]) {
521 let indent = self.detect_indent();
522 self.directives.retain(|d| {
523 !(d.is_non_directive && {
524 let t = d.raw_line.trim();
525 t == "# purple:tags" || t.starts_with("# purple:tags ")
526 })
527 });
528 let sanitized: Vec<String> = tags
529 .iter()
530 .map(|t| Self::sanitize_tag(t))
531 .filter(|t| !t.is_empty())
532 .collect();
533 if !sanitized.is_empty() {
534 let pos = self.content_end();
535 self.directives.insert(
536 pos,
537 Directive {
538 key: String::new(),
539 value: String::new(),
540 raw_line: format!("{}# purple:tags {}", indent, sanitized.join(",")),
541 is_non_directive: true,
542 },
543 );
544 }
545 }
546
547 pub fn set_provider_tags(&mut self, tags: &[String]) {
550 let indent = self.detect_indent();
551 self.directives.retain(|d| {
552 !(d.is_non_directive && {
553 let t = d.raw_line.trim();
554 t == "# purple:provider_tags" || t.starts_with("# purple:provider_tags ")
555 })
556 });
557 let sanitized: Vec<String> = tags
558 .iter()
559 .map(|t| Self::sanitize_tag(t))
560 .filter(|t| !t.is_empty())
561 .collect();
562 let raw = if sanitized.is_empty() {
563 format!("{}# purple:provider_tags", indent)
564 } else {
565 format!("{}# purple:provider_tags {}", indent, sanitized.join(","))
566 };
567 let pos = self.content_end();
568 self.directives.insert(
569 pos,
570 Directive {
571 key: String::new(),
572 value: String::new(),
573 raw_line: raw,
574 is_non_directive: true,
575 },
576 );
577 }
578
579 pub fn to_host_entry(&self) -> HostEntry {
585 let mut entry = HostEntry {
586 alias: self.host_pattern.clone(),
587 port: 22,
588 ..Default::default()
589 };
590 let mut port_seen = false;
591 for d in &self.directives {
592 if d.is_non_directive {
593 continue;
594 }
595 if d.key.eq_ignore_ascii_case("hostname") {
596 if entry.hostname.is_empty() {
597 entry.hostname = d.value.clone();
598 }
599 } else if d.key.eq_ignore_ascii_case("user") {
600 if entry.user.is_empty() {
601 entry.user = d.value.clone();
602 }
603 } else if d.key.eq_ignore_ascii_case("port") {
604 if !port_seen {
605 entry.port = d.value.parse().unwrap_or(22);
606 port_seen = true;
607 }
608 } else if d.key.eq_ignore_ascii_case("identityfile") {
609 if entry.identity_file.is_empty() {
610 entry.identity_file = d.value.clone();
611 }
612 } else if d.key.eq_ignore_ascii_case("proxyjump") {
613 if entry.proxy_jump.is_empty() {
614 entry.proxy_jump = d.value.clone();
615 }
616 } else if d.key.eq_ignore_ascii_case("certificatefile")
617 && entry.certificate_file.is_empty()
618 {
619 entry.certificate_file = d.value.clone();
620 }
621 }
622 entry.tags = self.tags();
623 entry.provider_tags = self.provider_tags();
624 entry.has_provider_tags = self.has_provider_tags_comment();
625 if let Some((id, _)) = self.provider_id() {
626 entry.provider = Some(id.provider);
627 entry.provider_label = id.label;
628 }
629 entry.tunnel_count = self.tunnel_count();
630 entry.askpass = self.askpass();
631 entry.vault_ssh = self.vault_ssh();
632 entry.vault_addr = self.vault_addr();
633 entry.provider_meta = self.meta();
634 entry.stale = self.stale();
635 entry
636 }
637
638 pub fn to_pattern_entry(&self) -> PatternEntry {
640 let mut entry = PatternEntry {
641 pattern: self.host_pattern.clone(),
642 hostname: String::new(),
643 user: String::new(),
644 port: 22,
645 identity_file: String::new(),
646 proxy_jump: String::new(),
647 tags: self.tags(),
648 askpass: self.askpass(),
649 source_file: None,
650 directives: Vec::new(),
651 };
652 let mut port_seen = false;
653 for d in &self.directives {
654 if d.is_non_directive {
655 continue;
656 }
657 match d.key.to_ascii_lowercase().as_str() {
658 "hostname" if entry.hostname.is_empty() => entry.hostname = d.value.clone(),
659 "user" if entry.user.is_empty() => entry.user = d.value.clone(),
660 "port" if !port_seen => {
661 entry.port = d.value.parse().unwrap_or(22);
662 port_seen = true;
663 }
664 "identityfile" if entry.identity_file.is_empty() => {
665 entry.identity_file = d.value.clone();
666 }
667 "proxyjump" if entry.proxy_jump.is_empty() => entry.proxy_jump = d.value.clone(),
668 _ => {}
669 }
670 entry.directives.push((d.key.clone(), d.value.clone()));
671 }
672 entry
673 }
674
675 pub fn tunnel_count(&self) -> u16 {
677 let count = self
678 .directives
679 .iter()
680 .filter(|d| {
681 !d.is_non_directive
682 && (d.key.eq_ignore_ascii_case("localforward")
683 || d.key.eq_ignore_ascii_case("remoteforward")
684 || d.key.eq_ignore_ascii_case("dynamicforward"))
685 })
686 .count();
687 count.min(u16::MAX as usize) as u16
688 }
689
690 #[allow(dead_code)]
692 pub fn has_tunnels(&self) -> bool {
693 self.directives.iter().any(|d| {
694 !d.is_non_directive
695 && (d.key.eq_ignore_ascii_case("localforward")
696 || d.key.eq_ignore_ascii_case("remoteforward")
697 || d.key.eq_ignore_ascii_case("dynamicforward"))
698 })
699 }
700
701 pub fn tunnel_directives(&self) -> Vec<crate::tunnel::TunnelRule> {
703 self.directives
704 .iter()
705 .filter(|d| !d.is_non_directive)
706 .filter_map(|d| crate::tunnel::TunnelRule::parse_value(&d.key, &d.value))
707 .collect()
708 }
709}