1use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5
6#[cfg(feature = "http")]
7use crate::Client;
8use crate::{Error, 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 words: String,
50}
51
52#[derive(Debug, Deserialize)]
54#[serde(deny_unknown_fields)]
55pub struct ApprovedPage {
56 pub drafts: Vec<ApprovedDraft>,
58 pub next: Option<String>,
60 pub total: usize,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
67pub enum Execution {
68 Executed {
70 request_digest: String,
72 receipt_digest: String,
74 },
75 RefusedOnExecution {
77 request_digest: String,
79 refusal: String,
81 reason: String,
83 },
84}
85
86impl Execution {
87 fn request_digest(&self) -> &str {
88 match self {
89 Self::Executed { request_digest, .. }
90 | Self::RefusedOnExecution { request_digest, .. } => request_digest,
91 }
92 }
93}
94
95pub trait Executor {
97 type Error: std::error::Error + Send + Sync + 'static;
99 fn receipt(&self, draft: &str) -> Result<Option<Execution>, Self::Error>;
101 fn execute_and_record(&mut self, draft: &ApprovedDraft) -> Result<Execution, Self::Error>;
104}
105
106#[derive(Debug, thiserror::Error)]
108pub enum ExecuteError<E: std::error::Error + 'static> {
109 #[error("draft_receipt_digest_mismatch")]
111 DigestMismatch,
112 #[error("draft_execution_failed")]
114 Product(#[source] E),
115 #[error("draft_contract_refused")]
117 Contract(#[source] Error),
118}
119
120#[derive(Debug, thiserror::Error)]
122pub enum RunError<E: std::error::Error + 'static> {
123 #[error("draft_pull_failed")]
125 Pull(#[source] Error),
126 #[error("draft_execution_failed: {draft}")]
128 Execution {
129 draft: String,
131 #[source]
133 source: ExecuteError<E>,
134 },
135 #[error("draft_acknowledgment_failed: {draft}")]
137 Record {
138 draft: String,
140 receipt: Box<Execution>,
142 #[source]
144 source: Error,
145 },
146}
147
148#[derive(Debug)]
150pub struct RunReport {
151 pub acknowledged: usize,
153 pub next: Option<String>,
155}
156
157#[must_use]
159pub fn request_digest(words: &str) -> String {
160 let mut text = String::with_capacity(64);
161 let digit = |nibble: u8| {
162 char::from(if nibble < 10 {
163 b'0' + nibble
164 } else {
165 b'a' + nibble - 10
166 })
167 };
168 for byte in Sha256::digest(words.as_bytes()) {
169 text.push(digit(byte >> 4));
170 text.push(digit(byte & 15));
171 }
172 text
173}
174
175pub fn execute_once<E: Executor>(
177 draft: &ApprovedDraft,
178 executor: &mut E,
179) -> Result<Execution, ExecuteError<E::Error>> {
180 validate(
181 &draft.grant,
182 &draft.target,
183 &draft.request_digest,
184 &draft.words,
185 )
186 .map_err(ExecuteError::Contract)?;
187 if draft.id.is_empty() || !crate::rights::audience_owns(&draft.target.kind, &draft.app) {
188 return Err(ExecuteError::Contract(Error::Invalid(
189 "draft audience or id differs",
190 )));
191 }
192 let receipt = match executor.receipt(&draft.id).map_err(ExecuteError::Product)? {
193 Some(receipt) => receipt,
194 None => executor
195 .execute_and_record(draft)
196 .map_err(ExecuteError::Product)?,
197 };
198 if receipt.request_digest() != draft.request_digest {
199 return Err(ExecuteError::DigestMismatch);
200 }
201 if let Execution::Executed { receipt_digest, .. } = &receipt
202 && (receipt_digest.len() != 64
203 || !receipt_digest.bytes().all(|byte| byte.is_ascii_hexdigit()))
204 {
205 return Err(ExecuteError::Contract(Error::Invalid(
206 "product receipt digest is malformed",
207 )));
208 }
209 Ok(receipt)
210}
211
212fn validate(grant: &str, target: &Target, digest: &str, words: &str) -> Result<(), Error> {
213 Target::new(&target.kind, &target.id, &target.action)?;
214 if grant.is_empty() || request_digest(words) != digest {
215 return Err(Error::Invalid("draft words or granting receipt differ"));
216 }
217 Ok(())
218}
219
220#[cfg(feature = "http")]
221impl Client {
222 pub async fn run_approved<E: Executor + Send>(
224 &self,
225 connector_pass: &str,
226 app: &str,
227 after: Option<&str>,
228 executor: &mut E,
229 ) -> Result<RunReport, RunError<E::Error>> {
230 let page = self
231 .approved_drafts(connector_pass, app, after)
232 .await
233 .map_err(RunError::Pull)?;
234 let mut report = RunReport {
235 acknowledged: 0,
236 next: page.next,
237 };
238 for draft in &page.drafts {
239 let receipt = execute_once(draft, executor).map_err(|source| RunError::Execution {
240 draft: draft.id.clone(),
241 source,
242 })?;
243 self.record_execution(connector_pass, draft, &receipt)
244 .await
245 .map_err(|source| RunError::Record {
246 draft: draft.id.clone(),
247 receipt: Box::new(receipt),
248 source,
249 })?;
250 report.acknowledged += 1;
251 }
252 Ok(report)
253 }
254
255 pub async fn create_draft(
257 &self,
258 pass: &str,
259 draft: &DraftRequest,
260 judgment: &crate::deliberate::CheckAnswer,
261 ) -> Result<DraftCreated, Error> {
262 if judgment.allowed
263 || !matches!(
264 judgment.mode,
265 Some(crate::Mode::ByDraft | crate::Mode::ByTwo)
266 )
267 || judgment.grant.as_deref() != Some(draft.grant.as_str())
268 || judgment.refusal.is_some()
269 {
270 return Err(Error::Invalid("only a held-mode right may create a draft"));
271 }
272 validate(
273 &draft.grant,
274 &draft.target,
275 &draft.request_digest,
276 &draft.words,
277 )?;
278 if pass.is_empty() || draft.operation.is_empty() {
279 return Err(Error::Invalid("draft caller pass or operation is missing"));
280 }
281 let response = self
282 .http
283 .post(self.endpoint("product-drafts")?)
284 .bearer_auth(pass)
285 .json(draft)
286 .send()
287 .await
288 .map_err(|error| Error::Transport(Box::new(error)))?;
289 let created: DraftCreated = response
290 .error_for_status()
291 .map_err(|error| Error::Transport(Box::new(error)))?
292 .json()
293 .await
294 .map_err(|error| Error::Transport(Box::new(error)))?;
295 if created.draft.is_empty() {
296 return Err(Error::CannotAsk("draft id is missing"));
297 }
298 Ok(created)
299 }
300
301 pub async fn approved_drafts(
303 &self,
304 connector_pass: &str,
305 app: &str,
306 after: Option<&str>,
307 ) -> Result<ApprovedPage, Error> {
308 if connector_pass.is_empty() || app.is_empty() {
309 return Err(Error::Invalid("draft connector or app is missing"));
310 }
311 let mut url = self.endpoint("product-drafts")?;
312 url.query_pairs_mut()
313 .append_pair("app", app)
314 .append_pair("state", "approved");
315 if let Some(after) = after {
316 url.query_pairs_mut().append_pair("after", after);
317 }
318 let response = self
319 .http
320 .get(url)
321 .bearer_auth(connector_pass)
322 .send()
323 .await
324 .map_err(|error| Error::Transport(Box::new(error)))?;
325 let page: ApprovedPage = response
326 .error_for_status()
327 .map_err(|error| Error::Transport(Box::new(error)))?
328 .json()
329 .await
330 .map_err(|error| Error::Transport(Box::new(error)))?;
331 if page.total < page.drafts.len()
332 || after.is_some_and(|cursor| page.next.as_deref() == Some(cursor))
333 {
334 return Err(Error::CannotAsk("draft population or continuation differs"));
335 }
336 let mut ids = std::collections::BTreeSet::new();
337 for draft in &page.drafts {
338 validate(
339 &draft.grant,
340 &draft.target,
341 &draft.request_digest,
342 &draft.words,
343 )?;
344 if draft.app != app
345 || draft.id.is_empty()
346 || !crate::rights::audience_owns(&draft.target.kind, app)
347 || !ids.insert(&draft.id)
348 {
349 return Err(Error::CannotAsk("approved draft belongs to another app"));
350 }
351 }
352 Ok(page)
353 }
354
355 pub async fn record_execution(
357 &self,
358 connector_pass: &str,
359 draft: &ApprovedDraft,
360 receipt: &Execution,
361 ) -> Result<(), Error> {
362 if connector_pass.is_empty() || receipt.request_digest() != draft.request_digest {
363 return Err(Error::Invalid("execution receipt differs from draft"));
364 }
365 let route = match receipt {
366 Execution::Executed { .. } => "executed",
367 Execution::RefusedOnExecution { .. } => "refused-on-execution",
368 };
369 if draft.id.is_empty() {
370 return Err(Error::Invalid("draft id is missing"));
371 }
372 let mut url = self.endpoint("product-drafts/")?;
373 url.path_segments_mut()
374 .map_err(|()| Error::Invalid("issuer cannot hold draft paths"))?
375 .pop_if_empty()
376 .push(&draft.id)
377 .push(route);
378 let body = match receipt {
379 Execution::Executed { receipt_digest, .. } => {
380 if receipt_digest.len() != 64
381 || !receipt_digest.bytes().all(|byte| byte.is_ascii_hexdigit())
382 {
383 return Err(Error::Invalid("execution receipt digest is malformed"));
384 }
385 serde_json::json!({ "receipt_digest": receipt_digest })
386 }
387 Execution::RefusedOnExecution {
388 refusal, reason, ..
389 } => {
390 if refusal.is_empty() || reason.is_empty() {
391 return Err(Error::Invalid(
392 "execution refusal needs a name and a reason",
393 ));
394 }
395 serde_json::json!({ "refusal": refusal, "reason": reason })
396 }
397 };
398 self.http
399 .post(url)
400 .bearer_auth(connector_pass)
401 .json(&body)
402 .send()
403 .await
404 .map_err(|error| Error::Transport(Box::new(error)))?
405 .error_for_status()
406 .map_err(|error| Error::Transport(Box::new(error)))?;
407 Ok(())
408 }
409}