diff options
| author | drebs <drebs@leap.se> | 2012-12-24 10:14:24 -0200 | 
|---|---|---|
| committer | drebs <drebs@leap.se> | 2012-12-24 10:14:24 -0200 | 
| commit | ca5fb41a55e1292005ed186baf3710831d9ad678 (patch) | |
| tree | ac7eb9e8f5d993732e769607903ef626518749cd /src | |
| parent | 564b82fa30ebcd8a0abfea54e00506dd77446a54 (diff) | |
| parent | 277f17aa7b7bbcc48583149a3d72d8621f83c0ff (diff) | |
Merge branch 'feature/u1db-openstack-backend' into develop
Diffstat (limited to 'src')
| -rw-r--r-- | src/leap/soledad/README | 21 | ||||
| -rw-r--r-- | src/leap/soledad/__init__.py | 87 | ||||
| -rw-r--r-- | src/leap/soledad/backends/__init__.py | 0 | ||||
| -rw-r--r-- | src/leap/soledad/backends/couch.py | 127 | ||||
| -rw-r--r-- | src/leap/soledad/backends/leap.py | 175 | ||||
| -rw-r--r-- | src/leap/soledad/backends/objectstore.py | 210 | ||||
| -rw-r--r-- | src/leap/soledad/backends/openstack.py | 98 | ||||
| -rw-r--r-- | src/leap/soledad/backends/sqlcipher.py | 127 | ||||
| -rw-r--r-- | src/leap/soledad/tests/__init__.py | 55 | ||||
| -rw-r--r-- | src/leap/soledad/tests/test_couch.py | 280 | ||||
| -rw-r--r-- | src/leap/soledad/tests/test_encrypted.py | 210 | ||||
| -rw-r--r-- | src/leap/soledad/tests/test_logs.py | 75 | ||||
| -rw-r--r-- | src/leap/soledad/tests/test_sqlcipher.py | 503 | ||||
| -rw-r--r-- | src/leap/soledad/util.py | 176 | 
14 files changed, 2144 insertions, 0 deletions
| diff --git a/src/leap/soledad/README b/src/leap/soledad/README new file mode 100644 index 00000000..b59d4184 --- /dev/null +++ b/src/leap/soledad/README @@ -0,0 +1,21 @@ +Soledad -- Synchronization Of Locally Encrypted Data Among Devices +================================================================== + +This software is under development. + +Dependencies +------------ + +Soledad depends on the following python libraries: + +  * u1db 0.1.4 [1] +  * python-swiftclient 1.2.0 [2] +  * python-gnupg 0.3.1 [3] +  * CouchDB 0.8 [4] +  * hmac 20101005 [5] + +[1] http://pypi.python.org/pypi/u1db/0.1.4 +[2] http://pypi.python.org/pypi/python-swiftclient/1.2.0 +[3] http://pypi.python.org/pypi/python-gnupg/0.3.1 +[4] http://pypi.python.org/pypi/CouchDB/0.8 +[5] http://pypi.python.org/pypi/hmac/20101005 diff --git a/src/leap/soledad/__init__.py b/src/leap/soledad/__init__.py new file mode 100644 index 00000000..6a3707ea --- /dev/null +++ b/src/leap/soledad/__init__.py @@ -0,0 +1,87 @@ +# License? + +"""A U1DB implementation for using Object Stores as its persistence layer.""" + +import os +import string +import random +import cStringIO +import hmac +from soledad.util import GPGWrapper + +class Soledad(object): + +    PREFIX        = os.environ['HOME']  + '/.config/leap/soledad' +    SECRET_PATH   = PREFIX + '/secret.gpg' +    GNUPG_HOME    = PREFIX + '/gnupg' +    SECRET_LENGTH = 50 + +    def __init__(self, user_email, gpghome=None): +        self._user_email = user_email +        if not os.path.isdir(self.PREFIX): +            os.makedirs(self.PREFIX) +        if not gpghome: +            gpghome = self.GNUPG_HOME +        self._gpg = GPGWrapper(gpghome=gpghome) +        # load OpenPGP keypair +        if not self._has_openpgp_keypair(): +            self._gen_openpgp_keypair() +        self._load_openpgp_keypair() +        # load secret +        if not self._has_secret(): +            self._gen_secret() +        self._load_secret() + +    def _has_secret(self): +        if os.path.isfile(self.SECRET_PATH): +            return True +        return False + +    def _load_secret(self): +        try: +            with open(self.SECRET_PATH) as f: +               self._secret = str(self._gpg.decrypt(f.read())) +        except IOError as e: +           raise IOError('Failed to open secret file %s.' % self.SECRET_PATH) + +    def _gen_secret(self): +        self._secret = ''.join(random.choice(string.ascii_uppercase + string.digits) for x in range(self.SECRET_LENGTH)) +        ciphertext = self._gpg.encrypt(self._secret, self._fingerprint, self._fingerprint) +        f = open(self.SECRET_PATH, 'w') +        f.write(str(ciphertext)) +        f.close() + + +    def _has_openpgp_keypair(self): +        try: +            self._gpg.find_key(self._user_email) +            return True +        except LookupError: +            return False + +    def _gen_openpgp_keypair(self): +        params = self._gpg.gen_key_input( +          key_type='RSA', +          key_length=4096, +          name_real=self._user_email, +          name_email=self._user_email, +          name_comment='Generated by LEAP Soledad.') +        self._gpg.gen_key(params) + +    def _load_openpgp_keypair(self): +        self._fingerprint = self._gpg.find_key(self._user_email)['fingerprint'] + +    def encrypt(self, data, sign=None, passphrase=None, symmetric=False): +        return str(self._gpg.encrypt(data, self._fingerprint, sign=sign, +                                     passphrase=passphrase, symmetric=symmetric)) + +    def encrypt_symmetric(self, doc_id, data, sign=None): +        h = hmac.new(self._secret, doc_id).hexdigest() +        return self.encrypt(data, sign=sign, passphrase=h, symmetric=True) + +    def decrypt(self, data, passphrase=None, symmetric=False): +        return str(self._gpg.decrypt(data, passphrase=passphrase)) + +    def decrypt_symmetric(self, doc_id, data): +        h = hmac.new(self._secret, doc_id).hexdigest() +        return self.decrypt(data, passphrase=h) diff --git a/src/leap/soledad/backends/__init__.py b/src/leap/soledad/backends/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/src/leap/soledad/backends/__init__.py diff --git a/src/leap/soledad/backends/couch.py b/src/leap/soledad/backends/couch.py new file mode 100644 index 00000000..ed356fdd --- /dev/null +++ b/src/leap/soledad/backends/couch.py @@ -0,0 +1,127 @@ +from u1db import errors +from u1db.remote.http_target import HTTPSyncTarget +from couchdb.client import Server, Document +from couchdb.http import ResourceNotFound +from soledad.backends.objectstore import ObjectStore +from soledad.backends.leap import LeapDocument + +try: +    import simplejson as json +except ImportError: +    import json  # noqa + + +class CouchDatabase(ObjectStore): +    """A U1DB implementation that uses Couch as its persistence layer.""" + +    def __init__(self, url, database, full_commit=True, session=None):  +        """Create a new Couch data container.""" +        self._url = url +        self._full_commit = full_commit +        self._session = session +        self._server = Server(url=self._url, +                              full_commit=self._full_commit, +                              session=self._session) +        # this will ensure that transaction and sync logs exist and are +        # up-to-date. +        self.set_document_factory(LeapDocument) +        try: +            self._database = self._server[database] +        except ResourceNotFound: +            self._server.create(database) +            self._database = self._server[database] +        super(CouchDatabase, self).__init__() + +    #------------------------------------------------------------------------- +    # implemented methods from Database +    #------------------------------------------------------------------------- + +    def _get_doc(self, doc_id, check_for_conflicts=False): +        """Get just the document content, without fancy handling. +         +        Conflicts do not happen on server side, so there's no need to check +        for them. +        """ +        cdoc = self._database.get(doc_id) +        if cdoc is None: +            return None +        doc = self._factory(doc_id=doc_id, rev=cdoc['u1db_rev']) +        if cdoc['u1db_json'] is not None: +            doc.content = json.loads(cdoc['u1db_json']) +        else: +            doc.make_tombstone() +        return doc + +    def get_all_docs(self, include_deleted=False): +        """Get all documents from the database.""" +        generation = self._get_generation() +        results = [] +        for doc_id in self._database: +            doc = self._get_doc(doc_id) +            if doc.content is None and not include_deleted: +                continue +            results.append(doc) +        return (generation, results) + +    def _put_doc(self, doc): +        # prepare couch's Document +        cdoc = Document() +        cdoc['_id'] = doc.doc_id +        # we have to guarantee that couch's _rev is cosistent +        old_cdoc = self._database.get(doc.doc_id) +        if old_cdoc is not None: +            cdoc['_rev'] = old_cdoc['_rev'] +        # store u1db's rev +        cdoc['u1db_rev'] = doc.rev +        # store u1db's content as json string +        if not doc.is_tombstone(): +            cdoc['u1db_json'] = doc.get_json() +        else: +            cdoc['u1db_json'] = None +        self._database.save(cdoc) + +    def get_sync_target(self): +        return CouchSyncTarget(self) + +    def close(self): +        raise NotImplementedError(self.close) + +    def sync(self, url, creds=None, autocreate=True): +        from u1db.sync import Synchronizer +        from u1db.remote.http_target import CouchSyncTarget +        return Synchronizer(self, CouchSyncTarget(url, creds=creds)).sync( +            autocreate=autocreate) + +    def _get_u1db_data(self): +        cdoc = self._database.get(self.U1DB_DATA_DOC_ID) +        content = json.loads(cdoc['u1db_json']) +        self._sync_log.log = content['sync_log'] +        self._transaction_log.log = content['transaction_log'] +        self._replica_uid = content['replica_uid'] +        self._couch_rev = cdoc['_rev'] + +    #------------------------------------------------------------------------- +    # Couch specific methods +    #------------------------------------------------------------------------- + +    # no specific methods so far. + +class CouchSyncTarget(HTTPSyncTarget): + +    def get_sync_info(self, source_replica_uid): +        source_gen, source_trans_id = self._db._get_replica_gen_and_trans_id( +            source_replica_uid) +        my_gen, my_trans_id = self._db._get_generation_info() +        return ( +            self._db._replica_uid, my_gen, my_trans_id, source_gen, +            source_trans_id) + +    def record_sync_info(self, source_replica_uid, source_replica_generation, +                         source_replica_transaction_id): +        if self._trace_hook: +            self._trace_hook('record_sync_info') +        self._db._set_replica_gen_and_trans_id( +            source_replica_uid, source_replica_generation, +            source_replica_transaction_id) + + diff --git a/src/leap/soledad/backends/leap.py b/src/leap/soledad/backends/leap.py new file mode 100644 index 00000000..9fbd49fe --- /dev/null +++ b/src/leap/soledad/backends/leap.py @@ -0,0 +1,175 @@ +try: +    import simplejson as json +except ImportError: +    import json  # noqa + +from u1db import Document +from u1db.remote.http_target import HTTPSyncTarget +from u1db.remote.http_database import HTTPDatabase +import base64 +from soledad.util import GPGWrapper + + +class NoDefaultKey(Exception): +    pass + +class NoSoledadInstance(Exception): +    pass + + +class LeapDocument(Document): +    """ +    LEAP Documents are standard u1db documents with cabability of returning an +    encrypted version of the document json string as well as setting document +    content based on an encrypted version of json string. +    """ + +    def __init__(self, doc_id=None, rev=None, json='{}', has_conflicts=False, +                 encrypted_json=None, soledad=None): +        super(LeapDocument, self).__init__(doc_id, rev, json, has_conflicts) +        self._soledad = soledad +        if encrypted_json: +            self.set_encrypted_json(encrypted_json) + +    def get_encrypted_json(self): +        """ +        Returns document's json serialization encrypted with user's public key. +        """ +        if not self._soledad: +            raise NoSoledadInstance() +        ciphertext = self._soledad.encrypt_symmetric(self.doc_id, self.get_json()) +        return json.dumps({'_encrypted_json' : ciphertext}) + +    def set_encrypted_json(self, encrypted_json): +        """ +        Set document's content based on encrypted version of json string. +        """ +        if not self._soledad: +            raise NoSoledadInstance() +        ciphertext = json.loads(encrypted_json)['_encrypted_json'] +        plaintext = self._soledad.decrypt_symmetric(self.doc_id, ciphertext) +        return self.set_json(plaintext) + + +class LeapDatabase(HTTPDatabase): +    """Implement the HTTP remote database API to a Leap server.""" + +    def __init__(self, url, document_factory=None, creds=None, soledad=None): +        super(LeapDatabase, self).__init__(url, creds=creds) +        self._soledad = soledad +        self._factory = LeapDocument + +    @staticmethod +    def open_database(url, create): +        db = LeapDatabase(url) +        db.open(create) +        return db + +    @staticmethod +    def delete_database(url): +        db = LeapDatabase(url) +        db._delete() +        db.close() + +    def get_sync_target(self): +        st = LeapSyncTarget(self._url.geturl()) +        st._creds = self._creds +        return st + +    def create_doc_from_json(self, content, doc_id=None): +        if doc_id is None: +            doc_id = self._allocate_doc_id() +        res, headers = self._request_json('PUT', ['doc', doc_id], {}, +                                          content, 'application/json') +        new_doc = self._factory(doc_id, res['rev'], content, soledad=self._soledad) +        return new_doc + + +class LeapSyncTarget(HTTPSyncTarget): + +    def __init__(self, url, creds=None, soledad=None): +        super(LeapSyncTarget, self).__init__(url, creds) +        self._soledad = soledad + +    def _parse_sync_stream(self, data, return_doc_cb, ensure_callback=None): +        """ +        Does the same as parent's method but ensures incoming content will be +        decrypted. +        """ +        parts = data.splitlines()  # one at a time +        if not parts or parts[0] != '[': +            raise BrokenSyncStream +        data = parts[1:-1] +        comma = False +        if data: +            line, comma = utils.check_and_strip_comma(data[0]) +            res = json.loads(line) +            if ensure_callback and 'replica_uid' in res: +                ensure_callback(res['replica_uid']) +            for entry in data[1:]: +                if not comma:  # missing in between comma +                    raise BrokenSyncStream +                line, comma = utils.check_and_strip_comma(entry) +                entry = json.loads(line) +                # decrypt after receiving from server. +                doc = LeapDocument(entry['id'], entry['rev'], +                                   encrypted_json=entry['content'], +                                   soledad=self._soledad) +                return_doc_cb(doc, entry['gen'], entry['trans_id']) +        if parts[-1] != ']': +            try: +                partdic = json.loads(parts[-1]) +            except ValueError: +                pass +            else: +                if isinstance(partdic, dict): +                    self._error(partdic) +            raise BrokenSyncStream +        if not data or comma:  # no entries or bad extra comma +            raise BrokenSyncStream +        return res + +    def sync_exchange(self, docs_by_generations, source_replica_uid, +                      last_known_generation, last_known_trans_id, +                      return_doc_cb, ensure_callback=None): +        """ +        Does the same as parent's method but encrypts content before syncing. +        """ +        self._ensure_connection() +        if self._trace_hook:  # for tests +            self._trace_hook('sync_exchange') +        url = '%s/sync-from/%s' % (self._url.path, source_replica_uid) +        self._conn.putrequest('POST', url) +        self._conn.putheader('content-type', 'application/x-u1db-sync-stream') +        for header_name, header_value in self._sign_request('POST', url, {}): +            self._conn.putheader(header_name, header_value) +        entries = ['['] +        size = 1 + +        def prepare(**dic): +            entry = comma + '\r\n' + json.dumps(dic) +            entries.append(entry) +            return len(entry) + +        comma = '' +        size += prepare( +            last_known_generation=last_known_generation, +            last_known_trans_id=last_known_trans_id, +            ensure=ensure_callback is not None) +        comma = ',' +        for doc, gen, trans_id in docs_by_generations: +            # encrypt before sending to server. +            size += prepare(id=doc.doc_id, rev=doc.rev, +                            content=doc.get_encrypted_json(), +                            gen=gen, trans_id=trans_id) +        entries.append('\r\n]') +        size += len(entries[-1]) +        self._conn.putheader('content-length', str(size)) +        self._conn.endheaders() +        for entry in entries: +            self._conn.send(entry) +        entries = None +        data, _ = self._response() +        res = self._parse_sync_stream(data, return_doc_cb, ensure_callback) +        data = None +        return res['new_generation'], res['new_transaction_id'] diff --git a/src/leap/soledad/backends/objectstore.py b/src/leap/soledad/backends/objectstore.py new file mode 100644 index 00000000..61445a1f --- /dev/null +++ b/src/leap/soledad/backends/objectstore.py @@ -0,0 +1,210 @@ +import uuid +from u1db.backends import CommonBackend +from u1db import errors, Document +from soledad.util import SyncLog, TransactionLog + + +class ObjectStore(CommonBackend): +    """ +    A backend for storing u1db data in an object store. +    """ + +    def __init__(self): +        # This initialization method should be called after the connection +        # with the database is established, so it can ensure that u1db data is +        # configured and up-to-date. +        self.set_document_factory(Document) +        self._sync_log = SyncLog() +        self._transaction_log = TransactionLog() +        self._ensure_u1db_data() + +    #------------------------------------------------------------------------- +    # implemented methods from Database +    #------------------------------------------------------------------------- + +    def set_document_factory(self, factory): +        self._factory = factory + +    def set_document_size_limit(self, limit): +        raise NotImplementedError(self.set_document_size_limit) + +    def whats_changed(self, old_generation=0): +        self._get_u1db_data() +        return self._transaction_log.whats_changed(old_generation) + +    def get_doc(self, doc_id, include_deleted=False): +        doc = self._get_doc(doc_id, check_for_conflicts=True) +        if doc is None: +            return None +        if doc.is_tombstone() and not include_deleted: +            return None +        return doc + +    def _put_doc(self, doc): +        raise NotImplementedError(self._put_doc) + +    def put_doc(self, doc): +        # consistency check +        if doc.doc_id is None: +            raise errors.InvalidDocId() +        self._check_doc_id(doc.doc_id) +        self._check_doc_size(doc) +        # check if document exists +        old_doc = self._get_doc(doc.doc_id, check_for_conflicts=True) +        if old_doc and old_doc.has_conflicts: +            raise errors.ConflictedDoc() +        if old_doc and doc.rev is None and old_doc.is_tombstone(): +            new_rev = self._allocate_doc_rev(old_doc.rev) +        else: +            if old_doc is not None: +                if old_doc.rev != doc.rev: +                    raise errors.RevisionConflict() +            else: +                if doc.rev is not None: +                    raise errors.RevisionConflict() +            new_rev = self._allocate_doc_rev(doc.rev) +        doc.rev = new_rev +        self._put_doc(doc) +        # update u1db generation and logs +        new_gen = self._get_generation() + 1 +        trans_id = self._allocate_transaction_id() +        self._transaction_log.append((new_gen, doc.doc_id, trans_id)) +        self._set_u1db_data() +        return doc.rev + +    def delete_doc(self, doc): +        old_doc = self._get_doc(doc.doc_id, check_for_conflicts=True) +        if old_doc is None: +            raise errors.DocumentDoesNotExist +        if old_doc.rev != doc.rev: +            raise errors.RevisionConflict() +        if old_doc.is_tombstone(): +            raise errors.DocumentAlreadyDeleted +        if old_doc.has_conflicts: +            raise errors.ConflictedDoc() +        new_rev = self._allocate_doc_rev(doc.rev) +        doc.rev = new_rev +        doc.make_tombstone() +        self._put_doc(doc) +        return new_rev + +    # start of index-related methods: these are not supported by this backend. + +    def create_index(self, index_name, *index_expressions): +        return False + +    def delete_index(self, index_name): +        return False + +    def list_indexes(self): +        return [] + +    def get_from_index(self, index_name, *key_values): +        return [] + +    def get_range_from_index(self, index_name, start_value=None, +                             end_value=None): +        return [] + +    def get_index_keys(self, index_name): +        return [] + +    # end of index-related methods: these are not supported by this backend. + +    def get_doc_conflicts(self, doc_id): +        return [] + +    def resolve_doc(self, doc, conflicted_doc_revs): +        raise NotImplementedError(self.resolve_doc) + +    def _get_replica_gen_and_trans_id(self, other_replica_uid): +        self._get_u1db_data() +        return self._sync_log.get_replica_gen_and_trans_id(other_replica_uid) + +    def _set_replica_gen_and_trans_id(self, other_replica_uid, +                                      other_generation, other_transaction_id): +        self._get_u1db_data() +        self._sync_log.set_replica_gen_and_trans_id(other_replica_uid, +                                                    other_generation, +                                                    other_transaction_id) +        self._set_u1db_data() + +    #------------------------------------------------------------------------- +    # implemented methods from CommonBackend +    #------------------------------------------------------------------------- + +    def _get_generation(self): +        self._get_u1db_data() +        return self._transaction_log.get_generation() + +    def _get_generation_info(self): +        self._get_u1db_data() +        return self._transaction_log.get_generation_info() + +    def _has_conflicts(self, doc_id): +        # Documents never have conflicts on server. +        return False + +    def _put_and_update_indexes(self, doc_id, old_doc, new_rev, content): +        raise NotImplementedError(self._put_and_update_indexes) + + +    def _get_trans_id_for_gen(self, generation): +        self._get_u1db_data() +        trans_id = self._transaction_log.get_trans_id_for_gen(generation) +        if trans_id is None: +            raise errors.InvalidGeneration +        return trans_id + +    #------------------------------------------------------------------------- +    # methods specific for object stores +    #------------------------------------------------------------------------- + +    def _ensure_u1db_data(self): +        """ +        Guarantee that u1db data (logs and replica info) exists in store. +        """ +        if not self._is_initialized(): +            self._initialize() +        self._get_u1db_data() + +    U1DB_DATA_DOC_ID = 'u1db_data' + +    def _is_initialized(self): +        """ +        Verify if u1db data exists in store. +        """ +        doc = self._get_doc(self.U1DB_DATA_DOC_ID) +        if not self._get_doc(self.U1DB_DATA_DOC_ID): +            return False +        return True + +    def _initialize(self): +        """ +        Create u1db data object in store. +        """ +        self._replica_uid = uuid.uuid4().hex +        doc = self._factory(doc_id=self.U1DB_DATA_DOC_ID) +        doc.content = { 'transaction_log' : [], +                        'sync_log' : [], +                        'replica_uid' : self._replica_uid } +        self._put_doc(doc) + +    def _get_u1db_data(self, u1db_data_doc_id): +        """ +        Fetch u1db configuration data from backend storage. +        """ +        NotImplementedError(self._get_u1db_data) + +    def _set_u1db_data(self): +        """ +        Save u1db configuration data on backend storage. +        """ +        doc = self._factory(doc_id=self.U1DB_DATA_DOC_ID) +        doc.content = { 'transaction_log' : self._transaction_log.log, +                        'sync_log'        : self._sync_log.log, +                        'replica_uid'     : self._replica_uid, +                        '_rev'            : self._couch_rev} +        self._put_doc(doc) + + diff --git a/src/leap/soledad/backends/openstack.py b/src/leap/soledad/backends/openstack.py new file mode 100644 index 00000000..c027231c --- /dev/null +++ b/src/leap/soledad/backends/openstack.py @@ -0,0 +1,98 @@ +from u1db import errors +from u1db.remote.http_target import HTTPSyncTarget +from swiftclient import client +from soledad.backends.objectstore import ObjectStore + + +class OpenStackDatabase(ObjectStore): +    """A U1DB implementation that uses OpenStack as its persistence layer.""" + +    def __init__(self, auth_url, user, auth_key, container): +        """Create a new OpenStack data container.""" +        self._auth_url = auth_url +        self._user = user +        self._auth_key = auth_key +        self._container = container +        self._connection = swiftclient.Connection(self._auth_url, self._user, +                                                  self._auth_key) +        self._get_auth() +        # this will ensure transaction and sync logs exist and are up-to-date. +        super(OpenStackDatabase, self).__init__() + +    #------------------------------------------------------------------------- +    # implemented methods from Database +    #------------------------------------------------------------------------- + +    def _get_doc(self, doc_id, check_for_conflicts=False): +        """Get just the document content, without fancy handling. +         +        Conflicts do not happen on server side, so there's no need to check +        for them. +        """ +        try: +            response, contents = self._connection.get_object(self._container, doc_id) +            # TODO: change revision to be a dictionary element? +            rev = response['x-object-meta-rev'] +            return self._factory(doc_id, rev, contents) +        except swiftclient.ClientException: +            return None + +    def get_all_docs(self, include_deleted=False): +        """Get all documents from the database.""" +        generation = self._get_generation() +        results = [] +        _, doc_ids = self._connection.get_container(self._container, +                                                    full_listing=True) +        for doc_id in doc_ids: +            doc = self._get_doc(doc_id) +            if doc.content is None and not include_deleted: +                continue +            results.append(doc) +        return (generation, results) + +    def _put_doc(self, doc, new_rev): +        new_rev = self._allocate_doc_rev(doc.rev) +        # TODO: change revision to be a dictionary element? +        headers = { 'X-Object-Meta-Rev' : new_rev } +        self._connection.put_object(self._container, doc_id, doc.get_json(), +                                    headers=headers) + +    def get_sync_target(self): +        return OpenStackSyncTarget(self) + +    def close(self): +        raise NotImplementedError(self.close) + +    def sync(self, url, creds=None, autocreate=True): +        from u1db.sync import Synchronizer +        from u1db.remote.http_target import OpenStackSyncTarget +        return Synchronizer(self, OpenStackSyncTarget(url, creds=creds)).sync( +            autocreate=autocreate) + +    #------------------------------------------------------------------------- +    # OpenStack specific methods +    #------------------------------------------------------------------------- + +    def _get_auth(self): +        self._url, self._auth_token = self._connection.get_auth() +        return self._url, self.auth_token + +class OpenStackSyncTarget(HTTPSyncTarget): + +    def get_sync_info(self, source_replica_uid): +        source_gen, source_trans_id = self._db._get_replica_gen_and_trans_id( +            source_replica_uid) +        my_gen, my_trans_id = self._db._get_generation_info() +        return ( +            self._db._replica_uid, my_gen, my_trans_id, source_gen, +            source_trans_id) + +    def record_sync_info(self, source_replica_uid, source_replica_generation, +                         source_replica_transaction_id): +        if self._trace_hook: +            self._trace_hook('record_sync_info') +        self._db._set_replica_gen_and_trans_id( +            source_replica_uid, source_replica_generation, +            source_replica_transaction_id) + + diff --git a/src/leap/soledad/backends/sqlcipher.py b/src/leap/soledad/backends/sqlcipher.py new file mode 100644 index 00000000..6fd6e619 --- /dev/null +++ b/src/leap/soledad/backends/sqlcipher.py @@ -0,0 +1,127 @@ +# Copyright 2011 Canonical Ltd. +# +# This file is part of u1db. +# +# u1db is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License version 3 +# as published by the Free Software Foundation. +# +# u1db is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with u1db.  If not, see <http://www.gnu.org/licenses/>. + +"""A U1DB implementation that uses SQLCipher as its persistence layer.""" + +import errno +import os +try: +    import simplejson as json +except ImportError: +    import json  # noqa +from sqlite3 import dbapi2 +import sys +import time +import uuid + +import pkg_resources + +from u1db.backends import CommonBackend, CommonSyncTarget +from u1db.backends.sqlite_backend import SQLitePartialExpandDatabase +from u1db import ( +    Document, +    errors, +    query_parser, +    vectorclock, +    ) + + +def open(path, create, document_factory=None, password=None): +    """Open a database at the given location. + +    Will raise u1db.errors.DatabaseDoesNotExist if create=False and the +    database does not already exist. + +    :param path: The filesystem path for the database to open. +    :param create: True/False, should the database be created if it doesn't +        already exist? +    :param document_factory: A function that will be called with the same +        parameters as Document.__init__. +    :return: An instance of Database. +    """ +    from u1db.backends import sqlite_backend +    return sqlite_backend.SQLCipherDatabase.open_database( +        path, password, create=create, document_factory=document_factory) + + +class SQLCipherDatabase(SQLitePartialExpandDatabase): +    """A U1DB implementation that uses SQLCipher as its persistence layer.""" + +    _index_storage_value = 'expand referenced encrypted' + + +    @classmethod +    def set_pragma_key(cls, db_handle, key): +       db_handle.cursor().execute("PRAGMA key = '%s'" % key) + +    def __init__(self, sqlite_file, password, document_factory=None): +        """Create a new sqlite file.""" +        self._db_handle = dbapi2.connect(sqlite_file) +        SQLCipherDatabase.set_pragma_key(self._db_handle, password) +        self._real_replica_uid = None +        self._ensure_schema() +        self._factory = document_factory or Document + +    @classmethod +    def _open_database(cls, sqlite_file, password, document_factory=None): +        if not os.path.isfile(sqlite_file): +            raise errors.DatabaseDoesNotExist() +        tries = 2 +        while True: +            # Note: There seems to be a bug in sqlite 3.5.9 (with python2.6) +            #       where without re-opening the database on Windows, it +            #       doesn't see the transaction that was just committed +            db_handle = dbapi2.connect(sqlite_file) +            SQLCipherDatabase.set_pragma_key(db_handle, password) +            c = db_handle.cursor() +            v, err = cls._which_index_storage(c) +            db_handle.close() +            if v is not None: +                break +            # possibly another process is initializing it, wait for it to be +            # done +            if tries == 0: +                raise err  # go for the richest error? +            tries -= 1 +            time.sleep(cls.WAIT_FOR_PARALLEL_INIT_HALF_INTERVAL) +        return SQLCipherDatabase._sqlite_registry[v]( +            sqlite_file, password, document_factory=document_factory) + +    @classmethod +    def open_database(cls, sqlite_file, password, create, backend_cls=None, +                      document_factory=None): +        try: +            return cls._open_database(sqlite_file, password, +                                      document_factory=document_factory) +        except errors.DatabaseDoesNotExist: +            if not create: +                raise +            if backend_cls is None: +                # default is SQLCipherPartialExpandDatabase +                backend_cls = SQLCipherDatabase +            return backend_cls(sqlite_file, password, +                               document_factory=document_factory) + +    @staticmethod +    def register_implementation(klass): +        """Register that we implement an SQLCipherDatabase. + +        The attribute _index_storage_value will be used as the lookup key. +        """ +        SQLCipherDatabase._sqlite_registry[klass._index_storage_value] = klass + + +SQLCipherDatabase.register_implementation(SQLCipherDatabase) diff --git a/src/leap/soledad/tests/__init__.py b/src/leap/soledad/tests/__init__.py new file mode 100644 index 00000000..7918b265 --- /dev/null +++ b/src/leap/soledad/tests/__init__.py @@ -0,0 +1,55 @@ +import unittest2 as unittest +import tempfile +import shutil + +class TestCase(unittest.TestCase): + +    def createTempDir(self, prefix='u1db-tmp-'): +        """Create a temporary directory to do some work in. + +        This directory will be scheduled for cleanup when the test ends. +        """ +        tempdir = tempfile.mkdtemp(prefix=prefix) +        self.addCleanup(shutil.rmtree, tempdir) +        return tempdir + +    def make_document(self, doc_id, doc_rev, content, has_conflicts=False): +        return self.make_document_for_test( +            self, doc_id, doc_rev, content, has_conflicts) + +    def make_document_for_test(self, test, doc_id, doc_rev, content, +                               has_conflicts): +        return make_document_for_test( +            test, doc_id, doc_rev, content, has_conflicts) + +    def assertGetDoc(self, db, doc_id, doc_rev, content, has_conflicts): +        """Assert that the document in the database looks correct.""" +        exp_doc = self.make_document(doc_id, doc_rev, content, +                                     has_conflicts=has_conflicts) +        self.assertEqual(exp_doc, db.get_doc(doc_id)) + +    def assertGetDocIncludeDeleted(self, db, doc_id, doc_rev, content, +                                   has_conflicts): +        """Assert that the document in the database looks correct.""" +        exp_doc = self.make_document(doc_id, doc_rev, content, +                                     has_conflicts=has_conflicts) +        self.assertEqual(exp_doc, db.get_doc(doc_id, include_deleted=True)) + +    def assertGetDocConflicts(self, db, doc_id, conflicts): +        """Assert what conflicts are stored for a given doc_id. + +        :param conflicts: A list of (doc_rev, content) pairs. +            The first item must match the first item returned from the +            database, however the rest can be returned in any order. +        """ +        if conflicts: +            conflicts = [(rev, (json.loads(cont) if isinstance(cont, basestring) +                           else cont)) for (rev, cont) in conflicts] +            conflicts = conflicts[:1] + sorted(conflicts[1:]) +        actual = db.get_doc_conflicts(doc_id) +        if actual: +            actual = [(doc.rev, (json.loads(doc.get_json()) +                   if doc.get_json() is not None else None)) for doc in actual] +            actual = actual[:1] + sorted(actual[1:]) +        self.assertEqual(conflicts, actual) + diff --git a/src/leap/soledad/tests/test_couch.py b/src/leap/soledad/tests/test_couch.py new file mode 100644 index 00000000..4468ae04 --- /dev/null +++ b/src/leap/soledad/tests/test_couch.py @@ -0,0 +1,280 @@ +import unittest2 +from soledad.backends.couch import CouchDatabase +from soledad.backends.leap import LeapDocument +from u1db import errors, vectorclock + +try: +    import simplejson as json +except ImportError: +    import json  # noqa + +simple_doc = '{"key": "value"}' +nested_doc = '{"key": "value", "sub": {"doc": "underneath"}}' + +def make_document_for_test(test, doc_id, rev, content, has_conflicts=False): +    return LeapDocument(doc_id, rev, content, has_conflicts=has_conflicts) + +class CouchTestCase(unittest2.TestCase): + +    def setUp(self): +        self.db = CouchDatabase('http://localhost:5984', 'u1db_tests') + +    def make_document(self, doc_id, doc_rev, content, has_conflicts=False): +        return self.make_document_for_test( +            self, doc_id, doc_rev, content, has_conflicts) + +    def make_document_for_test(self, test, doc_id, doc_rev, content, +                               has_conflicts): +        return make_document_for_test( +            test, doc_id, doc_rev, content, has_conflicts) + +    def assertGetDoc(self, db, doc_id, doc_rev, content, has_conflicts): +        """Assert that the document in the database looks correct.""" +        exp_doc = self.make_document(doc_id, doc_rev, content, +                                     has_conflicts=has_conflicts) +        self.assertEqual(exp_doc, db.get_doc(doc_id)) + +    def assertGetDocIncludeDeleted(self, db, doc_id, doc_rev, content, +                                   has_conflicts): +        """Assert that the document in the database looks correct.""" +        exp_doc = self.make_document(doc_id, doc_rev, content, +                                     has_conflicts=has_conflicts) +        self.assertEqual(exp_doc, db.get_doc(doc_id, include_deleted=True)) + + +    def test_create_doc_allocating_doc_id(self): +        doc = self.db.create_doc_from_json(simple_doc) +        self.assertNotEqual(None, doc.doc_id) +        self.assertNotEqual(None, doc.rev) +        self.assertGetDoc(self.db, doc.doc_id, doc.rev, simple_doc, False) + +    def test_create_doc_different_ids_same_db(self): +        doc1 = self.db.create_doc_from_json(simple_doc) +        doc2 = self.db.create_doc_from_json(nested_doc) +        self.assertNotEqual(doc1.doc_id, doc2.doc_id) + +    def test_create_doc_with_id(self): +        doc = self.db.create_doc_from_json(simple_doc, doc_id='my-id') +        self.assertEqual('my-id', doc.doc_id) +        self.assertNotEqual(None, doc.rev) +        self.assertGetDoc(self.db, doc.doc_id, doc.rev, simple_doc, False) + +    def test_create_doc_existing_id(self): +        doc = self.db.create_doc_from_json(simple_doc) +        new_content = '{"something": "else"}' +        self.assertRaises( +            errors.RevisionConflict, self.db.create_doc_from_json, +            new_content, doc.doc_id) +        self.assertGetDoc(self.db, doc.doc_id, doc.rev, simple_doc, False) + +    def test_put_doc_creating_initial(self): +        doc = self.make_document('my_doc_id', None, simple_doc) +        new_rev = self.db.put_doc(doc) +        self.assertIsNot(None, new_rev) +        self.assertGetDoc(self.db, 'my_doc_id', new_rev, simple_doc, False) + +    def test_put_doc_space_in_id(self): +        doc = self.make_document('my doc id', None, simple_doc) +        self.assertRaises(errors.InvalidDocId, self.db.put_doc, doc) + +    def test_put_doc_update(self): +        doc = self.db.create_doc_from_json(simple_doc, doc_id='my_doc_id') +        orig_rev = doc.rev +        doc.set_json('{"updated": "stuff"}') +        new_rev = self.db.put_doc(doc) +        self.assertNotEqual(new_rev, orig_rev) +        self.assertGetDoc(self.db, 'my_doc_id', new_rev, +                          '{"updated": "stuff"}', False) +        self.assertEqual(doc.rev, new_rev) + +    def test_put_non_ascii_key(self): +        content = json.dumps({u'key\xe5': u'val'}) +        doc = self.db.create_doc_from_json(content, doc_id='my_doc') +        self.assertGetDoc(self.db, 'my_doc', doc.rev, content, False) + +    def test_put_non_ascii_value(self): +        content = json.dumps({'key': u'\xe5'}) +        doc = self.db.create_doc_from_json(content, doc_id='my_doc') +        self.assertGetDoc(self.db, 'my_doc', doc.rev, content, False) + +    def test_put_doc_refuses_no_id(self): +        doc = self.make_document(None, None, simple_doc) +        self.assertRaises(errors.InvalidDocId, self.db.put_doc, doc) +        doc = self.make_document("", None, simple_doc) +        self.assertRaises(errors.InvalidDocId, self.db.put_doc, doc) + +    def test_put_doc_refuses_slashes(self): +        doc = self.make_document('a/b', None, simple_doc) +        self.assertRaises(errors.InvalidDocId, self.db.put_doc, doc) +        doc = self.make_document(r'\b', None, simple_doc) +        self.assertRaises(errors.InvalidDocId, self.db.put_doc, doc) + +    def test_put_doc_url_quoting_is_fine(self): +        doc_id = "%2F%2Ffoo%2Fbar" +        doc = self.make_document(doc_id, None, simple_doc) +        new_rev = self.db.put_doc(doc) +        self.assertGetDoc(self.db, doc_id, new_rev, simple_doc, False) + +    def test_put_doc_refuses_non_existing_old_rev(self): +        doc = self.make_document('doc-id', 'test:4', simple_doc) +        self.assertRaises(errors.RevisionConflict, self.db.put_doc, doc) + +    def test_put_doc_refuses_non_ascii_doc_id(self): +        doc = self.make_document('d\xc3\xa5c-id', None, simple_doc) +        self.assertRaises(errors.InvalidDocId, self.db.put_doc, doc) + +    def test_put_fails_with_bad_old_rev(self): +        doc = self.db.create_doc_from_json(simple_doc, doc_id='my_doc_id') +        old_rev = doc.rev +        bad_doc = self.make_document(doc.doc_id, 'other:1', +                                     '{"something": "else"}') +        self.assertRaises(errors.RevisionConflict, self.db.put_doc, bad_doc) +        self.assertGetDoc(self.db, 'my_doc_id', old_rev, simple_doc, False) + +    def test_create_succeeds_after_delete(self): +        doc = self.db.create_doc_from_json(simple_doc, doc_id='my_doc_id') +        self.db.delete_doc(doc) +        deleted_doc = self.db.get_doc('my_doc_id', include_deleted=True) +        deleted_vc = vectorclock.VectorClockRev(deleted_doc.rev) +        new_doc = self.db.create_doc_from_json(simple_doc, doc_id='my_doc_id') +        self.assertGetDoc(self.db, 'my_doc_id', new_doc.rev, simple_doc, False) +        new_vc = vectorclock.VectorClockRev(new_doc.rev) +        self.assertTrue( +            new_vc.is_newer(deleted_vc), +            "%s does not supersede %s" % (new_doc.rev, deleted_doc.rev)) + +    def test_put_succeeds_after_delete(self): +        doc = self.db.create_doc_from_json(simple_doc, doc_id='my_doc_id') +        self.db.delete_doc(doc) +        deleted_doc = self.db.get_doc('my_doc_id', include_deleted=True) +        deleted_vc = vectorclock.VectorClockRev(deleted_doc.rev) +        doc2 = self.make_document('my_doc_id', None, simple_doc) +        self.db.put_doc(doc2) +        self.assertGetDoc(self.db, 'my_doc_id', doc2.rev, simple_doc, False) +        new_vc = vectorclock.VectorClockRev(doc2.rev) +        self.assertTrue( +            new_vc.is_newer(deleted_vc), +            "%s does not supersede %s" % (doc2.rev, deleted_doc.rev)) + +    def test_get_doc_after_put(self): +        doc = self.db.create_doc_from_json(simple_doc, doc_id='my_doc_id') +        self.assertGetDoc(self.db, 'my_doc_id', doc.rev, simple_doc, False) + +    def test_get_doc_nonexisting(self): +        self.assertIs(None, self.db.get_doc('non-existing')) + +    def test_get_doc_deleted(self): +        doc = self.db.create_doc_from_json(simple_doc, doc_id='my_doc_id') +        self.db.delete_doc(doc) +        self.assertIs(None, self.db.get_doc('my_doc_id')) + +    def test_get_doc_include_deleted(self): +        doc = self.db.create_doc_from_json(simple_doc, doc_id='my_doc_id') +        self.db.delete_doc(doc) +        self.assertGetDocIncludeDeleted( +            self.db, doc.doc_id, doc.rev, None, False) + +    def test_get_docs(self): +        doc1 = self.db.create_doc_from_json(simple_doc) +        doc2 = self.db.create_doc_from_json(nested_doc) +        self.assertEqual([doc1, doc2], +                         list(self.db.get_docs([doc1.doc_id, doc2.doc_id]))) + +    def test_get_docs_deleted(self): +        doc1 = self.db.create_doc_from_json(simple_doc) +        doc2 = self.db.create_doc_from_json(nested_doc) +        self.db.delete_doc(doc1) +        self.assertEqual([doc2], +                         list(self.db.get_docs([doc1.doc_id, doc2.doc_id]))) + +    def test_get_docs_include_deleted(self): +        doc1 = self.db.create_doc_from_json(simple_doc) +        doc2 = self.db.create_doc_from_json(nested_doc) +        self.db.delete_doc(doc1) +        self.assertEqual( +            [doc1, doc2], +            list(self.db.get_docs([doc1.doc_id, doc2.doc_id], +                                  include_deleted=True))) + +    def test_get_docs_request_ordered(self): +        doc1 = self.db.create_doc_from_json(simple_doc) +        doc2 = self.db.create_doc_from_json(nested_doc) +        self.assertEqual([doc1, doc2], +                         list(self.db.get_docs([doc1.doc_id, doc2.doc_id]))) +        self.assertEqual([doc2, doc1], +                         list(self.db.get_docs([doc2.doc_id, doc1.doc_id]))) + +    def test_get_docs_empty_list(self): +        self.assertEqual([], list(self.db.get_docs([]))) + +    def test_handles_nested_content(self): +        doc = self.db.create_doc_from_json(nested_doc) +        self.assertGetDoc(self.db, doc.doc_id, doc.rev, nested_doc, False) + +    def test_handles_doc_with_null(self): +        doc = self.db.create_doc_from_json('{"key": null}') +        self.assertGetDoc(self.db, doc.doc_id, doc.rev, '{"key": null}', False) + +    def test_delete_doc(self): +        doc = self.db.create_doc_from_json(simple_doc) +        self.assertGetDoc(self.db, doc.doc_id, doc.rev, simple_doc, False) +        orig_rev = doc.rev +        self.db.delete_doc(doc) +        self.assertNotEqual(orig_rev, doc.rev) +        self.assertGetDocIncludeDeleted( +            self.db, doc.doc_id, doc.rev, None, False) +        self.assertIs(None, self.db.get_doc(doc.doc_id)) + +    def test_delete_doc_non_existent(self): +        doc = self.make_document('non-existing', 'other:1', simple_doc) +        self.assertRaises(errors.DocumentDoesNotExist, self.db.delete_doc, doc) + +    def test_delete_doc_already_deleted(self): +        doc = self.db.create_doc_from_json(simple_doc) +        self.db.delete_doc(doc) +        self.assertRaises(errors.DocumentAlreadyDeleted, +                          self.db.delete_doc, doc) +        self.assertGetDocIncludeDeleted( +            self.db, doc.doc_id, doc.rev, None, False) + +    def test_delete_doc_bad_rev(self): +        doc1 = self.db.create_doc_from_json(simple_doc) +        self.assertGetDoc(self.db, doc1.doc_id, doc1.rev, simple_doc, False) +        doc2 = self.make_document(doc1.doc_id, 'other:1', simple_doc) +        self.assertRaises(errors.RevisionConflict, self.db.delete_doc, doc2) +        self.assertGetDoc(self.db, doc1.doc_id, doc1.rev, simple_doc, False) + +    def test_delete_doc_sets_content_to_None(self): +        doc = self.db.create_doc_from_json(simple_doc) +        self.db.delete_doc(doc) +        self.assertIs(None, doc.get_json()) + +    def test_delete_doc_rev_supersedes(self): +        doc = self.db.create_doc_from_json(simple_doc) +        doc.set_json(nested_doc) +        self.db.put_doc(doc) +        doc.set_json('{"fishy": "content"}') +        self.db.put_doc(doc) +        old_rev = doc.rev +        self.db.delete_doc(doc) +        cur_vc = vectorclock.VectorClockRev(old_rev) +        deleted_vc = vectorclock.VectorClockRev(doc.rev) +        self.assertTrue(deleted_vc.is_newer(cur_vc), +                "%s does not supersede %s" % (doc.rev, old_rev)) + +    def test_delete_then_put(self): +        doc = self.db.create_doc_from_json(simple_doc) +        self.db.delete_doc(doc) +        self.assertGetDocIncludeDeleted( +            self.db, doc.doc_id, doc.rev, None, False) +        doc.set_json(nested_doc) +        self.db.put_doc(doc) +        self.assertGetDoc(self.db, doc.doc_id, doc.rev, nested_doc, False) + + + +    def tearDown(self): +        self.db._server.delete('u1db_tests') + +if __name__ == '__main__': +    unittest2.main() diff --git a/src/leap/soledad/tests/test_encrypted.py b/src/leap/soledad/tests/test_encrypted.py new file mode 100644 index 00000000..eafd258e --- /dev/null +++ b/src/leap/soledad/tests/test_encrypted.py @@ -0,0 +1,210 @@ +try: +    import simplejson as json +except ImportError: +    import json  # noqa + +import unittest2 as unittest +import os + +import u1db +from soledad import Soledad +from soledad.backends.leap import LeapDocument + + +class EncryptedSyncTestCase(unittest.TestCase): + +    PREFIX     = "/var/tmp" +    GNUPG_HOME = "%s/gnupg" % PREFIX +    DB1_FILE   = "%s/db1.u1db" % PREFIX +    DB2_FILE   = "%s/db2.u1db" % PREFIX +    EMAIL      = 'leap@leap.se' + +    def setUp(self): +        self.db1 = u1db.open(self.DB1_FILE, create=True, +                             document_factory=LeapDocument) +        self.db2 = u1db.open(self.DB2_FILE, create=True, +                             document_factory=LeapDocument) +        self.soledad = Soledad(self.EMAIL, gpghome=self.GNUPG_HOME) +        self.soledad._gpg.import_keys(PUBLIC_KEY) +        self.soledad._gpg.import_keys(PRIVATE_KEY) + +    def tearDown(self): +        os.unlink(self.DB1_FILE) +        os.unlink(self.DB2_FILE) + +    def test_get_set_encrypted(self): +        doc1 = LeapDocument(soledad=self.soledad) +        doc1.content = { 'key' : 'val' } +        doc2 = LeapDocument(doc_id=doc1.doc_id, +                                 encrypted_json=doc1.get_encrypted_json(), +                                 soledad=self.soledad) +        res1 = doc1.get_json() +        res2 = doc2.get_json() +        self.assertEqual(res1, res2, 'incorrect document encryption') + + +# Key material for testing +KEY_FINGERPRINT = "E36E738D69173C13D709E44F2F455E2824D18DDF" +PUBLIC_KEY = """ +-----BEGIN PGP PUBLIC KEY BLOCK----- +Version: GnuPG v1.4.10 (GNU/Linux) + +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 +=MuOY +-----END PGP PUBLIC KEY BLOCK----- +""" +PRIVATE_KEY = """ +-----BEGIN PGP PRIVATE KEY BLOCK----- +Version: GnuPG v1.4.10 (GNU/Linux) + +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 +=JTFu +-----END PGP PRIVATE KEY BLOCK----- +""" + +if __name__ == '__main__': +    unittest.main() diff --git a/src/leap/soledad/tests/test_logs.py b/src/leap/soledad/tests/test_logs.py new file mode 100644 index 00000000..d61700f2 --- /dev/null +++ b/src/leap/soledad/tests/test_logs.py @@ -0,0 +1,75 @@ +import unittest2 as unittest +from soledad.util import TransactionLog, SyncLog + + +class LogTestCase(unittest.TestCase): + + +    def test_transaction_log(self): +        data = [ +          (2, "doc_3", "tran_3"), +          (3, "doc_2", "tran_2"), +          (1, "doc_1", "tran_1") +        ] +        log = TransactionLog() +        log.log = data +        self.assertEqual(log.get_generation(), 3, 'error getting generation') +        self.assertEqual(log.get_generation_info(), (3, 'tran_2'), +                         'error getting generation info') +        self.assertEqual(log.get_trans_id_for_gen(1), 'tran_1', +                         'error getting trans_id for gen') +        self.assertEqual(log.get_trans_id_for_gen(2), 'tran_3', +                         'error getting trans_id for gen') +        self.assertEqual(log.get_trans_id_for_gen(3), 'tran_2', +                         'error getting trans_id for gen') + +    def test_sync_log(self): +        data = [ +          ("replica_3", 3, "tran_3"), +          ("replica_2", 2, "tran_2"), +          ("replica_1", 1, "tran_1") +        ] +        log = SyncLog() +        log.log = data +        # test getting +        self.assertEqual(log.get_replica_gen_and_trans_id('replica_3'), +            (3, 'tran_3'), 'error getting replica gen and trans id') +        self.assertEqual(log.get_replica_gen_and_trans_id('replica_2'), +            (2, 'tran_2'), 'error getting replica gen and trans id') +        self.assertEqual(log.get_replica_gen_and_trans_id('replica_1'), +            (1, 'tran_1'), 'error getting replica gen and trans id') +        # test setting +        log.set_replica_gen_and_trans_id('replica_1', 2, 'tran_12') +        self.assertEqual(len(log._log), 3, 'error in log size after setting') +        self.assertEqual(log.get_replica_gen_and_trans_id('replica_1'), +            (2, 'tran_12'), 'error setting replica gen and trans id') +        self.assertEqual(log.get_replica_gen_and_trans_id('replica_2'), +            (2, 'tran_2'), 'error setting replica gen and trans id') +        self.assertEqual(log.get_replica_gen_and_trans_id('replica_3'), +            (3, 'tran_3'), 'error setting replica gen and trans id') + +    def test_whats_changed(self): +        data = [ +          (2, "doc_3", "tran_3"), +          (3, "doc_2", "tran_2"), +          (1, "doc_1", "tran_1") +        ] +        log = TransactionLog() +        log.log = data +        self.assertEqual( +          log.whats_changed(3), +          (3, "tran_2", []), +          'error getting whats changed.') +        self.assertEqual( +          log.whats_changed(2), +          (3, "tran_2", [("doc_2",3,"tran_2")]), +          'error getting whats changed.') +        self.assertEqual( +          log.whats_changed(1), +          (3, "tran_2", [("doc_3",2,"tran_3"),("doc_2",3,"tran_2")]), +          'error getting whats changed.') + + +if __name__ == '__main__': +    unittest.main() + diff --git a/src/leap/soledad/tests/test_sqlcipher.py b/src/leap/soledad/tests/test_sqlcipher.py new file mode 100644 index 00000000..f9e9f681 --- /dev/null +++ b/src/leap/soledad/tests/test_sqlcipher.py @@ -0,0 +1,503 @@ +# Copyright 2011 Canonical Ltd. +# +# This file is part of u1db. +# +# u1db is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License version 3 +# as published by the Free Software Foundation. +# +# u1db is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with u1db.  If not, see <http://www.gnu.org/licenses/>. + +"""Test sqlite backend internals.""" + +import os +import time +import threading +import unittest2 as unittest + +from sqlite3 import dbapi2 + +from u1db import ( +    errors, +    query_parser, +    ) +from soledad.backends import sqlcipher +from soledad.backends.leap import LeapDocument +from soledad import tests + + +simple_doc = '{"key": "value"}' +nested_doc = '{"key": "value", "sub": {"doc": "underneath"}}' + + +class TestSQLCipherDatabase(tests.TestCase): + +    def test_atomic_initialize(self): +        tmpdir = self.createTempDir() +        dbname = os.path.join(tmpdir, 'atomic.db') + +        t2 = None  # will be a thread + +        class SQLCipherDatabaseTesting(sqlcipher.SQLCipherDatabase): +            _index_storage_value = "testing" + +            def __init__(self, dbname, ntry): +                self._try = ntry +                self._is_initialized_invocations = 0 +                password = '123456' +                super(SQLCipherDatabaseTesting, self).__init__(dbname, password) + +            def _is_initialized(self, c): +                res = super(SQLCipherDatabaseTesting, self)._is_initialized(c) +                if self._try == 1: +                    self._is_initialized_invocations += 1 +                    if self._is_initialized_invocations == 2: +                        t2.start() +                        # hard to do better and have a generic test +                        time.sleep(0.05) +                return res + +        outcome2 = [] + +        def second_try(): +            try: +                db2 = SQLCipherDatabaseTesting(dbname, 2) +            except Exception, e: +                outcome2.append(e) +            else: +                outcome2.append(db2) + +        t2 = threading.Thread(target=second_try) +        db1 = SQLCipherDatabaseTesting(dbname, 1) +        t2.join() + +        self.assertIsInstance(outcome2[0], SQLCipherDatabaseTesting) +        db2 = outcome2[0] +        self.assertTrue(db2._is_initialized(db1._get_sqlite_handle().cursor())) + + +_password = '123456' + + +class TestSQLCipherPartialExpandDatabase(tests.TestCase): + +    def setUp(self): +        super(TestSQLCipherPartialExpandDatabase, self).setUp() +        self.db = sqlcipher.SQLCipherDatabase(':memory:', _password) +        self.db._set_replica_uid('test') + +    def test_create_database(self): +        raw_db = self.db._get_sqlite_handle() +        self.assertNotEqual(None, raw_db) + +    def test_default_replica_uid(self): +        self.db = sqlcipher.SQLCipherDatabase(':memory:', _password) +        self.assertIsNot(None, self.db._replica_uid) +        self.assertEqual(32, len(self.db._replica_uid)) +        int(self.db._replica_uid, 16) + +    def test__close_sqlite_handle(self): +        raw_db = self.db._get_sqlite_handle() +        self.db._close_sqlite_handle() +        self.assertRaises(dbapi2.ProgrammingError, +            raw_db.cursor) + +    def test_create_database_initializes_schema(self): +        raw_db = self.db._get_sqlite_handle() +        c = raw_db.cursor() +        c.execute("SELECT * FROM u1db_config") +        config = dict([(r[0], r[1]) for r in c.fetchall()]) +        self.assertEqual({'sql_schema': '0', 'replica_uid': 'test', +                          'index_storage': 'expand referenced encrypted'}, config) + +        # These tables must exist, though we don't care what is in them yet +        c.execute("SELECT * FROM transaction_log") +        c.execute("SELECT * FROM document") +        c.execute("SELECT * FROM document_fields") +        c.execute("SELECT * FROM sync_log") +        c.execute("SELECT * FROM conflicts") +        c.execute("SELECT * FROM index_definitions") + +    def test__parse_index(self): +        self.db = sqlcipher.SQLCipherDatabase(':memory:', _password) +        g = self.db._parse_index_definition('fieldname') +        self.assertIsInstance(g, query_parser.ExtractField) +        self.assertEqual(['fieldname'], g.field) + +    def test__update_indexes(self): +        self.db = sqlcipher.SQLCipherDatabase(':memory:', _password) +        g = self.db._parse_index_definition('fieldname') +        c = self.db._get_sqlite_handle().cursor() +        self.db._update_indexes('doc-id', {'fieldname': 'val'}, +                                [('fieldname', g)], c) +        c.execute('SELECT doc_id, field_name, value FROM document_fields') +        self.assertEqual([('doc-id', 'fieldname', 'val')], +                         c.fetchall()) + +    def test__set_replica_uid(self): +        # Start from scratch, so that replica_uid isn't set. +        self.db = sqlcipher.SQLCipherDatabase(':memory:', _password) +        self.assertIsNot(None, self.db._real_replica_uid) +        self.assertIsNot(None, self.db._replica_uid) +        self.db._set_replica_uid('foo') +        c = self.db._get_sqlite_handle().cursor() +        c.execute("SELECT value FROM u1db_config WHERE name='replica_uid'") +        self.assertEqual(('foo',), c.fetchone()) +        self.assertEqual('foo', self.db._real_replica_uid) +        self.assertEqual('foo', self.db._replica_uid) +        self.db._close_sqlite_handle() +        self.assertEqual('foo', self.db._replica_uid) + +    def test__get_generation(self): +        self.assertEqual(0, self.db._get_generation()) + +    def test__get_generation_info(self): +        self.assertEqual((0, ''), self.db._get_generation_info()) + +    def test_create_index(self): +        self.db.create_index('test-idx', "key") +        self.assertEqual([('test-idx', ["key"])], self.db.list_indexes()) + +    def test_create_index_multiple_fields(self): +        self.db.create_index('test-idx', "key", "key2") +        self.assertEqual([('test-idx', ["key", "key2"])], +                         self.db.list_indexes()) + +    def test__get_index_definition(self): +        self.db.create_index('test-idx', "key", "key2") +        # TODO: How would you test that an index is getting used for an SQL +        #       request? +        self.assertEqual(["key", "key2"], +                         self.db._get_index_definition('test-idx')) + +    def test_list_index_mixed(self): +        # Make sure that we properly order the output +        c = self.db._get_sqlite_handle().cursor() +        # We intentionally insert the data in weird ordering, to make sure the +        # query still gets it back correctly. +        c.executemany("INSERT INTO index_definitions VALUES (?, ?, ?)", +                      [('idx-1', 0, 'key10'), +                       ('idx-2', 2, 'key22'), +                       ('idx-1', 1, 'key11'), +                       ('idx-2', 0, 'key20'), +                       ('idx-2', 1, 'key21')]) +        self.assertEqual([('idx-1', ['key10', 'key11']), +                          ('idx-2', ['key20', 'key21', 'key22'])], +                         self.db.list_indexes()) + +    def test_no_indexes_no_document_fields(self): +        self.db.create_doc_from_json( +            '{"key1": "val1", "key2": "val2"}') +        c = self.db._get_sqlite_handle().cursor() +        c.execute("SELECT doc_id, field_name, value FROM document_fields" +                  " ORDER BY doc_id, field_name, value") +        self.assertEqual([], c.fetchall()) + +    def test_create_extracts_fields(self): +        doc1 = self.db.create_doc_from_json('{"key1": "val1", "key2": "val2"}') +        doc2 = self.db.create_doc_from_json('{"key1": "valx", "key2": "valy"}') +        c = self.db._get_sqlite_handle().cursor() +        c.execute("SELECT doc_id, field_name, value FROM document_fields" +                  " ORDER BY doc_id, field_name, value") +        self.assertEqual([], c.fetchall()) +        self.db.create_index('test', 'key1', 'key2') +        c.execute("SELECT doc_id, field_name, value FROM document_fields" +                  " ORDER BY doc_id, field_name, value") +        self.assertEqual(sorted( +            [(doc1.doc_id, "key1", "val1"), +             (doc1.doc_id, "key2", "val2"), +             (doc2.doc_id, "key1", "valx"), +             (doc2.doc_id, "key2", "valy"), +            ]), sorted(c.fetchall())) + +    def test_put_updates_fields(self): +        self.db.create_index('test', 'key1', 'key2') +        doc1 = self.db.create_doc_from_json( +            '{"key1": "val1", "key2": "val2"}') +        doc1.content = {"key1": "val1", "key2": "valy"} +        self.db.put_doc(doc1) +        c = self.db._get_sqlite_handle().cursor() +        c.execute("SELECT doc_id, field_name, value FROM document_fields" +                  " ORDER BY doc_id, field_name, value") +        self.assertEqual([(doc1.doc_id, "key1", "val1"), +                          (doc1.doc_id, "key2", "valy"), +                         ], c.fetchall()) + +    def test_put_updates_nested_fields(self): +        self.db.create_index('test', 'key', 'sub.doc') +        doc1 = self.db.create_doc_from_json(nested_doc) +        c = self.db._get_sqlite_handle().cursor() +        c.execute("SELECT doc_id, field_name, value FROM document_fields" +                  " ORDER BY doc_id, field_name, value") +        self.assertEqual([(doc1.doc_id, "key", "value"), +                          (doc1.doc_id, "sub.doc", "underneath"), +                         ], c.fetchall()) + +    def test__ensure_schema_rollback(self): +        temp_dir = self.createTempDir(prefix='u1db-test-') +        path = temp_dir + '/rollback.db' + +        class SQLCipherPartialExpandDbTesting( +            sqlcipher.SQLCipherDatabase): + +            def _set_replica_uid_in_transaction(self, uid): +                super(SQLCipherPartialExpandDbTesting, +                    self)._set_replica_uid_in_transaction(uid) +                if fail: +                    raise Exception() + +        db = SQLCipherPartialExpandDbTesting.__new__(SQLCipherPartialExpandDbTesting) +        db._db_handle = dbapi2.connect(path)  # db is there but not yet init-ed +        fail = True +        self.assertRaises(Exception, db._ensure_schema) +        fail = False +        db._initialize(db._db_handle.cursor()) + +    def test__open_database(self): +        temp_dir = self.createTempDir(prefix='u1db-test-') +        path = temp_dir + '/test.sqlite' +        sqlcipher.SQLCipherDatabase(path, _password) +        db2 = sqlcipher.SQLCipherDatabase._open_database(path, _password) +        self.assertIsInstance(db2, sqlcipher.SQLCipherDatabase) + +    def test__open_database_with_factory(self): +        temp_dir = self.createTempDir(prefix='u1db-test-') +        path = temp_dir + '/test.sqlite' +        sqlcipher.SQLCipherDatabase(path, _password) +        db2 = sqlcipher.SQLCipherDatabase._open_database( +            path, _password, document_factory=LeapDocument) +        self.assertEqual(LeapDocument, db2._factory) + +    def test__open_database_non_existent(self): +        temp_dir = self.createTempDir(prefix='u1db-test-') +        path = temp_dir + '/non-existent.sqlite' +        self.assertRaises(errors.DatabaseDoesNotExist, +                         sqlcipher.SQLCipherDatabase._open_database, path, _password) + +    def test__open_database_during_init(self): +        temp_dir = self.createTempDir(prefix='u1db-test-') +        path = temp_dir + '/initialised.db' +        db = sqlcipher.SQLCipherDatabase.__new__( +                                    sqlcipher.SQLCipherDatabase) +        db._db_handle = dbapi2.connect(path)  # db is there but not yet init-ed +        self.addCleanup(db.close) +        observed = [] + +        class SQLCipherDatabaseTesting(sqlcipher.SQLCipherDatabase): +            WAIT_FOR_PARALLEL_INIT_HALF_INTERVAL = 0.1 + +            @classmethod +            def _which_index_storage(cls, c): +                res = super(SQLCipherDatabaseTesting, cls)._which_index_storage(c) +                db._ensure_schema()  # init db +                observed.append(res[0]) +                return res + +        db2 = SQLCipherDatabaseTesting._open_database(path, _password) +        self.addCleanup(db2.close) +        self.assertIsInstance(db2, sqlcipher.SQLCipherDatabase) +        self.assertEqual([None, +              sqlcipher.SQLCipherDatabase._index_storage_value], +                         observed) + +    def test__open_database_invalid(self): +        class SQLCipherDatabaseTesting(sqlcipher.SQLCipherDatabase): +            WAIT_FOR_PARALLEL_INIT_HALF_INTERVAL = 0.1 +        temp_dir = self.createTempDir(prefix='u1db-test-') +        path1 = temp_dir + '/invalid1.db' +        with open(path1, 'wb') as f: +            f.write("") +        self.assertRaises(dbapi2.OperationalError, +                          SQLCipherDatabaseTesting._open_database, path1, _password) +        with open(path1, 'wb') as f: +            f.write("invalid") +        self.assertRaises(dbapi2.DatabaseError, +                          SQLCipherDatabaseTesting._open_database, path1, _password) + +    def test_open_database_existing(self): +        temp_dir = self.createTempDir(prefix='u1db-test-') +        path = temp_dir + '/existing.sqlite' +        sqlcipher.SQLCipherDatabase(path, _password) +        db2 = sqlcipher.SQLCipherDatabase.open_database(path, _password, +                                                        create=False) +        self.assertIsInstance(db2, sqlcipher.SQLCipherDatabase) + +    def test_open_database_with_factory(self): +        temp_dir = self.createTempDir(prefix='u1db-test-') +        path = temp_dir + '/existing.sqlite' +        sqlcipher.SQLCipherDatabase(path, _password) +        db2 = sqlcipher.SQLCipherDatabase.open_database( +            path, _password, create=False, document_factory=LeapDocument) +        self.assertEqual(LeapDocument, db2._factory) + +    def test_open_database_create(self): +        temp_dir = self.createTempDir(prefix='u1db-test-') +        path = temp_dir + '/new.sqlite' +        sqlcipher.SQLCipherDatabase.open_database(path, _password, create=True) +        db2 = sqlcipher.SQLCipherDatabase.open_database(path, _password, create=False) +        self.assertIsInstance(db2, sqlcipher.SQLCipherDatabase) + +    def test_open_database_non_existent(self): +        temp_dir = self.createTempDir(prefix='u1db-test-') +        path = temp_dir + '/non-existent.sqlite' +        self.assertRaises(errors.DatabaseDoesNotExist, +                          sqlcipher.SQLCipherDatabase.open_database, path, +                          _password, create=False) + +    def test_delete_database_existent(self): +        temp_dir = self.createTempDir(prefix='u1db-test-') +        path = temp_dir + '/new.sqlite' +        db = sqlcipher.SQLCipherDatabase.open_database(path, _password, create=True) +        db.close() +        sqlcipher.SQLCipherDatabase.delete_database(path) +        self.assertRaises(errors.DatabaseDoesNotExist, +                          sqlcipher.SQLCipherDatabase.open_database, path, +                          _password, create=False) + +    def test_delete_database_nonexistent(self): +        temp_dir = self.createTempDir(prefix='u1db-test-') +        path = temp_dir + '/non-existent.sqlite' +        self.assertRaises(errors.DatabaseDoesNotExist, +                          sqlcipher.SQLCipherDatabase.delete_database, path) + +    def test__get_indexed_fields(self): +        self.db.create_index('idx1', 'a', 'b') +        self.assertEqual(set(['a', 'b']), self.db._get_indexed_fields()) +        self.db.create_index('idx2', 'b', 'c') +        self.assertEqual(set(['a', 'b', 'c']), self.db._get_indexed_fields()) + +    def test_indexed_fields_expanded(self): +        self.db.create_index('idx1', 'key1') +        doc1 = self.db.create_doc_from_json('{"key1": "val1", "key2": "val2"}') +        self.assertEqual(set(['key1']), self.db._get_indexed_fields()) +        c = self.db._get_sqlite_handle().cursor() +        c.execute("SELECT doc_id, field_name, value FROM document_fields" +                  " ORDER BY doc_id, field_name, value") +        self.assertEqual([(doc1.doc_id, 'key1', 'val1')], c.fetchall()) + +    def test_create_index_updates_fields(self): +        doc1 = self.db.create_doc_from_json('{"key1": "val1", "key2": "val2"}') +        self.db.create_index('idx1', 'key1') +        self.assertEqual(set(['key1']), self.db._get_indexed_fields()) +        c = self.db._get_sqlite_handle().cursor() +        c.execute("SELECT doc_id, field_name, value FROM document_fields" +                  " ORDER BY doc_id, field_name, value") +        self.assertEqual([(doc1.doc_id, 'key1', 'val1')], c.fetchall()) + +    def assertFormatQueryEquals(self, exp_statement, exp_args, definition, +                                values): +        statement, args = self.db._format_query(definition, values) +        self.assertEqual(exp_statement, statement) +        self.assertEqual(exp_args, args) + +    def test__format_query(self): +        self.assertFormatQueryEquals( +            "SELECT d.doc_id, d.doc_rev, d.content, count(c.doc_rev) FROM " +            "document d, document_fields d0 LEFT OUTER JOIN conflicts c ON " +            "c.doc_id = d.doc_id WHERE d.doc_id = d0.doc_id AND d0.field_name " +            "= ? AND d0.value = ? GROUP BY d.doc_id, d.doc_rev, d.content " +            "ORDER BY d0.value;", ["key1", "a"], +            ["key1"], ["a"]) + +    def test__format_query2(self): +        self.assertFormatQueryEquals( +            'SELECT d.doc_id, d.doc_rev, d.content, count(c.doc_rev) FROM ' +            'document d, document_fields d0, document_fields d1, ' +            'document_fields d2 LEFT OUTER JOIN conflicts c ON c.doc_id = ' +            'd.doc_id WHERE d.doc_id = d0.doc_id AND d0.field_name = ? AND ' +            'd0.value = ? AND d.doc_id = d1.doc_id AND d1.field_name = ? AND ' +            'd1.value = ? AND d.doc_id = d2.doc_id AND d2.field_name = ? AND ' +            'd2.value = ? GROUP BY d.doc_id, d.doc_rev, d.content ORDER BY ' +            'd0.value, d1.value, d2.value;', +            ["key1", "a", "key2", "b", "key3", "c"], +            ["key1", "key2", "key3"], ["a", "b", "c"]) + +    def test__format_query_wildcard(self): +        self.assertFormatQueryEquals( +            'SELECT d.doc_id, d.doc_rev, d.content, count(c.doc_rev) FROM ' +            'document d, document_fields d0, document_fields d1, ' +            'document_fields d2 LEFT OUTER JOIN conflicts c ON c.doc_id = ' +            'd.doc_id WHERE d.doc_id = d0.doc_id AND d0.field_name = ? AND ' +            'd0.value = ? AND d.doc_id = d1.doc_id AND d1.field_name = ? AND ' +            'd1.value GLOB ? AND d.doc_id = d2.doc_id AND d2.field_name = ? ' +            'AND d2.value NOT NULL GROUP BY d.doc_id, d.doc_rev, d.content ' +            'ORDER BY d0.value, d1.value, d2.value;', +            ["key1", "a", "key2", "b*", "key3"], ["key1", "key2", "key3"], +            ["a", "b*", "*"]) + +    def assertFormatRangeQueryEquals(self, exp_statement, exp_args, definition, +                                     start_value, end_value): +        statement, args = self.db._format_range_query( +            definition, start_value, end_value) +        self.assertEqual(exp_statement, statement) +        self.assertEqual(exp_args, args) + +    def test__format_range_query(self): +        self.assertFormatRangeQueryEquals( +            'SELECT d.doc_id, d.doc_rev, d.content, count(c.doc_rev) FROM ' +            'document d, document_fields d0, document_fields d1, ' +            'document_fields d2 LEFT OUTER JOIN conflicts c ON c.doc_id = ' +            'd.doc_id WHERE d.doc_id = d0.doc_id AND d0.field_name = ? AND ' +            'd0.value >= ? AND d.doc_id = d1.doc_id AND d1.field_name = ? AND ' +            'd1.value >= ? AND d.doc_id = d2.doc_id AND d2.field_name = ? AND ' +            'd2.value >= ? AND d.doc_id = d0.doc_id AND d0.field_name = ? AND ' +            'd0.value <= ? AND d.doc_id = d1.doc_id AND d1.field_name = ? AND ' +            'd1.value <= ? AND d.doc_id = d2.doc_id AND d2.field_name = ? AND ' +            'd2.value <= ? GROUP BY d.doc_id, d.doc_rev, d.content ORDER BY ' +            'd0.value, d1.value, d2.value;', +            ['key1', 'a', 'key2', 'b', 'key3', 'c', 'key1', 'p', 'key2', 'q', +             'key3', 'r'], +            ["key1", "key2", "key3"], ["a", "b", "c"], ["p", "q", "r"]) + +    def test__format_range_query_no_start(self): +        self.assertFormatRangeQueryEquals( +            'SELECT d.doc_id, d.doc_rev, d.content, count(c.doc_rev) FROM ' +            'document d, document_fields d0, document_fields d1, ' +            'document_fields d2 LEFT OUTER JOIN conflicts c ON c.doc_id = ' +            'd.doc_id WHERE d.doc_id = d0.doc_id AND d0.field_name = ? AND ' +            'd0.value <= ? AND d.doc_id = d1.doc_id AND d1.field_name = ? AND ' +            'd1.value <= ? AND d.doc_id = d2.doc_id AND d2.field_name = ? AND ' +            'd2.value <= ? GROUP BY d.doc_id, d.doc_rev, d.content ORDER BY ' +            'd0.value, d1.value, d2.value;', +            ['key1', 'a', 'key2', 'b', 'key3', 'c'], +            ["key1", "key2", "key3"], None, ["a", "b", "c"]) + +    def test__format_range_query_no_end(self): +        self.assertFormatRangeQueryEquals( +            'SELECT d.doc_id, d.doc_rev, d.content, count(c.doc_rev) FROM ' +            'document d, document_fields d0, document_fields d1, ' +            'document_fields d2 LEFT OUTER JOIN conflicts c ON c.doc_id = ' +            'd.doc_id WHERE d.doc_id = d0.doc_id AND d0.field_name = ? AND ' +            'd0.value >= ? AND d.doc_id = d1.doc_id AND d1.field_name = ? AND ' +            'd1.value >= ? AND d.doc_id = d2.doc_id AND d2.field_name = ? AND ' +            'd2.value >= ? GROUP BY d.doc_id, d.doc_rev, d.content ORDER BY ' +            'd0.value, d1.value, d2.value;', +            ['key1', 'a', 'key2', 'b', 'key3', 'c'], +            ["key1", "key2", "key3"], ["a", "b", "c"], None) + +    def test__format_range_query_wildcard(self): +        self.assertFormatRangeQueryEquals( +            'SELECT d.doc_id, d.doc_rev, d.content, count(c.doc_rev) FROM ' +            'document d, document_fields d0, document_fields d1, ' +            'document_fields d2 LEFT OUTER JOIN conflicts c ON c.doc_id = ' +            'd.doc_id WHERE d.doc_id = d0.doc_id AND d0.field_name = ? AND ' +            'd0.value >= ? AND d.doc_id = d1.doc_id AND d1.field_name = ? AND ' +            'd1.value >= ? AND d.doc_id = d2.doc_id AND d2.field_name = ? AND ' +            'd2.value NOT NULL AND d.doc_id = d0.doc_id AND d0.field_name = ? ' +            'AND d0.value <= ? AND d.doc_id = d1.doc_id AND d1.field_name = ? ' +            'AND (d1.value < ? OR d1.value GLOB ?) AND d.doc_id = d2.doc_id ' +            'AND d2.field_name = ? AND d2.value NOT NULL GROUP BY d.doc_id, ' +            'd.doc_rev, d.content ORDER BY d0.value, d1.value, d2.value;', +            ['key1', 'a', 'key2', 'b', 'key3', 'key1', 'p', 'key2', 'q', 'q*', +             'key3'], +            ["key1", "key2", "key3"], ["a", "b*", "*"], ["p", "q*", "*"]) + + +if __name__ == '__main__': +    unittest.main() diff --git a/src/leap/soledad/util.py b/src/leap/soledad/util.py new file mode 100644 index 00000000..41fd4548 --- /dev/null +++ b/src/leap/soledad/util.py @@ -0,0 +1,176 @@ +import os +import gnupg +import re + +class GPGWrapper(): +    """ +    This is a temporary class for handling GPG requests, and should be +    replaced by a more general class used throughout the project. +    """ + +    GNUPG_HOME    = os.environ['HOME'] + "/.config/leap/gnupg" +    GNUPG_BINARY  = "/usr/bin/gpg" # this has to be changed based on OS + +    def __init__(self, gpghome=GNUPG_HOME, gpgbinary=GNUPG_BINARY): +        self.gpg = gnupg.GPG(gnupghome=gpghome, gpgbinary=gpgbinary) + +    def find_key(self, email): +        """ +        Find user's key based on their email. +        """ +        for key in self.gpg.list_keys(): +            for uid in key['uids']: +                if re.search(email, uid): +                    return key +        raise LookupError("GnuPG public key for %s not found!" % email) + +    def encrypt(self, data, recipient, sign=None, always_trust=True, +                passphrase=None, symmetric=False): +        return self.gpg.encrypt(data, recipient, sign=sign, +                                always_trust=always_trust, +                                passphrase=passphrase, symmetric=symmetric) + +    def decrypt(self, data, always_trust=True, passphrase=None): +        result = self.gpg.decrypt(data, always_trust=always_trust, +                                passphrase=passphrase) +        return result + +    def import_keys(self, data): +        return self.gpg.import_keys(data) + +    def gen_key_input(self, **kwargs): +        return self.gpg.gen_key_input(**kwargs) + +    def gen_key(self, input): +        return self.gpg.gen_key(input) + + +#---------------------------------------------------------------------------- +# u1db Transaction and Sync logs. +#---------------------------------------------------------------------------- + +class SimpleLog(object): +    def __init__(self): +        self._log = [] + +    def _set_log(self, log): +        self._log = log + +    def _get_log(self): +        return self._log + +    log = property( +        _get_log, _set_log, doc="Log contents.") + +    def append(self, msg): +        self._log.append(msg) + +    def reduce(self, func, initializer=None): +        return reduce(func, self.log, initializer) + +    def map(self, func): +        return map(func, self.log) + +    def filter(self, func): +        return filter(func, self.log) + + +class TransactionLog(SimpleLog): +    """ +    An ordered list of (generation, doc_id, transaction_id) tuples. +    """ + +    def _set_log(self, log): +        self._log = log + +    def _get_log(self): +        return sorted(self._log, reverse=True) + +    log = property( +        _get_log, _set_log, doc="Log contents.") + +    def get_generation(self): +        """ +        Return the current generation. +        """ +        gens = self.map(lambda x: x[0]) +        if not gens: +            return 0 +        return max(gens) + +    def get_generation_info(self): +        """ +        Return the current generation and transaction id. +        """ +        if not self._log: +            return(0, '') +        info = self.map(lambda x: (x[0], x[2])) +        return reduce(lambda x, y: x if (x[0] > y[0]) else y, info) + +    def get_trans_id_for_gen(self, gen): +        """ +        Get the transaction id corresponding to a particular generation. +        """ +        log = self.reduce(lambda x, y: y if y[0] == gen else x) +        if log is None: +            return None +        return log[2] + +    def whats_changed(self, old_generation): +        """ +        Return a list of documents that have changed since old_generation. +        """ +        results = self.filter(lambda x: x[0] > old_generation) +        seen = set() +        changes = [] +        newest_trans_id = '' +        for generation, doc_id, trans_id in results: +            if doc_id not in seen: +                changes.append((doc_id, generation, trans_id)) +                seen.add(doc_id) +        if changes: +            cur_gen = changes[0][1]  # max generation +            newest_trans_id = changes[0][2] +            changes.reverse() +        else: +            results = self.log +            if not results: +                cur_gen = 0 +                newest_trans_id = '' +            else: +                cur_gen, _, newest_trans_id = results[0] + +        return cur_gen, newest_trans_id, changes +         + + +class SyncLog(SimpleLog): +    """ +    A list of (replica_id, generation, transaction_id) tuples. +    """ + +    def find_by_replica_uid(self, replica_uid): +        if not self.log: +            return () +        return self.reduce(lambda x, y: y if y[0] == replica_uid else x) + +    def get_replica_gen_and_trans_id(self, other_replica_uid): +        """ +        Return the last known generation and transaction id for the other db +        replica. +        """ +        info = self.find_by_replica_uid(other_replica_uid) +        if not info: +            return (0, '') +        return (info[1], info[2]) + +    def set_replica_gen_and_trans_id(self, other_replica_uid, +                                      other_generation, other_transaction_id): +        """ +        Set the last-known generation and transaction id for the other +        database replica. +        """ +        self.log = self.filter(lambda x: x[0] != other_replica_uid) +        self.append((other_replica_uid, other_generation, +                     other_transaction_id)) + | 
