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 fn name(&self) -> &'static str;
48
49 fn install_url(&self) -> &'static str;
51
52 fn has_work(&self) -> bool {
53 true
54 }
55
56 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 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 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 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}