use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[cfg(feature = "http")]
use crate::Client;
use crate::{Error, Mode, Target};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DraftRequest {
pub operation: String,
pub grant: String,
pub target: Target,
pub request_digest: String,
pub words: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DraftCreated {
pub draft: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ApprovedDraft {
pub id: String,
pub app: String,
pub grant: String,
pub target: Target,
pub request_digest: String,
pub approved_by: Vec<String>,
pub mode: Mode,
pub words: String,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ApprovedPage {
pub drafts: Vec<ApprovedDraft>,
pub next: Option<String>,
pub total: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
pub enum Execution {
Executed {
request_digest: String,
receipt_digest: String,
},
RefusedOnExecution {
request_digest: String,
refusal: String,
reason: String,
},
}
impl Execution {
fn request_digest(&self) -> &str {
match self {
Self::Executed { request_digest, .. }
| Self::RefusedOnExecution { request_digest, .. } => request_digest,
}
}
}
pub trait Executor {
type Error: std::error::Error + Send + Sync + 'static;
fn receipt(&self, draft: &str) -> Result<Option<Execution>, Self::Error>;
fn execute_and_record(&mut self, draft: &ApprovedDraft) -> Result<Execution, Self::Error>;
}
#[derive(Debug, thiserror::Error)]
pub enum ExecuteError<E: std::error::Error + 'static> {
#[error("draft_receipt_digest_mismatch")]
DigestMismatch,
#[error("draft_execution_failed")]
Product(#[source] E),
#[error("draft_contract_refused")]
Contract(#[source] Error),
}
#[derive(Debug, thiserror::Error)]
pub enum RunError<E: std::error::Error + 'static> {
#[error("draft_pull_failed")]
Pull(#[source] Error),
#[error("draft_execution_failed: {draft}")]
Execution {
draft: String,
#[source]
source: ExecuteError<E>,
},
#[error("draft_acknowledgment_failed: {draft}")]
Record {
draft: String,
receipt: Box<Execution>,
#[source]
source: Error,
},
}
#[derive(Debug)]
pub struct RunReport {
pub acknowledged: usize,
pub next: Option<String>,
}
#[must_use]
pub fn request_digest(words: &str) -> String {
let mut text = String::with_capacity(64);
let digit = |nibble: u8| {
char::from(if nibble < 10 {
b'0' + nibble
} else {
b'a' + nibble - 10
})
};
for byte in Sha256::digest(words.as_bytes()) {
text.push(digit(byte >> 4));
text.push(digit(byte & 15));
}
text
}
pub fn execute_once<E: Executor>(
draft: &ApprovedDraft,
executor: &mut E,
) -> Result<Execution, ExecuteError<E::Error>> {
approval(draft).map_err(ExecuteError::Contract)?;
validate(
&draft.grant,
&draft.target,
&draft.request_digest,
&draft.words,
)
.map_err(ExecuteError::Contract)?;
if draft.id.is_empty() || !crate::rights::audience_owns(&draft.target.kind, &draft.app) {
return Err(ExecuteError::Contract(Error::Invalid(
"draft audience or id differs",
)));
}
let receipt = match executor.receipt(&draft.id).map_err(ExecuteError::Product)? {
Some(receipt) => receipt,
None => executor
.execute_and_record(draft)
.map_err(ExecuteError::Product)?,
};
if receipt.request_digest() != draft.request_digest {
return Err(ExecuteError::DigestMismatch);
}
if let Execution::Executed { receipt_digest, .. } = &receipt
&& (receipt_digest.len() != 64
|| !receipt_digest.bytes().all(|byte| byte.is_ascii_hexdigit()))
{
return Err(ExecuteError::Contract(Error::Invalid(
"product receipt digest is malformed",
)));
}
Ok(receipt)
}
fn approval(draft: &ApprovedDraft) -> Result<(), Error> {
let distinct: std::collections::BTreeSet<_> = draft.approved_by.iter().collect();
if !matches!(
(draft.mode, draft.approved_by.len()),
(Mode::ByDraft, 1) | (Mode::ByTwo, 2)
) || distinct.len() != draft.approved_by.len()
|| draft.approved_by.iter().any(String::is_empty)
{
return Err(Error::Invalid("draft approval mode or approvers differ"));
}
Ok(())
}
fn validate(grant: &str, target: &Target, digest: &str, words: &str) -> Result<(), Error> {
Target::new(&target.kind, &target.id, &target.action)?;
if grant.is_empty() || request_digest(words) != digest {
return Err(Error::Invalid("draft words or granting receipt differ"));
}
Ok(())
}
#[cfg(feature = "http")]
impl Client {
pub async fn run_approved<E: Executor + Send>(
&self,
connector_pass: &str,
app: &str,
after: Option<&str>,
executor: &mut E,
) -> Result<RunReport, RunError<E::Error>> {
let page = self
.approved_drafts(connector_pass, app, after)
.await
.map_err(RunError::Pull)?;
let mut report = RunReport {
acknowledged: 0,
next: page.next,
};
for draft in &page.drafts {
let receipt = execute_once(draft, executor).map_err(|source| RunError::Execution {
draft: draft.id.clone(),
source,
})?;
self.record_execution(connector_pass, draft, &receipt)
.await
.map_err(|source| RunError::Record {
draft: draft.id.clone(),
receipt: Box::new(receipt),
source,
})?;
report.acknowledged += 1;
}
Ok(report)
}
pub async fn create_draft(
&self,
pass: &str,
draft: &DraftRequest,
judgment: &crate::deliberate::CheckAnswer,
) -> Result<DraftCreated, Error> {
if judgment.allowed
|| !matches!(
judgment.mode,
Some(crate::Mode::ByDraft | crate::Mode::ByTwo)
)
|| judgment.grant.as_deref() != Some(draft.grant.as_str())
|| judgment.refusal.is_some()
{
return Err(Error::Invalid("only a held-mode right may create a draft"));
}
validate(
&draft.grant,
&draft.target,
&draft.request_digest,
&draft.words,
)?;
if pass.is_empty() || draft.operation.is_empty() {
return Err(Error::Invalid("draft caller pass or operation is missing"));
}
let response = self
.http
.post(self.api_endpoint("product-drafts")?)
.bearer_auth(pass)
.json(draft)
.send()
.await
.map_err(|error| Error::Transport(Box::new(error)))?;
let created: DraftCreated = response
.error_for_status()
.map_err(|error| Error::Transport(Box::new(error)))?
.json()
.await
.map_err(|error| Error::Transport(Box::new(error)))?;
if created.draft.is_empty() {
return Err(Error::CannotAsk("draft id is missing"));
}
Ok(created)
}
pub async fn approved_drafts(
&self,
connector_pass: &str,
app: &str,
after: Option<&str>,
) -> Result<ApprovedPage, Error> {
if connector_pass.is_empty() || app.is_empty() {
return Err(Error::Invalid("draft connector or app is missing"));
}
let mut url = self.api_endpoint("product-drafts")?;
url.query_pairs_mut()
.append_pair("app", app)
.append_pair("state", "approved");
if let Some(after) = after {
url.query_pairs_mut().append_pair("after", after);
}
let response = self
.http
.get(url)
.bearer_auth(connector_pass)
.send()
.await
.map_err(|error| Error::Transport(Box::new(error)))?;
let page: ApprovedPage = response
.error_for_status()
.map_err(|error| Error::Transport(Box::new(error)))?
.json()
.await
.map_err(|error| Error::Transport(Box::new(error)))?;
if page.total < page.drafts.len()
|| after.is_some_and(|cursor| page.next.as_deref() == Some(cursor))
{
return Err(Error::CannotAsk("draft population or continuation differs"));
}
let mut ids = std::collections::BTreeSet::new();
for draft in &page.drafts {
approval(draft)?;
validate(
&draft.grant,
&draft.target,
&draft.request_digest,
&draft.words,
)?;
if draft.app != app
|| draft.id.is_empty()
|| !crate::rights::audience_owns(&draft.target.kind, app)
|| !ids.insert(&draft.id)
{
return Err(Error::CannotAsk("approved draft belongs to another app"));
}
}
Ok(page)
}
pub async fn record_execution(
&self,
connector_pass: &str,
draft: &ApprovedDraft,
receipt: &Execution,
) -> Result<(), Error> {
if connector_pass.is_empty() || receipt.request_digest() != draft.request_digest {
return Err(Error::Invalid("execution receipt differs from draft"));
}
let route = match receipt {
Execution::Executed { .. } => "executed",
Execution::RefusedOnExecution { .. } => "refused-on-execution",
};
if draft.id.is_empty() {
return Err(Error::Invalid("draft id is missing"));
}
let mut url = self.api_endpoint("product-drafts/")?;
url.path_segments_mut()
.map_err(|()| Error::Invalid("issuer cannot hold draft paths"))?
.pop_if_empty()
.push(&draft.id)
.push(route);
let body = match receipt {
Execution::Executed { receipt_digest, .. } => {
if receipt_digest.len() != 64
|| !receipt_digest.bytes().all(|byte| byte.is_ascii_hexdigit())
{
return Err(Error::Invalid("execution receipt digest is malformed"));
}
serde_json::json!({ "receipt_digest": receipt_digest })
}
Execution::RefusedOnExecution {
refusal, reason, ..
} => {
if refusal.is_empty() || reason.is_empty() {
return Err(Error::Invalid(
"execution refusal needs a name and a reason",
));
}
serde_json::json!({ "refusal": refusal, "reason": reason })
}
};
self.http
.post(url)
.bearer_auth(connector_pass)
.json(&body)
.send()
.await
.map_err(|error| Error::Transport(Box::new(error)))?
.error_for_status()
.map_err(|error| Error::Transport(Box::new(error)))?;
Ok(())
}
}