shimpz-cli 0.1.9

Fast local tooling for Shimpz Assistants
//! Install one published Assistant by immutable source hash.

use std::time::Duration;

use serde::{Deserialize, Serialize};
use ureq::{Agent, Body, http::Response};
use zeroize::Zeroizing;

use crate::auth;

const TEAMS_URL: &str = "https://developers.shimpz.com/api/v1/teams";
const INSTALLATIONS_URL: &str = "https://developers.shimpz.com/api/v1/installations";
const REQUIRED_SCOPE: &str = "assistant:install";
const REQUEST_TIMEOUT: Duration = Duration::from_mins(5);
const MAX_RESPONSE_BYTES: u64 = 64 * 1024;

pub(crate) fn run(source_digest: &str, selected_team: Option<&str>) -> Result<String, String> {
    let credentials = auth::ensure_authenticated(REQUIRED_SCOPE)?;
    let api = Api::new();
    let team = match selected_team {
        Some(team) => team.to_owned(),
        None => select_team(&api.teams(&credentials)?)?,
    };
    let installed = api.install(&credentials, &team, source_digest)?;
    installed.summary()
}

fn select_team(response: &TeamList) -> Result<String, String> {
    response.validate()?;
    match response.teams.as_slice() {
        [] => Err("No Team is available. Create a Team before installing an Assistant.".into()),
        [team] => Ok(team.id.clone()),
        teams => {
            let choices = teams
                .iter()
                .map(|team| format!("  {}  {}", team.id, team.name))
                .collect::<Vec<_>>()
                .join("\n");
            Err(format!(
                "More than one Team is available. Choose one with --team:\n{choices}"
            ))
        }
    }
}

struct Api {
    agent: Agent,
}

impl Api {
    fn new() -> Self {
        let config = Agent::config_builder()
            .timeout_global(Some(REQUEST_TIMEOUT))
            .max_redirects(0)
            .http_status_as_error(false)
            .build();
        Self {
            agent: config.into(),
        }
    }

    fn teams(&self, credentials: &crate::credentials::Credentials) -> Result<TeamList, String> {
        let authorization = Zeroizing::new(format!("Bearer {}", credentials.access_token()));
        let mut response = self
            .agent
            .get(TEAMS_URL)
            .header("Accept", "application/json")
            .header("Authorization", authorization.as_str())
            .call()
            .map_err(|_| unavailable())?;
        if response.status().as_u16() != 200 {
            return Err(status_error(&mut response, "Teams are unavailable"));
        }
        read_json(&mut response)
    }

    fn install(
        &self,
        credentials: &crate::credentials::Credentials,
        team_id: &str,
        source_digest: &str,
    ) -> Result<Installed, String> {
        let authorization = Zeroizing::new(format!("Bearer {}", credentials.access_token()));
        let mut response = self
            .agent
            .post(INSTALLATIONS_URL)
            .header("Accept", "application/json")
            .header("Authorization", authorization.as_str())
            .send_json(InstallRequest {
                team_id,
                source_digest,
            })
            .map_err(|_| unavailable())?;
        if response.status().as_u16() != 200 {
            return Err(status_error(
                &mut response,
                "Assistant installation was rejected",
            ));
        }
        let installed: Installed = read_json(&mut response)?;
        installed.validate(team_id, source_digest)?;
        Ok(installed)
    }
}

fn read_json<T: for<'de> Deserialize<'de>>(response: &mut Response<Body>) -> Result<T, String> {
    if response
        .headers()
        .get("Content-Type")
        .and_then(|value| value.to_str().ok())
        != Some("application/json")
    {
        return Err("Developers returned an invalid installation response".into());
    }
    response
        .body_mut()
        .with_config()
        .limit(MAX_RESPONSE_BYTES)
        .read_json()
        .map_err(|_| "Developers returned an invalid installation response".into())
}

fn status_error(response: &mut Response<Body>, fallback: &'static str) -> String {
    if response
        .headers()
        .get("Content-Type")
        .and_then(|value| value.to_str().ok())
        != Some("application/json")
    {
        return fallback.into();
    }
    response
        .body_mut()
        .with_config()
        .limit(MAX_RESPONSE_BYTES)
        .read_json::<ErrorEnvelope>()
        .ok()
        .filter(|envelope| envelope.error.valid())
        .map_or_else(|| fallback.into(), |envelope| envelope.error.message)
}

