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