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}