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