Skip to main content

microsandbox_image/checkpoint/
layer_selection.rs

1//! Checked layer selection shared by compaction and dependent archive planning.
2//!
3//! These plans describe physical layers, not logical snapshot ancestry. Callers resolve and pin
4//! the chain before planning and retain that same chain until the operation finishes.
5
6use std::ops::Range;
7
8use thiserror::Error;
9
10//--------------------------------------------------------------------------------------------------
11// Types
12//--------------------------------------------------------------------------------------------------
13
14/// Invalid physical-layer selection; no disk or archive mutation has occurred.
15#[derive(Clone, Debug, Eq, Error, PartialEq)]
16pub enum LayerSelectionError {
17    /// A runtime or checkpoint chain must contain at least one physical layer.
18    #[error("a disk chain must contain at least one layer")]
19    EmptyChain,
20    /// Compaction must combine at least two sealed layers, including the base.
21    #[error("compaction requires at least two layers, including the base")]
22    TooFewLayersToCompact,
23    /// The explicit count includes the unsealed writable head or exceeds the chain.
24    #[error("cannot compact {requested} layers: only {sealed} sealed layers are available")]
25    CompactionIncludesWritableHead {
26        /// Requested oldest-first count, including the base.
27        requested: usize,
28        /// Number of sealed layers, excluding the writable head.
29        sealed: usize,
30    },
31    /// Explicit suffix selection must include at least one checkpoint layer.
32    #[error("cannot export the last {requested} layers of a {available}-layer checkpoint")]
33    InvalidExportCount {
34        /// Requested newest-first count.
35        requested: usize,
36        /// Total immutable layers in the checkpoint.
37        available: usize,
38    },
39    /// The supplied baseline is not the exact physical prefix of the target.
40    #[error(
41        "the export baseline is not an exact physical prefix; export the new base first or save a complete archive"
42    )]
43    IncompatibleExportBase,
44}
45
46/// A prefix of an oldest-first runtime chain to consolidate into one base.
47///
48/// The last runtime layer is writable even when the sandbox is stopped. It is never selected.
49/// A count is an upper bound on sealed inputs. Fewer than two sealed layers produces a no-op.
50#[derive(Clone, Debug, Eq, PartialEq)]
51pub struct DiskCompactionPlan {
52    input_layers: usize,
53    prefix_layers: usize,
54}
55
56/// A contiguous suffix of an immutable checkpoint chain to include in an archive.
57///
58/// Unlike a runtime chain, every checkpoint layer is sealed. Its newest layer must not be
59/// subtracted as though it were the live writable head. Omitted layers are explicit dependencies.
60#[derive(Clone, Debug, Eq, PartialEq)]
61pub struct DiskLayerExportPlan {
62    checkpoint_layers: usize,
63    required_layers: usize,
64}
65
66//--------------------------------------------------------------------------------------------------
67// Methods
68//--------------------------------------------------------------------------------------------------
69
70impl DiskCompactionPlan {
71    /// Resolve an optional upper bound of oldest layers, including the base but not the writable head.
72    pub fn new(runtime_layers: usize, layers: Option<usize>) -> Result<Self, LayerSelectionError> {
73        let sealed = runtime_layers
74            .checked_sub(1)
75            .ok_or(LayerSelectionError::EmptyChain)?;
76        let prefix_layers = match layers {
77            Some(0 | 1) => return Err(LayerSelectionError::TooFewLayersToCompact),
78            _ if sealed < 2 => 0,
79            Some(requested) => requested.min(sealed),
80            None => sealed,
81        };
82        Ok(Self {
83            input_layers: runtime_layers,
84            prefix_layers,
85        })
86    }
87
88    /// Selected oldest-first prefix; an empty range means no rewrite is necessary.
89    pub fn prefix(&self) -> Range<usize> {
90        0..self.prefix_layers
91    }
92
93    /// Layers retained after the prefix, including the writable head.
94    pub fn retained(&self) -> Range<usize> {
95        self.prefix_layers..self.input_layers
96    }
97
98    /// Physical layer count after replacing the selected prefix with a single base.
99    pub fn output_layers(&self) -> usize {
100        if self.prefix_layers == 0 {
101            self.input_layers
102        } else {
103            self.input_layers - self.prefix_layers + 1
104        }
105    }
106
107    /// Whether this operation would leave the chain unchanged.
108    pub fn is_noop(&self) -> bool {
109        self.prefix_layers == 0
110    }
111}
112
113impl DiskLayerExportPlan {
114    /// Include every physical layer, with no external disk-layer dependencies.
115    pub fn complete(checkpoint_layers: usize) -> Result<Self, LayerSelectionError> {
116        if checkpoint_layers == 0 {
117            return Err(LayerSelectionError::EmptyChain);
118        }
119        Ok(Self {
120            checkpoint_layers,
121            required_layers: 0,
122        })
123    }
124
125    /// Include the newest `layers` immutable layers; explicitly require the preceding prefix.
126    pub fn last(checkpoint_layers: usize, layers: usize) -> Result<Self, LayerSelectionError> {
127        let mut plan = Self::complete(checkpoint_layers)?;
128        if layers == 0 || layers > checkpoint_layers {
129            return Err(LayerSelectionError::InvalidExportCount {
130                requested: layers,
131                available: checkpoint_layers,
132            });
133        }
134        plan.required_layers = checkpoint_layers - layers;
135        Ok(plan)
136    }
137
138    /// Require an exact baseline prefix and include only later physical layers.
139    ///
140    /// Supply comparable identity records containing both layer identity and interpretation
141    /// metadata, not just filenames or logical parent IDs. Resolve archive-local backing paths
142    /// before comparison. A compacted representation is not interchangeable with its old prefix.
143    pub fn since<T: PartialEq>(target: &[T], baseline: &[T]) -> Result<Self, LayerSelectionError> {
144        let mut plan = Self::complete(target.len())?;
145        if baseline.is_empty() || !target.starts_with(baseline) {
146            return Err(LayerSelectionError::IncompatibleExportBase);
147        }
148        plan.required_layers = baseline.len();
149        Ok(plan)
150    }
151
152    /// Immutable payload layers to include, in oldest-first order.
153    pub fn included(&self) -> Range<usize> {
154        self.required_layers..self.checkpoint_layers
155    }
156
157    /// Exact omitted prefix that the importer must resolve before publication or restore.
158    pub fn required(&self) -> Range<usize> {
159        0..self.required_layers
160    }
161
162    /// Whether the disk payload can be opened without an externally supplied prefix.
163    pub fn is_disk_complete(&self) -> bool {
164        self.required_layers == 0
165    }
166}
167
168//--------------------------------------------------------------------------------------------------
169// Tests
170//--------------------------------------------------------------------------------------------------
171
172#[cfg(test)]
173mod tests {
174    use super::{DiskCompactionPlan, DiskLayerExportPlan, LayerSelectionError};
175
176    #[test]
177    fn compaction_count_includes_base_and_excludes_writable_head() {
178        let plan = DiskCompactionPlan::new(5, Some(3)).unwrap();
179        assert_eq!(plan.prefix(), 0..3);
180        assert_eq!(plan.retained(), 3..5);
181        assert_eq!(plan.output_layers(), 3);
182        assert!(!plan.is_noop());
183    }
184
185    #[test]
186    fn default_compacts_all_sealed_layers_only() {
187        let plan = DiskCompactionPlan::new(5, None).unwrap();
188        assert_eq!(plan.prefix(), 0..4);
189        assert_eq!(plan.retained(), 4..5);
190        assert_eq!(plan.output_layers(), 2);
191    }
192
193    #[test]
194    fn insufficient_sealed_layers_are_a_noop_not_a_conversion() {
195        for count in [1, 2] {
196            for requested in [None, Some(2), Some(usize::MAX)] {
197                let plan = DiskCompactionPlan::new(count, requested).unwrap();
198                assert!(plan.is_noop());
199                assert_eq!(plan.prefix(), 0..0);
200                assert_eq!(plan.retained(), 0..count);
201                assert_eq!(plan.output_layers(), count);
202            }
203        }
204    }
205
206    #[test]
207    fn explicit_compaction_uses_up_to_available_sealed_layers() {
208        assert_eq!(
209            DiskCompactionPlan::new(0, None),
210            Err(LayerSelectionError::EmptyChain)
211        );
212        for count in [0, 1] {
213            assert_eq!(
214                DiskCompactionPlan::new(5, Some(count)),
215                Err(LayerSelectionError::TooFewLayersToCompact)
216            );
217        }
218        for count in [5, 6, usize::MAX] {
219            let plan = DiskCompactionPlan::new(5, Some(count)).unwrap();
220            assert_eq!(plan.prefix(), 0..4);
221            assert_eq!(plan.retained(), 4..5);
222            assert_eq!(plan.output_layers(), 2);
223        }
224    }
225
226    #[test]
227    fn export_includes_the_checkpoint_top_layer() {
228        let plan = DiskLayerExportPlan::last(5, 2).unwrap();
229        assert_eq!(plan.included(), 3..5);
230        assert_eq!(plan.required(), 0..3);
231        assert!(!plan.is_disk_complete());
232    }
233
234    #[test]
235    fn exporting_all_layers_is_disk_complete() {
236        let plan = DiskLayerExportPlan::last(5, 5).unwrap();
237        assert_eq!(plan, DiskLayerExportPlan::complete(5).unwrap());
238        assert_eq!(plan.required(), 0..0);
239        assert!(plan.is_disk_complete());
240    }
241
242    #[test]
243    fn export_rejects_empty_and_out_of_range_counts() {
244        assert_eq!(
245            DiskLayerExportPlan::complete(0),
246            Err(LayerSelectionError::EmptyChain)
247        );
248        for count in [0, 6, usize::MAX] {
249            assert!(DiskLayerExportPlan::last(5, count).is_err());
250        }
251    }
252
253    #[test]
254    fn since_requires_the_exact_physical_prefix() {
255        let target = [("base", "raw"), ("a", "qcow2"), ("b", "qcow2")];
256        let plan = DiskLayerExportPlan::since(&target, &target[..2]).unwrap();
257        assert_eq!(plan.required(), 0..2);
258        assert_eq!(plan.included(), 2..3);
259        for invalid in [
260            vec![],
261            vec![("compacted", "raw")],
262            vec![("base", "qcow2")],
263            vec![("a", "qcow2")],
264        ] {
265            assert_eq!(
266                DiskLayerExportPlan::since(&target, &invalid),
267                Err(LayerSelectionError::IncompatibleExportBase)
268            );
269        }
270        assert!(DiskLayerExportPlan::since(&target[..1], &target).is_err());
271    }
272
273    #[test]
274    fn equal_disk_prefix_can_omit_all_disk_bytes_without_omitting_vm_state() {
275        // This only plans disk payload. The exporter must still include the target descriptor
276        // and all required memory/execution objects, even when the disk has not changed.
277        let target = ["base", "a"];
278        let plan = DiskLayerExportPlan::since(&target, &target).unwrap();
279        assert_eq!(plan.included(), 2..2);
280        assert_eq!(plan.required(), 0..2);
281        assert!(!plan.is_disk_complete());
282    }
283}