haematite-cli 0.21.0

The haematite command: serve a store, its door and Studio, answer MCP, query with jq, and host the store's assistant seat
//! Request-scoped authority resolved by a source, never supplied by a record.

use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;

use crate::serve::storage::Failure;

type DeniedDocuments = BTreeMap<String, BTreeSet<String>>;

/// A source's answer for one request. Its fields cannot be set by a reader.
///
/// What it holds is private and is whatever its source resolved; a reader
/// asks it a question (`can_read_document`) and never looks inside.
///
/// The control for the two refusals below: the same path, used as intended,
/// compiles and runs. Without it a wrong path would also "fail to compile".
///
/// ```
/// use haematite_cli::serve::access::{Access, Standalone, request};
/// let access: Result<Access, _> = request(&Standalone);
/// assert!(access.is_ok());
/// ```
///
/// ```compile_fail,E0451
/// use haematite_cli::serve::access::Access;
/// let access = Access { denied_documents: None };
/// ```
///
/// ```compile_fail,E0277
/// use haematite_cli::serve::access::Access;
/// let access: Access = Default::default();
/// ```
pub struct Access {
    denied_documents: Option<Arc<DeniedDocuments>>,
}

impl Access {
    pub(in crate::serve) fn can_read_collection(&self, collection: &str) -> bool {
        self.denied_documents
            .as_ref()
            .and_then(|denied| denied.get(collection))
            .is_none_or(BTreeSet::is_empty)
    }

    pub(in crate::serve) fn can_read_store(&self) -> bool {
        self.denied_documents
            .as_ref()
            .is_none_or(|denied| denied.values().all(BTreeSet::is_empty))
    }

    pub(in crate::serve) fn can_read_document(&self, collection: &str, id: &str) -> bool {
        self.denied_documents
            .as_ref()
            .and_then(|denied| denied.get(collection))
            .is_none_or(|documents| !documents.contains(id))
    }
}

mod sealed {
    pub trait Source {}
}

/// Resolves authority without allowing a consumer to implement a granting source.
pub trait Source: sealed::Source + Send + Sync {
    /// Resolve the authority for one request.
    ///
    /// # Errors
    /// Returns the source's named refusal when authority cannot be resolved.
    fn access(&self) -> Result<Access, Failure>;
}

/// The authority of the owner who runs the service without a directory.
pub struct Standalone;

impl sealed::Source for Standalone {}

impl Source for Standalone {
    fn access(&self) -> Result<Access, Failure> {
        Ok(Access {
            denied_documents: None,
        })
    }
}

/// Asks the source once; source failures are returned without another source.
///
/// # Errors
/// Propagates the source's refusal without substituting another source.
pub fn request(source: &dyn Source) -> Result<Access, Failure> {
    source.access()
}

#[cfg(test)]
pub(super) struct TestSource {
    denied_documents: Arc<DeniedDocuments>,
    calls: std::sync::atomic::AtomicUsize,
    unavailable: bool,
}

#[cfg(test)]
impl TestSource {
    pub(super) fn denying(documents: &[(&str, &str)]) -> Self {
        let mut denied: DeniedDocuments = BTreeMap::new();
        for (collection, id) in documents {
            denied
                .entry((*collection).to_owned())
                .or_default()
                .insert((*id).to_owned());
        }
        Self {
            denied_documents: Arc::new(denied),
            calls: std::sync::atomic::AtomicUsize::new(0),
            unavailable: false,
        }
    }

    pub(super) fn allowing_collection(mut self, collection: &str) -> Self {
        Arc::make_mut(&mut self.denied_documents).insert(collection.to_owned(), BTreeSet::new());
        self
    }

    pub(super) fn unavailable() -> Self {
        Self {
            unavailable: true,
            ..Self::denying(&[])
        }
    }

    pub(super) fn calls(&self) -> usize {
        self.calls.load(std::sync::atomic::Ordering::Relaxed)
    }
}

#[cfg(test)]
impl sealed::Source for TestSource {}

#[cfg(test)]
impl Source for TestSource {
    fn access(&self) -> Result<Access, Failure> {
        self.calls
            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
        if self.unavailable {
            return Err(Failure::new(
                "access_unavailable",
                "request authority could not be resolved",
            ));
        }
        Ok(Access {
            denied_documents: Some(Arc::clone(&self.denied_documents)),
        })
    }
}