use std::process::Command;
use serde::Deserialize;
use serde::Serialize;
use crate::MonosecretError;
use crate::Provider;
use crate::SecretBytes;
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")
}
fn stores_as_password_line(value: &[u8]) -> bool {
std::str::from_utf8(value).is_ok_and(|text| {
!text.is_empty() && !text.contains(['\n', '\r', '\0']) && text.trim().len() == text.len()
})
}
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() -> Command {
Command::new("gopass")
}
fn is_binary_entry(entry: &str) -> crate::Result<bool> {
let output = Self::command()
.args(["show", "-y"])
.arg(entry)
.arg(BINARY_ENTRY_HEADER)
.output()
.map_err(|e| {
MonosecretError::ProviderOperationFailed(format!(
"Failed to execute 'gopass' command: {e}. Is gopass installed?"
))
})?;
Ok(output.status.success()
&& String::from_utf8_lossy(&output.stdout)
.trim()
.eq_ignore_ascii_case("base64"))
}
}
const BINARY_ENTRY_HEADER: &str = "Content-Transfer-Encoding";
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<SecretBytes>> {
let entry_name = super::flat_item(self, addr)?;
let mut output = Self::command()
.args(["show", "-y", "-o"])
.arg(&*entry_name)
.output()
.map_err(|e| {
MonosecretError::ProviderOperationFailed(format!(
"Failed to execute 'gopass' command: {e}. Is gopass installed?"
))
})?;
let lossless = output.status.code() == Some(11)
&& String::from_utf8_lossy(&output.stderr).contains("no password to display")
&& Self::is_binary_entry(&entry_name)?;
if lossless {
output = Self::command()
.arg("cat")
.arg(&*entry_name)
.stdin(std::process::Stdio::null())
.output()
.map_err(|e| {
MonosecretError::ProviderOperationFailed(format!(
"Failed to execute 'gopass cat' command: {e}"
))
})?;
}
if output.status.success() {
if lossless {
return Ok(Some(SecretBytes::from_vec(output.stdout)));
}
let content = String::from_utf8(output.stdout).map_err(|e| {
MonosecretError::ProviderOperationFailed(format!(
"Failed to parse gopass output as UTF-8: {e}"
))
})?;
Ok(Some(SecretBytes::from_utf8(content.trim())))
} 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: &SecretBytes) -> crate::Result<()> {
let entry_name = super::flat_item(self, addr)?;
let subcommand: &[&str] = if stores_as_password_line(value.expose_secret()) {
&["insert", "-m", "-f"]
} else {
&["cat"]
};
let mut child = Self::command()
.args(subcommand)
.arg(&*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()).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);
if output.status.code() == Some(1)
&& stderr.trim_end().ends_with(": meaningless write")
&& self.get(addr)?.is_some_and(|stored| stored == *value)
{
return Ok(());
}
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) -> &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 password_line_values_stay_text_entries() {
for value in ["hunter2", "with spaces inside", "🔐 émojis", "a\tb"] {
assert!(stores_as_password_line(value.as_bytes()), "{value:?}");
}
for value in [
"line1\nline2",
"trailing\n",
"a\r\nb",
"cr\ronly",
" padded ",
"\ttab",
"nul\0byte",
"",
] {
assert!(!stores_as_password_line(value.as_bytes()), "{value:?}");
}
assert!(!stores_as_password_line(b"\xff\xfe"));
}
#[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 = 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 = 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}");
}
}