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