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
//! The door's sign-in helper: lys-pass's, run to its end on a runtime owned
//! here, because the door's connections are synchronous (ACCESS-002 R2).
//!
//! The app's issued credential is read once from the file the configuration
//! names, handed to lys-pass and never printed, logged or sent anywhere but
//! Lys's token endpoint. A failure to read it says which file, never what
//! was in it.

use std::io::Read;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};

use lys_pass::Error;
use lys_pass::signin::{Secret, SignIn};

use super::config::{Lys, SignInBounds};
use super::signin::{Issuer, SignedIn, Started};
use crate::serve::pass::AUDIENCE;

/// The largest credential file read, in bytes.
const SECRET_BYTES: u64 = 4096;

/// lys-pass's sign-in helper for one Lys app.
pub struct LysSignIn {
    sign_in: SignIn,
    runtime: tokio::runtime::Runtime,
    bounds: SignInBounds,
    assistant: super::assistant_lys::Client,
}

impl LysSignIn {
    /// Read the credential `lys` names and the discovery document of its
    /// issuer.
    ///
    /// # Errors
    ///
    /// Names the credential file when it cannot be read or is not one line;
    /// otherwise lys-pass's refusal by name, `lys_could_not_be_asked` when
    /// the issuer cannot be reached.
    pub fn connect(lys: &Lys) -> Result<Self, String> {
        let bounds = SignInBounds::from_env()?;
        let mut connected = Self::connect_with_timeouts(
            lys,
            std::time::Duration::from_millis(bounds.connect_ms.get()),
            std::time::Duration::from_millis(bounds.total_ms.get()),
        )?;
        connected.bounds = bounds;
        Ok(connected)
    }

    fn connect_with_timeouts(
        lys: &Lys,
        connect: std::time::Duration,
        total: std::time::Duration,
    ) -> Result<Self, String> {
        let secret = read_secret(&lys.secret_file)?;
        let issuer = url::Url::parse(&lys.issuer)
            .map_err(|_| format!("the lys issuer {} is not an address", lys.issuer))?;
        let redirect = url::Url::parse(&lys.redirect)
            .map_err(|_| format!("the lys redirect {} is not an address", lys.redirect))?;
        let runtime = tokio::runtime::Builder::new_current_thread()
            .enable_all()
            .build()
            .map_err(|error| format!("the door's sign-in runtime could not start: {error}"))?;
        let http = reqwest::Client::builder()
            .connect_timeout(connect)
            .timeout(total)
            .redirect(reqwest::redirect::Policy::none())
            .no_proxy()
            .build()
            .map_err(|_| {
                "lys_could_not_be_asked: the Lys HTTP client could not start".to_owned()
            })?;
        let sign_in = runtime
            .block_on(SignIn::connect(
                http.clone(),
                &issuer,
                AUDIENCE,
                &lys.client_id,
                secret,
                redirect,
            ))
            .map_err(|error| format!("{}: the lys issuer {}", error.name(), lys.issuer))?;
        Ok(Self {
            sign_in,
            runtime,
            bounds: SignInBounds::default(),
            assistant: super::assistant_lys::Client {
                issuer: lys.issuer.clone(),
                http,
            },
        })
    }
}

impl Issuer for LysSignIn {
    fn assistant_identity(
        &self,
        agent: &str,
        pass: &str,
        store: &str,
    ) -> Result<String, crate::serve::storage::Failure> {
        self.runtime
            .block_on(self.assistant.identity(agent, pass, store, now()))
    }
    fn assistant_draft(
        &self,
        pass: &str,
        draft: &str,
        within: std::time::Duration,
    ) -> Result<serde_json::Value, crate::serve::storage::Failure> {
        self.runtime
            .block_on(self.assistant.draft(pass, draft, within))
    }
    fn assistant_decide(
        &self,
        pass: &str,
        draft: &serde_json::Value,
        decision: &str,
        within: std::time::Duration,
    ) -> Result<serde_json::Value, crate::serve::storage::Failure> {
        self.runtime
            .block_on(self.assistant.decide_held(pass, draft, decision, within))
    }
    fn assistant_url(&self) -> Result<&str, crate::serve::storage::Failure> {
        Ok(&self.assistant.issuer)
    }
    fn bounds(&self) -> SignInBounds {
        self.bounds
    }
    fn start(&self) -> Result<Started, Error> {
        self.sign_in.start()
    }

    fn finish(
        &self,
        code: &str,
        state: &str,
        browser: &str,
        started: &Started,
    ) -> Result<SignedIn, Error> {
        self.runtime
            .block_on(self.sign_in.finish(code, state, browser, started, now()))
    }

    fn refresh(&self, refresh: &str) -> Result<SignedIn, Error> {
        self.runtime.block_on(self.sign_in.refresh(refresh, now()))
    }
}

/// The credential in `path`: its one line, without the newline a file
/// written by hand ends with.
fn read_secret(path: &Path) -> Result<Secret, String> {
    let unreadable = || format!("the lys secret_file {} could not be read", path.display());
    let file = std::fs::File::open(path).map_err(|_| unreadable())?;
    let mut text = String::new();
    file.take(SECRET_BYTES + 1)
        .read_to_string(&mut text)
        .map_err(|_| unreadable())?;
    if u64::try_from(text.len()).unwrap_or(u64::MAX) > SECRET_BYTES {
        return Err(format!(
            "the lys secret_file {} is longer than a credential",
            path.display()
        ));
    }
    let line = text.strip_suffix('\n').unwrap_or(&text).to_owned();
    Secret::new(line).map_err(|_| {
        format!(
            "the lys secret_file {} is not one line of credential",
            path.display()
        )
    })
}

/// The present instant in Unix seconds; a clock before 1970 reads as 0, at
/// which every pass is refused as not yet valid.
fn now() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map_or(0, |elapsed| elapsed.as_secs())
}

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

#[cfg(test)]
#[path = "lys_deadline_tests.rs"]
mod deadline_tests;