Skip to main content

hashtree_lmdb/pool/
reader.rs

1use super::member::open_member_reader;
2use super::{
3    decode_manifest, delete_protection, map_heed, validate_controlled_manifest, LocationRecord,
4    PoolDeleteProtectionGuard, PoolDeleteProtectionStatus, PoolManifest, PoolMemberId,
5    PoolStoreConfig, MANIFEST_KEY,
6};
7use crate::{managed_env::ManagedEnv, LmdbBlobReader};
8use hashtree_core::store::StoreError;
9use hashtree_core::{sha256, types::Hash};
10use heed::types::Bytes;
11use heed::{Database, EnvFlags, EnvOpenOptions};
12use sha2::{Digest, Sha256};
13use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15use std::time::{Duration, Instant};
16
17const TERMINAL_AUDIT_BATCH_ITEMS: usize = 4_096;
18const TERMINAL_AUDIT_BATCH_BYTES: u64 = 64 * 1024 * 1024;
19
20/// Strictly read-only view of a PoolStore for exhaustive validation.
21///
22/// Unlike [`super::PoolStore`], this type opens both the catalog and member
23/// environments with `MDB_RDONLY`. It never finalizes `Pending` locations,
24/// updates access temperature, repairs locations, or records adaptive state.
25/// A validator can therefore inspect an online or copied Pool without changing
26/// any catalog or member bytes.
27pub struct PoolStoreReader {
28    catalog_path: PathBuf,
29    env: ManagedEnv,
30    manifest: Database<Bytes, Bytes>,
31    opened_manifest_bytes: Vec<u8>,
32    locations: Database<Bytes, Bytes>,
33    by_member: Database<Bytes, heed::types::Unit>,
34    temperature_state: Database<Bytes, Bytes>,
35    manifest_identity: PoolManifestIdentity,
36    member_ids: Vec<PoolMemberId>,
37    members: HashMap<PoolMemberId, LmdbBlobReader>,
38    member_errors: HashMap<PoolMemberId, String>,
39}
40
41/// One result from a bounded, strictly read-only Pool batch.
42pub struct PoolReadBatchItem {
43    pub hash: Hash,
44    pub member_candidates: Vec<PoolMemberId>,
45    pub declared_size: Option<u64>,
46    pub data: Option<Vec<u8>>,
47    pub error: Option<String>,
48}
49
50/// Exhaustive terminal-state proof for a controlled Pool.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct PoolTerminalAudit {
53    pub stored_locations: u64,
54    pub stored_bytes: u64,
55    pub catalog_sha256: Hash,
56    pub payload_sha256: Hash,
57    pub manifest_sha256: Hash,
58}
59
60/// Exact catalog-to-physical-member proof that does not reread blob payloads.
61///
62/// It rejects non-`Stored` catalog rows, missing or extra secondary-index
63/// entries, active moves, missing physical objects, declared/physical size
64/// mismatches, member orphans, and stale member indexes or counters. It must
65/// be bound to a separate completed content audit when used as a release gate.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct PoolPhysicalAudit {
68    pub stored_locations: u64,
69    pub stored_bytes: u64,
70    pub catalog_sha256: Hash,
71    pub physical_sha256: Hash,
72    pub manifest_sha256: Hash,
73}
74
75struct TerminalStateAudit {
76    physical: PoolPhysicalAudit,
77    payload_sha256: Option<Hash>,
78}
79
80/// Exact, read-only Pool catalog state for one blob.
81///
82/// Release auditors must distinguish a terminal `Stored` location from
83/// `Pending` and `Moving`: physical bytes on a destination member are not a
84/// completed residency proof until the catalog commits that destination.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum PoolCatalogLocation {
87    Missing,
88    Pending {
89        member: PoolMemberId,
90        size: u64,
91    },
92    Stored {
93        member: PoolMemberId,
94        size: u64,
95    },
96    Moving {
97        source: PoolMemberId,
98        target: PoolMemberId,
99        size: u64,
100    },
101}
102
103/// Identity of the complete Pool manifest observed by a read-only reader.
104///
105/// `sha256` covers the exact stored manifest bytes, including generation,
106/// member ordering, member states, and every member configuration field.
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct PoolManifestIdentity {
109    pub generation: u64,
110    pub sha256: Hash,
111    pub member_ids: Vec<PoolMemberId>,
112}
113
114impl PoolCatalogLocation {
115    pub(super) fn from_record(record: Option<LocationRecord>) -> Self {
116        match record {
117            None => Self::Missing,
118            Some(LocationRecord::Pending { member, size }) => Self::Pending { member, size },
119            Some(LocationRecord::Stored { member, size }) => Self::Stored { member, size },
120            Some(LocationRecord::Moving {
121                source,
122                target,
123                size,
124            }) => Self::Moving {
125                source,
126                target,
127                size,
128            },
129        }
130    }
131}
132
133impl PoolStoreReader {
134    pub fn open<P: AsRef<Path>>(path: P, config: PoolStoreConfig) -> Result<Self, StoreError> {
135        Self::open_inner(path.as_ref(), config, false, false, 1)
136    }
137
138    /// Open a strict reader for an exhaustive, key-ordered catalog and
139    /// payload audit with kernel read-ahead enabled on the catalog and every
140    /// member LMDB.
141    pub fn open_sequential<P: AsRef<Path>>(
142        path: P,
143        config: PoolStoreConfig,
144    ) -> Result<Self, StoreError> {
145        Self::open_inner(path.as_ref(), config, false, true, 1)
146    }
147
148    /// Open an exhaustive reader with bounded parallel reads for distinct
149    /// loose external member files.
150    pub fn open_sequential_with_read_concurrency<P: AsRef<Path>>(
151        path: P,
152        config: PoolStoreConfig,
153        read_concurrency: usize,
154    ) -> Result<Self, StoreError> {
155        Self::open_inner(path.as_ref(), config, false, true, read_concurrency)
156    }
157
158    /// Open a strict read-only validator while retaining unavailable manifest
159    /// members as explicit per-read errors.
160    ///
161    /// This is intentionally audit-only behavior. Ordinary readers must use
162    /// [`Self::open`], which fails immediately when any configured member
163    /// cannot be opened.
164    pub fn open_with_unavailable_members_for_audit<P: AsRef<Path>>(
165        path: P,
166        config: PoolStoreConfig,
167    ) -> Result<Self, StoreError> {
168        Self::open_inner(path.as_ref(), config, true, false, 1)
169    }
170
171    fn open_inner(
172        path: &Path,
173        config: PoolStoreConfig,
174        retain_unavailable_members: bool,
175        sequential_scan: bool,
176        read_concurrency: usize,
177    ) -> Result<Self, StoreError> {
178        if read_concurrency == 0 {
179            return Err(StoreError::Other(
180                "read-only Pool member read concurrency must be non-zero".into(),
181            ));
182        }
183        if config.temperature.enabled {
184            return Err(StoreError::Other(
185                "read-only Pool validation requires temperature tracking to be disabled".into(),
186            ));
187        }
188
189        let mut member_runtime_paths = config
190            .member_runtime_paths
191            .iter()
192            .map(|binding| (binding.id, binding.clone()))
193            .collect::<HashMap<_, _>>();
194        if member_runtime_paths.len() != config.member_runtime_paths.len() {
195            return Err(StoreError::Other(
196                "duplicate runtime path binding for controlled Pool reader".into(),
197            ));
198        }
199        let controlled = !member_runtime_paths.is_empty()
200            || config.catalog_lmdb_identity.is_some()
201            || config.expected_manifest_sha256.is_some();
202        if controlled
203            && (member_runtime_paths.is_empty()
204                || config.catalog_lmdb_identity.is_none()
205                || config.expected_manifest_sha256.is_none())
206        {
207            return Err(StoreError::Other(
208                "controlled Pool reader requires catalog identity, exact manifest SHA-256, and every member runtime binding".into(),
209            ));
210        }
211
212        let mut options = EnvOpenOptions::new();
213        options.max_dbs(super::CATALOG_DATABASES);
214        let mut flags = super::super::env_flags_from_env() | EnvFlags::READ_ONLY;
215        if sequential_scan {
216            flags.remove(EnvFlags::NO_READ_AHEAD);
217        } else {
218            flags |= EnvFlags::NO_READ_AHEAD;
219        }
220        unsafe {
221            options.flags(flags);
222        }
223        let env = unsafe {
224            match config.catalog_lmdb_identity {
225                Some(identity) => ManagedEnv::open_pinned(&options, path, identity),
226                None => ManagedEnv::open(&options, path),
227            }
228        }
229        .map_err(map_heed)?;
230        let rtxn = env.read_txn().map_err(map_heed)?;
231        let manifest_db: Database<Bytes, Bytes> = env
232            .open_database(&rtxn, Some("manifest"))
233            .map_err(map_heed)?
234            .ok_or_else(|| StoreError::Other("pool manifest database is missing".into()))?;
235        let locations = env
236            .open_database(&rtxn, Some("locations"))
237            .map_err(map_heed)?
238            .ok_or_else(|| StoreError::Other("pool locations database is missing".into()))?;
239        let by_member = env
240            .open_database(&rtxn, Some("by_member"))
241            .map_err(map_heed)?
242            .ok_or_else(|| StoreError::Other("pool member index database is missing".into()))?;
243        let temperature_state = env
244            .open_database(&rtxn, Some("temperature_state"))
245            .map_err(map_heed)?
246            .ok_or_else(|| {
247                StoreError::Other("pool temperature state database is missing".into())
248            })?;
249        let (manifest, manifest_sha256) = read_manifest(&manifest_db, &rtxn)?;
250        let opened_manifest_bytes = manifest_db
251            .get(&rtxn, MANIFEST_KEY)
252            .map_err(map_heed)?
253            .ok_or_else(|| StoreError::Other("pool manifest is missing".into()))?
254            .to_vec();
255        if let Some(expected) = config.expected_manifest_sha256 {
256            validate_controlled_manifest(&opened_manifest_bytes, expected, &member_runtime_paths)?;
257        }
258        rtxn.commit().map_err(map_heed)?;
259
260        let mut member_ids = manifest
261            .members
262            .iter()
263            .map(|member| member.id)
264            .collect::<Vec<_>>();
265        member_ids.sort_unstable();
266        let manifest_identity = PoolManifestIdentity {
267            generation: manifest.generation,
268            sha256: manifest_sha256,
269            member_ids: member_ids.clone(),
270        };
271        let mut members = HashMap::with_capacity(manifest.members.len());
272        let mut member_errors = HashMap::new();
273        for member in manifest.members {
274            let mut runtime_config = member.config.clone();
275            let pinned_identity = match member_runtime_paths.remove(&member.id) {
276                Some(binding) => {
277                    runtime_config.path = binding.runtime_path;
278                    runtime_config.external_blob_dir = binding.runtime_external_path;
279                    Some(binding.lmdb_identity)
280                }
281                None if controlled => {
282                    return Err(StoreError::Other(format!(
283                        "controlled Pool reader has no runtime binding for member {}",
284                        member.id
285                    )))
286                }
287                None => None,
288            };
289            match open_member_reader(
290                member.id,
291                &runtime_config,
292                pinned_identity,
293                sequential_scan,
294                read_concurrency,
295            ) {
296                Ok(reader) => {
297                    members.insert(member.id, reader);
298                }
299                Err(error) => {
300                    if !retain_unavailable_members {
301                        return Err(error);
302                    }
303                    member_errors.insert(member.id, error.to_string());
304                }
305            }
306        }
307        Ok(Self {
308            catalog_path: path.to_path_buf(),
309            env,
310            manifest: manifest_db,
311            opened_manifest_bytes,
312            locations,
313            by_member,
314            temperature_state,
315            manifest_identity,
316            member_ids,
317            members,
318            member_errors,
319        })
320    }
321
322    pub fn delete_protection_status(
323        &self,
324    ) -> Result<Option<PoolDeleteProtectionStatus>, StoreError> {
325        delete_protection::delete_protection_status(&self.env, self.manifest)
326    }
327
328    pub fn hold_delete_protection(
329        &self,
330        lease_id: Hash,
331        expected_record_sha256: Hash,
332    ) -> Result<PoolDeleteProtectionGuard, StoreError> {
333        delete_protection::hold_delete_protection(
334            &self.catalog_path,
335            &self.env,
336            self.manifest,
337            lease_id,
338            expected_record_sha256,
339        )
340    }
341
342    pub fn blob_location(&self, hash: &Hash) -> Result<Option<PoolMemberId>, StoreError> {
343        Ok(self
344            .read_location(hash)?
345            .map(LocationRecord::preferred_member))
346    }
347
348    /// Return the exact member identities declared by the Pool manifest.
349    ///
350    /// Validators use this to pin the complete target-member set instead of
351    /// accidentally accepting a newly added or removed member.
352    pub fn member_ids(&self) -> Vec<PoolMemberId> {
353        self.member_ids.clone()
354    }
355
356    /// Return the complete manifest identity captured by this reader.
357    pub fn manifest_identity(&self) -> PoolManifestIdentity {
358        self.manifest_identity.clone()
359    }
360
361    /// Exhaustively prove terminal catalog and physical member state.
362    ///
363    /// Every location must decode as `Stored`, reference a manifest member,
364    /// have its exact secondary index entry, and resolve to bytes of the
365    /// declared size and SHA-256. Each member's blob and metadata key sets must
366    /// be identical and exactly match catalog ownership, so missing metadata
367    /// and addressable member orphans are rejected even when counts coincide.
368    /// Callers must keep all target Pool writers fenced for the full scan.
369    pub fn validate_terminal_catalog_and_payloads(&self) -> Result<PoolTerminalAudit, StoreError> {
370        let audit = self.validate_terminal_state(true)?;
371        Ok(PoolTerminalAudit {
372            stored_locations: audit.physical.stored_locations,
373            stored_bytes: audit.physical.stored_bytes,
374            catalog_sha256: audit.physical.catalog_sha256,
375            payload_sha256: audit.payload_sha256.ok_or_else(|| {
376                StoreError::Other("terminal payload audit produced no payload digest".into())
377            })?,
378            manifest_sha256: audit.physical.manifest_sha256,
379        })
380    }
381
382    /// Exhaustively prove exact catalog/physical-member parity without
383    /// rereading multi-terabyte payload bodies.
384    pub fn validate_terminal_catalog_and_physical_state(
385        &self,
386    ) -> Result<PoolPhysicalAudit, StoreError> {
387        Ok(self.validate_terminal_state(false)?.physical)
388    }
389
390    /// Scan one bounded terminal catalog page without reading payload bytes.
391    ///
392    /// Every row must already be `Stored`; callers can merge the returned
393    /// hash/size stream with an independently certified content-evidence
394    /// stream while target writers remain fenced.
395    pub fn scan_terminal_catalog_entries_after(
396        &self,
397        after: Option<Hash>,
398        limit: usize,
399    ) -> Result<Vec<(Hash, u64)>, StoreError> {
400        if limit == 0 {
401            return Ok(Vec::new());
402        }
403        let rtxn = self.env.read_txn().map_err(map_heed)?;
404        let mut entries = Vec::with_capacity(limit);
405        let decode = |key: &[u8], encoded: &[u8]| -> Result<(Hash, u64), StoreError> {
406            let hash: Hash = key
407                .try_into()
408                .map_err(|_| StoreError::Other("invalid pool catalog hash key".into()))?;
409            let size = match LocationRecord::decode(encoded)? {
410                LocationRecord::Stored { size, .. } => size,
411                LocationRecord::Pending { .. } => {
412                    return Err(StoreError::Other(format!(
413                        "terminal Pool content proof rejected pending location {}",
414                        hashtree_core::to_hex(&hash)
415                    )))
416                }
417                LocationRecord::Moving { .. } => {
418                    return Err(StoreError::Other(format!(
419                        "terminal Pool content proof rejected moving location {}",
420                        hashtree_core::to_hex(&hash)
421                    )))
422                }
423            };
424            Ok((hash, size))
425        };
426        match after {
427            Some(after) => {
428                use std::ops::Bound;
429                let range = (Bound::Excluded(after.as_slice()), Bound::<&[u8]>::Unbounded);
430                for item in self.locations.range(&rtxn, &range).map_err(map_heed)? {
431                    let (key, encoded) = item.map_err(map_heed)?;
432                    entries.push(decode(key, encoded)?);
433                    if entries.len() >= limit {
434                        break;
435                    }
436                }
437            }
438            None => {
439                for item in self.locations.iter(&rtxn).map_err(map_heed)? {
440                    let (key, encoded) = item.map_err(map_heed)?;
441                    entries.push(decode(key, encoded)?);
442                    if entries.len() >= limit {
443                        break;
444                    }
445                }
446            }
447        }
448        Ok(entries)
449    }
450
451    fn validate_terminal_state(
452        &self,
453        verify_payloads: bool,
454    ) -> Result<TerminalStateAudit, StoreError> {
455        let rtxn = self.env.read_txn().map_err(map_heed)?;
456        let manifest = self
457            .manifest
458            .get(&rtxn, MANIFEST_KEY)
459            .map_err(map_heed)?
460            .ok_or_else(|| StoreError::Other("pool manifest is missing".into()))?;
461        if manifest != self.opened_manifest_bytes {
462            return Err(StoreError::Other(format!(
463                "pool manifest changed after terminal reader open: expected {}, found {}",
464                hashtree_core::to_hex(&self.manifest_identity.sha256),
465                hashtree_core::to_hex(&sha256(manifest))
466            )));
467        }
468        decode_manifest(manifest)?;
469
470        let mut catalog_digest = Sha256::new();
471        catalog_digest.update(b"hashtree-pool-terminal-catalog/v2\0");
472        catalog_digest.update((manifest.len() as u64).to_be_bytes());
473        catalog_digest.update(manifest);
474        let mut payload_digest = Sha256::new();
475        payload_digest.update(b"hashtree-pool-terminal-payloads/v1\0");
476        let mut expected_member_ownership = self
477            .member_ids
478            .iter()
479            .map(|member| {
480                let mut digest = Sha256::new();
481                digest.update(b"hashtree-pool-member-ownership/v1\0");
482                (*member, digest)
483            })
484            .collect::<HashMap<_, _>>();
485        let mut stored_locations = 0u64;
486        let mut stored_bytes = 0u64;
487        let mut member_counts = HashMap::<PoolMemberId, u64>::new();
488        let mut member_bytes = HashMap::<PoolMemberId, u64>::new();
489        let mut batch = Vec::<(Hash, PoolMemberId, u64)>::with_capacity(TERMINAL_AUDIT_BATCH_ITEMS);
490        let started = Instant::now();
491        let mut last_progress = started;
492
493        for item in self.locations.iter(&rtxn).map_err(map_heed)? {
494            let (hash, encoded) = item.map_err(map_heed)?;
495            let hash: Hash = hash
496                .try_into()
497                .map_err(|_| StoreError::Other("invalid pool catalog hash key".into()))?;
498            let location = LocationRecord::decode(encoded)?;
499            let (member, size) = match location {
500                LocationRecord::Stored { member, size } => (member, size),
501                LocationRecord::Pending { .. } => {
502                    return Err(StoreError::Other(format!(
503                        "terminal Pool audit rejected pending location {}",
504                        hashtree_core::to_hex(&hash)
505                    )))
506                }
507                LocationRecord::Moving { .. } => {
508                    return Err(StoreError::Other(format!(
509                        "terminal Pool audit rejected moving location {}",
510                        hashtree_core::to_hex(&hash)
511                    )))
512                }
513            };
514            if !self.members.contains_key(&member) {
515                return Err(self.member_unavailable_error(member));
516            }
517            if self
518                .by_member
519                .get(&rtxn, &super::member_hash_key(member, hash))
520                .map_err(map_heed)?
521                .is_none()
522            {
523                return Err(StoreError::Other(format!(
524                    "terminal Pool audit found no member index for {} on {member}",
525                    hashtree_core::to_hex(&hash)
526                )));
527            }
528            stored_locations = stored_locations
529                .checked_add(1)
530                .ok_or_else(|| StoreError::Other("terminal Pool location count overflow".into()))?;
531            stored_bytes = stored_bytes.checked_add(size).ok_or_else(|| {
532                StoreError::Other("terminal Pool declared byte total overflow".into())
533            })?;
534            let member_count = member_counts.entry(member).or_default();
535            *member_count = member_count
536                .checked_add(1)
537                .ok_or_else(|| StoreError::Other("terminal Pool member count overflow".into()))?;
538            let member_byte_total = member_bytes.entry(member).or_default();
539            *member_byte_total = member_byte_total.checked_add(size).ok_or_else(|| {
540                StoreError::Other("terminal Pool member byte total overflow".into())
541            })?;
542            let ownership = expected_member_ownership.get_mut(&member).ok_or_else(|| {
543                StoreError::Other(format!(
544                    "terminal Pool catalog references unavailable member {member}"
545                ))
546            })?;
547            ownership.update(hash);
548            ownership.update(size.to_be_bytes());
549            catalog_digest.update(hash);
550            catalog_digest.update((encoded.len() as u64).to_be_bytes());
551            catalog_digest.update(encoded);
552            if verify_payloads {
553                batch.push((hash, member, size));
554                if batch.len() >= TERMINAL_AUDIT_BATCH_ITEMS {
555                    self.validate_terminal_payload_batch(&batch, &mut payload_digest)?;
556                    batch.clear();
557                }
558            }
559            if last_progress.elapsed() >= Duration::from_secs(10) {
560                eprintln!(
561                    "Pool terminal audit: verified {stored_locations} catalogued blobs / {stored_bytes} declared bytes in {} s",
562                    started.elapsed().as_secs()
563                );
564                last_progress = Instant::now();
565            }
566        }
567        if verify_payloads && !batch.is_empty() {
568            self.validate_terminal_payload_batch(&batch, &mut payload_digest)?;
569        }
570
571        let indexed = self.by_member.len(&rtxn).map_err(map_heed)?;
572        if indexed != stored_locations {
573            return Err(StoreError::Other(format!(
574                "terminal Pool member index count {indexed} differs from stored location count {stored_locations}"
575            )));
576        }
577        catalog_digest.update(b"\0exact-by-member-index\0");
578        catalog_digest.update(indexed.to_be_bytes());
579        for (prefix, label) in [(b"m".as_slice(), "move"), (b"c".as_slice(), "move-cleanup")] {
580            if self
581                .temperature_state
582                .prefix_iter(&rtxn, prefix)
583                .map_err(map_heed)?
584                .next()
585                .transpose()
586                .map_err(map_heed)?
587                .is_some()
588            {
589                return Err(StoreError::Other(format!(
590                    "terminal Pool audit found active {label} ownership"
591                )));
592            }
593        }
594        catalog_digest.update(b"\0no-active-move-ownership\0");
595        rtxn.commit().map_err(map_heed)?;
596
597        let mut physical_digest = Sha256::new();
598        physical_digest.update(b"hashtree-pool-terminal-physical/v1\0");
599        physical_digest.update(self.manifest_identity.sha256);
600        for member in &self.member_ids {
601            let reader = self
602                .members
603                .get(member)
604                .ok_or_else(|| self.member_unavailable_error(*member))?;
605            let expected = member_counts.get(member).copied().unwrap_or(0);
606            let expected_bytes = member_bytes.get(member).copied().unwrap_or(0);
607            let keyset = reader.validate_terminal_member_keyset()?;
608            if keyset.blob_entries != expected
609                || keyset.metadata_entries != expected
610                || keyset.total_bytes != expected_bytes
611            {
612                return Err(StoreError::Other(format!(
613                    "terminal Pool member {member} has {} blob and {} metadata records / {} exact metadata bytes, expected exactly {expected} catalog locations / {expected_bytes} catalog bytes",
614                    keyset.blob_entries, keyset.metadata_entries, keyset.total_bytes
615                )));
616            }
617            let expected_ownership = expected_member_ownership
618                .remove(member)
619                .expect("every manifest member has an ownership digest")
620                .finalize();
621            if keyset.ownership_sha256.as_slice() != expected_ownership.as_slice() {
622                return Err(StoreError::Other(format!(
623                    "terminal Pool member {member} physical hash/size ownership differs from its exact catalog ownership"
624                )));
625            }
626            catalog_digest.update(member.as_bytes());
627            catalog_digest.update(expected.to_be_bytes());
628            catalog_digest.update(expected_bytes.to_be_bytes());
629            catalog_digest.update(keyset.blob_entries.to_be_bytes());
630            catalog_digest.update(keyset.metadata_entries.to_be_bytes());
631            catalog_digest.update(keyset.total_bytes.to_be_bytes());
632            catalog_digest.update(keyset.pinned_count.to_be_bytes());
633            catalog_digest.update(keyset.pinned_bytes.to_be_bytes());
634            catalog_digest.update(keyset.sha256);
635            physical_digest.update(member.as_bytes());
636            physical_digest.update(keyset.ownership_sha256);
637            physical_digest.update(keyset.sha256);
638        }
639
640        let catalog_sha256 = catalog_digest.finalize().into();
641        physical_digest.update(catalog_sha256);
642        physical_digest.update(stored_locations.to_be_bytes());
643        physical_digest.update(stored_bytes.to_be_bytes());
644        Ok(TerminalStateAudit {
645            physical: PoolPhysicalAudit {
646                stored_locations,
647                stored_bytes,
648                catalog_sha256,
649                physical_sha256: physical_digest.finalize().into(),
650                manifest_sha256: self.manifest_identity.sha256,
651            },
652            payload_sha256: verify_payloads.then(|| payload_digest.finalize().into()),
653        })
654    }
655
656    fn validate_terminal_payload_batch(
657        &self,
658        batch: &[(Hash, PoolMemberId, u64)],
659        payload_digest: &mut Sha256,
660    ) -> Result<(), StoreError> {
661        let mut by_member = HashMap::<PoolMemberId, Vec<(Hash, u64)>>::new();
662        for (hash, member, size) in batch {
663            by_member.entry(*member).or_default().push((*hash, *size));
664        }
665        for (member, expected) in by_member {
666            let reader = self
667                .members
668                .get(&member)
669                .ok_or_else(|| self.member_unavailable_error(member))?;
670            let mut offset = 0usize;
671            while offset < expected.len() {
672                let hashes = expected[offset..]
673                    .iter()
674                    .map(|(hash, _)| *hash)
675                    .collect::<Vec<_>>();
676                let found = reader.read_hashes_bounded(&hashes, TERMINAL_AUDIT_BATCH_BYTES)?;
677                if found.is_empty() {
678                    return Err(StoreError::Other(format!(
679                        "terminal Pool member {member} omitted a requested payload"
680                    )));
681                }
682                for ((found_hash, data), (expected_hash, expected_size)) in
683                    found.iter().zip(&expected[offset..])
684                {
685                    if found_hash != expected_hash {
686                        return Err(StoreError::Other(format!(
687                            "terminal Pool member {member} returned payloads out of order"
688                        )));
689                    }
690                    if data.len() as u64 != *expected_size || sha256(data) != *expected_hash {
691                        return Err(StoreError::Other(format!(
692                            "terminal Pool member {member} returned corrupt or size-mismatched bytes for {}",
693                            hashtree_core::to_hex(expected_hash)
694                        )));
695                    }
696                }
697                offset = offset.checked_add(found.len()).ok_or_else(|| {
698                    StoreError::Other("terminal Pool batch offset overflow".into())
699                })?;
700            }
701        }
702        for (hash, member, size) in batch {
703            payload_digest.update(hash);
704            payload_digest.update(member.as_bytes());
705            payload_digest.update(size.to_be_bytes());
706        }
707        Ok(())
708    }
709
710    /// Mark which sorted candidate hashes physically exist on one exact Pool
711    /// member. No catalog fallback or mutation is performed.
712    pub fn member_existing_hashes_in_sorted_candidates(
713        &self,
714        member: PoolMemberId,
715        hashes: &[Hash],
716    ) -> Result<Vec<bool>, StoreError> {
717        self.members
718            .get(&member)
719            .ok_or_else(|| self.member_unavailable_error(member))?
720            .existing_hashes_in_sorted_candidates(hashes)
721    }
722
723    /// Read a bounded prefix of sorted, physically present hashes from one
724    /// exact Pool member without checking their content hashes.
725    ///
726    /// Callers must pass only hashes reported present by
727    /// [`Self::member_existing_hashes_in_sorted_candidates`] and must verify
728    /// every returned body against its requested hash.
729    pub fn read_member_hashes_bounded_unverified(
730        &self,
731        member: PoolMemberId,
732        hashes: &[Hash],
733        byte_limit: u64,
734    ) -> Result<Vec<(Hash, Vec<u8>)>, StoreError> {
735        self.members
736            .get(&member)
737            .ok_or_else(|| self.member_unavailable_error(member))?
738            .read_hashes_bounded(hashes, byte_limit)
739    }
740
741    /// Resolve candidate member order for many hashes with one catalog
742    /// transaction. Moving records prefer the target and retain the source as
743    /// a read fallback; pending and stored records contain one candidate.
744    pub fn blob_member_candidates(
745        &self,
746        hashes: &[Hash],
747    ) -> Result<Vec<Vec<PoolMemberId>>, StoreError> {
748        let rtxn = self.env.read_txn().map_err(map_heed)?;
749        hashes
750            .iter()
751            .map(|hash| {
752                let location = self
753                    .locations
754                    .get(&rtxn, hash)
755                    .map_err(map_heed)?
756                    .map(LocationRecord::decode)
757                    .transpose()?;
758                Ok(match location {
759                    None => Vec::new(),
760                    Some(LocationRecord::Pending { member, .. })
761                    | Some(LocationRecord::Stored { member, .. }) => vec![member],
762                    Some(LocationRecord::Moving { source, target, .. }) => {
763                        vec![target, source]
764                    }
765                })
766            })
767            .collect()
768    }
769
770    /// Return exact catalog state for many hashes in one read transaction.
771    ///
772    /// This deliberately exposes `Pending` and `Moving` rather than reducing
773    /// them to read candidates, so a release audit cannot mistake an
774    /// in-progress write or relocation for terminal residency.
775    pub fn blob_catalog_locations(
776        &self,
777        hashes: &[Hash],
778    ) -> Result<Vec<PoolCatalogLocation>, StoreError> {
779        let rtxn = self.env.read_txn().map_err(map_heed)?;
780        hashes
781            .iter()
782            .map(|hash| {
783                let record = self
784                    .locations
785                    .get(&rtxn, hash)
786                    .map_err(map_heed)?
787                    .map(LocationRecord::decode)
788                    .transpose()?;
789                Ok(PoolCatalogLocation::from_record(record))
790            })
791            .collect()
792    }
793
794    pub fn blob_size_sync(&self, hash: &Hash) -> Result<Option<u64>, StoreError> {
795        Ok(self.read_location(hash)?.map(LocationRecord::size))
796    }
797
798    /// Read and hash-check bytes without finalizing or otherwise touching the
799    /// catalog. Moving records try the target first and then the source.
800    pub fn get_sync(&self, hash: &Hash) -> Result<Option<Vec<u8>>, StoreError> {
801        let Some(location) = self.read_location(hash)? else {
802            return Ok(None);
803        };
804        let ids = match location {
805            LocationRecord::Pending { member, .. } | LocationRecord::Stored { member, .. } => {
806                ([member, member], 1)
807            }
808            LocationRecord::Moving { source, target, .. } => ([target, source], 2),
809        };
810
811        let mut first_error = None;
812        for id in ids.0.into_iter().take(ids.1) {
813            let Some(member) = self.members.get(&id) else {
814                first_error.get_or_insert_with(|| self.member_unavailable_error(id));
815                continue;
816            };
817            match member.get_sync(hash) {
818                Ok(Some(data)) if sha256(&data) == *hash => return Ok(Some(data)),
819                Ok(Some(_)) => {
820                    first_error.get_or_insert_with(|| {
821                        StoreError::Other(format!("pool member {id} returned corrupt bytes"))
822                    });
823                }
824                Ok(None) => {}
825                Err(error) => {
826                    first_error.get_or_insert(error);
827                }
828            }
829        }
830        match first_error {
831            Some(error) => Err(error),
832            None => Ok(None),
833        }
834    }
835
836    /// Resolve a bounded prefix with one catalog transaction and coalesced
837    /// member reads. The returned prefix always contains at least one item
838    /// when `hashes` is non-empty and `byte_limit` is non-zero.
839    pub fn read_hashes_bounded(
840        &self,
841        hashes: &[Hash],
842        byte_limit: u64,
843    ) -> Result<Vec<PoolReadBatchItem>, StoreError> {
844        if hashes.is_empty() || byte_limit == 0 {
845            return Ok(Vec::new());
846        }
847
848        let rtxn = self.env.read_txn().map_err(map_heed)?;
849        let mut items = Vec::with_capacity(hashes.len());
850        let mut selected_bytes = 0u64;
851        for hash in hashes {
852            let location = self
853                .locations
854                .get(&rtxn, hash)
855                .map_err(map_heed)?
856                .map(LocationRecord::decode)
857                .transpose()?;
858            let declared_size = location.as_ref().map(|location| (*location).size());
859            let expected_size = declared_size.unwrap_or(0);
860            if !items.is_empty() && selected_bytes.saturating_add(expected_size) > byte_limit {
861                break;
862            }
863            let member_candidates = match location {
864                None => Vec::new(),
865                Some(LocationRecord::Pending { member, .. })
866                | Some(LocationRecord::Stored { member, .. }) => vec![member],
867                Some(LocationRecord::Moving { source, target, .. }) => vec![target, source],
868            };
869            items.push(PoolReadBatchItem {
870                hash: *hash,
871                member_candidates,
872                declared_size,
873                data: None,
874                error: None,
875            });
876            selected_bytes = selected_bytes.saturating_add(expected_size);
877        }
878        drop(rtxn);
879
880        for candidate_index in 0..2 {
881            let mut reads_by_member = HashMap::<PoolMemberId, Vec<(usize, Hash)>>::new();
882            for (item_index, item) in items.iter().enumerate() {
883                if item.data.is_none() {
884                    if let Some(member) = item.member_candidates.get(candidate_index) {
885                        reads_by_member
886                            .entry(*member)
887                            .or_default()
888                            .push((item_index, item.hash));
889                    }
890                }
891            }
892            for (member_id, requested) in reads_by_member {
893                let Some(member) = self.members.get(&member_id) else {
894                    let error = self.member_unavailable_error(member_id).to_string();
895                    for (item_index, _) in requested {
896                        items[item_index].error.get_or_insert_with(|| error.clone());
897                    }
898                    continue;
899                };
900                let hashes = requested.iter().map(|(_, hash)| *hash).collect::<Vec<_>>();
901                let present = member.existing_hashes_in_sorted_candidates(&hashes)?;
902                let present_requests = requested
903                    .into_iter()
904                    .zip(present)
905                    .filter_map(|(request, is_present)| is_present.then_some(request))
906                    .collect::<Vec<_>>();
907                if present_requests.is_empty() {
908                    continue;
909                }
910                let present_hashes = present_requests
911                    .iter()
912                    .map(|(_, hash)| *hash)
913                    .collect::<Vec<_>>();
914                let bodies = member.read_hashes_bounded(&present_hashes, u64::MAX)?;
915                if bodies.len() != present_requests.len() {
916                    return Err(StoreError::Other(format!(
917                        "pool member {member_id} bounded reader returned {} of {} requested blobs",
918                        bodies.len(),
919                        present_requests.len()
920                    )));
921                }
922                for ((item_index, expected_hash), (actual_hash, data)) in
923                    present_requests.into_iter().zip(bodies)
924                {
925                    if actual_hash != expected_hash {
926                        return Err(StoreError::Other(format!(
927                            "pool member {member_id} returned hashes out of order"
928                        )));
929                    }
930                    if sha256(&data) == expected_hash {
931                        items[item_index].data = Some(data);
932                    } else {
933                        items[item_index].error.get_or_insert_with(|| {
934                            format!("pool member {member_id} returned corrupt bytes")
935                        });
936                    }
937                }
938            }
939        }
940        for item in &mut items {
941            if item.member_candidates.is_empty() {
942                item.error
943                    .get_or_insert_with(|| "Pool catalog entry is missing".into());
944            } else if item.data.is_none() {
945                item.error
946                    .get_or_insert_with(|| "Pool payload is missing from candidate members".into());
947            } else {
948                item.error = None;
949            }
950        }
951        Ok(items)
952    }
953
954    fn read_location(&self, hash: &Hash) -> Result<Option<LocationRecord>, StoreError> {
955        let rtxn = self.env.read_txn().map_err(map_heed)?;
956        self.locations
957            .get(&rtxn, hash)
958            .map_err(map_heed)?
959            .map(LocationRecord::decode)
960            .transpose()
961    }
962
963    fn member_unavailable_error(&self, member: PoolMemberId) -> StoreError {
964        match self.member_errors.get(&member) {
965            Some(error) => StoreError::Other(format!(
966                "pool member {member} is unavailable to reader: {error}"
967            )),
968            None if self.member_ids.contains(&member) => {
969                StoreError::Other(format!("pool member {member} is unavailable to reader"))
970            }
971            None => StoreError::Other(format!("unknown pool member {member}")),
972        }
973    }
974}
975
976fn read_manifest(
977    database: &Database<Bytes, Bytes>,
978    txn: &heed::RoTxn<'_>,
979) -> Result<(PoolManifest, Hash), StoreError> {
980    let bytes = database
981        .get(txn, MANIFEST_KEY)
982        .map_err(map_heed)?
983        .ok_or_else(|| StoreError::Other("pool manifest is missing".into()))?;
984    Ok((decode_manifest(bytes)?, sha256(bytes)))
985}
986
987#[cfg(test)]
988mod tests {
989    use super::*;
990    use crate::{PoolMemberConfig, PoolStore};
991    use std::fs;
992
993    #[test]
994    fn sequential_reader_enables_pool_read_ahead_and_bounded_member_reads() -> Result<(), StoreError>
995    {
996        let temp = tempfile::tempdir().map_err(StoreError::Io)?;
997        let catalog = temp.path().join("catalog");
998        let member_path = temp.path().join("member");
999        let mut config = PoolStoreConfig::default();
1000        config.temperature.enabled = false;
1001        let pool = PoolStore::open(&catalog, config.clone())?;
1002        let member = pool.add_member(PoolMemberConfig::new(member_path, 1024 * 1024))?;
1003        drop(pool);
1004
1005        let sparse = PoolStoreReader::open(&catalog, config.clone())?;
1006        let sparse_catalog_flags = sparse
1007            .env
1008            .flags()
1009            .map_err(map_heed)?
1010            .ok_or_else(|| StoreError::Other("sparse Pool catalog has no flags".into()))?;
1011        assert!(sparse_catalog_flags.contains(EnvFlags::NO_READ_AHEAD));
1012        let sparse_member_flags = sparse
1013            .members
1014            .get(&member)
1015            .expect("manifest member is open")
1016            .store
1017            .env
1018            .flags()
1019            .map_err(map_heed)?
1020            .ok_or_else(|| StoreError::Other("sparse Pool member has no flags".into()))?;
1021        assert!(sparse_member_flags.contains(EnvFlags::NO_READ_AHEAD));
1022        drop(sparse);
1023
1024        let sequential =
1025            PoolStoreReader::open_sequential_with_read_concurrency(&catalog, config.clone(), 16)?;
1026        let sequential_catalog_flags = sequential
1027            .env
1028            .flags()
1029            .map_err(map_heed)?
1030            .ok_or_else(|| StoreError::Other("sequential Pool catalog has no flags".into()))?;
1031        assert!(!sequential_catalog_flags.contains(EnvFlags::NO_READ_AHEAD));
1032        let sequential_member = sequential
1033            .members
1034            .get(&member)
1035            .expect("manifest member is open");
1036        let sequential_member_flags = sequential_member
1037            .store
1038            .env
1039            .flags()
1040            .map_err(map_heed)?
1041            .ok_or_else(|| StoreError::Other("sequential Pool member has no flags".into()))?;
1042        assert!(!sequential_member_flags.contains(EnvFlags::NO_READ_AHEAD));
1043        assert_eq!(sequential_member.external_read_concurrency, 16);
1044        drop(sequential);
1045
1046        let error =
1047            match PoolStoreReader::open_sequential_with_read_concurrency(&catalog, config, 65) {
1048                Ok(_) => {
1049                    return Err(StoreError::Other(
1050                        "reader accepted concurrency above the member limit".into(),
1051                    ))
1052                }
1053                Err(error) => error,
1054            };
1055        assert!(error
1056            .to_string()
1057            .contains("outside its configured 1..=64 limit"));
1058        Ok(())
1059    }
1060
1061    #[test]
1062    fn reader_preserves_pending_and_moving_catalog_bytes() -> Result<(), StoreError> {
1063        let temp = tempfile::tempdir().map_err(StoreError::Io)?;
1064        let catalog = temp.path().join("catalog");
1065        let first_path = temp.path().join("first");
1066        let second_path = temp.path().join("second");
1067        let mut writable_config = PoolStoreConfig::default();
1068        writable_config.temperature.enabled = false;
1069        let pool = PoolStore::open(&catalog, writable_config)?;
1070        let first = pool.add_member(PoolMemberConfig::new(first_path, 1024 * 1024))?;
1071        let second = pool.add_member(PoolMemberConfig::new(second_path, 1024 * 1024))?;
1072
1073        let pending_data = b"pending validation payload";
1074        let pending_hash = sha256(pending_data);
1075        pool.get_member(first)?
1076            .put_sync(pending_hash, pending_data)?;
1077        let moving_data = b"moving validation payload";
1078        let moving_hash = sha256(moving_data);
1079        pool.get_member(first)?.put_sync(moving_hash, moving_data)?;
1080        pool.get_member(second)?
1081            .put_sync(moving_hash, moving_data)?;
1082        let mut wtxn = pool.env.write_txn().map_err(map_heed)?;
1083        pool.set_location_txn(
1084            &mut wtxn,
1085            pending_hash,
1086            Some(LocationRecord::Pending {
1087                member: first,
1088                size: pending_data.len() as u64,
1089            }),
1090        )?;
1091        pool.set_location_txn(
1092            &mut wtxn,
1093            moving_hash,
1094            Some(LocationRecord::Moving {
1095                source: first,
1096                target: second,
1097                size: moving_data.len() as u64,
1098            }),
1099        )?;
1100        wtxn.commit().map_err(map_heed)?;
1101        pool.force_sync()?;
1102        drop(pool);
1103
1104        let catalog_path = catalog.join("data.mdb");
1105        let before = fs::read(&catalog_path).map_err(StoreError::Io)?;
1106        let mut reader_config = PoolStoreConfig::default();
1107        reader_config.temperature.enabled = false;
1108        let reader = PoolStoreReader::open(&catalog, reader_config)?;
1109        assert_eq!(
1110            reader.blob_catalog_locations(&[pending_hash, moving_hash])?,
1111            vec![
1112                PoolCatalogLocation::Pending {
1113                    member: first,
1114                    size: pending_data.len() as u64,
1115                },
1116                PoolCatalogLocation::Moving {
1117                    source: first,
1118                    target: second,
1119                    size: moving_data.len() as u64,
1120                },
1121            ]
1122        );
1123        assert_eq!(
1124            reader.get_sync(&pending_hash)?.as_deref(),
1125            Some(pending_data.as_slice())
1126        );
1127        assert_eq!(
1128            reader.get_sync(&moving_hash)?.as_deref(),
1129            Some(moving_data.as_slice())
1130        );
1131        let batch = reader.read_hashes_bounded(
1132            &[pending_hash, moving_hash],
1133            pending_data.len() as u64 + moving_data.len() as u64,
1134        )?;
1135        assert_eq!(batch.len(), 2);
1136        assert_eq!(batch[0].hash, pending_hash);
1137        assert_eq!(batch[0].data.as_deref(), Some(pending_data.as_slice()));
1138        assert!(batch[0].error.is_none());
1139        assert_eq!(batch[1].hash, moving_hash);
1140        assert_eq!(batch[1].data.as_deref(), Some(moving_data.as_slice()));
1141        assert!(batch[1].error.is_none());
1142        let terminal_error = reader
1143            .validate_terminal_catalog_and_payloads()
1144            .expect_err("terminal audit must reject Pending and Moving");
1145        assert!(terminal_error.to_string().contains("pending location"));
1146        drop(reader);
1147        let after = fs::read(&catalog_path).map_err(StoreError::Io)?;
1148        assert_eq!(after, before);
1149
1150        let reopened = PoolStore::open(&catalog, {
1151            let mut config = PoolStoreConfig::default();
1152            config.temperature.enabled = false;
1153            config
1154        })?;
1155        assert!(matches!(
1156            reopened.read_location(&pending_hash)?,
1157            Some(LocationRecord::Pending { .. })
1158        ));
1159        assert!(matches!(
1160            reopened.read_location(&moving_hash)?,
1161            Some(LocationRecord::Moving { .. })
1162        ));
1163        Ok(())
1164    }
1165
1166    #[test]
1167    fn reader_can_probe_one_exact_member_without_catalog_fallback() -> Result<(), StoreError> {
1168        let temp = tempfile::tempdir().map_err(StoreError::Io)?;
1169        let catalog = temp.path().join("catalog");
1170        let first_path = temp.path().join("first");
1171        let second_path = temp.path().join("second");
1172        let mut config = PoolStoreConfig::default();
1173        config.temperature.enabled = false;
1174        let pool = PoolStore::open(&catalog, config.clone())?;
1175        let first = pool.add_member(PoolMemberConfig::new(first_path, 1024 * 1024))?;
1176        let second = pool.add_member(PoolMemberConfig::new(second_path, 1024 * 1024))?;
1177
1178        let data = b"exact member validation payload";
1179        let hash = sha256(data);
1180        pool.get_member(first)?.put_sync(hash, data)?;
1181        let mut wtxn = pool.env.write_txn().map_err(map_heed)?;
1182        pool.set_location_txn(
1183            &mut wtxn,
1184            hash,
1185            Some(LocationRecord::Stored {
1186                member: first,
1187                size: data.len() as u64,
1188            }),
1189        )?;
1190        wtxn.commit().map_err(map_heed)?;
1191        pool.force_sync()?;
1192        drop(pool);
1193
1194        let reader = PoolStoreReader::open(&catalog, config)?;
1195        assert_eq!(
1196            reader.blob_catalog_locations(&[hash])?,
1197            vec![PoolCatalogLocation::Stored {
1198                member: first,
1199                size: data.len() as u64,
1200            }]
1201        );
1202        let mut expected_members = vec![first, second];
1203        expected_members.sort_unstable();
1204        assert_eq!(reader.member_ids(), expected_members);
1205        assert_eq!(
1206            reader.member_existing_hashes_in_sorted_candidates(first, &[hash])?,
1207            vec![true]
1208        );
1209        assert_eq!(
1210            reader.member_existing_hashes_in_sorted_candidates(second, &[hash])?,
1211            vec![false]
1212        );
1213        assert_eq!(
1214            reader.read_member_hashes_bounded_unverified(first, &[hash], data.len() as u64)?,
1215            vec![(hash, data.to_vec())]
1216        );
1217        let audit = reader.validate_terminal_catalog_and_payloads()?;
1218        assert_eq!(audit.stored_locations, 1);
1219        assert_eq!(audit.stored_bytes, data.len() as u64);
1220        assert_eq!(audit.manifest_sha256, reader.manifest_identity().sha256);
1221        let physical = reader.validate_terminal_catalog_and_physical_state()?;
1222        assert_eq!(physical.stored_locations, 1);
1223        assert_eq!(physical.stored_bytes, data.len() as u64);
1224        assert_eq!(physical.catalog_sha256, audit.catalog_sha256);
1225        assert_eq!(physical.manifest_sha256, audit.manifest_sha256);
1226        Ok(())
1227    }
1228
1229    #[test]
1230    fn physical_audit_rejects_equal_count_catalog_member_ownership_mismatch(
1231    ) -> Result<(), StoreError> {
1232        let temp = tempfile::tempdir().map_err(StoreError::Io)?;
1233        let catalog = temp.path().join("catalog");
1234        let member_path = temp.path().join("member");
1235        let mut config = PoolStoreConfig::default();
1236        config.temperature.enabled = false;
1237        let pool = PoolStore::open(&catalog, config.clone())?;
1238        let member = pool.add_member(PoolMemberConfig::new(member_path, 16 * 1024 * 1024))?;
1239        let physical_body = b"physical ownership A";
1240        let catalog_body = b"physical ownership B";
1241        assert_eq!(physical_body.len(), catalog_body.len());
1242        let physical_hash = sha256(physical_body);
1243        let catalog_hash = sha256(catalog_body);
1244        pool.put_sync(physical_hash, physical_body)?;
1245        let mut wtxn = pool.env.write_txn().map_err(map_heed)?;
1246        pool.set_location_txn(&mut wtxn, physical_hash, None)?;
1247        pool.set_location_txn(
1248            &mut wtxn,
1249            catalog_hash,
1250            Some(LocationRecord::Stored {
1251                member,
1252                size: catalog_body.len() as u64,
1253            }),
1254        )?;
1255        wtxn.commit().map_err(map_heed)?;
1256        pool.force_sync()?;
1257        drop(pool);
1258
1259        let reader = PoolStoreReader::open(&catalog, config)?;
1260        let error = reader
1261            .validate_terminal_catalog_and_physical_state()
1262            .expect_err("equal-count ownership mismatch must fail physical audit");
1263        assert!(error.to_string().contains("physical hash/size ownership"));
1264        Ok(())
1265    }
1266
1267    #[test]
1268    fn terminal_audit_rejects_missing_bodies_and_addressable_member_orphans(
1269    ) -> Result<(), StoreError> {
1270        let temp = tempfile::tempdir().map_err(StoreError::Io)?;
1271        let catalog = temp.path().join("catalog");
1272        let member_path = temp.path().join("member");
1273        let mut config = PoolStoreConfig::default();
1274        config.temperature.enabled = false;
1275        let pool = PoolStore::open(&catalog, config.clone())?;
1276        let member = pool.add_member(PoolMemberConfig::new(member_path, 16 * 1024 * 1024))?;
1277        let stored_data = b"terminal audit catalogued body";
1278        let stored_hash = sha256(stored_data);
1279        pool.put_sync(stored_hash, stored_data)?;
1280        pool.get_member(member)?.delete_sync(&stored_hash)?;
1281        pool.force_sync()?;
1282        drop(pool);
1283
1284        let reader = PoolStoreReader::open(&catalog, config.clone())?;
1285        let missing = reader
1286            .validate_terminal_catalog_and_payloads()
1287            .expect_err("missing catalogued body must fail terminal audit");
1288        assert!(
1289            missing.to_string().contains("lost blob")
1290                || missing.to_string().contains("omitted")
1291                || missing.to_string().contains("missing")
1292        );
1293        drop(reader);
1294
1295        let pool = PoolStore::open(&catalog, config.clone())?;
1296        pool.put_sync(stored_hash, stored_data)?;
1297        let orphan_data = b"terminal audit addressable member orphan";
1298        pool.get_member(member)?
1299            .put_sync(sha256(orphan_data), orphan_data)?;
1300        pool.force_sync()?;
1301        drop(pool);
1302
1303        let reader = PoolStoreReader::open(&catalog, config)?;
1304        let orphan = reader
1305            .validate_terminal_catalog_and_payloads()
1306            .expect_err("addressable member orphan must fail terminal audit");
1307        assert!(orphan.to_string().contains("expected exactly"));
1308        Ok(())
1309    }
1310
1311    #[test]
1312    fn terminal_audit_rejects_equal_count_member_keyset_mismatch() -> Result<(), StoreError> {
1313        let temp = tempfile::tempdir().map_err(StoreError::Io)?;
1314        let catalog = temp.path().join("catalog");
1315        let member_path = temp.path().join("member");
1316        let mut config = PoolStoreConfig::default();
1317        config.temperature.enabled = false;
1318        let pool = PoolStore::open(&catalog, config.clone())?;
1319        let member = pool.add_member(PoolMemberConfig::new(member_path, 16 * 1024 * 1024))?;
1320        let first_data = b"terminal member keyset first body";
1321        let second_data = b"terminal member keyset second body";
1322        let first_hash = sha256(first_data);
1323        let second_hash = sha256(second_data);
1324        let metadata_orphan_hash = sha256(b"metadata-only replacement key");
1325        pool.put_sync(first_hash, first_data)?;
1326        pool.put_sync(second_hash, second_data)?;
1327
1328        let member_store = pool.get_member(member)?;
1329        let mut wtxn = member_store.env.write_txn().map_err(map_heed)?;
1330        let second_metadata = member_store
1331            .metadata
1332            .get(&wtxn, &second_hash)
1333            .map_err(map_heed)?
1334            .ok_or_else(|| StoreError::Other("test metadata row is missing".into()))?
1335            .to_vec();
1336        member_store
1337            .metadata
1338            .delete(&mut wtxn, &second_hash)
1339            .map_err(map_heed)?;
1340        member_store
1341            .metadata
1342            .put(&mut wtxn, &metadata_orphan_hash, &second_metadata)
1343            .map_err(map_heed)?;
1344        wtxn.commit().map_err(map_heed)?;
1345        pool.force_sync()?;
1346        drop(member_store);
1347        drop(pool);
1348
1349        let reader = PoolStoreReader::open(&catalog, config)?;
1350        let error = reader
1351            .validate_terminal_catalog_and_payloads()
1352            .expect_err("equal blob/metadata counts with different keys must fail terminal audit");
1353        assert!(error.to_string().contains("blobs/metadata key sets differ"));
1354        Ok(())
1355    }
1356
1357    #[test]
1358    fn reader_preserves_manifest_identity_when_a_member_is_unavailable() -> Result<(), StoreError> {
1359        let temp = tempfile::tempdir().map_err(StoreError::Io)?;
1360        let catalog = temp.path().join("catalog");
1361        let member_path = temp.path().join("member");
1362        let unavailable_path = temp.path().join("member-unavailable");
1363        let mut config = PoolStoreConfig::default();
1364        config.temperature.enabled = false;
1365        let pool = PoolStore::open(&catalog, config.clone())?;
1366        let member = pool.add_member(PoolMemberConfig::new(member_path.clone(), 1024 * 1024))?;
1367        drop(pool);
1368        fs::rename(&member_path, &unavailable_path).map_err(StoreError::Io)?;
1369
1370        let default_error = match PoolStoreReader::open(&catalog, config.clone()) {
1371            Ok(_) => {
1372                return Err(StoreError::Other(
1373                    "default reader accepted unavailable member".into(),
1374                ))
1375            }
1376            Err(error) => error,
1377        };
1378        assert!(default_error.to_string().contains("member"));
1379
1380        let reader = PoolStoreReader::open_with_unavailable_members_for_audit(&catalog, config)?;
1381        assert_eq!(reader.member_ids(), vec![member]);
1382        let error = reader
1383            .member_existing_hashes_in_sorted_candidates(member, &[sha256(b"missing")])
1384            .expect_err("unavailable member must remain an explicit read error");
1385        assert!(error.to_string().contains("unavailable to reader"));
1386        Ok(())
1387    }
1388}