Skip to content
Snippets Groups Projects
model.py 35.9 KiB
Newer Older
# Copyright 2019 PrivateStorage.io, LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""
This module implements models (in the MVC sense) for the client side of
the storage plugin.
"""

Tom Prince's avatar
Tom Prince committed
from datetime import datetime
from functools import wraps
from json import dumps, loads
from sqlite3 import OperationalError
from sqlite3 import connect as _connect
Tom Prince's avatar
Tom Prince committed
from aniso8601 import parse_datetime as _parse_datetime
from twisted.logger import Logger
from twisted.python.filepath import FilePath
from zope.interface import Interface, implementer
Tom Prince's avatar
Tom Prince committed
from ._base64 import urlsafe_b64decode
from .schema import get_schema_upgrades, get_schema_version, run_schema_upgrades
    get_configured_pass_value,
Tom Prince's avatar
Tom Prince committed
    pass_value_attribute,
Tom Prince's avatar
Tom Prince committed
from .validators import greater_than, has_length, is_base64_encoded
def parse_datetime(s, **kw):
    """
    Like ``aniso8601.parse_datetime`` but accept unicode as well.
    """
    if isinstance(s, unicode):
        s = s.encode("utf-8")
    assert isinstance(s, bytes)
    if "delimiter" in kw and isinstance(kw["delimiter"], unicode):
        kw["delimiter"] = kw["delimiter"].encode("utf-8")
    return _parse_datetime(s, **kw)


class ILeaseMaintenanceObserver(Interface):
    """
    An object which is interested in receiving events related to the progress
    of lease maintenance activity.
    """
Tom Prince's avatar
Tom Prince committed

    def observe(sizes):
        """
        Observe some shares encountered during lease maintenance.

        :param list[int] sizes: The sizes of the shares encountered.
        """

    def finish():
        """
        Observe that a run of lease maintenance has completed.
        """


class StoreOpenError(Exception):
    """
    There was a problem opening the underlying data store.
    """
Tom Prince's avatar
Tom Prince committed

    def __init__(self, reason):
        self.reason = reason


class NotEnoughTokens(Exception):
    """
    An attempt to extract tokens failed because the store does not contain as
    many tokens as were requested.
    """


CONFIG_DB_NAME = u"privatestorageio-zkapauthz-v1.sqlite3"
Tom Prince's avatar
Tom Prince committed

def open_and_initialize(path, connect=None):
    Open a SQLite3 database for use as a voucher store.

    Create the database and populate it with a schema, if it does not already
    exist.

    :param FilePath path: The location of the SQLite3 database file.

    :return: A SQLite3 connection object for the database at the given path.
    """
    if connect is None:
        connect = _connect
    try:
        path.parent().makedirs(ignoreExistingDirectory=True)
    except OSError as e:
        raise StoreOpenError(e)

    dbfile = path.asBytesMode().path
    try:
        conn = connect(
            dbfile,
            isolation_level="IMMEDIATE",
        )
    except OperationalError as e:
        raise StoreOpenError(e)
Jean-Paul Calderone's avatar
Jean-Paul Calderone committed
    # Enforcement of foreign key constraints is off by default.  It must be
    # enabled on a per-connection basis.  This is a helpful feature to ensure
    # consistency so we want it enforced and we use it in our schema.
    conn.execute("PRAGMA foreign_keys = ON")

    with conn:
        cursor = conn.cursor()
        actual_version = get_schema_version(cursor)
        schema_upgrades = list(get_schema_upgrades(actual_version))
        run_schema_upgrades(schema_upgrades, cursor)
