use std::process::Command;
use secrecy::ExposeSecret;
use secrecy::SecretString;
use serde::Deserialize;
use serde::Serialize;
use crate::MonosecretError;
use crate::Provider;
use crate::config::NativeAddress;
use crate::provider::Address;
use crate::provider::ProviderUrl;
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct GoPassConfig {
pub folder_prefix: Option<String>,
}
impl TryFrom<&ProviderUrl> for GoPassConfig {
type Error = MonosecretError;
fn try_from(url: &ProviderUrl) -> Result<Self, Self::Error> {
if url.scheme() != "gopass" {
return Err(MonosecretError::ProviderOperationFailed(format!(
"Invalid scheme '{}' for gopass provider",
url.scheme(),
)));
}
let mut config = Self::default();
if let Some(host) = url.host() {
let path = url.path();
config.folder_prefix = Some(format!("{host}{path}"));
}
Ok(config)
}
}
pub struct GoPassProvider {
config: GoPassConfig,
}
fn is_missing_entry(stderr: &str) -> bool {
stderr.contains("is not in the password store") || stderr.contains("does not exist")
}
crate::register_provider! {
struct: GoPassProvider,
config: GoPassConfig,
name: "gopass",
description: "Multi-user and multi-store abstraction layer over pass",
schemes: ["gopass"],
examples: ["gopass://", "gopass://monosecret/shared/{profile}/{key}"],
deletes: true,
}
impl GoPassProvider {
pub fn new(config: GoPassConfig) -> Self {
Self { config }
}
fn format_entry_name(&self, project: &str, profile: &str, key: &str) -> String {
let format_string = self
.config
.folder_prefix
.as_deref()
.unwrap_or("monosecret/{project}/{profile}/{key}");
format_string
.replace("{project}", project)
.replace("{profile}", profile)
.replace("{key}", key)
}
fn command(&self) -> Command {
Command::new("gopass")
}
}
impl Provider for GoPassProvider {
fn convention_address(
&self,
project: &str,
profile: &str,
key: &str,
) -> crate::Result<NativeAddress> {
Ok(NativeAddress {
item: self.format_entry_name(project, profile, key),
..Default::default()
})
}
fn get(&self, addr: Address<'_>) -> crate::Result<Option<SecretString>> {
let entry_name = super::flat_item(self, addr)?;
let output = self
.command()
.arg("show")
.arg("-y")
.arg("-o")
.arg(&*entry_name)
.output()
.map_err(|e| {
MonosecretError::ProviderOperationFailed(format!(
"Failed to execute 'gopass' command: {e}. Is gopass installed?"
))
})?;
if output.status.success() {
let content = String::from_utf8(output.stdout)
.map_err(|e| {
MonosecretError::ProviderOperationFailed(format!(
"Failed to parse gopass output as UTF-8: {e}"
))
})?
.trim()
.to_string();
Ok(Some(SecretString::new(content.into())))
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
if output.status.code() == Some(11) && is_missing_entry(&stderr) {
Ok(None)
} else {
Err(MonosecretError::ProviderOperationFailed(format!(
"gopass command failed: {stderr}"
)))
}
}
}
fn set(&self, addr: Address<'_>, value: &SecretString) -> crate::Result<()> {
let entry_name = super::flat_item(self, addr)?;
let mut child = self
.command()
.args(["insert", "-m", "-f", &entry_name])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|e| {
MonosecretError::ProviderOperationFailed(format!(
"Failed to execute gopass command: {e}"
))
})?;
let mut stdin = child.stdin.take().ok_or_else(|| {
MonosecretError::ProviderOperationFailed(
"Failed to obtain stdin for gopass command".to_string(),
)
})?;
use std::io::Write;
stdin
.write_all(value.expose_secret().as_bytes())
.map_err(|e| {
MonosecretError::ProviderOperationFailed(format!(
"Failed to write to gopass stdin: {e}"
))
})?;
drop(stdin);
let output = child.wait_with_output().map_err(|e| {
MonosecretError::ProviderOperationFailed(format!(
"Failed to wait for gopass command: {e}"
))
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(MonosecretError::ProviderOperationFailed(format!(
"gopass command failed: {stderr}"
)));
}
Ok(())
}
fn delete(&self, addr: Address<'_>) -> crate::Result<bool> {
let entry_name = super::flat_item(self, addr)?;
let output = self
.command()
.args(["rm", "-f", &entry_name])
.output()
.map_err(|error| {
MonosecretError::ProviderOperationFailed(format!(
"Failed to execute 'gopass' command: {error}. Is gopass installed?"
))
})?;
if output.status.success() {
return Ok(true);
}
let stderr = String::from_utf8_lossy(&output.stderr);
if is_missing_entry(&stderr) {
return Ok(false);
}
Err(MonosecretError::ProviderOperationFailed(format!(
"gopass command failed: {stderr}"
)))
}
fn supports_delete(&self) -> bool {
true
}
fn name(&self) -> &'static str {
Self::PROVIDER_NAME
}
fn uri(&self) -> String {
let prefix = self
.config
.folder_prefix
.as_deref()
.map(ProviderUrl::encode)
.unwrap_or_default();
if prefix.is_empty() {
"gopass".to_string()
} else {
format!("gopass://{prefix}")
}
}
fn entry_container_identity(&self) -> String {
"gopass".to_string()
}
}
#[cfg(test)]
mod tests {
use url::Url;
use super::*;
fn provider_url(s: &str) -> ProviderUrl {
ProviderUrl::new(Url::parse(s).unwrap())
}
#[test]
fn format_entry_name_default_pattern() {
let provider = GoPassProvider::new(GoPassConfig::default());
assert_eq!(
provider.format_entry_name("myproj", "prod", "API_KEY"),
"monosecret/myproj/prod/API_KEY"
);
}
#[test]
fn format_entry_name_custom_prefix() {
let provider = GoPassProvider::new(GoPassConfig {
folder_prefix: Some("team-store/{profile}/{key}".to_string()),
});
assert_eq!(
provider.format_entry_name("myproj", "prod", "API_KEY"),
"team-store/prod/API_KEY"
);
}
#[test]
fn try_from_sets_folder_prefix_from_host_and_path() {
let config =
GoPassConfig::try_from(&provider_url("gopass://monosecret/shared/{profile}/{key}"))
.unwrap();
assert_eq!(
config.folder_prefix.as_deref(),
Some("monosecret/shared/{profile}/{key}")
);
}
#[test]
fn try_from_bare_url_leaves_prefix_unset() {
let config = GoPassConfig::try_from(&provider_url("gopass://")).unwrap();
assert_eq!(config.folder_prefix, None);
}
#[test]
fn missing_entry_is_recognized_from_show_and_rm() {
for stderr in [
"Error: failed to retrieve secret \"monosecret/p/default/API_KEY\": \
entry is not in the password store\n",
"Error: Secret \"monosecret/p/default/API_KEY\" does not exist\n",
] {
assert!(is_missing_entry(stderr), "{stderr}");
}
for stderr in [
"Error: failed to decrypt: gpg: decryption failed: No secret key\n",
"Error: Store not initialized. Run gopass init.\n",
"",
] {
assert!(!is_missing_entry(stderr), "{stderr}");
}
}
#[test]
fn try_from_rejects_wrong_scheme() {
let err = GoPassConfig::try_from(&provider_url("pass://x")).unwrap_err();
assert!(err.to_string().contains("Invalid scheme"));
}
#[test]
fn uri_round_trips_default_and_prefix() {
assert_eq!(GoPassProvider::new(GoPassConfig::default()).uri(), "gopass");
let provider = GoPassProvider::new(GoPassConfig {
folder_prefix: Some("my store/{key}".to_string()),
});
assert_eq!(provider.uri(), "gopass://my%20store/{key}");
}
#[test]
fn native_address_names_the_entry() {
let p = GoPassProvider::new(GoPassConfig {
folder_prefix: Some("team-store/{profile}/{key}".to_string()),
});
let addr = crate::config::NativeAddress {
item: "work-store/email/work".into(),
..Default::default()
};
assert_eq!(
crate::provider::flat_item(&p, Address::Native(&addr)).unwrap(),
"work-store/email/work"
);
}
#[test]
fn native_address_rejects_field() {
let p = GoPassProvider::new(GoPassConfig::default());
let addr = crate::config::NativeAddress {
item: "email/work".into(),
field: Some("password".into()),
..Default::default()
};
let err = crate::provider::flat_item(&p, Address::Native(&addr)).unwrap_err();
assert!(err.to_string().contains("`field`"), "{err}");
}
}