Browse Source

fix creating of history entries. added test for correct dirtyObject events.

pull/3/head
Herbert Poul 4 years ago
parent
commit
c7eff31db3
  1. 3
      CHANGELOG.md
  2. 3
      lib/src/kdbx_custom_data.dart
  3. 72
      lib/src/kdbx_entry.dart
  4. 5
      lib/src/kdbx_file.dart
  5. 24
      lib/src/kdbx_group.dart
  6. 58
      lib/src/kdbx_object.dart
  7. 31
      lib/src/kdbx_xml.dart
  8. 1
      pubspec.yaml
  9. 11
      test/internal/test_utils.dart
  10. 26
      test/kdbx_binaries_test.dart
  11. 102
      test/kdbx_history_test.dart

3
CHANGELOG.md

@ -1,6 +1,7 @@
## unreleased ## unreleased
- fixed bug saving files with history entries which contain attachments. - fix bug saving files with history entries which contain attachments.
- fix bug which would create wrong history entries.
## 0.4.0+1 ## 0.4.0+1

3
lib/src/kdbx_custom_data.dart

@ -25,8 +25,7 @@ class KdbxCustomData extends KdbxNode {
String operator [](String key) => _data[key]; String operator [](String key) => _data[key];
void operator []=(String key, String value) { void operator []=(String key, String value) {
_data[key] = value; modify(() => _data[key] = value);
isDirty = true;
} }
@override @override

72
lib/src/kdbx_entry.dart

@ -74,11 +74,14 @@ class KdbxEntry extends KdbxObject {
return MapEntry(key, KdbxBinary.readBinaryXml(valueNode, isInline: true)); return MapEntry(key, KdbxBinary.readBinaryXml(valueNode, isInline: true));
})); }));
history.addAll(_historyElement history.addAll(node
.findElements('Entry') .findElements(KdbxXml.NODE_HISTORY)
.map( .singleOrNull
(entry) => KdbxEntry.read(ctx, parent, entry, isHistoryEntry: true)) ?.findElements('Entry')
.toList()); ?.map((entry) =>
KdbxEntry.read(ctx, parent, entry, isHistoryEntry: true))
?.toList() ??
[]);
} }
final bool isHistoryEntry; final bool isHistoryEntry;
@ -95,21 +98,10 @@ class KdbxEntry extends KdbxObject {
} }
} }
XmlElement get _historyElement => node
.findElements(KdbxXml.NODE_HISTORY)
.singleWhere((_) => true, orElse: () {
final el = XmlElement(XmlName(KdbxXml.NODE_HISTORY));
node.children.add(el);
return el;
});
@override @override
set isDirty(bool newDirty) { void onBeforeModify() {
if (!isDirty && newDirty) { super.onBeforeModify();
final history = _historyElement; history.add(KdbxEntry.read(ctx, parent, toXml())..file = file);
history.children.add(toXml());
}
super.isDirty = newDirty;
} }
@override @override
@ -180,12 +172,13 @@ class KdbxEntry extends KdbxObject {
_logger.finest('Value did not change for $key'); _logger.finest('Value did not change for $key');
return; return;
} }
isDirty = true; modify(() {
if (value == null) { if (value == null) {
_strings.remove(key); _strings.remove(key);
} else { } else {
_strings[key] = value; _strings[key] = value;
} }
});
} }
void renameKey(KdbxKey oldKey, KdbxKey newKey) { void renameKey(KdbxKey oldKey, KdbxKey newKey) {
@ -224,22 +217,23 @@ class KdbxEntry extends KdbxObject {
isProtected: isProtected, isProtected: isProtected,
value: bytes, value: bytes,
); );
file.ctx.addBinary(binary); modify(() {
_binaries[key] = binary; file.ctx.addBinary(binary);
isDirty = true; _binaries[key] = binary;
});
return binary; return binary;
} }
void removeBinary(KdbxKey binaryKey) { void removeBinary(KdbxKey binaryKey) {
final binary = _binaries.remove(binaryKey); modify(() {
if (binary == null) { final binary = _binaries.remove(binaryKey);
throw StateError( if (binary == null) {
'Trying to remove binary key $binaryKey does not exist.'); throw StateError(
} 'Trying to remove binary key $binaryKey does not exist.');
if (!binary.isInline) { }
file.ctx.removeBinary(binary); // binary will not be removed (yet) from file, because it will
} // be referenced in history.
isDirty = true; });
} }
KdbxKey _uniqueBinaryName(String fileName) { KdbxKey _uniqueBinaryName(String fileName) {
@ -261,3 +255,7 @@ class KdbxEntry extends KdbxObject {
return 'KdbxGroup{uuid=$uuid,name=$label}'; return 'KdbxGroup{uuid=$uuid,name=$label}';
} }
} }
extension<T> on Iterable<T> {
T get singleOrNull => singleWhere((element) => true, orElse: () => null);
}

