1use crate::core::disclosure::{gate_for_model, may_persist, DisclosureClass, ModelVisibility};
40use crate::core::stream_types::SharedMessage;
41use serde::{Deserialize, Serialize};
42use serde_json::{json, Value};
43use sha2::{Digest, Sha256};
44use std::io::{self, Read, Write};
45use std::path::Path;
46use std::sync::Arc;
47
48pub const MAX_SEGMENTS: usize = 128;
49pub const MAX_SEGMENT_BYTES: usize = 16 * 1024 * 1024;
50pub const MAX_STORE_BYTES: usize = 256 * 1024 * 1024;
51pub const MAX_INPUT_BYTES: usize = 16 * 1024 * 1024;
52pub const MAX_MESSAGES: usize = 4096;
53pub const MAX_NOTE_BYTES: usize = 8192;
54pub const MAX_FETCH_MESSAGES: usize = 128;
55pub const MAX_SEARCH_RESULTS: usize = 32;
56const WITHHELD: &str = "[archive: withheld]";
57
58pub type ArchiveRedactor = dyn Fn(&str) -> String + Send + Sync;
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct ArchiveRef {
65 pub id: String,
66 pub message_count: usize,
68 pub source_message_count: usize,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct ArchiveDescriptor {
73 pub id: String,
74 pub message_count: usize,
75 pub source_message_count: usize,
76 pub snippet: String,
78}
79
80#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
81#[serde(deny_unknown_fields)]
82pub struct ArchivedMessage {
83 pub source_index: usize,
85 pub block_indices: Vec<usize>,
87 pub message: SharedMessage,
88}
89
90#[derive(Clone, PartialEq, Serialize, Deserialize)]
94#[serde(deny_unknown_fields)]
95pub struct ArchiveProjection {
96 pub logical_id: String,
97 pub source_message_count: usize,
98 pub messages: Vec<ArchivedMessage>,
99 pub note: String,
100 pub digest: String,
101}
102
103pub fn project_archive(
106 logical_id: &str,
107 messages: &[SharedMessage],
108 note: &str,
109 redactor: Option<Arc<ArchiveRedactor>>,
110) -> io::Result<ArchiveProjection> {
111 validate_key(logical_id)?;
112 project_hashed(
113 &digest_parts(&[logical_id.as_bytes()]),
114 messages,
115 note,
116 redactor,
117 )
118}
119
120fn project_hashed(
121 logical_id: &str,
122 messages: &[SharedMessage],
123 note: &str,
124 redactor: Option<Arc<ArchiveRedactor>>,
125) -> io::Result<ArchiveProjection> {
126 if messages.len() > MAX_MESSAGES || note.len() > MAX_NOTE_BYTES {
127 return Err(invalid("archive message/note budget exceeded"));
128 }
129 let mut budget = InputBudget { bytes: 0, nodes: 0 };
130 for message in messages {
131 budget.message(message, redactor.is_some())?;
132 }
133 let projector = Projector { redactor };
134 let mut eligible = Vec::new();
135 for (source_index, message) in messages.iter().enumerate() {
136 if let Some((message, block_indices)) = projector.project(message)? {
137 eligible.push(ArchivedMessage {
138 source_index,
139 block_indices,
140 message: Arc::new(message),
141 });
142 }
143 }
144 let digest = projection_digest(logical_id, messages.len(), &eligible)?;
145 let note = projector.screen(note, DisclosureClass::ModelVisible)?;
146 if note.len() > MAX_NOTE_BYTES {
147 return Err(invalid("redacted note exceeds archive budget"));
148 }
149 Ok(ArchiveProjection {
150 logical_id: logical_id.to_owned(),
151 source_message_count: messages.len(),
152 messages: eligible,
153 note,
154 digest,
155 })
156}
157
158struct Projector {
159 redactor: Option<Arc<ArchiveRedactor>>,
160}
161
162pub struct ArchiveStore {
164 fs: PrivateDir,
165 scope: String,
166 logical: String,
167 redactor: Option<Arc<ArchiveRedactor>>,
168}
169
170#[derive(Serialize, Deserialize)]
171#[serde(deny_unknown_fields)]
172struct Record {
173 version: u32,
174 scope: String,
175 logical: String,
176 id: String,
177 digest: String,
178 body: Body,
179}
180
181#[derive(Serialize, Deserialize)]
182#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
183enum Body {
184 Live {
185 source_message_count: usize,
186 messages: Vec<ArchivedMessage>,
187 note: String,
188 },
189 Tombstone,
190}
191
192impl Record {
193 fn reference(&self) -> io::Result<ArchiveRef> {
194 match &self.body {
195 Body::Live {
196 source_message_count,
197 messages,
198 ..
199 } => Ok(ArchiveRef {
200 id: self.id.clone(),
201 message_count: messages.len(),
202 source_message_count: *source_message_count,
203 }),
204 Body::Tombstone => Err(not_found()),
205 }
206 }
207}
208
209impl ArchiveStore {
210 pub fn new(base: &Path, scope_key: &str, logical_id: &str) -> io::Result<Self> {
211 for key in [scope_key, logical_id] {
212 validate_key(key)?;
213 }
214 let scope = digest_parts(&[scope_key.as_bytes()]);
215 let logical = digest_parts(&[logical_id.as_bytes()]);
216 let fs = PrivateDir::new(base, &scope)?;
217 Ok(Self {
218 fs,
219 scope,
220 logical,
221 redactor: None,
222 })
223 }
224
225 pub fn with_redactor(mut self, redactor: Arc<ArchiveRedactor>) -> Self {
228 self.redactor = Some(redactor);
229 self
230 }
231
232 pub fn seal(&self, messages: &[SharedMessage], note: &str) -> io::Result<ArchiveRef> {
233 let projection = project_hashed(&self.logical, messages, note, self.redactor.clone())?;
234 let ArchiveProjection {
235 digest,
236 source_message_count,
237 messages: eligible,
238 note,
239 ..
240 } = projection;
241 let _lock = self.fs.lock()?;
242 let (records, used) = self.scan()?;
243 if let Some((_, record)) = records.iter().find(|(_, r)| r.digest == digest) {
244 if matches!(record.body, Body::Tombstone) {
245 return Err(io::Error::new(
246 io::ErrorKind::PermissionDenied,
247 "archive projection was forgotten",
248 ));
249 }
250 self.fs.sync()?;
252 return record.reference();
253 }
254 let slot = (0..MAX_SEGMENTS)
255 .find(|slot| !records.iter().any(|(s, _)| s == slot))
256 .ok_or_else(|| invalid("archive segment budget exhausted (tombstones retained)"))?;
257 let record = Record {
258 version: 1,
259 scope: self.scope.clone(),
260 logical: self.logical.clone(),
261 id: uuid::Uuid::new_v4().simple().to_string(),
262 digest,
263 body: Body::Live {
264 source_message_count,
265 messages: eligible,
266 note,
267 },
268 };
269 let bytes = bounded_json(&record, MAX_SEGMENT_BYTES)?;
270 if used + bytes.len() > MAX_STORE_BYTES {
271 return Err(invalid("archive storage byte budget exhausted"));
272 }
273 self.fs.publish(slot, &bytes)?;
274 record.reference()
275 }
276
277 pub fn fetch(&self, id: &str, start: usize, limit: usize) -> io::Result<Vec<SharedMessage>> {
278 Ok(self
279 .fetch_with_provenance(id, start, limit)?
280 .into_iter()
281 .map(|m| m.message)
282 .collect())
283 }
284
285 pub fn fetch_with_provenance(
288 &self,
289 id: &str,
290 start: usize,
291 limit: usize,
292 ) -> io::Result<Vec<ArchivedMessage>> {
293 validate_id(id)?;
294 if limit > MAX_FETCH_MESSAGES {
295 return Err(invalid("archive fetch limit exceeds 128"));
296 }
297 let _lock = self.fs.lock()?;
298 let (records, _) = self.scan()?;
299 let record = find_record(records, id)?;
300 match record.body {
301 Body::Live { messages, .. } => {
302 Ok(messages.into_iter().skip(start).take(limit).collect())
303 }
304 Body::Tombstone => Err(not_found()),
305 }
306 }
307
308 pub fn fetch_note(&self, id: &str) -> io::Result<String> {
311 validate_id(id)?;
312 let _lock = self.fs.lock()?;
313 let (records, _) = self.scan()?;
314 match find_record(records, id)?.body {
315 Body::Live { note, .. } => Ok(note),
316 Body::Tombstone => Err(not_found()),
317 }
318 }
319
320 pub fn search(&self, query: &str, limit: usize) -> io::Result<Vec<ArchiveDescriptor>> {
324 if query.len() > 512 || limit > MAX_SEARCH_RESULTS {
325 return Err(invalid("archive search query/result budget exceeded"));
326 }
327 if limit == 0 {
328 return Ok(Vec::new());
329 }
330 let query = query.to_lowercase();
331 let _lock = self.fs.lock()?;
332 let (records, _) = self.scan()?;
333 let mut result = Vec::new();
334 for (_, record) in records {
335 if let Body::Live {
336 messages,
337 source_message_count,
338 ..
339 } = &record.body
340 {
341 let mut snippet = None;
342 for message in messages {
343 let text = serde_json::to_string(&message.message).map_err(json_error)?;
344 if query.is_empty() || text.to_lowercase().contains(&query) {
345 snippet = Some(prefix(&text, 256).to_owned());
346 break;
347 }
348 }
349 if snippet.is_some() || (query.is_empty() && messages.is_empty()) {
350 result.push(ArchiveDescriptor {
351 id: record.id,
352 message_count: messages.len(),
353 source_message_count: *source_message_count,
354 snippet: snippet.unwrap_or_default(),
355 });
356 }
357 }
358 if result.len() == limit {
359 break;
360 }
361 }
362 Ok(result)
363 }
364
365 pub fn forget(&self, id: &str) -> io::Result<()> {
369 validate_id(id)?;
370 let _lock = self.fs.lock()?;
371 let (records, _) = self.scan()?;
372 let (slot, mut record) = records
373 .into_iter()
374 .find(|(_, r)| r.id == id)
375 .ok_or_else(not_found)?;
376 if matches!(record.body, Body::Tombstone) {
377 return self.fs.sync();
378 }
379 record.body = Body::Tombstone;
380 self.fs
381 .publish(slot, &bounded_json(&record, MAX_SEGMENT_BYTES)?)
382 }
383
384 pub fn export_records(&self) -> io::Result<Vec<Value>> {
388 let lock = self.fs.lock_existing()?;
389 let (records, _) = self.scan_with(true)?;
390 if lock.is_none() && !records.is_empty() {
391 return Err(corrupt());
392 }
393 Ok(records
394 .into_iter()
395 .map(|(_, record)| {
396 let (source_message_count, messages, note, tombstone) = match record.body {
397 Body::Live {
398 source_message_count,
399 messages,
400 note,
401 } => (source_message_count, messages, note, false),
402 Body::Tombstone => (0, Vec::new(), String::new(), true),
403 };
404 json!({
405 "id": record.id, "digest": record.digest, "logical_id": record.logical,
406 "source_message_count": source_message_count, "messages": messages,
407 "note": note, "tombstone": tombstone,
408 })
409 })
410 .collect())
411 }
412
413 fn scan(&self) -> io::Result<(Vec<(usize, Record)>, usize)> {
414 self.scan_with(false)
415 }
416
417 fn scan_with(&self, readonly: bool) -> io::Result<(Vec<(usize, Record)>, usize)> {
418 let mut records = Vec::new();
419 let mut used = 0;
420 for slot in 0..MAX_SEGMENTS {
421 let Some(bytes) = self.fs.read_with(slot, MAX_STORE_BYTES - used, readonly)? else {
422 continue;
423 };
424 used += bytes.len();
425 let record: Record = serde_json::from_slice(&bytes).map_err(|_| corrupt())?;
426 if record.version != 1
427 || record.scope != self.scope
428 || record.logical.len() != 64
429 || !record.logical.bytes().all(|b| b.is_ascii_hexdigit())
430 || validate_id(&record.id).is_err()
431 || record.digest.len() != 64
432 || !record.digest.bytes().all(|b| b.is_ascii_hexdigit())
433 {
434 return Err(corrupt());
435 }
436 if let Body::Live {
437 messages,
438 source_message_count,
439 note,
440 } = &record.body
441 {
442 if messages.len() > MAX_MESSAGES
443 || *source_message_count > MAX_MESSAGES
444 || note.len() > MAX_NOTE_BYTES
445 || messages
446 .iter()
447 .any(|m| m.source_index >= *source_message_count)
448 || messages
449 .windows(2)
450 .any(|m| m[0].source_index >= m[1].source_index)
451 || projection_digest(&record.logical, *source_message_count, messages)?
452 != record.digest
453 {
454 return Err(corrupt());
455 }
456 }
457 if records
458 .iter()
459 .any(|(_, r): &(usize, Record)| r.id == record.id || r.digest == record.digest)
460 {
461 return Err(corrupt());
462 }
463 records.push((slot, record));
464 }
465 Ok((records, used))
466 }
467}
468
469impl Projector {
470 fn screen(&self, text: &str, class: DisclosureClass) -> io::Result<String> {
471 if class == DisclosureClass::ModelVisibleAfterRedaction && self.redactor.is_none() {
472 return Ok(WITHHELD.to_owned());
473 }
474 let redactor = |text: &str| {
475 let text = self
476 .redactor
477 .as_ref()
478 .map_or_else(|| text.to_owned(), |r| r(text));
479 if credential_like(&text) {
480 WITHHELD.to_owned()
481 } else {
482 text
483 }
484 };
485 let text = redactor(text);
487 if text.len() > MAX_SEGMENT_BYTES {
488 return Err(invalid("redactor output exceeds archive budget"));
489 }
490 match gate_for_model(class, &text, false, Some(&|s| s.to_owned())) {
491 ModelVisibility::Visible(text) => Ok(text),
492 ModelVisibility::Withheld(_) => Ok(WITHHELD.to_owned()),
493 }
494 }
495
496 fn project(&self, message: &Value) -> io::Result<Option<(Value, Vec<usize>)>> {
497 let Some((class, role, content)) = projection_content(message) else {
498 return Ok(None);
499 };
500 if let Some(text) = content.as_str() {
501 return Ok(Some((
502 json!({"role": role, "content": self.screen(text, class)?}),
503 Vec::new(),
504 )));
505 }
506 let Some(blocks) = content.as_array() else {
507 return Ok(None);
508 };
509 let mut output = Vec::new();
510 let mut indices = Vec::new();
511 for (index, block) in blocks.iter().enumerate() {
512 let Some(mut block_class) = eligible_class(block) else {
513 continue;
514 };
515 if class == DisclosureClass::ModelVisibleAfterRedaction {
516 block_class = class;
517 }
518 let kept = match block.get("type").and_then(Value::as_str) {
519 Some("text") => block
520 .get("text")
521 .and_then(Value::as_str)
522 .map(|text| {
523 self.screen(text, block_class)
524 .map(|text| json!({"type":"text", "text":text}))
525 })
526 .transpose()?,
527 Some("tool_use") if role == "assistant" => {
528 if let (Some(id), Some(name)) =
529 (identifier(block, "id"), identifier(block, "name"))
530 {
531 let input = if name == "context_checkpoint" {
532 json!({"_archive_withheld":true})
535 } else if self.redactor.is_some() {
536 self.tool_input(block.get("input"), block_class)?
537 } else {
538 json!({"_archive_withheld":true})
539 };
540 Some(json!({"type":"tool_use", "id":id, "name":name, "input":input}))
541 } else {
542 None
543 }
544 }
545 Some("tool_result") if role == "user" => {
546 if let Some(id) = identifier(block, "tool_use_id") {
547 let content = if self.redactor.is_some() {
548 self.tool_result(&block["content"], block_class)?
549 } else {
550 Value::String(WITHHELD.to_owned())
551 };
552 let mut result =
553 json!({"type":"tool_result", "tool_use_id":id, "content":content});
554 if let Some(error) = block.get("is_error").and_then(Value::as_bool) {
555 result["is_error"] = json!(error);
556 }
557 Some(result)
558 } else {
559 None
560 }
561 }
562 _ => None,
563 };
564 if let Some(kept) = kept {
565 output.push(kept);
566 indices.push(index);
567 }
568 }
569 if output.is_empty() {
570 Ok(None)
571 } else {
572 Ok(Some((json!({"role":role,"content":output}), indices)))
573 }
574 }
575
576 fn tool_input(&self, input: Option<&Value>, class: DisclosureClass) -> io::Result<Value> {
577 if let Some(input) = input {
578 let mut budget = InputBudget { bytes: 0, nodes: 0 };
584 if let Some(bytes) = budget.input(input, 3)? {
585 budget.charge(bytes)?;
586 let limit = bytes
587 .checked_mul(6)
588 .ok_or_else(|| invalid("archive input byte budget exceeded"))?;
589 let encoded = bounded_json(input, limit)?;
590 let text =
591 self.screen(std::str::from_utf8(&encoded).map_err(|_| corrupt())?, class)?;
592 return Ok(serde_json::from_str(&text)
593 .unwrap_or_else(|_| json!({"_archive_withheld":true})));
594 }
595 }
596 Ok(json!({"_archive_withheld":true}))
597 }
598
599 fn tool_result(&self, content: &Value, class: DisclosureClass) -> io::Result<Value> {
600 if let Some(text) = content.as_str() {
601 return Ok(json!(self.screen(text, class)?));
602 }
603 let mut output = Vec::new();
604 if let Some(blocks) = content.as_array() {
605 for block in blocks {
606 if let Some(class) = eligible_class(block) {
607 if block["type"] == "text" {
608 if let Some(text) = block["text"].as_str() {
609 output.push(json!({"type":"text", "text":self.screen(text, class)?}));
610 }
611 }
612 }
613 }
614 }
615 if output.is_empty() {
616 Ok(json!(WITHHELD))
617 } else {
618 Ok(json!(output))
619 }
620 }
621}
622
623pub fn export_in(base: &Path, scope_key: &str) -> io::Result<Vec<Value>> {
627 validate_key(scope_key)?;
628 let scope = digest_parts(&[scope_key.as_bytes()]);
629 let fs = match PrivateDir::open_existing(base, &scope) {
630 Ok(fs) => fs,
631 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
632 Err(error) => return Err(error),
633 };
634 ArchiveStore {
635 fs,
636 scope,
637 logical: String::new(), redactor: None,
639 }
640 .export_records()
641}
642
643pub fn project_search(
646 base: &Path,
647 scope_key: &str,
648 query: &str,
649 limit: usize,
650) -> io::Result<Vec<ArchiveDescriptor>> {
651 ArchiveStore::new(base, scope_key, "project-reader")?.search(query, limit)
652}
653
654pub fn project_fetch(
656 base: &Path,
657 scope_key: &str,
658 id: &str,
659 start: usize,
660 limit: usize,
661) -> io::Result<Vec<SharedMessage>> {
662 ArchiveStore::new(base, scope_key, "project-reader")?.fetch(id, start, limit)
663}
664
665fn eligible_class(value: &Value) -> Option<DisclosureClass> {
666 if value
669 .get("channel")
670 .is_some_and(|v| !matches!(v.as_str(), Some("final")))
671 || value.get("content_class").is_some_and(|v| {
672 !matches!(
673 v.as_str(),
674 Some("user_text" | "assistant_text" | "tool_calls" | "tool_results")
675 )
676 })
677 || value
678 .get("private")
679 .is_some_and(|v| v != &Value::Bool(false))
680 {
681 return None;
682 }
683 for key in ["sensitivity", "retention", "retention_class"] {
684 if let Some(v) = value.get(key) {
685 if !matches!(v.as_str(), Some("normal" | "standard" | "model_visible")) {
686 return None;
687 }
688 }
689 }
690 let mut class = DisclosureClass::ModelVisible;
691 for key in ["disclosure", "disclosure_class"] {
692 if let Some(v) = value.get(key) {
693 let parsed = DisclosureClass::parse(v.as_str()?)?;
694 if !may_persist(parsed)
695 || !matches!(
696 parsed,
697 DisclosureClass::ModelVisible | DisclosureClass::ModelVisibleAfterRedaction
698 )
699 {
700 return None;
701 }
702 class = parsed;
703 }
704 }
705 Some(class)
706}
707
708fn projection_content(message: &Value) -> Option<(DisclosureClass, &str, &Value)> {
711 if message.get("_synaps_context").is_some() {
712 return None;
713 }
714 let class = eligible_class(message)?;
715 let role @ ("user" | "assistant") = message.get("role")?.as_str()? else {
716 return None;
717 };
718 Some((class, role, message.get("content")?))
719}
720
721fn identifier<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
722 let text = value.get(key)?.as_str()?;
723 (!text.is_empty()
724 && text.len() <= 128
725 && !credential_like(text)
726 && text
727 .bytes()
728 .all(|b| b.is_ascii_alphanumeric() || b"_-.:".contains(&b)))
729 .then_some(text)
730}
731
732fn credential_like(text: &str) -> bool {
734 let lower = text.to_ascii_lowercase();
735 [
736 "password",
737 "passwd",
738 "api_key",
739 "apikey",
740 "api-key",
741 "access_token",
742 "refresh_token",
743 "secret",
744 "authorization",
745 "bearer ",
746 "private key",
747 "sk-",
748 "ghp_",
749 "github_pat_",
750 "xoxb-",
751 "akia",
752 "eyjh",
753 ]
754 .iter()
755 .any(|needle| lower.contains(needle))
756}
757
758struct InputBudget {
759 bytes: usize,
760 nodes: usize,
761}
762impl InputBudget {
763 fn node(&mut self, depth: usize) -> io::Result<()> {
764 self.nodes += 1;
765 if depth > 32 || self.nodes > 65536 {
766 return Err(invalid("archive input structural budget exceeded"));
767 }
768 Ok(())
769 }
770
771 fn charge(&mut self, bytes: usize) -> io::Result<()> {
772 self.bytes = self.bytes.saturating_add(bytes);
773 if self.bytes > MAX_INPUT_BYTES {
774 return Err(invalid("archive input byte budget exceeded"));
775 }
776 Ok(())
777 }
778
779 fn field(&mut self, key: &str, text: &str, depth: usize) -> io::Result<()> {
782 self.node(depth)?;
783 self.charge(key.len().saturating_add(8).saturating_add(text.len()))
784 }
785
786 fn text_field(
787 &mut self,
788 key: &str,
789 text: &str,
790 class: DisclosureClass,
791 redactor: bool,
792 depth: usize,
793 ) -> io::Result<()> {
794 if class == DisclosureClass::ModelVisibleAfterRedaction && !redactor {
795 return self.node(depth); }
797 self.field(key, text, depth)
798 }
799
800 fn message(&mut self, message: &Value, redactor: bool) -> io::Result<()> {
804 self.node(0)?;
805 let Some((class, role, content)) = projection_content(message) else {
806 return Ok(());
807 };
808 self.charge(8)?;
809 self.field("role", role, 1)?;
810 if let Some(text) = content.as_str() {
811 return self.text_field("content", text, class, redactor, 1);
812 }
813 self.node(1)?;
814 let Some(blocks) = content.as_array() else {
815 return Ok(());
816 };
817 self.charge("content".len() + 8)?;
818 for block in blocks {
819 self.node(2)?;
820 let Some(mut block_class) = eligible_class(block) else {
821 continue;
822 };
823 if class == DisclosureClass::ModelVisibleAfterRedaction {
824 block_class = class;
825 }
826 match block.get("type").and_then(Value::as_str) {
827 Some("text") => {
828 if let Some(text) = block.get("text").and_then(Value::as_str) {
829 self.charge(8)?;
830 self.field("type", "text", 3)?;
831 self.text_field("text", text, block_class, redactor, 3)?;
832 }
833 }
834 Some("tool_use") if role == "assistant" => {
835 if let (Some(id), Some(name)) =
836 (identifier(block, "id"), identifier(block, "name"))
837 {
838 self.charge(8)?;
839 self.field("type", "tool_use", 3)?;
840 self.field("id", id, 3)?;
841 self.field("name", name, 3)?;
842 if name != "context_checkpoint" && redactor {
843 if let Some(input) = block.get("input") {
844 if let Some(bytes) = self.input(input, 3)? {
845 self.charge("input".len().saturating_add(bytes))?;
846 }
847 }
848 }
849 }
850 }
851 Some("tool_result") if role == "user" => {
852 if let Some(id) = identifier(block, "tool_use_id") {
853 self.charge(8)?;
854 self.field("type", "tool_result", 3)?;
855 self.field("tool_use_id", id, 3)?;
856 if block.get("is_error").and_then(Value::as_bool).is_some() {
857 self.field("is_error", "", 3)?;
858 }
859 if redactor {
860 if let Some(content) = block.get("content") {
861 self.tool_result(content, block_class)?;
862 }
863 }
864 }
865 }
866 _ => {}
867 }
868 }
869 Ok(())
870 }
871
872 fn tool_result(&mut self, content: &Value, class: DisclosureClass) -> io::Result<()> {
873 if let Some(text) = content.as_str() {
874 return self.text_field("content", text, class, true, 3);
875 }
876 self.node(3)?;
877 if let Some(blocks) = content.as_array() {
878 self.charge("content".len() + 8)?;
879 for block in blocks {
880 self.node(4)?;
881 if let Some(class) = eligible_class(block) {
882 if block["type"] == "text" {
883 if let Some(text) = block["text"].as_str() {
884 self.charge(8)?;
885 self.field("type", "text", 5)?;
886 self.text_field("text", text, class, true, 5)?;
887 }
888 }
889 }
890 }
891 }
892 Ok(())
893 }
894
895 fn input(&mut self, value: &Value, depth: usize) -> io::Result<Option<usize>> {
901 self.node(depth)?;
902 if eligible_class(value).is_none() {
903 return Ok(None);
904 }
905 let mut bytes: usize = 8;
906 match value {
907 Value::String(text) => bytes = bytes.saturating_add(text.len()),
908 Value::Array(values) => {
909 for value in values {
910 let Some(cost) = self.input(value, depth + 1)? else {
911 return Ok(None);
912 };
913 bytes = bytes.saturating_add(cost);
914 }
915 }
916 Value::Object(values) => {
917 for (key, value) in values {
918 let Some(cost) = self.input(value, depth + 1)? else {
919 return Ok(None);
920 };
921 bytes = bytes.saturating_add(key.len()).saturating_add(cost);
922 }
923 }
924 _ => {}
925 }
926 Ok(Some(bytes))
927 }
928}
929
930fn projection_digest(
931 scope: &str,
932 count: usize,
933 messages: &[ArchivedMessage],
934) -> io::Result<String> {
935 let bytes = bounded_json(messages, MAX_SEGMENT_BYTES)?;
936 Ok(digest_parts(&[
937 scope.as_bytes(),
938 &(count as u64).to_be_bytes(),
939 &bytes,
940 ]))
941}
942fn digest_parts(parts: &[&[u8]]) -> String {
943 let mut hash = Sha256::new();
944 for part in parts {
945 hash.update((part.len() as u64).to_be_bytes());
946 hash.update(part);
947 }
948 format!("{:x}", hash.finalize())
949}
950fn bounded_json<T: Serialize + ?Sized>(value: &T, limit: usize) -> io::Result<Vec<u8>> {
951 struct Sink {
952 bytes: Vec<u8>,
953 limit: usize,
954 }
955 impl Write for Sink {
956 fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
957 if bytes.len() > self.limit - self.bytes.len() {
958 return Err(invalid("archive serialized byte budget exceeded"));
959 }
960 self.bytes.extend_from_slice(bytes);
961 Ok(bytes.len())
962 }
963 fn flush(&mut self) -> io::Result<()> {
964 Ok(())
965 }
966 }
967 let mut sink = Sink {
968 bytes: Vec::new(),
969 limit,
970 };
971 serde_json::to_writer(&mut sink, value).map_err(json_error)?;
972 Ok(sink.bytes)
973}
974fn find_record(records: Vec<(usize, Record)>, id: &str) -> io::Result<Record> {
975 records
976 .into_iter()
977 .find(|(_, r)| r.id == id)
978 .map(|(_, r)| r)
979 .ok_or_else(not_found)
980}
981fn prefix(text: &str, bytes: usize) -> &str {
982 let mut end = bytes.min(text.len());
983 while !text.is_char_boundary(end) {
984 end -= 1;
985 }
986 &text[..end]
987}
988fn validate_key(key: &str) -> io::Result<()> {
989 if key.is_empty() || key.len() > 4096 {
990 return Err(invalid("archive scope/logical id must be 1..4096 bytes"));
991 }
992 Ok(())
993}
994
995fn validate_id(id: &str) -> io::Result<()> {
996 if id.len() != 32
997 || !id
998 .bytes()
999 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
1000 {
1001 Err(invalid("invalid opaque archive id"))
1002 } else {
1003 Ok(())
1004 }
1005}
1006fn invalid(message: &'static str) -> io::Error {
1007 io::Error::new(io::ErrorKind::InvalidInput, message)
1008}
1009fn corrupt() -> io::Error {
1010 io::Error::new(io::ErrorKind::InvalidData, "invalid archive record")
1011}
1012fn not_found() -> io::Error {
1013 io::Error::new(
1014 io::ErrorKind::NotFound,
1015 "archive not available in this scope",
1016 )
1017}
1018fn json_error(_: serde_json::Error) -> io::Error {
1019 invalid("archive JSON serialization failed or exceeded budget")
1020}
1021
1022#[cfg(unix)]
1026struct PrivateDir(std::fs::File);
1027#[cfg(unix)]
1028impl PrivateDir {
1029 fn new(base: &Path, scope: &str) -> io::Result<Self> {
1030 use std::os::unix::fs::OpenOptionsExt;
1031 if !base.is_absolute() || base == Path::new("/") {
1032 return Err(invalid("archive base must be an absolute non-root path"));
1033 }
1034 let mut dir = Self(
1035 std::fs::OpenOptions::new()
1036 .read(true)
1037 .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC)
1038 .open("/")?,
1039 );
1040 let components: Vec<_> = base.components().collect();
1041 for (index, component) in components.iter().enumerate() {
1042 match component {
1043 std::path::Component::RootDir => {}
1044 std::path::Component::Normal(name) => {
1045 let name = name
1046 .to_str()
1047 .ok_or_else(|| invalid("archive path must be UTF-8"))?;
1048 dir = dir.child(name, index == components.len() - 1)?;
1049 }
1050 _ => return Err(invalid("archive base contains traversal")),
1051 }
1052 }
1053 dir = dir.child("context-archives", true)?;
1054 dir.child(scope, true)
1055 }
1056 fn open_existing(base: &Path, scope: &str) -> io::Result<Self> {
1057 use std::os::unix::fs::OpenOptionsExt;
1058 if !base.is_absolute() || base == Path::new("/") {
1059 return Err(invalid("archive base must be an absolute non-root path"));
1060 }
1061 let mut dir = Self(
1062 std::fs::OpenOptions::new()
1063 .read(true)
1064 .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC)
1065 .open("/")?,
1066 );
1067 for component in base.components() {
1068 match component {
1069 std::path::Component::RootDir => {}
1070 std::path::Component::Normal(name) => {
1071 let name = c_name(
1072 name.to_str()
1073 .ok_or_else(|| invalid("archive path must be UTF-8"))?,
1074 )?;
1075 dir = Self(dir.open(&name, libc::O_RDONLY | libc::O_DIRECTORY)?);
1076 }
1077 _ => return Err(invalid("archive base contains traversal")),
1078 }
1079 }
1080 for name in ["context-archives", scope] {
1081 dir = Self(dir.open(&c_name(name)?, libc::O_RDONLY | libc::O_DIRECTORY)?);
1082 }
1083 Ok(dir)
1084 }
1085
1086 fn fd(&self) -> libc::c_int {
1087 use std::os::fd::AsRawFd;
1088 self.0.as_raw_fd()
1089 }
1090 fn child(&self, name: &str, private: bool) -> io::Result<Self> {
1091 use crate::core::private_fs::DIR_MODE;
1092 use std::os::unix::fs::PermissionsExt;
1093 let name = c_name(name)?;
1094 let created =
1096 unsafe { libc::mkdirat(self.fd(), name.as_ptr(), DIR_MODE as libc::mode_t) } == 0;
1097 if !created {
1098 let error = io::Error::last_os_error();
1099 if error.raw_os_error() != Some(libc::EEXIST) {
1100 return Err(error);
1101 }
1102 }
1103 let file = self.open(&name, libc::O_RDONLY | libc::O_DIRECTORY)?;
1104 if private || created {
1105 file.set_permissions(std::fs::Permissions::from_mode(DIR_MODE))?;
1106 }
1107 self.sync()?;
1110 Ok(Self(file))
1111 }
1112 fn open(&self, name: &std::ffi::CStr, flags: i32) -> io::Result<std::fs::File> {
1113 use std::os::fd::FromRawFd;
1114 let fd = unsafe {
1115 libc::openat(
1116 self.fd(),
1117 name.as_ptr(),
1118 flags | libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK,
1119 crate::core::private_fs::FILE_MODE,
1120 )
1121 };
1122 if fd < 0 {
1123 Err(io::Error::last_os_error())
1124 } else {
1125 Ok(unsafe { std::fs::File::from_raw_fd(fd) })
1126 }
1127 }
1128 fn check_readonly(file: &std::fs::File) -> io::Result<()> {
1129 use std::os::unix::fs::MetadataExt;
1130 let meta = file.metadata()?;
1131 if !meta.is_file() || meta.nlink() != 1 || meta.uid() != unsafe { libc::geteuid() } {
1132 return Err(io::Error::new(
1133 io::ErrorKind::PermissionDenied,
1134 "archive requires owned regular single-link files",
1135 ));
1136 }
1137 Ok(())
1138 }
1139 fn check(file: &std::fs::File) -> io::Result<()> {
1140 use std::os::unix::fs::PermissionsExt;
1141 Self::check_readonly(file)?;
1142 file.set_permissions(std::fs::Permissions::from_mode(
1143 crate::core::private_fs::FILE_MODE,
1144 ))
1145 }
1146 fn lock_existing(&self) -> io::Result<Option<std::fs::File>> {
1147 let file = match self.open(&c_name("lock")?, libc::O_RDONLY) {
1148 Ok(file) => file,
1149 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
1150 Err(error) => return Err(error),
1151 };
1152 Self::check_readonly(&file)?;
1153 if !fs4::fs_std::FileExt::try_lock_shared(&file)? {
1154 return Err(io::Error::new(
1155 io::ErrorKind::WouldBlock,
1156 "archive busy; retry export",
1157 ));
1158 }
1159 Ok(Some(file))
1160 }
1161 fn lock(&self) -> io::Result<std::fs::File> {
1162 let file = self.open(&c_name("lock")?, libc::O_RDWR | libc::O_CREAT)?;
1163 Self::check(&file)?;
1164 if !fs4::fs_std::FileExt::try_lock_exclusive(&file)? {
1165 return Err(io::Error::new(
1166 io::ErrorKind::WouldBlock,
1167 "archive busy; retain source and retry",
1168 ));
1169 }
1170 Ok(file)
1171 }
1172 fn read_with(
1173 &self,
1174 slot: usize,
1175 remaining: usize,
1176 readonly: bool,
1177 ) -> io::Result<Option<Vec<u8>>> {
1178 let file = match self.open(&c_name(&format!("{slot:03}.json"))?, libc::O_RDONLY) {
1179 Ok(file) => file,
1180 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
1181 Err(error) => return Err(error),
1182 };
1183 if readonly {
1184 Self::check_readonly(&file)?;
1185 } else {
1186 Self::check(&file)?;
1187 }
1188 let limit = remaining.min(MAX_SEGMENT_BYTES);
1189 if file.metadata()?.len() > limit as u64 {
1190 return Err(corrupt());
1191 }
1192 let mut bytes = Vec::new();
1193 file.take(limit as u64 + 1).read_to_end(&mut bytes)?;
1194 if bytes.len() > limit {
1195 return Err(corrupt());
1196 }
1197 Ok(Some(bytes))
1198 }
1199 fn publish(&self, slot: usize, bytes: &[u8]) -> io::Result<()> {
1200 let target = c_name(&format!("{slot:03}.json"))?;
1201 let tmp = c_name("pending.tmp")?;
1202 if unsafe { libc::unlinkat(self.fd(), tmp.as_ptr(), 0) } != 0 {
1205 let error = io::Error::last_os_error();
1206 if error.kind() != io::ErrorKind::NotFound {
1207 return Err(error);
1208 }
1209 }
1210 let result = (|| {
1211 let mut file = self.open(&tmp, libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL)?;
1212 file.write_all(bytes)?;
1213 file.sync_all()?;
1214 match self.open(&target, libc::O_RDONLY) {
1216 Ok(existing) => Self::check(&existing)?,
1217 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
1218 Err(error) => return Err(error),
1219 }
1220 if unsafe { libc::renameat(self.fd(), tmp.as_ptr(), self.fd(), target.as_ptr()) } != 0 {
1221 return Err(io::Error::last_os_error());
1222 }
1223 self.sync()
1224 })();
1225 if result.is_err() {
1226 unsafe {
1227 libc::unlinkat(self.fd(), tmp.as_ptr(), 0);
1228 }
1229 }
1230 result
1231 }
1232 fn sync(&self) -> io::Result<()> {
1233 self.0.sync_all()
1234 }
1235}
1236#[cfg(unix)]
1237fn c_name(name: &str) -> io::Result<std::ffi::CString> {
1238 if name.is_empty() || matches!(name, "." | "..") || name.contains(['/', '\\']) {
1239 return Err(invalid("invalid archive component"));
1240 }
1241 std::ffi::CString::new(name).map_err(|_| invalid("invalid archive component"))
1242}
1243
1244#[cfg(not(unix))]
1245struct PrivateDir;
1246#[cfg(not(unix))]
1247impl PrivateDir {
1248 fn unsupported<T>() -> io::Result<T> {
1249 Err(io::Error::new(
1250 io::ErrorKind::Unsupported,
1251 "private context archive requires Unix",
1252 ))
1253 }
1254 fn new(_: &Path, _: &str) -> io::Result<Self> {
1255 Self::unsupported()
1256 }
1257 fn lock(&self) -> io::Result<std::fs::File> {
1258 Self::unsupported()
1259 }
1260 fn open_existing(_: &Path, _: &str) -> io::Result<Self> {
1261 Self::unsupported()
1262 }
1263 fn lock_existing(&self) -> io::Result<Option<std::fs::File>> {
1264 Self::unsupported()
1265 }
1266 fn read_with(&self, _: usize, _: usize, _: bool) -> io::Result<Option<Vec<u8>>> {
1267 Self::unsupported()
1268 }
1269 fn publish(&self, _: usize, _: &[u8]) -> io::Result<()> {
1270 Self::unsupported()
1271 }
1272 fn sync(&self) -> io::Result<()> {
1273 Self::unsupported()
1274 }
1275}
1276
1277#[cfg(test)]
1278mod projection_tests {
1279 use super::*;
1280
1281 fn excluded_fixture(blob: &str) -> Vec<SharedMessage> {
1282 vec![
1283 Arc::new(json!({"role":"system","content":blob})),
1284 Arc::new(json!({"role":"developer","content":blob})),
1285 Arc::new(json!({"role":"user","_synaps_context":{"note":blob},"content":blob})),
1286 Arc::new(json!({"role":"user","retention":"local_only","content":blob})),
1287 Arc::new(json!({"role":"assistant","metadata":blob,"content":[
1288 {"type":"thinking","thinking":blob},
1289 {"type":"text","private":true,"text":blob},
1290 {"type":"image","source":{"data":blob}},
1291 {"type":"document","source":{"data":blob}},
1292 {"type":"text","text":" exact evidence αβ\n","metadata":blob},
1293 {"type":"tool_use","id":"cp","name":"context_checkpoint","input":{"note":blob}}
1294 ]})),
1295 Arc::new(json!({"role":"user","content":[
1296 {"type":"tool_result","tool_use_id":"r1","metadata":blob,"content":[
1297 {"type":"image","source":{"data":blob}},
1298 {"type":"text","sensitivity":"secret","text":blob},
1299 {"type":"text","text":"exact tool evidence\n","metadata":blob}
1300 ]}
1301 ]})),
1302 ]
1303 }
1304
1305 #[test]
1306 fn excluded_aggregate_does_not_consume_eligible_budget_or_change_digest() {
1307 let messages = excluded_fixture(&"x".repeat(2 * 1024 * 1024));
1309 let source = messages.clone();
1310 for redactor in [None, Some(Arc::new(str::to_owned) as Arc<ArchiveRedactor>)] {
1311 let small =
1312 project_archive("excluded", &excluded_fixture("x"), "note", redactor.clone())
1313 .unwrap();
1314 let large = project_archive("excluded", &messages, "note", redactor).unwrap();
1315 assert!(small == large);
1316 assert_eq!(large.source_message_count, 6);
1317 assert_eq!(large.messages[0].source_index, 4);
1318 assert_eq!(large.messages[0].block_indices, vec![4, 5]);
1319 }
1320 for (old, new) in source.iter().zip(&messages) {
1321 assert!(Arc::ptr_eq(old, new));
1322 }
1323 }
1324
1325 #[test]
1326 fn eligible_aggregate_refuses_before_any_redactor_call() {
1327 use std::sync::atomic::{AtomicUsize, Ordering};
1328 let calls = Arc::new(AtomicUsize::new(0));
1329 let count = calls.clone();
1330 let redactor: Arc<ArchiveRedactor> = Arc::new(move |_| {
1331 count.fetch_add(1, Ordering::SeqCst);
1332 "small".to_owned()
1333 });
1334 let piece = "x".repeat(MAX_INPUT_BYTES / 2);
1335 for messages in [
1336 vec![
1337 Arc::new(json!({"role":"user","content":piece})),
1338 Arc::new(json!({"role":"assistant","content":piece})),
1339 ],
1340 vec![Arc::new(json!({"role":"user","content":[{
1341 "type":"tool_result","tool_use_id":"r1","content":[
1342 {"type":"text","text":piece}, {"type":"text","text":piece}
1343 ]
1344 }]}))],
1345 ] {
1346 let before = messages.clone();
1347 let error = project_archive("overflow", &messages, "", Some(redactor.clone()))
1348 .err()
1349 .unwrap();
1350 assert!(error.to_string().contains("input byte budget"));
1351 assert_eq!(calls.load(Ordering::SeqCst), 0);
1352 assert_eq!(before, messages);
1353 }
1354 }
1355
1356 #[test]
1357 fn large_tool_results_exact_with_redactor_opaque_without_it() {
1358 let text = "evidence αβ\n".repeat(250_000);
1359 let make = |text: &str| {
1360 vec![Arc::new(json!({"role":"user","content":[{
1361 "type":"tool_result","tool_use_id":"r1","content":text
1362 }]}))]
1363 };
1364 let p = project_archive("logs", &make(&text), "", Some(Arc::new(str::to_owned))).unwrap();
1365 assert_eq!(p.messages[0].message["content"][0]["content"], text);
1366 let huge = make(&"x".repeat(MAX_INPUT_BYTES + 1));
1367 let p = project_archive("logs", &huge, "", None).unwrap();
1368 assert_eq!(p.messages[0].message["content"][0]["content"], WITHHELD);
1369 assert!(project_archive("logs", &huge, "", Some(Arc::new(str::to_owned))).is_err());
1370 }
1371
1372 #[test]
1373 fn whole_argument_withholding_discards_tentative_bytes_not_structural_guards() {
1374 let huge = "x".repeat(MAX_INPUT_BYTES + 1);
1375 let make = |input: Value| {
1376 vec![Arc::new(json!({"role":"assistant","content":[{
1377 "type":"tool_use","id":"r1","name":"read","input":input
1378 }]}))]
1379 };
1380 for input in [
1382 json!({"a":huge,"z":{"sensitivity":"secret","body":huge}}),
1383 json!({"a":{"sensitivity":"secret","body":huge},"z":huge}),
1384 ] {
1385 let p =
1386 project_archive("input", &make(input), "", Some(Arc::new(str::to_owned))).unwrap();
1387 assert_eq!(
1388 p.messages[0].message["content"][0]["input"],
1389 json!({"_archive_withheld":true})
1390 );
1391 }
1392 let input = json!({"type":"image","data":"YWJj"});
1394 let p = project_archive(
1395 "input",
1396 &make(input.clone()),
1397 "",
1398 Some(Arc::new(str::to_owned)),
1399 )
1400 .unwrap();
1401 assert_eq!(p.messages[0].message["content"][0]["input"], input);
1402 let large = make(json!({"type":"image","data":huge}));
1403 assert!(project_archive("input", &large, "", Some(Arc::new(str::to_owned))).is_err());
1404 assert!(project_archive("input", &large, "", None).is_ok());
1405 }
1406
1407 #[test]
1408 fn visited_structure_is_bounded_but_excluded_descendants_are_opaque() {
1409 let mut deep = json!("leaf");
1410 for _ in 0..40 {
1411 deep = json!([deep]);
1412 }
1413 let make = |input: Value| {
1414 vec![Arc::new(json!({"role":"assistant","content":[{
1415 "type":"tool_use","id":"r1","name":"read","input":input
1416 }]}))]
1417 };
1418 let identity = Some(Arc::new(str::to_owned) as Arc<ArchiveRedactor>);
1419 assert!(project_archive("depth", &make(deep.clone()), "", identity.clone()).is_err());
1420 assert!(project_archive("depth", &make(deep.clone()), "", None).is_ok());
1421 assert!(project_archive(
1422 "depth",
1423 &make(json!({"private":true,"blob":deep})),
1424 "",
1425 identity.clone()
1426 )
1427 .is_ok());
1428 assert!(project_archive(
1430 "depth",
1431 &[Arc::new(json!({"role":"user","content":deep}))],
1432 "",
1433 identity.clone()
1434 )
1435 .is_ok());
1436 let wide = vec![Value::Null; 65536];
1437 assert!(project_archive("nodes", &make(json!(wide)), "", identity.clone()).is_err());
1438 let excluded = vec![Arc::new(json!({"role":"user","content":wide}))];
1439 assert!(project_archive("nodes", &excluded, "", identity).is_err());
1440 }
1441
1442 #[test]
1443 fn withheld_after_redaction_text_is_not_scanned_without_redactor() {
1444 let messages = vec![Arc::new(json!({"role":"user",
1445 "disclosure":"model_visible_after_redaction", "content":"x".repeat(MAX_INPUT_BYTES+1)
1446 }))];
1447 let p = project_archive("withheld", &messages, "", None).unwrap();
1448 assert_eq!(p.messages[0].message["content"], WITHHELD);
1449 assert!(project_archive("withheld", &messages, "", Some(Arc::new(str::to_owned))).is_err());
1450 }
1451
1452 #[test]
1453 fn json_escape_scratch_can_exceed_segment_limit_before_redaction() {
1454 let input = json!({"text":"\0".repeat(3 * 1024 * 1024)});
1457 let messages = vec![Arc::new(json!({"role":"assistant","content":[{
1458 "type":"tool_use","id":"r1","name":"read","input":input
1459 }]}))];
1460 let redactor: Arc<ArchiveRedactor> = Arc::new(|s| {
1461 if s.starts_with('{') {
1462 assert!(s.len() > MAX_SEGMENT_BYTES);
1463 "{\"text\":\"screened\"}".to_owned()
1464 } else {
1465 s.to_owned()
1466 }
1467 });
1468 let p = project_archive("escaping", &messages, "", Some(redactor)).unwrap();
1469 assert_eq!(
1470 p.messages[0].message["content"][0]["input"],
1471 json!({"text":"screened"})
1472 );
1473 assert!(project_archive("escaping", &messages, "", Some(Arc::new(str::to_owned))).is_err());
1474 }
1475
1476 fn compatibility_fixture() -> Vec<SharedMessage> {
1477 vec![
1478 Arc::new(json!({"role":"system","content":"omitted"})),
1479 Arc::new(json!({"role":"user","content":" exact αβ\n"})),
1480 Arc::new(json!({"role":"assistant","content":[
1481 {"type":"thinking","thinking":"private"},
1482 {"type":"text","text":"classified-value"},
1483 {"type":"tool_use","id":"r1","name":"read","input":{"path":"src/lib.rs","n":7,"binary":{"type":"image","data":"YWJj"}}},
1484 {"type":"tool_use","id":"c1","name":"context_checkpoint","input":{"note":"hidden"}},
1485 {"type":"tool_use","id":"x1","name":"read","input":{"nested":{"disclosure":"local_only","body":"omitted"}}}
1486 ]})),
1487 Arc::new(json!({"role":"user","content":[
1488 {"type":"tool_result","tool_use_id":"r1","is_error":false,"content":[
1489 {"type":"image","data":"omitted"},
1490 {"type":"text","text":"exact result\n"},
1491 {"type":"text","text":"classified-value","disclosure":"model_visible_after_redaction"}
1492 ]}
1493 ]})),
1494 Arc::new(
1495 json!({"role":"user","disclosure":"model_visible_after_redaction","content":"classified-value"}),
1496 ),
1497 Arc::new(json!({"role":"user","_synaps_context":{},"content":"hidden"})),
1498 ]
1499 }
1500
1501 #[test]
1502 fn legacy_projection_golden() {
1503 for (redactor, digest) in [
1505 (
1506 None,
1507 "aeb544a71629f741def049575eefb1a765117d65aa914bdcc1657fa677622031",
1508 ),
1509 (
1510 Some(
1511 Arc::new(|s: &str| s.replace("classified-value", "[redacted]"))
1512 as Arc<ArchiveRedactor>,
1513 ),
1514 "d70b321210ca3d2bc531b31a35cc02ee267eeb572e8a6dd88f1fb62725b082ec",
1515 ),
1516 ] {
1517 let p = project_archive(
1518 "compat-session",
1519 &compatibility_fixture(),
1520 "hidden note",
1521 redactor,
1522 )
1523 .unwrap();
1524 assert_eq!(p.digest, digest);
1525 }
1526 }
1527
1528 #[test]
1529 fn pure_projection_keeps_legacy_logical_digest_and_never_opens_paths() {
1530 let tmp = tempfile::tempdir().unwrap();
1531 let logical = tmp
1532 .path()
1533 .join("not-created/session")
1534 .to_str()
1535 .unwrap()
1536 .to_owned();
1537 let messages = vec![
1538 Arc::new(json!({"role":"system","content":"OMITTED"})),
1539 Arc::new(json!({"role":"assistant","content":[
1540 {"type":"thinking","thinking":"OMITTED"},
1541 {"type":"text","text":" exact αβ evidence\n"}
1542 ]})),
1543 ];
1544 let source = serde_json::to_vec(&messages).unwrap();
1545 let first = project_archive(&logical, &messages, "hidden note", None).unwrap();
1546 assert_eq!(first.logical_id, digest_parts(&[logical.as_bytes()]));
1547 assert_eq!(
1548 first.digest,
1549 projection_digest(&first.logical_id, 2, &first.messages).unwrap()
1550 );
1551 assert_eq!(first.messages[0].source_index, 1);
1552 assert_eq!(first.messages[0].block_indices, vec![1]);
1553 assert_eq!(first.source_message_count, 2);
1554 let mut changed = messages.clone();
1555 changed[0] = Arc::new(json!({"role":"developer","content":"DIFFERENT OMITTED"}));
1556 let second = project_archive(&logical, &changed, "changed note", None).unwrap();
1557 assert_eq!(first.digest, second.digest);
1558 assert_ne!(
1559 first.digest,
1560 project_archive("other-session", &messages, "hidden note", None)
1561 .unwrap()
1562 .digest
1563 );
1564 assert_eq!(serde_json::to_vec(&messages).unwrap(), source);
1565 assert_eq!(std::fs::read_dir(tmp.path()).unwrap().count(), 0);
1566 }
1567
1568 #[test]
1569 fn pure_projection_shares_disclosure_redaction_and_input_limits() {
1570 let redactor: Arc<ArchiveRedactor> =
1571 Arc::new(|s| s.replace("classified-value", "[redacted]"));
1572 let messages = vec![
1573 Arc::new(json!({"role":"assistant","content":[
1574 {"type":"tool_use","id":"read_1","name":"read","input":{"path":"src/lib.rs"}},
1575 {"type":"tool_use","id":"cp","name":"context_checkpoint","input":{"note":"NOTE_ONLY"}}
1576 ]})),
1577 Arc::new(
1578 json!({"role":"user","content":[{"type":"tool_result","tool_use_id":"read_1","content":"classified-value"}]}),
1579 ),
1580 Arc::new(json!({"role":"user","sensitivity":"secret","content":"OMITTED"})),
1581 Arc::new(json!({"role":"user","_synaps_context":{},"content":"NOTE_ONLY"})),
1582 ];
1583 let projected =
1584 project_archive("session", &messages, "classified-value", Some(redactor)).unwrap();
1585 assert_eq!(projected.note, "[redacted]");
1586 let json = serde_json::to_string(&projected.messages).unwrap();
1587 assert!(!json.contains("classified-value"));
1588 assert!(!json.contains("NOTE_ONLY"));
1589 assert!(!json.contains("OMITTED"));
1590 assert!(json.contains("src/lib.rs"));
1591 assert_eq!(projected.messages.len(), 2);
1592 let withheld = project_archive("session", &messages, "password=bad", None).unwrap();
1593 assert_eq!(withheld.note, WITHHELD);
1594 assert!(!serde_json::to_string(&withheld.messages)
1595 .unwrap()
1596 .contains("src/lib.rs"));
1597 assert!(project_archive("", &[], "", None).is_err());
1598 assert!(project_archive(&"l".repeat(4097), &[], "", None).is_err());
1599 assert!(project_archive("s", &[], &"n".repeat(MAX_NOTE_BYTES + 1), None).is_err());
1600 assert!(
1601 project_archive("s", &vec![messages[0].clone(); MAX_MESSAGES + 1], "", None).is_err()
1602 );
1603 }
1604}
1605
1606#[cfg(all(test, unix))]
1607mod tests {
1608 use super::*;
1609 use std::os::unix::fs::{symlink, PermissionsExt};
1610
1611 fn msg(role: &str, content: Value) -> SharedMessage {
1612 Arc::new(json!({"role":role,"content":content}))
1613 }
1614 fn setup() -> (tempfile::TempDir, ArchiveStore) {
1615 let tmp = tempfile::tempdir().unwrap();
1616 let store =
1617 ArchiveStore::new(tmp.path(), "synthetic-project", "synthetic-logical").unwrap();
1618 (tmp, store)
1619 }
1620 fn directory(tmp: &tempfile::TempDir, store: &ArchiveStore) -> std::path::PathBuf {
1621 tmp.path().join("context-archives").join(&store.scope)
1622 }
1623
1624 #[test]
1625 fn export_preview_is_read_only_and_matches_projection_including_tombstones() {
1626 use std::os::unix::fs::MetadataExt;
1627 let tmp = tempfile::tempdir().unwrap();
1628 assert!(export_in(&tmp.path().join("missing"), "synthetic-project")
1629 .unwrap()
1630 .is_empty());
1631 assert!(export_in(tmp.path(), "synthetic-project")
1632 .unwrap()
1633 .is_empty());
1634 assert_eq!(std::fs::read_dir(tmp.path()).unwrap().count(), 0);
1635 let store =
1636 ArchiveStore::new(tmp.path(), "synthetic-project", "synthetic-logical").unwrap();
1637 let dir = directory(&tmp, &store);
1638 assert!(export_in(tmp.path(), "synthetic-project")
1640 .unwrap()
1641 .is_empty());
1642 assert!(!dir.join("lock").exists());
1643 let messages = vec![msg("user", json!("migration evidence"))];
1644 let reference = store.seal(&messages, "hidden note").unwrap();
1645 let projection =
1646 project_archive("synthetic-logical", &messages, "hidden note", None).unwrap();
1647 let tombstone = store
1648 .seal(&[msg("assistant", json!("remove me"))], "remove note")
1649 .unwrap();
1650 store.forget(&tombstone.id).unwrap();
1651 let mut paths = vec![
1652 tmp.path().to_path_buf(),
1653 tmp.path().join("context-archives"),
1654 dir.clone(),
1655 ];
1656 paths.extend(std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().path()));
1657 for path in &paths {
1659 let mode = if path.is_dir() { 0o750 } else { 0o640 };
1660 std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap();
1661 }
1662 let snapshot = || {
1663 paths
1664 .iter()
1665 .map(|p| {
1666 let m = std::fs::metadata(p).unwrap();
1667 (
1668 m.mode(),
1669 m.mtime(),
1670 m.mtime_nsec(),
1671 if m.is_file() {
1672 std::fs::read(p).unwrap()
1673 } else {
1674 vec![]
1675 },
1676 )
1677 })
1678 .collect::<Vec<_>>()
1679 };
1680 let before = snapshot();
1681 let rows = export_in(tmp.path(), "synthetic-project").unwrap();
1682 assert_eq!(rows, store.export_records().unwrap());
1683 assert_eq!(before, snapshot());
1684 assert_eq!(
1685 rows[0],
1686 json!({"id":reference.id,"digest":projection.digest,"logical_id":projection.logical_id,
1687 "source_message_count":1,"messages":projection.messages,"note":"hidden note","tombstone":false})
1688 );
1689 assert_eq!(rows[1]["id"], tombstone.id);
1690 assert_eq!(rows[1]["tombstone"], true);
1691 assert_eq!(rows[1]["source_message_count"], 0);
1692 assert_eq!(rows[1]["messages"], json!([]));
1693 assert_eq!(rows[1]["note"], "");
1694 let lock = store.fs.lock().unwrap();
1695 assert_eq!(
1696 export_in(tmp.path(), "synthetic-project")
1697 .unwrap_err()
1698 .kind(),
1699 io::ErrorKind::WouldBlock
1700 );
1701 drop(lock);
1702 std::fs::write(dir.join("000.json"), b"corrupt").unwrap();
1703 assert!(export_in(tmp.path(), "synthetic-project").is_err());
1704 }
1705
1706 #[test]
1707 fn export_preview_refuses_links_and_never_creates_a_missing_lock() {
1708 let (tmp, store) = setup();
1709 let dir = directory(&tmp, &store);
1710 let r = store
1711 .seal(&[msg("user", json!("evidence"))], "note")
1712 .unwrap();
1713 let path = dir.join("000.json");
1714 let bytes = std::fs::read(&path).unwrap();
1715 std::fs::remove_file(dir.join("lock")).unwrap();
1716 assert!(export_in(tmp.path(), "synthetic-project").is_err());
1717 assert!(!dir.join("lock").exists());
1718 assert!(store.fetch(&r.id, 0, 1).is_ok());
1720 std::fs::remove_file(&path).unwrap();
1721 let outside = tmp.path().join("outside");
1722 std::fs::write(&outside, &bytes).unwrap();
1723 symlink(&outside, &path).unwrap();
1724 assert!(export_in(tmp.path(), "synthetic-project").is_err());
1725 std::fs::remove_file(&path).unwrap();
1726 std::fs::hard_link(&outside, &path).unwrap();
1727 assert!(export_in(tmp.path(), "synthetic-project").is_err());
1728 let alias = tmp.path().join("alias");
1729 symlink(tmp.path(), &alias).unwrap();
1730 assert!(export_in(&alias, "synthetic-project").is_err());
1731 assert_eq!(std::fs::read(&outside).unwrap(), bytes);
1732 }
1733
1734 #[test]
1735 fn attachments_remain_outside_eligible_memory_archive() {
1736 let (tmp, store) = setup();
1737 let messages = vec![msg(
1738 "user",
1739 json!([
1740 {"type":"text","text":"Compare the selected files"},
1741 {"type":"image","source":{"type":"base64","media_type":"image/png","data":"ATTACHMENT_IMAGE_SECRET"}},
1742 {"type":"document","title":"ATTACHMENT_FILENAME_SECRET","source":{"type":"base64","media_type":"application/pdf","data":"ATTACHMENT_PDF_SECRET"}},
1743 {"type":"document","source":{"type":"text","media_type":"text/plain","data":"ATTACHMENT_TEXT_SECRET"}}
1744 ]),
1745 )];
1746 let reference = store.seal(&messages, "safe checkpoint").unwrap();
1747 let fetched = store.fetch(&reference.id, 0, 10).unwrap();
1748 let serialized = serde_json::to_string(&fetched).unwrap();
1749 assert!(serialized.contains("Compare the selected files"));
1750 assert!(!serialized.contains("ATTACHMENT_"));
1751 let raw = std::fs::read_to_string(directory(&tmp, &store).join("000.json")).unwrap();
1752 assert!(!raw.contains("ATTACHMENT_"));
1753 assert!(messages[0].to_string().contains("ATTACHMENT_TEXT_SECRET"));
1754 }
1755
1756 #[test]
1757 fn exact_text_source_indices_note_and_restart() {
1758 let (tmp, store) = setup();
1759 let messages = vec![
1760 msg("system", json!("FORBIDDEN SYSTEM")),
1761 msg("user", json!(" exact\n αβ ")),
1762 msg(
1763 "assistant",
1764 json!([
1765 {"type":"thinking","thinking":"FORBIDDEN REASONING"},
1766 {"type":"text","text":"evidence✓", "cache_control":{"type":"ephemeral"}},
1767 {"type":"image","data":"FORBIDDEN IMAGE"}
1768 ]),
1769 ),
1770 msg("developer", json!("FORBIDDEN DEVELOPER")),
1771 ];
1772 let reference = store.seal(&messages, "hidden routing note").unwrap();
1773 assert_eq!(reference.message_count, 2);
1774 assert_eq!(reference.source_message_count, 4);
1775 let reopened =
1776 ArchiveStore::new(tmp.path(), "synthetic-project", "synthetic-logical").unwrap();
1777 let fetched = reopened
1778 .fetch_with_provenance(&reference.id, 0, 128)
1779 .unwrap();
1780 assert_eq!(fetched[0].message, messages[1]);
1781 assert_eq!(fetched[0].source_index, 1);
1782 assert_eq!(fetched[1].source_index, 2);
1783 assert_eq!(fetched[1].block_indices, vec![1]);
1784 assert_eq!(
1785 reopened.fetch_note(&reference.id).unwrap(),
1786 "hidden routing note"
1787 );
1788 assert!(reopened.search("hidden routing", 10).unwrap().is_empty());
1789 assert_eq!(reopened.search("evidence", 10).unwrap()[0].id, reference.id);
1790 assert!(reopened
1791 .fetch(&reference.id, usize::MAX, 10)
1792 .unwrap()
1793 .is_empty());
1794 let raw = std::fs::read_to_string(directory(&tmp, &store).join("000.json")).unwrap();
1795 assert!(!raw.contains("FORBIDDEN"));
1796 assert!(!raw.contains("cache_control"));
1797 assert_eq!(messages[0]["content"], "FORBIDDEN SYSTEM");
1798 }
1799
1800 #[test]
1801 fn duplicate_retry_ignores_note_but_not_logical_id() {
1802 let (tmp, store) = setup();
1803 let messages = vec![msg("user", json!("same eligible words"))];
1804 let first = store.seal(&messages, "first").unwrap();
1805 assert_eq!(store.seal(&messages, "second").unwrap(), first);
1806 assert_eq!(store.fetch_note(&first.id).unwrap(), "first");
1807 let other = ArchiveStore::new(tmp.path(), "synthetic-project", "other-logical").unwrap();
1808 let second = other.seal(&messages, "").unwrap();
1809 assert_ne!(first.id, second.id);
1810 assert_eq!(
1811 project_search(tmp.path(), "synthetic-project", "", 10)
1812 .unwrap()
1813 .len(),
1814 2
1815 );
1816 assert_eq!(
1817 project_fetch(tmp.path(), "synthetic-project", &first.id, 0, 10).unwrap(),
1818 messages
1819 );
1820 assert_eq!(other.fetch(&first.id, 0, 1).unwrap(), messages);
1821 store.forget(&first.id).unwrap();
1822 assert!(store.seal(&messages, "third").is_err());
1823 assert_eq!(other.fetch(&second.id, 0, 1).unwrap(), messages);
1824 assert_eq!(other.seal(&messages, "third").unwrap(), second);
1825 }
1826
1827 #[test]
1828 fn deletion_is_content_free_durable_and_cannot_resurrect() {
1829 let (tmp, store) = setup();
1830 let messages = vec![msg("user", json!("uniquely removable content"))];
1831 let reference = store.seal(&messages, "removable note").unwrap();
1832 store.forget(&reference.id).unwrap();
1833 store.forget(&reference.id).unwrap();
1834 let raw = std::fs::read_to_string(directory(&tmp, &store).join("000.json")).unwrap();
1835 assert!(raw.contains("tombstone"));
1836 assert!(!raw.contains("removable"));
1837 assert!(!raw.contains("source_message_count"));
1838 let store =
1839 ArchiveStore::new(tmp.path(), "synthetic-project", "synthetic-logical").unwrap();
1840 assert_eq!(
1841 store.fetch(&reference.id, 0, 1).unwrap_err().kind(),
1842 io::ErrorKind::NotFound
1843 );
1844 assert!(store.fetch_note(&reference.id).is_err());
1845 assert!(store.search("", 10).unwrap().is_empty());
1846 assert_eq!(
1847 store.seal(&messages, "changed note").unwrap_err().kind(),
1848 io::ErrorKind::PermissionDenied
1849 );
1850 }
1851
1852 #[test]
1853 fn foreign_scope_copied_records_and_traversal_fail_closed() {
1854 let (tmp, store) = setup();
1855 let reference = store
1856 .seal(&[msg("user", json!("scope evidence"))], "")
1857 .unwrap();
1858 let foreign =
1859 ArchiveStore::new(tmp.path(), "foreign-project", "synthetic-logical").unwrap();
1860 assert!(foreign.fetch(&reference.id, 0, 1).is_err());
1861 for id in [
1862 "../000.json",
1863 "/tmp/file",
1864 "",
1865 "a/../b",
1866 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
1867 ] {
1868 assert_eq!(
1869 store.fetch(id, 0, 1).unwrap_err().kind(),
1870 io::ErrorKind::InvalidInput
1871 );
1872 assert!(store.forget(id).is_err());
1873 }
1874 std::fs::copy(
1875 directory(&tmp, &store).join("000.json"),
1876 directory(&tmp, &foreign).join("000.json"),
1877 )
1878 .unwrap();
1879 assert_eq!(
1880 foreign.search("", 1).unwrap_err().kind(),
1881 io::ErrorKind::InvalidData
1882 );
1883 assert!(ArchiveStore::new(&tmp.path().join("../escape"), "p", "l").is_err());
1884 }
1885
1886 #[test]
1887 fn disclosure_and_raw_tool_data_are_withheld() {
1888 let (tmp, store) = setup();
1889 let mut messages = vec![
1890 msg(
1891 "assistant",
1892 json!([{"type":"tool_use","id":"call_1","name":"read","input":{"path":"unclassified raw argument"}}]),
1893 ),
1894 msg(
1895 "user",
1896 json!([{"type":"tool_result","tool_use_id":"call_1","content":"unclassified raw output"}]),
1897 ),
1898 msg("user", json!("password=synthetic-credential")),
1899 ];
1900 for class in [
1901 "local_only",
1902 "persist_never_transmit",
1903 "never_persist",
1904 "model_visible_after_consent",
1905 "unknown",
1906 ] {
1907 messages.push(Arc::new(
1908 json!({"role":"user","content":"FORBIDDEN DISCLOSURE","disclosure":class}),
1909 ));
1910 }
1911 messages.push(Arc::new(
1912 json!({"role":"user","content":"FORBIDDEN SENSITIVITY","sensitivity":"secret"}),
1913 ));
1914 let reference = store.seal(&messages, "").unwrap();
1915 assert_eq!(reference.message_count, 3);
1916 let raw = std::fs::read_to_string(directory(&tmp, &store).join("000.json")).unwrap();
1917 for forbidden in ["unclassified raw", "synthetic-credential", "FORBIDDEN"] {
1918 assert!(!raw.contains(forbidden));
1919 }
1920 assert!(raw.contains("call_1"));
1921 assert!(raw.contains("_archive_withheld"));
1922 }
1923
1924 #[test]
1925 fn derived_context_envelope_is_not_searchable_source() {
1926 let (_tmp, store) = setup();
1927 let first = store
1928 .seal(
1929 &[msg("assistant", json!("original evidence"))],
1930 "NOTE_ONLY_SENTINEL",
1931 )
1932 .unwrap();
1933 let second=store.seal(&[
1934 Arc::new(json!({"role":"user","_synaps_context":{"schema":"synaps-context-window/1","archive":first.id},"content":"NOTE_ONLY_SENTINEL"})),
1935 Arc::new(json!({"role":"assistant","content":[{"type":"tool_use","id":"checkpoint-note","name":"context_checkpoint","input":{"phase":"plan","note":"NOTE_ONLY_SENTINEL"}}]})),
1936 msg("assistant",json!("second-window evidence")),
1937 ],"next note").unwrap();
1938 assert!(store.search("NOTE_ONLY_SENTINEL", 10).unwrap().is_empty());
1939 let fetched = store.fetch_with_provenance(&second.id, 0, 10).unwrap();
1940 assert_eq!(fetched.len(), 2);
1941 assert_eq!(fetched[0].source_index, 1);
1942 assert_eq!(fetched[1].source_index, 2);
1943 assert!(!serde_json::to_string(&fetched)
1944 .unwrap()
1945 .contains("NOTE_ONLY_SENTINEL"));
1946 }
1947
1948 #[test]
1949 fn checkpoint_tool_note_stays_hidden_even_with_redactor() {
1950 let (_tmp, store) = setup();
1951 let store = store.with_redactor(Arc::new(str::to_owned));
1952 let reference = store
1953 .seal(
1954 &[msg(
1955 "assistant",
1956 json!([
1957 {"type":"tool_use","id":"checkpoint-note","name":"context_checkpoint",
1958 "input":{"phase":"plan","note":"NOTE_ARGUMENT_SENTINEL"}}
1959 ]),
1960 )],
1961 "NOTE_ARGUMENT_SENTINEL",
1962 )
1963 .unwrap();
1964 assert!(store
1965 .search("NOTE_ARGUMENT_SENTINEL", 10)
1966 .unwrap()
1967 .is_empty());
1968 assert!(
1969 !serde_json::to_string(&store.fetch(&reference.id, 0, 10).unwrap())
1970 .unwrap()
1971 .contains("NOTE_ARGUMENT_SENTINEL")
1972 );
1973 assert_eq!(
1974 store.fetch_note(&reference.id).unwrap(),
1975 "NOTE_ARGUMENT_SENTINEL"
1976 );
1977 }
1978
1979 #[test]
1980 fn trusted_redactor_retains_exact_eligible_tool_evidence() {
1981 let (_tmp, store) = setup();
1982 let store = store.with_redactor(Arc::new(|s| s.replace("classified-value", "[redacted]")));
1983 let messages = vec![
1984 msg(
1985 "assistant",
1986 json!([{"type":"tool_use","id":"call_1","name":"read","input":{"path":"src/lib.rs","line":7}}]),
1987 ),
1988 msg(
1989 "user",
1990 json!([{"type":"tool_result","tool_use_id":"call_1","is_error":false,"content":"line 7\n exact evidence\nclassified-value"}]),
1991 ),
1992 ];
1993 let reference = store.seal(&messages, "").unwrap();
1994 let fetched = store.fetch(&reference.id, 0, 10).unwrap();
1995 assert_eq!(fetched[0], messages[0]);
1996 assert_eq!(
1997 fetched[1]["content"][0]["content"],
1998 "line 7\n exact evidence\n[redacted]"
1999 );
2000 }
2001
2002 #[test]
2003 fn oversized_input_output_limits_and_depth_leave_source_intact() {
2004 let (_tmp, store) = setup();
2005 let huge = vec![msg("user", json!("x".repeat(MAX_INPUT_BYTES + 1)))];
2006 assert!(store.seal(&huge, "").is_err());
2007 assert_eq!(
2008 huge[0]["content"].as_str().unwrap().len(),
2009 MAX_INPUT_BYTES + 1
2010 );
2011 let file_too_large = vec![msg("user", json!("\n".repeat(MAX_SEGMENT_BYTES / 2)))];
2012 assert!(store.seal(&file_too_large, "").is_err());
2013 assert!(store
2014 .seal(&vec![msg("user", json!("a")); MAX_MESSAGES + 1], "")
2015 .is_err());
2016 assert!(store.seal(&[], &"n".repeat(MAX_NOTE_BYTES + 1)).is_err());
2017 let mut deep = json!("value");
2018 for _ in 0..40 {
2019 deep = json!([deep]);
2020 }
2021 let deep = vec![msg(
2024 "assistant",
2025 json!([{
2026 "type":"tool_use", "id":"r1", "name":"read", "input":deep
2027 }]),
2028 )];
2029 let store = store.with_redactor(Arc::new(str::to_owned));
2030 assert!(store.seal(&deep, "").is_err());
2031 assert!(store.search(&"q".repeat(513), 1).is_err());
2032 assert!(store.search("", MAX_SEARCH_RESULTS + 1).is_err());
2033 assert!(store
2034 .fetch(&"a".repeat(32), 0, MAX_FETCH_MESSAGES + 1)
2035 .is_err());
2036 assert!(store.search("", 10).unwrap().is_empty());
2037 }
2038
2039 #[test]
2040 fn torn_final_fails_closed_stale_temporary_is_not_visible() {
2041 let (tmp, store) = setup();
2042 let dir = directory(&tmp, &store);
2043 std::fs::write(dir.join("pending.tmp"), b"unpublished partial").unwrap();
2044 assert!(store.search("", 1).unwrap().is_empty());
2045 let reference = store.seal(&[msg("user", json!("complete"))], "").unwrap();
2046 assert!(!dir.join("pending.tmp").exists());
2047 std::fs::write(dir.join("000.json"), b"{\"version\":1,").unwrap();
2048 assert_eq!(
2049 store.fetch(&reference.id, 0, 1).unwrap_err().kind(),
2050 io::ErrorKind::InvalidData
2051 );
2052 assert!(store.search("", 1).is_err());
2053 assert!(store.seal(&[msg("user", json!("next"))], "").is_err());
2054 assert!(store.forget(&reference.id).is_err());
2055 }
2056
2057 #[test]
2058 fn private_modes_symlink_hardlink_fifo_and_lock_refusal() {
2059 let (tmp, store) = setup();
2060 let dir = directory(&tmp, &store);
2061 let reference = store.seal(&[msg("user", json!("private"))], "").unwrap();
2062 for path in [
2063 tmp.path().to_path_buf(),
2064 tmp.path().join("context-archives"),
2065 dir.clone(),
2066 ] {
2067 assert_eq!(
2068 std::fs::metadata(path).unwrap().permissions().mode() & 0o777,
2069 0o700
2070 );
2071 }
2072 for name in ["000.json", "lock"] {
2073 assert_eq!(
2074 std::fs::metadata(dir.join(name))
2075 .unwrap()
2076 .permissions()
2077 .mode()
2078 & 0o777,
2079 0o600
2080 );
2081 }
2082 let lock = store.fs.lock().unwrap();
2083 assert_eq!(
2084 store.fetch(&reference.id, 0, 1).unwrap_err().kind(),
2085 io::ErrorKind::WouldBlock
2086 );
2087 drop(lock);
2088 let victim = tmp.path().join("synthetic-victim");
2089 std::fs::write(&victim, "do not change").unwrap();
2090 std::fs::remove_file(dir.join("000.json")).unwrap();
2091 symlink(&victim, dir.join("000.json")).unwrap();
2092 assert!(store.search("", 1).is_err());
2093 assert!(store.seal(&[msg("user", json!("private"))], "").is_err());
2094 assert_eq!(std::fs::read_to_string(&victim).unwrap(), "do not change");
2095 std::fs::remove_file(dir.join("000.json")).unwrap();
2096 std::fs::hard_link(&victim, dir.join("000.json")).unwrap();
2097 assert!(store.search("", 1).is_err());
2098 std::fs::remove_file(dir.join("000.json")).unwrap();
2099 let fifo = std::ffi::CString::new(dir.join("000.json").to_str().unwrap()).unwrap();
2100 assert_eq!(unsafe { libc::mkfifo(fifo.as_ptr(), 0o600) }, 0);
2101 assert!(store.search("", 1).is_err());
2102 let alias = tmp.path().join("alias");
2103 symlink(tmp.path().join("context-archives"), &alias).unwrap();
2104 assert!(ArchiveStore::new(&alias.join("nested"), "p", "l").is_err());
2105 std::fs::remove_file(dir.join("lock")).unwrap();
2106 symlink(&victim, dir.join("lock")).unwrap();
2107 assert!(store.fs.lock().is_err());
2108 }
2109
2110 #[test]
2111 fn multi_mib_context_roundtrips_without_truncation() {
2112 let (_tmp, store) = setup();
2113 let text = "eligible exact evidence\n".repeat(400_000);
2114 assert!(text.len() > 8 * 1024 * 1024);
2115 let messages = vec![msg("user", json!(text))];
2116 let reference = store.seal(&messages, "large source window").unwrap();
2117 assert_eq!(store.fetch(&reference.id, 0, 1).unwrap(), messages);
2118 assert_eq!(store.seal(&messages, "retry").unwrap(), reference);
2119 }
2120
2121 #[test]
2122 fn private_reasoning_disguised_as_assistant_text_is_omitted() {
2123 let (_tmp, store) = setup();
2124 let messages = vec![
2125 Arc::new(json!({"role":"assistant","channel":"analysis","content":"private analysis"})),
2126 Arc::new(
2127 json!({"role":"assistant","content_class":"private_reasoning","content":"private reasoning"}),
2128 ),
2129 Arc::new(json!({"role":"assistant","private":true,"content":"private text"})),
2130 Arc::new(json!({"role":"assistant","channel":"final","content":"public evidence"})),
2131 ];
2132 let reference = store.seal(&messages, "").unwrap();
2133 assert_eq!(reference.message_count, 1);
2134 let fetched = store.fetch_with_provenance(&reference.id, 0, 1).unwrap();
2135 assert_eq!(fetched[0].source_index, 3);
2136 assert_eq!(fetched[0].message["content"], "public evidence");
2137 }
2138
2139 #[test]
2140 fn after_redaction_requires_host_redactor_including_parent_class() {
2141 let (_tmp, store) = setup();
2142 let messages = vec![Arc::new(json!({
2143 "role":"assistant", "disclosure":"model_visible_after_redaction",
2144 "content":[{"type":"text", "text":"sensitive host classified material"}]
2145 }))];
2146 let reference = store.seal(&messages, "").unwrap();
2147 let fetched = store.fetch(&reference.id, 0, 1).unwrap();
2148 assert_eq!(fetched[0]["content"][0]["text"], WITHHELD);
2149 }
2150
2151 #[test]
2152 fn bounded_search_utf8_and_permanent_capacity() {
2153 let (_tmp, store) = setup();
2154 for index in 0..MAX_SEGMENTS {
2155 store
2156 .seal(
2157 &[msg("user", json!(format!("{index} {}", "é".repeat(200))))],
2158 "",
2159 )
2160 .unwrap();
2161 }
2162 let descriptors = store.search("", MAX_SEARCH_RESULTS).unwrap();
2163 assert_eq!(descriptors.len(), MAX_SEARCH_RESULTS);
2164 assert!(descriptors.iter().all(|d| d.snippet.len() <= 256));
2165 assert!(store.seal(&[msg("user", json!("overflow"))], "").is_err());
2166 store.forget(&descriptors[0].id).unwrap();
2167 assert!(store.seal(&[msg("user", json!("still full"))], "").is_err());
2168 }
2169}