Jean-Paul Calderone's avatar
Jean-Paul Calderone committed
    # Create some tables that only exist (along with their contents) for
    # this connection.  These are outside of the schema because they are not
    # persistent.  We can change them any time we like without worrying about
    # upgrade logic because we re-create them on every connection.
    conn.execute(
        """
        -- Track tokens in use by the process holding this connection.
        CREATE TEMPORARY TABLE [in-use] (
            [unblinded-token] text, -- The base64 encoded unblinded token.

            PRIMARY KEY([unblinded-token])
            -- A foreign key on unblinded-token to [unblinded-tokens]([token])
            -- would be alright - however SQLite3 foreign key constraints
            -- can't cross databases (and temporary tables are considered to
            -- be in a different database than normal tables).
        )
        """,
        -- Track tokens that we want to remove from the database.  Mainly just
        -- works around the awkward DB-API interface for dealing with deleting
        -- many rows.
        CREATE TEMPORARY TABLE [to-discard] (
            [unblinded-token] text
        )
        """,
    )
    conn.execute(
        """
        -- Track tokens that we want to remove from the [in-use] set.  Similar
        -- to [to-discard].
        CREATE TEMPORARY TABLE [to-reset] (
            [unblinded-token] text
        )
        """,
    )
Jean-Paul Calderone's avatar
Jean-Paul Calderone committed
    """
    Decorate a function so it is automatically passed a cursor with an active
    transaction as the first positional argument.  If the function returns
    normally then the transaction will be committed.  Otherwise, the
    transaction will be rolled back.
    """
    @wraps(f)
    def with_cursor(self, *a, **kw):
        with self._connection:
            cursor = self._connection.cursor()
            cursor.execute("BEGIN IMMEDIATE TRANSACTION")
            return f(self, cursor, *a, **kw)
def memory_connect(path, *a, **kw):
    """
    Always connect to an in-memory SQLite3 database.
    """
    return _connect(":memory:", *a, **kw)


# The largest integer SQLite3 can represent in an integer column.  Larger than
# this an the representation loses precision as a floating point.
_SQLITE3_INTEGER_MAX = 2 ** 63 - 1


    This class implements persistence for vouchers.
    :ivar allmydata.node._Config node_config: The Tahoe-LAFS node configuration object for
        the node that owns the persisted vouchers.

    :ivar now: A no-argument callable that returns the time of the call as a
        ``datetime`` instance.
Tom Prince's avatar
Tom Prince committed

    pass_value = pass_value_attribute()

    database_path = attr.ib(validator=attr.validators.instance_of(FilePath))
    _connection = attr.ib()

    @classmethod
    def from_node_config(cls, node_config, now, connect=None):
Jean-Paul Calderone's avatar
Jean-Paul Calderone committed
        """
        Create or open the ``VoucherStore`` for a given node.

        :param allmydata.node._Config node_config: The Tahoe-LAFS
            configuration object for the node for which we want to open a
            store.

        :param now: See ``VoucherStore.now``.

Jean-Paul Calderone's avatar
Jean-Paul Calderone committed
        :param connect: An alternate database connection function.  This is
            primarily for the purposes of the test suite.
        """
        db_path = FilePath(node_config.get_private_path(CONFIG_DB_NAME))
        conn = open_and_initialize(
            db_path,
            connect=connect,
            get_configured_pass_value(node_config),
    def get(self, cursor, voucher):
Jean-Paul Calderone's avatar
Jean-Paul Calderone committed
        """
        :param bytes voucher: The text value of a voucher to retrieve.
Jean-Paul Calderone's avatar
Jean-Paul Calderone committed

        :return Voucher: The voucher object that matches the given value.
        """
                [number], [created], [expected-tokens], [state], [finished], [token-count], [public-key], [counter]
        )
        refs = cursor.fetchall()
        if len(refs) == 0:
            raise KeyError(voucher)
        return Voucher.from_row(refs[0])
    def add(self, cursor, voucher, expected_tokens, counter, get_tokens):
Jean-Paul Calderone's avatar
Jean-Paul Calderone committed
        """
        Add random tokens associated with a voucher (possibly new, possibly
        existing) to the database.  If the (voucher, counter) pair is already
        present, do nothing.
Jean-Paul Calderone's avatar
Jean-Paul Calderone committed

        :param bytes voucher: The text value of a voucher with which to
        :param int expected_tokens: The total number of tokens for which this
            voucher is expected to be redeemed.  This is only respected the
            first time a voucher is added.  Subsequent calls with the same
            voucher but a different count ignore the value because it is
            already known (and the database knows better than the caller what
            it should be).

            This probably means ``add`` is a broken interface for doing these
            two things.  Maybe it should be fixed someday.

        :param int counter: The redemption counter for the given voucher with
            which to associate the tokens.
Jean-Paul Calderone's avatar
Jean-Paul Calderone committed

        :param list[RandomToken]: The tokens to add alongside the voucher.
        """
        now = self.now()
        if not isinstance(now, datetime):
            raise TypeError("{} returned {}, expected datetime".format(self.now, now))

            WHERE [voucher] = ? AND [counter] = ?
        rows = cursor.fetchall()
        if len(rows) > 0:
            self._log.info(
                "Loaded {count} random tokens for a voucher ({voucher}[{counter}]).",
                count=len(rows),
                voucher=voucher,
            tokens = list(
                RandomToken(token_value.encode("ascii")) for (token_value,) in rows
            )
        else:
            tokens = get_tokens()
            self._log.info(
                "Persisting {count} random tokens for a voucher ({voucher}[{counter}]).",
                INSERT OR IGNORE INTO [vouchers] ([number], [expected-tokens], [created]) VALUES (?, ?, ?)
                (voucher.decode("ascii"), expected_tokens, self.now()),
                INSERT INTO [tokens] ([voucher], [counter], [text]) VALUES (?, ?, ?)
                list(
                    (
                        voucher.decode("ascii"),
                        counter,
                        token.token_value.decode("ascii"),
                    )
                    for token in tokens
                ),

    @with_cursor
    def list(self, cursor):
Jean-Paul Calderone's avatar
Jean-Paul Calderone committed
        """
        Get all known vouchers.

        :return list[Voucher]: All vouchers known to the store.
        """
                [number], [created], [expected-tokens], [state], [finished], [token-count], [public-key], [counter]
            """,
        )
        refs = cursor.fetchall()
