Skip to main content

purple_ssh/providers/
config.rs

1use std::io;
2use std::path::PathBuf;
3
4use crate::fs_util;
5
6/// A configured provider section from ~/.purple/providers.
7#[derive(Debug, Clone)]
8pub struct ProviderSection {
9    pub provider: String,
10    pub token: String,
11    pub alias_prefix: String,
12    pub user: String,
13    pub identity_file: String,
14    pub url: String,
15    pub verify_tls: bool,
16    pub auto_sync: bool,
17    pub profile: String,
18    pub regions: String,
19    pub project: String,
20    pub compartment: String,
21    pub vault_role: String,
22    /// Optional `VAULT_ADDR` override passed to the `vault` CLI when signing
23    /// SSH certs. Empty = inherit parent env. Stored as a plain string so an
24    /// uninitialized field (via `..Default::default()`) stays innocuous.
25    pub vault_addr: String,
26}
27
28/// Default for auto_sync: false for proxmox (N+1 API calls), true for all others.
29fn default_auto_sync(provider: &str) -> bool {
30    !matches!(provider, "proxmox")
31}
32
33/// Parsed provider configuration from ~/.purple/providers.
34#[derive(Debug, Clone, Default)]
35pub struct ProviderConfig {
36    pub sections: Vec<ProviderSection>,
37    /// Override path for save(). None uses the default ~/.purple/providers.
38    /// Set to Some in tests to avoid writing to the real config.
39    pub path_override: Option<PathBuf>,
40}
41
42fn config_path() -> Option<PathBuf> {
43    dirs::home_dir().map(|h| h.join(".purple/providers"))
44}
45
46impl ProviderConfig {
47    /// Load provider config from ~/.purple/providers.
48    /// Returns empty config if file doesn't exist (normal first-use).
49    /// Prints a warning to stderr on real IO errors (permissions, etc.).
50    pub fn load() -> Self {
51        let path = match config_path() {
52            Some(p) => p,
53            None => return Self::default(),
54        };
55        let content = match std::fs::read_to_string(&path) {
56            Ok(c) => c,
57            Err(e) if e.kind() == io::ErrorKind::NotFound => return Self::default(),
58            Err(e) => {
59                eprintln!("! Could not read {}: {}", path.display(), e);
60                return Self::default();
61            }
62        };
63        Self::parse(&content)
64    }
65
66    /// Parse INI-style provider config.
67    pub(crate) fn parse(content: &str) -> Self {
68        let mut sections = Vec::new();
69        let mut current: Option<ProviderSection> = None;
70
71        for line in content.lines() {
72            let trimmed = line.trim();
73            if trimmed.is_empty() || trimmed.starts_with('#') {
74                continue;
75            }
76            if trimmed.starts_with('[') && trimmed.ends_with(']') {
77                if let Some(section) = current.take() {
78                    if !sections
79                        .iter()
80                        .any(|s: &ProviderSection| s.provider == section.provider)
81                    {
82                        sections.push(section);
83                    }
84                }
85                let name = trimmed[1..trimmed.len() - 1].trim().to_string();
86                if sections.iter().any(|s| s.provider == name) {
87                    current = None;
88                    continue;
89                }
90                let short_label = super::get_provider(&name)
91                    .map(|p| p.short_label().to_string())
92                    .unwrap_or_else(|| name.clone());
93                let auto_sync_default = default_auto_sync(&name);
94                current = Some(ProviderSection {
95                    provider: name,
96                    token: String::new(),
97                    alias_prefix: short_label,
98                    user: "root".to_string(),
99                    identity_file: String::new(),
100                    url: String::new(),
101                    verify_tls: true,
102                    auto_sync: auto_sync_default,
103                    profile: String::new(),
104                    regions: String::new(),
105                    project: String::new(),
106                    compartment: String::new(),
107                    vault_role: String::new(),
108                    vault_addr: String::new(),
109                });
110            } else if let Some(ref mut section) = current {
111                if let Some((key, value)) = trimmed.split_once('=') {
112                    let key = key.trim();
113                    let value = value.trim().to_string();
114                    match key {
115                        "token" => section.token = value,
116                        "alias_prefix" => section.alias_prefix = value,
117                        "user" => section.user = value,
118                        "key" => section.identity_file = value,
119                        "url" => section.url = value,
120                        "verify_tls" => {
121                            section.verify_tls =
122                                !matches!(value.to_lowercase().as_str(), "false" | "0" | "no")
123                        }
124                        "auto_sync" => {
125                            section.auto_sync =
126                                !matches!(value.to_lowercase().as_str(), "false" | "0" | "no")
127                        }
128                        "profile" => section.profile = value,
129                        "regions" => section.regions = value,
130                        "project" => section.project = value,
131                        "compartment" => section.compartment = value,
132                        "vault_role" => {
133                            // Silently drop invalid roles so parsing stays infallible.
134                            section.vault_role = if crate::vault_ssh::is_valid_role(&value) {
135                                value
136                            } else {
137                                String::new()
138                            };
139                        }
140                        "vault_addr" => {
141                            // Same silent-drop policy as vault_role: a bad
142                            // value is ignored on parse rather than crashing
143                            // the whole config load.
144                            section.vault_addr = if crate::vault_ssh::is_valid_vault_addr(&value) {
145                                value
146                            } else {
147                                String::new()
148                            };
149                        }
150                        _ => {}
151                    }
152                }
153            }
154        }
155        if let Some(section) = current {
156            if !sections.iter().any(|s| s.provider == section.provider) {
157                sections.push(section);
158            }
159        }
160        Self {
161            sections,
162            path_override: None,
163        }
164    }
165
166    /// Strip control characters (newlines, tabs, etc.) from a config value
167    /// to prevent INI format corruption from paste errors.
168    fn sanitize_value(s: &str) -> String {
169        s.chars().filter(|c| !c.is_control()).collect()
170    }
171
172    /// Save provider config to ~/.purple/providers (atomic write, chmod 600).
173    /// Respects path_override when set (used in tests).
174    pub fn save(&self) -> io::Result<()> {
175        if crate::demo_flag::is_demo() {
176            return Ok(());
177        }
178        let path = match &self.path_override {
179            Some(p) => p.clone(),
180            None => match config_path() {
181                Some(p) => p,
182                None => {
183                    return Err(io::Error::new(
184                        io::ErrorKind::NotFound,
185                        "Could not determine home directory",
186                    ));
187                }
188            },
189        };
190
191        let mut content = String::new();
192        for (i, section) in self.sections.iter().enumerate() {
193            if i > 0 {
194                content.push('\n');
195            }
196            content.push_str(&format!("[{}]\n", Self::sanitize_value(&section.provider)));
197            content.push_str(&format!("token={}\n", Self::sanitize_value(&section.token)));
198            content.push_str(&format!(
199                "alias_prefix={}\n",
200                Self::sanitize_value(&section.alias_prefix)
201            ));
202            content.push_str(&format!("user={}\n", Self::sanitize_value(&section.user)));
203            if !section.identity_file.is_empty() {
204                content.push_str(&format!(
205                    "key={}\n",
206                    Self::sanitize_value(&section.identity_file)
207                ));
208            }
209            if !section.url.is_empty() {
210                content.push_str(&format!("url={}\n", Self::sanitize_value(&section.url)));
211            }
212            if !section.verify_tls {
213                content.push_str("verify_tls=false\n");
214            }
215            if !section.profile.is_empty() {
216                content.push_str(&format!(
217                    "profile={}\n",
218                    Self::sanitize_value(&section.profile)
219                ));
220            }
221            if !section.regions.is_empty() {
222                content.push_str(&format!(
223                    "regions={}\n",
224                    Self::sanitize_value(&section.regions)
225                ));
226            }
227            if !section.project.is_empty() {
228                content.push_str(&format!(
229                    "project={}\n",
230                    Self::sanitize_value(&section.project)
231                ));
232            }
233            if !section.compartment.is_empty() {
234                content.push_str(&format!(
235                    "compartment={}\n",
236                    Self::sanitize_value(&section.compartment)
237                ));
238            }
239            if !section.vault_role.is_empty()
240                && crate::vault_ssh::is_valid_role(&section.vault_role)
241            {
242                content.push_str(&format!(
243                    "vault_role={}\n",
244                    Self::sanitize_value(&section.vault_role)
245                ));
246            }
247            if !section.vault_addr.is_empty()
248                && crate::vault_ssh::is_valid_vault_addr(&section.vault_addr)
249            {
250                content.push_str(&format!(
251                    "vault_addr={}\n",
252                    Self::sanitize_value(&section.vault_addr)
253                ));
254            }
255            if section.auto_sync != default_auto_sync(&section.provider) {
256                content.push_str(if section.auto_sync {
257                    "auto_sync=true\n"
258                } else {
259                    "auto_sync=false\n"
260                });
261            }
262        }
263
264        fs_util::atomic_write(&path, content.as_bytes())
265    }
266
267    /// Get a configured provider section by name.
268    pub fn section(&self, provider: &str) -> Option<&ProviderSection> {
269        self.sections.iter().find(|s| s.provider == provider)
270    }
271
272    /// Add or replace a provider section.
273    pub fn set_section(&mut self, section: ProviderSection) {
274        if let Some(existing) = self
275            .sections
276            .iter_mut()
277            .find(|s| s.provider == section.provider)
278        {
279            *existing = section;
280        } else {
281            self.sections.push(section);
282        }
283    }
284
285    /// Remove a provider section.
286    pub fn remove_section(&mut self, provider: &str) {
287        self.sections.retain(|s| s.provider != provider);
288    }
289
290    /// Get all configured provider sections.
291    pub fn configured_providers(&self) -> &[ProviderSection] {
292        &self.sections
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    #[test]
301    fn vault_role_invalid_is_dropped_on_parse() {
302        let config = ProviderConfig::parse("[aws]\ntoken=abc\nvault_role=-format=json\n");
303        assert_eq!(config.sections.len(), 1);
304        assert!(config.sections[0].vault_role.is_empty());
305    }
306
307    #[test]
308    fn vault_role_valid_is_parsed() {
309        let config = ProviderConfig::parse("[aws]\ntoken=abc\nvault_role=ssh/sign/engineer\n");
310        assert_eq!(config.sections[0].vault_role, "ssh/sign/engineer");
311    }
312
313    #[test]
314    fn vault_role_roundtrip_preserves_value() {
315        // Full parse → save → re-parse roundtrip for the provider-level
316        // vault_role field. Catches regressions where save() forgets to emit
317        // the field or parse() forgets to read it back.
318        let tmp = std::env::temp_dir().join(format!(
319            "purple_vault_role_roundtrip_{}_{}",
320            std::process::id(),
321            std::time::SystemTime::now()
322                .duration_since(std::time::UNIX_EPOCH)
323                .map(|d| d.as_nanos())
324                .unwrap_or(0)
325        ));
326        let config = ProviderConfig {
327            path_override: Some(tmp.clone()),
328            sections: vec![ProviderSection {
329                provider: "aws".to_string(),
330                token: "abc".to_string(),
331                alias_prefix: "aws".to_string(),
332                user: "ec2-user".to_string(),
333                identity_file: String::new(),
334                url: String::new(),
335                verify_tls: true,
336                profile: String::new(),
337                regions: "us-east-1".to_string(),
338                project: String::new(),
339                compartment: String::new(),
340                vault_role: "ssh-client-signer/sign/engineer".to_string(),
341                vault_addr: String::new(),
342                auto_sync: true,
343            }],
344        };
345        config.save().expect("save failed");
346
347        let content = std::fs::read_to_string(&tmp).expect("read failed");
348        assert!(
349            content.contains("vault_role=ssh-client-signer/sign/engineer"),
350            "serialized form missing vault_role: {}",
351            content
352        );
353
354        let reparsed = ProviderConfig::parse(&content);
355        assert_eq!(reparsed.sections.len(), 1);
356        assert_eq!(
357            reparsed.sections[0].vault_role,
358            "ssh-client-signer/sign/engineer"
359        );
360
361        let _ = std::fs::remove_file(&tmp);
362    }
363
364    #[test]
365    fn vault_role_invalid_skipped_on_write() {
366        let mut config = ProviderConfig::parse("[aws]\ntoken=abc\n");
367        // Inject an invalid role directly (bypassing parse) to simulate tampering.
368        config.sections[0].vault_role = "bad role".to_string();
369        // Emulate serialization logic for vault_role.
370        let mut out = String::new();
371        if !config.sections[0].vault_role.is_empty()
372            && crate::vault_ssh::is_valid_role(&config.sections[0].vault_role)
373        {
374            out.push_str("vault_role=");
375        }
376        assert!(out.is_empty(), "invalid role must be skipped on write");
377    }
378
379    #[test]
380    fn test_parse_empty() {
381        let config = ProviderConfig::parse("");
382        assert!(config.sections.is_empty());
383    }
384
385    #[test]
386    fn test_parse_single_section() {
387        let content = "\
388[digitalocean]
389token=dop_v1_abc123
390alias_prefix=do
391user=root
392key=~/.ssh/id_ed25519
393";
394        let config = ProviderConfig::parse(content);
395        assert_eq!(config.sections.len(), 1);
396        let s = &config.sections[0];
397        assert_eq!(s.provider, "digitalocean");
398        assert_eq!(s.token, "dop_v1_abc123");
399        assert_eq!(s.alias_prefix, "do");
400        assert_eq!(s.user, "root");
401        assert_eq!(s.identity_file, "~/.ssh/id_ed25519");
402    }
403
404    #[test]
405    fn test_parse_multiple_sections() {
406        let content = "\
407[digitalocean]
408token=abc
409
410[vultr]
411token=xyz
412user=deploy
413";
414        let config = ProviderConfig::parse(content);
415        assert_eq!(config.sections.len(), 2);
416        assert_eq!(config.sections[0].provider, "digitalocean");
417        assert_eq!(config.sections[1].provider, "vultr");
418        assert_eq!(config.sections[1].user, "deploy");
419    }
420
421    #[test]
422    fn test_parse_comments_and_blanks() {
423        let content = "\
424# Provider config
425
426[linode]
427# API token
428token=mytoken
429";
430        let config = ProviderConfig::parse(content);
431        assert_eq!(config.sections.len(), 1);
432        assert_eq!(config.sections[0].token, "mytoken");
433    }
434
435    #[test]
436    fn test_set_section_add() {
437        let mut config = ProviderConfig::default();
438        config.set_section(ProviderSection {
439            provider: "vultr".to_string(),
440            token: "abc".to_string(),
441            alias_prefix: "vultr".to_string(),
442            user: "root".to_string(),
443            identity_file: String::new(),
444            url: String::new(),
445            verify_tls: true,
446            auto_sync: true,
447            profile: String::new(),
448            regions: String::new(),
449            project: String::new(),
450            compartment: String::new(),
451            vault_role: String::new(),
452            vault_addr: String::new(),
453        });
454        assert_eq!(config.sections.len(), 1);
455    }
456
457    #[test]
458    fn test_set_section_replace() {
459        let mut config = ProviderConfig::parse("[vultr]\ntoken=old\n");
460        config.set_section(ProviderSection {
461            provider: "vultr".to_string(),
462            token: "new".to_string(),
463            alias_prefix: "vultr".to_string(),
464            user: "root".to_string(),
465            identity_file: String::new(),
466            url: String::new(),
467            verify_tls: true,
468            auto_sync: true,
469            profile: String::new(),
470            regions: String::new(),
471            project: String::new(),
472            compartment: String::new(),
473            vault_role: String::new(),
474            vault_addr: String::new(),
475        });
476        assert_eq!(config.sections.len(), 1);
477        assert_eq!(config.sections[0].token, "new");
478    }
479
480    #[test]
481    fn test_remove_section() {
482        let mut config = ProviderConfig::parse("[vultr]\ntoken=abc\n[linode]\ntoken=xyz\n");
483        config.remove_section("vultr");
484        assert_eq!(config.sections.len(), 1);
485        assert_eq!(config.sections[0].provider, "linode");
486    }
487
488    #[test]
489    fn test_section_lookup() {
490        let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\n");
491        assert!(config.section("digitalocean").is_some());
492        assert!(config.section("vultr").is_none());
493    }
494
495    #[test]
496    fn test_parse_duplicate_sections_first_wins() {
497        let content = "\
498[digitalocean]
499token=first
500
501[digitalocean]
502token=second
503";
504        let config = ProviderConfig::parse(content);
505        assert_eq!(config.sections.len(), 1);
506        assert_eq!(config.sections[0].token, "first");
507    }
508
509    #[test]
510    fn test_parse_duplicate_sections_trailing() {
511        let content = "\
512[vultr]
513token=abc
514
515[linode]
516token=xyz
517
518[vultr]
519token=dup
520";
521        let config = ProviderConfig::parse(content);
522        assert_eq!(config.sections.len(), 2);
523        assert_eq!(config.sections[0].provider, "vultr");
524        assert_eq!(config.sections[0].token, "abc");
525        assert_eq!(config.sections[1].provider, "linode");
526    }
527
528    #[test]
529    fn test_defaults_applied() {
530        let config = ProviderConfig::parse("[hetzner]\ntoken=abc\n");
531        let s = &config.sections[0];
532        assert_eq!(s.user, "root");
533        assert_eq!(s.alias_prefix, "hetzner");
534        assert!(s.identity_file.is_empty());
535        assert!(s.url.is_empty());
536        assert!(s.verify_tls);
537        assert!(s.auto_sync);
538    }
539
540    #[test]
541    fn test_parse_url_and_verify_tls() {
542        let content = "\
543[proxmox]
544token=user@pam!purple=secret
545url=https://pve.example.com:8006
546verify_tls=false
547";
548        let config = ProviderConfig::parse(content);
549        assert_eq!(config.sections.len(), 1);
550        let s = &config.sections[0];
551        assert_eq!(s.url, "https://pve.example.com:8006");
552        assert!(!s.verify_tls);
553    }
554
555    #[test]
556    fn test_url_and_verify_tls_round_trip() {
557        let content = "\
558[proxmox]
559token=tok
560alias_prefix=pve
561user=root
562url=https://pve.local:8006
563verify_tls=false
564";
565        let config = ProviderConfig::parse(content);
566        let s = &config.sections[0];
567        assert_eq!(s.url, "https://pve.local:8006");
568        assert!(!s.verify_tls);
569    }
570
571    #[test]
572    fn test_verify_tls_default_true() {
573        // verify_tls not present -> defaults to true
574        let config = ProviderConfig::parse("[proxmox]\ntoken=abc\nurl=https://pve:8006\n");
575        assert!(config.sections[0].verify_tls);
576    }
577
578    #[test]
579    fn test_verify_tls_false_variants() {
580        for value in &["false", "False", "FALSE", "0", "no", "No", "NO"] {
581            let content = format!(
582                "[proxmox]\ntoken=abc\nurl=https://pve:8006\nverify_tls={}\n",
583                value
584            );
585            let config = ProviderConfig::parse(&content);
586            assert!(
587                !config.sections[0].verify_tls,
588                "verify_tls={} should be false",
589                value
590            );
591        }
592    }
593
594    #[test]
595    fn test_verify_tls_true_variants() {
596        for value in &["true", "True", "1", "yes"] {
597            let content = format!(
598                "[proxmox]\ntoken=abc\nurl=https://pve:8006\nverify_tls={}\n",
599                value
600            );
601            let config = ProviderConfig::parse(&content);
602            assert!(
603                config.sections[0].verify_tls,
604                "verify_tls={} should be true",
605                value
606            );
607        }
608    }
609
610    #[test]
611    fn test_non_proxmox_url_not_written() {
612        // url and verify_tls=false must not appear for non-Proxmox providers in saved config
613        let section = ProviderSection {
614            provider: "digitalocean".to_string(),
615            token: "tok".to_string(),
616            alias_prefix: "do".to_string(),
617            user: "root".to_string(),
618            identity_file: String::new(),
619            url: String::new(), // empty: not written
620            verify_tls: true,   // default: not written
621            auto_sync: true,    // default for non-proxmox: not written
622            profile: String::new(),
623            regions: String::new(),
624            project: String::new(),
625            compartment: String::new(),
626            vault_role: String::new(),
627            vault_addr: String::new(),
628        };
629        let mut config = ProviderConfig::default();
630        config.set_section(section);
631        // Parse it back: url and verify_tls should be at defaults
632        let s = &config.sections[0];
633        assert!(s.url.is_empty());
634        assert!(s.verify_tls);
635    }
636
637    #[test]
638    fn test_proxmox_url_fallback_in_section() {
639        // Simulates the update path: existing section has url, new section should preserve it
640        let existing = ProviderConfig::parse(
641            "[proxmox]\ntoken=old\nalias_prefix=pve\nuser=root\nurl=https://pve.local:8006\n",
642        );
643        let existing_url = existing
644            .section("proxmox")
645            .map(|s| s.url.clone())
646            .unwrap_or_default();
647        assert_eq!(existing_url, "https://pve.local:8006");
648
649        let mut config = existing;
650        config.set_section(ProviderSection {
651            provider: "proxmox".to_string(),
652            token: "new".to_string(),
653            alias_prefix: "pve".to_string(),
654            user: "root".to_string(),
655            identity_file: String::new(),
656            url: existing_url,
657            verify_tls: true,
658            auto_sync: false,
659            profile: String::new(),
660            regions: String::new(),
661            project: String::new(),
662            compartment: String::new(),
663            vault_role: String::new(),
664            vault_addr: String::new(),
665        });
666        assert_eq!(config.sections[0].token, "new");
667        assert_eq!(config.sections[0].url, "https://pve.local:8006");
668    }
669
670    #[test]
671    fn test_auto_sync_default_true_for_non_proxmox() {
672        let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\n");
673        assert!(config.sections[0].auto_sync);
674    }
675
676    #[test]
677    fn test_auto_sync_default_false_for_proxmox() {
678        let config = ProviderConfig::parse("[proxmox]\ntoken=abc\nurl=https://pve:8006\n");
679        assert!(!config.sections[0].auto_sync);
680    }
681
682    #[test]
683    fn test_auto_sync_explicit_true() {
684        let config =
685            ProviderConfig::parse("[proxmox]\ntoken=abc\nurl=https://pve:8006\nauto_sync=true\n");
686        assert!(config.sections[0].auto_sync);
687    }
688
689    #[test]
690    fn test_auto_sync_explicit_false_non_proxmox() {
691        let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\nauto_sync=false\n");
692        assert!(!config.sections[0].auto_sync);
693    }
694
695    #[test]
696    fn test_auto_sync_not_written_when_default() {
697        // non-proxmox with auto_sync=true (default) -> not written
698        let mut config = ProviderConfig::default();
699        config.set_section(ProviderSection {
700            provider: "digitalocean".to_string(),
701            token: "tok".to_string(),
702            alias_prefix: "do".to_string(),
703            user: "root".to_string(),
704            identity_file: String::new(),
705            url: String::new(),
706            verify_tls: true,
707            auto_sync: true,
708            profile: String::new(),
709            regions: String::new(),
710            project: String::new(),
711            compartment: String::new(),
712            vault_role: String::new(),
713            vault_addr: String::new(),
714        });
715        // Re-parse: auto_sync should still be true (default)
716        assert!(config.sections[0].auto_sync);
717
718        // proxmox with auto_sync=false (default) -> not written
719        let mut config2 = ProviderConfig::default();
720        config2.set_section(ProviderSection {
721            provider: "proxmox".to_string(),
722            token: "tok".to_string(),
723            alias_prefix: "pve".to_string(),
724            user: "root".to_string(),
725            identity_file: String::new(),
726            url: "https://pve:8006".to_string(),
727            verify_tls: true,
728            auto_sync: false,
729            profile: String::new(),
730            regions: String::new(),
731            project: String::new(),
732            compartment: String::new(),
733            vault_role: String::new(),
734            vault_addr: String::new(),
735        });
736        assert!(!config2.sections[0].auto_sync);
737    }
738
739    #[test]
740    fn test_auto_sync_false_variants() {
741        for value in &["false", "False", "FALSE", "0", "no"] {
742            let content = format!("[digitalocean]\ntoken=abc\nauto_sync={}\n", value);
743            let config = ProviderConfig::parse(&content);
744            assert!(
745                !config.sections[0].auto_sync,
746                "auto_sync={} should be false",
747                value
748            );
749        }
750    }
751
752    #[test]
753    fn test_auto_sync_true_variants() {
754        for value in &["true", "True", "TRUE", "1", "yes"] {
755            // Start from proxmox default=false, override to true via explicit value
756            let content = format!(
757                "[proxmox]\ntoken=abc\nurl=https://pve:8006\nauto_sync={}\n",
758                value
759            );
760            let config = ProviderConfig::parse(&content);
761            assert!(
762                config.sections[0].auto_sync,
763                "auto_sync={} should be true",
764                value
765            );
766        }
767    }
768
769    #[test]
770    fn test_auto_sync_malformed_value_treated_as_true() {
771        // Unrecognised value is not "false"/"0"/"no", so treated as true (like verify_tls)
772        let config =
773            ProviderConfig::parse("[proxmox]\ntoken=abc\nurl=https://pve:8006\nauto_sync=maybe\n");
774        assert!(config.sections[0].auto_sync);
775    }
776
777    #[test]
778    fn test_auto_sync_written_only_when_non_default() {
779        // proxmox defaults to false — setting it to true is non-default, so it IS written
780        let mut config = ProviderConfig::default();
781        config.set_section(ProviderSection {
782            provider: "proxmox".to_string(),
783            token: "tok".to_string(),
784            alias_prefix: "pve".to_string(),
785            user: "root".to_string(),
786            identity_file: String::new(),
787            url: "https://pve:8006".to_string(),
788            verify_tls: true,
789            auto_sync: true, // non-default for proxmox
790            profile: String::new(),
791            regions: String::new(),
792            project: String::new(),
793            compartment: String::new(),
794            vault_role: String::new(),
795            vault_addr: String::new(),
796        });
797        // Simulate save by rebuilding content string (same logic as save())
798        let content =
799            "[proxmox]\ntoken=tok\nalias_prefix=pve\nuser=root\nurl=https://pve:8006\nauto_sync=true\n"
800        .to_string();
801        let reparsed = ProviderConfig::parse(&content);
802        assert!(reparsed.sections[0].auto_sync);
803
804        // digitalocean defaults to true — setting it to false IS written
805        let content2 = "[digitalocean]\ntoken=tok\nalias_prefix=do\nuser=root\nauto_sync=false\n";
806        let reparsed2 = ProviderConfig::parse(content2);
807        assert!(!reparsed2.sections[0].auto_sync);
808    }
809
810    // =========================================================================
811    // configured_providers accessor
812    // =========================================================================
813
814    #[test]
815    fn test_configured_providers_empty() {
816        let config = ProviderConfig::default();
817        assert!(config.configured_providers().is_empty());
818    }
819
820    #[test]
821    fn test_configured_providers_returns_all() {
822        let content = "[digitalocean]\ntoken=a\n\n[vultr]\ntoken=b\n";
823        let config = ProviderConfig::parse(content);
824        assert_eq!(config.configured_providers().len(), 2);
825    }
826
827    // =========================================================================
828    // Parse edge cases
829    // =========================================================================
830
831    #[test]
832    fn test_parse_unknown_keys_ignored() {
833        let content = "[digitalocean]\ntoken=abc\nfoo=bar\nunknown_key=value\n";
834        let config = ProviderConfig::parse(content);
835        assert_eq!(config.sections.len(), 1);
836        assert_eq!(config.sections[0].token, "abc");
837    }
838
839    #[test]
840    fn test_parse_unknown_provider_still_parsed() {
841        let content = "[aws]\ntoken=secret\n";
842        let config = ProviderConfig::parse(content);
843        assert_eq!(config.sections.len(), 1);
844        assert_eq!(config.sections[0].provider, "aws");
845    }
846
847    #[test]
848    fn test_parse_whitespace_in_section_name() {
849        let content = "[ digitalocean ]\ntoken=abc\n";
850        let config = ProviderConfig::parse(content);
851        assert_eq!(config.sections.len(), 1);
852        assert_eq!(config.sections[0].provider, "digitalocean");
853    }
854
855    #[test]
856    fn test_parse_value_with_equals() {
857        // Token might contain = signs (base64)
858        let content = "[digitalocean]\ntoken=abc=def==\n";
859        let config = ProviderConfig::parse(content);
860        assert_eq!(config.sections[0].token, "abc=def==");
861    }
862
863    #[test]
864    fn test_parse_whitespace_around_key_value() {
865        let content = "[digitalocean]\n  token = my-token  \n";
866        let config = ProviderConfig::parse(content);
867        assert_eq!(config.sections[0].token, "my-token");
868    }
869
870    #[test]
871    fn test_parse_key_field_sets_identity_file() {
872        let content = "[digitalocean]\ntoken=abc\nkey=~/.ssh/id_rsa\n";
873        let config = ProviderConfig::parse(content);
874        assert_eq!(config.sections[0].identity_file, "~/.ssh/id_rsa");
875    }
876
877    #[test]
878    fn test_section_lookup_missing() {
879        let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\n");
880        assert!(config.section("vultr").is_none());
881    }
882
883    #[test]
884    fn test_section_lookup_found() {
885        let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\n");
886        let section = config.section("digitalocean").unwrap();
887        assert_eq!(section.token, "abc");
888    }
889
890    #[test]
891    fn test_remove_nonexistent_section_noop() {
892        let mut config = ProviderConfig::parse("[digitalocean]\ntoken=abc\n");
893        config.remove_section("vultr");
894        assert_eq!(config.sections.len(), 1);
895    }
896
897    // =========================================================================
898    // Default alias_prefix from short_label
899    // =========================================================================
900
901    #[test]
902    fn test_default_alias_prefix_digitalocean() {
903        let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\n");
904        assert_eq!(config.sections[0].alias_prefix, "do");
905    }
906
907    #[test]
908    fn test_default_alias_prefix_upcloud() {
909        let config = ProviderConfig::parse("[upcloud]\ntoken=abc\n");
910        assert_eq!(config.sections[0].alias_prefix, "uc");
911    }
912
913    #[test]
914    fn test_default_alias_prefix_proxmox() {
915        let config = ProviderConfig::parse("[proxmox]\ntoken=abc\n");
916        assert_eq!(config.sections[0].alias_prefix, "pve");
917    }
918
919    #[test]
920    fn test_alias_prefix_override() {
921        let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\nalias_prefix=ocean\n");
922        assert_eq!(config.sections[0].alias_prefix, "ocean");
923    }
924
925    // =========================================================================
926    // Default user is root
927    // =========================================================================
928
929    #[test]
930    fn test_default_user_is_root() {
931        let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\n");
932        assert_eq!(config.sections[0].user, "root");
933    }
934
935    #[test]
936    fn test_user_override() {
937        let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\nuser=admin\n");
938        assert_eq!(config.sections[0].user, "admin");
939    }
940
941    // =========================================================================
942    // Proxmox URL scheme validation context
943    // =========================================================================
944
945    #[test]
946    fn test_proxmox_url_parsed() {
947        let config = ProviderConfig::parse("[proxmox]\ntoken=abc\nurl=https://pve.local:8006\n");
948        assert_eq!(config.sections[0].url, "https://pve.local:8006");
949    }
950
951    #[test]
952    fn test_non_proxmox_url_parsed_but_ignored() {
953        // URL field is parsed for all providers, but only Proxmox uses it
954        let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\nurl=https://api.do.com\n");
955        assert_eq!(config.sections[0].url, "https://api.do.com");
956    }
957
958    // =========================================================================
959    // Duplicate sections
960    // =========================================================================
961
962    #[test]
963    fn test_duplicate_section_first_wins() {
964        let content = "[digitalocean]\ntoken=first\n\n[digitalocean]\ntoken=second\n";
965        let config = ProviderConfig::parse(content);
966        assert_eq!(config.sections.len(), 1);
967        assert_eq!(config.sections[0].token, "first");
968    }
969
970    // =========================================================================
971    // verify_tls parsing
972    // =========================================================================
973
974    // =========================================================================
975    // auto_sync default per provider
976    // =========================================================================
977
978    #[test]
979    fn test_auto_sync_default_proxmox_false() {
980        let config = ProviderConfig::parse("[proxmox]\ntoken=abc\n");
981        assert!(!config.sections[0].auto_sync);
982    }
983
984    #[test]
985    fn test_auto_sync_default_all_others_true() {
986        for provider in &[
987            "digitalocean",
988            "vultr",
989            "linode",
990            "hetzner",
991            "upcloud",
992            "aws",
993            "scaleway",
994            "gcp",
995            "azure",
996            "tailscale",
997            "oracle",
998            "ovh",
999        ] {
1000            let content = format!("[{}]\ntoken=abc\n", provider);
1001            let config = ProviderConfig::parse(&content);
1002            assert!(
1003                config.sections[0].auto_sync,
1004                "auto_sync should default to true for {}",
1005                provider
1006            );
1007        }
1008    }
1009
1010    #[test]
1011    fn test_auto_sync_override_proxmox_to_true() {
1012        let config = ProviderConfig::parse("[proxmox]\ntoken=abc\nauto_sync=true\n");
1013        assert!(config.sections[0].auto_sync);
1014    }
1015
1016    #[test]
1017    fn test_auto_sync_override_do_to_false() {
1018        let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\nauto_sync=false\n");
1019        assert!(!config.sections[0].auto_sync);
1020    }
1021
1022    // =========================================================================
1023    // set_section and remove_section
1024    // =========================================================================
1025
1026    #[test]
1027    fn test_set_section_adds_new() {
1028        let mut config = ProviderConfig::default();
1029        let section = ProviderSection {
1030            provider: "vultr".to_string(),
1031            token: "tok".to_string(),
1032            alias_prefix: "vultr".to_string(),
1033            user: "root".to_string(),
1034            identity_file: String::new(),
1035            url: String::new(),
1036            verify_tls: true,
1037            auto_sync: true,
1038            profile: String::new(),
1039            regions: String::new(),
1040            project: String::new(),
1041            compartment: String::new(),
1042            vault_role: String::new(),
1043            vault_addr: String::new(),
1044        };
1045        config.set_section(section);
1046        assert_eq!(config.sections.len(), 1);
1047        assert_eq!(config.sections[0].provider, "vultr");
1048    }
1049
1050    #[test]
1051    fn test_set_section_replaces_existing() {
1052        let mut config = ProviderConfig::parse("[vultr]\ntoken=old\n");
1053        assert_eq!(config.sections[0].token, "old");
1054        let section = ProviderSection {
1055            provider: "vultr".to_string(),
1056            token: "new".to_string(),
1057            alias_prefix: "vultr".to_string(),
1058            user: "root".to_string(),
1059            identity_file: String::new(),
1060            url: String::new(),
1061            verify_tls: true,
1062            auto_sync: true,
1063            profile: String::new(),
1064            regions: String::new(),
1065            project: String::new(),
1066            compartment: String::new(),
1067            vault_role: String::new(),
1068            vault_addr: String::new(),
1069        };
1070        config.set_section(section);
1071        assert_eq!(config.sections.len(), 1);
1072        assert_eq!(config.sections[0].token, "new");
1073    }
1074
1075    #[test]
1076    fn test_remove_section_keeps_others() {
1077        let mut config = ProviderConfig::parse("[vultr]\ntoken=abc\n\n[linode]\ntoken=def\n");
1078        assert_eq!(config.sections.len(), 2);
1079        config.remove_section("vultr");
1080        assert_eq!(config.sections.len(), 1);
1081        assert_eq!(config.sections[0].provider, "linode");
1082    }
1083
1084    // =========================================================================
1085    // Comments and blank lines
1086    // =========================================================================
1087
1088    #[test]
1089    fn test_comments_ignored() {
1090        let content = "# This is a comment\n[digitalocean]\n# Another comment\ntoken=abc\n";
1091        let config = ProviderConfig::parse(content);
1092        assert_eq!(config.sections.len(), 1);
1093        assert_eq!(config.sections[0].token, "abc");
1094    }
1095
1096    #[test]
1097    fn test_blank_lines_ignored() {
1098        let content = "\n\n[digitalocean]\n\ntoken=abc\n\n";
1099        let config = ProviderConfig::parse(content);
1100        assert_eq!(config.sections.len(), 1);
1101        assert_eq!(config.sections[0].token, "abc");
1102    }
1103
1104    // =========================================================================
1105    // Multiple providers
1106    // =========================================================================
1107
1108    #[test]
1109    fn test_multiple_providers() {
1110        let content = "[digitalocean]\ntoken=do-tok\n\n[vultr]\ntoken=vultr-tok\n\n[proxmox]\ntoken=pve-tok\nurl=https://pve:8006\n";
1111        let config = ProviderConfig::parse(content);
1112        assert_eq!(config.sections.len(), 3);
1113        assert_eq!(config.sections[0].provider, "digitalocean");
1114        assert_eq!(config.sections[1].provider, "vultr");
1115        assert_eq!(config.sections[2].provider, "proxmox");
1116        assert_eq!(config.sections[2].url, "https://pve:8006");
1117    }
1118
1119    // =========================================================================
1120    // Token with special characters
1121    // =========================================================================
1122
1123    #[test]
1124    fn test_token_with_equals_sign() {
1125        // API tokens can contain = signs (e.g., base64)
1126        let content = "[digitalocean]\ntoken=dop_v1_abc123==\n";
1127        let config = ProviderConfig::parse(content);
1128        // split_once('=') splits at first =, so "dop_v1_abc123==" is preserved
1129        assert_eq!(config.sections[0].token, "dop_v1_abc123==");
1130    }
1131
1132    #[test]
1133    fn test_proxmox_token_with_exclamation() {
1134        let content = "[proxmox]\ntoken=user@pam!api-token=12345678-abcd\nurl=https://pve:8006\n";
1135        let config = ProviderConfig::parse(content);
1136        assert_eq!(config.sections[0].token, "user@pam!api-token=12345678-abcd");
1137    }
1138
1139    // =========================================================================
1140    // Parse serialization roundtrip
1141    // =========================================================================
1142
1143    #[test]
1144    fn test_serialize_roundtrip_single_provider() {
1145        let content = "[digitalocean]\ntoken=abc\nalias_prefix=do\nuser=root\n";
1146        let config = ProviderConfig::parse(content);
1147        let mut serialized = String::new();
1148        for section in &config.sections {
1149            serialized.push_str(&format!("[{}]\n", section.provider));
1150            serialized.push_str(&format!("token={}\n", section.token));
1151            serialized.push_str(&format!("alias_prefix={}\n", section.alias_prefix));
1152            serialized.push_str(&format!("user={}\n", section.user));
1153        }
1154        let reparsed = ProviderConfig::parse(&serialized);
1155        assert_eq!(reparsed.sections.len(), 1);
1156        assert_eq!(reparsed.sections[0].token, "abc");
1157        assert_eq!(reparsed.sections[0].alias_prefix, "do");
1158        assert_eq!(reparsed.sections[0].user, "root");
1159    }
1160
1161    // =========================================================================
1162    // verify_tls parsing variants
1163    // =========================================================================
1164
1165    #[test]
1166    fn test_verify_tls_values() {
1167        for (val, expected) in [
1168            ("false", false),
1169            ("False", false),
1170            ("FALSE", false),
1171            ("0", false),
1172            ("no", false),
1173            ("No", false),
1174            ("NO", false),
1175            ("true", true),
1176            ("True", true),
1177            ("1", true),
1178            ("yes", true),
1179            ("anything", true), // any unrecognized value defaults to true
1180        ] {
1181            let content = format!("[digitalocean]\ntoken=t\nverify_tls={}\n", val);
1182            let config = ProviderConfig::parse(&content);
1183            assert_eq!(
1184                config.sections[0].verify_tls, expected,
1185                "verify_tls={} should be {}",
1186                val, expected
1187            );
1188        }
1189    }
1190
1191    // =========================================================================
1192    // auto_sync parsing variants
1193    // =========================================================================
1194
1195    #[test]
1196    fn test_auto_sync_values() {
1197        for (val, expected) in [
1198            ("false", false),
1199            ("False", false),
1200            ("FALSE", false),
1201            ("0", false),
1202            ("no", false),
1203            ("No", false),
1204            ("true", true),
1205            ("1", true),
1206            ("yes", true),
1207        ] {
1208            let content = format!("[digitalocean]\ntoken=t\nauto_sync={}\n", val);
1209            let config = ProviderConfig::parse(&content);
1210            assert_eq!(
1211                config.sections[0].auto_sync, expected,
1212                "auto_sync={} should be {}",
1213                val, expected
1214            );
1215        }
1216    }
1217
1218    // =========================================================================
1219    // Default values
1220    // =========================================================================
1221
1222    #[test]
1223    fn test_default_user_root_when_not_specified() {
1224        let content = "[digitalocean]\ntoken=abc\n";
1225        let config = ProviderConfig::parse(content);
1226        assert_eq!(config.sections[0].user, "root");
1227    }
1228
1229    #[test]
1230    fn test_default_alias_prefix_from_short_label() {
1231        // DigitalOcean short_label is "do"
1232        let content = "[digitalocean]\ntoken=abc\n";
1233        let config = ProviderConfig::parse(content);
1234        assert_eq!(config.sections[0].alias_prefix, "do");
1235    }
1236
1237    #[test]
1238    fn test_default_alias_prefix_unknown_provider() {
1239        // Unknown provider uses the section name as default prefix
1240        let content = "[unknown_cloud]\ntoken=abc\n";
1241        let config = ProviderConfig::parse(content);
1242        assert_eq!(config.sections[0].alias_prefix, "unknown_cloud");
1243    }
1244
1245    #[test]
1246    fn test_default_identity_file_empty() {
1247        let content = "[digitalocean]\ntoken=abc\n";
1248        let config = ProviderConfig::parse(content);
1249        assert!(config.sections[0].identity_file.is_empty());
1250    }
1251
1252    #[test]
1253    fn test_default_url_empty() {
1254        let content = "[digitalocean]\ntoken=abc\n";
1255        let config = ProviderConfig::parse(content);
1256        assert!(config.sections[0].url.is_empty());
1257    }
1258
1259    // =========================================================================
1260    // GCP project field
1261    // =========================================================================
1262
1263    #[test]
1264    fn test_gcp_project_parsed() {
1265        let config = ProviderConfig::parse("[gcp]\ntoken=abc\nproject=my-gcp-project\n");
1266        assert_eq!(config.sections[0].project, "my-gcp-project");
1267    }
1268
1269    #[test]
1270    fn test_gcp_project_default_empty() {
1271        let config = ProviderConfig::parse("[gcp]\ntoken=abc\n");
1272        assert!(config.sections[0].project.is_empty());
1273    }
1274
1275    #[test]
1276    fn test_gcp_project_roundtrip() {
1277        let content = "[gcp]\ntoken=sa.json\nproject=my-project\nregions=us-central1-a\n";
1278        let config = ProviderConfig::parse(content);
1279        assert_eq!(config.sections[0].project, "my-project");
1280        assert_eq!(config.sections[0].regions, "us-central1-a");
1281        // Re-serialize and parse
1282        let serialized = format!(
1283            "[gcp]\ntoken={}\nproject={}\nregions={}\n",
1284            config.sections[0].token, config.sections[0].project, config.sections[0].regions,
1285        );
1286        let reparsed = ProviderConfig::parse(&serialized);
1287        assert_eq!(reparsed.sections[0].project, "my-project");
1288        assert_eq!(reparsed.sections[0].regions, "us-central1-a");
1289    }
1290
1291    #[test]
1292    fn test_default_alias_prefix_gcp() {
1293        let config = ProviderConfig::parse("[gcp]\ntoken=abc\n");
1294        assert_eq!(config.sections[0].alias_prefix, "gcp");
1295    }
1296
1297    // =========================================================================
1298    // configured_providers and section methods
1299    // =========================================================================
1300
1301    #[test]
1302    fn test_configured_providers_returns_all_sections() {
1303        let content = "[digitalocean]\ntoken=a\n\n[vultr]\ntoken=b\n";
1304        let config = ProviderConfig::parse(content);
1305        assert_eq!(config.configured_providers().len(), 2);
1306    }
1307
1308    #[test]
1309    fn test_section_by_name() {
1310        let content = "[digitalocean]\ntoken=do-tok\n\n[vultr]\ntoken=vultr-tok\n";
1311        let config = ProviderConfig::parse(content);
1312        let do_section = config.section("digitalocean").unwrap();
1313        assert_eq!(do_section.token, "do-tok");
1314        let vultr_section = config.section("vultr").unwrap();
1315        assert_eq!(vultr_section.token, "vultr-tok");
1316    }
1317
1318    #[test]
1319    fn test_section_not_found() {
1320        let config = ProviderConfig::parse("");
1321        assert!(config.section("nonexistent").is_none());
1322    }
1323
1324    // =========================================================================
1325    // Key without value
1326    // =========================================================================
1327
1328    #[test]
1329    fn test_line_without_equals_ignored() {
1330        let content = "[digitalocean]\ntoken=abc\ngarbage_line\nuser=admin\n";
1331        let config = ProviderConfig::parse(content);
1332        assert_eq!(config.sections[0].token, "abc");
1333        assert_eq!(config.sections[0].user, "admin");
1334    }
1335
1336    #[test]
1337    fn test_unknown_key_ignored() {
1338        let content = "[digitalocean]\ntoken=abc\nfoo=bar\nbaz=qux\nuser=admin\n";
1339        let config = ProviderConfig::parse(content);
1340        assert_eq!(config.sections[0].token, "abc");
1341        assert_eq!(config.sections[0].user, "admin");
1342    }
1343
1344    // =========================================================================
1345    // Whitespace handling
1346    // =========================================================================
1347
1348    #[test]
1349    fn test_whitespace_around_section_name() {
1350        let content = "[  digitalocean  ]\ntoken=abc\n";
1351        let config = ProviderConfig::parse(content);
1352        assert_eq!(config.sections[0].provider, "digitalocean");
1353    }
1354
1355    #[test]
1356    fn test_whitespace_around_key_value() {
1357        let content = "[digitalocean]\n  token  =  abc  \n  user  =  admin  \n";
1358        let config = ProviderConfig::parse(content);
1359        assert_eq!(config.sections[0].token, "abc");
1360        assert_eq!(config.sections[0].user, "admin");
1361    }
1362
1363    // =========================================================================
1364    // set_section edge cases
1365    // =========================================================================
1366
1367    #[test]
1368    fn test_set_section_multiple_adds() {
1369        let mut config = ProviderConfig::default();
1370        for name in ["digitalocean", "vultr", "hetzner"] {
1371            config.set_section(ProviderSection {
1372                provider: name.to_string(),
1373                token: format!("{}-tok", name),
1374                alias_prefix: name.to_string(),
1375                user: "root".to_string(),
1376                identity_file: String::new(),
1377                url: String::new(),
1378                verify_tls: true,
1379                auto_sync: true,
1380                profile: String::new(),
1381                regions: String::new(),
1382                project: String::new(),
1383                compartment: String::new(),
1384                vault_role: String::new(),
1385                vault_addr: String::new(),
1386            });
1387        }
1388        assert_eq!(config.sections.len(), 3);
1389    }
1390
1391    #[test]
1392    fn test_remove_section_all() {
1393        let content = "[digitalocean]\ntoken=a\n\n[vultr]\ntoken=b\n";
1394        let mut config = ProviderConfig::parse(content);
1395        config.remove_section("digitalocean");
1396        config.remove_section("vultr");
1397        assert!(config.sections.is_empty());
1398    }
1399
1400    // =========================================================================
1401    // Oracle / compartment field
1402    // =========================================================================
1403
1404    #[test]
1405    fn test_compartment_field_round_trip() {
1406        use std::path::PathBuf;
1407        let content = "[oracle]\ntoken=~/.oci/config\ncompartment=ocid1.compartment.oc1..example\n";
1408        let config = ProviderConfig::parse(content);
1409        assert_eq!(
1410            config.sections[0].compartment,
1411            "ocid1.compartment.oc1..example"
1412        );
1413
1414        // Save to a temp file and re-parse
1415        let tmp = std::env::temp_dir().join("purple_test_compartment_round_trip");
1416        let mut cfg = config;
1417        cfg.path_override = Some(PathBuf::from(&tmp));
1418        cfg.save().expect("save failed");
1419        let saved = std::fs::read_to_string(&tmp).expect("read failed");
1420        let _ = std::fs::remove_file(&tmp);
1421        let reparsed = ProviderConfig::parse(&saved);
1422        assert_eq!(
1423            reparsed.sections[0].compartment,
1424            "ocid1.compartment.oc1..example"
1425        );
1426    }
1427
1428    #[test]
1429    fn test_auto_sync_default_true_for_oracle() {
1430        let config = ProviderConfig::parse("[oracle]\ntoken=~/.oci/config\n");
1431        assert!(config.sections[0].auto_sync);
1432    }
1433
1434    #[test]
1435    fn test_sanitize_value_strips_control_chars() {
1436        assert_eq!(ProviderConfig::sanitize_value("clean"), "clean");
1437        assert_eq!(ProviderConfig::sanitize_value("has\nnewline"), "hasnewline");
1438        assert_eq!(ProviderConfig::sanitize_value("has\ttab"), "hastab");
1439        assert_eq!(
1440            ProviderConfig::sanitize_value("has\rcarriage"),
1441            "hascarriage"
1442        );
1443        assert_eq!(ProviderConfig::sanitize_value("has\x00null"), "hasnull");
1444        assert_eq!(ProviderConfig::sanitize_value(""), "");
1445    }
1446
1447    #[test]
1448    fn test_save_sanitizes_token_with_newline() {
1449        let path = std::env::temp_dir().join(format!(
1450            "__purple_test_config_sanitize_{}.ini",
1451            std::process::id()
1452        ));
1453        let config = ProviderConfig {
1454            sections: vec![ProviderSection {
1455                provider: "digitalocean".to_string(),
1456                token: "abc\ndef".to_string(),
1457                alias_prefix: "do".to_string(),
1458                user: "root".to_string(),
1459                identity_file: String::new(),
1460                url: String::new(),
1461                verify_tls: true,
1462                auto_sync: true,
1463                profile: String::new(),
1464                regions: String::new(),
1465                project: String::new(),
1466                compartment: String::new(),
1467                vault_role: String::new(),
1468                vault_addr: String::new(),
1469            }],
1470            path_override: Some(path.clone()),
1471        };
1472        config.save().unwrap();
1473        let content = std::fs::read_to_string(&path).unwrap();
1474        let _ = std::fs::remove_file(&path);
1475        // Token should be on a single line with newline stripped
1476        assert!(content.contains("token=abcdef\n"));
1477        assert!(!content.contains("token=abc\ndef"));
1478    }
1479
1480    #[test]
1481    fn provider_vault_role_invalid_characters_rejected_on_parse() {
1482        // Values with spaces, shell metacharacters or newlines are silently
1483        // dropped so parsing stays infallible but invalid roles never reach
1484        // the Vault CLI.
1485        let cases = [
1486            "[aws]\ntoken=abc\nvault_role=bad role\n",
1487            "[aws]\ntoken=abc\nvault_role=role;rm\n",
1488            "[aws]\ntoken=abc\nvault_role=role$(x)\n",
1489            "[aws]\ntoken=abc\nvault_role=role|cat\n",
1490        ];
1491        for input in &cases {
1492            let config = ProviderConfig::parse(input);
1493            assert!(
1494                config.sections[0].vault_role.is_empty(),
1495                "expected empty vault_role for input: {:?}",
1496                input
1497            );
1498        }
1499    }
1500
1501    #[test]
1502    fn test_vault_role_default_empty() {
1503        let config = ProviderConfig::parse("[aws]\ntoken=abc\n");
1504        assert!(config.sections[0].vault_role.is_empty());
1505    }
1506
1507    #[test]
1508    fn test_vault_role_not_written_when_empty() {
1509        let tmpdir = std::env::temp_dir();
1510        let path = tmpdir.join("purple_test_vault_role_empty.ini");
1511        let mut config = ProviderConfig::parse("[aws]\ntoken=abc\n");
1512        config.path_override = Some(path.clone());
1513        config.save().unwrap();
1514        let content = std::fs::read_to_string(&path).unwrap();
1515        let _ = std::fs::remove_file(&path);
1516        assert!(!content.contains("vault_role"));
1517    }
1518
1519    #[test]
1520    fn test_vault_role_round_trip() {
1521        let tmpdir = std::env::temp_dir();
1522        let path = tmpdir.join("purple_test_vault_role_rt.ini");
1523        let mut config = ProviderConfig::parse("[aws]\ntoken=abc\nvault_role=ssh/sign/engineer\n");
1524        config.path_override = Some(path.clone());
1525        config.save().unwrap();
1526        let content = std::fs::read_to_string(&path).unwrap();
1527        let _ = std::fs::remove_file(&path);
1528        assert!(content.contains("vault_role=ssh/sign/engineer"));
1529    }
1530
1531    // ---- vault_addr tests ----
1532
1533    #[test]
1534    fn vault_addr_default_empty() {
1535        let config = ProviderConfig::parse("[aws]\ntoken=abc\n");
1536        assert!(config.sections[0].vault_addr.is_empty());
1537    }
1538
1539    #[test]
1540    fn vault_addr_parsed() {
1541        let config = ProviderConfig::parse("[aws]\ntoken=abc\nvault_addr=http://127.0.0.1:8200\n");
1542        assert_eq!(config.sections[0].vault_addr, "http://127.0.0.1:8200");
1543    }
1544
1545    #[test]
1546    fn vault_addr_invalid_dropped_on_parse() {
1547        // Whitespace and control chars are not allowed in a VAULT_ADDR.
1548        for input in [
1549            "[aws]\ntoken=abc\nvault_addr=has space\n",
1550            "[aws]\ntoken=abc\nvault_addr=\n",
1551        ] {
1552            let config = ProviderConfig::parse(input);
1553            assert!(
1554                config.sections[0].vault_addr.is_empty(),
1555                "expected empty vault_addr for input: {:?}",
1556                input
1557            );
1558        }
1559    }
1560
1561    #[test]
1562    fn vault_addr_round_trip() {
1563        let tmpdir = std::env::temp_dir();
1564        let path = tmpdir.join("purple_test_vault_addr_rt.ini");
1565        let mut config =
1566            ProviderConfig::parse("[aws]\ntoken=abc\nvault_addr=http://127.0.0.1:8200\n");
1567        config.path_override = Some(path.clone());
1568        config.save().unwrap();
1569        let content = std::fs::read_to_string(&path).unwrap();
1570        let _ = std::fs::remove_file(&path);
1571        assert!(content.contains("vault_addr=http://127.0.0.1:8200"));
1572    }
1573
1574    #[test]
1575    fn vault_addr_not_written_when_empty() {
1576        let tmpdir = std::env::temp_dir();
1577        let path = tmpdir.join("purple_test_vault_addr_empty.ini");
1578        let mut config = ProviderConfig::parse("[aws]\ntoken=abc\n");
1579        config.path_override = Some(path.clone());
1580        config.save().unwrap();
1581        let content = std::fs::read_to_string(&path).unwrap();
1582        let _ = std::fs::remove_file(&path);
1583        assert!(!content.contains("vault_addr"));
1584    }
1585}