1use super::member::{open_member_reader, verify_member_path};
2use super::{
3 decode_manifest, map_heed, validate_controlled_manifest, LocationRecord, PoolCatalogLocation,
4 PoolManifest, PoolMemberConfig, PoolMemberId, PoolMemberState, PoolStoreConfig,
5 CATALOG_DATABASES, CATALOG_MAX_READERS, EXTERNAL_MARKER_NAME, MANIFEST_KEY, MEMBER_MARKER_NAME,
6};
7use crate::{managed_env::ManagedEnv, ExternalBlobOptions, LmdbBlobReader};
8use async_trait::async_trait;
9use hashtree_core::store::{slice_blob_range, Store, StoreError, StoreStats};
10use hashtree_core::{sha256, to_hex, types::Hash};
11use heed::types::Bytes;
12use heed::{Database, EnvFlags};
13use sha2::{Digest, Sha256};
14use std::collections::HashMap;
15use std::path::Path;
16use std::sync::Arc;
17
18struct ReadOnlyPoolStoreInner {
19 _env: ManagedEnv,
21 manifest: Database<Bytes, Bytes>,
22 opened_manifest_bytes: Vec<u8>,
23 opened_manifest_sha256: String,
24 locations: Database<Bytes, Bytes>,
25 members: HashMap<PoolMemberId, Arc<LmdbBlobReader>>,
26 manifest_snapshot: ReadOnlyPoolManifestSnapshot,
27}
28
29#[derive(Clone)]
38pub struct ReadOnlyPoolStore {
39 inner: Arc<ReadOnlyPoolStoreInner>,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct ReadOnlyPoolCatalogAudit {
44 pub stored_locations: u64,
45 pub sha256: String,
46 pub manifest_sha256: String,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct ReadOnlyPoolManifestMember {
52 pub id: PoolMemberId,
53 pub state: PoolMemberState,
54 pub config: PoolMemberConfig,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct ReadOnlyPoolManifestSnapshot {
63 pub generation: u64,
64 pub sha256: Hash,
65 pub members: Vec<ReadOnlyPoolManifestMember>,
66}
67
68fn require_opened_manifest(
69 current: &[u8],
70 opened: &[u8],
71 opened_sha256: &str,
72) -> Result<(), StoreError> {
73 if current != opened {
74 return Err(StoreError::Other(format!(
75 "pool manifest changed after read-only members were opened: expected {opened_sha256}, found {}",
76 to_hex(&sha256(current))
77 )));
78 }
79 Ok(())
80}
81
82impl ReadOnlyPoolStore {
83 pub fn open<P: AsRef<Path>>(path: P) -> Result<Self, StoreError> {
84 Self::open_inner(path.as_ref(), None)
85 }
86
87 pub fn open_controlled<P: AsRef<Path>>(
94 path: P,
95 config: PoolStoreConfig,
96 ) -> Result<Self, StoreError> {
97 if config.temperature.enabled {
98 return Err(StoreError::Other(
99 "controlled read-only Pool open requires temperature tracking to be disabled"
100 .into(),
101 ));
102 }
103 if config.catalog_lmdb_identity.is_none()
104 || config.expected_manifest_sha256.is_none()
105 || config.member_runtime_paths.is_empty()
106 {
107 return Err(StoreError::Other(
108 "controlled read-only Pool open requires catalog identity, exact manifest SHA-256, and every member runtime binding".into(),
109 ));
110 }
111 Self::open_inner(path.as_ref(), Some(config))
112 }
113
114 fn open_inner(path: &Path, config: Option<PoolStoreConfig>) -> Result<Self, StoreError> {
115 if !path.join("data.mdb").is_file() {
116 return Err(StoreError::Other(format!(
117 "read-only pool catalog is missing at {}",
118 path.display()
119 )));
120 }
121
122 let mut options = ManagedEnv::read_only_options(path).map_err(map_heed)?;
123 options
124 .max_dbs(CATALOG_DATABASES)
125 .max_readers(CATALOG_MAX_READERS);
126 unsafe {
127 options.flags(EnvFlags::NO_READ_AHEAD);
128 }
129 let env = unsafe {
130 match config
131 .as_ref()
132 .and_then(|config| config.catalog_lmdb_identity)
133 {
134 Some(identity) => ManagedEnv::open_pinned(&options, path, identity),
135 None => ManagedEnv::open(&options, path),
136 }
137 }
138 .map_err(|error| {
139 StoreError::Other(format!(
140 "open read-only pool catalog {}: {error}",
141 path.display()
142 ))
143 })?;
144 let rtxn = env.read_txn().map_err(map_heed)?;
145 let manifest_db = env
146 .open_database::<Bytes, Bytes>(&rtxn, Some("manifest"))
147 .map_err(map_heed)?
148 .ok_or_else(|| StoreError::Other("pool manifest database is missing".into()))?;
149 let locations = env
150 .open_database::<Bytes, Bytes>(&rtxn, Some("locations"))
151 .map_err(map_heed)?
152 .ok_or_else(|| StoreError::Other("pool locations database is missing".into()))?;
153 let manifest_bytes = manifest_db
154 .get(&rtxn, MANIFEST_KEY)
155 .map_err(map_heed)?
156 .ok_or_else(|| StoreError::Other("pool manifest is missing".into()))?;
157 let opened_manifest_bytes = manifest_bytes.to_vec();
158 let opened_manifest_sha256 = to_hex(&sha256(&opened_manifest_bytes));
159 let mut member_runtime_paths = config
160 .as_ref()
161 .map(|config| {
162 config
163 .member_runtime_paths
164 .iter()
165 .map(|binding| (binding.id, binding.clone()))
166 .collect::<HashMap<_, _>>()
167 })
168 .unwrap_or_default();
169 if config
170 .as_ref()
171 .is_some_and(|config| member_runtime_paths.len() != config.member_runtime_paths.len())
172 {
173 return Err(StoreError::Other(
174 "duplicate runtime path binding for controlled read-only Pool open".into(),
175 ));
176 }
177 let manifest = match config
178 .as_ref()
179 .and_then(|config| config.expected_manifest_sha256)
180 {
181 Some(expected) => validate_controlled_manifest(
182 &opened_manifest_bytes,
183 expected,
184 &member_runtime_paths,
185 )?,
186 None => decode_manifest(&opened_manifest_bytes)?,
187 };
188 let manifest_snapshot = ReadOnlyPoolManifestSnapshot {
189 generation: manifest.generation,
190 sha256: sha256(&opened_manifest_bytes),
191 members: manifest
192 .members
193 .iter()
194 .map(|member| ReadOnlyPoolManifestMember {
195 id: member.id,
196 state: member.state,
197 config: member.config.clone(),
198 })
199 .collect(),
200 };
201 rtxn.commit().map_err(map_heed)?;
203
204 let members = if config.is_some() {
205 open_controlled_members(&manifest, &mut member_runtime_paths)?
206 } else {
207 open_members(&manifest)?
208 };
209 Ok(Self {
210 inner: Arc::new(ReadOnlyPoolStoreInner {
211 _env: env,
212 manifest: manifest_db,
213 opened_manifest_bytes,
214 opened_manifest_sha256,
215 locations,
216 members,
217 manifest_snapshot,
218 }),
219 })
220 }
221
222 pub fn manifest_snapshot(&self) -> Result<ReadOnlyPoolManifestSnapshot, StoreError> {
227 let rtxn = self.inner._env.read_txn().map_err(map_heed)?;
228 let manifest = self
229 .inner
230 .manifest
231 .get(&rtxn, MANIFEST_KEY)
232 .map_err(map_heed)?
233 .ok_or_else(|| StoreError::Other("pool manifest is missing".into()))?;
234 require_opened_manifest(
235 manifest,
236 &self.inner.opened_manifest_bytes,
237 &self.inner.opened_manifest_sha256,
238 )?;
239 decode_manifest(manifest)?;
240 Ok(self.inner.manifest_snapshot.clone())
241 }
242
243 pub fn blob_catalog_locations(
248 &self,
249 hashes: &[Hash],
250 ) -> Result<Vec<PoolCatalogLocation>, StoreError> {
251 let rtxn = self.inner._env.read_txn().map_err(map_heed)?;
252 let manifest = self
253 .inner
254 .manifest
255 .get(&rtxn, MANIFEST_KEY)
256 .map_err(map_heed)?
257 .ok_or_else(|| StoreError::Other("pool manifest is missing".into()))?;
258 require_opened_manifest(
259 manifest,
260 &self.inner.opened_manifest_bytes,
261 &self.inner.opened_manifest_sha256,
262 )?;
263 hashes
264 .iter()
265 .map(|hash| {
266 let record = self
267 .inner
268 .locations
269 .get(&rtxn, hash)
270 .map_err(map_heed)?
271 .map(LocationRecord::decode)
272 .transpose()?;
273 Ok(match record {
274 None => PoolCatalogLocation::Missing,
275 Some(LocationRecord::Pending { member, size }) => {
276 PoolCatalogLocation::Pending { member, size }
277 }
278 Some(LocationRecord::Stored { member, size }) => {
279 PoolCatalogLocation::Stored { member, size }
280 }
281 Some(LocationRecord::Moving {
282 source,
283 target,
284 size,
285 }) => PoolCatalogLocation::Moving {
286 source,
287 target,
288 size,
289 },
290 })
291 })
292 .collect()
293 }
294
295 fn read_location(&self, hash: &Hash) -> Result<Option<LocationRecord>, StoreError> {
296 let rtxn = self.inner._env.read_txn().map_err(map_heed)?;
297 self.inner
298 .locations
299 .get(&rtxn, hash)
300 .map_err(map_heed)?
301 .map(LocationRecord::decode)
302 .transpose()
303 }
304
305 pub fn validate_committed_catalog(&self) -> Result<ReadOnlyPoolCatalogAudit, StoreError> {
311 let rtxn = self.inner._env.read_txn().map_err(map_heed)?;
312 let manifest = self
313 .inner
314 .manifest
315 .get(&rtxn, MANIFEST_KEY)
316 .map_err(map_heed)?
317 .ok_or_else(|| StoreError::Other("pool manifest is missing".into()))?;
318 require_opened_manifest(
319 manifest,
320 &self.inner.opened_manifest_bytes,
321 &self.inner.opened_manifest_sha256,
322 )?;
323 decode_manifest(manifest)?;
325 let mut digest = Sha256::new();
326 digest.update(b"hashtree-read-only-pool-catalog-v1\0");
327 digest.update((manifest.len() as u64).to_be_bytes());
328 digest.update(manifest);
329 let mut stored = 0u64;
330 for item in self.inner.locations.iter(&rtxn).map_err(map_heed)? {
331 let (hash, encoded) = item.map_err(map_heed)?;
332 let hash: Hash = hash
333 .try_into()
334 .map_err(|_| StoreError::Other("invalid pool catalog hash key".into()))?;
335 let location = LocationRecord::decode(encoded)?;
336 match location {
337 LocationRecord::Stored { member, .. } => {
338 if !self.inner.members.contains_key(&member) {
339 return Err(StoreError::Other(format!(
340 "pool catalog location {} references unknown member {member}",
341 to_hex(&hash)
342 )));
343 }
344 stored = stored.checked_add(1).ok_or_else(|| {
345 StoreError::Other("pool catalog location count overflow".into())
346 })?;
347 }
348 LocationRecord::Pending { .. } => {
349 return Err(StoreError::Other(format!(
350 "pool catalog contains pending location {}",
351 to_hex(&hash)
352 )));
353 }
354 LocationRecord::Moving { .. } => {
355 return Err(StoreError::Other(format!(
356 "pool catalog contains moving location {}",
357 to_hex(&hash)
358 )));
359 }
360 }
361 digest.update(hash);
362 digest.update((encoded.len() as u64).to_be_bytes());
363 digest.update(encoded);
364 }
365 let digest: Hash = digest.finalize().into();
366 Ok(ReadOnlyPoolCatalogAudit {
367 stored_locations: stored,
368 sha256: to_hex(&digest),
369 manifest_sha256: self.inner.opened_manifest_sha256.clone(),
370 })
371 }
372
373 pub fn require_durable_external_blob_writes(&self) -> Result<(), StoreError> {
377 let manifest = decode_manifest(&self.inner.opened_manifest_bytes)?;
378 for member in manifest.members {
379 if member.config.external_blob_dir.is_some() && !member.config.external_blob_sync {
380 return Err(StoreError::Other(format!(
381 "pool member {} has external_blob_sync disabled",
382 member.id
383 )));
384 }
385 }
386 Ok(())
387 }
388
389 pub fn get_sync(&self, hash: &Hash) -> Result<Option<Vec<u8>>, StoreError> {
390 let Some(location) = self.read_location(hash)? else {
391 return Ok(None);
392 };
393 let id = match location {
394 LocationRecord::Stored { member, .. } => member,
395 LocationRecord::Pending { .. } => {
396 return Err(StoreError::Other(format!(
397 "read-only terminal verification rejected pending pool location {}",
398 to_hex(hash)
399 )));
400 }
401 LocationRecord::Moving { .. } => {
402 return Err(StoreError::Other(format!(
403 "read-only terminal verification rejected moving pool location {}",
404 to_hex(hash)
405 )));
406 }
407 };
408 let member = self.inner.members.get(&id).ok_or_else(|| {
409 StoreError::Other(format!("pool location references unknown member {id}"))
410 })?;
411 match member.get_sync(hash)? {
412 Some(data) if sha256(&data) == *hash && data.len() as u64 == location.size() => {
413 Ok(Some(data))
414 }
415 Some(_) => Err(StoreError::Other(format!(
416 "read-only pool member {id} returned corrupt or size-mismatched bytes"
417 ))),
418 None => Err(StoreError::Other(format!(
419 "pool catalog location {} references missing bytes on member {id}",
420 to_hex(hash)
421 ))),
422 }
423 }
424
425 pub fn blob_size_sync(&self, hash: &Hash) -> Result<Option<u64>, StoreError> {
426 let Some(location) = self.read_location(hash)? else {
427 return Ok(None);
428 };
429 match location {
430 LocationRecord::Stored { member, size } => {
431 let member_store = self.inner.members.get(&member).ok_or_else(|| {
432 StoreError::Other(format!("pool location references unknown member {member}"))
433 })?;
434 match member_store.blob_size_sync(hash)? {
435 Some(member_size) if member_size == size => Ok(Some(size)),
436 Some(member_size) => Err(StoreError::Other(format!(
437 "pool catalog size {size} for {} differs from member {member} size {member_size}",
438 to_hex(hash)
439 ))),
440 None => Err(StoreError::Other(format!(
441 "pool catalog location {} references missing bytes on member {member}",
442 to_hex(hash)
443 ))),
444 }
445 }
446 LocationRecord::Pending { .. } => Err(StoreError::Other(format!(
447 "read-only terminal verification rejected pending pool location {}",
448 to_hex(hash)
449 ))),
450 LocationRecord::Moving { .. } => Err(StoreError::Other(format!(
451 "read-only terminal verification rejected moving pool location {}",
452 to_hex(hash)
453 ))),
454 }
455 }
456
457 pub fn get_range_sync(
458 &self,
459 hash: &Hash,
460 start: u64,
461 end_inclusive: u64,
462 ) -> Result<Option<Vec<u8>>, StoreError> {
463 self.get_sync(hash)?
464 .map(|data| slice_blob_range(&data, start, end_inclusive))
465 .transpose()
466 }
467}
468
469fn open_members(
470 manifest: &PoolManifest,
471) -> Result<HashMap<PoolMemberId, Arc<LmdbBlobReader>>, StoreError> {
472 let mut stores = HashMap::with_capacity(manifest.members.len());
473 for member in &manifest.members {
474 let config = &member.config;
475 verify_member_path(&config.path, MEMBER_MARKER_NAME, member.id)?;
476 let external = external_options(member.id, config)?;
477 if stores
478 .insert(
479 member.id,
480 Arc::new(LmdbBlobReader::open(&config.path, external)?),
481 )
482 .is_some()
483 {
484 return Err(StoreError::Other(format!(
485 "pool manifest repeats member {}",
486 member.id
487 )));
488 }
489 }
490 Ok(stores)
491}
492
493fn open_controlled_members(
494 manifest: &PoolManifest,
495 bindings: &mut HashMap<PoolMemberId, super::PoolMemberRuntimePaths>,
496) -> Result<HashMap<PoolMemberId, Arc<LmdbBlobReader>>, StoreError> {
497 let mut stores = HashMap::with_capacity(manifest.members.len());
498 for member in &manifest.members {
499 let binding = bindings.remove(&member.id).ok_or_else(|| {
500 StoreError::Other(format!(
501 "controlled read-only Pool open has no runtime binding for member {}",
502 member.id
503 ))
504 })?;
505 let mut runtime_config = member.config.clone();
506 runtime_config.path = binding.runtime_path;
507 runtime_config.external_blob_dir = binding.runtime_external_path;
508 let reader = open_member_reader(
509 member.id,
510 &runtime_config,
511 Some(binding.lmdb_identity),
512 false,
513 1,
514 )?;
515 if stores.insert(member.id, Arc::new(reader)).is_some() {
516 return Err(StoreError::Other(format!(
517 "pool manifest repeats member {}",
518 member.id
519 )));
520 }
521 }
522 if let Some(unexpected) = bindings.keys().next() {
523 return Err(StoreError::Other(format!(
524 "controlled read-only Pool open has runtime binding for unknown member {unexpected}"
525 )));
526 }
527 Ok(stores)
528}
529
530fn external_options(
531 id: PoolMemberId,
532 config: &PoolMemberConfig,
533) -> Result<Option<ExternalBlobOptions>, StoreError> {
534 match (
535 config.external_blob_dir.as_ref(),
536 config.external_blob_min_bytes,
537 ) {
538 (Some(path), Some(min_bytes)) => {
539 verify_member_path(path, EXTERNAL_MARKER_NAME, id)?;
540 Ok(Some(ExternalBlobOptions {
541 base_path: path.clone(),
542 min_bytes: usize::try_from(min_bytes).map_err(|_| {
543 StoreError::Other("pool external blob threshold exceeds usize".into())
544 })?,
545 sync: config.external_blob_sync,
546 pack_target_bytes: config
547 .external_pack_target_bytes
548 .map(usize::try_from)
549 .transpose()
550 .map_err(|_| {
551 StoreError::Other("pool external pack target exceeds usize".into())
552 })?,
553 }))
554 }
555 (None, None) => Ok(None),
556 _ => Err(StoreError::Other(
557 "invalid pool external blob configuration".into(),
558 )),
559 }
560}
561
562fn read_only_error() -> StoreError {
563 StoreError::Other("read-only PoolStore does not permit mutation".into())
564}
565
566#[async_trait]
567impl Store for ReadOnlyPoolStore {
568 async fn put(&self, _hash: Hash, _data: Vec<u8>) -> Result<bool, StoreError> {
569 Err(read_only_error())
570 }
571
572 async fn get(&self, hash: &Hash) -> Result<Option<Vec<u8>>, StoreError> {
573 self.get_sync(hash)
574 }
575
576 async fn get_range(
577 &self,
578 hash: &Hash,
579 start: u64,
580 end_inclusive: u64,
581 ) -> Result<Option<Vec<u8>>, StoreError> {
582 self.get_sync(hash)?
583 .map(|data| slice_blob_range(&data, start, end_inclusive))
584 .transpose()
585 }
586
587 async fn blob_size(&self, hash: &Hash) -> Result<Option<u64>, StoreError> {
588 self.blob_size_sync(hash)
589 }
590
591 async fn has(&self, hash: &Hash) -> Result<bool, StoreError> {
592 Ok(self.get_sync(hash)?.is_some())
593 }
594
595 async fn delete(&self, _hash: &Hash) -> Result<bool, StoreError> {
596 Err(read_only_error())
597 }
598
599 async fn stats(&self) -> StoreStats {
600 StoreStats::default()
601 }
602
603 async fn pin(&self, _hash: &Hash) -> Result<(), StoreError> {
604 Err(read_only_error())
605 }
606
607 async fn unpin(&self, _hash: &Hash) -> Result<(), StoreError> {
608 Err(read_only_error())
609 }
610}
611
612#[cfg(test)]
613mod tests {
614 use super::*;
615 use crate::{
616 PinnedLmdbFileIdentity, PinnedLmdbIdentity, PoolMemberRuntimePaths, PoolStore,
617 PoolStoreConfig,
618 };
619 use std::fs;
620 #[cfg(unix)]
621 use std::fs::File;
622 #[cfg(target_os = "linux")]
623 use std::os::fd::AsRawFd;
624 #[cfg(unix)]
625 use std::os::unix::fs::MetadataExt;
626 use std::process::Command;
627 use tempfile::TempDir;
628
629 const MUTATE_CATALOG_ENV: &str = "HASHTREE_READ_ONLY_MUTATE_CATALOG";
630 const MUTATE_MEMBER_ENV: &str = "HASHTREE_READ_ONLY_MUTATE_MEMBER";
631
632 fn file_sha256(path: &Path) -> Hash {
633 sha256(&fs::read(path).expect("read LMDB data file"))
634 }
635
636 fn file_modified(path: &Path) -> std::time::SystemTime {
637 fs::metadata(path)
638 .expect("read LMDB data file metadata")
639 .modified()
640 .expect("read LMDB data file mtime")
641 }
642
643 fn pool_config() -> PoolStoreConfig {
644 let mut config = PoolStoreConfig::default();
645 config.temperature.enabled = false;
646 config
647 }
648
649 #[cfg(unix)]
650 fn lmdb_identity(path: &Path) -> PinnedLmdbIdentity {
651 let data = fs::metadata(path.join("data.mdb")).expect("data.mdb metadata");
652 let lock = fs::metadata(path.join("lock.mdb")).expect("lock.mdb metadata");
653 PinnedLmdbIdentity {
654 data: PinnedLmdbFileIdentity {
655 device: data.dev(),
656 inode: data.ino(),
657 },
658 lock: PinnedLmdbFileIdentity {
659 device: lock.dev(),
660 inode: lock.ino(),
661 },
662 }
663 }
664
665 #[cfg(unix)]
666 struct PinnedDirectory {
667 _directory: File,
668 runtime_path: std::path::PathBuf,
669 }
670
671 #[cfg(unix)]
672 fn pin_directory(path: &Path) -> PinnedDirectory {
673 let directory = File::open(path).expect("open directory");
674 #[cfg(target_os = "linux")]
675 let runtime_path =
676 std::path::PathBuf::from(format!("/proc/self/fd/{}", directory.as_raw_fd()));
677 #[cfg(not(target_os = "linux"))]
678 let runtime_path = path.to_path_buf();
679 PinnedDirectory {
680 _directory: directory,
681 runtime_path,
682 }
683 }
684
685 #[cfg(unix)]
686 #[test]
687 fn controlled_open_exposes_exact_manifest_and_catalog_states() {
688 let temp = TempDir::new().expect("temp dir");
689 let catalog = temp.path().join("catalog");
690 let source_path = temp.path().join("source");
691 let target_path = temp.path().join("target");
692 let source_config = PoolMemberConfig::new(source_path.clone(), 16 * 1024 * 1024);
693 let target_config = PoolMemberConfig::new(target_path.clone(), 16 * 1024 * 1024);
694 let pool = PoolStore::open(&catalog, pool_config()).expect("open pool");
695 let source = pool
696 .add_member(source_config.clone())
697 .expect("add source member");
698 let target = pool
699 .add_member(target_config.clone())
700 .expect("add target member");
701
702 let stored_data = b"controlled read-only stored bytes";
703 let stored_hash = sha256(stored_data);
704 pool.put_sync(stored_hash, stored_data)
705 .expect("put stored blob");
706 let stored_location = pool
707 .read_location(&stored_hash)
708 .expect("read stored location")
709 .expect("stored catalog record");
710 let missing_hash = sha256(b"controlled read-only missing");
711 let pending_hash = sha256(b"controlled read-only pending");
712 let moving_hash = sha256(b"controlled read-only moving");
713 let mut wtxn = pool.env.write_txn().expect("catalog write txn");
714 pool.set_location_txn(
715 &mut wtxn,
716 pending_hash,
717 Some(LocationRecord::Pending {
718 member: source,
719 size: 37,
720 }),
721 )
722 .expect("write pending");
723 pool.set_location_txn(
724 &mut wtxn,
725 moving_hash,
726 Some(LocationRecord::Moving {
727 source,
728 target,
729 size: 41,
730 }),
731 )
732 .expect("write moving");
733 wtxn.commit().expect("commit catalog states");
734 pool.force_sync().expect("sync pool");
735 drop(pool);
736
737 let ordinary =
738 ReadOnlyPoolStore::open(&catalog).expect("open ordinary read-only snapshot reader");
739 let expected_snapshot = ordinary.manifest_snapshot().expect("manifest snapshot");
740 assert_eq!(
741 expected_snapshot.sha256,
742 sha256(&ordinary.inner.opened_manifest_bytes)
743 );
744 assert_eq!(expected_snapshot.members.len(), 2);
745 assert_eq!(
746 expected_snapshot
747 .members
748 .iter()
749 .find(|member| member.id == source)
750 .expect("source snapshot")
751 .config,
752 source_config
753 );
754 assert_eq!(
755 expected_snapshot
756 .members
757 .iter()
758 .find(|member| member.id == target)
759 .expect("target snapshot")
760 .config,
761 target_config
762 );
763 drop(ordinary);
764
765 let catalog_identity = lmdb_identity(&catalog);
766 let source_identity = lmdb_identity(&source_path);
767 let target_identity = lmdb_identity(&target_path);
768 let catalog_pin = pin_directory(&catalog);
769 let source_pin = pin_directory(&source_path);
770 let target_pin = pin_directory(&target_path);
771 let mut controlled = pool_config();
772 controlled.catalog_lmdb_identity = Some(catalog_identity);
773 controlled.expected_manifest_sha256 = Some(expected_snapshot.sha256);
774 controlled.member_runtime_paths = vec![
775 PoolMemberRuntimePaths {
776 id: source,
777 configured_path: source_path,
778 runtime_path: source_pin.runtime_path.clone(),
779 configured_external_path: None,
780 runtime_external_path: None,
781 lmdb_identity: source_identity,
782 },
783 PoolMemberRuntimePaths {
784 id: target,
785 configured_path: target_path,
786 runtime_path: target_pin.runtime_path.clone(),
787 configured_external_path: None,
788 runtime_external_path: None,
789 lmdb_identity: target_identity,
790 },
791 ];
792
793 let mut enabled_temperature = controlled.clone();
794 enabled_temperature.temperature.enabled = true;
795 assert!(
796 ReadOnlyPoolStore::open_controlled(&catalog_pin.runtime_path, enabled_temperature)
797 .err()
798 .expect("temperature-enabled controlled open must fail")
799 .to_string()
800 .contains("temperature tracking")
801 );
802
803 let mut wrong_manifest = controlled.clone();
804 wrong_manifest.expected_manifest_sha256 = Some(sha256(b"wrong manifest authority"));
805 assert!(
806 ReadOnlyPoolStore::open_controlled(&catalog_pin.runtime_path, wrong_manifest)
807 .err()
808 .expect("wrong manifest authority must fail")
809 .to_string()
810 .contains("manifest SHA-256 differs")
811 );
812
813 let mut missing_binding = controlled.clone();
814 missing_binding.member_runtime_paths.pop();
815 assert!(
816 ReadOnlyPoolStore::open_controlled(&catalog_pin.runtime_path, missing_binding)
817 .err()
818 .expect("incomplete member authority must fail")
819 .to_string()
820 .contains("pinned pool topology")
821 );
822
823 let reader = ReadOnlyPoolStore::open_controlled(&catalog_pin.runtime_path, controlled)
824 .expect("open controlled read-only Pool");
825 assert_eq!(
826 reader.manifest_snapshot().expect("controlled snapshot"),
827 expected_snapshot
828 );
829 let expected_stored = match stored_location {
830 LocationRecord::Stored { member, size } => PoolCatalogLocation::Stored { member, size },
831 other => panic!("put committed unexpected location {other:?}"),
832 };
833 let locations = reader
834 .blob_catalog_locations(&[missing_hash, pending_hash, stored_hash, moving_hash])
835 .expect("read exact catalog states");
836 assert_eq!(
837 locations,
838 vec![
839 PoolCatalogLocation::Missing,
840 PoolCatalogLocation::Pending {
841 member: source,
842 size: 37,
843 },
844 expected_stored,
845 PoolCatalogLocation::Moving {
846 source,
847 target,
848 size: 41,
849 },
850 ]
851 );
852 assert_eq!(
853 reader.get_sync(&stored_hash).expect("read stored blob"),
854 Some(stored_data.to_vec())
855 );
856 }
857
858 #[tokio::test]
859 async fn reads_real_pool_without_mutating_data_files() {
860 let temp = TempDir::new().expect("temp dir");
861 let catalog = temp.path().join("catalog");
862 let member = temp.path().join("member");
863 let pool = PoolStore::open(&catalog, pool_config()).expect("open pool");
864 pool.add_member(PoolMemberConfig::new(member.clone(), 1024 * 1024))
865 .expect("add member");
866 let data = b"read-only terminal audit bytes";
867 let hash = sha256(data);
868 pool.put_sync(hash, data).expect("put blob");
869 pool.force_sync().expect("sync pool");
870 drop(pool);
871
872 let catalog_data = catalog.join("data.mdb");
873 let member_data = member.join("data.mdb");
874 let catalog_before = file_sha256(&catalog_data);
875 let member_before = file_sha256(&member_data);
876 let catalog_mtime_before = file_modified(&catalog_data);
877 let member_mtime_before = file_modified(&member_data);
878 let reader = ReadOnlyPoolStore::open(&catalog).expect("open read-only pool");
879 reader
880 .require_durable_external_blob_writes()
881 .expect("ordinary pool members use durable external writes");
882 let audit = reader
883 .validate_committed_catalog()
884 .expect("validate catalog");
885 assert_eq!(audit.stored_locations, 1);
886 assert_eq!(audit.manifest_sha256.len(), 64);
887 assert_eq!(
888 reader.get_sync(&hash).expect("get blob"),
889 Some(data.to_vec())
890 );
891 assert_eq!(
892 reader.blob_size_sync(&hash).expect("blob size"),
893 Some(data.len() as u64)
894 );
895 assert_eq!(
896 reader.get_range(&hash, 5, 8).await.expect("blob range"),
897 Some(data[5..=8].to_vec())
898 );
899 assert!(reader.has(&hash).await.expect("has blob"));
900 assert!(reader.put(hash, data.to_vec()).await.is_err());
901 assert!(reader.delete(&hash).await.is_err());
902 assert!(reader.pin(&hash).await.is_err());
903 assert!(reader.unpin(&hash).await.is_err());
904 drop(reader);
905
906 assert_eq!(file_sha256(&catalog_data), catalog_before);
907 assert_eq!(file_sha256(&member_data), member_before);
908 assert_eq!(file_modified(&catalog_data), catalog_mtime_before);
909 assert_eq!(file_modified(&member_data), member_mtime_before);
910 }
911
912 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
913 async fn production_pool_read_allows_nested_reader_on_tokio_worker() {
914 let temp = TempDir::new().expect("generated Pool root");
915 let catalog = temp.path().join("catalog");
916 let member = temp.path().join("member");
917 let pool = PoolStore::open(&catalog, pool_config()).expect("open generated Pool");
918 pool.add_member(PoolMemberConfig::new(member, 16 * 1024 * 1024))
919 .expect("add generated member");
920 let body = b"real nested Pool reader body";
921 let hash = sha256(body);
922 pool.put_sync(hash, body).expect("store generated body");
923 pool.force_sync().expect("sync generated Pool");
924 drop(pool);
925
926 let reader = ReadOnlyPoolStore::open(&catalog).expect("open production Pool reader");
927 tokio::task::block_in_place(|| {
928 let held = reader
929 .inner
930 ._env
931 .read_txn()
932 .expect("hold first catalog read transaction");
933 assert_eq!(
934 reader
935 .get_sync(&hash)
936 .expect("open nested production catalog transaction"),
937 Some(body.to_vec())
938 );
939 drop(held);
940 });
941 }
942
943 #[test]
944 fn durable_writer_check_rejects_unsynced_external_pool_members() {
945 let temp = TempDir::new().expect("temp dir");
946 let catalog = temp.path().join("catalog");
947 let member = temp.path().join("member");
948 let external = temp.path().join("external");
949 let pool = PoolStore::open(&catalog, pool_config()).expect("open pool");
950 pool.add_member(
951 PoolMemberConfig::new(member, 1024 * 1024)
952 .with_external_blobs(external, 1, false, None),
953 )
954 .expect("add unsynced external member");
955 pool.force_sync().expect("sync pool manifest");
956 drop(pool);
957
958 let reader = ReadOnlyPoolStore::open(&catalog).expect("open read-only pool");
959 let error = reader
960 .require_durable_external_blob_writes()
961 .expect_err("unsynced external member must fail durable writer validation");
962 assert!(error.to_string().contains("external_blob_sync disabled"));
963 }
964
965 #[test]
966 fn rejects_pending_and_moving_catalog_records() {
967 let temp = TempDir::new().expect("temp dir");
968 let catalog = temp.path().join("catalog");
969 let pool = PoolStore::open(&catalog, pool_config()).expect("open pool");
970 let source = pool
971 .add_member(PoolMemberConfig::new(
972 temp.path().join("source"),
973 1024 * 1024,
974 ))
975 .expect("add source");
976 let target = pool
977 .add_member(PoolMemberConfig::new(
978 temp.path().join("target"),
979 1024 * 1024,
980 ))
981 .expect("add target");
982 let pending_hash = sha256(b"pending audit record");
983 let moving_hash = sha256(b"moving audit record");
984 let mut wtxn = pool.env.write_txn().expect("catalog write txn");
985 pool.set_location_txn(
986 &mut wtxn,
987 pending_hash,
988 Some(LocationRecord::Pending {
989 member: source,
990 size: 20,
991 }),
992 )
993 .expect("write pending location");
994 pool.set_location_txn(
995 &mut wtxn,
996 moving_hash,
997 Some(LocationRecord::Moving {
998 source,
999 target,
1000 size: 19,
1001 }),
1002 )
1003 .expect("write moving location");
1004 wtxn.commit().expect("commit interrupted records");
1005 pool.force_sync().expect("sync catalog");
1006 drop(pool);
1007
1008 let reader = ReadOnlyPoolStore::open(&catalog).expect("open read-only pool");
1009 let error = reader
1010 .validate_committed_catalog()
1011 .expect_err("catalog with interrupted records must fail");
1012 assert!(
1013 error.to_string().contains("pending") || error.to_string().contains("moving"),
1014 "unexpected catalog error: {error}"
1015 );
1016 assert!(reader
1017 .get_sync(&pending_hash)
1018 .expect_err("pending read must fail")
1019 .to_string()
1020 .contains("pending"));
1021 assert!(reader
1022 .get_sync(&moving_hash)
1023 .expect_err("moving read must fail")
1024 .to_string()
1025 .contains("moving"));
1026 assert!(reader
1027 .blob_size_sync(&pending_hash)
1028 .expect_err("pending size must fail")
1029 .to_string()
1030 .contains("pending"));
1031 assert!(reader
1032 .blob_size_sync(&moving_hash)
1033 .expect_err("moving size must fail")
1034 .to_string()
1035 .contains("moving"));
1036 }
1037
1038 #[test]
1039 fn rejects_stored_location_with_missing_member_bytes() {
1040 let temp = TempDir::new().expect("temp dir");
1041 let catalog = temp.path().join("catalog");
1042 let member_path = temp.path().join("member");
1043 let pool = PoolStore::open(&catalog, pool_config()).expect("open pool");
1044 let member = pool
1045 .add_member(PoolMemberConfig::new(member_path, 1024 * 1024))
1046 .expect("add member");
1047 let data = b"catalogued but physically missing";
1048 let hash = sha256(data);
1049 pool.put_sync(hash, data).expect("put blob");
1050 let member_store = pool.get_member(member).expect("open member");
1051 assert!(member_store
1052 .delete_sync(&hash)
1053 .expect("delete only member bytes"));
1054 pool.force_sync().expect("sync corrupt physical state");
1055 drop(member_store);
1056 drop(pool);
1057
1058 let reader = ReadOnlyPoolStore::open(&catalog).expect("open read-only pool");
1059 let get_error = reader
1060 .get_sync(&hash)
1061 .expect_err("stored location without bytes must fail closed");
1062 assert!(get_error.to_string().contains(&to_hex(&hash)));
1063 assert!(get_error.to_string().contains(&member.to_string()));
1064 assert!(get_error.to_string().contains("missing bytes"));
1065 let size_error = reader
1066 .blob_size_sync(&hash)
1067 .expect_err("size must verify physical bytes");
1068 assert!(size_error.to_string().contains("missing bytes"));
1069 }
1070
1071 #[test]
1072 fn rejects_catalog_location_for_unknown_member() {
1073 let temp = TempDir::new().expect("temp dir");
1074 let catalog = temp.path().join("catalog");
1075 let pool = PoolStore::open(&catalog, pool_config()).expect("open pool");
1076 pool.add_member(PoolMemberConfig::new(
1077 temp.path().join("member"),
1078 1024 * 1024,
1079 ))
1080 .expect("add member");
1081 let hash = sha256(b"unknown member audit record");
1082 let unknown = PoolMemberId::new();
1083 let mut wtxn = pool.env.write_txn().expect("catalog write txn");
1084 pool.set_location_txn(
1085 &mut wtxn,
1086 hash,
1087 Some(LocationRecord::Stored {
1088 member: unknown,
1089 size: 27,
1090 }),
1091 )
1092 .expect("write unknown member location");
1093 wtxn.commit().expect("commit unknown member record");
1094 pool.force_sync().expect("sync catalog");
1095 drop(pool);
1096
1097 let reader = ReadOnlyPoolStore::open(&catalog).expect("open read-only pool");
1098 assert!(reader
1099 .validate_committed_catalog()
1100 .expect_err("unknown catalog member must fail")
1101 .to_string()
1102 .contains("unknown member"));
1103 assert!(reader
1104 .get_sync(&hash)
1105 .expect_err("unknown member read must fail")
1106 .to_string()
1107 .contains("unknown member"));
1108 assert!(reader
1109 .blob_size_sync(&hash)
1110 .expect_err("unknown member size must fail")
1111 .to_string()
1112 .contains("unknown member"));
1113 }
1114
1115 #[test]
1116 fn rejects_manifest_snapshot_that_differs_from_opened_members() {
1117 let opened = br#"{"version":1,"members":[]}"#;
1118 let opened_sha256 = to_hex(&sha256(opened));
1119 require_opened_manifest(opened, opened, &opened_sha256).unwrap();
1120
1121 let changed = br#"{"version":1,"members":[{"id":"changed"}]}"#;
1122 let error = require_opened_manifest(changed, opened, &opened_sha256)
1123 .expect_err("changed manifest snapshot must fail");
1124 assert!(error.to_string().contains(&opened_sha256));
1125 assert!(error.to_string().contains(&to_hex(&sha256(changed))));
1126 }
1127
1128 #[test]
1129 #[ignore = "subprocess entry point for read-only PoolStore manifest mutation"]
1130 fn read_only_manifest_mutator_helper() {
1131 let Some(catalog) = std::env::var_os(MUTATE_CATALOG_ENV) else {
1132 return;
1133 };
1134 let member = std::env::var_os(MUTATE_MEMBER_ENV).expect("mutator member path");
1135 let pool = PoolStore::open(catalog, pool_config()).expect("open catalog writer");
1136 pool.add_member(PoolMemberConfig::new(member.into(), 1024 * 1024))
1137 .expect("mutate pool manifest");
1138 pool.force_sync().expect("sync mutated manifest");
1139 }
1140
1141 #[test]
1142 fn live_catalog_audit_rejects_manifest_changed_after_reader_open() {
1143 check_live_catalog_manifest_change(true);
1144 }
1145
1146 #[test]
1147 fn live_catalog_growth_invalidates_reader_without_crashing() {
1148 check_live_catalog_manifest_change(false);
1149 }
1150
1151 fn check_live_catalog_manifest_change(reserve_catalog_space: bool) {
1152 let temp = TempDir::new().expect("temp dir");
1153 let catalog = temp.path().join("catalog");
1154 let initial_member = temp.path().join("initial-member");
1155 let added_member = temp.path().join("added-member");
1156 let pool = PoolStore::open(&catalog, pool_config()).expect("open pool");
1157 pool.add_member(PoolMemberConfig::new(initial_member, 1024 * 1024))
1158 .expect("add initial member");
1159 pool.force_sync().expect("sync initial manifest");
1160 drop(pool);
1161
1162 let catalog_data = catalog.join("data.mdb");
1163 if reserve_catalog_space {
1164 let file = fs::OpenOptions::new()
1167 .write(true)
1168 .open(&catalog_data)
1169 .expect("open closed catalog for preallocation");
1170 file.set_len(file.metadata().expect("catalog metadata").len() + 128 * 1024)
1171 .expect("reserve catalog pages");
1172 }
1173 let opened_size = fs::metadata(&catalog_data).expect("catalog size").len();
1174 let reader = ReadOnlyPoolStore::open(&catalog).expect("open pinned read-only pool");
1175 reader
1176 .validate_committed_catalog()
1177 .expect("initial catalog matches opened manifest");
1178
1179 let output = Command::new(std::env::current_exe().expect("test binary"))
1180 .arg("--ignored")
1181 .arg("--exact")
1182 .arg("pool::read_only::tests::read_only_manifest_mutator_helper")
1183 .env(MUTATE_CATALOG_ENV, &catalog)
1184 .env(MUTATE_MEMBER_ENV, &added_member)
1185 .env("RUST_TEST_THREADS", "1")
1186 .output()
1187 .expect("run manifest mutator helper");
1188 assert!(
1189 output.status.success(),
1190 "manifest mutator failed: stdout={} stderr={}",
1191 String::from_utf8_lossy(&output.stdout),
1192 String::from_utf8_lossy(&output.stderr)
1193 );
1194
1195 let changed_size = fs::metadata(&catalog_data)
1196 .expect("changed catalog size")
1197 .len();
1198 if reserve_catalog_space {
1199 assert_eq!(
1200 changed_size, opened_size,
1201 "manifest change must fit the mapping"
1202 );
1203 } else {
1204 assert!(changed_size > opened_size, "writer must grow the catalog");
1205 }
1206 let error = reader
1207 .validate_committed_catalog()
1208 .expect_err("changed live manifest must differ from opened member snapshot");
1209 let expected = if cfg!(windows) && !reserve_catalog_space {
1210 "MDB_MAP_RESIZED"
1211 } else {
1212 "manifest changed after read-only members were opened"
1213 };
1214 assert!(
1215 error.to_string().contains(expected),
1216 "unexpected live manifest mismatch: {error}"
1217 );
1218 drop(reader);
1219 let reopened = ReadOnlyPoolStore::open(&catalog).expect("reopen changed catalog");
1220 assert_eq!(
1221 reopened
1222 .manifest_snapshot()
1223 .expect("new manifest")
1224 .members
1225 .len(),
1226 2
1227 );
1228 }
1229}