use crate::provider::{Provider, ProviderUrl};
use crate::{Result, SecretSpecError};
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
use std::io::Write;
use std::process::{Command, Stdio};
#[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 = SecretSpecError;
fn try_from(url: &ProviderUrl) -> std::result::Result<Self, Self::Error> {
if url.scheme() != "lastpass" {
return Err(SecretSpecError::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-SecretSpec"],
preflight: check_auth,
}
impl LastPassProvider {
pub fn new(config: LastPassConfig) -> Self {
Self { config }
}
fn execute_lpass_command(&self, 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(SecretSpecError::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(SecretSpecError::ProviderOperationFailed(
"LastPass authentication required. Please run 'lpass login' first.".to_string(),
));
}
return Err(SecretSpecError::ProviderOperationFailed(
error_msg.to_string(),
));
}
String::from_utf8(output.stdout)
.map_err(|e| SecretSpecError::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("secretspec/{project}/{profile}/{key}");
format_string
.replace("{project}", project)
.replace("{profile}", profile)
.replace("{key}", key)
}
fn check_login_status(&self) -> Result<bool> {
match self.execute_lpass_command(&["status"]) {
Ok(output) => Ok(!output.contains("Not logged in")),
Err(SecretSpecError::ProviderOperationFailed(msg))
if msg.contains("Not logged in")
|| msg.contains("LastPass authentication required") =>
{
Ok(false)
}
Err(e) => Err(e),
}
}
pub(crate) fn check_auth(&self) -> Result<()> {
if !self.check_login_status()? {
return Err(SecretSpecError::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(SecretSpecError::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(SecretSpecError::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(SecretSpecError::ProviderOperationFailed(
error_msg.to_string(),
));
}
}
Ok(())
}
}
impl Default for LastPassProvider {
fn default() -> Self {
Self::new(LastPassConfig::default())
}
}