Skip to content

Commit acea084

Browse files
authored
PM-23649: CXF Import note (#382)
## 🎟️ Tracking https://bitwarden.atlassian.net/browse/PM-23649 <!-- Paste the link to the Jira or GitHub issue or otherwise describe / point to where this change is coming from. --> ## 📔 Objective This PR maps the Note type, and adds mapping the note for existing vault items. Note: A separate task has been created to track that we're mapping notes for all other cipheritems (some are not created yet). <!-- Describe what the purpose of this PR is, for example what bug you're fixing or new feature you're adding. --> ## ⏰ Reminders before review - Contributor guidelines followed - All formatters and local linters executed and passed - Written new unit and / or integration tests where applicable - Protected functional changes with optionality (feature flags) - Used internationalization (i18n) for all UI strings - CI builds passed - Communicated to DevOps any deployment requirements - Updated any necessary documentation (Confluence, contributing docs) or informed the documentation team ## 🦮 Reviewer guidelines <!-- Suggested interactions but feel free to use (or not) as you desire! --> - 👍 (`:+1:`) or similar for great changes - 📝 (`:memo:`) or ℹ️ (`:information_source:`) for notes or general info - ❓ (`:question:`) for questions - 🤔 (`:thinking:`) or 💭 (`:thought_balloon:`) for more open inquiry that's not quite a confirmed issue and could potentially benefit from discussion - 🎨 (`:art:`) for suggestions / improvements - ❌ (`:x:`) or ⚠️ (`:warning:`) for more significant problems or concerns needing attention - 🌱 (`:seedling:`) or ♻️ (`:recycle:`) for future improvements or indications of technical debt - ⛏ (`:pick:`) for minor or nitpick changes
1 parent 4153278 commit acea084

File tree

3 files changed

+297
-6
lines changed

3 files changed

+297
-6
lines changed

crates/bitwarden-exporters/src/cxf/import.rs

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
use chrono::{DateTime, Utc};
22
use credential_exchange_format::{
33
Account as CxfAccount, ApiKeyCredential, BasicAuthCredential, Credential, CreditCardCredential,
4-
Item, PasskeyCredential, WifiCredential,
4+
Item, NoteCredential, PasskeyCredential, WifiCredential,
55
};
66

77
use crate::{
88
cxf::{
99
api_key::api_key_to_fields,
1010
login::{to_fields, to_login},
11+
note::extract_note_content,
1112
wifi::wifi_to_fields,
1213
CxfError,
1314
},
@@ -52,7 +53,7 @@ fn convert_date(ts: Option<u64>) -> DateTime<Utc> {
5253
.unwrap_or(Utc::now())
5354
}
5455

55-
fn parse_item(value: Item) -> Vec<ImportingCipher> {
56+
pub(crate) fn parse_item(value: Item) -> Vec<ImportingCipher> {
5657
let grouped = group_credentials_by_type(value.credentials);
5758

5859
let creation_date = convert_date(value.creation_at);
@@ -62,6 +63,9 @@ fn parse_item(value: Item) -> Vec<ImportingCipher> {
6263

6364
let scope = value.scope.as_ref();
6465

66+
// Extract note content if present (to be added to parent cipher)
67+
let note_content = grouped.note.first().map(extract_note_content);
68+
6569
// Login credentials
6670
if !grouped.basic_auth.is_empty() || !grouped.passkey.is_empty() {
6771
let basic_auth = grouped.basic_auth.first();
@@ -72,7 +76,7 @@ fn parse_item(value: Item) -> Vec<ImportingCipher> {
7276
output.push(ImportingCipher {
7377
folder_id: None, // TODO: Handle folders
7478
name: value.title.clone(),
75-
notes: None,
79+
notes: note_content.clone(),
7680
r#type: CipherType::Login(Box::new(login)),
7781
favorite: false,
7882
reprompt: 0,
@@ -92,7 +96,7 @@ fn parse_item(value: Item) -> Vec<ImportingCipher> {
9296
output.push(ImportingCipher {
9397
folder_id: None, // TODO: Handle folders
9498
name: value.title.clone(),
95-
notes: None,
99+
notes: note_content.clone(),
96100
r#type: CipherType::Card(Box::new(credit_card.into())),
97101
favorite: false,
98102
reprompt: 0,
@@ -110,7 +114,7 @@ fn parse_item(value: Item) -> Vec<ImportingCipher> {
110114
output.push(ImportingCipher {
111115
folder_id: None, // TODO: Handle folders
112116
name: value.title.clone(),
113-
notes: None,
117+
notes: note_content.clone(),
114118
r#type: CipherType::SecureNote(Box::new(SecureNote {
115119
r#type: SecureNoteType::Generic,
116120
})),
@@ -130,7 +134,7 @@ fn parse_item(value: Item) -> Vec<ImportingCipher> {
130134
output.push(ImportingCipher {
131135
folder_id: None, // TODO: Handle folders
132136
name: value.title.clone(),
133-
notes: None,
137+
notes: note_content.clone(),
134138
r#type: CipherType::SecureNote(Box::new(SecureNote {
135139
r#type: SecureNoteType::Generic,
136140
})),
@@ -143,6 +147,26 @@ fn parse_item(value: Item) -> Vec<ImportingCipher> {
143147
})
144148
}
145149

150+
// Standalone Note credentials -> Secure Note (only if no other credentials exist)
151+
if !grouped.note.is_empty() && output.is_empty() {
152+
let note_content = grouped.note.first().map(extract_note_content);
153+
154+
output.push(ImportingCipher {
155+
folder_id: None, // TODO: Handle folders
156+
name: value.title.clone(),
157+
notes: note_content,
158+
r#type: CipherType::SecureNote(Box::new(SecureNote {
159+
r#type: SecureNoteType::Generic,
160+
})),
161+
favorite: false,
162+
reprompt: 0,
163+
fields: vec![],
164+
revision_date,
165+
creation_date,
166+
deleted_date: None,
167+
})
168+
}
169+
146170
output
147171
}
148172

@@ -184,6 +208,10 @@ fn group_credentials_by_type(credentials: Vec<Credential>) -> GroupedCredentials
184208
Credential::Wifi(wifi) => Some(wifi.as_ref()),
185209
_ => None,
186210
}),
211+
note: filter_credentials(&credentials, |c| match c {
212+
Credential::Note(note) => Some(note.as_ref()),
213+
_ => None,
214+
}),
187215
}
188216
}
189217

@@ -193,6 +221,7 @@ struct GroupedCredentials {
193221
passkey: Vec<PasskeyCredential>,
194222
credit_card: Vec<CreditCardCredential>,
195223
wifi: Vec<WifiCredential>,
224+
note: Vec<NoteCredential>,
196225
}
197226

198227
#[cfg(test)]

crates/bitwarden-exporters/src/cxf/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,5 @@ mod api_key;
1616
mod card;
1717
mod editable_field;
1818
mod login;
19+
mod note;
1920
mod wifi;
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
use credential_exchange_format::NoteCredential;
2+
3+
/// Extract note content from a CXF Note credential
4+
/// The way notes are handled (in import.rs) depends on their context:
5+
/// - If part of an item, use parent type and map content to Cipher::notes
6+
/// - If standalone, map to SecureNote
7+
///
8+
/// That's why we only have this small utility function and tests here.
9+
pub(super) fn extract_note_content(note: &NoteCredential) -> String {
10+
note.content.value.0.clone()
11+
}
12+
13+
#[cfg(test)]
14+
mod tests {
15+
use super::*;
16+
17+
#[test]
18+
fn test_extract_note_content_with_content() {
19+
let note = NoteCredential {
20+
content: "This is a test note with important information."
21+
.to_owned()
22+
.into(),
23+
};
24+
25+
let content = extract_note_content(&note);
26+
assert_eq!(
27+
content,
28+
"This is a test note with important information.".to_string()
29+
);
30+
}
31+
32+
#[test]
33+
fn test_extract_note_content_empty_string() {
34+
let note = NoteCredential {
35+
content: "".to_owned().into(),
36+
};
37+
38+
let content = extract_note_content(&note);
39+
assert_eq!(content, "".to_string());
40+
}
41+
42+
#[test]
43+
fn test_extract_note_content_multiline() {
44+
let note = NoteCredential {
45+
content: "Line 1\nLine 2\nLine 3".to_owned().into(),
46+
};
47+
48+
let content = extract_note_content(&note);
49+
assert_eq!(content, "Line 1\nLine 2\nLine 3".to_string());
50+
}
51+
52+
#[test]
53+
fn test_extract_note_content_special_characters() {
54+
let note = NoteCredential {
55+
content: "Note with emojis 🔐 and special chars: @#$%^&*()"
56+
.to_owned()
57+
.into(),
58+
};
59+
60+
let content = extract_note_content(&note);
61+
assert_eq!(
62+
content,
63+
"Note with emojis 🔐 and special chars: @#$%^&*()".to_string()
64+
);
65+
}
66+
67+
#[test]
68+
fn test_extract_note_content_very_long() {
69+
let long_content = "A".repeat(10000);
70+
let note = NoteCredential {
71+
content: long_content.clone().into(),
72+
};
73+
74+
let content = extract_note_content(&note);
75+
assert_eq!(content, long_content);
76+
}
77+
78+
#[test]
79+
fn test_cxf_example_note_integration() {
80+
use std::fs;
81+
82+
use crate::{cxf::import::parse_cxf_spec, CipherType};
83+
84+
// Read the actual CXF example file
85+
let cxf_data = fs::read_to_string("resources/cxf_example.json")
86+
.expect("Should be able to read cxf_example.json");
87+
88+
let items = parse_cxf_spec(cxf_data).expect("Should parse CXF data successfully");
89+
90+
// Find the note item (Home alarm)
91+
let note_cipher = items
92+
.iter()
93+
.find(|cipher| cipher.name == "Home alarm")
94+
.expect("Should find Home alarm note item");
95+
96+
// Validate it's a SecureNote cipher
97+
match &note_cipher.r#type {
98+
CipherType::SecureNote(_) => (), // Successfully identified as SecureNote
99+
_ => panic!("Expected SecureNote for standalone note credential"),
100+
}
101+
102+
// Validate the note content
103+
assert_eq!(
104+
note_cipher.notes,
105+
Some("some instructionts to enable/disable the alarm".to_string())
106+
);
107+
108+
// Should have no custom fields since it's a standalone note
109+
assert_eq!(note_cipher.fields.len(), 0);
110+
111+
// Validate basic properties
112+
assert_eq!(note_cipher.name, "Home alarm");
113+
assert_eq!(note_cipher.folder_id, None);
114+
assert!(!note_cipher.favorite);
115+
}
116+
117+
#[test]
118+
fn test_standalone_note_credential() {
119+
use credential_exchange_format::{Credential, Item};
120+
121+
use crate::{cxf::import::parse_item, CipherType, ImportingCipher};
122+
123+
let item = Item {
124+
id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(),
125+
creation_at: Some(1706613834),
126+
modified_at: Some(1706623773),
127+
title: "My Important Note".to_string(),
128+
subtitle: None,
129+
favorite: None,
130+
credentials: vec![Credential::Note(Box::new(NoteCredential {
131+
content:
132+
"This is a standalone secure note with important information.\nLine 2\nLine 3"
133+
.to_string()
134+
.into(),
135+
}))],
136+
tags: None,
137+
extensions: None,
138+
scope: None,
139+
};
140+
141+
let ciphers: Vec<ImportingCipher> = parse_item(item);
142+
assert_eq!(ciphers.len(), 1);
143+
let cipher = ciphers.first().unwrap();
144+
145+
assert_eq!(cipher.folder_id, None);
146+
assert_eq!(cipher.name, "My Important Note");
147+
assert_eq!(
148+
cipher.notes,
149+
Some(
150+
"This is a standalone secure note with important information.\nLine 2\nLine 3"
151+
.to_string()
152+
)
153+
);
154+
155+
match &cipher.r#type {
156+
CipherType::SecureNote(_) => (), // Successfully created a SecureNote
157+
_ => panic!("Expected SecureNote"),
158+
};
159+
160+
assert_eq!(cipher.fields.len(), 0); // Notes don't have custom fields
161+
}
162+
163+
// TODO: Consider moving this logic to import.rs since it's more about how notes are handled
164+
// during the import process
165+
#[test]
166+
fn test_note_as_part_of_login() {
167+
use credential_exchange_format::{BasicAuthCredential, Credential, Item};
168+
169+
use crate::{cxf::import::parse_item, CipherType, ImportingCipher};
170+
171+
let item = Item {
172+
id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(),
173+
creation_at: Some(1706613834),
174+
modified_at: Some(1706623773),
175+
title: "Login with Note".to_string(),
176+
subtitle: None,
177+
favorite: None,
178+
credentials: vec![
179+
Credential::BasicAuth(Box::new(BasicAuthCredential {
180+
username: Some("testuser".to_string().into()),
181+
password: Some("testpass".to_string().into()),
182+
})),
183+
Credential::Note(Box::new(NoteCredential {
184+
content: "This note should be added to the login cipher."
185+
.to_string()
186+
.into(),
187+
})),
188+
],
189+
tags: None,
190+
extensions: None,
191+
scope: None,
192+
};
193+
194+
let ciphers: Vec<ImportingCipher> = parse_item(item);
195+
assert_eq!(ciphers.len(), 1); // Should create only one cipher (Login with note content)
196+
let cipher = ciphers.first().unwrap();
197+
198+
assert_eq!(cipher.name, "Login with Note");
199+
assert_eq!(
200+
cipher.notes,
201+
Some("This note should be added to the login cipher.".to_string())
202+
);
203+
204+
match &cipher.r#type {
205+
CipherType::Login(_) => (), // Should be a Login cipher
206+
_ => panic!("Expected Login cipher with note content"),
207+
};
208+
}
209+
210+
#[test]
211+
fn test_note_as_part_of_api_key() {
212+
use credential_exchange_format::{ApiKeyCredential, Credential, Item};
213+
214+
use crate::{cxf::import::parse_item, CipherType, ImportingCipher};
215+
216+
let item = Item {
217+
id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(),
218+
creation_at: Some(1706613834),
219+
modified_at: Some(1706623773),
220+
title: "API Key with Note".to_string(),
221+
subtitle: None,
222+
favorite: None,
223+
credentials: vec![
224+
Credential::ApiKey(Box::new(ApiKeyCredential {
225+
key: Some("api-key-12345".to_string().into()),
226+
username: Some("api-user".to_string().into()),
227+
key_type: Some("Bearer".to_string().into()),
228+
url: None,
229+
valid_from: None,
230+
expiry_date: None,
231+
})),
232+
Credential::Note(Box::new(NoteCredential {
233+
content: "This note should be added to the API key cipher."
234+
.to_string()
235+
.into(),
236+
})),
237+
],
238+
tags: None,
239+
extensions: None,
240+
scope: None,
241+
};
242+
243+
let ciphers: Vec<ImportingCipher> = parse_item(item);
244+
assert_eq!(ciphers.len(), 1); // Should create only one cipher (SecureNote with note content)
245+
let cipher = ciphers.first().unwrap();
246+
247+
assert_eq!(cipher.name, "API Key with Note");
248+
assert_eq!(
249+
cipher.notes,
250+
Some("This note should be added to the API key cipher.".to_string())
251+
);
252+
253+
match &cipher.r#type {
254+
CipherType::SecureNote(_) => (), // Should be a SecureNote cipher
255+
_ => panic!("Expected SecureNote cipher with note content"),
256+
};
257+
258+
// Should have API key fields
259+
assert!(!cipher.fields.is_empty());
260+
}
261+
}

0 commit comments

Comments
 (0)