Skip to main content

hashtree_lmdb/
migration_audit.rs

1use crate::{managed_env::ManagedEnv, map_heed_error};
2use hashtree_core::store::StoreError;
3use hashtree_core::types::Hash;
4use heed::types::Bytes;
5use heed::{Database, EnvFlags, EnvOpenOptions};
6use sha2::{Digest, Sha256};
7use std::path::{Path, PathBuf};
8
9// The audit retains independent source-reconciliation and target-body proof
10// B-trees. At the 4 GiB reusable-evidence bound, each can contain roughly
11// 107 million hash/size rows; 32 GiB leaves room for both trees plus LMDB page
12// split overhead. This is a virtual address-space cap and grows on demand.
13const AUDIT_MAP_SIZE: usize = 32 * 1024 * 1024 * 1024;
14// The fourth named DB quarantines target evidence until the complete external
15// manifest has been authenticated. Keep one spare so older environments can
16// still fail on their authority binding rather than MDB_DBS_FULL.
17const AUDIT_DATABASE_COUNT: u32 = 5;
18const BINDING_KEY: &[u8] = b"authority-binding-v1";
19const TARGET_CURSOR_KEY: &[u8] = b"target-catalog-cursor-v1";
20const TARGET_FENCE_BINDING_KEY: &[u8] = b"target-fence-binding-v1";
21const TARGET_IMPORT_STAGING_AUTHORITY_KEY: &[u8] = b"target-import-staging-authority-v1";
22const TARGET_IMPORT_AUTHENTICATED_KEY: &[u8] = b"target-import-authenticated-v1";
23const TARGET_IMPORT_PROMOTION_CURSOR_KEY: &[u8] = b"target-import-promotion-cursor-v1";
24const TARGET_IMPORT_PROMOTED_KEY: &[u8] = b"target-import-promoted-v1";
25
26/// Exact summary of a durable online migration verified set.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct PoolMigrationAuditSummary {
29    pub entries: u64,
30    pub bytes: u64,
31    pub content_sha256: Hash,
32}
33
34/// Durable, provenance-separated source reconciliation and target body proofs.
35///
36/// A source entry means root independently observed the exact source key and a
37/// terminal `Stored` target catalog location whose catalog supplied the size.
38/// It is not a source-body or target-body proof. A target entry means the
39/// target body was independently hash-verified. The root controller owns the
40/// writable instance. Workers open it read-only.
41pub struct PoolMigrationAuditStore {
42    path: PathBuf,
43    env: ManagedEnv,
44    source_reconciled: Database<Bytes, Bytes>,
45    target_verified: Database<Bytes, Bytes>,
46    target_import_staging: Database<Bytes, Bytes>,
47    metadata: Database<Bytes, Bytes>,
48    binding: Hash,
49    writable: bool,
50}
51
52impl PoolMigrationAuditStore {
53    pub fn open(path: &Path, binding: Hash) -> Result<Self, StoreError> {
54        std::fs::create_dir_all(path).map_err(StoreError::Io)?;
55        let mut options = EnvOpenOptions::new();
56        options
57            .map_size(AUDIT_MAP_SIZE)
58            .max_dbs(AUDIT_DATABASE_COUNT);
59        let env = unsafe { ManagedEnv::open(&options, path) }.map_err(map_heed_error)?;
60        let mut wtxn = env.write_txn().map_err(map_heed_error)?;
61        let source_reconciled = env
62            .create_database(&mut wtxn, Some("source_reconciled"))
63            .map_err(map_heed_error)?;
64        let target_verified = env
65            .create_database(&mut wtxn, Some("target_verified"))
66            .map_err(map_heed_error)?;
67        let target_import_staging = env
68            .create_database(&mut wtxn, Some("target_import_staging"))
69            .map_err(map_heed_error)?;
70        let metadata = env
71            .create_database(&mut wtxn, Some("metadata"))
72            .map_err(map_heed_error)?;
73        match metadata.get(&wtxn, BINDING_KEY).map_err(map_heed_error)? {
74            Some(actual) if actual != binding => {
75                return Err(StoreError::Other(
76                    "online migration audit store belongs to a different authority binding".into(),
77                ))
78            }
79            Some(_) => {}
80            None => metadata
81                .put(&mut wtxn, BINDING_KEY, binding.as_slice())
82                .map_err(map_heed_error)?,
83        }
84        wtxn.commit().map_err(map_heed_error)?;
85        env.force_sync().map_err(map_heed_error)?;
86        Ok(Self {
87            path: path.to_path_buf(),
88            env,
89            source_reconciled,
90            target_verified,
91            target_import_staging,
92            metadata,
93            binding,
94            writable: true,
95        })
96    }
97
98    pub fn open_read_only(path: &Path, binding: Hash) -> Result<Self, StoreError> {
99        let mut options = EnvOpenOptions::new();
100        options.max_dbs(AUDIT_DATABASE_COUNT);
101        unsafe {
102            options.flags(EnvFlags::READ_ONLY | EnvFlags::NO_READ_AHEAD);
103        }
104        let env = unsafe { ManagedEnv::open(&options, path) }.map_err(map_heed_error)?;
105        let rtxn = env.read_txn().map_err(map_heed_error)?;
106        let source_reconciled = env
107            .open_database(&rtxn, Some("source_reconciled"))
108            .map_err(map_heed_error)?
109            .ok_or_else(|| {
110                StoreError::Other(
111                    "online migration source reconciliation database is missing".into(),
112                )
113            })?;
114        let target_verified = env
115            .open_database(&rtxn, Some("target_verified"))
116            .map_err(map_heed_error)?
117            .ok_or_else(|| {
118                StoreError::Other("online migration target audit database is missing".into())
119            })?;
120        let target_import_staging = env
121            .open_database(&rtxn, Some("target_import_staging"))
122            .map_err(map_heed_error)?
123            .ok_or_else(|| {
124                StoreError::Other(
125                    "online migration target import staging database is missing".into(),
126                )
127            })?;
128        let metadata = env
129            .open_database(&rtxn, Some("metadata"))
130            .map_err(map_heed_error)?
131            .ok_or_else(|| {
132                StoreError::Other("online migration audit metadata database is missing".into())
133            })?;
134        let actual = metadata
135            .get(&rtxn, BINDING_KEY)
136            .map_err(map_heed_error)?
137            .ok_or_else(|| {
138                StoreError::Other("online migration audit lost its authority binding".into())
139            })?;
140        if actual != binding {
141            return Err(StoreError::Other(
142                "online migration audit store belongs to a different authority binding".into(),
143            ));
144        }
145        rtxn.commit().map_err(map_heed_error)?;
146        Ok(Self {
147            path: path.to_path_buf(),
148            env,
149            source_reconciled,
150            target_verified,
151            target_import_staging,
152            metadata,
153            binding,
154            writable: false,
155        })
156    }
157
158    pub fn path(&self) -> &Path {
159        &self.path
160    }
161
162    pub fn binding(&self) -> Hash {
163        self.binding
164    }
165
166    /// Return whether each sorted source candidate already has an exact
167    /// durable hash/size reconciliation. A conflicting size is corruption,
168    /// never a cache miss.
169    pub fn contains_source_reconciled_exact_sorted(
170        &self,
171        candidates: &[(Hash, u64)],
172    ) -> Result<Vec<bool>, StoreError> {
173        self.contains_exact_sorted_in(self.source_reconciled, candidates, "source reconciliation")
174    }
175
176    pub fn contains_target_exact_sorted(
177        &self,
178        candidates: &[(Hash, u64)],
179    ) -> Result<Vec<bool>, StoreError> {
180        self.contains_exact_sorted_in(self.target_verified, candidates, "target")
181    }
182
183    /// Root-commit exact source-to-target catalog reconciliations.
184    ///
185    /// These entries deliberately do not enter `target_verified`: the
186    /// exhaustive target audit must still read and hash every target body.
187    pub fn record_reconciled_source(&self, entries: &[(Hash, u64)]) -> Result<(), StoreError> {
188        self.require_writable()?;
189        require_sorted_candidates(entries)?;
190        if entries.is_empty() {
191            return Ok(());
192        }
193        let mut wtxn = self.env.write_txn().map_err(map_heed_error)?;
194        self.put_verified_txn(
195            &mut wtxn,
196            self.source_reconciled,
197            entries,
198            "source reconciliation",
199        )?;
200        wtxn.commit().map_err(map_heed_error)?;
201        self.env.force_sync().map_err(map_heed_error)
202    }
203
204    /// Start or resume the target-fenced certification epoch. Existing
205    /// hash/size body proofs remain valid under Pool's content-addressed,
206    /// non-replacing write contract. The raw catalog cursor is reset on the
207    /// first transition so every final catalog row is revisited under the
208    /// exact held fence.
209    pub fn begin_target_fenced_epoch(&self, fence_binding: Hash) -> Result<(), StoreError> {
210        self.require_writable()?;
211        let mut wtxn = self.env.write_txn().map_err(map_heed_error)?;
212        match self
213            .metadata
214            .get(&wtxn, TARGET_FENCE_BINDING_KEY)
215            .map_err(map_heed_error)?
216        {
217            Some(actual) if actual == fence_binding => {}
218            Some(_) => {
219                return Err(StoreError::Other(
220                    "online migration target fence authority changed; use a new rollout".into(),
221                ))
222            }
223            None => {
224                self.metadata
225                    .delete(&mut wtxn, TARGET_CURSOR_KEY)
226                    .map_err(map_heed_error)?;
227                self.metadata
228                    .put(
229                        &mut wtxn,
230                        TARGET_FENCE_BINDING_KEY,
231                        fence_binding.as_slice(),
232                    )
233                    .map_err(map_heed_error)?;
234            }
235        }
236        wtxn.commit().map_err(map_heed_error)?;
237        self.env.force_sync().map_err(map_heed_error)
238    }
239
240    pub fn target_fence_binding(&self) -> Result<Option<Hash>, StoreError> {
241        let rtxn = self.env.read_txn().map_err(map_heed_error)?;
242        self.metadata
243            .get(&rtxn, TARGET_FENCE_BINDING_KEY)
244            .map_err(map_heed_error)?
245            .map(|bytes| {
246                bytes.try_into().map_err(|_| {
247                    StoreError::Other(
248                        "online migration audit target fence binding has an invalid length".into(),
249                    )
250                })
251            })
252            .transpose()
253    }
254
255    /// Commit exact target content proofs and their raw catalog cursor in one
256    /// force-synced transaction. The cursor can therefore never become
257    /// durable ahead of the proofs that allowed it to advance.
258    pub fn record_verified_target_page(
259        &self,
260        entries: &[(Hash, u64)],
261        cursor: Hash,
262    ) -> Result<(), StoreError> {
263        self.require_writable()?;
264        require_sorted_candidates(entries)?;
265        let mut wtxn = self.env.write_txn().map_err(map_heed_error)?;
266        self.put_verified_txn(&mut wtxn, self.target_verified, entries, "target")?;
267        self.metadata
268            .put(&mut wtxn, TARGET_CURSOR_KEY, cursor.as_slice())
269            .map_err(map_heed_error)?;
270        wtxn.commit().map_err(map_heed_error)?;
271        self.env.force_sync().map_err(map_heed_error)
272    }
273
274    /// Prepare a quarantined import bound to the complete certification and
275    /// evidence authority. Returns `true` when the caller must rebuild the
276    /// staging database, or `false` when an authenticated promotion can be
277    /// resumed (or has already completed).
278    ///
279    /// Unauthenticated staging rows are never consulted by target queries or
280    /// exports. A changed authority fails closed for the lifetime of this
281    /// rollout-bound audit store.
282    pub fn prepare_target_import(&self, authority: Hash) -> Result<bool, StoreError> {
283        self.require_writable()?;
284        let mut wtxn = self.env.write_txn().map_err(map_heed_error)?;
285        if let Some(actual) = self
286            .metadata
287            .get(&wtxn, TARGET_IMPORT_PROMOTED_KEY)
288            .map_err(map_heed_error)?
289        {
290            require_exact_import_authority(actual, authority)?;
291            wtxn.commit().map_err(map_heed_error)?;
292            return Ok(false);
293        }
294        if let Some(actual) = self
295            .metadata
296            .get(&wtxn, TARGET_IMPORT_AUTHENTICATED_KEY)
297            .map_err(map_heed_error)?
298        {
299            require_exact_import_authority(actual, authority)?;
300            wtxn.commit().map_err(map_heed_error)?;
301            return Ok(false);
302        }
303        self.target_import_staging
304            .clear(&mut wtxn)
305            .map_err(map_heed_error)?;
306        self.metadata
307            .delete(&mut wtxn, TARGET_IMPORT_PROMOTION_CURSOR_KEY)
308            .map_err(map_heed_error)?;
309        self.metadata
310            .put(
311                &mut wtxn,
312                TARGET_IMPORT_STAGING_AUTHORITY_KEY,
313                authority.as_slice(),
314            )
315            .map_err(map_heed_error)?;
316        wtxn.commit().map_err(map_heed_error)?;
317        self.env.force_sync().map_err(map_heed_error)?;
318        Ok(true)
319    }
320
321    /// Append exact hash/size rows to the untrusted quarantine. This never
322    /// changes the target proof set visible to migration workers.
323    pub fn stage_target_import_entries(
324        &self,
325        authority: Hash,
326        entries: &[(Hash, u64)],
327    ) -> Result<(), StoreError> {
328        self.require_writable()?;
329        require_sorted_candidates(entries)?;
330        if entries.is_empty() {
331            return Ok(());
332        }
333        let mut wtxn = self.env.write_txn().map_err(map_heed_error)?;
334        self.require_staging_authority(&wtxn, authority)?;
335        if self
336            .metadata
337            .get(&wtxn, TARGET_IMPORT_AUTHENTICATED_KEY)
338            .map_err(map_heed_error)?
339            .is_some()
340        {
341            return Err(StoreError::Other(
342                "authenticated target import staging is immutable".into(),
343            ));
344        }
345        self.put_verified_txn(
346            &mut wtxn,
347            self.target_import_staging,
348            entries,
349            "staged target import",
350        )?;
351        wtxn.commit().map_err(map_heed_error)?;
352        self.env.force_sync().map_err(map_heed_error)
353    }
354
355    /// Make a completely validated staging database eligible for promotion.
356    /// The caller may invoke this only after authenticated EOF and exact
357    /// receipt-summary validation. The marker and all prior staging batches
358    /// are ordered by force-synced LMDB commits.
359    pub fn authenticate_target_import(&self, authority: Hash) -> Result<(), StoreError> {
360        self.require_writable()?;
361        let mut wtxn = self.env.write_txn().map_err(map_heed_error)?;
362        self.require_staging_authority(&wtxn, authority)?;
363        match self
364            .metadata
365            .get(&wtxn, TARGET_IMPORT_AUTHENTICATED_KEY)
366            .map_err(map_heed_error)?
367        {
368            Some(actual) => require_exact_import_authority(actual, authority)?,
369            None => self
370                .metadata
371                .put(
372                    &mut wtxn,
373                    TARGET_IMPORT_AUTHENTICATED_KEY,
374                    authority.as_slice(),
375                )
376                .map_err(map_heed_error)?,
377        }
378        wtxn.commit().map_err(map_heed_error)?;
379        self.env.force_sync().map_err(map_heed_error)
380    }
381
382    /// Promote at most one authenticated staging batch into the visible proof
383    /// set. Returns `true` once promotion and quarantine cleanup are durable.
384    ///
385    /// A crash can expose only a prefix whose complete source manifest was
386    /// already authenticated. The promotion cursor and target writes share one
387    /// LMDB transaction. Each promoted staging row is deleted in that same
388    /// transaction, so promotion never needs a second full-size copy of the
389    /// imported proof set. The authenticated marker makes destructive draining
390    /// safe, while the cursor makes retries exact and idempotent.
391    pub fn promote_authenticated_target_import_batch(
392        &self,
393        authority: Hash,
394        batch_size: usize,
395    ) -> Result<bool, StoreError> {
396        self.require_writable()?;
397        if batch_size == 0 {
398            return Err(StoreError::Other(
399                "target import promotion batch size must be non-zero".into(),
400            ));
401        }
402        {
403            let rtxn = self.env.read_txn().map_err(map_heed_error)?;
404            if let Some(actual) = self
405                .metadata
406                .get(&rtxn, TARGET_IMPORT_PROMOTED_KEY)
407                .map_err(map_heed_error)?
408            {
409                require_exact_import_authority(actual, authority)?;
410                rtxn.commit().map_err(map_heed_error)?;
411                return Ok(true);
412            }
413            require_exact_import_authority(
414                self.metadata
415                    .get(&rtxn, TARGET_IMPORT_AUTHENTICATED_KEY)
416                    .map_err(map_heed_error)?
417                    .ok_or_else(|| {
418                        StoreError::Other(
419                            "target import cannot promote before authenticated EOF".into(),
420                        )
421                    })?,
422                authority,
423            )?;
424            rtxn.commit().map_err(map_heed_error)?;
425        }
426
427        let cursor = {
428            let rtxn = self.env.read_txn().map_err(map_heed_error)?;
429            let cursor: Option<Hash> = self
430                .metadata
431                .get(&rtxn, TARGET_IMPORT_PROMOTION_CURSOR_KEY)
432                .map_err(map_heed_error)?
433                .map(|bytes| {
434                    bytes.try_into().map_err(|_| {
435                        StoreError::Other(
436                            "target import promotion cursor has an invalid length".into(),
437                        )
438                    })
439                })
440                .transpose()?;
441            rtxn.commit().map_err(map_heed_error)?;
442            cursor
443        };
444        let rtxn = self.env.read_txn().map_err(map_heed_error)?;
445        let mut entries = Vec::with_capacity(batch_size);
446        if let Some(cursor) = cursor {
447            use std::ops::Bound::{Excluded, Unbounded};
448            let range = (Excluded(cursor.as_slice()), Unbounded);
449            for item in self
450                .target_import_staging
451                .range(&rtxn, &range)
452                .map_err(map_heed_error)?
453                .take(batch_size)
454            {
455                let (hash, encoded) = item.map_err(map_heed_error)?;
456                entries.push((
457                    hash.try_into().map_err(|_| {
458                        StoreError::Other(
459                            "target import staging contains an invalid hash key".into(),
460                        )
461                    })?,
462                    decode_size(encoded)?,
463                ));
464            }
465        } else {
466            for item in self
467                .target_import_staging
468                .iter(&rtxn)
469                .map_err(map_heed_error)?
470                .take(batch_size)
471            {
472                let (hash, encoded) = item.map_err(map_heed_error)?;
473                entries.push((
474                    hash.try_into().map_err(|_| {
475                        StoreError::Other(
476                            "target import staging contains an invalid hash key".into(),
477                        )
478                    })?,
479                    decode_size(encoded)?,
480                ));
481            }
482        }
483        rtxn.commit().map_err(map_heed_error)?;
484
485        let mut wtxn = self.env.write_txn().map_err(map_heed_error)?;
486        self.require_staging_authority(&wtxn, authority)?;
487        require_exact_import_authority(
488            self.metadata
489                .get(&wtxn, TARGET_IMPORT_AUTHENTICATED_KEY)
490                .map_err(map_heed_error)?
491                .ok_or_else(|| {
492                    StoreError::Other(
493                        "target import authentication disappeared during promotion".into(),
494                    )
495                })?,
496            authority,
497        )?;
498        if entries.is_empty() {
499            self.target_import_staging
500                .clear(&mut wtxn)
501                .map_err(map_heed_error)?;
502            for key in [
503                TARGET_IMPORT_STAGING_AUTHORITY_KEY,
504                TARGET_IMPORT_AUTHENTICATED_KEY,
505                TARGET_IMPORT_PROMOTION_CURSOR_KEY,
506            ] {
507                self.metadata
508                    .delete(&mut wtxn, key)
509                    .map_err(map_heed_error)?;
510            }
511            self.metadata
512                .put(&mut wtxn, TARGET_IMPORT_PROMOTED_KEY, authority.as_slice())
513                .map_err(map_heed_error)?;
514        } else {
515            self.put_verified_txn(
516                &mut wtxn,
517                self.target_verified,
518                &entries,
519                "promoted target import",
520            )?;
521            for (hash, _) in &entries {
522                self.target_import_staging
523                    .delete(&mut wtxn, hash.as_slice())
524                    .map_err(map_heed_error)?;
525            }
526            self.metadata
527                .put(
528                    &mut wtxn,
529                    TARGET_IMPORT_PROMOTION_CURSOR_KEY,
530                    entries
531                        .last()
532                        .expect("nonempty target import batch")
533                        .0
534                        .as_slice(),
535                )
536                .map_err(map_heed_error)?;
537        }
538        wtxn.commit().map_err(map_heed_error)?;
539        self.env.force_sync().map_err(map_heed_error)?;
540        Ok(entries.is_empty())
541    }
542
543    pub fn promoted_target_import_authority(&self) -> Result<Option<Hash>, StoreError> {
544        let rtxn = self.env.read_txn().map_err(map_heed_error)?;
545        self.metadata
546            .get(&rtxn, TARGET_IMPORT_PROMOTED_KEY)
547            .map_err(map_heed_error)?
548            .map(|bytes| {
549                bytes.try_into().map_err(|_| {
550                    StoreError::Other(
551                        "promoted target import authority has an invalid length".into(),
552                    )
553                })
554            })
555            .transpose()
556    }
557
558    pub fn target_cursor(&self) -> Result<Option<Hash>, StoreError> {
559        let rtxn = self.env.read_txn().map_err(map_heed_error)?;
560        self.metadata
561            .get(&rtxn, TARGET_CURSOR_KEY)
562            .map_err(map_heed_error)?
563            .map(|bytes| {
564                bytes.try_into().map_err(|_| {
565                    StoreError::Other(
566                        "online migration audit target cursor has an invalid length".into(),
567                    )
568                })
569            })
570            .transpose()
571    }
572
573    pub fn reset_target_cursor(&self) -> Result<(), StoreError> {
574        self.require_writable()?;
575        let mut wtxn = self.env.write_txn().map_err(map_heed_error)?;
576        self.metadata
577            .delete(&mut wtxn, TARGET_CURSOR_KEY)
578            .map_err(map_heed_error)?;
579        wtxn.commit().map_err(map_heed_error)?;
580        self.env.force_sync().map_err(map_heed_error)
581    }
582
583    /// Stream every reconciled source hash/size record in canonical order.
584    pub fn for_each_source_reconciled_batch(
585        &self,
586        batch_size: usize,
587        visit: impl FnMut(&[(Hash, u64)]) -> Result<(), StoreError>,
588    ) -> Result<PoolMigrationAuditSummary, StoreError> {
589        self.for_each_verified_batch_in(self.source_reconciled, batch_size, visit)
590    }
591
592    pub fn for_each_target_verified_batch(
593        &self,
594        batch_size: usize,
595        visit: impl FnMut(&[(Hash, u64)]) -> Result<(), StoreError>,
596    ) -> Result<PoolMigrationAuditSummary, StoreError> {
597        self.for_each_verified_batch_in(self.target_verified, batch_size, visit)
598    }
599
600    fn for_each_verified_batch_in(
601        &self,
602        database: Database<Bytes, Bytes>,
603        batch_size: usize,
604        mut visit: impl FnMut(&[(Hash, u64)]) -> Result<(), StoreError>,
605    ) -> Result<PoolMigrationAuditSummary, StoreError> {
606        if batch_size == 0 {
607            return Err(StoreError::Other(
608                "online migration audit export batch size must be non-zero".into(),
609            ));
610        }
611        let rtxn = self.env.read_txn().map_err(map_heed_error)?;
612        let mut hasher = Sha256::new();
613        hasher.update(b"hashtree-pool-migration-source-content/v3\0");
614        let mut entries = 0u64;
615        let mut bytes = 0u64;
616        let mut batch = Vec::with_capacity(batch_size);
617        for item in database.iter(&rtxn).map_err(map_heed_error)? {
618            let (key, encoded) = item.map_err(map_heed_error)?;
619            let hash: Hash = key.try_into().map_err(|_| {
620                StoreError::Other("online migration audit contains an invalid hash key".into())
621            })?;
622            let size = decode_size(encoded)?;
623            batch.push((hash, size));
624            if batch.len() == batch_size {
625                visit(&batch)?;
626                batch.clear();
627            }
628            hasher.update(hash);
629            hasher.update(size.to_be_bytes());
630            entries = entries.checked_add(1).ok_or_else(|| {
631                StoreError::Other("online migration audit entry count overflow".into())
632            })?;
633            bytes = bytes.checked_add(size).ok_or_else(|| {
634                StoreError::Other("online migration audit byte count overflow".into())
635            })?;
636        }
637        if !batch.is_empty() {
638            visit(&batch)?;
639        }
640        rtxn.commit().map_err(map_heed_error)?;
641        Ok(PoolMigrationAuditSummary {
642            entries,
643            bytes,
644            content_sha256: hasher.finalize().into(),
645        })
646    }
647
648    /// Find the first source key not covered by this durable set.
649    ///
650    /// This scans only the authoritative raw blob keys. It never reads source
651    /// values, metadata sizes, external file lengths, or payload bodies.
652    pub fn first_unreconciled_source_key(
653        &self,
654        source: &crate::LmdbBlobReader,
655        page_size: usize,
656    ) -> Result<Option<Hash>, StoreError> {
657        self.first_unreconciled_source_key_parallel(source, page_size, 1)
658    }
659
660    /// Find the first source key not covered by this durable set using an
661    /// ordered, coherent-snapshot parallel raw-key scan.
662    ///
663    /// Only source keys are prefetched. LMDB values, metadata sizes, external
664    /// file lengths, and payload bodies are never requested.
665    pub fn first_unreconciled_source_key_parallel(
666        &self,
667        source: &crate::LmdbBlobReader,
668        page_size: usize,
669        concurrency: usize,
670    ) -> Result<Option<Hash>, StoreError> {
671        if page_size == 0 {
672            return Err(StoreError::Other(
673                "online migration audit coverage page size must be non-zero".into(),
674            ));
675        }
676        let mut scanner = source.parallel_raw_key_scanner(None, concurrency)?;
677        loop {
678            let hashes = scanner.next_page(page_size)?;
679            if hashes.is_empty() {
680                return Ok(None);
681            }
682            let rtxn = self.env.read_txn().map_err(map_heed_error)?;
683            for hash in &hashes {
684                if self
685                    .source_reconciled
686                    .get(&rtxn, hash)
687                    .map_err(map_heed_error)?
688                    .is_none()
689                {
690                    return Ok(Some(*hash));
691                }
692            }
693        }
694    }
695
696    pub fn validate_binding(&self) -> Result<(), StoreError> {
697        let rtxn = self.env.read_txn().map_err(map_heed_error)?;
698        let actual = self
699            .metadata
700            .get(&rtxn, BINDING_KEY)
701            .map_err(map_heed_error)?
702            .ok_or_else(|| {
703                StoreError::Other("online migration audit lost its authority binding".into())
704            })?;
705        if actual != self.binding {
706            return Err(StoreError::Other(
707                "online migration audit authority binding changed".into(),
708            ));
709        }
710        rtxn.commit().map_err(map_heed_error)
711    }
712
713    fn contains_exact_sorted_in(
714        &self,
715        database: Database<Bytes, Bytes>,
716        candidates: &[(Hash, u64)],
717        proof_kind: &str,
718    ) -> Result<Vec<bool>, StoreError> {
719        require_sorted_candidates(candidates)?;
720        let rtxn = self.env.read_txn().map_err(map_heed_error)?;
721        candidates
722            .iter()
723            .map(|(hash, size)| {
724                let Some(encoded) = database.get(&rtxn, hash).map_err(map_heed_error)? else {
725                    return Ok(false);
726                };
727                let actual = decode_size(encoded)?;
728                if actual != *size {
729                    return Err(StoreError::Other(format!(
730                        "online migration {proof_kind} audit size for {} changed from {actual} to {size}",
731                        hashtree_core::to_hex(hash)
732                    )));
733                }
734                Ok(true)
735            })
736            .collect()
737    }
738
739    fn put_verified_txn(
740        &self,
741        wtxn: &mut heed::RwTxn<'_>,
742        database: Database<Bytes, Bytes>,
743        entries: &[(Hash, u64)],
744        proof_kind: &str,
745    ) -> Result<(), StoreError> {
746        for (hash, size) in entries {
747            if let Some(encoded) = database.get(wtxn, hash).map_err(map_heed_error)? {
748                let actual = decode_size(encoded)?;
749                if actual != *size {
750                    return Err(StoreError::Other(format!(
751                        "online migration {proof_kind} audit size for {} changed from {actual} to {size}",
752                        hashtree_core::to_hex(hash)
753                    )));
754                }
755                continue;
756            }
757            database
758                .put(wtxn, hash, &size.to_be_bytes())
759                .map_err(map_heed_error)?;
760        }
761        Ok(())
762    }
763
764    fn require_writable(&self) -> Result<(), StoreError> {
765        if !self.writable {
766            return Err(StoreError::Other(
767                "online migration audit store is read-only".into(),
768            ));
769        }
770        Ok(())
771    }
772
773    fn require_staging_authority(
774        &self,
775        txn: &heed::RoTxn<'_>,
776        authority: Hash,
777    ) -> Result<(), StoreError> {
778        require_exact_import_authority(
779            self.metadata
780                .get(txn, TARGET_IMPORT_STAGING_AUTHORITY_KEY)
781                .map_err(map_heed_error)?
782                .ok_or_else(|| {
783                    StoreError::Other("target import staging has no authority".into())
784                })?,
785            authority,
786        )
787    }
788}
789
790fn require_exact_import_authority(actual: &[u8], expected: Hash) -> Result<(), StoreError> {
791    if actual != expected {
792        return Err(StoreError::Other(
793            "target import authority changed; use a new rollout".into(),
794        ));
795    }
796    Ok(())
797}
798
799fn require_sorted_candidates(candidates: &[(Hash, u64)]) -> Result<(), StoreError> {
800    if candidates.windows(2).any(|pair| pair[0].0 >= pair[1].0) {
801        return Err(StoreError::Other(
802            "online migration audit candidates must be unique and strictly sorted".into(),
803        ));
804    }
805    Ok(())
806}
807
808fn decode_size(encoded: &[u8]) -> Result<u64, StoreError> {
809    let bytes: [u8; 8] = encoded.try_into().map_err(|_| {
810        StoreError::Other("online migration audit contains an invalid size value".into())
811    })?;
812    Ok(u64::from_be_bytes(bytes))
813}
814
815#[cfg(test)]
816mod tests {
817    use super::*;
818    use crate::{
819        migrate_lmdb_hashes_with_max_buffer_bytes_and_authorizer, LmdbBlobReader, LmdbBlobStore,
820        PoolMemberConfig, PoolStore, PoolStoreConfig,
821    };
822    use hashtree_core::sha256;
823
824    #[test]
825    fn verified_set_is_durable_exact_and_authority_bound() {
826        let temp = tempfile::tempdir().expect("temporary audit root");
827        let path = temp.path().join("online-audit");
828        let binding = sha256(b"rollout/source/pool authority");
829        let first = sha256(b"first verified body");
830        let second = sha256(b"second verified body");
831        let mut entries = vec![(first, 19), (second, 20)];
832        entries.sort_unstable_by_key(|(hash, _)| *hash);
833        {
834            let audit = PoolMigrationAuditStore::open(&path, binding).expect("create audit");
835            audit
836                .record_reconciled_source(&entries)
837                .expect("record verified entries");
838        }
839        let audit = PoolMigrationAuditStore::open(&path, binding).expect("reopen audit");
840        assert_eq!(
841            audit
842                .contains_source_reconciled_exact_sorted(&entries)
843                .expect("query exact entries"),
844            vec![true, true]
845        );
846        assert_eq!(
847            audit
848                .contains_target_exact_sorted(&entries)
849                .expect("query target provenance"),
850            vec![false, false]
851        );
852        audit
853            .record_verified_target_page(&entries, entries.last().expect("last entry").0)
854            .expect("record independent target-body proof");
855        assert_eq!(
856            audit
857                .contains_target_exact_sorted(&entries)
858                .expect("query independent target provenance"),
859            vec![true, true]
860        );
861        assert!(audit
862            .contains_source_reconciled_exact_sorted(&[(first, 18)])
863            .expect_err("conflicting size must fail")
864            .to_string()
865            .contains("changed"));
866        drop(audit);
867        let error = match PoolMigrationAuditStore::open(&path, sha256(b"different authority")) {
868            Ok(_) => panic!("different authority must fail"),
869            Err(error) => error,
870        };
871        assert!(error.to_string().contains("different authority"));
872    }
873
874    #[test]
875    fn coverage_scan_reads_only_keys_and_finds_only_unreconciled_source() {
876        let temp = tempfile::tempdir().expect("temporary migration root");
877        let source_path = temp.path().join("source");
878        let source = LmdbBlobStore::new(&source_path).expect("create source");
879        let first_body = b"first source body";
880        let second_body = b"second source body";
881        let first = sha256(first_body);
882        let second = sha256(second_body);
883        source
884            .put_sync(first, first_body)
885            .expect("put first source");
886        source
887            .put_sync(second, second_body)
888            .expect("put second source");
889        drop(source);
890        let reader = LmdbBlobReader::open(&source_path, None).expect("open source reader");
891
892        let audit = PoolMigrationAuditStore::open(
893            &temp.path().join("online-audit"),
894            sha256(b"coverage authority"),
895        )
896        .expect("create online audit");
897        audit
898            .record_reconciled_source(&[(first, first_body.len() as u64)])
899            .expect("record first source");
900        assert_eq!(
901            audit
902                .first_unreconciled_source_key_parallel(&reader, 1, 4)
903                .expect("scan coverage"),
904            Some(second)
905        );
906        audit
907            .record_reconciled_source(&[(second, second_body.len() as u64)])
908            .expect("record second source");
909        assert_eq!(
910            audit
911                .first_unreconciled_source_key_parallel(&reader, 1, 4)
912                .expect("scan complete coverage"),
913            None
914        );
915    }
916
917    #[test]
918    fn durable_verified_set_resumes_real_explicit_pool_migration() {
919        let temp = tempfile::tempdir().expect("temporary migration root");
920        let source_path = temp.path().join("source");
921        let source = LmdbBlobStore::new(&source_path).expect("create source");
922        let bodies = [
923            b"online migration body zero".as_slice(),
924            b"online migration body one".as_slice(),
925            b"online migration body two".as_slice(),
926        ];
927        for body in bodies {
928            source
929                .put_sync(sha256(body), body)
930                .expect("write source body");
931        }
932        source.force_sync().expect("sync source");
933        drop(source);
934        let source = LmdbBlobReader::open(&source_path, None).expect("open source reader");
935        let hashes = source.scan_hashes_after(None, 16).expect("scan source");
936        let sizes = source
937            .sizes_for_sorted_hashes(&hashes)
938            .expect("read source sizes");
939
940        let pool = PoolStore::open(temp.path().join("catalog"), PoolStoreConfig::default())
941            .expect("open target Pool");
942        pool.add_member(PoolMemberConfig::new(
943            temp.path().join("member"),
944            64 * 1024 * 1024,
945        ))
946        .expect("add target member");
947        let mut authorize = |_: Option<Hash>, _: usize| Ok(());
948        let first = migrate_lmdb_hashes_with_max_buffer_bytes_and_authorizer(
949            &source,
950            &pool,
951            &hashes[..2],
952            None,
953            usize::MAX,
954            &mut authorize,
955        )
956        .expect("migrate first explicit page");
957        pool.force_sync().expect("sync target");
958
959        let audit_path = temp.path().join("online-audit");
960        let binding = sha256(b"resumable real migration authority");
961        let audit = PoolMigrationAuditStore::open(&audit_path, binding).expect("create audit");
962        audit
963            .record_reconciled_source(&first.verified_source_entries)
964            .expect("record first verified page");
965        drop(audit);
966
967        let audit = PoolMigrationAuditStore::open(&audit_path, binding).expect("reopen audit");
968        let entries = hashes
969            .iter()
970            .copied()
971            .zip(sizes.iter().copied())
972            .collect::<Vec<_>>();
973        assert_eq!(
974            audit
975                .contains_source_reconciled_exact_sorted(&entries)
976                .expect("query resumed coverage"),
977            vec![true, true, false]
978        );
979        let resumed = migrate_lmdb_hashes_with_max_buffer_bytes_and_authorizer(
980            &source,
981            &pool,
982            &hashes[2..],
983            hashes.get(1).copied(),
984            usize::MAX,
985            &mut authorize,
986        )
987        .expect("resume only missing explicit body");
988        pool.force_sync().expect("sync resumed target");
989        audit
990            .record_reconciled_source(&resumed.verified_source_entries)
991            .expect("record resumed page");
992        assert_eq!(
993            audit
994                .contains_source_reconciled_exact_sorted(&entries)
995                .expect("query complete coverage"),
996            vec![true, true, true]
997        );
998        assert_eq!(pool.stats().expect("target stats").count, 3);
999    }
1000
1001    #[test]
1002    fn target_fence_rescans_catalog_but_retains_root_verified_content_proofs() {
1003        let temp = tempfile::tempdir().expect("temporary audit root");
1004        let path = temp.path().join("online-audit");
1005        let binding = sha256(b"root ledger authority");
1006        let pre_fence = sha256(b"pre-fence target");
1007        let fenced = sha256(b"fenced target");
1008        let root = PoolMigrationAuditStore::open(&path, binding).expect("create root ledger");
1009        root.record_reconciled_source(&[(pre_fence, 16)])
1010            .expect("record source reconciliation before target fence");
1011        assert_eq!(
1012            root.contains_target_exact_sorted(&[(pre_fence, 16)])
1013                .expect("query target proof before target audit"),
1014            vec![false]
1015        );
1016        root.record_verified_target_page(&[(pre_fence, 16)], pre_fence)
1017            .expect("record pre-fence target page");
1018        assert_eq!(
1019            root.contains_target_exact_sorted(&[(pre_fence, 16)])
1020                .expect("query pre-fence target proof"),
1021            vec![true]
1022        );
1023        assert_eq!(
1024            root.target_cursor().expect("read pre-fence cursor"),
1025            Some(pre_fence)
1026        );
1027
1028        let fence = sha256(b"exact persistent mask authorities");
1029        root.begin_target_fenced_epoch(fence)
1030            .expect("begin target proof epoch");
1031        assert_eq!(
1032            root.target_cursor().expect("read reset target cursor"),
1033            None
1034        );
1035        root.record_verified_target_page(&[(fenced, 13)], fenced)
1036            .expect("record root target proof");
1037        drop(root);
1038        let worker =
1039            PoolMigrationAuditStore::open_read_only(&path, binding).expect("open worker ledger");
1040        assert_eq!(
1041            worker.target_fence_binding().expect("read fence"),
1042            Some(fence)
1043        );
1044        let mut target_entries = vec![(pre_fence, 16), (fenced, 13)];
1045        target_entries.sort_unstable_by_key(|(hash, _)| *hash);
1046        assert_eq!(
1047            worker
1048                .contains_target_exact_sorted(&target_entries)
1049                .expect("read exact target provenance"),
1050            vec![true, true]
1051        );
1052        assert!(worker
1053            .record_reconciled_source(&[(pre_fence, 16)])
1054            .expect_err("worker ledger writes must fail")
1055            .to_string()
1056            .contains("read-only"));
1057        drop(worker);
1058        let root = PoolMigrationAuditStore::open(&path, binding).expect("reopen root ledger");
1059        assert!(root
1060            .begin_target_fenced_epoch(sha256(b"changed masks"))
1061            .expect_err("fence authority change must fail closed")
1062            .to_string()
1063            .contains("changed"));
1064    }
1065
1066    #[test]
1067    fn certified_target_import_is_quarantined_resumable_and_authority_bound() {
1068        let temp = tempfile::tempdir().expect("temporary audit root");
1069        let mut entries = (0_u64..17)
1070            .map(|index| {
1071                let body = format!("prior target body {index}");
1072                (sha256(body.as_bytes()), body.len() as u64)
1073            })
1074            .collect::<Vec<_>>();
1075        entries.sort_unstable_by_key(|(hash, _)| *hash);
1076        let promotion_batch_size = 4;
1077        let imported_path = temp.path().join("imported-audit");
1078        let binding = sha256(b"new source, binary, and parent-cert authority");
1079        let import_authority = sha256(b"exact certification plus evidence authority");
1080        let changed_authority = sha256(b"different certification authority");
1081
1082        {
1083            let imported =
1084                PoolMigrationAuditStore::open(&imported_path, binding).expect("create new audit");
1085            assert_eq!(
1086                imported.env.info().map_size,
1087                AUDIT_MAP_SIZE,
1088                "fresh audit must reserve capacity for both full proof trees"
1089            );
1090            assert!(imported
1091                .prepare_target_import(import_authority)
1092                .expect("prepare quarantine"));
1093            imported
1094                .stage_target_import_entries(import_authority, &entries[..3])
1095                .expect("stage unauthenticated prefix");
1096            assert_eq!(
1097                imported
1098                    .contains_target_exact_sorted(&entries)
1099                    .expect("query proof set before authenticated EOF"),
1100                vec![false; entries.len()],
1101                "unmarked staging must remain invisible"
1102            );
1103        }
1104
1105        {
1106            let imported = PoolMigrationAuditStore::open(&imported_path, binding)
1107                .expect("reopen after pre-marker crash");
1108            assert!(imported
1109                .prepare_target_import(import_authority)
1110                .expect("discard untrusted prefix"));
1111            imported
1112                .stage_target_import_entries(import_authority, &entries)
1113                .expect("stage complete authenticated input");
1114            imported
1115                .authenticate_target_import(import_authority)
1116                .expect("publish authenticated marker");
1117        }
1118
1119        {
1120            let imported = PoolMigrationAuditStore::open(&imported_path, binding)
1121                .expect("reopen after post-marker crash");
1122            assert!(!imported
1123                .prepare_target_import(import_authority)
1124                .expect("resume authenticated staging"));
1125            assert!(!imported
1126                .promote_authenticated_target_import_batch(import_authority, promotion_batch_size,)
1127                .expect("promote one production batch"));
1128            let rtxn = imported.env.read_txn().expect("read staged row count");
1129            assert_eq!(
1130                imported
1131                    .target_import_staging
1132                    .len(&rtxn)
1133                    .expect("count remaining staged rows"),
1134                (entries.len() - promotion_batch_size) as u64,
1135                "each promoted row must drain from staging in the same transaction"
1136            );
1137            assert_eq!(
1138                imported
1139                    .contains_target_exact_sorted(&entries)
1140                    .expect("query partially promoted proofs"),
1141                [
1142                    vec![true; promotion_batch_size],
1143                    vec![false; entries.len() - promotion_batch_size]
1144                ]
1145                .concat(),
1146                "only an authenticated prefix may become visible"
1147            );
1148        }
1149
1150        let imported = PoolMigrationAuditStore::open(&imported_path, binding)
1151            .expect("reopen during promotion");
1152        assert_eq!(
1153            imported.env.info().map_size,
1154            AUDIT_MAP_SIZE,
1155            "reopened audit must retain the full proof-tree capacity"
1156        );
1157        let rtxn = imported
1158            .env
1159            .read_txn()
1160            .expect("read staged rows after reopen");
1161        assert_eq!(
1162            imported
1163                .target_import_staging
1164                .len(&rtxn)
1165                .expect("count staged rows after reopen"),
1166            (entries.len() - promotion_batch_size) as u64,
1167            "a crash/reopen must preserve the exact drained prefix"
1168        );
1169        drop(rtxn);
1170        while !imported
1171            .promote_authenticated_target_import_batch(import_authority, promotion_batch_size)
1172            .expect("resume exact idempotent promotion")
1173        {}
1174        let rtxn = imported
1175            .env
1176            .read_txn()
1177            .expect("read final staged row count");
1178        assert_eq!(
1179            imported
1180                .target_import_staging
1181                .len(&rtxn)
1182                .expect("count final staged rows"),
1183            0,
1184            "completed promotion must leave no duplicate staging tree"
1185        );
1186        drop(rtxn);
1187        assert_eq!(
1188            imported
1189                .promoted_target_import_authority()
1190                .expect("read durable import provenance"),
1191            Some(import_authority)
1192        );
1193        assert_eq!(
1194            imported
1195                .contains_target_exact_sorted(&entries)
1196                .expect("query promoted body proofs"),
1197            vec![true; entries.len()]
1198        );
1199        assert_eq!(
1200            imported.target_cursor().expect("query imported cursor"),
1201            None,
1202            "imported body evidence must not skip the new rollout's catalog scan"
1203        );
1204        assert_eq!(
1205            imported
1206                .contains_source_reconciled_exact_sorted(&entries)
1207                .expect("query source provenance"),
1208            vec![false; entries.len()],
1209            "target evidence must not become source reconciliation evidence"
1210        );
1211        assert!(imported
1212            .prepare_target_import(changed_authority)
1213            .expect_err("changed parent authority must fail closed")
1214            .to_string()
1215            .contains("authority changed"));
1216    }
1217}