Skip to main content

hashtree_cli/storage/
retention.rs

1use anyhow::{Context, Result};
2use futures::executor::block_on as sync_block_on;
3use hashtree_core::store::Store;
4use hashtree_core::{
5    decode_tree_node_by_cid, sha256, to_hex, types::Hash, Cid, HashTree, HashTreeConfig, LinkType,
6};
7use serde::de::{self, IgnoredAny, MapAccess, SeqAccess, Visitor};
8use serde::{Deserialize, Serialize};
9use std::collections::{BTreeMap, HashSet};
10use std::fs::{File, OpenOptions};
11#[cfg(unix)]
12use std::os::fd::AsRawFd;
13use std::path::{Path, PathBuf};
14use std::sync::Arc;
15use std::time::{SystemTime, UNIX_EPOCH};
16
17use super::quota::{CacheQuotaAdmission, CacheWritePermit};
18use super::{BlobMetadata, HashtreeStore, PRIORITY_FOLLOWED, PRIORITY_OWN};
19
20const MAX_PINNED_TREE_NODES: usize = 10_000_000;
21const MAX_UNBOUNDED_PINNED_TREE_BYTES: u64 = 1 << 50;
22const ORPHAN_SCAN_PAGE_SIZE: usize = 4_096;
23const RETENTION_ROOTS_LOCK_FILE: &str = ".retention-roots.lock";
24const MAX_PROFILE_REPAIR_RETENTION_LEASE_BYTES: u64 = 64 * 1024;
25const MAX_PROFILE_REPAIR_RETENTION_ROOTS: usize = 64;
26
27pub const PROFILE_REPAIR_RETENTION_LEASE_FORMAT: &str =
28    "iris-social/bulk-profile-index-repair-retention@1";
29pub const PROFILE_REPAIR_RETENTION_LEASE_RELATIVE_PATH: &str =
30    "nostr-index/bulk-projection-v2/profile-repair-v1/retention.json";
31
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
33#[serde(deny_unknown_fields)]
34pub struct ProfileRepairRetentionLease {
35    pub format: String,
36    pub authority_sha256: String,
37    pub roots: BTreeMap<String, String>,
38}
39
40impl ProfileRepairRetentionLease {
41    pub fn validate(&self) -> Result<()> {
42        if self.format != PROFILE_REPAIR_RETENTION_LEASE_FORMAT {
43            anyhow::bail!(
44                "profile repair retention lease has unsupported format {}",
45                self.format
46            );
47        }
48        if self.authority_sha256.len() != 64
49            || !self
50                .authority_sha256
51                .bytes()
52                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
53        {
54            anyhow::bail!(
55                "profile repair retention lease authority must be 64 lowercase hexadecimal characters"
56            );
57        }
58        if self.roots.is_empty() || self.roots.len() > MAX_PROFILE_REPAIR_RETENTION_ROOTS {
59            anyhow::bail!(
60                "profile repair retention lease must contain between 1 and {} roots",
61                MAX_PROFILE_REPAIR_RETENTION_ROOTS
62            );
63        }
64        for (label, encoded) in &self.roots {
65            if label.is_empty()
66                || label.len() > 128
67                || !label
68                    .bytes()
69                    .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
70            {
71                anyhow::bail!("profile repair retention lease has invalid root label {label:?}");
72            }
73            let cid = Cid::parse(encoded)
74                .map_err(|error| anyhow::anyhow!("invalid retained root {label}: {error}"))?;
75            if cid.to_string() != *encoded {
76                anyhow::bail!(
77                    "profile repair retention lease root {label} is not canonical CID text"
78                );
79            }
80        }
81        Ok(())
82    }
83
84    pub fn canonical_bytes(&self) -> Result<Vec<u8>> {
85        self.validate()?;
86        let mut bytes =
87            serde_json::to_vec(self).map_err(|error| anyhow::anyhow!("encode lease: {error}"))?;
88        bytes.push(b'\n');
89        Ok(bytes)
90    }
91
92    pub fn sha256(&self) -> Result<String> {
93        Ok(to_hex(&hashtree_core::sha256(&self.canonical_bytes()?)))
94    }
95
96    fn root_cids(&self) -> Result<Vec<Cid>> {
97        self.validate()?;
98        self.roots
99            .iter()
100            .map(|(label, encoded)| {
101                Cid::parse(encoded)
102                    .map_err(|error| anyhow::anyhow!("invalid retained root {label}: {error}"))
103            })
104            .collect()
105    }
106}
107
108#[derive(Debug, Clone, Copy)]
109enum RetentionRootsLockMode {
110    Shared,
111    Exclusive,
112}
113
114pub struct ProfileRepairRetentionPublicationGuard {
115    file: File,
116}
117
118impl Drop for ProfileRepairRetentionPublicationGuard {
119    fn drop(&mut self) {
120        #[cfg(unix)]
121        unsafe {
122            libc::flock(self.file.as_raw_fd(), libc::LOCK_UN);
123        }
124    }
125}
126
127/// Acquire the existing retention-root publication lock without creating any
128/// path or opening the application blob writer.
129///
130/// Recovery commands use this before they snapshot profile roots or inspect
131/// retained event DAGs. Requiring an existing direct regular file keeps a
132/// typo, symlink swap, or incomplete Social namespace from silently creating a
133/// different lock authority.
134pub fn acquire_existing_profile_repair_retention_guard(
135    base_path: &Path,
136) -> Result<ProfileRepairRetentionPublicationGuard> {
137    let path = base_path.join(RETENTION_ROOTS_LOCK_FILE);
138    let before = std::fs::symlink_metadata(&path)
139        .with_context(|| format!("inspect existing retention-roots lock {}", path.display()))?;
140    if before.file_type().is_symlink() || !before.file_type().is_file() {
141        anyhow::bail!(
142            "existing retention-roots lock is not a direct regular file: {}",
143            path.display()
144        );
145    }
146    let file = OpenOptions::new()
147        .read(true)
148        .write(true)
149        .open(&path)
150        .with_context(|| format!("open existing retention-roots lock {}", path.display()))?;
151    #[cfg(unix)]
152    {
153        let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) };
154        if result != 0 {
155            return Err(std::io::Error::last_os_error()).with_context(|| {
156                format!("acquire existing retention-roots lock {}", path.display())
157            });
158        }
159        use std::os::unix::fs::MetadataExt;
160        let opened = file
161            .metadata()
162            .with_context(|| format!("inspect opened retention-roots lock {}", path.display()))?;
163        let current = std::fs::symlink_metadata(&path)
164            .with_context(|| format!("reinspect retention-roots lock {}", path.display()))?;
165        if current.file_type().is_symlink()
166            || !current.file_type().is_file()
167            || opened.dev() != before.dev()
168            || opened.ino() != before.ino()
169            || current.dev() != before.dev()
170            || current.ino() != before.ino()
171        {
172            anyhow::bail!(
173                "retention-roots lock identity changed while it was acquired: {}",
174                path.display()
175            );
176        }
177    }
178    #[cfg(not(unix))]
179    {
180        let _ = before;
181        anyhow::bail!("retention-root transactions require an operating-system advisory file lock");
182    }
183    Ok(ProfileRepairRetentionPublicationGuard { file })
184}
185
186pub(super) struct ActiveRetentionProtection {
187    _guard: ProfileRepairRetentionPublicationGuard,
188    _profile_roots: Option<crate::socialgraph::ProfileRootSnapshotGuard>,
189    hashes: HashSet<Hash>,
190}
191
192impl ActiveRetentionProtection {
193    pub(super) fn contains(&self, hash: &Hash) -> bool {
194        self.hashes.contains(hash)
195    }
196
197    fn hashes(&self) -> &HashSet<Hash> {
198        &self.hashes
199    }
200}
201
202#[derive(Debug, Clone, Copy, PartialEq, Eq)]
203struct OrphanCleanupProgress {
204    freed_bytes: u64,
205    scanned: usize,
206    sweep_complete: bool,
207}
208
209/// Resource limits for validating and indexing a complete pinned DAG.
210#[derive(Debug, Clone, Copy)]
211pub struct TreeIndexLimits {
212    pub max_nodes: usize,
213    pub max_bytes: u64,
214}
215
216/// Result of atomically indexing and pinning a complete DAG.
217#[derive(Debug, Clone, Copy, PartialEq, Eq)]
218pub struct PinTreeResult {
219    pub indexed_hashes: usize,
220    pub total_size: u64,
221    pub already_pinned: bool,
222}
223
224#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225pub struct RootRetentionReport {
226    pub total_hashes: usize,
227    pub reachable_hashes: usize,
228    pub pinned_hashes: usize,
229    pub candidate_hashes: usize,
230    pub deleted_hashes: usize,
231    pub logical_bytes_before: u64,
232    pub logical_bytes_after: u64,
233}
234
235#[derive(Debug, thiserror::Error)]
236pub enum PinTreeError {
237    #[error("root blob {hash} is missing")]
238    MissingRoot { hash: String },
239    #[error("descendant blob {hash} is missing")]
240    MissingDescendant { hash: String },
241    #[error("invalid DAG node {hash}: {message}")]
242    InvalidDag { hash: String, message: String },
243    #[error("DAG exceeds the {max_nodes} node limit")]
244    NodeLimitExceeded { max_nodes: usize },
245    #[error("DAG exceeds the {max_bytes} byte limit")]
246    ByteLimitExceeded { max_bytes: u64 },
247    #[error("storage error: {0}")]
248    Storage(String),
249}
250
251struct TreeIndexPlan {
252    tracked_hashes: HashSet<Hash>,
253    total_size: u64,
254}
255
256/// Metadata for a synced tree (for eviction tracking)
257#[derive(Debug, Clone, Serialize)]
258pub struct TreeMeta {
259    /// Pubkey of tree owner
260    pub owner: String,
261    /// Tree name if known (from nostr key like "npub.../name")
262    pub name: Option<String>,
263    /// Unix timestamp when this tree was synced
264    pub synced_at: u64,
265    /// Total size of all blobs in this tree
266    pub total_size: u64,
267    /// Eviction priority: 255=own/pinned, 128=followed, 64=other
268    pub priority: u8,
269}
270
271impl<'de> Deserialize<'de> for TreeMeta {
272    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
273    where
274        D: serde::Deserializer<'de>,
275    {
276        const FIELDS: &[&str] = &[
277            "owner",
278            "name",
279            "synced_at",
280            "last_accessed_at",
281            "total_size",
282            "priority",
283        ];
284
285        struct TreeMetaVisitor;
286
287        impl<'de> Visitor<'de> for TreeMetaVisitor {
288            type Value = TreeMeta;
289
290            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
291                formatter.write_str("TreeMeta as current or legacy metadata")
292            }
293
294            fn visit_seq<A>(self, mut seq: A) -> std::result::Result<Self::Value, A::Error>
295            where
296                A: SeqAccess<'de>,
297            {
298                let has_accidental_access_field = matches!(seq.size_hint(), Some(6));
299                let owner = seq
300                    .next_element()?
301                    .ok_or_else(|| de::Error::invalid_length(0, &self))?;
302                let name = seq
303                    .next_element()?
304                    .ok_or_else(|| de::Error::invalid_length(1, &self))?;
305                let synced_at = seq
306                    .next_element()?
307                    .ok_or_else(|| de::Error::invalid_length(2, &self))?;
308
309                if has_accidental_access_field {
310                    let _: IgnoredAny = seq
311                        .next_element()?
312                        .ok_or_else(|| de::Error::invalid_length(3, &self))?;
313                }
314
315                let total_size = seq
316                    .next_element()?
317                    .ok_or_else(|| de::Error::invalid_length(3, &self))?;
318                let priority = seq
319                    .next_element()?
320                    .ok_or_else(|| de::Error::invalid_length(4, &self))?;
321
322                Ok(TreeMeta {
323                    owner,
324                    name,
325                    synced_at,
326                    total_size,
327                    priority,
328                })
329            }
330
331            fn visit_map<A>(self, mut map: A) -> std::result::Result<Self::Value, A::Error>
332            where
333                A: MapAccess<'de>,
334            {
335                let mut owner = None;
336                let mut name = None;
337                let mut synced_at = None;
338                let mut total_size = None;
339                let mut priority = None;
340
341                while let Some(key) = map.next_key::<String>()? {
342                    match key.as_str() {
343                        "owner" => owner = Some(map.next_value()?),
344                        "name" => name = Some(map.next_value()?),
345                        "synced_at" => synced_at = Some(map.next_value()?),
346                        "last_accessed_at" => {
347                            let _: IgnoredAny = map.next_value()?;
348                        }
349                        "total_size" => total_size = Some(map.next_value()?),
350                        "priority" => priority = Some(map.next_value()?),
351                        _ => {
352                            let _: IgnoredAny = map.next_value()?;
353                        }
354                    }
355                }
356
357                Ok(TreeMeta {
358                    owner: owner.ok_or_else(|| de::Error::missing_field("owner"))?,
359                    name: name.unwrap_or(None),
360                    synced_at: synced_at.ok_or_else(|| de::Error::missing_field("synced_at"))?,
361                    total_size: total_size.ok_or_else(|| de::Error::missing_field("total_size"))?,
362                    priority: priority.ok_or_else(|| de::Error::missing_field("priority"))?,
363                })
364            }
365        }
366
367        deserializer.deserialize_struct("TreeMeta", FIELDS, TreeMetaVisitor)
368    }
369}
370
371#[derive(Debug)]
372pub struct StorageStats {
373    pub total_dags: usize,
374    pub pinned_dags: usize,
375    pub total_bytes: u64,
376}
377
378/// Storage usage broken down by priority tier
379#[derive(Debug, Clone)]
380pub struct StorageByPriority {
381    /// Own/pinned trees (priority 255)
382    pub own: u64,
383    /// Followed users' trees (priority 128)
384    pub followed: u64,
385    /// Other trees (priority 64)
386    pub other: u64,
387}
388
389#[derive(Debug, Clone)]
390pub struct PinnedItem {
391    pub cid: String,
392    pub name: String,
393    pub is_directory: bool,
394    pub size_bytes: u64,
395}
396
397#[derive(Debug, Clone)]
398pub struct OwnedBlobStats {
399    pub owner: [u8; 32],
400    pub count: usize,
401    pub total_bytes: u64,
402}
403
404fn pinned_item_name(hash: &Hash, meta: Option<&TreeMeta>) -> String {
405    let Some(meta) = meta else {
406        return to_hex(hash);
407    };
408
409    match (meta.owner.as_str(), meta.name.as_deref()) {
410        ("pinned", Some(name)) => name.to_string(),
411        ("", Some(name)) => name.to_string(),
412        (owner, Some(name)) if !owner.is_empty() => format!("{owner}/{name}"),
413        (owner, None) if !owner.is_empty() && owner != "pinned" => owner.to_string(),
414        _ => to_hex(hash),
415    }
416}
417
418fn unix_timestamp_now() -> u64 {
419    SystemTime::now()
420        .duration_since(UNIX_EPOCH)
421        .unwrap_or_default()
422        .as_secs()
423}
424
425impl HashtreeStore {
426    fn acquire_retention_roots_lock(
427        &self,
428        mode: RetentionRootsLockMode,
429    ) -> Result<ProfileRepairRetentionPublicationGuard> {
430        let path = self.base_path().join(RETENTION_ROOTS_LOCK_FILE);
431        let file = OpenOptions::new()
432            .read(true)
433            .write(true)
434            .create(true)
435            .truncate(false)
436            .open(&path)
437            .with_context(|| format!("open retention-roots lock {}", path.display()))?;
438        #[cfg(unix)]
439        {
440            let operation = match mode {
441                RetentionRootsLockMode::Shared => libc::LOCK_SH,
442                RetentionRootsLockMode::Exclusive => libc::LOCK_EX,
443            };
444            let result = unsafe { libc::flock(file.as_raw_fd(), operation) };
445            if result != 0 {
446                return Err(std::io::Error::last_os_error())
447                    .with_context(|| format!("acquire retention-roots lock {}", path.display()));
448            }
449        }
450        #[cfg(not(unix))]
451        {
452            let _ = mode;
453            anyhow::bail!(
454                "retention-root transactions require an operating-system advisory file lock"
455            );
456        }
457        Ok(ProfileRepairRetentionPublicationGuard { file })
458    }
459
460    /// Serialize publication of the immutable Social repair retention lease
461    /// against every local retention deletion pass. Persist and fsync the
462    /// canonical lease while this guard is alive.
463    pub fn acquire_profile_repair_retention_publication_guard(
464        &self,
465    ) -> Result<ProfileRepairRetentionPublicationGuard> {
466        self.acquire_retention_roots_lock(RetentionRootsLockMode::Exclusive)
467    }
468
469    pub fn profile_repair_retention_lease_path(&self) -> PathBuf {
470        self.base_path()
471            .join(PROFILE_REPAIR_RETENTION_LEASE_RELATIVE_PATH)
472    }
473
474    pub fn validate_profile_repair_retention_lease(
475        &self,
476        expected_sha256: &str,
477    ) -> Result<ProfileRepairRetentionLease> {
478        let path = self.profile_repair_retention_lease_path();
479        let metadata = std::fs::symlink_metadata(&path)
480            .with_context(|| format!("inspect {}", path.display()))?;
481        if metadata.file_type().is_symlink() || !metadata.file_type().is_file() {
482            anyhow::bail!(
483                "profile repair retention lease is not a direct regular file: {}",
484                path.display()
485            );
486        }
487        if metadata.len() > MAX_PROFILE_REPAIR_RETENTION_LEASE_BYTES {
488            anyhow::bail!(
489                "profile repair retention lease exceeds {} bytes: {}",
490                MAX_PROFILE_REPAIR_RETENTION_LEASE_BYTES,
491                path.display()
492            );
493        }
494        let bytes = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?;
495        let actual_sha256 = to_hex(&hashtree_core::sha256(&bytes));
496        if actual_sha256 != expected_sha256 {
497            anyhow::bail!(
498                "profile repair retention lease SHA-256 mismatch: expected {expected_sha256}, found {actual_sha256}"
499            );
500        }
501        let lease: ProfileRepairRetentionLease =
502            serde_json::from_slice(&bytes).with_context(|| format!("decode {}", path.display()))?;
503        if lease.canonical_bytes()? != bytes {
504            anyhow::bail!(
505                "profile repair retention lease is not canonical: {}",
506                path.display()
507            );
508        }
509        Ok(lease)
510    }
511
512    /// Freeze the active immutable repair lease and resolve every full-CID DAG
513    /// it names while holding the shared side of the lease-publication lock.
514    /// Keep this value alive through the complete deletion pass.
515    pub(super) fn active_retention_protection(&self) -> Result<ActiveRetentionProtection> {
516        let guard = self.acquire_retention_roots_lock(RetentionRootsLockMode::Shared)?;
517        let profile_roots =
518            crate::socialgraph::acquire_profile_root_snapshot_guard(self.base_path())?;
519        let mut roots = match profile_roots.as_ref() {
520            Some(profile_roots) => profile_roots.retention_roots()?,
521            None => Vec::new(),
522        };
523        roots.extend(self.profile_repair_retention_roots()?);
524        roots.sort_by_key(Cid::to_string);
525        roots.dedup();
526        let hashes = self.collect_socialgraph_protected(&roots)?;
527        Ok(ActiveRetentionProtection {
528            _guard: guard,
529            _profile_roots: profile_roots,
530            hashes,
531        })
532    }
533
534    /// Retain only one Nostr event-index DAG plus explicit pins in the writable
535    /// blob store. This is intended for a dedicated, closed-writer index store.
536    ///
537    /// Nostr B-tree links mark event values as files even though the values are
538    /// direct blobs. Traversing directory/fanout links only therefore visits
539    /// every index node while avoiding millions of unnecessary event reads.
540    pub fn retain_nostr_root(&self, root: &Cid, apply: bool) -> Result<RootRetentionReport> {
541        let retention = self.active_retention_protection()?;
542        let mut reachable = self.nostr_index_hashes(root)?;
543        reachable.extend(retention.hashes().iter().copied());
544
545        let rtxn = self.env.read_txn()?;
546        let pinned = self
547            .pins
548            .iter(&rtxn)?
549            .filter_map(std::result::Result::ok)
550            .filter_map(|(hash, _)| hash.try_into().ok())
551            .collect::<HashSet<Hash>>();
552        drop(rtxn);
553
554        let stats_before = self
555            .router
556            .writable_stats()
557            .map_err(|error| anyhow::anyhow!("read writable storage stats: {error}"))?;
558        let all_hashes = self
559            .router
560            .list_writable()
561            .map_err(|error| anyhow::anyhow!("list writable hashes: {error}"))?;
562        let candidates = all_hashes
563            .iter()
564            .filter(|hash| !reachable.contains(*hash) && !pinned.contains(*hash))
565            .copied()
566            .collect::<Vec<_>>();
567
568        let mut deleted = 0usize;
569        if apply {
570            const DELETE_BATCH_SIZE: usize = 16_384;
571            for (batch_index, batch) in candidates.chunks(DELETE_BATCH_SIZE).enumerate() {
572                deleted = deleted.saturating_add(
573                    self.router
574                        .delete_many_local_only(batch)
575                        .map_err(|error| anyhow::anyhow!("delete unreachable batch: {error}"))?,
576                );
577                if (batch_index + 1).is_multiple_of(32) {
578                    eprintln!(
579                        "Retained-root cleanup: deleted {deleted}/{} unreachable hashes",
580                        candidates.len()
581                    );
582                }
583            }
584        }
585        let stats_after = self
586            .router
587            .writable_stats()
588            .map_err(|error| anyhow::anyhow!("read writable storage stats: {error}"))?;
589
590        Ok(RootRetentionReport {
591            total_hashes: all_hashes.len(),
592            reachable_hashes: reachable.len(),
593            pinned_hashes: pinned.len(),
594            candidate_hashes: candidates.len(),
595            deleted_hashes: deleted,
596            logical_bytes_before: stats_before.total_bytes,
597            logical_bytes_after: stats_after.total_bytes,
598        })
599    }
600
601    /// Reachable blocks of an event index, including raw event-value blobs.
602    pub(crate) fn nostr_index_hashes(&self, root: &Cid) -> Result<HashSet<Hash>> {
603        let tree = HashTree::new(HashTreeConfig::new(self.store_arc()));
604        sync_block_on(async {
605            let mut reachable = HashSet::new();
606            let mut stack = vec![(root.clone(), "root".to_string())];
607            while let Some((cid, path)) = stack.pop() {
608                if !reachable.insert(cid.hash) {
609                    continue;
610                }
611                let node = tree
612                    .get_tree_node_by_cid(&cid)
613                    .await
614                    .map_err(|error| anyhow::anyhow!("read retained root DAG: {error}"))?
615                    .ok_or_else(|| {
616                        anyhow::anyhow!(
617                            "retained directory node {} is missing at {path}",
618                            to_hex(&cid.hash)
619                        )
620                    })?;
621                for link in node.links {
622                    if link.link_type.is_directory_like() {
623                        let name = link.name.as_deref().unwrap_or("<unnamed>");
624                        stack.push((link.to_cid(), format!("{path}/{name}")));
625                    } else {
626                        reachable.insert(link.hash);
627                    }
628                }
629            }
630            Ok::<_, anyhow::Error>(reachable)
631        })
632    }
633
634    async fn collect_tree_hashes<S: Store>(
635        &self,
636        tree: &HashTree<S>,
637        root: &Cid,
638        require_tree_root: bool,
639    ) -> Result<HashSet<Hash>> {
640        let mut hashes = HashSet::new();
641        let mut stack = vec![(root.clone(), true)];
642
643        while let Some((cid, is_root)) = stack.pop() {
644            if !hashes.insert(cid.hash) {
645                continue;
646            }
647
648            let node = tree
649                .get_node(&cid)
650                .await
651                .map_err(|e| anyhow::anyhow!("Failed to get protected tree node: {}", e))?;
652            if let Some(node) = node {
653                for link in &node.links {
654                    stack.push((link.to_cid(), false));
655                }
656            } else if is_root && require_tree_root {
657                anyhow::bail!(
658                    "protected retention root {} is missing or is not a tree",
659                    cid
660                );
661            }
662        }
663
664        Ok(hashes)
665    }
666
667    fn profile_repair_retention_roots(&self) -> Result<Vec<Cid>> {
668        let path = self.profile_repair_retention_lease_path();
669        let metadata = match std::fs::symlink_metadata(&path) {
670            Ok(metadata) => metadata,
671            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
672            Err(error) => {
673                return Err(error).with_context(|| format!("inspect {}", path.display()));
674            }
675        };
676        if metadata.file_type().is_symlink() || !metadata.file_type().is_file() {
677            anyhow::bail!(
678                "profile repair retention lease is not a direct regular file: {}",
679                path.display()
680            );
681        }
682        if metadata.len() > MAX_PROFILE_REPAIR_RETENTION_LEASE_BYTES {
683            anyhow::bail!(
684                "profile repair retention lease exceeds {} bytes: {}",
685                MAX_PROFILE_REPAIR_RETENTION_LEASE_BYTES,
686                path.display()
687            );
688        }
689        let bytes = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?;
690        let lease: ProfileRepairRetentionLease =
691            serde_json::from_slice(&bytes).with_context(|| format!("decode {}", path.display()))?;
692        if lease.canonical_bytes()? != bytes {
693            anyhow::bail!(
694                "profile repair retention lease is not canonical: {}",
695                path.display()
696            );
697        }
698        lease.root_cids()
699    }
700
701    fn collect_socialgraph_protected(&self, roots: &[Cid]) -> Result<HashSet<Hash>> {
702        let mut protected = HashSet::new();
703        let tree = HashTree::new(HashTreeConfig::new(self.store_arc()).public());
704        for root in roots {
705            protected.extend(sync_block_on(self.collect_tree_hashes(&tree, root, true))?);
706        }
707        Ok(protected)
708    }
709
710    /// Return the socialgraph protection snapshot for the active bounded sweep.
711    ///
712    /// Root changes during a sweep are unioned with the existing snapshot.
713    /// This can temporarily over-protect an old DAG, but can never make either
714    /// the old or new root disposable. The union is discarded at the sweep
715    /// boundary and rebuilt from the current roots.
716    fn socialgraph_protected_for_orphan_sweep(&self, roots: &[Cid]) -> Result<Arc<HashSet<Hash>>> {
717        let root_ids = roots.iter().map(Cid::to_string).collect::<Vec<_>>();
718        {
719            let state = self
720                .orphan_scan
721                .lock()
722                .unwrap_or_else(std::sync::PoisonError::into_inner);
723            if state.socialgraph_roots.as_ref() == Some(&root_ids) {
724                return Ok(Arc::clone(&state.socialgraph_protected));
725            }
726        }
727
728        let newly_protected = self.collect_socialgraph_protected(roots)?;
729        let mut state = self
730            .orphan_scan
731            .lock()
732            .unwrap_or_else(std::sync::PoisonError::into_inner);
733        if state.socialgraph_roots.as_ref() == Some(&root_ids) {
734            return Ok(Arc::clone(&state.socialgraph_protected));
735        }
736
737        let protected = if state.sweep.is_some() && state.socialgraph_roots.is_some() {
738            let mut union = (*state.socialgraph_protected).clone();
739            union.extend(newly_protected);
740            union
741        } else {
742            newly_protected
743        };
744        state.socialgraph_roots = Some(root_ids);
745        state.socialgraph_protected = Arc::new(protected);
746        Ok(Arc::clone(&state.socialgraph_protected))
747    }
748
749    fn metadata_protects_orphan(&self, hash: &Hash) -> Result<bool> {
750        let rtxn = self.env.read_txn()?;
751        if self.pins.get(&rtxn, hash.as_slice())?.is_some() {
752            return Ok(true);
753        }
754        if self
755            .blob_trees
756            .prefix_iter(&rtxn, hash.as_slice())?
757            .next()
758            .transpose()?
759            .is_some()
760        {
761            return Ok(true);
762        }
763        let has_owner = self
764            .blob_owners
765            .prefix_iter(&rtxn, hash.as_slice())?
766            .next()
767            .transpose()?
768            .is_some();
769        Ok(has_owner)
770    }
771
772    fn evict_disposable_orphans_page(
773        &self,
774        target_bytes: u64,
775        additional_protected: &HashSet<Hash>,
776        page_size: usize,
777    ) -> Result<OrphanCleanupProgress> {
778        if page_size == 0 {
779            anyhow::bail!("orphan cleanup page size must be greater than zero");
780        }
781
782        let _retention_roots = self.acquire_retention_roots_lock(RetentionRootsLockMode::Shared)?;
783        let profile_roots =
784            crate::socialgraph::acquire_profile_root_snapshot_guard(self.base_path())?;
785        let mut roots = match profile_roots.as_ref() {
786            Some(profile_roots) => profile_roots.retention_roots()?,
787            None => Vec::new(),
788        };
789        roots.extend(self.profile_repair_retention_roots()?);
790        roots.sort_by_key(Cid::to_string);
791        roots.dedup();
792        let stats = self
793            .router
794            .writable_stats()
795            .map_err(|e| anyhow::anyhow!("Failed to get writable stats: {}", e))?;
796        let mut current_size = stats.total_bytes;
797        if current_size <= target_bytes {
798            let mut state = self
799                .orphan_scan
800                .lock()
801                .unwrap_or_else(std::sync::PoisonError::into_inner);
802            state.sweep = None;
803            state.socialgraph_roots = None;
804            state.socialgraph_protected = Arc::new(HashSet::new());
805            return Ok(OrphanCleanupProgress {
806                freed_bytes: 0,
807                scanned: 0,
808                sweep_complete: false,
809            });
810        }
811
812        let (after, start_after, wrapped) = {
813            let mut state = self
814                .orphan_scan
815                .lock()
816                .unwrap_or_else(std::sync::PoisonError::into_inner);
817            if state.sweep.is_none() {
818                state.sweep = Some(super::OrphanSweep {
819                    start_after: state.cursor,
820                    wrapped: false,
821                });
822            }
823            let sweep = state.sweep.expect("orphan sweep initialized");
824            (state.cursor, sweep.start_after, sweep.wrapped)
825        };
826        let socialgraph_protected = self.socialgraph_protected_for_orphan_sweep(&roots)?;
827        let mut candidates = self
828            .router
829            .scan_writable_hashes_after(after, page_size)
830            .map_err(|e| anyhow::anyhow!("Failed to scan writable hashes: {}", e))?;
831        let backend_page_len = candidates.len();
832        let mut crossed_start = false;
833        if wrapped {
834            let start_after = start_after.expect("only a non-zero cursor sweep wraps");
835            let keep = candidates.partition_point(|hash| *hash <= start_after);
836            crossed_start = keep < candidates.len();
837            candidates.truncate(keep);
838        }
839
840        let mut freed = 0u64;
841        let mut scanned = 0usize;
842        let mut last_examined = None;
843        for hash in &candidates {
844            if current_size <= target_bytes {
845                break;
846            }
847            let hash = *hash;
848            scanned += 1;
849            last_examined = Some(hash);
850
851            if socialgraph_protected.contains(&hash)
852                || additional_protected.contains(&hash)
853                || self.metadata_protects_orphan(&hash)?
854            {
855                continue;
856            }
857
858            let Some(_delete_guard) = self.cache_quota.begin_retention_delete(hash) else {
859                continue;
860            };
861            // Recheck all durable metadata after the deletion claim. Ownership
862            // writers serialize with the claim; the point checks also narrow
863            // the pin/index publication window without materializing either DB.
864            if self.metadata_protects_orphan(&hash)? {
865                continue;
866            }
867
868            let Some(size) = self
869                .router
870                .blob_size_sync(&hash)
871                .map_err(|e| anyhow::anyhow!("Failed to get blob size: {}", e))?
872            else {
873                continue;
874            };
875
876            if self
877                .router
878                .delete_local_only(&hash)
879                .map_err(|e| anyhow::anyhow!("Failed to delete orphaned blob: {}", e))?
880            {
881                freed = freed.saturating_add(size);
882                current_size = current_size.saturating_sub(size);
883                tracing::debug!(
884                    "Deleted disposable orphaned blob {} ({} bytes)",
885                    &to_hex(&hash)[..8],
886                    size
887                );
888            }
889        }
890
891        let target_reached = current_size <= target_bytes;
892        let processed_whole_page = scanned == candidates.len();
893        let backend_exhausted = backend_page_len < page_size;
894        let mut sweep_complete = false;
895        let mut state = self
896            .orphan_scan
897            .lock()
898            .unwrap_or_else(std::sync::PoisonError::into_inner);
899        if let Some(last_examined) = last_examined {
900            state.cursor = Some(last_examined);
901        }
902
903        if target_reached {
904            state.sweep = None;
905            state.socialgraph_roots = None;
906            state.socialgraph_protected = Arc::new(HashSet::new());
907        } else if processed_whole_page {
908            let sweep = state.sweep.expect("active orphan sweep");
909            if sweep.wrapped {
910                if crossed_start || backend_exhausted {
911                    state.cursor = sweep.start_after;
912                    state.sweep = None;
913                    sweep_complete = true;
914                }
915            } else if backend_exhausted {
916                if sweep.start_after.is_none() {
917                    state.cursor = None;
918                    state.sweep = None;
919                    sweep_complete = true;
920                } else {
921                    state.cursor = None;
922                    state.sweep = Some(super::OrphanSweep {
923                        wrapped: true,
924                        ..sweep
925                    });
926                }
927            }
928            if sweep_complete {
929                state.socialgraph_roots = None;
930                state.socialgraph_protected = Arc::new(HashSet::new());
931            }
932        }
933
934        Ok(OrphanCleanupProgress {
935            freed_bytes: freed,
936            scanned,
937            sweep_complete,
938        })
939    }
940
941    fn evict_disposable_orphans_to_target_raw(
942        &self,
943        target_bytes: u64,
944        additional_protected: &HashSet<Hash>,
945    ) -> Result<OrphanCleanupProgress> {
946        self.evict_disposable_orphans_page(
947            target_bytes,
948            additional_protected,
949            ORPHAN_SCAN_PAGE_SIZE,
950        )
951    }
952
953    fn evict_disposable_orphans_to_target(&self, target_bytes: u64) -> Result<u64> {
954        let cleanup = self
955            .cache_quota
956            .begin_standalone_cleanup()
957            .map_err(|denial| anyhow::anyhow!(denial.to_string()))?;
958        let result =
959            self.evict_disposable_orphans_to_target_raw(target_bytes, cleanup.inflight_hashes());
960        if result.is_ok() {
961            let after_usage = self
962                .router
963                .writable_stats()
964                .map_err(|error| {
965                    anyhow::anyhow!("Failed to get writable stats after cache cleanup: {error}")
966                })?
967                .total_bytes;
968            cleanup.complete(after_usage);
969        }
970        result.map(|progress| progress.freed_bytes)
971    }
972
973    pub(super) fn prepare_cached_blob_write(
974        &self,
975        incoming_bytes: u64,
976        hashes: Vec<Hash>,
977        force_cleanup: bool,
978    ) -> Result<CacheWritePermit<'_>> {
979        if let Some(denial) = self.cache_quota.quick_denial() {
980            anyhow::bail!(denial.to_string());
981        }
982
983        let observed_usage = if self.max_size_bytes == 0 {
984            0
985        } else {
986            self.router
987                .writable_stats()
988                .map_err(|error| anyhow::anyhow!("Failed to get writable stats: {error}"))?
989                .total_bytes
990        };
991        match self
992            .cache_quota
993            .begin_admission(
994                observed_usage,
995                incoming_bytes,
996                hashes,
997                self.max_size_bytes,
998                force_cleanup,
999            )
1000            .map_err(|denial| anyhow::anyhow!(denial.to_string()))?
1001        {
1002            CacheQuotaAdmission::Admitted(permit) => Ok(permit),
1003            CacheQuotaAdmission::Cleanup(cleanup) => {
1004                let progress = self.evict_disposable_orphans_to_target_raw(
1005                    cleanup.target_bytes(),
1006                    cleanup.inflight_hashes(),
1007                )?;
1008                let after_usage = self
1009                    .router
1010                    .writable_stats()
1011                    .map_err(|error| {
1012                        anyhow::anyhow!("Failed to get writable stats after cache cleanup: {error}")
1013                    })?
1014                    .total_bytes;
1015                cleanup
1016                    .complete(after_usage, progress.freed_bytes, progress.sweep_complete)
1017                    .map_err(|denial| anyhow::anyhow!(denial.to_string()))
1018            }
1019        }
1020    }
1021
1022    /// Number of cache/orphan cleanup leadership epochs started by this store.
1023    ///
1024    /// This is intentionally a cheap diagnostic so overload tests and operators
1025    /// can verify that concurrent cache pressure coalesces into one scanner.
1026    pub fn cache_cleanup_epoch_count(&self) -> u64 {
1027        self.cache_quota.cleanup_epoch_count()
1028    }
1029
1030    pub fn make_room_for_cached_blob(&self, incoming_bytes: u64) -> Result<u64> {
1031        if self.max_size_bytes == 0 {
1032            return Ok(0);
1033        }
1034
1035        let stats = self
1036            .router
1037            .writable_stats()
1038            .map_err(|e| anyhow::anyhow!("Failed to get writable stats: {}", e))?;
1039        if stats.total_bytes.saturating_add(incoming_bytes) <= self.max_size_bytes {
1040            return Ok(0);
1041        }
1042
1043        let target = if incoming_bytes >= self.max_size_bytes {
1044            0
1045        } else {
1046            (self.max_size_bytes.saturating_mul(9) / 10)
1047                .min(self.max_size_bytes.saturating_sub(incoming_bytes))
1048        };
1049        self.evict_disposable_orphans_to_target(target)
1050    }
1051
1052    pub fn enforce_cached_blob_budget_after_insert(&self, inserted_bytes: u64) -> Result<u64> {
1053        if self.max_size_bytes == 0 || inserted_bytes == 0 {
1054            return Ok(0);
1055        }
1056
1057        let stats = self
1058            .router
1059            .writable_stats()
1060            .map_err(|e| anyhow::anyhow!("Failed to get writable stats: {}", e))?;
1061        if stats.total_bytes <= self.max_size_bytes {
1062            return Ok(0);
1063        }
1064
1065        let target = if inserted_bytes >= self.max_size_bytes {
1066            inserted_bytes
1067        } else {
1068            (self.max_size_bytes.saturating_mul(9) / 10)
1069                .saturating_add(inserted_bytes)
1070                .min(self.max_size_bytes)
1071        };
1072        self.evict_disposable_orphans_to_target(target)
1073    }
1074
1075    pub fn make_room_for_durable_blob(&self, incoming_bytes: u64) -> Result<u64> {
1076        if self.max_size_bytes == 0 || incoming_bytes == 0 {
1077            return Ok(0);
1078        }
1079
1080        if incoming_bytes > self.max_size_bytes {
1081            anyhow::bail!(
1082                "storage limit exceeded: incoming blob is {} bytes but limit is {} bytes",
1083                incoming_bytes,
1084                self.max_size_bytes
1085            );
1086        }
1087
1088        let stats = self
1089            .router
1090            .writable_stats()
1091            .map_err(|e| anyhow::anyhow!("Failed to get writable stats: {}", e))?;
1092        if stats.total_bytes.saturating_add(incoming_bytes) <= self.max_size_bytes {
1093            return Ok(0);
1094        }
1095
1096        let target = (self.max_size_bytes.saturating_mul(9) / 10)
1097            .min(self.max_size_bytes.saturating_sub(incoming_bytes));
1098        let freed = self.evict_with_policy_to_target(stats.total_bytes, target)?;
1099
1100        let next_stats = self
1101            .router
1102            .writable_stats()
1103            .map_err(|e| anyhow::anyhow!("Failed to get writable stats after eviction: {}", e))?;
1104        if next_stats.total_bytes.saturating_add(incoming_bytes) > self.max_size_bytes {
1105            anyhow::bail!(
1106                "storage limit exceeded: {} bytes used, {} byte incoming blob, {} byte limit",
1107                next_stats.total_bytes,
1108                incoming_bytes,
1109                self.max_size_bytes
1110            );
1111        }
1112
1113        Ok(freed)
1114    }
1115
1116    pub fn enforce_durable_blob_budget_after_insert(&self, inserted_bytes: u64) -> Result<u64> {
1117        if self.max_size_bytes == 0 || inserted_bytes == 0 {
1118            return Ok(0);
1119        }
1120
1121        if inserted_bytes > self.max_size_bytes {
1122            anyhow::bail!(
1123                "storage limit exceeded: inserted blobs are {} bytes but limit is {} bytes",
1124                inserted_bytes,
1125                self.max_size_bytes
1126            );
1127        }
1128
1129        let stats = self
1130            .router
1131            .writable_stats()
1132            .map_err(|e| anyhow::anyhow!("Failed to get writable stats: {}", e))?;
1133        if stats.total_bytes <= self.max_size_bytes {
1134            return Ok(0);
1135        }
1136
1137        let target = (self.max_size_bytes.saturating_mul(9) / 10)
1138            .saturating_add(inserted_bytes)
1139            .min(self.max_size_bytes);
1140        let freed = self.evict_with_policy_to_target(stats.total_bytes, target)?;
1141
1142        let next_stats = self
1143            .router
1144            .writable_stats()
1145            .map_err(|e| anyhow::anyhow!("Failed to get writable stats after eviction: {}", e))?;
1146        if next_stats.total_bytes > self.max_size_bytes {
1147            anyhow::bail!(
1148                "storage limit exceeded: {} bytes used after inserting {} bytes, {} byte limit",
1149                next_stats.total_bytes,
1150                inserted_bytes,
1151                self.max_size_bytes
1152            );
1153        }
1154
1155        Ok(freed)
1156    }
1157
1158    pub fn relieve_cached_blob_write_pressure(&self, incoming_bytes: u64) -> Result<u64> {
1159        let stats = self
1160            .router
1161            .writable_stats()
1162            .map_err(|e| anyhow::anyhow!("Failed to get writable stats: {}", e))?;
1163        if stats.total_bytes == 0 {
1164            return Ok(0);
1165        }
1166
1167        let headroom = incoming_bytes.max(stats.total_bytes / 10).max(1);
1168        let target = stats.total_bytes.saturating_sub(headroom);
1169        self.evict_disposable_orphans_to_target(target)
1170    }
1171
1172    /// Pin a hash (prevent garbage collection)
1173    pub fn pin(&self, hash: &[u8; 32]) -> Result<()> {
1174        let mut wtxn = self.env.write_txn()?;
1175        self.pins.put(&mut wtxn, hash.as_slice(), &())?;
1176        wtxn.commit()?;
1177        Ok(())
1178    }
1179
1180    /// Unpin a hash (allow garbage collection)
1181    pub fn unpin(&self, hash: &[u8; 32]) -> Result<()> {
1182        let mut wtxn = self.env.write_txn()?;
1183        self.pins.delete(&mut wtxn, hash.as_slice())?;
1184        wtxn.commit()?;
1185        Ok(())
1186    }
1187
1188    /// Check if hash is pinned
1189    pub fn is_pinned(&self, hash: &[u8; 32]) -> Result<bool> {
1190        let rtxn = self.env.read_txn()?;
1191        Ok(self.pins.get(&rtxn, hash.as_slice())?.is_some())
1192    }
1193
1194    /// List all pinned hashes (raw bytes)
1195    pub fn list_pins_raw(&self) -> Result<Vec<[u8; 32]>> {
1196        let rtxn = self.env.read_txn()?;
1197        let mut pins = Vec::new();
1198
1199        for item in self.pins.iter(&rtxn)? {
1200            let (hash_bytes, _) = item?;
1201            if hash_bytes.len() == 32 {
1202                let mut hash = [0u8; 32];
1203                hash.copy_from_slice(hash_bytes);
1204                pins.push(hash);
1205            }
1206        }
1207
1208        Ok(pins)
1209    }
1210
1211    /// List all pinned hashes with names
1212    pub fn list_pins_with_names(&self) -> Result<Vec<PinnedItem>> {
1213        let rtxn = self.env.read_txn()?;
1214        let store = self.store_arc();
1215        let tree = HashTree::new(HashTreeConfig::new(store).public());
1216        let mut pins = Vec::new();
1217
1218        for item in self.pins.iter(&rtxn)? {
1219            let (hash_bytes, _) = item?;
1220            if hash_bytes.len() != 32 {
1221                continue;
1222            }
1223            let mut hash = [0u8; 32];
1224            hash.copy_from_slice(hash_bytes);
1225
1226            // Try to determine if it's a directory
1227            let is_directory =
1228                sync_block_on(async { tree.is_directory(&hash).await.unwrap_or(false) });
1229
1230            let meta = self
1231                .tree_meta
1232                .get(&rtxn, hash.as_slice())?
1233                .map(|bytes| {
1234                    rmp_serde::from_slice::<TreeMeta>(bytes)
1235                        .map_err(|e| anyhow::anyhow!("Failed to deserialize TreeMeta: {}", e))
1236                })
1237                .transpose()?;
1238            let size_bytes = if let Some(meta) = meta.as_ref() {
1239                meta.total_size
1240            } else {
1241                self.router
1242                    .blob_size_sync(&hash)
1243                    .map_err(|e| anyhow::anyhow!("Failed to get pinned blob size: {}", e))?
1244                    .unwrap_or(0)
1245            };
1246
1247            pins.push(PinnedItem {
1248                cid: to_hex(&hash),
1249                name: pinned_item_name(&hash, meta.as_ref()),
1250                is_directory,
1251                size_bytes,
1252            });
1253        }
1254
1255        Ok(pins)
1256    }
1257
1258    pub fn owned_blob_stats(&self) -> Result<Vec<OwnedBlobStats>> {
1259        let rtxn = self.env.read_txn()?;
1260        let mut owners = Vec::new();
1261
1262        for item in self.pubkey_blobs.iter(&rtxn)? {
1263            let (owner_bytes, blobs_bytes) = item?;
1264            if owner_bytes.len() != 32 {
1265                continue;
1266            }
1267
1268            let blobs: Vec<BlobMetadata> = serde_json::from_slice(blobs_bytes)
1269                .map_err(|e| anyhow::anyhow!("Failed to deserialize blob metadata: {}", e))?;
1270            let mut owner = [0u8; 32];
1271            owner.copy_from_slice(owner_bytes);
1272            let total_bytes = blobs
1273                .iter()
1274                .fold(0u64, |total, blob| total.saturating_add(blob.size));
1275            owners.push(OwnedBlobStats {
1276                owner,
1277                count: blobs.len(),
1278                total_bytes,
1279            });
1280        }
1281
1282        owners.sort_by_key(|stats| stats.owner);
1283        Ok(owners)
1284    }
1285
1286    // === Tree indexing for eviction ===
1287
1288    /// Bounds complete-DAG indexing by both the configured storage budget and
1289    /// an absolute traversal-node ceiling. A zero storage budget means the raw
1290    /// store is unbounded, but authenticated requests still retain a hard cap.
1291    pub fn tree_index_limits(&self) -> TreeIndexLimits {
1292        TreeIndexLimits {
1293            max_nodes: MAX_PINNED_TREE_NODES,
1294            max_bytes: if self.max_size_bytes == 0 {
1295                MAX_UNBOUNDED_PINNED_TREE_BYTES
1296            } else {
1297                self.max_size_bytes
1298            },
1299        }
1300    }
1301
1302    /// Validate every referenced blob, then index all descendants and pin the
1303    /// root in one LMDB transaction. No pin or index metadata is written if
1304    /// traversal, decryption, decoding, or resource validation fails.
1305    pub fn pin_and_index_tree(
1306        &self,
1307        root: &Cid,
1308        owner: &str,
1309        name: Option<&str>,
1310        priority: u8,
1311        limits: TreeIndexLimits,
1312    ) -> std::result::Result<PinTreeResult, PinTreeError> {
1313        let store = self.store_arc();
1314        let tree = HashTree::new(HashTreeConfig::new(store).public());
1315        let plan = sync_block_on(self.collect_tree_index(&tree, root, limits))?;
1316        let already_pinned = self
1317            .write_tree_index(
1318                &root.hash,
1319                &plan.tracked_hashes,
1320                plan.total_size,
1321                owner,
1322                name,
1323                priority,
1324                None,
1325                true,
1326            )
1327            .map_err(|error| PinTreeError::Storage(error.to_string()))?;
1328
1329        Ok(PinTreeResult {
1330            indexed_hashes: plan.tracked_hashes.len(),
1331            total_size: plan.total_size,
1332            already_pinned,
1333        })
1334    }
1335
1336    /// Index a tree after sync - tracks all blobs in the tree for eviction
1337    ///
1338    /// If `ref_key` is provided (e.g. "npub.../name"), it will replace any existing
1339    /// tree with that ref, allowing old versions to be evicted.
1340    pub fn index_tree(
1341        &self,
1342        root_hash: &Hash,
1343        owner: &str,
1344        name: Option<&str>,
1345        priority: u8,
1346        ref_key: Option<&str>,
1347    ) -> Result<()> {
1348        let root_hex = to_hex(root_hash);
1349
1350        // If ref_key provided, check for and unindex old version
1351        if let Some(key) = ref_key {
1352            let rtxn = self.env.read_txn()?;
1353            if let Some(old_hash_bytes) = self.tree_refs.get(&rtxn, key)? {
1354                if old_hash_bytes != root_hash.as_slice() {
1355                    let old_hash: Hash = old_hash_bytes
1356                        .try_into()
1357                        .map_err(|_| anyhow::anyhow!("Invalid hash in tree_refs"))?;
1358                    drop(rtxn);
1359                    let _ = self.unpin(&old_hash);
1360                    // Unindex old tree (will delete orphaned blobs)
1361                    let _ = self.unindex_tree(&old_hash);
1362                    tracing::debug!("Replaced old tree for ref {}", key);
1363                }
1364            }
1365        }
1366
1367        let store = self.store_arc();
1368        let tree = HashTree::new(HashTreeConfig::new(store).public());
1369
1370        let plan = sync_block_on(self.collect_tree_index(
1371            &tree,
1372            &Cid::public(*root_hash),
1373            TreeIndexLimits {
1374                max_nodes: MAX_PINNED_TREE_NODES,
1375                max_bytes: MAX_UNBOUNDED_PINNED_TREE_BYTES,
1376            },
1377        ))?;
1378        self.write_tree_index(
1379            root_hash,
1380            &plan.tracked_hashes,
1381            plan.total_size,
1382            owner,
1383            name,
1384            priority,
1385            ref_key,
1386            false,
1387        )?;
1388
1389        tracing::debug!(
1390            "Indexed tree {} ({} blobs, {} bytes, priority {})",
1391            &root_hex[..8],
1392            plan.tracked_hashes.len(),
1393            plan.total_size,
1394            priority
1395        );
1396
1397        Ok(())
1398    }
1399
1400    #[allow(clippy::too_many_arguments)]
1401    fn write_tree_index(
1402        &self,
1403        root_hash: &Hash,
1404        tracked_hashes: &HashSet<Hash>,
1405        total_size: u64,
1406        owner: &str,
1407        name: Option<&str>,
1408        priority: u8,
1409        ref_key: Option<&str>,
1410        pin: bool,
1411    ) -> Result<bool> {
1412        let mut wtxn = self.env.write_txn()?;
1413        let already_pinned = self.pins.get(&wtxn, root_hash.as_slice())?.is_some();
1414
1415        for tracked_hash in tracked_hashes {
1416            let mut key = [0u8; 64];
1417            key[..32].copy_from_slice(tracked_hash);
1418            key[32..].copy_from_slice(root_hash);
1419            self.blob_trees.put(&mut wtxn, &key[..], &())?;
1420        }
1421
1422        let meta = TreeMeta {
1423            owner: owner.to_string(),
1424            name: name.map(str::to_string),
1425            synced_at: unix_timestamp_now(),
1426            total_size,
1427            priority,
1428        };
1429        let meta_bytes = rmp_serde::to_vec(&meta)
1430            .map_err(|error| anyhow::anyhow!("Failed to serialize TreeMeta: {error}"))?;
1431        self.tree_meta
1432            .put(&mut wtxn, root_hash.as_slice(), &meta_bytes)?;
1433
1434        if let Some(key) = ref_key {
1435            self.tree_refs.put(&mut wtxn, key, root_hash.as_slice())?;
1436        }
1437        if pin {
1438            self.pins.put(&mut wtxn, root_hash.as_slice(), &())?;
1439        }
1440
1441        wtxn.commit()?;
1442        Ok(already_pinned)
1443    }
1444
1445    async fn collect_tree_index<S: Store>(
1446        &self,
1447        tree: &HashTree<S>,
1448        root: &Cid,
1449        limits: TreeIndexLimits,
1450    ) -> std::result::Result<TreeIndexPlan, PinTreeError> {
1451        let mut hashes = HashSet::new();
1452        let mut visited = HashSet::new();
1453        let mut total_size = 0u64;
1454        let mut stored_size = 0u64;
1455        // (cid, count logical bytes, decode/follow tree nodes, require a tree node)
1456        let mut stack = vec![(root.clone(), true, true, false)];
1457
1458        while let Some((cid, count_bytes, follow_tree, require_tree)) = stack.pop() {
1459            let visit_key = (cid.hash, cid.key, follow_tree);
1460            if !visited.insert(visit_key) {
1461                continue;
1462            }
1463            if visited.len() > limits.max_nodes {
1464                return Err(PinTreeError::NodeLimitExceeded {
1465                    max_nodes: limits.max_nodes,
1466                });
1467            }
1468
1469            let missing = || {
1470                if cid.hash == root.hash {
1471                    PinTreeError::MissingRoot {
1472                        hash: to_hex(&cid.hash),
1473                    }
1474                } else {
1475                    PinTreeError::MissingDescendant {
1476                        hash: to_hex(&cid.hash),
1477                    }
1478                }
1479            };
1480            let size = self
1481                .router
1482                .blob_size_sync(&cid.hash)
1483                .map_err(|error| PinTreeError::Storage(error.to_string()))?
1484                .ok_or_else(missing)?;
1485            if hashes.insert(cid.hash) {
1486                stored_size = stored_size
1487                    .checked_add(size)
1488                    .filter(|size| *size <= limits.max_bytes)
1489                    .ok_or(PinTreeError::ByteLimitExceeded {
1490                        max_bytes: limits.max_bytes,
1491                    })?;
1492            }
1493
1494            // Catalog metadata can outlive a missing or damaged member payload.
1495            // Read every visited block, including raw leaves, and decode the same
1496            // buffer so validating a tree node never doubles its physical reads.
1497            let data = tree
1498                .get_blob(&cid.hash)
1499                .await
1500                .map_err(|error| PinTreeError::Storage(error.to_string()))?
1501                .ok_or_else(missing)?;
1502            if data.len() as u64 != size || sha256(&data) != cid.hash {
1503                return Err(PinTreeError::InvalidDag {
1504                    hash: to_hex(&cid.hash),
1505                    message: "stored payload does not match its size or content hash".to_string(),
1506                });
1507            }
1508            if !follow_tree {
1509                continue;
1510            }
1511
1512            let node =
1513                decode_tree_node_by_cid(&cid, data).map_err(|error| PinTreeError::InvalidDag {
1514                    hash: to_hex(&cid.hash),
1515                    message: error.to_string(),
1516                })?;
1517            let Some(node) = node else {
1518                if require_tree {
1519                    return Err(PinTreeError::InvalidDag {
1520                        hash: to_hex(&cid.hash),
1521                        message: "directory link does not contain a tree node".to_string(),
1522                    });
1523                }
1524                if count_bytes {
1525                    total_size = total_size
1526                        .checked_add(size)
1527                        .filter(|size| *size <= limits.max_bytes)
1528                        .ok_or(PinTreeError::ByteLimitExceeded {
1529                            max_bytes: limits.max_bytes,
1530                        })?;
1531                }
1532                continue;
1533            };
1534
1535            if visited
1536                .len()
1537                .saturating_add(stack.len())
1538                .saturating_add(node.links.len())
1539                > limits.max_nodes
1540            {
1541                return Err(PinTreeError::NodeLimitExceeded {
1542                    max_nodes: limits.max_nodes,
1543                });
1544            }
1545
1546            for link in &node.links {
1547                match link.link_type {
1548                    LinkType::Blob => {
1549                        if count_bytes {
1550                            total_size = total_size
1551                                .checked_add(link.size)
1552                                .filter(|size| *size <= limits.max_bytes)
1553                                .ok_or(PinTreeError::ByteLimitExceeded {
1554                                    max_bytes: limits.max_bytes,
1555                                })?;
1556                        }
1557                        stack.push((link.to_cid(), false, false, false));
1558                    }
1559                    LinkType::File => {
1560                        if count_bytes {
1561                            total_size = total_size
1562                                .checked_add(link.size)
1563                                .filter(|size| *size <= limits.max_bytes)
1564                                .ok_or(PinTreeError::ByteLimitExceeded {
1565                                    max_bytes: limits.max_bytes,
1566                                })?;
1567                        }
1568                        stack.push((link.to_cid(), false, true, false));
1569                    }
1570                    LinkType::Dir | LinkType::Fanout => {
1571                        stack.push((link.to_cid(), count_bytes, true, true));
1572                    }
1573                }
1574            }
1575        }
1576
1577        Ok(TreeIndexPlan {
1578            tracked_hashes: hashes,
1579            total_size,
1580        })
1581    }
1582
1583    /// Unindex a tree - removes blob-tree mappings and deletes orphaned blobs.
1584    /// Returns the number of bytes freed.
1585    pub fn unindex_tree(&self, root_hash: &Hash) -> Result<u64> {
1586        let cleanup = self
1587            .cache_quota
1588            .begin_standalone_cleanup()
1589            .map_err(|denial| anyhow::anyhow!(denial.to_string()))?;
1590        let retention = self.active_retention_protection()?;
1591        let mut protected = cleanup.inflight_hashes().clone();
1592        protected.extend(retention.hashes().iter().copied());
1593        let result = self.unindex_tree_raw(root_hash, &protected);
1594        if result.is_ok() {
1595            let after_usage = self
1596                .router
1597                .writable_stats()
1598                .map_err(|error| {
1599                    anyhow::anyhow!("Failed to get writable stats after tree unindex: {error}")
1600                })?
1601                .total_bytes;
1602            cleanup.complete(after_usage);
1603        }
1604        result
1605    }
1606
1607    fn unindex_tree_raw(
1608        &self,
1609        root_hash: &Hash,
1610        additional_protected: &HashSet<Hash>,
1611    ) -> Result<u64> {
1612        let root_hex = to_hex(root_hash);
1613
1614        let store = self.store_arc();
1615        let tree = HashTree::new(HashTreeConfig::new(store).public());
1616
1617        // Walk tree and collect all blob hashes
1618        let tracked_hashes =
1619            sync_block_on(self.collect_tree_hashes(&tree, &Cid::public(*root_hash), false))?;
1620
1621        let mut wtxn = self.env.write_txn()?;
1622        let mut freed = 0u64;
1623
1624        // For each blob, remove the blob-tree entry and check if orphaned
1625        for tracked_hash in &tracked_hashes {
1626            // Delete blob-tree entry (64-byte key: blob_hash ++ tree_hash)
1627            let mut key = [0u8; 64];
1628            key[..32].copy_from_slice(tracked_hash);
1629            key[32..].copy_from_slice(root_hash);
1630            self.blob_trees.delete(&mut wtxn, &key[..])?;
1631
1632            // Check if blob is in any other tree (prefix scan on first 32 bytes)
1633            let mut has_other_tree = false;
1634            for item in self.blob_trees.prefix_iter(&wtxn, &tracked_hash[..])? {
1635                if item.is_ok() {
1636                    has_other_tree = true;
1637                    break;
1638                }
1639            }
1640
1641            let has_owner = self
1642                .blob_owners
1643                .prefix_iter(&wtxn, tracked_hash.as_slice())?
1644                .next()
1645                .transpose()?
1646                .is_some();
1647
1648            // Tree retention must not delete committed Blossom data or a body
1649            // whose owner/index transaction is still in flight.
1650            if !has_other_tree && !has_owner && !additional_protected.contains(tracked_hash) {
1651                let Some(_delete_guard) = self.cache_quota.begin_retention_delete(*tracked_hash)
1652                else {
1653                    continue;
1654                };
1655                if let Some(size) = self
1656                    .router
1657                    .blob_size_sync(tracked_hash)
1658                    .map_err(|e| anyhow::anyhow!("Failed to get blob size: {}", e))?
1659                {
1660                    freed += size;
1661                    // Delete locally only - keep S3 as archive
1662                    self.router
1663                        .delete_local_only(tracked_hash)
1664                        .map_err(|e| anyhow::anyhow!("Failed to delete blob: {}", e))?;
1665                }
1666            }
1667        }
1668
1669        // Delete tree metadata
1670        self.tree_meta.delete(&mut wtxn, root_hash.as_slice())?;
1671
1672        wtxn.commit()?;
1673
1674        tracing::debug!("Unindexed tree {} ({} bytes freed)", &root_hex[..8], freed);
1675
1676        Ok(freed)
1677    }
1678
1679    /// Get tree metadata
1680    pub fn get_tree_meta(&self, root_hash: &Hash) -> Result<Option<TreeMeta>> {
1681        let rtxn = self.env.read_txn()?;
1682        if let Some(bytes) = self.tree_meta.get(&rtxn, root_hash.as_slice())? {
1683            let meta: TreeMeta = rmp_serde::from_slice(bytes)
1684                .map_err(|e| anyhow::anyhow!("Failed to deserialize TreeMeta: {}", e))?;
1685            Ok(Some(meta))
1686        } else {
1687            Ok(None)
1688        }
1689    }
1690
1691    pub fn get_tree_ref(&self, key: &str) -> Result<Option<Hash>> {
1692        let rtxn = self.env.read_txn()?;
1693        let Some(bytes) = self.tree_refs.get(&rtxn, key)? else {
1694            return Ok(None);
1695        };
1696
1697        let hash: Hash = bytes
1698            .try_into()
1699            .map_err(|_| anyhow::anyhow!("Invalid hash in tree_refs"))?;
1700        Ok(Some(hash))
1701    }
1702
1703    /// List all indexed trees
1704    pub fn list_indexed_trees(&self) -> Result<Vec<(Hash, TreeMeta)>> {
1705        let rtxn = self.env.read_txn()?;
1706        let mut trees = Vec::new();
1707
1708        for item in self.tree_meta.iter(&rtxn)? {
1709            let (hash_bytes, meta_bytes) = item?;
1710            let hash: Hash = hash_bytes
1711                .try_into()
1712                .map_err(|_| anyhow::anyhow!("Invalid hash in tree_meta"))?;
1713            let meta: TreeMeta = rmp_serde::from_slice(meta_bytes)
1714                .map_err(|e| anyhow::anyhow!("Failed to deserialize TreeMeta: {}", e))?;
1715            trees.push((hash, meta));
1716        }
1717
1718        Ok(trees)
1719    }
1720
1721    /// Get total tracked storage size (sum of all tree_meta.total_size)
1722    pub fn tracked_size(&self) -> Result<u64> {
1723        let rtxn = self.env.read_txn()?;
1724        let mut total = 0u64;
1725
1726        for item in self.tree_meta.iter(&rtxn)? {
1727            let (_, bytes) = item?;
1728            let meta: TreeMeta = rmp_serde::from_slice(bytes)
1729                .map_err(|e| anyhow::anyhow!("Failed to deserialize TreeMeta: {}", e))?;
1730            total += meta.total_size;
1731        }
1732
1733        Ok(total)
1734    }
1735
1736    /// Get evictable trees sorted by (priority ASC, synced_at ASC).
1737    ///
1738    /// Blob-level access and raw LRU order live in the storage adapter. Indexed
1739    /// tree metadata stays cheap and does not try to summarize all descendant
1740    /// blob access on every stats or eviction pass.
1741    fn get_evictable_trees(&self) -> Result<Vec<(Hash, TreeMeta)>> {
1742        let mut trees = self.list_indexed_trees()?;
1743
1744        // Sort by priority (lower first), then by age.
1745        trees.sort_by(|a, b| match a.1.priority.cmp(&b.1.priority) {
1746            std::cmp::Ordering::Equal => a.1.synced_at.cmp(&b.1.synced_at),
1747            other => other,
1748        });
1749
1750        Ok(trees)
1751    }
1752
1753    /// Run eviction if storage is over quota
1754    /// Returns bytes freed
1755    ///
1756    /// Eviction order:
1757    /// 1. Orphaned blobs (not in any indexed tree and not pinned)
1758    /// 2. Trees by priority (lowest first) and access age (least recent first)
1759    pub fn evict_if_needed(&self) -> Result<u64> {
1760        // Get storage used by the canonical writable store.
1761        let stats = self
1762            .router
1763            .writable_stats()
1764            .map_err(|e| anyhow::anyhow!("Failed to get writable stats: {}", e))?;
1765        let current = stats.total_bytes;
1766
1767        if current <= self.max_size_bytes {
1768            return Ok(0);
1769        }
1770
1771        // Target 90% of max to avoid constant eviction
1772        let target = self.max_size_bytes * 90 / 100;
1773        self.evict_with_policy_to_target(current, target)
1774    }
1775
1776    fn evict_with_policy_to_target(&self, current: u64, target: u64) -> Result<u64> {
1777        let cleanup = self
1778            .cache_quota
1779            .begin_standalone_cleanup()
1780            .map_err(|denial| anyhow::anyhow!(denial.to_string()))?;
1781        let mut freed = 0u64;
1782        let mut current_size = current;
1783
1784        // Phase 1: Evict orphaned blobs (not in any tree and not pinned)
1785        if self.evict_orphans {
1786            let orphan_progress =
1787                self.evict_disposable_orphans_to_target_raw(target, cleanup.inflight_hashes())?;
1788            freed += orphan_progress.freed_bytes;
1789            current_size = current_size.saturating_sub(orphan_progress.freed_bytes);
1790
1791            if orphan_progress.freed_bytes > 0 {
1792                tracing::info!(
1793                    "Evicted orphaned blobs: {} bytes freed",
1794                    orphan_progress.freed_bytes
1795                );
1796            }
1797
1798            // Do not evict indexed trees merely because the current bounded
1799            // orphan page was protected. Finish one complete orphan sweep
1800            // before escalating to durable tree policy.
1801            if current_size > target && !orphan_progress.sweep_complete {
1802                let after_usage = self
1803                    .router
1804                    .writable_stats()
1805                    .map_err(|error| {
1806                        anyhow::anyhow!(
1807                            "Failed to get writable stats after bounded orphan cleanup: {error}"
1808                        )
1809                    })?
1810                    .total_bytes;
1811                cleanup.complete(after_usage);
1812                return Ok(freed);
1813            }
1814        } else {
1815            tracing::debug!("Skipping orphan blob eviction; storage.evict_orphans=false");
1816        }
1817
1818        // Check if we're now under target
1819        if current_size <= target {
1820            if freed > 0 {
1821                tracing::info!("Eviction complete: {} bytes freed", freed);
1822            }
1823            let after_usage = self
1824                .router
1825                .writable_stats()
1826                .map_err(|error| {
1827                    anyhow::anyhow!("Failed to get writable stats after retention cleanup: {error}")
1828                })?
1829                .total_bytes;
1830            cleanup.complete(after_usage);
1831            return Ok(freed);
1832        }
1833
1834        // Phase 2: Evict trees by priority (lowest first) and access age (least recent first)
1835        // Own trees CAN be evicted (just last), but PINNED trees are never evicted
1836        let retention = self.active_retention_protection()?;
1837        let retention_protected = retention.hashes();
1838        let mut additional_protected = cleanup.inflight_hashes().clone();
1839        additional_protected.extend(retention_protected.iter().copied());
1840        let evictable = self.get_evictable_trees()?;
1841
1842        for (root_hash, meta) in evictable {
1843            if current_size <= target {
1844                break;
1845            }
1846
1847            let root_hex = to_hex(&root_hash);
1848
1849            // Never evict pinned trees
1850            if self.is_pinned(&root_hash)? {
1851                continue;
1852            }
1853            if retention_protected.contains(&root_hash) {
1854                continue;
1855            }
1856
1857            let tree_freed = self.unindex_tree_raw(&root_hash, &additional_protected)?;
1858            freed += tree_freed;
1859            current_size = current_size.saturating_sub(tree_freed);
1860
1861            tracing::info!(
1862                "Evicted tree {} (owner={}, priority={}, {} bytes)",
1863                &root_hex[..8],
1864                &meta.owner[..8.min(meta.owner.len())],
1865                meta.priority,
1866                tree_freed
1867            );
1868        }
1869
1870        if freed > 0 {
1871            tracing::info!("Eviction complete: {} bytes freed", freed);
1872        }
1873
1874        let after_usage = self
1875            .router
1876            .writable_stats()
1877            .map_err(|error| {
1878                anyhow::anyhow!("Failed to get writable stats after retention cleanup: {error}")
1879            })?
1880            .total_bytes;
1881        cleanup.complete(after_usage);
1882        Ok(freed)
1883    }
1884
1885    /// Get the maximum storage size in bytes
1886    pub fn max_size_bytes(&self) -> u64 {
1887        self.max_size_bytes
1888    }
1889
1890    /// Get storage usage by priority tier
1891    pub fn storage_by_priority(&self) -> Result<StorageByPriority> {
1892        let rtxn = self.env.read_txn()?;
1893        let mut own = 0u64;
1894        let mut followed = 0u64;
1895        let mut other = 0u64;
1896
1897        for item in self.tree_meta.iter(&rtxn)? {
1898            let (_, bytes) = item?;
1899            let meta: TreeMeta = rmp_serde::from_slice(bytes)
1900                .map_err(|e| anyhow::anyhow!("Failed to deserialize TreeMeta: {}", e))?;
1901
1902            if meta.priority == PRIORITY_OWN {
1903                own += meta.total_size;
1904            } else if meta.priority >= PRIORITY_FOLLOWED {
1905                followed += meta.total_size;
1906            } else {
1907                other += meta.total_size;
1908            }
1909        }
1910
1911        Ok(StorageByPriority {
1912            own,
1913            followed,
1914            other,
1915        })
1916    }
1917
1918    /// Get storage statistics
1919    pub fn get_storage_stats(&self) -> Result<StorageStats> {
1920        let rtxn = self.env.read_txn()?;
1921        let total_pins = self.pins.len(&rtxn)? as usize;
1922
1923        let stats = self
1924            .router
1925            .stats()
1926            .map_err(|e| anyhow::anyhow!("Failed to get stats: {}", e))?;
1927
1928        Ok(StorageStats {
1929            total_dags: stats.count,
1930            pinned_dags: total_pins,
1931            total_bytes: stats.total_bytes,
1932        })
1933    }
1934}
1935
1936#[cfg(test)]
1937mod tests {
1938    use super::*;
1939    use hashtree_config::StorageBackend;
1940    use hashtree_core::Cid;
1941    use hashtree_index::{BTree, BTreeOptions};
1942    use nostr::{EventBuilder, Keys, Kind, Timestamp};
1943    use std::io::Write;
1944    use std::path::Path;
1945    use tempfile::TempDir;
1946
1947    use crate::storage::PRIORITY_OTHER;
1948
1949    #[cfg(unix)]
1950    #[test]
1951    fn existing_profile_repair_retention_guard_never_creates_its_authority() {
1952        let temp_dir = TempDir::new().expect("temp dir");
1953        let lock_path = temp_dir.path().join(RETENTION_ROOTS_LOCK_FILE);
1954
1955        let error = acquire_existing_profile_repair_retention_guard(temp_dir.path())
1956            .err()
1957            .expect("missing authority must fail");
1958        assert!(error
1959            .to_string()
1960            .contains("inspect existing retention-roots lock"));
1961        assert!(
1962            !lock_path.exists(),
1963            "recovery guard created a missing lock authority"
1964        );
1965
1966        std::fs::write(&lock_path, b"existing-authority").expect("create existing lock authority");
1967        let before = std::fs::metadata(&lock_path).expect("inspect existing lock authority");
1968        let guard = acquire_existing_profile_repair_retention_guard(temp_dir.path())
1969            .expect("acquire existing lock authority");
1970        let after = std::fs::metadata(&lock_path).expect("reinspect existing lock authority");
1971        assert_eq!(before.len(), after.len());
1972        assert_eq!(
1973            std::fs::read(&lock_path).expect("read existing lock authority"),
1974            b"existing-authority"
1975        );
1976        drop(guard);
1977    }
1978
1979    fn write_root_file(path: &Path, cid: &Cid) {
1980        #[derive(Serialize)]
1981        struct StoredCid {
1982            hash: [u8; 32],
1983            key: Option<[u8; 32]>,
1984        }
1985
1986        std::fs::create_dir_all(path.parent().expect("root file parent")).expect("create dir");
1987        let bytes = rmp_serde::to_vec_named(&StoredCid {
1988            hash: cid.hash,
1989            key: cid.key,
1990        })
1991        .expect("encode cid");
1992        std::fs::write(path, bytes).expect("write root file");
1993    }
1994
1995    fn build_test_tree(store: &HashtreeStore) -> Cid {
1996        let index = BTree::new(store.store_arc(), BTreeOptions { order: Some(8) });
1997        sync_block_on(index.build(vec![
1998            ("alpha".to_string(), "one".to_string()),
1999            ("beta".to_string(), "two".to_string()),
2000            ("gamma".to_string(), "three".to_string()),
2001        ]))
2002        .expect("build btree")
2003        .expect("non-empty root")
2004    }
2005
2006    fn build_deep_test_tree(store: &HashtreeStore) -> Cid {
2007        let index = BTree::new(store.store_arc(), BTreeOptions { order: Some(4) });
2008        let entries: Vec<_> = (0..256)
2009            .map(|index| (format!("key-{index:04}"), format!("value-{index:04}")))
2010            .collect();
2011        sync_block_on(index.build(entries))
2012            .expect("build deep btree")
2013            .expect("non-empty deep root")
2014    }
2015
2016    fn build_generated_encrypted_tree(
2017        store: &HashtreeStore,
2018        namespace: &str,
2019        entry_count: usize,
2020    ) -> Cid {
2021        let index = BTree::new(store.store_arc(), BTreeOptions { order: Some(4) });
2022        let entries: Vec<_> = (0..entry_count)
2023            .map(|index| {
2024                (
2025                    format!("{namespace}-key-{index:04}"),
2026                    format!("{namespace}-value-{index:04}"),
2027                )
2028            })
2029            .collect();
2030        let root = sync_block_on(index.build(entries))
2031            .expect("build generated encrypted btree")
2032            .expect("non-empty generated encrypted root");
2033        assert!(
2034            root.key.is_some(),
2035            "generated retention coverage must exercise a full encrypted CID"
2036        );
2037        root
2038    }
2039
2040    fn generated_tree_hashes(store: &HashtreeStore, root: &Cid) -> HashSet<Hash> {
2041        let tree = HashTree::new(HashTreeConfig::new(store.store_arc()).public());
2042        let hashes = sync_block_on(store.collect_tree_hashes(&tree, root, true))
2043            .expect("collect generated encrypted DAG");
2044        assert!(hashes.len() > 2, "generated DAG must contain descendants");
2045        hashes
2046    }
2047
2048    fn publish_generated_profile_repair_lease(store: &HashtreeStore, label: &str, root: &Cid) {
2049        let lease = ProfileRepairRetentionLease {
2050            format: PROFILE_REPAIR_RETENTION_LEASE_FORMAT.to_string(),
2051            authority_sha256: to_hex(&hashtree_core::sha256(root.to_string().as_bytes())),
2052            roots: BTreeMap::from([(label.to_string(), root.to_string())]),
2053        };
2054        let lease_bytes = lease.canonical_bytes().expect("canonical generated lease");
2055        let publication = store
2056            .acquire_profile_repair_retention_publication_guard()
2057            .expect("exclusive generated retention publication");
2058        let lease_path = store.profile_repair_retention_lease_path();
2059        std::fs::create_dir_all(lease_path.parent().expect("lease parent"))
2060            .expect("create generated lease parent");
2061        let mut lease_file = File::create(&lease_path).expect("create generated lease");
2062        lease_file
2063            .write_all(&lease_bytes)
2064            .expect("write generated lease");
2065        lease_file.sync_all().expect("sync generated lease");
2066        File::open(lease_path.parent().expect("lease parent"))
2067            .expect("open generated lease parent")
2068            .sync_all()
2069            .expect("sync generated lease parent");
2070        drop(publication);
2071    }
2072
2073    #[cfg(feature = "lmdb")]
2074    fn bounded_lmdb_store(path: &Path, max_size_bytes: u64) -> HashtreeStore {
2075        // Seed the legacy single-store path so the shared-layout opener does
2076        // not create a fresh PoolStore. Bounded orphan deletion is
2077        // intentionally LMDB-only until PoolStore has a remove-hot-copy API.
2078        drop(
2079            super::super::LocalStore::new_unbounded_with_lmdb_map_size(
2080                path.join("blobs"),
2081                &StorageBackend::Lmdb,
2082                Some(16 * 1024 * 1024),
2083            )
2084            .expect("seed single LMDB"),
2085        );
2086        HashtreeStore::with_options_and_backend(
2087            path,
2088            None,
2089            max_size_bytes,
2090            true,
2091            &StorageBackend::Lmdb,
2092        )
2093        .expect("LMDB store")
2094    }
2095
2096    #[cfg(feature = "lmdb")]
2097    fn put_ordered_hashes(store: &HashtreeStore, count: u8) -> Vec<Hash> {
2098        let mut hashes = Vec::new();
2099        for value in 1..=count {
2100            let data = [value];
2101            let hash = hashtree_core::sha256(&data);
2102            store
2103                .router
2104                .put_sync(hash, &data)
2105                .expect("put ordered test hash");
2106            hashes.push(hash);
2107        }
2108        hashes.sort_unstable();
2109        hashes
2110    }
2111
2112    #[cfg(feature = "lmdb")]
2113    #[test]
2114    fn bounded_orphan_sweep_progresses_and_preserves_all_durable_classes() {
2115        let temp_dir = TempDir::new().expect("temp dir");
2116        let store = bounded_lmdb_store(temp_dir.path(), 1024 * 1024);
2117        let hashes = put_ordered_hashes(&store, 7);
2118
2119        store.pin(&hashes[0]).expect("pin first hash");
2120        let mut tree_key = [0u8; 64];
2121        tree_key[..32].copy_from_slice(&hashes[1]);
2122        tree_key[32..].fill(99);
2123        let mut wtxn = store.env.write_txn().expect("metadata write");
2124        store
2125            .blob_trees
2126            .put(&mut wtxn, &tree_key, &())
2127            .expect("index second hash");
2128        wtxn.commit().expect("commit tree index");
2129        store
2130            .set_blob_owner(&hashes[2], &[77; 32])
2131            .expect("own third hash");
2132        let socialgraph_root = build_test_tree(&store);
2133        let socialgraph_hashes = generated_tree_hashes(&store, &socialgraph_root);
2134        write_root_file(
2135            &temp_dir.path().join("socialgraph/events-root.msgpack"),
2136            &socialgraph_root,
2137        );
2138
2139        let mut progress = Vec::new();
2140        loop {
2141            let page = store
2142                .evict_disposable_orphans_page(0, &HashSet::new(), 2)
2143                .expect("bounded orphan page");
2144            assert!(page.scanned <= 2, "page exceeded its candidate bound");
2145            progress.push(page);
2146            if page.sweep_complete {
2147                break;
2148            }
2149            assert!(progress.len() < 50, "bounded sweep did not converge");
2150        }
2151
2152        assert_eq!(progress.iter().map(|page| page.freed_bytes).sum::<u64>(), 4);
2153        assert!(progress.iter().all(|page| page.scanned <= 2));
2154        for hash in &hashes[..3] {
2155            assert!(store.blob_exists(hash).expect("protected blob lookup"));
2156        }
2157        for hash in &hashes[3..] {
2158            assert!(!store.blob_exists(hash).expect("orphan blob lookup"));
2159        }
2160        for hash in socialgraph_hashes {
2161            assert!(
2162                store.blob_exists(&hash).expect("socialgraph blob lookup"),
2163                "bounded sweep deleted generated socialgraph DAG hash {}",
2164                to_hex(&hash)
2165            );
2166        }
2167    }
2168
2169    #[cfg(feature = "lmdb")]
2170    #[test]
2171    fn socialgraph_root_change_unions_protection_until_sweep_boundary() {
2172        let temp_dir = TempDir::new().expect("temp dir");
2173        let store = bounded_lmdb_store(temp_dir.path(), 1024 * 1024);
2174        let old_root = build_generated_encrypted_tree(&store, "old-live-root", 24);
2175        let old_hashes = generated_tree_hashes(&store, &old_root);
2176        let root_path = temp_dir.path().join("socialgraph/events-root.msgpack");
2177        write_root_file(&root_path, &old_root);
2178
2179        let first = store
2180            .evict_disposable_orphans_page(0, &HashSet::new(), 1)
2181            .expect("first root page");
2182        assert_eq!(first.scanned, 1);
2183        assert_eq!(first.freed_bytes, 0);
2184        let new_root = build_generated_encrypted_tree(&store, "new-live-root", 24);
2185        let new_hashes = generated_tree_hashes(&store, &new_root);
2186        write_root_file(&root_path, &new_root);
2187
2188        let second = store
2189            .evict_disposable_orphans_page(0, &HashSet::new(), 1)
2190            .expect("changed root page");
2191        assert_eq!(second.scanned, 1);
2192        assert_eq!(second.freed_bytes, 0);
2193        {
2194            let state = store
2195                .orphan_scan
2196                .lock()
2197                .unwrap_or_else(std::sync::PoisonError::into_inner);
2198            assert!(old_hashes
2199                .iter()
2200                .all(|hash| state.socialgraph_protected.contains(hash)));
2201            assert!(new_hashes
2202                .iter()
2203                .all(|hash| state.socialgraph_protected.contains(hash)));
2204        }
2205
2206        let mut sweep_complete = false;
2207        for _ in 0..256 {
2208            let page = store
2209                .evict_disposable_orphans_page(0, &HashSet::new(), 1)
2210                .expect("finish unioned socialgraph sweep");
2211            assert_eq!(page.freed_bytes, 0);
2212            if page.sweep_complete {
2213                sweep_complete = true;
2214                break;
2215            }
2216        }
2217        assert!(sweep_complete, "unioned socialgraph sweep did not converge");
2218        for hash in old_hashes.iter().chain(new_hashes.iter()) {
2219            assert!(
2220                store.blob_exists(hash).expect("unioned root lookup"),
2221                "active sweep deleted union-protected DAG hash {}",
2222                to_hex(hash)
2223            );
2224        }
2225    }
2226
2227    #[cfg(feature = "lmdb")]
2228    #[test]
2229    fn immutable_profile_repair_lease_preserves_complete_generated_dag() {
2230        let temp_dir = TempDir::new().expect("temp dir");
2231        let store = bounded_lmdb_store(temp_dir.path(), 64 * 1024 * 1024);
2232        let protected_root = build_deep_test_tree(&store);
2233        let tree = HashTree::new(HashTreeConfig::new(store.store_arc()).public());
2234        let protected_hashes =
2235            sync_block_on(store.collect_tree_hashes(&tree, &protected_root, true))
2236                .expect("collect generated repair DAG");
2237        assert!(protected_hashes.len() > 2);
2238
2239        let orphan_bytes = b"unleased generated orphan";
2240        let orphan = hashtree_core::sha256(orphan_bytes);
2241        store
2242            .router
2243            .put_sync(orphan, orphan_bytes)
2244            .expect("put generated orphan");
2245
2246        let lease = ProfileRepairRetentionLease {
2247            format: PROFILE_REPAIR_RETENTION_LEASE_FORMAT.to_string(),
2248            authority_sha256: "11".repeat(32),
2249            roots: BTreeMap::from([("profile-search".to_string(), protected_root.to_string())]),
2250        };
2251        let lease_bytes = lease.canonical_bytes().expect("canonical lease");
2252        let publication = store
2253            .acquire_profile_repair_retention_publication_guard()
2254            .expect("exclusive retention publication");
2255        let lease_path = store.profile_repair_retention_lease_path();
2256        std::fs::create_dir_all(lease_path.parent().expect("lease parent"))
2257            .expect("create lease parent");
2258        let mut lease_file = File::create(&lease_path).expect("create lease");
2259        lease_file.write_all(&lease_bytes).expect("write lease");
2260        lease_file.sync_all().expect("sync lease");
2261        File::open(lease_path.parent().expect("lease parent"))
2262            .expect("open lease parent")
2263            .sync_all()
2264            .expect("sync lease parent");
2265        drop(publication);
2266
2267        loop {
2268            let page = store
2269                .evict_disposable_orphans_page(0, &HashSet::new(), 31)
2270                .expect("lease-protected orphan sweep");
2271            if page.sweep_complete {
2272                break;
2273            }
2274        }
2275
2276        for hash in protected_hashes {
2277            assert!(
2278                store.blob_exists(&hash).expect("protected blob lookup"),
2279                "lease lost generated DAG blob {}",
2280                to_hex(&hash)
2281            );
2282        }
2283        assert!(!store.blob_exists(&orphan).expect("orphan lookup"));
2284    }
2285
2286    #[cfg(feature = "lmdb")]
2287    #[test]
2288    fn garbage_collection_preserves_leased_generated_encrypted_dag() {
2289        let temp_dir = TempDir::new().expect("temp dir");
2290        let store = bounded_lmdb_store(temp_dir.path(), 64 * 1024 * 1024);
2291        let leased_root = build_generated_encrypted_tree(&store, "gc-leased", 96);
2292        let leased_hashes = generated_tree_hashes(&store, &leased_root);
2293        let current_root = build_generated_encrypted_tree(&store, "gc-current", 96);
2294        let current_hashes = generated_tree_hashes(&store, &current_root);
2295        write_root_file(
2296            &temp_dir.path().join("socialgraph/events-root.msgpack"),
2297            &current_root,
2298        );
2299        let orphan_bytes = format!("gc-unleased-orphan:{}", leased_root);
2300        let orphan_hash = hashtree_core::sha256(orphan_bytes.as_bytes());
2301        store
2302            .router
2303            .put_sync(orphan_hash, orphan_bytes.as_bytes())
2304            .expect("put generated GC orphan");
2305        publish_generated_profile_repair_lease(&store, "gc-leased", &leased_root);
2306
2307        let report = store.gc().expect("lease-aware garbage collection");
2308
2309        assert_eq!(report.deleted_dags, 1);
2310        assert!(!store.blob_exists(&orphan_hash).expect("GC orphan lookup"));
2311        for hash in leased_hashes.into_iter().chain(current_hashes) {
2312            assert!(
2313                store.blob_exists(&hash).expect("leased GC hash lookup"),
2314                "GC deleted active encrypted DAG hash {}",
2315                to_hex(&hash)
2316            );
2317        }
2318    }
2319
2320    #[test]
2321    fn garbage_collection_preserves_generated_pending_profile_root_pair_until_recovery() {
2322        let temp_dir = TempDir::new().expect("temp dir");
2323        let store = HashtreeStore::with_embedded_options(temp_dir.path(), None, 64 * 1024 * 1024)
2324            .expect("embedded generated store");
2325        let graph = crate::socialgraph::open_test_social_graph_store_with_storage(
2326            temp_dir.path(),
2327            store.store_arc(),
2328            None,
2329        )
2330        .expect("open generated social graph");
2331
2332        let mut profiles = Vec::new();
2333        let mut decisions = BTreeMap::new();
2334        for index in 0..80 {
2335            let keys = Keys::generate();
2336            let event = EventBuilder::new(
2337                Kind::Metadata,
2338                serde_json::json!({
2339                    "display_name": format!("pending-profile-{index:04}")
2340                })
2341                .to_string(),
2342            )
2343            .custom_created_at(Timestamp::from_secs(index + 1))
2344            .sign_with_keys(&keys)
2345            .expect("sign generated profile");
2346            decisions.insert(event.pubkey.to_hex(), Some(1));
2347            profiles.push(event);
2348        }
2349        let expected_profile = profiles[0].clone();
2350        let prepared = graph
2351            .build_unpublished_profile_index_repair_with_frozen_distances(&profiles, &decisions)
2352            .expect("build generated replacement profile roots");
2353        let mut pending_hashes = HashSet::new();
2354        for root in [
2355            prepared
2356                .new_roots()
2357                .by_pubkey
2358                .as_ref()
2359                .expect("generated by-pubkey root"),
2360            prepared
2361                .new_roots()
2362                .search
2363                .as_ref()
2364                .expect("generated search root"),
2365        ] {
2366            pending_hashes.extend(generated_tree_hashes(&store, root));
2367        }
2368
2369        let crash = graph
2370            .crash_after_prepared_profile_root_pair_intent(&prepared)
2371            .expect_err("generated commit must stop after its durable intent");
2372        assert!(format!("{crash:#}").contains("injected interruption after durable"));
2373        let commit_path = temp_dir
2374            .path()
2375            .join("socialgraph/profile-root-pair.commit.json");
2376        assert!(commit_path.is_file(), "generated commit intent is missing");
2377        assert!(
2378            !temp_dir
2379                .path()
2380                .join("socialgraph/profiles-by-pubkey-root.msgpack")
2381                .exists(),
2382            "crash injection unexpectedly installed by-pubkey root"
2383        );
2384        assert!(
2385            !temp_dir
2386                .path()
2387                .join("socialgraph/profile-search-root.msgpack")
2388                .exists(),
2389            "crash injection unexpectedly installed search root"
2390        );
2391        drop(graph);
2392
2393        let orphan_bytes = b"pending-root-pair-unrelated-orphan";
2394        let orphan_hash = hashtree_core::sha256(orphan_bytes);
2395        store
2396            .router
2397            .put_sync(orphan_hash, orphan_bytes)
2398            .expect("put generated orphan");
2399
2400        let report = store.gc().expect("pending-commit-aware garbage collection");
2401        assert_eq!(report.deleted_dags, 1);
2402        assert!(!store.blob_exists(&orphan_hash).expect("orphan lookup"));
2403        for hash in &pending_hashes {
2404            assert!(
2405                store.blob_exists(hash).expect("pending DAG lookup"),
2406                "GC deleted pending root-pair DAG hash {}",
2407                to_hex(hash)
2408            );
2409        }
2410
2411        let recovered = crate::socialgraph::open_test_social_graph_store_with_storage(
2412            temp_dir.path(),
2413            store.store_arc(),
2414            None,
2415        )
2416        .expect("recover generated pending root pair");
2417        assert!(!commit_path.exists(), "pending commit was not recovered");
2418        assert_eq!(
2419            recovered
2420                .latest_profile_event(&expected_profile.pubkey.to_hex())
2421                .expect("read recovered profile")
2422                .expect("recovered profile is missing")
2423                .id,
2424            expected_profile.id
2425        );
2426    }
2427
2428    #[cfg(feature = "lmdb")]
2429    #[test]
2430    fn applied_root_retention_preserves_leased_generated_encrypted_dag() {
2431        let temp_dir = TempDir::new().expect("temp dir");
2432        let store = bounded_lmdb_store(temp_dir.path(), 64 * 1024 * 1024);
2433        let retained_root = build_generated_encrypted_tree(&store, "retained-target", 96);
2434        let leased_root = build_generated_encrypted_tree(&store, "retention-leased", 96);
2435        let leased_hashes = generated_tree_hashes(&store, &leased_root);
2436        let current_root = build_generated_encrypted_tree(&store, "retention-current", 96);
2437        let current_hashes = generated_tree_hashes(&store, &current_root);
2438        write_root_file(
2439            &temp_dir
2440                .path()
2441                .join("socialgraph/profile-search-root.msgpack"),
2442            &current_root,
2443        );
2444        let orphan_bytes = format!("retention-unleased-orphan:{}", leased_root);
2445        let orphan_hash = hashtree_core::sha256(orphan_bytes.as_bytes());
2446        store
2447            .router
2448            .put_sync(orphan_hash, orphan_bytes.as_bytes())
2449            .expect("put generated retention orphan");
2450        publish_generated_profile_repair_lease(&store, "retention-leased", &leased_root);
2451
2452        let report = store
2453            .retain_nostr_root(&retained_root, true)
2454            .expect("lease-aware apply retention");
2455
2456        assert_eq!(report.candidate_hashes, 1);
2457        assert_eq!(report.deleted_hashes, 1);
2458        assert!(!store
2459            .blob_exists(&orphan_hash)
2460            .expect("retention orphan lookup"));
2461        for hash in leased_hashes.into_iter().chain(current_hashes) {
2462            assert!(
2463                store
2464                    .blob_exists(&hash)
2465                    .expect("leased retention hash lookup"),
2466                "apply retention deleted active encrypted DAG hash {}",
2467                to_hex(&hash)
2468            );
2469        }
2470        let leased_index = BTree::new(store.store_arc(), BTreeOptions { order: Some(4) });
2471        assert_eq!(
2472            sync_block_on(leased_index.get(Some(&leased_root), "retention-leased-key-0095"))
2473                .expect("read leased encrypted DAG after retention"),
2474            Some("retention-leased-value-0095".to_string())
2475        );
2476    }
2477
2478    #[cfg(feature = "lmdb")]
2479    #[test]
2480    fn poolstore_orphan_cleanup_fails_closed_without_deleting_catalog_data() {
2481        let temp_dir = TempDir::new().expect("temp dir");
2482        let store = HashtreeStore::with_options_and_backend(
2483            temp_dir.path(),
2484            None,
2485            1024 * 1024,
2486            true,
2487            &StorageBackend::Lmdb,
2488        )
2489        .expect("fresh shared store");
2490        assert!(matches!(
2491            store.router.local_store().as_ref(),
2492            super::super::LocalStore::Pool(_)
2493        ));
2494        let data = b"durable pool catalog data";
2495        let hash = hashtree_core::sha256(data);
2496        store.router.put_sync(hash, data).expect("put pool blob");
2497
2498        let error = store
2499            .evict_disposable_orphans_to_target(0)
2500            .expect_err("PoolStore orphan deletion must fail closed");
2501        assert!(
2502            error.to_string().contains("tier-aware deletion"),
2503            "unexpected error: {error}"
2504        );
2505        assert!(store.blob_exists(&hash).expect("pool blob lookup"));
2506    }
2507
2508    #[cfg(feature = "lmdb")]
2509    #[test]
2510    fn hash_inserted_before_cursor_is_seen_after_wrap() {
2511        let temp_dir = TempDir::new().expect("temp dir");
2512        let store = bounded_lmdb_store(temp_dir.path(), 1024 * 1024);
2513        let mut entries = (1u8..=8)
2514            .map(|value| {
2515                let data = vec![value];
2516                (hashtree_core::sha256(&data), data)
2517            })
2518            .collect::<Vec<_>>();
2519        entries.sort_unstable_by_key(|(hash, _)| *hash);
2520        let (behind_cursor_hash, behind_cursor_data) = entries[0].clone();
2521        for (hash, data) in &entries[1..=3] {
2522            store
2523                .router
2524                .put_sync(*hash, data)
2525                .expect("put initial cursor fixture");
2526        }
2527
2528        let first = store
2529            .evict_disposable_orphans_page(0, &HashSet::new(), 1)
2530            .expect("first page");
2531        assert_eq!(first.scanned, 1);
2532        assert_eq!(first.freed_bytes, 1);
2533        store
2534            .router
2535            .put_sync(behind_cursor_hash, &behind_cursor_data)
2536            .expect("insert behind active cursor");
2537
2538        for _ in 0..8 {
2539            store
2540                .evict_disposable_orphans_page(0, &HashSet::new(), 1)
2541                .expect("continue wrapped sweep");
2542            if !store
2543                .blob_exists(&behind_cursor_hash)
2544                .expect("behind-cursor lookup")
2545            {
2546                return;
2547            }
2548        }
2549        panic!("hash inserted behind the active cursor was not seen after wrap");
2550    }
2551
2552    #[cfg(feature = "lmdb")]
2553    #[test]
2554    fn orphan_cleanup_keeps_indexed_tree_hashes() {
2555        let temp_dir = TempDir::new().expect("temp dir");
2556        let store = bounded_lmdb_store(temp_dir.path(), 1024);
2557        let cid = build_test_tree(&store);
2558
2559        store
2560            .index_tree(
2561                &cid.hash,
2562                "owner",
2563                Some("tree"),
2564                PRIORITY_OTHER,
2565                Some("owner/tree"),
2566            )
2567            .expect("index tree");
2568        let freed = store
2569            .evict_disposable_orphans_to_target(0)
2570            .expect("orphan cleanup");
2571
2572        assert!(freed < 1024);
2573        assert!(store.blob_exists(&cid.hash).expect("root exists"));
2574    }
2575
2576    #[test]
2577    fn list_pins_with_names_uses_indexed_tree_metadata() {
2578        let temp_dir = TempDir::new().expect("temp dir");
2579        let store = HashtreeStore::with_options(temp_dir.path(), None, 1024 * 1024).expect("store");
2580        let cid = build_test_tree(&store);
2581
2582        store.pin(&cid.hash).expect("pin tree");
2583        store
2584            .index_tree(
2585                &cid.hash,
2586                "npub1example",
2587                Some("playlist"),
2588                PRIORITY_OTHER,
2589                Some("npub1example/playlist"),
2590            )
2591            .expect("index tree");
2592
2593        let pins = store.list_pins_with_names().expect("list pins");
2594
2595        assert_eq!(pins.len(), 1);
2596        assert_eq!(pins[0].name, "npub1example/playlist");
2597        assert!(pins[0].size_bytes > 0);
2598    }
2599
2600    #[test]
2601    fn index_tree_records_multilevel_file_size_from_links() {
2602        let temp_dir = TempDir::new().expect("temp dir");
2603        let store = HashtreeStore::with_options(temp_dir.path(), None, 1024 * 1024).expect("store");
2604        let tree = HashTree::new(
2605            HashTreeConfig::new(store.store_arc())
2606                .public()
2607                .with_chunk_size(4)
2608                .with_max_links(2),
2609        );
2610        let data = (0u8..31).collect::<Vec<_>>();
2611        let (cid, size) = sync_block_on(tree.put(&data)).expect("put file");
2612
2613        store
2614            .index_tree(
2615                &cid.hash,
2616                "npub1example",
2617                Some("large-file"),
2618                PRIORITY_OTHER,
2619                Some("npub1example/large-file"),
2620            )
2621            .expect("index tree");
2622
2623        let meta = store
2624            .get_tree_meta(&cid.hash)
2625            .expect("tree meta")
2626            .expect("indexed meta");
2627        assert_eq!(size, data.len() as u64);
2628        assert_eq!(meta.total_size, data.len() as u64);
2629    }
2630
2631    #[test]
2632    fn get_tree_ref_returns_stored_root() {
2633        let temp_dir = TempDir::new().expect("temp dir");
2634        let store = HashtreeStore::with_options(temp_dir.path(), None, 1024 * 1024).expect("store");
2635        let cid = build_test_tree(&store);
2636
2637        store
2638            .index_tree(
2639                &cid.hash,
2640                "npub1example",
2641                Some("playlist"),
2642                PRIORITY_OTHER,
2643                Some("npub1example/playlist"),
2644            )
2645            .expect("index tree");
2646
2647        assert_eq!(
2648            store
2649                .get_tree_ref("npub1example/playlist")
2650                .expect("tree ref lookup"),
2651            Some(cid.hash)
2652        );
2653    }
2654
2655    #[test]
2656    fn tree_meta_deserializes_metadata_without_tree_access_field() {
2657        #[derive(Serialize)]
2658        struct LegacyTreeMeta {
2659            owner: String,
2660            name: Option<String>,
2661            synced_at: u64,
2662            total_size: u64,
2663            priority: u8,
2664        }
2665
2666        let bytes = rmp_serde::to_vec(&LegacyTreeMeta {
2667            owner: "owner".to_string(),
2668            name: Some("tree".to_string()),
2669            synced_at: 123,
2670            total_size: 456,
2671            priority: PRIORITY_OTHER,
2672        })
2673        .expect("serialize legacy metadata");
2674        let meta: TreeMeta = rmp_serde::from_slice(&bytes).expect("deserialize tree metadata");
2675
2676        assert_eq!(meta.owner, "owner");
2677        assert_eq!(meta.name.as_deref(), Some("tree"));
2678        assert_eq!(meta.synced_at, 123);
2679        assert_eq!(meta.total_size, 456);
2680        assert_eq!(meta.priority, PRIORITY_OTHER);
2681    }
2682
2683    #[test]
2684    fn tree_meta_deserializes_accidental_access_field_but_drops_it_on_write() {
2685        #[derive(Serialize)]
2686        struct AccidentalTreeMeta {
2687            owner: String,
2688            name: Option<String>,
2689            synced_at: u64,
2690            last_accessed_at: u64,
2691            total_size: u64,
2692            priority: u8,
2693        }
2694
2695        let bytes = rmp_serde::to_vec(&AccidentalTreeMeta {
2696            owner: "owner".to_string(),
2697            name: Some("tree".to_string()),
2698            synced_at: 123,
2699            last_accessed_at: 999,
2700            total_size: 456,
2701            priority: PRIORITY_OTHER,
2702        })
2703        .expect("serialize accidental metadata");
2704        let meta: TreeMeta = rmp_serde::from_slice(&bytes).expect("deserialize tree metadata");
2705        let encoded = rmp_serde::to_vec(&meta).expect("serialize current metadata");
2706        let reparsed: (String, Option<String>, u64, u64, u8) =
2707            rmp_serde::from_slice(&encoded).expect("parse current metadata shape");
2708
2709        assert_eq!(meta.owner, "owner");
2710        assert_eq!(meta.name.as_deref(), Some("tree"));
2711        assert_eq!(meta.synced_at, 123);
2712        assert_eq!(meta.total_size, 456);
2713        assert_eq!(meta.priority, PRIORITY_OTHER);
2714        assert_eq!(reparsed.0, "owner");
2715        assert_eq!(reparsed.3, 456);
2716        assert_eq!(reparsed.4, PRIORITY_OTHER);
2717    }
2718
2719    #[cfg(feature = "lmdb")]
2720    #[test]
2721    fn eviction_prefers_oldest_tree_within_priority() {
2722        let temp_dir = TempDir::new().expect("temp dir");
2723        let store = bounded_lmdb_store(temp_dir.path(), 500);
2724
2725        let hash1 = hashtree_core::sha256(&[1u8; 200]);
2726        let hash2 = hashtree_core::sha256(&[2u8; 200]);
2727        let hash3 = hashtree_core::sha256(&[3u8; 200]);
2728        store.put_blob(&[1u8; 200]).expect("put blob 1");
2729        store.put_blob(&[2u8; 200]).expect("put blob 2");
2730        store.put_blob(&[3u8; 200]).expect("put blob 3");
2731        store
2732            .index_tree(&hash1, "owner1", Some("tree1"), PRIORITY_OTHER, None)
2733            .expect("index tree 1");
2734        store
2735            .index_tree(&hash2, "owner2", Some("tree2"), PRIORITY_OTHER, None)
2736            .expect("index tree 2");
2737        store
2738            .index_tree(&hash3, "owner3", Some("tree3"), PRIORITY_OTHER, None)
2739            .expect("index tree 3");
2740
2741        let freed = store.evict_if_needed().expect("evict");
2742
2743        assert!(freed > 0);
2744        assert!(
2745            store.get_tree_meta(&hash3).expect("tree meta").is_some(),
2746            "newest tree should survive before older peers at the same priority"
2747        );
2748    }
2749
2750    #[cfg(feature = "lmdb")]
2751    #[test]
2752    fn orphan_cleanup_keeps_socialgraph_root_hashes() {
2753        let temp_dir = TempDir::new().expect("temp dir");
2754        let store = bounded_lmdb_store(temp_dir.path(), 1024);
2755        let cid = build_test_tree(&store);
2756        write_root_file(
2757            &temp_dir.path().join("socialgraph/events-root.msgpack"),
2758            &cid,
2759        );
2760
2761        let freed = store
2762            .evict_disposable_orphans_to_target(0)
2763            .expect("orphan cleanup");
2764
2765        assert!(freed < 1024);
2766        assert!(store.blob_exists(&cid.hash).expect("root exists"));
2767    }
2768
2769    #[test]
2770    fn retained_nostr_root_cleanup_is_dry_run_first_and_keeps_the_dag() {
2771        let temp_dir = TempDir::new().expect("temp dir");
2772        let store = HashtreeStore::with_options(temp_dir.path(), None, 1024 * 1024).expect("store");
2773        let root = build_deep_test_tree(&store);
2774        let orphan_bytes = b"unreachable historical index node";
2775        let orphan = hashtree_core::sha256(orphan_bytes);
2776        store.put_blob(orphan_bytes).expect("put orphan");
2777
2778        let dry_run = store
2779            .retain_nostr_root(&root, false)
2780            .expect("retention dry run");
2781        assert_eq!(dry_run.deleted_hashes, 0);
2782        assert_eq!(dry_run.candidate_hashes, 1);
2783        assert_eq!(dry_run.total_hashes, dry_run.reachable_hashes + 1);
2784        assert!(dry_run.reachable_hashes > 256);
2785        assert!(store.blob_exists(&orphan).expect("orphan exists"));
2786
2787        let applied = store
2788            .retain_nostr_root(&root, true)
2789            .expect("apply retention");
2790        assert_eq!(applied.deleted_hashes, applied.candidate_hashes);
2791        assert!(!store.blob_exists(&orphan).expect("orphan deleted"));
2792        let index = BTree::new(store.store_arc(), BTreeOptions { order: Some(8) });
2793        assert_eq!(
2794            sync_block_on(index.get(Some(&root), "key-0255")).expect("read retained index"),
2795            Some("value-0255".to_string())
2796        );
2797    }
2798}