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