5
lib/src/kdbx_file.dart

@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:collection/collection.dart';
import 'package:kdbx/src/crypto/protected_value.dart'; import 'package:kdbx/src/crypto/protected_value.dart';
import 'package:kdbx/src/kdbx_consts.dart'; import 'package:kdbx/src/kdbx_consts.dart';
import 'package:kdbx/src/kdbx_dao.dart'; import 'package:kdbx/src/kdbx_dao.dart';
@ -51,7 +52,7 @@ class KdbxFile {
/// Marks all dirty objects as clean. Called by [KdbxFormat.save]. /// Marks all dirty objects as clean. Called by [KdbxFormat.save].
void onSaved() { void onSaved() {
dirtyObjects.clear(); dirtyObjects.clear();
_dirtyObjectsChanged.add(dirtyObjects); _dirtyObjectsChanged.add(const {});
} }
Iterable<KdbxObject> get _allObjects => body.rootGroup Iterable<KdbxObject> get _allObjects => body.rootGroup
@ -61,7 +62,7 @@ class KdbxFile {
void dirtyObject(KdbxObject kdbxObject) { void dirtyObject(KdbxObject kdbxObject) {
dirtyObjects.add(kdbxObject); dirtyObjects.add(kdbxObject);
_dirtyObjectsChanged.add(dirtyObjects); _dirtyObjectsChanged.add(UnmodifiableSetView(Set.of(dirtyObjects)));
} }
void dispose() { void dispose() {

24
lib/src/kdbx_group.dart

@ -65,8 +65,7 @@ class KdbxGroup extends KdbxObject {
throw StateError( throw StateError(
'Invalid operation. Trying to add entry which is already in another group.'); 'Invalid operation. Trying to add entry which is already in another group.');
} }
_entries.add(entry); modify(() => _entries.add(entry));
isDirty = true;
} }
void addGroup(KdbxGroup group) { void addGroup(KdbxGroup group) {
@ -74,22 +73,23 @@ class KdbxGroup extends KdbxObject {
throw StateError( throw StateError(
'Invalid operation. Trying to add group which is already in another group.'); 'Invalid operation. Trying to add group which is already in another group.');
} }
_groups.add(group); modify(() => _groups.add(group));
isDirty = true;
} }
void internalRemoveGroup(KdbxGroup group) { void internalRemoveGroup(KdbxGroup group) {
if (!_groups.remove(group)) { modify(() {
throw StateError('Unable to remove $group from $this (Not found)'); if (!_groups.remove(group)) {
} throw StateError('Unable to remove $group from $this (Not found)');
isDirty = true; }
});
} }
void internalRemoveEntry(KdbxEntry entry) { void internalRemoveEntry(KdbxEntry entry) {
if (!_entries.remove(entry)) { modify(() {
throw StateError('Unable to remove $entry from $this (Not found)'); if (!_entries.remove(entry)) {
} throw StateError('Unable to remove $entry from $this (Not found)');
isDirty = true; }
});
} }
/// returns all parents recursively including this group. /// returns all parents recursively including this group.

58
lib/src/kdbx_object.dart

