Skip to main content

ic_backup/model/attempt_journal/authority/
mod.rs

1//! Immutable operation identity and finite attempt allowance; no fresh authority.
2
3use super::AttemptJournalRecordError;
4use crate::model::artifacts::{ArtifactChecksumRecord, canonical_hash};
5use crate::model::evidence_digest::EvidenceDigest;
6use serde::{Deserialize, Serialize};
7
8/// Maximum total mutation and reconciliation-observation attempts per journal.
9pub const MAX_OPERATION_ATTEMPTS: u32 = 1024;
10
11/// Passive operation identity selected by the integration before effects.
12#[derive(Clone, Debug)]
13pub struct OperationBindingRequest {
14    /// Canonical immutable plan/intent digest supplied by its owner.
15    pub intent: String,
16    /// Exact operation sequence within that intent.
17    pub operation_sequence: u64,
18    /// Qualified network identity fingerprint; an endpoint label is insufficient.
19    pub network: String,
20    /// Exact selected caller principal; credentials are never retained here.
21    pub caller: String,
22    /// Exact physical canister principal selected for this operation.
23    pub target: String,
24    /// Opaque exact release evidence digest supplied by the integration.
25    pub release: String,
26    /// Canonical exact mutating request digest supplied by its owning codec.
27    pub request: String,
28}
29
30/// Canonical immutable declared operation identity, not observed live authority.
31#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
32#[serde(try_from = "BindingFields")]
33pub struct OperationBindingRecord {
34    intent: String,
35    operation_sequence: u64,
36    network: String,
37    caller: String,
38    target: String,
39    release: String,
40    request: String,
41}
42
43#[derive(Deserialize)]
44#[serde(deny_unknown_fields)]
45struct BindingFields {
46    intent: String,
47    operation_sequence: u64,
48    network: String,
49    caller: String,
50    target: String,
51    release: String,
52    request: String,
53}
54
55impl TryFrom<BindingFields> for OperationBindingRecord {
56    type Error = AttemptJournalRecordError;
57    fn try_from(fields: BindingFields) -> Result<Self, Self::Error> {
58        Self::new(&OperationBindingRequest {
59            intent: fields.intent,
60            operation_sequence: fields.operation_sequence,
61            network: fields.network,
62            caller: fields.caller,
63            target: fields.target,
64            release: fields.release,
65            request: fields.request,
66        })
67    }
68}
69
70impl OperationBindingRecord {
71    /// Canonicalize exact declared identities without IO or authorizing dispatch.
72    ///
73    /// # Errors
74    /// Rejects malformed principal text or SHA-256 digest fields.
75    pub fn new(request: &OperationBindingRequest) -> Result<Self, AttemptJournalRecordError> {
76        Ok(Self {
77            intent: canonical_hash(&request.intent)?,
78            operation_sequence: request.operation_sequence,
79            network: canonical_hash(&request.network)?,
80            caller: crate::model::principal::canonical_text(&request.caller)
81                .ok_or(AttemptJournalRecordError::InvalidPrincipal)?,
82            target: crate::model::principal::canonical_text(&request.target)
83                .ok_or(AttemptJournalRecordError::InvalidPrincipal)?,
84            release: canonical_hash(&request.release)?,
85            request: canonical_hash(&request.request)?,
86        })
87    }
88    /// Read immutable intent identity.
89    #[must_use]
90    pub fn intent(&self) -> &str {
91        &self.intent
92    }
93    /// Read the exact operation sequence.
94    #[must_use]
95    pub const fn operation_sequence(&self) -> u64 {
96        self.operation_sequence
97    }
98    /// Read the exact declared network fingerprint.
99    #[must_use]
100    pub fn network(&self) -> &str {
101        &self.network
102    }
103    /// Read the canonical selected caller principal.
104    #[must_use]
105    pub fn caller(&self) -> &str {
106        &self.caller
107    }
108    /// Read the canonical selected physical target principal.
109    #[must_use]
110    pub fn target(&self) -> &str {
111        &self.target
112    }
113    /// Read the integration-owned release digest.
114    #[must_use]
115    pub fn release(&self) -> &str {
116        &self.release
117    }
118    /// Read the exact canonical mutating request digest.
119    #[must_use]
120    pub fn request(&self) -> &str {
121        &self.request
122    }
123}
124
125/// Immutable finite allowances; consumption never refunds or replenishes these limits.
126#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
127#[serde(try_from = "BudgetFields")]
128pub struct AttemptBudgetRecord {
129    mutations: u32,
130    observations: u32,
131}
132
133#[derive(Deserialize)]
134#[serde(deny_unknown_fields)]
135struct BudgetFields {
136    mutations: u32,
137    observations: u32,
138}
139
140impl TryFrom<BudgetFields> for AttemptBudgetRecord {
141    type Error = AttemptJournalRecordError;
142    fn try_from(fields: BudgetFields) -> Result<Self, Self::Error> {
143        Self::new(fields.mutations, fields.observations)
144    }
145}
146
147impl AttemptBudgetRecord {
148    /// Admit finite independent mutation and observation allowances, including zero.
149    ///
150    /// # Errors
151    /// Rejects overflow or a total greater than [`MAX_OPERATION_ATTEMPTS`].
152    pub fn new(mutations: u32, observations: u32) -> Result<Self, AttemptJournalRecordError> {
153        if mutations
154            .checked_add(observations)
155            .is_none_or(|total| total > MAX_OPERATION_ATTEMPTS)
156        {
157            return Err(AttemptJournalRecordError::BudgetTooLarge);
158        }
159        Ok(Self {
160            mutations,
161            observations,
162        })
163    }
164    /// Read the immutable mutation-attempt ceiling.
165    #[must_use]
166    pub const fn mutations(&self) -> u32 {
167        self.mutations
168    }
169    /// Read the immutable reconciliation-observation ceiling.
170    #[must_use]
171    pub const fn observations(&self) -> u32 {
172        self.observations
173    }
174}
175
176/// Named exact declared operation and budget admitted by a journal owner.
177///
178/// This data alone is not fresh permission, settlement evidence or a dispatch permit.
179#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
180#[serde(deny_unknown_fields)]
181pub struct AttemptAuthorityRecord {
182    binding: OperationBindingRecord,
183    budget: AttemptBudgetRecord,
184}
185
186impl AttemptAuthorityRecord {
187    /// Bind already validated operation identity to immutable finite limits.
188    #[must_use]
189    pub const fn new(binding: OperationBindingRecord, budget: AttemptBudgetRecord) -> Self {
190        Self { binding, budget }
191    }
192    /// Read canonical declared identity.
193    #[must_use]
194    pub const fn binding(&self) -> &OperationBindingRecord {
195        &self.binding
196    }
197    /// Read immutable ceilings.
198    #[must_use]
199    pub const fn budget(&self) -> &AttemptBudgetRecord {
200        &self.budget
201    }
202    /// Hash exact immutable authority using the maintained domain-separated encoding.
203    ///
204    /// Encoding is the ASCII domain (including its NUL), four fixed 64-byte
205    /// lowercase digest strings in intent/network/release/request order, big-endian
206    /// u64 operation sequence, u8-length-prefixed canonical ASCII caller/target
207    /// text, then big-endian u32 mutation/observation limits. Mutable events are excluded.
208    #[must_use]
209    pub fn digest(&self) -> ArtifactChecksumRecord {
210        let mut bytes = EvidenceDigest::new(b"ic-backup/attempt-authority/v1\0");
211        for digest in [
212            &self.binding.intent,
213            &self.binding.network,
214            &self.binding.release,
215            &self.binding.request,
216        ] {
217            bytes.append(digest.as_bytes());
218        }
219        bytes.append(&self.binding.operation_sequence.to_be_bytes());
220        for principal in [&self.binding.caller, &self.binding.target] {
221            // Admission bounds canonical ASCII text to at most 63 bytes.
222            bytes.byte(principal.len().to_le_bytes()[0]);
223            bytes.append(principal.as_bytes());
224        }
225        bytes.append(&self.budget.mutations.to_be_bytes());
226        bytes.append(&self.budget.observations.to_be_bytes());
227        bytes.finish()
228    }
229}