1pub mod custom_serde;
9pub mod entry;
10pub mod group;
11pub mod meta;
12pub mod tags;
13pub mod times;
14pub mod timestamp;
15
16use serde::{Deserialize, Serialize, Serializer};
17
18use base64::{engine::general_purpose as base64_engine, Engine as _};
19use std::collections::{HashMap, HashSet};
20use thiserror::Error;
21use uuid::Uuid;
22
23use crate::{
24 crypt::ciphers::Cipher,
25 db::{GroupId, Value},
26 format::xml_db::{
27 custom_serde::cs_opt_string, entry::UnprotectError, group::Group, meta::Meta, timestamp::Timestamp,
28 },
29};
30#[cfg(feature = "save_kdbx4")]
31use crate::{crypt::CryptographyError, db::DatabaseSaveError};
32
33pub fn parse_xml(
34 data: &[u8],
35 header_attachments: &[Value<Vec<u8>>],
36 inner_decryptor: &mut dyn Cipher,
37) -> Result<crate::db::Database, ParseXmlError> {
38 let kdbx: KeePassFile = quick_xml::de::from_reader(data)?;
39 Ok(kdbx.xml_to_db(inner_decryptor, header_attachments)?)
40}
41
42#[derive(Debug, Error)]
44#[non_exhaustive]
45pub enum ParseXmlError {
46 #[error("Error parsing XML inside KDBX: {0}")]
48 Xml(#[from] quick_xml::DeError),
49
50 #[error("Error unprotecting entry: {0}")]
53 Unprotect(#[from] UnprotectError),
54}
55
56#[cfg(feature = "save_kdbx4")]
57#[allow(clippy::type_complexity)]
58pub fn to_xml(
59 db: &crate::db::Database,
60 inner_encryptor: &mut dyn Cipher,
61) -> Result<(Vec<u8>, Vec<crate::db::Value<Vec<u8>>>), DatabaseSaveError> {
62 let kdbx = KeePassFile::db_to_xml(db, inner_encryptor)?;
63 let xml = quick_xml::se::to_string_with_root("KeePassFile", &kdbx)?
64 .as_bytes()
65 .to_vec();
66
67 let mut attachments: Vec<(usize, Value<Vec<u8>>)> = db
68 .attachments
69 .iter()
70 .map(|(id, attachment)| (id.id(), attachment.data.clone()))
71 .collect();
72
73 attachments.sort_by_key(|(id, _)| *id);
74
75 let attachments = attachments.into_iter().map(|(_, data)| data).collect();
76
77 Ok((xml, attachments))
78}
79
80#[derive(Debug, Serialize, Deserialize)]
81#[serde(rename_all = "PascalCase")]
82struct KeePassFile {
83 meta: Meta,
84 root: Root,
85}
86
87impl KeePassFile {
88 fn xml_to_db(
90 mut self,
91 inner_decryptor: &mut dyn Cipher,
92 header_attachments: &[Value<Vec<u8>>],
93 ) -> Result<crate::db::Database, UnprotectError> {
94 let mut db = crate::db::Database::new_with_root_id(GroupId::from_uuid(self.root.group.uuid.0));
95
96 let mut attachments = HashMap::new();
97
98 for (i, header_attachment) in header_attachments.iter().enumerate() {
100 let attachment = crate::db::Attachment {
101 id: crate::db::AttachmentId::new(i),
102 entries: HashSet::new(),
103 data: header_attachment.clone(),
104 };
105 attachments.insert(attachment.id, attachment);
106 }
107
108 if let Some(binaries) = self.meta.binaries.take() {
110 for binary in binaries.binaries {
111 let id = crate::db::AttachmentId::next_free(&db);
112 let data = binary.xml_to_db(inner_decryptor)?;
113
114 attachments.insert(
115 id,
116 crate::db::Attachment {
117 id,
118 entries: HashSet::new(),
119 data,
120 },
121 );
122 }
123 }
124
125 let custom_icons = self
126 .meta
127 .custom_icons
128 .take()
129 .map(|ci| {
130 ci.icons
131 .into_iter()
132 .map(|icon| {
133 let ci: crate::db::CustomIcon = icon.into();
134 (ci.id, ci)
135 })
136 .collect()
137 })
138 .unwrap_or_default();
139
140 db.meta = self.meta.into();
141
142 db.deleted_objects = self
143 .root
144 .deleted_objects
145 .map(|del_objs| {
146 del_objs
147 .objects
148 .into_iter()
149 .map(|obj| (obj.uuid.0, obj.deletion_time.map(|ts| ts.time)))
150 .collect()
151 })
152 .unwrap_or_default();
153
154 self.root
155 .group
156 .xml_to_db_handle(db.root_mut(), &attachments, &custom_icons, inner_decryptor)?;
157
158 db.attachments = attachments;
159 db.custom_icons = custom_icons;
160
161 let entry_ids: Vec<crate::db::EntryId> = db.entries.keys().copied().collect();
168 for entry_id in entry_ids {
169 if let Some(crate::db::Icon::Custom(icon_id)) =
171 db.entries.get(&entry_id).and_then(|e| e.icon.as_ref())
172 {
173 if let Some(icon) = db.custom_icons.get_mut(icon_id) {
174 icon.entries.insert((entry_id, None));
175 }
176 }
177
178 if let Some(entry) = db.entries.get(&entry_id) {
180 let history_len = entry.history.as_ref().map_or(0, |h| h.entries.len());
181
182 for i in 0..history_len {
183 #[allow(clippy::indexing_slicing)] if let Some(crate::db::Icon::Custom(icon_id)) =
185 entry.history.as_ref().and_then(|h| h.entries[i].icon.as_ref())
186 {
187 if let Some(icon) = db.custom_icons.get_mut(icon_id) {
188 icon.entries.insert((entry_id, Some(i)));
189 }
190 }
191 }
192 }
193 }
194
195 let group_ids: Vec<crate::db::GroupId> = db.groups.keys().copied().collect();
196 for group_id in group_ids {
197 if let Some(crate::db::Icon::Custom(icon_id)) =
198 db.groups.get(&group_id).and_then(|g| g.icon.as_ref())
199 {
200 if let Some(icon) = db.custom_icons.get_mut(icon_id) {
201 icon.groups.insert(group_id);
202 }
203 }
204 }
205
206 Ok(db)
207 }
208
209 #[cfg(feature = "save_kdbx4")]
211 fn db_to_xml(db: &crate::db::Database, inner_cipher: &mut dyn Cipher) -> Result<Self, CryptographyError> {
212 use crate::format::xml_db::meta::Icon;
213
214 let group = Group::db_to_xml(db.root(), inner_cipher)?;
215
216 let mut meta: Meta = db.meta.clone().into();
217 meta.custom_icons.get_or_insert_default().icons =
218 db.custom_icons.values().cloned().map(Icon::from).collect();
219
220 let deleted_objects = if db.deleted_objects.is_empty() {
221 None
222 } else {
223 Some(DeletedObjects {
224 objects: db
225 .deleted_objects
226 .iter()
227 .map(|(uuid, deletion_time)| DeletedObject {
228 uuid: UUID(*uuid),
229 deletion_time: deletion_time.map(Timestamp::from),
230 })
231 .collect(),
232 })
233 };
234
235 Ok(KeePassFile {
236 meta,
237 root: Root {
238 group,
239 deleted_objects,
240 },
241 })
242 }
243}
244
245#[allow(clippy::upper_case_acronyms)] #[derive(Debug, PartialEq, Eq, Clone, Copy)]
248pub struct UUID(Uuid);
249
250impl<'de> Deserialize<'de> for UUID {
251 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
252 where
253 D: serde::Deserializer<'de>,
254 {
255 let input = String::deserialize(deserializer)?;
256
257 let v = base64_engine::STANDARD
258 .decode(input)
259 .map_err(serde::de::Error::custom)?;
260
261 let uuid = Uuid::from_slice(&v).map_err(serde::de::Error::custom)?;
262 Ok(UUID(uuid))
263 }
264}
265
266impl Serialize for UUID {
267 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
268 where
269 S: Serializer,
270 {
271 let b64 = base64_engine::STANDARD.encode(self.0.as_bytes());
272 serializer.serialize_str(&b64)
273 }
274}
275
276#[derive(Debug, Serialize, Deserialize)]
277pub struct Root {
278 #[serde(rename = "Group")]
279 pub group: Group,
280
281 #[serde(default, rename = "DeletedObjects")]
282 pub deleted_objects: Option<DeletedObjects>,
283}
284
285#[derive(Debug, Serialize, Deserialize)]
286pub struct DeletedObjects {
287 #[serde(default, rename = "DeletedObject")]
288 pub objects: Vec<DeletedObject>,
289}
290
291#[derive(Debug, Serialize, Deserialize)]
292pub struct DeletedObject {
293 #[serde(rename = "UUID")]
294 uuid: UUID,
295
296 #[serde(
297 default,
298 rename = "DeletionTime",
299 alias = "deletion_time",
300 with = "cs_opt_string"
301 )]
302 deletion_time: Option<Timestamp>,
303}
304
305#[allow(clippy::unwrap_used)]
306#[cfg(test)]
307mod tests {
308
309 use super::*;
310
311 #[derive(Serialize, Deserialize)]
312 struct Test<T>(T);
313
314 #[test]
315 fn test_deserialize_uuid() {
316 let uuid_str = "AAECAwQFBgcICQoLDA0ODw==";
317 let uuid: UUID = quick_xml::de::from_str(uuid_str).unwrap();
318 assert_eq!(
319 uuid.0.as_bytes(),
320 &[0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f]
321 );
322 }
323
324 #[test]
325 fn test_serialize_uuid() {
326 let uuid = UUID(Uuid::from_bytes([
327 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f,
328 ]));
329 let serialized = quick_xml::se::to_string(&Test(uuid)).unwrap();
330 assert_eq!(serialized, "<Test>AAECAwQFBgcICQoLDA0ODw==</Test>");
331 }
332
333 #[test]
334 fn test_serialize_deleted_object_deletion_time() {
335 let deleted_object = DeletedObject {
336 uuid: UUID(Uuid::nil()),
337 deletion_time: Some(Timestamp::new_iso8601(
338 chrono::NaiveDateTime::parse_from_str("2026-08-15T12:34:56", "%Y-%m-%dT%H:%M:%S").unwrap(),
339 )),
340 };
341
342 let serialized = quick_xml::se::to_string_with_root("DeletedObject", &deleted_object).unwrap();
343
344 assert!(serialized.contains("<DeletionTime>"));
345 assert!(!serialized.contains("<deletion_time>"));
346 }
347
348 #[test]
349 fn test_deserialize_legacy_deleted_object_deletion_time() {
350 let deleted_object: DeletedObject = quick_xml::de::from_str(
351 "<DeletedObject><UUID>AAAAAAAAAAAAAAAAAAAAAA==</UUID><deletion_time>2026-08-15T12:34:56Z</deletion_time></DeletedObject>",
352 )
353 .unwrap();
354
355 assert!(deleted_object.deletion_time.is_some());
356 }
357
358 #[cfg(feature = "save_kdbx4")]
359 #[test]
360 fn test_serialize_deletion_time_mode() {
361 let xml = r#"<KeePassFile>
362 <Meta></Meta>
363 <Root>
364 <Group><UUID>tP/vJ/3uSHyomfPZ4dXVlg==</UUID><Name></Name></Group>
365 <DeletedObjects>
366 <DeletedObject>
367 <UUID>30lsaI9KSYefuJb0PHSRiw==</UUID>
368 <DeletionTime>io8Y4g4AAAA=</DeletionTime>
369 </DeletedObject>
370 </DeletedObjects>
371 </Root>
372 </KeePassFile>"#;
373 let mut cipher = crate::config::InnerCipherConfig::Plain.get_cipher(&[]).unwrap();
374 let db = parse_xml(xml.as_bytes(), &[], &mut *cipher).unwrap();
375 let kdbx = KeePassFile::db_to_xml(&db, &mut *cipher).unwrap();
376 let xml = quick_xml::se::to_string_with_root("DeletedObjects", &kdbx.root.deleted_objects).unwrap();
377 assert!(xml.contains("<DeletionTime>io8Y4g4AAAA=</DeletionTime>"));
378 }
379}