lade_sdk/providers/
mod.rs1use 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 fn name(&self) -> &'static str;
46
47 fn install_url(&self) -> &'static str;
49
50 fn has_work(&self) -> bool {
51 true
52 }
53
54 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 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 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}