From 64bec4347394a5b0d060b616dc8f3c23d74035fe Mon Sep 17 00:00:00 2001 From: Herbert Poul Date: Tue, 1 Sep 2020 22:27:36 +0200 Subject: [PATCH] WIP: merging incoming changes #80 --- lib/kdbx.dart | 24 ++++- lib/src/kdbx_binary.dart | 7 ++ lib/src/kdbx_custom_data.dart | 2 + lib/src/kdbx_dao.dart | 4 +- lib/src/kdbx_deleted_object.dart | 3 +- lib/src/kdbx_entry.dart | 124 ++++++++++++++++++++++- lib/src/kdbx_file.dart | 11 ++ lib/src/kdbx_format.dart | 167 +++++++++++++++++++++++++++---- lib/src/kdbx_group.dart | 139 +++++++++++++++++++++---- lib/src/kdbx_meta.dart | 42 +++++++- lib/src/kdbx_object.dart | 64 +++++++++++- lib/src/kdbx_times.dart | 21 +++- lib/src/kdbx_xml.dart | 37 ++++++- pubspec.yaml | 1 + 14 files changed, 585 insertions(+), 61 deletions(-) diff --git a/lib/kdbx.dart b/lib/kdbx.dart index be1dc2f..f0c3bed 100644 --- a/lib/kdbx.dart +++ b/lib/kdbx.dart @@ -9,10 +9,19 @@ export 'src/kdbx_binary.dart' show KdbxBinary; export 'src/kdbx_consts.dart'; export 'src/kdbx_custom_data.dart'; export 'src/kdbx_dao.dart' show KdbxDao; -export 'src/kdbx_entry.dart'; +export 'src/kdbx_entry.dart' show KdbxEntry, KdbxKey; export 'src/kdbx_file.dart'; -export 'src/kdbx_format.dart'; -export 'src/kdbx_group.dart'; +export 'src/kdbx_format.dart' + show + KdbxBody, + Credentials, + CredentialsPart, + HashCredentials, + KdbxFormat, + KeyFileComposite, + KeyFileCredentials, + PasswordCredentials; +export 'src/kdbx_group.dart' show KdbxGroup; export 'src/kdbx_header.dart' show KdbxException, @@ -21,5 +30,12 @@ export 'src/kdbx_header.dart' KdbxUnsupportedException, KdbxVersion; export 'src/kdbx_meta.dart'; -export 'src/kdbx_object.dart'; +export 'src/kdbx_object.dart' + show + KdbxUuid, + KdbxObject, + KdbxNode, + Changeable, + ChangeEvent, + KdbxNodeContext; export 'src/utils/byte_utils.dart' show ByteUtils; diff --git a/lib/src/kdbx_binary.dart b/lib/src/kdbx_binary.dart index d1f3065..ff68866 100644 --- a/lib/src/kdbx_binary.dart +++ b/lib/src/kdbx_binary.dart @@ -6,6 +6,7 @@ import 'package:kdbx/src/utils/byte_utils.dart'; import 'package:kdbx/src/kdbx_header.dart'; import 'package:kdbx/src/kdbx_xml.dart'; import 'package:meta/meta.dart'; +import 'package:quiver/core.dart'; import 'package:xml/xml.dart'; class KdbxBinary { @@ -13,6 +14,7 @@ class KdbxBinary { final bool isInline; final bool isProtected; final Uint8List value; + int _valueHashCode; static KdbxBinary readBinaryInnerHeader(InnerHeaderField field) { final flags = field.bytes[0]; @@ -25,6 +27,11 @@ class KdbxBinary { ); } + int get valueHashCode => _valueHashCode ??= hashObjects(value); + + bool valueEqual(KdbxBinary other) => + valueHashCode == other.valueHashCode && ByteUtils.eq(value, value); + InnerHeaderField writeToInnerHeader() { final writer = WriterHelper(); final flags = isProtected ? 0x01 : 0x00; diff --git a/lib/src/kdbx_custom_data.dart b/lib/src/kdbx_custom_data.dart index ae6426e..77b2f74 100644 --- a/lib/src/kdbx_custom_data.dart +++ b/lib/src/kdbx_custom_data.dart @@ -28,6 +28,8 @@ class KdbxCustomData extends KdbxNode { modify(() => _data[key] = value); } + bool containsKey(String key) => _data.containsKey(key); + @override xml.XmlElement toXml() { final el = super.toXml(); diff --git a/lib/src/kdbx_dao.dart b/lib/src/kdbx_dao.dart index 69b8e4b..a3cc190 100644 --- a/lib/src/kdbx_dao.dart +++ b/lib/src/kdbx_dao.dart @@ -1,5 +1,7 @@ -import 'package:kdbx/kdbx.dart'; +import 'package:kdbx/src/kdbx_entry.dart'; import 'package:kdbx/src/kdbx_file.dart'; +import 'package:kdbx/src/kdbx_group.dart'; +import 'package:kdbx/src/kdbx_object.dart'; import 'package:meta/meta.dart'; /// Helper object for accessing and modifing data inside diff --git a/lib/src/kdbx_deleted_object.dart b/lib/src/kdbx_deleted_object.dart index 4f95c9d..b88ed98 100644 --- a/lib/src/kdbx_deleted_object.dart +++ b/lib/src/kdbx_deleted_object.dart @@ -1,4 +1,5 @@ -import 'package:kdbx/kdbx.dart'; +import 'package:kdbx/src/kdbx_format.dart'; +import 'package:kdbx/src/kdbx_object.dart'; import 'package:kdbx/src/kdbx_xml.dart'; import 'package:xml/xml.dart'; diff --git a/lib/src/kdbx_entry.dart b/lib/src/kdbx_entry.dart index 18df046..edd5628 100644 --- a/lib/src/kdbx_entry.dart +++ b/lib/src/kdbx_entry.dart @@ -1,11 +1,11 @@ import 'dart:typed_data'; -import 'package:kdbx/kdbx.dart'; import 'package:kdbx/src/crypto/protected_value.dart'; import 'package:kdbx/src/internal/extension_utils.dart'; import 'package:kdbx/src/kdbx_binary.dart'; import 'package:kdbx/src/kdbx_consts.dart'; import 'package:kdbx/src/kdbx_file.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'; @@ -13,6 +13,7 @@ import 'package:kdbx/src/kdbx_xml.dart'; import 'package:logging/logging.dart'; import 'package:meta/meta.dart'; import 'package:path/path.dart' as path; +import 'package:quiver/check.dart'; import 'package:xml/xml.dart'; final _logger = Logger('kdbx.kdbx_entry'); @@ -37,10 +38,74 @@ class KdbxKey { } } +extension KdbxEntryInternal on KdbxEntry { + KdbxEntry cloneInto(KdbxGroup otherGroup, {bool toHistoryEntry = false}) => + KdbxEntry.create( + otherGroup.file, + otherGroup, + isHistoryEntry: toHistoryEntry, + ) + ..forceSetUuid(uuid) + ..let(toHistoryEntry ? (x) => null : otherGroup.addEntry) + .._overwriteFrom( + OverwriteContext.noop, + this, + includeHistory: !toHistoryEntry, + ); + + List get _overwriteNodes => [ + ...objectNodes, + foregroundColor, + backgroundColor, + overrideURL, + tags, + ]; + + void _overwriteFrom( + OverwriteContext overwriteContext, + KdbxEntry other, { + bool includeHistory = false, + }) { + // we only support overwriting history, if it is empty. + checkArgument(!includeHistory || history.isEmpty, + message: + 'We can only overwrite with history, if local history is empty.'); + assertSameUuid(other, 'overwrite'); + overwriteSubNodesFrom( + overwriteContext, + _overwriteNodes, + other._overwriteNodes, + ); + // overwrite all strings + _strings.clear(); + _strings.addAll(other._strings); + // overwrite all binaries + final newBinaries = other._binaries.map((key, value) => MapEntry( + key, + ctx.findBinaryByValue(value) ?? + (value..let((that) => ctx.addBinary(that))), + )); + _binaries.clear(); + _binaries.addAll(newBinaries); + times.overwriteFrom(other.times); + if (includeHistory) { + for (final historyEntry in other.history) { + history.add(historyEntry.cloneInto(parent, toHistoryEntry: false)); + } + } + } +} + class KdbxEntry extends KdbxObject { - KdbxEntry.create(KdbxFile file, KdbxGroup parent) - : isHistoryEntry = false, - history = [], + /// Creates a new entry in the given parent group. + /// callers are still responsible for calling [parent.addEntry(..)]! + /// + /// FIXME: this makes no sense, we should automatically attach this to the parent. + KdbxEntry.create( + KdbxFile file, + KdbxGroup parent, { + this.isHistoryEntry = false, + }) : history = [], super.create(file.ctx, file, 'Entry', parent) { icon.set(KdbxIcon.Key); } @@ -89,6 +154,11 @@ class KdbxEntry extends KdbxObject { final List history; + ColorNode get foregroundColor => ColorNode(this, 'ForegroundColor'); + ColorNode get backgroundColor => ColorNode(this, 'BackgroundColor'); + StringNode get overrideURL => StringNode(this, 'OverrideURL'); + StringNode get tags => StringNode(this, 'Tags'); + @override set file(KdbxFile file) { super.file = file; @@ -102,7 +172,12 @@ class KdbxEntry extends KdbxObject { @override void onBeforeModify() { super.onBeforeModify(); - history.add(KdbxEntry.read(ctx, parent, toXml())..file = file); + history.add(KdbxEntry.read( + ctx, + parent, + toXml(), + isHistoryEntry: true, + )..file = file); } @override @@ -251,6 +326,45 @@ class KdbxEntry extends KdbxObject { throw StateError('Unable to find unique name for $fileName'); } + static KdbxEntry _findHistoryEntry( + List history, DateTime lastModificationTime) => + history.firstWhere( + (history) => + history.times.lastModificationTime.get() == lastModificationTime, + orElse: () => null); + + @override + void merge(MergeContext mergeContext, KdbxEntry other) { + assertSameUuid(other, 'merge'); + if (other.wasModifiedAfter(this)) { + // other object is newer, create new history entry and copy fields. + modify(() => _overwriteFrom(mergeContext, other)); + } else if (wasModifiedAfter(other)) { + // we are newer. check if the old revision lives on in our history. + final ourLastModificationTime = times.lastModificationTime.get(); + final historyEntry = _findHistoryEntry(history, ourLastModificationTime); + if (historyEntry == null) { + // it seems like we don't know about that state, so we have to add + // it to history. + history.add(other.cloneInto(parent, toHistoryEntry: true)); + } + } + // copy missing history entries. + for (final otherHistoryEntry in other.history) { + final meHistoryEntry = _findHistoryEntry( + history, otherHistoryEntry.times.lastModificationTime.get()); + if (meHistoryEntry == null) { + mergeContext.trackChange( + this, + debug: 'merge in history ' + '${otherHistoryEntry.times.lastModificationTime.get()}', + ); + history.add(otherHistoryEntry.cloneInto(parent, toHistoryEntry: true)); + } + } + mergeContext.markAsMerged(this); + } + @override String toString() { return 'KdbxGroup{uuid=$uuid,name=$label}'; diff --git a/lib/src/kdbx_file.dart b/lib/src/kdbx_file.dart index 01c2520..3477872 100644 --- a/lib/src/kdbx_file.dart +++ b/lib/src/kdbx_file.dart @@ -119,6 +119,17 @@ class KdbxFile { body.meta.headerHash.remove(); header.upgrade(majorVersion); } + + /// Merges the given file into this file. + /// Both files must have the same origin (ie. same root group UUID). + /// FIXME: THiS iS NOT YET FINISHED, DO NOT USE. + void merge(KdbxFile other) { + if (other.body.rootGroup.uuid != body.rootGroup.uuid) { + throw KdbxUnsupportedException( + 'Root groups of source and dest file do not match.'); + } + body.merge(other.body); + } } class CachedValue { diff --git a/lib/src/kdbx_format.dart b/lib/src/kdbx_format.dart index 0e64114..96bff0b 100644 --- a/lib/src/kdbx_format.dart +++ b/lib/src/kdbx_format.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'dart:typed_data'; import 'package:archive/archive.dart'; +import 'package:supercharged_dart/supercharged_dart.dart'; import 'package:argon2_ffi_base/argon2_ffi_base.dart'; import 'package:convert/convert.dart' as convert; import 'package:crypto/crypto.dart' as crypto; @@ -27,6 +28,7 @@ import 'package:kdbx/src/kdbx_xml.dart'; import 'package:logging/logging.dart'; import 'package:meta/meta.dart'; import 'package:pointycastle/export.dart'; +import 'package:quiver/iterables.dart'; import 'package:xml/xml.dart' as xml; final _logger = Logger('kdbx.format'); @@ -72,7 +74,8 @@ class KdbxReadWriteContext { @required this.header, }) : assert(binaries != null), assert(header != null), - _binaries = binaries; + _binaries = binaries, + _deletedObjects = []; static final kdbxContext = Expando(); @@ -89,8 +92,10 @@ class KdbxReadWriteContext { kdbxContext[node.document] = ctx; } + // TODO make [_binaries] and [_deletedObjects] late init :-) @protected final List _binaries; + final List _deletedObjects; Iterable get binariesIterable => _binaries; @@ -98,6 +103,12 @@ class KdbxReadWriteContext { int get versionMajor => header.version.major; + void initContext(Iterable binaries, + Iterable deletedObjects) { + _binaries.addAll(binaries); + _deletedObjects.addAll(deletedObjects); + } + KdbxBinary binaryById(int id) { if (id >= _binaries.length) { return null; @@ -109,6 +120,12 @@ class KdbxReadWriteContext { _binaries.add(binary); } + KdbxBinary findBinaryByValue(KdbxBinary binary) { + // TODO create a hashset or map? + return _binaries.firstWhere((element) => element.valueEqual(binary), + orElse: () => null); + } + /// finds the ID of the given binary. /// if it can't be found, [KdbxCorruptedFileException] is thrown. int findBinaryId(KdbxBinary binary) { @@ -193,9 +210,7 @@ class HashCredentials implements Credentials { } class KdbxBody extends KdbxNode { - KdbxBody.create(this.meta, this.rootGroup) - : _deletedObjects = [], - super.create('KeePassFile') { + KdbxBody.create(this.meta, this.rootGroup) : super.create('KeePassFile') { node.children.add(meta.node); final rootNode = xml.XmlElement(xml.XmlName('Root')); node.children.add(rootNode); @@ -206,17 +221,14 @@ class KdbxBody extends KdbxNode { xml.XmlElement node, this.meta, this.rootGroup, - Iterable deletedObjects, - ) : _deletedObjects = List.of(deletedObjects), - super.read(node); + ) : super.read(node); // final xml.XmlDocument xmlDocument; final KdbxMeta meta; final KdbxGroup rootGroup; - final List _deletedObjects; @visibleForTesting - List get deletedObjects => _deletedObjects; + List get deletedObjects => ctx._deletedObjects; Future writeV3(WriterHelper writer, KdbxFile kdbxFile, ProtectedSaltGenerator saltGenerator) async { @@ -284,6 +296,63 @@ class KdbxBody extends KdbxNode { } } + KdbxReadWriteContext get ctx => rootGroup.ctx; + + Map _createObjectIndex() => Map.fromEntries( + concat([rootGroup.getAllGroups(), rootGroup.getAllEntries()]) + .map((e) => MapEntry(e.uuid, e))); + + void merge(KdbxBody other) { + // sync deleted objects. + final deleted = + Map.fromEntries(ctx._deletedObjects.map((e) => MapEntry(e.uuid, e))); + final incomingDeleted = {}; + + for (final obj in other.ctx._deletedObjects) { + if (!deleted.containsKey(obj.uuid)) { + final del = KdbxDeletedObject.create(ctx, obj.uuid); + ctx._deletedObjects.add(del); + incomingDeleted[del.uuid] = del; + deleted[del.uuid] = del; + } + } + + final mergeContext = MergeContext( + objectIndex: _createObjectIndex(), + deletedObjects: deleted, + ); + + // sync binaries + for (final binary in other.ctx.binariesIterable) { + if (ctx.findBinaryByValue(binary) == null) { + ctx.addBinary(binary); + mergeContext.trackChange(this, + debug: 'adding new binary ${binary.value.length}'); + } + } + meta.merge(other.meta); + rootGroup.merge(mergeContext, other.rootGroup); + + // remove deleted objects + for (final incomingDelete in incomingDeleted.values) { + final object = mergeContext.objectIndex[incomingDelete.uuid]; + mergeContext.trackChange(object, debug: 'was deleted.'); + } + + // FIXME do some cleanup. + + _logger.info('Finished merging. ${mergeContext.debugChanges()}'); + final incomingObjects = other._createObjectIndex(); + _logger.info('Merged: ${mergeContext.merged} vs. ' + '(local objects: ${mergeContext.objectIndex.length}, ' + 'incoming objects: ${incomingObjects.length})'); + + // sanity checks + if (mergeContext.merged.keys.length != mergeContext.objectIndex.length) { + // TODO figure out what went wrong. + } + } + xml.XmlDocument generateXml(ProtectedSaltGenerator saltGenerator) { final rootGroupNode = rootGroup.toXml(); // update protected values... @@ -316,7 +385,7 @@ class KdbxBody extends KdbxNode { rootGroupNode, XmlUtils.createNode( KdbxXml.NODE_DELETED_OBJECTS, - _deletedObjects.map((e) => e.toXml()).toList(), + ctx._deletedObjects.map((e) => e.toXml()).toList(), ), ]), ], @@ -330,6 +399,65 @@ class KdbxBody extends KdbxNode { } } +abstract class OverwriteContext { + const OverwriteContext(); + static const noop = OverwriteContextNoop(); + void trackChange(KdbxObject object, {String node, String debug}); +} + +class OverwriteContextNoop implements OverwriteContext { + const OverwriteContextNoop(); + @override + void trackChange(KdbxObject object, {String node, String debug}) {} +} + +class MergeChange { + MergeChange({this.object, this.node, this.debug}); + + final KdbxNode object; + + /// the name of the subnode of [object]. + final String node; + final String debug; +} + +class MergeContext implements OverwriteContext { + MergeContext({this.objectIndex, this.deletedObjects}); + final Map objectIndex; + final Map deletedObjects; + final Map merged = {}; + final List changes = []; + + void markAsMerged(KdbxObject object) { + if (merged.containsKey(object.uuid)) { + throw StateError( + 'object was already market as merged! ${object.uuid}: $object'); + } + merged[object.uuid] = object; + } + + @override + void trackChange(KdbxNode object, {String node, String debug}) { + changes.add(MergeChange( + object: object, + node: node, + debug: debug, + )); + } + + String debugChanges() { + final group = + changes.groupBy((element) => element.object, valueTransform: (x) => x); + return group.entries + .map((e) => [ + e.key.toString(), + ': ', + ...e.value.map((e) => e.toString()), + ].join('\n ')) + .join('\n'); + } +} + class _KeysV4 { _KeysV4(this.hmacKey, this.cipherKey); @@ -645,15 +773,12 @@ class KdbxFormat { final root = keePassFile.findElements('Root').single; final kdbxMeta = KdbxMeta.read(meta, ctx); - if (kdbxMeta.binaries?.isNotEmpty == true) { - ctx._binaries.addAll(kdbxMeta.binaries); - } else if (header.innerHeader.binaries.isNotEmpty) { - ctx._binaries.addAll(header.innerHeader.binaries - .map((e) => KdbxBinary.readBinaryInnerHeader(e))); - } + // kdbx < 4 has binaries in the meta section, >= 4 in the binary header. + final binaries = kdbxMeta.binaries?.isNotEmpty == true + ? kdbxMeta.binaries + : header.innerHeader.binaries + .map((e) => KdbxBinary.readBinaryInnerHeader(e)); - final rootGroup = - KdbxGroup.read(ctx, null, root.findElements(KdbxXml.NODE_GROUP).single); final deletedObjects = root .findElements(KdbxXml.NODE_DELETED_OBJECTS) .singleOrNull @@ -661,8 +786,12 @@ class KdbxFormat { .findElements(KdbxDeletedObject.NODE_NAME) .map((node) => KdbxDeletedObject.read(node, ctx))) ?? []; + ctx.initContext(binaries, deletedObjects); + + final rootGroup = + KdbxGroup.read(ctx, null, root.findElements(KdbxXml.NODE_GROUP).single); _logger.fine('successfully read Meta.'); - return KdbxBody.read(keePassFile, kdbxMeta, rootGroup, deletedObjects); + return KdbxBody.read(keePassFile, kdbxMeta, rootGroup); } Uint8List _decryptContent( diff --git a/lib/src/kdbx_group.dart b/lib/src/kdbx_group.dart index acebe51..2af4ea6 100644 --- a/lib/src/kdbx_group.dart +++ b/lib/src/kdbx_group.dart @@ -1,18 +1,22 @@ import 'package:kdbx/kdbx.dart'; import 'package:kdbx/src/kdbx_consts.dart'; import 'package:kdbx/src/kdbx_entry.dart'; +import 'package:kdbx/src/kdbx_format.dart'; import 'package:kdbx/src/kdbx_xml.dart'; +import 'package:logging/logging.dart'; import 'package:meta/meta.dart'; import 'package:xml/xml.dart'; import 'kdbx_object.dart'; +final _logger = Logger('kdbx_group'); + class KdbxGroup extends KdbxObject { - KdbxGroup.create( - {@required KdbxReadWriteContext ctx, - @required KdbxGroup parent, - @required String name}) - : super.create( + KdbxGroup.create({ + @required KdbxReadWriteContext ctx, + @required KdbxGroup parent, + @required String name, + }) : super.create( ctx, parent?.file, KdbxXml.NODE_GROUP, @@ -65,6 +69,8 @@ class KdbxGroup extends KdbxObject { throw StateError( 'Invalid operation. Trying to add entry which is already in another group.'); } + assert(_entries.findByUuid(entry.uuid) == null, + 'must not already be in this group.'); modify(() => _entries.add(entry)); } @@ -76,36 +82,127 @@ class KdbxGroup extends KdbxObject { modify(() => _groups.add(group)); } - void internalRemoveGroup(KdbxGroup group) { - modify(() { - if (!_groups.remove(group)) { - throw StateError('Unable to remove $group from $this (Not found)'); - } - }); - } - - void internalRemoveEntry(KdbxEntry entry) { - modify(() { - if (!_entries.remove(entry)) { - throw StateError('Unable to remove $entry from $this (Not found)'); - } - }); - } - /// returns all parents recursively including this group. List get breadcrumbs => [...?parent?.breadcrumbs, this]; StringNode get name => StringNode(this, 'Name'); + StringNode get notes => StringNode(this, 'Notes'); + // String get name => text('Name') ?? ''; BooleanNode get expanded => BooleanNode(this, 'IsExpanded'); + StringNode get defaultAutoTypeSequence => + StringNode(this, 'DefaultAutoTypeSequence'); + BooleanNode get enableAutoType => BooleanNode(this, 'EnableAutoType'); BooleanNode get enableSearching => BooleanNode(this, 'EnableSearching'); + UuidNode get lastTopVisibleEntry => UuidNode(this, 'LastTopVisibleEntry'); + + @override + void merge(MergeContext mergeContext, KdbxGroup other) { + assertSameUuid(other, 'merge'); + + if (other.wasModifiedAfter(this)) { + _logger.finest('merge: other group was modified $uuid'); + _overwriteFrom(mergeContext, other); + } + _mergeSubObjects( + mergeContext, + _groups, + other._groups, + importToHere: (other) => + KdbxGroup.create(ctx: ctx, parent: this, name: other.name.get()) + ..forceSetUuid(other.uuid) + .._overwriteFrom(mergeContext, other), + ); + _mergeSubObjects( + mergeContext, + _entries, + other._entries, + importToHere: (other) => other.cloneInto(this), + ); + } + + void _mergeSubObjects( + MergeContext mergeContext, List me, List other, + {@required T Function(T obj) importToHere}) { + // possibilities: + // 1. Not changed at all 👍 + // 2. Deleted in other + // 3. Deleted in this + // 4. Modified in other + // 5. Modified in this + // 6. Moved in other + // 7. Moved in this + + for (final otherObj in other) { + final meObj = me.findByUuid(otherObj.uuid); + if (meObj == null) { + // moved or deleted. + + final movedObj = mergeContext.objectIndex[otherObj.uuid]; + if (movedObj == null) { + // item was created in the other file. we have to import it + importToHere(otherObj); + } else { + // item was moved. + if (otherObj.wasMovedAfter(movedObj)) { + // item was moved in the other file, so we have to move it here. + file.move(movedObj, this); + } else { + // item was moved in this file, so nothing to do. + } + movedObj.merge(mergeContext, otherObj); + } + } else { + meObj.merge(mergeContext, otherObj); + } + } + mergeContext.markAsMerged(this); + } + + List get _overwriteNodes => [ + ...objectNodes, + name, + notes, + expanded, + defaultAutoTypeSequence, + enableAutoType, + enableSearching, + lastTopVisibleEntry, + ]; + + void _overwriteFrom(MergeContext mergeContext, KdbxGroup other) { + assertSameUuid(other, 'overwrite'); + overwriteSubNodesFrom(mergeContext, _overwriteNodes, other._overwriteNodes); + // we should probably check that [lastTopVisibleEntry] is still a + // valid reference? + times.overwriteFrom(other.times); + } + @override String toString() { return 'KdbxGroup{uuid=$uuid,name=${name.get()}}'; } } + +extension KdbxGroupInternal on KdbxGroup { + void internalRemoveGroup(KdbxGroup group) { + modify(() { + if (!_groups.remove(group)) { + throw StateError('Unable to remove $group from $this (Not found)'); + } + }); + } + + void internalRemoveEntry(KdbxEntry entry) { + modify(() { + if (!_entries.remove(entry)) { + throw StateError('Unable to remove $entry from $this (Not found)'); + } + }); + } +} diff --git a/lib/src/kdbx_meta.dart b/lib/src/kdbx_meta.dart index 58c1977..8439c80 100644 --- a/lib/src/kdbx_meta.dart +++ b/lib/src/kdbx_meta.dart @@ -2,10 +2,10 @@ import 'dart:convert'; import 'dart:typed_data'; import 'package:collection/collection.dart'; -import 'package:kdbx/kdbx.dart'; import 'package:kdbx/src/internal/extension_utils.dart'; import 'package:kdbx/src/kdbx_binary.dart'; import 'package:kdbx/src/kdbx_custom_data.dart'; +import 'package:kdbx/src/kdbx_format.dart'; import 'package:kdbx/src/kdbx_header.dart'; import 'package:kdbx/src/kdbx_object.dart'; import 'package:kdbx/src/kdbx_xml.dart'; @@ -165,6 +165,46 @@ class KdbxMeta extends KdbxNode implements KdbxNodeContext { ); return ret; } + + // Merge in changes in [other] into this meta data. + void merge(KdbxMeta other) { + // FIXME make sure this is finished + if (other.databaseNameChanged.isAfter(databaseNameChanged)) { + databaseName.set(other.databaseName.get()); + databaseNameChanged.set(other.databaseNameChanged.get()); + } + if (other.databaseDescriptionChanged.isAfter(databaseDescriptionChanged)) { + databaseDescription.set(other.databaseDescription.get()); + databaseDescriptionChanged.set(other.databaseDescriptionChanged.get()); + } + if (other.defaultUserNameChanged.isAfter(defaultUserNameChanged)) { + defaultUserName.set(other.defaultUserName.get()); + defaultUserNameChanged.set(other.defaultUserNameChanged.get()); + } + if (other.masterKeyChanged.isAfter(masterKeyChanged)) { + throw UnimplementedError( + 'Other database changed master key. not supported.'); + } + if (other.recycleBinChanged.isAfter(recycleBinChanged)) { + recycleBinEnabled.set(other.recycleBinEnabled.get()); + recycleBinUUID.set(other.recycleBinUUID.get()); + recycleBinChanged.set(other.recycleBinChanged.get()); + } + final otherIsNewer = other.settingsChanged.isAfter(settingsChanged); + + // merge custom data + for (final otherCustomDataEntry in other.customData.entries) { + if (otherIsNewer || !customData.containsKey(otherCustomDataEntry.key)) { + customData[otherCustomDataEntry.key] = otherCustomDataEntry.value; + } + } + // merge custom icons + for (final otherCustomIcon in other._customIcons.values) { + _customIcons[otherCustomIcon.uuid] ??= otherCustomIcon; + } + + settingsChanged.set(other.settingsChanged.get()); + } } class KdbxCustomIcon { diff --git a/lib/src/kdbx_object.dart b/lib/src/kdbx_object.dart index a50d08d..24b0b61 100644 --- a/lib/src/kdbx_object.dart +++ b/lib/src/kdbx_object.dart @@ -2,14 +2,16 @@ import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; -import 'package:kdbx/kdbx.dart'; import 'package:kdbx/src/internal/extension_utils.dart'; import 'package:kdbx/src/kdbx_file.dart'; +import 'package:kdbx/src/kdbx_format.dart'; import 'package:kdbx/src/kdbx_group.dart'; +import 'package:kdbx/src/kdbx_meta.dart'; import 'package:kdbx/src/kdbx_times.dart'; import 'package:kdbx/src/kdbx_xml.dart'; import 'package:logging/logging.dart'; import 'package:meta/meta.dart'; +import 'package:quiver/iterables.dart'; import 'package:uuid/uuid.dart'; import 'package:uuid/uuid_util.dart'; import 'package:xml/xml.dart'; @@ -36,6 +38,9 @@ mixin Changeable { bool _isDirty = false; + /// allow recursive calls to [modify] + bool _isInModify = false; + /// Called before the *first* modification (ie. before `isDirty` changes /// from false to true) @protected @@ -49,14 +54,16 @@ mixin Changeable { void onAfterModify() {} RET modify(RET Function() modify) { - if (_isDirty) { + if (_isDirty || _isInModify) { return modify(); } + _isInModify = true; onBeforeModify(); try { return modify(); } finally { _isDirty = true; + _isInModify = false; onAfterModify(); _controller.add(ChangeEvent(object: this as T, isDirty: _isDirty)); } @@ -102,9 +109,49 @@ abstract class KdbxNode with Changeable { } } +extension IterableKdbxObject on Iterable { + T findByUuid(KdbxUuid uuid) => + firstWhere((element) => element.uuid == uuid, orElse: () => null); +} + +extension KdbxObjectInternal on KdbxObject { + List get objectNodes => [ + icon, + customIconUuid, + ]; + + /// should only be used in internal code, used to clone + /// from one kdbx file into another. (like merging). + void forceSetUuid(KdbxUuid uuid) { + _uuid.set(uuid, force: true); + } + + void assertSameUuid(KdbxObject other, String debugAction) { + if (uuid != other.uuid) { + throw StateError( + 'Uuid of other object does not match current object for $debugAction'); + } + } + + void overwriteSubNodesFrom(OverwriteContext overwriteContext, + List myNodes, List otherNodes) { + for (final node in zip([myNodes, otherNodes])) { + final me = node[0]; + final other = node[1]; + if (me.set(other.get())) { + overwriteContext.trackChange(this, node: me.name); + } + } + } +} + abstract class KdbxObject extends KdbxNode { - KdbxObject.create(this.ctx, this.file, String nodeName, KdbxGroup parent) - : assert(ctx != null), + KdbxObject.create( + this.ctx, + this.file, + String nodeName, + KdbxGroup parent, + ) : assert(ctx != null), times = KdbxTimes.create(ctx), _parent = parent, super.create(nodeName) { @@ -156,6 +203,13 @@ abstract class KdbxObject extends KdbxNode { file?.dirtyObject(this); } + bool wasModifiedAfter(KdbxObject other) => times.lastModificationTime + .get() + .isAfter(other.times.lastModificationTime.get()); + + bool wasMovedAfter(KdbxObject other) => + times.locationChanged.get().isAfter(other.times.locationChanged.get()); + @override XmlElement toXml() { final el = super.toXml(); @@ -167,6 +221,8 @@ abstract class KdbxObject extends KdbxNode { void internalChangeParent(KdbxGroup parent) { modify(() => _parent = parent); } + + void merge(MergeContext mergeContext, covariant KdbxObject other); } class KdbxUuid { diff --git a/lib/src/kdbx_times.dart b/lib/src/kdbx_times.dart index 08518d6..764ee5a 100644 --- a/lib/src/kdbx_times.dart +++ b/lib/src/kdbx_times.dart @@ -1,7 +1,8 @@ import 'package:clock/clock.dart'; -import 'package:kdbx/kdbx.dart'; +import 'package:kdbx/src/kdbx_format.dart'; import 'package:kdbx/src/kdbx_object.dart'; import 'package:kdbx/src/kdbx_xml.dart'; +import 'package:quiver/iterables.dart'; import 'package:xml/xml.dart'; class KdbxTimes extends KdbxNode implements KdbxNodeContext { @@ -38,4 +39,22 @@ class KdbxTimes extends KdbxNode implements KdbxNodeContext { accessedNow(); lastModificationTime.set(clock.now().toUtc()); } + + List get _nodes => [ + creationTime, + lastModificationTime, + lastAccessTime, + expiryTime, + expires, + usageCount, + locationChanged, + ]; + + void overwriteFrom(KdbxTimes other) { + for (final pair in zip([_nodes, other._nodes])) { + final me = pair[0]; + final other = pair[1]; + me.set(other.get()); + } + } } diff --git a/lib/src/kdbx_xml.dart b/lib/src/kdbx_xml.dart index fceaca4..f432eac 100644 --- a/lib/src/kdbx_xml.dart +++ b/lib/src/kdbx_xml.dart @@ -2,7 +2,9 @@ import 'dart:convert'; import 'dart:typed_data'; import 'package:clock/clock.dart'; -import 'package:kdbx/kdbx.dart'; +import 'package:kdbx/src/kdbx_format.dart'; +import 'package:kdbx/src/kdbx_header.dart'; +import 'package:kdbx/src/kdbx_object.dart'; import 'package:kdbx/src/utils/byte_utils.dart'; import 'package:kdbx/src/kdbx_consts.dart'; import 'package:meta/meta.dart'; @@ -58,7 +60,7 @@ abstract class KdbxSubNode { T get(); - void set(T value); + bool set(T value); void remove() { node.modify(() { @@ -103,9 +105,9 @@ abstract class KdbxSubTextNode extends KdbxSubNode { } @override - void set(T value, {bool force = false}) { + bool set(T value, {bool force = false}) { if (get() == value && force != true) { - return; + return false; } node.modify(() { final el = @@ -125,6 +127,7 @@ abstract class KdbxSubTextNode extends KdbxSubNode { el.children.add(XmlText(stringValue)); }); _onModify?.call(); + return true; } @override @@ -183,6 +186,30 @@ class IconNode extends KdbxSubTextNode { String encode(KdbxIcon value) => value.index.toString(); } +class KdbxColor { + const KdbxColor._fromRgbCode(this._rgb) : assert(_rgb != null && _rgb != ''); + const KdbxColor._nullColor() : _rgb = ''; + + factory KdbxColor.parse(String rgb) => + rgb.isEmpty ? nullColor : KdbxColor._fromRgbCode(rgb); + + static const nullColor = KdbxColor._nullColor(); + + final String _rgb; + + bool get isNull => this == nullColor; +} + +class ColorNode extends KdbxSubTextNode { + ColorNode(KdbxNode node, String name) : super(node, name); + + @override + KdbxColor decode(String value) => KdbxColor.parse(value); + + @override + String encode(KdbxColor value) => value.isNull ? '' : value._rgb; +} + class BooleanNode extends KdbxSubTextNode { BooleanNode(KdbxNode node, String name) : super(node, name); @@ -210,6 +237,8 @@ class DateTimeUtcNode extends KdbxSubTextNode { KdbxReadWriteContext get _ctx => (node as KdbxNodeContext).ctx; + bool isAfter(DateTimeUtcNode other) => get().isAfter(other.get()); + void setToNow() { set(clock.now().toUtc()); } diff --git a/pubspec.yaml b/pubspec.yaml index 64da110..aa02dd3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -23,6 +23,7 @@ dependencies: path: '>=1.6.0 <2.0.0' quiver: '>=2.1.0 <3.0.0' archive: '>=2.0.13 <3.0.0' + supercharged_dart: '>=1.2.0 <2.0.0' collection: '>=1.14.0 <2.0.0'