Skip to main content

krabka_verified/
snapshot.rs

1//! Snapshot-transfer chunk admission.
2
3use {creusot_std::prelude::*, std::cmp::Ordering};
4
5/// Pure decision for one snapshot response chunk.
6#[krabka_macros::model_type]
7pub enum SnapshotChunkDecision {
8    Restart,
9    Continue { next_position: i64 },
10    Complete,
11}
12
13/// Admission result for a fully assembled snapshot at the controller boundary.
14#[krabka_macros::model_type]
15pub enum SnapshotInstallDecision {
16    Reject,
17    Stale,
18    Install,
19}
20
21/// Classify a decoded, fully assembled snapshot against live controller state.
22#[ensures(match result {
23    SnapshotInstallDecision::Reject => downgrade_pending
24        || snapshot_end@ < 0
25        || snapshot_epoch@ < 0
26        || log_end@ < 0,
27    SnapshotInstallDecision::Stale => !downgrade_pending
28        && snapshot_end@ >= 0
29        && snapshot_epoch@ >= 0
30        && log_end@ >= 0
31        && snapshot_end@ <= log_end@,
32    SnapshotInstallDecision::Install => !downgrade_pending
33        && snapshot_end@ >= 0
34        && snapshot_epoch@ >= 0
35        && log_end@ >= 0
36        && snapshot_end@ > log_end@,
37})]
38#[must_use]
39pub fn snapshot_install_decision(
40    downgrade_pending: bool,
41    snapshot_end: i64,
42    snapshot_epoch: i32,
43    log_end: i64,
44) -> SnapshotInstallDecision {
45    if downgrade_pending || snapshot_end < 0 || snapshot_epoch < 0 || log_end < 0 {
46        SnapshotInstallDecision::Reject
47    } else if snapshot_end <= log_end {
48        SnapshotInstallDecision::Stale
49    } else {
50        SnapshotInstallDecision::Install
51    }
52}
53
54/// A checkpoint may prune only a nonnegative boundary already committed.
55#[ensures(result == (snapshot_end@ >= 0 && snapshot_end@ <= committed_end@))]
56#[must_use]
57pub fn snapshot_prune_admission(snapshot_end: i64, committed_end: i64) -> bool {
58    snapshot_end >= 0 && snapshot_end <= committed_end
59}
60
61/// Admit exactly the next bounded chunk for one fixed snapshot identity and size.
62#[ensures(match result {
63    SnapshotChunkDecision::Restart => !identity_matches
64        || received@ < 0
65        || chunk_len@ < 0
66        || max_size@ < 0
67        || declared_size@ < 0
68        || declared_size@ > max_size@
69        || position@ != received@
70        || match fixed_size {
71            Some(size) => size@ != declared_size@,
72            None => false,
73        }
74        || received@ + chunk_len@ > declared_size@,
75    SnapshotChunkDecision::Continue { next_position } => snapshot_chunk_shape_valid(identity_matches, fixed_size, received@, declared_size@, position@, chunk_len@, max_size@)
76        && next_position@ == received@ + chunk_len@
77        && next_position@ < declared_size@,
78    SnapshotChunkDecision::Complete => snapshot_chunk_shape_valid(identity_matches, fixed_size, received@, declared_size@, position@, chunk_len@, max_size@)
79        && received@ + chunk_len@ == declared_size@,
80})]
81#[must_use]
82pub fn snapshot_chunk_admission(
83    identity_matches: bool,
84    fixed_size: Option<i64>,
85    received: i64,
86    declared_size: i64,
87    position: i64,
88    chunk_len: i64,
89    max_size: i64,
90) -> SnapshotChunkDecision {
91    if !identity_matches
92        || received < 0
93        || chunk_len < 0
94        || max_size < 0
95        || declared_size < 0
96        || declared_size > max_size
97        || position != received
98    {
99        return SnapshotChunkDecision::Restart;
100    }
101    if let Some(size) = fixed_size
102        && size != declared_size
103    {
104        return SnapshotChunkDecision::Restart;
105    }
106    let Some(next_position) = received.checked_add(chunk_len) else {
107        return SnapshotChunkDecision::Restart;
108    };
109    match next_position.cmp(&declared_size) {
110        Ordering::Greater => SnapshotChunkDecision::Restart,
111        Ordering::Equal => SnapshotChunkDecision::Complete,
112        Ordering::Less => SnapshotChunkDecision::Continue { next_position },
113    }
114}
115
116// cargo-mutants: #[cfg(creusot)] spec function; not compiled outside Creusot, so no test can tell.
117#[cfg(creusot)]
118#[cfg_attr(test, mutants::skip)]
119#[logic(open)]
120/// A chunk continues one fixed identity and declared size at the exact received position.
121fn snapshot_chunk_shape_valid(
122    identity_matches: bool,
123    fixed_size: Option<i64>,
124    received: Int,
125    declared_size: Int,
126    position: Int,
127    chunk_len: Int,
128    max_size: Int,
129) -> bool {
130    pearlite! { identity_matches && received >= 0 && chunk_len >= 0 && max_size >= 0
131    && declared_size >= 0 && declared_size <= max_size && position == received
132    && match fixed_size { Some(size) => size@ == declared_size, None => true, } }
133}
134
135#[cfg(test)]
136mod tests {
137    use {
138        super::{
139            SnapshotChunkDecision, SnapshotInstallDecision, snapshot_chunk_admission,
140            snapshot_install_decision, snapshot_prune_admission,
141        },
142        assert2::check,
143    };
144
145    #[yare::parameterized(
146        empty_stale = { false, 0, 0, 0, SnapshotInstallDecision::Stale },
147        after_empty = { false, 1, 0, 0, SnapshotInstallDecision::Install },
148        beyond_log_end = { false, 11, 3, 10, SnapshotInstallDecision::Install },
149        at_log_end = { false, 10, 3, 10, SnapshotInstallDecision::Stale },
150        before_log_end = { false, 9, 3, 10, SnapshotInstallDecision::Stale },
151        pending = { true, 11, 3, 10, SnapshotInstallDecision::Reject },
152        negative_end = { false, -1, 3, 10, SnapshotInstallDecision::Reject },
153        negative_epoch = { false, 11, -1, 10, SnapshotInstallDecision::Reject },
154        negative_log_end = { false, 11, 3, -1, SnapshotInstallDecision::Reject },
155    )]
156    fn install_admission_rejects_malformed_and_separates_stale_from_future(
157        pending: bool,
158        end: i64,
159        epoch: i32,
160        log_end: i64,
161        expected: SnapshotInstallDecision,
162    ) {
163        check!(snapshot_install_decision(pending, end, epoch, log_end) == expected);
164    }
165
166    #[test]
167    fn prune_admission_never_crosses_the_committed_frontier() {
168        check!(snapshot_prune_admission(10, 10));
169        check!(snapshot_prune_admission(9, 10));
170        check!(!snapshot_prune_admission(11, 10));
171        check!(!snapshot_prune_admission(-1, 10));
172    }
173
174    #[yare::parameterized(
175        next_chunk = {
176            true,
177            None,
178            0,
179            6,
180            0,
181            3,
182            10,
183            SnapshotChunkDecision::Continue { next_position: 3 },
184        },
185        final_chunk = { true, Some(6), 3, 6, 3, 3, 10, SnapshotChunkDecision::Complete },
186        empty_snapshot = { true, None, 0, 0, 0, 0, 10, SnapshotChunkDecision::Complete },
187        empty_snapshot_at_zero_cap = { true, None, 0, 0, 0, 0, 0, SnapshotChunkDecision::Complete },
188        exact_max_size = { true, None, 0, 10, 0, 10, 10, SnapshotChunkDecision::Complete },
189        identity_changed = { false, Some(6), 3, 6, 3, 3, 10, SnapshotChunkDecision::Restart },
190        fixed_size_changed = { true, Some(7), 3, 6, 3, 3, 10, SnapshotChunkDecision::Restart },
191        position_replayed = { true, Some(6), 3, 6, 0, 3, 10, SnapshotChunkDecision::Restart },
192        position_skipped = { true, Some(6), 3, 6, 2, 3, 10, SnapshotChunkDecision::Restart },
193        chunk_starts_after_position = {
194            true,
195            Some(6),
196            3,
197            6,
198            4,
199            2,
200            10,
201            SnapshotChunkDecision::Restart,
202        },
203        chunk_exceeds_declared_size = {
204            true,
205            Some(6),
206            3,
207            6,
208            3,
209            4,
210            10,
211            SnapshotChunkDecision::Restart,
212        },
213        negative_received = { true, None, -1, 6, -1, 3, 10, SnapshotChunkDecision::Restart },
214        negative_chunk_size = { true, None, 0, 6, 0, -1, 10, SnapshotChunkDecision::Restart },
215        negative_max_size = { true, None, 0, 6, 0, 3, -1, SnapshotChunkDecision::Restart },
216        negative_declared_size = { true, None, 0, -1, 0, 0, 10, SnapshotChunkDecision::Restart },
217        exceeds_max_size = { true, None, 0, 11, 0, 1, 10, SnapshotChunkDecision::Restart },
218        addition_overflow = {
219            true,
220            None,
221            i64::MAX,
222            i64::MAX,
223            i64::MAX,
224            1,
225            i64::MAX,
226            SnapshotChunkDecision::Restart,
227        },
228    )]
229    fn chunk_admission_covers_retries_order_bounds_and_completion(
230        identity_matches: bool,
231        fixed_size: Option<i64>,
232        received: i64,
233        declared_size: i64,
234        position: i64,
235        chunk_len: i64,
236        max_size: i64,
237        expected: SnapshotChunkDecision,
238    ) {
239        check!(
240            snapshot_chunk_admission(
241                identity_matches,
242                fixed_size,
243                received,
244                declared_size,
245                position,
246                chunk_len,
247                max_size
248            ) == expected
249        );
250    }
251}