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)> {
107 for d in &self.directives {
108 if d.is_non_directive {
109 let trimmed = d.raw_line.trim();
110 if let Some(rest) = trimmed.strip_prefix("# purple:provider ") {
111 if let Some((name, id)) = rest.split_once(':') {
112 return Some((name.trim().to_string(), id.trim().to_string()));
113 }
114 }
115 }
116 }
117 None
118 }
119
120 pub fn set_provider(&mut self, provider_name: &str, server_id: &str) {
122 let indent = self.detect_indent();
123 self.directives.retain(|d| {
124 !(d.is_non_directive && d.raw_line.trim().starts_with("# purple:provider "))
125 });
126 let pos = self.content_end();
127 self.directives.insert(
128 pos,
129 Directive {
130 key: String::new(),
131 value: String::new(),
132 raw_line: format!(
133 "{}# purple:provider {}:{}",
134 indent, provider_name, server_id
135 ),
136 is_non_directive: true,
137 },
138 );
139 }
140
141 pub fn askpass(&self) -> Option<String> {
143 for d in &self.directives {
144 if d.is_non_directive {
145 let trimmed = d.raw_line.trim();
146 if let Some(rest) = trimmed.strip_prefix("# purple:askpass ") {
147 let val = rest.trim();
148 if !val.is_empty() {
149 return Some(val.to_string());
150 }
151 }
152 }
153 }
154 None
155 }
156
157 pub fn vault_ssh(&self) -> Option<String> {
159 for d in &self.directives {
160 if d.is_non_directive {
161 let trimmed = d.raw_line.trim();
162 if let Some(rest) = trimmed.strip_prefix("# purple:vault-ssh ") {
163 let val = rest.trim();
164 if !val.is_empty() && crate::vault_ssh::is_valid_role(val) {
165 return Some(val.to_string());
166 }
167 }
168 }
169 }
170 None
171 }
172
173 pub fn set_vault_ssh(&mut self, role: &str) {
175 let indent = self.detect_indent();
176 self.directives.retain(|d| {
177 !(d.is_non_directive && {
178 let t = d.raw_line.trim();
179 t == "# purple:vault-ssh" || t.starts_with("# purple:vault-ssh ")
180 })
181 });
182 if !role.is_empty() {
183 let pos = self.content_end();
184 self.directives.insert(
185 pos,
186 Directive {
187 key: String::new(),
188 value: String::new(),
189 raw_line: format!("{}# purple:vault-ssh {}", indent, role),
190 is_non_directive: true,
191 },
192 );
193 }
194 }
195
196 pub fn vault_addr(&self) -> Option<String> {
202 for d in &self.directives {
203 if d.is_non_directive {
204 let trimmed = d.raw_line.trim();
205 if let Some(rest) = trimmed.strip_prefix("# purple:vault-addr ") {
206 let val = rest.trim();
207 if !val.is_empty() && crate::vault_ssh::is_valid_vault_addr(val) {
208 return Some(val.to_string());
209 }
210 }
211 }
212 }
213 None
214 }
215
216 pub fn set_vault_addr(&mut self, url: &str) {
220 let indent = self.detect_indent();
221 self.directives.retain(|d| {
222 !(d.is_non_directive && {
223 let t = d.raw_line.trim();
224 t == "# purple:vault-addr" || t.starts_with("# purple:vault-addr ")
225 })
226 });
227 if !url.is_empty() {
228 let pos = self.content_end();
229 self.directives.insert(
230 pos,
231 Directive {
232 key: String::new(),
233 value: String::new(),
234 raw_line: format!("{}# purple:vault-addr {}", indent, url),
235 is_non_directive: true,
236 },
237 );
238 }
239 }
240
241 pub fn set_askpass(&mut self, source: &str) {
244 let indent = self.detect_indent();
245 self.directives.retain(|d| {
246 !(d.is_non_directive && {
247 let t = d.raw_line.trim();
248 t == "# purple:askpass" || t.starts_with("# purple:askpass ")
249 })
250 });
251 if !source.is_empty() {
252 let pos = self.content_end();
253 self.directives.insert(
254 pos,
255 Directive {
256 key: String::new(),
257 value: String::new(),
258 raw_line: format!("{}# purple:askpass {}", indent, source),
259 is_non_directive: true,
260 },
261 );
262 }
263 }
264
265 pub fn meta(&self) -> Vec<(String, String)> {
268 for d in &self.directives {
269 if d.is_non_directive {
270 let trimmed = d.raw_line.trim();
271 if let Some(rest) = trimmed.strip_prefix("# purple:meta ") {
272 return rest
273 .split(',')
274 .filter_map(|pair| {
275 let (k, v) = pair.split_once('=')?;
276 let k = k.trim();
277 let v = v.trim();
278 if k.is_empty() {
279 None
280 } else {
281 Some((k.to_string(), v.to_string()))
282 }
283 })
284 .collect();
285 }
286 }
287 }
288 Vec::new()
289 }
290
291 pub fn set_meta(&mut self, meta: &[(String, String)]) {
294 let indent = self.detect_indent();
295 self.directives.retain(|d| {
296 !(d.is_non_directive && {
297 let t = d.raw_line.trim();
298 t == "# purple:meta" || t.starts_with("# purple:meta ")
299 })
300 });
301 if !meta.is_empty() {
302 let encoded: Vec<String> = meta
303 .iter()
304 .map(|(k, v)| {
305 let clean_k = Self::sanitize_tag(&k.replace([',', '='], ""));
306 let clean_v = Self::sanitize_tag(&v.replace(',', ""));
307 format!("{}={}", clean_k, clean_v)
308 })
309 .collect();
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:meta {}", indent, encoded.join(",")),
317 is_non_directive: true,
318 },
319 );
320 }
321 }
322
323 pub fn stale(&self) -> Option<u64> {
326 for d in &self.directives {
327 if d.is_non_directive {
328 let trimmed = d.raw_line.trim();
329 if let Some(rest) = trimmed.strip_prefix("# purple:stale ") {
330 return rest.trim().parse::<u64>().ok();
331 }
332 }
333 }
334 None
335 }
336
337 pub fn set_stale(&mut self, timestamp: u64) {
340 let indent = self.detect_indent();
341 self.clear_stale();
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:stale {}", indent, timestamp),
349 is_non_directive: true,
350 },
351 );
352 }
353
354 pub fn clear_stale(&mut self) {
356 self.directives.retain(|d| {
357 !(d.is_non_directive && {
358 let t = d.raw_line.trim();
359 t == "# purple:stale" || t.starts_with("# purple:stale ")
360 })
361 });
362 }
363
364 pub(super) fn sanitize_tag(tag: &str) -> String {
367 tag.chars()
368 .filter(|c| {
369 !c.is_control()
370 && *c != ','
371 && !('\u{200B}'..='\u{200F}').contains(c) && !('\u{202A}'..='\u{202E}').contains(c) && !('\u{2066}'..='\u{2069}').contains(c) && *c != '\u{FEFF}' })
376 .take(128)
377 .collect()
378 }
379
380 pub fn set_tags(&mut self, tags: &[String]) {
382 let indent = self.detect_indent();
383 self.directives.retain(|d| {
384 !(d.is_non_directive && {
385 let t = d.raw_line.trim();
386 t == "# purple:tags" || t.starts_with("# purple:tags ")
387 })
388 });
389 let sanitized: Vec<String> = tags
390 .iter()
391 .map(|t| Self::sanitize_tag(t))
392 .filter(|t| !t.is_empty())
393 .collect();
394 if !sanitized.is_empty() {
395 let pos = self.content_end();
396 self.directives.insert(
397 pos,
398 Directive {
399 key: String::new(),
400 value: String::new(),
401 raw_line: format!("{}# purple:tags {}", indent, sanitized.join(",")),
402 is_non_directive: true,
403 },
404 );
405 }
406 }
407
408 pub fn set_provider_tags(&mut self, tags: &[String]) {
411 let indent = self.detect_indent();
412 self.directives.retain(|d| {
413 !(d.is_non_directive && {
414 let t = d.raw_line.trim();
415 t == "# purple:provider_tags" || t.starts_with("# purple:provider_tags ")
416 })
417 });
418 let sanitized: Vec<String> = tags
419 .iter()
420 .map(|t| Self::sanitize_tag(t))
421 .filter(|t| !t.is_empty())
422 .collect();
423 let raw = if sanitized.is_empty() {
424 format!("{}# purple:provider_tags", indent)
425 } else {
426 format!("{}# purple:provider_tags {}", indent, sanitized.join(","))
427 };
428 let pos = self.content_end();
429 self.directives.insert(
430 pos,
431 Directive {
432 key: String::new(),
433 value: String::new(),
434 raw_line: raw,
435 is_non_directive: true,
436 },
437 );
438 }
439
440 pub fn to_host_entry(&self) -> HostEntry {
442 let mut entry = HostEntry {
443 alias: self.host_pattern.clone(),
444 port: 22,
445 ..Default::default()
446 };
447 for d in &self.directives {
448 if d.is_non_directive {
449 continue;
450 }
451 if d.key.eq_ignore_ascii_case("hostname") {
452 entry.hostname = d.value.clone();
453 } else if d.key.eq_ignore_ascii_case("user") {
454 entry.user = d.value.clone();
455 } else if d.key.eq_ignore_ascii_case("port") {
456 entry.port = d.value.parse().unwrap_or(22);
457 } else if d.key.eq_ignore_ascii_case("identityfile") {
458 if entry.identity_file.is_empty() {
459 entry.identity_file = d.value.clone();
460 }
461 } else if d.key.eq_ignore_ascii_case("proxyjump") {
462 entry.proxy_jump = d.value.clone();
463 } else if d.key.eq_ignore_ascii_case("certificatefile")
464 && entry.certificate_file.is_empty()
465 {
466 entry.certificate_file = d.value.clone();
467 }
468 }
469 entry.tags = self.tags();
470 entry.provider_tags = self.provider_tags();
471 entry.has_provider_tags = self.has_provider_tags_comment();
472 entry.provider = self.provider().map(|(name, _)| name);
473 entry.tunnel_count = self.tunnel_count();
474 entry.askpass = self.askpass();
475 entry.vault_ssh = self.vault_ssh();
476 entry.vault_addr = self.vault_addr();
477 entry.provider_meta = self.meta();
478 entry.stale = self.stale();
479 entry
480 }
481
482 pub fn to_pattern_entry(&self) -> PatternEntry {
484 let mut entry = PatternEntry {
485 pattern: self.host_pattern.clone(),
486 hostname: String::new(),
487 user: String::new(),
488 port: 22,
489 identity_file: String::new(),
490 proxy_jump: String::new(),
491 tags: self.tags(),
492 askpass: self.askpass(),
493 source_file: None,
494 directives: Vec::new(),
495 };
496 for d in &self.directives {
497 if d.is_non_directive {
498 continue;
499 }
500 match d.key.to_ascii_lowercase().as_str() {
501 "hostname" => entry.hostname = d.value.clone(),
502 "user" => entry.user = d.value.clone(),
503 "port" => entry.port = d.value.parse().unwrap_or(22),
504 "identityfile" if entry.identity_file.is_empty() => {
505 entry.identity_file = d.value.clone();
506 }
507 "proxyjump" => entry.proxy_jump = d.value.clone(),
508 _ => {}
509 }
510 entry.directives.push((d.key.clone(), d.value.clone()));
511 }
512 entry
513 }
514
515 pub fn tunnel_count(&self) -> u16 {
517 let count = self
518 .directives
519 .iter()
520 .filter(|d| {
521 !d.is_non_directive
522 && (d.key.eq_ignore_ascii_case("localforward")
523 || d.key.eq_ignore_ascii_case("remoteforward")
524 || d.key.eq_ignore_ascii_case("dynamicforward"))
525 })
526 .count();
527 count.min(u16::MAX as usize) as u16
528 }
529
530 #[allow(dead_code)]
532 pub fn has_tunnels(&self) -> bool {
533 self.directives.iter().any(|d| {
534 !d.is_non_directive
535 && (d.key.eq_ignore_ascii_case("localforward")
536 || d.key.eq_ignore_ascii_case("remoteforward")
537 || d.key.eq_ignore_ascii_case("dynamicforward"))
538 })
539 }
540
541 pub fn tunnel_directives(&self) -> Vec<crate::tunnel::TunnelRule> {
543 self.directives
544 .iter()
545 .filter(|d| !d.is_non_directive)
546 .filter_map(|d| crate::tunnel::TunnelRule::parse_value(&d.key, &d.value))
547 .collect()
548 }
549}