@ -35,14 +35,38 @@ mixin Changeable<T> {
bool _isDirty = false; bool _isDirty = false;
set isDirty(bool dirty) { /// Called before the *first* modification (ie. before `isDirty` changes
// _logger.finest('changing dirty (old:$_isDirty) $dirty'); /// from false to true)
if (!_isDirty && !dirty) { @protected
// no need for change events when already not-dirty. @mustCallSuper
void onBeforeModify() {}
/// Called after the *first* modification (ie. after `isDirty` changed
/// from false to true)
@protected
@mustCallSuper
void onAfterModify() {}
RET modify<RET>(RET Function() modify) {
if (_isDirty) {
return modify();
}
onBeforeModify();
try {
return modify();
} finally {
_isDirty = true;
onAfterModify();
_controller.add(ChangeEvent(object: this as T, isDirty: _isDirty));
}
}
void clean() {
if (!_isDirty) {
return; return;
} }
_isDirty = dirty; _isDirty = false;
_controller.add(ChangeEvent(object: this as T, isDirty: dirty)); _controller.add(ChangeEvent(object: this as T, isDirty: _isDirty));
} }
bool get isDirty => _isDirty; bool get isDirty => _isDirty;
@ -59,6 +83,9 @@ abstract class KdbxNode with Changeable<KdbxNode> {
KdbxNode.read(this.node); KdbxNode.read(this.node);
/// XML Node used while reading this KdbxNode.
/// Must NOT be modified. Only copies which are obtained through [toXml].
/// this node should always represent the original loaded state.
final XmlElement node; final XmlElement node;
// @protected // @protected
@ -68,7 +95,7 @@ abstract class KdbxNode with Changeable<KdbxNode> {
/// will mark this object as not dirty. /// will mark this object as not dirty.
@mustCallSuper @mustCallSuper
XmlElement toXml() { XmlElement toXml() {
isDirty = false; clean();
final el = node.copy() as XmlElement; final el = node.copy() as XmlElement;
return el; return el;
} }
@ -108,15 +135,11 @@ abstract class KdbxObject extends KdbxNode {
KdbxGroup _parent; KdbxGroup _parent;
@override @override
set isDirty(bool dirty) { void onAfterModify() {
if (dirty) { super.onAfterModify();
times.modifiedNow(); times.modifiedNow();
if (/*!isDirty && */ dirty) { // during initial `create` the file will be null.
// during initial `create` the file will be null. file?.dirtyObject(this);
file?.dirtyObject(this);
}
}
super.isDirty = dirty;
} }
@override @override
@ -128,8 +151,7 @@ abstract class KdbxObject extends KdbxNode {
} }
void internalChangeParent(KdbxGroup parent) { void internalChangeParent(KdbxGroup parent) {
_parent = parent; modify(() => _parent = parent);
isDirty = true;
} }
} }

31
lib/src/kdbx_xml.dart

@ -75,22 +75,23 @@ abstract class KdbxSubTextNode<T> extends KdbxSubNode<T> {
if (get() == value) { if (get() == value) {
return; return;
} }
node.isDirty = true; node.modify(() {
final el = final el =
node.node.findElements(name).singleWhere((x) => true, orElse: () { node.node.findElements(name).singleWhere((x) => true, orElse: () {
final el = XmlElement(XmlName(name)); final el = XmlElement(XmlName(name));
node.node.children.add(el); node.node.children.add(el);
return el; return el;
});
el.children.clear();
if (value == null) {
return;
}
final stringValue = encode(value);
if (stringValue == null) {
return;
}
el.children.add(XmlText(stringValue));
}); });
el.children.clear();
if (value == null) {
return;
}
final stringValue = encode(value);
if (stringValue == null) {
return;
}
el.children.add(XmlText(stringValue));
} }
@override @override

1
pubspec.yaml

@ -35,3 +35,4 @@ dependencies:
dev_dependencies: dev_dependencies:
pedantic: '>=1.7.0 <2.0.0' pedantic: '>=1.7.0 <2.0.0'
test: '>=1.6.0 <2.0.0' test: '>=1.6.0 <2.0.0'
fake_async: ^1.1.0

11
test/internal/test_utils.dart

