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 indent = self.detect_indent();
216 self.directives.retain(|d| {
217 !(d.is_non_directive && d.raw_line.trim().starts_with("# purple:provider "))
218 });
219 let pos = self.content_end();
220 self.directives.insert(
221 pos,
222 Directive {
223 key: String::new(),
224 value: String::new(),
225 raw_line: format!("{}# purple:provider {}:{}", indent, id, server_id),
226 is_non_directive: true,
227 },
228 );
229 }
230
231 pub fn askpass(&self) -> Option<String> {
233 for d in &self.directives {
234 if d.is_non_directive {
235 let trimmed = d.raw_line.trim();
236 if let Some(rest) = trimmed.strip_prefix("# purple:askpass ") {
237 let val = rest.trim();
238 if !val.is_empty() {
239 return Some(val.to_string());
240 }
241 }
242 }
243 }
244 None
245 }
246
247 pub fn vault_ssh(&self) -> Option<String> {
249 for d in &self.directives {
250 if d.is_non_directive {
251 let trimmed = d.raw_line.trim();
252 if let Some(rest) = trimmed.strip_prefix("# purple:vault-ssh ") {
253 let val = rest.trim();
254 if !val.is_empty() && crate::vault_ssh::is_valid_role(val) {
255 return Some(val.to_string());
256 }
257 }
258 }
259 }
260 None
261 }
262
263 pub fn set_vault_ssh(&mut self, role: &str) {
265 let indent = self.detect_indent();
266 self.directives.retain(|d| {
267 !(d.is_non_directive && {
268 let t = d.raw_line.trim();
269 t == "# purple:vault-ssh" || t.starts_with("# purple:vault-ssh ")
270 })
271 });
272 if !role.is_empty() {
273 let pos = self.content_end();
274 self.directives.insert(
275 pos,
276 Directive {
277 key: String::new(),
278 value: String::new(),
279 raw_line: format!("{}# purple:vault-ssh {}", indent, role),
280 is_non_directive: true,
281 },
282 );
283 }
284 }
285
286 pub fn vault_addr(&self) -> Option<String> {
292 for d in &self.directives {
293 if d.is_non_directive {
294 let trimmed = d.raw_line.trim();
295 if let Some(rest) = trimmed.strip_prefix("# purple:vault-addr ") {
296 let val = rest.trim();
297 if !val.is_empty() && crate::vault_ssh::is_valid_vault_addr(val) {
298 return Some(val.to_string());
299 }
300 }
301 }
302 }
303 None
304 }
305
306 pub fn set_vault_addr(&mut self, url: &str) {
310 let indent = self.detect_indent();
311 self.directives.retain(|d| {
312 !(d.is_non_directive && {
313 let t = d.raw_line.trim();
314 t == "# purple:vault-addr" || t.starts_with("# purple:vault-addr ")
315 })
316 });
317 if !url.is_empty() {
318 let pos = self.content_end();
319 self.directives.insert(
320 pos,
321 Directive {
322 key: String::new(),
323 value: String::new(),
324 raw_line: format!("{}# purple:vault-addr {}", indent, url),
325 is_non_directive: true,
326 },
327 );
328 }
329 }
330
331 pub fn set_askpass(&mut self, source: &str) {
334 let indent = self.detect_indent();
335 self.directives.retain(|d| {
336 !(d.is_non_directive && {
337 let t = d.raw_line.trim();
338 t == "# purple:askpass" || t.starts_with("# purple:askpass ")
339 })
340 });
341 if !source.is_empty() {
342 let pos = self.content_end();
343 self.directives.insert(
344 pos,
345 Directive {
346 key: String::new(),
347 value: String::new(),
348 raw_line: format!("{}# purple:askpass {}", indent, source),
349 is_non_directive: true,
350 },
351 );
352 }
353 }
354
355 pub fn meta(&self) -> Vec<(String, String)> {
358 for d in &self.directives {
359 if d.is_non_directive {
360 let trimmed = d.raw_line.trim();
361 if let Some(rest) = trimmed.strip_prefix("# purple:meta ") {
362 return rest
363 .split(',')
364 .filter_map(|pair| {
365 let (k, v) = pair.split_once('=')?;
366 let k = k.trim();
367 let v = v.trim();
368 if k.is_empty() {
369 None
370 } else {
371 Some((k.to_string(), v.to_string()))
372 }
373 })
374 .collect();
375 }
376 }
377 }
378 Vec::new()
379 }
380
381 pub fn set_meta(&mut self, meta: &[(String, String)]) {
384 let indent = self.detect_indent();
385 self.directives.retain(|d| {
386 !(d.is_non_directive && {
387 let t = d.raw_line.trim();
388 t == "# purple:meta" || t.starts_with("# purple:meta ")
389 })
390 });
391 if !meta.is_empty() {
392 let encoded: Vec<String> = meta
393 .iter()
394 .map(|(k, v)| {
395 let clean_k = Self::sanitize_tag(&k.replace([',', '='], ""));
396 let clean_v = Self::sanitize_tag(&v.replace(',', ""));
397 format!("{}={}", clean_k, clean_v)
398 })
399 .collect();
400 let pos = self.content_end();
401 self.directives.insert(
402 pos,
403 Directive {
404 key: String::new(),
405 value: String::new(),
406 raw_line: format!("{}# purple:meta {}", indent, encoded.join(",")),
407 is_non_directive: true,
408 },
409 );
410 }
411 }
412
413 pub fn stale(&self) -> Option<u64> {
416 for d in &self.directives {
417 if d.is_non_directive {
418 let trimmed = d.raw_line.trim();
419 if let Some(rest) = trimmed.strip_prefix("# purple:stale ") {
420 return rest.trim().parse::<u64>().ok();
421 }
422 }
423 }
424 None
425 }
426
427 pub fn set_stale(&mut self, timestamp: u64) {
430 let indent = self.detect_indent();
431 self.clear_stale();
432 let pos = self.content_end();
433 self.directives.insert(
434 pos,
435 Directive {
436 key: String::new(),
437 value: String::new(),
438 raw_line: format!("{}# purple:stale {}", indent, timestamp),
439 is_non_directive: true,
440 },
441 );
442 }
443
444 pub fn clear_stale(&mut self) {
446 self.directives.retain(|d| {
447 !(d.is_non_directive && {
448 let t = d.raw_line.trim();
449 t == "# purple:stale" || t.starts_with("# purple:stale ")
450 })
451 });
452 }
453
454 pub(super) fn sanitize_tag(tag: &str) -> String {
457 tag.chars()
458 .filter(|c| {
459 !c.is_control()
460 && *c != ','
461 && !('\u{200B}'..='\u{200F}').contains(c) && !('\u{202A}'..='\u{202E}').contains(c) && !('\u{2066}'..='\u{2069}').contains(c) && *c != '\u{FEFF}' })
466 .take(128)
467 .collect()
468 }
469
470 pub fn set_tags(&mut self, tags: &[String]) {
472 let indent = self.detect_indent();
473 self.directives.retain(|d| {
474 !(d.is_non_directive && {
475 let t = d.raw_line.trim();
476 t == "# purple:tags" || t.starts_with("# purple:tags ")
477 })
478 });
479 let sanitized: Vec<String> = tags
480 .iter()
481 .map(|t| Self::sanitize_tag(t))
482 .filter(|t| !t.is_empty())
483 .collect();
484 if !sanitized.is_empty() {
485 let pos = self.content_end();
486 self.directives.insert(
487 pos,
488 Directive {
489 key: String::new(),
490 value: String::new(),
491 raw_line: format!("{}# purple:tags {}", indent, sanitized.join(",")),
492 is_non_directive: true,
493 },
494 );
495 }
496 }
497
498 pub fn set_provider_tags(&mut self, tags: &[String]) {
501 let indent = self.detect_indent();
502 self.directives.retain(|d| {
503 !(d.is_non_directive && {
504 let t = d.raw_line.trim();
505 t == "# purple:provider_tags" || t.starts_with("# purple:provider_tags ")
506 })
507 });
508 let sanitized: Vec<String> = tags
509 .iter()
510 .map(|t| Self::sanitize_tag(t))
511 .filter(|t| !t.is_empty())
512 .collect();
513 let raw = if sanitized.is_empty() {
514 format!("{}# purple:provider_tags", indent)
515 } else {
516 format!("{}# purple:provider_tags {}", indent, sanitized.join(","))
517 };
518 let pos = self.content_end();
519 self.directives.insert(
520 pos,
521 Directive {
522 key: String::new(),
523 value: String::new(),
524 raw_line: raw,
525 is_non_directive: true,
526 },
527 );
528 }
529
530 pub fn to_host_entry(&self) -> HostEntry {
532 let mut entry = HostEntry {
533 alias: self.host_pattern.clone(),
534 port: 22,
535 ..Default::default()
536 };
537 for d in &self.directives {
538 if d.is_non_directive {
539 continue;
540 }
541 if d.key.eq_ignore_ascii_case("hostname") {
542 entry.hostname = d.value.clone();
543 } else if d.key.eq_ignore_ascii_case("user") {
544 entry.user = d.value.clone();
545 } else if d.key.eq_ignore_ascii_case("port") {
546 entry.port = d.value.parse().unwrap_or(22);
547 } else if d.key.eq_ignore_ascii_case("identityfile") {
548 if entry.identity_file.is_empty() {
549 entry.identity_file = d.value.clone();
550 }
551 } else if d.key.eq_ignore_ascii_case("proxyjump") {
552 entry.proxy_jump = d.value.clone();
553 } else if d.key.eq_ignore_ascii_case("certificatefile")
554 && entry.certificate_file.is_empty()
555 {
556 entry.certificate_file = d.value.clone();
557 }
558 }
559 entry.tags = self.tags();
560 entry.provider_tags = self.provider_tags();
561 entry.has_provider_tags = self.has_provider_tags_comment();
562 if let Some((id, _)) = self.provider_id() {
563 entry.provider = Some(id.provider);
564 entry.provider_label = id.label;
565 }
566 entry.tunnel_count = self.tunnel_count();
567 entry.askpass = self.askpass();
568 entry.vault_ssh = self.vault_ssh();
569 entry.vault_addr = self.vault_addr();
570 entry.provider_meta = self.meta();
571 entry.stale = self.stale();
572 entry
573 }
574
575 pub fn to_pattern_entry(&self) -> PatternEntry {
577 let mut entry = PatternEntry {
578 pattern: self.host_pattern.clone(),
579 hostname: String::new(),
580 user: String::new(),
581 port: 22,
582 identity_file: String::new(),
583 proxy_jump: String::new(),
584 tags: self.tags(),
585 askpass: self.askpass(),
586 source_file: None,
587 directives: Vec::new(),
588 };
589 for d in &self.directives {
590 if d.is_non_directive {
591 continue;
592 }
593 match d.key.to_ascii_lowercase().as_str() {
594 "hostname" => entry.hostname = d.value.clone(),
595 "user" => entry.user = d.value.clone(),
596 "port" => entry.port = d.value.parse().unwrap_or(22),
597 "identityfile" if entry.identity_file.is_empty() => {
598 entry.identity_file = d.value.clone();
599 }
600 "proxyjump" => entry.proxy_jump = d.value.clone(),
601 _ => {}
602 }
603 entry.directives.push((d.key.clone(), d.value.clone()));
604 }
605 entry
606 }
607
608 pub fn tunnel_count(&self) -> u16 {
610 let count = self
611 .directives
612 .iter()
613 .filter(|d| {
614 !d.is_non_directive
615 && (d.key.eq_ignore_ascii_case("localforward")
616 || d.key.eq_ignore_ascii_case("remoteforward")
617 || d.key.eq_ignore_ascii_case("dynamicforward"))
618 })
619 .count();
620 count.min(u16::MAX as usize) as u16
621 }
622
623 #[allow(dead_code)]
625 pub fn has_tunnels(&self) -> bool {
626 self.directives.iter().any(|d| {
627 !d.is_non_directive
628 && (d.key.eq_ignore_ascii_case("localforward")
629 || d.key.eq_ignore_ascii_case("remoteforward")
630 || d.key.eq_ignore_ascii_case("dynamicforward"))
631 })
632 }
633
634 pub fn tunnel_directives(&self) -> Vec<crate::tunnel::TunnelRule> {
636 self.directives
637 .iter()
638 .filter(|d| !d.is_non_directive)
639 .filter_map(|d| crate::tunnel::TunnelRule::parse_value(&d.key, &d.value))
640 .collect()
641 }
642}