Skip to main content

lean_ctx/core/
archive.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4
5use super::data_dir::lean_ctx_data_dir;
6
7pub mod authority;
8pub mod bindings;
9pub mod delivered;
10pub mod presented;
11pub mod recovery;
12pub mod register;
13
14pub use recovery::{
15    RecoveryId, RecoveryRefusal, recovery_lineage, retrieve_recovery_checked,
16    store_recovery_with_lineage,
17};
18
19/// Protection is active in some layer (project policy pack, or the global
20/// gateway in governed or sovereign mode): only source-bound archives are
21/// written, listed or served. One predicate for every store.
22use super::context_admission::stored::source_authority_required;
23
24#[cfg(test)]
25#[path = "archive/source_tests.rs"]
26mod source_tests;
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct ArchiveEntry {
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub authority: Option<authority::ArchiveAuthority>,
32    pub id: String,
33    pub tool: String,
34    pub command: String,
35    pub size_chars: usize,
36    pub size_tokens: usize,
37    pub created_at: DateTime<Utc>,
38    pub session_id: Option<String>,
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub protected_until: Option<DateTime<Utc>>,
41    #[serde(default, skip_serializing_if = "Vec::is_empty")]
42    pub aliases: Vec<String>,
43    /// The lineage of a recovery copy that is not derived from project files
44    /// alone (`archive::recovery`). Such an entry has no `authority`, so every
45    /// other read path refuses it; only `retrieve_recovery_checked` serves it.
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub(crate) lineage: Option<delivered::Lineage>,
48    /// The partition (`project_hash::hash_project_root`) of the project that
49    /// stored this archive. An archive without a source binding is
50    /// addressed by its partition and content, so two projects never share
51    /// one entry, and it is listed, searched and served only there. One
52    /// written before 3.11.5 names none and is never served (AP05-G4); the
53    /// owner adopts it with `lean-ctx migrate legacy-context`.
54    #[serde(default, skip_serializing_if = "Vec::is_empty")]
55    pub projects: Vec<String>,
56    /// The request project root the archive was stored for. A partition
57    /// hash also covers the project's identity (remote, package name); when
58    /// that changes, this recorded root proves which project the archive
59    /// belongs to (`legacy_adoption`).
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub project_root: Option<String>,
62}
63
64/// The partition of the request project, shared by every archive write and
65/// read. Without a bound request project there is none: nothing unbound is
66/// stored or served (the process directory is never a project).
67pub(crate) fn current_partition() -> Option<String> {
68    super::policy::diagnostics::request_project()
69        .map(|root| super::project_hash::hash_project_root(&root.to_string_lossy()))
70}
71
72/// The id of an archive without a source binding: its partition and its
73/// content, so the same text stored by two projects is two entries.
74fn unbound_id(partition: &str, content: &str) -> String {
75    let mut hasher = blake3::Hasher::new();
76    hasher.update(partition.as_bytes());
77    hasher.update(&[0]);
78    hasher.update(content.as_bytes());
79    hasher.finalize().to_hex()[..16].to_string()
80}
81
82/// An archive without a source binding belongs to the projects that stored it.
83fn in_current_partition(entry: &ArchiveEntry) -> bool {
84    current_partition().is_some_and(|partition| entry.projects.contains(&partition))
85}
86
87/// Archives without a source binding that were stored before archives named
88/// their project: served to no project (AP05-G4). Read for the explicit
89/// `lean-ctx migrate legacy-context` adoption only.
90pub fn legacy_unscoped_entries() -> Vec<ArchiveEntry> {
91    list_entries_raw(None)
92        .into_iter()
93        .filter(|entry| {
94            entry.authority.is_none()
95                && entry.lineage.is_none()
96                && entry.id.len() == 16
97                && (entry.projects.is_empty() || is_drifted(entry))
98        })
99        .collect()
100}
101
102/// An archive whose partition no longer matches the current partition of
103/// the root it recorded: that project's identity (remote, package name)
104/// changed since. Served to no project until adopted.
105pub fn is_drifted(entry: &ArchiveEntry) -> bool {
106    match (&entry.project_root, entry.projects.first()) {
107        (Some(root), Some(partition)) => super::project_hash::hash_project_root(root) != *partition,
108        _ => false,
109    }
110}
111
112/// The metadata of the unbound archive `id`, whatever project holds it.
113/// For the local owner's `lean-ctx migrate legacy-context` listing only.
114pub(crate) fn unbound_entry(id: &str) -> Option<ArchiveEntry> {
115    read_entry(id)
116        .filter(|entry| entry.authority.is_none() && entry.lineage.is_none() && id.len() == 16)
117}
118
119/// Copy the legacy archive `id` into `project_root`: its stored text passes
120/// the admission of a normal archive write there and gets that project's
121/// own id. The legacy entry is left as it is. `Ok` names the new id; `Err`
122/// is the content-free refusal.
123pub fn adopt_legacy_entry(id: &str, project_root: &std::path::Path) -> Result<String, String> {
124    let entry = unbound_entry(id)
125        .filter(|entry| entry.projects.is_empty() || is_drifted(entry))
126        .ok_or("not a legacy archive")?;
127    let content = super::atomic_fs::read_store_file(&archive_base_dir(), &content_path(id))
128        .map_err(|_| "archive content unavailable")?;
129    super::policy::runtime::REQUEST_PROJECT
130        .sync_scope(
131            std::cell::RefCell::new(Some(project_root.to_path_buf())),
132            || {
133                store_with_result(
134                    &entry.tool,
135                    &entry.command,
136                    &content,
137                    entry.session_id.as_deref(),
138                )
139            },
140        )
141        .map(|stored| stored.id)
142        .ok_or_else(|| "withheld by the current policy, or archiving is disabled".to_string())
143}
144
145fn archive_base_dir() -> PathBuf {
146    lean_ctx_data_dir()
147        .unwrap_or_else(|_| PathBuf::from(".lean-ctx"))
148        .join("archives")
149}
150
151fn entry_dir(id: &str) -> PathBuf {
152    let prefix = if id.len() >= 2 { &id[..2] } else { id };
153    archive_base_dir().join(prefix)
154}
155
156fn content_path(id: &str) -> PathBuf {
157    entry_dir(id).join(format!("{id}.txt"))
158}
159
160fn meta_path(id: &str) -> PathBuf {
161    entry_dir(id).join(format!("{id}.meta.json"))
162}
163
164fn lock_path() -> PathBuf {
165    archive_base_dir().join(".lock")
166}
167
168fn with_archive_lock<T>(f: impl FnOnce() -> T) -> Option<T> {
169    use fs2::FileExt;
170
171    std::fs::create_dir_all(archive_base_dir()).ok()?;
172    let lock = std::fs::OpenOptions::new()
173        .read(true)
174        .write(true)
175        .create(true)
176        .truncate(false)
177        .open(lock_path())
178        .ok()?;
179    #[cfg(unix)]
180    set_private_file_perms(&lock_path());
181    lock.lock_exclusive().ok()?;
182    let result = f();
183    let _ = FileExt::unlock(&lock);
184    Some(result)
185}
186
187#[cfg(unix)]
188fn set_private_file_perms(path: &PathBuf) {
189    use std::os::unix::fs::PermissionsExt;
190    let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
191}
192
193/// The first 16 hex digits of the content's BLAKE3 hash: stable across Rust
194/// releases and builds. Releases up to 3.12.0 used `DefaultHasher`, whose
195/// output Rust does not keep stable; those ids have the same shape and stay
196/// readable, because an entry is found by its stored id. Only
197/// [`content_matches_id`] recomputes an id, and its caller (the governor's R4
198/// archive) checks the id it has just stored.
199fn compute_id(content: &str) -> String {
200    let mut id = crate::core::hasher::hash_str(content);
201    id.truncate(16);
202    id
203}
204
205pub fn is_enabled() -> bool {
206    if let Ok(v) = std::env::var("LEAN_CTX_ARCHIVE") {
207        return !matches!(v.as_str(), "0" | "false" | "off");
208    }
209    super::config::Config::load().archive.enabled
210}
211
212fn threshold_chars() -> usize {
213    if let Ok(v) = std::env::var("LEAN_CTX_ARCHIVE_THRESHOLD")
214        && let Ok(n) = v.parse::<usize>()
215    {
216        return n;
217    }
218    super::config::Config::load().archive.threshold_chars
219}
220
221fn max_age_hours() -> u64 {
222    if let Ok(v) = std::env::var("LEAN_CTX_ARCHIVE_TTL")
223        && let Ok(n) = v.parse::<u64>()
224    {
225        return n;
226    }
227    super::config::Config::load().archive_max_age_hours_effective()
228}
229
230/// Effective on-disk byte budget for archived `.txt`/`.meta.json` content,
231/// derived from `[archive] max_disk_mb` (or the simplified global `max_disk_mb`)
232/// so it matches what `doctor` reports. `0` disables the size cap; the TTL still
233/// applies. This is what bounds the archive store on disk (#417).
234fn max_disk_bytes() -> u64 {
235    super::config::Config::load()
236        .archive_max_disk_mb_effective()
237        .saturating_mul(1024 * 1024)
238}
239
240pub fn should_archive(content: &str) -> bool {
241    is_enabled() && content.len() >= threshold_chars()
242}
243
244#[cfg(not(any(test, feature = "test-support")))]
245pub const MAX_ARCHIVE_SIZE: usize = 10 * 1024 * 1024; // 10 MB
246/// Same truncation path at a size lib tests can push through the full shell
247/// pipeline: 10 MB of output held the global test env lock for ~50 s. Stays
248/// above the ~290 KB that the large-but-untruncated shell tests produce.
249/// Also for the unit tests above the Engine (`test-support`).
250#[cfg(any(test, feature = "test-support"))]
251pub const MAX_ARCHIVE_SIZE: usize = 1024 * 1024;
252const BACKGROUND_RETENTION_HOURS: i64 = 1;
253
254pub fn store(tool: &str, command: &str, content: &str, session_id: Option<&str>) -> Option<String> {
255    store_with_result(tool, command, content, session_id).map(|result| result.id)
256}
257
258#[derive(Debug, Clone, PartialEq, Eq)]
259pub struct ArchiveStoreResult {
260    pub id: String,
261    pub captured_chars: usize,
262    pub archived_chars: usize,
263    pub truncated: bool,
264}
265
266pub fn store_with_result(
267    tool: &str,
268    command: &str,
269    content: &str,
270    session_id: Option<&str>,
271) -> Option<ArchiveStoreResult> {
272    store_with_authority(tool, command, content, session_id, None)
273}
274
275pub fn store_with_authority(
276    tool: &str,
277    command: &str,
278    content: &str,
279    session_id: Option<&str>,
280    authority: Option<&authority::ArchiveAuthority>,
281) -> Option<ArchiveStoreResult> {
282    super::policy::runtime::with_source_view(|| {
283        if source_authority_required() && authority.is_none() {
284            return None;
285        }
286        if authority.is_some_and(|origin| !origin.admitted()) {
287            return None;
288        }
289        let content = super::policy::content::protect_active(content).ok()?;
290        let command = super::policy::content::protect_active(command).ok()?;
291        // Archives and their FTS rows are derived stores: only admitted text
292        // enters them, never withheld or restricted content (G5, E3). A
293        // command line carries credentials as often as its output does.
294        let content = super::context_admission::recovery::admit_for_storage(&content)?;
295        let command = super::context_admission::recovery::admit_for_storage(&command)?;
296        store_admitted(tool, &command, &content, session_id, authority)
297    })
298    .ok()
299    .flatten()
300}
301
302fn store_admitted(
303    tool: &str,
304    command: &str,
305    content: &str,
306    session_id: Option<&str>,
307    authority: Option<&authority::ArchiveAuthority>,
308) -> Option<ArchiveStoreResult> {
309    if !is_enabled() || content.is_empty() {
310        return None;
311    }
312
313    let (content, mut result) = prepared_content(content);
314    result.id = match authority {
315        Some(authority) => authority.id(content)?,
316        None => unbound_id(&current_partition()?, content),
317    };
318    let id = result.id.clone();
319    with_archive_lock(|| {
320        let c_path = content_path(&id);
321        if c_path.exists() && meta_path(&id).exists() {
322            return Some(());
323        }
324        std::fs::create_dir_all(entry_dir(&id)).ok()?;
325        if !c_path.exists() {
326            super::atomic_fs::try_atomic_write(&c_path, content.as_bytes(), None).ok()?;
327            #[cfg(unix)]
328            set_private_file_perms(&c_path);
329        }
330        let mut entry = new_entry(&id, tool, command, content, session_id, Utc::now());
331        entry.authority = authority.cloned();
332        if write_metadata(&entry).is_none() {
333            let _ = std::fs::remove_file(c_path);
334            return None;
335        }
336        Some(())
337    })
338    .flatten()?;
339    if authority.is_none() {
340        super::archive_fts::index_entry(&id, tool, command, content);
341    }
342    if authority.is_some() {
343        super::context_admission::receipt_store::references::note_recovery(&id);
344        // Lets a holder of exactly this text find its source-bound copy
345        // (`bound_recovery_id`). The pointer authorizes nothing: resolving the
346        // id re-admits every source.
347        let pointer = recovery_pointer_path(content);
348        if let Some(directory) = pointer.parent()
349            && std::fs::create_dir_all(directory).is_ok()
350        {
351            let _ = super::atomic_fs::try_atomic_write(&pointer, id.as_bytes(), None);
352        }
353    }
354    Some(result)
355}
356
357fn recovery_pointer_path(content: &str) -> PathBuf {
358    archive_base_dir()
359        .join("by-content")
360        .join(format!("{}.id", blake3::hash(content.as_bytes()).to_hex()))
361}
362
363/// Store a source-bound, revocable recovery copy of permitted content whose
364/// sources are known (G02): the copy can be handed back only while every
365/// source in `authority` is still admitted for the reader. Returns the
366/// archive id to put into a recovery handle (`ctx_expand(id="…")`), or `None`
367/// when the content may not be stored (withheld or restricted text, an
368/// incomplete authority, a source that no longer admits, archive disabled).
369///
370/// Content WITHOUT a known source has no such copy: under protection it
371/// stays raw-only (`store` / `store_background` refuse it there).
372pub fn store_recovery_copy(
373    tool: &str,
374    label: &str,
375    content: &str,
376    authority: &authority::ArchiveAuthority,
377) -> Option<String> {
378    store_with_authority(tool, label, content, None, Some(authority)).map(|stored| stored.id)
379}
380
381/// The id of the source-bound recovery copy that holds exactly `content`, if
382/// one was stored (by `store_recovery_copy`, or by the MCP pipeline for a tool
383/// that declares its sources). A lookup only: nothing is authorized here.
384/// Resolve the id with `retrieve_checked`, inside the request's project
385/// scope — that re-admits every source under the current policy and role and
386/// refuses with a typed, content-free reason when rights are gone.
387#[must_use]
388pub fn bound_recovery_id(content: &str) -> Option<String> {
389    let (content, _) = prepared_content(content);
390    let pointer = recovery_pointer_path(content);
391    let id = super::atomic_fs::read_store_file(&archive_base_dir(), &pointer).ok()?;
392    let id = id.trim();
393    (id.len() == 64
394        && id
395            .bytes()
396            .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
397        && read_entry(id).is_some_and(|entry| entry.authority.is_some()))
398    .then(|| id.to_owned())
399}
400
401/// Persist a terminal background-shell result and guarantee that lean-ctx's
402/// managed cleanup paths retain it for at least one hour.
403pub fn store_background(
404    tool: &str,
405    job_id: &str,
406    content: &str,
407    session_id: Option<&str>,
408) -> Option<ArchiveStoreResult> {
409    super::policy::runtime::with_source_view(|| {
410        store_background_with_limits(
411            tool,
412            job_id,
413            content,
414            session_id,
415            Utc::now(),
416            max_disk_bytes(),
417        )
418    })
419    .ok()
420    .flatten()
421}
422
423fn store_background_with_limits(
424    tool: &str,
425    job_id: &str,
426    content: &str,
427    session_id: Option<&str>,
428    now: DateTime<Utc>,
429    budget_bytes: u64,
430) -> Option<ArchiveStoreResult> {
431    // Command output has no complete source binding yet. Keep fresh checked
432    // output usable, but do not publish a protected recovery handle for it.
433    if source_authority_required() {
434        return None;
435    }
436    if !is_enabled() || content.is_empty() {
437        return None;
438    }
439    // Derived store: only admitted text (G5, E3).
440    let content = super::context_admission::recovery::admit_for_storage(content)?;
441
442    let (content, mut result) = prepared_content(&content);
443    result.id = unbound_id(&current_partition()?, content);
444    let id = result.id.clone();
445    let protected_until = now + chrono::Duration::hours(BACKGROUND_RETENTION_HOURS);
446    let (stored, evicted) = with_archive_lock(|| {
447        let mut entry = read_entry(&id)
448            .unwrap_or_else(|| new_entry(&id, tool, job_id, content, session_id, now));
449        if !entry.aliases.iter().any(|alias| alias == job_id) {
450            entry.aliases.push(job_id.to_string());
451        }
452        entry.protected_until = Some(
453            entry
454                .protected_until
455                .map_or(protected_until, |current| current.max(protected_until)),
456        );
457
458        let metadata = serde_json::to_string_pretty(&entry).ok()?;
459        let existing_bytes = scanned_entries()
460            .into_iter()
461            .find(|candidate| candidate.id == id)
462            .map_or(0, |candidate| candidate.bytes);
463        let required_bytes = content.len() as u64 + metadata.len() as u64;
464        let evicted = admit_locked(&id, existing_bytes, required_bytes, budget_bytes, now)?;
465
466        let c_path = content_path(&id);
467        let content_existed = c_path.exists();
468        if std::fs::create_dir_all(entry_dir(&id)).is_err() {
469            return Some((false, evicted));
470        }
471        if !content_existed {
472            if super::atomic_fs::try_atomic_write(&c_path, content.as_bytes(), None).is_err() {
473                return Some((false, evicted));
474            }
475            #[cfg(unix)]
476            set_private_file_perms(&c_path);
477        }
478        if super::atomic_fs::try_atomic_write(&meta_path(&id), metadata.as_bytes(), None).is_err() {
479            if !content_existed {
480                let _ = std::fs::remove_file(c_path);
481            }
482            return Some((false, evicted));
483        }
484        #[cfg(unix)]
485        set_private_file_perms(&meta_path(&id));
486        Some((true, evicted))
487    })
488    .flatten()?;
489
490    for evicted_id in evicted {
491        super::archive_fts::remove_entry(&evicted_id);
492    }
493    // A content-addressed archive may have been indexed by an earlier generic
494    // store. Protected background output must not remain on the FTS eviction path.
495    super::archive_fts::remove_entry(&id);
496    stored.then_some(result)
497}
498
499fn prepared_content(content: &str) -> (&str, ArchiveStoreResult) {
500    let captured_chars = content.chars().count();
501    let truncated = content.len() > MAX_ARCHIVE_SIZE;
502    let content = if truncated {
503        &content[..content.floor_char_boundary(MAX_ARCHIVE_SIZE)]
504    } else {
505        content
506    };
507    let id = compute_id(content);
508    let result = ArchiveStoreResult {
509        id,
510        captured_chars,
511        archived_chars: content.chars().count(),
512        truncated,
513    };
514    (content, result)
515}
516
517fn new_entry(
518    id: &str,
519    tool: &str,
520    command: &str,
521    content: &str,
522    session_id: Option<&str>,
523    created_at: DateTime<Utc>,
524) -> ArchiveEntry {
525    ArchiveEntry {
526        authority: None,
527        id: id.to_string(),
528        tool: tool.to_string(),
529        command: command.to_string(),
530        size_chars: content.len(),
531        size_tokens: super::tokens::count_tokens(content),
532        created_at,
533        session_id: session_id.map(std::string::ToString::to_string),
534        protected_until: None,
535        aliases: Vec::new(),
536        lineage: None,
537        projects: current_partition().into_iter().collect(),
538        project_root: super::policy::diagnostics::request_project()
539            .map(|root| root.to_string_lossy().into_owned()),
540    }
541}
542
543/// The tool whose output archive `id` holds (engine metrics K10).
544pub fn tool_of(id: &str) -> Option<String> {
545    read_entry(id).map(|entry| entry.tool)
546}
547
548fn read_entry(id: &str) -> Option<ArchiveEntry> {
549    serde_json::from_str(&std::fs::read_to_string(meta_path(id)).ok()?).ok()
550}
551
552fn write_metadata(entry: &ArchiveEntry) -> Option<()> {
553    let path = meta_path(&entry.id);
554    let json = serde_json::to_string_pretty(entry).ok()?;
555    super::atomic_fs::try_atomic_write(&path, json.as_bytes(), None).ok()?;
556    #[cfg(unix)]
557    set_private_file_perms(&path);
558    Some(())
559}
560
561#[derive(Debug, Clone, Copy, PartialEq, Eq)]
562pub enum ArchiveResolveError {
563    Malformed,
564    Missing,
565    Refused(&'static str),
566}
567
568/// Resolve a canonical archive ID under the current policy: untrusted handles
569/// never supply path components, the read never leaves the store, and
570/// source-bound archives are re-admitted against their source.
571pub fn retrieve_checked(id: &str) -> Result<String, ArchiveResolveError> {
572    super::policy::runtime::with_source_view(|| {
573        retrieve_admitted_budgeted(id, &mut crate::core::limits::max_read_bytes())
574    })
575    .unwrap_or(Err(ArchiveResolveError::Refused(
576        "current policy cannot be verified",
577    )))
578}
579
580pub fn retrieve(id: &str) -> Option<String> {
581    retrieve_checked(id).ok()
582}
583
584fn retrieve_admitted(id: &str) -> Option<String> {
585    retrieve_admitted_budgeted(id, &mut crate::core::limits::max_read_bytes()).ok()
586}
587
588/// Check the content-address contract used when the archive ID was minted:
589/// a public archive is addressed by its content, a source-bound one by its
590/// authority and content.
591pub fn content_matches_id(id: &str, content: &str) -> bool {
592    match read_entry(id).and_then(|entry| entry.authority) {
593        Some(origin) => origin.id(content).as_deref() == Some(id),
594        None => match read_entry(id).and_then(|entry| entry.projects.first().cloned()) {
595            Some(partition) => unbound_id(&partition, content) == id,
596            None => compute_id(content) == id,
597        },
598    }
599}
600
601/// [`retrieve_checked`] charging source re-admission against `source_bytes`.
602fn retrieve_admitted_budgeted(
603    id: &str,
604    source_bytes: &mut usize,
605) -> Result<String, ArchiveResolveError> {
606    // Only canonical IDs: 16 hex for public archives, 64 for source-bound
607    // ones. An untrusted handle can never supply path components.
608    if !matches!(id.len(), 16 | 64)
609        || !id
610            .bytes()
611            .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
612    {
613        return Err(ArchiveResolveError::Malformed);
614    }
615    let entry = read_entry(id);
616    let authority = entry.as_ref().and_then(|entry| entry.authority.as_ref());
617    if authority.is_none() && (id.len() == 64 || source_authority_required()) {
618        return Err(ArchiveResolveError::Refused(
619            "stored output has no source authority under the active policy",
620        ));
621    }
622    // Another project's output, or one stored before archives named their
623    // project, does not exist for this request.
624    if authority.is_none() && !entry.as_ref().is_some_and(in_current_partition) {
625        // A legacy archive whose recorded session proves this project is
626        // copied into it once and served as that copy.
627        if let Some(adopted) = super::legacy_adoption::adopt_provable_archive(id)
628            && adopted != id
629        {
630            return retrieve_admitted_budgeted(&adopted, source_bytes);
631        }
632        return Err(ArchiveResolveError::Missing);
633    }
634    if authority.is_some_and(|origin| !origin.admitted_budgeted(source_bytes)) {
635        return Err(ArchiveResolveError::Refused(
636            "the archive's source is no longer admitted",
637        ));
638    }
639    // Archive content reaches the model through ctx_expand: never follow a link
640    // planted in the store to a file outside it.
641    let content = super::atomic_fs::read_store_file(&archive_base_dir(), &content_path(id))
642        .map_err(|error| match error {
643            super::atomic_fs::StoreReadError::Escapes => {
644                ArchiveResolveError::Refused("archive entry resolves outside the archive store")
645            }
646            super::atomic_fs::StoreReadError::Io(error) => match error.kind() {
647                std::io::ErrorKind::PermissionDenied => {
648                    ArchiveResolveError::Refused("archive read refused by filesystem policy")
649                }
650                std::io::ErrorKind::InvalidData => ArchiveResolveError::Malformed,
651                _ => ArchiveResolveError::Missing,
652            },
653        })?;
654    if content.len() > MAX_ARCHIVE_SIZE {
655        return Err(ArchiveResolveError::Refused(
656            "archive entry exceeds the archive size bound",
657        ));
658    }
659    if authority.is_some_and(|origin| origin.id(&content).as_deref() != Some(id)) {
660        return Err(ArchiveResolveError::Refused(
661            "archive content no longer matches its source-bound address",
662        ));
663    }
664    let released = super::policy::content::protect_active(&content)
665        .map(std::borrow::Cow::into_owned)
666        .map_err(|_| {
667            ArchiveResolveError::Refused("stored output is withheld by current content rules")
668        })?;
669    // A resolved source-bound copy leaves towards the model again: register
670    // the released text with the lineage that was re-admitted just above.
671    if let Some(origin) = authority {
672        delivered::record_verified(&delivered::digest(&released), origin);
673    }
674    Ok(released)
675}
676
677/// Format a range of lines from content with `{:>6}|` line-number gutter.
678/// Shared by `retrieve_with_range` (archive) and `expand_reference` (ref store).
679pub fn format_range(content: &str, start: usize, end: usize) -> String {
680    let lines: Vec<&str> = content.lines().collect();
681    let start = start.saturating_sub(1).min(lines.len());
682    let end = end.min(lines.len());
683    if start >= end {
684        return String::new();
685    }
686    lines[start..end]
687        .iter()
688        .enumerate()
689        .map(|(i, line)| format!("{:>6}|{line}", start + i + 1))
690        .collect::<Vec<_>>()
691        .join("\n")
692}
693
694/// Search content for lines matching `pattern` (case-insensitive) and return
695/// gutter-prefixed matches. `label` appears in the result message (e.g. "archive
696/// a1966..." or "reference ref_18bb..."). Shared by archive and ref store paths.
697pub fn format_search(content: &str, pattern: &str, label: &str) -> String {
698    let pattern_lower = pattern.to_lowercase();
699    let matches: Vec<String> = content
700        .lines()
701        .enumerate()
702        .filter(|(_, line)| line.to_lowercase().contains(&pattern_lower))
703        .map(|(i, line)| format!("{:>6}|{line}", i + 1))
704        .collect();
705    if matches.is_empty() {
706        format!("No matches for \"{pattern}\" in {label}")
707    } else {
708        format!(
709            "{} match(es) for \"{}\":\n{}",
710            matches.len(),
711            pattern,
712            matches.join("\n")
713        )
714    }
715}
716
717/// Describe JSON structure: navigate `path` (dot/slash separated) into parsed
718/// content, then format with `describe_json`. `label` appears in error messages.
719/// Shared by archive and ref store paths.
720pub fn format_json_keys(content: &str, path: Option<&str>, label: &str) -> Option<String> {
721    let root: serde_json::Value = serde_json::from_str(content.trim()).ok()?;
722    let mut cur = &root;
723    let mut walked = String::from("$");
724    if let Some(p) = path {
725        for seg in p.split(['.', '/']).filter(|s| !s.is_empty()) {
726            let next = if let Ok(idx) = seg.parse::<usize>() {
727                cur.get(idx)
728            } else {
729                cur.get(seg)
730            };
731            match next {
732                Some(v) => {
733                    cur = v;
734                    walked.push('.');
735                    walked.push_str(seg);
736                }
737                None => {
738                    return Some(format!("Path '{p}' not found at '{walked}' in {label}"));
739                }
740            }
741        }
742    }
743    Some(format!("{walked} => {}", describe_json(cur)))
744}
745
746pub fn retrieve_with_range(id: &str, start: usize, end: usize) -> Option<String> {
747    let content = retrieve(id)?;
748    Some(format_range(&content, start, end))
749}
750
751pub fn retrieve_with_search(id: &str, pattern: &str) -> Option<String> {
752    let content = retrieve(id)?;
753    Some(format_search(&content, pattern, &format!("archive {id}")))
754}
755
756/// Retrieve the first `n` lines of an archived entry, with a line-number gutter.
757pub fn retrieve_head(id: &str, n: usize) -> Option<String> {
758    retrieve_with_range(id, 1, n)
759}
760
761/// Retrieve the last `n` lines of an archived entry, with a line-number gutter.
762pub fn retrieve_tail(id: &str, n: usize) -> Option<String> {
763    let content = retrieve(id)?;
764    let total = content.lines().count();
765    let start = if total > n { total - n + 1 } else { 1 };
766    retrieve_with_range(id, start, total)
767}
768
769/// Describe the JSON structure of an archived entry.
770pub fn retrieve_json_keys(id: &str, path: Option<&str>) -> Option<String> {
771    let content = retrieve(id)?;
772    format_json_keys(&content, path, &format!("archive {id}"))
773}
774
775pub(crate) fn json_type_hint(v: &serde_json::Value) -> String {
776    use serde_json::Value;
777    match v {
778        Value::Object(m) => format!("object({})", m.len()),
779        Value::Array(a) => format!("array({})", a.len()),
780        Value::String(s) => {
781            let preview: String = s.chars().take(40).collect();
782            if s.chars().count() > 40 {
783                format!("string \"{preview}…\"")
784            } else {
785                format!("string \"{preview}\"")
786            }
787        }
788        Value::Number(n) => format!("number {n}"),
789        Value::Bool(b) => format!("bool {b}"),
790        Value::Null => "null".to_string(),
791    }
792}
793
794pub(crate) fn describe_json(v: &serde_json::Value) -> String {
795    use serde_json::Value;
796    match v {
797        Value::Object(map) => {
798            let mut keys: Vec<&String> = map.keys().collect();
799            keys.sort();
800            let rendered: Vec<String> = keys
801                .iter()
802                .map(|k| format!("  {k}: {}", json_type_hint(&map[*k])))
803                .collect();
804            format!("object ({} keys)\n{}", map.len(), rendered.join("\n"))
805        }
806        Value::Array(arr) => {
807            let elem = arr.first().map_or("empty", |e| match e {
808                Value::Object(_) => "object",
809                Value::Array(_) => "array",
810                Value::String(_) => "string",
811                Value::Number(_) => "number",
812                Value::Bool(_) => "bool",
813                Value::Null => "null",
814            });
815            let mut out = format!("array ({} items of {elem})", arr.len());
816            if let Some(Value::Object(map)) = arr.first() {
817                let mut keys: Vec<&String> = map.keys().collect();
818                keys.sort();
819                out.push_str(&format!(
820                    "\n  [0] keys: {}",
821                    keys.iter()
822                        .map(|s| s.as_str())
823                        .collect::<Vec<_>>()
824                        .join(", ")
825                ));
826            }
827            out
828        }
829        Value::String(s) => format!("string ({} chars)", s.len()),
830        Value::Number(n) => format!("number ({n})"),
831        Value::Bool(b) => format!("bool ({b})"),
832        Value::Null => "null".to_string(),
833    }
834}
835
836pub fn list_entries(session_id: Option<&str>) -> Vec<ArchiveEntry> {
837    super::legacy_adoption::auto_adopt_archives();
838    super::policy::runtime::with_source_view(|| {
839        let mut entries: Vec<_> = admitted_entries(session_id)
840            .into_iter()
841            .map(|(entry, _)| entry)
842            .collect();
843        if !source_authority_required() {
844            entries.extend(list_entries_raw(session_id).into_iter().filter(|entry| {
845                entry.authority.is_none() && entry.id.len() != 64 && in_current_partition(entry)
846            }));
847            entries.sort_by_key(|entry| std::cmp::Reverse(entry.created_at));
848        }
849        entries
850    })
851    .unwrap_or_default()
852}
853
854/// Source-bound discovery only. Legacy Community archives use their existing
855/// metadata/index paths and do not consume this reauthorization budget.
856pub(crate) fn admitted_entries(session_id: Option<&str>) -> Vec<(ArchiveEntry, String)> {
857    super::policy::runtime::with_source_view(|| {
858        // Bound candidates, not only matches: stale newest entries may consume
859        // the window. An older known ID or alias can still be retrieved directly.
860        let mut remaining = super::policy::content::MAX_PROTECTED_CONTENT_BYTES;
861        let mut source_bytes = super::policy::content::MAX_PROTECTED_CONTENT_BYTES;
862        list_entries_raw(session_id)
863            .into_iter()
864            .filter(|entry| match &entry.authority {
865                Some(origin) => origin.matches_project(),
866                None => false,
867            })
868            .take(128)
869            .filter_map(|mut entry| {
870                if remaining == 0 {
871                    return None;
872                }
873                let content = retrieve_admitted_budgeted(&entry.id, &mut source_bytes).ok()?;
874                if content.len() > remaining {
875                    return None;
876                }
877                remaining -= content.len();
878                entry.command = super::policy::content::protect_active(&entry.command)
879                    .ok()?
880                    .into_owned();
881                entry.tool = super::policy::content::protect_active(&entry.tool)
882                    .ok()?
883                    .into_owned();
884                Some((entry, content))
885            })
886            .collect()
887    })
888    .unwrap_or_default()
889}
890
891fn list_entries_raw(session_id: Option<&str>) -> Vec<ArchiveEntry> {
892    let base = archive_base_dir();
893    if !base.exists() {
894        return Vec::new();
895    }
896    let mut entries = Vec::new();
897    if let Ok(dirs) = std::fs::read_dir(&base) {
898        for dir_entry in dirs.flatten() {
899            if !dir_entry.path().is_dir() {
900                continue;
901            }
902            if let Ok(files) = std::fs::read_dir(dir_entry.path()) {
903                for file in files.flatten() {
904                    let path = file.path();
905                    if path.extension().and_then(|e| e.to_str()) != Some("json") {
906                        continue;
907                    }
908                    if let Ok(data) = std::fs::read_to_string(&path)
909                        && let Ok(entry) = serde_json::from_str::<ArchiveEntry>(&data)
910                    {
911                        if let Some(sid) = session_id
912                            && entry.session_id.as_deref() != Some(sid)
913                        {
914                            continue;
915                        }
916                        entries.push(entry);
917                    }
918                }
919            }
920        }
921    }
922    entries.sort_by_key(|e| std::cmp::Reverse(e.created_at));
923    entries
924}
925
926pub fn resolve_alias(alias: &str) -> Option<String> {
927    super::policy::runtime::with_source_view(|| {
928        let entry = list_entries_raw(None)
929            .into_iter()
930            .find(|entry| entry.aliases.iter().any(|candidate| candidate == alias))?;
931        retrieve_admitted(&entry.id)?;
932        Some(entry.id)
933    })
934    .ok()
935    .flatten()
936}
937
938pub(crate) fn is_protected(id: &str) -> bool {
939    read_entry(id)
940        .and_then(|entry| entry.protected_until)
941        .is_some_and(|until| until > Utc::now())
942}
943
944/// Remove only the on-disk content + metadata files for an archive id, leaving
945/// the FTS index untouched. Used by the FTS cap-enforcer so the `.txt`/`.meta.json`
946/// blobs of rows it evicts can't outlive their index entry as orphans (#417).
947pub fn remove_files(id: &str) {
948    let _ = with_archive_lock(|| {
949        if !is_protected(id) {
950            remove_files_locked(id);
951        }
952    });
953}
954
955fn remove_files_locked(id: &str) {
956    let _ = std::fs::remove_file(content_path(id));
957    let _ = std::fs::remove_file(meta_path(id));
958}
959
960struct ScannedArchive {
961    id: String,
962    created_at: DateTime<Utc>,
963    protected_until: Option<DateTime<Utc>>,
964    bytes: u64,
965}
966
967fn scanned_entries() -> Vec<ScannedArchive> {
968    list_entries_raw(None)
969        .into_iter()
970        .map(|entry| {
971            let content_bytes = std::fs::metadata(content_path(&entry.id)).map_or(0, |m| m.len());
972            let metadata_bytes = std::fs::metadata(meta_path(&entry.id)).map_or(0, |m| m.len());
973            ScannedArchive {
974                id: entry.id,
975                created_at: entry.created_at,
976                protected_until: entry.protected_until,
977                bytes: content_bytes + metadata_bytes,
978            }
979        })
980        .collect()
981}
982
983fn protected_at(entry: &ScannedArchive, now: DateTime<Utc>) -> bool {
984    entry.protected_until.is_some_and(|until| until > now)
985}
986
987fn admit_locked(
988    new_id: &str,
989    existing_bytes: u64,
990    required_bytes: u64,
991    budget_bytes: u64,
992    now: DateTime<Utc>,
993) -> Option<Vec<String>> {
994    if budget_bytes == 0 {
995        return Some(Vec::new());
996    }
997
998    let mut entries = scanned_entries();
999    let live_bytes: u64 = entries.iter().map(|entry| entry.bytes).sum();
1000    let mut projected = live_bytes
1001        .saturating_sub(existing_bytes)
1002        .saturating_add(required_bytes);
1003    if projected <= budget_bytes {
1004        return Some(Vec::new());
1005    }
1006
1007    entries.sort_by_key(|entry| entry.created_at);
1008    let candidates: Vec<_> = entries
1009        .into_iter()
1010        .filter(|entry| entry.id != new_id && !protected_at(entry, now))
1011        .collect();
1012    let reclaimable: u64 = candidates.iter().map(|entry| entry.bytes).sum();
1013    if projected.saturating_sub(reclaimable) > budget_bytes {
1014        return None;
1015    }
1016
1017    let mut evicted = Vec::new();
1018    for entry in candidates {
1019        remove_files_locked(&entry.id);
1020        projected = projected.saturating_sub(entry.bytes);
1021        evicted.push(entry.id);
1022        if projected <= budget_bytes {
1023            break;
1024        }
1025    }
1026    Some(evicted)
1027}
1028
1029/// Prune archived entries that exceed the age TTL (`max_age_hours`) or that push
1030/// the on-disk store past its size budget (`max_disk_mb`). The content file,
1031/// metadata, and FTS index are removed together so the two stores stay in sync.
1032/// Returns the number of entries removed.
1033///
1034/// Wired into MCP-start + periodic maintenance (`super::storage_maintenance`)
1035/// and `lean-ctx cache prune`; without an enforcer the archive grew unbounded on
1036/// disk and starved the host of RAM via the page cache (#417).
1037pub fn cleanup() -> u32 {
1038    let now = Utc::now();
1039    let cutoff = now - chrono::Duration::hours(max_age_hours() as i64);
1040    cleanup_with(cutoff, max_disk_bytes(), now)
1041}
1042
1043/// Core of [`cleanup`], parameterized for testing: drop entries older than
1044/// `cutoff`, then evict the oldest survivors until the total on-disk footprint is
1045/// at or below `budget_bytes` (`0` = no size cap).
1046fn cleanup_with(cutoff: DateTime<Utc>, budget_bytes: u64, now: DateTime<Utc>) -> u32 {
1047    let base = archive_base_dir();
1048    if !base.exists() {
1049        return 0;
1050    }
1051
1052    let removed = with_archive_lock(|| {
1053        let mut entries = scanned_entries();
1054        entries.sort_by_key(|entry| entry.created_at);
1055        let mut live_bytes: u64 = entries.iter().map(|entry| entry.bytes).sum();
1056        let mut removed = std::collections::HashSet::new();
1057
1058        for entry in &entries {
1059            if entry.created_at < cutoff && !protected_at(entry, now) {
1060                remove_files_locked(&entry.id);
1061                live_bytes = live_bytes.saturating_sub(entry.bytes);
1062                removed.insert(entry.id.clone());
1063            }
1064        }
1065
1066        if budget_bytes > 0 {
1067            for entry in &entries {
1068                if live_bytes <= budget_bytes {
1069                    break;
1070                }
1071                if !removed.contains(&entry.id) && !protected_at(entry, now) {
1072                    remove_files_locked(&entry.id);
1073                    live_bytes = live_bytes.saturating_sub(entry.bytes);
1074                    removed.insert(entry.id.clone());
1075                }
1076            }
1077        }
1078        removed
1079    })
1080    .unwrap_or_default();
1081    for id in &removed {
1082        super::archive_fts::remove_entry(id);
1083    }
1084    removed.len() as u32
1085}
1086
1087pub fn disk_usage_bytes() -> u64 {
1088    let base = archive_base_dir();
1089    if !base.exists() {
1090        return 0;
1091    }
1092    let mut total = 0u64;
1093    if let Ok(dirs) = std::fs::read_dir(&base) {
1094        for dir_entry in dirs.flatten() {
1095            if let Ok(files) = std::fs::read_dir(dir_entry.path()) {
1096                for file in files.flatten() {
1097                    total += file.metadata().map_or(0, |m| m.len());
1098                }
1099            }
1100        }
1101    }
1102    total
1103}
1104
1105/// Filesystem path of an archived entry's verbatim content. Exposed so recovery
1106/// hints can offer the MCP-free "read this file directly" route alongside
1107/// `ctx_expand(id=...)` — the same content is reachable both ways.
1108pub fn content_path_str(id: &str) -> String {
1109    content_path(id).to_string_lossy().into_owned()
1110}
1111
1112pub fn format_hint(id: &str, size_chars: usize, size_tokens: usize) -> String {
1113    // Unified, non-MCP-first recovery grammar (see [`crate::core::recovery`]): the
1114    // archived blob is a real file readable with any tool, and `ctx_expand(id)`
1115    // reaches the same bytes for surgical slices.
1116    let clause = crate::core::recovery::handle_clause(id, Some(&content_path_str(id)));
1117    format!("[Archived: {size_chars} chars ({size_tokens} tok). {clause}]")
1118}
1119
1120#[cfg(test)]
1121mod tests {
1122    use super::*;
1123
1124    #[test]
1125    fn compute_id_deterministic() {
1126        let id1 = compute_id("test content");
1127        let id2 = compute_id("test content");
1128        assert_eq!(id1, id2);
1129        let id3 = compute_id("different content");
1130        assert_ne!(id1, id3);
1131        // A fixed vector: the id must not change with the Rust release.
1132        assert_eq!(id1, "ead3df8af4aece77");
1133    }
1134
1135    #[test]
1136    fn nonexistent_id_returns_none() {
1137        assert!(retrieve("nonexistent_archive_id_xyz").is_none());
1138    }
1139
1140    #[test]
1141    fn format_hint_readable() {
1142        let hint = format_hint("abc123", 5000, 1200);
1143        assert!(hint.contains("5000 chars"));
1144        assert!(hint.contains("1200 tok"));
1145        assert!(hint.contains("ctx_expand"));
1146        assert!(hint.contains("abc123"));
1147    }
1148
1149    fn write_test_entry(id: &str, created_at: DateTime<Utc>, content_bytes: usize) {
1150        std::fs::create_dir_all(entry_dir(id)).unwrap();
1151        std::fs::write(content_path(id), "x".repeat(content_bytes)).unwrap();
1152        let entry = ArchiveEntry {
1153            authority: None,
1154            id: id.to_string(),
1155            tool: "ctx_shell".to_string(),
1156            command: "test".to_string(),
1157            size_chars: content_bytes,
1158            size_tokens: content_bytes / 4,
1159            created_at,
1160            session_id: None,
1161            protected_until: None,
1162            aliases: Vec::new(),
1163            lineage: None,
1164            projects: current_partition().into_iter().collect(),
1165            project_root: None,
1166        };
1167        std::fs::write(meta_path(id), serde_json::to_string(&entry).unwrap()).unwrap();
1168    }
1169
1170    #[test]
1171    fn cleanup_removes_expired_keeps_fresh() {
1172        let _lock = crate::core::data_dir::test_env_lock();
1173        let tmp = tempfile::tempdir().unwrap();
1174        crate::test_env::set_var("LEAN_CTX_DATA_DIR", tmp.path());
1175
1176        let now = Utc::now();
1177        write_test_entry("aa_old", now - chrono::Duration::hours(100), 100);
1178        write_test_entry("bb_new", now - chrono::Duration::hours(1), 100);
1179
1180        // Cutoff = 48h ago; budget effectively unlimited so only the TTL applies.
1181        let removed = cleanup_with(now - chrono::Duration::hours(48), u64::MAX, now);
1182        assert_eq!(removed, 1);
1183        assert!(!content_path("aa_old").exists());
1184        assert!(!meta_path("aa_old").exists());
1185        assert!(content_path("bb_new").exists());
1186
1187        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
1188    }
1189
1190    #[test]
1191    fn cleanup_enforces_disk_budget_oldest_first() {
1192        let _lock = crate::core::data_dir::test_env_lock();
1193        let tmp = tempfile::tempdir().unwrap();
1194        crate::test_env::set_var("LEAN_CTX_DATA_DIR", tmp.path());
1195
1196        let now = Utc::now();
1197        write_test_entry("c1_oldest", now - chrono::Duration::minutes(30), 10_000);
1198        write_test_entry("c2_middle", now - chrono::Duration::minutes(20), 10_000);
1199        write_test_entry("c3_newest", now - chrono::Duration::minutes(10), 10_000);
1200
1201        // Nothing expired (cutoff far in the past). Budget 25 KB holds the two
1202        // newest (~20 KB content + meta); the single oldest entry is evicted.
1203        let removed = cleanup_with(now - chrono::Duration::days(365), 25_000, now);
1204        assert_eq!(removed, 1, "only the oldest over-budget entry is evicted");
1205        assert!(!content_path("c1_oldest").exists());
1206        assert!(content_path("c2_middle").exists());
1207        assert!(content_path("c3_newest").exists());
1208
1209        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
1210    }
1211
1212    #[test]
1213    fn legacy_metadata_defaults_background_retention_fields() {
1214        let entry: ArchiveEntry = serde_json::from_value(serde_json::json!({
1215            "id": "legacy",
1216            "tool": "ctx_shell",
1217            "command": "printf legacy",
1218            "size_chars": 6,
1219            "size_tokens": 2,
1220            "created_at": "2026-01-01T00:00:00Z",
1221            "session_id": null
1222        }))
1223        .unwrap();
1224
1225        assert!(entry.protected_until.is_none());
1226        assert!(entry.aliases.is_empty(), "{:?}", entry.aliases);
1227    }
1228
1229    #[test]
1230    fn protected_archive_survives_cleanup_and_direct_fts_removal_path() {
1231        let _lock = crate::core::data_dir::test_env_lock();
1232        let tmp = tempfile::tempdir().unwrap();
1233        crate::test_env::set_var("LEAN_CTX_DATA_DIR", tmp.path());
1234
1235        let now = Utc::now();
1236        write_test_entry("protected", now - chrono::Duration::hours(100), 100);
1237        let mut entry = read_entry("protected").unwrap();
1238        entry.protected_until = Some(now + chrono::Duration::hours(1));
1239        write_metadata(&entry).unwrap();
1240
1241        assert_eq!(cleanup_with(now - chrono::Duration::hours(48), 1, now), 0);
1242        remove_files("protected");
1243        assert!(content_path("protected").exists());
1244
1245        let later = now + chrono::Duration::hours(2);
1246        assert_eq!(cleanup_with(now, 1, later), 1);
1247        assert!(!content_path("protected").exists());
1248
1249        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
1250    }
1251
1252    /// Unbound archives belong to the request project (AP05-G4).
1253    fn in_project<T>(project: &std::path::Path, f: impl FnOnce() -> T) -> T {
1254        crate::core::policy::runtime::REQUEST_PROJECT
1255            .sync_scope(std::cell::RefCell::new(Some(project.to_path_buf())), f)
1256    }
1257
1258    #[test]
1259    fn background_store_refuses_when_protected_entries_fill_budget() {
1260        let _lock = crate::core::data_dir::test_env_lock();
1261        let tmp = tempfile::tempdir().unwrap();
1262        crate::test_env::set_var("LEAN_CTX_DATA_DIR", tmp.path());
1263        crate::test_env::set_var("LEAN_CTX_ARCHIVE", "1");
1264        let project = tmp.path().join("project");
1265        std::fs::create_dir_all(&project).unwrap();
1266        in_project(&project, || {
1267            let now = Utc::now();
1268            let first = store_background_with_limits(
1269                "ctx_shell",
1270                "shell_first",
1271                "first protected output",
1272                None,
1273                now,
1274                u64::MAX,
1275            )
1276            .unwrap();
1277            let budget = scanned_entries()
1278                .into_iter()
1279                .find(|entry| entry.id == first.id)
1280                .unwrap()
1281                .bytes;
1282
1283            assert!(
1284                store_background_with_limits(
1285                    "ctx_shell",
1286                    "shell_second",
1287                    "different protected output",
1288                    None,
1289                    now,
1290                    budget,
1291                )
1292                .is_none()
1293            );
1294            assert_eq!(resolve_alias("shell_first"), Some(first.id));
1295            assert!(resolve_alias("shell_second").is_none());
1296        });
1297
1298        crate::test_env::remove_var("LEAN_CTX_ARCHIVE");
1299        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
1300    }
1301
1302    #[test]
1303    fn concurrent_background_writers_merge_content_aliases() {
1304        let _lock = crate::core::data_dir::test_env_lock();
1305        let tmp = tempfile::tempdir().unwrap();
1306        crate::test_env::set_var("LEAN_CTX_DATA_DIR", tmp.path());
1307        crate::test_env::set_var("LEAN_CTX_ARCHIVE", "1");
1308
1309        let project = tmp.path().join("project");
1310        std::fs::create_dir_all(&project).unwrap();
1311        let barrier = std::sync::Arc::new(std::sync::Barrier::new(3));
1312        let mut writers = Vec::new();
1313        for job_id in ["shell_alpha", "shell_beta"] {
1314            let barrier = barrier.clone();
1315            let project = project.clone();
1316            writers.push(std::thread::spawn(move || {
1317                barrier.wait();
1318                in_project(&project, || {
1319                    store_background_with_limits(
1320                        "ctx_shell",
1321                        job_id,
1322                        "shared terminal output",
1323                        None,
1324                        Utc::now(),
1325                        0,
1326                    )
1327                })
1328                .unwrap()
1329            }));
1330        }
1331        barrier.wait();
1332        let first = writers.remove(0).join().unwrap();
1333        let second = writers.remove(0).join().unwrap();
1334
1335        assert_eq!(first.id, second.id);
1336        in_project(&project, || {
1337            assert_eq!(resolve_alias("shell_alpha"), Some(first.id.clone()));
1338            assert_eq!(resolve_alias("shell_beta"), Some(first.id));
1339        });
1340
1341        crate::test_env::remove_var("LEAN_CTX_ARCHIVE");
1342        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
1343    }
1344}