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}