Commit a025bea8 authored by Evan Widloski's avatar Evan Widloski Committed by Philipp Schmitt
Browse files

Stringfix (#128)

* add test for delete_custom_property

* string fixes and python2 FIXMEs

* move `first`, `history` into _xpath, use kp._xpath everywhere

* clarify return types

* move .parentgroup to baseelement.py

* use UUID for __eq__, fix #122

* fix for when no attachments present

* perform all tests on both kdbx3 and kdbx4
parent b88f4dcc
......@@ -235,9 +235,12 @@ where ``id`` is an int. Removes attachment data from the database and deletes a
where ``id`` is an int, ``filename`` is a string, and element is an ``Entry`` or ``Group`` to search under.
* if ``first=False``, the function returns a list of ``Attachment`` s or ``[]`` if there are no matches
* if ``first=True``, the function returns the first ``Attachment`` match, or ``None`` if there are no matches
**binaries**
list containing attachment data. List index corresponds to attachment id.
list of bytes containing attachment data. List index corresponds to attachment id.
**attachments**
......@@ -245,7 +248,7 @@ list containing all ``Attachment`` s in the database.
**Entry.add_attachment** (id, filename)
where ``id`` is an int and ``filename`` is a string. Creates a reference using the given filename to a database attachment. The existence of an attachment with the given id is not checked.
where ``id`` is an int and ``filename`` is a string. Creates a reference using the given filename to a database attachment. The existence of an attachment with the given id is not checked. Returns ``Attachment``.
**Entry.delete_attachment** (attachment)
......
from __future__ import print_function
# FIXME python2
from __future__ import unicode_literals
from __future__ import print_function
from __future__ import absolute_import
from future.utils import python_2_unicode_compatible
import pykeepass.entry
from collections import namedtuple
# FIXME python2
@python_2_unicode_compatible
class Attachment(object):
def __init__(self, element=None, kp=None, id=None, filename=None):
self._element = element
......
from __future__ import unicode_literals
from __future__ import absolute_import
from lxml import etree
from lxml.etree import Element
from lxml.builder import E
from datetime import datetime, timedelta
import base64
from binascii import Error as BinasciiError
from dateutil import parser, tz
import uuid
import struct
......@@ -42,6 +42,9 @@ class BaseElement(object):
)
)
def _xpath(self, xpath, **kwargs):
return self._kp._xpath(xpath, tree=self._element, **kwargs)
def _get_subelement_text(self, tag):
v = self._element.find(tag)
if v is not None:
......@@ -53,6 +56,16 @@ class BaseElement(object):
self._element.remove(v)
self._element.append(getattr(E, tag)(value))
@property
def group(self):
return self._xpath(
'(ancestor::Group)[last()]',
first=True,
cast=True
)
parentgroup = group
def dump_xml(self, pretty_print=False):
return etree.tostring(self._element, pretty_print=pretty_print)
......@@ -88,7 +101,15 @@ class BaseElement(object):
if self._kp.version >= (4, 0):
diff_seconds = int(
(value - datetime(year=1, month=1, day=1)).total_seconds()
(
self._datetime_to_utc(value) -
datetime(
year=1,
month=1,
day=1,
tzinfo=tz.gettz('UTC')
)
).total_seconds()
)
return base64.b64encode(
struct.pack('<Q', diff_seconds)
......@@ -103,11 +124,9 @@ class BaseElement(object):
# decode KDBX4 date from b64 format
try:
return (
datetime(year=1, month=1, day=1) +
datetime(year=1, month=1, day=1, tzinfo=tz.gettz('UTC')) +
timedelta(
seconds=int.from_bytes(
base64.b64decode(text), 'little'
)
seconds = struct.unpack('<Q', base64.b64decode(text))[0]
)
)
except BinasciiError:
......@@ -195,3 +214,9 @@ class BaseElement(object):
def __repr__(self):
return self.__str__()
def __eq__(self, other):
if hasattr(other, 'uuid'):
return self.uuid == other.uuid
else:
return False
# FIXME python2
from __future__ import unicode_literals
from __future__ import absolute_import
from future.utils import python_2_unicode_compatible
from copy import deepcopy
from lxml.etree import Element, _Element
from lxml.objectify import ObjectifiedElement
......@@ -26,7 +29,8 @@ reserved_keys = [
'History'
]
# FIXME python2
@python_2_unicode_compatible
class Entry(BaseElement):
def __init__(self, title=None, username=None, password=None, url=None,
......@@ -75,19 +79,14 @@ class Entry(BaseElement):
self._element = element
def _get_string_field(self, key):
results = self._element.xpath('String/Key[text()="{}"]/../Value'.format(key))
if results:
return results[0].text
field = self._xpath('String/Key[text()="{}"]/../Value'.format(key), first=True)
if field is not None:
return field.text
def _set_string_field(self, key, value):
results = self._element.xpath('String/Key[text()="{}"]/..'.format(key))
if results:
logger.debug(
'There is field named {}. Remove it and create again.'.format(key)
)
self._element.remove(results[0])
else:
logger.debug('No field named {}. Create it.'.format(key))
field = self._xpath('String/Key[text()="{}"]/..'.format(key), first=True)
if field is not None:
self._element.remove(field)
self._element.append(E.String(E.Key(key), E.Value(value)))
def _get_string_field_keys(self, exclude_reserved=False):
......@@ -216,16 +215,6 @@ class Entry(BaseElement):
return parent.tag == 'History'
return False
@property
def group(self):
if self.is_a_history_entry:
ancestor = self._element.getparent().getparent()
else:
ancestor = self._element.getparent()
if ancestor is not None:
return pykeepass.group.Group(element=ancestor, kp=self._kp)
parentgroup = group
@property
def path(self):
......@@ -257,10 +246,10 @@ class Entry(BaseElement):
def delete_custom_property(self, key):
if key not in self._get_string_field_keys(exclude_reserved=True):
raise AttributeError('No such key: {}'.format(key))
prop = self._element.xpath('String/Key[text()="{}"]/..'.format(key))
if len(prop) < 1:
prop = self._xpath('String/Key[text()="{}"]/..'.format(key), first=True)
if prop is None:
raise AttributeError('Could not find property element')
self._element.remove(prop[0])
self._element.remove(prop)
@property
def custom_properties(self):
......@@ -293,16 +282,4 @@ class Entry(BaseElement):
self._element.append(history)
def __str__(self):
return str(
'Entry: "{} ({})"'.format(self.path, self.username).encode('utf-8')
)
def __eq__(self, other):
return (
(self.title, self.username, self.password, self.url,
self.notes, self.icon, self.tags, self.atime, self.ctime,
self.mtime, self.expires, self.uuid) ==
(other.title, other.username, other.password, other.url,
other.notes, other.icon, other.tags, other.atime, other.ctime,
other.mtime, other.expires, other.uuid)
)
return 'Entry: "{} ({})"'.format(self.path, self.username)
# FIXME python2
from __future__ import unicode_literals
from __future__ import absolute_import
from future.utils import python_2_unicode_compatible
from pykeepass.baseelement import BaseElement
from lxml.etree import Element, _Element
from lxml.objectify import ObjectifiedElement
from lxml.builder import E
import pykeepass.entry
# FIXME python2
@python_2_unicode_compatible
class Group(BaseElement):
def __init__(self, name=None, element=None, icon=None, notes=None,
......@@ -65,12 +69,6 @@ class Group(BaseElement):
def subgroups(self):
return [Group(element=x, kp=self._kp) for x in self._element.findall('Group')]
@property
def parentgroup(self):
if self._element.getparent() is None:
return None
return Group(element=self._element.getparent(), kp=self._kp)
@property
def is_root_group(self):
return self._element.getparent().tag == 'Root'
......@@ -96,4 +94,4 @@ class Group(BaseElement):
self._element.append(entries._element)
def __str__(self):
return str('Group: "{}"'.format(self.path).encode('utf-8'))
return 'Group: "{}"'.format(self.path)
......@@ -40,12 +40,11 @@ class DynamicDict(Adapter):
# map ListContainer to Container
def _decode(self, obj, context, path):
d = OrderedDict()
for l in self.lump:
d[l] = ListContainer([])
for item in obj:
if item[self.key] in self.lump:
if item[self.key] in d:
d[item[self.key]].append(item)
else:
d[item[self.key]] = ListContainer([item])
d[item[self.key]].append(item)
else:
d[item[self.key]] = item
......
#!/usr/bin/env python
# coding: utf-8
from __future__ import print_function
# FIXME python2
from __future__ import unicode_literals
from __future__ import print_function
from __future__ import absolute_import
from future.utils import python_2_unicode_compatible
import base64
import logging
import os
......@@ -25,6 +28,8 @@ from pykeepass.exceptions import *
logger = logging.getLogger(__name__)
# FIXME python2
@python_2_unicode_compatible
class PyKeePass(object):
def __init__(self, filename, password=None, keyfile=None,
......@@ -129,29 +134,40 @@ class PyKeePass(object):
)
)
def _xpath(self, xpath_str, tree=None):
def _xpath(self, xpath_str, tree=None, first=False, history=False,
cast=False, **kwargs):
if tree is None:
tree = self.tree
logger.debug(xpath_str)
result = tree.xpath(
elements = tree.xpath(
xpath_str, namespaces={'re': 'http://exslt.org/regular-expressions'}
)
# Typed result array
res = []
for r in result:
if r.tag == 'Entry':
res.append(Entry(element=r, kp=self))
elif r.tag == 'Group':
res.append(Group(element=r, kp=self))
elif r.tag == 'Binary' and r.getparent().tag == 'Entry':
res.append(Attachment(element=r, kp=self))
else:
res.append(r)
for e in elements:
if history or e.getparent().tag != 'History':
if cast:
if e.tag == 'Entry':
res.append(Entry(element=e, kp=self))
elif e.tag == 'Group':
res.append(Group(element=e, kp=self))
elif e.tag == 'Binary' and e.getparent().tag == 'Entry':
res.append(Attachment(element=e, kp=self))
else:
raise Exception('Could not cast element {}'.format(e))
else:
res.append(e)
# return first object in list or None
if first:
res = res[0] if res else None
return res
def _find(self, prefix, keys_xp, path=None, tree=None, history=False, first=False,
regex=False, flags=None, **kwargs):
def _find(self, prefix, keys_xp, path=None, tree=None, first=False,
history=False, regex=False, flags=None, **kwargs):
xp = ''
......@@ -191,21 +207,24 @@ class PyKeePass(object):
xp += keys_xp[regex][key].format(value, flags=flags)
res = self._xpath(xp, tree=tree._element if tree else None)
res = self._xpath(
xp,
tree=tree._element if tree else None,
first=first,
history=history,
cast=True,
**kwargs
)
return res
#---------- Groups ----------
def find_groups(self, first=False, recursive=True, path=None, group=None,
**kwargs):
def find_groups(self, recursive=True, path=None, group=None, **kwargs):
prefix = '//Group' if recursive else '/Group'
res = self._find(prefix, group_xp, path=path, tree=group, **kwargs)
# return first object in list or None
if first:
res = res[0] if res else None
return res
......@@ -286,19 +305,11 @@ class PyKeePass(object):
#---------- Entries ----------
def find_entries(self, history=False, first=False, recursive=True,
path=None, group=None, **kwargs):
def find_entries(self, recursive=True, path=None, group=None, **kwargs):
prefix = '//Entry' if recursive else '/Entry'
res = self._find(prefix, entry_xp, path=path, tree=group, **kwargs)
if history is False:
res = [item for item in res if item._element.getparent().tag != 'History']
# return first object in list or None
if first:
res = res[0] if res else None
return res
......@@ -434,18 +445,11 @@ class PyKeePass(object):
#---------- Attachments ----------
def find_attachments(self, history=False, first=False, recursive=True,
path=None, element=None, **kwargs):
def find_attachments(self, recursive=True, path=None, element=None, **kwargs):
prefix = '//Binary' if recursive else '/Binary'
res = self._find(prefix, attachment_xp, path=path, tree=element, **kwargs)
if history is False:
res = [item for item in res if item._element.getparent().getparent().tag != 'History']
# return first object in list or None
if first:
res = res[0] if res else None
return res
@property
......@@ -482,7 +486,10 @@ class PyKeePass(object):
c = Container(type='binary', data=data)
self.kdbx.body.payload.inner_header.binary.append(c)
else:
binaries = self._xpath('/KeePassFile/Meta/Binaries')[0]
binaries = self._xpath(
'/KeePassFile/Meta/Binaries',
first=True
)
if compressed:
# gzip compression
data = zlib.compress(data)
......@@ -503,7 +510,7 @@ class PyKeePass(object):
self.kdbx.body.payload.inner_header.binary.pop(id)
else:
# remove binary element from XML
binaries = self._xpath('/KeePassFile/Meta/Binaries')[0]
binaries = self._xpath('/KeePassFile/Meta/Binaries', first=True)
binaries.remove(binaries.getchildren()[id])
except IndexError:
raise AttachmentError('No such attachment with id {}'.format(id))
......@@ -513,5 +520,9 @@ class PyKeePass(object):
reference.delete()
# decrement references greater than this id
for reference in self._xpath('//Binary/Value[@Ref > "{}"]/..'.format(id)):
binaries_gt = self._xpath(
'//Binary/Value[@Ref > "{}"]/..'.format(id),
cast=True
)
for reference in binaries_gt:
reference.id = reference.id - 1
# FIXME python2
from __future__ import unicode_literals
attachment_xp = {
False: {
'id': '/Value[@Ref="{}"]/..',
......
......@@ -2,4 +2,5 @@ lxml==4.3.0
pycryptodome==3.7.2
construct==2.9.45
argon2-cffi==18.3.0
python-dateutil==2.7.5
\ No newline at end of file
python-dateutil==2.7.5
future==0.16.0
......@@ -17,7 +17,9 @@ setup(
"construct",
"argon2_cffi",
"pycryptodome",
"lxml"
"lxml",
# FIXME python2
future
],
include_package_data=True
)
<?xml version="1.0" encoding="utf-8"?>
<KeyFile>
<Meta>
<Version>1.00</Version>
</Meta>
<Key>
<Data>Qp9MrFM1RpSLO8iHZHGAiPbr8Z+hDFpp0cgtH+RM0hw=</Data>
</Key>
</KeyFile>
No preview for this file type
<?xml version="1.0" encoding="UTF-8"?><KeyFile><Meta><Version>1.00</Version></Meta><Key><Data>FPOLJdmva6e92asrP7t3Gg8UxRAgkN2TrElYi/R24Bw=</Data></Key></KeyFile>
<?xml version="1.0" encoding="utf-8"?>
<KeyFile>
<Meta>
<Version>1.00</Version>
</Meta>
<Key>
<Data>Qp9MrFM1RpSLO8iHZHGAiPbr8Z+hDFpp0cgtH+RM0hw=</Data>
</Key>
</KeyFile>
No preview for this file type
# -*- coding: utf-8 -*-
# FIXME python2
from __future__ import unicode_literals
from datetime import datetime, timedelta
from dateutil import tz
from pykeepass import icons, PyKeePass
......@@ -27,17 +32,26 @@ Missing Tests:
base_dir = os.path.dirname(os.path.realpath(__file__))
logger = logging.getLogger("pykeepass")
class EntryFindTests(unittest.TestCase):
class KDBX3Tests(unittest.TestCase):
database = 'test3.kdbx'
password = 'password'
keyfile = 'test3.key'
# get some things ready before testing
def setUp(self):
self.kp = PyKeePass(
os.path.join(base_dir, 'test.kdbx'),
password='password',
keyfile=os.path.join(base_dir, 'test.key')
os.path.join(base_dir, self.database),
password=self.password,
keyfile=os.path.join(base_dir, self.keyfile)
)
class KDBX4Tests(KDBX3Tests):
database = 'test4.kdbx'
password = 'password'
keyfile = 'test4.key'
class EntryFindTests3(KDBX3Tests):
#---------- Finding entries -----------
def test_find_entries_by_title(self):
......@@ -184,15 +198,12 @@ class EntryFindTests(unittest.TestCase):
def test_print_entries(self):
self.assertIsInstance(self.kp.entries.__repr__(), str)
class GroupFindTests(unittest.TestCase):
e = self.kp.find_entries(title='Тест', first=True)
e.save_history()
self.assertIsInstance(e.__repr__(), str)
self.assertIsInstance(e.history.__repr__(), str)
# get some things ready before testing
def setUp(self):
self.kp = PyKeePass(
os.path.join(base_dir, 'test.kdbx'),
password='password',
keyfile=os.path.join(base_dir, 'test.key')
)
class GroupFindTests3(KDBX3Tests):
#---------- Finding groups -----------
......@@ -266,17 +277,10 @@ class GroupFindTests(unittest.TestCase):
self.assertIsInstance(self.kp.groups.__repr__(), str)
class EntryTests(unittest.TestCase):
# get some things ready before testing
def setUp(self):
self.kp = PyKeePass(
os.path.join(base_dir, 'test.kdbx'),
password='password',
keyfile=os.path.join(base_dir, 'test.key')
)
class EntryTests3(KDBX3Tests):
def test_fields(self):
time = datetime.now()
time = datetime.now().replace(microsecond=0)
entry = Entry(
'title',
'username',
......@@ -302,10 +306,14 @@ class EntryTests(unittest.TestCase):
self.assertEqual(entry.icon, icons.KEY)
self.assertEqual(entry.is_a_history_entry, False)
self.assertEqual(self.kp.find_entries(title='subentry', first=True).path, 'foobar_group/subgroup/subentry')
self.assertEqual(
self.kp.find_entries(title='root_entry', first=True).history[0].group,
self.kp.root_group
)
def test_set_and_get_fields(self):
time = datetime.now()
changed_time = datetime.now() + timedelta(hours=9)
time = datetime.now().replace(microsecond=0)
changed_time = time + timedelta(hours=9)
changed_string = 'changed_'
entry = Entry(
'title',
......@@ -337,6 +345,8 @@ class EntryTests(unittest.TestCase):
self.assertEqual(entry.icon, icons.GLOBE)
self.assertEqual(entry.get_custom_property('foo'), 'bar')
self.assertIn('foo', entry.custom_properties)
entry.delete_custom_property('foo')
self.assertEqual(entry.get_custom_property('foo'), None)
# test time properties
self.assertEqual(entry.expires, False)
self.assertEqual(entry.expiry_time,
......@@ -397,96 +407,74 @@ class EntryTests(unittest.TestCase):
self.assertEqual(entry.attachments[0].filename, 'foobar2.txt')
class GroupTests(unittest.TestCase):
# get some things ready before testing
def setUp(self):
self.kp = PyKeePass(
os.path.join(base_dir, 'test.kdbx'),
password='password',
keyfile=os.path.join(base_dir, 'test.key')
)
class GroupTests3(KDBX3Tests):
def test_fields(self):