use std::{collections::HashMap, path::Path, sync::Arc};
use anyhow::{Result, bail};
use async_trait::async_trait;
use futures::future::try_join_all;
use itertools::Itertools;
use log::debug;
use rustc_hash::FxHashMap;
use std::process::Stdio;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use url::Url;
use crate::Hydration;
use super::{Provider, add_url};
static SEP: &str = "'Km5Ge8AbNc+QSBauOIN0jg'";
#[derive(Default)]
pub struct OnePassword {
urls: FxHashMap<Url, String>,
}
impl OnePassword {
pub fn new() -> Self {
Default::default()
}
}
#[async_trait]
impl Provider for OnePassword {
fn add(&mut self, value: String) -> Result<()> {
match Url::parse(&value) {
Ok(url) if url.scheme() == "op" => {
if url.path().contains('+') {
bail!(
"1Password secret references cannot contain '+' in any path segment. \
Use the item or field UUID instead (found with: op item get 'NAME' --format json)."
);
}
add_url(&mut self.urls, value, "op")
}
_ => bail!("Not an op scheme"),
}
}
fn has_work(&self) -> bool {
!self.urls.is_empty()
}
async fn resolve(&self, _: &Path, extra_env: &HashMap<String, String>) -> Result<Hydration> {
let extra_env = Arc::new(extra_env.clone());
let fetches = self
.urls
.iter()
.into_group_map_by(|(url, _)| url.host().expect("Missing host"))
.into_iter()
.map(|(host, group)| {
let vars = group
.into_iter()
.enumerate()
.map(|(idx, (_, value))| (idx.to_string(), value.clone()))
.collect::<HashMap<_, _>>();
let host = host.clone();
let extra_env = Arc::clone(&extra_env);
async move {
if vars.is_empty() {
return Ok(Hydration::default());
}
let input = &vars.values()
.map(|v| {
let s = v.replace(&format!("{host}/"), "");
urlencoding::decode(&s).expect("invalid percent-encoding in op:// URL").into_owned()
})
.join(SEP);
let cmd = &["op", "inject", "--account", &host.to_string()];
debug!("Lade run: {}", cmd.join(" "));
let mut process = Command::new(cmd[0])
.args(&cmd[1..])
.envs(extra_env.iter())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.stdin(Stdio::piped())
.spawn()?;
debug!("stdin: {:?}", input);
let mut stdin = process.stdin.take().expect("Failed to open stdin");
if let Err(e) = stdin.write_all(input.as_bytes()).await
&& e.kind() != std::io::ErrorKind::BrokenPipe
{
bail!("1Password error: {e}");
}
drop(stdin);
let child = match process.wait_with_output().await {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
bail!("1Password CLI not found. Make sure the binary is in your PATH or install it from https://1password.com/downloads/command-line/.")
},
Err(e) => bail!("1Password error: {e}"),
Ok(child) => child,
};
let output = String::from_utf8_lossy(&child.stdout).trim().replace('\n', "\\n");
let errors = String::from_utf8_lossy(&child.stderr);
if errors.contains("[ERROR]") {
bail!("1Password error: {errors}")
}
debug!("stdout: {:?}", output);
debug!("stderr: {:?}", errors);
let loaded = output.split(SEP).collect::<Vec<_>>();
if loaded.len() != vars.len() {
bail!("1Password error: {errors}")
}
let hydration = vars
.iter().zip_eq(loaded)
.map(|((_, key), value)| (key.clone(), value.to_string()))
.collect::<Hydration>();
debug!("hydration: {:?}", hydration);
Ok(hydration)
}
})
.collect::<Vec<_>>();
Ok(try_join_all(fetches)
.await?
.into_iter()
.flatten()
.collect::<Hydration>())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::providers::fake_cli;
use std::collections::HashMap;
use std::path::Path;
use tempfile::tempdir;
#[test]
fn test_add_valid_op_scheme() {
let mut p = OnePassword::new();
assert!(
p.add("op://my.1password.com/vault_uuid/item_uuid/password".to_string())
.is_ok()
);
}
#[test]
fn test_add_rejects_wrong_scheme() {
let mut p = OnePassword::new();
assert!(p.add("vault://host/mount/key/field".to_string()).is_err());
}
#[tokio::test]
#[cfg(unix)]
async fn test_resolve_fake_cli_single_secret() {
let fake_bin = tempdir().unwrap();
fake_cli(&fake_bin, "op", "cat > /dev/null\nprintf 'op_secret_value'");
let mut p = OnePassword::new();
p.add("op://my.1password.com/vault_uuid/item_uuid/password".to_string())
.unwrap();
let extra = HashMap::from([(
"PATH".to_string(),
fake_bin.path().to_string_lossy().into_owned(),
)]);
let result = p.resolve(Path::new("."), &extra).await.unwrap();
assert_eq!(
result
.get("op://my.1password.com/vault_uuid/item_uuid/password")
.unwrap(),
"op_secret_value"
);
}
#[tokio::test]
#[cfg(unix)]
async fn test_resolve_error_in_stderr() {
let fake_bin = tempdir().unwrap();
fake_cli(&fake_bin, "op", "echo '[ERROR] authentication failed' >&2");
let mut p = OnePassword::new();
p.add("op://my.1password.com/vault_uuid/item_uuid/password".to_string())
.unwrap();
let extra = HashMap::from([(
"PATH".to_string(),
fake_bin.path().to_string_lossy().into_owned(),
)]);
let result = p.resolve(Path::new("."), &extra).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("1Password error"));
}
#[tokio::test]
#[cfg(unix)]
async fn test_resolve_cli_not_found() {
let empty_bin = tempdir().unwrap();
let mut p = OnePassword::new();
p.add("op://my.1password.com/vault_uuid/item_uuid/password".to_string())
.unwrap();
let extra = HashMap::from([(
"PATH".to_string(),
empty_bin.path().to_string_lossy().into_owned(),
)]);
let result = p.resolve(Path::new("."), &extra).await;
assert!(result.is_err());
}
#[test]
fn test_add_rejects_plus_in_field_name() {
let mut p = OnePassword::new();
let result = p.add("op://my.1password.com/vault_uuid/item_uuid/field+name".to_string());
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("UUID"));
}
#[test]
fn test_add_rejects_plus_in_item_name() {
let mut p = OnePassword::new();
let result = p.add("op://my.1password.com/vault_uuid/item+name/field".to_string());
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("UUID"));
}
#[test]
fn test_add_accepts_uuid_path_with_section() {
let mut p = OnePassword::new();
assert!(
p.add(
"op://my.1password.com/vault_uuid/item_uuid/Section_abc123/field_uuid".to_string()
)
.is_ok()
);
}
#[tokio::test]
#[cfg(unix)]
async fn test_resolve_section_and_field_uuid() {
let fake_bin = tempdir().unwrap();
fake_cli(&fake_bin, "op", "cat > /dev/null\nprintf 'secret_value'");
let mut p = OnePassword::new();
p.add(
"op://my.1password.com/vault_uuid/item_uuid/Section_abc123def/field_uuid456"
.to_string(),
)
.unwrap();
let extra = HashMap::from([(
"PATH".to_string(),
fake_bin.path().to_string_lossy().into_owned(),
)]);
let result = p.resolve(Path::new("."), &extra).await.unwrap();
assert_eq!(
result
.get("op://my.1password.com/vault_uuid/item_uuid/Section_abc123def/field_uuid456")
.unwrap(),
"secret_value"
);
}
}