haematite-cli 0.33.0

The haematite command: serve a store, its door and Studio, answer MCP, query with jq, and host the store's assistant seat
Documentation
//! The person at the keyboard, on a door with no lys block (ACCESS-003 R2).
//!
//! A loopback address proves nothing: any process on the machine can reach
//! it. So the door makes a key of 32 random bytes when it starts and prints
//! one line to its own standard output, the origin's `/local` with the key
//! as its query. Whoever opens that link is at the door's terminal. The key
//! is held only in this process's memory, written nowhere else, compared in
//! constant time, and spent by its first use: a second use, a wrong key and
//! no key are refused in the same words. The session it opens names the
//! local owner of the door's process, has no expiry while the door runs and
//! ends when the door stops or the person signs out.

use std::collections::BTreeSet;
use std::sync::Mutex;

use lys_pass::Error;
use serde_json::{Value, json};

use super::session::{SignedIn, Started, lock, session_id};
use super::signin::Issuer;
use crate::serve::contracts::Caller;
use crate::serve::storage::Failure;

/// The one refusal of `/local`, the same for a spent, a wrong and a missing
/// key, so a refusal says nothing about the key.
pub(super) fn refused() -> Failure {
    Failure::new(
        "door_local_key_refused",
        "this link does not open Studio; use the link the door printed when it started",
    )
}

/// Studio's sign-in on a door with no lys block.
pub struct Local {
    /// The local owner of the door's process.
    owner: Caller,
    /// The printed key until its first use.
    key: Mutex<Option<String>>,
    /// The sessions it opened, while the door runs.
    sessions: Mutex<BTreeSet<String>>,
}

impl Local {
    /// A sign-in for `owner`, with a new key: the key is answered once, for
    /// the door to print, and kept only here.
    ///
    /// # Errors
    ///
    /// `door_random_unavailable` when the system's random source cannot be read.
    pub fn new(owner: Caller) -> Result<(Self, String), Failure> {
        let key = session_id()?;
        Ok((
            Self {
                owner,
                key: Mutex::new(Some(key.clone())),
                sessions: Mutex::new(BTreeSet::new()),
            },
            key,
        ))
    }

    /// Who is at the keyboard.
    #[must_use]
    pub const fn owner(&self) -> &Caller {
        &self.owner
    }

    /// Open a session with `offered`, spending the key; answers its id.
    ///
    /// # Errors
    ///
    /// [`refused`] for a spent, wrong or missing key; `door_poisoned`, or
    /// `door_random_unavailable` before the key is spent.
    pub fn open(&self, offered: Option<&str>) -> Result<String, Failure> {
        let id = session_id()?;
        let mut key = lock(&self.key)?;
        let matches = key
            .as_deref()
            .zip(offered)
            .is_some_and(|(held, offered)| same(held.as_bytes(), offered.as_bytes()));
        if !matches {
            return Err(refused());
        }
        *key = None;
        drop(key);
        lock(&self.sessions)?.insert(id.clone());
        Ok(id)
    }

    /// Whether `id` is a session this door opened and has not ended.
    ///
    /// # Errors
    ///
    /// `door_poisoned`.
    pub fn holds(&self, id: &str) -> Result<bool, Failure> {
        Ok(lock(&self.sessions)?.contains(id))
    }

    /// The session line for `id`: whose it is, and that it is local.
    ///
    /// # Errors
    ///
    /// `session_unknown`, or `door_poisoned`.
    pub fn session(&self, id: &str) -> Result<Value, Failure> {
        if !self.holds(id)? {
            return Err(Failure::new("session_unknown", "sign in to Studio first"));
        }
        Ok(json!({"holder": self.owner.subject(), "local": true}))
    }

    /// End session `id`, answering whether it was held.
    ///
    /// # Errors
    ///
    /// `door_poisoned`.
    pub fn end(&self, id: &str) -> Result<bool, Failure> {
        Ok(lock(&self.sessions)?.remove(id))
    }
}

/// The issuer of a door with no lys block: it signs nobody in through Lys,
/// so every sign-in step is refused by name. The door's routes refuse
/// `/signin` and `/callback` before it is reached.
pub struct NoLys;

impl Issuer for NoLys {
    fn start(&self) -> Result<Started, Error> {
        Err(Error::Invalid(NO_LYS))
    }

    fn finish(&self, _: &str, _: &str, _: &str, _: &Started) -> Result<SignedIn, Error> {
        Err(Error::Invalid(NO_LYS))
    }

    fn refresh(&self, _: &str) -> Result<SignedIn, Error> {
        Err(Error::Invalid(NO_LYS))
    }
}

const NO_LYS: &str = "this door has no lys block";

/// The link the door prints at its start: `origin`'s `/local` with `key`.
#[must_use]
pub fn link(origin: &str, key: &str) -> String {
    format!("{origin}/local?key={key}")
}

/// Whether `held` and `offered` are equal, in time that depends on their
/// lengths alone: every byte is compared whatever the first difference.
fn same(held: &[u8], offered: &[u8]) -> bool {
    if held.len() != offered.len() {
        return false;
    }
    held.iter()
        .zip(offered)
        .fold(0_u8, |differs, (left, right)| differs | (left ^ right))
        == 0
}

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