chio-store-sqlite 0.1.2

SQLite-backed persistence, query, and report implementations for Chio
Documentation
use chio_core::canonical::canonical_json_bytes;
use chio_core::{sha256_hex, StoreMutationFence};
use rusqlite::{params, Connection, OptionalExtension, Transaction};
use serde::Serialize;

use super::{FrostStoreError, SqliteFrostStore};

const PROJECTION_CHAIN_GENESIS: &str =
    "0000000000000000000000000000000000000000000000000000000000000000";
const COMMIT_DIGEST_PREFIX: &[u8] = b"chio.frost.projection-commit.digest.v1\0";

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ProjectionCommitPreimage<'a> {
    format: &'static str,
    projection_key: &'a str,
    projection_sequence: u64,
    projection_type: &'a str,
    mutation_kind: &'a str,
    record_digest: &'a str,
    previous_chain_digest: &'a str,
    store_uuid: &'a str,
    store_lease_id: &'a str,
    store_owner_epoch: u64,
}

pub(super) struct ProjectionMutation<'a> {
    pub sequence: u64,
    pub projection_type: &'a str,
    pub mutation_kind: &'a str,
    pub record_digest: &'a str,
}

pub(super) fn append_projection_commit(
    transaction: &Transaction<'_>,
    store: &SqliteFrostStore,
    projection_key: &str,
    mutation: ProjectionMutation<'_>,
    fence: &StoreMutationFence,
) -> Result<(), FrostStoreError> {
    if !matches!(
        mutation.projection_type,
        "ceremony" | "rotation" | "signer" | "coordinator"
    ) {
        return Err(invalid("unknown FROST projection type"));
    }
    let previous = if mutation.sequence == 1 {
        PROJECTION_CHAIN_GENESIS.to_string()
    } else {
        transaction
            .query_row(
                r#"
                SELECT chain_digest FROM frost_projection_commits
                WHERE projection_key = ?1 AND projection_sequence = ?2
                "#,
                params![
                    projection_key,
                    sqlite_u64(mutation.sequence - 1, "previous FROST sequence")?
                ],
                |row| row.get::<_, String>(0),
            )
            .optional()
            .map_err(super::sqlite_error)?
            .ok_or_else(|| invalid("previous FROST projection commit is absent"))?
    };
    let chain_digest = projection_commit_digest(
        projection_key,
        mutation.sequence,
        mutation.projection_type,
        mutation.mutation_kind,
        mutation.record_digest,
        &previous,
        fence,
    )?;
    transaction
        .execute(
            r#"
            INSERT INTO frost_projection_commits (
                projection_key, projection_sequence, projection_type,
                mutation_kind, record_digest, previous_chain_digest,
                chain_digest, store_uuid, store_lease_id, store_owner_epoch
            ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
            "#,
            params![
                projection_key,
                sqlite_u64(mutation.sequence, "FROST projection sequence")?,
                mutation.projection_type,
                mutation.mutation_kind,
                mutation.record_digest,
                &previous,
                &chain_digest,
                &fence.store_uuid,
                &fence.lease_id,
                sqlite_u64(fence.owner_epoch, "FROST store owner epoch")?,
            ],
        )
        .map_err(super::sqlite_error)?;
    store
        .serving_owner
        .append_global_commit(
            transaction,
            mutation.mutation_kind,
            "frost",
            projection_key,
            mutation.sequence,
        )
        .map_err(|error| FrostStoreError::Unavailable(error.to_string()))
}

pub(super) fn verify_projection_commit_chains(
    connection: &Connection,
) -> Result<(), FrostStoreError> {
    let mut statement = connection
        .prepare(
            r#"
            SELECT projection_key, projection_sequence, projection_type,
                   mutation_kind, record_digest, previous_chain_digest,
                   chain_digest, store_uuid, store_lease_id, store_owner_epoch
            FROM frost_projection_commits
            ORDER BY projection_key, projection_sequence
            "#,
        )
        .map_err(super::sqlite_error)?;
    let mut rows = statement.query([]).map_err(super::sqlite_error)?;
    let mut current_key = String::new();
    let mut expected_sequence = 0_u64;
    let mut previous = PROJECTION_CHAIN_GENESIS.to_string();
    while let Some(row) = rows.next().map_err(super::sqlite_error)? {
        let key: String = row.get(0).map_err(super::sqlite_error)?;
        let sequence = read_u64(
            row.get::<_, i64>(1).map_err(super::sqlite_error)?,
            "projection sequence",
        )?;
        let projection_type: String = row.get(2).map_err(super::sqlite_error)?;
        let mutation_kind: String = row.get(3).map_err(super::sqlite_error)?;
        let record_digest: String = row.get(4).map_err(super::sqlite_error)?;
        let committed_previous: String = row.get(5).map_err(super::sqlite_error)?;
        let chain_digest: String = row.get(6).map_err(super::sqlite_error)?;
        let fence = StoreMutationFence {
            store_uuid: row.get(7).map_err(super::sqlite_error)?,
            lease_id: row.get(8).map_err(super::sqlite_error)?,
            owner_epoch: read_u64(
                row.get::<_, i64>(9).map_err(super::sqlite_error)?,
                "projection owner epoch",
            )?,
        };
        if key != current_key {
            current_key = key.clone();
            expected_sequence = 0;
            previous = PROJECTION_CHAIN_GENESIS.to_string();
        }
        expected_sequence = expected_sequence
            .checked_add(1)
            .ok_or_else(|| invalid("projection sequence overflowed"))?;
        if !matches!(
            projection_type.as_str(),
            "ceremony" | "rotation" | "signer" | "coordinator"
        ) || sequence != expected_sequence
            || committed_previous != previous
        {
            return Err(invalid("FROST projection commit chain is discontinuous"));
        }
        let expected = projection_commit_digest(
            &key,
            sequence,
            &projection_type,
            &mutation_kind,
            &record_digest,
            &previous,
            &fence,
        )?;
        if chain_digest != expected {
            return Err(invalid("FROST projection commit digest is invalid"));
        }
        previous = chain_digest;
    }
    Ok(())
}

#[allow(clippy::too_many_arguments)]
fn projection_commit_digest(
    key: &str,
    sequence: u64,
    projection_type: &str,
    mutation_kind: &str,
    record_digest: &str,
    previous_chain_digest: &str,
    fence: &StoreMutationFence,
) -> Result<String, FrostStoreError> {
    prefixed_digest(
        COMMIT_DIGEST_PREFIX,
        &ProjectionCommitPreimage {
            format: "chio.frost.projection-commit.v1",
            projection_key: key,
            projection_sequence: sequence,
            projection_type,
            mutation_kind,
            record_digest,
            previous_chain_digest,
            store_uuid: &fence.store_uuid,
            store_lease_id: &fence.lease_id,
            store_owner_epoch: fence.owner_epoch,
        },
    )
}

pub(super) fn prefixed_digest<T: Serialize>(
    prefix: &[u8],
    value: &T,
) -> Result<String, FrostStoreError> {
    let canonical = canonical_json_bytes(value)
        .map_err(|error| FrostStoreError::InvalidState(error.to_string()))?;
    let mut bytes = Vec::with_capacity(prefix.len() + canonical.len());
    bytes.extend_from_slice(prefix);
    bytes.extend_from_slice(&canonical);
    Ok(sha256_hex(&bytes))
}

fn sqlite_u64(value: u64, field: &'static str) -> Result<i64, FrostStoreError> {
    i64::try_from(value)
        .map_err(|_| FrostStoreError::InvalidState(format!("{field} exceeds SQLite range")))
}

fn read_u64(value: i64, field: &'static str) -> Result<u64, FrostStoreError> {
    u64::try_from(value).map_err(|_| FrostStoreError::InvalidState(format!("{field} is negative")))
}

fn invalid(detail: impl Into<String>) -> FrostStoreError {
    FrostStoreError::InvalidState(detail.into())
}