Skip to content
Snippets Groups Projects
model.py 33.6 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.
"""

from functools import (
    wraps,
from datetime import (
    datetime,
)
from zope.interface import (
    Interface,
    implementer,
)

from sqlite3 import (
    OperationalError,
    connect as _connect,
from aniso8601 import (
    parse_datetime,
)
from twisted.logger import (
    Logger,
)
from twisted.python.filepath import (
    FilePath,
from ._base64 import (
    urlsafe_b64decode,
)

from .validators import (
    is_base64_encoded,
    has_length,
    greater_than,
)

    pass_value_attribute,
    get_configured_pass_value,
from .schema import (
    get_schema_version,
    get_schema_upgrades,
    run_schema_upgrades,
)


class ILeaseMaintenanceObserver(Interface):
    """
    An object which is interested in receiving events related to the progress
    of lease maintenance activity.
    """
    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.
    """
    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"
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.
    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 unicode voucher: The text value of a voucher to retrieve.

        :return Voucher: The voucher object that matches the given value.
        """
                [number], [created], [expected-tokens], [state], [finished], [token-count], [public-key], [counter]
            (voucher,),
        )
        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 unicode voucher: The text value of a voucher with which to
            associate the tokens.

        :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)
                for (token_value,)
                in rows
            )
        else:
            tokens = get_tokens()
            self._log.info(
                "Persisting {count} random tokens for a voucher ({voucher}[{counter}]).",
                count=len(tokens),
                voucher=voucher,
                INSERT OR IGNORE INTO [vouchers] ([number], [expected-tokens], [created]) VALUES (?, ?, ?)
                (voucher, expected_tokens, self.now())
                INSERT INTO [tokens] ([voucher], [counter], [text]) VALUES (?, ?, ?)
                    (voucher, counter, token.token_value)

    @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()
            Voucher.from_row(row)
            for row
    def _insert_unblinded_tokens(self, cursor, unblinded_tokens):
        Helper function to really insert unblinded tokens into the database.
        """
        cursor.executemany(
            """
            INSERT INTO [unblinded-tokens] VALUES (?)
            """,
            list(
                (token,)
                for token
                in unblinded_tokens
            ),
        )

    @with_cursor
    def insert_unblinded_tokens(self, cursor, unblinded_tokens):
        """
        Store some unblinded tokens, for example as part of a backup-restore
        process.

        :param list[unicode] unblinded_tokens: The unblinded tokens to store.
        """
        self._insert_unblinded_tokens(cursor, unblinded_tokens)

    def insert_unblinded_tokens_for_voucher(self, cursor, voucher, public_key, unblinded_tokens, completed):
        Store some unblinded tokens received from redemption of a voucher.
        :param unicode 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…