syncular-runtime 0.1.0

Shared Rust runtime for Syncular SQLite-backed native and browser clients.
Documentation
use crate::app_schema::AppTableMetadata;
use crate::error::{Result, SyncularError};
use crate::protocol::{
    AuthLeasePayload, AuthLeaseProvenance, SyncOperation, AUTH_LEASE_CODE_EXPIRED,
    AUTH_LEASE_CODE_MISSING, AUTH_LEASE_CODE_SCOPE_MISMATCH,
};
use crate::store::AuthLeaseRecord;
use serde_json::{Map, Value};

#[derive(Debug, Clone)]
pub struct ActiveAuthLeasePolicy<'a> {
    pub actor_id: Option<&'a str>,
    pub now_ms: i64,
}

#[derive(Debug, Clone)]
pub struct MutationOperationScope {
    pub table: String,
    pub op: String,
    pub scopes: Map<String, Value>,
    pub requires_scope_values: bool,
    pub missing_required_scope_values: bool,
}

pub fn app_table_operation_scope(
    metadata: &AppTableMetadata,
    operation: &SyncOperation,
    row: Option<&Value>,
    row_exists_or_will_be_written: bool,
) -> MutationOperationScope {
    let mut scopes = Map::new();
    if let Some(Value::Object(object)) = row {
        for scope in metadata.scopes {
            if let Some(value) = object.get(scope.column).and_then(scope_value_string) {
                scopes.insert(scope.name.to_string(), Value::String(value));
            }
        }
    }
    let missing_required_scope_values = row_exists_or_will_be_written
        && metadata.scopes.iter().any(|scope| {
            scope.required
                && !matches!(scopes.get(scope.name), Some(Value::String(value)) if !value.is_empty())
        });

    MutationOperationScope {
        table: operation.table.clone(),
        op: operation.op.clone(),
        scopes,
        requires_scope_values: row_exists_or_will_be_written
            && metadata.scopes.iter().any(|scope| scope.required),
        missing_required_scope_values,
    }
}

pub fn system_table_operation_scope(operation: &SyncOperation) -> MutationOperationScope {
    let scopes = operation
        .payload
        .as_ref()
        .and_then(|payload| payload.get("scopes"))
        .and_then(Value::as_object)
        .map(|object| {
            object
                .iter()
                .filter_map(|(key, value)| {
                    scope_value_string(value).map(|value| (key.clone(), Value::String(value)))
                })
                .collect()
        })
        .unwrap_or_default();

    MutationOperationScope {
        table: operation.table.clone(),
        op: operation.op.clone(),
        scopes,
        requires_scope_values: false,
        missing_required_scope_values: false,
    }
}

pub fn select_active_auth_lease_for_operations(
    policy: ActiveAuthLeasePolicy<'_>,
    candidate_leases: Vec<AuthLeaseRecord>,
    current_schema_version: i32,
    operations: &[MutationOperationScope],
) -> Result<AuthLeaseProvenance> {
    for operation in operations {
        if operation.missing_required_scope_values {
            return Err(SyncularError::protocol_message(format!(
                "{}: mutation for table {} is missing required lease scope values",
                AUTH_LEASE_CODE_SCOPE_MISMATCH, operation.table
            )));
        }
    }

    let mut saw_expired_covering_lease = false;
    for lease in candidate_leases {
        if lease.status != "active" {
            continue;
        }
        if lease.schema_version != current_schema_version {
            continue;
        }
        let Ok(payload) = serde_json::from_str::<AuthLeasePayload>(&lease.payload_json) else {
            continue;
        };
        if payload.schema_version != current_schema_version {
            continue;
        }
        if let Some(actor_id) = policy.actor_id {
            if payload.actor_id != actor_id {
                continue;
            }
        }
        if auth_lease_payload_covers_operations(&payload, operations) {
            if lease.not_before_ms > policy.now_ms || payload.not_before_ms > policy.now_ms {
                continue;
            }
            if lease.expires_at_ms <= policy.now_ms || payload.expires_at_ms <= policy.now_ms {
                saw_expired_covering_lease = true;
                continue;
            }
            return Ok(AuthLeaseProvenance {
                lease_id: lease.lease_id,
                lease_expires_at_ms: lease.expires_at_ms,
                lease_status_at_enqueue: lease.status,
                lease_scope_summary_json: serde_json::to_string(&payload.scopes).ok(),
                lease_token: Some(lease.token),
            });
        }
    }

    if saw_expired_covering_lease {
        return Err(SyncularError::protocol_message(format!(
            "{}: matching auth lease is expired",
            AUTH_LEASE_CODE_EXPIRED
        )));
    }

    Err(SyncularError::protocol_message(format!(
        "{}: no active auth lease covers generated mutation batch",
        AUTH_LEASE_CODE_MISSING
    )))
}

fn auth_lease_payload_covers_operations(
    payload: &AuthLeasePayload,
    operations: &[MutationOperationScope],
) -> bool {
    operations.iter().all(|operation| {
        let Some(scope) = payload.scopes.iter().find(|scope| {
            scope.table == operation.table
                && scope
                    .operations
                    .iter()
                    .any(|allowed_op| allowed_op == &operation.op)
        }) else {
            return false;
        };

        if operation.requires_scope_values && operation.scopes.is_empty() {
            return false;
        }

        operation.scopes.iter().all(|(name, value)| {
            scope
                .values
                .get(name)
                .is_some_and(|lease_value| lease_scope_value_covers(lease_value, value))
        })
    })
}

fn lease_scope_value_covers(lease_value: &Value, requested_value: &Value) -> bool {
    let Some(requested) = scope_value_string(requested_value) else {
        return false;
    };
    match lease_value {
        Value::String(value) => value == "*" || value == &requested,
        Value::Array(values) => values.iter().any(|value| {
            value
                .as_str()
                .is_some_and(|value| value == "*" || value == requested)
        }),
        other => scope_value_string(other).is_some_and(|value| value == "*" || value == requested),
    }
}

fn scope_value_string(value: &Value) -> Option<String> {
    match value {
        Value::Null => None,
        Value::String(value) => {
            if value.is_empty() {
                None
            } else {
                Some(value.clone())
            }
        }
        Value::Number(value) => Some(value.to_string()),
        Value::Bool(value) => Some(value.to_string()),
        Value::Array(_) | Value::Object(_) => None,
    }
}