use std::{
collections::HashMap,
path::Path,
sync::{Arc, Mutex},
};
use anyhow::{Result, anyhow, bail};
use async_trait::async_trait;
use futures::future::try_join_all;
use rustc_hash::{FxHashMap, FxHashSet};
use serde::de::DeserializeOwned;
use std::process::Stdio;
use tokio::process::Command;
use url::Url;
use crate::Hydration;
pub mod compat;
pub mod network;
#[derive(Clone, Default)]
pub struct Warnings(Arc<Mutex<Vec<String>>>);
impl Warnings {
pub fn push(&self, msg: impl Into<String>) {
self.0.lock().unwrap().push(msg.into());
}
pub fn take(&self) -> Vec<String> {
std::mem::take(&mut *self.0.lock().unwrap())
}
}
mod doppler;
mod file;
mod infisical;
mod onepassword;
mod passbolt;
mod raw;
mod sh;
mod vault;
#[async_trait]
pub trait Provider: Sync {
fn add(&mut self, value: String) -> Result<()>;
fn name(&self) -> &'static str;
fn install_url(&self) -> &'static str;
fn has_work(&self) -> bool {
true
}
fn masks_in_output(&self) -> bool {
true
}
async fn resolve(
&self,
cwd: &Path,
extra_env: &HashMap<String, String>,
warnings: &Warnings,
) -> Result<Hydration>;
}
pub struct Providers {
by_scheme: FxHashMap<&'static str, Box<dyn Provider + Send>>,
fallback: Box<dyn Provider + Send>,
}
impl Default for Providers {
fn default() -> Self {
Self::new()
}
}
impl Providers {
pub fn new() -> Self {
let mut by_scheme: FxHashMap<&'static str, Box<dyn Provider + Send>> = FxHashMap::default();
by_scheme.insert("doppler", Box::new(doppler::Doppler::new()));
by_scheme.insert("infisical", Box::new(infisical::Infisical::new()));
by_scheme.insert("op", Box::new(onepassword::OnePassword::new()));
by_scheme.insert("vault", Box::new(vault::Vault::new()));
by_scheme.insert("passbolt", Box::new(passbolt::Passbolt::new()));
by_scheme.insert("file", Box::new(file::File::new()));
by_scheme.insert(
"sh",
Box::new(sh::Shell::new(
"sh",
"sh",
"https://pubs.opengroup.org/onlinepubs/9699919799/utilities/sh.html",
)),
);
by_scheme.insert(
"bash",
Box::new(sh::Shell::new(
"bash",
"bash",
"https://www.gnu.org/software/bash/",
)),
);
by_scheme.insert(
"zsh",
Box::new(sh::Shell::new("zsh", "zsh", "https://www.zsh.org/")),
);
by_scheme.insert(
"fish",
Box::new(sh::Shell::new("fish", "fish", "https://fishshell.com/")),
);
Self {
by_scheme,
fallback: Box::new(raw::Raw::new()),
}
}
pub fn provider(&self, scheme: &str) -> Option<&(dyn Provider + Send)> {
self.by_scheme.get(scheme).map(|p| p.as_ref())
}
pub fn add(&mut self, value: String) -> Result<()> {
let scheme = value.split_once("://").map(|(s, _)| s).unwrap_or("");
match self.by_scheme.get_mut(scheme) {
Some(p) => match p.add(value.clone()) {
Ok(()) => Ok(()),
Err(_) => self.fallback.add(value),
},
None => self.fallback.add(value),
}
}
pub async fn resolve(
&self,
cwd: &Path,
extra_env: &HashMap<String, String>,
warnings: &Warnings,
) -> Result<(Hydration, FxHashSet<String>)> {
let active: Vec<&dyn Provider> = self
.by_scheme
.values()
.map(|p| p.as_ref() as &dyn Provider)
.chain(std::iter::once(self.fallback.as_ref() as &dyn Provider))
.filter(|p| p.has_work())
.collect();
let results = try_join_all(active.iter().map(|p| async move {
let hydration = p.resolve(cwd, extra_env, warnings).await?;
Ok::<_, anyhow::Error>((p.masks_in_output(), hydration))
}))
.await?;
let mut full_hydration = Hydration::default();
let mut maskable_sources = FxHashSet::default();
for (masks, hydration) in results {
if masks {
maskable_sources.extend(hydration.keys().cloned());
}
full_hydration.extend(hydration);
}
Ok((full_hydration, maskable_sources))
}
}
pub fn add_url(urls: &mut FxHashMap<Url, String>, value: String, scheme: &str) -> Result<()> {
match Url::parse(&value) {
Ok(url) if url.scheme() == scheme => {
urls.insert(url, value);
Ok(())
}
_ => bail!("Not a {scheme} scheme"),
}
}
pub async fn run_cli(
cmd: &[&str],
extra_env: &HashMap<String, String>,
name: &str,
install_url: &str,
cwd: Option<&Path>,
) -> Result<std::process::Output> {
let mut c = Command::new(cmd[0]);
c.args(&cmd[1..])
.envs(extra_env.iter())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(dir) = cwd {
c.current_dir(dir);
}
c.output().await.map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => anyhow!(
"{name} CLI not found. Make sure the binary is in your PATH or install it from {install_url}."
),
_ => anyhow!("{name} error: {e}"),
})
}
pub fn deserialize_output<T: DeserializeOwned>(
output: &std::process::Output,
name: &str,
) -> Result<T> {
serde_json::from_slice(&output.stdout).map_err(|err| {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow!("{name} error: {err} (stderr: {stderr})")
})
}
pub fn host_with_port(url: &Url) -> String {
match url.port() {
Some(port) => format!("{}:{}", url.host().expect("Missing host"), port),
None => url.host().expect("Missing host").to_string(),
}
}
#[cfg(test)]
pub fn fake_cli(dir: &tempfile::TempDir, name: &str, script_body: &str) {
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
let path = dir.path().join(name);
let tmp_path = dir.path().join(format!(".{name}.tmp"));
let mut file = std::fs::File::create(&tmp_path).unwrap();
write!(file, "#!/bin/sh\n{script_body}\n").unwrap();
file.sync_all().unwrap();
file.set_permissions(std::fs::Permissions::from_mode(0o755))
.unwrap();
drop(file);
std::fs::rename(tmp_path, path).unwrap();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn has_work_for(scheme: &str, uri: &str) -> bool {
let mut p = Providers::new();
p.add(uri.to_string()).unwrap();
p.by_scheme
.get(scheme)
.map(|prov| prov.has_work())
.unwrap_or(false)
}
fn fallback_has_work(uri: &str) -> bool {
let mut p = Providers::new();
p.add(uri.to_string()).unwrap();
p.fallback.has_work()
}
#[test]
fn test_dispatch_doppler() {
assert!(has_work_for(
"doppler",
"doppler://api.doppler.com/proj/env/KEY"
));
assert!(!fallback_has_work("doppler://api.doppler.com/proj/env/KEY"));
}
#[test]
fn test_dispatch_vault() {
assert!(has_work_for("vault", "vault://localhost/secret/app/pass"));
assert!(!fallback_has_work("vault://localhost/secret/app/pass"));
}
#[test]
fn test_dispatch_op() {
assert!(has_work_for("op", "op://my.1password.com/vault/item/field"));
assert!(!fallback_has_work("op://my.1password.com/vault/item/field"));
}
#[test]
fn test_dispatch_plain_value_to_fallback() {
assert!(fallback_has_work("plainvalue"));
}
#[test]
fn test_dispatch_bang_escaped_to_fallback() {
assert!(fallback_has_work("!escaped"));
}
#[test]
fn test_dispatch_unknown_scheme_to_fallback() {
assert!(fallback_has_work("foo://some/path"));
}
#[test]
fn test_dispatch_file_without_query_falls_back_to_raw() {
assert!(fallback_has_work("file:///path/to/config.json"));
assert!(!has_work_for("file", "file:///path/to/config.json"));
}
#[test]
fn test_dispatch_file_with_query_goes_to_file() {
assert!(has_work_for(
"file",
"file:///path/to/config.json?query=.key"
));
assert!(!fallback_has_work("file:///path/to/config.json?query=.key"));
}
}