1use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5
6#[cfg(feature = "http")]
7use crate::Client;
8use crate::{Error, Mode, Target};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12#[serde(deny_unknown_fields)]
13pub struct DraftRequest {
14 pub operation: String,
16 pub grant: String,
18 pub target: Target,
20 pub request_digest: String,
22 pub words: String,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
28#[serde(deny_unknown_fields)]
29pub struct DraftCreated {
30 pub draft: String,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
36#[serde(deny_unknown_fields)]
37pub struct ApprovedDraft {
38 pub id: String,
40 pub app: String,
42 pub grant: String,
44 pub target: Target,
46 pub request_digest: String,
48 pub approved_by: Vec<String>,
50 pub mode: Mode,
52 pub words: String,
54}
55
56#[derive(Debug, Deserialize)]
58#[serde(deny_unknown_fields)]
59pub struct ApprovedPage {
60 pub drafts: Vec<ApprovedDraft>,
62 pub next: Option<String>,
64 pub total: usize,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
71pub enum Execution {
72 Executed {
74 request_digest: String,
76 receipt_digest: String,
78 },
79 RefusedOnExecution {
81 request_digest: String,
83 refusal: String,
85 reason: String,
87 },
88}
89
90impl Execution {
91 fn request_digest(&self) -> &str {
92 match self {
93 Self::Executed { request_digest, .. }
94 | Self::RefusedOnExecution { request_digest, .. } => request_digest,
95 }
96 }
97}
98
99pub trait Executor {
101 type Error: std::error::Error + Send + Sync + 'static;
103 fn receipt(&self, draft: &str) -> Result<Option<Execution>, Self::Error>;
110 fn execute_and_record(&mut self, draft: &ApprovedDraft) -> Result<Execution, Self::Error>;
118}
119
120#[derive(Debug, thiserror::Error)]
122pub enum ExecuteError<E: std::error::Error + 'static> {
123 #[error("draft_receipt_digest_mismatch")]
125 DigestMismatch,
126 #[error("draft_execution_failed")]
128 Product(#[source] E),
129 #[error("draft_contract_refused")]
131 Contract(#[source] Error),
132}
133
134#[derive(Debug, thiserror::Error)]
136pub enum RunError<E: std::error::Error + 'static> {
137 #[error("draft_pull_failed")]
139 Pull(#[source] Error),
140 #[error("draft_execution_failed: {draft}")]
142 Execution {
143 draft: String,
145 #[source]
147 source: ExecuteError<E>,
148 },
149 #[error("draft_acknowledgment_failed: {draft}")]
151 Record {
152 draft: String,
154 receipt: Box<Execution>,
156 #[source]
158 source: Error,
159 },
160}
161
162#[derive(Debug)]
164pub struct RunReport {
165 pub acknowledged: usize,
167 pub next: Option<String>,
169}
170
171#[must_use]
173pub fn request_digest(words: &str) -> String {
174 let mut text = String::with_capacity(64);
175 let digit = |nibble: u8| {
176 char::from(if nibble < 10 {
177 b'0' + nibble
178 } else {
179 b'a' + nibble - 10
180 })
181 };
182 for byte in Sha256::digest(words.as_bytes()) {
183 text.push(digit(byte >> 4));
184 text.push(digit(byte & 15));
185 }
186 text
187}
188
189pub fn execute_once<E: Executor>(
199 draft: &ApprovedDraft,
200 executor: &mut E,
201) -> Result<Execution, ExecuteError<E::Error>> {
202 approval(draft).map_err(ExecuteError::Contract)?;
203 validate(
204 &draft.grant,
205 &draft.target,
206 &draft.request_digest,
207 &draft.words,
208 )
209 .map_err(ExecuteError::Contract)?;
210 if draft.id.is_empty() || !crate::rights::audience_owns(&draft.target.kind, &draft.app) {
211 return Err(ExecuteError::Contract(Error::Invalid(
212 "draft audience or id differs",
213 )));
214 }
215 let receipt = match executor.receipt(&draft.id).map_err(ExecuteError::Product)? {
216 Some(receipt) => receipt,
217 None => executor
218 .execute_and_record(draft)
219 .map_err(ExecuteError::Product)?,
220 };
221 if receipt.request_digest() != draft.request_digest {
222 return Err(ExecuteError::DigestMismatch);
223 }
224 if let Execution::Executed { receipt_digest, .. } = &receipt
225 && (receipt_digest.len() != 64
226 || !receipt_digest.bytes().all(|byte| byte.is_ascii_hexdigit()))
227 {
228 return Err(ExecuteError::Contract(Error::Invalid(
229 "product receipt digest is malformed",
230 )));
231 }
232 Ok(receipt)
233}
234
235fn approval(draft: &ApprovedDraft) -> Result<(), Error> {
236 let distinct: std::collections::BTreeSet<_> = draft.approved_by.iter().collect();
237 if !matches!(
238 (draft.mode, draft.approved_by.len()),
239 (Mode::ByDraft, 1) | (Mode::ByTwo, 2)
240 ) || distinct.len() != draft.approved_by.len()
241 || draft.approved_by.iter().any(String::is_empty)
242 {
243 return Err(Error::Invalid("draft approval mode or approvers differ"));
244 }
245 Ok(())
246}
247
248fn validate(grant: &str, target: &Target, digest: &str, words: &str) -> Result<(), Error> {
249 Target::new(&target.kind, &target.id, &target.action)?;
250 if grant.is_empty() || request_digest(words) != digest {
251 return Err(Error::Invalid("draft words or granting receipt differ"));
252 }
253 Ok(())
254}
255
256#[cfg(feature = "http")]
257impl Client {
258 pub async fn run_approved<E: Executor + Send>(
267 &self,
268 connector_pass: &str,
269 app: &str,
270 after: Option<&str>,
271 executor: &mut E,
272 ) -> Result<RunReport, RunError<E::Error>> {
273 let page = self
274 .approved_drafts(connector_pass, app, after)
275 .await
276 .map_err(RunError::Pull)?;
277 let mut report = RunReport {
278 acknowledged: 0,
279 next: page.next,
280 };
281 for draft in &page.drafts {
282 let receipt = execute_once(draft, executor).map_err(|source| RunError::Execution {
283 draft: draft.id.clone(),
284 source,
285 })?;
286 self.record_execution(connector_pass, draft, &receipt)
287 .await
288 .map_err(|source| RunError::Record {
289 draft: draft.id.clone(),
290 receipt: Box::new(receipt),
291 source,
292 })?;
293 report.acknowledged += 1;
294 }
295 Ok(report)
296 }
297
298 pub async fn create_draft(
308 &self,
309 pass: &str,
310 draft: &DraftRequest,
311 judgment: &crate::deliberate::CheckAnswer,
312 ) -> Result<DraftCreated, Error> {
313 if judgment.allowed
314 || !matches!(
315 judgment.mode,
316 Some(crate::Mode::ByDraft | crate::Mode::ByTwo)
317 )
318 || judgment.grant.as_deref() != Some(draft.grant.as_str())
319 || judgment.refusal.is_some()
320 {
321 return Err(Error::Invalid("only a held-mode right may create a draft"));
322 }
323 validate(
324 &draft.grant,
325 &draft.target,
326 &draft.request_digest,
327 &draft.words,
328 )?;
329 if pass.is_empty() || draft.operation.is_empty() {
330 return Err(Error::Invalid("draft caller pass or operation is missing"));
331 }
332 let response = self
333 .http
334 .post(self.api_endpoint("product-drafts")?)
335 .bearer_auth(pass)
336 .json(draft)
337 .send()
338 .await
339 .map_err(|error| Error::Transport(Box::new(error)))?;
340 let created: DraftCreated = response
341 .error_for_status()
342 .map_err(|error| Error::Transport(Box::new(error)))?
343 .json()
344 .await
345 .map_err(|error| Error::Transport(Box::new(error)))?;
346 if created.draft.is_empty() {
347 return Err(Error::CannotAsk("draft id is missing"));
348 }
349 Ok(created)
350 }
351
352 pub async fn approved_drafts(
363 &self,
364 connector_pass: &str,
365 app: &str,
366 after: Option<&str>,
367 ) -> Result<ApprovedPage, Error> {
368 if connector_pass.is_empty() || app.is_empty() {
369 return Err(Error::Invalid("draft connector or app is missing"));
370 }
371 let mut url = self.api_endpoint("product-drafts")?;
372 url.query_pairs_mut()
373 .append_pair("app", app)
374 .append_pair("state", "approved");
375 if let Some(after) = after {
376 url.query_pairs_mut().append_pair("after", after);
377 }
378 let response = self
379 .http
380 .get(url)
381 .bearer_auth(connector_pass)
382 .send()
383 .await
384 .map_err(|error| Error::Transport(Box::new(error)))?;
385 let page: ApprovedPage = response
386 .error_for_status()
387 .map_err(|error| Error::Transport(Box::new(error)))?
388 .json()
389 .await
390 .map_err(|error| Error::Transport(Box::new(error)))?;
391 if page.total < page.drafts.len()
392 || after.is_some_and(|cursor| page.next.as_deref() == Some(cursor))
393 {
394 return Err(Error::CannotAsk("draft population or continuation differs"));
395 }
396 let mut ids = std::collections::BTreeSet::new();
397 for draft in &page.drafts {
398 approval(draft)?;
399 validate(
400 &draft.grant,
401 &draft.target,
402 &draft.request_digest,
403 &draft.words,
404 )?;
405 if draft.app != app
406 || draft.id.is_empty()
407 || !crate::rights::audience_owns(&draft.target.kind, app)
408 || !ids.insert(&draft.id)
409 {
410 return Err(Error::CannotAsk("approved draft belongs to another app"));
411 }
412 }
413 Ok(page)
414 }
415
416 pub async fn record_execution(
426 &self,
427 connector_pass: &str,
428 draft: &ApprovedDraft,
429 receipt: &Execution,
430 ) -> Result<(), Error> {
431 if connector_pass.is_empty() || receipt.request_digest() != draft.request_digest {
432 return Err(Error::Invalid("execution receipt differs from draft"));
433 }
434 let route = match receipt {
435 Execution::Executed { .. } => "executed",
436 Execution::RefusedOnExecution { .. } => "refused-on-execution",
437 };
438 if draft.id.is_empty() {
439 return Err(Error::Invalid("draft id is missing"));
440 }
441 let mut url = self.api_endpoint("product-drafts/")?;
442 url.path_segments_mut()
443 .map_err(|()| Error::Invalid("issuer cannot hold draft paths"))?
444 .pop_if_empty()
445 .push(&draft.id)
446 .push(route);
447 let body = match receipt {
448 Execution::Executed { receipt_digest, .. } => {
449 if receipt_digest.len() != 64
450 || !receipt_digest.bytes().all(|byte| byte.is_ascii_hexdigit())
451 {
452 return Err(Error::Invalid("execution receipt digest is malformed"));
453 }
454 serde_json::json!({ "receipt_digest": receipt_digest })
455 }
456 Execution::RefusedOnExecution {
457 refusal, reason, ..
458 } => {
459 if refusal.is_empty() || reason.is_empty() {
460 return Err(Error::Invalid(
461 "execution refusal needs a name and a reason",
462 ));
463 }
464 serde_json::json!({ "refusal": refusal, "reason": reason })
465 }
466 };
467 self.http
468 .post(url)
469 .bearer_auth(connector_pass)
470 .json(&body)
471 .send()
472 .await
473 .map_err(|error| Error::Transport(Box::new(error)))?
474 .error_for_status()
475 .map_err(|error| Error::Transport(Box::new(error)))?;
476 Ok(())
477 }
478}