ic-backup 0.5.1

Host-side snapshot backup and same-release recovery for Internet Computer canisters
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
//! Immutable local binding of declarations to graph operations and original attempt authority.

mod budget;
mod context;
mod operation;
pub use budget::{AllocatedAttemptsView, MAX_PLAN_ATTEMPTS, PlanBudgetRecord};
pub use context::{PlanContextRecord, PlanContextRequest};
pub use operation::{PlannedOperationRecord, PlannedOperationRequest};

use crate::model::{
    artifacts::{ArtifactChecksumRecord, ChecksumError},
    attempt_journal::{
        AttemptAuthorityRecord, AttemptJournalRecordError, OperationBindingRecord,
        OperationBindingRequest,
    },
    effect_graph::{EffectGraphRecord, MAX_EFFECT_OPERATIONS},
    inventory::{InventoryRecord, InventoryRecordError, MAX_INVENTORY_TARGETS},
};
use serde::{Deserialize, Deserializer, Serialize, de};
use std::{collections::BTreeSet, fmt};
use thiserror::Error;

/// Maximum encoded input and canonical output bytes admitted by plan persistence.
pub const MAX_OPERATION_PLAN_BYTES: u64 = 1024 * 1024;

/// Passive immutable-plan declaration assembled from validated owner records.
#[derive(Clone, Debug)]
pub struct OperationPlanRequest {
    /// Exact declared network/caller/release shared by operations.
    pub context: PlanContextRecord,
    /// Full original declared physical inventory, including unselected parents.
    pub inventory: InventoryRecord,
    /// Nonempty explicit physical selection, without role or privileged-root semantics.
    pub selected_targets: Vec<String>,
    /// Exact declared dependencies over the operation table.
    pub graph: EffectGraphRecord,
    /// One exact target/request/original-budget binding per graph operation.
    pub operations: Vec<PlannedOperationRecord>,
    /// Original declared aggregate attempt ceilings.
    pub budget: PlanBudgetRecord,
}

