Skip to main content

lade_sdk/providers/
mod.rs

1use std::{
2    collections::HashMap,
3    path::Path,
4    sync::{Arc, Mutex},
5};
6
7use anyhow::{Result, anyhow, bail};
8use async_trait::async_trait;
9use futures::future::try_join_all;
10use rustc_hash::{FxHashMap, FxHashSet};
11use serde::de::DeserializeOwned;
12use std::process::Stdio;
13use tokio::process::Command;
14use url::Url;
15
16use crate::Hydration;
17
18pub mod compat;
19
20#[derive(Clone, Default)]
21pub struct Warnings(Arc<Mutex<Vec<String>>>);
22
23impl Warnings {
24    pub fn push(&self, msg: impl Into<String>) {
25        self.0.lock().unwrap().push(msg.into());
26    }
27
28    pub fn take(&self) -> Vec<String> {
29        std::mem::take(&mut *self.0.lock().unwrap())
30    }
31}
32mod doppler;
33mod file;
34mod infisical;
35mod onepassword;
36mod passbolt;
37mod raw;
38mod vault;
39
40#[async_trait]
41pub trait Provider: Sync {
42    fn add(&mut self, value: String) -> Result<()>;
43
44    /// Human-readable provider name, used in user-facing messages.
45    fn name(&self) -> &'static str;
46
47    /// Where to install/find the backing tool, used in user-facing messages.
48    fn install_url(&self) -> &'static str;
49
50    fn has_work(&self) -> bool {
51        true
52    }
53
54    /// Whether resolved values from this provider should be masked in subprocess output.
55    fn masks_in_output(&self) -> bool {
56        true
57    }
58
59    async fn resolve(
60        &self,
61        cwd: &Path,
62        extra_env: &HashMap<String, String>,
63        warnings: &Warnings,
64    ) -> Result<Hydration>;
65}
66
67pub struct Providers {
68    by_scheme: FxHashMap<&'static str, Box<dyn Provider + Send>>,
69    fallback: Box<dyn Provider + Send>,
70}
71
72impl Default for Providers {
73    fn default() -> Self {
74        Self::new()
75    }
76}
77
78impl Providers {
79    pub fn new() -> Self {
80        let mut by_scheme: FxHashMap<&'static str, Box<dyn Provider + Send>> = FxHashMap::default();
81        by_scheme.insert("doppler", Box::new(doppler::Doppler::new()));
82        by_scheme.insert("infisical", Box::new(infisical::Infisical::new()));
83        by_scheme.insert("op", Box::new(onepassword::OnePassword::new()));
84        by_scheme.insert("vault", Box::new(vault::Vault::new()));
85        by_scheme.insert("passbolt", Box::new(passbolt::Passbolt::new()));
86        by_scheme.insert("file", Box::new(file::File::new()));
87        Self {
88            by_scheme,
89            fallback: Box::new(raw::Raw::new()),
90        }
91    }
92
93    pub fn provider(&self, scheme: &str) -> Option<&(dyn Provider + Send)> {
94        self.by_scheme.get(scheme).map(|p| p.as_ref())
95    }
96
97    pub fn add(&mut self, value: String) -> Result<()> {
98        let scheme = value.split_once("://").map(|(s, _)| s).unwrap_or("");
99        match self.by_scheme.get_mut(scheme) {
100            Some(p) => match p.add(value.clone()) {
101                Ok(()) => Ok(()),
102                // Preserve today's behaviour: a scheme-specific provider that rejects the value
103                // (e.g. file:// without ?query=) falls back to Raw rather than returning an error.
104                Err(_) => self.fallback.add(value),
105            },
106            None => self.fallback.add(value),
107        }
108    }
109
110    pub async fn resolve(
111        &self,
112        cwd: &Path,
113        extra_env: &HashMap<String, String>,
114        warnings: &Warnings,
115    ) -> Result<(Hydration, FxHashSet<String>)> {
116        let active: Vec<&dyn Provider> = self
117            .by_scheme
118            .values()
119            .map(|p| p.as_ref() as &dyn Provider)
120            .chain(std::iter::once(self.fallback.as_ref() as &dyn Provider))
121            .filter(|p| p.has_work())
122            .collect();
123
124        let results = try_join_all(active.iter().map(|p| async move {
125            let hydration = p.resolve(cwd, extra_env, warnings).await?;
126            Ok::<_, anyhow::Error>((p.masks_in_output(), hydration))
127        }))
128        .await?;
129
130        let mut full_hydration = Hydration::default();
131        let mut maskable_sources = FxHashSet::default();
132
133        for (masks, hydration) in results {
134            if masks {
135                maskable_sources.extend(hydration.keys().cloned());
136            }
137            full_hydration.extend(hydration);
138        }
139
140        Ok((full_hydration, maskable_sources))
141    }
142}
143
144pub fn add_url(urls: &mut FxHashMap<Url, String>, value: String, scheme: &str) -> Result<()> {
145    match Url::parse(&value) {
146        Ok(url) if url.scheme() == scheme => {
147            urls.insert(url, value);
148            Ok(())
149        }
150        _ => bail!("Not a {scheme} scheme"),
151    }
152}
153
154pub async fn run_cli(
155    cmd: &[&str],
156    extra_env: &HashMap<String, String>,
157    name: &str,
158    install_url: &str,
159    cwd: Option<&Path>,
160) -> Result<std::process::Output> {
161    let mut c = Command::new(cmd[0]);
162    c.args(&cmd[1..])
163        .envs(extra_env.iter())
164        .stdout(Stdio::piped())
165        .stderr(Stdio::piped());
166    if let Some(dir) = cwd {
167        c.current_dir(dir);
168    }
169    c.output().await.map_err(|e| match e.kind() {
170        std::io::ErrorKind::NotFound => anyhow!(
171            "{name} CLI not found. Make sure the binary is in your PATH or install it from {install_url}."
172        ),
173        _ => anyhow!("{name} error: {e}"),
174    })
175}
176
177pub fn deserialize_output<T: DeserializeOwned>(
178    output: &std::process::Output,
179    name: &str,
180) -> Result<T> {
181    serde_json::from_slice(&output.stdout).map_err(|err| {
182        let stderr = String::from_utf8_lossy(&output.stderr);
183        anyhow!("{name} error: {err} (stderr: {stderr})")
184    })
185}
186
187pub fn host_with_port(url: &Url) -> String {
188    match url.port() {
189        Some(port) => format!("{}:{}", url.host().expect("Missing host"), port),
190        None => url.host().expect("Missing host").to_string(),
191    }
192}
193
194#[cfg(test)]
195pub fn fake_cli(dir: &tempfile::TempDir, name: &str, script_body: &str) {
196    #[cfg(unix)]
197    {
198        use std::os::unix::fs::PermissionsExt;
199        let path = dir.path().join(name);
200        std::fs::write(&path, format!("#!/bin/sh\n{script_body}\n")).unwrap();
201        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    fn has_work_for(scheme: &str, uri: &str) -> bool {
210        let mut p = Providers::new();
211        p.add(uri.to_string()).unwrap();
212        p.by_scheme
213            .get(scheme)
214            .map(|prov| prov.has_work())
215            .unwrap_or(false)
216    }
217
218    fn fallback_has_work(uri: &str) -> bool {
219        let mut p = Providers::new();
220        p.add(uri.to_string()).unwrap();
221        p.fallback.has_work()
222    }
223
224    #[test]
225    fn test_dispatch_doppler() {
226        assert!(has_work_for(
227            "doppler",
228            "doppler://api.doppler.com/proj/env/KEY"
229        ));
230        assert!(!fallback_has_work("doppler://api.doppler.com/proj/env/KEY"));
231    }
232
233    #[test]
234    fn test_dispatch_vault() {
235        assert!(has_work_for("vault", "vault://localhost/secret/app/pass"));
236        assert!(!fallback_has_work("vault://localhost/secret/app/pass"));
237    }
238
239    #[test]
240    fn test_dispatch_op() {
241        assert!(has_work_for("op", "op://my.1password.com/vault/item/field"));
242        assert!(!fallback_has_work("op://my.1password.com/vault/item/field"));
243    }
244
245    #[test]
246    fn test_dispatch_plain_value_to_fallback() {
247        assert!(fallback_has_work("plainvalue"));
248    }
249
250    #[test]
251    fn test_dispatch_bang_escaped_to_fallback() {
252        assert!(fallback_has_work("!escaped"));
253    }
254
255    #[test]
256    fn test_dispatch_unknown_scheme_to_fallback() {
257        assert!(fallback_has_work("foo://some/path"));
258    }
259
260    #[test]
261    fn test_dispatch_file_without_query_falls_back_to_raw() {
262        // file:// without ?query= is rejected by File::add and must land on Raw.
263        assert!(fallback_has_work("file:///path/to/config.json"));
264        assert!(!has_work_for("file", "file:///path/to/config.json"));
265    }
266
267    #[test]
268    fn test_dispatch_file_with_query_goes_to_file() {
269        assert!(has_work_for(
270            "file",
271            "file:///path/to/config.json?query=.key"
272        ));
273        assert!(!fallback_has_work("file:///path/to/config.json?query=.key"));
274    }
275}