Skip to content

Commit 4dd9fee

Browse files
committed
Add database merge functionality
Implement full database merge support following KeePassXC's conceptual algorithm with UUID-based matching and timestamp-based conflict resolution. Features: - MergeMode.STANDARD: Add and update entries/groups (safe default) - MergeMode.SYNCHRONIZE: Also apply deletions from source - Timestamp-based conflict resolution (newer modification wins) - Losing version preserved in winner's history - Location change tracking via location_changed timestamp - Binary deduplication by SHA-256 hash - Custom icon merging by UUID - Recycle bin exclusion during merge - DeletedObjects support for SYNCHRONIZE mode Public API: - Database.merge(source, mode=MergeMode.STANDARD) -> MergeResult - MergeMode enum (STANDARD, SYNCHRONIZE) - MergeResult dataclass with change counts and summary() - MergeError exception for merge failures - DeletedObject dataclass for tracking deletions Includes 31 comprehensive tests covering basic merges, conflicts, groups, history, binaries, custom icons, location changes, and synchronize mode deletions. Closes #54
1 parent cbdde30 commit 4dd9fee

File tree

5 files changed

+1297
-1
lines changed

5 files changed

+1297
-1
lines changed

src/kdbxtool/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
Kdbx3UpgradeRequired,
4343
KdbxError,
4444
KdfError,
45+
MergeError,
4546
MissingCredentialsError,
4647
TwofishNotAvailableError,
4748
UnknownCipherError,
@@ -52,6 +53,7 @@
5253
YubiKeySlotError,
5354
YubiKeyTimeoutError,
5455
)
56+
from .merge import DeletedObject, MergeMode, MergeResult
5557
from .models import Attachment, Entry, Group, HistoryEntry, Times
5658
from .security import AesKdfConfig, Argon2Config, Cipher, KdfType
5759
from .security.keyfile import (
@@ -79,6 +81,10 @@
7981
"Times",
8082
"Cipher",
8183
"KdfType",
84+
# Merge support
85+
"MergeMode",
86+
"MergeResult",
87+
"DeletedObject",
8288
# Keyfile support
8389
"KeyFileVersion",
8490
"create_keyfile",
@@ -103,6 +109,7 @@
103109
"CredentialError",
104110
"InvalidPasswordError",
105111
"InvalidKeyFileError",
112+
"MergeError",
106113
"MissingCredentialsError",
107114
"DatabaseError",
108115
"EntryNotFoundError",

src/kdbxtool/database.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,12 @@
2121
from datetime import UTC, datetime, timedelta
2222
from pathlib import Path
2323
from types import TracebackType
24-
from typing import Protocol, cast
24+
from typing import TYPE_CHECKING, Protocol, cast
2525
from xml.etree.ElementTree import Element, SubElement, tostring
2626

27+
if TYPE_CHECKING:
28+
from .merge import DeletedObject
29+
2730
from Cryptodome.Cipher import ChaCha20, Salsa20
2831
from defusedxml import ElementTree as DefusedET
2932

@@ -170,6 +173,7 @@ class DatabaseSettings:
170173
history_max_items: int = 10
171174
history_max_size: int = 6 * 1024 * 1024 # 6 MiB
172175
custom_icons: dict[uuid_module.UUID, CustomIcon] = field(default_factory=dict)
176+
deleted_objects: list["DeletedObject"] = field(default_factory=list)
173177

174178

175179
class Database:
@@ -307,6 +311,45 @@ def dump(self) -> str:
307311

308312
return "\n".join(lines)
309313

314+
def merge(
315+
self,
316+
source: "Database",
317+
*,
318+
mode: "MergeMode" = None,
319+
) -> "MergeResult":
320+
"""Merge another database into this one.
321+
322+
Combines entries, groups, history, attachments, and custom icons
323+
from the source database into this database using UUID-based
324+
matching and timestamp-based conflict resolution.
325+
326+
Args:
327+
source: Database to merge from (read-only)
328+
mode: Merge mode (STANDARD or SYNCHRONIZE). Defaults to STANDARD.
329+
- STANDARD: Add and update only, never deletes
330+
- SYNCHRONIZE: Full sync including deletions
331+
332+
Returns:
333+
MergeResult with counts and statistics about the merge
334+
335+
Raises:
336+
MergeError: If merge cannot be completed
337+
338+
Example:
339+
>>> target_db = Database.open("main.kdbx", password="secret")
340+
>>> source_db = Database.open("branch.kdbx", password="secret")
341+
>>> result = target_db.merge(source_db)
342+
>>> print(f"Added {result.entries_added} entries")
343+
>>> target_db.save()
344+
"""
345+
from .merge import MergeMode, Merger, MergeResult
346+
347+
if mode is None:
348+
mode = MergeMode.STANDARD
349+
350+
merger = Merger(self, source, mode=mode)
351+
return merger.merge()
352+
310353
@property
311354
def transformed_key(self) -> bytes | None:
312355
"""Get the transformed key for caching.

src/kdbxtool/exceptions.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,3 +317,14 @@ def __init__(self) -> None:
317317
"Saving a KDBX3 database will upgrade it to KDBX4 format. "
318318
"Use save(allow_upgrade=True) to confirm, or save to a different file."
319319
)
320+
321+
322+
class MergeError(DatabaseError):
323+
"""Error during database merge operation.
324+
325+
Raised when a merge operation fails due to incompatible databases,
326+
invalid state, or other merge-specific issues.
327+
"""
328+
329+
def __init__(self, message: str = "Merge operation failed") -> None:
330+
super().__init__(message)

0 commit comments

Comments
 (0)