1use crate::core::disclosure::{gate_for_model, may_persist, DisclosureClass, ModelVisibility};
48use crate::core::stream_types::SharedMessage;
49use serde::{Deserialize, Serialize};
50use serde_json::{json, Value};
51use sha2::{Digest, Sha256};
52use std::collections::HashMap;
53use std::io::{self, Write};
54#[cfg(unix)]
55use std::io::Read;
56use std::path::Path;
57use std::sync::atomic::{AtomicBool, Ordering};
58use std::sync::{Arc, Condvar, Mutex, OnceLock, PoisonError, Weak};
59use std::time::{Duration, Instant};
60
61pub const MAX_SEGMENTS: usize = 128;
62pub const MAX_SEGMENT_BYTES: usize = 16 * 1024 * 1024;
63pub const MAX_STORE_BYTES: usize = 256 * 1024 * 1024;
64pub const MAX_INPUT_BYTES: usize = 16 * 1024 * 1024;
65pub const MAX_MESSAGES: usize = 4096;
66pub const MAX_NOTE_BYTES: usize = 8192;
67pub const MAX_FETCH_MESSAGES: usize = 128;
68pub const MAX_SEARCH_RESULTS: usize = 32;
69pub const LOCK_WAIT: Duration = Duration::from_secs(30);
72const ABORT_POLL: Duration = Duration::from_millis(20);
74const WITHHELD: &str = "[archive: withheld]";
75const BUSY: &str = "archive busy; retain source and retry";
76const BUSY_EXPORT: &str = "archive busy; retry export";
77
78pub type ArchiveRedactor = dyn Fn(&str) -> String + Send + Sync;
82
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84pub struct ArchiveRef {
85 pub id: String,
86 pub message_count: usize,
88 pub source_message_count: usize,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92pub struct ArchiveDescriptor {
93 pub id: String,
94 pub message_count: usize,
95 pub source_message_count: usize,
96 pub snippet: String,
98}
99
100#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
101#[serde(deny_unknown_fields)]
102pub struct ArchivedMessage {
103 pub source_index: usize,
105 pub block_indices: Vec<usize>,
107 pub message: SharedMessage,
108}
109
110#[derive(Clone, PartialEq, Serialize, Deserialize)]
114#[serde(deny_unknown_fields)]
115pub struct ArchiveProjection {
116 pub logical_id: String,
117 pub source_message_count: usize,
118 pub messages: Vec<ArchivedMessage>,
119 pub note: String,
120 pub digest: String,
121}
122
123pub fn project_archive(
126 logical_id: &str,
127 messages: &[SharedMessage],
128 note: &str,
129 redactor: Option<Arc<ArchiveRedactor>>,
130) -> io::Result<ArchiveProjection> {
131 validate_key(logical_id)?;
132 project_hashed(
133 &digest_parts(&[logical_id.as_bytes()]),
134 messages,
135 note,
136 redactor,
137 )
138}
139
140fn project_hashed(
141 logical_id: &str,
142 messages: &[SharedMessage],
143 note: &str,
144 redactor: Option<Arc<ArchiveRedactor>>,
145) -> io::Result<ArchiveProjection> {
146 if messages.len() > MAX_MESSAGES || note.len() > MAX_NOTE_BYTES {
147 return Err(invalid("archive message/note budget exceeded"));
148 }
149 let mut budget = InputBudget { bytes: 0, nodes: 0 };
150 for message in messages {
151 budget.message(message, redactor.is_some())?;
152 }
153 let projector = Projector { redactor };
154 let mut eligible = Vec::new();
155 for (source_index, message) in messages.iter().enumerate() {
156 if let Some((message, block_indices)) = projector.project(message)? {
157 eligible.push(ArchivedMessage {
158 source_index,
159 block_indices,
160 message: Arc::new(message),
161 });
162 }
163 }
164 let digest = projection_digest(logical_id, messages.len(), &eligible)?;
165 let note = projector.screen(note, DisclosureClass::ModelVisible)?;
166 if note.len() > MAX_NOTE_BYTES {
167 return Err(invalid("redacted note exceeds archive budget"));
168 }
169 Ok(ArchiveProjection {
170 logical_id: logical_id.to_owned(),
171 source_message_count: messages.len(),
172 messages: eligible,
173 note,
174 digest,
175 })
176}
177
178struct Projector {
179 redactor: Option<Arc<ArchiveRedactor>>,
180}
181
182pub struct ArchiveStore {
184 fs: PrivateDir,
185 scope: String,
186 logical: String,
187 redactor: Option<Arc<ArchiveRedactor>>,
188 abort: Option<Arc<AtomicBool>>,
189 wait: Duration,
190}
191
192struct Gate {
197 held: Mutex<bool>,
198 released: Condvar,
199 #[cfg(test)]
201 waited: std::sync::atomic::AtomicUsize,
202}
203
204struct Locked {
206 file: Option<FlockGuard>,
207 _gate: GateGuard,
208}
209
210struct GateGuard(Arc<Gate>);
211
212type DirIdentity = (u64, u64);
214type Gates = Mutex<HashMap<DirIdentity, Weak<Gate>>>;
215
216impl Gate {
217 fn for_directory(identity: DirIdentity) -> Arc<Gate> {
219 static GATES: OnceLock<Gates> = OnceLock::new();
220 let mut gates = GATES
221 .get_or_init(Default::default)
222 .lock()
223 .unwrap_or_else(PoisonError::into_inner);
224 if let Some(gate) = gates.get(&identity).and_then(Weak::upgrade) {
225 return gate;
226 }
227 gates.retain(|_, gate| gate.strong_count() > 0);
228 let gate = Arc::new(Gate {
229 held: Mutex::new(false),
230 released: Condvar::new(),
231 #[cfg(test)]
232 waited: Default::default(),
233 });
234 gates.insert(identity, Arc::downgrade(&gate));
235 gate
236 }
237
238 fn enter(
239 gate: Arc<Gate>,
240 wait: Duration,
241 abort: Option<&AtomicBool>,
242 busy: &'static str,
243 ) -> io::Result<GateGuard> {
244 let deadline = Instant::now() + wait;
245 {
246 let mut held = gate.held.lock().unwrap_or_else(PoisonError::into_inner);
247 let mut waited = false;
248 loop {
249 if abort.is_some_and(|abort| abort.load(Ordering::Acquire)) {
250 return Err(io::Error::new(
251 io::ErrorKind::Interrupted,
252 "archive operation canceled before it started",
253 ));
254 }
255 let now = Instant::now();
259 if waited && now >= deadline {
260 return Err(io::Error::new(io::ErrorKind::WouldBlock, busy));
261 }
262 if !*held {
263 *held = true;
264 break;
265 }
266 if now >= deadline {
267 return Err(io::Error::new(io::ErrorKind::WouldBlock, busy));
268 }
269 let mut slice = deadline - now;
270 if abort.is_some() {
271 slice = slice.min(ABORT_POLL);
272 }
273 #[cfg(test)]
274 if !waited {
275 gate.waited.fetch_add(1, Ordering::AcqRel);
276 }
277 waited = true;
278 held = gate
279 .released
280 .wait_timeout(held, slice)
281 .unwrap_or_else(PoisonError::into_inner)
282 .0;
283 }
284 }
285 Ok(GateGuard(gate))
286 }
287}
288
289impl Drop for GateGuard {
290 fn drop(&mut self) {
291 *self.0.held.lock().unwrap_or_else(PoisonError::into_inner) = false;
292 self.0.released.notify_all();
295 }
296}
297
298struct FlockGuard(std::fs::File);
303
304impl Drop for FlockGuard {
305 fn drop(&mut self) {
306 let _ = fs4::fs_std::FileExt::unlock(&self.0);
307 }
308}
309
310#[derive(Serialize, Deserialize)]
311#[serde(deny_unknown_fields)]
312struct Record {
313 version: u32,
314 scope: String,
315 logical: String,
316 id: String,
317 digest: String,
318 body: Body,
319}
320
321#[derive(Serialize, Deserialize)]
322#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
323enum Body {
324 Live {
325 source_message_count: usize,
326 messages: Vec<ArchivedMessage>,
327 note: String,
328 },
329 Tombstone,
330}
331
332impl Record {
333 fn reference(&self) -> io::Result<ArchiveRef> {
334 match &self.body {
335 Body::Live {
336 source_message_count,
337 messages,
338 ..
339 } => Ok(ArchiveRef {
340 id: self.id.clone(),
341 message_count: messages.len(),
342 source_message_count: *source_message_count,
343 }),
344 Body::Tombstone => Err(not_found()),
345 }
346 }
347}
348
349impl ArchiveStore {
350 pub fn new(base: &Path, scope_key: &str, logical_id: &str) -> io::Result<Self> {
351 for key in [scope_key, logical_id] {
352 validate_key(key)?;
353 }
354 let scope = digest_parts(&[scope_key.as_bytes()]);
355 let logical = digest_parts(&[logical_id.as_bytes()]);
356 let fs = PrivateDir::new(base, &scope)?;
357 Ok(Self {
358 fs,
359 scope,
360 logical,
361 redactor: None,
362 abort: None,
363 wait: LOCK_WAIT,
364 })
365 }
366
367 pub fn with_redactor(mut self, redactor: Arc<ArchiveRedactor>) -> Self {
370 self.redactor = Some(redactor);
371 self
372 }
373
374 pub fn with_abort(mut self, abort: Arc<AtomicBool>) -> Self {
381 self.abort = Some(abort);
382 self
383 }
384
385 fn exclusive(&self) -> io::Result<Locked> {
388 let gate = self.enter(BUSY)?;
389 Ok(Locked {
390 file: Some(self.fs.lock()?),
391 _gate: gate,
392 })
393 }
394
395 fn enter(&self, busy: &'static str) -> io::Result<GateGuard> {
396 let gate = Gate::for_directory(self.fs.identity()?);
397 Gate::enter(gate, self.wait, self.abort.as_deref(), busy)
398 }
399
400 pub fn seal(&self, messages: &[SharedMessage], note: &str) -> io::Result<ArchiveRef> {
401 self.seal_with_note(messages, note)
402 .map(|(reference, _)| reference)
403 }
404
405 pub fn seal_with_note(
409 &self,
410 messages: &[SharedMessage],
411 note: &str,
412 ) -> io::Result<(ArchiveRef, String)> {
413 let projection = project_hashed(&self.logical, messages, note, self.redactor.clone())?;
414 let ArchiveProjection {
415 digest,
416 source_message_count,
417 messages: eligible,
418 note,
419 ..
420 } = projection;
421 let _lock = self.exclusive()?;
422 let (records, used) = self.scan()?;
423 if let Some((_, record)) = records.iter().find(|(_, r)| r.digest == digest) {
424 let Body::Live { note: stored, .. } = &record.body else {
425 return Err(io::Error::new(
426 io::ErrorKind::PermissionDenied,
427 "archive projection was forgotten",
428 ));
429 };
430 self.fs.sync()?;
432 return Ok((record.reference()?, stored.clone()));
433 }
434 let slot = (0..MAX_SEGMENTS)
435 .find(|slot| !records.iter().any(|(s, _)| s == slot))
436 .ok_or_else(|| invalid("archive segment budget exhausted (tombstones retained)"))?;
437 let record = Record {
438 version: 1,
439 scope: self.scope.clone(),
440 logical: self.logical.clone(),
441 id: uuid::Uuid::new_v4().simple().to_string(),
442 digest,
443 body: Body::Live {
444 source_message_count,
445 messages: eligible,
446 note: note.clone(),
447 },
448 };
449 let bytes = bounded_json(&record, MAX_SEGMENT_BYTES)?;
450 if used + bytes.len() > MAX_STORE_BYTES {
451 return Err(invalid("archive storage byte budget exhausted"));
452 }
453 self.fs.publish(slot, &bytes)?;
454 Ok((record.reference()?, note))
455 }
456
457 pub fn fetch(&self, id: &str, start: usize, limit: usize) -> io::Result<Vec<SharedMessage>> {
458 Ok(self
459 .fetch_with_provenance(id, start, limit)?
460 .into_iter()
461 .map(|m| m.message)
462 .collect())
463 }
464
465 pub fn fetch_with_provenance(
468 &self,
469 id: &str,
470 start: usize,
471 limit: usize,
472 ) -> io::Result<Vec<ArchivedMessage>> {
473 validate_id(id)?;
474 if limit > MAX_FETCH_MESSAGES {
475 return Err(invalid("archive fetch limit exceeds 128"));
476 }
477 let _lock = self.exclusive()?;
478 let (records, _) = self.scan()?;
479 let record = find_record(records, id)?;
480 match record.body {
481 Body::Live { messages, .. } => {
482 Ok(messages.into_iter().skip(start).take(limit).collect())
483 }
484 Body::Tombstone => Err(not_found()),
485 }
486 }
487
488 pub fn fetch_note(&self, id: &str) -> io::Result<String> {
491 validate_id(id)?;
492 let _lock = self.exclusive()?;
493 let (records, _) = self.scan()?;
494 match find_record(records, id)?.body {
495 Body::Live { note, .. } => Ok(note),
496 Body::Tombstone => Err(not_found()),
497 }
498 }
499
500 pub fn search(&self, query: &str, limit: usize) -> io::Result<Vec<ArchiveDescriptor>> {
504 if query.len() > 512 || limit > MAX_SEARCH_RESULTS {
505 return Err(invalid("archive search query/result budget exceeded"));
506 }
507 if limit == 0 {
508 return Ok(Vec::new());
509 }
510 let query = query.to_lowercase();
511 let _lock = self.exclusive()?;
512 let (records, _) = self.scan()?;
513 let mut result = Vec::new();
514 for (_, record) in records {
515 if let Body::Live {
516 messages,
517 source_message_count,
518 ..
519 } = &record.body
520 {
521 let mut snippet = None;
522 for message in messages {
523 let text = serde_json::to_string(&message.message).map_err(json_error)?;
524 if query.is_empty() || text.to_lowercase().contains(&query) {
525 snippet = Some(prefix(&text, 256).to_owned());
526 break;
527 }
528 }
529 if snippet.is_some() || (query.is_empty() && messages.is_empty()) {
530 result.push(ArchiveDescriptor {
531 id: record.id,
532 message_count: messages.len(),
533 source_message_count: *source_message_count,
534 snippet: snippet.unwrap_or_default(),
535 });
536 }
537 }
538 if result.len() == limit {
539 break;
540 }
541 }
542 Ok(result)
543 }
544
545 pub fn forget(&self, id: &str) -> io::Result<()> {
549 validate_id(id)?;
550 let _lock = self.exclusive()?;
551 let (records, _) = self.scan()?;
552 let (slot, mut record) = records
553 .into_iter()
554 .find(|(_, r)| r.id == id)
555 .ok_or_else(not_found)?;
556 if matches!(record.body, Body::Tombstone) {
557 return self.fs.sync();
558 }
559 record.body = Body::Tombstone;
560 self.fs
561 .publish(slot, &bounded_json(&record, MAX_SEGMENT_BYTES)?)
562 }
563
564 pub fn export_records(&self) -> io::Result<Vec<Value>> {
568 let gate = self.enter(BUSY_EXPORT)?;
569 let lock = Locked {
570 file: self.fs.lock_existing()?,
571 _gate: gate,
572 };
573 let (records, _) = self.scan_with(true)?;
574 if lock.file.is_none() && !records.is_empty() {
575 return Err(corrupt());
576 }
577 Ok(records
578 .into_iter()
579 .map(|(_, record)| {
580 let (source_message_count, messages, note, tombstone) = match record.body {
581 Body::Live {
582 source_message_count,
583 messages,
584 note,
585 } => (source_message_count, messages, note, false),
586 Body::Tombstone => (0, Vec::new(), String::new(), true),
587 };
588 json!({
589 "id": record.id, "digest": record.digest, "logical_id": record.logical,
590 "source_message_count": source_message_count, "messages": messages,
591 "note": note, "tombstone": tombstone,
592 })
593 })
594 .collect())
595 }
596
597 fn scan(&self) -> io::Result<(Vec<(usize, Record)>, usize)> {
598 self.scan_with(false)
599 }
600
601 fn scan_with(&self, readonly: bool) -> io::Result<(Vec<(usize, Record)>, usize)> {
602 let mut records = Vec::new();
603 let mut used = 0;
604 for slot in 0..MAX_SEGMENTS {
605 let Some(bytes) = self.fs.read_with(slot, MAX_STORE_BYTES - used, readonly)? else {
606 continue;
607 };
608 used += bytes.len();
609 let record: Record = serde_json::from_slice(&bytes).map_err(|_| corrupt())?;
610 if record.version != 1
611 || record.scope != self.scope
612 || record.logical.len() != 64
613 || !record.logical.bytes().all(|b| b.is_ascii_hexdigit())
614 || validate_id(&record.id).is_err()
615 || record.digest.len() != 64
616 || !record.digest.bytes().all(|b| b.is_ascii_hexdigit())
617 {
618 return Err(corrupt());
619 }
620 if let Body::Live {
621 messages,
622 source_message_count,
623 note,
624 } = &record.body
625 {
626 if messages.len() > MAX_MESSAGES
627 || *source_message_count > MAX_MESSAGES
628 || note.len() > MAX_NOTE_BYTES
629 || messages
630 .iter()
631 .any(|m| m.source_index >= *source_message_count)
632 || messages
633 .windows(2)
634 .any(|m| m[0].source_index >= m[1].source_index)
635 || projection_digest(&record.logical, *source_message_count, messages)?
636 != record.digest
637 {
638 return Err(corrupt());
639 }
640 }
641 if records
642 .iter()
643 .any(|(_, r): &(usize, Record)| r.id == record.id || r.digest == record.digest)
644 {
645 return Err(corrupt());
646 }
647 records.push((slot, record));
648 }
649 Ok((records, used))
650 }
651}
652
653impl Projector {
654 fn screen(&self, text: &str, class: DisclosureClass) -> io::Result<String> {
655 if class == DisclosureClass::ModelVisibleAfterRedaction && self.redactor.is_none() {
656 return Ok(WITHHELD.to_owned());
657 }
658 let redactor = |text: &str| {
659 let text = self
660 .redactor
661 .as_ref()
662 .map_or_else(|| text.to_owned(), |r| r(text));
663 if credential_like(&text) {
664 WITHHELD.to_owned()
665 } else {
666 text
667 }
668 };
669 let text = redactor(text);
671 if text.len() > MAX_SEGMENT_BYTES {
672 return Err(invalid("redactor output exceeds archive budget"));
673 }
674 match gate_for_model(class, &text, false, Some(&|s| s.to_owned())) {
675 ModelVisibility::Visible(text) => Ok(text),
676 ModelVisibility::Withheld(_) => Ok(WITHHELD.to_owned()),
677 }
678 }
679
680 fn project(&self, message: &Value) -> io::Result<Option<(Value, Vec<usize>)>> {
681 let Some((class, role, content)) = projection_content(message) else {
682 return Ok(None);
683 };
684 if let Some(text) = content.as_str() {
685 return Ok(Some((
686 json!({"role": role, "content": self.screen(text, class)?}),
687 Vec::new(),
688 )));
689 }
690 let Some(blocks) = content.as_array() else {
691 return Ok(None);
692 };
693 let mut output = Vec::new();
694 let mut indices = Vec::new();
695 for (index, block) in blocks.iter().enumerate() {
696 let Some(mut block_class) = eligible_class(block) else {
697 continue;
698 };
699 if class == DisclosureClass::ModelVisibleAfterRedaction {
700 block_class = class;
701 }
702 let kept = match block.get("type").and_then(Value::as_str) {
703 Some("text") => block
704 .get("text")
705 .and_then(Value::as_str)
706 .map(|text| {
707 self.screen(text, block_class)
708 .map(|text| json!({"type":"text", "text":text}))
709 })
710 .transpose()?,
711 Some("tool_use") if role == "assistant" => {
712 if let (Some(id), Some(name)) =
713 (identifier(block, "id"), identifier(block, "name"))
714 {
715 let input = if name == "context_checkpoint" {
716 json!({"_archive_withheld":true})
719 } else if self.redactor.is_some() {
720 self.tool_input(block.get("input"), block_class)?
721 } else {
722 json!({"_archive_withheld":true})
723 };
724 Some(json!({"type":"tool_use", "id":id, "name":name, "input":input}))
725 } else {
726 None
727 }
728 }
729 Some("tool_result") if role == "user" => {
730 if let Some(id) = identifier(block, "tool_use_id") {
731 let content = if self.redactor.is_some() {
732 self.tool_result(&block["content"], block_class)?
733 } else {
734 Value::String(WITHHELD.to_owned())
735 };
736 let mut result =
737 json!({"type":"tool_result", "tool_use_id":id, "content":content});
738 if let Some(error) = block.get("is_error").and_then(Value::as_bool) {
739 result["is_error"] = json!(error);
740 }
741 Some(result)
742 } else {
743 None
744 }
745 }
746 _ => None,
747 };
748 if let Some(kept) = kept {
749 output.push(kept);
750 indices.push(index);
751 }
752 }
753 if output.is_empty() {
754 Ok(None)
755 } else {
756 Ok(Some((json!({"role":role,"content":output}), indices)))
757 }
758 }
759
760 fn tool_input(&self, input: Option<&Value>, class: DisclosureClass) -> io::Result<Value> {
761 if let Some(input) = input {
762 let mut budget = InputBudget { bytes: 0, nodes: 0 };
768 if let Some(bytes) = budget.input(input, 3)? {
769 budget.charge(bytes)?;
770 let limit = bytes
771 .checked_mul(6)
772 .ok_or_else(|| invalid("archive input byte budget exceeded"))?;
773 let encoded = bounded_json(input, limit)?;
774 let text =
775 self.screen(std::str::from_utf8(&encoded).map_err(|_| corrupt())?, class)?;
776 return Ok(serde_json::from_str(&text)
777 .unwrap_or_else(|_| json!({"_archive_withheld":true})));
778 }
779 }
780 Ok(json!({"_archive_withheld":true}))
781 }
782
783 fn tool_result(&self, content: &Value, class: DisclosureClass) -> io::Result<Value> {
784 if let Some(text) = content.as_str() {
785 return Ok(json!(self.screen(text, class)?));
786 }
787 let mut output = Vec::new();
788 if let Some(blocks) = content.as_array() {
789 for block in blocks {
790 if let Some(class) = eligible_class(block) {
791 if block["type"] == "text" {
792 if let Some(text) = block["text"].as_str() {
793 output.push(json!({"type":"text", "text":self.screen(text, class)?}));
794 }
795 }
796 }
797 }
798 }
799 if output.is_empty() {
800 Ok(json!(WITHHELD))
801 } else {
802 Ok(json!(output))
803 }
804 }
805}
806
807pub fn export_in(base: &Path, scope_key: &str) -> io::Result<Vec<Value>> {
811 validate_key(scope_key)?;
812 let scope = digest_parts(&[scope_key.as_bytes()]);
813 let fs = match PrivateDir::open_existing(base, &scope) {
814 Ok(fs) => fs,
815 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
816 Err(error) => return Err(error),
817 };
818 ArchiveStore {
819 fs,
820 scope,
821 logical: String::new(), redactor: None,
823 abort: None,
824 wait: LOCK_WAIT,
825 }
826 .export_records()
827}
828
829pub fn project_search(
832 base: &Path,
833 scope_key: &str,
834 query: &str,
835 limit: usize,
836) -> io::Result<Vec<ArchiveDescriptor>> {
837 ArchiveStore::new(base, scope_key, "project-reader")?.search(query, limit)
838}
839
840pub fn project_fetch(
842 base: &Path,
843 scope_key: &str,
844 id: &str,
845 start: usize,
846 limit: usize,
847) -> io::Result<Vec<SharedMessage>> {
848 ArchiveStore::new(base, scope_key, "project-reader")?.fetch(id, start, limit)
849}
850
851fn eligible_class(value: &Value) -> Option<DisclosureClass> {
852 if value
855 .get("channel")
856 .is_some_and(|v| !matches!(v.as_str(), Some("final")))
857 || value.get("content_class").is_some_and(|v| {
858 !matches!(
859 v.as_str(),
860 Some("user_text" | "assistant_text" | "tool_calls" | "tool_results")
861 )
862 })
863 || value
864 .get("private")
865 .is_some_and(|v| v != &Value::Bool(false))
866 {
867 return None;
868 }
869 for key in ["sensitivity", "retention", "retention_class"] {
870 if let Some(v) = value.get(key) {
871 if !matches!(v.as_str(), Some("normal" | "standard" | "model_visible")) {
872 return None;
873 }
874 }
875 }
876 let mut class = DisclosureClass::ModelVisible;
877 for key in ["disclosure", "disclosure_class"] {
878 if let Some(v) = value.get(key) {
879 let parsed = DisclosureClass::parse(v.as_str()?)?;
880 if !may_persist(parsed)
881 || !matches!(
882 parsed,
883 DisclosureClass::ModelVisible | DisclosureClass::ModelVisibleAfterRedaction
884 )
885 {
886 return None;
887 }
888 class = parsed;
889 }
890 }
891 Some(class)
892}
893
894fn projection_content(message: &Value) -> Option<(DisclosureClass, &str, &Value)> {
898 if message.get("_synaps_context").is_some()
899 || crate::core::host_note::is_tool_in_flight_note(message)
900 {
901 return None;
902 }
903 let class = eligible_class(message)?;
904 let role @ ("user" | "assistant") = message.get("role")?.as_str()? else {
905 return None;
906 };
907 Some((class, role, message.get("content")?))
908}
909
910fn identifier<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
911 let text = value.get(key)?.as_str()?;
912 (!text.is_empty()
913 && text.len() <= 128
914 && !credential_like(text)
915 && text
916 .bytes()
917 .all(|b| b.is_ascii_alphanumeric() || b"_-.:".contains(&b)))
918 .then_some(text)
919}
920
921fn credential_like(text: &str) -> bool {
923 let lower = text.to_ascii_lowercase();
924 [
925 "password",
926 "passwd",
927 "api_key",
928 "apikey",
929 "api-key",
930 "access_token",
931 "refresh_token",
932 "secret",
933 "authorization",
934 "bearer ",
935 "private key",
936 "sk-",
937 "ghp_",
938 "github_pat_",
939 "xoxb-",
940 "akia",
941 "eyjh",
942 ]
943 .iter()
944 .any(|needle| lower.contains(needle))
945}
946
947struct InputBudget {
948 bytes: usize,
949 nodes: usize,
950}
951impl InputBudget {
952 fn node(&mut self, depth: usize) -> io::Result<()> {
953 self.nodes += 1;
954 if depth > 32 || self.nodes > 65536 {
955 return Err(invalid("archive input structural budget exceeded"));
956 }
957 Ok(())
958 }
959
960 fn charge(&mut self, bytes: usize) -> io::Result<()> {
961 self.bytes = self.bytes.saturating_add(bytes);
962 if self.bytes > MAX_INPUT_BYTES {
963 return Err(invalid("archive input byte budget exceeded"));
964 }
965 Ok(())
966 }
967
968 fn field(&mut self, key: &str, text: &str, depth: usize) -> io::Result<()> {
971 self.node(depth)?;
972 self.charge(key.len().saturating_add(8).saturating_add(text.len()))
973 }
974
975 fn text_field(
976 &mut self,
977 key: &str,
978 text: &str,
979 class: DisclosureClass,
980 redactor: bool,
981 depth: usize,
982 ) -> io::Result<()> {
983 if class == DisclosureClass::ModelVisibleAfterRedaction && !redactor {
984 return self.node(depth); }
986 self.field(key, text, depth)
987 }
988
989 fn message(&mut self, message: &Value, redactor: bool) -> io::Result<()> {
993 self.node(0)?;
994 let Some((class, role, content)) = projection_content(message) else {
995 return Ok(());
996 };
997 self.charge(8)?;
998 self.field("role", role, 1)?;
999 if let Some(text) = content.as_str() {
1000 return self.text_field("content", text, class, redactor, 1);
1001 }
1002 self.node(1)?;
1003 let Some(blocks) = content.as_array() else {
1004 return Ok(());
1005 };
1006 self.charge("content".len() + 8)?;
1007 for block in blocks {
1008 self.node(2)?;
1009 let Some(mut block_class) = eligible_class(block) else {
1010 continue;
1011 };
1012 if class == DisclosureClass::ModelVisibleAfterRedaction {
1013 block_class = class;
1014 }
1015 match block.get("type").and_then(Value::as_str) {
1016 Some("text") => {
1017 if let Some(text) = block.get("text").and_then(Value::as_str) {
1018 self.charge(8)?;
1019 self.field("type", "text", 3)?;
1020 self.text_field("text", text, block_class, redactor, 3)?;
1021 }
1022 }
1023 Some("tool_use") if role == "assistant" => {
1024 if let (Some(id), Some(name)) =
1025 (identifier(block, "id"), identifier(block, "name"))
1026 {
1027 self.charge(8)?;
1028 self.field("type", "tool_use", 3)?;
1029 self.field("id", id, 3)?;
1030 self.field("name", name, 3)?;
1031 if name != "context_checkpoint" && redactor {
1032 if let Some(input) = block.get("input") {
1033 if let Some(bytes) = self.input(input, 3)? {
1034 self.charge("input".len().saturating_add(bytes))?;
1035 }
1036 }
1037 }
1038 }
1039 }
1040 Some("tool_result") if role == "user" => {
1041 if let Some(id) = identifier(block, "tool_use_id") {
1042 self.charge(8)?;
1043 self.field("type", "tool_result", 3)?;
1044 self.field("tool_use_id", id, 3)?;
1045 if block.get("is_error").and_then(Value::as_bool).is_some() {
1046 self.field("is_error", "", 3)?;
1047 }
1048 if redactor {
1049 if let Some(content) = block.get("content") {
1050 self.tool_result(content, block_class)?;
1051 }
1052 }
1053 }
1054 }
1055 _ => {}
1056 }
1057 }
1058 Ok(())
1059 }
1060
1061 fn tool_result(&mut self, content: &Value, class: DisclosureClass) -> io::Result<()> {
1062 if let Some(text) = content.as_str() {
1063 return self.text_field("content", text, class, true, 3);
1064 }
1065 self.node(3)?;
1066 if let Some(blocks) = content.as_array() {
1067 self.charge("content".len() + 8)?;
1068 for block in blocks {
1069 self.node(4)?;
1070 if let Some(class) = eligible_class(block) {
1071 if block["type"] == "text" {
1072 if let Some(text) = block["text"].as_str() {
1073 self.charge(8)?;
1074 self.field("type", "text", 5)?;
1075 self.text_field("text", text, class, true, 5)?;
1076 }
1077 }
1078 }
1079 }
1080 }
1081 Ok(())
1082 }
1083
1084 fn input(&mut self, value: &Value, depth: usize) -> io::Result<Option<usize>> {
1090 self.node(depth)?;
1091 if eligible_class(value).is_none() {
1092 return Ok(None);
1093 }
1094 let mut bytes: usize = 8;
1095 match value {
1096 Value::String(text) => bytes = bytes.saturating_add(text.len()),
1097 Value::Array(values) => {
1098 for value in values {
1099 let Some(cost) = self.input(value, depth + 1)? else {
1100 return Ok(None);
1101 };
1102 bytes = bytes.saturating_add(cost);
1103 }
1104 }
1105 Value::Object(values) => {
1106 for (key, value) in values {
1107 let Some(cost) = self.input(value, depth + 1)? else {
1108 return Ok(None);
1109 };
1110 bytes = bytes.saturating_add(key.len()).saturating_add(cost);
1111 }
1112 }
1113 _ => {}
1114 }
1115 Ok(Some(bytes))
1116 }
1117}
1118
1119fn projection_digest(
1120 scope: &str,
1121 count: usize,
1122 messages: &[ArchivedMessage],
1123) -> io::Result<String> {
1124 let bytes = bounded_json(messages, MAX_SEGMENT_BYTES)?;
1125 Ok(digest_parts(&[
1126 scope.as_bytes(),
1127 &(count as u64).to_be_bytes(),
1128 &bytes,
1129 ]))
1130}
1131fn digest_parts(parts: &[&[u8]]) -> String {
1132 let mut hash = Sha256::new();
1133 for part in parts {
1134 hash.update((part.len() as u64).to_be_bytes());
1135 hash.update(part);
1136 }
1137 format!("{:x}", hash.finalize())
1138}
1139fn bounded_json<T: Serialize + ?Sized>(value: &T, limit: usize) -> io::Result<Vec<u8>> {
1140 struct Sink {
1141 bytes: Vec<u8>,
1142 limit: usize,
1143 }
1144 impl Write for Sink {
1145 fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
1146 if bytes.len() > self.limit - self.bytes.len() {
1147 return Err(invalid("archive serialized byte budget exceeded"));
1148 }
1149 self.bytes.extend_from_slice(bytes);
1150 Ok(bytes.len())
1151 }
1152 fn flush(&mut self) -> io::Result<()> {
1153 Ok(())
1154 }
1155 }
1156 let mut sink = Sink {
1157 bytes: Vec::new(),
1158 limit,
1159 };
1160 serde_json::to_writer(&mut sink, value).map_err(json_error)?;
1161 Ok(sink.bytes)
1162}
1163fn find_record(records: Vec<(usize, Record)>, id: &str) -> io::Result<Record> {
1164 records
1165 .into_iter()
1166 .find(|(_, r)| r.id == id)
1167 .map(|(_, r)| r)
1168 .ok_or_else(not_found)
1169}
1170fn prefix(text: &str, bytes: usize) -> &str {
1171 let mut end = bytes.min(text.len());
1172 while !text.is_char_boundary(end) {
1173 end -= 1;
1174 }
1175 &text[..end]
1176}
1177fn validate_key(key: &str) -> io::Result<()> {
1178 if key.is_empty() || key.len() > 4096 {
1179 return Err(invalid("archive scope/logical id must be 1..4096 bytes"));
1180 }
1181 Ok(())
1182}
1183
1184fn validate_id(id: &str) -> io::Result<()> {
1185 if id.len() != 32
1186 || !id
1187 .bytes()
1188 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
1189 {
1190 Err(invalid("invalid opaque archive id"))
1191 } else {
1192 Ok(())
1193 }
1194}
1195fn invalid(message: &'static str) -> io::Error {
1196 io::Error::new(io::ErrorKind::InvalidInput, message)
1197}
1198fn corrupt() -> io::Error {
1199 io::Error::new(io::ErrorKind::InvalidData, "invalid archive record")
1200}
1201fn not_found() -> io::Error {
1202 io::Error::new(
1203 io::ErrorKind::NotFound,
1204 "archive not available in this scope",
1205 )
1206}
1207fn json_error(_: serde_json::Error) -> io::Error {
1208 invalid("archive JSON serialization failed or exceeded budget")
1209}
1210
1211#[cfg(unix)]
1215struct PrivateDir(std::fs::File);
1216#[cfg(unix)]
1217impl PrivateDir {
1218 fn new(base: &Path, scope: &str) -> io::Result<Self> {
1219 use std::os::unix::fs::OpenOptionsExt;
1220 if !base.is_absolute() || base == Path::new("/") {
1221 return Err(invalid("archive base must be an absolute non-root path"));
1222 }
1223 let mut dir = Self(
1224 std::fs::OpenOptions::new()
1225 .read(true)
1226 .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC)
1227 .open("/")?,
1228 );
1229 let components: Vec<_> = base.components().collect();
1230 for (index, component) in components.iter().enumerate() {
1231 match component {
1232 std::path::Component::RootDir => {}
1233 std::path::Component::Normal(name) => {
1234 let name = name
1235 .to_str()
1236 .ok_or_else(|| invalid("archive path must be UTF-8"))?;
1237 dir = dir.child(name, index == components.len() - 1)?;
1238 }
1239 _ => return Err(invalid("archive base contains traversal")),
1240 }
1241 }
1242 dir = dir.child("context-archives", true)?;
1243 dir.child(scope, true)
1244 }
1245 fn open_existing(base: &Path, scope: &str) -> io::Result<Self> {
1246 use std::os::unix::fs::OpenOptionsExt;
1247 if !base.is_absolute() || base == Path::new("/") {
1248 return Err(invalid("archive base must be an absolute non-root path"));
1249 }
1250 let mut dir = Self(
1251 std::fs::OpenOptions::new()
1252 .read(true)
1253 .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC)
1254 .open("/")?,
1255 );
1256 for component in base.components() {
1257 match component {
1258 std::path::Component::RootDir => {}
1259 std::path::Component::Normal(name) => {
1260 let name = c_name(
1261 name.to_str()
1262 .ok_or_else(|| invalid("archive path must be UTF-8"))?,
1263 )?;
1264 dir = Self(dir.open(&name, libc::O_RDONLY | libc::O_DIRECTORY)?);
1265 }
1266 _ => return Err(invalid("archive base contains traversal")),
1267 }
1268 }
1269 for name in ["context-archives", scope] {
1270 dir = Self(dir.open(&c_name(name)?, libc::O_RDONLY | libc::O_DIRECTORY)?);
1271 }
1272 Ok(dir)
1273 }
1274
1275 fn fd(&self) -> libc::c_int {
1276 use std::os::fd::AsRawFd;
1277 self.0.as_raw_fd()
1278 }
1279 fn child(&self, name: &str, private: bool) -> io::Result<Self> {
1280 use crate::core::private_fs::DIR_MODE;
1281 use std::os::unix::fs::PermissionsExt;
1282 let name = c_name(name)?;
1283 let created =
1285 unsafe { libc::mkdirat(self.fd(), name.as_ptr(), DIR_MODE as libc::mode_t) } == 0;
1286 if !created {
1287 let error = io::Error::last_os_error();
1288 if error.raw_os_error() != Some(libc::EEXIST) {
1289 return Err(error);
1290 }
1291 }
1292 let file = self.open(&name, libc::O_RDONLY | libc::O_DIRECTORY)?;
1293 if private || created {
1294 file.set_permissions(std::fs::Permissions::from_mode(DIR_MODE))?;
1295 }
1296 self.sync()?;
1299 Ok(Self(file))
1300 }
1301 fn open(&self, name: &std::ffi::CStr, flags: i32) -> io::Result<std::fs::File> {
1302 use std::os::fd::FromRawFd;
1303 let fd = unsafe {
1304 libc::openat(
1305 self.fd(),
1306 name.as_ptr(),
1307 flags | libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK,
1308 crate::core::private_fs::FILE_MODE,
1309 )
1310 };
1311 if fd < 0 {
1312 Err(io::Error::last_os_error())
1313 } else {
1314 Ok(unsafe { std::fs::File::from_raw_fd(fd) })
1315 }
1316 }
1317 fn check_readonly(file: &std::fs::File) -> io::Result<()> {
1318 use std::os::unix::fs::MetadataExt;
1319 let meta = file.metadata()?;
1320 if !meta.is_file() || meta.nlink() != 1 || meta.uid() != unsafe { libc::geteuid() } {
1321 return Err(io::Error::new(
1322 io::ErrorKind::PermissionDenied,
1323 "archive requires owned regular single-link files",
1324 ));
1325 }
1326 Ok(())
1327 }
1328 fn check(file: &std::fs::File) -> io::Result<()> {
1329 use std::os::unix::fs::PermissionsExt;
1330 Self::check_readonly(file)?;
1331 file.set_permissions(std::fs::Permissions::from_mode(
1332 crate::core::private_fs::FILE_MODE,
1333 ))
1334 }
1335 fn identity(&self) -> io::Result<DirIdentity> {
1337 use std::os::unix::fs::MetadataExt;
1338 let meta = self.0.metadata()?;
1339 Ok((meta.dev(), meta.ino()))
1340 }
1341 fn lock_existing(&self) -> io::Result<Option<FlockGuard>> {
1342 let file = match self.open(&c_name("lock")?, libc::O_RDONLY) {
1343 Ok(file) => file,
1344 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
1345 Err(error) => return Err(error),
1346 };
1347 Self::check_readonly(&file)?;
1348 if !fs4::fs_std::FileExt::try_lock_shared(&file)? {
1349 return Err(io::Error::new(io::ErrorKind::WouldBlock, BUSY_EXPORT));
1350 }
1351 Ok(Some(FlockGuard(file)))
1352 }
1353 fn lock(&self) -> io::Result<FlockGuard> {
1355 let file = self.open(&c_name("lock")?, libc::O_RDWR | libc::O_CREAT)?;
1356 Self::check(&file)?;
1357 if !fs4::fs_std::FileExt::try_lock_exclusive(&file)? {
1358 return Err(io::Error::new(io::ErrorKind::WouldBlock, BUSY));
1359 }
1360 Ok(FlockGuard(file))
1361 }
1362 fn read_with(
1363 &self,
1364 slot: usize,
1365 remaining: usize,
1366 readonly: bool,
1367 ) -> io::Result<Option<Vec<u8>>> {
1368 let file = match self.open(&c_name(&format!("{slot:03}.json"))?, libc::O_RDONLY) {
1369 Ok(file) => file,
1370 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
1371 Err(error) => return Err(error),
1372 };
1373 if readonly {
1374 Self::check_readonly(&file)?;
1375 } else {
1376 Self::check(&file)?;
1377 }
1378 let limit = remaining.min(MAX_SEGMENT_BYTES);
1379 if file.metadata()?.len() > limit as u64 {
1380 return Err(corrupt());
1381 }
1382 let mut bytes = Vec::new();
1383 file.take(limit as u64 + 1).read_to_end(&mut bytes)?;
1384 if bytes.len() > limit {
1385 return Err(corrupt());
1386 }
1387 Ok(Some(bytes))
1388 }
1389 fn publish(&self, slot: usize, bytes: &[u8]) -> io::Result<()> {
1390 let target = c_name(&format!("{slot:03}.json"))?;
1391 let tmp = c_name("pending.tmp")?;
1392 if unsafe { libc::unlinkat(self.fd(), tmp.as_ptr(), 0) } != 0 {
1395 let error = io::Error::last_os_error();
1396 if error.kind() != io::ErrorKind::NotFound {
1397 return Err(error);
1398 }
1399 }
1400 let result = (|| {
1401 let mut file = self.open(&tmp, libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL)?;
1402 file.write_all(bytes)?;
1403 file.sync_all()?;
1404 match self.open(&target, libc::O_RDONLY) {
1406 Ok(existing) => Self::check(&existing)?,
1407 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
1408 Err(error) => return Err(error),
1409 }
1410 if unsafe { libc::renameat(self.fd(), tmp.as_ptr(), self.fd(), target.as_ptr()) } != 0 {
1411 return Err(io::Error::last_os_error());
1412 }
1413 self.sync()
1414 })();
1415 if result.is_err() {
1416 unsafe {
1417 libc::unlinkat(self.fd(), tmp.as_ptr(), 0);
1418 }
1419 }
1420 result
1421 }
1422 fn sync(&self) -> io::Result<()> {
1423 self.0.sync_all()
1424 }
1425}
1426#[cfg(unix)]
1427fn c_name(name: &str) -> io::Result<std::ffi::CString> {
1428 if name.is_empty() || matches!(name, "." | "..") || name.contains(['/', '\\']) {
1429 return Err(invalid("invalid archive component"));
1430 }
1431 std::ffi::CString::new(name).map_err(|_| invalid("invalid archive component"))
1432}
1433
1434#[cfg(not(unix))]
1435struct PrivateDir;
1436#[cfg(not(unix))]
1437impl PrivateDir {
1438 fn unsupported<T>() -> io::Result<T> {
1439 Err(io::Error::new(
1440 io::ErrorKind::Unsupported,
1441 "private context archive requires Unix",
1442 ))
1443 }
1444 fn new(_: &Path, _: &str) -> io::Result<Self> {
1445 Self::unsupported()
1446 }
1447 fn identity(&self) -> io::Result<DirIdentity> {
1448 Self::unsupported()
1449 }
1450 fn lock(&self) -> io::Result<FlockGuard> {
1451 Self::unsupported()
1452 }
1453 fn open_existing(_: &Path, _: &str) -> io::Result<Self> {
1454 Self::unsupported()
1455 }
1456 fn lock_existing(&self) -> io::Result<Option<FlockGuard>> {
1457 Self::unsupported()
1458 }
1459 fn read_with(&self, _: usize, _: usize, _: bool) -> io::Result<Option<Vec<u8>>> {
1460 Self::unsupported()
1461 }
1462 fn publish(&self, _: usize, _: &[u8]) -> io::Result<()> {
1463 Self::unsupported()
1464 }
1465 fn sync(&self) -> io::Result<()> {
1466 Self::unsupported()
1467 }
1468}
1469
1470#[cfg(test)]
1471mod projection_tests {
1472 use super::*;
1473
1474 fn excluded_fixture(blob: &str) -> Vec<SharedMessage> {
1475 vec![
1476 Arc::new(json!({"role":"system","content":blob})),
1477 Arc::new(json!({"role":"developer","content":blob})),
1478 Arc::new(json!({"role":"user","_synaps_context":{"note":blob},"content":blob})),
1479 Arc::new(json!({"role":"user","retention":"local_only","content":blob})),
1480 Arc::new(json!({"role":"assistant","metadata":blob,"content":[
1481 {"type":"thinking","thinking":blob},
1482 {"type":"text","private":true,"text":blob},
1483 {"type":"image","source":{"data":blob}},
1484 {"type":"document","source":{"data":blob}},
1485 {"type":"text","text":" exact evidence αβ\n","metadata":blob},
1486 {"type":"tool_use","id":"cp","name":"context_checkpoint","input":{"note":blob}}
1487 ]})),
1488 Arc::new(json!({"role":"user","content":[
1489 {"type":"tool_result","tool_use_id":"r1","metadata":blob,"content":[
1490 {"type":"image","source":{"data":blob}},
1491 {"type":"text","sensitivity":"secret","text":blob},
1492 {"type":"text","text":"exact tool evidence\n","metadata":blob}
1493 ]}
1494 ]})),
1495 ]
1496 }
1497
1498 #[test]
1499 fn excluded_aggregate_does_not_consume_eligible_budget_or_change_digest() {
1500 let messages = excluded_fixture(&"x".repeat(2 * 1024 * 1024));
1502 let source = messages.clone();
1503 for redactor in [None, Some(Arc::new(str::to_owned) as Arc<ArchiveRedactor>)] {
1504 let small =
1505 project_archive("excluded", &excluded_fixture("x"), "note", redactor.clone())
1506 .unwrap();
1507 let large = project_archive("excluded", &messages, "note", redactor).unwrap();
1508 assert!(small == large);
1509 assert_eq!(large.source_message_count, 6);
1510 assert_eq!(large.messages[0].source_index, 4);
1511 assert_eq!(large.messages[0].block_indices, vec![4, 5]);
1512 }
1513 for (old, new) in source.iter().zip(&messages) {
1514 assert!(Arc::ptr_eq(old, new));
1515 }
1516 }
1517
1518 #[test]
1519 fn eligible_aggregate_refuses_before_any_redactor_call() {
1520 use std::sync::atomic::{AtomicUsize, Ordering};
1521 let calls = Arc::new(AtomicUsize::new(0));
1522 let count = calls.clone();
1523 let redactor: Arc<ArchiveRedactor> = Arc::new(move |_| {
1524 count.fetch_add(1, Ordering::SeqCst);
1525 "small".to_owned()
1526 });
1527 let piece = "x".repeat(MAX_INPUT_BYTES / 2);
1528 for messages in [
1529 vec![
1530 Arc::new(json!({"role":"user","content":piece})),
1531 Arc::new(json!({"role":"assistant","content":piece})),
1532 ],
1533 vec![Arc::new(json!({"role":"user","content":[{
1534 "type":"tool_result","tool_use_id":"r1","content":[
1535 {"type":"text","text":piece}, {"type":"text","text":piece}
1536 ]
1537 }]}))],
1538 ] {
1539 let before = messages.clone();
1540 let error = project_archive("overflow", &messages, "", Some(redactor.clone()))
1541 .err()
1542 .unwrap();
1543 assert!(error.to_string().contains("input byte budget"));
1544 assert_eq!(calls.load(Ordering::SeqCst), 0);
1545 assert_eq!(before, messages);
1546 }
1547 }
1548
1549 #[test]
1550 fn large_tool_results_exact_with_redactor_opaque_without_it() {
1551 let text = "evidence αβ\n".repeat(250_000);
1552 let make = |text: &str| {
1553 vec![Arc::new(json!({"role":"user","content":[{
1554 "type":"tool_result","tool_use_id":"r1","content":text
1555 }]}))]
1556 };
1557 let p = project_archive("logs", &make(&text), "", Some(Arc::new(str::to_owned))).unwrap();
1558 assert_eq!(p.messages[0].message["content"][0]["content"], text);
1559 let huge = make(&"x".repeat(MAX_INPUT_BYTES + 1));
1560 let p = project_archive("logs", &huge, "", None).unwrap();
1561 assert_eq!(p.messages[0].message["content"][0]["content"], WITHHELD);
1562 assert!(project_archive("logs", &huge, "", Some(Arc::new(str::to_owned))).is_err());
1563 }
1564
1565 #[test]
1566 fn whole_argument_withholding_discards_tentative_bytes_not_structural_guards() {
1567 let huge = "x".repeat(MAX_INPUT_BYTES + 1);
1568 let make = |input: Value| {
1569 vec![Arc::new(json!({"role":"assistant","content":[{
1570 "type":"tool_use","id":"r1","name":"read","input":input
1571 }]}))]
1572 };
1573 for input in [
1575 json!({"a":huge,"z":{"sensitivity":"secret","body":huge}}),
1576 json!({"a":{"sensitivity":"secret","body":huge},"z":huge}),
1577 ] {
1578 let p =
1579 project_archive("input", &make(input), "", Some(Arc::new(str::to_owned))).unwrap();
1580 assert_eq!(
1581 p.messages[0].message["content"][0]["input"],
1582 json!({"_archive_withheld":true})
1583 );
1584 }
1585 let input = json!({"type":"image","data":"YWJj"});
1587 let p = project_archive(
1588 "input",
1589 &make(input.clone()),
1590 "",
1591 Some(Arc::new(str::to_owned)),
1592 )
1593 .unwrap();
1594 assert_eq!(p.messages[0].message["content"][0]["input"], input);
1595 let large = make(json!({"type":"image","data":huge}));
1596 assert!(project_archive("input", &large, "", Some(Arc::new(str::to_owned))).is_err());
1597 assert!(project_archive("input", &large, "", None).is_ok());
1598 }
1599
1600 #[test]
1601 fn visited_structure_is_bounded_but_excluded_descendants_are_opaque() {
1602 let mut deep = json!("leaf");
1603 for _ in 0..40 {
1604 deep = json!([deep]);
1605 }
1606 let make = |input: Value| {
1607 vec![Arc::new(json!({"role":"assistant","content":[{
1608 "type":"tool_use","id":"r1","name":"read","input":input
1609 }]}))]
1610 };
1611 let identity = Some(Arc::new(str::to_owned) as Arc<ArchiveRedactor>);
1612 assert!(project_archive("depth", &make(deep.clone()), "", identity.clone()).is_err());
1613 assert!(project_archive("depth", &make(deep.clone()), "", None).is_ok());
1614 assert!(project_archive(
1615 "depth",
1616 &make(json!({"private":true,"blob":deep})),
1617 "",
1618 identity.clone()
1619 )
1620 .is_ok());
1621 assert!(project_archive(
1623 "depth",
1624 &[Arc::new(json!({"role":"user","content":deep}))],
1625 "",
1626 identity.clone()
1627 )
1628 .is_ok());
1629 let wide = vec![Value::Null; 65536];
1630 assert!(project_archive("nodes", &make(json!(wide)), "", identity.clone()).is_err());
1631 let excluded = vec![Arc::new(json!({"role":"user","content":wide}))];
1632 assert!(project_archive("nodes", &excluded, "", identity).is_err());
1633 }
1634
1635 #[test]
1636 fn withheld_after_redaction_text_is_not_scanned_without_redactor() {
1637 let messages = vec![Arc::new(json!({"role":"user",
1638 "disclosure":"model_visible_after_redaction", "content":"x".repeat(MAX_INPUT_BYTES+1)
1639 }))];
1640 let p = project_archive("withheld", &messages, "", None).unwrap();
1641 assert_eq!(p.messages[0].message["content"], WITHHELD);
1642 assert!(project_archive("withheld", &messages, "", Some(Arc::new(str::to_owned))).is_err());
1643 }
1644
1645 #[test]
1646 fn json_escape_scratch_can_exceed_segment_limit_before_redaction() {
1647 let input = json!({"text":"\0".repeat(3 * 1024 * 1024)});
1650 let messages = vec![Arc::new(json!({"role":"assistant","content":[{
1651 "type":"tool_use","id":"r1","name":"read","input":input
1652 }]}))];
1653 let redactor: Arc<ArchiveRedactor> = Arc::new(|s| {
1654 if s.starts_with('{') {
1655 assert!(s.len() > MAX_SEGMENT_BYTES);
1656 "{\"text\":\"screened\"}".to_owned()
1657 } else {
1658 s.to_owned()
1659 }
1660 });
1661 let p = project_archive("escaping", &messages, "", Some(redactor)).unwrap();
1662 assert_eq!(
1663 p.messages[0].message["content"][0]["input"],
1664 json!({"text":"screened"})
1665 );
1666 assert!(project_archive("escaping", &messages, "", Some(Arc::new(str::to_owned))).is_err());
1667 }
1668
1669 fn compatibility_fixture() -> Vec<SharedMessage> {
1670 vec![
1671 Arc::new(json!({"role":"system","content":"omitted"})),
1672 Arc::new(json!({"role":"user","content":" exact αβ\n"})),
1673 Arc::new(json!({"role":"assistant","content":[
1674 {"type":"thinking","thinking":"private"},
1675 {"type":"text","text":"classified-value"},
1676 {"type":"tool_use","id":"r1","name":"read","input":{"path":"src/lib.rs","n":7,"binary":{"type":"image","data":"YWJj"}}},
1677 {"type":"tool_use","id":"c1","name":"context_checkpoint","input":{"note":"hidden"}},
1678 {"type":"tool_use","id":"x1","name":"read","input":{"nested":{"disclosure":"local_only","body":"omitted"}}}
1679 ]})),
1680 Arc::new(json!({"role":"user","content":[
1681 {"type":"tool_result","tool_use_id":"r1","is_error":false,"content":[
1682 {"type":"image","data":"omitted"},
1683 {"type":"text","text":"exact result\n"},
1684 {"type":"text","text":"classified-value","disclosure":"model_visible_after_redaction"}
1685 ]}
1686 ]})),
1687 Arc::new(
1688 json!({"role":"user","disclosure":"model_visible_after_redaction","content":"classified-value"}),
1689 ),
1690 Arc::new(json!({"role":"user","_synaps_context":{},"content":"hidden"})),
1691 ]
1692 }
1693
1694 #[test]
1695 fn legacy_projection_golden() {
1696 for (redactor, digest) in [
1698 (
1699 None,
1700 "aeb544a71629f741def049575eefb1a765117d65aa914bdcc1657fa677622031",
1701 ),
1702 (
1703 Some(
1704 Arc::new(|s: &str| s.replace("classified-value", "[redacted]"))
1705 as Arc<ArchiveRedactor>,
1706 ),
1707 "d70b321210ca3d2bc531b31a35cc02ee267eeb572e8a6dd88f1fb62725b082ec",
1708 ),
1709 ] {
1710 let p = project_archive(
1711 "compat-session",
1712 &compatibility_fixture(),
1713 "hidden note",
1714 redactor,
1715 )
1716 .unwrap();
1717 assert_eq!(p.digest, digest);
1718 }
1719 }
1720
1721 #[test]
1722 fn pure_projection_keeps_legacy_logical_digest_and_never_opens_paths() {
1723 let tmp = tempfile::tempdir().unwrap();
1724 let logical = tmp
1725 .path()
1726 .join("not-created/session")
1727 .to_str()
1728 .unwrap()
1729 .to_owned();
1730 let messages = vec![
1731 Arc::new(json!({"role":"system","content":"OMITTED"})),
1732 Arc::new(json!({"role":"assistant","content":[
1733 {"type":"thinking","thinking":"OMITTED"},
1734 {"type":"text","text":" exact αβ evidence\n"}
1735 ]})),
1736 ];
1737 let source = serde_json::to_vec(&messages).unwrap();
1738 let first = project_archive(&logical, &messages, "hidden note", None).unwrap();
1739 assert_eq!(first.logical_id, digest_parts(&[logical.as_bytes()]));
1740 assert_eq!(
1741 first.digest,
1742 projection_digest(&first.logical_id, 2, &first.messages).unwrap()
1743 );
1744 assert_eq!(first.messages[0].source_index, 1);
1745 assert_eq!(first.messages[0].block_indices, vec![1]);
1746 assert_eq!(first.source_message_count, 2);
1747 let mut changed = messages.clone();
1748 changed[0] = Arc::new(json!({"role":"developer","content":"DIFFERENT OMITTED"}));
1749 let second = project_archive(&logical, &changed, "changed note", None).unwrap();
1750 assert_eq!(first.digest, second.digest);
1751 assert_ne!(
1752 first.digest,
1753 project_archive("other-session", &messages, "hidden note", None)
1754 .unwrap()
1755 .digest
1756 );
1757 assert_eq!(serde_json::to_vec(&messages).unwrap(), source);
1758 assert_eq!(std::fs::read_dir(tmp.path()).unwrap().count(), 0);
1759 }
1760
1761 #[test]
1762 fn pure_projection_shares_disclosure_redaction_and_input_limits() {
1763 let redactor: Arc<ArchiveRedactor> =
1764 Arc::new(|s| s.replace("classified-value", "[redacted]"));
1765 let messages = vec![
1766 Arc::new(json!({"role":"assistant","content":[
1767 {"type":"tool_use","id":"read_1","name":"read","input":{"path":"src/lib.rs"}},
1768 {"type":"tool_use","id":"cp","name":"context_checkpoint","input":{"note":"NOTE_ONLY"}}
1769 ]})),
1770 Arc::new(
1771 json!({"role":"user","content":[{"type":"tool_result","tool_use_id":"read_1","content":"classified-value"}]}),
1772 ),
1773 Arc::new(json!({"role":"user","sensitivity":"secret","content":"OMITTED"})),
1774 Arc::new(json!({"role":"user","_synaps_context":{},"content":"NOTE_ONLY"})),
1775 Arc::new(json!({"role":"user","content":crate::core::host_note::tool_in_flight_note_text(
1777 "Canceled by user",
1778 &["bash".into()],
1779 )})),
1780 ];
1781 let projected =
1782 project_archive("session", &messages, "classified-value", Some(redactor)).unwrap();
1783 assert_eq!(projected.note, "[redacted]");
1784 let json = serde_json::to_string(&projected.messages).unwrap();
1785 assert!(!json.contains("classified-value"));
1786 assert!(!json.contains("NOTE_ONLY"));
1787 assert!(!json.contains("partially applied"), "the tool note is not archived");
1788 assert!(!json.contains("OMITTED"));
1789 assert!(json.contains("src/lib.rs"));
1790 assert_eq!(projected.messages.len(), 2);
1791 let withheld = project_archive("session", &messages, "password=bad", None).unwrap();
1792 assert_eq!(withheld.note, WITHHELD);
1793 assert!(!serde_json::to_string(&withheld.messages)
1794 .unwrap()
1795 .contains("src/lib.rs"));
1796 assert!(project_archive("", &[], "", None).is_err());
1797 assert!(project_archive(&"l".repeat(4097), &[], "", None).is_err());
1798 assert!(project_archive("s", &[], &"n".repeat(MAX_NOTE_BYTES + 1), None).is_err());
1799 assert!(
1800 project_archive("s", &vec![messages[0].clone(); MAX_MESSAGES + 1], "", None).is_err()
1801 );
1802 }
1803}
1804
1805#[cfg(all(test, unix))]
1809#[serial_test::parallel(process_spawn)]
1810mod tests {
1811 use super::*;
1812 use std::os::unix::fs::{symlink, PermissionsExt};
1813
1814 fn msg(role: &str, content: Value) -> SharedMessage {
1815 Arc::new(json!({"role":role,"content":content}))
1816 }
1817 fn setup() -> (tempfile::TempDir, ArchiveStore) {
1818 let tmp = tempfile::tempdir().unwrap();
1819 let store =
1820 ArchiveStore::new(tmp.path(), "synthetic-project", "synthetic-logical").unwrap();
1821 (tmp, store)
1822 }
1823 fn directory(tmp: &tempfile::TempDir, store: &ArchiveStore) -> std::path::PathBuf {
1824 tmp.path().join("context-archives").join(&store.scope)
1825 }
1826
1827 #[test]
1828 fn export_preview_is_read_only_and_matches_projection_including_tombstones() {
1829 use std::os::unix::fs::MetadataExt;
1830 let tmp = tempfile::tempdir().unwrap();
1831 assert!(export_in(&tmp.path().join("missing"), "synthetic-project")
1832 .unwrap()
1833 .is_empty());
1834 assert!(export_in(tmp.path(), "synthetic-project")
1835 .unwrap()
1836 .is_empty());
1837 assert_eq!(std::fs::read_dir(tmp.path()).unwrap().count(), 0);
1838 let store =
1839 ArchiveStore::new(tmp.path(), "synthetic-project", "synthetic-logical").unwrap();
1840 let dir = directory(&tmp, &store);
1841 assert!(export_in(tmp.path(), "synthetic-project")
1843 .unwrap()
1844 .is_empty());
1845 assert!(!dir.join("lock").exists());
1846 let messages = vec![msg("user", json!("migration evidence"))];
1847 let reference = store.seal(&messages, "hidden note").unwrap();
1848 let projection =
1849 project_archive("synthetic-logical", &messages, "hidden note", None).unwrap();
1850 let tombstone = store
1851 .seal(&[msg("assistant", json!("remove me"))], "remove note")
1852 .unwrap();
1853 store.forget(&tombstone.id).unwrap();
1854 let mut paths = vec![
1855 tmp.path().to_path_buf(),
1856 tmp.path().join("context-archives"),
1857 dir.clone(),
1858 ];
1859 paths.extend(std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().path()));
1860 for path in &paths {
1862 let mode = if path.is_dir() { 0o750 } else { 0o640 };
1863 std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap();
1864 }
1865 let snapshot = || {
1866 paths
1867 .iter()
1868 .map(|p| {
1869 let m = std::fs::metadata(p).unwrap();
1870 (
1871 m.mode(),
1872 m.mtime(),
1873 m.mtime_nsec(),
1874 if m.is_file() {
1875 std::fs::read(p).unwrap()
1876 } else {
1877 vec![]
1878 },
1879 )
1880 })
1881 .collect::<Vec<_>>()
1882 };
1883 let before = snapshot();
1884 let rows = export_in(tmp.path(), "synthetic-project").unwrap();
1885 assert_eq!(rows, store.export_records().unwrap());
1886 assert_eq!(before, snapshot());
1887 assert_eq!(
1888 rows[0],
1889 json!({"id":reference.id,"digest":projection.digest,"logical_id":projection.logical_id,
1890 "source_message_count":1,"messages":projection.messages,"note":"hidden note","tombstone":false})
1891 );
1892 assert_eq!(rows[1]["id"], tombstone.id);
1893 assert_eq!(rows[1]["tombstone"], true);
1894 assert_eq!(rows[1]["source_message_count"], 0);
1895 assert_eq!(rows[1]["messages"], json!([]));
1896 assert_eq!(rows[1]["note"], "");
1897 let lock = store.fs.lock().unwrap();
1898 assert_eq!(
1899 export_in(tmp.path(), "synthetic-project")
1900 .unwrap_err()
1901 .kind(),
1902 io::ErrorKind::WouldBlock
1903 );
1904 drop(lock);
1905 std::fs::write(dir.join("000.json"), b"corrupt").unwrap();
1906 assert!(export_in(tmp.path(), "synthetic-project").is_err());
1907 }
1908
1909 #[test]
1910 fn export_preview_refuses_links_and_never_creates_a_missing_lock() {
1911 let (tmp, store) = setup();
1912 let dir = directory(&tmp, &store);
1913 let r = store
1914 .seal(&[msg("user", json!("evidence"))], "note")
1915 .unwrap();
1916 let path = dir.join("000.json");
1917 let bytes = std::fs::read(&path).unwrap();
1918 std::fs::remove_file(dir.join("lock")).unwrap();
1919 assert!(export_in(tmp.path(), "synthetic-project").is_err());
1920 assert!(!dir.join("lock").exists());
1921 assert!(store.fetch(&r.id, 0, 1).is_ok());
1923 std::fs::remove_file(&path).unwrap();
1924 let outside = tmp.path().join("outside");
1925 std::fs::write(&outside, &bytes).unwrap();
1926 symlink(&outside, &path).unwrap();
1927 assert!(export_in(tmp.path(), "synthetic-project").is_err());
1928 std::fs::remove_file(&path).unwrap();
1929 std::fs::hard_link(&outside, &path).unwrap();
1930 assert!(export_in(tmp.path(), "synthetic-project").is_err());
1931 let alias = tmp.path().join("alias");
1932 symlink(tmp.path(), &alias).unwrap();
1933 assert!(export_in(&alias, "synthetic-project").is_err());
1934 assert_eq!(std::fs::read(&outside).unwrap(), bytes);
1935 }
1936
1937 #[test]
1938 fn attachments_remain_outside_eligible_memory_archive() {
1939 let (tmp, store) = setup();
1940 let messages = vec![msg(
1941 "user",
1942 json!([
1943 {"type":"text","text":"Compare the selected files"},
1944 {"type":"image","source":{"type":"base64","media_type":"image/png","data":"ATTACHMENT_IMAGE_SECRET"}},
1945 {"type":"document","title":"ATTACHMENT_FILENAME_SECRET","source":{"type":"base64","media_type":"application/pdf","data":"ATTACHMENT_PDF_SECRET"}},
1946 {"type":"document","source":{"type":"text","media_type":"text/plain","data":"ATTACHMENT_TEXT_SECRET"}}
1947 ]),
1948 )];
1949 let reference = store.seal(&messages, "safe checkpoint").unwrap();
1950 let fetched = store.fetch(&reference.id, 0, 10).unwrap();
1951 let serialized = serde_json::to_string(&fetched).unwrap();
1952 assert!(serialized.contains("Compare the selected files"));
1953 assert!(!serialized.contains("ATTACHMENT_"));
1954 let raw = std::fs::read_to_string(directory(&tmp, &store).join("000.json")).unwrap();
1955 assert!(!raw.contains("ATTACHMENT_"));
1956 assert!(messages[0].to_string().contains("ATTACHMENT_TEXT_SECRET"));
1957 }
1958
1959 #[test]
1960 fn exact_text_source_indices_note_and_restart() {
1961 let (tmp, store) = setup();
1962 let messages = vec![
1963 msg("system", json!("FORBIDDEN SYSTEM")),
1964 msg("user", json!(" exact\n αβ ")),
1965 msg(
1966 "assistant",
1967 json!([
1968 {"type":"thinking","thinking":"FORBIDDEN REASONING"},
1969 {"type":"text","text":"evidence✓", "cache_control":{"type":"ephemeral"}},
1970 {"type":"image","data":"FORBIDDEN IMAGE"}
1971 ]),
1972 ),
1973 msg("developer", json!("FORBIDDEN DEVELOPER")),
1974 ];
1975 let reference = store.seal(&messages, "hidden routing note").unwrap();
1976 assert_eq!(reference.message_count, 2);
1977 assert_eq!(reference.source_message_count, 4);
1978 let reopened =
1979 ArchiveStore::new(tmp.path(), "synthetic-project", "synthetic-logical").unwrap();
1980 let fetched = reopened
1981 .fetch_with_provenance(&reference.id, 0, 128)
1982 .unwrap();
1983 assert_eq!(fetched[0].message, messages[1]);
1984 assert_eq!(fetched[0].source_index, 1);
1985 assert_eq!(fetched[1].source_index, 2);
1986 assert_eq!(fetched[1].block_indices, vec![1]);
1987 assert_eq!(
1988 reopened.fetch_note(&reference.id).unwrap(),
1989 "hidden routing note"
1990 );
1991 assert!(reopened.search("hidden routing", 10).unwrap().is_empty());
1992 assert_eq!(reopened.search("evidence", 10).unwrap()[0].id, reference.id);
1993 assert!(reopened
1994 .fetch(&reference.id, usize::MAX, 10)
1995 .unwrap()
1996 .is_empty());
1997 let raw = std::fs::read_to_string(directory(&tmp, &store).join("000.json")).unwrap();
1998 assert!(!raw.contains("FORBIDDEN"));
1999 assert!(!raw.contains("cache_control"));
2000 assert_eq!(messages[0]["content"], "FORBIDDEN SYSTEM");
2001 }
2002
2003 #[test]
2004 fn duplicate_retry_ignores_note_but_not_logical_id() {
2005 let (tmp, store) = setup();
2006 let messages = vec![msg("user", json!("same eligible words"))];
2007 let first = store.seal(&messages, "first").unwrap();
2008 assert_eq!(store.seal(&messages, "second").unwrap(), first);
2009 assert_eq!(store.fetch_note(&first.id).unwrap(), "first");
2010 let other = ArchiveStore::new(tmp.path(), "synthetic-project", "other-logical").unwrap();
2011 let second = other.seal(&messages, "").unwrap();
2012 assert_ne!(first.id, second.id);
2013 assert_eq!(
2014 project_search(tmp.path(), "synthetic-project", "", 10)
2015 .unwrap()
2016 .len(),
2017 2
2018 );
2019 assert_eq!(
2020 project_fetch(tmp.path(), "synthetic-project", &first.id, 0, 10).unwrap(),
2021 messages
2022 );
2023 assert_eq!(other.fetch(&first.id, 0, 1).unwrap(), messages);
2024 store.forget(&first.id).unwrap();
2025 assert!(store.seal(&messages, "third").is_err());
2026 assert_eq!(other.fetch(&second.id, 0, 1).unwrap(), messages);
2027 assert_eq!(other.seal(&messages, "third").unwrap(), second);
2028 }
2029
2030 #[test]
2031 fn deletion_is_content_free_durable_and_cannot_resurrect() {
2032 let (tmp, store) = setup();
2033 let messages = vec![msg("user", json!("uniquely removable content"))];
2034 let reference = store.seal(&messages, "removable note").unwrap();
2035 store.forget(&reference.id).unwrap();
2036 store.forget(&reference.id).unwrap();
2037 let raw = std::fs::read_to_string(directory(&tmp, &store).join("000.json")).unwrap();
2038 assert!(raw.contains("tombstone"));
2039 assert!(!raw.contains("removable"));
2040 assert!(!raw.contains("source_message_count"));
2041 let store =
2042 ArchiveStore::new(tmp.path(), "synthetic-project", "synthetic-logical").unwrap();
2043 assert_eq!(
2044 store.fetch(&reference.id, 0, 1).unwrap_err().kind(),
2045 io::ErrorKind::NotFound
2046 );
2047 assert!(store.fetch_note(&reference.id).is_err());
2048 assert!(store.search("", 10).unwrap().is_empty());
2049 assert_eq!(
2050 store.seal(&messages, "changed note").unwrap_err().kind(),
2051 io::ErrorKind::PermissionDenied
2052 );
2053 }
2054
2055 #[test]
2056 fn foreign_scope_copied_records_and_traversal_fail_closed() {
2057 let (tmp, store) = setup();
2058 let reference = store
2059 .seal(&[msg("user", json!("scope evidence"))], "")
2060 .unwrap();
2061 let foreign =
2062 ArchiveStore::new(tmp.path(), "foreign-project", "synthetic-logical").unwrap();
2063 assert!(foreign.fetch(&reference.id, 0, 1).is_err());
2064 for id in [
2065 "../000.json",
2066 "/tmp/file",
2067 "",
2068 "a/../b",
2069 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
2070 ] {
2071 assert_eq!(
2072 store.fetch(id, 0, 1).unwrap_err().kind(),
2073 io::ErrorKind::InvalidInput
2074 );
2075 assert!(store.forget(id).is_err());
2076 }
2077 std::fs::copy(
2078 directory(&tmp, &store).join("000.json"),
2079 directory(&tmp, &foreign).join("000.json"),
2080 )
2081 .unwrap();
2082 assert_eq!(
2083 foreign.search("", 1).unwrap_err().kind(),
2084 io::ErrorKind::InvalidData
2085 );
2086 assert!(ArchiveStore::new(&tmp.path().join("../escape"), "p", "l").is_err());
2087 }
2088
2089 #[test]
2090 fn disclosure_and_raw_tool_data_are_withheld() {
2091 let (tmp, store) = setup();
2092 let mut messages = vec![
2093 msg(
2094 "assistant",
2095 json!([{"type":"tool_use","id":"call_1","name":"read","input":{"path":"unclassified raw argument"}}]),
2096 ),
2097 msg(
2098 "user",
2099 json!([{"type":"tool_result","tool_use_id":"call_1","content":"unclassified raw output"}]),
2100 ),
2101 msg("user", json!("password=synthetic-credential")),
2102 ];
2103 for class in [
2104 "local_only",
2105 "persist_never_transmit",
2106 "never_persist",
2107 "model_visible_after_consent",
2108 "unknown",
2109 ] {
2110 messages.push(Arc::new(
2111 json!({"role":"user","content":"FORBIDDEN DISCLOSURE","disclosure":class}),
2112 ));
2113 }
2114 messages.push(Arc::new(
2115 json!({"role":"user","content":"FORBIDDEN SENSITIVITY","sensitivity":"secret"}),
2116 ));
2117 let reference = store.seal(&messages, "").unwrap();
2118 assert_eq!(reference.message_count, 3);
2119 let raw = std::fs::read_to_string(directory(&tmp, &store).join("000.json")).unwrap();
2120 for forbidden in ["unclassified raw", "synthetic-credential", "FORBIDDEN"] {
2121 assert!(!raw.contains(forbidden));
2122 }
2123 assert!(raw.contains("call_1"));
2124 assert!(raw.contains("_archive_withheld"));
2125 }
2126
2127 #[test]
2128 fn derived_context_envelope_is_not_searchable_source() {
2129 let (_tmp, store) = setup();
2130 let first = store
2131 .seal(
2132 &[msg("assistant", json!("original evidence"))],
2133 "NOTE_ONLY_SENTINEL",
2134 )
2135 .unwrap();
2136 let second=store.seal(&[
2137 Arc::new(json!({"role":"user","_synaps_context":{"schema":"synaps-context-window/1","archive":first.id},"content":"NOTE_ONLY_SENTINEL"})),
2138 Arc::new(json!({"role":"assistant","content":[{"type":"tool_use","id":"checkpoint-note","name":"context_checkpoint","input":{"phase":"plan","note":"NOTE_ONLY_SENTINEL"}}]})),
2139 msg("assistant",json!("second-window evidence")),
2140 ],"next note").unwrap();
2141 assert!(store.search("NOTE_ONLY_SENTINEL", 10).unwrap().is_empty());
2142 let fetched = store.fetch_with_provenance(&second.id, 0, 10).unwrap();
2143 assert_eq!(fetched.len(), 2);
2144 assert_eq!(fetched[0].source_index, 1);
2145 assert_eq!(fetched[1].source_index, 2);
2146 assert!(!serde_json::to_string(&fetched)
2147 .unwrap()
2148 .contains("NOTE_ONLY_SENTINEL"));
2149 }
2150
2151 #[test]
2152 fn checkpoint_tool_note_stays_hidden_even_with_redactor() {
2153 let (_tmp, store) = setup();
2154 let store = store.with_redactor(Arc::new(str::to_owned));
2155 let reference = store
2156 .seal(
2157 &[msg(
2158 "assistant",
2159 json!([
2160 {"type":"tool_use","id":"checkpoint-note","name":"context_checkpoint",
2161 "input":{"phase":"plan","note":"NOTE_ARGUMENT_SENTINEL"}}
2162 ]),
2163 )],
2164 "NOTE_ARGUMENT_SENTINEL",
2165 )
2166 .unwrap();
2167 assert!(store
2168 .search("NOTE_ARGUMENT_SENTINEL", 10)
2169 .unwrap()
2170 .is_empty());
2171 assert!(
2172 !serde_json::to_string(&store.fetch(&reference.id, 0, 10).unwrap())
2173 .unwrap()
2174 .contains("NOTE_ARGUMENT_SENTINEL")
2175 );
2176 assert_eq!(
2177 store.fetch_note(&reference.id).unwrap(),
2178 "NOTE_ARGUMENT_SENTINEL"
2179 );
2180 }
2181
2182 #[test]
2183 fn trusted_redactor_retains_exact_eligible_tool_evidence() {
2184 let (_tmp, store) = setup();
2185 let store = store.with_redactor(Arc::new(|s| s.replace("classified-value", "[redacted]")));
2186 let messages = vec![
2187 msg(
2188 "assistant",
2189 json!([{"type":"tool_use","id":"call_1","name":"read","input":{"path":"src/lib.rs","line":7}}]),
2190 ),
2191 msg(
2192 "user",
2193 json!([{"type":"tool_result","tool_use_id":"call_1","is_error":false,"content":"line 7\n exact evidence\nclassified-value"}]),
2194 ),
2195 ];
2196 let reference = store.seal(&messages, "").unwrap();
2197 let fetched = store.fetch(&reference.id, 0, 10).unwrap();
2198 assert_eq!(fetched[0], messages[0]);
2199 assert_eq!(
2200 fetched[1]["content"][0]["content"],
2201 "line 7\n exact evidence\n[redacted]"
2202 );
2203 }
2204
2205 #[test]
2206 fn oversized_input_output_limits_and_depth_leave_source_intact() {
2207 let (_tmp, store) = setup();
2208 let huge = vec![msg("user", json!("x".repeat(MAX_INPUT_BYTES + 1)))];
2209 assert!(store.seal(&huge, "").is_err());
2210 assert_eq!(
2211 huge[0]["content"].as_str().unwrap().len(),
2212 MAX_INPUT_BYTES + 1
2213 );
2214 let file_too_large = vec![msg("user", json!("\n".repeat(MAX_SEGMENT_BYTES / 2)))];
2215 assert!(store.seal(&file_too_large, "").is_err());
2216 assert!(store
2217 .seal(&vec![msg("user", json!("a")); MAX_MESSAGES + 1], "")
2218 .is_err());
2219 assert!(store.seal(&[], &"n".repeat(MAX_NOTE_BYTES + 1)).is_err());
2220 let mut deep = json!("value");
2221 for _ in 0..40 {
2222 deep = json!([deep]);
2223 }
2224 let deep = vec![msg(
2227 "assistant",
2228 json!([{
2229 "type":"tool_use", "id":"r1", "name":"read", "input":deep
2230 }]),
2231 )];
2232 let store = store.with_redactor(Arc::new(str::to_owned));
2233 assert!(store.seal(&deep, "").is_err());
2234 assert!(store.search(&"q".repeat(513), 1).is_err());
2235 assert!(store.search("", MAX_SEARCH_RESULTS + 1).is_err());
2236 assert!(store
2237 .fetch(&"a".repeat(32), 0, MAX_FETCH_MESSAGES + 1)
2238 .is_err());
2239 assert!(store.search("", 10).unwrap().is_empty());
2240 }
2241
2242 #[test]
2243 fn torn_final_fails_closed_stale_temporary_is_not_visible() {
2244 let (tmp, store) = setup();
2245 let dir = directory(&tmp, &store);
2246 std::fs::write(dir.join("pending.tmp"), b"unpublished partial").unwrap();
2247 assert!(store.search("", 1).unwrap().is_empty());
2248 let reference = store.seal(&[msg("user", json!("complete"))], "").unwrap();
2249 assert!(!dir.join("pending.tmp").exists());
2250 std::fs::write(dir.join("000.json"), b"{\"version\":1,").unwrap();
2251 assert_eq!(
2252 store.fetch(&reference.id, 0, 1).unwrap_err().kind(),
2253 io::ErrorKind::InvalidData
2254 );
2255 assert!(store.search("", 1).is_err());
2256 assert!(store.seal(&[msg("user", json!("next"))], "").is_err());
2257 assert!(store.forget(&reference.id).is_err());
2258 }
2259
2260 #[test]
2261 fn private_modes_symlink_hardlink_fifo_and_lock_refusal() {
2262 let (tmp, store) = setup();
2263 let dir = directory(&tmp, &store);
2264 let reference = store.seal(&[msg("user", json!("private"))], "").unwrap();
2265 for path in [
2266 tmp.path().to_path_buf(),
2267 tmp.path().join("context-archives"),
2268 dir.clone(),
2269 ] {
2270 assert_eq!(
2271 std::fs::metadata(path).unwrap().permissions().mode() & 0o777,
2272 0o700
2273 );
2274 }
2275 for name in ["000.json", "lock"] {
2276 assert_eq!(
2277 std::fs::metadata(dir.join(name))
2278 .unwrap()
2279 .permissions()
2280 .mode()
2281 & 0o777,
2282 0o600
2283 );
2284 }
2285 let lock = store.fs.lock().unwrap();
2286 assert_eq!(
2287 store.fetch(&reference.id, 0, 1).unwrap_err().kind(),
2288 io::ErrorKind::WouldBlock
2289 );
2290 drop(lock);
2291 let victim = tmp.path().join("synthetic-victim");
2292 std::fs::write(&victim, "do not change").unwrap();
2293 std::fs::remove_file(dir.join("000.json")).unwrap();
2294 symlink(&victim, dir.join("000.json")).unwrap();
2295 assert!(store.search("", 1).is_err());
2296 assert!(store.seal(&[msg("user", json!("private"))], "").is_err());
2297 assert_eq!(std::fs::read_to_string(&victim).unwrap(), "do not change");
2298 std::fs::remove_file(dir.join("000.json")).unwrap();
2299 std::fs::hard_link(&victim, dir.join("000.json")).unwrap();
2300 assert!(store.search("", 1).is_err());
2301 std::fs::remove_file(dir.join("000.json")).unwrap();
2302 let fifo = std::ffi::CString::new(dir.join("000.json").to_str().unwrap()).unwrap();
2303 assert_eq!(unsafe { libc::mkfifo(fifo.as_ptr(), 0o600) }, 0);
2304 assert!(store.search("", 1).is_err());
2305 let alias = tmp.path().join("alias");
2306 symlink(tmp.path().join("context-archives"), &alias).unwrap();
2307 assert!(ArchiveStore::new(&alias.join("nested"), "p", "l").is_err());
2308 std::fs::remove_file(dir.join("lock")).unwrap();
2309 symlink(&victim, dir.join("lock")).unwrap();
2310 assert!(store.fs.lock().is_err());
2311 }
2312
2313 #[test]
2314 fn multi_mib_context_roundtrips_without_truncation() {
2315 let (_tmp, store) = setup();
2316 let text = "eligible exact evidence\n".repeat(400_000);
2317 assert!(text.len() > 8 * 1024 * 1024);
2318 let messages = vec![msg("user", json!(text))];
2319 let reference = store.seal(&messages, "large source window").unwrap();
2320 assert_eq!(store.fetch(&reference.id, 0, 1).unwrap(), messages);
2321 assert_eq!(store.seal(&messages, "retry").unwrap(), reference);
2322 }
2323
2324 #[test]
2325 fn private_reasoning_disguised_as_assistant_text_is_omitted() {
2326 let (_tmp, store) = setup();
2327 let messages = vec![
2328 Arc::new(json!({"role":"assistant","channel":"analysis","content":"private analysis"})),
2329 Arc::new(
2330 json!({"role":"assistant","content_class":"private_reasoning","content":"private reasoning"}),
2331 ),
2332 Arc::new(json!({"role":"assistant","private":true,"content":"private text"})),
2333 Arc::new(json!({"role":"assistant","channel":"final","content":"public evidence"})),
2334 ];
2335 let reference = store.seal(&messages, "").unwrap();
2336 assert_eq!(reference.message_count, 1);
2337 let fetched = store.fetch_with_provenance(&reference.id, 0, 1).unwrap();
2338 assert_eq!(fetched[0].source_index, 3);
2339 assert_eq!(fetched[0].message["content"], "public evidence");
2340 }
2341
2342 #[test]
2343 fn after_redaction_requires_host_redactor_including_parent_class() {
2344 let (_tmp, store) = setup();
2345 let messages = vec![Arc::new(json!({
2346 "role":"assistant", "disclosure":"model_visible_after_redaction",
2347 "content":[{"type":"text", "text":"sensitive host classified material"}]
2348 }))];
2349 let reference = store.seal(&messages, "").unwrap();
2350 let fetched = store.fetch(&reference.id, 0, 1).unwrap();
2351 assert_eq!(fetched[0]["content"][0]["text"], WITHHELD);
2352 }
2353
2354 #[test]
2355 fn bounded_search_utf8_and_permanent_capacity() {
2356 let (_tmp, store) = setup();
2357 for index in 0..MAX_SEGMENTS {
2358 store
2359 .seal(
2360 &[msg("user", json!(format!("{index} {}", "é".repeat(200))))],
2361 "",
2362 )
2363 .unwrap();
2364 }
2365 let descriptors = store.search("", MAX_SEARCH_RESULTS).unwrap();
2366 assert_eq!(descriptors.len(), MAX_SEARCH_RESULTS);
2367 assert!(descriptors.iter().all(|d| d.snippet.len() <= 256));
2368 assert!(store.seal(&[msg("user", json!("overflow"))], "").is_err());
2369 store.forget(&descriptors[0].id).unwrap();
2370 assert!(store.seal(&[msg("user", json!("still full"))], "").is_err());
2371 }
2372}
2373
2374#[cfg(all(test, unix))]
2375mod lock_tests {
2376 use super::*;
2377 use std::time::{Duration, Instant};
2378
2379 fn msg(role: &str, content: Value) -> SharedMessage {
2380 Arc::new(json!({"role":role,"content":content}))
2381 }
2382 fn setup() -> (tempfile::TempDir, ArchiveStore) {
2383 let tmp = tempfile::tempdir().unwrap();
2384 let store =
2385 ArchiveStore::new(tmp.path(), "synthetic-project", "synthetic-logical").unwrap();
2386 (tmp, store)
2387 }
2388
2389 fn wait_until(condition: impl Fn() -> bool) {
2391 let started = Instant::now();
2392 while !condition() {
2393 assert!(started.elapsed() < Duration::from_secs(30), "timed out");
2394 std::thread::sleep(Duration::from_millis(1));
2395 }
2396 }
2397
2398 fn gate_of(store: &ArchiveStore) -> Arc<Gate> {
2400 Gate::for_directory(store.fs.identity().unwrap())
2401 }
2402
2403 #[test]
2408 fn a_descriptor_inherited_by_a_forked_child_does_not_keep_the_archive_locked() {
2409 use std::io::{Read, Write};
2410 use std::os::fd::AsRawFd;
2411 use std::os::unix::process::CommandExt;
2412 use std::process::ExitStatus;
2413 use std::thread::JoinHandle;
2414 struct Child {
2416 release: Option<io::PipeWriter>,
2417 spawner: Option<JoinHandle<io::Result<ExitStatus>>>,
2418 }
2419 impl Child {
2420 fn release_and_reap(&mut self) -> Option<std::thread::Result<io::Result<ExitStatus>>> {
2421 if let Some(mut release) = self.release.take() {
2423 let _ = release.write_all(b"r");
2424 }
2425 self.spawner.take().map(JoinHandle::join)
2427 }
2428 }
2429 impl Drop for Child {
2430 fn drop(&mut self) {
2431 let _ = self.release_and_reap();
2432 }
2433 }
2434 let (_tmp, store) = setup();
2435 let reference = store
2436 .seal(&[msg("user", json!("evidence"))], "note")
2437 .unwrap();
2438 let (mut forked, signal) = io::pipe().unwrap();
2440 let (hold, release) = io::pipe().unwrap();
2441 let (signal_fd, hold_fd) = (signal.as_raw_fd(), hold.as_raw_fd());
2442 let held = store.fs.lock().unwrap();
2443 let mut child = Child {
2444 release: Some(release),
2445 spawner: Some(std::thread::spawn(move || {
2446 let mut command = std::process::Command::new("true");
2447 unsafe {
2449 command.pre_exec(move || {
2450 let interrupted =
2451 || io::Error::last_os_error().raw_os_error() == Some(libc::EINTR);
2452 loop {
2453 match libc::write(signal_fd, b"f".as_ptr().cast(), 1) {
2454 1 => break,
2455 -1 if interrupted() => {}
2456 _ => libc::_exit(1),
2458 }
2459 }
2460 let mut byte = 0u8;
2461 while libc::read(hold_fd, (&mut byte as *mut u8).cast(), 1) < 0
2462 && interrupted()
2463 {}
2464 Ok(())
2465 });
2466 }
2467 let status = command.status();
2468 drop((signal, hold));
2469 status
2470 })),
2471 };
2472 let deadline = Instant::now() + Duration::from_secs(30);
2475 loop {
2476 let mut ready = libc::pollfd {
2477 fd: forked.as_raw_fd(),
2478 events: libc::POLLIN,
2479 revents: 0,
2480 };
2481 let left = deadline.saturating_duration_since(Instant::now());
2482 let timeout = left.as_millis().try_into().unwrap_or(i32::MAX);
2483 match unsafe { libc::poll(&mut ready, 1, timeout) } {
2484 1 => break,
2485 0 => panic!("the forked child never signaled"),
2486 _ => assert_eq!(io::Error::last_os_error().raw_os_error(), Some(libc::EINTR)),
2487 }
2488 }
2489 forked.read_exact(&mut [0; 1]).unwrap();
2490 drop(held);
2491 let fetched = store.fetch(&reference.id, 0, 1);
2493 let status = child.release_and_reap().unwrap().unwrap().unwrap();
2494 assert!(status.success());
2495 assert_eq!(fetched.unwrap().len(), 1);
2496 }
2497
2498 #[test]
2501 fn an_expired_waiter_is_not_admitted_when_the_gate_frees_late() {
2502 let gate = Gate::for_directory((u64::MAX, u64::MAX));
2503 let holder = Gate::enter(gate.clone(), Duration::ZERO, None, BUSY).unwrap();
2504 let wait = Duration::from_millis(50);
2505 let waiter = {
2506 let gate = gate.clone();
2507 std::thread::spawn(move || Gate::enter(gate, wait, None, BUSY).map(drop))
2508 };
2509 wait_until(|| gate.waited.load(Ordering::Acquire) == 1);
2510 {
2511 let mut held = gate.held.lock().unwrap();
2514 *held = false;
2515 std::thread::sleep(wait * 2);
2516 }
2517 let error = waiter.join().unwrap().unwrap_err();
2518 assert_eq!(error.kind(), io::ErrorKind::WouldBlock);
2519 assert_eq!(error.to_string(), BUSY);
2520 drop(holder);
2521 Gate::enter(gate, Duration::ZERO, None, BUSY).unwrap();
2523 }
2524
2525 #[test]
2526 fn concurrent_operations_in_one_process_wait_instead_of_reporting_busy() {
2527 let tmp = tempfile::tempdir().unwrap();
2528 let workers = 8;
2529 let barrier = Arc::new(std::sync::Barrier::new(workers));
2530 let handles: Vec<_> = (0..workers)
2531 .map(|worker| {
2532 let base = tmp.path().to_path_buf();
2533 let barrier = barrier.clone();
2534 std::thread::spawn(move || {
2535 let logical = format!("synthetic-logical-{worker}");
2536 let store = ArchiveStore::new(&base, "synthetic-project", &logical)?;
2537 barrier.wait();
2538 for round in 0..8 {
2539 let text = format!("worker {worker} round {round}");
2540 let reference = store.seal(&[msg("user", json!(text))], "note")?;
2541 assert_eq!(store.fetch(&reference.id, 0, 1)?.len(), 1);
2542 assert_eq!(store.fetch_note(&reference.id)?, "note");
2543 store.search("worker", 4)?;
2544 }
2545 Ok::<_, io::Error>(())
2546 })
2547 })
2548 .collect();
2549 let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
2550 for result in results {
2551 result.unwrap();
2552 }
2553 let store = ArchiveStore::new(tmp.path(), "synthetic-project", "synthetic-reader").unwrap();
2554 assert_eq!(store.scan().unwrap().0.len(), workers * 8);
2555 }
2556
2557 #[test]
2559 fn a_lock_held_outside_this_process_still_fails_closed_without_waiting() {
2560 let (_tmp, store) = setup();
2561 let reference = store
2562 .seal(&[msg("user", json!("evidence"))], "note")
2563 .unwrap();
2564 let foreign = store.fs.lock().unwrap();
2565 let started = Instant::now();
2566 let results = [
2567 store
2568 .seal(&[msg("user", json!("other evidence"))], "")
2569 .map(drop),
2570 store.fetch(&reference.id, 0, 1).map(drop),
2571 store.fetch_note(&reference.id).map(drop),
2572 store.search("", 1).map(drop),
2573 store.forget(&reference.id),
2574 ];
2575 for result in results {
2576 assert_eq!(result.unwrap_err().kind(), io::ErrorKind::WouldBlock);
2577 }
2578 assert!(started.elapsed() < Duration::from_secs(10));
2579 drop(foreign);
2580 assert_eq!(store.fetch(&reference.id, 0, 1).unwrap().len(), 1);
2581 assert_eq!(store.scan().unwrap().0.len(), 1);
2582 }
2583
2584 fn other(tmp: &tempfile::TempDir) -> ArchiveStore {
2585 ArchiveStore::new(tmp.path(), "synthetic-project", "synthetic-other").unwrap()
2586 }
2587
2588 #[test]
2589 fn an_operation_queues_behind_the_one_in_progress_in_this_process() {
2590 let (tmp, store) = setup();
2591 let held = store.exclusive().unwrap();
2592 let queued = other(&tmp);
2593 let waiter =
2594 std::thread::spawn(move || queued.seal(&[msg("user", json!("queued evidence"))], ""));
2595 let gate = gate_of(&store);
2596 wait_until(|| gate.waited.load(Ordering::Acquire) == 1);
2597 assert!(!waiter.is_finished());
2598 drop(held);
2599 let reference = waiter.join().unwrap().unwrap();
2600 assert_eq!(store.fetch(&reference.id, 0, 1).unwrap().len(), 1);
2601 }
2602
2603 #[test]
2604 fn a_stuck_operation_in_this_process_fails_closed_after_the_bounded_wait() {
2605 let (tmp, store) = setup();
2606 let held = store.exclusive().unwrap();
2607 let mut queued = other(&tmp);
2608 queued.wait = Duration::from_millis(50);
2609 let started = Instant::now();
2610 let error = queued.search("", 1).unwrap_err();
2611 assert_eq!(error.kind(), io::ErrorKind::WouldBlock);
2612 assert_eq!(error.to_string(), BUSY);
2613 assert!(started.elapsed() >= queued.wait);
2614 drop(held);
2615 assert!(queued.search("", 1).unwrap().is_empty());
2616 }
2617
2618 #[test]
2619 fn an_aborted_operation_never_starts_even_while_queued() {
2620 let (tmp, store) = setup();
2621 let abort = Arc::new(AtomicBool::new(true));
2622 let error = other(&tmp)
2623 .with_abort(abort.clone())
2624 .seal(&[msg("user", json!("abandoned before start"))], "")
2625 .unwrap_err();
2626 assert_eq!(error.kind(), io::ErrorKind::Interrupted);
2627
2628 abort.store(false, Ordering::Release);
2629 let held = store.exclusive().unwrap();
2630 let queued = other(&tmp).with_abort(abort.clone());
2631 let waiter = std::thread::spawn(move || {
2632 queued.seal(&[msg("user", json!("abandoned while queued"))], "")
2633 });
2634 let gate = gate_of(&store);
2635 wait_until(|| gate.waited.load(Ordering::Acquire) == 1);
2636 assert!(!waiter.is_finished());
2637 abort.store(true, Ordering::Release);
2638 let error = waiter.join().unwrap().unwrap_err();
2640 assert_eq!(error.kind(), io::ErrorKind::Interrupted);
2641 drop(held);
2642 assert!(store.scan().unwrap().0.is_empty());
2643 assert!(store.search("", 1).unwrap().is_empty());
2644 }
2645
2646 #[test]
2647 fn seal_with_note_returns_the_note_stored_with_the_segment() {
2648 let (_tmp, store) = setup();
2649 let messages = [msg("user", json!("evidence"))];
2650 let (first, note) = store.seal_with_note(&messages, "first note").unwrap();
2651 assert_eq!(note, "first note");
2652 let (again, note) = store.seal_with_note(&messages, "second note").unwrap();
2653 assert_eq!(again, first);
2654 assert_eq!(note, "first note");
2655 assert_eq!(store.fetch_note(&first.id).unwrap(), "first note");
2656 store.forget(&first.id).unwrap();
2657 let error = store.seal_with_note(&messages, "").unwrap_err();
2658 assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
2659 }
2660}