use std::io::Write;
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::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(e.to_string()))
}
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 name(&self) -> &'static str {
Self::PROVIDER_NAME
}
fn uri(&self) -> String {
if let Some(ref prefix) = self.config.folder_prefix {
if let Some(folder) = prefix.split('/').next() {
if folder.is_empty() || folder == "Shared" {
"lastpass".to_string()
} else {
format!("lastpass://{}", ProviderUrl::encode(folder))
}
} else {
"lastpass".to_string()
}
} else {
"lastpass".to_string()
}
}
fn get(&self, project: &str, key: &str, profile: &str) -> Result<Option<SecretString>> {
let item_name = self.format_item_name(project, key, profile);
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, project: &str, key: &str, value: &SecretString, profile: &str) -> Result<()> {
let item_name = self.format_item_name(project, key, profile);
if self.get(project, key, profile)?.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.as_mut() {
stdin.write_all(value.expose_secret().as_bytes())?;
}
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.as_mut() {
stdin.write_all(value.expose_secret().as_bytes())?;
}
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(all(test, unix))]
mod tests {
use std::os::unix::fs::PermissionsExt;
use std::sync::Mutex;
use super::*;
static LASTPASS_TEST_LOCK: Mutex<()> = Mutex::new(());
struct PathGuard {
original: Option<std::ffi::OsString>,
_lock: std::sync::MutexGuard<'static, ()>,
}
impl PathGuard {
#[allow(clippy::used_underscore_binding)]
fn prepend(dir: &std::path::Path) -> Self {
let _lock = LASTPASS_TEST_LOCK.lock().unwrap();
let original = std::env::var_os("PATH");
let new_path = match &original {
Some(p) => {
let mut paths: Vec<std::path::PathBuf> = std::env::split_paths(p).collect();
paths.insert(0, dir.to_path_buf());
std::env::join_paths(paths)
.unwrap_or_else(|_| dir.to_string_lossy().into_owned().into())
}
None => std::ffi::OsString::from(dir),
};
unsafe {
std::env::set_var("PATH", new_path);
}
Self { original, _lock }
}
}
impl Drop for PathGuard {
fn drop(&mut self) {
match self.original.take() {
Some(p) => unsafe {
std::env::set_var("PATH", p);
},
None => unsafe {
std::env::remove_var("PATH");
},
}
}
}
fn write_fake_lpass(dir: &std::path::Path, script_body: &str) {
let script = dir.join("lpass");
std::fs::write(&script, script_body).unwrap();
let mut perms = std::fs::metadata(&script).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&script, perms).unwrap();
}
#[test]
fn execute_lpass_command_errors_when_cli_not_installed() {
let _lock = LASTPASS_TEST_LOCK.lock().unwrap();
if Command::new("lpass").arg("--version").output().is_ok() {
eprintln!("skipping: lpass is installed");
return;
}
let err = LastPassProvider::execute_lpass_command(&["status"]).unwrap_err();
insta::assert_snapshot!(err.to_string());
}
#[test]
fn check_login_status_returns_false_when_not_logged_in() {
let dir = tempfile::tempdir().unwrap();
write_fake_lpass(dir.path(), "#!/bin/sh\nprintf 'Not logged in\\n'\nexit 0\n");
let _guard = PathGuard::prepend(dir.path());
let logged_in = LastPassProvider::check_login_status().unwrap();
assert!(!logged_in);
}
#[test]
fn check_auth_errors_when_not_logged_in() {
let dir = tempfile::tempdir().unwrap();
write_fake_lpass(dir.path(), "#!/bin/sh\nprintf 'Not logged in\\n'\nexit 0\n");
let _guard = PathGuard::prepend(dir.path());
let provider = LastPassProvider::default();
let err = provider.check_auth().unwrap_err();
insta::assert_snapshot!(err.to_string());
}
#[test]
fn get_returns_value_when_secret_exists() {
let dir = tempfile::tempdir().unwrap();
write_fake_lpass(
dir.path(),
"#!/bin/sh\ncase \"$1\" in\n status) printf 'Logged in\\n' ;;\n show) printf 'secret-value\\n' ;;\nesac\n",
);
let _guard = PathGuard::prepend(dir.path());
let provider = LastPassProvider::default();
let value = provider
.get("project", "KEY", "default")
.unwrap()
.expect("should find secret");
insta::assert_snapshot!(value.expose_secret());
}
#[test]
fn get_returns_none_when_secret_not_found() {
let dir = tempfile::tempdir().unwrap();
write_fake_lpass(
dir.path(),
"#!/bin/sh\ncase \"$1\" in\n status) printf 'Logged in\\n' ;;\n show) printf 'Could not find specified account\\n' >&2; exit 1 ;;\nesac\n",
);
let _guard = PathGuard::prepend(dir.path());
let provider = LastPassProvider::default();
let result = provider.get("project", "MISSING", "default").unwrap();
assert!(result.is_none());
}
}