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
19use 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 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub(crate) lineage: Option<delivered::Lineage>,
48 #[serde(default, skip_serializing_if = "Vec::is_empty")]
55 pub projects: Vec<String>,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub project_root: Option<String>,
62}
63
64pub(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
72fn 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
82fn in_current_partition(entry: &ArchiveEntry) -> bool {
84 current_partition().is_some_and(|partition| entry.projects.contains(&partition))
85}
86
87pub 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
102pub 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
112pub(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
119pub 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
193fn 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
230fn 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; #[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 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(¤t_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 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
363pub 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#[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
401pub 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 if source_authority_required() {
434 return None;
435 }
436 if !is_enabled() || content.is_empty() {
437 return None;
438 }
439 let content = super::context_admission::recovery::admit_for_storage(content)?;
441
442 let (content, mut result) = prepared_content(&content);
443 result.id = unbound_id(¤t_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 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
543pub 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
568pub 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
588pub 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
601fn retrieve_admitted_budgeted(
603 id: &str,
604 source_bytes: &mut usize,
605) -> Result<String, ArchiveResolveError> {
606 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 if authority.is_none() && !entry.as_ref().is_some_and(in_current_partition) {
625 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 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 if let Some(origin) = authority {
672 delivered::record_verified(&delivered::digest(&released), origin);
673 }
674 Ok(released)
675}
676
677pub 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
694pub 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
717pub 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
756pub fn retrieve_head(id: &str, n: usize) -> Option<String> {
758 retrieve_with_range(id, 1, n)
759}
760
761pub 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
769pub 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
854pub(crate) fn admitted_entries(session_id: Option<&str>) -> Vec<(ArchiveEntry, String)> {
857 super::policy::runtime::with_source_view(|| {
858 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
944pub 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
1029pub 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
1043fn 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
1105pub 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 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 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 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 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 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}