diff --git a/pykeepass/pykeepass.py b/pykeepass/pykeepass.py index d959e1ea..5425c652 100644 --- a/pykeepass/pykeepass.py +++ b/pykeepass/pykeepass.py @@ -10,6 +10,7 @@ import re import uuid import zlib +import elementpath from copy import deepcopy from construct import Container, ChecksumError @@ -245,9 +246,13 @@ def _xpath(self, xpath_str, tree=None, first=False, history=False, if tree is None: tree = self.tree logger.debug(xpath_str) - elements = tree.xpath( - xpath_str, namespaces={'re': 'http://exslt.org/regular-expressions'} - ) + + # dirty hack + if xpath_str == "(ancestor::Group)[last()]": + # elementpath.select() returns an empty list for this XPath + elements = tree.xpath(xpath_str) + else: + elements = elementpath.select(tree, xpath_str) res = [] for e in elements: @@ -270,11 +275,25 @@ def _xpath(self, xpath_str, tree=None, first=False, history=False, return res + @staticmethod + def _escape_xpath_quotes(value): + if isinstance(value, str): + # single quotes work fine, double quotes mess with syntax + # in e.g.: [text()="some""thing"] + value = value.replace('"', '""') + return value + def _find(self, prefix, keys_xp, path=None, tree=None, first=False, history=False, regex=False, flags=None, **kwargs): xp = '' + if flags is None: + # check for None only, don't care about "" or other falsy values + # don't supply None, it'll treat it as N,o,n,e due to string + # formatting i.e. as separate XPath fn:matches() flags. + flags = "" + if path is not None: first = True @@ -303,7 +322,9 @@ def _find(self, prefix, keys_xp, path=None, tree=None, first=False, # handle searching custom string fields if 'string' in kwargs.keys(): for key, value in kwargs['string'].items(): - xp += keys_xp[regex]['string'].format(key, value, flags=flags) + xp += keys_xp[regex]['string'].format( + key, self._escape_xpath_quotes(value), flags=flags + ) kwargs.pop('string') @@ -320,7 +341,9 @@ def _find(self, prefix, keys_xp, path=None, tree=None, first=False, if key not in keys_xp[regex].keys(): raise TypeError('Invalid keyword argument "{}"'.format(key)) - xp += keys_xp[regex][key].format(value, flags=flags) + xp += keys_xp[regex][key].format( + self._escape_xpath_quotes(value), flags=flags + ) res = self._xpath( xp, @@ -566,7 +589,6 @@ def move_entry(self, entry, destination_group): # ---------- Attachments ---------- 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) diff --git a/pykeepass/xpath.py b/pykeepass/xpath.py index 0391fd75..ede7dcf9 100644 --- a/pykeepass/xpath.py +++ b/pykeepass/xpath.py @@ -7,8 +7,8 @@ 'filename': '/Key[text()="{}"]/..' }, True: { - 'id': '/Value[re:test(@Ref, "{}", "{flags}")]/..', - 'filename': '/Key[re:test(text(), "{}", "{flags}")]/..' + 'id': '/Value[matches(@Ref, "{}", "{flags}")]/..', + 'filename': '/Key[matches(text(), "{}", "{flags}")]/..' } } @@ -18,8 +18,8 @@ 'entry': '/Entry/String/Key[text()="Title"]/../Value[text()="{}"]/../..', }, True: { - 'group': '/Group/Name[re:test(text(), "{}", "{flags}")]/..', - 'entry': '/Entry/String/Key[text()="Title"]/../Value[re:test(text(), "{}", "{flags}")]/../..', + 'group': '/Group/Name[matches(text(), "{}", "{flags}")]/..', + 'entry': '/Entry/String/Key[text()="Title"]/../Value[matches(text(), "{}", "{flags}")]/../..', } } @@ -37,16 +37,16 @@ 'autotype_enabled': '/AutoType/Enabled[text()="{}"]/../..', }, True: { - 'title': '/String/Key[text()="Title"]/../Value[re:test(text(), "{}", "{flags}")]/../..', - 'username': '/String/Key[text()="UserName"]/../Value[re:test(text(), "{}", "{flags}")]/../..', - 'password': '/String/Key[text()="Password"]/../Value[re:test(text(), "{}", "{flags}")]/../..', - 'url': '/String/Key[text()="URL"]/../Value[re:test(text(), "{}", "{flags}")]/../..', - 'notes': '/String/Key[text()="Notes"]/../Value[re:test(text(), "{}", "{flags}")]/../..', - 'uuid': '/UUID[re:test(text(), "{}", "{flags}")]/..', - 'tags': '/Tags[re:test(text(), "{}", "{flags}")]/..', - 'string': '/String/Key[text()="{}"]/../Value[re:test(text(), "{}", "{flags}")]/../..', - 'autotype_sequence': '/AutoType/DefaultSequence[re:test(text(), "{}", "{flags}")]/../..', - 'autotype_enabled': '/AutoType/Enabled[re:test(text(), "{}", "{flags}")]/../..', + 'title': '/String/Key[text()="Title"]/../Value[matches(text(), "{}", "{flags}")]/../..', + 'username': '/String/Key[text()="UserName"]/../Value[matches(text(), "{}", "{flags}")]/../..', + 'password': '/String/Key[text()="Password"]/../Value[matches(text(), "{}", "{flags}")]/../..', + 'url': '/String/Key[text()="URL"]/../Value[matches(text(), "{}", "{flags}")]/../..', + 'notes': '/String/Key[text()="Notes"]/../Value[matches(text(), "{}", "{flags}")]/../..', + 'uuid': '/UUID[matches(text(), "{}", "{flags}")]/..', + 'tags': '/Tags[matches(text(), "{}", "{flags}")]/..', + 'string': '/String/Key[text()="{}"]/../Value[matches(text(), "{}", "{flags}")]/../..', + 'autotype_sequence': '/AutoType/DefaultSequence[matches(text(), "{}", "{flags}")]/../..', + 'autotype_enabled': '/AutoType/Enabled[matches(text(), "{}", "{flags}")]/../..', } } @@ -57,8 +57,8 @@ 'notes': '/Notes[text()="{}"]/..', }, True: { - 'name': '/Name[re:test(text(), "{}", "{flags}")]/..', - 'uuid': '/UUID[re:test(text(), "{}", "{flags}")]/..', - 'notes': '/Notes[re:test(text(), "{}", "{flags}")]/..', + 'name': '/Name[matches(text(), "{}", "{flags}")]/..', + 'uuid': '/UUID[matches(text(), "{}", "{flags}")]/..', + 'notes': '/Notes[matches(text(), "{}", "{flags}")]/..', } } diff --git a/requirements.txt b/requirements.txt index 7dfdf280..58bc9186 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ construct==2.10.54 argon2-cffi==19.2.0 python-dateutil==2.8.0 future==0.17.1 +elementpath==2.0.3 diff --git a/tests/tests.py b/tests/tests.py index c75e898e..21c3eb87 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -133,7 +133,7 @@ def test_find_entries_by_string(self): self.assertEqual(uu, results.uuid) def test_find_entries_by_autotype_sequence(self): - results = self.kp.find_entries(autotype_sequence='{TAB}', regex=True) + results = self.kp.find_entries(autotype_sequence='\{TAB\}', regex=True) self.assertEqual(len(results), 1) self.assertEqual(results[0].autotype_sequence, '{USERNAME}{TAB}{PASSWORD}{ENTER}') @@ -750,6 +750,34 @@ def test_issue193(self): self.assertTrue(g.name is None) self.assertTrue(g in self.kp.groups) + def test_issue123(self): + # issue 193 - kp.entries can't contain quotes or apostrophes + entries = [ + self.kp.add_entry( + self.kp.root_group, 'te"st', 'user"', 'single"' + ), + self.kp.add_entry( + self.kp.root_group, 'te""st', 'user""', 'double"' + ), + self.kp.add_entry( + self.kp.root_group, "te'st", "user'", "single'" + ), + self.kp.add_entry( + self.kp.root_group, "te''st", "user''", "double''" + ) + ] + for entry in entries: + self.assertTrue(entry.title is not None) + self.assertTrue(entry in self.kp.entries) + + for entry in entries: + temp = self.kp.find_entries(title=entry.title) + self.assertIsInstance(temp, list) + self.assertEqual(len(temp), 1) + temp = temp[0] + self.assertEqual(temp.title, entry.title) + self.assertEqual(temp.username, entry.username) + self.assertEqual(temp.password, entry.password) class EntryFindTests4(KDBX4Tests, EntryFindTests3):