use std::process::Command;
use std::process::Stdio;
use secrecy::ExposeSecret;
use secrecy::SecretString;
use serde::Deserialize;
use serde::Serialize;
use crate::MonosecretError;
use crate::Result;
use crate::provider::Address;
use crate::provider::Provider;
use crate::provider::ProviderUrl;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LastPassConfig {
pub folder_prefix: Option<String>,
}
impl Default for LastPassConfig {
fn default() -> Self {
Self {
folder_prefix: None,
}
}
}
impl TryFrom<&ProviderUrl> for LastPassConfig {
type Error = MonosecretError;
fn try_from(url: &ProviderUrl) -> std::result::Result<Self, Self::Error> {
if url.scheme() != "lastpass" {
return Err(MonosecretError::ProviderOperationFailed(format!(
"Invalid scheme '{}' for lastpass provider",
url.scheme()
)));
}
let mut config = Self::default();
if let Some(host) = url.host() {
config.folder_prefix = Some(format!("{}{}", host, url.path()));
}
Ok(config)
}
}
pub struct LastPassProvider {
#[allow(dead_code)]
config: LastPassConfig,
}
crate::register_provider! {
struct: LastPassProvider,
config: LastPassConfig,
name: "lastpass",
description: "LastPass password manager",
schemes: ["lastpass"],
examples: ["lastpass://", "lastpass://Shared-Monosecret"],
preflight: check_auth,
}
impl LastPassProvider {
pub fn new(config: LastPassConfig) -> Self {
Self { config }
}
fn execute_lpass_command(args: &[&str]) -> Result<String> {
let mut cmd = Command::new("lpass");
cmd.args(args);
let output = match cmd.output() {
Ok(output) => output,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(MonosecretError::ProviderOperationFailed(
"LastPass CLI (lpass) is not installed.\n\nTo install it:\n - macOS: brew install lastpass-cli\n - Linux: Check your package manager (apt install lastpass-cli, yum install lastpass-cli, etc.)\n - NixOS: nix-env -iA nixpkgs.lastpass-cli\n\nAfter installation, run 'lpass login <your-email>' to authenticate.".to_string(),
));
}
Err(e) => return Err(e.into()),
};
if !output.status.success() {
let error_msg = String::from_utf8_lossy(&output.stderr);
if error_msg.contains("Could not find decryption key")
|| error_msg.contains("Not logged in")
{
return Err(MonosecretError::ProviderOperationFailed(
"LastPass authentication required. Please run 'lpass login' first.".to_string(),
));
}
return Err(MonosecretError::ProviderOperationFailed(
error_msg.to_string(),
));
}
String::from_utf8(output.stdout).map_err(|e| {
MonosecretError::ProviderOperationFailed(format!(
"LastPass CLI returned non-UTF-8 output: {}",
crate::error::display_error_chain(&e)
))
})
}
fn format_item_name(&self, project: &str, key: &str, profile: &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 check_login_status() -> Result<bool> {
match Self::execute_lpass_command(&["status"]) {
Ok(output) => Ok(!output.contains("Not logged in")),
Err(MonosecretError::ProviderOperationFailed(msg))
if msg.contains("Not logged in")
|| msg.contains("LastPass authentication required") =>
{
Ok(false)
}
Err(e) => Err(e),
}
}
#[allow(clippy::unused_self)]
pub(crate) fn check_auth(&self) -> Result<()> {
if !Self::check_login_status()? {
return Err(MonosecretError::ProviderOperationFailed(
"LastPass authentication required. Please run 'lpass login <your-email>' first."
.to_string(),
));
}
Ok(())
}
}
impl Provider for LastPassProvider {
fn convention_address(
&self,
project: &str,
profile: &str,
key: &str,
) -> Result<crate::config::NativeAddress> {
Ok(crate::config::NativeAddress {
item: self.format_item_name(project, key, profile),
..Default::default()
})
}
fn name(&self) -> &'static str {
Self::PROVIDER_NAME
}
fn auth_scope_key(&self) -> Option<String> {
Some(String::new())
}
fn uri(&self) -> String {
match self.config.folder_prefix.as_deref() {
Some(prefix) if !prefix.is_empty() => {
format!("lastpass://{}", ProviderUrl::encode(prefix))
}
_ => "lastpass".to_string(),
}
}
fn entry_container_identity(&self) -> String {
"lastpass".to_string()
}
fn get(&self, addr: Address<'_>) -> Result<Option<SecretString>> {
let item_name = crate::provider::flat_item(self, addr)?;
match Self::execute_lpass_command(&["show", "--sync=now", "--password", &item_name]) {
Ok(output) => {
let password = output.trim();
if password.is_empty() {
Ok(None)
} else {
Ok(Some(SecretString::new(password.to_string().into())))
}
}
Err(MonosecretError::ProviderOperationFailed(msg))
if msg.contains("Could not find specified account") =>
{
Ok(None)
}
Err(e) => Err(e),
}
}
fn set(&self, addr: Address<'_>, value: &SecretString) -> Result<()> {
let item_name = crate::provider::flat_item(self, addr)?;
if self.get(addr)?.is_some() {
let args = vec![
"edit",
"--sync=now",
&item_name,
"--password",
"--non-interactive",
];
let mut cmd = Command::new("lpass");
cmd.args(&args);
cmd.env("LPASS_DISABLE_PINENTRY", "1");
let mut child = cmd
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
if let Some(stdin) = child.stdin.take() {
super::write_child_stdin(stdin, value.expose_secret())?;
}
let output = child.wait_with_output()?;
if !output.status.success() {
let error_msg = String::from_utf8_lossy(&output.stderr);
return Err(MonosecretError::ProviderOperationFailed(
error_msg.to_string(),
));
}
} else {
let args = vec![
"add",
"--sync=now",
&item_name,
"--password",
"--non-interactive",
];
let mut cmd = Command::new("lpass");
cmd.args(&args);
cmd.env("LPASS_DISABLE_PINENTRY", "1");
let mut child = cmd
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
if let Some(stdin) = child.stdin.take() {
super::write_child_stdin(stdin, value.expose_secret())?;
}
let output = child.wait_with_output()?;
if !output.status.success() {
let error_msg = String::from_utf8_lossy(&output.stderr);
return Err(MonosecretError::ProviderOperationFailed(
error_msg.to_string(),
));
}
}
Ok(())
}
}
impl Default for LastPassProvider {
fn default() -> Self {
Self::new(LastPassConfig::default())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn provider_of(spec: &str) -> Box<dyn Provider> {
Box::<dyn Provider>::try_from(spec).expect("the spec must be valid")
}
fn uri_of(spec: &str) -> String {
provider_of(spec).uri()
}
fn addressed_item(spec: &str) -> String {
provider_of(spec)
.convention_address("my-app", "production", "API_KEY")
.expect("a convention address")
.item
}
#[test]
fn format_item_name_default_pattern() {
let provider = LastPassProvider::new(LastPassConfig::default());
assert_eq!(
provider.format_item_name("myproj", "API_KEY", "prod"),
"monosecret/myproj/prod/API_KEY"
);
}
#[test]
fn format_item_name_custom_prefix() {
let provider = LastPassProvider::new(LastPassConfig {
folder_prefix: Some("Work/{profile}/{key}".to_string()),
});
assert_eq!(
provider.format_item_name("myproj", "API_KEY", "prod"),
"Work/prod/API_KEY"
);
}
#[test]
fn uri_round_trips_the_item_template() {
for spec in [
"lastpass",
"lastpass://",
"lastpass://Work",
"lastpass://Shared",
"lastpass://Shared/{project}/{profile}/{key}",
"lastpass://Shared-Monosecret/{project}/{profile}/{key}",
"lastpass://Work/TeamA/{project}/{profile}/{key}",
"lastpass://Shared Items/dev/{key}",
] {
let rendered = uri_of(spec);
assert_eq!(
addressed_item(&rendered),
addressed_item(spec),
"{spec} rendered as {rendered}, which does not read back as the same item",
);
}
}
#[test]
fn uri_distinguishes_two_templates_under_one_folder() {
assert_ne!(
uri_of("lastpass://Work/TeamA/{project}/{profile}/{key}"),
uri_of("lastpass://Work/TeamB/{project}/{profile}/{key}"),
);
}
}
#[cfg(test)]
mod reference_tests {
use super::*;
#[test]
fn native_address_names_the_item() {
let p = LastPassProvider::new(LastPassConfig {
folder_prefix: Some("Work/{key}".to_string()),
});
let addr = crate::config::NativeAddress {
item: "Shared/api-token".into(),
..Default::default()
};
assert_eq!(
crate::provider::flat_item(&p, Address::Native(&addr)).unwrap(),
"Shared/api-token"
);
}
#[test]
fn native_address_rejects_field() {
let p = LastPassProvider::new(LastPassConfig::default());
let addr = crate::config::NativeAddress {
item: "api-token".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}");
}
}