Skip to main content

ic_backup/model/ic_snapshot_download/
mod.rs

1//! Bounded metadata-derived exact download plans; no calls, spending or transfer proof.
2
3use crate::model::{
4    attempt_journal::{AttemptBudgetRecord, AttemptJournalRecordError},
5    effect_graph::{EffectGraphError, EffectGraphRecord, EffectNodeRecord, EffectNodeRequest},
6    execution_workflow::{
7        ExecutionStageBindingRecord, ExecutionStagePredecessorRecord, ExecutionWorkflowError,
8        ExecutionWorkflowRecord,
9    },
10    ic_snapshot_data::{
11        IcSnapshotDataError, IcSnapshotDataRequest, MAX_IC_SNAPSHOT_DATA_CHUNK_BYTES,
12    },
13    ic_snapshot_metadata::IcSnapshotMetadataReply,
14    operation_plan::{
15        OperationPlanError, OperationPlanRecord, OperationPlanRequest, PlanBudgetRecord,
16        PlannedOperationRecord, PlannedOperationRequest,
17    },
18};
19use ic_management_canister_types::SnapshotDataKind;
20use thiserror::Error;
21
22/// Exact finite data requests and their original allocation-bound ordinary child plan.
23///
24/// Requests cover module, heap and stable regions in that order, then every known
25/// chunk in original metadata order. Empty regions need no requests; even empty
26/// known chunks require a hash-checked reply. Opaque operation IDs are zero-based
27/// ordinals with explicit sequential dependencies for the contiguous writer.
28/// Every request has one mutation-lane replicated update and zero recovery calls.
29/// Lost replies therefore stop with pending spending; this is not a retry schedule.
30/// The child aggregate retains original stage ceilings and leaves spare allowance
31/// unassigned. An entirely read-free snapshot has no child plan or binding.
32pub struct IcSnapshotDownloadPlan<'workflow, 'metadata> {
33    workflow: &'workflow ExecutionWorkflowRecord,
34    sequence: u64,
35    metadata: &'metadata IcSnapshotMetadataReply<'metadata>,
36    requests: Vec<IcSnapshotDataRequest<'metadata>>,
37    plan: Option<OperationPlanRecord>,
38}
39impl<'workflow, 'metadata> IcSnapshotDownloadPlan<'workflow, 'metadata> {
40    /// Derive exact data payloads within the original target and mutation allocation.
41    ///
42    /// Count checked region ceilings and known chunks before allocating/encoding
43    /// requests. No iteration or allocation grows with an unadmitted remote nat64.
44    /// Fresh access, authentic metadata/extent custody and execution remain separate.
45    /// # Errors
46    /// Rejects unknown/wrong stages, invalid chunks, arithmetic overflow, insufficient
47    /// original allowance and existing bounded request/plan admission failures.
48    pub fn new(
49        workflow: &'workflow ExecutionWorkflowRecord,
50        sequence: u64,
51        metadata: &'metadata IcSnapshotMetadataReply<'metadata>,
52        chunk_bytes: u64,
53    ) -> Result<Self, IcSnapshotDownloadPlanningError> {
54        let stage = workflow.stage(sequence)?;
55        if stage.target() != metadata.request().target() {
56            return Err(IcSnapshotDownloadPlanningError::MetadataMismatch);
57        }
58        if chunk_bytes == 0 || chunk_bytes > MAX_IC_SNAPSHOT_DATA_CHUNK_BYTES as u64 {
59            return Err(IcSnapshotDownloadPlanningError::InvalidChunkSize);
60        }
61        let values = metadata.metadata();
62        let sizes = [
63            values.wasm_module_size,
64            values.wasm_memory_size,
65            values.stable_memory_size,
66        ];
67        let mut count = u64::try_from(values.wasm_chunk_store.len())
68            .map_err(|_| IcSnapshotDownloadPlanningError::CountOverflow)?;
69        for size in sizes {
70            let region = size / chunk_bytes + u64::from(size % chunk_bytes != 0);
71            count = count
72                .checked_add(region)
73                .ok_or(IcSnapshotDownloadPlanningError::CountOverflow)?;
74        }
75        if count > u64::from(stage.budget().mutations()) {
76            return Err(IcSnapshotDownloadPlanningError::InsufficientAllowance {
77                required: count,
78                original: stage.budget().mutations(),
79            });
80        }
81        let capacity =
82            usize::try_from(count).map_err(|_| IcSnapshotDownloadPlanningError::CountOverflow)?;
83        let mut requests = Vec::with_capacity(capacity);
84        for (region, total) in sizes.into_iter().enumerate() {
85            let mut offset = 0;
86            while offset < total {
87                let size = (total - offset).min(chunk_bytes);
88                let kind = match region {
89                    0 => SnapshotDataKind::WasmModule { offset, size },
90                    1 => SnapshotDataKind::WasmMemory { offset, size },
91                    _ => SnapshotDataKind::StableMemory { offset, size },
92                };
93                requests.push(IcSnapshotDataRequest::new(metadata, kind)?);
94                offset += size; // The admitted size never exceeds total - offset.
95            }
96        }
97        for chunk in &values.wasm_chunk_store {
98            requests.push(IcSnapshotDataRequest::new(
99                metadata,
100                SnapshotDataKind::WasmChunk {
101                    hash: chunk.hash.clone(),
102                },
103            )?);
104        }
105        let plan = if requests.is_empty() {
106            None
107        } else {
108            let mut nodes = Vec::with_capacity(capacity);
109            let mut operations = Vec::with_capacity(capacity);
110            for (index, request) in requests.iter().enumerate() {
111                let sequence = u64::try_from(index)
112                    .map_err(|_| IcSnapshotDownloadPlanningError::CountOverflow)?;
113                nodes.push(EffectNodeRecord::new(EffectNodeRequest {
114                    operation_sequence: sequence,
115                    depends_on: sequence.checked_sub(1).into_iter().collect(),
116                })?);
117                operations.push(PlannedOperationRecord::new(PlannedOperationRequest {
118                    operation_sequence: sequence,
119                    target: stage.target().into(),
120                    request: request.digest().hash().into(),
121                    budget: AttemptBudgetRecord::new(1, 0)?,
122                })?);
123            }
124            let original = workflow.allocation();
125            Some(OperationPlanRecord::new(OperationPlanRequest {
126                context: original.context().clone(),
127                inventory: original.inventory().clone(),
128                selected_targets: vec![stage.target().into()],
129                graph: EffectGraphRecord::new(nodes)?,
130                operations,
131                budget: PlanBudgetRecord::new(
132                    stage.budget().mutations(),
133                    stage.budget().observations(),
134                )?,
135            })?)
136        };
137        Ok(Self {
138            workflow,
139            sequence,
140            metadata,
141            requests,
142            plan,
143        })
144    }
145    /// Read exact ordered requests; operation IDs are their zero-based indices.
146    #[must_use]
147    pub fn requests(&self) -> &[IcSnapshotDataRequest<'metadata>] {
148        &self.requests
149    }
150    /// Read the original-allocation child plan. None means no data calls are needed,
151    /// not that capture, durable publication or complete transfer is qualified.
152    #[must_use]
153    pub const fn plan(&self) -> Option<&OperationPlanRecord> {
154        self.plan.as_ref()
155    }
156
157    /// Re-admit an exact retained data-stage binding and its original metadata evidence.
158    /// This structural association grants no fresh permission or transfer attestation.
159    pub(crate) fn validate_binding(
160        &self,
161        binding: &ExecutionStageBindingRecord,
162    ) -> Result<(), IcSnapshotDownloadPlanningError> {
163        let plan = self
164            .plan
165            .as_ref()
166            .ok_or(IcSnapshotDownloadPlanningError::NoDataReads)?;
167        binding.validate(self.workflow, plan)?;
168        if binding.stage_sequence() != self.sequence
169            || !binding
170                .predecessors()
171                .iter()
172                .any(|row| row.learned_evidence() == &self.metadata.digest())
173        {
174            return Err(IcSnapshotDownloadPlanningError::MetadataMismatch);
175        }
176        Ok(())
177    }
178    /// Bind the exact original metadata stage and reply evidence before stage creation.
179    ///
180    /// The source stage must contain exactly one metadata request under this original
181    /// workflow. Its direct predecessor row must retain its binding and this exact
182    /// metadata request/raw-reply evidence digest. Existing stage persistence still
183    /// admits the complete Applied source journals and settlement. These checks do
184    /// not authenticate metadata or prove that an opaque receipt describes its reply.
185    /// # Errors
186    /// Rejects a read-free plan, wrong metadata stage/plan/input or original dependencies.
187    pub fn bind(
188        &self,
189        metadata_binding: &ExecutionStageBindingRecord,
190        metadata_plan: &OperationPlanRecord,
191        predecessors: Vec<ExecutionStagePredecessorRecord>,
192    ) -> Result<ExecutionStageBindingRecord, IcSnapshotDownloadPlanningError> {
193        let plan = self
194            .plan
195            .as_ref()
196            .ok_or(IcSnapshotDownloadPlanningError::NoDataReads)?;
197        metadata_binding.validate(self.workflow, metadata_plan)?;
198        if metadata_plan.operations().len() != 1
199            || metadata_plan.operations()[0].request() != self.metadata.request().digest().hash()
200            || !predecessors.iter().any(|row| {
201                row.stage_sequence() == metadata_binding.stage_sequence()
202                    && row.binding() == &metadata_binding.digest()
203                    && row.learned_evidence() == &self.metadata.digest()
204            })
205        {
206            return Err(IcSnapshotDownloadPlanningError::MetadataMismatch);
207        }
208        Ok(ExecutionStageBindingRecord::new(
209            self.workflow,
210            self.sequence,
211            plan,
212            predecessors,
213        )?)
214    }
215}
216
217/// Pure planning failure; no filesystem, provider, reservation or receipt changes.
218#[derive(Debug, Error)]
219pub enum IcSnapshotDownloadPlanningError {
220    /// Chunk size is outside the existing 1..=1 MiB request boundary.
221    #[error("invalid snapshot download chunk size")]
222    InvalidChunkSize,
223    /// Combined remote declared region counts cannot fit nat64.
224    #[error("snapshot download request count overflow")]
225    CountOverflow,
226    /// Original stage cannot pay for every exact required data request.
227    #[error("snapshot download needs {required} updates; original allocation is {original}")]
228    InsufficientAllowance {
229        /// Complete exact required update count.
230        required: u64,
231        /// Original stage mutation ceiling, excluding workflow headroom.
232        original: u32,
233    },
234    /// Target, original metadata stage/request or exact reply evidence changed.
235    #[error("snapshot download metadata differs from original stage")]
236    MetadataMismatch,
237    /// There is no data call to bind; no placeholder operation is created.
238    #[error("snapshot download has no data reads to bind")]
239    NoDataReads,
240    /// Canonical original plan or allocation rejected.
241    #[error(transparent)]
242    Plan(#[from] OperationPlanError),
243    /// Existing explicit graph owner rejected.
244    #[error(transparent)]
245    Graph(#[from] EffectGraphError),
246    /// Existing immutable stage owner rejected.
247    #[error(transparent)]
248    Workflow(#[from] ExecutionWorkflowError),
249    /// Existing exact metadata-bound data request owner rejected.
250    #[error(transparent)]
251    Data(#[from] IcSnapshotDataError),
252    /// Existing per-operation budget owner rejected.
253    #[error(transparent)]
254    Budget(#[from] AttemptJournalRecordError),
255}
256
257#[cfg(test)]
258pub(crate) mod tests;