#[derive(Serialize)]
struct InstallRequest<'a> {
    team_id: &'a str,
    source_digest: &'a str,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct TeamList {
    version: u8,
    teams: Vec<Team>,
}

impl TeamList {
    fn validate(&self) -> Result<(), String> {
        if self.version != 1
            || self.teams.len() > 128
            || self.teams.iter().any(|team| !team.valid())
        {
            return Err("Developers returned an invalid Team list".into());
        }
        Ok(())
    }
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Team {
    id: String,
    name: String,
}

impl Team {
    fn valid(&self) -> bool {
        valid_team_id(&self.id)
            && !self.name.is_empty()
            && self.name.len() <= 80
            && self.name.chars().all(|character| !character.is_control())
    }
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Installed {
    version: u8,
    status: String,
    team_id: String,
    assistant_id: String,
    source_digest: String,
    oci_digest: String,
    binding_digest: String,
}

impl Installed {
    fn validate(&self, team_id: &str, source_digest: &str) -> Result<(), String> {
        if self.version != 1
            || self.status != "installed"
            || self.team_id != team_id
            || self.source_digest != source_digest
            || !valid_assistant_id(&self.assistant_id)
            || !valid_digest(&self.oci_digest)
            || !valid_digest(&self.binding_digest)
        {
            return Err("Developers returned an invalid installation response".into());
        }
        Ok(())
    }

    fn summary(&self) -> Result<String, String> {
        self.validate(&self.team_id, &self.source_digest)?;
        Ok(format!(
            "Assistant installed.\nAssistant: {}\nTeam: {}\nSource: {}\nImage digest: {}\nBinding: {}",
            self.assistant_id,
            self.team_id,
            self.source_digest,
            self.oci_digest,
            self.binding_digest
        ))
    }
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ErrorEnvelope {
    error: ErrorBody,
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ErrorBody {
    code: String,
    message: String,
    request_id: String,
}

impl ErrorBody {
    fn valid(&self) -> bool {
        !self.code.is_empty()
            && self.code.len() <= 64
            && self
                .code
                .bytes()
                .all(|byte| byte.is_ascii_lowercase() || byte == b'_')
            && !self.message.is_empty()
            && self.message.len() <= 256
            && !self.request_id.is_empty()
            && self.request_id.len() <= 64
    }
}

fn valid_team_id(value: &str) -> bool {
    !value.is_empty()
        && value.len() <= 40
        && value
            .bytes()
            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
}

fn valid_assistant_id(value: &str) -> bool {
    !value.is_empty()
        && value.len() <= 40
        && value.starts_with(|character: char| character.is_ascii_lowercase())
        && value
            .bytes()
            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
        && !value.ends_with('-')
        && !value.contains("--")
}

fn valid_digest(value: &str) -> bool {
    value.len() == 71
        && value.starts_with("sha256:")
        && value[7..]
            .bytes()
            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
}

fn unavailable() -> String {
    "Developers is unavailable; check your connection and try again".into()
}

#[cfg(test)]
mod tests {
    use super::{Installed, Team, TeamList, select_team};

    #[test]
    fn one_team_is_selected_without_an_extra_decision() {
        assert_eq!(
            select_team(&TeamList {
                version: 1,
                teams: vec![Team {
                    id: "team_1".into(),
                    name: "First Team".into(),
                }],
            }),
            Ok("team_1".into())
        );
    }

    #[test]
    fn multiple_teams_require_an_explicit_stable_id() {
        let error = select_team(&TeamList {
            version: 1,
            teams: vec![
                Team {
                    id: "team_1".into(),
                    name: "First Team".into(),
                },
                Team {
                    id: "team_2".into(),
                    name: "Second Team".into(),
                },
            ],
        })
        .unwrap_err();

        assert!(error.contains("--team"));
        assert!(error.contains("team_1"));
        assert!(error.contains("team_2"));
    }

    #[test]
    fn installation_response_is_bound_to_the_request() {
        let digest = format!("sha256:{}", "a".repeat(64));
        let installed = Installed {
            version: 1,
            status: "installed".into(),
            team_id: "team_1".into(),
            assistant_id: "hello-world".into(),
            source_digest: digest.clone(),
            oci_digest: format!("sha256:{}", "b".repeat(64)),
            binding_digest: format!("sha256:{}", "c".repeat(64)),
        };

        assert!(installed.validate("team_1", &digest).is_ok());
        assert!(installed.validate("team_2", &digest).is_err());
    }
}