/// Canonical v1 operation plan binding declarations to exact finite attempt authority.
///
/// This is not an authenticated backup/restore plan, fresh preflight, qualified
/// request payload, consistency/lifecycle contract or executable dispatch permit.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(try_from = "PlanFields")]
pub struct OperationPlanRecord {
    version: u16,
    context: PlanContextRecord,
    inventory: InventoryRecord,
    selected_targets: Vec<String>,
    graph: EffectGraphRecord,
    operations: Vec<PlannedOperationRecord>,
    budget: PlanBudgetRecord,
    #[serde(skip)]
    allocated: AllocatedAttemptsView,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct PlanFields {
    version: u16,
    context: PlanContextRecord,
    inventory: InventoryRecord,
    #[serde(deserialize_with = "bounded_selected")]
    selected_targets: Vec<String>,
    graph: EffectGraphRecord,
    #[serde(deserialize_with = "bounded_operations")]
    operations: Vec<PlannedOperationRecord>,
    budget: PlanBudgetRecord,
}
impl TryFrom<PlanFields> for OperationPlanRecord {
    type Error = OperationPlanError;
    fn try_from(fields: PlanFields) -> Result<Self, Self::Error> {
        if fields.version != 1 {
            return Err(OperationPlanError::UnsupportedVersion(fields.version));
        }
        Self::new(OperationPlanRequest {
            context: fields.context,
            inventory: fields.inventory,
            selected_targets: fields.selected_targets,
            graph: fields.graph,
            operations: fields.operations,
            budget: fields.budget,
        })
    }
}
impl OperationPlanRecord {
    /// Admit canonical exact graph/table/physical selection and aggregate attempt binding.
    ///
    /// # Errors
    /// Rejects invalid selection, operation identity mismatch, unused targets and excessive allowances.
    pub fn new(mut request: OperationPlanRequest) -> Result<Self, OperationPlanError> {
        if request.selected_targets.is_empty() {
            return Err(OperationPlanError::EmptySelection);
        }
        if request.selected_targets.len() > MAX_INVENTORY_TARGETS {
            return Err(OperationPlanError::TooManySelected);
        }
        for id in &mut request.selected_targets {
            *id = request.inventory.target(id)?.canister_id().into();
        }
        request.selected_targets.sort();
        for pair in request.selected_targets.windows(2) {
            if pair[0] == pair[1] {
                return Err(OperationPlanError::DuplicateSelected(pair[0].clone()));
            }
        }
        if request.operations.len() > MAX_EFFECT_OPERATIONS {
            return Err(OperationPlanError::TooManyOperations);
        }
        request
            .operations
            .sort_by_key(PlannedOperationRecord::operation_sequence);
        for pair in request.operations.windows(2) {
            if pair[0].operation_sequence() == pair[1].operation_sequence() {
                return Err(OperationPlanError::DuplicateOperation(
                    pair[0].operation_sequence(),
                ));
            }
        }
        if request.operations.len() != request.graph.nodes().len() {
            return Err(OperationPlanError::OperationCountMismatch);
        }
        let selected: BTreeSet<_> = request
            .selected_targets
            .iter()
            .map(String::as_str)
            .collect();
        let mut used = BTreeSet::new();
        let mut allocated = AllocatedAttemptsView {
            mutations: 0,
            observations: 0,
        };
        for (node, operation) in request.graph.nodes().iter().zip(&request.operations) {
            if node.operation_sequence() != operation.operation_sequence() {
                return Err(OperationPlanError::OperationGraphMismatch {
                    expected: node.operation_sequence(),
                    actual: operation.operation_sequence(),
                });
            }
            if !selected.contains(operation.target()) {
                return Err(OperationPlanError::UnselectedTarget(
                    operation.target().into(),
                ));
            }
            used.insert(operation.target());
            allocated.mutations = allocated
                .mutations
                .checked_add(operation.budget().mutations())
                .ok_or(OperationPlanError::BudgetTooLarge)?;
            allocated.observations = allocated
                .observations
                .checked_add(operation.budget().observations())
                .ok_or(OperationPlanError::BudgetTooLarge)?;
        }
        if !request.budget.admits(allocated) {
            return Err(OperationPlanError::AssignedBudgetExceeded);
        }
        for id in &request.selected_targets {
            if !used.contains(id.as_str()) {
                return Err(OperationPlanError::UnusedTarget(id.clone()));
            }
        }
        Ok(Self {
            version: 1,
            context: request.context,
            inventory: request.inventory,
            selected_targets: request.selected_targets,
            graph: request.graph,
            operations: request.operations,
            budget: request.budget,
            allocated,
        })
    }
    /// Read immutable declared context.
    #[must_use]
    pub const fn context(&self) -> &PlanContextRecord {
        &self.context
    }
    /// Read the full original declared inventory.
    #[must_use]
    pub const fn inventory(&self) -> &InventoryRecord {
        &self.inventory
    }
    /// Read canonical exact physical selection, without effect-order semantics.
    #[must_use]
    pub fn selected_targets(&self) -> &[String] {
        &self.selected_targets
    }
    /// Read the bound original explicit dependency graph.
    #[must_use]
    pub const fn graph(&self) -> &EffectGraphRecord {
        &self.graph
    }
    /// Read operation bindings in canonical sequence order.
    #[must_use]
    pub fn operations(&self) -> &[PlannedOperationRecord] {
        &self.operations
    }
    /// Read original aggregate attempt ceilings.
    #[must_use]
    pub const fn budget(&self) -> &PlanBudgetRecord {
        &self.budget
    }
    /// Project assigned allowances only; this performs no observations or consumption.
    #[must_use]
    pub const fn allocated_attempts(&self) -> AllocatedAttemptsView {
        self.allocated
    }
    /// Resolve an exact declared operation binding.
    ///
    /// # Errors
    /// Rejects an operation absent from the exact original plan.
    pub fn operation(&self, sequence: u64) -> Result<&PlannedOperationRecord, OperationPlanError> {
        self.operations
            .binary_search_by_key(&sequence, PlannedOperationRecord::operation_sequence)
            .map(|index| &self.operations[index])
            .map_err(|_| OperationPlanError::UnknownOperation(sequence))
    }
    /// Derive exact existing attempt authority under the full original canonical plan digest.
    ///
    /// Repeated derivation returns the same declaration and never creates/resets a journal.
    /// Fresh authority, actual request bytes and dispatch admission stay caller-owned.
    ///
    /// # Errors
    /// Rejects unknown operations or invalid derived identity admission.
    pub fn attempt_authority(
        &self,
        sequence: u64,
    ) -> Result<AttemptAuthorityRecord, OperationPlanError> {
        let operation = self.operation(sequence)?;
        self.authority_for_operation(operation, &self.digest())
    }
    /// Derive every original authority in canonical sequence order with one full plan hash.
    ///
    /// This avoids repeated full-plan encoding for bounded complete evidence scans.
    /// It grants no new allowance, journal creation/reset or fresh dispatch permission.
    /// # Errors
    /// Rejects invalid derived identity admission through the same scalar authority owner.
    pub fn attempt_authorities(&self) -> Result<Vec<AttemptAuthorityRecord>, OperationPlanError> {
        let intent = self.digest();
        self.operations
            .iter()
            .map(|operation| self.authority_for_operation(operation, &intent))
            .collect()
    }
    fn authority_for_operation(
        &self,
        operation: &PlannedOperationRecord,
        intent: &ArtifactChecksumRecord,
    ) -> Result<AttemptAuthorityRecord, OperationPlanError> {
        let binding = OperationBindingRecord::new(&OperationBindingRequest {
            intent: intent.hash().into(),
            operation_sequence: operation.operation_sequence(),
            network: self.context.network().into(),
            caller: self.context.caller().into(),
            target: operation.target().into(),
            release: self.context.release().into(),
            request: operation.request().into(),
        })?;
        Ok(AttemptAuthorityRecord::new(
            binding,
            operation.budget().clone(),
        ))
    }
    /// Hash the entire canonical declaration using the maintained nonrecursive v1 encoding.
    ///
    /// Derived intent/authority, allocated views and local progress are excluded.
    #[must_use]
    pub fn digest(&self) -> ArtifactChecksumRecord {
        let mut bytes = b"ic-backup/operation-plan/v1\0".to_vec();
        bytes.extend_from_slice(self.context.network().as_bytes());
        append_principal(&mut bytes, self.context.caller());
        bytes.extend_from_slice(self.context.release().as_bytes());
        bytes.extend_from_slice(self.inventory.digest().hash().as_bytes());
        bytes.extend_from_slice(self.graph.digest().hash().as_bytes());
        append_count(&mut bytes, self.selected_targets.len());
        for target in &self.selected_targets {
            append_principal(&mut bytes, target);
        }
        bytes.extend_from_slice(&self.budget.mutations().to_be_bytes());
        bytes.extend_from_slice(&self.budget.observations().to_be_bytes());
        append_count(&mut bytes, self.operations.len());
        for operation in &self.operations {
            bytes.extend_from_slice(&operation.operation_sequence().to_be_bytes());
            append_principal(&mut bytes, operation.target());
            bytes.extend_from_slice(operation.request().as_bytes());
            bytes.extend_from_slice(&operation.budget().mutations().to_be_bytes());
            bytes.extend_from_slice(&operation.budget().observations().to_be_bytes());
        }
        ArtifactChecksumRecord::from_bytes(&bytes)
    }
}
fn append_principal(bytes: &mut Vec<u8>, text: &str) {
    // Owning boundaries admit at most 63 canonical ASCII bytes, so the low byte is exact.
    bytes.push(text.len().to_le_bytes()[0]);
    bytes.extend_from_slice(text.as_bytes());
}
#[expect(
    clippy::cast_possible_truncation,
    reason = "validated selection/operation counts are at most 8192"
)]
fn append_count(bytes: &mut Vec<u8>, count: usize) {
    bytes.extend_from_slice(&(count as u32).to_be_bytes());
}
fn bounded_selected<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<String>, D::Error> {
    bounded::<D, String, MAX_INVENTORY_TARGETS>(deserializer)
}
fn bounded_operations<'de, D: Deserializer<'de>>(
    deserializer: D,
) -> Result<Vec<PlannedOperationRecord>, D::Error> {
    bounded::<D, PlannedOperationRecord, MAX_EFFECT_OPERATIONS>(deserializer)
}
fn bounded<'de, D, T, const LIMIT: usize>(deserializer: D) -> Result<Vec<T>, D::Error>
where
    D: Deserializer<'de>,
    T: Deserialize<'de>,
{
    struct Visitor<T, const LIMIT: usize>(std::marker::PhantomData<T>);
    impl<'de, T: Deserialize<'de>, const LIMIT: usize> de::Visitor<'de> for Visitor<T, LIMIT> {
        type Value = Vec<T>;
        fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
            write!(f, "at most {LIMIT} plan entries")
        }
        fn visit_seq<A: de::SeqAccess<'de>>(
            self,
            mut sequence: A,
        ) -> Result<Self::Value, A::Error> {
            let mut entries = Vec::new();
            while entries.len() < LIMIT {
                match sequence.next_element()? {
                    Some(entry) => entries.push(entry),
                    None => return Ok(entries),
                }
            }
            if sequence.next_element::<de::IgnoredAny>()?.is_some() {
                return Err(de::Error::custom(format!(
                    "plan list exceeds {LIMIT} entries"
                )));
            }
            Ok(entries)
        }
    }
    deserializer.deserialize_seq(Visitor::<T, LIMIT>(std::marker::PhantomData))
}

