use std::collections::HashMap;
use std::process::Command;
const GRANT_SECS: u64 = 86_400;
pub struct Passbox {
pub namespace: Option<String>,
pub agent: Option<String>,
pub grant: Vec<String>,
token: Option<String>,
}
impl Passbox {
pub fn new(namespace: Option<&str>, agent: Option<&str>, grant: &[&str]) -> Self {
Self {
namespace: namespace.map(str::to_string),
agent: agent.map(str::to_string),
grant: grant.iter().map(|s| s.to_string()).collect(),
token: None,
}
}
fn name(&self, path: &str) -> String {
match &self.namespace {
Some(ns) => format!("{ns}/{path}"),
None => path.to_string(),
}
}
fn token(&mut self) -> Option<String> {
if let Ok(inherited) = std::env::var("PASSBOX_TOKEN")
&& !inherited.is_empty()
{
return Some(inherited);
}
if self.token.is_some() || self.grant.is_empty() {
return self.token.clone();
}
let names: Vec<String> = self.grant.iter().map(|n| self.name(n)).collect();
let out = Command::new("passbox")
.arg("grant")
.args(&names)
.args(["--for", &GRANT_SECS.to_string()])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let token = String::from_utf8_lossy(&out.stdout).trim().to_string();
self.token = (!token.is_empty()).then_some(token);
self.token.clone()
}
fn command(&mut self, args: &[&str]) -> Command {
let mut cmd = Command::new("passbox");
cmd.args(args);
if let Some(agent) = self.agent.clone() {
cmd.env("PASSBOX_AGENT", agent);
}
if let Some(token) = self.token() {
cmd.env("PASSBOX_TOKEN", token);
}
cmd
}
pub fn get(&mut self, path: &str) -> Result<String, String> {
let name = self.name(path);
let out = self
.command(&["get", "--reveal", &name])
.output()
.map_err(|e| format!("passbox is not on PATH: {e}"))?;
if !out.status.success() {
return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
}
Ok(String::from_utf8_lossy(&out.stdout).to_string())
}
pub fn fields(&mut self, path: &str) -> Result<HashMap<String, String>, String> {
Ok(parse_fields(&self.get(path)?))
}
pub fn run_with(&mut self, var: &str, path: &str, argv: &[&str]) -> Result<String, String> {
let name = self.name(path);
let out = self
.command(&["exec", "--env", &format!("{var}={name}"), "--"])
.args(argv)
.output()
.map_err(|e| format!("passbox is not on PATH: {e}"))?;
if !out.status.success() {
return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
}
Ok(String::from_utf8_lossy(&out.stdout).to_string())
}
pub fn is_available(&self) -> bool {
let dir = std::env::var("PASSBOX_DIR")
.map(std::path::PathBuf::from)
.unwrap_or_else(|_| {
std::path::PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".passbox")
});
dir.join("wraps").is_dir()
&& Command::new("passbox")
.arg("--version")
.output()
.is_ok_and(|o| o.status.success())
}
}
pub fn parse_fields(whole: &str) -> HashMap<String, String> {
let mut out = HashMap::new();
let mut lines = whole.trim().lines();
if let Some(first) = lines.next() {
out.insert("password".to_string(), first.to_string());
}
for line in lines {
if let Some((k, v)) = line.split_once(':') {
let (k, v) = (k.trim().to_lowercase(), v.trim());
if !k.is_empty() && !v.is_empty() {
out.insert(k, v.to_string());
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fields_split_the_password_from_the_rest() {
let f = parse_fields("s3cret\nUsername: bot\nurl: https://example.com\nnote:\n");
assert_eq!(f["password"], "s3cret");
assert_eq!(f["username"], "bot");
assert_eq!(f["url"], "https://example.com");
assert!(!f.contains_key("note"));
}
#[test]
fn a_namespace_prefixes_every_name() {
assert_eq!(
Passbox::new(Some("acme"), None, &[]).name("token"),
"acme/token"
);
assert_eq!(Passbox::new(None, None, &[]).name("token"), "token");
}
}