From ae7f17bde3c1deb02fdcad2c4589952b9d114020 Mon Sep 17 00:00:00 2001 From: Chris Tomlinson Date: Thu, 15 Feb 2024 10:53:33 +0000 Subject: [PATCH] Kdbx 4.1 (#6) Support KDBX v4.1 (now default) Also adds some 4.0 missing features like Group custom data Remove deprecations, lint issues, etc. --- .github/workflows/dart.yml | 2 +- analysis_options.yaml | 1 + dart_test.yaml | 4 + lib/src/kdbx_custom_data.dart | 82 ++++++- lib/src/kdbx_entry.dart | 32 +-- lib/src/kdbx_file.dart | 50 ++++- lib/src/kdbx_format.dart | 8 +- lib/src/kdbx_group.dart | 9 +- lib/src/kdbx_header.dart | 13 +- lib/src/kdbx_meta.dart | 126 +++++++++-- lib/src/kdbx_object.dart | 43 +++- lib/src/kdbx_xml.dart | 52 +++-- test/icon/kdbx_customicon_test.dart | 4 + test/kdbx4_1_test.dart | 99 +++++++++ test/kdbx4_test_pointycastle.dart | 2 +- test/kdbx_binaries_test.dart | 8 +- test/kdbx_customdata_test.dart | 60 +++++ test/kdbx_upgrade_test.dart | 87 +++++++- test/merge/kdbx_merge_test.dart | 328 ++++++++++++++++++++++++---- 19 files changed, 876 insertions(+), 134 deletions(-) create mode 100644 test/kdbx4_1_test.dart create mode 100644 test/kdbx_customdata_test.dart diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml index 4af9f83..c045d73 100644 --- a/.github/workflows/dart.yml +++ b/.github/workflows/dart.yml @@ -13,7 +13,7 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: dart-lang/setup-dart@v1 with: sdk: ${{ matrix.sdk }} diff --git a/analysis_options.yaml b/analysis_options.yaml index d3426df..ef5dd9f 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -11,6 +11,7 @@ analyzer: missing_return: warning # allow having TODOs in the code todo: ignore + invalid_use_of_protected_member: error language: strict-casts: true strict-raw-types: true diff --git a/dart_test.yaml b/dart_test.yaml index d89c09d..bc7d675 100644 --- a/dart_test.yaml +++ b/dart_test.yaml @@ -1,3 +1,7 @@ tags: kdbx3: {} kdbx4: {} + +# macOs github runners are slower +on_platform: + mac-os: {timeout: 2x} \ No newline at end of file diff --git a/lib/src/kdbx_custom_data.dart b/lib/src/kdbx_custom_data.dart index 37ec755..eb7dd0c 100644 --- a/lib/src/kdbx_custom_data.dart +++ b/lib/src/kdbx_custom_data.dart @@ -3,12 +3,12 @@ import 'package:kdbx/src/kdbx_object.dart'; import 'package:kdbx/src/kdbx_xml.dart'; import 'package:xml/xml.dart' as xml; -class KdbxCustomData extends KdbxNode { - KdbxCustomData.create() +class KdbxObjectCustomData extends KdbxNode { + KdbxObjectCustomData.create() : _data = {}, - super.create(TAG_NAME); + super.create(KdbxXml.NODE_CUSTOM_DATA); - KdbxCustomData.read(xml.XmlElement node) + KdbxObjectCustomData.read(xml.XmlElement node) : _data = Map.fromEntries( node.findElements(KdbxXml.NODE_CUSTOM_DATA_ITEM).map((el) { final key = el.singleTextNode(KdbxXml.NODE_KEY); @@ -17,8 +17,6 @@ class KdbxCustomData extends KdbxNode { })), super.read(node); - static const String TAG_NAME = KdbxXml.NODE_CUSTOM_DATA; - final Map _data; Iterable> get entries => _data.entries; @@ -29,6 +27,7 @@ class KdbxCustomData extends KdbxNode { } bool containsKey(String key) => _data.containsKey(key); + String? remove(String key) => modify(() => _data.remove(key)); @override xml.XmlElement toXml() { @@ -44,7 +43,76 @@ class KdbxCustomData extends KdbxNode { return el; } - void overwriteFrom(KdbxCustomData other) { + void overwriteFrom(KdbxObjectCustomData other) { + _data.clear(); + _data.addAll(other._data); + } +} + +typedef KdbxMetaCustomDataItem = ({ + String value, + DateTime? lastModified, +}); + +class KdbxMetaCustomData extends KdbxNode { + KdbxMetaCustomData.create() + : _data = {}, + super.create(KdbxXml.NODE_CUSTOM_DATA); + + KdbxMetaCustomData.read(xml.XmlElement node) + : _data = Map.fromEntries( + node.findElements(KdbxXml.NODE_CUSTOM_DATA_ITEM).map((el) { + final key = el.singleTextNode(KdbxXml.NODE_KEY); + final value = el.singleTextNode(KdbxXml.NODE_VALUE); + final lastModified = + el.singleElement(KdbxXml.NODE_LAST_MODIFICATION_TIME)?.innerText; + return MapEntry(key, ( + value: value, + lastModified: lastModified != null + ? DateTimeUtils.fromBase64(lastModified) + : null + )); + })), + super.read(node); + + final Map _data; + + Iterable> get entries => + _data.entries; + + KdbxMetaCustomDataItem? operator [](String key) => _data[key]; + void operator []=(String key, KdbxMetaCustomDataItem value) { + modify(() => _data[key] = value); + } + + bool containsKey(String key) => _data.containsKey(key); + KdbxMetaCustomDataItem? remove(String key) => modify(() => _data.remove(key)); + + @override + xml.XmlElement toXml() { + final el = super.toXml(); + el.children.clear(); + el.children.addAll( + _data.entries.map((e) { + //TODO: We don't have any context here so have to output everything regardless + // of intended kdbx version. Maybe we can improve that one day to allow + // safer output of earlier kdbx versions? + final d = e.value.lastModified != null + ? DateTimeUtils.toBase64(e.value.lastModified!) + : null; + + return XmlUtils.createNode(KdbxXml.NODE_CUSTOM_DATA_ITEM, [ + XmlUtils.createTextNode(KdbxXml.NODE_KEY, e.key), + XmlUtils.createTextNode(KdbxXml.NODE_VALUE, e.value.value), + if (d != null) + XmlUtils.createTextNode(KdbxXml.NODE_LAST_MODIFICATION_TIME, d), + ]); + }), + ); + return el; + } + + void overwriteFrom(KdbxMetaCustomData other) { _data.clear(); _data.addAll(other._data); } diff --git a/lib/src/kdbx_entry.dart b/lib/src/kdbx_entry.dart index 12ab3aa..429c0c9 100644 --- a/lib/src/kdbx_entry.dart +++ b/lib/src/kdbx_entry.dart @@ -389,7 +389,7 @@ extension KdbxEntryInternal on KdbxEntry { foregroundColor, backgroundColor, overrideURL, - tags, + qualityCheck, ]; void _overwriteFrom( @@ -426,6 +426,8 @@ extension KdbxEntryInternal on KdbxEntry { )); _binaries.clear(); _binaries.addAll(newBinaries); + // Dart doesn't know this is actually OK since it's an extension to a subclass + // ignore: invalid_use_of_protected_member customData.overwriteFrom(other.customData); times.overwriteFrom(other.times); if (includeHistory) { @@ -456,8 +458,7 @@ class KdbxEntry extends KdbxObject { KdbxFile file, KdbxGroup parent, { this.isHistoryEntry = false, - }) : customData = KdbxCustomData.create(), - history = [], + }) : history = [], super.create(file.ctx, file, 'Entry', parent) { icon.set(KdbxIcon.Key); _browserSettings = BrowserEntrySettings( @@ -467,11 +468,7 @@ class KdbxEntry extends KdbxObject { KdbxEntry.read(KdbxReadWriteContext ctx, KdbxGroup? parent, XmlElement node, {this.isHistoryEntry = false}) - : customData = node - .singleElement(KdbxXml.NODE_CUSTOM_DATA) - ?.let((e) => KdbxCustomData.read(e)) ?? - KdbxCustomData.create(), - history = [], + : history = [], super.read(ctx, parent, node) { _strings.addEntries(node.findElements(KdbxXml.NODE_STRING).map((el) { final key = KdbxKey(el.findElements(KdbxXml.NODE_KEY).single.text); @@ -552,16 +549,17 @@ class KdbxEntry extends KdbxObject { _browserSettings = null; } - final KdbxCustomData customData; - final bool isHistoryEntry; final List history; - ColorNode get foregroundColor => ColorNode(this, 'ForegroundColor'); - ColorNode get backgroundColor => ColorNode(this, 'BackgroundColor'); - StringNode get overrideURL => StringNode(this, 'OverrideURL'); - StringListNode get tags => StringListNode(this, 'Tags'); + ColorNode get foregroundColor => + ColorNode(this, KdbxXml.NODE_FOREGROUND_COLOR); + ColorNode get backgroundColor => + ColorNode(this, KdbxXml.NODE_BACKGROUND_COLOR); + StringNode get overrideURL => StringNode(this, KdbxXml.NODE_OVERRIDE_URL); + NullableBooleanNode get qualityCheck => + NullableBooleanNode(this, KdbxXml.NODE_QUALITY_CHECK); @override set file(KdbxFile? file) { @@ -617,6 +615,12 @@ class KdbxEntry extends KdbxObject { @override XmlElement toXml() { final el = super.toXml()..replaceSingle(customData.toXml()); + + if (ctx.version < KdbxVersion.V4_1) { + XmlUtils.removeChildrenByName(el, KdbxXml.NODE_QUALITY_CHECK); + XmlUtils.removeChildrenByName(el, KdbxXml.NODE_PREVIOUS_PARENT_GROUP); + } + XmlUtils.removeChildrenByName(el, KdbxXml.NODE_STRING); XmlUtils.removeChildrenByName(el, KdbxXml.NODE_HISTORY); XmlUtils.removeChildrenByName(el, KdbxXml.NODE_BINARY); diff --git a/lib/src/kdbx_file.dart b/lib/src/kdbx_file.dart index ad6028e..16b3885 100644 --- a/lib/src/kdbx_file.dart +++ b/lib/src/kdbx_file.dart @@ -2,15 +2,8 @@ import 'dart:async'; import 'dart:typed_data'; import 'package:collection/collection.dart'; -import 'package:kdbx/src/credentials/credentials.dart'; -import 'package:kdbx/src/crypto/protected_value.dart'; -import 'package:kdbx/src/kdbx_consts.dart'; -import 'package:kdbx/src/kdbx_dao.dart'; -import 'package:kdbx/src/kdbx_exceptions.dart'; +import 'package:kdbx/kdbx.dart'; import 'package:kdbx/src/kdbx_format.dart'; -import 'package:kdbx/src/kdbx_group.dart'; -import 'package:kdbx/src/kdbx_header.dart'; -import 'package:kdbx/src/kdbx_object.dart'; import 'package:logging/logging.dart'; import 'package:quiver/check.dart'; import 'package:synchronized/synchronized.dart'; @@ -150,17 +143,50 @@ class KdbxFile { return recycleBin ?? _createRecycleBin(); } - /// Upgrade v3 file to v4. - void upgrade(int majorVersion) { + /// Upgrade v3 file to v4.x + void upgrade(int majorVersion, int minorVersion) { checkArgument(majorVersion == 4, message: 'Must be majorVersion 4'); - body.meta.settingsChanged.setToNow(); body.meta.headerHash.remove(); - header.upgrade(majorVersion); + header.version.major == 4 + ? header.upgradeMinor(majorVersion, minorVersion) + : header.upgrade(majorVersion, minorVersion); + + upgradeDateTimeFormatV4(); + + body.meta.settingsChanged.setToNow(); + } + + void upgradeDateTimeFormatV4() { + body.meta.databaseNameChanged.upgrade(); + body.meta.databaseDescriptionChanged.upgrade(); + body.meta.defaultUserNameChanged.upgrade(); + body.meta.masterKeyChanged.upgrade(); + body.meta.recycleBinChanged.upgrade(); + body.meta.entryTemplatesGroupChanged.upgrade(); + body.meta.settingsChanged.upgrade(); + body.rootGroup.getAllGroups().values.forEach(upgradeAllObjectTimesV4); + body.rootGroup.getAllEntries().values.forEach(upgradeAllObjectTimesV4); + } + + void upgradeAllObjectTimesV4(KdbxObject obj) { + obj.times.creationTime.upgrade(); + obj.times.lastModificationTime.upgrade(); + obj.times.lastAccessTime.upgrade(); + obj.times.expiryTime.upgrade(); + obj.times.locationChanged.upgrade(); + + if (obj is KdbxEntry) { + obj.history.forEach(upgradeAllObjectTimesV4); + } } /// Merges the given file into this file. /// Both files must have the same origin (ie. same root group UUID). MergeContext merge(KdbxFile other) { + if (header.version < other.header.version) { + throw KdbxUnsupportedException( + 'Kdbx version of source is newer. Upgrade file version before attempting to merge.'); + } if (other.body.rootGroup.uuid != body.rootGroup.uuid) { throw KdbxUnsupportedException( 'Root groups of source and dest file do not match.'); diff --git a/lib/src/kdbx_format.dart b/lib/src/kdbx_format.dart index 9834bac..edc0c45 100644 --- a/lib/src/kdbx_format.dart +++ b/lib/src/kdbx_format.dart @@ -61,6 +61,7 @@ class KdbxReadWriteContext { final KdbxHeader header; int get versionMajor => header.version.major; + KdbxVersion get version => header.version; void initContext(Iterable binaries, Iterable deletedObjects) { @@ -316,7 +317,8 @@ class KdbxBody extends KdbxNode { final now = clock.now().toUtc(); final historyMaxItems = (meta.historyMaxItems.get() ?? 0) > 0 ? meta.historyMaxItems.get() - : double.maxFinite as int; + : (double.maxFinite).toInt(); + final usedCustomIcons = HashSet(); final unusedCustomIcons = HashSet(); final usedBinaries = {}; @@ -519,14 +521,14 @@ class KdbxFormat { static bool dartWebWorkaround = false; /// Creates a new, empty [KdbxFile] with default settings. - /// If [header] is not given by default a kdbx 4.0 file will be created. + /// If [header] is not given by default a kdbx 4.1 file will be created. KdbxFile create( Credentials credentials, String name, { String? generator, KdbxHeader? header, }) { - header ??= KdbxHeader.createV4(); + header ??= KdbxHeader.createV4_1(); final ctx = KdbxReadWriteContext(header: header); final meta = KdbxMeta.create( databaseName: name, diff --git a/lib/src/kdbx_group.dart b/lib/src/kdbx_group.dart index 53fd244..1d7520a 100644 --- a/lib/src/kdbx_group.dart +++ b/lib/src/kdbx_group.dart @@ -42,7 +42,13 @@ class KdbxGroup extends KdbxObject { @override XmlElement toXml() { - final el = super.toXml(); + final el = super.toXml()..replaceSingle(customData.toXml()); + + if (ctx.version < KdbxVersion.V4_1) { + XmlUtils.removeChildrenByName(el, KdbxXml.NODE_TAGS); + XmlUtils.removeChildrenByName(el, KdbxXml.NODE_PREVIOUS_PARENT_GROUP); + } + XmlUtils.removeChildrenByName(el, 'Group'); XmlUtils.removeChildrenByName(el, 'Entry'); el.children.addAll(groups.values.map((g) => g.toXml())); @@ -254,6 +260,7 @@ class KdbxGroup extends KdbxObject { overwriteSubNodesFrom(mergeContext, _overwriteNodes, other._overwriteNodes); // we should probably check that [lastTopVisibleEntry] is still a // valid reference? + customData.overwriteFrom(other.customData); times.overwriteFrom(other.times); } diff --git a/lib/src/kdbx_header.dart b/lib/src/kdbx_header.dart index 05f1ed6..9101476 100644 --- a/lib/src/kdbx_header.dart +++ b/lib/src/kdbx_header.dart @@ -569,11 +569,18 @@ class KdbxHeader { void writeKdfParameters(VarDictionary kdfParameters) => _setHeaderField(HeaderFields.KdfParameters, kdfParameters.write()); - void upgrade(int majorVersion) { + void upgradeMinor(int majorVersion, int minorVersion) { + checkArgument(majorVersion == KdbxVersion.V4.major, + message: 'Can only upgrade v4'); + _logger.info('Upgrading header to $minorVersion'); + _version = KdbxVersion._(majorVersion, minorVersion); + } + + void upgrade(int majorVersion, int minorVersion) { checkArgument(majorVersion == KdbxVersion.V4.major, message: 'Can only upgrade to 4'); - _logger.info('Upgrading header to $majorVersion'); - _version = KdbxVersion._(majorVersion, 0); + _logger.info('Upgrading header to $majorVersion.$minorVersion'); + _version = KdbxVersion._(majorVersion, minorVersion); if (fields[HeaderFields.KdfParameters] == null) { _logger.fine('Creating kdf parameters.'); writeKdfParameters(_createKdfDefaultParameters()); diff --git a/lib/src/kdbx_meta.dart b/lib/src/kdbx_meta.dart index d270d07..a7ee9f4 100644 --- a/lib/src/kdbx_meta.dart +++ b/lib/src/kdbx_meta.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:typed_data'; +import 'package:clock/clock.dart'; import 'package:collection/collection.dart'; import 'package:kdbx/src/internal/extension_utils.dart'; import 'package:kdbx/src/kdbx_binary.dart'; @@ -24,7 +25,7 @@ class KdbxMeta extends KdbxNode implements KdbxNodeContext { required String databaseName, required this.ctx, String? generator, - }) : customData = KdbxCustomData.create(), + }) : customData = KdbxMetaCustomData.create(), binaries = [], _customIcons = {}, super.create('Meta') { @@ -42,8 +43,8 @@ class KdbxMeta extends KdbxNode implements KdbxNodeContext { KdbxMeta.read(xml.XmlElement node, this.ctx) : customData = node .singleElement(KdbxXml.NODE_CUSTOM_DATA) - ?.let((e) => KdbxCustomData.read(e)) ?? - KdbxCustomData.create(), + ?.let((e) => KdbxMetaCustomData.read(e)) ?? + KdbxMetaCustomData.create(), binaries = node .singleElement(KdbxXml.NODE_BINARIES) ?.let((el) sync* { @@ -71,11 +72,20 @@ class KdbxMeta extends KdbxNode implements KdbxNodeContext { .singleElement(KdbxXml.NODE_CUSTOM_ICONS) ?.let((el) sync* { for (final iconNode in el.findElements(KdbxXml.NODE_ICON)) { + final lastModified = iconNode + .singleElement(KdbxXml.NODE_LAST_MODIFICATION_TIME) + ?.innerText; yield KdbxCustomIcon( uuid: KdbxUuid( iconNode.singleTextNode(KdbxXml.NODE_UUID)), - data: base64.decode( - iconNode.singleTextNode(KdbxXml.NODE_DATA))); + data: base64 + .decode(iconNode.singleTextNode(KdbxXml.NODE_DATA)), + name: iconNode + .singleElement(KdbxXml.NODE_NAME) + ?.innerText, + lastModified: lastModified != null + ? DateTimeUtils.fromBase64(lastModified) + : null); } }) .map((e) => MapEntry(e.uuid, e)) @@ -86,7 +96,7 @@ class KdbxMeta extends KdbxNode implements KdbxNodeContext { @override final KdbxReadWriteContext ctx; - final KdbxCustomData customData; + final KdbxMetaCustomData customData; /// only used in Kdbx 3 final List? binaries; @@ -103,6 +113,10 @@ class KdbxMeta extends KdbxNode implements KdbxNodeContext { modify(() => _customIcons[customIcon.uuid] = customIcon); } + void modifyCustomIcon(KdbxCustomIcon customIcon) { + modify(() => _customIcons[customIcon.uuid] = customIcon); + } + void removeCustomIcon(KdbxUuid id) { if (!_customIcons.containsKey(id)) { return; @@ -162,7 +176,7 @@ class KdbxMeta extends KdbxNode implements KdbxNodeContext { BrowserDbSettings? _browserSettings; BrowserDbSettings get browserSettings { if (_browserSettings == null) { - final tempJson = customData['KeePassRPC.Config']; + final tempJson = customData['KeePassRPC.Config']?.value; if (tempJson != null) { _browserSettings = BrowserDbSettings.fromJson(tempJson); @@ -174,14 +188,15 @@ class KdbxMeta extends KdbxNode implements KdbxNodeContext { } set browserSettings(BrowserDbSettings settings) { - customData['KeePassRPC.Config'] = settings.toJson(); + customData['KeePassRPC.Config'] = + (value: settings.toJson(), lastModified: clock.now().toUtc()); settingsChanged.setToNow(); } KeeVaultEmbeddedConfig? _keeVaultSettings; KeeVaultEmbeddedConfig get keeVaultSettings { if (_keeVaultSettings == null) { - final tempJson = customData['KeeVault.Config']; + final tempJson = customData['KeeVault.Config']?.value; if (tempJson != null) { _keeVaultSettings = KeeVaultEmbeddedConfig.fromJson(tempJson); @@ -193,7 +208,8 @@ class KdbxMeta extends KdbxNode implements KdbxNodeContext { } set keeVaultSettings(KeeVaultEmbeddedConfig settings) { - customData['KeeVault.Config'] = settings.toJson(); + customData['KeeVault.Config'] = + (value: settings.toJson(), lastModified: clock.now().toUtc()); settingsChanged.setToNow(); } @@ -222,7 +238,12 @@ class KdbxMeta extends KdbxNode implements KdbxNodeContext { (customIcon) => XmlUtils.createNode(KdbxXml.NODE_ICON, [ XmlUtils.createTextNode(KdbxXml.NODE_UUID, customIcon.uuid.uuid), XmlUtils.createTextNode( - KdbxXml.NODE_DATA, base64.encode(customIcon.data)) + KdbxXml.NODE_DATA, base64.encode(customIcon.data)), + if (ctx.version > KdbxVersion.V4 && customIcon.name != null) + XmlUtils.createTextNode(KdbxXml.NODE_NAME, customIcon.name!), + if (ctx.version > KdbxVersion.V4 && customIcon.lastModified != null) + XmlUtils.createTextNode(KdbxXml.NODE_LAST_MODIFICATION_TIME, + DateTimeUtils.toBase64(customIcon.lastModified!)), ]), )), ); @@ -257,18 +278,12 @@ class KdbxMeta extends KdbxNode implements KdbxNodeContext { final otherIsNewer = other.settingsChanged.isAfter(settingsChanged); // merge custom data - for (final otherCustomDataEntry in other.customData.entries) { - if ((otherIsNewer || !customData.containsKey(otherCustomDataEntry.key)) && - !ctx.deletedObjects.containsKey(otherCustomDataEntry.key)) { - customData[otherCustomDataEntry.key] = otherCustomDataEntry.value; - } - } + mergeKdbxMetaCustomDataWithDates( + customData, other.customData, ctx, otherIsNewer); // merge custom icons // Unused icons will be cleaned up later - for (final otherCustomIcon in other._customIcons.values) { - _customIcons[otherCustomIcon.uuid] ??= otherCustomIcon; - } + mergeCustomIconsWithDates(_customIcons, other._customIcons, ctx); if (other.entryTemplatesGroupChanged.isAfter(entryTemplatesGroupChanged)) { entryTemplatesGroup.set(other.entryTemplatesGroup.get()); @@ -291,6 +306,66 @@ class KdbxMeta extends KdbxNode implements KdbxNodeContext { } } + void mergeKdbxMetaCustomDataWithDates( + KdbxMetaCustomData local, + KdbxMetaCustomData other, + MergeContext ctx, + bool assumeRemoteIsNewerWhenDatesMissing) { + for (final entry in other.entries) { + final otherKey = entry.key; + final otherItem = entry.value; + final existingItem = local[otherKey]; + if (existingItem != null) { + if ((existingItem.lastModified == null || + otherItem.lastModified == null) && + assumeRemoteIsNewerWhenDatesMissing) { + local[otherKey] = ( + value: otherItem.value, + lastModified: otherItem.lastModified ?? clock.now().toUtc(), + ); + } else if (existingItem.lastModified != null && + otherItem.lastModified != null && + otherItem.lastModified!.isAfter(existingItem.lastModified!)) { + local[otherKey] = otherItem; + } + } else if (!ctx.deletedObjects.containsKey(otherKey)) { + local[otherKey] = otherItem; + } + } + } + + void mergeCustomIconsWithDates( + Map local, + Map other, + MergeContext ctx, + ) { + for (final entry in other.entries) { + final otherKey = entry.key; + final otherItem = entry.value; + final existingItem = local[otherKey]; + if (existingItem != null) { + if (existingItem.lastModified == null) { + local[otherKey] = KdbxCustomIcon( + uuid: otherItem.uuid, + data: otherItem.data, + lastModified: otherItem.lastModified ?? clock.now().toUtc(), + name: otherItem.name, + ); + } else if (otherItem.lastModified != null && + otherItem.lastModified!.isAfter(existingItem.lastModified!)) { + local[otherKey] = otherItem; + } + } else if (!ctx.deletedObjects.containsKey(otherKey)) { + local[otherKey] = KdbxCustomIcon( + uuid: otherItem.uuid, + data: otherItem.data, + lastModified: otherItem.lastModified ?? clock.now().toUtc(), + name: otherItem.name, + ); + } + } + } + // Import changes in [other] into this meta data. void import(KdbxMeta other) { // import custom icons @@ -512,11 +587,20 @@ class BrowserDbSettings { } class KdbxCustomIcon { - KdbxCustomIcon({required this.uuid, required this.data}); + KdbxCustomIcon({ + required this.uuid, + required this.data, + this.name, + this.lastModified, + }); /// uuid of the icon, must be unique within each file. final KdbxUuid uuid; /// Encoded png data of the image. will be base64 encoded into the kdbx file. final Uint8List data; + + final String? name; + + final DateTime? lastModified; } diff --git a/lib/src/kdbx_object.dart b/lib/src/kdbx_object.dart index dddcf9e..458f061 100644 --- a/lib/src/kdbx_object.dart +++ b/lib/src/kdbx_object.dart @@ -17,6 +17,8 @@ import 'package:uuid/uuid.dart'; import 'package:uuid/uuid_util.dart'; import 'package:xml/xml.dart'; +import 'kdbx_custom_data.dart'; + // ignore: unused_element final _logger = Logger('kdbx.kdbx_object'); @@ -140,10 +142,8 @@ extension UnmodifiableMapViewKdbxObject } extension KdbxObjectInternal on KdbxObject { - List> get objectNodes => [ - icon, - customIconUuid, - ]; + List> get objectNodes => + [icon, customIconUuid, previousParentGroup, tags]; /// should only be used in internal code, used to clone /// from one kdbx file into another. (like merging). @@ -178,14 +178,19 @@ abstract class KdbxObject extends KdbxNode { this.file, String nodeName, KdbxGroup? parent, - ) : times = KdbxTimes.create(ctx), + ) : customData = KdbxObjectCustomData.create(), + times = KdbxTimes.create(ctx), _parent = parent, super.create(nodeName) { _uuid.set(KdbxUuid.random()); } KdbxObject.read(this.ctx, KdbxGroup? parent, XmlElement node) - : times = KdbxTimes.read(node.findElements('Times').single, ctx), + : customData = node + .singleElement(KdbxXml.NODE_CUSTOM_DATA) + ?.let((e) => KdbxObjectCustomData.read(e)) ?? + KdbxObjectCustomData.create(), + times = KdbxTimes.read(node.findElements('Times').single, ctx), _parent = parent, super.read(node); @@ -210,7 +215,31 @@ abstract class KdbxObject extends KdbxNode { KdbxGroup? _parent; late final UuidNode previousParentGroup = - UuidNode(this, 'PreviousParentGroup'); + UuidNode(this, KdbxXml.NODE_PREVIOUS_PARENT_GROUP); + + StringListNode get tags => StringListNode(this, KdbxXml.NODE_TAGS); + + @protected + final KdbxObjectCustomData customData; + + String? getCustomData(String key) => customData[key]; + + void setCustomData(String key, String? value) { + if (customData[key] == value) { + _logger.finest('Custom data did not change for $key'); + return; + } + // We have to call modify from here to ensure the correct overload of + // onAfterModify gets called. Otherwise direct changes to a KdbxObjectCustomData + // node will not affect the modification date of the entry that contains that node. + modify(() { + if (value == null) { + customData.remove(key); + } else { + customData[key] = value; + } + }); + } bool get isInRecycleBin { final bin = file!.recycleBin; diff --git a/lib/src/kdbx_xml.dart b/lib/src/kdbx_xml.dart index 3f1b904..2a6fb5f 100644 --- a/lib/src/kdbx_xml.dart +++ b/lib/src/kdbx_xml.dart @@ -31,6 +31,12 @@ class KdbxXml { static const NODE_PREVIOUS_PARENT_GROUP = 'PreviousParentGroup'; static const NODE_CUSTOM_ICONS = 'CustomIcons'; static const NODE_CUSTOM_DATA = 'CustomData'; + static const NODE_QUALITY_CHECK = 'QualityCheck'; + static const NODE_OVERRIDE_URL = 'OverrideURL'; + static const NODE_BACKGROUND_COLOR = 'BackgroundColor'; + static const NODE_FOREGROUND_COLOR = 'ForegroundColor'; + static const NODE_TAGS = 'Tags'; + static const NODE_LAST_MODIFICATION_TIME = 'LastModificationTime'; /// CustomIcons >> Icon static const NODE_ICON = 'Icon'; @@ -38,6 +44,9 @@ class KdbxXml { /// CustomIcons >> Icon >> Data static const NODE_DATA = 'Data'; + /// CustomIcons >> Icon >> Name + static const NODE_NAME = 'Name'; + /// Used for objects UUID and CustomIcons static const NODE_UUID = 'UUID'; @@ -139,6 +148,13 @@ abstract class KdbxSubTextNode extends KdbxSubNode { String toString() { return '$runtimeType{${_opt(name)?.text}}'; } + + void upgrade() { + final T? value = get(); + if (value != null) { + _opt(name)?.innerText = encode(value) ?? ''; + } + } } class IntNode extends KdbxSubTextNode { @@ -270,8 +286,6 @@ class NullableBooleanNode extends KdbxSubTextNode { class DateTimeUtcNode extends KdbxSubTextNode { DateTimeUtcNode(KdbxNodeContext node, String name) : super(node, name); - static const EpochSeconds = 62135596800; - KdbxReadWriteContext get _ctx => (node as KdbxNodeContext).ctx; static final minDate = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); @@ -293,13 +307,7 @@ class DateTimeUtcNode extends KdbxSubTextNode { return DateTime.parse(value); } // kdbx 4.x uses base64 encoded date. - final decoded = base64.decode(value); - - final secondsFrom00 = ReaderHelper(decoded).readUint64(); - - return DateTime.fromMillisecondsSinceEpoch( - (secondsFrom00 - EpochSeconds) * 1000, - isUtc: true); + return DateTimeUtils.fromBase64(value); } catch (e, stackTrace) { _logger.severe( 'Error while parsing time for {$name}: {$value}', e, stackTrace); @@ -312,11 +320,7 @@ class DateTimeUtcNode extends KdbxSubTextNode { assert(value!.isUtc); if (_ctx.versionMajor >= 4) { // for kdbx v4 we need to support binary/base64 - final secondsFrom00 = - (value!.millisecondsSinceEpoch ~/ 1000) + EpochSeconds; - final encoded = base64.encode( - (WriterHelper()..writeUint64(secondsFrom00)).output.toBytes()); - return encoded; + return DateTimeUtils.toBase64(value!); } return DateTimeUtils.toIso8601StringSeconds(value!); } @@ -339,6 +343,26 @@ class XmlUtils { } class DateTimeUtils { + static const EpochSeconds = 62135596800; + static String toBase64(DateTime dateTime) { + final secondsFrom00 = + (dateTime.millisecondsSinceEpoch ~/ 1000) + EpochSeconds; + final encoded = base64 + .encode((WriterHelper()..writeUint64(secondsFrom00)).output.toBytes()); + + return encoded; + } + + static DateTime fromBase64(String value) { + final decoded = base64.decode(value); + + final secondsFrom00 = ReaderHelper(decoded).readUint64(); + + return DateTime.fromMillisecondsSinceEpoch( + (secondsFrom00 - EpochSeconds) * 1000, + isUtc: true); + } + static String toIso8601StringSeconds(DateTime dateTime) { final y = _fourDigits(dateTime.year); final m = _twoDigits(dateTime.month); diff --git a/test/icon/kdbx_customicon_test.dart b/test/icon/kdbx_customicon_test.dart index 3afa982..4741714 100644 --- a/test/icon/kdbx_customicon_test.dart +++ b/test/icon/kdbx_customicon_test.dart @@ -23,5 +23,9 @@ void main() { file.body.cleanup(); // actually deleted expect(file.body.meta.customIcons.length, 1); + expect( + file.body.deletedObjects.any((deletedObj) => + deletedObj.uuid.uuid == entry.customIconUuid.get()?.uuid), + true); }); } diff --git a/test/kdbx4_1_test.dart b/test/kdbx4_1_test.dart new file mode 100644 index 0000000..d6fb8f5 --- /dev/null +++ b/test/kdbx4_1_test.dart @@ -0,0 +1,99 @@ +@Tags(['kdbx4_1']) + +import 'package:kdbx/kdbx.dart'; +import 'package:logging/logging.dart'; +import 'package:logging_appenders/logging_appenders.dart'; +import 'package:test/test.dart'; +import 'internal/test_utils.dart'; + +final _logger = Logger('kdbx4_1_test'); + +// ignore_for_file: non_constant_identifier_names + +void main() { + Logger.root.level = Level.ALL; + PrintAppender().attachToLogger(Logger.root); + final kdbxFormat = TestUtil.kdbxFormat(); + if (!kdbxFormat.argon2.isFfi) { + throw StateError('Expected ffi!'); + } + + group('Kdbx v4.1', () { + // Probably should do similar to make v3 more robust too but we don't use that and there's no risk of regression so not now. + test('New features fail on v4.0', () async { + final credentials = Credentials(ProtectedValue.fromString('asdf')); + final kdbx = kdbxFormat.create( + credentials, + 'Test Keystore', + header: KdbxHeader.createV4(), + ); + final rootGroup = kdbx.body.rootGroup; + final e1 = TestUtil.createEntry(kdbx, rootGroup, 'user1', 'LoremIpsum'); + final e2 = + TestUtil.createEntry(kdbx, rootGroup, 'user2', 'Second Password'); + rootGroup.tags.set(['t1', 't2']); + e1.qualityCheck.set(true); + e2.qualityCheck.set(false); + final saved = await kdbx.save(); + + final loadedKdbx = await kdbxFormat.read( + saved, Credentials(ProtectedValue.fromString('asdf'))); + + _logger.fine('Successfully loaded kdbx $loadedKdbx'); + final entry1 = loadedKdbx.body.rootGroup.entries.first; + final entry2 = loadedKdbx.body.rootGroup.entries.last; + expect(entry1.qualityCheck.get(), null); + expect(entry2.qualityCheck.get(), null); + expect(loadedKdbx.body.rootGroup.tags.get(), null); + }); + + test('Tags work on entries and groups', () async { + final credentials = Credentials(ProtectedValue.fromString('asdf')); + final kdbx = kdbxFormat.create( + credentials, + 'Test Keystore', + header: KdbxHeader.createV4_1(), + ); + final rootGroup = kdbx.body.rootGroup; + final e = TestUtil.createEntry(kdbx, rootGroup, 'user1', 'LoremIpsum'); + TestUtil.createEntry(kdbx, rootGroup, 'user2', 'Second Password'); + rootGroup.tags.set(['t1', 't2']); + e.tags.set(['t3', 't4']); + final saved = await kdbx.save(); + + final loadedKdbx = await kdbxFormat.read( + saved, Credentials(ProtectedValue.fromString('asdf'))); + + _logger.fine('Successfully loaded kdbx $loadedKdbx'); + final firstEntry = loadedKdbx.body.rootGroup.entries.first; + expect(loadedKdbx.body.rootGroup.tags.get(), ['t1', 't2']); + expect(firstEntry.tags.get(), ['t3', 't4']); + }); + + test('Entry password quality estimation', () async { + final credentials = Credentials(ProtectedValue.fromString('asdf')); + final kdbx = kdbxFormat.create( + credentials, + 'Test Keystore', + header: KdbxHeader.createV4_1(), + ); + final rootGroup = kdbx.body.rootGroup; + final e1 = TestUtil.createEntry(kdbx, rootGroup, 'user1', 'LoremIpsum'); + final e2 = + TestUtil.createEntry(kdbx, rootGroup, 'user2', 'Second Password'); + expect(e1.qualityCheck.get(), null); + e1.qualityCheck.set(true); + e2.qualityCheck.set(false); + final saved = await kdbx.save(); + + final loadedKdbx = await kdbxFormat.read( + saved, Credentials(ProtectedValue.fromString('asdf'))); + + _logger.fine('Successfully loaded kdbx $loadedKdbx'); + final entry1 = loadedKdbx.body.rootGroup.entries.first; + final entry2 = loadedKdbx.body.rootGroup.entries.last; + expect(entry1.qualityCheck.get(), true); + expect(entry2.qualityCheck.get(), false); + }); + }); +} diff --git a/test/kdbx4_test_pointycastle.dart b/test/kdbx4_test_pointycastle.dart index 4bcadb4..ca49f5b 100644 --- a/test/kdbx4_test_pointycastle.dart +++ b/test/kdbx4_test_pointycastle.dart @@ -33,7 +33,7 @@ void main() { final kdbx = kdbxFormat.create( credentials, 'Test Keystore', - header: KdbxHeader.createV4(), + header: KdbxHeader.createV4_1(), ); final rootGroup = kdbx.body.rootGroup; _createEntry(kdbx, rootGroup, 'user1', 'LoremIpsum'); diff --git a/test/kdbx_binaries_test.dart b/test/kdbx_binaries_test.dart index 35f1e6e..130fb40 100644 --- a/test/kdbx_binaries_test.dart +++ b/test/kdbx_binaries_test.dart @@ -24,13 +24,9 @@ Future _testAddNewAttachment(String filePath) async { f.body.rootGroup.addEntry(entry); expect(entry.binaryEntries, hasLength(0)); entry.createBinary( - isProtected: false, - name: 'test.txt', - bytes: utf8.encode('Content1')); + isProtected: false, name: 'test.txt', bytes: utf8.encode('Content1')); entry.createBinary( - isProtected: false, - name: 'test.txt', - bytes: utf8.encode('Content2')); + isProtected: false, name: 'test.txt', bytes: utf8.encode('Content2')); return await f.save(); })(); { diff --git a/test/kdbx_customdata_test.dart b/test/kdbx_customdata_test.dart new file mode 100644 index 0000000..8ee2ba9 --- /dev/null +++ b/test/kdbx_customdata_test.dart @@ -0,0 +1,60 @@ +// ignore_for_file: invalid_use_of_protected_member + + +import 'package:clock/clock.dart'; +import 'package:kdbx/kdbx.dart'; +import 'package:logging/logging.dart'; +import 'package:logging_appenders/logging_appenders.dart'; +import 'package:test/test.dart'; + +import 'internal/test_utils.dart'; + +final _logger = Logger('kdbx4_customdata_test'); + +// ignore_for_file: non_constant_identifier_names + +void main() { + Logger.root.level = Level.ALL; + PrintAppender().attachToLogger(Logger.root); + final kdbxFormat = TestUtil.kdbxFormat(); + if (!kdbxFormat.argon2.isFfi) { + throw StateError('Expected ffi!'); + } + var now = DateTime.fromMillisecondsSinceEpoch(0); + + final fakeClock = Clock(() => now); + void proceedSeconds(int seconds) { + now = now.add(Duration(seconds: seconds)); + } + + setUp(() { + now = DateTime.fromMillisecondsSinceEpoch(0); + }); + + group('CustomData', () { + test('CustomData works on entries and groups', () async { + final credentials = Credentials(ProtectedValue.fromString('asdf')); + final kdbx = kdbxFormat.create( + credentials, + 'Test Keystore', + header: KdbxHeader.createV4(), + ); + final rootGroup = kdbx.body.rootGroup; + final e1 = TestUtil.createEntry(kdbx, rootGroup, 'user1', 'LoremIpsum'); + expect(e1.customData.entries.length, 0); + expect(rootGroup.customData.entries.length, 0); + e1.customData['tcd1'] = 'tv1'; + rootGroup.customData['tcd2'] = 'tv2'; + + final saved = await kdbx.save(); + + final loadedKdbx = await kdbxFormat.read( + saved, Credentials(ProtectedValue.fromString('asdf'))); + + _logger.fine('Successfully loaded kdbx $loadedKdbx'); + final entry1 = loadedKdbx.body.rootGroup.entries.first; + expect(entry1.customData['tcd1'], 'tv1'); + expect(rootGroup.customData['tcd2'], 'tv2'); + }); + }); +} diff --git a/test/kdbx_upgrade_test.dart b/test/kdbx_upgrade_test.dart index 144b691..e90b790 100644 --- a/test/kdbx_upgrade_test.dart +++ b/test/kdbx_upgrade_test.dart @@ -1,27 +1,102 @@ -@Tags(['kdbx3', 'kdbx4']) import 'package:kdbx/kdbx.dart'; +import 'package:kdbx/src/internal/extension_utils.dart'; +import 'package:kdbx/src/kdbx_xml.dart'; import 'package:test/test.dart'; import 'internal/test_utils.dart'; void main() { TestUtil.setupLogging(); - group('Test upgrade from v3 to v4', () { + group('Test kdbx format upgrades', () { final format = TestUtil.kdbxFormat(); test('Read v3, write v4', () async { final file = await TestUtil.readKdbxFile('test/FooBar.kdbx', password: 'FooBar'); expect(file.header.version, KdbxVersion.V3_1); - file.upgrade(KdbxVersion.V4.major); + file.upgrade(KdbxVersion.V4.major, 0); final v4 = await TestUtil.saveAndRead(file); expect(v4.header.version, KdbxVersion.V4); - await TestUtil.saveTestOutput('kdbx4upgrade', v4); + await TestUtil.saveTestOutput('kdbx4upgrade3-4', v4); }, tags: 'kdbx3'); - test('kdbx4 is the new default', () async { + + test('Read v3, write v4.1', () async { final file = - format.create(Credentials(ProtectedValue.fromString('asdf')), 'test'); + await TestUtil.readKdbxFile('test/FooBar.kdbx', password: 'FooBar'); + expect(file.header.version, KdbxVersion.V3_1); + file.upgrade(KdbxVersion.V4.major, 1); + final v4 = await TestUtil.saveAndRead(file); + expect(v4.header.version, KdbxVersion.V4_1); + await TestUtil.saveTestOutput('kdbx4upgrade3-41', v4); + }, tags: 'kdbx4'); + + test('Read v4, write v4.1', () async { + final file = await TestUtil.readKdbxFile('test/kdbx4_keeweb.kdbx', + password: 'asdf'); expect(file.header.version, KdbxVersion.V4); + file.upgrade(KdbxVersion.V4.major, 1); + final v4 = await TestUtil.saveAndRead(file); + expect(v4.header.version, KdbxVersion.V4_1); + await TestUtil.saveTestOutput('kdbx4upgrade4-41', v4); + }, tags: 'kdbx4'); + + test('kdbx4.1 is the new default', () async { + final file = + format.create(Credentials(ProtectedValue.fromString('asdf')), 'test'); + expect(file.header.version, KdbxVersion.V4_1); }); + + test('Upgrade from < v4 transforms persisted date format', () async { + final file = + await TestUtil.readKdbxFile('test/FooBar.kdbx', password: 'FooBar'); + expect(file.header.version, KdbxVersion.V3_1); + file.upgrade(KdbxVersion.V4.major, 1); + final v4 = await TestUtil.saveAndRead(await TestUtil.saveAndRead(file)); + expect(v4.header.version, KdbxVersion.V4_1); + + final metaValues = [ + v4.body.meta.node.singleElement('DatabaseNameChanged')?.text, + v4.body.meta.node.singleElement('DatabaseDescriptionChanged')?.text, + v4.body.meta.node.singleElement('DefaultUserNameChanged')?.text, + v4.body.meta.node.singleElement('MasterKeyChanged')?.text, + v4.body.meta.node.singleElement('RecycleBinChanged')?.text, + v4.body.meta.node.singleElement('EntryTemplatesGroupChanged')?.text, + v4.body.meta.node.singleElement('SettingsChanged')?.text, + ]; + metaValues.forEach(checkIsBase64Date); + + v4.body.rootGroup + .getAllEntries() + .values + .forEach(checkObjectHasBase64Dates); + v4.body.rootGroup + .getAllGroups() + .values + .forEach(checkObjectHasBase64Dates); + }, tags: 'kdbx4'); }, tags: ['kdbx4']); } + +// Sometimes the nodes can contain an XmlNodeList with a single element, rather than directly containing an XmlText node. Bug in XML lib? +// Have to work around by using deprecated text property which works no matter which approach the library decides to take this time. +void checkObjectHasBase64Dates(KdbxObject? obj) { + if (obj != null) { + [ + obj.times.node.singleElement('CreationTime')?.text, + obj.times.node.singleElement('LastModificationTime')?.text, + obj.times.node.singleElement('LastAccessTime')?.text, + obj.times.node.singleElement('ExpiryTime')?.text, + obj.times.node.singleElement('LocationChanged')?.text, + ].forEach(checkIsBase64Date); + + if (obj is KdbxEntry) { + obj.history.forEach(checkObjectHasBase64Dates); + } + } +} + +void checkIsBase64Date(String? val) { + if (val != null) { + expect(DateTimeUtils.fromBase64(val), isA()); + } +} diff --git a/test/merge/kdbx_merge_test.dart b/test/merge/kdbx_merge_test.dart index 12e8a95..7d1fc47 100644 --- a/test/merge/kdbx_merge_test.dart +++ b/test/merge/kdbx_merge_test.dart @@ -1,3 +1,5 @@ +// ignore_for_file: invalid_use_of_protected_member + import 'dart:typed_data'; import 'package:clock/clock.dart'; @@ -71,6 +73,32 @@ void main() { }); group('Real merges', () { + final icon1 = KdbxCustomIcon( + uuid: KdbxUuid.random(), + data: Uint8List.fromList([1, 2, 3]), + lastModified: fakeClock.now().toUtc(), + name: 'icon1', + ); + final icon2 = KdbxCustomIcon( + uuid: KdbxUuid.random(), + data: Uint8List.fromList([4, 5, 6]), + lastModified: fakeClock.now().add(const Duration(minutes: 5)).toUtc(), + name: 'icon2', + ); + final icon3 = KdbxCustomIcon( + uuid: KdbxUuid.random(), + data: Uint8List.fromList([7, 8, 9]), + lastModified: fakeClock.now().add(const Duration(minutes: 10)).toUtc(), + name: 'icon3', + ); + final icon4 = KdbxCustomIcon( + uuid: KdbxUuid.random(), + data: Uint8List.fromList([10, 11, 12]), + ); + final icon5 = KdbxCustomIcon( + uuid: KdbxUuid.random(), + data: Uint8List.fromList([13, 14, 15]), + ); test('Local file custom data wins', () async { await withClock(fakeClock, () async { final file = await TestUtil.createRealFile(proceedSeconds); @@ -78,25 +106,141 @@ void main() { final fileMod = await TestUtil.saveAndRead(file); final fileReverse = await TestUtil.saveAndRead(file); - fileMod.body.meta.customData['custom1'] = 'custom value 2'; + fileMod.body.meta.customData['custom1'] = + (value: 'custom value 2', lastModified: null); + proceedSeconds(10); + file.body.meta.customData['custom1'] = + (value: 'custom value 1', lastModified: null); + fileMod.body.meta.customData['custom2'] = + (value: 'custom value 3', lastModified: null); + + final file2 = await TestUtil.saveAndRead(fileMod); + final file2Reverse = await TestUtil.saveAndRead(fileMod); + + final merge = file.merge(file2); + final set = Set.from(merge.merged.keys); + expect(set, hasLength(5)); + expect(file.body.meta.customData['custom1'], + (value: 'custom value 1', lastModified: null)); + expect(file.body.meta.customData['custom2'], + (value: 'custom value 3', lastModified: null)); + + final mergeReverse = file2Reverse.merge(fileReverse); + final setReverse = Set.from(mergeReverse.merged.keys); + expect(setReverse, hasLength(5)); + expect(file2Reverse.body.meta.customData['custom1'], + (value: 'custom value 2', lastModified: null)); + expect(file2Reverse.body.meta.customData['custom2'], + (value: 'custom value 3', lastModified: null)); + }); + }); + + test('Newer file custom data wins', () async { + await withClock(fakeClock, () async { + final file = await TestUtil.createRealFile(proceedSeconds); + + final time1 = fakeClock.now().toUtc(); + final fileMod = await TestUtil.saveAndRead(file); + + fileMod.body.meta.customData['custom1'] = + (value: 'custom value 2', lastModified: time1); + proceedSeconds(10); + final time2 = fakeClock.now().toUtc(); + file.body.meta.customData['custom1'] = + (value: 'custom value 1', lastModified: time2); + fileMod.body.meta.customData['custom2'] = + (value: 'custom value 3', lastModified: time2); + + final fileReverse = await TestUtil.saveAndRead(file); + final file2 = await TestUtil.saveAndRead(fileMod); + final file2Reverse = await TestUtil.saveAndRead(fileMod); + + final merge = file.merge(file2); + final set = Set.from(merge.merged.keys); + expect(set, hasLength(5)); + expect(file.body.meta.customData['custom1'], + (value: 'custom value 1', lastModified: time2)); + expect(file.body.meta.customData['custom2'], + (value: 'custom value 3', lastModified: time2)); + + final mergeReverse = file2Reverse.merge(fileReverse); + final setReverse = Set.from(mergeReverse.merged.keys); + expect(setReverse, hasLength(5)); + expect(file2Reverse.body.meta.customData['custom1'], + (value: 'custom value 1', lastModified: time2)); + expect(file2Reverse.body.meta.customData['custom2'], + (value: 'custom value 3', lastModified: time2)); + }); + }); + + test('Local entry custom data wins', () async { + await withClock(fakeClock, () async { + final file = await TestUtil.createRealFile(proceedSeconds); + + final fileMod = await TestUtil.saveAndRead(file); + + fileMod.body.rootGroup.entries.first + .setCustomData('custom1', 'custom value 2'); + proceedSeconds(10); + file.body.rootGroup.entries.first + .setCustomData('custom1', 'custom value 1'); + fileMod.body.rootGroup.entries.first + .setCustomData('custom2', 'custom value 3'); + + final fileReverse = await TestUtil.saveAndRead(file); + final file2 = await TestUtil.saveAndRead(fileMod); + final file2Reverse = await TestUtil.saveAndRead(fileMod); + + final merge = file.merge(file2); + final set = Set.from(merge.merged.keys); + expect(set, hasLength(5)); + expect(file.body.rootGroup.entries.first.customData['custom1'], + 'custom value 1'); + expect(file.body.rootGroup.entries.first.customData['custom2'], null); + + final mergeReverse = file2Reverse.merge(fileReverse); + final setReverse = Set.from(mergeReverse.merged.keys); + expect(setReverse, hasLength(5)); + expect(file2Reverse.body.rootGroup.entries.first.customData['custom1'], + 'custom value 2'); + expect(file2Reverse.body.rootGroup.entries.first.customData['custom2'], + 'custom value 3'); + }); + }); + + test('Newer entry custom data wins', () async { + await withClock(fakeClock, () async { + final file = await TestUtil.createRealFile(proceedSeconds); + + final fileMod = await TestUtil.saveAndRead(file); + + file.body.rootGroup.entries.first + .setCustomData('custom1', 'custom value 1'); proceedSeconds(10); - file.body.meta.customData['custom1'] = 'custom value 1'; - fileMod.body.meta.customData['custom2'] = 'custom value 3'; + fileMod.body.rootGroup.entries.first + .setCustomData('custom1', 'custom value 2'); + fileMod.body.rootGroup.entries.first + .setCustomData('custom2', 'custom value 3'); + final fileReverse = await TestUtil.saveAndRead(file); final file2 = await TestUtil.saveAndRead(fileMod); final file2Reverse = await TestUtil.saveAndRead(fileMod); final merge = file.merge(file2); final set = Set.from(merge.merged.keys); expect(set, hasLength(5)); - expect(file.body.meta.customData['custom1'], 'custom value 1'); - expect(file.body.meta.customData['custom2'], 'custom value 3'); + expect(file.body.rootGroup.entries.first.customData['custom1'], + 'custom value 2'); + expect(file.body.rootGroup.entries.first.customData['custom2'], + 'custom value 3'); final mergeReverse = file2Reverse.merge(fileReverse); final setReverse = Set.from(mergeReverse.merged.keys); expect(setReverse, hasLength(5)); - expect(file2Reverse.body.meta.customData['custom1'], 'custom value 2'); - expect(file2Reverse.body.meta.customData['custom2'], 'custom value 3'); + expect(file2Reverse.body.rootGroup.entries.first.customData['custom1'], + 'custom value 2'); + expect(file2Reverse.body.rootGroup.entries.first.customData['custom2'], + 'custom value 3'); }); }); @@ -114,6 +258,145 @@ void main() { ), ); }); + + // We don't prevent merging into a newer KDBX version since that might be the only + // way to avoid permanent merge failures. However, updating each DB to the latest + // version before merging is probably safest, especially for major version differences. + test('Generates merge error when merging into an older KDBX version', + () async { + final file = await TestUtil.createRealFile(proceedSeconds); + final file2 = await TestUtil.saveAndRead(file); + file.header.upgradeMinor(4, 0); + expect( + () => file.merge(file2), + throwsA( + isA().having( + (error) => error.hint, + 'hint', + 'Kdbx version of source is newer. Upgrade file version before attempting to merge.', + ), + ), + ); + }); + + test('Local v4.0 file gets all custom icons', () async { + await withClock(fakeClock, () async { + final file = await TestUtil.createRealFile(proceedSeconds); + file.header.upgradeMinor(4, 0); + final fileMod = await TestUtil.saveAndRead(file); + + final allEntries = file.body.rootGroup.getAllEntries().values.toList(); + final entry1 = allEntries[0]; + + final allEntriesMod = + fileMod.body.rootGroup.getAllEntries().values.toList(); + final entry2Mod = allEntriesMod[1]; + + entry1.customIcon = icon4; + proceedSeconds(10); + entry2Mod.customIcon = icon5; + + final file2 = await TestUtil.saveAndRead(fileMod); + + file.merge(file2); + final sutFile = await TestUtil.saveAndRead(file); + final sutIcon4 = sutFile.body.meta.customIcons[icon4.uuid]; + final sutIcon5 = sutFile.body.meta.customIcons[icon5.uuid]; + expect(sutIcon4?.uuid.uuid, icon4.uuid.uuid); + expect(sutIcon4?.lastModified, null); + expect(sutIcon5?.uuid.uuid, icon5.uuid.uuid); + expect(sutIcon5?.lastModified, null); + }); + }); + + test( + 'Local v4.1 file gets all custom icons and new modified date for merged icon', + () async { + await withClock(fakeClock, () async { + final file = await TestUtil.createRealFile(proceedSeconds); + final fileMod = await TestUtil.saveAndRead(file); + + final allEntries = file.body.rootGroup.getAllEntries().values.toList(); + final entry1 = allEntries[0]; + final entry2 = allEntries[1]; + + final allEntriesMod = + fileMod.body.rootGroup.getAllEntries().values.toList(); + final entry2Mod = allEntriesMod[1]; + + entry1.customIcon = icon4; + entry2.customIcon = icon5; + proceedSeconds(10); + entry2Mod.customIcon = icon5; + + final file2 = await TestUtil.saveAndRead(fileMod); + + file.merge(file2); + final sutFile = await TestUtil.saveAndRead(file); + final sutIcon4 = sutFile.body.meta.customIcons[icon4.uuid]; + final sutIcon5 = sutFile.body.meta.customIcons[icon5.uuid]; + expect(sutIcon4?.uuid.uuid, icon4.uuid.uuid); + expect(sutIcon4?.lastModified, null); + expect(sutIcon5?.uuid.uuid, icon5.uuid.uuid); + expect(sutIcon5?.lastModified, isA()); + }); + }); + + test('Newer file custom icon wins', () async { + await withClock(fakeClock, () async { + final file = await TestUtil.createRealFile(proceedSeconds); + final fileMod = await TestUtil.saveAndRead(file); + + final allEntries = file.body.rootGroup.getAllEntries().values.toList(); + final entry1 = allEntries[0]; + final entry2 = allEntries[1]; + + final allEntriesMod = + fileMod.body.rootGroup.getAllEntries().values.toList(); + final entry2Mod = allEntriesMod[1]; + + entry1.customIcon = icon1; + entry2.customIcon = icon2; + proceedSeconds(10); + entry2Mod.customIcon = icon3; + final iconModTime = + icon2.lastModified?.add(const Duration(minutes: 1)).toUtc(); + final iconModData = Uint8List.fromList([100, 101, 102]); + file.body.meta.modifyCustomIcon(KdbxCustomIcon( + uuid: icon2.uuid, + data: iconModData, + lastModified: iconModTime, + name: 'modified', + )); + + final fileTarget = await TestUtil.saveAndRead(file); + final file2 = await TestUtil.saveAndRead(fileMod); + + fileTarget.merge(file2); + final sutFile = await TestUtil.saveAndRead(fileTarget); + final sutIcon1 = sutFile.body.meta.customIcons[icon1.uuid]; + final sutIcon2 = sutFile.body.meta.customIcons[icon2.uuid]; + final sutIcon3 = sutFile.body.meta.customIcons[icon3.uuid]; + expect(sutIcon1?.uuid.uuid, icon1.uuid.uuid); + expect(sutIcon1?.lastModified, icon1.lastModified); + expect(sutIcon2?.uuid.uuid, icon2.uuid.uuid); + expect(sutIcon2?.lastModified, iconModTime); + expect(sutIcon2?.data, iconModData); + expect(sutIcon2?.name, 'modified'); + expect(sutIcon3?.uuid.uuid, icon3.uuid.uuid); + expect(sutIcon3?.lastModified, icon3.lastModified); + expect(sutIcon3?.name, icon3.name); + expect(sutIcon3?.data, icon3.data); + expect( + sutFile.body.rootGroup + .getAllEntries() + .values + .toList()[1] + .customIcon + ?.uuid, + icon3.uuid); + }); + }); }); group('Moving entries', () { @@ -475,13 +758,6 @@ void main() { hasLength(0)); }), ); - // test( - // 'Adds binary to remote entry', - // () async => await withClock(fakeClock, () async { - // final file = await TestUtil.createRealFile(proceedSeconds); - // await TestUtil.saveAndRead(file); - // }), - // ); }); group('Group merges', () { @@ -587,10 +863,6 @@ void main() { ); }); -// meh? -// 'merges binaries' - //('merges custom icons', - group('History merges', () { /* @@ -1118,24 +1390,4 @@ when merging, can look at this value to decide whether history entries from the }), ); }); - - // group('Kdbx4.1 merges', () { - // Future TestUtil.createRealFile(proceedSeconds) async { - // final file = TestUtil.createEmptyFile(); - // _createEntry(file, file.body.rootGroup, 'test1', 'test1'); - // final subGroup = - // file.createGroup(parent: file.body.rootGroup, name: 'Sub Group'); - // _createEntry(file, subGroup, 'test2', 'test2'); - // proceedSeconds(10); - // return await TestUtil.saveAndRead(file); - // } - - // test('Newest file plugin data wins', () async { - // await withClock(fakeClock, () async { - // final file = await TestUtil.createRealFile(proceedSeconds); - - // final fileMod = await TestUtil.saveAndRead(file); - // }); - // }); - // }); }