use anyhow::{Context, Result};
use serde::Deserialize;
use std::process::Command;
pub struct ProtonPass;
#[derive(Debug, Deserialize)]
pub struct VaultListResponse {
pub vaults: Vec<Vault>,
}
#[derive(Debug, Deserialize)]
pub struct Vault {
pub name: String,
}
#[derive(Debug, Deserialize)]
pub struct ItemListResponse {
pub items: Vec<Item>,
}
#[derive(Debug, Deserialize)]
pub struct Item {
pub content: ItemContent,
}
#[derive(Debug, Deserialize)]
pub struct ItemContent {
pub title: String,
pub content: ItemData,
#[serde(default)]
pub extra_fields: Vec<ExtraField>,
}
#[derive(Debug, Deserialize)]
pub struct ItemData {
#[serde(rename = "SshKey")]
pub ssh_key: Option<SshKeyData>,
#[serde(rename = "Custom")]
pub custom: Option<CustomData>,
}
#[derive(Debug, Deserialize)]
pub struct SshKeyData {
pub private_key: Option<String>,
pub public_key: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct CustomData {
#[serde(default)]
pub sections: Vec<CustomSection>,
}
#[derive(Debug, Deserialize)]
pub struct CustomSection {
pub section_name: String,
#[serde(default)]
pub section_fields: Vec<SectionField>,
}
#[derive(Debug, Deserialize)]
pub struct SectionField {
pub name: String,
pub content: FieldContent,
}
#[derive(Debug, Deserialize)]
pub struct ExtraField {
pub name: String,
pub content: FieldContent,
}
#[derive(Debug, Deserialize)]
pub struct FieldContent {
#[serde(rename = "Text")]
pub text: Option<String>,
}
#[derive(Debug)]
pub struct SshItem {
pub title: String,
pub private_key: Option<String>,
pub public_key: Option<String>,
pub host: Option<String>,
pub username: Option<String>,
pub aliases: Option<String>,
pub ssh: Option<String>,
pub server_command: Option<String>,
pub jump: Option<String>,
}
impl ProtonPass {
pub fn new() -> Self {
Self
}
pub fn list_vaults(&self) -> Result<Vec<String>> {
let output = Command::new("pass-cli")
.args(["vault", "list", "--output", "json"])
.output()
.context("Failed to execute pass-cli vault list")?;
if !output.status.success() {
anyhow::bail!(
"pass-cli vault list failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
let response: VaultListResponse = serde_json::from_slice(&output.stdout)
.context("Failed to parse vault list response")?;
Ok(response
.vaults
.into_iter()
.map(|v| v.name)
.filter(|name| name != "Trash")
.collect())
}
pub fn list_ssh_keys(&self, vault: &str) -> Result<Vec<SshItem>> {
let output = Command::new("pass-cli")
.args([
"item",
"list",
vault,
"--filter-type",
"ssh-key",
"--filter-state",
"active",
"--output",
"json",
])
.output()
.context("Failed to execute pass-cli item list")?;
if !output.status.success() || output.stdout.is_empty() {
return Ok(Vec::new());
}
let response: ItemListResponse =
serde_json::from_slice(&output.stdout).context("Failed to parse item list response")?;
let items = response
.items
.into_iter()
.map(|item| {
let ssh_key = item.content.content.ssh_key;
let (private_key, public_key) = ssh_key
.map(|k| (k.private_key, k.public_key))
.unwrap_or((None, None));
let host = Self::get_field(&item.content.extra_fields, "Host");
let username = Self::get_field(&item.content.extra_fields, "Username");
let aliases = Self::get_field(&item.content.extra_fields, "Aliases");
let ssh = Self::get_field(&item.content.extra_fields, "SSH");
let server_command = Self::get_field(&item.content.extra_fields, "Server Command");
let jump = Self::get_field(&item.content.extra_fields, "Jump");
SshItem {
title: item.content.title,
private_key,
public_key,
host,
username,
aliases,
ssh,
server_command,
jump,
}
})
.collect();
Ok(items)
}
pub fn list_teleport_items(&self, vault: &str) -> Result<Vec<SshItem>> {
let output = Command::new("pass-cli")
.args([
"item",
"list",
vault,
"--filter-type",
"custom",
"--filter-state",
"active",
"--output",
"json",
])
.output()
.context("Failed to execute pass-cli item list")?;
if !output.status.success() || output.stdout.is_empty() {
return Ok(Vec::new());
}
let response: ItemListResponse =
serde_json::from_slice(&output.stdout).context("Failed to parse item list response")?;
let items = response
.items
.into_iter()
.filter_map(|item| {
let custom = item.content.content.custom?;
let teleport_section = custom
.sections
.iter()
.find(|s| s.section_name == "Teleport Rclone Config")?;
let ssh = Self::get_section_field(&teleport_section.section_fields, "SSH");
let server_command =
Self::get_section_field(&teleport_section.section_fields, "Server Command");
if ssh.is_none() && server_command.is_none() {
return None;
}
Some(SshItem {
title: item.content.title,
private_key: None,
public_key: None,
host: None,
username: None,
aliases: None,
ssh,
server_command,
jump: None,
})
})
.collect();
Ok(items)
}
pub fn list_all_items(&self, vault: &str) -> Result<Vec<SshItem>> {
let mut items = self.list_ssh_keys(vault)?;
items.extend(self.list_teleport_items(vault)?);
Ok(items)
}
pub fn get_item_field(&self, path: &str) -> Result<String> {
let output = Command::new("pass-cli")
.args(["item", "view", path])
.output()
.context("Failed to execute pass-cli item view")?;
if !output.status.success() {
anyhow::bail!(
"Failed to get value from '{}': {}",
path,
String::from_utf8_lossy(&output.stderr)
);
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub fn update_item_field(
&self,
vault: &str,
title: &str,
field: &str,
value: &str,
) -> Result<()> {
let field_arg = format!("{}={}", field, value);
let output = Command::new("pass-cli")
.args([
"item",
"update",
"--vault-name",
vault,
"--item-title",
title,
"--field",
&field_arg,
])
.output()
.context("Failed to execute pass-cli item update")?;
if !output.status.success() {
anyhow::bail!(
"Failed to update field '{}': {}",
field,
String::from_utf8_lossy(&output.stderr)
);
}
Ok(())
}
pub fn vault_exists(&self, name: &str) -> Result<bool> {
let vaults = self.list_vaults()?;
Ok(vaults.iter().any(|v| v == name))
}
pub fn list_item_titles(&self, vault: &str) -> Result<Vec<String>> {
let output = Command::new("pass-cli")
.args([
"item",
"list",
vault,
"--filter-state",
"active",
"--output",
"json",
])
.output()
.context("Failed to execute pass-cli item list")?;
if !output.status.success() || output.stdout.is_empty() {
return Ok(Vec::new());
}
let response: ItemListResponse =
serde_json::from_slice(&output.stdout).context("Failed to parse item list response")?;
Ok(response
.items
.into_iter()
.map(|item| item.content.title)
.collect())
}
pub fn create_vault(&self, name: &str) -> Result<()> {
let output = Command::new("pass-cli")
.args(["vault", "create", "--name", name])
.output()
.context("Failed to execute pass-cli vault create")?;
if !output.status.success() {
anyhow::bail!(
"Failed to create vault '{}': {}",
name,
String::from_utf8_lossy(&output.stderr)
);
}
Ok(())
}
pub fn create_tsh_item(
&self,
vault: &str,
title: &str,
ssh_command: &str,
server_command: &str,
) -> Result<()> {
use std::io::Write;
let template = serde_json::json!({
"title": title,
"note": "",
"sections": [
{
"section_name": "Teleport Rclone Config",
"fields": [
{
"field_name": "SSH",
"field_type": "text",
"value": ssh_command
},
{
"field_name": "Server Command",
"field_type": "text",
"value": server_command
}
]
}
]
});
let mut temp_file =
tempfile::NamedTempFile::new().context("Failed to create temp file for template")?;
temp_file
.write_all(template.to_string().as_bytes())
.context("Failed to write template to temp file")?;
let output = Command::new("pass-cli")
.args([
"item",
"create",
"custom",
"--vault-name",
vault,
"--from-template",
temp_file.path().to_str().unwrap(),
])
.output()
.context("Failed to create custom item")?;
if !output.status.success() {
anyhow::bail!(
"Failed to create item '{}': {}",
title,
String::from_utf8_lossy(&output.stderr)
);
}
Ok(())
}
fn get_field(fields: &[ExtraField], name: &str) -> Option<String> {
fields
.iter()
.find(|f| f.name == name)
.and_then(|f| f.content.text.clone())
.filter(|s| !s.is_empty())
}
fn get_section_field(fields: &[SectionField], name: &str) -> Option<String> {
fields
.iter()
.find(|f| f.name == name)
.and_then(|f| f.content.text.clone())
.filter(|s| !s.is_empty())
}
}
impl Default for ProtonPass {
fn default() -> Self {
Self::new()
}
}