haematite-cli 0.21.7

The haematite command: serve a store, its door and Studio, answer MCP, query with jq, and host the store's assistant seat
Documentation
//! What one served request acts on, named as the Lys resources its route
//! matrix row asks about (ACCESS-001 R2).
//!
//! A request becomes one or more subjects: its route's row and the name of
//! the placed resource, a collection or a branch, or none for the database
//! itself. An edge acts on the collections of both its ends, so it asks
//! about both; an import asks about every collection it writes. Every
//! subject must be allowed for the request to run.

use crate::serve::protocol::{Change, Edge, Operation};
use crate::serve::route_matrix::{Route, route};
use crate::serve::storage::Failure;

/// One resource a request needs its right on.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Subject {
    /// The route's matrix row.
    pub route: &'static Route,
    /// The placed resource under the database, or none for the database.
    pub name: Option<String>,
}

/// The subjects of `operation`, or none for protocol and language metadata.
///
/// # Errors
///
/// Returns `route_unclassified` when the request's route has no matrix row,
/// so an unclassified route is refused rather than run.
pub fn subjects(operation: &Operation) -> Result<Vec<Subject>, Failure> {
    let named = |wire: &str, name: &str| one(wire, Some(name.to_owned()));
    let database = |wire: &str| one(wire, None);
    match operation {
        Operation::Hello(_)
        | Operation::Present(_)
        | Operation::QueryCheck(_)
        | Operation::QueryWords(_) => Ok(Vec::new()),
        Operation::Branches(_) => database("branches.list"),
        Operation::Collections(_) => database("collections.list"),
        Operation::Get(read) => named("documents.get", &read.collection),
        Operation::List(page) => named("documents.list", &page.collection),
        Operation::History(page) => named("history.list", &page.branch),
        Operation::Follow(follow) => named("head.follow", &follow.branch),
        Operation::Review(page) => named("branch.review", &page.branch),
        Operation::ReviewCounts(page) => named("branch.review_counts", &page.branch),
        Operation::MergePrepare(page) => named("branch.merge_prepare", &page.branch),
        Operation::MergeDangling(page) => named("branch.merge_dangling", &page.branch),
        Operation::Mutate(mutation) => {
            let mut subjects = change_subjects(&mutation.change, &mutation.branch)?;
            // Branch control is decided by its own row; every other change
            // writes the branch's data, so it needs the right on that branch.
            if !matches!(
                mutation.change,
                Change::Fork(_) | Change::Discard(_) | Change::Merge(_)
            ) {
                subjects.extend(named("mutation.apply", &mutation.branch)?);
            }
            Ok(subjects)
        }
        Operation::Receipt(_) => database("mutation.receipt"),
        Operation::DraftsRun(_) => database("drafts.run"),
        Operation::CountsRebuild(rebuild) => named("counts.rebuild", &rebuild.branch),
        Operation::CountsAudit(selection) => named("counts.audit", &selection.branch),
        Operation::EdgesOut(page) => named("edge.out", &page.node.collection),
        Operation::EdgesIn(page) => named("edge.in", &page.node.collection),
        Operation::GraphExport(_) => database("graph.export"),
        Operation::VectorGet(read) => named("vectors.get", &read.collection),
        Operation::VectorList(page) => named("vectors.list", &page.collection),
        Operation::VectorSearch(search) => named("vectors.search", &search.collection),
        Operation::VectorSearchText(search) => named("vectors.search_text", &search.collection),
        Operation::StoreStats(_) => database("store.stats"),
        Operation::VectorFields(_) => database("vectors.fields"),
        Operation::EmbedRewalk(rewalk) => named("embed.rewalk_longer", &rewalk.collection),
        Operation::Relations(_) => database("relations.list"),
        Operation::QueryRun(_) => database("query.run"),
        Operation::ChangesList(_) => database("changes.list"),
    }
}

fn change_subjects(change: &Change, branch: &str) -> Result<Vec<Subject>, Failure> {
    let named = |wire: &str, name: &str| one(wire, Some(name.to_owned()));
    match change {
        Change::Collection(_) => one("collection.put", None),
        Change::Put(write) => named("document.put", &write.collection),
        Change::AppendPrune(write) => named("document.append_prune", &write.collection),
        Change::Delete(delete) => named("document.delete", &delete.collection),
        Change::DeleteMany(delete) => named("documents.delete_many", &delete.collection),
        Change::Fork(_) => one("branch.fork", None),
        Change::Discard(_) => one("branch.discard", None),
        // A merge writes the branch it merges into.
        Change::Merge(_) => named("branch.merge", branch),
        Change::EdgePut(edge) => ends("edge.put", [edge]),
        Change::EdgeDelete(edge) => {
            both_ends("edge.delete", &edge.from.collection, &edge.to.collection)
        }
        Change::IndexDeclare(declaration) => named("index.declare", &declaration.collection),
        Change::VectorDeclare(declaration) => named("vector.declare", &declaration.collection),
        Change::VectorPut(write) => named("vector.put", &write.collection),
        Change::VectorPutMany(import) => {
            let route = row("vectors.put_many")?;
            let mut subjects = Vec::new();
            let mut collections = std::collections::BTreeSet::new();
            for vector in &import.vectors {
                if collections.insert(&vector.collection) {
                    subjects.push(Subject {
                        route,
                        name: Some(vector.collection.clone()),
                    });
                }
            }
            if subjects.is_empty() {
                return Err(Failure::new(
                    "import_empty",
                    "a vector import names no collection",
                ));
            }
            Ok(subjects)
        }
        Change::VectorDelete(delete) => named("vector.delete", &delete.collection),
        Change::PutMany(import) => named("documents.put_many", &import.collection),
        Change::EdgePutMany(import) => ends("edges.put_many", &import.edges),
        Change::RelationPut(_) => one("relation.put", None),
        Change::RelationDelete(_) => one("relation.delete", None),
    }
}

fn row(wire: &str) -> Result<&'static Route, Failure> {
    route(wire).ok_or_else(|| {
        Failure::new(
            "route_unclassified",
            "this route has no row in the access matrix and is refused",
        )
    })
}

fn one(wire: &str, name: Option<String>) -> Result<Vec<Subject>, Failure> {
    Ok(vec![Subject {
        route: row(wire)?,
        name,
    }])
}

fn both_ends(wire: &str, from: &str, to: &str) -> Result<Vec<Subject>, Failure> {
    let route = row(wire)?;
    let mut subjects = vec![Subject {
        route,
        name: Some(from.to_owned()),
    }];
    if to != from {
        subjects.push(Subject {
            route,
            name: Some(to.to_owned()),
        });
    }
    Ok(subjects)
}

/// Every distinct collection at either end of `edges`, in first-seen order.
fn ends<'a>(
    wire: &str,
    edges: impl IntoIterator<Item = &'a Edge>,
) -> Result<Vec<Subject>, Failure> {
    let route = row(wire)?;
    let mut subjects: Vec<Subject> = Vec::new();
    for edge in edges {
        for collection in [&edge.from.collection, &edge.to.collection] {
            if !subjects
                .iter()
                .any(|subject| subject.name.as_deref() == Some(collection.as_str()))
            {
                subjects.push(Subject {
                    route,
                    name: Some(collection.clone()),
                });
            }
        }
    }
    if subjects.is_empty() {
        return Err(Failure::new(
            "import_empty",
            "an edge import names no edge, so it acts on no collection",
        ));
    }
    Ok(subjects)
}

#[cfg(test)]
#[path = "authorize_tests.rs"]
mod tests;