Skip to main content

cloud_sdk/operation/permit/
fingerprint.rs

1//! Versioned plan-confirm identity with exact or strong-digest comparison.
2
3use core::fmt;
4
5use cloud_sdk_sanitization::sanitize_bytes;
6use subtle::ConstantTimeEq;
7
8use super::{
9    AttemptBudget, PermitContext, PermitIdempotencyKey, PermitScope, PermitValidity, PlanChange,
10    PlanCost, PlanFingerprintScope, ReplayPolicy,
11};
12use crate::buffer::{encode_snapshot_bounded, measure_snapshot_bounded};
13use crate::operation::{CostIntent, OperationImpact, PreparedRequest};
14use crate::retry::{DigestAlgorithm, FingerprintHasher};
15use crate::transport::EndpointIdentity;
16
17mod encoding;
18mod error;
19use encoding::encode;
20pub use error::PlanFingerprintBuildError;
21use error::map_infallible;
22
23const DOMAIN: &[u8] = b"cloud-sdk/plan-confirm/v1\0";
24/// Maximum complete exact plan-confirm input.
25pub const MAX_CANONICAL_PLAN_BYTES: usize = 16_777_216;
26
27/// Complete immutable plan-confirm inputs.
28#[derive(Clone, Copy)]
29pub struct PlanConfirmation<'a, 'request> {
30    prepared: PreparedRequest<'request>,
31    endpoint: EndpointIdentity<'a>,
32    account: PlanFingerprintScope<'a>,
33    tenant: PlanFingerprintScope<'a>,
34    context: PermitContext<'a>,
35    validity: PermitValidity,
36    replay: ReplayPolicy,
37    attempts: AttemptBudget,
38    change: PlanChange,
39    cost: Option<PlanCost>,
40    idempotency: Option<PermitIdempotencyKey<'a>>,
41}
42
43impl<'a, 'request> PlanConfirmation<'a, 'request> {
44    /// Binds caller policy and current plan observations to one prepared request.
45    #[allow(clippy::too_many_arguments)]
46    #[must_use]
47    pub const fn new(
48        prepared: PreparedRequest<'request>,
49        endpoint: EndpointIdentity<'a>,
50        account: PlanFingerprintScope<'a>,
51        tenant: PlanFingerprintScope<'a>,
52        context: PermitContext<'a>,
53        validity: PermitValidity,
54        replay: ReplayPolicy,
55        attempts: AttemptBudget,
56        change: PlanChange,
57        cost: Option<PlanCost>,
58        idempotency: Option<PermitIdempotencyKey<'a>>,
59    ) -> Self {
60        Self {
61            prepared,
62            endpoint,
63            account,
64            tenant,
65            context,
66            validity,
67            replay,
68            attempts,
69            change,
70            cost,
71            idempotency,
72        }
73    }
74}
75
76impl fmt::Debug for PlanConfirmation<'_, '_> {
77    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
78        formatter
79            .debug_struct("PlanConfirmation")
80            .field("prepared", &self.prepared)
81            .field("endpoint", &self.endpoint)
82            .field("account", &"[redacted]")
83            .field("tenant", &"[redacted]")
84            .field("context", &"[redacted]")
85            .field("validity", &self.validity)
86            .field("replay", &self.replay)
87            .field("attempts", &self.attempts)
88            .field("change", &self.change)
89            .field("cost", &self.cost)
90            .field("idempotency", &"[redacted]")
91            .finish()
92    }
93}
94
95/// Caller-buffer exact plan-confirm input, cleared in full on drop.
96pub struct CanonicalPlanFingerprint<'output, 'plan, 'request> {
97    storage: &'output mut [u8],
98    len: usize,
99    plan: PlanConfirmation<'plan, 'request>,
100    scope: PermitScope,
101}
102
103impl<'output, 'plan, 'request> CanonicalPlanFingerprint<'output, 'plan, 'request> {
104    /// Returns a redacted exact comparison reference.
105    #[must_use]
106    pub fn as_ref(&self) -> PlanFingerprintRef<'_> {
107        PlanFingerprintRef(PlanFingerprintKind::Exact(self.bytes()))
108    }
109
110    /// Returns the exact prepared request and confirmed authority metadata.
111    #[must_use]
112    pub fn subject(&self) -> PlanSubject<'request, '_> {
113        subject(&self.plan, self.scope, self.as_ref())
114    }
115
116    /// Returns the complete canonical byte length without exposing bytes.
117    #[must_use]
118    pub const fn len(&self) -> usize {
119        self.len
120    }
121
122    /// Reports whether the canonical input is empty. It is never empty.
123    #[must_use]
124    pub const fn is_empty(&self) -> bool {
125        false
126    }
127
128    fn bytes(&self) -> &[u8] {
129        self.storage.get(..self.len).unwrap_or_default()
130    }
131}
132
133impl fmt::Debug for CanonicalPlanFingerprint<'_, '_, '_> {
134    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
135        formatter
136            .debug_struct("CanonicalPlanFingerprint")
137            .field("len", &self.len)
138            .field("scope", &self.scope)
139            .field("bytes", &"[redacted]")
140            .finish()
141    }
142}
143
144impl Drop for CanonicalPlanFingerprint<'_, '_, '_> {
145    fn drop(&mut self) {
146        sanitize_bytes(self.storage);
147        self.len = 0;
148    }
149}
150
151/// Caller-buffer strong digest of a plan confirmation, cleared on drop.
152pub struct PlanFingerprintDigest<'output, 'plan, 'request> {
153    algorithm: DigestAlgorithm,
154    storage: &'output mut [u8],
155    len: usize,
156    plan: PlanConfirmation<'plan, 'request>,
157    scope: PermitScope,
158}
159
160impl PlanFingerprintDigest<'_, '_, '_> {
161    /// Returns the admitted collision-resistant digest algorithm.
162    #[must_use]
163    pub const fn algorithm(&self) -> DigestAlgorithm {
164        self.algorithm
165    }
166
167    /// Returns a redacted strong-digest comparison reference.
168    #[must_use]
169    pub fn as_ref(&self) -> PlanFingerprintRef<'_> {
170        PlanFingerprintRef(PlanFingerprintKind::Digest {
171            algorithm: self.algorithm,
172            bytes: self.storage.get(..self.len).unwrap_or_default(),
173        })
174    }
175
176    /// Returns the exact request and confirmed authority metadata.
177    #[must_use]
178    pub fn subject(&self) -> PlanSubject<'_, '_> {
179        subject(&self.plan, self.scope, self.as_ref())
180    }
181}
182
183impl fmt::Debug for PlanFingerprintDigest<'_, '_, '_> {
184    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
185        formatter
186            .debug_struct("PlanFingerprintDigest")
187            .field("algorithm", &self.algorithm)
188            .field("scope", &self.scope)
189            .field("bytes", &"[redacted]")
190            .finish()
191    }
192}
193
194impl Drop for PlanFingerprintDigest<'_, '_, '_> {
195    fn drop(&mut self) {
196        sanitize_bytes(self.storage);
197        self.len = 0;
198    }
199}
200
201/// Borrowed exact or strong-digest plan identity.
202#[derive(Clone, Copy)]
203pub struct PlanFingerprintRef<'a>(PlanFingerprintKind<'a>);
204
205#[derive(Clone, Copy)]
206enum PlanFingerprintKind<'a> {
207    Exact(&'a [u8]),
208    Digest {
209        algorithm: DigestAlgorithm,
210        bytes: &'a [u8],
211    },
212}
213
214impl PlanFingerprintRef<'_> {
215    pub(crate) fn matches(self, other: Self) -> bool {
216        match (self.0, other.0) {
217            (PlanFingerprintKind::Exact(left), PlanFingerprintKind::Exact(right)) => {
218                constant_time_eq(left, right)
219            }
220            (
221                PlanFingerprintKind::Digest {
222                    algorithm: left,
223                    bytes: left_bytes,
224                },
225                PlanFingerprintKind::Digest {
226                    algorithm: right,
227                    bytes: right_bytes,
228                },
229            ) => left == right && constant_time_eq(left_bytes, right_bytes),
230            _ => false,
231        }
232    }
233}
234
235impl fmt::Debug for PlanFingerprintRef<'_> {
236    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
237        formatter.write_str("PlanFingerprintRef([redacted])")
238    }
239}
240
241/// One prepared request inseparably bound to a validated plan confirmation.
242#[derive(Clone, Copy)]
243pub struct PlanSubject<'request, 'fingerprint> {
244    prepared: &'fingerprint PreparedRequest<'request>,
245    fingerprint: PlanFingerprintRef<'fingerprint>,
246    endpoint: EndpointIdentity<'fingerprint>,
247    scope: PermitScope,
248    validity: PermitValidity,
249    replay: ReplayPolicy,
250    attempts: AttemptBudget,
251    idempotency: Option<PermitIdempotencyKey<'fingerprint>>,
252}
253
254impl<'request, 'fingerprint> PlanSubject<'request, 'fingerprint> {
255    /// Returns the required permit scope.
256    #[must_use]
257    pub const fn scope(self) -> PermitScope {
258        self.scope
259    }
260
261    /// Returns the caller-selected replay policy.
262    #[must_use]
263    pub const fn replay_policy(self) -> ReplayPolicy {
264        self.replay
265    }
266
267    /// Returns the hard attempt budget.
268    #[must_use]
269    pub const fn attempt_budget(self) -> AttemptBudget {
270        self.attempts
271    }
272
273    pub(crate) const fn endpoint(self) -> EndpointIdentity<'fingerprint> {
274        self.endpoint
275    }
276
277    pub(crate) const fn prepared(self) -> PreparedRequest<'request> {
278        *self.prepared
279    }
280
281    pub(crate) const fn fingerprint(self) -> PlanFingerprintRef<'fingerprint> {
282        self.fingerprint
283    }
284
285    pub(crate) const fn validity(self) -> PermitValidity {
286        self.validity
287    }
288
289    pub(crate) const fn idempotency(self) -> Option<PermitIdempotencyKey<'fingerprint>> {
290        self.idempotency
291    }
292}
293
294impl fmt::Debug for PlanSubject<'_, '_> {
295    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
296        formatter
297            .debug_struct("PlanSubject")
298            .field("prepared", &self.prepared)
299            .field("fingerprint", &"[redacted]")
300            .field("endpoint", &self.endpoint)
301            .field("scope", &self.scope)
302            .field("validity", &self.validity)
303            .field("replay", &self.replay)
304            .field("attempts", &self.attempts)
305            .field("idempotency", &"[redacted]")
306            .finish()
307    }
308}
309
310/// Builds exact canonical plan-confirm bytes into caller-owned storage.
311pub fn build_canonical_plan<'output, 'plan, 'request>(
312    plan: PlanConfirmation<'plan, 'request>,
313    output: &'output mut [u8],
314) -> Result<
315    CanonicalPlanFingerprint<'output, 'plan, 'request>,
316    PlanFingerprintBuildError<core::convert::Infallible>,
317> {
318    if plan.prepared.body_sensitivity().requires_digest() {
319        sanitize_bytes(output);
320        return Err(PlanFingerprintBuildError::SensitiveBodyRequiresDigest);
321    }
322    build_canonical_plan_inner(plan, output)
323}
324
325#[allow(
326    clippy::large_types_passed_by_value,
327    reason = "the returned fingerprint must own the complete confirmed plan"
328)]
329fn build_canonical_plan_inner<'output, 'plan, 'request>(
330    plan: PlanConfirmation<'plan, 'request>,
331    output: &'output mut [u8],
332) -> Result<
333    CanonicalPlanFingerprint<'output, 'plan, 'request>,
334    PlanFingerprintBuildError<core::convert::Infallible>,
335> {
336    sanitize_bytes(output);
337    let scope = validate(&plan)?;
338    let required = measure_snapshot_bounded(
339        &plan,
340        MAX_CANONICAL_PLAN_BYTES,
341        PlanFingerprintBuildError::InputTooLarge,
342        encode,
343    )?;
344    if output.len() < required {
345        return Err(PlanFingerprintBuildError::OutputTooSmall);
346    }
347    let len = encode_snapshot_bounded(
348        &plan,
349        output,
350        MAX_CANONICAL_PLAN_BYTES,
351        PlanFingerprintBuildError::InputTooLarge,
352        encode,
353    )?;
354    Ok(CanonicalPlanFingerprint {
355        storage: output,
356        len,
357        plan,
358        scope,
359    })
360}
361
362/// Builds a caller-selected collision-resistant digest and clears scratch.
363pub fn build_plan_digest<'output, 'plan, 'request, H: FingerprintHasher>(
364    plan: PlanConfirmation<'plan, 'request>,
365    scratch: &mut [u8],
366    output: &'output mut [u8],
367    hasher: &H,
368) -> Result<PlanFingerprintDigest<'output, 'plan, 'request>, PlanFingerprintBuildError<H::Error>> {
369    sanitize_bytes(output);
370    let exact = build_canonical_plan_inner(plan, scratch).map_err(map_infallible)?;
371    let algorithm = hasher.algorithm();
372    let expected = algorithm.output_len();
373    if output.len() < expected {
374        return Err(PlanFingerprintBuildError::OutputTooSmall);
375    }
376    let mut rollback = DigestRollback::new(output);
377    let len = hasher
378        .digest(exact.bytes(), rollback.target(expected))
379        .map_err(PlanFingerprintBuildError::Hasher)?;
380    if len != expected {
381        return Err(PlanFingerprintBuildError::InvalidDigestLength);
382    }
383    let output = rollback.disarm();
384    Ok(PlanFingerprintDigest {
385        algorithm,
386        storage: output,
387        len,
388        plan,
389        scope: exact.scope,
390    })
391}
392
393struct DigestRollback<'a> {
394    output: &'a mut [u8],
395    armed: bool,
396}
397
398impl<'a> DigestRollback<'a> {
399    fn new(output: &'a mut [u8]) -> Self {
400        Self {
401            output,
402            armed: true,
403        }
404    }
405
406    fn target(&mut self, len: usize) -> &mut [u8] {
407        self.output.get_mut(..len).unwrap_or_default()
408    }
409
410    fn disarm(mut self) -> &'a mut [u8] {
411        self.armed = false;
412        core::mem::take(&mut self.output)
413    }
414}
415
416impl Drop for DigestRollback<'_> {
417    fn drop(&mut self) {
418        if self.armed {
419            sanitize_bytes(self.output);
420        }
421    }
422}
423
424pub(super) fn validate<E>(
425    plan: &PlanConfirmation<'_, '_>,
426) -> Result<PermitScope, PlanFingerprintBuildError<E>> {
427    if plan.prepared.operation_id().is_none() {
428        return Err(PlanFingerprintBuildError::MissingOperationId);
429    }
430    if !plan
431        .prepared
432        .service()
433        .endpoint_policy()
434        .admits(plan.endpoint)
435    {
436        return Err(PlanFingerprintBuildError::EndpointNotAdmitted);
437    }
438    if plan.change == PlanChange::NoOp {
439        return Err(PlanFingerprintBuildError::NoOp);
440    }
441    plan.account
442        .bytes()
443        .map_err(PlanFingerprintBuildError::Context)?;
444    plan.tenant
445        .bytes()
446        .map_err(PlanFingerprintBuildError::Context)?;
447    let metadata = plan.prepared.metadata();
448    let scope = match (metadata.cost_intent(), metadata.impact()) {
449        (CostIntent::MayIncurCost, _) => PermitScope::Cost,
450        (_, OperationImpact::Destructive) => PermitScope::Destructive,
451        (_, OperationImpact::Mutation) => PermitScope::Mutation,
452        (_, OperationImpact::ReadOnly) => return Err(PlanFingerprintBuildError::ReadOnlyOperation),
453    };
454    match (scope, plan.cost) {
455        (PermitScope::Cost, None) => return Err(PlanFingerprintBuildError::MissingCost),
456        (PermitScope::Mutation | PermitScope::Destructive, Some(_)) => {
457            return Err(PlanFingerprintBuildError::UnexpectedCost);
458        }
459        _ => {}
460    }
461    match (plan.replay, plan.attempts.get(), plan.idempotency) {
462        (ReplayPolicy::SingleAttempt, 1, None)
463        | (ReplayPolicy::RecoverNotSent, _, None)
464        | (ReplayPolicy::ReconcileThenRetry, _, Some(_)) => {}
465        (ReplayPolicy::SingleAttempt, _, _) => {
466            return Err(PlanFingerprintBuildError::InvalidSingleAttemptBudget);
467        }
468        (ReplayPolicy::ReconcileThenRetry, _, None) => {
469            return Err(PlanFingerprintBuildError::MissingIdempotency);
470        }
471        (_, _, Some(_)) => return Err(PlanFingerprintBuildError::UnexpectedIdempotency),
472    }
473    Ok(scope)
474}
475
476fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
477    left.len() == right.len() && bool::from(left.ct_eq(right))
478}
479
480fn subject<'request, 'plan: 'fingerprint, 'fingerprint>(
481    plan: &'fingerprint PlanConfirmation<'plan, 'request>,
482    scope: PermitScope,
483    fingerprint: PlanFingerprintRef<'fingerprint>,
484) -> PlanSubject<'request, 'fingerprint> {
485    PlanSubject {
486        prepared: &plan.prepared,
487        fingerprint,
488        endpoint: plan.endpoint,
489        scope,
490        validity: plan.validity,
491        replay: plan.replay,
492        attempts: plan.attempts,
493        idempotency: plan.idempotency,
494    }
495}