Skip to main content

via/
config.rs

1use std::collections::BTreeMap;
2use std::env;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use serde::{Deserialize, Serialize};
7
8use crate::error::ViaError;
9
10#[derive(Debug, Deserialize)]
11pub struct Config {
12    pub version: u32,
13    #[serde(default)]
14    pub providers: BTreeMap<String, ProviderConfig>,
15    #[serde(default)]
16    pub services: BTreeMap<String, ServiceConfig>,
17}
18
19#[derive(Debug, Deserialize)]
20#[serde(tag = "type")]
21pub enum ProviderConfig {
22    #[serde(rename = "1password")]
23    OnePassword {
24        #[serde(default)]
25        account: Option<String>,
26        #[serde(default)]
27        cache: OnePasswordCacheMode,
28        #[serde(default = "default_onepassword_cache_ttl_seconds")]
29        cache_ttl_seconds: u64,
30    },
31}
32
33#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
34#[serde(rename_all = "snake_case")]
35pub enum OnePasswordCacheMode {
36    Off,
37    Daemon,
38}
39
40#[derive(Debug, Deserialize)]
41pub struct ServiceConfig {
42    #[serde(default)]
43    pub description: Option<String>,
44    #[serde(default)]
45    pub hint: Option<String>,
46    pub provider: String,
47    #[serde(default)]
48    pub secrets: BTreeMap<String, String>,
49    #[serde(default)]
50    pub commands: BTreeMap<String, CommandConfig>,
51}
52
53#[derive(Debug, Deserialize)]
54#[serde(tag = "mode")]
55pub enum CommandConfig {
56    #[serde(rename = "rest")]
57    Rest(RestCommandConfig),
58    #[serde(rename = "delegated")]
59    Delegated(DelegatedCommandConfig),
60}
61
62#[derive(Debug, Clone, Copy, Serialize)]
63#[serde(rename_all = "lowercase")]
64pub enum CapabilityMode {
65    Rest,
66    Delegated,
67}
68
69#[derive(Debug, Deserialize)]
70pub struct RestCommandConfig {
71    #[serde(default)]
72    pub description: Option<String>,
73    pub base_url: String,
74    #[serde(default = "default_method")]
75    pub method_default: String,
76    #[serde(default)]
77    pub auth: Option<AuthConfig>,
78    #[serde(default)]
79    pub headers: BTreeMap<String, String>,
80}
81
82#[derive(Debug, Deserialize)]
83#[serde(tag = "type")]
84pub enum AuthConfig {
85    #[serde(rename = "bearer")]
86    Bearer { secret: String },
87    #[serde(rename = "headers")]
88    Headers {
89        #[serde(default)]
90        headers: BTreeMap<String, SecretHeaderConfig>,
91    },
92    #[serde(rename = "github_app")]
93    GitHubApp {
94        #[serde(default)]
95        secret: Option<String>,
96        #[serde(default)]
97        credential: Option<String>,
98        #[serde(default)]
99        private_key: Option<String>,
100    },
101    #[serde(rename = "oauth")]
102    OAuth { credential: String },
103}
104
105#[derive(Debug, Deserialize)]
106pub struct SecretHeaderConfig {
107    pub secret: String,
108    #[serde(default)]
109    pub prefix: String,
110    #[serde(default)]
111    pub suffix: String,
112}
113
114#[derive(Debug, Deserialize)]
115pub struct DelegatedCommandConfig {
116    #[serde(default)]
117    pub description: Option<String>,
118    pub program: String,
119    #[serde(default)]
120    pub args_prefix: Vec<String>,
121    #[serde(default)]
122    pub inject: InjectConfig,
123    #[serde(default)]
124    pub check: Vec<String>,
125}
126
127#[derive(Debug, Default, Deserialize)]
128pub struct InjectConfig {
129    #[serde(default)]
130    pub env: BTreeMap<String, SecretBinding>,
131}
132
133#[derive(Debug, Deserialize)]
134pub struct SecretBinding {
135    pub secret: String,
136}
137
138impl Config {
139    pub fn load(path: Option<&Path>) -> Result<Self, ViaError> {
140        let path = resolve_path(path)?;
141
142        let raw = fs::read_to_string(&path).map_err(|source| ViaError::ReadConfig {
143            path: path.clone(),
144            source,
145        })?;
146        Self::from_toml_str(&raw)
147    }
148
149    pub(crate) fn from_toml_str(raw: &str) -> Result<Self, ViaError> {
150        let config: Self = toml::from_str(raw)?;
151        config.validate()?;
152        Ok(config)
153    }
154
155    fn validate(&self) -> Result<(), ViaError> {
156        if self.version != 1 {
157            return Err(ViaError::InvalidConfig(format!(
158                "unsupported config version {}; expected 1",
159                self.version
160            )));
161        }
162
163        for (service_name, service) in &self.services {
164            if !self.providers.contains_key(&service.provider) {
165                return Err(ViaError::InvalidConfig(format!(
166                    "service `{service_name}` references unknown provider `{}`",
167                    service.provider
168                )));
169            }
170
171            for (secret_name, reference) in &service.secrets {
172                if !reference.starts_with("op://") {
173                    return Err(ViaError::InvalidConfig(format!(
174                        "secret `{service_name}.{secret_name}` must be an op:// reference"
175                    )));
176                }
177            }
178
179            for (command_name, command) in &service.commands {
180                command.validate(service_name, command_name, service)?;
181            }
182        }
183
184        Ok(())
185    }
186}
187
188pub fn resolve_path(path: Option<&Path>) -> Result<PathBuf, ViaError> {
189    match path {
190        Some(path) => Ok(path.to_path_buf()),
191        None => default_config_path(),
192    }
193}
194
195impl CommandConfig {
196    pub fn description(&self) -> Option<&String> {
197        match self {
198            CommandConfig::Rest(config) => config.description.as_ref(),
199            CommandConfig::Delegated(config) => config.description.as_ref(),
200        }
201    }
202
203    pub fn mode(&self) -> CapabilityMode {
204        match self {
205            CommandConfig::Rest(_) => CapabilityMode::Rest,
206            CommandConfig::Delegated(_) => CapabilityMode::Delegated,
207        }
208    }
209
210    fn validate(
211        &self,
212        service_name: &str,
213        command_name: &str,
214        service: &ServiceConfig,
215    ) -> Result<(), ViaError> {
216        match self {
217            CommandConfig::Rest(rest) => {
218                if rest.base_url.trim().is_empty() {
219                    return Err(ViaError::InvalidConfig(format!(
220                        "command `{service_name}.{command_name}` must set rest base_url"
221                    )));
222                }
223
224                if let Some(auth) = &rest.auth {
225                    match auth {
226                        AuthConfig::Bearer { secret } => {
227                            validate_secret_name(service_name, command_name, service, secret)?;
228                        }
229                        AuthConfig::Headers { headers } => {
230                            if headers.is_empty() {
231                                return Err(ViaError::InvalidConfig(format!(
232                                    "command `{service_name}.{command_name}` headers auth must configure at least one header"
233                                )));
234                            }
235                            for secret_header in headers.values() {
236                                validate_secret_name(
237                                    service_name,
238                                    command_name,
239                                    service,
240                                    &secret_header.secret,
241                                )?;
242                            }
243                        }
244                        AuthConfig::GitHubApp {
245                            secret,
246                            credential,
247                            private_key,
248                        } => validate_github_app_auth(
249                            service_name,
250                            command_name,
251                            service,
252                            secret.as_deref(),
253                            credential.as_deref(),
254                            private_key.as_deref(),
255                        )?,
256                        AuthConfig::OAuth { credential } => {
257                            validate_secret_name(service_name, command_name, service, credential)?;
258                        }
259                    }
260                }
261            }
262            CommandConfig::Delegated(delegated) => {
263                if delegated.program.trim().is_empty() {
264                    return Err(ViaError::InvalidConfig(format!(
265                        "command `{service_name}.{command_name}` must set delegated program"
266                    )));
267                }
268
269                for binding in delegated.inject.env.values() {
270                    validate_secret_name(service_name, command_name, service, &binding.secret)?;
271                }
272            }
273        }
274
275        Ok(())
276    }
277}
278
279fn validate_secret_name(
280    service_name: &str,
281    command_name: &str,
282    service: &ServiceConfig,
283    secret: &str,
284) -> Result<(), ViaError> {
285    if service.secrets.contains_key(secret) {
286        return Ok(());
287    }
288
289    Err(ViaError::InvalidConfig(format!(
290        "command `{service_name}.{command_name}` references unknown secret `{secret}`"
291    )))
292}
293
294fn validate_github_app_auth(
295    service_name: &str,
296    command_name: &str,
297    service: &ServiceConfig,
298    secret: Option<&str>,
299    credential: Option<&str>,
300    private_key: Option<&str>,
301) -> Result<(), ViaError> {
302    match (secret, credential, private_key) {
303        (Some(secret), None, None) => {
304            validate_secret_name(service_name, command_name, service, secret)
305        }
306        (None, Some(credential), Some(private_key)) => {
307            validate_secret_name(service_name, command_name, service, credential)?;
308            validate_secret_name(service_name, command_name, service, private_key)
309        }
310        _ => Err(ViaError::InvalidConfig(format!(
311            "command `{service_name}.{command_name}` github_app auth must set either `secret` or both `credential` and `private_key`"
312        ))),
313    }
314}
315
316fn default_method() -> String {
317    "GET".to_owned()
318}
319
320impl Default for OnePasswordCacheMode {
321    fn default() -> Self {
322        if cfg!(unix) {
323            Self::Daemon
324        } else {
325            Self::Off
326        }
327    }
328}
329
330fn default_onepassword_cache_ttl_seconds() -> u64 {
331    300
332}
333
334fn default_config_path() -> Result<PathBuf, ViaError> {
335    if let Ok(path) = env::var("VIA_CONFIG") {
336        return Ok(PathBuf::from(path));
337    }
338
339    let local = PathBuf::from("via.toml");
340    if local.exists() {
341        return Ok(local);
342    }
343
344    let home = env::var_os("HOME")
345        .map(PathBuf::from)
346        .ok_or_else(|| ViaError::ConfigNotFound("HOME is not set".to_owned()))?;
347    Ok(home.join(".config").join("via").join("config.toml"))
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353
354    const VALID: &str = r#"
355version = 1
356
357[providers.onepassword]
358type = "1password"
359
360[services.github]
361description = "GitHub access"
362hint = "via github api /user"
363provider = "onepassword"
364
365[services.github.secrets]
366token = "op://Private/GitHub/token"
367
368[services.github.commands.api]
369description = "REST access"
370mode = "rest"
371base_url = "https://api.github.com"
372
373[services.github.commands.api.auth]
374type = "bearer"
375secret = "token"
376
377[services.github.commands.gh]
378description = "GitHub CLI access"
379mode = "delegated"
380program = "gh"
381check = ["--version"]
382
383[services.github.commands.gh.inject.env.GH_TOKEN]
384secret = "token"
385"#;
386
387    #[test]
388    fn parses_valid_config() {
389        let config = Config::from_toml_str(VALID).unwrap();
390
391        assert_eq!(config.version, 1);
392        assert_eq!(
393            config.services["github"].hint.as_deref(),
394            Some("via github api /user")
395        );
396        assert!(config.services["github"].commands.contains_key("api"));
397        assert!(config.services["github"].commands.contains_key("gh"));
398    }
399
400    #[test]
401    fn rejects_unknown_provider() {
402        let raw = VALID.replace("provider = \"onepassword\"", "provider = \"missing\"");
403
404        assert!(matches!(
405            Config::from_toml_str(&raw),
406            Err(ViaError::InvalidConfig(message)) if message.contains("unknown provider")
407        ));
408    }
409
410    #[test]
411    fn rejects_plaintext_secret_values() {
412        let raw = VALID.replace("op://Private/GitHub/token", "ghp_plaintext");
413
414        assert!(matches!(
415            Config::from_toml_str(&raw),
416            Err(ViaError::InvalidConfig(message)) if message.contains("must be an op:// reference")
417        ));
418    }
419
420    #[test]
421    fn rejects_unknown_rest_secret() {
422        let raw = VALID.replace("secret = \"token\"", "secret = \"missing\"");
423
424        assert!(matches!(
425            Config::from_toml_str(&raw),
426            Err(ViaError::InvalidConfig(message)) if message.contains("unknown secret")
427        ));
428    }
429
430    #[test]
431    fn accepts_github_app_rest_auth() {
432        let raw = VALID.replace(
433            r#"[services.github.commands.api.auth]
434type = "bearer"
435secret = "token""#,
436            r#"[services.github.commands.api.auth]
437type = "github_app"
438credential = "token"
439private_key = "token""#,
440        );
441
442        assert!(Config::from_toml_str(&raw).is_ok());
443    }
444
445    #[test]
446    fn accepts_oauth_rest_auth() {
447        let raw = VALID.replace(
448            r#"[services.github.commands.api.auth]
449type = "bearer"
450secret = "token""#,
451            r#"[services.github.commands.api.auth]
452type = "oauth"
453credential = "token""#,
454        );
455
456        assert!(Config::from_toml_str(&raw).is_ok());
457    }
458
459    #[test]
460    fn accepts_onepassword_daemon_cache() {
461        let raw = VALID.replace(
462            r#"[providers.onepassword]
463type = "1password""#,
464            r#"[providers.onepassword]
465type = "1password"
466cache = "daemon"
467cache_ttl_seconds = 600"#,
468        );
469        let config = Config::from_toml_str(&raw).unwrap();
470
471        match &config.providers["onepassword"] {
472            ProviderConfig::OnePassword {
473                cache,
474                cache_ttl_seconds,
475                ..
476            } => {
477                assert_eq!(*cache, OnePasswordCacheMode::Daemon);
478                assert_eq!(*cache_ttl_seconds, 600);
479            }
480        }
481    }
482
483    #[test]
484    fn defaults_onepassword_cache_for_platform() {
485        let config = Config::from_toml_str(VALID).unwrap();
486
487        match &config.providers["onepassword"] {
488            ProviderConfig::OnePassword {
489                cache,
490                cache_ttl_seconds,
491                ..
492            } => {
493                #[cfg(unix)]
494                assert_eq!(*cache, OnePasswordCacheMode::Daemon);
495                #[cfg(not(unix))]
496                assert_eq!(*cache, OnePasswordCacheMode::Off);
497                assert_eq!(*cache_ttl_seconds, 300);
498            }
499        }
500    }
501
502    #[test]
503    fn accepts_secret_header_rest_auth() {
504        let raw = VALID.replace(
505            r#"[services.github.commands.api.auth]
506type = "bearer"
507secret = "token""#,
508            r#"[services.github.commands.api.auth]
509type = "headers"
510
511[services.github.commands.api.auth.headers.Authorization]
512secret = "token"
513prefix = "Token "
514
515[services.github.commands.api.auth.headers.X-Api-Key]
516secret = "token""#,
517        );
518
519        assert!(Config::from_toml_str(&raw).is_ok());
520    }
521
522    #[test]
523    fn rejects_empty_secret_header_rest_auth() {
524        let raw = VALID.replace(
525            r#"[services.github.commands.api.auth]
526type = "bearer"
527secret = "token""#,
528            r#"[services.github.commands.api.auth]
529type = "headers""#,
530        );
531
532        assert!(matches!(
533            Config::from_toml_str(&raw),
534            Err(ViaError::InvalidConfig(message)) if message.contains("at least one header")
535        ));
536    }
537
538    #[test]
539    fn rejects_unsupported_version() {
540        let raw = VALID.replace("version = 1", "version = 2");
541
542        assert!(matches!(
543            Config::from_toml_str(&raw),
544            Err(ViaError::InvalidConfig(message)) if message.contains("unsupported config version")
545        ));
546    }
547
548    #[test]
549    fn rejects_empty_rest_base_url() {
550        let raw = VALID.replace("base_url = \"https://api.github.com\"", "base_url = \"\"");
551
552        assert!(matches!(
553            Config::from_toml_str(&raw),
554            Err(ViaError::InvalidConfig(message)) if message.contains("base_url")
555        ));
556    }
557
558    #[test]
559    fn rejects_empty_delegated_program() {
560        let raw = VALID.replace("program = \"gh\"", "program = \"\"");
561
562        assert!(matches!(
563            Config::from_toml_str(&raw),
564            Err(ViaError::InvalidConfig(message)) if message.contains("delegated program")
565        ));
566    }
567}