/// Typed original plan identity, structural binding or finite allowance rejection.
#[derive(Debug, Error)]
pub enum OperationPlanError {
    /// Only protocol generation v1 is maintained.
    #[error("unsupported operation plan version {0}")]
    UnsupportedVersion(u16),
    /// At least one exact physical selection is required.
    #[error("operation plan has no selected targets")]
    EmptySelection,
    /// Exact selection exceeds the maintained inventory bound.
    #[error("operation plan exceeds {MAX_INVENTORY_TARGETS} selected targets")]
    TooManySelected,
    /// Operation bindings exceed the maintained graph count.
    #[error("operation plan exceeds {MAX_EFFECT_OPERATIONS} operations")]
    TooManyOperations,
    /// Principal text fails canonical admission at this named field.
    #[error("invalid operation plan principal in {0}")]
    InvalidPrincipal(&'static str),
    /// Equivalent physical target was explicitly selected twice.
    #[error("duplicate selected plan target {0}")]
    DuplicateSelected(String),
    /// Operation identity was bound more than once.
    #[error("duplicate planned operation {0}")]
    DuplicateOperation(u64),
    /// Operation table does not have one binding per graph node.
    #[error("operation table count differs from dependency graph")]
    OperationCountMismatch,
    /// Exact table identity differs from its corresponding canonical graph node.
    #[error("operation table differs from graph: expected {expected}, actual {actual}")]
    OperationGraphMismatch {
        /// Exact graph operation identity.
        expected: u64,
        /// Rejected table identity.
        actual: u64,
    },
    /// Exact operation target lies outside explicit selection.
    #[error("operation targets unselected physical identity {0}")]
    UnselectedTarget(String),
    /// A selected physical target has no bound operation.
    #[error("selected plan target has no operation {0}")]
    UnusedTarget(String),
    /// Requested exact operation is absent.
    #[error("unknown planned operation {0}")]
    UnknownOperation(u64),
    /// Aggregate ceilings overflow or exceed the maintained maximum.
    #[error("operation plan attempt ceiling exceeds {MAX_PLAN_ATTEMPTS}")]
    BudgetTooLarge,
    /// Assigned original allowances exceed a declared mutation/observation ceiling.
    #[error("assigned operation attempts exceed original plan ceilings")]
    AssignedBudgetExceeded,
    /// Digest text fails canonical admission.
    #[error(transparent)]
    Checksum(#[from] ChecksumError),
    /// Owning inventory rejects selected identity.
    #[error(transparent)]
    Inventory(#[from] InventoryRecordError),
    /// Existing attempt authority boundary rejected derivation.
    #[error(transparent)]
    Attempt(#[from] AttemptJournalRecordError),
}

#[cfg(test)]
pub(crate) mod tests;