use std::fs::{self, File};
use std::io::BufReader;
use std::path::{Path, PathBuf};
use clap::{ArgAction, Parser, Subcommand, ValueEnum};
use clap_serde_derive::ClapSerde;
use libc::{PR_SET_DUMPABLE, prctl};
use libc::{mlock, munlock};
use log::debug;
use nix::sys::resource::{Resource, setrlimit};
use passless_config_doc::ConfigDoc;
use serde::{Deserialize, Serialize};
#[cfg(feature = "agent")]
use crate::agent::AgentConfig;
use crate::error::Error;
pub fn local_path() -> String {
dirs::data_dir()
.expect("Could not determine data directory: $XDG_DATA_HOME or $HOME/.local/share")
.join("passless/local")
.to_string_lossy()
.into_owned()
}
#[derive(ClapSerde, Debug, Clone, Serialize, Deserialize, ConfigDoc)]
#[group(id = "local-backend-config")]
pub struct LocalBackendConfig {
#[arg(
long = "local-path",
env = "PASSLESS_LOCAL_PATH",
id = "local-path",
value_name = "PATH"
)]
#[serde(default)]
#[default(local_path())]
pub path: String,
}
pub fn pass_store_path() -> String {
dirs::home_dir()
.expect("Could not determine home directory: $HOME")
.join(".password-store")
.to_string_lossy()
.into_owned()
}
#[derive(ClapSerde, Debug, Clone, Serialize, Deserialize, ConfigDoc)]
#[group(id = "pass-backend-config")]
pub struct PassBackendConfig {
#[arg(
long = "pass-store-path",
env = "PASSLESS_PASS_STORE_PATH",
id = "pass-store-path",
value_name = "PATH"
)]
#[serde(default)]
#[default(pass_store_path())]
pub store_path: String,
#[arg(
long = "pass-path",
env = "PASSLESS_PASS_PATH",
id = "pass-path",
value_name = "PATH"
)]
#[serde(default)]
#[default("fido2".to_string())]
pub path: String,
#[arg(
long = "pass-gpg-backend",
env = "PASSLESS_PASS_GPG_BACKEND",
value_name = "BACKEND"
)]
#[serde(default)]
#[default("gnupg-bin".to_string())]
pub gpg_backend: String,
}
pub fn tpm_path() -> String {
dirs::data_dir()
.expect("Could not determine data directory: $XDG_DATA_HOME or $HOME/.local/share")
.join("passless/tpm")
.to_string_lossy()
.into_owned()
}
#[cfg(feature = "tpm")]
#[derive(ClapSerde, Debug, Clone, Serialize, Deserialize, ConfigDoc)]
#[group(id = "tpm-backend-config")]
pub struct TpmBackendConfig {
#[arg(
long = "tpm-path",
env = "PASSLESS_TPM_PATH",
id = "tpm-path",
value_name = "PATH"
)]
#[serde(default)]
#[default(tpm_path())]
pub path: String,
#[arg(long = "tpm-tcti", env = "PASSLESS_TPM_TCTI", value_name = "TCTI")]
#[serde(default)]
#[default("device:/dev/tpmrm0".to_string())]
pub tcti: String,
#[arg(long = "tpm-portable", env = "PASSLESS_TPM_PORTABLE")]
#[serde(default)]
#[default(false)]
pub portable: bool,
}
#[derive(ClapSerde, Debug, Clone, Serialize, Deserialize, ConfigDoc)]
#[group(id = "security")]
pub struct SecurityConfig {
#[arg(long = "check-mlock", env = "PASSLESS_CHECK_MLOCK")]
#[serde(default)]
#[default(true)]
pub check_mlock: bool,
#[arg(long = "disable-core-dumps", env = "PASSLESS_DISABLE_CORE_DUMPS")]
#[serde(default)]
#[default(true)]
pub disable_core_dumps: bool,
#[arg(
long = "constant-signature-counter",
env = "PASSLESS_CONSTANT_SIGNATURE_COUNTER",
action = ArgAction::Set,
require_equals = true,
num_args = 0..=1,
default_missing_value = "true"
)]
#[serde(default)]
pub constant_signature_counter: bool,
#[arg(
long = "enable-credential-backup",
env = "PASSLESS_ENABLE_CREDENTIAL_BACKUP",
action = ArgAction::Set,
require_equals = true,
num_args = 0..=1,
default_missing_value = "true"
)]
#[serde(default)]
pub enable_credential_backup: bool,
#[arg(
long = "always-uv",
env = "PASSLESS_ALWAYS_UV",
action = ArgAction::Set,
require_equals = true,
num_args = 0..=1,
default_value = "true",
default_missing_value = "true"
)]
#[serde(default)]
#[default(true)]
pub always_uv: bool,
#[arg(
long = "user-verification-registration",
env = "PASSLESS_USER_VERIFICATION_REGISTRATION"
)]
#[serde(default)]
#[default(true)]
pub user_verification_registration: bool,
#[arg(
long = "user-verification-authentication",
env = "PASSLESS_USER_VERIFICATION_AUTHENTICATION"
)]
#[serde(default)]
#[default(true)]
pub user_verification_authentication: bool,
#[arg(
long = "notification-timeout",
env = "PASSLESS_NOTIFICATION_TIMEOUT",
value_name = "SECONDS"
)]
#[serde(default)]
#[default(30)]
pub notification_timeout: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum PinEnforcement {
Never,
#[default]
Optional,
Required,
}
impl std::str::FromStr for PinEnforcement {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"never" => Ok(PinEnforcement::Never),
"optional" => Ok(PinEnforcement::Optional),
"required" => Ok(PinEnforcement::Required),
_ => Err(format!(
"Invalid PIN enforcement '{}'. Must be: never, optional, or required",
s
)),
}
}
}
impl std::fmt::Display for PinEnforcement {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PinEnforcement::Never => write!(f, "never"),
PinEnforcement::Optional => write!(f, "optional"),
PinEnforcement::Required => write!(f, "required"),
}
}
}
#[derive(ClapSerde, Debug, Clone, Serialize, Deserialize, ConfigDoc)]
#[group(id = "pin")]
pub struct PinConfig {
#[arg(
long = "pin-enforcement",
env = "PASSLESS_PIN_ENFORCEMENT",
value_name = "POLICY"
)]
#[serde(default)]
#[default(PinEnforcement::Optional)]
pub enforcement: PinEnforcement,
#[arg(
long = "pin-min-length",
env = "PASSLESS_PIN_MIN_LENGTH",
value_name = "LENGTH"
)]
#[serde(default)]
#[default(4)]
pub min_length: u8,
#[arg(
long = "pin-max-retries",
env = "PASSLESS_PIN_MAX_RETRIES",
value_name = "RETRIES"
)]
#[serde(default)]
#[default(8)]
pub max_retries: u8,
#[arg(
long = "pin-max-uv-retries",
env = "PASSLESS_PIN_MAX_UV_RETRIES",
value_name = "RETRIES"
)]
#[serde(default)]
#[default(8)]
pub max_uv_retries: u8,
#[arg(
long = "pin-auto-lock-timeout",
env = "PASSLESS_PIN_AUTO_LOCK_TIMEOUT",
value_name = "SECONDS"
)]
#[serde(default)]
#[default(0)]
pub auto_lock_timeout: u32,
}
impl PinConfig {
pub fn validate(&self) -> crate::error::Result<()> {
if self.min_length < 4 || self.min_length > 63 {
return Err(crate::error::Error::Config(format!(
"pin.min_length must be between 4 and 63, got {}",
self.min_length
)));
}
if self.max_retries == 0 {
return Err(crate::error::Error::Config(
"pin.max_retries must be greater than 0".to_string(),
));
}
if self.max_uv_retries == 0 {
return Err(crate::error::Error::Config(
"pin.max_uv_retries must be greater than 0".to_string(),
));
}
Ok(())
}
}
impl SecurityConfig {
pub fn apply_hardening(&self) -> Result<(), Box<dyn std::error::Error>> {
if self.disable_core_dumps {
self.disable_core_dumps_impl()?;
}
if self.check_mlock {
self.probe_mlock_capability()?;
}
Ok(())
}
fn disable_core_dumps_impl(&self) -> Result<(), Box<dyn std::error::Error>> {
debug!("Disabling core dumps to prevent credential leakage");
setrlimit(Resource::RLIMIT_CORE, 0, 0)?;
let r = unsafe { prctl(PR_SET_DUMPABLE, 0, 0, 0, 0) };
if r != 0 {
log::warn!("prctl(PR_SET_DUMPABLE) failed: {}", r);
}
Ok(())
}
fn probe_mlock_capability(&self) -> Result<(), Box<dyn std::error::Error>> {
debug!("Check mlock capability");
let test_size = 4096;
let test_buffer = vec![0u8; test_size];
let ptr = test_buffer.as_ptr() as *const libc::c_void;
let lock_result = unsafe { mlock(ptr, test_size) };
if lock_result == 0 {
unsafe { munlock(ptr, test_size) };
log::debug!("MLOCK is enabled - sensitive data will not be swapped to disk");
} else {
log::warn!(
"mlock capability probe failed - memory locking may not be available.\n\
Hint: grant CAP_IPC_LOCK to the binary with: 'sudo setcap cap_ipc_lock=+ep $(which passless)'"
);
}
Ok(())
}
}
#[derive(ClapSerde, Serialize, Deserialize, Debug, ConfigDoc)]
pub struct AppConfig {
#[arg(short = 't', long = "backend-type", env = "PASSLESS_BACKEND_TYPE")]
#[serde(default)]
#[default("pass".to_string())]
pub backend_type: String,
#[arg(
short,
long,
env = "PASSLESS_VERBOSE",
action = ArgAction::Set,
require_equals = true,
num_args = 0..=1,
default_missing_value = "true"
)]
#[default(true)]
#[serde(default)]
pub verbose: bool,
#[clap_serde]
#[serde(default)]
#[command(flatten)]
pub pass: PassBackendConfig,
#[cfg(feature = "tpm")]
#[clap_serde]
#[serde(default)]
#[command(flatten)]
pub tpm: TpmBackendConfig,
#[clap_serde]
#[serde(default)]
#[command(flatten)]
pub local: LocalBackendConfig,
#[clap_serde]
#[serde(default)]
#[command(flatten)]
pub security: SecurityConfig,
#[clap_serde]
#[serde(default)]
#[command(flatten)]
pub pin: PinConfig,
#[cfg(feature = "agent")]
#[arg(skip)]
pub agents: AgentConfig,
}
#[derive(Debug, Clone)]
pub enum BackendConfig {
Local {
path: String,
},
Pass {
store_path: String,
path: String,
gpg_backend: String,
},
#[cfg(feature = "tpm")]
Tpm {
path: String,
tcti: String,
portable: bool,
},
}
impl BackendConfig {
pub fn canonicalize_path(path: &Path) -> PathBuf {
match fs::canonicalize(path) {
Ok(p) => p,
Err(_) => {
let mut current = path.to_path_buf();
let mut suffix = Vec::new();
loop {
match fs::canonicalize(¤t) {
Ok(base) => {
let mut result = base;
for component in suffix.iter().rev() {
result.push(component);
}
return result;
}
Err(_) => {
if let Some(file_name) = current.file_name() {
suffix.push(file_name.to_os_string());
current = current
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_default();
} else {
return path.to_path_buf();
}
}
}
}
}
}
}
pub fn state_path(&self) -> PathBuf {
match self {
BackendConfig::Local { path } => Self::canonicalize_path(Path::new(path)),
BackendConfig::Pass {
store_path, path, ..
} => Self::canonicalize_path(&Path::new(store_path).join(path)),
#[cfg(feature = "tpm")]
BackendConfig::Tpm { path, .. } => Self::canonicalize_path(Path::new(path)),
}
}
pub fn state_display(&self) -> String {
match self {
BackendConfig::Local { path } => path.clone(),
BackendConfig::Pass {
store_path, path, ..
} => {
format!("{}/{}", store_path, path)
}
#[cfg(feature = "tpm")]
BackendConfig::Tpm { path, .. } => path.clone(),
}
}
pub fn validate(&self) -> crate::error::Result<()> {
match self {
BackendConfig::Local { path } => {
let p = Path::new(path);
if !p.is_absolute() && !p.starts_with("~") {
debug!("Local backend path is relative: {}, canonicalizing", path);
}
Ok(())
}
BackendConfig::Pass {
store_path, path, ..
} => {
let p = Path::new(path);
if p.is_absolute() {
return Err(Error::Config(format!(
"Pass backend 'path' must be relative, got absolute path: {}",
path
)));
}
if path.contains("..") {
return Err(Error::Config(format!(
"Pass backend 'path' must not contain '..': {}",
path
)));
}
let combined = Path::new(store_path).join(path);
let canonical_store = Self::canonicalize_path(Path::new(store_path));
let canonical_combined = Self::canonicalize_path(&combined);
if !canonical_combined.starts_with(&canonical_store) {
return Err(Error::Config(format!(
"Pass backend 'path' escapes store_path: {} not beneath {}",
canonical_combined.display(),
canonical_store.display()
)));
}
Ok(())
}
#[cfg(feature = "tpm")]
BackendConfig::Tpm { path, .. } => {
let p = Path::new(path);
if !p.is_absolute() && !p.starts_with("~") {
debug!("TPM backend path is relative: {}, canonicalizing", path);
}
Ok(())
}
}
}
}
impl AppConfig {
pub fn load(args: &mut Args) -> crate::error::Result<Self> {
let default_config_path = dirs::config_dir().map(|p| p.join("passless/config.toml"));
let config_file_path = args
.config_path
.as_ref()
.or(default_config_path.as_ref())
.filter(|p| p.exists());
if let Some(path) = config_file_path
&& let Ok(f) = File::open(path)
{
log::info!("Loading configuration from: {}", path.display());
let content = std::io::read_to_string(BufReader::new(f)).unwrap_or_default();
#[cfg(feature = "agent")]
let agent_config = {
match toml::from_str::<toml::Table>(&content) {
Ok(table) => match table.get("agents") {
Some(agents_value) => serde::Deserialize::deserialize(agents_value.clone())
.map_err(|e| {
Error::Config(format!(
"failed to parse [agents] section in {}: {}",
path.display(),
e
))
})?,
None => AgentConfig::default(),
},
Err(e) => {
return Err(Error::Config(format!(
"failed to parse config file {} as TOML: {}",
path.display(),
e
)));
}
}
};
match toml::from_str::<<AppConfig as ClapSerde>::Opt>(&content) {
Ok(file_config) => {
#[allow(unused_mut)]
let mut config = AppConfig::from(file_config).merge(&mut args.config);
#[cfg(feature = "agent")]
{
config.agents = agent_config;
}
return Ok(config);
}
Err(e) => {
return Err(Error::Config(format!(
"failed to parse config file {}: {}",
path.display(),
e
)));
}
}
}
#[allow(unused_mut)]
let mut config = AppConfig::from(&mut args.config);
#[cfg(feature = "agent")]
{
config.agents = AgentConfig::default();
}
Ok(config)
}
pub fn backend(&self) -> crate::error::Result<BackendConfig> {
match self.backend_type.as_str() {
"local" => Ok(BackendConfig::Local {
path: self.local.path.clone(),
}),
"pass" => Ok(BackendConfig::Pass {
store_path: self.pass.store_path.clone(),
path: self.pass.path.clone(),
gpg_backend: self.pass.gpg_backend.clone(),
}),
#[cfg(feature = "tpm")]
"tpm" => Ok(BackendConfig::Tpm {
path: self.tpm.path.clone(),
tcti: self.tpm.tcti.clone(),
portable: self.tpm.portable,
}),
_ => Err(crate::error::Error::Config(format!(
"Invalid backend_type '{}'. Must be one of: local, pass, tpm",
self.backend_type
))),
}
}
pub fn apply_security_hardening(&self) -> Result<(), Box<dyn std::error::Error>> {
self.security.apply_hardening()
}
pub fn security_config(&self) -> SecurityConfig {
self.security.clone()
}
pub fn pin_config(&self) -> PinConfig {
self.pin.clone()
}
pub fn validate(&self) -> crate::error::Result<()> {
self.pin.validate()?;
#[cfg(feature = "agent")]
{
let human_path = self.backend().ok().map(|b| b.state_path());
self.agents.validate(human_path.as_deref())?;
}
Ok(())
}
}
#[derive(Parser)]
#[command(author, version, about)]
pub struct Args {
#[arg(short, long, env = "PASSLESS_CONFIG")]
pub config_path: Option<PathBuf>,
#[command(flatten)]
pub config: <AppConfig as ClapSerde>::Opt,
#[command(subcommand)]
pub command: Option<Commands>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputFormat {
Plain,
Json,
}
impl std::str::FromStr for OutputFormat {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"plain" => Ok(OutputFormat::Plain),
"json" => Ok(OutputFormat::Json),
_ => Err(format!(
"Invalid output format '{}'. Must be 'plain' or 'json'",
s
)),
}
}
}
impl std::fmt::Display for OutputFormat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
OutputFormat::Plain => write!(f, "plain"),
OutputFormat::Json => write!(f, "json"),
}
}
}
#[derive(Subcommand, Debug, Clone)]
pub enum Commands {
Config {
#[command(subcommand)]
action: ConfigAction,
},
Client {
#[arg(short = 'D', long = "device", value_name = "INDEX|NAME", global = true)]
device: Option<String>,
#[arg(
short = 'o',
long = "output",
value_name = "FORMAT",
default_value = "plain",
global = true
)]
output: OutputFormat,
#[command(subcommand)]
action: ClientAction,
},
#[cfg(feature = "agent")]
AgentAdmin {
#[arg(
short = 'o',
long = "output",
value_name = "FORMAT",
default_value = "json",
global = true
)]
output: OutputFormat,
#[command(subcommand)]
action: AgentAdminAction,
},
#[cfg(feature = "agent")]
Agent {
#[arg(long, value_name = "PROFILE", global = true)]
profile: Option<String>,
#[arg(
short = 'o',
long = "output",
value_name = "FORMAT",
default_value = "json",
global = true
)]
output: OutputFormat,
#[command(subcommand)]
action: crate::AgentCommand,
},
#[cfg(feature = "tpm")]
Tpm {
#[command(subcommand)]
action: TpmAction,
},
}
#[cfg(feature = "tpm")]
#[derive(Subcommand, Debug, Clone)]
pub enum TpmAction {
#[command(group(clap::ArgGroup::new("seed-source").args(["generate", "seed_file", "seed_stdin"])))]
Provision {
#[arg(long)]
generate: bool,
#[arg(long = "seed-file", value_name = "PATH")]
seed_file: Option<PathBuf>,
#[arg(long = "seed-stdin")]
seed_stdin: bool,
#[arg(long = "tpm-path", env = "PASSLESS_TPM_PATH")]
path: Option<String>,
#[arg(long = "tpm-tcti", env = "PASSLESS_TPM_TCTI")]
tcti: Option<String>,
},
Status {
#[arg(long = "tpm-path", env = "PASSLESS_TPM_PATH")]
path: Option<String>,
#[arg(long = "tpm-tcti", env = "PASSLESS_TPM_TCTI")]
tcti: Option<String>,
},
Remove {
#[arg(long)]
confirm: bool,
#[arg(long = "tpm-path", env = "PASSLESS_TPM_PATH")]
path: Option<String>,
#[arg(long = "tpm-tcti", env = "PASSLESS_TPM_TCTI")]
tcti: Option<String>,
},
#[command(group(clap::ArgGroup::new("selection").args(["credential_id", "all"]).required(true)))]
Migrate {
#[arg(long = "credential-id", value_name = "ID")]
credential_id: Option<String>,
#[arg(long)]
all: bool,
#[arg(long)]
dry_run: bool,
#[arg(long = "backup-dir", value_name = "PATH")]
backup_dir: Option<String>,
#[arg(long = "tpm-path", env = "PASSLESS_TPM_PATH")]
path: Option<String>,
#[arg(long = "tpm-tcti", env = "PASSLESS_TPM_TCTI")]
tcti: Option<String>,
},
}
#[derive(Subcommand, Debug, Clone)]
pub enum ConfigAction {
Print,
}
#[cfg(feature = "agent")]
#[derive(Subcommand, Debug, Clone)]
pub enum AgentAdminAction {
Install {
#[arg(value_enum, default_value_t = AgentSkillTarget::Auto)]
target: AgentSkillTarget,
#[arg(long, value_enum, default_value_t = AgentSkillScope::User)]
scope: AgentSkillScope,
#[arg(long)]
force: bool,
},
Profile {
#[command(subcommand)]
action: AdminProfileAction,
},
Policy {
#[command(subcommand)]
action: AdminPolicyAction,
},
Credential {
#[command(subcommand)]
action: AdminCredentialAction,
},
Delegation {
#[command(subcommand)]
action: AdminDelegationAction,
},
Session {
#[command(subcommand)]
action: AdminSessionAction,
},
Audit {
#[command(subcommand)]
action: AdminAuditAction,
},
#[command(hide = true)]
Shutdown {
#[arg(long)]
confirm: bool,
},
}
#[cfg(feature = "agent")]
#[derive(Subcommand, Debug, Clone)]
pub enum AdminProfileAction {
Check {
#[arg(value_name = "PROFILE")]
profile: String,
},
Show {
#[arg(value_name = "PROFILE")]
profile: String,
},
List,
Enable {
#[arg(value_name = "PROFILE")]
profile: String,
},
Disable {
#[arg(value_name = "PROFILE")]
profile: String,
},
}
#[cfg(feature = "agent")]
#[derive(Subcommand, Debug, Clone)]
pub enum AdminPolicyAction {
Check {
#[arg(value_name = "PROFILE")]
profile: String,
},
Reload {
#[arg(value_name = "PROFILE")]
profile: String,
},
Show {
#[arg(value_name = "PROFILE")]
profile: String,
},
}
#[cfg(feature = "agent")]
#[derive(Subcommand, Debug, Clone)]
pub enum AdminCredentialAction {
List {
#[arg(short = 'd', long = "domain", value_name = "DOMAIN")]
rp_id: Option<String>,
},
Show {
#[arg(value_name = "CREDENTIAL_REF")]
credential_ref: String,
},
Revoke {
#[arg(value_name = "CREDENTIAL_REF")]
credential_ref: String,
#[arg(long)]
confirm: bool,
},
Delete {
#[arg(value_name = "CREDENTIAL_REF")]
credential_ref: String,
#[arg(long)]
confirm: bool,
},
}
#[cfg(feature = "agent")]
#[derive(Subcommand, Debug, Clone)]
pub enum AdminDelegationAction {
Show {
#[arg(value_name = "GRANT_ID")]
grant_id: String,
},
List {
#[arg(long, value_name = "PROFILE")]
profile: Option<String>,
},
Revoke {
#[arg(value_name = "GRANT_ID")]
grant_id: String,
#[arg(long)]
confirm: bool,
},
}
#[cfg(feature = "agent")]
#[derive(Subcommand, Debug, Clone)]
pub enum AdminSessionAction {
Show {
#[arg(value_name = "SESSION_ID")]
session_id: String,
},
List {
#[arg(long, value_name = "PROFILE")]
profile: Option<String>,
},
Revoke {
#[arg(value_name = "SESSION_ID")]
session_id: String,
#[arg(long)]
confirm: bool,
},
}
#[cfg(feature = "agent")]
#[derive(Subcommand, Debug, Clone)]
pub enum AdminAuditAction {
Status,
Verify,
Export {
#[arg(long, value_enum, default_value_t = AdminAuditExportFormat::Json)]
format: AdminAuditExportFormat,
},
}
#[cfg(feature = "agent")]
#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
pub enum AdminAuditExportFormat {
Json,
Csv,
}
#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgentSkillTarget {
Auto,
Opencode,
Claude,
Pi,
}
#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgentSkillScope {
User,
Project,
}
#[derive(Subcommand, Debug, Clone)]
pub enum ClientAction {
Devices,
Info,
Reset {
#[arg(long = "yes-i-really-want-to-reset-my-device", action = ArgAction::Count)]
confirm: u8,
},
List {
#[arg(short = 'd', long = "domain", value_name = "DOMAIN")]
rp_id: Option<String>,
},
Show {
#[arg(value_name = "CREDENTIAL_ID")]
credential_id: String,
},
Delete {
#[arg(value_name = "CREDENTIAL_ID")]
credential_id: String,
},
Rename {
#[arg(value_name = "CREDENTIAL_ID")]
credential_id: String,
#[arg(short = 'u', long = "user-name", value_name = "NAME")]
user_name: Option<String>,
#[arg(short = 'n', long = "display-name", value_name = "NAME")]
display_name: Option<String>,
},
Backup {
#[arg(value_name = "CREDENTIAL_ID")]
credential_id: String,
#[arg(long, value_name = "RECIPIENT")]
recipient: String,
#[arg(long, value_name = "PATH")]
output_file: PathBuf,
#[arg(long = "yes-i-understand-this-exports-a-passkey")]
confirm: bool,
},
Restore {
#[arg(value_name = "PATH")]
input_file: PathBuf,
#[arg(long)]
replace: bool,
#[arg(long = "yes-i-understand-this-restores-a-passkey")]
confirm: bool,
},
Pin {
#[command(subcommand)]
action: PinAction,
},
}
#[derive(Subcommand, Debug, Clone)]
pub enum PinAction {
Set {
#[arg(value_name = "PIN")]
pin: String,
},
Change {
#[arg(value_name = "OLD_PIN")]
old_pin: String,
#[arg(value_name = "NEW_PIN")]
new_pin: String,
},
UvReset,
}
#[cfg(feature = "agent")]
#[derive(Subcommand, Debug, Clone)]
pub enum AgentCommand {
Doctor,
Capabilities,
Instructions,
Intent {
#[command(subcommand)]
action: AgentIntentAction,
},
Delegation {
#[command(subcommand)]
action: AgentDelegationAction,
},
Credential {
#[command(subcommand)]
action: AgentCredentialAction,
},
BrowserStatus,
EndpointStatus,
BrowserControl {
#[arg(long, value_name = "JSON", conflicts_with = "request_file")]
request: Option<String>,
#[arg(long, value_name = "PATH", conflicts_with = "request")]
request_file: Option<std::path::PathBuf>,
#[arg(long, value_name = "MS", default_value = "5000")]
timeout_ms: u32,
},
Run {
#[arg(long, value_name = "PROFILE")]
profile: String,
#[arg(last = true, required = true)]
command: Vec<std::path::PathBuf>,
},
}
#[cfg(feature = "agent")]
#[derive(Subcommand, Debug, Clone)]
pub enum AgentIntentAction {
Create {
#[arg(value_enum)]
action: AgentIntentActionType,
#[arg(long, value_name = "RP_ID")]
rp: String,
#[arg(long, value_name = "CREDENTIAL_REF")]
credential: Option<String>,
#[arg(long, value_name = "REASON")]
reason: Option<String>,
},
Show {
#[arg(value_name = "REQUEST_ID")]
request_id: String,
},
Wait {
#[arg(value_name = "REQUEST_ID")]
request_id: String,
#[arg(long, value_name = "SECONDS")]
timeout: Option<u64>,
#[arg(long, value_name = "MS")]
poll_interval: Option<u64>,
},
Cancel {
#[arg(value_name = "REQUEST_ID")]
request_id: String,
},
}
#[cfg(feature = "agent")]
#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgentIntentActionType {
Register,
Authenticate,
}
#[cfg(feature = "agent")]
#[derive(Subcommand, Debug, Clone)]
pub enum AgentDelegationAction {
Request {
#[arg(long, value_name = "RP_ID")]
rp: String,
#[arg(long, value_name = "CREDENTIAL_REF")]
credential: String,
#[arg(long, value_name = "SECONDS")]
session_ttl: u64,
#[arg(long, value_name = "REASON")]
reason: Option<String>,
},
Show {
#[arg(value_name = "REQUEST_ID")]
request_id: String,
},
Wait {
#[arg(value_name = "REQUEST_ID")]
request_id: String,
#[arg(long, value_name = "SECONDS")]
timeout: Option<u64>,
#[arg(long, value_name = "MS")]
poll_interval: Option<u64>,
},
Cancel {
#[arg(value_name = "REQUEST_ID")]
request_id: String,
},
}
#[cfg(feature = "agent")]
#[derive(Subcommand, Debug, Clone)]
pub enum AgentCredentialAction {
List,
Show {
#[arg(value_name = "CREDENTIAL_REF")]
credential_ref: String,
},
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "agent")]
#[test]
fn test_agent_admin_install_defaults() {
let args = Args::try_parse_from(["passless", "agent-admin", "install"]).unwrap();
assert!(matches!(
args.command,
Some(Commands::AgentAdmin {
action: AgentAdminAction::Install {
target: AgentSkillTarget::Auto,
scope: AgentSkillScope::User,
force: false,
},
..
})
));
}
#[cfg(feature = "agent")]
#[test]
fn test_agent_admin_install_explicit_options() {
let args = Args::try_parse_from([
"passless",
"agent-admin",
"install",
"claude",
"--scope",
"project",
"--force",
])
.unwrap();
assert!(matches!(
args.command,
Some(Commands::AgentAdmin {
action: AgentAdminAction::Install {
target: AgentSkillTarget::Claude,
scope: AgentSkillScope::Project,
force: true,
},
..
})
));
}
#[test]
fn test_pin_config_default_max_uv_retries() {
let config = PinConfig {
enforcement: PinEnforcement::Optional,
min_length: 4,
max_retries: 8,
max_uv_retries: 8,
auto_lock_timeout: 0,
};
assert_eq!(config.max_uv_retries, 8);
}
#[test]
fn test_pin_config_validate_success() {
let config = PinConfig {
enforcement: PinEnforcement::Optional,
min_length: 4,
max_retries: 8,
max_uv_retries: 8,
auto_lock_timeout: 0,
};
assert!(config.validate().is_ok());
}
#[test]
fn test_pin_config_validate_zero_max_uv_retries() {
let config = PinConfig {
enforcement: PinEnforcement::Optional,
min_length: 4,
max_retries: 8,
max_uv_retries: 0,
auto_lock_timeout: 0,
};
let result = config.validate();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("max_uv_retries"));
}
#[test]
fn test_pin_config_validate_zero_max_retries() {
let config = PinConfig {
enforcement: PinEnforcement::Optional,
min_length: 4,
max_retries: 0,
max_uv_retries: 8,
auto_lock_timeout: 0,
};
let result = config.validate();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("max_retries"));
}
#[test]
fn test_pin_config_validate_invalid_min_length() {
let config = PinConfig {
enforcement: PinEnforcement::Optional,
min_length: 3,
max_retries: 8,
max_uv_retries: 8,
auto_lock_timeout: 0,
};
let result = config.validate();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("min_length"));
}
#[test]
fn test_canonicalize_path_existing() {
let dir = std::env::temp_dir();
let canonical = BackendConfig::canonicalize_path(&dir);
assert!(canonical.is_absolute());
assert!(canonical.exists());
}
#[test]
fn test_canonicalize_path_nonexistent() {
let base = std::env::temp_dir();
let nonexistent = base.join("passless_test_nonexistent_dir_12345/sub");
let canonical = BackendConfig::canonicalize_path(&nonexistent);
assert!(canonical.is_absolute());
assert!(canonical.starts_with(BackendConfig::canonicalize_path(&base)));
}
#[test]
fn test_canonicalize_path_symlink() {
let dir = tempfile::tempdir().unwrap();
let real = dir.path().join("real");
std::fs::create_dir(&real).unwrap();
let link = dir.path().join("link");
std::os::unix::fs::symlink(&real, &link).unwrap();
let canonical_real = BackendConfig::canonicalize_path(&real);
let canonical_link = BackendConfig::canonicalize_path(&link);
assert_eq!(canonical_real, canonical_link);
}
#[test]
fn test_local_state_path_relative_and_absolute() {
let dir = tempfile::tempdir_in(".").unwrap();
let abs_path = std::fs::canonicalize(dir.path()).unwrap();
let rel_path = dir.path().to_path_buf();
let backend_abs = BackendConfig::Local {
path: abs_path.display().to_string(),
};
let backend_rel = BackendConfig::Local {
path: rel_path.display().to_string(),
};
assert_eq!(backend_abs.state_path(), backend_rel.state_path());
}
#[test]
fn test_pass_state_path_different_subpaths() {
let store = "/tmp/passless_test_store";
let backend_a = BackendConfig::Pass {
store_path: store.to_string(),
path: "fido2".to_string(),
gpg_backend: "gnupg-bin".to_string(),
};
let backend_b = BackendConfig::Pass {
store_path: store.to_string(),
path: "fido2-other".to_string(),
gpg_backend: "gnupg-bin".to_string(),
};
assert_ne!(backend_a.state_path(), backend_b.state_path());
}
#[test]
fn test_different_local_paths_produce_different_identities() {
let backend_a = BackendConfig::Local {
path: "/tmp/passless_a".to_string(),
};
let backend_b = BackendConfig::Local {
path: "/tmp/passless_b".to_string(),
};
assert_ne!(backend_a.state_path(), backend_b.state_path());
}
#[cfg(feature = "agent")]
#[test]
fn test_agent_admin_profile_list() {
let args = Args::try_parse_from(["passless", "agent-admin", "profile", "list"]).unwrap();
assert!(matches!(
args.command,
Some(Commands::AgentAdmin {
action: AgentAdminAction::Profile {
action: AdminProfileAction::List,
},
..
})
));
}
#[cfg(feature = "agent")]
#[test]
fn test_agent_admin_credential_delete_without_confirm() {
let args = Args::try_parse_from([
"passless",
"agent-admin",
"credential",
"delete",
"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
])
.unwrap();
assert!(matches!(
args.command,
Some(Commands::AgentAdmin {
action: AgentAdminAction::Credential {
action: AdminCredentialAction::Delete { confirm: false, .. },
},
..
})
));
}
#[cfg(feature = "agent")]
#[test]
fn test_agent_admin_credential_delete_with_confirm() {
let args = Args::try_parse_from([
"passless",
"agent-admin",
"credential",
"delete",
"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
"--confirm",
])
.unwrap();
assert!(matches!(
args.command,
Some(Commands::AgentAdmin {
action: AgentAdminAction::Credential {
action: AdminCredentialAction::Delete { confirm: true, .. },
},
..
})
));
}
#[cfg(feature = "agent")]
#[test]
fn test_agent_admin_shutdown_hidden() {
let args =
Args::try_parse_from(["passless", "agent-admin", "shutdown", "--confirm"]).unwrap();
assert!(matches!(
args.command,
Some(Commands::AgentAdmin {
action: AgentAdminAction::Shutdown { confirm: true },
..
})
));
}
#[cfg(feature = "agent")]
#[test]
fn test_agent_admin_output_default_json() {
let args = Args::try_parse_from(["passless", "agent-admin", "profile", "list"]).unwrap();
match args.command {
Some(Commands::AgentAdmin { output, .. }) => {
assert_eq!(output, OutputFormat::Json);
}
_ => panic!("expected AgentAdmin command"),
}
}
#[cfg(feature = "agent")]
#[test]
fn test_agent_admin_output_plain() {
let args = Args::try_parse_from([
"passless",
"agent-admin",
"--output",
"plain",
"profile",
"list",
])
.unwrap();
match args.command {
Some(Commands::AgentAdmin { output, .. }) => {
assert_eq!(output, OutputFormat::Plain);
}
_ => panic!("expected AgentAdmin command"),
}
}
#[cfg(feature = "agent")]
#[test]
fn test_agent_doctor_parses() {
let args = Args::try_parse_from(["passless", "agent", "doctor"]).unwrap();
assert!(matches!(
args.command,
Some(Commands::Agent {
action: AgentCommand::Doctor,
..
})
));
}
#[cfg(feature = "agent")]
#[test]
fn test_agent_run_with_command() {
let args = Args::try_parse_from([
"passless",
"agent",
"run",
"--profile",
"myprofile",
"--",
"/usr/bin/test",
"arg1",
])
.unwrap();
match args.command {
Some(Commands::Agent {
action: AgentCommand::Run { profile, command },
..
}) => {
assert_eq!(profile, "myprofile");
assert_eq!(command.len(), 2);
}
_ => panic!("expected Agent Run command"),
}
}
#[cfg(feature = "agent")]
#[test]
fn test_agent_intent_create_parses() {
let args = Args::try_parse_from([
"passless",
"agent",
"intent",
"create",
"register",
"--rp",
"example.com",
])
.unwrap();
assert!(matches!(
args.command,
Some(Commands::Agent {
action: AgentCommand::Intent {
action: AgentIntentAction::Create {
action: AgentIntentActionType::Register,
..
},
},
..
})
));
}
#[cfg(feature = "agent")]
#[test]
fn test_agent_output_default_json() {
let args = Args::try_parse_from(["passless", "agent", "doctor"]).unwrap();
match args.command {
Some(Commands::Agent { output, .. }) => {
assert_eq!(output, OutputFormat::Json);
}
_ => panic!("expected Agent command"),
}
}
#[cfg(feature = "agent")]
#[test]
fn test_shell_completions_contain_agent_commands() {
use clap::CommandFactory;
let cmd = Args::command();
let mut buf = Vec::new();
clap_complete::generate(
clap_complete::Shell::Bash,
&mut cmd.clone(),
"passless",
&mut buf,
);
let completion = String::from_utf8(buf).unwrap();
for expected in [
"agent-admin",
"agent",
"install",
"browser-control",
"intent",
"delegation",
"doctor",
"capabilities",
"instructions",
] {
assert!(
completion.contains(expected),
"bash completion missing '{}'",
expected
);
}
}
#[cfg(feature = "agent")]
#[test]
fn test_shell_completions_zsh_contain_agent_commands() {
use clap::CommandFactory;
let cmd = Args::command();
let mut buf = Vec::new();
clap_complete::generate(
clap_complete::Shell::Zsh,
&mut cmd.clone(),
"passless",
&mut buf,
);
let completion = String::from_utf8(buf).unwrap();
for expected in ["agent-admin", "agent", "install", "browser-control"] {
assert!(
completion.contains(expected),
"zsh completion missing '{}'",
expected
);
}
}
#[cfg(feature = "agent")]
#[test]
fn test_config_print_includes_agent_fields() {
let mut default_args = Args::parse_from(["passless"]);
let config = AppConfig::from(&mut default_args.config);
let toml_output = config.to_toml_with_comments();
assert!(
toml_output.contains("backend_type"),
"config print missing backend_type"
);
assert!(
toml_output.contains("[security]"),
"config print missing [security] section"
);
assert!(
toml_output.contains("[pin]"),
"config print missing [pin] section"
);
assert!(
toml_output.contains("always_uv"),
"config print missing always_uv"
);
assert!(
toml_output.contains("notification_timeout"),
"config print missing notification_timeout"
);
}
#[test]
fn test_config_print_contains_passless_header() {
let mut default_args = Args::parse_from(["passless"]);
let config = AppConfig::from(&mut default_args.config);
let toml_output = config.to_toml_with_comments();
assert!(toml_output.contains("Passless Configuration File"));
assert!(toml_output.contains("~/.config/passless/config.toml"));
}
#[test]
fn test_config_print_contains_local_backend_section() {
let mut default_args = Args::parse_from(["passless"]);
let config = AppConfig::from(&mut default_args.config);
let toml_output = config.to_toml_with_comments();
assert!(toml_output.contains("[local]"));
assert!(toml_output.contains("path"));
}
#[test]
fn test_config_print_contains_pass_backend_section() {
let mut default_args = Args::parse_from(["passless"]);
let config = AppConfig::from(&mut default_args.config);
let toml_output = config.to_toml_with_comments();
assert!(toml_output.contains("[pass]"));
assert!(toml_output.contains("store_path"));
assert!(toml_output.contains("gpg_backend"));
}
}