Skip to main content

ic_backup/policy/ic_observation/
mod.rs

1//! Pure reserved IC observation association and passive qualified original effect claims.
2
3use crate::model::{
4    artifacts::ArtifactChecksumRecord,
5    attempt_journal::{AttemptAuthorityRecord, AttemptJournalRecord},
6    ic_lifecycle_reply::{IcLifecycleReply, IcLifecycleReplyError},
7    ic_observation::{IcObservationRequest, IcObservationResponse, IcObservationResponseInput},
8    ic_request::{IcManagementMethodRecord, IcManagementRequestRecord},
9    ic_snapshot_reply::{IcSnapshotInfo, IcSnapshotReply, IcSnapshotReplyError},
10    operation_plan::PlanContextRecord,
11};
12use thiserror::Error;
13
14/// Existing method-specific wire projections; no fresh permission or effect attribution.
15#[derive(Debug)]
16pub enum IcObservationReplyView<'a> {
17    /// Canonical bounded snapshot inventory with unchanged raw identities/metadata.
18    Inventory(IcSnapshotReply<'a>),
19    /// Required status/controller projection; Stopped alone proves no drain or load outcome.
20    Status(IcLifecycleReply<'a>),
21}
22
23/// Read-only association to exact original reserved observation evidence.
24#[derive(Debug)]
25pub struct IcObservationResponseView<'a> {
26    response: &'a IcObservationResponse,
27    reply: IcObservationReplyView<'a>,
28}
29impl<'a> IcObservationResponseView<'a> {
30    /// Read immutable original claims and exact raw response evidence.
31    #[must_use]
32    pub const fn response(&self) -> &'a IcObservationResponse {
33        self.response
34    }
35    /// Read the existing decoder's bounded method-specific projection.
36    #[must_use]
37    pub const fn reply(&self) -> &IcObservationReplyView<'a> {
38        &self.reply
39    }
40
41    // The view owns its decoded inventory. Settlement keeps an admitted index
42    // and borrows the exact descriptor through this owner when requested.
43    pub(super) fn inventory_snapshot(&self, index: usize) -> Option<&IcSnapshotInfo> {
44        match &self.reply {
45            IcObservationReplyView::Inventory(reply) => reply.snapshots().get(index),
46            IcObservationReplyView::Status(_) => None,
47        }
48    }
49}
50
51/// Match current original reservations and actual claims, then reuse existing decoders.
52///
53/// This performs no IO, dispatch or receipt transition. Successful wire association
54/// proves no actual authentication, chronology, freshness, exclusive capture attribution,
55/// lifecycle equivalence, load success, application safety or permission. Zero/one/many
56/// snapshots and Stopped/controller projections never automatically settle a mutation.
57/// Lost replies retain the pending observation; they cannot mean settled Uncertain.
58/// # Errors
59/// Rejects changed reservations/authority/attempts/bytes/context/target and invalid wire.
60pub fn validate_response<'a>(
61    request: &IcObservationRequest<'a>,
62    journal: &AttemptJournalRecord,
63    response: &'a IcObservationResponse,
64) -> Result<IcObservationResponseView<'a>, IcObservationAssociationError> {
65    request.validate_journal(journal)?;
66    validate_association(
67        &ObservationAssociation {
68            authority: request.authority(),
69            mutation_attempt: request.mutation_attempt(),
70            observation_attempt: request.observation_attempt(),
71            payload: request.payload(),
72            context: request.plan().context(),
73        },
74        response,
75    )
76}
77
78pub(crate) struct ObservationAssociation<'binding, 'payload> {
79    pub authority: &'binding AttemptAuthorityRecord,
80    pub mutation_attempt: u32,
81    pub observation_attempt: u32,
82    pub payload: &'payload IcManagementRequestRecord,
83    pub context: &'binding PlanContextRecord,
84}
85
86pub(crate) fn validate_association<'a>(
87    binding: &ObservationAssociation<'_, 'a>,
88    response: &'a IcObservationResponse,
89) -> Result<IcObservationResponseView<'a>, IcObservationAssociationError> {
90    validate_claims(
91        &ObservationClaims {
92            authority: binding.authority,
93            mutation_attempt: binding.mutation_attempt,
94            observation_attempt: binding.observation_attempt,
95            request: binding.payload.digest(),
96            context: binding.context,
97            target: binding.payload.target(),
98        },
99        response.input(),
100    )?;
101    let reply = match binding.payload.method() {
102        IcManagementMethodRecord::ListCanisterSnapshots => IcObservationReplyView::Inventory(
103            IcSnapshotReply::decode(binding.payload, &response.input().reply)?,
104        ),
105        // The sealed request excludes mutations. Status stays with its existing owner.
106        _ => IcObservationReplyView::Status(IcLifecycleReply::decode(
107            binding.payload,
108            &response.input().reply,
109        )?),
110    };
111    Ok(IcObservationResponseView { response, reply })
112}
113
114pub(crate) struct ObservationClaims<'a> {
115    pub authority: &'a AttemptAuthorityRecord,
116    pub mutation_attempt: u32,
117    pub observation_attempt: u32,
118    pub request: ArtifactChecksumRecord,
119    pub context: &'a PlanContextRecord,
120    pub target: &'a str,
121}
122
123pub(crate) fn validate_claims(
124    binding: &ObservationClaims<'_>,
125    input: &IcObservationResponseInput,
126) -> Result<(), IcObservationAssociationError> {
127    if input.authority != binding.authority.digest() {
128        return Err(IcObservationAssociationError::AuthorityMismatch);
129    }
130    if input.mutation_attempt != binding.mutation_attempt
131        || input.observation_attempt != binding.observation_attempt
132    {
133        return Err(IcObservationAssociationError::AttemptMismatch);
134    }
135    if input.request != binding.request {
136        return Err(IcObservationAssociationError::RequestMismatch);
137    }
138    if &input.context != binding.context {
139        return Err(IcObservationAssociationError::ContextMismatch);
140    }
141    if input.target != binding.target {
142        return Err(IcObservationAssociationError::TargetMismatch);
143    }
144    Ok(())
145}
146
147/// Typed passive association denial; all original spending/obligations remain retained.
148#[derive(Debug, Error)]
149pub enum IcObservationAssociationError {
150    /// Current original journal no longer matches the request.
151    #[error(transparent)]
152    Reservation(#[from] crate::model::attempt_journal::ObservationReservationError),
153    /// Another original operation authority was claimed.
154    #[error("IC observation response authority mismatch")]
155    AuthorityMismatch,
156    /// Another original mutation or observation attempt was claimed.
157    #[error("IC observation response attempt mismatch")]
158    AttemptMismatch,
159    /// Another exact observation payload digest was claimed.
160    #[error("IC observation response request mismatch")]
161    RequestMismatch,
162    /// Actual claimed network/caller/release differs.
163    #[error("IC observation response context mismatch")]
164    ContextMismatch,
165    /// Actual claimed response target differs.
166    #[error("IC observation response target mismatch")]
167    TargetMismatch,
168    /// Existing inventory decoder rejected the reply.
169    #[error(transparent)]
170    Inventory(#[from] IcSnapshotReplyError),
171    /// Existing status decoder rejected the reply.
172    #[error(transparent)]
173    Status(#[from] IcLifecycleReplyError),
174}
175
176mod capture_settlement;
177pub use capture_settlement::{IcCaptureSettlementError, validate_capture_settlement};
178
179mod settlement;
180pub use settlement::{
181    IcLifecycleSettlementError, IcLifecycleSettlementView, validate_lifecycle_settlement,
182};
183
184#[cfg(test)]
185mod tests;