Skip to main content

keepass/format/xml_db/
mod.rs

1//! XML (de)serialization for KeePass databases.
2//!
3//! This module provides types that mirror the ones in `crate::db`, but are tailored to closely fit
4//! the XML structure of KeePass databases for easy `#[derive(Serialize, Deserialize)]`.
5//!
6//! See <https://keepass.info/help/download/KDBX_XML.xsd> for an XML schema.
7
8pub 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/// Errors that can occur during parsing of the inner XML database of a KDBX file
43#[derive(Debug, Error)]
44#[non_exhaustive]
45pub enum ParseXmlError {
46    /// Errors related to XML deserialization or serialization.
47    #[error("Error parsing XML inside KDBX: {0}")]
48    Xml(#[from] quick_xml::DeError),
49
50    /// Errors related to unprotecting entries, such as decryption failures or unsupported
51    /// encryption methods.
52    #[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    /// Convert from XML representation to database representation.
89    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        // convert header attachments (KDBX4-style) to database attachments
99        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        // convert XML attachments (KDBX3-style) to database attachments
109        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        // Re-populate CustomIcon back-reference sets.
162        //
163        // The XML parser creates CustomIcon values with empty `entries` and `groups` sets
164        // because icon data and entry/group data live in separate parts of the XML file.
165        // We perform a single pass here to reconstruct all back-references from the icon
166        // fields that were already set on each entry and group during xml_to_db_handle.
167        let entry_ids: Vec<crate::db::EntryId> = db.entries.keys().copied().collect();
168        for entry_id in entry_ids {
169            // current version
170            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            // historical versions
179            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)] // We just checked that the index is in bounds
184                    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    /// Convert from database representation to XML representation.
210    #[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/// A UUID deserialized from a Base64 string.
246#[allow(clippy::upper_case_acronyms)] // Keep the name consistent with KeePass XML schema
247#[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}