use crate::provider::{Address, Provider, ProviderUrl};
use crate::{Result, SecretSpecError};
use secrecy::SecretString;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::process::{Command, Stdio};
const DCLI_NOT_INSTALLED_HELP: &str = "\
Dashlane CLI (dcli) is not installed.
To install it:
- macOS: brew install dashlane/tap/dashlane-cli
- Linux: download the dcli-linux-x64 binary from
https://github.com/Dashlane/dashlane-cli/releases
After installation, run 'dcli sync' to register this device and log in.";
const AUTH_REQUIRED_HELP: &str = "\
Dashlane authentication required. Run 'dcli sync' to register this device and \
log in, or set DASHLANE_SERVICE_DEVICE_KEYS for a non-interactive device \
registered with 'dcli devices register'.";
const INPUT_REQUIRED_HELP: &str = "\
Dashlane CLI asked for input SecretSpec cannot supply: this device is not \
registered, or the vault is locked. Run 'dcli sync' to register and unlock it, \
or set DASHLANE_SERVICE_DEVICE_KEYS for a non-interactive device registered \
with 'dcli devices register'.";
const LOCKED_HELP: &str = "\
The Dashlane vault is locked. Run any 'dcli' command to unlock it with your \
master password, or re-enable 'dcli configure save-master-password true'.";
const NO_CACHE_DIR_HELP: &str = "\
Cannot locate a cache directory to hold the private Dashlane CLI state that \
DASHLANE_SERVICE_DEVICE_KEYS requires. Set XDG_CACHE_HOME (or HOME) to a \
writable directory, or log in with 'dcli sync' and drop the credential.";
const DEVICE_KEYS_ENV: &str = "DASHLANE_SERVICE_DEVICE_KEYS";
const SERVICE_DEVICE_KEYS: &str = "service_device_keys";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ItemType {
Secret,
Note,
Password,
}
impl ItemType {
fn as_str(self) -> &'static str {
match self {
Self::Secret => "secret",
Self::Note => "note",
Self::Password => "password",
}
}
fn default_field(self) -> &'static str {
match self {
Self::Secret | Self::Note => "content",
Self::Password => "password",
}
}
fn search_order() -> &'static [Self] {
&[Self::Secret, Self::Password, Self::Note]
}
fn parse(value: &str) -> Option<Self> {
match value.to_ascii_lowercase().as_str() {
"secret" | "secrets" => Some(Self::Secret),
"note" | "notes" => Some(Self::Note),
"password" | "passwords" | "login" | "logins" => Some(Self::Password),
_ => None,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DashlaneConfig {
pub item_type: Option<ItemType>,
}
impl TryFrom<&ProviderUrl> for DashlaneConfig {
type Error = SecretSpecError;
fn try_from(url: &ProviderUrl) -> std::result::Result<Self, Self::Error> {
if url.scheme() != "dashlane" {
return Err(SecretSpecError::ProviderOperationFailed(format!(
"Invalid scheme '{}' for dashlane provider",
url.scheme()
)));
}
let item_type = match url.host().filter(|h| !h.is_empty()) {
Some(host) => Some(ItemType::parse(&host).ok_or_else(|| {
SecretSpecError::ProviderOperationFailed(format!(
"unknown Dashlane item type '{host}'. Use dashlane://secret, \
dashlane://note or dashlane://password, or plain dashlane:// \
to search all three."
))
})?),
None => None,
};
let path = url.path();
if !path.is_empty() && path != "/" {
let trimmed = path.trim_start_matches('/');
let hint = crate::config::ref_table_hint(None, trimmed, None, None);
return Err(SecretSpecError::ProviderOperationFailed(format!(
"dashlane:// takes no path: the authority selects the item type, \
not an item. To name one specific item, use {hint} on the secret \
instead"
)));
}
Ok(Self { item_type })
}
}
pub struct DashlaneProvider {
config: DashlaneConfig,
credentials: crate::provider::ProviderCredentials,
}
crate::register_provider! {
struct: DashlaneProvider,
config: DashlaneConfig,
name: "dashlane",
description: "Dashlane password manager, read-only (0.18+)",
schemes: ["dashlane"],
examples: ["dashlane://", "dashlane://note", "dashlane://password"],
credential_names: [SERVICE_DEVICE_KEYS],
preflight: check_auth,
}
#[derive(Debug, Deserialize)]
struct VaultItem {
id: String,
title: Option<String>,
#[serde(flatten)]
fields: HashMap<String, serde_json::Value>,
}
impl VaultItem {
fn bare_id(&self) -> &str {
self.id.trim_start_matches('{').trim_end_matches('}')
}
fn matches(&self, name: &str) -> bool {
let bare = name.trim_start_matches('{').trim_end_matches('}');
if !bare.is_empty() && self.bare_id().eq_ignore_ascii_case(bare) {
return true;
}
self.title
.as_deref()
.is_some_and(|title| title.to_lowercase() == name.to_lowercase())
}
fn field(&self, field: &str) -> Option<String> {
let raw = self.fields.get(field)?;
let value = match raw {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Null => return None,
other => other.to_string(),
};
(!value.is_empty()).then_some(value)
}
}
enum Found {
Value(SecretString),
NoItem,
NoField,
}
fn scoped_state_dir(keys: &str) -> Result<PathBuf> {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
keys.hash(&mut hasher);
let cache = crate::config::cache_dir()
.ok_or_else(|| SecretSpecError::ProviderOperationFailed(NO_CACHE_DIR_HELP.to_string()))?;
Ok(cache
.join("dashlane")
.join(format!("{:016x}", hasher.finish())))
}
#[cfg(unix)]
fn create_private_dir(dir: &std::path::Path) -> Result<()> {
use std::fs::{DirBuilder, Permissions, metadata, set_permissions};
use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
let private = |e: std::io::Error| {
SecretSpecError::ProviderOperationFailed(format!(
"could not prepare a private dcli state directory at {}: {e}",
dir.display()
))
};
DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(dir)
.map_err(private)?;
let mode = metadata(dir).map_err(private)?.permissions().mode();
if mode & 0o077 != 0 {
set_permissions(dir, Permissions::from_mode(0o700)).map_err(private)?;
}
Ok(())
}
#[cfg(not(unix))]
fn create_private_dir(dir: &std::path::Path) -> Result<()> {
std::fs::create_dir_all(dir).map_err(|e| {
SecretSpecError::ProviderOperationFailed(format!(
"could not prepare a private dcli state directory at {}: {e}",
dir.display()
))
})
}
fn is_unknown_command(message: &str) -> bool {
message.contains("unknown command")
}
fn strip_ansi(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let mut chars = input.chars();
while let Some(c) = chars.next() {
if c != '\u{1b}' {
out.push(c);
continue;
}
for c in chars.by_ref() {
if c.is_ascii_alphabetic() {
break;
}
}
}
out
}
impl DashlaneProvider {
pub fn new(config: DashlaneConfig) -> Self {
Self {
config,
credentials: Default::default(),
}
}
fn run(&self, args: &[&str]) -> Result<Vec<u8>> {
let mut cmd = Command::new("dcli");
if let Some(keys) = self.device_keys() {
let dir = scoped_state_dir(&keys)?;
create_private_dir(&dir)?;
cmd.env("HOME", &dir);
cmd.env("APPDATA", &dir);
cmd.env(DEVICE_KEYS_ENV, keys);
}
cmd.args(args);
cmd.stdin(Stdio::null());
let output = match cmd.output() {
Ok(output) => output,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(SecretSpecError::ProviderOperationFailed(
DCLI_NOT_INSTALLED_HELP.to_string(),
));
}
Err(e) => return Err(e.into()),
};
if !output.status.success() {
let stderr = strip_ansi(&String::from_utf8_lossy(&output.stderr));
let stderr = stderr.trim();
if stderr.contains("force closed the prompt") {
return Err(SecretSpecError::ProviderOperationFailed(
INPUT_REQUIRED_HELP.to_string(),
));
}
return Err(SecretSpecError::ProviderOperationFailed(format!(
"dcli {} failed: {stderr}",
args.join(" ")
)));
}
Ok(output.stdout)
}
fn list(&self, item_type: ItemType) -> Result<Vec<VaultItem>> {
let stdout = match self.run(&[item_type.as_str(), "-o", "json"]) {
Ok(stdout) => stdout,
Err(SecretSpecError::ProviderOperationFailed(message))
if is_unknown_command(&message) =>
{
return Ok(Vec::new());
}
Err(e) => return Err(e),
};
serde_json::from_slice(&stdout).map_err(|e| {
SecretSpecError::ProviderOperationFailed(format!(
"could not parse the output of 'dcli {} -o json' (line {}, column {})",
item_type.as_str(),
e.line(),
e.column()
))
})
}
fn find_unique<'a>(
items: &'a [VaultItem],
name: &str,
item_type: ItemType,
) -> Result<Option<&'a VaultItem>> {
let mut matches = items.iter().filter(|item| item.matches(name));
let Some(first) = matches.next() else {
return Ok(None);
};
let extra = matches.count();
if extra > 0 {
return Err(SecretSpecError::ProviderOperationFailed(format!(
"{} Dashlane {} items are titled '{name}'. Rename them, or point \
the secret at one identifier with ref = {{ item = \"{}\" }}.",
extra + 1,
item_type.as_str(),
first.bare_id(),
)));
}
Ok(Some(first))
}
fn types_to_search(&self) -> &'static [ItemType] {
match self.config.item_type {
Some(ItemType::Secret) => &[ItemType::Secret],
Some(ItemType::Note) => &[ItemType::Note],
Some(ItemType::Password) => &[ItemType::Password],
None => ItemType::search_order(),
}
}
fn device_keys(&self) -> Option<String> {
crate::provider::credential_or_env(&self.credentials, SERVICE_DEVICE_KEYS, DEVICE_KEYS_ENV)
}
pub(crate) fn check_auth(&self) -> Result<()> {
if self.device_keys().is_some() {
return Ok(());
}
let stdout = self.run(&["status"])?;
let status = String::from_utf8_lossy(&stdout);
let mut logged_in = false;
let mut locked = false;
for line in status.lines() {
match line.split_once(':').map(|(k, v)| (k.trim(), v.trim())) {
Some(("Logged in", value)) => logged_in = value == "yes",
Some(("Locked", value)) => locked = value == "yes",
_ => {}
}
}
if !logged_in {
return Err(SecretSpecError::ProviderOperationFailed(
AUTH_REQUIRED_HELP.to_string(),
));
}
if locked {
return Err(SecretSpecError::ProviderOperationFailed(
LOCKED_HELP.to_string(),
));
}
Ok(())
}
fn lookup(
&self,
items: &[VaultItem],
item_type: ItemType,
name: &str,
field: Option<&str>,
) -> Result<Found> {
let Some(item) = Self::find_unique(items, name, item_type)? else {
return Ok(Found::NoItem);
};
match item.field(field.unwrap_or_else(|| item_type.default_field())) {
Some(value) => Ok(Found::Value(SecretString::new(value.into()))),
None if field.is_some() => Ok(Found::NoField),
None => Ok(Found::NoItem),
}
}
fn missing_field(name: &str, field: &str) -> SecretSpecError {
SecretSpecError::ProviderOperationFailed(format!(
"no Dashlane item named '{name}' has a '{field}' field"
))
}
}
impl Provider for DashlaneProvider {
fn convention_address(
&self,
project: &str,
profile: &str,
key: &str,
) -> Result<crate::config::NativeAddress> {
Ok(crate::config::NativeAddress {
item: format!("secretspec/{project}/{profile}/{key}"),
..Default::default()
})
}
fn name(&self) -> &'static str {
Self::PROVIDER_NAME
}
fn supported_coords(&self) -> &'static [&'static str] {
&["field"]
}
fn with_credentials(&mut self, credentials: crate::provider::ProviderCredentials) {
self.credentials = credentials;
}
fn auth_scope_key(&self) -> Option<String> {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
self.device_keys().hash(&mut hasher);
Some(format!("{:x}", hasher.finish()))
}
fn uri(&self) -> String {
match self.config.item_type {
Some(item_type) => format!("dashlane://{}", item_type.as_str()),
None => "dashlane".to_string(),
}
}
fn get(&self, addr: Address<'_>) -> Result<Option<SecretString>> {
let coords = self.resolve_coords(addr)?;
let mut lacked_field = false;
for &item_type in self.types_to_search() {
let items = self.list(item_type)?;
match self.lookup(&items, item_type, &coords.item, coords.field.as_deref())? {
Found::Value(value) => return Ok(Some(value)),
Found::NoField => lacked_field = true,
Found::NoItem => {}
}
}
match (lacked_field, coords.field.as_deref()) {
(true, Some(field)) => Err(Self::missing_field(&coords.item, field)),
_ => Ok(None),
}
}
fn get_many(&self, requests: &[(&str, Address<'_>)]) -> Result<HashMap<String, SecretString>> {
let mut resolved = Vec::with_capacity(requests.len());
for (name, addr) in requests {
resolved.push((*name, self.resolve_coords(*addr)?));
}
let mut found = HashMap::new();
let mut lacked_field: Vec<&str> = Vec::new();
for &item_type in self.types_to_search() {
if resolved.len() == found.len() {
break;
}
let items = self.list(item_type)?;
for (name, coords) in &resolved {
if found.contains_key(*name) {
continue;
}
match self.lookup(&items, item_type, &coords.item, coords.field.as_deref())? {
Found::Value(value) => {
found.insert((*name).to_string(), value);
}
Found::NoField => lacked_field.push(name),
Found::NoItem => {}
}
}
}
for (name, coords) in &resolved {
if !found.contains_key(*name) && lacked_field.contains(name) {
let field = coords.field.as_deref().unwrap_or_default();
return Err(Self::missing_field(&coords.item, field));
}
}
Ok(found)
}
fn set(&self, addr: Address<'_>, _value: &SecretString) -> Result<()> {
self.check_writable(addr)
}
fn check_writable(&self, _addr: Address<'_>) -> Result<()> {
Err(SecretSpecError::ProviderOperationFailed(
"The Dashlane provider is read-only: dcli cannot create or edit vault \
items. Add the item in a Dashlane app, run 'dcli sync', then read it \
here."
.to_string(),
))
}
}
impl Default for DashlaneProvider {
fn default() -> Self {
Self::new(DashlaneConfig::default())
}
}
#[cfg(test)]
mod tests {
use super::*;
use url::Url;
fn config(spec: &str) -> Result<DashlaneConfig> {
DashlaneConfig::try_from(&ProviderUrl::new(Url::parse(spec).unwrap()))
}
fn item(id: &str, title: &str, field: &str, value: &str) -> VaultItem {
VaultItem {
id: id.to_string(),
title: Some(title.to_string()),
fields: HashMap::from([(
field.to_string(),
serde_json::Value::String(value.to_string()),
)]),
}
}
#[test]
fn plain_uri_searches_every_type() {
let provider = DashlaneProvider::new(config("dashlane://").unwrap());
assert_eq!(provider.types_to_search(), ItemType::search_order());
assert_eq!(provider.uri(), "dashlane");
}
#[test]
fn authority_pins_the_item_type() {
let provider = DashlaneProvider::new(config("dashlane://note").unwrap());
assert_eq!(provider.types_to_search(), &[ItemType::Note]);
assert_eq!(provider.uri(), "dashlane://note");
}
#[test]
fn login_is_an_alias_for_password() {
let provider = DashlaneProvider::new(config("dashlane://login").unwrap());
assert_eq!(provider.types_to_search(), &[ItemType::Password]);
assert_eq!(provider.uri(), "dashlane://password");
}
#[test]
fn unknown_item_type_is_rejected() {
let err = config("dashlane://passkey").unwrap_err();
assert!(
err.to_string().contains("unknown Dashlane item type"),
"{err}"
);
}
#[test]
fn path_is_rejected_with_ref_hint() {
let err = config("dashlane://note/my-item").unwrap_err();
assert!(
err.to_string().contains("ref = { item = \"my-item\" }"),
"{err}"
);
}
#[test]
fn foreign_scheme_is_rejected() {
let err = config("keyring://").unwrap_err();
assert!(err.to_string().contains("Invalid scheme"), "{err}");
}
#[test]
fn convention_items_use_the_shared_layout() {
let provider = DashlaneProvider::default();
let addr = provider
.convention_address("myproject", "production", "API_KEY")
.unwrap();
assert_eq!(addr.item, "secretspec/myproject/production/API_KEY");
}
#[test]
fn native_address_names_the_item() {
let provider = DashlaneProvider::default();
let addr = crate::config::NativeAddress {
item: "GitHub token".into(),
..Default::default()
};
let coords = provider.resolve_coords(Address::Native(&addr)).unwrap();
assert_eq!(coords.item, "GitHub token");
}
#[test]
fn native_address_accepts_field() {
let provider = DashlaneProvider::default();
let addr = crate::config::NativeAddress {
item: "GitHub".into(),
field: Some("login".into()),
..Default::default()
};
let coords = provider.resolve_coords(Address::Native(&addr)).unwrap();
assert_eq!(coords.field.as_deref(), Some("login"));
}
#[test]
fn native_address_rejects_vault() {
let provider = DashlaneProvider::default();
let addr = crate::config::NativeAddress {
item: "GitHub".into(),
vault: Some("Private".into()),
..Default::default()
};
let err = provider.resolve_coords(Address::Native(&addr)).unwrap_err();
assert!(err.to_string().contains("`vault`"), "{err}");
}
#[test]
fn titles_match_case_insensitively() {
let items = vec![item("{ABC}", "My Token", "content", "v")];
assert!(
DashlaneProvider::find_unique(&items, "my token", ItemType::Secret)
.unwrap()
.is_some()
);
}
#[test]
fn identifiers_match_braced_or_bare() {
let items = vec![item(
"{D47734C4-0ABE-423A-8633-6B9F10A38905}",
"My Token",
"content",
"v",
)];
for name in [
"D47734C4-0ABE-423A-8633-6B9F10A38905",
"{D47734C4-0ABE-423A-8633-6B9F10A38905}",
] {
assert!(
DashlaneProvider::find_unique(&items, name, ItemType::Secret)
.unwrap()
.is_some(),
"{name}"
);
}
}
#[test]
fn partial_names_do_not_match() {
let items = vec![item("{ABC}", "production-token", "content", "v")];
assert!(
DashlaneProvider::find_unique(&items, "production", ItemType::Secret)
.unwrap()
.is_none()
);
}
#[test]
fn duplicate_titles_are_refused() {
let items = vec![
item("{ONE}", "shared", "content", "a"),
item("{TWO}", "shared", "content", "b"),
];
let err = DashlaneProvider::find_unique(&items, "shared", ItemType::Note).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("2 Dashlane note items"), "{msg}");
assert!(msg.contains("ONE"), "{msg}");
}
#[test]
fn fields_are_read_as_strings() {
let json = r#"[{"id":"{A}","title":"t","content":"v","numberUse":"7"}]"#;
let items: Vec<VaultItem> = serde_json::from_str(json).unwrap();
assert_eq!(items[0].field("content").as_deref(), Some("v"));
assert_eq!(items[0].field("numberUse").as_deref(), Some("7"));
assert_eq!(items[0].field("missing"), None);
}
#[test]
fn untitled_items_are_tolerated() {
let json = r#"[{"id":"{A}","password":"v"}]"#;
let items: Vec<VaultItem> = serde_json::from_str(json).unwrap();
assert!(
DashlaneProvider::find_unique(&items, "anything", ItemType::Password)
.unwrap()
.is_none()
);
assert!(
DashlaneProvider::find_unique(&items, "A", ItemType::Password)
.unwrap()
.is_some()
);
}
#[test]
fn a_missing_referenced_field_is_distinguished() {
let provider = DashlaneProvider::default();
let items = vec![item("{A}", "GitHub", "password", "v")];
assert!(matches!(
provider
.lookup(&items, ItemType::Password, "GitHub", Some("otpSecret"))
.unwrap(),
Found::NoField
));
assert!(
DashlaneProvider::missing_field("GitHub", "otpSecret")
.to_string()
.contains("has a 'otpSecret' field")
);
}
#[test]
fn an_earlier_type_lacking_the_field_does_not_end_the_search() {
let provider = DashlaneProvider::default();
let notes = vec![item("{N}", "Production database", "content", "notes")];
let logins = vec![item("{L}", "Production database", "login", "app_user")];
assert!(matches!(
provider
.lookup(¬es, ItemType::Note, "Production database", Some("login"))
.unwrap(),
Found::NoField
));
let found = provider
.lookup(
&logins,
ItemType::Password,
"Production database",
Some("login"),
)
.unwrap();
match found {
Found::Value(value) => {
use secrecy::ExposeSecret;
assert_eq!(value.expose_secret(), "app_user");
}
_ => panic!("the login carries the field and must resolve"),
}
}
#[test]
fn an_empty_default_field_reads_as_unset() {
let provider = DashlaneProvider::default();
let items = vec![item("{A}", "GitHub", "content", "")];
assert!(matches!(
provider
.lookup(&items, ItemType::Note, "GitHub", None)
.unwrap(),
Found::NoItem
));
}
#[test]
fn logins_outrank_notes_in_the_search_order() {
let order = ItemType::search_order();
let pos = |t: ItemType| order.iter().position(|&o| o == t).unwrap();
assert!(pos(ItemType::Secret) < pos(ItemType::Password));
assert!(pos(ItemType::Password) < pos(ItemType::Note));
}
#[test]
fn titles_fold_beyond_ascii() {
let items = vec![item("{A}", "Überblick", "content", "v")];
assert!(
DashlaneProvider::find_unique(&items, "überblick", ItemType::Note)
.unwrap()
.is_some()
);
}
#[test]
fn the_scoped_state_dir_never_contains_the_keys() {
let keys = "dls_ACCESS_SECRETPAYLOAD";
let dir = scoped_state_dir(keys).expect("a cache dir should resolve");
let shown = dir.display().to_string();
assert!(!shown.contains("SECRETPAYLOAD"), "{shown}");
assert!(shown.contains("dashlane"), "{shown}");
assert_eq!(dir, scoped_state_dir(keys).unwrap());
assert_ne!(dir, scoped_state_dir("dls_OTHER_IDENTITY").unwrap());
}
#[cfg(unix)]
#[test]
fn the_scoped_state_dir_is_owner_only() {
use std::os::unix::fs::PermissionsExt;
let root = tempfile::tempdir().unwrap();
let dir = root.path().join("dashlane").join("0123456789abcdef");
let mode =
|path: &std::path::Path| std::fs::metadata(path).unwrap().permissions().mode() & 0o777;
create_private_dir(&dir).unwrap();
assert_eq!(mode(&dir), 0o700, "a newly created state directory");
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();
create_private_dir(&dir).unwrap();
assert_eq!(mode(&dir), 0o700, "a state directory that already existed");
}
#[test]
fn each_type_reads_its_own_default_field() {
assert_eq!(ItemType::Secret.default_field(), "content");
assert_eq!(ItemType::Note.default_field(), "content");
assert_eq!(ItemType::Password.default_field(), "password");
}
#[test]
fn an_unregistered_cli_is_reported_as_needing_setup() {
let stderr = "\u{1b}[31merror: User force closed the prompt with 0 null\u{1b}[0m";
assert!(strip_ansi(stderr).contains("force closed the prompt"));
}
#[test]
fn an_unknown_lister_is_not_a_failure() {
assert!(is_unknown_command(&strip_ansi(
"\u{1b}[31merror: unknown command 'secret'\u{1b}[0m"
)));
assert!(!is_unknown_command("error: User force closed the prompt"));
}
#[test]
fn ansi_escapes_are_stripped() {
assert_eq!(
strip_ansi("\u{1b}[31merror: No matching item found\u{1b}[0m"),
"error: No matching item found"
);
}
#[test]
fn writes_are_refused_with_the_reason() {
let provider = DashlaneProvider::default();
let addr = Address::convention("p", "default", "K");
let err = provider.check_writable(addr).unwrap_err();
assert!(err.to_string().contains("read-only"), "{err}");
assert!(provider.set(addr, &SecretString::new("v".into())).is_err());
}
}
#[cfg(test)]
mod live {
use super::*;
const ABSENT: &str = "secretspec-live-test-item-that-does-not-exist";
#[test]
#[ignore = "needs an authenticated dcli and a real vault"]
fn preflight_accepts_a_registered_unlocked_cli() {
let provider = DashlaneProvider::default();
if provider.device_keys().is_some() {
println!(
"{DEVICE_KEYS_ENV} is set, so the status probe is skipped; \
unset it to exercise the `dcli status` parser"
);
return;
}
provider.check_auth().unwrap_or_else(|e| {
panic!("`dcli status` did not read as registered and unlocked: {e}")
});
}
#[test]
#[ignore = "needs an authenticated dcli and a real vault"]
fn every_lister_parses_the_live_vault() {
let provider = DashlaneProvider::default();
for &item_type in ItemType::search_order() {
let items = provider.list(item_type).unwrap_or_else(|e| {
panic!("`dcli {} -o json` did not parse: {e}", item_type.as_str())
});
println!(
"{}: {} items ({} titled, {} with a default field)",
item_type.as_str(),
items.len(),
items.iter().filter(|i| i.title.is_some()).count(),
items
.iter()
.filter(|i| i.field(item_type.default_field()).is_some())
.count(),
);
for item in &items {
assert!(!item.id.is_empty(), "every live item carries an id");
assert!(
!item.bare_id().contains(['{', '}']),
"bare_id should strip the braces dcli wraps an id in"
);
}
}
}
#[test]
#[ignore = "needs an authenticated dcli and a real vault"]
fn an_absent_item_reads_as_unset() {
let provider = DashlaneProvider::default();
let addr = crate::config::NativeAddress {
item: ABSENT.into(),
..Default::default()
};
match provider.get(Address::Native(&addr)) {
Ok(None) => {}
Ok(Some(_)) => panic!("no vault should hold an item named {ABSENT}"),
Err(e) => panic!("an absent item must read as unset, not as an error: {e}"),
}
}
#[test]
#[ignore = "needs an authenticated dcli and a real vault"]
fn reads_an_item_named_by_the_operator() {
let Ok(item) = std::env::var("SECRETSPEC_DASHLANE_TEST_ITEM") else {
println!("SECRETSPEC_DASHLANE_TEST_ITEM is unset; skipping");
return;
};
let field = std::env::var("SECRETSPEC_DASHLANE_TEST_FIELD").ok();
let config = DashlaneConfig {
item_type: std::env::var("SECRETSPEC_DASHLANE_TEST_TYPE")
.ok()
.map(|t| {
ItemType::parse(&t)
.expect("SECRETSPEC_DASHLANE_TEST_TYPE is not a Dashlane item type")
}),
};
let addr = crate::config::NativeAddress {
item: item.clone(),
field: field.clone(),
..Default::default()
};
let value = DashlaneProvider::new(config)
.get(Address::Native(&addr))
.unwrap_or_else(|e| panic!("reading the named item failed: {e}"))
.unwrap_or_else(|| {
panic!(
"the named item resolved to nothing. Check the title matches \
exactly, that its content type is being searched, and that \
`dcli sync` has run."
)
});
use secrecy::ExposeSecret;
let len = value.expose_secret().len();
assert!(len > 0, "the named item resolved to an empty value");
println!(
"read the named item{}: {len} bytes",
field.map(|f| format!(" (field '{f}')")).unwrap_or_default(),
);
assert!(
!format!("{value:?}").contains(value.expose_secret()),
"the secret leaked through Debug"
);
}
}