1use std::fs;
21use std::io;
22use std::path::{Path, PathBuf};
23
24use serde::Serialize;
25
26#[derive(Debug)]
28pub enum RetentionError {
29 Io(io::Error),
30 UnreadableChain(PathBuf),
33 BudgetUnmet {
36 budget: u64,
37 remaining: u64,
38 protected_chain_heads: usize,
39 },
40}
41
42impl std::fmt::Display for RetentionError {
43 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44 match self {
45 RetentionError::Io(e) => write!(f, "retention io error: {e}"),
46 RetentionError::UnreadableChain(path) => write!(
47 f,
48 "unreadable named-chain file {} — refusing every destructive session \
49 operation while chain protection is unknown",
50 path.display()
51 ),
52 RetentionError::BudgetUnmet {
53 budget,
54 remaining,
55 protected_chain_heads,
56 } => write!(
57 f,
58 "disk budget unmet: {remaining} bytes remain against a budget of \
59 {budget} after evicting every unprotected artifact \
60 ({protected_chain_heads} protected chain head(s) retained)"
61 ),
62 }
63 }
64}
65
66impl std::error::Error for RetentionError {}
67
68impl From<io::Error> for RetentionError {
69 fn from(e: io::Error) -> Self {
70 RetentionError::Io(e)
71 }
72}
73
74#[derive(Debug, Clone)]
77pub struct RetentionRoots {
78 pub config_dir: PathBuf,
80 pub base_dir: PathBuf,
82 pub cache_dir: PathBuf,
84}
85
86impl RetentionRoots {
87 pub fn resolve() -> Self {
89 let cache_dir = std::env::var("HOME")
90 .map(|home| PathBuf::from(home).join(".cache/synaps"))
91 .unwrap_or_else(|_| crate::config::base_dir().join("cache"));
92 Self {
93 config_dir: crate::config::get_active_config_dir(),
94 base_dir: crate::config::base_dir(),
95 cache_dir,
96 }
97 }
98
99 fn sessions_dir(&self) -> PathBuf {
100 self.config_dir.join("sessions")
101 }
102 fn chains_dir(&self) -> PathBuf {
103 self.config_dir.join("chains")
104 }
105 fn memory_dir(&self) -> PathBuf {
106 self.base_dir.join("memory")
107 }
108 fn index_dir(&self) -> PathBuf {
109 self.memory_dir().join("index")
110 }
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
115#[serde(rename_all = "snake_case")]
116pub enum RetentionDomain {
117 Sessions,
118 Memory,
119 MemoryIndex,
120 Traces,
121 Logs,
122}
123
124#[derive(Debug, Clone, Copy, Default)]
126pub struct RetentionPolicy {
127 pub max_age_days: Option<u32>,
128 pub max_disk_bytes: Option<u64>,
129}
130
131const DAY_MS: u64 = 86_400_000;
132
133#[derive(Debug, Clone, Serialize)]
134pub struct DomainReport {
135 pub domain: RetentionDomain,
136 pub files: usize,
137 pub bytes: u64,
138}
139
140#[derive(Debug, Clone, Serialize)]
141pub struct RetentionReport {
142 pub domains: Vec<DomainReport>,
143 pub total_bytes: u64,
144}
145
146#[derive(Debug, Clone, Copy, Default, Serialize)]
147pub struct SweepOutcome {
148 pub deleted_files: usize,
149 pub freed_bytes: u64,
150 pub protected_chain_heads: usize,
151 pub memory_records_dropped: usize,
152}
153
154#[derive(Debug, Clone, Copy, Serialize)]
155pub struct ExportSummary {
156 pub files: usize,
157 pub bytes: u64,
158}
159
160fn list_files(dir: &Path) -> Vec<PathBuf> {
161 let mut out = Vec::new();
162 if let Ok(entries) = fs::read_dir(dir) {
163 for entry in entries.flatten() {
164 if entry.path().is_file() {
165 out.push(entry.path());
166 }
167 }
168 }
169 out.sort();
170 out
171}
172
173fn log_files(config_dir: &Path) -> Vec<PathBuf> {
174 let mut out: Vec<PathBuf> = list_files(config_dir)
175 .into_iter()
176 .filter(|p| {
177 p.file_name()
178 .and_then(|n| n.to_str())
179 .is_some_and(|n| n.starts_with("synaps.log"))
180 })
181 .collect();
182 out.sort();
183 out
184}
185
186fn dir_bytes(dir: &Path) -> u64 {
187 let mut total = 0;
188 if let Ok(entries) = fs::read_dir(dir) {
189 for entry in entries.flatten() {
190 let path = entry.path();
191 if path.is_dir() {
192 total += dir_bytes(&path);
193 } else if let Ok(meta) = entry.metadata() {
194 total += meta.len();
195 }
196 }
197 }
198 total
199}
200
201fn chain_heads(roots: &RetentionRoots) -> Result<Vec<String>, RetentionError> {
205 let mut heads = Vec::new();
206 for path in list_files(&roots.chains_dir()) {
207 let raw =
208 fs::read_to_string(&path).map_err(|_| RetentionError::UnreadableChain(path.clone()))?;
209 let value: serde_json::Value = serde_json::from_str(&raw)
210 .map_err(|_| RetentionError::UnreadableChain(path.clone()))?;
211 match value["head"].as_str() {
212 Some(head) => heads.push(head.to_string()),
213 None => return Err(RetentionError::UnreadableChain(path.clone())),
214 }
215 }
216 Ok(heads)
217}
218
219fn session_age_ms(path: &Path) -> Option<u64> {
221 if let Ok(raw) = fs::read_to_string(path) {
222 if let Ok(value) = serde_json::from_str::<serde_json::Value>(&raw) {
223 if let Some(updated) = value["updated_at"].as_str() {
224 if let Ok(ts) = chrono::DateTime::parse_from_rfc3339(updated) {
225 return Some(ts.timestamp_millis().max(0) as u64);
226 }
227 }
228 }
229 }
230 mtime_ms(path)
231}
232
233fn mtime_ms(path: &Path) -> Option<u64> {
234 fs::metadata(path)
235 .and_then(|m| m.modified())
236 .ok()
237 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
238 .map(|d| d.as_millis() as u64)
239}
240
241pub fn inspect(roots: &RetentionRoots) -> io::Result<RetentionReport> {
243 let sessions = list_files(&roots.sessions_dir());
244 let memory: Vec<PathBuf> = list_files(&roots.memory_dir())
245 .into_iter()
246 .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("jsonl"))
247 .collect();
248 let traces = cache_files(&roots.cache_dir);
249 let logs = log_files(&roots.config_dir);
250 let index_files = walk_regular_files(&roots.index_dir());
251
252 let sum = |files: &[PathBuf]| -> u64 {
253 files
254 .iter()
255 .filter_map(|p| fs::metadata(p).ok().map(|m| m.len()))
256 .sum()
257 };
258 let index_bytes = dir_bytes(&roots.index_dir());
259
260 let domains = vec![
261 DomainReport {
262 domain: RetentionDomain::Sessions,
263 files: sessions.len(),
264 bytes: sum(&sessions),
265 },
266 DomainReport {
267 domain: RetentionDomain::Memory,
268 files: memory.len(),
269 bytes: sum(&memory),
270 },
271 DomainReport {
272 domain: RetentionDomain::MemoryIndex,
273 files: index_files.len(),
274 bytes: index_bytes,
275 },
276 DomainReport {
277 domain: RetentionDomain::Traces,
278 files: traces.len(),
279 bytes: sum(&traces),
280 },
281 DomainReport {
282 domain: RetentionDomain::Logs,
283 files: logs.len(),
284 bytes: sum(&logs),
285 },
286 ];
287 let total_bytes = domains.iter().map(|d| d.bytes).sum();
288 Ok(RetentionReport {
289 domains,
290 total_bytes,
291 })
292}
293
294pub fn sweep_at(
298 roots: &RetentionRoots,
299 policy: &RetentionPolicy,
300 now_ms: u64,
301) -> Result<SweepOutcome, RetentionError> {
302 let mut outcome = SweepOutcome::default();
303 let protected: Vec<String> = chain_heads(roots)?;
306 let age_cutoff = policy
307 .max_age_days
308 .map(|days| now_ms.saturating_sub(days as u64 * DAY_MS));
309
310 for path in list_files(&roots.sessions_dir()) {
314 if path.extension().and_then(|e| e.to_str()) != Some("journal")
315 || path.with_extension("json").exists()
316 {
317 continue;
318 }
319 let id = path
320 .file_stem()
321 .and_then(|s| s.to_str())
322 .unwrap_or_default()
323 .to_string();
324 if protected.contains(&id) {
325 outcome.protected_chain_heads += 1;
326 continue;
327 }
328 delete_file(&path, &mut outcome)?;
329 }
330
331 if let Some(cutoff) = age_cutoff {
334 for path in list_files(&roots.sessions_dir()) {
335 if is_paired_journal(&path) {
338 continue;
339 }
340 let id = path
341 .file_stem()
342 .and_then(|s| s.to_str())
343 .unwrap_or_default()
344 .to_string();
345 if protected.contains(&id) {
346 outcome.protected_chain_heads += 1;
347 continue;
348 }
349 let Some(age_ref) = session_age_ms(&path) else {
350 continue; };
352 if age_ref < cutoff {
353 delete_session_artifact(&path, &mut outcome)?;
354 }
355 }
356
357 for path in cache_files(&roots.cache_dir)
360 .into_iter()
361 .chain(log_files(&roots.config_dir))
362 {
363 if mtime_ms(&path).is_some_and(|m| m < cutoff) {
364 delete_file(&path, &mut outcome)?;
365 }
366 }
367 }
368
369 for path in list_files(&roots.memory_dir()) {
374 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
375 continue;
376 }
377 let dropped = compact_memory_file(&path, now_ms, age_cutoff)?;
378 if dropped > 0 {
379 outcome.memory_records_dropped += dropped;
380 if let Some(ns) = path.file_stem().and_then(|s| s.to_str()) {
383 let index_dir = roots.index_dir().join(ns);
384 if index_dir.exists() {
385 outcome.freed_bytes += dir_bytes(&index_dir);
386 fs::remove_dir_all(&index_dir)?;
387 }
388 }
389 }
390 }
391
392 if let Some(budget) = policy.max_disk_bytes {
398 let total_now = |roots: &RetentionRoots| -> u64 {
399 inspect(roots).map(|r| r.total_bytes).unwrap_or(u64::MAX)
400 };
401
402 if total_now(roots) > budget {
404 if let Ok(entries) = fs::read_dir(roots.index_dir()) {
405 for entry in entries.flatten() {
406 if total_now(roots) <= budget {
407 break;
408 }
409 let dir = entry.path();
410 if dir.is_dir() {
411 outcome.freed_bytes += dir_bytes(&dir);
412 fs::remove_dir_all(&dir).map_err(RetentionError::Io)?;
413 outcome.deleted_files += 1;
414 }
415 }
416 }
417 }
418
419 if total_now(roots) > budget {
421 let mut candidates: Vec<(u64, PathBuf, bool)> = Vec::new();
422 for path in list_files(&roots.sessions_dir()) {
423 if is_paired_journal(&path) {
425 continue;
426 }
427 let id = path
428 .file_stem()
429 .and_then(|s| s.to_str())
430 .unwrap_or_default()
431 .to_string();
432 let age = session_age_ms(&path).unwrap_or(u64::MAX);
433 candidates.push((age, path, protected.contains(&id)));
434 }
435 for path in cache_files(&roots.cache_dir)
436 .into_iter()
437 .chain(log_files(&roots.config_dir))
438 {
439 let age = mtime_ms(&path).unwrap_or(u64::MAX);
440 candidates.push((age, path, false));
441 }
442 candidates.sort_by_key(|item| item.0);
443 for (_, path, is_protected) in candidates {
444 if total_now(roots) <= budget {
445 break;
446 }
447 if is_protected {
448 outcome.protected_chain_heads += 1;
449 continue;
450 }
451 delete_file(&path, &mut outcome).map_err(RetentionError::Io)?;
452 }
453 }
454
455 if total_now(roots) > budget {
457 let over = total_now(roots) - budget;
458 let dropped = evict_oldest_memory_records(roots, over)?;
459 outcome.memory_records_dropped += dropped;
460 }
461
462 let remaining = total_now(roots);
464 if remaining > budget {
465 return Err(RetentionError::BudgetUnmet {
466 budget,
467 remaining,
468 protected_chain_heads: protected.len(),
469 });
470 }
471 }
472
473 Ok(outcome)
474}
475
476fn evict_oldest_memory_records(
480 roots: &RetentionRoots,
481 bytes_to_free: u64,
482) -> Result<usize, RetentionError> {
483 let mut live: Vec<(u64, PathBuf, String, usize)> = Vec::new();
485 for path in list_files(&roots.memory_dir()) {
486 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
487 continue;
488 }
489 let raw = fs::read_to_string(&path).map_err(RetentionError::Io)?;
490 let mut tombstoned: std::collections::HashSet<String> = std::collections::HashSet::new();
491 let mut records: Vec<(u64, String, usize)> = Vec::new();
492 for line in raw.lines() {
493 let Ok(value) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
494 continue;
495 };
496 if let Some(id) = value["tombstone"].as_str() {
497 tombstoned.insert(id.to_string());
498 continue;
499 }
500 if let Some(id) = value["id"].as_str() {
501 records.push((
502 value["timestamp_ms"].as_u64().unwrap_or(0),
503 id.to_string(),
504 line.len() + 1,
505 ));
506 }
507 }
508 for (ts, id, len) in records {
509 if !tombstoned.contains(&id) {
510 live.push((ts, path.clone(), id, len));
511 }
512 }
513 }
514 live.sort_by_key(|item| item.0);
515
516 let mut freed: u64 = 0;
517 let mut victims: std::collections::HashMap<PathBuf, std::collections::HashSet<String>> =
518 std::collections::HashMap::new();
519 let mut dropped = 0usize;
520 for (_, path, id, len) in live {
521 if freed >= bytes_to_free {
522 break;
523 }
524 victims.entry(path).or_default().insert(id);
525 freed += len as u64;
526 dropped += 1;
527 }
528
529 for (path, ids) in victims {
530 let raw = fs::read_to_string(&path).map_err(RetentionError::Io)?;
531 let kept: Vec<&str> = raw
532 .lines()
533 .filter(|line| {
534 let Ok(value) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
535 return false; };
537 if let Some(id) = value["id"].as_str() {
538 return !ids.contains(id);
539 }
540 if let Some(id) = value["tombstone"].as_str() {
543 return !ids.contains(id);
544 }
545 true
546 })
547 .collect();
548 let mut body = kept.join("\n");
549 if !body.is_empty() {
550 body.push('\n');
551 }
552 crate::core::private_fs::write_atomic_private(&path, body.as_bytes())
553 .map_err(io::Error::from)
554 .map_err(RetentionError::Io)?;
555 }
556 Ok(dropped)
557}
558
559fn walk_regular_files(root: &Path) -> Vec<PathBuf> {
563 fn recurse(dir: &Path, depth: usize, out: &mut Vec<PathBuf>) {
564 if depth > 32 {
565 return;
566 }
567 if let Ok(entries) = fs::read_dir(dir) {
568 for entry in entries.flatten() {
569 let Ok(file_type) = entry.file_type() else {
570 continue;
571 };
572 if file_type.is_symlink() {
573 continue; }
575 let path = entry.path();
576 if file_type.is_dir() {
577 recurse(&path, depth + 1, out);
578 } else if file_type.is_file() {
579 out.push(path);
580 }
581 }
582 }
583 }
584 let mut out = Vec::new();
585 recurse(root, 0, &mut out);
586 out.sort();
587 out
588}
589
590fn cache_files(cache_dir: &Path) -> Vec<PathBuf> {
592 walk_regular_files(cache_dir)
593}
594
595pub fn sweep(
597 roots: &RetentionRoots,
598 policy: &RetentionPolicy,
599) -> Result<SweepOutcome, RetentionError> {
600 sweep_at(roots, policy, crate::epoch_millis())
601}
602
603fn delete_file(path: &Path, outcome: &mut SweepOutcome) -> io::Result<()> {
604 let len = fs::metadata(path).map(|m| m.len()).unwrap_or(0);
605 fs::remove_file(path)?;
606 outcome.deleted_files += 1;
607 outcome.freed_bytes += len;
608 Ok(())
609}
610
611fn is_paired_journal(path: &Path) -> bool {
615 path.extension().and_then(|e| e.to_str()) == Some("journal")
616 && path.with_extension("json").exists()
617}
618
619fn delete_session_artifact(path: &Path, outcome: &mut SweepOutcome) -> io::Result<()> {
623 delete_file(path, outcome)?;
624 if path.extension().and_then(|e| e.to_str()) == Some("json") {
625 let journal = path.with_extension("journal");
626 if journal.exists() {
627 delete_file(&journal, outcome)?;
628 }
629 }
630 Ok(())
631}
632
633fn compact_memory_file(
638 path: &Path,
639 now_ms: u64,
640 global_cutoff_ms: Option<u64>,
641) -> io::Result<usize> {
642 let raw = fs::read_to_string(path)?;
643 let mut tombstoned: std::collections::HashSet<String> = std::collections::HashSet::new();
644 let mut records: Vec<(Option<String>, u64, Option<u64>, String)> = Vec::new();
645 let mut dropped = 0usize;
646
647 for line in raw.lines() {
648 let trimmed = line.trim();
649 if trimmed.is_empty() {
650 continue;
651 }
652 let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) else {
653 dropped += 1; continue;
655 };
656 if let Some(id) = value["tombstone"].as_str() {
657 tombstoned.insert(id.to_string());
658 continue;
659 }
660 let ts = value["timestamp_ms"].as_u64().unwrap_or(0);
661 let per_record_span_ms = value["retention"]["max_age_days"]
662 .as_u64()
663 .map(|days| days * DAY_MS);
664 records.push((
665 value["id"].as_str().map(String::from),
666 ts,
667 per_record_span_ms,
668 trimmed.to_string(),
669 ));
670 }
671
672 let mut kept: Vec<String> = Vec::new();
673 for (id, ts, per_record_span_ms, line) in records {
674 if id.as_ref().is_some_and(|id| tombstoned.contains(id)) {
675 dropped += 1;
676 continue;
677 }
678 if per_record_span_ms.is_some_and(|span| ts.saturating_add(span) < now_ms) {
679 dropped += 1;
680 continue;
681 }
682 if global_cutoff_ms.is_some_and(|cutoff| ts < cutoff) {
683 dropped += 1;
684 continue;
685 }
686 kept.push(line);
687 }
688
689 if dropped > 0 {
691 let mut body = kept.join("\n");
692 if !body.is_empty() {
693 body.push('\n');
694 }
695 crate::core::private_fs::write_atomic_private(path, body.as_bytes())
696 .map_err(io::Error::from)?;
697 }
698 Ok(dropped)
699}
700
701#[cfg(unix)]
709pub fn export(roots: &RetentionRoots, dest: &Path) -> io::Result<ExportSummary> {
710 use crate::private_fs::ConfinedDir;
711 let mut summary = ExportSummary { files: 0, bytes: 0 };
712 let root = ConfinedDir::create_root(dest)?;
713
714 let flat = [
719 (roots.sessions_dir(), "sessions"),
720 (roots.memory_dir(), "memory"),
721 ];
722 for (src, sub) in flat {
723 let Ok(src_root) = ConfinedDir::open_root(&src) else {
724 continue; };
726 let out = root.child_dir(sub)?;
727 for entry in src_root.entries()? {
728 if !entry.is_file {
729 continue; }
731 let components = [entry.name.clone()];
732 match src_root.open_file(&components) {
733 Ok(source) => {
734 let bytes = copy_opened_into_confined(source, &out, &entry.name)?;
735 summary.bytes += bytes;
736 summary.files += 1;
737 }
738 Err(_) => continue,
741 }
742 }
743 }
744
745 if let Ok(cache_root) = ConfinedDir::open_root(&roots.cache_dir) {
747 let traces_root = root.child_dir("traces")?;
748 export_tree_from_handles(&cache_root, &traces_root, &mut summary, 0)?;
749 }
750
751 if let Ok(logs_root) = ConfinedDir::open_root(&roots.config_dir) {
754 let logs_out = root.child_dir("logs")?;
755 for entry in logs_root.entries()? {
756 if !entry.is_file || !entry.name.starts_with("synaps.log") {
757 continue;
758 }
759 match logs_root.open_file(std::slice::from_ref(&entry.name)) {
760 Ok(source) => {
761 let bytes = copy_opened_into_confined(source, &logs_out, &entry.name)?;
762 summary.bytes += bytes;
763 summary.files += 1;
764 }
765 Err(_) => continue,
766 }
767 }
768 }
769 Ok(summary)
770}
771
772#[cfg(unix)]
775fn copy_opened_into_confined(
776 mut source: fs::File,
777 dest_dir: &crate::private_fs::ConfinedDir,
778 name: &str,
779) -> io::Result<u64> {
780 use std::io::{Read, Write};
781 if !source.metadata()?.is_file() {
782 return Err(io::Error::other("confined export source is not regular"));
783 }
784 let mut out = dest_dir.create_file(name)?;
785 let mut buf = [0u8; 64 * 1024];
786 let mut written = 0u64;
787 loop {
788 let n = source.read(&mut buf)?;
789 if n == 0 {
790 break;
791 }
792 out.write_all(&buf[..n])?;
793 written += n as u64;
794 }
795 Ok(written)
796}
797
798#[cfg(unix)]
802fn export_tree_from_handles(
803 source: &crate::private_fs::ConfinedDir,
804 destination: &crate::private_fs::ConfinedDir,
805 summary: &mut ExportSummary,
806 depth: usize,
807) -> io::Result<()> {
808 if depth > 32 {
809 return Err(io::Error::other("confined export source depth exceeds 32"));
810 }
811 for entry in source.entries()? {
812 if entry.is_file {
813 match source.open_file(std::slice::from_ref(&entry.name)) {
814 Ok(opened) => {
815 let bytes = copy_opened_into_confined(opened, destination, &entry.name)?;
816 summary.bytes += bytes;
817 summary.files += 1;
818 }
819 Err(_) => continue,
820 }
821 } else if entry.is_dir {
822 let source_child = match source.open_dirs(std::slice::from_ref(&entry.name)) {
823 Ok(child) => child,
824 Err(_) => continue,
825 };
826 let dest_child = destination.child_dir(&entry.name)?;
827 export_tree_from_handles(&source_child, &dest_child, summary, depth + 1)?;
828 }
829 }
830 Ok(())
831}
832
833#[cfg(not(unix))]
835pub fn export(_roots: &RetentionRoots, _dest: &Path) -> io::Result<ExportSummary> {
836 Err(io::Error::other(
837 "confined export requires a unix platform (directory-handle-relative creation)",
838 ))
839}
840
841pub fn forget(roots: &RetentionRoots, domain: RetentionDomain, id: &str) -> io::Result<()> {
845 match domain {
846 RetentionDomain::Sessions => {
847 let file = sanitize_file_id(id)?;
849 let heads = chain_heads(roots).map_err(|e| io::Error::other(e.to_string()))?;
850 if heads.contains(&id.to_string()) {
851 return Err(io::Error::other(format!(
852 "session {id} is the head of a named chain — delete or repoint the \
853 chain first (retention never leaves chains dangling)"
854 )));
855 }
856 let path = roots
857 .sessions_dir()
858 .join(format!("{}.json", file.display()));
859 fs::remove_file(path)?;
860 let journal = roots
863 .sessions_dir()
864 .join(format!("{}.journal", file.display()));
865 match fs::remove_file(journal) {
866 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
867 other => other,
868 }
869 }
870 RetentionDomain::Traces => {
871 #[cfg(unix)]
875 {
876 let mut components = crate::private_fs::validated_relative_components(id)
877 .map_err(|_| io::Error::other(format!("invalid artifact id: {id:?}")))?;
878 let name = components
879 .pop()
880 .ok_or_else(|| io::Error::other(format!("invalid artifact id: {id:?}")))?;
881 let root = crate::private_fs::ConfinedDir::open_root(&roots.cache_dir)?;
882 let dir = root.open_dirs(&components)?;
883 dir.remove_file(&name)
884 }
885 #[cfg(not(unix))]
886 {
887 fs::remove_file(roots.cache_dir.join(sanitize_file_id(id)?))
888 }
889 }
890 RetentionDomain::Logs => {
891 let file = sanitize_file_id(id)?;
892 if !id.starts_with("synaps.log") {
893 return Err(io::Error::other(
894 "invalid log id: must start with synaps.log",
895 ));
896 }
897 fs::remove_file(roots.config_dir.join(file))
898 }
899 RetentionDomain::MemoryIndex => {
900 let dir = roots.index_dir().join(sanitize_file_id(id)?);
901 fs::remove_dir_all(dir)
902 }
903 RetentionDomain::Memory => {
904 let (namespace, record_id) = id.split_once(':').ok_or_else(|| {
905 io::Error::other("invalid memory id: use \"<namespace>:<record-id>\"")
906 })?;
907 let namespace = sanitize_file_id(namespace)?;
908 if record_id.is_empty() || !record_id.starts_with("mem-") {
909 return Err(io::Error::other(format!(
910 "invalid memory record id: {record_id:?}"
911 )));
912 }
913 let path = roots
914 .memory_dir()
915 .join(format!("{}.jsonl", namespace.display()));
916 let raw = fs::read_to_string(&path)?;
917
918 let mut live = false;
922 let mut tombstoned = false;
923 for line in raw.lines() {
924 let Ok(value) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
925 continue;
926 };
927 if value["tombstone"].as_str() == Some(record_id) {
928 tombstoned = true;
929 } else if value["id"].as_str() == Some(record_id) {
930 live = true;
931 }
932 }
933 if !live || tombstoned {
934 return Err(io::Error::other(format!(
935 "record {record_id} not present as a live record in namespace {}",
936 namespace.display()
937 )));
938 }
939 let line = serde_json::json!({
940 "tombstone": record_id,
941 "timestamp_ms": crate::epoch_millis(),
942 });
943 let mut body = raw;
944 if !body.ends_with('\n') && !body.is_empty() {
945 body.push('\n');
946 }
947 body.push_str(&line.to_string());
948 body.push('\n');
949 crate::core::private_fs::write_atomic_private(&path, body.as_bytes())
950 .map_err(io::Error::from)
951 }
952 }
953}
954
955fn sanitize_file_id(id: &str) -> io::Result<PathBuf> {
957 if id.is_empty() || id.contains('/') || id.contains('\\') || id.contains("..") {
958 return Err(io::Error::other(format!("invalid artifact id: {id:?}")));
959 }
960 Ok(PathBuf::from(id))
961}