@ -12,6 +12,8 @@ import 'package:logging/logging.dart';
final _logger = Logger('test_utils'); final _logger = Logger('test_utils');
class TestUtil { class TestUtil {
static final keyTitle = KdbxKey('Title');
static KdbxFormat kdbxFormat() { static KdbxFormat kdbxFormat() {
Argon2.resolveLibraryForceDynamic = true; Argon2.resolveLibraryForceDynamic = true;
return KdbxFormat(Argon2FfiFlutter(resolveLibrary: (path) { return KdbxFormat(Argon2FfiFlutter(resolveLibrary: (path) {
@ -35,10 +37,15 @@ class TestUtil {
} }
static Future<KdbxFile> readKdbxFileBytes(Uint8List data, static Future<KdbxFile> readKdbxFileBytes(Uint8List data,
{String password = 'asdf'}) async { {String password = 'asdf', Credentials credentials}) async {
final kdbxFormat = TestUtil.kdbxFormat(); final kdbxFormat = TestUtil.kdbxFormat();
final file = await kdbxFormat.read( final file = await kdbxFormat.read(
data, Credentials(ProtectedValue.fromString(password))); data, credentials ?? Credentials(ProtectedValue.fromString(password)));
return file; return file;
} }
static Future<KdbxFile> saveAndRead(KdbxFile file) async {
return await readKdbxFileBytes(await file.save(),
credentials: file.credentials);
}
} }

26
test/kdbx_binaries_test.dart

@ -85,6 +85,18 @@ void main() {
final entry = file.body.rootGroup.entries.first; final entry = file.body.rootGroup.entries.first;
expectKeepass2binariesContents(entry); expectKeepass2binariesContents(entry);
}); });
test('modify file with binary in history', () async {
final fileRead =
await TestUtil.readKdbxFile('test/keepass2binaries.kdbx');
final updateEntry = (KdbxFile file) {
final entry = fileRead.body.rootGroup.entries.first;
entry.setString(KdbxKey('title'), PlainValue('example'));
};
updateEntry(fileRead);
final saved = await fileRead.save();
final file = await TestUtil.readKdbxFileBytes(saved);
await file.save();
});
test('Add new attachment', () async { test('Add new attachment', () async {
await _testAddNewAttachment('test/keepass2binaries.kdbx'); await _testAddNewAttachment('test/keepass2binaries.kdbx');
}); });
@ -95,7 +107,7 @@ void main() {
expectKeepass2binariesContents(entry); expectKeepass2binariesContents(entry);
expect(file.ctx.binariesIterable, hasLength(3)); expect(file.ctx.binariesIterable, hasLength(3));
entry.removeBinary(KdbxKey('example1.txt')); entry.removeBinary(KdbxKey('example1.txt'));
expect(file.ctx.binariesIterable, hasLength(2)); expect(file.ctx.binariesIterable, hasLength(3));
return await file.save(); return await file.save();
})(); })();
final file = await TestUtil.readKdbxFileBytes(saved); final file = await TestUtil.readKdbxFileBytes(saved);
@ -103,7 +115,12 @@ void main() {
expect(entry.binaryEntries, hasLength(2)); expect(entry.binaryEntries, hasLength(2));
expect(entry.binaryEntries.map((e) => (e.key.key)), expect(entry.binaryEntries.map((e) => (e.key.key)),
['example2.txt', 'keepasslogo.jpeg']); ['example2.txt', 'keepasslogo.jpeg']);
expect(file.ctx.binariesIterable, hasLength(2)); // the file itself will contain 3 items, because it is still
// available in history.
expect(file.ctx.binariesIterable, hasLength(3));
expect(entry.history.last.binaryEntries, hasLength(3));
// make sure the file can still be saved.
await file.save();
}); });
}); });
group('kdbx4 attachment', () { group('kdbx4 attachment', () {
@ -139,7 +156,8 @@ void main() {
hasLength(7092)); hasLength(7092));
expect(file.ctx.binariesIterable, hasLength(2)); expect(file.ctx.binariesIterable, hasLength(2));
entry.removeBinary(KdbxKey('example2.txt')); entry.removeBinary(KdbxKey('example2.txt'));
expect(file.ctx.binariesIterable, hasLength(1)); // the binary remains in the file, since it is referenced in the history
expect(file.ctx.binariesIterable, hasLength(2));
expect(file.dirtyObjects, [entry]); expect(file.dirtyObjects, [entry]);
return await file.save(); return await file.save();
})(); })();
@ -148,7 +166,7 @@ void main() {
expect(entry.binaryEntries, hasLength(0)); expect(entry.binaryEntries, hasLength(0));
expectBinary(file.body.rootGroup.entries.last, 'keepasslogo.jpeg', expectBinary(file.body.rootGroup.entries.last, 'keepasslogo.jpeg',
hasLength(7092)); hasLength(7092));
expect(file.ctx.binariesIterable, hasLength(1)); expect(file.ctx.binariesIterable, hasLength(2));
}); });
test('Add new attachment kdbx4', () async { test('Add new attachment kdbx4', () async {
await _testAddNewAttachment('test/keepass2kdbx4binaries.kdbx'); await _testAddNewAttachment('test/keepass2kdbx4binaries.kdbx');

102
test/kdbx_history_test.dart

@ -0,0 +1,102 @@
import 'dart:async';
import 'package:kdbx/kdbx.dart';
import 'package:logging_appenders/logging_appenders.dart';
import 'package:quiver/core.dart';
import 'package:test/test.dart';
import 'internal/test_utils.dart';
class StreamExpect<T> {
StreamExpect(this.stream) {
stream.listen((event) {
if (_expectNext == null) {
fail('Got event, but none was expected. $event');
}
expect(event, _expectNext.orNull);
_expectNext = null;
}, onDone: () {
expect(_expectNext, isNull);
isDone = true;
}, onError: (dynamic error) {
expect(_expectNext, isNull);
this.error = error;
});
}
Future<RET> expectNext<RET>(T value, FutureOr<RET> Function() cb) async {
if (_expectNext != null) {
fail('The last event was never received. last: $_expectNext');
}
_expectNext = Optional.fromNullable(value);
try {
return await cb();
} finally {
await pumpEventQueue();
}
}
void expectFinished() {
expect(isDone, true);
}
final Stream<T> stream;
bool isDone = false;
dynamic error;
Optional<T> _expectNext;
}
void main() {
PrintAppender.setupLogging();
group('test history for values', () {
test('check history creation', () async {
final file = await TestUtil.readKdbxFile('test/keepass2test.kdbx');
const valueOrig = 'Sample Entry';
const value1 = 'new';
const value2 = 'new2';
final dirtyExpect = StreamExpect(file.dirtyObjectsChanged);
{
final first = file.body.rootGroup.entries.first;
expect(file.header.versionMajor, 3);
expect(first.getString(TestUtil.keyTitle).getText(), valueOrig);
await dirtyExpect.expectNext({first}, () {
first.setString(TestUtil.keyTitle, PlainValue(value1));
});
}
expect(file.dirtyObjects, hasLength(1));
final f2 =
await dirtyExpect.expectNext({}, () => TestUtil.saveAndRead(file));
expect(file.dirtyObjects, isEmpty);
{
final first = f2.body.rootGroup.entries.first;
expect(first.getString(TestUtil.keyTitle).getText(), value1);
expect(first.history.last.getString(TestUtil.keyTitle).getText(),
valueOrig);
await dirtyExpect.expectNext({}, () => file.save());
}
// edit the original file again, and there should be a second history
{
final first = file.body.rootGroup.entries.first;
await dirtyExpect.expectNext({first},
() => first.setString(TestUtil.keyTitle, PlainValue(value2)));
}
final f3 =
await dirtyExpect.expectNext({}, () => TestUtil.saveAndRead(file));
expect(file.dirtyObjects, isEmpty);
{
final first = f3.body.rootGroup.entries.first;
expect(first.getString(TestUtil.keyTitle).getText(), value2);
expect(first.history, hasLength(2));
expect(
first.history.last.getString(TestUtil.keyTitle).getText(), value1);
expect(first.history.first.getString(TestUtil.keyTitle).getText(),
valueOrig);
await dirtyExpect.expectNext({}, () => file.save());
}
file.dispose();
await pumpEventQueue();
dirtyExpect.expectFinished();
});
});
}
Loading…
Cancel
Save