//! Direct, additive KeePass KDBX imports into a pass-compatible repository. use std::{ collections::BTreeSet, error::Error, fmt, fs, path::{Path, PathBuf}, }; use keepass::{ Database, DatabaseKey, db::{Entry, EntryRef, GroupRef, Icon, fields}, }; use crate::{ command::{EditRequest, InsertInput, InsertRequest}, crypto::{KeyStore, SecretProvider}, git::{AutomaticEntryCommitter, GitError, GitIdentity}, repository::{EntryPath, Repository, RepositoryError, SecretBytes}, write::{InsertContent, OverwriteDecision, VaultWriter, WriteError}, }; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum KdbxImportMode { AddAndUpdate, QuickAdd, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct KdbxImportRequest { source: PathBuf, key_file: Option, mode: KdbxImportMode, } impl KdbxImportRequest { pub fn new( source: impl Into, key_file: Option, mode: KdbxImportMode, ) -> Self { Self { source: source.into(), key_file, mode, } } pub fn source(&self) -> &Path { &self.source } pub fn key_file(&self) -> Option<&Path> { self.key_file.as_deref() } pub fn mode(&self) -> KdbxImportMode { self.mode } } #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct KdbxImportOutcome { added: usize, updated: usize, unchanged: usize, skipped: usize, changed_paths: Vec, } impl KdbxImportOutcome { pub fn added(&self) -> usize { self.added } pub fn updated(&self) -> usize { self.updated } pub fn unchanged(&self) -> usize { self.unchanged } pub fn skipped(&self) -> usize { self.skipped } pub fn changed_paths(&self) -> &[EntryPath] { &self.changed_paths } } pub struct KdbxImporter<'a> { repository: &'a Repository, keys: &'a KeyStore, } impl<'a> KdbxImporter<'a> { pub fn new(repository: &'a Repository, keys: &'a KeyStore) -> Self { Self { repository, keys } } pub fn import( &self, request: &KdbxImportRequest, password: SecretBytes, secrets: &mut impl SecretProvider, identity: GitIdentity, ) -> Result { let mut source = fs::File::open(request.source()).map_err(|source| KdbxImportError::Io { operation: "open KDBX database", path: request.source.clone(), source, })?; let password = std::str::from_utf8(password.expose()) .map_err(|_| KdbxImportError::PasswordEncoding)?; let mut key = DatabaseKey::new().with_password(password); if let Some(path) = request.key_file() { let mut file = fs::File::open(path).map_err(|source| KdbxImportError::Io { operation: "open KDBX key file", path: path.to_owned(), source, })?; key = key .with_keyfile(&mut file) .map_err(|source| KdbxImportError::Io { operation: "read KDBX key file", path: path.to_owned(), source, })?; } let database = Database::open(&mut source, key).map_err(KdbxImportError::Open)?; let items = collect_items(&database)?; let writer = VaultWriter::new(self.repository, self.keys); let mut outcome = KdbxImportOutcome::default(); for item in items { let path = item.path.to_string(); let exists = writer.entry_exists(&path)?; if exists && request.mode() == KdbxImportMode::QuickAdd { outcome.skipped += 1; continue; } let mut committer = AutomaticEntryCommitter::for_entry(self.repository, &path, identity.clone())?; if exists { let session = writer.begin_edit(&EditRequest { entry: path }, secrets)?; if session.plaintext().expose() == item.contents.expose() { outcome.unchanged += 1; continue; } writer.finish_edit( session, item.contents, "IronStorage KDBX importer", None, &mut committer, )?; outcome.updated += 1; } else { writer.insert( &InsertRequest { entry: path, input: InsertInput::Multiline, force: false, }, InsertContent::multiline(item.contents.expose().to_vec()), OverwriteDecision::Decline, None, &mut committer, )?; outcome.added += 1; } outcome.changed_paths.push(item.path); } Ok(outcome) } } struct ImportItem { path: EntryPath, contents: SecretBytes, } fn collect_items(database: &Database) -> Result, KdbxImportError> { let mut items = Vec::new(); let mut used = BTreeSet::new(); let recycle_bin = database.recycle_bin().map(|group| group.id()); collect_group(database.root(), &[], recycle_bin, &mut used, &mut items)?; Ok(items) } fn collect_group( group: GroupRef<'_>, components: &[String], recycle_bin: Option, used: &mut BTreeSet, items: &mut Vec, ) -> Result<(), KdbxImportError> { for entry in group.entries() { collect_entry(entry, components, used, items)?; } for child in group.groups() { if Some(child.id()) == recycle_bin { continue; } let mut child_components = components.to_vec(); let name = sanitize_component(&child.name); if !name.is_empty() { child_components.push(name); } collect_group(child, &child_components, recycle_bin, used, items)?; } Ok(()) } fn collect_entry( entry: EntryRef<'_>, components: &[String], used: &mut BTreeSet, items: &mut Vec, ) -> Result<(), KdbxImportError> { let title = entry_title(&entry); let attachments = entry .attachments_named() .map(|(name, attachment)| (name.to_owned(), attachment.data.get().to_vec())) .collect::>(); let attachment_names = attachments .iter() .map(|(name, _)| name.as_str()) .collect::>(); items.push(ImportItem { path: reserve_path(components, &title, used)?, contents: render_entry(&entry, &attachment_names), }); for (name, data) in attachments { items.push(ImportItem { path: reserve_path(components, &sanitize_component(&name), used)?, contents: SecretBytes::new(data), }); } let history_len = entry .history .as_ref() .map_or(0, |history| history.get_entries().len()); if history_len != 0 { let mut history_components = vec!["History".to_owned()]; history_components.extend_from_slice(components); for index in 0..history_len { if let Some(historical) = entry.historical(index) { items.push(ImportItem { path: reserve_path(&history_components, &entry_title(&historical), used)?, contents: render_entry(&historical, &[]), }); } } } Ok(()) } fn reserve_path( components: &[String], title: &str, used: &mut BTreeSet, ) -> Result { let mut path = components.iter().collect::(); let title = if title.is_empty() { "notitle" } else { title }; path.push(title); let original = path.clone(); let mut suffix = 1_u64; while !used.insert(path.clone()) { path = original.clone(); path.set_file_name(format!("{title}-{suffix}")); suffix = suffix.saturating_add(1); } EntryPath::parse(path) } fn entry_title(entry: &Entry) -> String { let title = entry.get_title().filter(|title| !title.trim().is_empty()); let candidate = title .or_else(|| entry.get_url().filter(|url| !url.trim().is_empty())) .or_else(|| { entry .get_username() .filter(|login| !login.trim().is_empty()) }) .unwrap_or("notitle"); let candidate = if title.is_none() { url::Url::parse(candidate) .ok() .and_then(|url| url.host_str().map(str::to_owned)) .unwrap_or_else(|| candidate.to_owned()) } else { candidate.to_owned() }; sanitize_component(&candidate) } fn sanitize_component(value: &str) -> String { let cleaned = value .chars() .map(|character| { if character.is_control() || "<>:\"/\\|?*".contains(character) { '-' } else { character } }) .collect::(); match cleaned.trim() { "." | ".." => "-".to_owned(), cleaned => cleaned.to_owned(), } } fn render_entry(entry: &Entry, attachment_names: &[&str]) -> SecretBytes { let mut output = Vec::new(); let password = normalize_newlines(entry.get_password().unwrap_or_default()); let first_line = password .split_once('\n') .map_or(password.as_str(), |(first, _)| first); output.extend_from_slice(first_line.as_bytes()); output.push(b'\n'); if password.contains('\n') { append_field(&mut output, "password_multiline", &password); } append_field( &mut output, "login", entry.get_username().unwrap_or_default(), ); append_field(&mut output, "url", entry.get_url().unwrap_or_default()); let otp = otp_uri(entry); if let Some(otp) = &otp { output.extend_from_slice(otp.as_bytes()); output.push(b'\n'); } append_field( &mut output, "comments", entry.get(fields::NOTES).unwrap_or_default(), ); if !attachment_names.is_empty() { append_field(&mut output, "attachments", &attachment_names.join(", ")); } if !entry.tags.is_empty() { append_field(&mut output, "tags", &entry.tags.join(", ")); } if let Some(autotype) = &entry.autotype { append_field( &mut output, "autotype_enabled", if autotype.enabled { "true" } else { "false" }, ); append_field( &mut output, "autotype_sequence", autotype.default_sequence.as_deref().unwrap_or_default(), ); if !autotype.associations.is_empty() { let associations = autotype .associations .iter() .map(|association| format!("{} => {}", association.window, association.sequence)) .collect::>() .join("\n"); append_field(&mut output, "autotype_associations", &associations); } } if let Some(icon) = entry.icon() { let icon = match icon { Icon::BuiltIn(index) => index.to_string(), Icon::Custom(id) => id.to_string(), }; append_field(&mut output, "icon", &icon); } let mut custom = entry .fields .iter() .filter(|(name, value)| { !fields::KNOWN_FIELDS.contains(&name.as_str()) && !(name.eq_ignore_ascii_case(fields::OTP) && otp.is_some()) && !value.get().is_empty() }) .map(|(name, value)| (field_name(name), value.get().as_str())) .collect::>(); custom.sort_by_cached_key(|(name, _)| name.to_lowercase()); let mut names = BTreeSet::from([ "attachments".to_owned(), "autotype_associations".to_owned(), "autotype_enabled".to_owned(), "autotype_sequence".to_owned(), "comments".to_owned(), "icon".to_owned(), "login".to_owned(), "password_multiline".to_owned(), "tags".to_owned(), "url".to_owned(), ]); for (mut name, value) in custom { let original = name.clone(); let mut suffix = 2_u64; while !names.insert(name.to_lowercase()) { name = format!("{original}_{suffix}"); suffix = suffix.saturating_add(1); } append_field(&mut output, &name, value); } SecretBytes::new(output) } fn field_name(value: &str) -> String { let name = value .chars() .map(|character| { if character == ':' || character.is_whitespace() || character.is_control() { '_' } else { character } }) .collect::(); if name.is_empty() { "field".to_owned() } else { name } } fn append_field(output: &mut Vec, name: &str, value: &str) { if value.is_empty() { return; } output.extend_from_slice(name.as_bytes()); output.extend_from_slice(b": "); let value = normalize_newlines(value); for byte in value.bytes() { output.push(byte); if byte == b'\n' { output.push(b' '); } } output.push(b'\n'); } fn normalize_newlines(value: &str) -> String { value.replace("\r\n", "\n").replace('\r', "\n") } fn otp_uri(entry: &Entry) -> Option { let raw = entry .get(fields::OTP) .filter(|value| !value.trim().is_empty()); if let Some(raw) = raw && raw.starts_with("otpauth://") && crate::otp::OtpUri::parse_str(raw).is_ok() { return Some(raw.to_owned()); } let secret = raw .filter(|value| !value.starts_with("otpauth://")) .or_else(|| entry.get("TimeOtp-Secret-Base32")) .or_else(|| entry.get("TOTP Seed"))?; let secret = secret .chars() .filter(|character| !character.is_whitespace()) .collect::(); let title = entry .get_title() .filter(|title| !title.is_empty()) .unwrap_or("Imported"); let mut uri = url::Url::parse("otpauth://totp/Imported").expect("static OTP URI is valid"); uri.set_path(title); { let mut query = uri.query_pairs_mut(); query.append_pair("secret", &secret); query.append_pair("issuer", "Imported"); query.append_pair( "digits", entry .get("TimeOtp-Length") .or_else(|| (entry.get("TOTP Settings") == Some("30;S")).then_some("6")) .unwrap_or("6"), ); query.append_pair("period", entry.get("TimeOtp-Period").unwrap_or("30")); if let Some(algorithm) = entry.get("TimeOtp-Algorithm") { let algorithm = algorithm.replace("HMAC-", "").replace('-', ""); query.append_pair("algorithm", &algorithm); } } let uri = uri.to_string(); crate::otp::OtpUri::parse_str(&uri).ok().map(|_| uri) } #[derive(Debug)] pub enum KdbxImportError { Io { operation: &'static str, path: PathBuf, source: std::io::Error, }, PasswordEncoding, Open(keepass::db::DatabaseOpenError), Repository(RepositoryError), Write(WriteError), Git(GitError), } impl fmt::Display for KdbxImportError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Io { operation, path, .. } => write!(formatter, "cannot {operation}: {}", path.display()), Self::PasswordEncoding => formatter.write_str("KDBX password is not valid UTF-8"), Self::Open(_) => formatter.write_str("cannot decrypt or parse the KDBX database"), Self::Repository(error) => error.fmt(formatter), Self::Write(error) => error.fmt(formatter), Self::Git(error) => error.fmt(formatter), } } } impl Error for KdbxImportError { fn source(&self) -> Option<&(dyn Error + 'static)> { match self { Self::Io { source, .. } => Some(source), Self::Open(error) => Some(error), Self::Repository(error) => Some(error), Self::Write(error) => Some(error), Self::Git(error) => Some(error), Self::PasswordEncoding => None, } } } impl From for KdbxImportError { fn from(error: RepositoryError) -> Self { Self::Repository(error) } } impl From for KdbxImportError { fn from(error: WriteError) -> Self { Self::Write(error) } } impl From for KdbxImportError { fn from(error: GitError) -> Self { Self::Git(error) } }