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 fn set_tags(&mut self, tags: &[String]) {
500 let indent = self.detect_indent();
501 self.directives.retain(|d| {
502 !(d.is_non_directive && {
503 let t = d.raw_line.trim();
504 t == "# purple:tags" || t.starts_with("# purple:tags ")
505 })
506 });
507 let sanitized: Vec<String> = tags
508 .iter()
509 .map(|t| Self::sanitize_tag(t))
510 .filter(|t| !t.is_empty())
511 .collect();
512 if !sanitized.is_empty() {
513 let pos = self.content_end();
514 self.directives.insert(
515 pos,
516 Directive {
517 key: String::new(),
518 value: String::new(),
519 raw_line: format!("{}# purple:tags {}", indent, sanitized.join(",")),
520 is_non_directive: true,
521 },
522 );
523 }
524 }
525
526 pub fn set_provider_tags(&mut self, tags: &[String]) {
529 let indent = self.detect_indent();
530 self.directives.retain(|d| {
531 !(d.is_non_directive && {
532 let t = d.raw_line.trim();
533 t == "# purple:provider_tags" || t.starts_with("# purple:provider_tags ")
534 })
535 });
536 let sanitized: Vec<String> = tags
537 .iter()
538 .map(|t| Self::sanitize_tag(t))
539 .filter(|t| !t.is_empty())
540 .collect();
541 let raw = if sanitized.is_empty() {
542 format!("{}# purple:provider_tags", indent)
543 } else {
544 format!("{}# purple:provider_tags {}", indent, sanitized.join(","))
545 };
546 let pos = self.content_end();
547 self.directives.insert(
548 pos,
549 Directive {
550 key: String::new(),
551 value: String::new(),
552 raw_line: raw,
553 is_non_directive: true,
554 },
555 );
556 }
557
558 pub fn to_host_entry(&self) -> HostEntry {
560 let mut entry = HostEntry {
561 alias: self.host_pattern.clone(),
562 port: 22,
563 ..Default::default()
564 };
565 for d in &self.directives {
566 if d.is_non_directive {
567 continue;
568 }
569 if d.key.eq_ignore_ascii_case("hostname") {
570 entry.hostname = d.value.clone();
571 } else if d.key.eq_ignore_ascii_case("user") {
572 entry.user = d.value.clone();
573 } else if d.key.eq_ignore_ascii_case("port") {
574 entry.port = d.value.parse().unwrap_or(22);
575 } else if d.key.eq_ignore_ascii_case("identityfile") {
576 if entry.identity_file.is_empty() {
577 entry.identity_file = d.value.clone();
578 }
579 } else if d.key.eq_ignore_ascii_case("proxyjump") {
580 entry.proxy_jump = d.value.clone();
581 } else if d.key.eq_ignore_ascii_case("certificatefile")
582 && entry.certificate_file.is_empty()
583 {
584 entry.certificate_file = d.value.clone();
585 }
586 }
587 entry.tags = self.tags();
588 entry.provider_tags = self.provider_tags();
589 entry.has_provider_tags = self.has_provider_tags_comment();
590 if let Some((id, _)) = self.provider_id() {
591 entry.provider = Some(id.provider);
592 entry.provider_label = id.label;
593 }
594 entry.tunnel_count = self.tunnel_count();
595 entry.askpass = self.askpass();
596 entry.vault_ssh = self.vault_ssh();
597 entry.vault_addr = self.vault_addr();
598 entry.provider_meta = self.meta();
599 entry.stale = self.stale();
600 entry
601 }
602
603 pub fn to_pattern_entry(&self) -> PatternEntry {
605 let mut entry = PatternEntry {
606 pattern: self.host_pattern.clone(),
607 hostname: String::new(),
608 user: String::new(),
609 port: 22,
610 identity_file: String::new(),
611 proxy_jump: String::new(),
612 tags: self.tags(),
613 askpass: self.askpass(),
614 source_file: None,
615 directives: Vec::new(),
616 };
617 for d in &self.directives {
618 if d.is_non_directive {
619 continue;
620 }
621 match d.key.to_ascii_lowercase().as_str() {
622 "hostname" => entry.hostname = d.value.clone(),
623 "user" => entry.user = d.value.clone(),
624 "port" => entry.port = d.value.parse().unwrap_or(22),
625 "identityfile" if entry.identity_file.is_empty() => {
626 entry.identity_file = d.value.clone();
627 }
628 "proxyjump" => entry.proxy_jump = d.value.clone(),
629 _ => {}
630 }
631 entry.directives.push((d.key.clone(), d.value.clone()));
632 }
633 entry
634 }
635
636 pub fn tunnel_count(&self) -> u16 {
638 let count = self
639 .directives
640 .iter()
641 .filter(|d| {
642 !d.is_non_directive
643 && (d.key.eq_ignore_ascii_case("localforward")
644 || d.key.eq_ignore_ascii_case("remoteforward")
645 || d.key.eq_ignore_ascii_case("dynamicforward"))
646 })
647 .count();
648 count.min(u16::MAX as usize) as u16
649 }
650
651 #[allow(dead_code)]
653 pub fn has_tunnels(&self) -> bool {
654 self.directives.iter().any(|d| {
655 !d.is_non_directive
656 && (d.key.eq_ignore_ascii_case("localforward")
657 || d.key.eq_ignore_ascii_case("remoteforward")
658 || d.key.eq_ignore_ascii_case("dynamicforward"))
659 })
660 }
661
662 pub fn tunnel_directives(&self) -> Vec<crate::tunnel::TunnelRule> {
664 self.directives
665 .iter()
666 .filter(|d| !d.is_non_directive)
667 .filter_map(|d| crate::tunnel::TunnelRule::parse_value(&d.key, &d.value))
668 .collect()
669 }
670}