Tom Prince's avatar
Tom Prince committed
        return list(Voucher.from_row(row) for row in refs)
    def _insert_unblinded_tokens(self, cursor, unblinded_tokens, group_id):
        Helper function to really insert unblinded tokens into the database.
        """
        cursor.executemany(
            """
            INSERT INTO [unblinded-tokens] ([token], [redemption-group]) VALUES (?, ?)
Tom Prince's avatar
Tom Prince committed
            list((token, group_id) for token in unblinded_tokens),
    def insert_unblinded_tokens(self, cursor, unblinded_tokens, group_id):
        """
        Store some unblinded tokens, for example as part of a backup-restore
        process.

        :param list[unicode] unblinded_tokens: The unblinded tokens to store.

        :param int group_id: The unique identifier of the redemption group to
            which these tokens belong.
        self._insert_unblinded_tokens(cursor, unblinded_tokens, group_id)
Tom Prince's avatar
Tom Prince committed
    def insert_unblinded_tokens_for_voucher(
        self, cursor, voucher, public_key, unblinded_tokens, completed, spendable
    ):
        Store some unblinded tokens received from redemption of a voucher.
        :param bytes voucher: The voucher associated with the unblinded
            tokens.  This voucher will be marked as redeemed to indicate it
            has fulfilled its purpose and has no further use for us.
        :param unicode public_key: The encoded public key for the private key
            which was used to sign these tokens.

        :param list[UnblindedToken] unblinded_tokens: The unblinded tokens to
            store.

        :param bool completed: ``True`` if redemption of this voucher is now
            complete, ``False`` otherwise.
Loading
Loading full blame...