#![forbid(unsafe_code)]
use std::future::Future;
use std::pin::Pin;
use serde::Serialize;
use serde::de::DeserializeOwned;
#[allow(
clippy::all,
clippy::pedantic,
missing_docs,
unused_imports,
irrefutable_let_patterns
)]
pub mod types {
include!(concat!(env!("OUT_DIR"), "/types.rs"));
}
#[allow(clippy::all, clippy::pedantic)]
mod catalog {
use crate::{
CliSpelling, ConfirmGate, HttpMethod, MediaType, OperationSpec, ParamLocation, ParamSpec,
};
include!(concat!(env!("OUT_DIR"), "/catalog.rs"));
}
pub use catalog::{CATALOG, OperationId};
#[allow(clippy::all, clippy::pedantic)]
pub mod ops {
include!(concat!(env!("OUT_DIR"), "/ops.rs"));
}
pub use ops::{EngenhoControl, OperationVisitor, visit};
pub use types::{AuthorityTier, ReinitOp};
#[cfg(feature = "tls")]
pub mod pin;
pub const SPEC_YAML: &str = include_str!("../../spec/engenho-control.openapi.yaml");
pub const API_VERSION: &str = "engenho.control/v1";
pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum HttpMethod {
Get,
Put,
Post,
Delete,
Patch,
}
impl HttpMethod {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Get => "GET",
Self::Put => "PUT",
Self::Post => "POST",
Self::Delete => "DELETE",
Self::Patch => "PATCH",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ConfirmGate {
None,
Executes(ReinitOp),
Issue,
Cancel,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct CliSpelling {
pub resource: &'static str,
pub verb: &'static str,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OperationSpec {
pub id: OperationId,
pub method: HttpMethod,
pub path: &'static str,
pub tag: &'static str,
pub tier: AuthorityTier,
pub sensitive: bool,
pub gate: ConfirmGate,
pub cli: CliSpelling,
pub success_status: u16,
pub params: &'static [ParamSpec],
pub body: bool,
pub response_media: MediaType,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ParamLocation {
Path,
Query,
Header,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ParamSpec {
pub name: &'static str,
pub location: ParamLocation,
pub required: bool,
pub kind: wire::Kind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum MediaType {
Json,
Yaml,
}
impl MediaType {
#[must_use]
pub const fn content_type(self) -> &'static str {
match self {
Self::Json => "application/json",
Self::Yaml => "application/yaml",
}
}
}
impl OperationId {
#[must_use]
pub fn route(method: &str, path: &str) -> Option<(Self, Vec<(String, String)>)> {
let segments: Vec<&str> = path.trim_end_matches('/').split('/').collect();
CATALOG.iter().find_map(|row| {
if !row.method.as_str().eq_ignore_ascii_case(method) {
return None;
}
let template: Vec<&str> = row.path.split('/').collect();
if template.len() != segments.len() {
return None;
}
let mut params = Vec::new();
for (want, got) in template.iter().zip(&segments) {
match want.strip_prefix('{').and_then(|w| w.strip_suffix('}')) {
Some(name) => params.push((name.to_string(), wire::decode_segment(got)?)),
None if want == got => {}
None => return None,
}
}
Some((row.id, params))
})
}
}
pub trait Operation: Send + Sync + 'static {
const ID: OperationId;
type Request: wire::OperationRequest + Send + 'static;
type Response: Serialize + DeserializeOwned + Send + 'static;
fn invoke<'a>(
ctl: &'a dyn EngenhoControl,
by: &'a Principal,
req: Self::Request,
) -> BoxFuture<'a, Result<Self::Response, ControlError>>;
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Principal {
attested: types::AttestedView,
declared: types::DeclaredView,
grant: types::GrantView,
}
impl Principal {
#[must_use]
pub fn mint(
attested: types::AttestedView,
declared: types::DeclaredView,
grant: types::GrantView,
) -> Self {
Self {
attested,
declared,
grant,
}
}
#[must_use]
pub fn attested(&self) -> &types::AttestedView {
&self.attested
}
#[must_use]
pub fn declared(&self) -> &types::DeclaredView {
&self.declared
}
#[must_use]
pub fn grant(&self) -> &types::GrantView {
&self.grant
}
#[must_use]
pub fn effective(&self) -> AuthorityTier {
self.grant.effective
}
#[must_use]
pub fn view(&self) -> types::PrincipalView {
types::PrincipalView {
attested: self.attested.clone(),
declared: self.declared.clone(),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum ControlError {
Refused(types::Refusal),
Blind(types::Blind),
}
impl ControlError {
#[must_use]
pub fn refused(reason: types::RefusalReason, because: impl Into<String>) -> Self {
Self::Refused(types::Refusal {
outcome: types::RefusalOutcome::Refused,
reason,
because: because.into(),
legal: Vec::new(),
})
}
#[must_use]
pub fn refused_with(
reason: types::RefusalReason,
because: impl Into<String>,
legal: Vec<String>,
) -> Self {
Self::Refused(types::Refusal {
outcome: types::RefusalOutcome::Refused,
reason,
because: because.into(),
legal,
})
}
#[must_use]
pub fn blind(reason: types::BlindReason, because: impl Into<String>) -> Self {
Self::Blind(types::Blind {
outcome: types::BlindOutcome::Blind,
reason,
because: because.into(),
})
}
#[must_use]
pub const fn status(&self) -> u16 {
use types::RefusalReason as R;
match self {
Self::Blind(_) => 503,
Self::Refused(r) => match r.reason {
R::UnknownOperation => 404,
R::InsufficientAuthority
| R::ConfirmationRequired
| R::ConfirmationMismatch
| R::ConfirmationExpired => 403,
R::PreconditionFailed
| R::RuntimeRunning
| R::RuntimeNotRunning
| R::RuntimeNotFailed
| R::RuntimeNotStopped
| R::LifecycleBusy
| R::Wedged
| R::RespawnRefused => 409,
R::UnknownLeaf
| R::NotOverridableLeaf
| R::InvalidValue
| R::ConfigRejected
| R::Unsupported => 422,
},
}
}
}
impl std::fmt::Display for ControlError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Refused(r) => write!(f, "refused ({}): {}", r.reason, r.because),
Self::Blind(b) => write!(f, "blind ({}): {}", b.reason, b.because),
}
}
}
impl std::error::Error for ControlError {}
pub mod wire {
use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::Value;
use crate::HttpMethod;
pub trait OperationRequest: Sized {
fn to_http(&self) -> HttpRequest;
fn from_http(parts: &HttpParts) -> Result<Self, BadRequest>;
}
#[derive(Debug, Clone, PartialEq)]
pub struct HttpRequest {
pub method: HttpMethod,
pub path: String,
pub query: Vec<(&'static str, String)>,
pub headers: Vec<(&'static str, String)>,
pub body: Option<Value>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct HttpParts {
pub path_params: Vec<(String, String)>,
pub query: Vec<(String, String)>,
pub headers: Vec<(String, String)>,
pub body: Option<Value>,
}
impl HttpParts {
#[must_use]
pub fn path_param(&self, name: &str) -> Option<&str> {
self.path_params
.iter()
.find(|(k, _)| k == name)
.map(|(_, v)| v.as_str())
}
#[must_use]
pub fn query_param(&self, name: &str) -> Option<&str> {
self.query
.iter()
.find(|(k, _)| k == name)
.map(|(_, v)| v.as_str())
}
#[must_use]
pub fn header(&self, name: &str) -> Option<&str> {
self.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(name))
.map(|(_, v)| v.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BadRequest {
Missing(&'static str),
Parameter {
name: &'static str,
detail: String,
},
Body(String),
}
impl std::fmt::Display for BadRequest {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Missing(name) => write!(f, "missing required {name}"),
Self::Parameter { name, detail } => write!(f, "parameter {name}: {detail}"),
Self::Body(detail) => write!(f, "body: {detail}"),
}
}
}
impl std::error::Error for BadRequest {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Kind {
Text,
Integer,
Boolean,
}
#[must_use]
pub fn render<T: Serialize>(v: &T) -> String {
match serde_json::to_value(v).expect("spec parameter types serialize") {
Value::String(s) => s,
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
other => other.to_string(),
}
}
pub fn parse<T: DeserializeOwned>(
name: &'static str,
kind: Kind,
raw: &str,
) -> Result<T, BadRequest> {
let bad = |detail: String| BadRequest::Parameter { name, detail };
let value = match kind {
Kind::Text => Value::String(raw.to_string()),
Kind::Integer => {
Value::Number(raw.parse::<u64>().map_err(|e| bad(e.to_string()))?.into())
}
Kind::Boolean => Value::Bool(raw.parse::<bool>().map_err(|e| bad(e.to_string()))?),
};
serde_json::from_value(value).map_err(|e| bad(e.to_string()))
}
#[must_use]
pub fn decode_segment(s: &str) -> Option<String> {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' {
let hex = s.get(i + 1..i + 3)?;
out.push(u8::from_str_radix(hex, 16).ok()?);
i += 3;
} else {
out.push(bytes[i]);
i += 1;
}
}
String::from_utf8(out).ok()
}
#[must_use]
pub fn encode_segment(s: &str) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
out.push(char::from(b));
} else {
let _ = write!(out, "%{b:02X}");
}
}
out
}
}