From 77005469ff4b8179e6bca6e64b83eb020d801f1b Mon Sep 17 00:00:00 2001 From: Cristhian Melo Date: Fri, 19 Jun 2026 19:02:21 -0500 Subject: [PATCH 1/3] feat(import): add Bitwarden JSON import with extensible importer trait MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement command that imports Bitwarden JSON exports (individual and organization vaults). Key aspects: - Trait-based Importer + ImportEntry model (OCP/SRP) - Bitwarden JSON parser handles login/note/card/identity types - Folder/collection hierarchy preserved as path prefixes - TOTP normalization (raw key → otpauth:// URI) - Name sanitization (replaces INVALIDS, space→-, &→and, @→At) - Auto-dedup with numeric suffix (-1, -2...) on name conflicts - GPG encryption via existing InsertEntry/GpgCommand infrastructure - Git auto-commit support - Full unit test coverage for parser, sanitizer, content builder --- src/cli.rs | 79 ++++- src/password_store/import.rs | 255 +++++++++++++++ src/password_store/importer/bitwarden.rs | 396 +++++++++++++++++++++++ src/password_store/importer/mod.rs | 29 ++ src/password_store/mod.rs | 3 + src/password_store/store_directory.rs | 4 + 6 files changed, 763 insertions(+), 3 deletions(-) create mode 100644 src/password_store/import.rs create mode 100644 src/password_store/importer/bitwarden.rs create mode 100644 src/password_store/importer/mod.rs diff --git a/src/cli.rs b/src/cli.rs index 2bfe99a..f0d7d17 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -15,10 +15,11 @@ use crate::password_generator::{ max_passphrase_words, max_password_length, }; use crate::password_store::{ - DecryptedEntry, DoctorReport, EditEntry, GitCommand, GpgCommand, InitStore, InitStoreResult, - InsertEntry, ListEntries, MoveEntry, OtpCode, PasswordStore, Recipients, RecipientsResult, - RemoveEntry, SearchEntries, ShowEntry, StoreDirectory, + DecryptedEntry, DoctorReport, EditEntry, GitCommand, GpgCommand, ImportEntries, ImportResult, + InitStore, InitStoreResult, InsertEntry, ListEntries, MoveEntry, OtpCode, PasswordStore, + Recipients, RecipientsResult, RemoveEntry, SearchEntries, ShowEntry, StoreDirectory, }; +use crate::password_store::importer::bitwarden::BitwardenImporter; use tree_output::EntryTree; #[derive(Debug, Parser)] @@ -100,6 +101,9 @@ enum Command { #[command(name = "complete-entries", hide = true)] CompleteEntries(CompleteEntriesCommand), + + #[command(about = "Import entries from other password managers")] + Import(ImportCommand), } #[derive(Debug, Parser)] @@ -359,6 +363,24 @@ struct CompleteEntriesCommand { prefix: String, } +#[derive(Debug, Parser)] +struct ImportCommand { + #[arg( + help = "Import from a Bitwarden JSON export", + long = "bitwarden" + )] + bitwarden: bool, + + #[arg(help = "Path to the import file")] + file: PathBuf, + + #[arg(short = 'f', long, help = "Overwrite existing entries")] + force: bool, + + #[arg(long, help = "Output in JSON format")] + json: bool, +} + pub fn run() -> Result<(), CliError> { let cli = Cli::parse(); @@ -385,6 +407,7 @@ pub fn run() -> Result<(), CliError> { Some(Command::Otp(command)) => generate_otp(command, store_directory), Some(Command::Search(command)) => search_entries(command, store_directory), Some(Command::Doctor(command)) => run_doctor(command, store_directory), + Some(Command::Import(command)) => import_entries(command, store_directory), Some(Command::CompleteEntries(command)) => { completion::complete_entries(&command.prefix, store_dir); Ok(()) @@ -440,6 +463,7 @@ impl Command { Self::Otp(command) => command.json, Self::Search(command) => command.json, Self::Doctor(command) => command.json, + Self::Import(command) => command.json, Self::Completions(_) | Self::CompleteEntries(_) => false, } } @@ -662,6 +686,51 @@ fn print_json_insert(entry_name: &str) -> Result<(), CliError> { Ok(()) } +fn import_entries(command: ImportCommand, store_directory: StoreDirectory) -> Result<(), CliError> { + let store = PasswordStore::open(store_directory)?; + let gpg = GpgCommand::from_environment(); + let data = std::fs::read_to_string(&command.file) + .map_err(|e| CliError::ImportFailed(format!("cannot read '{}': {}", command.file.display(), e)))?; + + let result = ImportEntries::new(&store, &gpg) + .execute(&BitwardenImporter, &data, command.force)?; + + auto_commit(&store, &format!("Imported {} entries from Bitwarden", result.imported))?; + + if command.json { + print_json_import(&result, "Bitwarden")?; + } else { + print_text_import(&result, "Bitwarden"); + } + + Ok(()) +} + +fn print_text_import(result: &ImportResult, source: &str) { + println!("Imported {}/{} entries from {}", result.imported, result.imported + result.skipped, source); + for error in &result.errors { + eprintln!(" {}", error); + } +} + +fn print_json_import(result: &ImportResult, source: &str) -> Result<(), CliError> { + #[derive(Serialize)] + struct ImportJson<'a> { + source: &'a str, + imported: usize, + skipped: usize, + errors: &'a [String], + } + let json = serde_json::to_string_pretty(&ImportJson { + source, + imported: result.imported, + skipped: result.skipped, + errors: &result.errors, + })?; + println!("{json}"); + Ok(()) +} + fn auto_commit(store: &PasswordStore, message: &str) -> Result<(), CliError> { GitCommand::from_environment().auto_commit(store, message)?; Ok(()) @@ -1159,6 +1228,9 @@ pub enum CliError { #[error("doctor checks failed")] DoctorFailed, + #[error("import failed: {0}")] + ImportFailed(String), + #[error("error already reported")] Reported, @@ -1188,6 +1260,7 @@ impl CliError { Self::RemoveConfirmationRequired => "remove_confirmation_required", Self::RemoveAborted => "remove_aborted", Self::DoctorFailed => "doctor_checks_failed", + Self::ImportFailed(_) => "import_failed", Self::Reported => "reported", Self::NoEntryPoint => "no_entry_or_subcommand_provided", } diff --git a/src/password_store/import.rs b/src/password_store/import.rs new file mode 100644 index 0000000..c6e8ab7 --- /dev/null +++ b/src/password_store/import.rs @@ -0,0 +1,255 @@ +use super::{ + importer::{Importer, ImportEntry}, + EntryName, GpgCommand, InsertEntry, PasswordStore, PasswordStoreError, +}; + +#[derive(Debug)] +pub struct ImportResult { + pub imported: usize, + pub skipped: usize, + pub errors: Vec, +} + +pub struct ImportEntries<'store, 'gpg> { + store: &'store PasswordStore, + gpg: &'gpg GpgCommand, +} + +impl<'store, 'gpg> ImportEntries<'store, 'gpg> { + pub fn new(store: &'store PasswordStore, gpg: &'gpg GpgCommand) -> Self { + Self { store, gpg } + } + + pub fn execute( + &self, + importer: &dyn Importer, + data: &str, + force: bool, + ) -> Result { + let entries = importer.parse(data).map_err(|error| { + PasswordStoreError::ImportFailed(error.to_string()) + })?; + + let total = entries.len(); + let mut imported = 0; + let mut errors = Vec::new(); + + for entry in entries { + match self.insert_import_entry(entry, force) { + Ok(()) => imported += 1, + Err(PasswordStoreError::EntryAlreadyExists(name)) => { + errors.push(format!("'{}' already exists; use --force to overwrite or resolve manually", name)); + } + Err(e) => { + errors.push(e.to_string()); + } + } + } + + Ok(ImportResult { + imported, + skipped: total - imported, + errors, + }) + } + + fn insert_import_entry( + &self, + entry: ImportEntry, + force: bool, + ) -> Result<(), PasswordStoreError> { + let sanitized_folder = entry.folder.as_deref().map(sanitize_path); + let sanitized_name = sanitize_segment(&entry.name); + let base_name = match sanitized_folder { + Some(ref folder) => format!("{folder}/{sanitized_name}"), + None => sanitized_name, + }; + + let name = self.resolve_entry_name(&base_name, force)?; + let content = build_entry_content(&entry); + InsertEntry::new(self.store, self.gpg).execute(&name, &content, true) + } + + fn resolve_entry_name( + &self, + base_name: &str, + force: bool, + ) -> Result { + let entry_name = EntryName::from_user_input(base_name).map_err(|error| { + PasswordStoreError::InvalidEntryName { + entry: base_name.to_owned(), + reason: error.message(), + } + })?; + let encrypted_file = entry_name.encrypted_file_path(self.store.path()); + + if !encrypted_file.exists() || force { + return Ok(entry_name.into_string()); + } + + for i in 1..1000 { + let candidate = format!("{base_name}-{i}"); + let entry_name = EntryName::from_user_input(&candidate).map_err(|error| { + PasswordStoreError::InvalidEntryName { + entry: candidate, + reason: error.message(), + } + })?; + let encrypted_file = entry_name.encrypted_file_path(self.store.path()); + if !encrypted_file.exists() { + return Ok(entry_name.into_string()); + } + } + + Err(PasswordStoreError::EntryAlreadyExists(base_name.to_owned())) + } +} + +const INVALIDS: &[(char, &str)] = &[ + ('<', "-"), ('>', "-"), (':', "-"), ('"', "-"), + ('/', "-"), ('\\', "-"), ('|', "-"), ('?', "-"), ('*', "-"), + ('&', "and"), ('@', "At"), +]; + +fn sanitize_path(path: &str) -> String { + path.trim() + .split('/') + .map(sanitize_segment) + .filter(|s| !s.is_empty()) + .collect::>() + .join("/") +} + +fn sanitize_segment(segment: &str) -> String { + let mut cleaned = String::with_capacity(segment.len()); + for c in segment.trim().chars() { + match c { + '\0' | '\t' | '\'' | '[' | ']' => {} + _ if is_invalid(c) => { + let replacement = INVALIDS.iter().find(|&&(ch, _)| ch == c).map(|&(_, s)| s).unwrap_or("-"); + cleaned.push_str(replacement); + } + _ => cleaned.push(c), + } + } + cleaned +} + +fn is_invalid(c: char) -> bool { + INVALIDS.iter().any(|&(ch, _)| ch == c) +} + +fn build_entry_content(entry: &ImportEntry) -> String { + let mut lines = Vec::new(); + + lines.push(entry.password.clone().unwrap_or_default()); + + for field in &entry.fields { + lines.push(format!("{}: {}", field.name, field.value)); + } + + if let Some(ref uri) = entry.otp_uri { + lines.push(uri.clone()); + } + + if let Some(ref notes) = entry.notes { + for line in notes.lines() { + lines.push(line.to_owned()); + } + } + + lines.join("\n") + "\n" +} + +#[cfg(test)] +mod tests { + use super::{build_entry_content, sanitize_path, sanitize_segment}; + use crate::password_store::importer::ImportEntry; + use crate::password_store::EntryField; + + #[test] + fn segment_replaces_invalids() { + assert_eq!(sanitize_segment("ac:d\"e/f\\g|h?i*j"), "a-b-c-d-e-f-g-h-i-j"); + } + + #[test] + fn segment_strips_control_chars() { + assert_eq!(sanitize_segment("foo\x00bar\tbaz"), "foobarbaz"); + } + + #[test] + fn segment_replaces_ampersand() { + assert_eq!(sanitize_segment("foo&bar"), "fooandbar"); + } + + #[test] + fn segment_replaces_at_sign() { + assert_eq!(sanitize_segment("foo@bar"), "fooAtbar"); + } + + #[test] + fn segment_strips_brackets_and_quote() { + assert_eq!(sanitize_segment("foo'bar[baz]"), "foobarbaz"); + } + + #[test] + fn segment_trims_whitespace() { + assert_eq!(sanitize_segment(" foo "), "foo"); + } + + #[test] + fn path_preserves_separators_and_sanitizes_segments() { + assert_eq!(sanitize_path("Social/My Bank"), "Social/My Bank"); + assert_eq!(sanitize_path("Social/My Result, ImportError> { + let root: Value = serde_json::from_str(data)?; + + if root.get("encrypted").and_then(|v| v.as_bool()).unwrap_or(false) { + return Err(ImportError::EncryptedFile); + } + + let items = root + .get("items") + .and_then(|v| v.as_array()) + .ok_or(ImportError::NoEntries)?; + + if items.is_empty() { + return Err(ImportError::NoEntries); + } + + let folders = build_folder_map(&root); + let mut entries = Vec::new(); + + for item in items { + if let Some(entry) = parse_item(item, &folders) { + entries.push(entry); + } + } + + if entries.is_empty() { + return Err(ImportError::NoEntries); + } + + Ok(entries) + } +} + +fn build_folder_map(root: &Value) -> HashMap { + let mut map = HashMap::new(); + + for key in &["folders", "collections"] { + let Some(groups) = root.get(*key).and_then(|v| v.as_array()) else { + continue; + }; + + for group in groups { + let id = group.get("id").and_then(|v| v.as_str()).unwrap_or_default().to_owned(); + let name = group.get("name").and_then(|v| v.as_str()).unwrap_or_default().to_owned(); + if !id.is_empty() { + map.insert(id, name); + } + } + } + + map +} + +fn parse_item(item: &Value, folders: &HashMap) -> Option { + let name = item.get("name")?.as_str()?.trim().to_owned(); + if name.is_empty() { + return None; + } + + let folder = resolve_folder(item, folders); + let login = item.get("login"); + + let password = login + .and_then(|l| l.get("password")) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(String::from); + + let username = login + .and_then(|l| l.get("username")) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(String::from); + + let totp = login + .and_then(|l| l.get("totp")) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(String::from); + + let uris = login + .and_then(|l| l.get("uris")) + .and_then(|v| v.as_array()); + + let mut fields = Vec::new(); + + if let Some(ref u) = username { + fields.push(EntryField { + name: "username".to_owned(), + value: u.clone(), + }); + } + + if let Some(uris) = uris { + for (i, uri_obj) in uris.iter().enumerate() { + let uri = uri_obj + .get("uri") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()); + if let Some(uri) = uri { + let key = if i == 0 { "url".to_owned() } else { format!("url{}", i + 1) }; + fields.push(EntryField { + name: key, + value: uri.to_owned(), + }); + } + } + } + + let item_type = item.get("type").and_then(|v| v.as_i64()).unwrap_or(1); + if item_type == 3 { + if let Some(card) = item.get("card") { + flatten_object(card, &mut fields); + } + } else if item_type == 4 && let Some(identity) = item.get("identity") { + flatten_object(identity, &mut fields); + } + + if let Some(fields_arr) = item.get("fields").and_then(|v| v.as_array()) { + for field in fields_arr { + let fname = field.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let fvalue = field.get("value").and_then(|v| v.as_str()).unwrap_or(""); + if !fname.is_empty() && !fvalue.is_empty() { + fields.push(EntryField { + name: fname.to_owned(), + value: fvalue.to_owned(), + }); + } + } + } + + let otp_uri = totp.map(|t| normalize_totp(&t, &name)); + + let notes = item + .get("notes") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(String::from); + + Some(ImportEntry { + name, + password, + fields, + otp_uri, + notes, + folder, + }) +} + +fn resolve_folder(item: &Value, folders: &HashMap) -> Option { + if let Some(folder_id) = item.get("folderId").and_then(|v| v.as_str()) + && !folder_id.is_empty() + { + return folders.get(folder_id).filter(|n| !n.is_empty()).cloned(); + } + + if let Some(collection_ids) = item.get("collectionIds").and_then(|v| v.as_array()) { + for coll_id in collection_ids { + if let Some(id) = coll_id.as_str() + && !id.is_empty() + && let Some(name) = folders.get(id) + && !name.is_empty() + { + return Some(name.clone()); + } + } + } + + None +} + +fn flatten_object(obj: &Value, fields: &mut Vec) { + let Some(map) = obj.as_object() else { + return; + }; + + for (k, v) in map { + if let Some(s) = v.as_str() + && !s.is_empty() + { + fields.push(EntryField { + name: k.clone(), + value: s.to_owned(), + }); + } + } +} + +fn normalize_totp(totp: &str, name: &str) -> String { + if totp.starts_with("otpauth://") { + totp.to_owned() + } else { + format!("otpauth://totp/{name}?secret={totp}") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_individual_vault() { + let json = r#"{ + "encrypted": false, + "folders": [ + { "id": "f1", "name": "Social" } + ], + "items": [ + { + "id": "i1", + "folderId": "f1", + "type": 1, + "name": "Twitter", + "notes": "My Twitter account", + "favorite": false, + "fields": [ + { "name": "handle", "value": "@me", "type": 0 } + ], + "login": { + "uris": [{ "match": null, "uri": "https://twitter.com" }], + "username": "me@example.com", + "password": "secret123", + "totp": "JBSWY3DPEHPK3PXP" + } + } + ] + }"#; + + let importer = BitwardenImporter; + let entries = importer.parse(json).expect("parse"); + assert_eq!(entries.len(), 1); + + let entry = &entries[0]; + assert_eq!(entry.name, "Twitter"); + assert_eq!(entry.folder.as_deref(), Some("Social")); + assert_eq!(entry.password.as_deref(), Some("secret123")); + assert!(entry.fields.iter().any(|f| f.name == "username" && f.value == "me@example.com")); + assert!(entry.fields.iter().any(|f| f.name == "url" && f.value == "https://twitter.com")); + assert!(entry.fields.iter().any(|f| f.name == "handle" && f.value == "@me")); + assert!(entry.otp_uri.as_deref().unwrap().starts_with("otpauth://")); + assert_eq!(entry.notes.as_deref(), Some("My Twitter account")); + } + + #[test] + fn rejects_encrypted_export() { + let json = r#"{"encrypted": true, "items": []}"#; + let importer = BitwardenImporter; + assert!(matches!(importer.parse(json), Err(ImportError::EncryptedFile))); + } + + #[test] + fn handles_no_folder() { + let json = r#"{ + "encrypted": false, + "items": [ + { + "id": "i1", + "folderId": null, + "type": 1, + "name": "Direct Login", + "login": { "username": "user", "password": "pass" } + } + ] + }"#; + + let importer = BitwardenImporter; + let entries = importer.parse(json).expect("parse"); + assert_eq!(entries.len(), 1); + assert!(entries[0].folder.is_none()); + } + + #[test] + fn handles_secure_note() { + let json = r#"{ + "encrypted": false, + "items": [ + { + "id": "i1", + "type": 2, + "name": "My Note", + "notes": "This is a secure note content" + } + ] + }"#; + + let importer = BitwardenImporter; + let entries = importer.parse(json).expect("parse"); + assert_eq!(entries.len(), 1); + assert!(entries[0].password.is_none()); + assert_eq!(entries[0].notes.as_deref(), Some("This is a secure note content")); + } + + #[test] + fn handles_card() { + let json = r#"{ + "encrypted": false, + "items": [ + { + "id": "i1", + "type": 3, + "name": "My Card", + "card": { + "brand": "Visa", + "number": "4111111111111111", + "cardholderName": "John Doe" + } + } + ] + }"#; + + let importer = BitwardenImporter; + let entries = importer.parse(json).expect("parse"); + assert_eq!(entries.len(), 1); + assert!(entries[0].fields.iter().any(|f| f.name == "brand" && f.value == "Visa")); + } + + #[test] + fn handles_organization_vault() { + let json = r#"{ + "encrypted": false, + "collections": [ + { "id": "c1", "organizationId": "o1", "name": "Team Passwords" } + ], + "items": [ + { + "id": "i1", + "organizationId": "o1", + "collectionIds": ["c1"], + "type": 1, + "name": "Shared Login", + "login": { "username": "admin", "password": "admin123" } + } + ] + }"#; + + let importer = BitwardenImporter; + let entries = importer.parse(json).expect("parse"); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].folder.as_deref(), Some("Team Passwords")); + } + + #[test] + fn normalizes_raw_totp_key() { + assert_eq!( + normalize_totp("JBSWY3DPEHPK3PXP", "test"), + "otpauth://totp/test?secret=JBSWY3DPEHPK3PXP" + ); + } + + #[test] + fn preserves_full_otpauth_uri() { + assert_eq!( + normalize_totp("otpauth://totp/example?secret=ABC", "ignored"), + "otpauth://totp/example?secret=ABC" + ); + } + + #[test] + fn handles_multiple_uris() { + let json = r#"{ + "encrypted": false, + "items": [ + { + "id": "i1", + "type": 1, + "name": "Multi URL", + "login": { + "uris": [ + { "match": null, "uri": "https://primary.com" }, + { "match": null, "uri": "https://backup.com" } + ], + "username": "user", + "password": "pass" + } + } + ] + }"#; + + let importer = BitwardenImporter; + let entries = importer.parse(json).expect("parse"); + assert_eq!(entries.len(), 1); + assert!(entries[0].fields.iter().any(|f| f.name == "url" && f.value == "https://primary.com")); + assert!(entries[0].fields.iter().any(|f| f.name == "url2" && f.value == "https://backup.com")); + } +} diff --git a/src/password_store/importer/mod.rs b/src/password_store/importer/mod.rs new file mode 100644 index 0000000..af1a6e1 --- /dev/null +++ b/src/password_store/importer/mod.rs @@ -0,0 +1,29 @@ +pub mod bitwarden; + +use super::EntryField; + +#[derive(Debug, Clone)] +pub struct ImportEntry { + pub name: String, + pub password: Option, + pub fields: Vec, + pub otp_uri: Option, + pub notes: Option, + pub folder: Option, +} + +pub trait Importer: std::fmt::Debug { + fn parse(&self, data: &str) -> Result, ImportError>; +} + +#[derive(Debug, thiserror::Error)] +pub enum ImportError { + #[error("the import file is encrypted; decrypt it first")] + EncryptedFile, + + #[error("JSON parse error: {0}")] + Json(#[from] serde_json::Error), + + #[error("import file contains no recognizable entries")] + NoEntries, +} diff --git a/src/password_store/mod.rs b/src/password_store/mod.rs index 242740b..49373f3 100644 --- a/src/password_store/mod.rs +++ b/src/password_store/mod.rs @@ -4,6 +4,8 @@ mod edit_entry; mod entry_name; mod git; mod gpg; +mod import; +pub mod importer; mod init_store; mod insert_entry; mod list_entries; @@ -21,6 +23,7 @@ pub use edit_entry::EditEntry; pub use entry_name::EntryName; pub use git::GitCommand; pub use gpg::GpgCommand; +pub use import::{ImportEntries, ImportResult}; pub use init_store::{InitStore, InitStoreResult}; pub use insert_entry::InsertEntry; pub use list_entries::ListEntries; diff --git a/src/password_store/store_directory.rs b/src/password_store/store_directory.rs index b55888d..7746fd5 100644 --- a/src/password_store/store_directory.rs +++ b/src/password_store/store_directory.rs @@ -126,6 +126,9 @@ pub enum PasswordStoreError { #[error("recipient not found: {0}")] RecipientNotFound(String), + #[error("import failed: {0}")] + ImportFailed(String), + #[error("failed to access password store: {0}")] Io(#[from] std::io::Error), } @@ -155,6 +158,7 @@ impl PasswordStoreError { Self::OtpNotFound => "otp_not_found", Self::InvalidOtpUri(_) => "invalid_otp_uri", Self::RecipientNotFound(_) => "recipient_not_found", + Self::ImportFailed(_) => "import_failed", Self::Io(_) => "io_error", } } From ccda09988607934b01dcfa12fbb0800c4ebb919e Mon Sep 17 00:00:00 2001 From: Cristhian Melo Date: Fri, 19 Jun 2026 19:06:21 -0500 Subject: [PATCH 2/3] docs: add import guide and README reference Create docs/import.md with Bitwarden import instructions. Add reference in README features and quick start. --- README.md | 2 ++ docs/import.md | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 docs/import.md diff --git a/README.md b/README.md index b2b6df8..b73fc3a 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ rpass git status - **Works everywhere** — Linux, macOS, Windows (no Bash dependency) - **password-store compatible** — reads and writes existing `.gpg` entries and `.gpg-id` files - **JSON output** — structured responses for Raycast, Vicinae, and custom integrations +- **Import** — migrate from other password managers ([docs](docs/import.md)) - **TOTP support** — generate one-time codes from `otpauth://` lines - **Git integration** — explicit `rpass git ` commands; write commands auto-commit - **Shell completions** — Bash, Zsh, Fish, PowerShell @@ -56,6 +57,7 @@ rpass insert example/login # insert a password rpass edit example/login # edit an entry rpass rm example/login # remove an entry rpass mv example/login archive/login # move/rename an entry +rpass import --bitwarden ~/export.json # import from Bitwarden rpass otp example/login # generate a TOTP code rpass git status # run git inside the store rpass doctor # check your local setup diff --git a/docs/import.md b/docs/import.md new file mode 100644 index 0000000..f5ce0b2 --- /dev/null +++ b/docs/import.md @@ -0,0 +1,17 @@ +# Import + +Import passwords from other password managers into your rpass store. + + rpass import --bitwarden + +## Bitwarden + +1. In Bitwarden, go to **Tools → Export vault** and select **File format: JSON** (unencrypted). +2. Run: `rpass import --bitwarden ~/bitwarden_export.json` + +| Flag | Description | +|---|---| +| `--force` | Overwrite existing entries | +| `--json` | JSON output | + +Supports individual and organization vaults. Folders and collections become path prefixes. All item types are handled: logins, secure notes, cards, and identities. From ea43cf9536bedfdf5cfd0f66b2c41b03244c9150 Mon Sep 17 00:00:00 2001 From: Cristhian Melo Date: Fri, 19 Jun 2026 19:10:47 -0500 Subject: [PATCH 3/3] style: format with clippy --- src/cli.rs | 28 ++++---- src/password_store/import.rs | 40 +++++++---- src/password_store/importer/bitwarden.rs | 84 +++++++++++++++++++----- 3 files changed, 114 insertions(+), 38 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index f0d7d17..45aa35d 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -14,12 +14,12 @@ use crate::password_generator::{ default_passphrase_words, default_password_length, generate_passphrase, generate_password, max_passphrase_words, max_password_length, }; +use crate::password_store::importer::bitwarden::BitwardenImporter; use crate::password_store::{ DecryptedEntry, DoctorReport, EditEntry, GitCommand, GpgCommand, ImportEntries, ImportResult, InitStore, InitStoreResult, InsertEntry, ListEntries, MoveEntry, OtpCode, PasswordStore, Recipients, RecipientsResult, RemoveEntry, SearchEntries, ShowEntry, StoreDirectory, }; -use crate::password_store::importer::bitwarden::BitwardenImporter; use tree_output::EntryTree; #[derive(Debug, Parser)] @@ -365,10 +365,7 @@ struct CompleteEntriesCommand { #[derive(Debug, Parser)] struct ImportCommand { - #[arg( - help = "Import from a Bitwarden JSON export", - long = "bitwarden" - )] + #[arg(help = "Import from a Bitwarden JSON export", long = "bitwarden")] bitwarden: bool, #[arg(help = "Path to the import file")] @@ -689,13 +686,17 @@ fn print_json_insert(entry_name: &str) -> Result<(), CliError> { fn import_entries(command: ImportCommand, store_directory: StoreDirectory) -> Result<(), CliError> { let store = PasswordStore::open(store_directory)?; let gpg = GpgCommand::from_environment(); - let data = std::fs::read_to_string(&command.file) - .map_err(|e| CliError::ImportFailed(format!("cannot read '{}': {}", command.file.display(), e)))?; + let data = std::fs::read_to_string(&command.file).map_err(|e| { + CliError::ImportFailed(format!("cannot read '{}': {}", command.file.display(), e)) + })?; - let result = ImportEntries::new(&store, &gpg) - .execute(&BitwardenImporter, &data, command.force)?; + let result = + ImportEntries::new(&store, &gpg).execute(&BitwardenImporter, &data, command.force)?; - auto_commit(&store, &format!("Imported {} entries from Bitwarden", result.imported))?; + auto_commit( + &store, + &format!("Imported {} entries from Bitwarden", result.imported), + )?; if command.json { print_json_import(&result, "Bitwarden")?; @@ -707,7 +708,12 @@ fn import_entries(command: ImportCommand, store_directory: StoreDirectory) -> Re } fn print_text_import(result: &ImportResult, source: &str) { - println!("Imported {}/{} entries from {}", result.imported, result.imported + result.skipped, source); + println!( + "Imported {}/{} entries from {}", + result.imported, + result.imported + result.skipped, + source + ); for error in &result.errors { eprintln!(" {}", error); } diff --git a/src/password_store/import.rs b/src/password_store/import.rs index c6e8ab7..872361b 100644 --- a/src/password_store/import.rs +++ b/src/password_store/import.rs @@ -1,6 +1,6 @@ use super::{ - importer::{Importer, ImportEntry}, EntryName, GpgCommand, InsertEntry, PasswordStore, PasswordStoreError, + importer::{ImportEntry, Importer}, }; #[derive(Debug)] @@ -26,9 +26,9 @@ impl<'store, 'gpg> ImportEntries<'store, 'gpg> { data: &str, force: bool, ) -> Result { - let entries = importer.parse(data).map_err(|error| { - PasswordStoreError::ImportFailed(error.to_string()) - })?; + let entries = importer + .parse(data) + .map_err(|error| PasswordStoreError::ImportFailed(error.to_string()))?; let total = entries.len(); let mut imported = 0; @@ -38,7 +38,10 @@ impl<'store, 'gpg> ImportEntries<'store, 'gpg> { match self.insert_import_entry(entry, force) { Ok(()) => imported += 1, Err(PasswordStoreError::EntryAlreadyExists(name)) => { - errors.push(format!("'{}' already exists; use --force to overwrite or resolve manually", name)); + errors.push(format!( + "'{}' already exists; use --force to overwrite or resolve manually", + name + )); } Err(e) => { errors.push(e.to_string()); @@ -106,9 +109,17 @@ impl<'store, 'gpg> ImportEntries<'store, 'gpg> { } const INVALIDS: &[(char, &str)] = &[ - ('<', "-"), ('>', "-"), (':', "-"), ('"', "-"), - ('/', "-"), ('\\', "-"), ('|', "-"), ('?', "-"), ('*', "-"), - ('&', "and"), ('@', "At"), + ('<', "-"), + ('>', "-"), + (':', "-"), + ('"', "-"), + ('/', "-"), + ('\\', "-"), + ('|', "-"), + ('?', "-"), + ('*', "-"), + ('&', "and"), + ('@', "At"), ]; fn sanitize_path(path: &str) -> String { @@ -126,7 +137,11 @@ fn sanitize_segment(segment: &str) -> String { match c { '\0' | '\t' | '\'' | '[' | ']' => {} _ if is_invalid(c) => { - let replacement = INVALIDS.iter().find(|&&(ch, _)| ch == c).map(|&(_, s)| s).unwrap_or("-"); + let replacement = INVALIDS + .iter() + .find(|&&(ch, _)| ch == c) + .map(|&(_, s)| s) + .unwrap_or("-"); cleaned.push_str(replacement); } _ => cleaned.push(c), @@ -164,12 +179,15 @@ fn build_entry_content(entry: &ImportEntry) -> String { #[cfg(test)] mod tests { use super::{build_entry_content, sanitize_path, sanitize_segment}; - use crate::password_store::importer::ImportEntry; use crate::password_store::EntryField; + use crate::password_store::importer::ImportEntry; #[test] fn segment_replaces_invalids() { - assert_eq!(sanitize_segment("ac:d\"e/f\\g|h?i*j"), "a-b-c-d-e-f-g-h-i-j"); + assert_eq!( + sanitize_segment("ac:d\"e/f\\g|h?i*j"), + "a-b-c-d-e-f-g-h-i-j" + ); } #[test] diff --git a/src/password_store/importer/bitwarden.rs b/src/password_store/importer/bitwarden.rs index 533e998..f9616a0 100644 --- a/src/password_store/importer/bitwarden.rs +++ b/src/password_store/importer/bitwarden.rs @@ -12,7 +12,11 @@ impl Importer for BitwardenImporter { fn parse(&self, data: &str) -> Result, ImportError> { let root: Value = serde_json::from_str(data)?; - if root.get("encrypted").and_then(|v| v.as_bool()).unwrap_or(false) { + if root + .get("encrypted") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + { return Err(ImportError::EncryptedFile); } @@ -51,8 +55,16 @@ fn build_folder_map(root: &Value) -> HashMap { }; for group in groups { - let id = group.get("id").and_then(|v| v.as_str()).unwrap_or_default().to_owned(); - let name = group.get("name").and_then(|v| v.as_str()).unwrap_or_default().to_owned(); + let id = group + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_owned(); + let name = group + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_owned(); if !id.is_empty() { map.insert(id, name); } @@ -89,9 +101,7 @@ fn parse_item(item: &Value, folders: &HashMap) -> Option) -> Option) -> Option