blob: 321a99c5168cd24100d855da21d674b94b948e4e [file] [log] [blame]
//! Cargo registry 1password credential process.
use cargo_credential::{
Action, CacheControl, Credential, CredentialResponse, Error, RegistryInfo, Secret,
};
use serde::Deserialize;
use std::io::Read;
use std::process::{Command, Stdio};
const CARGO_TAG: &str = "cargo-registry";
/// Implementation of 1password keychain access for Cargo registries.
struct OnePasswordKeychain {
account: Option<String>,
vault: Option<String>,
}
/// 1password Login item type, used for the JSON output of `op item get`.
#[derive(Deserialize)]
struct Login {
fields: Vec<Field>,
}
#[derive(Deserialize)]
struct Field {
id: String,
value: Option<String>,
}
/// 1password item from `op items list`.
#[derive(Deserialize)]
struct ListItem {
id: String,
urls: Vec<Url>,
}
#[derive(Deserialize)]
struct Url {
href: String,
}
impl OnePasswordKeychain {
fn new(args: &[&str]) -> Result<OnePasswordKeychain, Error> {
let mut args = args.iter();
let mut action = false;
let mut account = None;
let mut vault = None;
while let Some(arg) = args.next() {
match *arg {
"--account" => {
account = Some(args.next().ok_or("--account needs an arg")?);
}
"--vault" => {
vault = Some(args.next().ok_or("--vault needs an arg")?);
}
s if s.starts_with('-') => {
return Err(format!("unknown option {}", s).into());
}
_ => {
if action {
return Err("too many arguments".into());
} else {
action = true;
}
}
}
}
Ok(OnePasswordKeychain {
account: account.map(|s| s.to_string()),
vault: vault.map(|s| s.to_string()),
})
}
fn signin(&self) -> Result<Option<String>, Error> {
// If there are any session env vars, we'll assume that this is the
// correct account, and that the user knows what they are doing.
if std::env::vars().any(|(name, _)| name.starts_with("OP_SESSION_")) {
return Ok(None);
}
let mut cmd = Command::new("op");
cmd.args(["signin", "--raw"]);
if let Some(account) = &self.account {
cmd.arg("--account");
cmd.arg(account);
}
cmd.stdout(Stdio::piped());
let mut child = cmd
.spawn()
.map_err(|e| format!("failed to spawn `op`: {}", e))?;
let mut buffer = String::new();
child
.stdout
.as_mut()
.unwrap()
.read_to_string(&mut buffer)
.map_err(|e| format!("failed to get session from `op`: {}", e))?;
if let Some(end) = buffer.find('\n') {
buffer.truncate(end);
}
let status = child
.wait()
.map_err(|e| format!("failed to wait for `op`: {}", e))?;
if !status.success() {
return Err(format!("failed to run `op signin`: {}", status).into());
}
if buffer.is_empty() {
// When using CLI integration, `op signin` returns no output,
// so there is no need to set the session.
return Ok(None);
}
Ok(Some(buffer))
}
fn make_cmd(&self, session: &Option<String>, args: &[&str]) -> Command {
let mut cmd = Command::new("op");
cmd.args(args);
if let Some(account) = &self.account {
cmd.arg("--account");
cmd.arg(account);
}
if let Some(vault) = &self.vault {
cmd.arg("--vault");
cmd.arg(vault);
}
if let Some(session) = session {
cmd.arg("--session");
cmd.arg(session);
}
cmd
}
fn run_cmd(&self, mut cmd: Command) -> Result<String, Error> {
cmd.stdout(Stdio::piped());
let mut child = cmd
.spawn()
.map_err(|e| format!("failed to spawn `op`: {}", e))?;
let mut buffer = String::new();
child
.stdout
.as_mut()
.unwrap()
.read_to_string(&mut buffer)
.map_err(|e| format!("failed to read `op` output: {}", e))?;
let status = child
.wait()
.map_err(|e| format!("failed to wait for `op`: {}", e))?;
if !status.success() {
return Err(format!("`op` command exit error: {}", status).into());
}
Ok(buffer)
}
fn search(&self, session: &Option<String>, index_url: &str) -> Result<Option<String>, Error> {
let cmd = self.make_cmd(
session,
&[
"items",
"list",
"--categories",
"Login",
"--tags",
CARGO_TAG,
"--format",
"json",
],
);
let buffer = self.run_cmd(cmd)?;
let items: Vec<ListItem> = serde_json::from_str(&buffer)
.map_err(|e| format!("failed to deserialize JSON from 1password list: {}", e))?;
let mut matches = items
.into_iter()
.filter(|item| item.urls.iter().any(|url| url.href == index_url));
match matches.next() {
Some(login) => {
// Should this maybe just sort on `updatedAt` and return the newest one?
if matches.next().is_some() {
return Err(format!(
"too many 1password logins match registry `{}`, \
consider deleting the excess entries",
index_url
)
.into());
}
Ok(Some(login.id))
}
None => Ok(None),
}
}
fn modify(
&self,
session: &Option<String>,
id: &str,
token: Secret<&str>,
_name: Option<&str>,
) -> Result<(), Error> {
let cmd = self.make_cmd(
session,
&["item", "edit", id, &format!("password={}", token.expose())],
);
self.run_cmd(cmd)?;
Ok(())
}
fn create(
&self,
session: &Option<String>,
index_url: &str,
token: Secret<&str>,
name: Option<&str>,
) -> Result<(), Error> {
let title = match name {
Some(name) => format!("Cargo registry token for {}", name),
None => "Cargo registry token".to_string(),
};
let cmd = self.make_cmd(
session,
&[
"item",
"create",
"--category",
"Login",
&format!("password={}", token.expose()),
&format!("url={}", index_url),
"--title",
&title,
"--tags",
CARGO_TAG,
],
);
self.run_cmd(cmd)?;
Ok(())
}
fn get_token(&self, session: &Option<String>, id: &str) -> Result<Secret<String>, Error> {
let cmd = self.make_cmd(session, &["item", "get", "--format=json", id]);
let buffer = self.run_cmd(cmd)?;
let item: Login = serde_json::from_str(&buffer)
.map_err(|e| format!("failed to deserialize JSON from 1password get: {}", e))?;
let password = item.fields.into_iter().find(|item| item.id == "password");
match password {
Some(password) => password
.value
.map(Secret::from)
.ok_or("missing password value for entry".into()),
None => Err("could not find password field".into()),
}
}
fn delete(&self, session: &Option<String>, id: &str) -> Result<(), Error> {
let cmd = self.make_cmd(session, &["item", "delete", id]);
self.run_cmd(cmd)?;
Ok(())
}
}
pub struct OnePasswordCredential {}
impl Credential for OnePasswordCredential {
fn perform(
&self,
registry: &RegistryInfo<'_>,
action: &Action<'_>,
args: &[&str],
) -> Result<CredentialResponse, Error> {
let op = OnePasswordKeychain::new(args)?;
match action {
Action::Get(_) => {
let session = op.signin()?;
if let Some(id) = op.search(&session, registry.index_url)? {
op.get_token(&session, &id)
.map(|token| CredentialResponse::Get {
token,
cache: CacheControl::Session,
operation_independent: true,
})
} else {
Err(Error::NotFound)
}
}
Action::Login(options) => {
let session = op.signin()?;
// Check if an item already exists.
if let Some(id) = op.search(&session, registry.index_url)? {
eprintln!("note: token already exists for `{}`", registry.index_url);
let token = cargo_credential::read_token(options, registry)?;
op.modify(&session, &id, token.as_deref(), None)?;
} else {
let token = cargo_credential::read_token(options, registry)?;
op.create(&session, registry.index_url, token.as_deref(), None)?;
}
Ok(CredentialResponse::Login)
}
Action::Logout => {
let session = op.signin()?;
// Check if an item already exists.
if let Some(id) = op.search(&session, registry.index_url)? {
op.delete(&session, &id)?;
Ok(CredentialResponse::Logout)
} else {
Err(Error::NotFound)
}
}
_ => Err(Error::OperationNotSupported),
}
}
}
fn main() {
cargo_credential::main(OnePasswordCredential {});
}