1use crate::core::disclosure::{gate_for_model, may_persist, DisclosureClass, ModelVisibility};
142use crate::core::stream_types::SharedMessage;
143use serde::{Deserialize, Serialize};
144use serde_json::{json, Value};
145use sha2::{Digest, Sha256};
146use std::cell::Cell;
147use std::collections::HashMap;
148use std::io::{self, Write};
149#[cfg(unix)]
150use std::io::Read;
151use std::path::{Path, PathBuf};
152use std::sync::atomic::{AtomicBool, Ordering};
153use std::sync::{Arc, Condvar, Mutex, OnceLock, PoisonError, Weak};
154use std::time::{Duration, Instant};
155
156pub const SHELF_FILES: usize = 128;
159const LAST_SHELF: u64 = 999_999;
163pub const MAX_SEGMENTS: usize = SHELF_FILES;
165pub const MAX_SEGMENT_BYTES: usize = 16 * 1024 * 1024;
166pub const SEARCH_SCAN_FILES: usize = 1024;
168pub const SEARCH_SCAN_BYTES: usize = 256 * 1024 * 1024;
170pub const MAX_INPUT_BYTES: usize = 16 * 1024 * 1024;
171pub const MAX_MESSAGES: usize = 4096;
172pub const MAX_NOTE_BYTES: usize = 8192;
173pub const MAX_FETCH_MESSAGES: usize = 128;
174pub const MAX_SEARCH_RESULTS: usize = 32;
175pub const REDUCED_KEY: &str = "_archive_reduced";
179pub const INPUT_JSON_KEY: &str = "_archive_json";
185const REDUCED_TEXT_BYTES: usize = 4096;
187const REDUCED_BLOCKS: usize = 256;
189pub const LOCK_WAIT: Duration = Duration::from_secs(30);
192const ABORT_POLL: Duration = Duration::from_millis(20);
194pub const PROCESS_LOCK_WAIT: Duration = Duration::from_secs(5);
197const PROCESS_LOCK_POLL: Duration = Duration::from_millis(25);
199const STALE_TEMP: Duration = Duration::from_secs(60 * 60);
203const WITHHELD: &str = "[archive: withheld]";
204pub const NOT_FOUND: &str = "archive not available in this scope";
207const BUSY: &str = "archive busy; retain source and retry";
208
209pub type ArchiveRedactor = dyn Fn(&str) -> String + Send + Sync;
213
214#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
215pub struct ArchiveRef {
216 pub id: String,
217 pub message_count: usize,
219 pub source_message_count: usize,
220}
221
222#[derive(Debug, Clone, PartialEq, Eq)]
224pub struct ArchivePart {
225 pub archive: ArchiveRef,
226 pub first_source_index: usize,
230 pub reduced_messages: usize,
233}
234
235#[derive(Debug, Clone, PartialEq, Eq)]
238pub struct SealedWindow {
239 pub parts: Vec<ArchivePart>,
241 pub note: String,
246}
247
248#[derive(Debug, Clone, PartialEq, Eq)]
250pub struct Forgotten {
251 pub ids: Vec<String>,
257 pub unread: Option<String>,
262}
263
264#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
265pub struct ArchiveDescriptor {
266 pub id: String,
267 pub message_count: usize,
268 pub source_message_count: usize,
269 pub snippet: String,
271}
272
273#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
274#[serde(deny_unknown_fields)]
275pub struct ArchivedMessage {
276 pub source_index: usize,
278 pub block_indices: Vec<usize>,
280 pub message: SharedMessage,
281}
282
283#[derive(Clone, PartialEq, Serialize, Deserialize)]
287#[serde(deny_unknown_fields)]
288pub struct ArchiveProjection {
289 pub logical_id: String,
290 pub source_message_count: usize,
291 pub messages: Vec<ArchivedMessage>,
292 pub note: String,
293 pub digest: String,
294}
295
296pub fn project_archive(
299 logical_id: &str,
300 messages: &[SharedMessage],
301 note: &str,
302 redactor: Option<Arc<ArchiveRedactor>>,
303) -> io::Result<ArchiveProjection> {
304 validate_key(logical_id)?;
305 project_hashed(
306 &digest_parts(&[logical_id.as_bytes()]),
307 messages,
308 note,
309 redactor,
310 Reduce::None,
311 )
312}
313
314#[derive(Clone, Copy, PartialEq, Eq)]
325enum Reduce {
326 None,
329 LargeInputs,
332 InputsAsText,
335 Inputs,
337 Text,
342}
343
344impl Reduce {
345 fn next(self) -> Option<Self> {
346 match self {
347 Reduce::None => Some(Reduce::LargeInputs),
348 Reduce::LargeInputs => Some(Reduce::InputsAsText),
349 Reduce::InputsAsText => Some(Reduce::Inputs),
350 Reduce::Inputs => Some(Reduce::Text),
351 Reduce::Text => None,
352 }
353 }
354
355 fn in_place_of(self, input: &Value) -> Option<usize> {
362 let as_text = || input_text_cost(input).unwrap_or(0);
363 match self {
364 Reduce::None => None,
365 Reduce::LargeInputs => InputBudget::alone(input).is_err().then(as_text),
366 Reduce::InputsAsText => Some(as_text()),
367 Reduce::Inputs | Reduce::Text => Some(0),
368 }
369 }
370}
371
372fn input_text_cost(input: &Value) -> Option<usize> {
376 let cost = json_len(input).saturating_add(INPUT_JSON_KEY.len() + 16);
377 (cost <= MAX_INPUT_BYTES).then_some(cost)
378}
379
380fn input_eligible(input: &Value) -> bool {
385 let mut pending = vec![input];
386 while let Some(value) = pending.pop() {
387 if eligible_class(value).is_none() {
388 return false;
389 }
390 match value {
391 Value::Array(values) => pending.extend(values),
392 Value::Object(values) => pending.extend(values.values()),
393 _ => {}
394 }
395 }
396 true
397}
398
399fn project_hashed(
400 logical_id: &str,
401 messages: &[SharedMessage],
402 note: &str,
403 redactor: Option<Arc<ArchiveRedactor>>,
404 reduce: Reduce,
405) -> io::Result<ArchiveProjection> {
406 debug_assert!(reduce == Reduce::None || messages.len() <= 1);
407 if note.len() > MAX_NOTE_BYTES {
409 return Err(invalid("archive message/note budget exceeded"));
410 }
411 if messages.len() > MAX_MESSAGES {
412 return Err(over("archive message/note budget exceeded"));
413 }
414 if reduce != Reduce::Text {
417 let mut budget = InputBudget {
418 bytes: 0,
419 nodes: 0,
420 reduce,
421 };
422 for message in messages {
423 budget.message(message, redactor.is_some())?;
424 }
425 }
426 let projector = Projector {
427 redactor,
428 reduce,
429 omitted: Cell::new(0),
430 as_text: Cell::new(0),
431 };
432 let mut eligible = Vec::new();
433 for (source_index, message) in messages.iter().enumerate() {
434 if let Some((message, block_indices)) = projector.project(message)? {
435 eligible.push(ArchivedMessage {
436 source_index,
437 block_indices,
438 message: Arc::new(message),
439 });
440 }
441 }
442 let digest = projection_digest(logical_id, messages.len(), &eligible)?;
443 let note = projector.screen(note, DisclosureClass::ModelVisible)?;
444 if note.len() > MAX_NOTE_BYTES {
445 return Err(invalid("redacted note exceeds archive budget"));
446 }
447 Ok(ArchiveProjection {
448 logical_id: logical_id.to_owned(),
449 source_message_count: messages.len(),
450 messages: eligible,
451 note,
452 digest,
453 })
454}
455
456struct Projector {
457 redactor: Option<Arc<ArchiveRedactor>>,
458 reduce: Reduce,
459 omitted: Cell<usize>,
461 as_text: Cell<usize>,
463}
464
465pub struct ArchiveStore {
467 fs: PrivateDir,
468 scope: String,
469 logical: String,
470 redactor: Option<Arc<ArchiveRedactor>>,
471 abort: Option<Arc<AtomicBool>>,
472 wait: Duration,
473 process_wait: Duration,
475 directory: PathBuf,
478}
479
480struct Gate {
485 held: Mutex<bool>,
486 released: Condvar,
487 #[cfg(test)]
489 waited: std::sync::atomic::AtomicUsize,
490}
491
492struct Locked {
494 _file: Option<FlockGuard>,
495 _gate: GateGuard,
496}
497
498struct GateGuard(Arc<Gate>);
499
500type DirIdentity = (u64, u64);
502type Gates = Mutex<HashMap<DirIdentity, Weak<Gate>>>;
503
504impl Gate {
505 fn for_directory(identity: DirIdentity) -> Arc<Gate> {
507 static GATES: OnceLock<Gates> = OnceLock::new();
508 let mut gates = GATES
509 .get_or_init(Default::default)
510 .lock()
511 .unwrap_or_else(PoisonError::into_inner);
512 if let Some(gate) = gates.get(&identity).and_then(Weak::upgrade) {
513 return gate;
514 }
515 gates.retain(|_, gate| gate.strong_count() > 0);
516 let gate = Arc::new(Gate {
517 held: Mutex::new(false),
518 released: Condvar::new(),
519 #[cfg(test)]
520 waited: Default::default(),
521 });
522 gates.insert(identity, Arc::downgrade(&gate));
523 gate
524 }
525
526 fn enter(
527 gate: Arc<Gate>,
528 wait: Duration,
529 abort: Option<&AtomicBool>,
530 busy: &'static str,
531 ) -> io::Result<GateGuard> {
532 let deadline = Instant::now() + wait;
533 {
534 let mut held = gate.held.lock().unwrap_or_else(PoisonError::into_inner);
535 let mut waited = false;
536 loop {
537 if abort.is_some_and(|abort| abort.load(Ordering::Acquire)) {
538 return Err(canceled());
539 }
540 let now = Instant::now();
544 if waited && now >= deadline {
545 return Err(io::Error::new(io::ErrorKind::WouldBlock, busy));
546 }
547 if !*held {
548 *held = true;
549 break;
550 }
551 if now >= deadline {
552 return Err(io::Error::new(io::ErrorKind::WouldBlock, busy));
553 }
554 let mut slice = deadline - now;
555 if abort.is_some() {
556 slice = slice.min(ABORT_POLL);
557 }
558 #[cfg(test)]
559 if !waited {
560 gate.waited.fetch_add(1, Ordering::AcqRel);
561 }
562 waited = true;
563 held = gate
564 .released
565 .wait_timeout(held, slice)
566 .unwrap_or_else(PoisonError::into_inner)
567 .0;
568 }
569 }
570 Ok(GateGuard(gate))
571 }
572}
573
574impl Drop for GateGuard {
575 fn drop(&mut self) {
576 *self.0.held.lock().unwrap_or_else(PoisonError::into_inner) = false;
577 self.0.released.notify_all();
580 }
581}
582
583struct FlockGuard(std::fs::File);
588
589impl Drop for FlockGuard {
590 fn drop(&mut self) {
591 let _ = fs4::fs_std::FileExt::unlock(&self.0);
592 }
593}
594
595#[derive(Debug)]
598struct LockRefused(&'static str);
599
600impl std::fmt::Display for LockRefused {
601 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
602 write!(f, "archive lock file {}", self.0)
603 }
604}
605
606impl std::error::Error for LockRefused {}
607
608struct Located {
611 shelf: String,
613 record: Record,
614 forgotten: bool,
617}
618
619enum Shelved {
621 Nothing,
622 This(PrivateDir, Record),
624 Other,
626}
627
628#[derive(Debug, Clone, PartialEq, Eq)]
631pub struct Unscanned {
632 pub stopped_after: Option<usize>,
635 pub unread: Option<String>,
640}
641
642#[derive(Default)]
646struct Unread {
647 places: usize,
648 first: Option<(String, io::Error)>,
649}
650
651impl Unread {
652 fn pass(&mut self, place: &str, error: io::Error) {
653 tracing::warn!(%place, %error, "archive lookup passed over what it could not read");
655 self.places += 1;
656 self.first.get_or_insert_with(|| (place.to_owned(), error));
657 }
658
659 fn notice(&self) -> Option<String> {
663 let (place, error) = self.first.as_ref()?;
664 let places = self.places;
665 Some(format!("{places} place(s) could not be read, first {place}: {error}"))
666 }
667
668 fn notice_in(&self, directory: &Path) -> Option<String> {
671 let notice = self.notice()?;
672 Some(format!("{notice} (in {})", directory.display()))
673 }
674
675 fn not_found(self) -> io::Error {
679 let Some((place, error)) = self.first else {
680 return not_found();
681 };
682 let kind = match error.kind() {
683 io::ErrorKind::NotFound => io::ErrorKind::Other,
684 kind => kind,
685 };
686 let places = self.places;
687 io::Error::new(
688 kind,
689 format!(
690 "archive not found in what could be read; {places} place(s) that may hold it \
691 could not be read, first {place}: {error}"
692 ),
693 )
694 }
695}
696
697#[derive(Debug)]
702enum Fault {
703 Parse(serde_json::Error),
706 Version(u32),
707 Scope,
709 Id,
710 Logical,
711 Digest,
712 Name,
714 Limit,
717 Order,
719 Mismatch { record: String, content: String },
721 TombstoneCreated,
723 Size(io::Error),
725}
726
727impl Fault {
728 fn into_read_error(self) -> io::Error {
732 match self {
733 Fault::Size(error) => error,
734 _ => corrupt(),
735 }
736 }
737}
738
739impl std::fmt::Display for Fault {
740 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
741 match self {
742 Fault::Parse(error) => {
745 let text = error.to_string();
746 let what = match error.classify() {
747 serde_json::error::Category::Data if text.starts_with("unknown field") => {
748 "an unknown field"
749 }
750 serde_json::error::Category::Data if text.starts_with("missing field") => {
751 "a field is missing"
752 }
753 serde_json::error::Category::Data if text.starts_with("duplicate field") => {
754 "a field is there twice"
755 }
756 serde_json::error::Category::Data if text.starts_with("unknown variant") => {
757 "an unknown state"
758 }
759 serde_json::error::Category::Data => "a field of the wrong type",
760 serde_json::error::Category::Eof => "the JSON ends early",
761 _ => "not JSON",
762 };
763 write!(f, "not an archive record: {what} (column {})", error.column())
764 }
765 Fault::Version(version) => write!(f, "version {version} is not supported"),
766 Fault::Scope => f.write_str("scope is not this project's"),
767 Fault::Id => f.write_str("invalid id"),
768 Fault::Logical => f.write_str("invalid logical"),
769 Fault::Digest => f.write_str("invalid digest"),
770 Fault::Name => f.write_str("file name is not the record's id"),
771 Fault::Limit => f.write_str("over a per-archive limit"),
772 Fault::Order => f.write_str("source_index out of order"),
773 Fault::Mismatch { record, content } => {
774 write!(f, "digest does not match content (record {record}, content {content})")
775 }
776 Fault::TombstoneCreated => f.write_str("a tombstone carries no created_ms"),
777 Fault::Size(_) => f.write_str("over the 16 MiB archive size"),
778 }
779 }
780}
781
782#[derive(Serialize, Deserialize)]
783#[serde(deny_unknown_fields)]
784struct Record {
785 version: u32,
786 scope: String,
787 logical: String,
788 id: String,
789 digest: String,
790 #[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
797 created_ms: Option<u64>,
798 body: Body,
799}
800
801fn present<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<Option<u64>, D::Error> {
804 u64::deserialize(deserializer).map(Some)
805}
806
807#[derive(Debug, Clone, Copy, PartialEq, Eq)]
811pub(crate) struct Written {
812 pub(crate) created_ms: Option<u64>,
815 pub(crate) modified: Option<std::time::SystemTime>,
818}
819
820impl Written {
821 fn now() -> Self {
825 let since_epoch = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH);
826 Written {
827 created_ms: since_epoch.ok().and_then(|since| u64::try_from(since.as_millis()).ok()),
828 modified: None,
829 }
830 }
831}
832
833#[derive(Serialize, Deserialize)]
834#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
835enum Body {
836 Live {
837 source_message_count: usize,
838 messages: Vec<ArchivedMessage>,
839 note: String,
840 },
841 Tombstone,
842}
843
844impl Record {
845 fn reference(&self) -> io::Result<ArchiveRef> {
846 match &self.body {
847 Body::Live {
848 source_message_count,
849 messages,
850 ..
851 } => Ok(ArchiveRef {
852 id: self.id.clone(),
853 message_count: messages.len(),
854 source_message_count: *source_message_count,
855 }),
856 Body::Tombstone => Err(not_found()),
857 }
858 }
859}
860
861const CREATED_AHEAD_MS: u64 = 24 * 60 * 60 * 1000;
864
865#[derive(Debug, Clone, PartialEq, Eq)]
867pub enum ImportOutcome {
868 Stored,
870 Present { note_differs: bool },
875 Forgotten,
878 KeptForgotten,
881}
882
883#[derive(Debug, Clone, PartialEq, Eq)]
885pub struct Imported {
886 pub outcome: ImportOutcome,
887 pub id: String,
889 pub live: bool,
891 pub message_count: usize,
893 pub shelf: Option<String>,
896 pub same_content_as: Option<String>,
900 pub unread: Option<String>,
903}
904
905#[derive(Debug, Clone, PartialEq, Eq)]
908pub struct ImportRefused {
909 pub reason: String,
912 pub id: Option<String>,
914}
915
916#[derive(Default)]
920pub struct ImportSeen {
921 projects: HashMap<String, Planned>,
922}
923
924#[derive(Default)]
926struct Planned {
927 ids: HashMap<String, Held>,
929 forgotten: std::collections::HashSet<String>,
931 shelf: Option<(u64, usize)>,
933}
934
935#[derive(Clone)]
937struct Held {
938 digest: String,
939 note: Option<String>,
941 shelf: Option<String>,
942}
943
944#[derive(Default)]
947struct Probe {
948 forgotten: bool,
950 live: Option<String>,
952 held: bool,
955 taken: [bool; 2],
958}
959
960impl Probe {
961 fn guard(&self, digest: &str) -> Option<String> {
965 let free = self.taken.iter().position(|taken| !taken)?;
966 (!self.held).then(|| digest[free * 32..(free + 1) * 32].to_owned())
967 }
968}
969
970impl Planned {
971 fn shelve(&mut self, store: Option<&ArchiveStore>, shelves: &[String]) -> String {
975 let (number, held) = self.shelf.get_or_insert_with(|| {
976 let newest = shelves.first().and_then(|name| Some((shelf_number(name)?, name)));
977 let Some((number, name)) = newest else {
978 return (0, SHELF_FILES);
979 };
980 let names = store
981 .and_then(|store| store.fs.open_child(name, true).ok().flatten())
982 .and_then(|shelf| shelf.names().ok());
983 let held = names.map_or(SHELF_FILES, |names| {
985 names.iter().filter(|(name, ..)| name.ends_with(".json")).count()
986 });
987 (number, held)
988 });
989 if *held >= SHELF_FILES {
990 *number += 1;
991 *held = 0;
992 }
993 *held += 1;
994 shelf_name(*number)
995 }
996}
997
998pub fn project_scope(scope_key: &str) -> io::Result<String> {
1000 validate_key(scope_key)?;
1001 Ok(digest_parts(&[scope_key.as_bytes()]))
1002}
1003
1004pub fn record_scope(line: &[u8]) -> Result<String, ImportRefused> {
1010 #[derive(Deserialize)]
1011 struct Scoped {
1012 scope: String,
1013 }
1014 let refused = |reason: String| ImportRefused { reason, id: None };
1015 let scoped: Scoped =
1016 serde_json::from_slice(line).map_err(|error| refused(Fault::Parse(error).to_string()))?;
1017 if !is_scope(&scoped.scope) {
1018 return Err(refused("invalid scope".to_owned()));
1019 }
1020 Ok(scoped.scope)
1021}
1022
1023fn is_scope(scope: &str) -> bool {
1025 scope.len() == 64 && lower_hex(scope)
1026}
1027fn lower_hex(text: &str) -> bool {
1028 text.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
1029}
1030
1031impl ArchiveStore {
1032 pub fn new(base: &Path, scope_key: &str, logical_id: &str) -> io::Result<Self> {
1042 let (scope, logical) = Self::keys(scope_key, logical_id)?;
1043 let fs = PrivateDir::new(base, &scope)?;
1044 Ok(Self::opened(fs, base, scope, logical))
1045 }
1046
1047 pub fn open_existing(
1054 base: &Path,
1055 scope_key: &str,
1056 logical_id: &str,
1057 ) -> io::Result<Option<Self>> {
1058 let (scope, logical) = Self::keys(scope_key, logical_id)?;
1059 match PrivateDir::open_existing(base, &scope) {
1060 Ok(fs) => Ok(Some(Self::opened(fs, base, scope, logical))),
1061 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
1062 Err(error) => Err(error),
1063 }
1064 }
1065
1066 fn keys(scope_key: &str, logical_id: &str) -> io::Result<(String, String)> {
1069 for key in [scope_key, logical_id] {
1070 validate_key(key)?;
1071 }
1072 let scope = digest_parts(&[scope_key.as_bytes()]);
1073 Ok((scope, digest_parts(&[logical_id.as_bytes()])))
1074 }
1075
1076 fn opened(fs: PrivateDir, base: &Path, scope: String, logical: String) -> Self {
1077 Self {
1078 directory: base.join("context-archives").join(&scope),
1079 fs,
1080 scope,
1081 logical,
1082 redactor: None,
1083 abort: None,
1084 wait: LOCK_WAIT,
1085 process_wait: PROCESS_LOCK_WAIT,
1086 }
1087 }
1088
1089 pub fn with_redactor(mut self, redactor: Arc<ArchiveRedactor>) -> Self {
1092 self.redactor = Some(redactor);
1093 self
1094 }
1095
1096 pub fn with_abort(mut self, abort: Arc<AtomicBool>) -> Self {
1105 self.abort = Some(abort);
1106 self
1107 }
1108
1109 fn exclusive(&self) -> io::Result<Locked> {
1115 self.locked(|fs| fs.lock().map(Some))
1116 }
1117
1118 fn lock_error(&self, error: io::Error) -> io::Error {
1121 let refused = error.get_ref().and_then(|inner| inner.downcast_ref::<LockRefused>());
1122 let Some(LockRefused(what)) = refused else {
1123 return error;
1124 };
1125 let file = self.directory.join("lock");
1126 let file = file.display();
1127 io::Error::new(
1128 error.kind(),
1129 format!(
1130 "context archive lock file {file} {what}; nothing is saved or forgotten while \
1131 it is. It holds no data: remove it (rm '{file}') and try again"
1132 ),
1133 )
1134 }
1135
1136 fn shared(&self) -> io::Result<Locked> {
1142 self.locked(PrivateDir::lock_existing)
1143 }
1144
1145 fn locked(
1146 &self,
1147 lock: impl Fn(&PrivateDir) -> io::Result<Option<FlockGuard>>,
1148 ) -> io::Result<Locked> {
1149 let gate = self.enter(BUSY)?;
1150 let file = crate::core::lock_file::lock_within(
1152 self.process_wait,
1153 PROCESS_LOCK_POLL,
1154 || match lock(&self.fs) {
1155 Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
1156 self.check_abort()?;
1157 Ok(None)
1158 }
1159 result => result.map(Some).map_err(|error| self.lock_error(error)),
1160 },
1161 )?
1162 .ok_or_else(|| io::Error::new(io::ErrorKind::WouldBlock, BUSY))?;
1163 Ok(Locked { _file: file, _gate: gate })
1164 }
1165
1166 fn check_abort(&self) -> io::Result<()> {
1168 match &self.abort {
1169 Some(abort) if abort.load(Ordering::Acquire) => Err(canceled()),
1170 _ => Ok(()),
1171 }
1172 }
1173
1174 fn enter(&self, busy: &'static str) -> io::Result<GateGuard> {
1175 let gate = Gate::for_directory(self.fs.identity()?);
1176 Gate::enter(gate, self.wait, self.abort.as_deref(), busy)
1177 }
1178
1179 pub fn seal(&self, messages: &[SharedMessage], note: &str) -> io::Result<ArchiveRef> {
1180 self.seal_with_note(messages, note)
1181 .map(|(reference, _)| reference)
1182 }
1183
1184 pub fn seal_with_note(
1188 &self,
1189 messages: &[SharedMessage],
1190 note: &str,
1191 ) -> io::Result<(ArchiveRef, String)> {
1192 let (reference, note, _) = self.seal_one(messages, note, Reduce::None)?;
1193 Ok((reference, note))
1194 }
1195
1196 pub fn seal_window(&self, messages: &[SharedMessage], note: &str) -> io::Result<SealedWindow> {
1225 let redactor = self.redactor.clone();
1228 let screened = project_hashed(&self.logical, &[], note, redactor, Reduce::None)?.note;
1229 let mut window = SealedWindow {
1230 parts: Vec::new(),
1231 note: String::new(),
1232 };
1233 let mut start = 0;
1234 loop {
1235 self.check_abort()?;
1237 let mut end = self.fill(messages, start);
1238 loop {
1245 let note = if end == messages.len() { note } else { "" };
1246 match self.seal_part(messages, start..end, note, &mut window) {
1247 Ok(()) => break,
1248 Err(error) if over_budget(&error) && end - start > 1 => {
1249 end = start + (1 << (end - start - 1).ilog2());
1250 }
1251 Err(error) => return Err(error),
1252 }
1253 }
1254 start = end;
1255 if start == messages.len() {
1256 break;
1257 }
1258 }
1259 if let Some((last, earlier)) = window.parts.split_last() {
1263 if earlier.iter().any(|part| part.archive.id == last.archive.id) {
1264 window.note = screened;
1265 }
1266 }
1267 Ok(window)
1268 }
1269
1270 fn fill(&self, messages: &[SharedMessage], start: usize) -> usize {
1278 let mut budget = InputBudget {
1279 bytes: 0,
1280 nodes: 0,
1281 reduce: Reduce::None,
1282 };
1283 let limit = messages.len().min(start.saturating_add(MAX_MESSAGES));
1284 let redactor = self.redactor.is_some();
1285 let over = (start..limit).find(|&index| budget.message(&messages[index], redactor).is_err());
1286 let end = over.unwrap_or(limit).max(start + 1).min(messages.len());
1287 before_results(messages, start, end)
1288 }
1289
1290 fn seal_part(
1294 &self,
1295 messages: &[SharedMessage],
1296 range: std::ops::Range<usize>,
1297 note: &str,
1298 window: &mut SealedWindow,
1299 ) -> io::Result<()> {
1300 let mut reduce = Reduce::None;
1301 loop {
1302 match self.seal_one(&messages[range.clone()], note, reduce) {
1303 Ok((archive, stored, reduced_messages)) => {
1304 window.parts.push(ArchivePart {
1305 archive,
1306 first_source_index: range.start,
1307 reduced_messages,
1308 });
1309 window.note = stored;
1310 return Ok(());
1311 }
1312 Err(error) if !over_budget(&error) => return Err(error),
1313 Err(error) => match reduce.next().filter(|_| range.len() == 1) {
1316 Some(next) => reduce = next,
1317 None => return Err(error),
1318 },
1319 }
1320 }
1321 }
1322
1323 fn seal_one(
1326 &self,
1327 messages: &[SharedMessage],
1328 note: &str,
1329 reduce: Reduce,
1330 ) -> io::Result<(ArchiveRef, String, usize)> {
1331 let redactor = self.redactor.clone();
1332 let projection = project_hashed(&self.logical, messages, note, redactor, reduce)?;
1333 let reduced = projection
1334 .messages
1335 .iter()
1336 .filter(|archived| archived.message.get(REDUCED_KEY).is_some())
1337 .count();
1338 let (reference, note) = self.seal_projection(projection, None)?;
1339 Ok((reference, note, reduced))
1340 }
1341
1342 pub(crate) fn seal_projection(
1356 &self,
1357 projection: ArchiveProjection,
1358 written: Option<Written>,
1359 ) -> io::Result<(ArchiveRef, String)> {
1360 self.admit(&projection)?;
1361 let _lock = self.exclusive()?;
1362 self.store(projection, written.unwrap_or_else(Written::now))
1363 }
1364
1365 fn admit(&self, projection: &ArchiveProjection) -> io::Result<()> {
1373 if self.logical.is_empty() {
1376 return Err(invalid("archive store was opened for a project, not a chat: no seal"));
1377 }
1378 let count = projection.source_message_count;
1379 Self::admit_content(
1380 &self.logical,
1381 &projection.logical_id,
1382 count,
1383 &projection.messages,
1384 &projection.digest,
1385 )
1386 }
1387
1388 fn admit_content(
1393 chat: &str,
1394 logical: &str,
1395 count: usize,
1396 messages: &[ArchivedMessage],
1397 digest: &str,
1398 ) -> io::Result<()> {
1399 if digest.len() != 64 || !digest.bytes().all(|b| b.is_ascii_hexdigit()) {
1400 return Err(invalid("archive projection digest is not 64 hex characters"));
1401 }
1402 if logical != chat {
1403 return Err(invalid("archive projection is of another chat"));
1404 }
1405 if projection_digest(logical, count, messages)? != digest {
1406 return Err(invalid("archive projection digest is not the digest of its content"));
1407 }
1408 Ok(())
1409 }
1410
1411 fn store(
1427 &self,
1428 projection: ArchiveProjection,
1429 written: Written,
1430 ) -> io::Result<(ArchiveRef, String)> {
1431 let ArchiveProjection {
1432 digest,
1433 source_message_count,
1434 messages,
1435 note,
1436 ..
1437 } = projection;
1438 let mut record = Record {
1439 version: 1,
1440 scope: self.scope.clone(),
1441 logical: self.logical.clone(),
1442 id: String::new(),
1443 digest,
1444 created_ms: written.created_ms,
1445 body: Body::Live {
1446 source_message_count,
1447 messages,
1448 note: note.clone(),
1449 },
1450 };
1451 let mut legacy_read = false;
1452 for _ in 0..3 {
1457 let shelves = self.shelves()?;
1458 let (mut stored, mut free) = (None, None);
1459 for half in [0..32, 32..64] {
1461 let id = &record.digest[half];
1462 match self.shelved(&shelves, id, &record.digest, false)? {
1463 Shelved::This(_, found) if matches!(found.body, Body::Tombstone) => {
1464 return Err(forgotten());
1465 }
1466 Shelved::This(shelf, found) => {
1467 stored.get_or_insert((shelf, found));
1468 }
1469 Shelved::Other => {}
1470 Shelved::Nothing => {
1471 free.get_or_insert_with(|| id.to_owned());
1472 }
1473 }
1474 }
1475 if let Some((shelf, found)) = stored {
1476 return Self::already(&shelf, &found);
1477 }
1478 if !legacy_read {
1479 if let Some(found) = self.legacy_projection(&record.digest, false)? {
1481 return Self::already(&self.fs, &found);
1482 }
1483 legacy_read = true;
1484 }
1485 let Some(id) = free else {
1486 break;
1487 };
1488 record.id = id;
1489 let bytes = bounded_json(&record, MAX_SEGMENT_BYTES)?;
1490 let name = archive_name(&record.id);
1491 self.readable(&name, &bytes)?;
1493 let (shelf, _) = self.writable_shelf(&shelves)?;
1495 match shelf.publish_dated(&name, &bytes, true, written.modified) {
1496 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
1497 result => return result.and_then(|()| Ok((record.reference()?, note))),
1498 }
1499 }
1500 Err(io::Error::new(
1501 io::ErrorKind::AlreadyExists,
1502 "both archive ids of this projection are taken by other files",
1503 ))
1504 }
1505
1506 fn readable(&self, name: &str, bytes: &[u8]) -> io::Result<()> {
1517 match Self::parsed(&self.scope, name, bytes) {
1518 Ok(_) => Ok(()),
1519 Err(fault) => Err(io::Error::new(
1520 io::ErrorKind::InvalidInput,
1521 format!("archive record would not be read back and is not written: {fault}"),
1522 )),
1523 }
1524 }
1525
1526 fn shelved(
1549 &self,
1550 shelves: &[String],
1551 id: &str,
1552 digest: &str,
1553 readonly: bool,
1554 ) -> io::Result<Shelved> {
1555 let name = archive_name(id);
1556 let mut taken = false;
1557 for shelf in shelves {
1558 let dir = match self.fs.open_child(shelf, readonly) {
1559 Ok(Some(dir)) => dir,
1560 Ok(None) => continue,
1561 Err(error) if PrivateDir::passing(&error) => return Err(error),
1562 Err(error) => {
1563 tracing::warn!(%shelf, %error, "seal passed over an archive shelf");
1564 continue;
1565 }
1566 };
1567 let raw = match dir.read_with(&name, MAX_SEGMENT_BYTES, readonly) {
1568 Ok(Some(raw)) => raw,
1569 Ok(None) => continue,
1570 Err(error) if PrivateDir::not_a_segment(&error) => {
1571 tracing::warn!(%shelf, file = %name, %error, "seal found its archive id taken");
1572 taken = true;
1573 continue;
1574 }
1575 Err(error) => return Err(error),
1576 };
1577 match self.record(&name, &raw) {
1578 Ok(record) if record.digest == digest => return Ok(Shelved::This(dir, record)),
1579 Err(_) if says_forgotten(&raw, digest) => return Err(forgotten()),
1580 _ => {
1583 tracing::warn!(%shelf, file = %name, "seal found its archive id taken");
1584 taken = true;
1585 }
1586 }
1587 }
1588 Ok(if taken { Shelved::Other } else { Shelved::Nothing })
1589 }
1590
1591 fn already(dir: &PrivateDir, record: &Record) -> io::Result<(ArchiveRef, String)> {
1595 let Body::Live { note, .. } = &record.body else {
1596 return Err(forgotten());
1597 };
1598 dir.sync()?;
1600 Ok((record.reference()?, note.clone()))
1601 }
1602
1603 pub fn fetch(&self, id: &str, start: usize, limit: usize) -> io::Result<Vec<SharedMessage>> {
1604 Ok(self
1605 .fetch_with_provenance(id, start, limit)?
1606 .into_iter()
1607 .map(|m| m.message)
1608 .collect())
1609 }
1610
1611 pub fn fetch_with_provenance(
1614 &self,
1615 id: &str,
1616 start: usize,
1617 limit: usize,
1618 ) -> io::Result<Vec<ArchivedMessage>> {
1619 validate_id(id)?;
1620 if limit > MAX_FETCH_MESSAGES {
1621 return Err(invalid("archive fetch limit exceeds 128"));
1622 }
1623 match self.find(id)?.body {
1624 Body::Live { messages, .. } => {
1625 Ok(messages.into_iter().skip(start).take(limit).collect())
1626 }
1627 Body::Tombstone => Err(not_found()),
1628 }
1629 }
1630
1631 pub fn fetch_note(&self, id: &str) -> io::Result<String> {
1634 validate_id(id)?;
1635 match self.find(id)?.body {
1636 Body::Live { note, .. } => Ok(note),
1637 Body::Tombstone => Err(not_found()),
1638 }
1639 }
1640
1641 pub fn search(&self, query: &str, limit: usize) -> io::Result<Vec<ArchiveDescriptor>> {
1646 self.search_scan(query, limit).map(|(rows, _)| rows)
1647 }
1648
1649 pub fn search_scan(
1667 &self,
1668 query: &str,
1669 limit: usize,
1670 ) -> io::Result<(Vec<ArchiveDescriptor>, Option<Unscanned>)> {
1671 if query.len() > 512 || limit > MAX_SEARCH_RESULTS {
1672 return Err(invalid("archive search query/result budget exceeded"));
1673 }
1674 if limit == 0 {
1675 return Ok((Vec::new(), None));
1676 }
1677 let query = query.to_lowercase();
1678 self.check_abort()?;
1679 let (shelves, mut slots) = self.layout()?;
1680 let mut rows = Vec::new();
1681 let (mut files, mut bytes) = (0, 0);
1682 let mut legacy_lock = None;
1683 let mut unread = Unread::default();
1684 let done = |rows: Vec<ArchiveDescriptor>, stopped_after: Option<usize>, unread: &Unread| {
1687 let unread = unread.notice();
1688 let unscanned = (stopped_after.is_some() || unread.is_some()).then_some(Unscanned {
1689 stopped_after,
1690 unread,
1691 });
1692 Ok::<_, io::Error>((rows, unscanned))
1693 };
1694 for shelf in shelves.iter().map(Some).chain([None]) {
1696 let opened;
1697 let (dir, names) = match shelf {
1698 Some(shelf) => {
1699 let listed = self.fs.open_child(shelf, true).and_then(|dir| match dir {
1700 Some(dir) => Ok(Some((dir.names()?, dir))),
1701 None => Ok(None),
1702 });
1703 let (mut entries, dir) = match listed {
1704 Ok(Some(listed)) => listed,
1705 Ok(None) => continue,
1706 Err(error) => {
1708 unread.pass(shelf, error);
1709 continue;
1710 }
1711 };
1712 entries.retain(|(name, ..)| archive_id(name).is_some());
1713 entries.sort_unstable_by(|a, b| b.2.cmp(&a.2).then_with(|| a.0.cmp(&b.0)));
1716 opened = dir;
1717 let names: Vec<_> = entries.into_iter().map(|(name, ..)| name).collect();
1718 (&opened, names)
1719 }
1720 None => (&self.fs, std::mem::take(&mut slots)),
1721 };
1722 for name in names {
1723 if rows.len() == limit {
1724 return done(rows, None, &unread);
1725 }
1726 if files == SEARCH_SCAN_FILES || bytes >= SEARCH_SCAN_BYTES {
1727 return done(rows, Some(files), &unread);
1728 }
1729 if shelf.is_none() && legacy_lock.is_none() {
1730 legacy_lock = Some(self.shared()?);
1731 }
1732 let place = || match shelf {
1733 Some(shelf) => format!("{shelf}/{name}"),
1734 None => name.clone(),
1735 };
1736 let raw = match dir.read_with(&name, MAX_SEGMENT_BYTES, true) {
1737 Ok(Some(raw)) => raw,
1738 Ok(None) => continue,
1739 Err(error) => {
1743 unread.pass(&place(), error);
1744 files += 1;
1745 continue;
1746 }
1747 };
1748 files += 1;
1749 bytes += raw.len();
1750 match self.record(&name, &raw) {
1751 Ok(record) => rows.extend(describe(record, &query)?),
1752 Err(error) => unread.pass(&place(), error),
1753 }
1754 }
1755 }
1756 done(rows, None, &unread)
1757 }
1758
1759 pub fn forget(&self, id: &str) -> io::Result<Forgotten> {
1792 validate_id(id)?;
1793 let _lock = self.exclusive()?;
1794 self.forget_locked(id)
1795 }
1796
1797 fn write_guard(
1810 &self,
1811 shelves: &[String],
1812 logical: &str,
1813 digest: &str,
1814 taken: [bool; 2],
1815 ) -> io::Result<()> {
1816 for (index, id) in [&digest[..32], &digest[32..]].into_iter().enumerate() {
1817 if taken[index] {
1818 continue;
1819 }
1820 let guard = Record {
1821 version: 1,
1822 scope: self.scope.clone(),
1823 logical: logical.to_owned(),
1824 id: id.to_owned(),
1825 digest: digest.to_owned(),
1826 created_ms: None,
1827 body: Body::Tombstone,
1828 };
1829 let bytes = bounded_json(&guard, MAX_SEGMENT_BYTES)?;
1830 let name = archive_name(id);
1831 self.readable(&name, &bytes)?;
1832 let (shelf, _) = self.writable_shelf(shelves)?;
1833 match shelf.publish(&name, &bytes, true) {
1834 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
1835 result => return result,
1836 }
1837 }
1838 Ok(())
1839 }
1840
1841 fn forget_locked(&self, id: &str) -> io::Result<Forgotten> {
1844 let (shelves, slots) = self.layout()?;
1845 let mut unread = Unread::default();
1847 let asked = match self.locate(&shelves, id, false, &mut unread)? {
1848 Some(found) => found.record,
1849 None => match self.legacy_segment(&slots, id, false, &mut unread) {
1850 Some((_, record)) => record,
1851 None => return Err(unread.not_found()),
1852 },
1853 };
1854 let digest = asked.digest.as_str();
1855 let derived = [&digest[..32], &digest[32..]];
1856 let mut names: Vec<_> = derived.iter().map(|id| archive_name(id)).collect();
1857 if !derived.contains(&id) {
1858 names.push(archive_name(id));
1859 }
1860 let mut unread = Unread::default();
1864 let mut copies = Vec::new();
1865 let (mut guarded, mut taken) = (false, [false; 2]);
1868 for shelf in &shelves {
1869 let dir = match self.fs.open_child(shelf, false) {
1870 Ok(Some(dir)) => dir,
1871 Ok(None) => continue,
1872 Err(error) => {
1873 unread.pass(shelf, error);
1874 continue;
1875 }
1876 };
1877 for (index, name) in names.iter().enumerate() {
1878 let raw = match dir.read_with(name, MAX_SEGMENT_BYTES, false) {
1879 Ok(Some(raw)) => Some(raw),
1880 Ok(None) => continue,
1881 Err(error) => {
1882 unread.pass(&format!("{shelf}/{name}"), error);
1883 None
1884 }
1885 };
1886 if let Some(held) = taken.get_mut(index) {
1887 *held = true;
1888 }
1889 let Some(raw) = raw else {
1890 continue;
1891 };
1892 match self.record(name, &raw) {
1893 Ok(record) if record.digest == digest => {
1894 copies.push((Some(shelf), name.clone(), record));
1895 }
1896 Ok(_) => {}
1898 Err(_) if says_forgotten(&raw, digest) => guarded |= index < 2,
1900 Err(error) => unread.pass(&format!("{shelf}/{name}"), error),
1903 }
1904 }
1905 for (name, record) in self.twins(&dir, shelf, digest, &names, false, &mut unread) {
1911 copies.push((Some(shelf), name, record));
1912 }
1913 }
1914 for slot in &slots {
1915 let raw = self.fs.read_with(slot, MAX_SEGMENT_BYTES, false);
1916 match raw.and_then(|raw| raw.map(|raw| self.record(slot, &raw)).transpose()) {
1917 Ok(Some(record)) if record.digest == digest => {
1918 copies.push((None, slot.clone(), record));
1919 }
1920 Ok(_) => {}
1921 Err(error) => unread.pass(slot, error),
1923 }
1924 }
1925 let seen_by_a_seal = |(shelf, name, _): &(Option<&String>, String, Record)| {
1927 shelf.is_some() && archive_id(name).is_some_and(|id| derived.contains(&id))
1928 };
1929 copies.sort_by_key(|copy| !seen_by_a_seal(copy));
1930 guarded |= copies.first().is_some_and(seen_by_a_seal);
1931 if !guarded {
1932 self.write_guard(&shelves, &asked.logical, digest, taken)?;
1935 }
1936 let mut ids = Vec::new();
1937 for (shelf, name, mut record) in copies {
1938 let opened;
1939 let dir = match shelf {
1940 Some(shelf) => {
1941 opened = self.fs.open_child(shelf, false)?.ok_or_else(not_found)?;
1942 &opened
1943 }
1944 None => &self.fs,
1945 };
1946 if matches!(record.body, Body::Tombstone) {
1947 dir.sync()?;
1949 continue;
1950 }
1951 record.body = Body::Tombstone;
1954 record.created_ms = None;
1955 dir.publish(&name, &bounded_json(&record, MAX_SEGMENT_BYTES)?, false)?;
1956 ids.push(record.id);
1957 }
1958 ids.sort_by_key(|forgotten| forgotten != id);
1959 Ok(Forgotten {
1960 ids,
1961 unread: unread.notice_in(&self.directory),
1962 })
1963 }
1964
1965 fn twins(
1971 &self,
1972 dir: &PrivateDir,
1973 shelf: &str,
1974 digest: &str,
1975 names: &[String],
1976 readonly: bool,
1977 unread: &mut Unread,
1978 ) -> Vec<(String, Record)> {
1979 let listed = match dir.names() {
1980 Ok(listed) => listed,
1981 Err(error) => {
1982 unread.pass(shelf, error);
1983 return Vec::new();
1984 }
1985 };
1986 let mut twins = Vec::new();
1987 for (name, is_dir, _) in listed {
1988 if is_dir || archive_id(&name).is_none() || names.contains(&name) {
1989 continue;
1990 }
1991 let raw = match dir.read_with(&name, MAX_SEGMENT_BYTES, readonly) {
1992 Ok(Some(raw)) => raw,
1993 Ok(None) => continue,
1997 Err(error) if PrivateDir::not_a_segment(&error) => continue,
1998 Err(error) => {
2001 unread.pass(&format!("{shelf}/{name}"), error);
2002 continue;
2003 }
2004 };
2005 if !names_digest(&raw, digest) {
2007 continue;
2008 }
2009 match self.record(&name, &raw) {
2010 Ok(record) => twins.push((name, record)),
2011 Err(_) if says_forgotten(&raw, digest) => {}
2012 Err(error) => unread.pass(&format!("{shelf}/{name}"), error),
2015 }
2016 }
2017 twins
2018 }
2019
2020 fn live_twin(
2025 &self,
2026 shelves: &[String],
2027 digest: &str,
2028 names: &[String],
2029 readonly: bool,
2030 ) -> io::Result<Option<String>> {
2031 let mut unread = Unread::default();
2032 let mut live = None;
2033 for shelf in shelves {
2034 let dir = match self.fs.open_child(shelf, readonly) {
2035 Ok(Some(dir)) => dir,
2036 Ok(None) => continue,
2037 Err(error) => {
2038 unread.pass(shelf, error);
2039 continue;
2040 }
2041 };
2042 for (_, record) in self.twins(&dir, shelf, digest, names, readonly, &mut unread) {
2043 if matches!(record.body, Body::Live { .. }) {
2044 live.get_or_insert(record.id);
2045 }
2046 }
2047 }
2048 match (live, unread.notice()) {
2049 (Some(id), _) => Ok(Some(id)),
2050 (None, None) => Ok(None),
2051 (None, Some(notice)) => Err(io::Error::other(format!(
2052 "a tombstone is not imported while a file that may hold its content live cannot be read: {notice}"
2053 ))),
2054 }
2055 }
2056
2057 pub fn for_scope(base: &Path, scope: &str) -> io::Result<Self> {
2063 if !is_scope(scope) {
2064 return Err(invalid("archive scope must be 64 lowercase hex characters"));
2065 }
2066 let fs = PrivateDir::new(base, scope)?;
2067 Ok(Self::opened(fs, base, scope.to_owned(), String::new()))
2068 }
2069
2070 pub fn existing_scope(base: &Path, scope: &str) -> io::Result<Option<Self>> {
2074 if !is_scope(scope) {
2075 return Err(invalid("archive scope must be 64 lowercase hex characters"));
2076 }
2077 match PrivateDir::open_existing(base, scope) {
2078 Ok(fs) => Ok(Some(Self::opened(fs, base, scope.to_owned(), String::new()))),
2079 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
2080 Err(error) => Err(error),
2081 }
2082 }
2083
2084 pub fn scope(&self) -> &str {
2086 &self.scope
2087 }
2088
2089 pub fn import(
2121 &self,
2122 line: &[u8],
2123 rehome: bool,
2124 dry_run: bool,
2125 seen: &mut ImportSeen,
2126 ) -> io::Result<Result<Imported, ImportRefused>> {
2127 Self::import_in(Some(self), &self.scope, line, rehome, dry_run, seen)
2128 }
2129
2130 pub fn import_dry_absent(
2134 scope: &str,
2135 line: &[u8],
2136 rehome: bool,
2137 seen: &mut ImportSeen,
2138 ) -> io::Result<Result<Imported, ImportRefused>> {
2139 if !is_scope(scope) {
2140 return Err(invalid("archive scope must be 64 lowercase hex characters"));
2141 }
2142 Self::import_in(None, scope, line, rehome, true, seen)
2143 }
2144
2145 fn import_in(
2146 store: Option<&Self>,
2147 scope: &str,
2148 line: &[u8],
2149 rehome: bool,
2150 dry_run: bool,
2151 seen: &mut ImportSeen,
2152 ) -> io::Result<Result<Imported, ImportRefused>> {
2153 let refuse = |reason: String, id: Option<&str>| {
2154 Ok(Err(ImportRefused {
2155 reason,
2156 id: id.map(str::to_owned),
2157 }))
2158 };
2159 let mut record: Record = match serde_json::from_slice(line) {
2160 Ok(record) => record,
2161 Err(error) => return refuse(Fault::Parse(error).to_string(), None),
2162 };
2163 let id = validate_id(&record.id).is_ok().then(|| record.id.clone());
2164 let id = id.as_deref();
2165 if !is_scope(&record.scope) {
2166 return refuse("invalid scope".to_owned(), id);
2167 }
2168 if rehome {
2169 scope.clone_into(&mut record.scope);
2170 }
2171 let Ok(bytes) = bounded_json(&record, MAX_SEGMENT_BYTES) else {
2174 return refuse("over the 16 MiB archive size".to_owned(), id);
2175 };
2176 let name = archive_name(&record.id);
2177 let record = match Self::parsed(scope, &name, &bytes) {
2178 Ok(record) => record,
2179 Err(fault) => return refuse(fault.to_string(), id),
2180 };
2181 if !lower_hex(&record.digest) {
2183 return refuse(Fault::Digest.to_string(), id);
2184 }
2185 let mut modified = None;
2186 if let Body::Live {
2187 source_message_count,
2188 messages,
2189 ..
2190 } = &record.body
2191 {
2192 if !messages.iter().all(|archived| archived.message.is_object()) {
2193 return refuse("message is not an object".to_owned(), id);
2194 }
2195 let (logical, digest) = (&record.logical, &record.digest);
2197 let admitted =
2198 Self::admit_content(logical, logical, *source_message_count, messages, digest);
2199 if let Err(error) = admitted {
2200 return refuse(error.to_string(), id);
2201 }
2202 if let Some(created_ms) = record.created_ms {
2203 let now = Written::now().created_ms.unwrap_or(0);
2207 if created_ms > now.saturating_add(CREATED_AHEAD_MS) {
2208 let reason = "created_ms is more than a day after this machine's clock";
2209 return refuse(reason.to_owned(), id);
2210 }
2211 let time = std::time::UNIX_EPOCH.checked_add(Duration::from_millis(created_ms));
2212 let Some(time) = time else {
2213 return refuse("created_ms is not a time a file can have".to_owned(), id);
2214 };
2215 modified = Some(time);
2216 }
2217 }
2218 let incoming = match &record.body {
2219 Body::Live { note, .. } => Some(note.as_str()),
2220 Body::Tombstone => None,
2221 };
2222 let message_count = match &record.body {
2223 Body::Live { messages, .. } => messages.len(),
2224 Body::Tombstone => 0,
2225 };
2226 let digest = record.digest.as_str();
2227 let mut plan = match dry_run {
2228 true => Some(seen.projects.entry(scope.to_owned()).or_default()),
2229 false => None,
2230 };
2231 let mut _lock = None;
2234 let (shelves, slots) = match (store, dry_run) {
2235 (Some(store), false) => {
2236 _lock = Some(store.exclusive()?);
2237 store.layout()?
2238 }
2239 (Some(store), true) => {
2240 store.check_abort()?;
2241 let layout = store.layout()?;
2242 if !layout.1.is_empty() {
2243 _lock = Some(store.shared()?);
2244 }
2245 layout
2246 }
2247 (None, true) => Default::default(),
2248 (None, false) => return Err(invalid("an import that writes needs a store")),
2249 };
2250
2251 let mut found = plan.as_ref().and_then(|plan| plan.ids.get(&record.id).cloned());
2253 if let (None, Some(store)) = (&found, store) {
2254 let mut unread = Unread::default();
2255 let located = match store.locate(&shelves, &record.id, dry_run, &mut unread)? {
2256 Some(located) if located.forgotten => {
2257 return Err(io::Error::new(
2258 io::ErrorKind::InvalidData,
2259 "a damaged file under this archive id says its content was forgotten, \
2260 over a valid record on an older shelf",
2261 ));
2262 }
2263 Some(located) => Some((Some(located.shelf), located.record)),
2264 None => store
2265 .legacy_segment(&slots, &record.id, dry_run, &mut unread)
2266 .map(|(_, record)| (None, record)),
2267 };
2268 if located.is_none() && unread.places > 0 {
2269 return Err(unread.not_found());
2271 }
2272 found = located.map(|(shelf, held)| Held {
2273 digest: held.digest,
2274 note: match held.body {
2275 Body::Live { note, .. } => Some(note),
2276 Body::Tombstone => None,
2277 },
2278 shelf,
2279 });
2280 }
2281 if let (Some(held), Some(plan)) = (&mut found, &plan) {
2282 if plan.forgotten.contains(&held.digest) {
2283 held.note = None;
2284 }
2285 }
2286 let imported = |outcome, shelf, same_content_as, unread| {
2287 Ok(Ok(Imported {
2288 outcome,
2289 id: record.id.clone(),
2290 live: incoming.is_some(),
2291 message_count,
2292 shelf,
2293 same_content_as,
2294 unread,
2295 }))
2296 };
2297 if let Some(held) = found {
2298 if held.digest != digest {
2299 return refuse("id already holds different content".to_owned(), id);
2300 }
2301 let mut unread = None;
2302 let outcome = match (held.note.as_deref(), incoming) {
2303 (Some(stored), Some(note)) => ImportOutcome::Present {
2304 note_differs: stored != note,
2305 },
2306 (None, None) => ImportOutcome::Present { note_differs: false },
2307 (None, Some(_)) => ImportOutcome::KeptForgotten,
2308 (Some(_), None) => {
2309 match (&mut plan, store) {
2310 (Some(plan), _) => {
2311 let probe =
2314 Self::probe(store, Some(&**plan), &shelves, digest, true)?;
2315 if let Some(free) = probe.guard(digest) {
2316 let shelf = Some(plan.shelve(store, &shelves));
2317 let guard = Held {
2318 digest: digest.to_owned(),
2319 note: None,
2320 shelf,
2321 };
2322 plan.ids.insert(free, guard);
2323 }
2324 plan.forgotten.insert(digest.to_owned());
2325 for planned in plan.ids.values_mut() {
2326 if planned.digest == digest {
2327 planned.note = None;
2328 }
2329 }
2330 }
2331 (None, Some(store)) => unread = store.forget_locked(&record.id)?.unread,
2332 (None, None) => {}
2333 }
2334 ImportOutcome::Forgotten
2335 }
2336 };
2337 if let (false, Some(store)) = (dry_run, store) {
2338 if outcome != ImportOutcome::Forgotten {
2339 match &held.shelf {
2344 Some(shelf) => {
2345 store.fs.open_child(shelf, false)?.ok_or_else(not_found)?.sync()?;
2346 }
2347 None => store.fs.sync()?,
2348 }
2349 }
2350 }
2351 return imported(outcome, held.shelf, None, unread);
2352 }
2353
2354 let mut probe = Self::probe(store, plan.as_deref(), &shelves, digest, dry_run)?;
2356 let mut same_content_as = None;
2357 if incoming.is_some() {
2358 if probe.forgotten {
2359 return refuse("this content was forgotten in this store".to_owned(), id);
2360 }
2361 same_content_as = probe.live.take();
2362 } else if let Some(other) =
2363 Self::live_copy(store, plan.as_deref(), &probe, &shelves, &record, dry_run)?
2364 {
2365 let reason = format!(
2370 "a tombstone of content that is live in this store as {other}: forget that \
2371 archive here, then import again"
2372 );
2373 return refuse(reason, id);
2374 }
2375 let derived = record.id == digest[..32] || record.id == digest[32..];
2378 let guard = match incoming.is_none() && !derived {
2379 true => probe.guard(digest),
2380 false => None,
2381 };
2382 let shelf = match (plan, store) {
2383 (Some(plan), _) => {
2384 if let Some(guard) = guard {
2385 let held = Held {
2386 digest: digest.to_owned(),
2387 note: None,
2388 shelf: Some(plan.shelve(store, &shelves)),
2389 };
2390 plan.ids.insert(guard, held);
2391 }
2392 let shelf = plan.shelve(store, &shelves);
2393 let held = Held {
2394 digest: digest.to_owned(),
2395 note: incoming.map(str::to_owned),
2396 shelf: Some(shelf.clone()),
2397 };
2398 plan.ids.insert(record.id.clone(), held);
2399 shelf
2400 }
2401 (None, Some(store)) => {
2402 let mut shelves = shelves;
2403 if guard.is_some() {
2404 store.write_guard(&shelves, &record.logical, digest, probe.taken)?;
2405 shelves = store.shelves()?;
2406 }
2407 let (dir, shelf) = store.writable_shelf(&shelves)?;
2409 dir.publish_dated(&name, &bytes, true, modified)?;
2410 shelf
2411 }
2412 (None, None) => return Err(invalid("an import that writes needs a store")),
2413 };
2414 imported(ImportOutcome::Stored, Some(shelf), same_content_as, None)
2415 }
2416
2417 fn live_copy(
2422 store: Option<&Self>,
2423 plan: Option<&Planned>,
2424 probe: &Probe,
2425 shelves: &[String],
2426 record: &Record,
2427 readonly: bool,
2428 ) -> io::Result<Option<String>> {
2429 let digest = record.digest.as_str();
2430 if probe.live.is_some() {
2431 return Ok(probe.live.clone());
2432 }
2433 if let Some(plan) = plan {
2434 if plan.forgotten.contains(digest) {
2435 return Ok(None);
2437 }
2438 let live = |(_, held): &(&String, &Held)| held.digest == digest && held.note.is_some();
2439 if let Some((id, _)) = plan.ids.iter().filter(live).min_by_key(|(id, _)| *id) {
2440 return Ok(Some(id.clone()));
2441 }
2442 }
2443 let Some(store) = store else {
2444 return Ok(None);
2445 };
2446 let names = [&digest[..32], &digest[32..], &record.id].map(archive_name);
2447 store.live_twin(shelves, digest, &names, readonly)
2448 }
2449
2450 fn probe(
2454 store: Option<&Self>,
2455 plan: Option<&Planned>,
2456 shelves: &[String],
2457 digest: &str,
2458 readonly: bool,
2459 ) -> io::Result<Probe> {
2460 let mut probe = Probe::default();
2461 for (index, half) in [0..32, 32..64].into_iter().enumerate() {
2462 let id = &digest[half];
2463 probe.taken[index] = true;
2464 let shelved = match store {
2465 Some(store) => store.shelved(shelves, id, digest, readonly),
2466 None => Ok(Shelved::Nothing),
2467 };
2468 match shelved {
2469 Ok(Shelved::This(_, record)) => {
2470 probe.held = true;
2471 match record.body {
2472 Body::Tombstone => probe.forgotten = true,
2473 Body::Live { .. } => {
2474 probe.live.get_or_insert(record.id);
2475 }
2476 }
2477 }
2478 Ok(Shelved::Other) => {}
2479 Ok(Shelved::Nothing) => match plan.and_then(|plan| plan.ids.get(id)) {
2480 Some(planned) if planned.digest == digest => {
2481 probe.held = true;
2482 match planned.note {
2483 None => probe.forgotten = true,
2484 Some(_) => {
2485 probe.live.get_or_insert_with(|| id.to_owned());
2486 }
2487 }
2488 }
2489 Some(_) => {}
2490 None => probe.taken[index] = false,
2491 },
2492 Err(error) if is_forgotten(&error) => {
2493 probe.held = true;
2494 probe.forgotten = true;
2495 }
2496 Err(error) => return Err(error),
2497 }
2498 }
2499 if let Some(store) = store {
2500 match store.legacy_projection(digest, readonly) {
2501 Ok(Some(record)) => match record.body {
2502 Body::Tombstone => probe.forgotten = true,
2503 Body::Live { .. } => {
2504 probe.live.get_or_insert(record.id);
2505 }
2506 },
2507 Ok(None) => {}
2508 Err(error) if is_forgotten(&error) => probe.forgotten = true,
2509 Err(error) => return Err(error),
2510 }
2511 }
2512 if plan.is_some_and(|plan| plan.forgotten.contains(digest)) {
2513 probe.forgotten = true;
2514 probe.live = None;
2515 }
2516 Ok(probe)
2517 }
2518
2519 fn shelves(&self) -> io::Result<Vec<String>> {
2521 Ok(self.layout()?.0)
2522 }
2523
2524 fn layout(&self) -> io::Result<(Vec<String>, Vec<String>)> {
2531 let (mut shelves, mut slots) = (Vec::new(), Vec::new());
2532 for (name, ..) in self.fs.names()? {
2533 if let Some(number) = shelf_number(&name) {
2534 shelves.push((number, name));
2535 } else if slot_number(&name).is_some() {
2536 slots.push(name);
2537 }
2538 }
2539 shelves.sort_unstable();
2540 slots.sort_unstable();
2541 let shelves = shelves.into_iter().rev().map(|(_, name)| name).collect();
2542 slots.reverse();
2543 Ok((shelves, slots))
2544 }
2545
2546 fn writable_shelf(&self, shelves: &[String]) -> io::Result<(PrivateDir, String)> {
2556 let highest = shelves.first().and_then(|name| shelf_number(name));
2557 let highest = highest.unwrap_or(0);
2558 let last = highest >= LAST_SHELF;
2561 for (index, name) in shelves.iter().enumerate() {
2562 let shelf = self.fs.child(name, true);
2564 match shelf.and_then(|shelf| Ok((shelf.names()?, shelf))) {
2565 Ok((names, shelf)) => {
2566 sweep(&shelf, &names);
2567 let held = names.iter().filter(|(name, ..)| name.ends_with(".json"));
2568 if last || (index == 0 && held.count() < SHELF_FILES) {
2569 return Ok((shelf, name.clone()));
2570 }
2571 break;
2572 }
2573 Err(error) if PrivateDir::passing(&error) => return Err(error),
2576 Err(error) => {
2577 tracing::warn!(shelf = %name, %error, "seal passed over an archive shelf");
2578 if !last {
2579 break;
2580 }
2581 }
2582 }
2583 }
2584 if last {
2585 return Err(invalid("archive shelf numbers exhausted"));
2586 }
2587 let name = shelf_name(highest + 1);
2588 Ok((self.fs.child(&name, true)?, name))
2589 }
2590
2591 fn locate(
2608 &self,
2609 shelves: &[String],
2610 id: &str,
2611 readonly: bool,
2612 unread: &mut Unread,
2613 ) -> io::Result<Option<Located>> {
2614 let name = archive_name(id);
2615 let (mut first, mut passed) = (None, Vec::<Vec<u8>>::new());
2618 for shelf in shelves {
2619 let dir = match self.fs.open_child(shelf, readonly) {
2620 Ok(Some(dir)) => dir,
2621 Ok(None) => continue,
2622 Err(error) => {
2623 unread.pass(shelf, error);
2624 continue;
2625 }
2626 };
2627 let error = match dir.read_with(&name, MAX_SEGMENT_BYTES, readonly) {
2628 Ok(None) => continue,
2629 Ok(Some(raw)) => match self.record(&name, &raw) {
2630 Ok(record) => {
2631 let forgotten =
2632 passed.iter().any(|raw| says_forgotten(raw, &record.digest));
2633 return Ok(Some(Located {
2634 shelf: shelf.clone(),
2635 record,
2636 forgotten,
2637 }));
2638 }
2639 Err(error) => {
2640 passed.push(raw);
2641 error
2642 }
2643 },
2644 Err(error) if PrivateDir::not_a_segment(&error) => error,
2645 Err(error) => return Err(error),
2646 };
2647 tracing::warn!(%shelf, file = %name, %error, "archive lookup passed over a file under its id");
2648 first.get_or_insert_with(|| {
2650 io::Error::new(
2651 error.kind(),
2652 format!("the newest file under this id is not its record: {shelf}/{name}: {error}"),
2653 )
2654 });
2655 }
2656 first.map_or(Ok(None), Err)
2657 }
2658
2659 fn find(&self, id: &str) -> io::Result<Record> {
2665 self.check_abort()?;
2666 let (shelves, slots) = self.layout()?;
2667 let mut unread = Unread::default();
2668 if let Some(found) = self.locate(&shelves, id, true, &mut unread)? {
2669 return if found.forgotten { Err(not_found()) } else { Ok(found.record) };
2671 }
2672 if !slots.is_empty() {
2673 let _lock = self.shared()?;
2674 if let Some((_, record)) = self.legacy_segment(&slots, id, true, &mut unread) {
2675 return Ok(record);
2676 }
2677 }
2678 Err(unread.not_found())
2679 }
2680
2681 fn legacy_segment(
2689 &self,
2690 slots: &[String],
2691 id: &str,
2692 readonly: bool,
2693 unread: &mut Unread,
2694 ) -> Option<(String, Record)> {
2695 for name in slots {
2696 let raw = self.fs.read_with(name, MAX_SEGMENT_BYTES, readonly);
2697 match raw.and_then(|raw| raw.map(|raw| self.record(name, &raw)).transpose()) {
2698 Ok(Some(record)) if record.id == id => return Some((name.clone(), record)),
2699 Ok(_) => {}
2700 Err(error) => unread.pass(name, error),
2701 }
2702 }
2703 None
2704 }
2705
2706 fn record(&self, name: &str, raw: &[u8]) -> io::Result<Record> {
2710 Self::parsed(&self.scope, name, raw).map_err(Fault::into_read_error)
2711 }
2712
2713 fn parsed(scope: &str, name: &str, raw: &[u8]) -> Result<Record, Fault> {
2715 let record: Record = serde_json::from_slice(raw).map_err(Fault::Parse)?;
2716 Self::check(scope, &record, name)?;
2717 Ok(record)
2718 }
2719
2720 fn check(scope: &str, record: &Record, name: &str) -> Result<(), Fault> {
2724 let hex = |text: &str| text.len() == 64 && text.bytes().all(|b| b.is_ascii_hexdigit());
2725 if record.version != 1 {
2726 return Err(Fault::Version(record.version));
2727 }
2728 if record.scope != scope {
2729 return Err(Fault::Scope);
2730 }
2731 if validate_id(&record.id).is_err() {
2732 return Err(Fault::Id);
2733 }
2734 if !hex(&record.logical) {
2735 return Err(Fault::Logical);
2736 }
2737 if !hex(&record.digest) {
2738 return Err(Fault::Digest);
2739 }
2740 if name != archive_name(&record.id) && slot_number(name).is_none() {
2742 return Err(Fault::Name);
2743 }
2744 let Body::Live {
2745 messages,
2746 source_message_count,
2747 note,
2748 } = &record.body
2749 else {
2750 return match record.created_ms {
2752 Some(_) => Err(Fault::TombstoneCreated),
2753 None => Ok(()),
2754 };
2755 };
2756 if messages.len() > MAX_MESSAGES
2757 || *source_message_count > MAX_MESSAGES
2758 || note.len() > MAX_NOTE_BYTES
2759 {
2760 return Err(Fault::Limit);
2761 }
2762 if messages.iter().any(|m| m.source_index >= *source_message_count)
2763 || messages.windows(2).any(|m| m[0].source_index >= m[1].source_index)
2764 {
2765 return Err(Fault::Order);
2766 }
2767 let content = projection_digest(&record.logical, *source_message_count, messages)
2768 .map_err(Fault::Size)?;
2769 if content != record.digest {
2770 return Err(Fault::Mismatch {
2771 record: record.digest.clone(),
2772 content,
2773 });
2774 }
2775 Ok(())
2776 }
2777
2778 fn legacy_projection(&self, digest: &str, readonly: bool) -> io::Result<Option<Record>> {
2799 for slot in 0..MAX_SEGMENTS {
2800 let name = slot_name(slot);
2801 let record = match self.fs.read_with(&name, MAX_SEGMENT_BYTES, readonly) {
2802 Ok(Some(raw)) => match self.record(&name, &raw) {
2803 Err(_) if says_forgotten(&raw, digest) => return Err(forgotten()),
2804 record => record,
2805 },
2806 Ok(None) => continue,
2807 Err(error) => Err(error),
2808 };
2809 match record {
2810 Ok(record) if record.digest == digest => return Ok(Some(record)),
2811 Ok(_) => {}
2812 Err(error) if PrivateDir::passing(&error) => return Err(error),
2814 Err(error) => {
2816 tracing::warn!(slot = %name, %error, "seal passed over a legacy archive slot");
2817 }
2818 }
2819 }
2820 Ok(None)
2821 }
2822}
2823
2824fn describe(record: Record, query: &str) -> io::Result<Option<ArchiveDescriptor>> {
2826 let Body::Live {
2827 messages,
2828 source_message_count,
2829 ..
2830 } = &record.body
2831 else {
2832 return Ok(None);
2833 };
2834 let mut snippet = None;
2835 for message in messages {
2836 let text = serde_json::to_string(&message.message).map_err(json_error)?;
2837 if query.is_empty() || text.to_lowercase().contains(query) {
2838 snippet = Some(prefix(&text, 256).to_owned());
2839 break;
2840 }
2841 }
2842 if snippet.is_none() && !(query.is_empty() && messages.is_empty()) {
2843 return Ok(None);
2844 }
2845 Ok(Some(ArchiveDescriptor {
2846 message_count: messages.len(),
2847 source_message_count: *source_message_count,
2848 snippet: snippet.unwrap_or_default(),
2849 id: record.id,
2850 }))
2851}
2852
2853impl Projector {
2854 fn screen(&self, text: &str, class: DisclosureClass) -> io::Result<String> {
2855 if class == DisclosureClass::ModelVisibleAfterRedaction && self.redactor.is_none() {
2856 return Ok(WITHHELD.to_owned());
2857 }
2858 let redactor = |text: &str| {
2859 let text = self
2860 .redactor
2861 .as_ref()
2862 .map_or_else(|| text.to_owned(), |r| r(text));
2863 if credential_like(&text) {
2864 WITHHELD.to_owned()
2865 } else {
2866 text
2867 }
2868 };
2869 let text = redactor(text);
2871 if text.len() > MAX_SEGMENT_BYTES && self.reduce != Reduce::Text {
2874 return Err(over("redactor output exceeds archive budget"));
2875 }
2876 match gate_for_model(class, &text, false, Some(&|s| s.to_owned())) {
2877 ModelVisibility::Visible(text) => Ok(text),
2878 ModelVisibility::Withheld(_) => Ok(WITHHELD.to_owned()),
2879 }
2880 }
2881
2882 fn text(&self, text: &str, class: DisclosureClass) -> io::Result<String> {
2886 let text = self.screen(text, class)?;
2887 if self.reduce != Reduce::Text || text.len() <= REDUCED_TEXT_BYTES {
2888 return Ok(text);
2889 }
2890 let kept = prefix(&text, REDUCED_TEXT_BYTES);
2891 let omitted = self.omission(format_args!("{} bytes", text.len() - kept.len()));
2892 Ok(format!("{kept}\n{omitted}"))
2893 }
2894
2895 fn omission(&self, what: std::fmt::Arguments<'_>) -> String {
2898 self.omitted.set(self.omitted.get() + 1);
2899 format!("[archive: {what} omitted, over the per-archive limit]")
2900 }
2901
2902 fn stored(&self, role: &str, content: Value) -> Value {
2909 let mut message = json!({"role": role, "content": content});
2910 let (omitted, as_text) = (self.omitted.replace(0), self.as_text.replace(0));
2911 let changed = match (omitted, as_text) {
2912 (0, 0) => return message,
2913 (omitted, 0) => format!("{omitted} omission(s)"),
2914 (0, as_text) => format!("{as_text} tool input(s) as JSON text"),
2915 (omitted, as_text) => {
2916 format!("{omitted} omission(s) and {as_text} tool input(s) as JSON text")
2917 }
2918 };
2919 message[REDUCED_KEY] = json!(format!(
2920 "this message alone is over the per-archive limit; it is stored with {changed}, \
2921 each marked in place"
2922 ));
2923 message
2924 }
2925
2926 fn project(&self, message: &Value) -> io::Result<Option<(Value, Vec<usize>)>> {
2927 let Some((class, role, content)) = projection_content(message) else {
2928 return Ok(None);
2929 };
2930 self.omitted.set(0);
2931 self.as_text.set(0);
2932 if let Some(text) = content.as_str() {
2933 let content = json!(self.text(text, class)?);
2934 return Ok(Some((self.stored(role, content), Vec::new())));
2935 }
2936 let Some(blocks) = content.as_array() else {
2937 return Ok(None);
2938 };
2939 let mut output = Vec::new();
2940 let mut indices = Vec::new();
2941 for (index, block) in blocks.iter().enumerate() {
2942 if self.reduce == Reduce::Text && output.len() == REDUCED_BLOCKS {
2943 let rest = self.omission(format_args!("{} blocks", blocks.len() - index));
2945 output.push(json!({"type":"text", "text":rest}));
2946 indices.push(index);
2947 break;
2948 }
2949 let Some(mut block_class) = eligible_class(block) else {
2950 continue;
2951 };
2952 if class == DisclosureClass::ModelVisibleAfterRedaction {
2953 block_class = class;
2954 }
2955 let kept = match block.get("type").and_then(Value::as_str) {
2956 Some("text") => block
2957 .get("text")
2958 .and_then(Value::as_str)
2959 .map(|text| {
2960 self.text(text, block_class)
2961 .map(|text| json!({"type":"text", "text":text}))
2962 })
2963 .transpose()?,
2964 Some("tool_use") if role == "assistant" => {
2965 if let (Some(id), Some(name)) =
2966 (identifier(block, "id"), identifier(block, "name"))
2967 {
2968 let input = if name == "context_checkpoint" {
2969 json!({"_archive_withheld":true})
2972 } else if self.redactor.is_some() {
2973 self.tool_input(block.get("input"), block_class)?
2974 } else {
2975 json!({"_archive_withheld":true})
2976 };
2977 Some(json!({"type":"tool_use", "id":id, "name":name, "input":input}))
2978 } else {
2979 None
2980 }
2981 }
2982 Some("tool_result") if role == "user" => {
2983 if let Some(id) = identifier(block, "tool_use_id") {
2984 let content = if self.redactor.is_some() {
2985 self.tool_result(&block["content"], block_class)?
2986 } else {
2987 Value::String(WITHHELD.to_owned())
2988 };
2989 let mut result =
2990 json!({"type":"tool_result", "tool_use_id":id, "content":content});
2991 if let Some(error) = block.get("is_error").and_then(Value::as_bool) {
2992 result["is_error"] = json!(error);
2993 }
2994 Some(result)
2995 } else {
2996 None
2997 }
2998 }
2999 _ => None,
3000 };
3001 if let Some(kept) = kept {
3002 output.push(kept);
3003 indices.push(index);
3004 }
3005 }
3006 if output.is_empty() {
3007 Ok(None)
3008 } else {
3009 Ok(Some((self.stored(role, json!(output)), indices)))
3010 }
3011 }
3012
3013 fn tool_input(&self, input: Option<&Value>, class: DisclosureClass) -> io::Result<Value> {
3016 match (self.reduce, input) {
3017 (Reduce::Inputs | Reduce::Text, Some(input)) => Ok(self.omitted_input(input)),
3018 (Reduce::InputsAsText, Some(input)) => self.input_as_text(input, class),
3019 (Reduce::LargeInputs, Some(value)) => match self.exact_input(input, class) {
3020 Err(error) if over_budget(&error) => self.input_as_text(value, class),
3021 exact => exact,
3022 },
3023 _ => self.exact_input(input, class),
3024 }
3025 }
3026
3027 fn omitted_input(&self, input: &Value) -> Value {
3030 let bytes = json_len(input);
3031 let marker = self.omission(format_args!("tool input of {bytes} bytes"));
3032 json!({"_archive_omitted": marker})
3033 }
3034
3035 fn input_as_text(&self, input: &Value, class: DisclosureClass) -> io::Result<Value> {
3044 if !input_eligible(input) {
3045 return Ok(json!({"_archive_withheld":true}));
3046 }
3047 if input_text_cost(input).is_none() {
3048 return Ok(self.omitted_input(input));
3049 }
3050 let encoded = bounded_json(input, MAX_INPUT_BYTES)?;
3051 let text = std::str::from_utf8(&encoded).map_err(|_| corrupt())?;
3052 match self.screen(text, class) {
3053 Ok(text) if text == WITHHELD => Ok(json!({"_archive_withheld":true})),
3054 Ok(text) => {
3055 self.as_text.set(self.as_text.get() + 1);
3056 Ok(json!({INPUT_JSON_KEY: text}))
3057 }
3058 Err(error) if over_budget(&error) => Ok(self.omitted_input(input)),
3059 Err(error) => Err(error),
3060 }
3061 }
3062
3063 fn exact_input(&self, input: Option<&Value>, class: DisclosureClass) -> io::Result<Value> {
3064 if let Some(input) = input {
3065 if let Some(bytes) = InputBudget::alone(input)? {
3071 let limit = bytes
3072 .checked_mul(6)
3073 .ok_or_else(|| over("archive input byte budget exceeded"))?;
3074 let encoded = bounded_json(input, limit)?;
3075 let text =
3076 self.screen(std::str::from_utf8(&encoded).map_err(|_| corrupt())?, class)?;
3077 return Ok(serde_json::from_str(&text)
3078 .unwrap_or_else(|_| json!({"_archive_withheld":true})));
3079 }
3080 }
3081 Ok(json!({"_archive_withheld":true}))
3082 }
3083
3084 fn tool_result(&self, content: &Value, class: DisclosureClass) -> io::Result<Value> {
3085 if let Some(text) = content.as_str() {
3086 return Ok(json!(self.text(text, class)?));
3087 }
3088 let mut output = Vec::new();
3089 if let Some(blocks) = content.as_array() {
3090 for (index, block) in blocks.iter().enumerate() {
3091 if let Some(class) = eligible_class(block) {
3092 if block["type"] == "text" {
3093 if let Some(text) = block["text"].as_str() {
3094 let text = self.text(text, class)?;
3095 if self.reduce == Reduce::Text {
3096 let rest = blocks.len() - index - 1;
3099 if rest == 0 {
3100 return Ok(json!(text));
3101 }
3102 let rest = self.omission(format_args!("{rest} blocks"));
3103 return Ok(json!(format!("{text}\n{rest}")));
3104 }
3105 output.push(json!({"type":"text", "text":text}));
3106 }
3107 }
3108 }
3109 }
3110 }
3111 if output.is_empty() {
3112 Ok(json!(WITHHELD))
3113 } else {
3114 Ok(json!(output))
3115 }
3116 }
3117}
3118
3119const READER: &str = "project-reader";
3122
3123pub fn archive_file(base: &Path, scope_key: &str, id: &str) -> io::Result<Option<PathBuf>> {
3128 validate_id(id)?;
3129 let Some(store) = ArchiveStore::open_existing(base, scope_key, READER)? else {
3130 return Ok(None);
3131 };
3132 let directory = base.join("context-archives").join(&store.scope);
3133 let mut unread = Unread::default();
3134 let (shelves, slots) = store.layout()?;
3135 if let Some(found) = store.locate(&shelves, id, true, &mut unread)? {
3136 if found.forgotten {
3137 return Err(corrupt());
3139 }
3140 return Ok(Some(directory.join(found.shelf).join(archive_name(id))));
3141 }
3142 match store.legacy_segment(&slots, id, true, &mut unread) {
3143 Some((slot, _)) => Ok(Some(directory.join(slot))),
3144 None if unread.places == 0 => Ok(None),
3145 None => Err(unread.not_found()),
3146 }
3147}
3148
3149pub fn project_search(
3153 base: &Path,
3154 scope_key: &str,
3155 query: &str,
3156 limit: usize,
3157) -> io::Result<Vec<ArchiveDescriptor>> {
3158 match ArchiveStore::open_existing(base, scope_key, READER)? {
3159 Some(store) => store.search(query, limit),
3160 None => Ok(Vec::new()),
3161 }
3162}
3163
3164pub fn project_fetch(
3168 base: &Path,
3169 scope_key: &str,
3170 id: &str,
3171 start: usize,
3172 limit: usize,
3173) -> io::Result<Vec<SharedMessage>> {
3174 validate_id(id)?;
3175 match ArchiveStore::open_existing(base, scope_key, READER)? {
3176 Some(store) => store.fetch(id, start, limit),
3177 None => Err(not_found()),
3178 }
3179}
3180
3181fn eligible_class(value: &Value) -> Option<DisclosureClass> {
3182 if value
3185 .get("channel")
3186 .is_some_and(|v| !matches!(v.as_str(), Some("final")))
3187 || value.get("content_class").is_some_and(|v| {
3188 !matches!(
3189 v.as_str(),
3190 Some("user_text" | "assistant_text" | "tool_calls" | "tool_results")
3191 )
3192 })
3193 || value
3194 .get("private")
3195 .is_some_and(|v| v != &Value::Bool(false))
3196 {
3197 return None;
3198 }
3199 for key in ["sensitivity", "retention", "retention_class"] {
3200 if let Some(v) = value.get(key) {
3201 if !matches!(v.as_str(), Some("normal" | "standard" | "model_visible")) {
3202 return None;
3203 }
3204 }
3205 }
3206 let mut class = DisclosureClass::ModelVisible;
3207 for key in ["disclosure", "disclosure_class"] {
3208 if let Some(v) = value.get(key) {
3209 let parsed = DisclosureClass::parse(v.as_str()?)?;
3210 if !may_persist(parsed)
3211 || !matches!(
3212 parsed,
3213 DisclosureClass::ModelVisible | DisclosureClass::ModelVisibleAfterRedaction
3214 )
3215 {
3216 return None;
3217 }
3218 class = parsed;
3219 }
3220 }
3221 Some(class)
3222}
3223
3224fn projection_content(message: &Value) -> Option<(DisclosureClass, &str, &Value)> {
3228 if message.get("_synaps_context").is_some()
3229 || crate::core::host_note::is_tool_in_flight_note(message)
3230 {
3231 return None;
3232 }
3233 let class = eligible_class(message)?;
3234 let role @ ("user" | "assistant") = message.get("role")?.as_str()? else {
3235 return None;
3236 };
3237 Some((class, role, message.get("content")?))
3238}
3239
3240fn identifier<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
3241 let text = value.get(key)?.as_str()?;
3242 (!text.is_empty()
3243 && text.len() <= 128
3244 && !credential_like(text)
3245 && text
3246 .bytes()
3247 .all(|b| b.is_ascii_alphanumeric() || b"_-.:".contains(&b)))
3248 .then_some(text)
3249}
3250
3251fn credential_like(text: &str) -> bool {
3253 let lower = text.to_ascii_lowercase();
3254 [
3255 "password",
3256 "passwd",
3257 "api_key",
3258 "apikey",
3259 "api-key",
3260 "access_token",
3261 "refresh_token",
3262 "secret",
3263 "authorization",
3264 "bearer ",
3265 "private key",
3266 "sk-",
3267 "ghp_",
3268 "github_pat_",
3269 "xoxb-",
3270 "akia",
3271 "eyjh",
3272 ]
3273 .iter()
3274 .any(|needle| lower.contains(needle))
3275}
3276
3277struct InputBudget {
3278 bytes: usize,
3279 nodes: usize,
3280 reduce: Reduce,
3281}
3282impl InputBudget {
3283 fn node(&mut self, depth: usize) -> io::Result<()> {
3284 self.nodes += 1;
3285 if depth > 32 || self.nodes > 65536 {
3286 return Err(over("archive input structural budget exceeded"));
3287 }
3288 Ok(())
3289 }
3290
3291 fn charge(&mut self, bytes: usize) -> io::Result<()> {
3292 self.bytes = self.bytes.saturating_add(bytes);
3293 if self.bytes > MAX_INPUT_BYTES {
3294 return Err(over("archive input byte budget exceeded"));
3295 }
3296 Ok(())
3297 }
3298
3299 fn alone(input: &Value) -> io::Result<Option<usize>> {
3302 let mut budget = InputBudget {
3303 bytes: 0,
3304 nodes: 0,
3305 reduce: Reduce::None,
3306 };
3307 let bytes = budget.input(input, 3)?;
3308 if let Some(bytes) = bytes {
3309 budget.charge(bytes)?;
3310 }
3311 Ok(bytes)
3312 }
3313
3314 fn field(&mut self, key: &str, text: &str, depth: usize) -> io::Result<()> {
3317 self.node(depth)?;
3318 self.charge(key.len().saturating_add(8).saturating_add(text.len()))
3319 }
3320
3321 fn text_field(
3322 &mut self,
3323 key: &str,
3324 text: &str,
3325 class: DisclosureClass,
3326 redactor: bool,
3327 depth: usize,
3328 ) -> io::Result<()> {
3329 if class == DisclosureClass::ModelVisibleAfterRedaction && !redactor {
3330 return self.node(depth); }
3332 self.field(key, text, depth)
3333 }
3334
3335 fn message(&mut self, message: &Value, redactor: bool) -> io::Result<()> {
3339 self.node(0)?;
3340 let Some((class, role, content)) = projection_content(message) else {
3341 return Ok(());
3342 };
3343 self.charge(8)?;
3344 self.field("role", role, 1)?;
3345 if let Some(text) = content.as_str() {
3346 return self.text_field("content", text, class, redactor, 1);
3347 }
3348 self.node(1)?;
3349 let Some(blocks) = content.as_array() else {
3350 return Ok(());
3351 };
3352 self.charge("content".len() + 8)?;
3353 for block in blocks {
3354 self.node(2)?;
3355 let Some(mut block_class) = eligible_class(block) else {
3356 continue;
3357 };
3358 if class == DisclosureClass::ModelVisibleAfterRedaction {
3359 block_class = class;
3360 }
3361 match block.get("type").and_then(Value::as_str) {
3362 Some("text") => {
3363 if let Some(text) = block.get("text").and_then(Value::as_str) {
3364 self.charge(8)?;
3365 self.field("type", "text", 3)?;
3366 self.text_field("text", text, block_class, redactor, 3)?;
3367 }
3368 }
3369 Some("tool_use") if role == "assistant" => {
3370 if let (Some(id), Some(name)) =
3371 (identifier(block, "id"), identifier(block, "name"))
3372 {
3373 self.charge(8)?;
3374 self.field("type", "tool_use", 3)?;
3375 self.field("id", id, 3)?;
3376 self.field("name", name, 3)?;
3377 if name != "context_checkpoint" && redactor {
3378 let input = block.get("input");
3379 let in_place = input.and_then(|input| self.reduce.in_place_of(input));
3380 match (input, in_place) {
3381 (Some(input), None) => {
3383 if let Some(bytes) = self.input(input, 3)? {
3384 self.charge("input".len().saturating_add(bytes))?;
3385 }
3386 }
3387 (_, None | Some(0)) => {}
3389 (_, Some(bytes)) => {
3391 self.node(4)?;
3392 self.charge("input".len().saturating_add(bytes))?;
3393 }
3394 }
3395 }
3396 }
3397 }
3398 Some("tool_result") if role == "user" => {
3399 if let Some(id) = identifier(block, "tool_use_id") {
3400 self.charge(8)?;
3401 self.field("type", "tool_result", 3)?;
3402 self.field("tool_use_id", id, 3)?;
3403 if block.get("is_error").and_then(Value::as_bool).is_some() {
3404 self.field("is_error", "", 3)?;
3405 }
3406 if redactor {
3407 if let Some(content) = block.get("content") {
3408 self.tool_result(content, block_class)?;
3409 }
3410 }
3411 }
3412 }
3413 _ => {}
3414 }
3415 }
3416 Ok(())
3417 }
3418
3419 fn tool_result(&mut self, content: &Value, class: DisclosureClass) -> io::Result<()> {
3420 if let Some(text) = content.as_str() {
3421 return self.text_field("content", text, class, true, 3);
3422 }
3423 self.node(3)?;
3424 if let Some(blocks) = content.as_array() {
3425 self.charge("content".len() + 8)?;
3426 for block in blocks {
3427 self.node(4)?;
3428 if let Some(class) = eligible_class(block) {
3429 if block["type"] == "text" {
3430 if let Some(text) = block["text"].as_str() {
3431 self.charge(8)?;
3432 self.field("type", "text", 5)?;
3433 self.text_field("text", text, class, true, 5)?;
3434 }
3435 }
3436 }
3437 }
3438 }
3439 Ok(())
3440 }
3441
3442 fn input(&mut self, value: &Value, depth: usize) -> io::Result<Option<usize>> {
3448 self.node(depth)?;
3449 if eligible_class(value).is_none() {
3450 return Ok(None);
3451 }
3452 let mut bytes: usize = 8;
3453 match value {
3454 Value::String(text) => bytes = bytes.saturating_add(text.len()),
3455 Value::Array(values) => {
3456 for value in values {
3457 let Some(cost) = self.input(value, depth + 1)? else {
3458 return Ok(None);
3459 };
3460 bytes = bytes.saturating_add(cost);
3461 }
3462 }
3463 Value::Object(values) => {
3464 for (key, value) in values {
3465 let Some(cost) = self.input(value, depth + 1)? else {
3466 return Ok(None);
3467 };
3468 bytes = bytes.saturating_add(key.len()).saturating_add(cost);
3469 }
3470 }
3471 _ => {}
3472 }
3473 Ok(Some(bytes))
3474 }
3475}
3476
3477fn projection_digest(
3478 scope: &str,
3479 count: usize,
3480 messages: &[ArchivedMessage],
3481) -> io::Result<String> {
3482 let bytes = bounded_json(messages, MAX_SEGMENT_BYTES)?;
3483 Ok(digest_parts(&[
3484 scope.as_bytes(),
3485 &(count as u64).to_be_bytes(),
3486 &bytes,
3487 ]))
3488}
3489fn digest_parts(parts: &[&[u8]]) -> String {
3490 let mut hash = Sha256::new();
3491 for part in parts {
3492 hash.update((part.len() as u64).to_be_bytes());
3493 hash.update(part);
3494 }
3495 format!("{:x}", hash.finalize())
3496}
3497fn bounded_json<T: Serialize + ?Sized>(value: &T, limit: usize) -> io::Result<Vec<u8>> {
3498 struct Sink {
3499 bytes: Vec<u8>,
3500 limit: usize,
3501 }
3502 impl Write for Sink {
3503 fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
3504 if bytes.len() > self.limit - self.bytes.len() {
3505 return Err(over("archive serialized byte budget exceeded"));
3506 }
3507 self.bytes.extend_from_slice(bytes);
3508 Ok(bytes.len())
3509 }
3510 fn flush(&mut self) -> io::Result<()> {
3511 Ok(())
3512 }
3513 }
3514 let mut sink = Sink {
3515 bytes: Vec::new(),
3516 limit,
3517 };
3518 serde_json::to_writer(&mut sink, value).map_err(json_error)?;
3519 Ok(sink.bytes)
3520}
3521fn json_len(value: &Value) -> usize {
3523 struct Count(usize);
3524 impl Write for Count {
3525 fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
3526 self.0 += bytes.len();
3527 Ok(bytes.len())
3528 }
3529 fn flush(&mut self) -> io::Result<()> {
3530 Ok(())
3531 }
3532 }
3533 let mut count = Count(0);
3534 let _ = serde_json::to_writer(&mut count, value);
3536 count.0
3537}
3538fn before_results(messages: &[SharedMessage], start: usize, end: usize) -> usize {
3545 let is_result = |index: usize| {
3546 let message = &messages[index];
3547 let blocks = message["content"].as_array();
3548 message["role"] == "user"
3549 && blocks.is_some_and(|blocks| blocks.iter().any(|b| b["type"] == "tool_result"))
3550 };
3551 if end >= messages.len() {
3552 return end;
3553 }
3554 let mut cut = end;
3555 while cut > start + 1 && is_result(cut) {
3556 cut -= 1;
3557 }
3558 if is_result(cut) {
3560 end
3561 } else {
3562 cut
3563 }
3564}
3565fn slot_name(slot: usize) -> String {
3567 format!("{slot:03}.json")
3568}
3569fn slot_number(name: &str) -> Option<usize> {
3570 let slot = name.strip_suffix(".json")?.parse().ok()?;
3571 (slot < MAX_SEGMENTS && slot_name(slot) == name).then_some(slot)
3572}
3573fn archive_name(id: &str) -> String {
3575 format!("{id}.json")
3576}
3577fn archive_id(name: &str) -> Option<&str> {
3578 name.strip_suffix(".json")
3579 .filter(|id| validate_id(id).is_ok())
3580}
3581fn shelf_name(number: u64) -> String {
3583 format!("s{number:06}")
3584}
3585fn shelf_number(name: &str) -> Option<u64> {
3588 let number = name.strip_prefix('s')?.parse().ok()?;
3589 (number <= LAST_SHELF && shelf_name(number) == name).then_some(number)
3590}
3591fn says_forgotten(raw: &[u8], digest: &str) -> bool {
3597 #[derive(Deserialize)]
3598 struct Marked {
3599 digest: String,
3600 body: State,
3601 }
3602 #[derive(Deserialize)]
3603 struct State {
3604 state: String,
3605 }
3606 serde_json::from_slice::<Marked>(raw)
3607 .is_ok_and(|marked| marked.digest == digest && marked.body.state == "tombstone")
3608}
3609fn names_digest(raw: &[u8], digest: &str) -> bool {
3612 #[derive(Deserialize)]
3613 struct Named {
3614 digest: String,
3615 }
3616 serde_json::from_slice::<Named>(raw).is_ok_and(|named| named.digest == digest)
3617}
3618#[cfg(unix)]
3622fn temp_name() -> String {
3623 format!("pending.{}.tmp", uuid::Uuid::new_v4().simple())
3624}
3625fn is_temp_name(name: &str) -> bool {
3628 name.starts_with("pending.") && name.ends_with(".tmp")
3629}
3630fn sweep(dir: &PrivateDir, names: &[(String, bool, i64)]) {
3637 let now = std::time::SystemTime::now()
3638 .duration_since(std::time::UNIX_EPOCH)
3639 .map_or(0, |since| since.as_nanos() as i128);
3640 for (name, is_dir, modified) in names {
3641 let stale = now - i128::from(*modified) > STALE_TEMP.as_nanos() as i128;
3642 if is_temp_name(name) && !is_dir && stale {
3643 if let Err(error) = dir.discard(name) {
3644 tracing::warn!(file = %name, %error, "stale temporary archive file not removed");
3645 }
3646 }
3647 }
3648}
3649fn prefix(text: &str, bytes: usize) -> &str {
3650 let mut end = bytes.min(text.len());
3651 while !text.is_char_boundary(end) {
3652 end -= 1;
3653 }
3654 &text[..end]
3655}
3656fn validate_key(key: &str) -> io::Result<()> {
3657 if key.is_empty() || key.len() > 4096 {
3658 return Err(invalid("archive scope/logical id must be 1..4096 bytes"));
3659 }
3660 Ok(())
3661}
3662
3663fn validate_id(id: &str) -> io::Result<()> {
3664 if id.len() != 32
3665 || !id
3666 .bytes()
3667 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
3668 {
3669 Err(invalid("invalid opaque archive id"))
3670 } else {
3671 Ok(())
3672 }
3673}
3674fn invalid(message: &'static str) -> io::Error {
3675 io::Error::new(io::ErrorKind::InvalidInput, message)
3676}
3677fn corrupt() -> io::Error {
3678 io::Error::new(io::ErrorKind::InvalidData, "invalid archive record")
3679}
3680fn not_found() -> io::Error {
3681 io::Error::new(io::ErrorKind::NotFound, NOT_FOUND)
3682}
3683const FORGOTTEN: &str = "archive projection was forgotten";
3684fn forgotten() -> io::Error {
3685 io::Error::new(io::ErrorKind::PermissionDenied, FORGOTTEN)
3686}
3687fn is_forgotten(error: &io::Error) -> bool {
3689 error.kind() == io::ErrorKind::PermissionDenied
3690 && error.raw_os_error().is_none()
3691 && error.to_string() == FORGOTTEN
3692}
3693fn canceled() -> io::Error {
3694 io::Error::new(
3695 io::ErrorKind::Interrupted,
3696 "archive operation canceled before it started",
3697 )
3698}
3699fn json_error(_: serde_json::Error) -> io::Error {
3700 over("archive JSON serialization failed or exceeded budget")
3701}
3702#[derive(Debug)]
3706struct OverBudget(&'static str);
3707impl std::fmt::Display for OverBudget {
3708 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3709 f.write_str(self.0)
3710 }
3711}
3712impl std::error::Error for OverBudget {}
3713fn over(message: &'static str) -> io::Error {
3714 io::Error::new(io::ErrorKind::InvalidInput, OverBudget(message))
3715}
3716fn over_budget(error: &io::Error) -> bool {
3717 error.get_ref().is_some_and(|inner| inner.is::<OverBudget>())
3718}
3719
3720#[cfg(unix)]
3724struct PrivateDir(std::fs::File);
3725#[cfg(unix)]
3726impl PrivateDir {
3727 fn check_base(base: &Path) -> io::Result<()> {
3730 if !base.is_absolute() || base == Path::new("/") {
3731 return Err(invalid("archive base must be an absolute non-root path"));
3732 }
3733 for component in base.components() {
3734 match component {
3735 std::path::Component::RootDir => {}
3736 std::path::Component::Normal(name) => {
3737 name.to_str().ok_or_else(|| invalid("archive path must be UTF-8"))?;
3738 }
3739 _ => return Err(invalid("archive base contains traversal")),
3740 }
3741 }
3742 Ok(())
3743 }
3744 fn resolve(base: &Path) -> io::Result<(PathBuf, Vec<String>)> {
3751 Self::check_base(base)?;
3752 let (mut existing, mut missing) = (base.to_path_buf(), Vec::new());
3753 let real = loop {
3754 match std::fs::canonicalize(&existing) {
3755 Ok(real) => break real,
3756 Err(error) if error.kind() == io::ErrorKind::NotFound => {
3757 let name = existing.file_name().and_then(|name| name.to_str());
3759 missing.push(name.ok_or(error)?.to_owned());
3760 existing.pop();
3761 }
3762 Err(error) => return Err(error),
3763 }
3764 };
3765 if real == Path::new("/") && missing.is_empty() {
3766 return Err(invalid("archive base must be an absolute non-root path"));
3767 }
3768 missing.reverse();
3769 Ok((real, missing))
3770 }
3771 fn at(path: &Path) -> io::Result<Self> {
3774 use std::os::unix::fs::OpenOptionsExt;
3775 let mut options = std::fs::OpenOptions::new();
3776 options.read(true);
3777 options.custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC);
3778 options.open(path).map(Self)
3779 }
3780 fn new(base: &Path, scope: &str) -> io::Result<Self> {
3781 use crate::core::private_fs::DIR_MODE;
3782 use std::os::unix::fs::PermissionsExt;
3783 let (real, mut missing) = Self::resolve(base)?;
3784 let mut above = real.clone();
3788 if missing.is_empty() {
3789 let name = real.file_name().and_then(|name| name.to_str());
3790 missing.push(name.ok_or_else(|| invalid("archive path must be UTF-8"))?.to_owned());
3791 above.pop();
3792 }
3793 let closed = |error: &io::Error| {
3794 matches!(error.raw_os_error(), Some(libc::EACCES | libc::EPERM))
3795 };
3796 let mut dir = match Self::at(&above) {
3797 Ok(above) => {
3798 let last = missing.len() - 1;
3799 let mut dir = above;
3800 for (index, name) in missing.iter().enumerate() {
3801 dir = dir.child(name, index == last)?;
3802 }
3803 dir
3804 }
3805 Err(error) if closed(&error) && above != real => {
3808 let dir = Self::at(&real)?;
3809 dir.0.set_permissions(std::fs::Permissions::from_mode(DIR_MODE))?;
3810 dir
3811 }
3812 Err(error) => return Err(error),
3813 };
3814 dir = dir.child("context-archives", true)?;
3815 dir.child(scope, true)
3816 }
3817 fn open_existing(base: &Path, scope: &str) -> io::Result<Self> {
3818 Self::check_base(base)?;
3819 let real = match std::fs::canonicalize(base) {
3824 Ok(real) => real,
3825 Err(error) if error.kind() == io::ErrorKind::NotFound => {
3826 return Err(match std::fs::symlink_metadata(base) {
3827 Ok(_) => io::Error::other(format!(
3828 "the archive base is a link to a folder that is not there: {error}"
3829 )),
3830 Err(_) => error,
3831 });
3832 }
3833 Err(error) => return Err(error),
3834 };
3835 if real == Path::new("/") {
3836 return Err(invalid("archive base must be an absolute non-root path"));
3837 }
3838 let mut dir = Self::at(&real)?;
3839 for name in ["context-archives", scope] {
3840 dir = Self(dir.open(&c_name(name)?, libc::O_RDONLY | libc::O_DIRECTORY)?);
3841 }
3842 Ok(dir)
3843 }
3844
3845 fn fd(&self) -> libc::c_int {
3846 use std::os::fd::AsRawFd;
3847 self.0.as_raw_fd()
3848 }
3849 fn child(&self, name: &str, private: bool) -> io::Result<Self> {
3850 use crate::core::private_fs::DIR_MODE;
3851 use std::os::unix::fs::PermissionsExt;
3852 let name = c_name(name)?;
3853 let created =
3855 unsafe { libc::mkdirat(self.fd(), name.as_ptr(), DIR_MODE as libc::mode_t) } == 0;
3856 if !created {
3857 let error = io::Error::last_os_error();
3858 if error.raw_os_error() != Some(libc::EEXIST) {
3859 return Err(error);
3860 }
3861 }
3862 let file = self.open(&name, libc::O_RDONLY | libc::O_DIRECTORY)?;
3863 if private || created {
3864 file.set_permissions(std::fs::Permissions::from_mode(DIR_MODE))?;
3865 }
3866 self.sync()?;
3869 Ok(Self(file))
3870 }
3871 fn open_child(&self, name: &str, readonly: bool) -> io::Result<Option<Self>> {
3875 use crate::core::private_fs::DIR_MODE;
3876 use std::os::unix::fs::PermissionsExt;
3877 let file = match self.open(&c_name(name)?, libc::O_RDONLY | libc::O_DIRECTORY) {
3878 Ok(file) => file,
3879 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
3880 Err(error) => return Err(error),
3881 };
3882 if !readonly {
3883 file.set_permissions(std::fs::Permissions::from_mode(DIR_MODE))?;
3884 }
3885 Ok(Some(Self(file)))
3886 }
3887 fn names(&self) -> io::Result<Vec<(String, bool, i64)>> {
3892 use std::os::fd::IntoRawFd;
3893 let fd = self
3896 .open(c".", libc::O_RDONLY | libc::O_DIRECTORY)?
3897 .into_raw_fd();
3898 let stream = unsafe { libc::fdopendir(fd) };
3899 if stream.is_null() {
3900 let error = io::Error::last_os_error();
3901 unsafe { libc::close(fd) };
3902 return Err(error);
3903 }
3904 let names = self.read_names(stream);
3905 unsafe { libc::closedir(stream) }; names
3907 }
3908 fn read_names(&self, stream: *mut libc::DIR) -> io::Result<Vec<(String, bool, i64)>> {
3911 let mut names = Vec::new();
3912 loop {
3913 unsafe { *errno() = 0 };
3919 let entry = unsafe { libc::readdir(stream) };
3920 if entry.is_null() {
3921 return match unsafe { *errno() } {
3922 0 => Ok(names),
3923 code => Err(io::Error::from_raw_os_error(code)),
3924 };
3925 }
3926 let (raw, name) = unsafe {
3930 let raw = std::ptr::addr_of!((*entry).d_name).cast::<libc::c_char>();
3931 (raw, std::ffi::CStr::from_ptr(raw))
3932 };
3933 let Some(name) = name.to_str().ok().filter(|name| c_name(name).is_ok()) else {
3934 continue;
3935 };
3936 let mut stat: libc::stat = unsafe { std::mem::zeroed() };
3937 let found =
3938 unsafe { libc::fstatat(self.fd(), raw, &mut stat, libc::AT_SYMLINK_NOFOLLOW) };
3939 if found != 0 {
3940 let error = io::Error::last_os_error();
3941 if error.kind() == io::ErrorKind::NotFound {
3943 continue;
3944 }
3945 return Err(error);
3946 }
3947 let kind = stat.st_mode & libc::S_IFMT;
3948 #[allow(clippy::useless_conversion)]
3949 let modified = i64::from(stat.st_mtime)
3950 .saturating_mul(1_000_000_000)
3951 .saturating_add(i64::from(stat.st_mtime_nsec));
3952 names.push((name.to_owned(), kind == libc::S_IFDIR, modified));
3953 }
3954 }
3955 fn open(&self, name: &std::ffi::CStr, flags: i32) -> io::Result<std::fs::File> {
3956 use std::os::fd::FromRawFd;
3957 #[cfg(test)]
3958 if let Some(error) = fault("open", name) {
3959 return Err(error);
3960 }
3961 let fd = unsafe {
3962 libc::openat(
3963 self.fd(),
3964 name.as_ptr(),
3965 flags | libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK,
3966 crate::core::private_fs::FILE_MODE,
3967 )
3968 };
3969 if fd < 0 {
3970 Err(io::Error::last_os_error())
3971 } else {
3972 Ok(unsafe { std::fs::File::from_raw_fd(fd) })
3973 }
3974 }
3975 fn check_readonly(file: &std::fs::File) -> io::Result<()> {
3976 Self::check_links(file, 1)
3977 }
3978 fn check_unlocked(file: &std::fs::File) -> io::Result<()> {
3984 Self::check_links(file, 0)
3985 }
3986 fn check_links(file: &std::fs::File, fewest: u64) -> io::Result<()> {
3987 use std::os::unix::fs::MetadataExt;
3988 let meta = file.metadata()?;
3989 let named = (fewest..=1).contains(&meta.nlink());
3990 if !meta.is_file() || !named || meta.uid() != unsafe { libc::geteuid() } {
3991 return Err(io::Error::new(
3992 io::ErrorKind::PermissionDenied,
3993 "archive requires owned regular single-link files",
3994 ));
3995 }
3996 Ok(())
3997 }
3998 fn check(file: &std::fs::File) -> io::Result<()> {
3999 use std::os::unix::fs::PermissionsExt;
4000 Self::check_readonly(file)?;
4001 file.set_permissions(std::fs::Permissions::from_mode(
4002 crate::core::private_fs::FILE_MODE,
4003 ))
4004 }
4005 fn not_a_segment(error: &io::Error) -> bool {
4014 let of_the_file = [libc::ELOOP, libc::EACCES, libc::EPERM, libc::ENXIO];
4015 error.raw_os_error().is_none_or(|code| of_the_file.contains(&code))
4016 }
4017 fn passing(error: &io::Error) -> bool {
4025 let passing = [libc::EMFILE, libc::ENFILE, libc::ENOMEM, libc::EINTR, libc::EAGAIN];
4026 error.raw_os_error().is_some_and(|code| passing.contains(&code))
4027 }
4028 fn identity(&self) -> io::Result<DirIdentity> {
4030 use std::os::unix::fs::MetadataExt;
4031 let meta = self.0.metadata()?;
4032 Ok((meta.dev(), meta.ino()))
4033 }
4034 fn lock_file(&self, create: bool) -> io::Result<Option<std::fs::File>> {
4041 use std::os::unix::fs::{MetadataExt, PermissionsExt};
4042 let refused = |what| io::Error::new(io::ErrorKind::PermissionDenied, LockRefused(what));
4043 let name = c_name("lock")?;
4044 let mut opened = match create {
4045 true => self.open(&name, libc::O_RDWR | libc::O_CREAT),
4046 false => self.open(&name, libc::O_RDONLY),
4047 };
4048 if create && opened.as_ref().is_err_and(|error| error.raw_os_error() == Some(libc::EACCES)) {
4049 match self.open(&name, libc::O_RDONLY) {
4051 Err(error) if error.kind() == io::ErrorKind::NotFound => return opened.map(Some),
4053 again => opened = again,
4054 }
4055 }
4056 let file = match opened {
4057 Ok(file) => file,
4058 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
4059 Err(error) => {
4060 return Err(match error.raw_os_error() {
4061 Some(libc::ELOOP | libc::EMLINK) => refused("is a link"),
4063 Some(libc::EISDIR | libc::ENXIO) => refused("is not a regular file"),
4064 Some(libc::EACCES) => refused("cannot be opened by its owner with its mode"),
4065 _ => error,
4066 });
4067 }
4068 };
4069 let meta = file.metadata()?;
4070 if !meta.is_file() {
4071 return Err(refused("is not a regular file"));
4072 }
4073 if meta.uid() != unsafe { libc::geteuid() } {
4074 return Err(refused("is owned by another user"));
4075 }
4076 let mode = meta.permissions().mode() & 0o7777;
4077 if create && mode & 0o077 != 0 && meta.nlink() == 1 {
4081 file.set_permissions(std::fs::Permissions::from_mode(mode & 0o700))?;
4082 }
4083 Ok(Some(file))
4084 }
4085 fn lock_existing(&self) -> io::Result<Option<FlockGuard>> {
4086 let Some(file) = self.lock_file(false)? else {
4087 return Ok(None);
4088 };
4089 if !fs4::fs_std::FileExt::try_lock_shared(&file)? {
4090 return Err(io::Error::new(io::ErrorKind::WouldBlock, BUSY));
4091 }
4092 Ok(Some(FlockGuard(file)))
4093 }
4094 fn lock(&self) -> io::Result<FlockGuard> {
4097 let file = self.lock_file(true)?.ok_or_else(not_found)?;
4098 if !fs4::fs_std::FileExt::try_lock_exclusive(&file)? {
4099 return Err(io::Error::new(io::ErrorKind::WouldBlock, BUSY));
4100 }
4101 Ok(FlockGuard(file))
4102 }
4103 fn read_with(
4104 &self,
4105 name: &str,
4106 remaining: usize,
4107 readonly: bool,
4108 ) -> io::Result<Option<Vec<u8>>> {
4109 let file = match self.open(&c_name(name)?, libc::O_RDONLY) {
4110 Ok(file) => file,
4111 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
4112 Err(error) => return Err(error),
4113 };
4114 Self::read_open(file, remaining, readonly).map(Some)
4115 }
4116 fn read_open(file: std::fs::File, remaining: usize, readonly: bool) -> io::Result<Vec<u8>> {
4120 if readonly {
4121 Self::check_unlocked(&file)?;
4122 } else {
4123 Self::check(&file)?;
4124 }
4125 let limit = remaining.min(MAX_SEGMENT_BYTES);
4126 if file.metadata()?.len() > limit as u64 {
4127 return Err(corrupt());
4128 }
4129 let mut bytes = Vec::new();
4130 file.take(limit as u64 + 1).read_to_end(&mut bytes)?;
4131 if bytes.len() > limit {
4132 return Err(corrupt());
4133 }
4134 Ok(bytes)
4135 }
4136 fn publish(&self, name: &str, bytes: &[u8], create_new: bool) -> io::Result<()> {
4142 self.publish_dated(name, bytes, create_new, None)
4143 }
4144 fn publish_dated(
4151 &self,
4152 name: &str,
4153 bytes: &[u8],
4154 create_new: bool,
4155 modified: Option<std::time::SystemTime>,
4156 ) -> io::Result<()> {
4157 let target = c_name(name)?;
4158 let tmp = c_name(&temp_name())?;
4161 let mut file = self.open(&tmp, libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL)?;
4162 let result = (|| {
4163 file.write_all(bytes)?;
4164 file.sync_all()?;
4165 if create_new {
4166 self.rename_new(&tmp, &target)?;
4167 } else {
4168 match self.open(&target, libc::O_RDONLY) {
4170 Ok(existing) => Self::check(&existing)?,
4171 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
4172 Err(error) => return Err(error),
4173 }
4174 self.rename(&tmp, &target)?;
4175 }
4176 if let Some(time) = modified {
4177 #[cfg(test)]
4185 if let Some(error) = fault("date", &target) {
4186 return Err(error);
4187 }
4188 let times = std::fs::FileTimes::new().set_accessed(time).set_modified(time);
4189 file.set_times(times)?;
4190 file.sync_all()?;
4191 }
4192 self.sync()
4193 })();
4194 if result.is_err() {
4195 unsafe {
4197 libc::unlinkat(self.fd(), tmp.as_ptr(), 0);
4198 }
4199 }
4200 result
4201 }
4202 fn rename_new(&self, from: &std::ffi::CStr, to: &std::ffi::CStr) -> io::Result<()> {
4206 #[cfg(target_os = "linux")]
4209 let code = unsafe {
4210 libc::syscall(
4211 libc::SYS_renameat2,
4212 self.fd(),
4213 from.as_ptr(),
4214 self.fd(),
4215 to.as_ptr(),
4216 libc::RENAME_NOREPLACE,
4217 )
4218 };
4219 #[cfg(target_os = "macos")]
4220 let code = unsafe {
4221 libc::renameatx_np(
4222 self.fd(),
4223 from.as_ptr(),
4224 self.fd(),
4225 to.as_ptr(),
4226 libc::RENAME_EXCL,
4227 )
4228 };
4229 #[cfg(any(target_os = "linux", target_os = "macos"))]
4230 {
4231 if code == 0 {
4232 return Ok(());
4233 }
4234 let error = io::Error::last_os_error();
4235 let unsupported = [libc::ENOSYS, libc::EINVAL, libc::ENOTSUP];
4238 if !error.raw_os_error().is_some_and(|code| unsupported.contains(&code)) {
4239 return Err(error);
4240 }
4241 }
4242 self.rename_probed(from, to)
4243 }
4244 fn rename_probed(&self, from: &std::ffi::CStr, to: &std::ffi::CStr) -> io::Result<()> {
4247 match self.open(to, libc::O_RDONLY) {
4248 Ok(_) => Err(io::Error::new(
4249 io::ErrorKind::AlreadyExists,
4250 "archive file already exists",
4251 )),
4252 Err(error) if error.kind() == io::ErrorKind::NotFound => self.rename(from, to),
4253 Err(error) => Err(error),
4254 }
4255 }
4256 fn rename(&self, from: &std::ffi::CStr, to: &std::ffi::CStr) -> io::Result<()> {
4258 #[cfg(test)]
4259 if let Some(error) = fault("rename", to) {
4260 return Err(error);
4261 }
4262 match unsafe { libc::renameat(self.fd(), from.as_ptr(), self.fd(), to.as_ptr()) } {
4263 0 => Ok(()),
4264 _ => Err(io::Error::last_os_error()),
4265 }
4266 }
4267 fn discard(&self, name: &str) -> io::Result<()> {
4271 let name = c_name(name)?;
4272 let mut stat: libc::stat = unsafe { std::mem::zeroed() };
4273 let found = unsafe {
4274 libc::fstatat(self.fd(), name.as_ptr(), &mut stat, libc::AT_SYMLINK_NOFOLLOW)
4275 };
4276 if found != 0 {
4277 return Err(io::Error::last_os_error());
4278 }
4279 let ours = stat.st_uid == unsafe { libc::geteuid() } && stat.st_nlink == 1;
4280 if stat.st_mode & libc::S_IFMT != libc::S_IFREG || !ours {
4281 return Ok(());
4282 }
4283 match unsafe { libc::unlinkat(self.fd(), name.as_ptr(), 0) } {
4284 0 => Ok(()),
4285 _ => Err(io::Error::last_os_error()),
4286 }
4287 }
4288 fn sync(&self) -> io::Result<()> {
4289 #[cfg(test)]
4290 if let Some(error) = fault("sync", c"") {
4291 return Err(error);
4292 }
4293 self.0.sync_all()
4294 }
4295}
4296#[cfg(target_os = "linux")]
4301use libc::__errno_location as errno;
4302#[cfg(any(target_vendor = "apple", target_os = "freebsd"))]
4303use libc::__error as errno;
4304#[cfg(any(target_os = "android", target_os = "netbsd", target_os = "openbsd"))]
4305use libc::__errno as errno;
4306#[cfg(all(test, unix))]
4311thread_local! {
4312 static FAULTS: std::cell::RefCell<Vec<(&'static str, String, i32)>> =
4313 const { std::cell::RefCell::new(Vec::new()) };
4314}
4315#[cfg(all(test, unix))]
4316fn fault(call: &str, name: &std::ffi::CStr) -> Option<io::Error> {
4317 FAULTS.with_borrow(|faults| {
4318 let hit = |(failing, prefix, _): &&(&str, String, i32)| {
4319 *failing == call && name.to_bytes().starts_with(prefix.as_bytes())
4320 };
4321 let code = faults.iter().find(hit).map(|(.., code)| *code);
4322 code.map(io::Error::from_raw_os_error)
4323 })
4324}
4325#[cfg(unix)]
4326fn c_name(name: &str) -> io::Result<std::ffi::CString> {
4327 if name.is_empty() || matches!(name, "." | "..") || name.contains(['/', '\\']) {
4328 return Err(invalid("invalid archive component"));
4329 }
4330 std::ffi::CString::new(name).map_err(|_| invalid("invalid archive component"))
4331}
4332
4333#[cfg(not(unix))]
4334struct PrivateDir;
4335#[cfg(not(unix))]
4336impl PrivateDir {
4337 fn unsupported<T>() -> io::Result<T> {
4338 Err(io::Error::new(
4339 io::ErrorKind::Unsupported,
4340 "private context archive requires Unix",
4341 ))
4342 }
4343 fn new(_: &Path, _: &str) -> io::Result<Self> {
4344 Self::unsupported()
4345 }
4346 fn not_a_segment(_: &io::Error) -> bool {
4347 false
4348 }
4349 fn passing(_: &io::Error) -> bool {
4350 false
4351 }
4352 fn identity(&self) -> io::Result<DirIdentity> {
4353 Self::unsupported()
4354 }
4355 fn lock(&self) -> io::Result<FlockGuard> {
4356 Self::unsupported()
4357 }
4358 fn open_existing(_: &Path, _: &str) -> io::Result<Self> {
4359 Self::unsupported()
4360 }
4361 fn child(&self, _: &str, _: bool) -> io::Result<Self> {
4362 Self::unsupported()
4363 }
4364 fn open_child(&self, _: &str, _: bool) -> io::Result<Option<Self>> {
4365 Self::unsupported()
4366 }
4367 fn names(&self) -> io::Result<Vec<(String, bool, i64)>> {
4368 Self::unsupported()
4369 }
4370 fn lock_existing(&self) -> io::Result<Option<FlockGuard>> {
4371 Self::unsupported()
4372 }
4373 fn read_with(&self, _: &str, _: usize, _: bool) -> io::Result<Option<Vec<u8>>> {
4374 Self::unsupported()
4375 }
4376 fn publish(&self, _: &str, _: &[u8], _: bool) -> io::Result<()> {
4377 Self::unsupported()
4378 }
4379 fn publish_dated(
4380 &self,
4381 _: &str,
4382 _: &[u8],
4383 _: bool,
4384 _: Option<std::time::SystemTime>,
4385 ) -> io::Result<()> {
4386 Self::unsupported()
4387 }
4388 fn discard(&self, _: &str) -> io::Result<()> {
4389 Self::unsupported()
4390 }
4391 fn sync(&self) -> io::Result<()> {
4392 Self::unsupported()
4393 }
4394}
4395
4396#[cfg(test)]
4397mod projection_tests {
4398 use super::*;
4399
4400 fn excluded_fixture(blob: &str) -> Vec<SharedMessage> {
4401 vec![
4402 Arc::new(json!({"role":"system","content":blob})),
4403 Arc::new(json!({"role":"developer","content":blob})),
4404 Arc::new(json!({"role":"user","_synaps_context":{"note":blob},"content":blob})),
4405 Arc::new(json!({"role":"user","retention":"local_only","content":blob})),
4406 Arc::new(json!({"role":"assistant","metadata":blob,"content":[
4407 {"type":"thinking","thinking":blob},
4408 {"type":"text","private":true,"text":blob},
4409 {"type":"image","source":{"data":blob}},
4410 {"type":"document","source":{"data":blob}},
4411 {"type":"text","text":" exact evidence αβ\n","metadata":blob},
4412 {"type":"tool_use","id":"cp","name":"context_checkpoint","input":{"note":blob}}
4413 ]})),
4414 Arc::new(json!({"role":"user","content":[
4415 {"type":"tool_result","tool_use_id":"r1","metadata":blob,"content":[
4416 {"type":"image","source":{"data":blob}},
4417 {"type":"text","sensitivity":"secret","text":blob},
4418 {"type":"text","text":"exact tool evidence\n","metadata":blob}
4419 ]}
4420 ]})),
4421 ]
4422 }
4423
4424 #[test]
4425 fn excluded_aggregate_does_not_consume_eligible_budget_or_change_digest() {
4426 let messages = excluded_fixture(&"x".repeat(2 * 1024 * 1024));
4428 let source = messages.clone();
4429 for redactor in [None, Some(Arc::new(str::to_owned) as Arc<ArchiveRedactor>)] {
4430 let small =
4431 project_archive("excluded", &excluded_fixture("x"), "note", redactor.clone())
4432 .unwrap();
4433 let large = project_archive("excluded", &messages, "note", redactor).unwrap();
4434 assert!(small == large);
4435 assert_eq!(large.source_message_count, 6);
4436 assert_eq!(large.messages[0].source_index, 4);
4437 assert_eq!(large.messages[0].block_indices, vec![4, 5]);
4438 }
4439 for (old, new) in source.iter().zip(&messages) {
4440 assert!(Arc::ptr_eq(old, new));
4441 }
4442 }
4443
4444 #[test]
4445 fn eligible_aggregate_refuses_before_any_redactor_call() {
4446 use std::sync::atomic::{AtomicUsize, Ordering};
4447 let calls = Arc::new(AtomicUsize::new(0));
4448 let count = calls.clone();
4449 let redactor: Arc<ArchiveRedactor> = Arc::new(move |_| {
4450 count.fetch_add(1, Ordering::SeqCst);
4451 "small".to_owned()
4452 });
4453 let piece = "x".repeat(MAX_INPUT_BYTES / 2);
4454 for messages in [
4455 vec![
4456 Arc::new(json!({"role":"user","content":piece})),
4457 Arc::new(json!({"role":"assistant","content":piece})),
4458 ],
4459 vec![Arc::new(json!({"role":"user","content":[{
4460 "type":"tool_result","tool_use_id":"r1","content":[
4461 {"type":"text","text":piece}, {"type":"text","text":piece}
4462 ]
4463 }]}))],
4464 ] {
4465 let before = messages.clone();
4466 let error = project_archive("overflow", &messages, "", Some(redactor.clone()))
4467 .err()
4468 .unwrap();
4469 assert!(error.to_string().contains("input byte budget"));
4470 assert_eq!(calls.load(Ordering::SeqCst), 0);
4471 assert_eq!(before, messages);
4472 }
4473 }
4474
4475 #[test]
4476 fn large_tool_results_exact_with_redactor_opaque_without_it() {
4477 let text = "evidence αβ\n".repeat(250_000);
4478 let make = |text: &str| {
4479 vec![Arc::new(json!({"role":"user","content":[{
4480 "type":"tool_result","tool_use_id":"r1","content":text
4481 }]}))]
4482 };
4483 let p = project_archive("logs", &make(&text), "", Some(Arc::new(str::to_owned))).unwrap();
4484 assert_eq!(p.messages[0].message["content"][0]["content"], text);
4485 let huge = make(&"x".repeat(MAX_INPUT_BYTES + 1));
4486 let p = project_archive("logs", &huge, "", None).unwrap();
4487 assert_eq!(p.messages[0].message["content"][0]["content"], WITHHELD);
4488 assert!(project_archive("logs", &huge, "", Some(Arc::new(str::to_owned))).is_err());
4489 }
4490
4491 #[test]
4492 fn whole_argument_withholding_discards_tentative_bytes_not_structural_guards() {
4493 let huge = "x".repeat(MAX_INPUT_BYTES + 1);
4494 let make = |input: Value| {
4495 vec![Arc::new(json!({"role":"assistant","content":[{
4496 "type":"tool_use","id":"r1","name":"read","input":input
4497 }]}))]
4498 };
4499 for input in [
4501 json!({"a":huge,"z":{"sensitivity":"secret","body":huge}}),
4502 json!({"a":{"sensitivity":"secret","body":huge},"z":huge}),
4503 ] {
4504 let p =
4505 project_archive("input", &make(input), "", Some(Arc::new(str::to_owned))).unwrap();
4506 assert_eq!(
4507 p.messages[0].message["content"][0]["input"],
4508 json!({"_archive_withheld":true})
4509 );
4510 }
4511 let input = json!({"type":"image","data":"YWJj"});
4513 let p = project_archive(
4514 "input",
4515 &make(input.clone()),
4516 "",
4517 Some(Arc::new(str::to_owned)),
4518 )
4519 .unwrap();
4520 assert_eq!(p.messages[0].message["content"][0]["input"], input);
4521 let large = make(json!({"type":"image","data":huge}));
4522 assert!(project_archive("input", &large, "", Some(Arc::new(str::to_owned))).is_err());
4523 assert!(project_archive("input", &large, "", None).is_ok());
4524 }
4525
4526 #[test]
4527 fn visited_structure_is_bounded_but_excluded_descendants_are_opaque() {
4528 let mut deep = json!("leaf");
4529 for _ in 0..40 {
4530 deep = json!([deep]);
4531 }
4532 let make = |input: Value| {
4533 vec![Arc::new(json!({"role":"assistant","content":[{
4534 "type":"tool_use","id":"r1","name":"read","input":input
4535 }]}))]
4536 };
4537 let identity = Some(Arc::new(str::to_owned) as Arc<ArchiveRedactor>);
4538 assert!(project_archive("depth", &make(deep.clone()), "", identity.clone()).is_err());
4539 assert!(project_archive("depth", &make(deep.clone()), "", None).is_ok());
4540 assert!(project_archive(
4541 "depth",
4542 &make(json!({"private":true,"blob":deep})),
4543 "",
4544 identity.clone()
4545 )
4546 .is_ok());
4547 assert!(project_archive(
4549 "depth",
4550 &[Arc::new(json!({"role":"user","content":deep}))],
4551 "",
4552 identity.clone()
4553 )
4554 .is_ok());
4555 let wide = vec![Value::Null; 65536];
4556 assert!(project_archive("nodes", &make(json!(wide)), "", identity.clone()).is_err());
4557 let excluded = vec![Arc::new(json!({"role":"user","content":wide}))];
4558 assert!(project_archive("nodes", &excluded, "", identity).is_err());
4559 }
4560
4561 #[test]
4562 fn withheld_after_redaction_text_is_not_scanned_without_redactor() {
4563 let messages = vec![Arc::new(json!({"role":"user",
4564 "disclosure":"model_visible_after_redaction", "content":"x".repeat(MAX_INPUT_BYTES+1)
4565 }))];
4566 let p = project_archive("withheld", &messages, "", None).unwrap();
4567 assert_eq!(p.messages[0].message["content"], WITHHELD);
4568 assert!(project_archive("withheld", &messages, "", Some(Arc::new(str::to_owned))).is_err());
4569 }
4570
4571 #[test]
4572 fn json_escape_scratch_can_exceed_segment_limit_before_redaction() {
4573 let input = json!({"text":"\0".repeat(3 * 1024 * 1024)});
4576 let messages = vec![Arc::new(json!({"role":"assistant","content":[{
4577 "type":"tool_use","id":"r1","name":"read","input":input
4578 }]}))];
4579 let redactor: Arc<ArchiveRedactor> = Arc::new(|s| {
4580 if s.starts_with('{') {
4581 assert!(s.len() > MAX_SEGMENT_BYTES);
4582 "{\"text\":\"screened\"}".to_owned()
4583 } else {
4584 s.to_owned()
4585 }
4586 });
4587 let p = project_archive("escaping", &messages, "", Some(redactor)).unwrap();
4588 assert_eq!(
4589 p.messages[0].message["content"][0]["input"],
4590 json!({"text":"screened"})
4591 );
4592 assert!(project_archive("escaping", &messages, "", Some(Arc::new(str::to_owned))).is_err());
4593 }
4594
4595 fn compatibility_fixture() -> Vec<SharedMessage> {
4596 vec![
4597 Arc::new(json!({"role":"system","content":"omitted"})),
4598 Arc::new(json!({"role":"user","content":" exact αβ\n"})),
4599 Arc::new(json!({"role":"assistant","content":[
4600 {"type":"thinking","thinking":"private"},
4601 {"type":"text","text":"classified-value"},
4602 {"type":"tool_use","id":"r1","name":"read","input":{"path":"src/lib.rs","n":7,"binary":{"type":"image","data":"YWJj"}}},
4603 {"type":"tool_use","id":"c1","name":"context_checkpoint","input":{"note":"hidden"}},
4604 {"type":"tool_use","id":"x1","name":"read","input":{"nested":{"disclosure":"local_only","body":"omitted"}}}
4605 ]})),
4606 Arc::new(json!({"role":"user","content":[
4607 {"type":"tool_result","tool_use_id":"r1","is_error":false,"content":[
4608 {"type":"image","data":"omitted"},
4609 {"type":"text","text":"exact result\n"},
4610 {"type":"text","text":"classified-value","disclosure":"model_visible_after_redaction"}
4611 ]}
4612 ]})),
4613 Arc::new(
4614 json!({"role":"user","disclosure":"model_visible_after_redaction","content":"classified-value"}),
4615 ),
4616 Arc::new(json!({"role":"user","_synaps_context":{},"content":"hidden"})),
4617 ]
4618 }
4619
4620 #[test]
4621 fn legacy_projection_golden() {
4622 for (redactor, digest) in [
4624 (
4625 None,
4626 "aeb544a71629f741def049575eefb1a765117d65aa914bdcc1657fa677622031",
4627 ),
4628 (
4629 Some(
4630 Arc::new(|s: &str| s.replace("classified-value", "[redacted]"))
4631 as Arc<ArchiveRedactor>,
4632 ),
4633 "d70b321210ca3d2bc531b31a35cc02ee267eeb572e8a6dd88f1fb62725b082ec",
4634 ),
4635 ] {
4636 let p = project_archive(
4637 "compat-session",
4638 &compatibility_fixture(),
4639 "hidden note",
4640 redactor,
4641 )
4642 .unwrap();
4643 assert_eq!(p.digest, digest);
4644 }
4645 }
4646
4647 #[test]
4648 fn pure_projection_keeps_legacy_logical_digest_and_never_opens_paths() {
4649 let tmp = tempfile::tempdir().unwrap();
4650 let logical = tmp
4651 .path()
4652 .join("not-created/session")
4653 .to_str()
4654 .unwrap()
4655 .to_owned();
4656 let messages = vec![
4657 Arc::new(json!({"role":"system","content":"OMITTED"})),
4658 Arc::new(json!({"role":"assistant","content":[
4659 {"type":"thinking","thinking":"OMITTED"},
4660 {"type":"text","text":" exact αβ evidence\n"}
4661 ]})),
4662 ];
4663 let source = serde_json::to_vec(&messages).unwrap();
4664 let first = project_archive(&logical, &messages, "hidden note", None).unwrap();
4665 assert_eq!(first.logical_id, digest_parts(&[logical.as_bytes()]));
4666 assert_eq!(
4667 first.digest,
4668 projection_digest(&first.logical_id, 2, &first.messages).unwrap()
4669 );
4670 assert_eq!(first.messages[0].source_index, 1);
4671 assert_eq!(first.messages[0].block_indices, vec![1]);
4672 assert_eq!(first.source_message_count, 2);
4673 let mut changed = messages.clone();
4674 changed[0] = Arc::new(json!({"role":"developer","content":"DIFFERENT OMITTED"}));
4675 let second = project_archive(&logical, &changed, "changed note", None).unwrap();
4676 assert_eq!(first.digest, second.digest);
4677 assert_ne!(
4678 first.digest,
4679 project_archive("other-session", &messages, "hidden note", None)
4680 .unwrap()
4681 .digest
4682 );
4683 assert_eq!(serde_json::to_vec(&messages).unwrap(), source);
4684 assert_eq!(std::fs::read_dir(tmp.path()).unwrap().count(), 0);
4685 }
4686
4687 #[test]
4688 fn pure_projection_shares_disclosure_redaction_and_input_limits() {
4689 let redactor: Arc<ArchiveRedactor> =
4690 Arc::new(|s| s.replace("classified-value", "[redacted]"));
4691 let messages = vec![
4692 Arc::new(json!({"role":"assistant","content":[
4693 {"type":"tool_use","id":"read_1","name":"read","input":{"path":"src/lib.rs"}},
4694 {"type":"tool_use","id":"cp","name":"context_checkpoint","input":{"note":"NOTE_ONLY"}}
4695 ]})),
4696 Arc::new(
4697 json!({"role":"user","content":[{"type":"tool_result","tool_use_id":"read_1","content":"classified-value"}]}),
4698 ),
4699 Arc::new(json!({"role":"user","sensitivity":"secret","content":"OMITTED"})),
4700 Arc::new(json!({"role":"user","_synaps_context":{},"content":"NOTE_ONLY"})),
4701 Arc::new(json!({"role":"user","content":crate::core::host_note::tool_in_flight_note_text(
4703 "Canceled by user",
4704 &["bash".into()],
4705 )})),
4706 ];
4707 let projected =
4708 project_archive("session", &messages, "classified-value", Some(redactor)).unwrap();
4709 assert_eq!(projected.note, "[redacted]");
4710 let json = serde_json::to_string(&projected.messages).unwrap();
4711 assert!(!json.contains("classified-value"));
4712 assert!(!json.contains("NOTE_ONLY"));
4713 assert!(!json.contains("partially applied"), "the tool note is not archived");
4714 assert!(!json.contains("OMITTED"));
4715 assert!(json.contains("src/lib.rs"));
4716 assert_eq!(projected.messages.len(), 2);
4717 let withheld = project_archive("session", &messages, "password=bad", None).unwrap();
4718 assert_eq!(withheld.note, WITHHELD);
4719 assert!(!serde_json::to_string(&withheld.messages)
4720 .unwrap()
4721 .contains("src/lib.rs"));
4722 assert!(project_archive("", &[], "", None).is_err());
4723 assert!(project_archive(&"l".repeat(4097), &[], "", None).is_err());
4724 assert!(project_archive("s", &[], &"n".repeat(MAX_NOTE_BYTES + 1), None).is_err());
4725 assert!(
4726 project_archive("s", &vec![messages[0].clone(); MAX_MESSAGES + 1], "", None).is_err()
4727 );
4728 }
4729}
4730
4731#[cfg(all(test, unix))]
4735#[serial_test::parallel(process_spawn)]
4736mod tests {
4737 use super::*;
4738 use std::os::unix::fs::{symlink, PermissionsExt};
4739
4740 fn msg(role: &str, content: Value) -> SharedMessage {
4741 Arc::new(json!({"role":role,"content":content}))
4742 }
4743 fn setup() -> (tempfile::TempDir, ArchiveStore) {
4744 let tmp = tempfile::tempdir().unwrap();
4745 let store = setup_in(tmp.path(), "synthetic-project");
4746 (tmp, store)
4747 }
4748 fn setup_in(base: &Path, project: &str) -> ArchiveStore {
4749 ArchiveStore::new(base, project, "synthetic-logical").unwrap()
4750 }
4751 fn directory(tmp: &tempfile::TempDir, store: &ArchiveStore) -> std::path::PathBuf {
4752 tmp.path().join("context-archives").join(&store.scope)
4753 }
4754 fn shelf_files(dir: &Path) -> Vec<PathBuf> {
4756 let mut files = Vec::new();
4757 for shelf in std::fs::read_dir(dir).unwrap() {
4758 let shelf = shelf.unwrap().path();
4759 if std::fs::symlink_metadata(&shelf).unwrap().is_dir() {
4760 let entries = std::fs::read_dir(&shelf).unwrap();
4761 files.extend(entries.map(|entry| entry.unwrap().path()));
4762 }
4763 }
4764 files.retain(|file| file.extension().is_some_and(|extension| extension == "json"));
4765 files.sort();
4766 files
4767 }
4768 fn only_archive_file(dir: &Path) -> PathBuf {
4770 let files = shelf_files(dir);
4771 assert_eq!(files.len(), 1);
4772 assert_eq!(files[0].parent().unwrap(), dir.join("s000001"));
4773 files[0].clone()
4774 }
4775 fn seal_legacy_slot(
4778 store: &ArchiveStore,
4779 slot: usize,
4780 messages: &[SharedMessage],
4781 note: &str,
4782 ) -> ArchiveRef {
4783 let projection =
4784 project_hashed(&store.logical, messages, note, None, Reduce::None).unwrap();
4785 let record = Record {
4786 version: 1,
4787 scope: store.scope.clone(),
4788 logical: store.logical.clone(),
4789 id: uuid::Uuid::new_v4().simple().to_string(),
4790 digest: projection.digest,
4791 created_ms: None,
4793 body: Body::Live {
4794 source_message_count: projection.source_message_count,
4795 messages: projection.messages,
4796 note: projection.note,
4797 },
4798 };
4799 let bytes = bounded_json(&record, MAX_SEGMENT_BYTES).unwrap();
4800 let _lock = store.exclusive().unwrap();
4801 store.fs.publish(&slot_name(slot), &bytes, false).unwrap();
4802 record.reference().unwrap()
4803 }
4804 fn text(content: &str) -> Vec<SharedMessage> {
4805 vec![msg("user", json!(content))]
4806 }
4807 fn mode(path: &Path) -> u32 {
4808 std::fs::metadata(path).unwrap().permissions().mode() & 0o777
4809 }
4810 fn identity(path: &Path) -> (u64, u64) {
4813 use std::os::unix::fs::MetadataExt;
4814 let meta = std::fs::symlink_metadata(path).unwrap();
4815 (meta.dev(), meta.ino())
4816 }
4817 fn temporary_files(dir: &Path) -> Vec<PathBuf> {
4819 let mut found = Vec::new();
4820 let mut pending = vec![dir.to_path_buf()];
4821 while let Some(directory) = pending.pop() {
4822 for entry in std::fs::read_dir(&directory).unwrap() {
4823 let path = entry.unwrap().path();
4824 if is_temp_name(path.file_name().unwrap().to_str().unwrap()) {
4825 found.push(path);
4826 } else if std::fs::symlink_metadata(&path).unwrap().is_dir() {
4827 pending.push(path);
4828 }
4829 }
4830 }
4831 found
4832 }
4833 fn written_ago(path: &Path, age: Duration) {
4835 let file = std::fs::File::options().write(true).open(path).unwrap();
4836 file.set_modified(std::time::SystemTime::now() - age).unwrap();
4837 }
4838 fn snapshot(dir: &Path) -> std::collections::BTreeMap<PathBuf, String> {
4842 let mut files = std::collections::BTreeMap::new();
4843 let mut pending = vec![dir.to_path_buf()];
4844 while let Some(directory) = pending.pop() {
4845 for entry in std::fs::read_dir(&directory).unwrap() {
4846 let path = entry.unwrap().path();
4847 if std::fs::symlink_metadata(&path).unwrap().is_dir() {
4848 pending.push(path);
4849 } else {
4850 let hash = digest_parts(&[&std::fs::read(&path).unwrap()]);
4851 let file = format!("{hash} {:?}", identity(&path));
4852 files.insert(path, file);
4853 }
4854 }
4855 }
4856 files
4857 }
4858
4859 #[test]
4860 fn attachments_remain_outside_eligible_memory_archive() {
4861 let (tmp, store) = setup();
4862 let messages = vec![msg(
4863 "user",
4864 json!([
4865 {"type":"text","text":"Compare the selected files"},
4866 {"type":"image","source":{"type":"base64","media_type":"image/png","data":"ATTACHMENT_IMAGE_SECRET"}},
4867 {"type":"document","title":"ATTACHMENT_FILENAME_SECRET","source":{"type":"base64","media_type":"application/pdf","data":"ATTACHMENT_PDF_SECRET"}},
4868 {"type":"document","source":{"type":"text","media_type":"text/plain","data":"ATTACHMENT_TEXT_SECRET"}}
4869 ]),
4870 )];
4871 let reference = store.seal(&messages, "safe checkpoint").unwrap();
4872 let fetched = store.fetch(&reference.id, 0, 10).unwrap();
4873 let serialized = serde_json::to_string(&fetched).unwrap();
4874 assert!(serialized.contains("Compare the selected files"));
4875 assert!(!serialized.contains("ATTACHMENT_"));
4876 let raw = std::fs::read_to_string(only_archive_file(&directory(&tmp, &store))).unwrap();
4877 assert!(!raw.contains("ATTACHMENT_"));
4878 assert!(messages[0].to_string().contains("ATTACHMENT_TEXT_SECRET"));
4879 }
4880
4881 #[test]
4882 fn exact_text_source_indices_note_and_restart() {
4883 let (tmp, store) = setup();
4884 let messages = vec![
4885 msg("system", json!("FORBIDDEN SYSTEM")),
4886 msg("user", json!(" exact\n αβ ")),
4887 msg(
4888 "assistant",
4889 json!([
4890 {"type":"thinking","thinking":"FORBIDDEN REASONING"},
4891 {"type":"text","text":"evidence✓", "cache_control":{"type":"ephemeral"}},
4892 {"type":"image","data":"FORBIDDEN IMAGE"}
4893 ]),
4894 ),
4895 msg("developer", json!("FORBIDDEN DEVELOPER")),
4896 ];
4897 let reference = store.seal(&messages, "hidden routing note").unwrap();
4898 assert_eq!(reference.message_count, 2);
4899 assert_eq!(reference.source_message_count, 4);
4900 let reopened =
4901 ArchiveStore::new(tmp.path(), "synthetic-project", "synthetic-logical").unwrap();
4902 let fetched = reopened
4903 .fetch_with_provenance(&reference.id, 0, 128)
4904 .unwrap();
4905 assert_eq!(fetched[0].message, messages[1]);
4906 assert_eq!(fetched[0].source_index, 1);
4907 assert_eq!(fetched[1].source_index, 2);
4908 assert_eq!(fetched[1].block_indices, vec![1]);
4909 assert_eq!(
4910 reopened.fetch_note(&reference.id).unwrap(),
4911 "hidden routing note"
4912 );
4913 assert!(reopened.search("hidden routing", 10).unwrap().is_empty());
4914 assert_eq!(reopened.search("evidence", 10).unwrap()[0].id, reference.id);
4915 assert!(reopened
4916 .fetch(&reference.id, usize::MAX, 10)
4917 .unwrap()
4918 .is_empty());
4919 let raw = std::fs::read_to_string(only_archive_file(&directory(&tmp, &store))).unwrap();
4920 assert!(!raw.contains("FORBIDDEN"));
4921 assert!(!raw.contains("cache_control"));
4922 assert_eq!(messages[0]["content"], "FORBIDDEN SYSTEM");
4923 }
4924
4925 #[test]
4926 fn duplicate_retry_ignores_note_but_not_logical_id() {
4927 let (tmp, store) = setup();
4928 let messages = vec![msg("user", json!("same eligible words"))];
4929 let first = store.seal(&messages, "first").unwrap();
4930 assert_eq!(store.seal(&messages, "second").unwrap(), first);
4931 assert_eq!(store.fetch_note(&first.id).unwrap(), "first");
4932 let other = ArchiveStore::new(tmp.path(), "synthetic-project", "other-logical").unwrap();
4933 let second = other.seal(&messages, "").unwrap();
4934 assert_ne!(first.id, second.id);
4935 assert_eq!(
4936 project_search(tmp.path(), "synthetic-project", "", 10)
4937 .unwrap()
4938 .len(),
4939 2
4940 );
4941 assert_eq!(
4942 project_fetch(tmp.path(), "synthetic-project", &first.id, 0, 10).unwrap(),
4943 messages
4944 );
4945 assert_eq!(other.fetch(&first.id, 0, 1).unwrap(), messages);
4946 store.forget(&first.id).unwrap();
4947 assert!(store.seal(&messages, "third").is_err());
4948 assert_eq!(other.fetch(&second.id, 0, 1).unwrap(), messages);
4949 assert_eq!(other.seal(&messages, "third").unwrap(), second);
4950 }
4951
4952 #[test]
4953 fn deletion_is_content_free_durable_and_cannot_resurrect() {
4954 let (tmp, store) = setup();
4955 let messages = vec![msg("user", json!("uniquely removable content"))];
4956 let reference = store.seal(&messages, "removable note").unwrap();
4957 store.forget(&reference.id).unwrap();
4958 store.forget(&reference.id).unwrap();
4959 let raw = std::fs::read_to_string(only_archive_file(&directory(&tmp, &store))).unwrap();
4960 assert!(raw.contains("tombstone"));
4961 assert!(!raw.contains("removable"));
4962 assert!(!raw.contains("source_message_count"));
4963 let store =
4964 ArchiveStore::new(tmp.path(), "synthetic-project", "synthetic-logical").unwrap();
4965 assert_eq!(
4966 store.fetch(&reference.id, 0, 1).unwrap_err().kind(),
4967 io::ErrorKind::NotFound
4968 );
4969 assert!(store.fetch_note(&reference.id).is_err());
4970 assert!(store.search("", 10).unwrap().is_empty());
4971 assert_eq!(
4972 store.seal(&messages, "changed note").unwrap_err().kind(),
4973 io::ErrorKind::PermissionDenied
4974 );
4975 }
4976
4977 #[test]
4978 fn foreign_scope_copied_records_and_traversal_fail_closed() {
4979 let (tmp, store) = setup();
4980 let reference = store
4981 .seal(&[msg("user", json!("scope evidence"))], "")
4982 .unwrap();
4983 let foreign =
4984 ArchiveStore::new(tmp.path(), "foreign-project", "synthetic-logical").unwrap();
4985 assert!(foreign.fetch(&reference.id, 0, 1).is_err());
4986 for id in [
4987 "../000.json",
4988 "/tmp/file",
4989 "",
4990 "a/../b",
4991 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
4992 ] {
4993 assert_eq!(
4994 store.fetch(id, 0, 1).unwrap_err().kind(),
4995 io::ErrorKind::InvalidInput
4996 );
4997 assert!(store.forget(id).is_err());
4998 }
4999 let source = only_archive_file(&directory(&tmp, &store));
5002 let foreign_dir = directory(&tmp, &foreign);
5003 std::fs::create_dir(foreign_dir.join("s000001")).unwrap();
5004 for target in [
5005 foreign_dir.join("s000001").join(source.file_name().unwrap()),
5006 foreign_dir.join("000.json"),
5007 ] {
5008 std::fs::copy(&source, &target).unwrap();
5009 assert_eq!(
5010 foreign.fetch(&reference.id, 0, 1).unwrap_err().kind(),
5011 io::ErrorKind::InvalidData
5012 );
5013 assert!(foreign.search("", 1).unwrap().is_empty());
5014 std::fs::remove_file(&target).unwrap();
5015 }
5016 assert!(ArchiveStore::new(&tmp.path().join("../escape"), "p", "l").is_err());
5017 }
5018
5019 #[test]
5020 fn disclosure_and_raw_tool_data_are_withheld() {
5021 let (tmp, store) = setup();
5022 let mut messages = vec![
5023 msg(
5024 "assistant",
5025 json!([{"type":"tool_use","id":"call_1","name":"read","input":{"path":"unclassified raw argument"}}]),
5026 ),
5027 msg(
5028 "user",
5029 json!([{"type":"tool_result","tool_use_id":"call_1","content":"unclassified raw output"}]),
5030 ),
5031 msg("user", json!("password=synthetic-credential")),
5032 ];
5033 for class in [
5034 "local_only",
5035 "persist_never_transmit",
5036 "never_persist",
5037 "model_visible_after_consent",
5038 "unknown",
5039 ] {
5040 messages.push(Arc::new(
5041 json!({"role":"user","content":"FORBIDDEN DISCLOSURE","disclosure":class}),
5042 ));
5043 }
5044 messages.push(Arc::new(
5045 json!({"role":"user","content":"FORBIDDEN SENSITIVITY","sensitivity":"secret"}),
5046 ));
5047 let reference = store.seal(&messages, "").unwrap();
5048 assert_eq!(reference.message_count, 3);
5049 let raw = std::fs::read_to_string(only_archive_file(&directory(&tmp, &store))).unwrap();
5050 for forbidden in ["unclassified raw", "synthetic-credential", "FORBIDDEN"] {
5051 assert!(!raw.contains(forbidden));
5052 }
5053 assert!(raw.contains("call_1"));
5054 assert!(raw.contains("_archive_withheld"));
5055 }
5056
5057 #[test]
5058 fn derived_context_envelope_is_not_searchable_source() {
5059 let (_tmp, store) = setup();
5060 let first = store
5061 .seal(
5062 &[msg("assistant", json!("original evidence"))],
5063 "NOTE_ONLY_SENTINEL",
5064 )
5065 .unwrap();
5066 let second=store.seal(&[
5067 Arc::new(json!({"role":"user","_synaps_context":{"schema":"synaps-context-window/1","archive":first.id},"content":"NOTE_ONLY_SENTINEL"})),
5068 Arc::new(json!({"role":"assistant","content":[{"type":"tool_use","id":"checkpoint-note","name":"context_checkpoint","input":{"phase":"plan","note":"NOTE_ONLY_SENTINEL"}}]})),
5069 msg("assistant",json!("second-window evidence")),
5070 ],"next note").unwrap();
5071 assert!(store.search("NOTE_ONLY_SENTINEL", 10).unwrap().is_empty());
5072 let fetched = store.fetch_with_provenance(&second.id, 0, 10).unwrap();
5073 assert_eq!(fetched.len(), 2);
5074 assert_eq!(fetched[0].source_index, 1);
5075 assert_eq!(fetched[1].source_index, 2);
5076 assert!(!serde_json::to_string(&fetched)
5077 .unwrap()
5078 .contains("NOTE_ONLY_SENTINEL"));
5079 }
5080
5081 #[test]
5082 fn checkpoint_tool_note_stays_hidden_even_with_redactor() {
5083 let (_tmp, store) = setup();
5084 let store = store.with_redactor(Arc::new(str::to_owned));
5085 let reference = store
5086 .seal(
5087 &[msg(
5088 "assistant",
5089 json!([
5090 {"type":"tool_use","id":"checkpoint-note","name":"context_checkpoint",
5091 "input":{"phase":"plan","note":"NOTE_ARGUMENT_SENTINEL"}}
5092 ]),
5093 )],
5094 "NOTE_ARGUMENT_SENTINEL",
5095 )
5096 .unwrap();
5097 assert!(store
5098 .search("NOTE_ARGUMENT_SENTINEL", 10)
5099 .unwrap()
5100 .is_empty());
5101 assert!(
5102 !serde_json::to_string(&store.fetch(&reference.id, 0, 10).unwrap())
5103 .unwrap()
5104 .contains("NOTE_ARGUMENT_SENTINEL")
5105 );
5106 assert_eq!(
5107 store.fetch_note(&reference.id).unwrap(),
5108 "NOTE_ARGUMENT_SENTINEL"
5109 );
5110 }
5111
5112 #[test]
5113 fn trusted_redactor_retains_exact_eligible_tool_evidence() {
5114 let (_tmp, store) = setup();
5115 let store = store.with_redactor(Arc::new(|s| s.replace("classified-value", "[redacted]")));
5116 let messages = vec![
5117 msg(
5118 "assistant",
5119 json!([{"type":"tool_use","id":"call_1","name":"read","input":{"path":"src/lib.rs","line":7}}]),
5120 ),
5121 msg(
5122 "user",
5123 json!([{"type":"tool_result","tool_use_id":"call_1","is_error":false,"content":"line 7\n exact evidence\nclassified-value"}]),
5124 ),
5125 ];
5126 let reference = store.seal(&messages, "").unwrap();
5127 let fetched = store.fetch(&reference.id, 0, 10).unwrap();
5128 assert_eq!(fetched[0], messages[0]);
5129 assert_eq!(
5130 fetched[1]["content"][0]["content"],
5131 "line 7\n exact evidence\n[redacted]"
5132 );
5133 }
5134
5135 #[test]
5136 fn oversized_input_output_limits_and_depth_leave_source_intact() {
5137 let (_tmp, store) = setup();
5138 let huge = vec![msg("user", json!("x".repeat(MAX_INPUT_BYTES + 1)))];
5139 assert!(store.seal(&huge, "").is_err());
5140 assert_eq!(
5141 huge[0]["content"].as_str().unwrap().len(),
5142 MAX_INPUT_BYTES + 1
5143 );
5144 let file_too_large = vec![msg("user", json!("\n".repeat(MAX_SEGMENT_BYTES / 2)))];
5145 assert!(store.seal(&file_too_large, "").is_err());
5146 assert!(store
5147 .seal(&vec![msg("user", json!("a")); MAX_MESSAGES + 1], "")
5148 .is_err());
5149 assert!(store.seal(&[], &"n".repeat(MAX_NOTE_BYTES + 1)).is_err());
5150 let mut deep = json!("value");
5151 for _ in 0..40 {
5152 deep = json!([deep]);
5153 }
5154 let deep = vec![msg(
5157 "assistant",
5158 json!([{
5159 "type":"tool_use", "id":"r1", "name":"read", "input":deep
5160 }]),
5161 )];
5162 let store = store.with_redactor(Arc::new(str::to_owned));
5163 assert!(store.seal(&deep, "").is_err());
5164 assert!(store.search(&"q".repeat(513), 1).is_err());
5165 assert!(store.search("", MAX_SEARCH_RESULTS + 1).is_err());
5166 assert!(store
5167 .fetch(&"a".repeat(32), 0, MAX_FETCH_MESSAGES + 1)
5168 .is_err());
5169 assert!(store.search("", 10).unwrap().is_empty());
5170 }
5171
5172 #[test]
5173 fn torn_final_fails_closed_stale_temporary_is_not_visible() {
5174 let (tmp, store) = setup();
5175 let dir = directory(&tmp, &store);
5176 let reference = store.seal(&text("complete"), "").unwrap();
5177 let file = only_archive_file(&dir);
5178 let stale = dir.join("s000001").join("pending.tmp");
5179 std::fs::write(&stale, b"unpublished partial").unwrap();
5180 written_ago(&stale, STALE_TEMP * 2);
5181 assert_eq!(store.search("", 2).unwrap().len(), 1);
5182 let other = store.seal(&text("another"), "").unwrap();
5183 assert!(!stale.exists());
5184 std::fs::write(&file, b"{\"version\":1,").unwrap();
5185 let torn = identity(&file);
5186 for result in [
5187 store.fetch(&reference.id, 0, 1).map(drop),
5188 store.fetch_note(&reference.id).map(drop),
5189 store.forget(&reference.id).map(drop),
5190 ] {
5191 assert_eq!(result.unwrap_err().kind(), io::ErrorKind::InvalidData);
5192 }
5193 let again = store.seal(&text("complete"), "").unwrap();
5197 assert_ne!(again.id, reference.id);
5198 assert_eq!(store.fetch(&again.id, 0, 1).unwrap(), text("complete"));
5199 assert_eq!(std::fs::read(&file).unwrap(), b"{\"version\":1,");
5200 assert_eq!(identity(&file), torn);
5201 assert_eq!(store.fetch(&other.id, 0, 1).unwrap(), text("another"));
5203 assert!(store.seal(&text("next"), "").is_ok());
5204 }
5205
5206 #[test]
5207 fn private_modes_symlink_hardlink_fifo_and_lock_refusal() {
5208 let (tmp, store) = setup();
5209 let dir = directory(&tmp, &store);
5210 let reference = store.seal(&text("private"), "").unwrap();
5211 let file = only_archive_file(&dir);
5212 for path in [
5213 tmp.path().to_path_buf(),
5214 tmp.path().join("context-archives"),
5215 dir.clone(),
5216 dir.join("s000001"),
5217 ] {
5218 assert_eq!(mode(&path), 0o700);
5219 }
5220 for path in [&file, &dir.join("lock")] {
5221 assert_eq!(mode(path), 0o600);
5222 }
5223 std::fs::set_permissions(dir.join("s000001"), std::fs::Permissions::from_mode(0o755))
5226 .unwrap();
5227 std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o644)).unwrap();
5228 let located = archive_file(tmp.path(), "synthetic-project", &reference.id).unwrap();
5229 assert_eq!(located, Some(file.clone()));
5230 assert_eq!((mode(&dir.join("s000001")), mode(&file)), (0o755, 0o644));
5231 store.seal(&text("private"), "").unwrap();
5232 assert_eq!((mode(&dir.join("s000001")), mode(&file)), (0o700, 0o600));
5233 let victim = tmp.path().join("synthetic-victim");
5234 std::fs::write(&victim, "do not change").unwrap();
5235 std::fs::set_permissions(&victim, std::fs::Permissions::from_mode(0o644)).unwrap();
5236 let refused = || {
5237 let (_, unscanned) = store.search_scan("", 2).unwrap();
5239 let unread = unscanned.unwrap().unread.unwrap();
5240 let place = format!("first s000001/{}: ", archive_name(&reference.id));
5241 assert!(unread.starts_with("1 place(s) could not be read, ") && unread.contains(&place));
5242 assert!(store.fetch(&reference.id, 0, 1).is_err());
5243 assert!(store.fetch_note(&reference.id).is_err());
5244 assert!(store.forget(&reference.id).is_err());
5245 let other = store.seal(&text("private"), "").unwrap();
5249 assert_ne!(other.id, reference.id);
5250 assert_eq!(store.fetch(&other.id, 0, 1).unwrap(), text("private"));
5251 assert!(archive_file(tmp.path(), "synthetic-project", &reference.id).is_err());
5252 assert_eq!(std::fs::read_to_string(&victim).unwrap(), "do not change");
5253 assert_eq!(mode(&victim), 0o644);
5254 };
5255 std::fs::remove_file(&file).unwrap();
5256 symlink(&victim, &file).unwrap();
5257 refused();
5258 std::fs::remove_file(&file).unwrap();
5259 std::fs::hard_link(&victim, &file).unwrap();
5260 refused();
5261 std::fs::remove_file(&file).unwrap();
5262 let fifo = std::ffi::CString::new(file.to_str().unwrap()).unwrap();
5263 assert_eq!(unsafe { libc::mkfifo(fifo.as_ptr(), 0o600) }, 0);
5264 refused();
5265 std::fs::remove_file(dir.join("lock")).unwrap();
5266 symlink(&victim, dir.join("lock")).unwrap();
5267 assert!(store.fs.lock().is_err());
5268 }
5269
5270 #[test]
5274 fn a_lock_file_with_two_names_or_read_only_is_used_and_a_link_is_refused_by_name() {
5275 let (tmp, store) = setup();
5276 let dir = directory(&tmp, &store);
5277 let lock = dir.join("lock");
5278 let mut other = setup_in(tmp.path(), "synthetic-project");
5279 other.process_wait = Duration::ZERO;
5280 store.seal(&text("first"), "").unwrap();
5281 let legacy = seal_legacy_slot(&store, 0, &text("sealed long ago"), "");
5284 let works = |words: &str| {
5285 assert_eq!(store.fetch(&legacy.id, 0, 1).unwrap(), text("sealed long ago"), "{words}");
5286 let sealed = store.seal(&text(words), "").unwrap();
5287 assert_eq!(store.forget(&sealed.id).unwrap(), forgotten(&[&sealed.id]));
5288 let held = store.fs.lock().unwrap();
5291 assert!(store.fs.lock().is_err_and(|error| error.kind() == io::ErrorKind::WouldBlock));
5292 let busy = other.seal(&text(words), "").unwrap_err();
5293 assert_eq!(busy.kind(), io::ErrorKind::WouldBlock, "{words}: {busy}");
5294 assert_eq!(busy.to_string(), BUSY);
5295 drop(held);
5296 assert!(other.seal(&text(&format!("{words}, after")), "").is_ok());
5297 };
5298 let second = tmp.path().join("a-backup-of-the-lock");
5300 std::fs::hard_link(&lock, &second).unwrap();
5301 works("with two names");
5302 std::fs::set_permissions(&lock, std::fs::Permissions::from_mode(0o644)).unwrap();
5305 works("loosened, with two names");
5306 assert_eq!((mode(&lock), mode(&second)), (0o644, 0o644));
5307 for links in [2, 1] {
5309 std::fs::set_permissions(&lock, std::fs::Permissions::from_mode(0o400)).unwrap();
5310 works(&format!("read-only, {links} name(s)"));
5311 assert_eq!(mode(&lock), 0o400, "left as it is");
5312 if links == 2 {
5313 assert_eq!(identity(&lock), identity(&second), "not replaced");
5314 std::fs::remove_file(&second).unwrap();
5315 }
5316 }
5317 assert_eq!(std::fs::metadata(&lock).unwrap().len(), 0, "never written");
5318 std::fs::set_permissions(&lock, std::fs::Permissions::from_mode(0o644)).unwrap();
5320 works("loosened");
5321 assert_eq!(mode(&lock), 0o600);
5322
5323 let victim = tmp.path().join("synthetic-victim");
5325 std::fs::write(&victim, "do not change").unwrap();
5326 std::fs::set_permissions(&victim, std::fs::Permissions::from_mode(0o644)).unwrap();
5327 let sealed = store.seal(&text("to forget later"), "").unwrap();
5328 let refused = |what: &str| {
5329 let before = snapshot(&dir.join("s000001"));
5330 let errors = [
5331 store.seal(&text("refused"), "").unwrap_err(),
5332 store.forget(&sealed.id).unwrap_err(),
5333 store.fetch(&legacy.id, 0, 1).unwrap_err(),
5334 ];
5335 for error in errors {
5336 assert_eq!(error.kind(), io::ErrorKind::PermissionDenied, "{what}: {error}");
5337 let message = error.to_string();
5338 let named = format!("context archive lock file {} {what}", lock.display());
5339 assert!(message.starts_with(&named), "{message}");
5340 assert!(message.contains(&format!("rm '{}'", lock.display())), "{message}");
5341 }
5342 assert_eq!(snapshot(&dir.join("s000001")), before, "{what}");
5343 assert_eq!(store.fetch(&sealed.id, 0, 1).unwrap(), text("to forget later"));
5345 };
5346 std::fs::remove_file(&lock).unwrap();
5347 symlink(&victim, &lock).unwrap();
5348 refused("is a link");
5349 assert!(std::fs::symlink_metadata(&lock).unwrap().file_type().is_symlink());
5350 assert_eq!(std::fs::read_to_string(&victim).unwrap(), "do not change");
5351 assert_eq!(mode(&victim), 0o644);
5352 std::fs::remove_file(&lock).unwrap();
5353 std::fs::create_dir(&lock).unwrap();
5354 refused("is not a regular file");
5355 std::fs::remove_dir(&lock).unwrap();
5356 let fifo = std::ffi::CString::new(lock.to_str().unwrap()).unwrap();
5357 assert_eq!(unsafe { libc::mkfifo(fifo.as_ptr(), 0o600) }, 0);
5358 refused("is not a regular file");
5359 std::fs::remove_file(&lock).unwrap();
5360 if unsafe { libc::geteuid() } != 0 {
5361 std::fs::write(&lock, b"").unwrap();
5362 std::fs::set_permissions(&lock, std::fs::Permissions::from_mode(0o000)).unwrap();
5363 refused("cannot be opened by its owner with its mode");
5364 std::fs::remove_file(&lock).unwrap();
5365 }
5366 assert_eq!(store.forget(&sealed.id).unwrap(), forgotten(&[&sealed.id]));
5368 assert_eq!(mode(&lock), 0o600);
5369 }
5370
5371 #[test]
5372 fn lock_free_read_does_not_change_file_mode() {
5373 let (tmp, store) = setup();
5374 let dir = directory(&tmp, &store);
5375 let reference = store.seal(&text("private"), "note").unwrap();
5376 let (shelf, file) = (dir.join("s000001"), only_archive_file(&dir));
5377 std::fs::set_permissions(&shelf, std::fs::Permissions::from_mode(0o755)).unwrap();
5378 std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o644)).unwrap();
5379 assert_eq!(store.fetch(&reference.id, 0, 1).unwrap(), text("private"));
5380 assert_eq!(store.fetch_note(&reference.id).unwrap(), "note");
5381 assert_eq!(store.search("private", 1).unwrap().len(), 1);
5382 assert_eq!((mode(&shelf), mode(&file)), (0o755, 0o644));
5383 assert_eq!(store.seal(&text("private"), "").unwrap(), reference);
5385 assert_eq!((mode(&shelf), mode(&file)), (0o700, 0o600));
5386 }
5387
5388 type Stamp = (u32, (i64, i64), (i64, i64));
5390 fn stamps(root: &Path) -> std::collections::BTreeMap<PathBuf, Stamp> {
5394 use std::os::unix::fs::MetadataExt;
5395 let mut found = std::collections::BTreeMap::new();
5396 let mut pending = vec![root.to_path_buf()];
5397 while let Some(path) = pending.pop() {
5398 let meta = std::fs::symlink_metadata(&path).unwrap();
5399 if meta.is_dir() {
5400 let entries = std::fs::read_dir(&path).unwrap();
5401 pending.extend(entries.map(|entry| entry.unwrap().path()));
5402 }
5403 let changed = (meta.ctime(), meta.ctime_nsec());
5404 let modified = (meta.mtime(), meta.mtime_nsec());
5405 found.insert(path, (meta.mode(), changed, modified));
5406 }
5407 found
5408 }
5409
5410 fn read_everything(
5413 base: &Path,
5414 live: &[(&ArchiveRef, &str, &str)],
5415 gone: &[String],
5416 rows: usize,
5417 ) {
5418 let open = || ArchiveStore::open_existing(base, "synthetic-project", "any-reader");
5419 for (reference, words, note) in live {
5420 let id = &reference.id;
5421 assert_eq!(open().unwrap().unwrap().fetch(id, 0, 1).unwrap(), text(words));
5422 assert_eq!(&open().unwrap().unwrap().fetch_note(id).unwrap(), note);
5423 let found = project_fetch(base, "synthetic-project", id, 0, 1);
5424 assert_eq!(found.unwrap(), text(words));
5425 let located = archive_file(base, "synthetic-project", id).unwrap().unwrap();
5426 assert!(std::fs::read_to_string(located).unwrap().contains(words));
5427 let found = open().unwrap().unwrap().search(words, 8).unwrap();
5428 assert_eq!(found.len(), 1, "{words}");
5429 assert_eq!(found[0].id, *id);
5430 }
5431 for id in gone {
5432 for result in [
5433 open().unwrap().unwrap().fetch(id, 0, 1).map(drop),
5434 open().unwrap().unwrap().fetch_note(id).map(drop),
5435 project_fetch(base, "synthetic-project", id, 0, 1).map(drop),
5436 ] {
5437 let error = result.unwrap_err();
5438 assert_eq!(error.kind(), io::ErrorKind::NotFound);
5439 assert_eq!(error.to_string(), NOT_FOUND);
5440 }
5441 }
5442 assert_eq!(open().unwrap().unwrap().search("", 8).unwrap().len(), rows);
5443 assert_eq!(project_search(base, "synthetic-project", "", 8).unwrap().len(), rows);
5444 assert_eq!(open().unwrap().unwrap().search_scan("", 8).unwrap().1, None);
5445 }
5446
5447 #[test]
5453 fn reads_write_nothing_and_work_where_nothing_can_be_written() {
5454 let (tmp, store) = setup();
5455 let dir = directory(&tmp, &store);
5456 let shelved = store.seal(&text("read only"), "note").unwrap();
5457 let forgotten = store.seal(&text("forgotten"), "").unwrap();
5458 store.forget(&forgotten.id).unwrap();
5459 let legacy = seal_legacy_slot(&store, 0, &text("sealed long ago"), "old note");
5460 let old_forgotten = seal_legacy_slot(&store, 1, &text("forgotten long ago"), "");
5461 store.forget(&old_forgotten.id).unwrap();
5462 drop(store);
5463 let live = [(&shelved, "read only", "note"), (&legacy, "sealed long ago", "old note")];
5464 let gone = [forgotten.id.clone(), old_forgotten.id.clone(), "a".repeat(32)];
5465 let context = tmp.path().join("context-archives");
5466 let dirs = vec![tmp.path().to_path_buf(), context, dir.clone(), dir.join("s000001")];
5467 let _reopen = Reopen(dirs.clone());
5468 for lock in [true, false] {
5471 if !lock {
5472 std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
5473 std::fs::remove_file(dir.join("lock")).unwrap();
5474 }
5475 for path in &dirs {
5477 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
5478 }
5479 for path in shelf_files(&dir).into_iter().chain([dir.join("000.json")]) {
5480 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o644)).unwrap();
5481 }
5482 std::thread::sleep(Duration::from_millis(50));
5484 let before = stamps(tmp.path());
5485 assert_eq!(before.len(), if lock { 10 } else { 9 }, "4 directories, 5 files, lock");
5486 read_everything(tmp.path(), &live, &gone, 2);
5487 assert_eq!(stamps(tmp.path()), before, "lock file: {lock}");
5488
5489 for path in &dirs {
5491 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o500)).unwrap();
5492 }
5493 for path in shelf_files(&dir).into_iter().chain([dir.join("000.json")]) {
5494 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o400)).unwrap();
5495 }
5496 if lock {
5497 let read_only = std::fs::Permissions::from_mode(0o400);
5498 std::fs::set_permissions(dir.join("lock"), read_only).unwrap();
5499 }
5500 std::thread::sleep(Duration::from_millis(50));
5501 let before = stamps(tmp.path());
5502 read_everything(tmp.path(), &live, &gone, 2);
5503 assert_eq!(stamps(tmp.path()), before, "read-only, lock file: {lock}");
5504 }
5505 ArchiveStore::new(tmp.path(), "synthetic-project", "any").unwrap();
5508 assert_eq!((mode(tmp.path()), mode(&dir), mode(&dir.join("s000001"))), (0o700, 0o700, 0o500));
5509 }
5510
5511 #[test]
5514 fn a_read_of_a_project_without_archives_creates_nothing() {
5515 let tmp = tempfile::tempdir().unwrap();
5516 let id = "a".repeat(32);
5517 let absent = |base: &Path, project: &str| {
5518 assert!(ArchiveStore::open_existing(base, project, "reader").unwrap().is_none());
5519 assert!(project_search(base, project, "", 8).unwrap().is_empty());
5520 let error = project_fetch(base, project, &id, 0, 1).unwrap_err();
5521 assert_eq!(error.kind(), io::ErrorKind::NotFound);
5522 assert_eq!(error.to_string(), NOT_FOUND);
5523 assert_eq!(archive_file(base, project, &id).unwrap(), None);
5524 };
5525 absent(&tmp.path().join("missing"), "synthetic-project");
5526 absent(tmp.path(), "synthetic-project");
5527 assert_eq!(std::fs::read_dir(tmp.path()).unwrap().count(), 0);
5528 let other = setup_in(tmp.path(), "another-project");
5531 other.seal(&text("elsewhere"), "").unwrap();
5532 std::thread::sleep(Duration::from_millis(50));
5533 let before = stamps(tmp.path());
5534 absent(tmp.path(), "synthetic-project");
5535 assert_eq!(stamps(tmp.path()), before);
5536 assert_eq!(std::fs::read_dir(tmp.path().join("context-archives")).unwrap().count(), 1);
5537 for (project, id) in [("", id.as_str()), ("synthetic-project", "../x")] {
5539 let error = project_fetch(tmp.path(), project, id, 0, 1).unwrap_err();
5540 assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
5541 }
5542 let context = tmp.path().join("context-archives");
5545 let scope = digest_parts(&[b"synthetic-project"]);
5546 symlink(context.join(&other.scope), context.join(&scope)).unwrap();
5547 assert!(ArchiveStore::open_existing(tmp.path(), "synthetic-project", "reader").is_err());
5548 assert!(project_search(tmp.path(), "synthetic-project", "", 8).is_err());
5549 }
5550
5551 #[test]
5555 fn a_base_reached_through_a_link_works_and_a_link_below_the_base_is_refused() {
5556 let tmp = tempfile::tempdir().unwrap();
5557 let root = std::fs::canonicalize(tmp.path()).unwrap();
5559 let disk = root.join("another-disk");
5560 std::fs::create_dir_all(disk.join("synaps")).unwrap();
5561 std::fs::create_dir(root.join("home")).unwrap();
5562 symlink(disk.join("synaps"), root.join("home/.synaps-cli")).unwrap();
5565 symlink(&disk, root.join("mount")).unwrap();
5566 let bases = [
5567 (root.join("home/.synaps-cli"), disk.join("synaps")),
5568 (root.join("mount/not/yet"), disk.join("not/yet")),
5569 ];
5570 for (base, real) in &bases {
5571 let store = setup_in(base, "synthetic-project");
5572 let real_dir = real.join("context-archives").join(&store.scope);
5573 let words = "through a link";
5574 let sealed = store.seal_with_note(&text(words), "a note").unwrap().0;
5575 let file = only_archive_file(&real_dir);
5576 assert_eq!(file.file_name().unwrap().to_str(), Some(archive_name(&sealed.id).as_str()));
5577 assert_eq!(mode(real), 0o700);
5578 assert_eq!(store.fetch(&sealed.id, 0, 1).unwrap(), text(words));
5579 assert_eq!(store.search("through", 8).unwrap().len(), 1);
5580 assert_eq!(project_fetch(base, "synthetic-project", &sealed.id, 0, 1).unwrap(), text(words));
5583 assert_eq!(project_search(base, "synthetic-project", "through", 8).unwrap().len(), 1);
5584 let located = archive_file(base, "synthetic-project", &sealed.id).unwrap().unwrap();
5585 assert_eq!(std::fs::canonicalize(located).unwrap(), file);
5586 let again = setup_in(base, "synthetic-project");
5587 assert_eq!(again.seal(&text(words), "").unwrap(), sealed);
5588 assert_eq!(again.forget(&sealed.id).unwrap(), forgotten(&[&sealed.id]));
5589 assert!(is_tombstone_of(&store, &file, &digest_of(words)));
5590 assert!(store.search("through", 8).unwrap().is_empty());
5591 assert_eq!(shelf_files(&real_dir).len(), 1);
5592 }
5593 for link in [root.join("home/.synaps-cli"), root.join("mount")] {
5594 assert!(std::fs::symlink_metadata(link).unwrap().file_type().is_symlink());
5595 }
5596
5597 let outside = root.join("outside");
5600 std::fs::create_dir(&outside).unwrap();
5601 let empty = || std::fs::read_dir(&outside).unwrap().count() == 0;
5602 let (base, real) = &bases[0];
5603 let scope = digest_parts(&[b"linked-project"]);
5604 symlink(&outside, real.join("context-archives").join(&scope)).unwrap();
5605 assert!(ArchiveStore::new(base, "linked-project", "l").is_err());
5606 assert!(ArchiveStore::open_existing(base, "linked-project", "l").is_err());
5607 assert!(project_search(base, "linked-project", "", 8).is_err());
5608 for base in [root.join("plain"), root.join("mount/linked-below")] {
5609 std::fs::create_dir(&base).unwrap();
5610 symlink(&outside, base.join("context-archives")).unwrap();
5611 assert!(ArchiveStore::new(&base, "synthetic-project", "l").is_err());
5612 assert!(ArchiveStore::open_existing(&base, "synthetic-project", "l").is_err());
5613 }
5614 let store = setup_in(base, "synthetic-project");
5615 let real_dir = real.join("context-archives").join(&store.scope);
5616 symlink(&outside, real_dir.join("s000002")).unwrap();
5617 let sealed = store.seal(&text("past a shelf that is a link"), "").unwrap();
5618 let file = real_dir.join("s000003").join(archive_name(&sealed.id));
5619 assert!(file.is_file());
5620 assert!(empty(), "nothing was written through a link");
5621
5622 if unsafe { libc::geteuid() } == 0 {
5624 return; }
5626 let parent = root.join("closed");
5627 let base = parent.join("base");
5628 std::fs::create_dir_all(&base).unwrap();
5629 std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o100)).unwrap();
5630 let result = (|| {
5631 let store = ArchiveStore::new(&base, "synthetic-project", "synthetic-logical")?;
5632 let sealed = store.seal(&text("under a closed parent"), "")?;
5633 let read = project_fetch(&base, "synthetic-project", &sealed.id, 0, 1)?;
5634 store.forget(&sealed.id)?;
5635 Ok::<_, io::Error>(read)
5636 })();
5637 std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700)).unwrap();
5638 assert_eq!(result.unwrap(), text("under a closed parent"));
5639 assert_eq!(mode(&base), 0o700);
5640 }
5641
5642 #[test]
5643 fn a_shelf_name_that_is_a_link_or_a_file_is_not_followed_and_does_not_stop_a_seal() {
5644 let (tmp, store) = setup();
5645 let dir = directory(&tmp, &store);
5646 let reference = store.seal(&text("shelved"), "").unwrap();
5647 let shelf = dir.join("s000001");
5648 let outside = tmp.path().join("outside-shelf");
5649 std::fs::rename(&shelf, &outside).unwrap();
5650 let before = snapshot(&outside);
5651 let unread = |result: io::Result<()>| {
5652 let error = result.unwrap_err();
5653 assert_ne!(error.kind(), io::ErrorKind::NotFound, "{error}");
5654 assert!(error.to_string().contains("s000001"), "{error}");
5655 assert!(error.to_string().contains("could not be read"), "{error}");
5656 };
5657 let refused = |id: &str| {
5661 unread(store.fetch(id, 0, 1).map(drop));
5662 unread(store.fetch_note(id).map(drop));
5663 unread(store.forget(id).map(drop));
5664 unread(archive_file(tmp.path(), "synthetic-project", id).map(drop));
5665 let (_, unscanned) = store.search_scan("", MAX_SEARCH_RESULTS).unwrap();
5666 let passed = unscanned.unwrap().unread.unwrap();
5667 assert!(passed.starts_with("1 place(s) could not be read, first s000001: "), "{passed}");
5668 };
5669 symlink(&outside, &shelf).unwrap();
5670 refused(&reference.id);
5671 std::fs::remove_file(&shelf).unwrap();
5672 std::fs::write(&shelf, b"not a directory").unwrap();
5673 refused(&reference.id);
5674 assert_eq!(snapshot(&outside), before);
5676 assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2, "lock and s000001");
5677
5678 let new = store.seal(&text("something new"), "").unwrap();
5680 assert!(dir.join("s000002").join(archive_name(&new.id)).is_file());
5681 assert_eq!(std::fs::read(&shelf).unwrap(), b"not a directory");
5682 std::fs::remove_file(&shelf).unwrap();
5683 symlink(&outside, &shelf).unwrap();
5684 assert_eq!(store.seal(&text("shelved"), "").unwrap(), reference);
5686 assert!(dir.join("s000002").join(archive_name(&reference.id)).is_file());
5687 assert_eq!(snapshot(&outside), before);
5688 assert_eq!(store.fetch(&new.id, 0, 1).unwrap(), text("something new"));
5691 assert_eq!(store.fetch(&reference.id, 0, 1).unwrap(), text("shelved"));
5692 let located = archive_file(tmp.path(), "synthetic-project", &new.id).unwrap();
5693 assert_eq!(located, Some(dir.join("s000002").join(archive_name(&new.id))));
5694 refused(&"a".repeat(32));
5695 std::fs::write(dir.join("s000003"), b"not a directory").unwrap();
5697 let next = store.seal(&text("after a file"), "").unwrap();
5698 assert!(dir.join("s000004").join(archive_name(&next.id)).is_file());
5699 symlink(&outside, dir.join("s000005")).unwrap();
5700 let last = store.seal(&text("after a link"), "").unwrap();
5701 assert!(dir.join("s000006").join(archive_name(&last.id)).is_file());
5702 assert_eq!(std::fs::read(dir.join("s000003")).unwrap(), b"not a directory");
5703 assert_eq!(snapshot(&outside), before);
5704 std::fs::remove_file(&shelf).unwrap();
5705 std::fs::remove_file(dir.join("s000003")).unwrap();
5706 std::fs::remove_file(dir.join("s000005")).unwrap();
5707 assert_eq!(store.search("", MAX_SEARCH_RESULTS).unwrap().len(), 4);
5708 }
5709
5710 struct Reopen(Vec<PathBuf>);
5712 impl Drop for Reopen {
5713 fn drop(&mut self) {
5714 for dir in &self.0 {
5715 let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
5716 }
5717 }
5718 }
5719
5720 #[test]
5721 fn a_shelf_that_cannot_be_opened_does_not_stop_a_seal() {
5722 if unsafe { libc::geteuid() } == 0 {
5723 return; }
5725 let (tmp, store) = setup();
5726 let dir = directory(&tmp, &store);
5727 let seal = |index: usize| store.seal(&text(&format!("archive {index}")), "").unwrap();
5728 let ids: Vec<_> = (0..=SHELF_FILES).map(|index| seal(index).id).collect();
5729 let (older, newer) = (dir.join("s000001"), dir.join("s000002"));
5730 let _reopen = Reopen(vec![older.clone(), newer.clone()]);
5731 let close = |shelf: &Path, mode: u32| {
5732 std::fs::set_permissions(shelf, std::fs::Permissions::from_mode(mode)).unwrap();
5733 };
5734 close(&older, 0o000);
5736 let next = seal(1_000);
5737 assert!(newer.join(archive_name(&next.id)).is_file());
5738 assert_eq!(seal(0).id, ids[0]);
5740 assert!(newer.join(archive_name(&ids[0])).is_file());
5741 assert_eq!(store.fetch(&next.id, 0, 1).unwrap(), text("archive 1000"));
5743 assert_eq!(store.fetch(&ids[0], 0, 1).unwrap(), text("archive 0"));
5744 for id in [ids[5].clone(), "a".repeat(32)] {
5745 for result in [store.fetch(&id, 0, 1).map(drop), store.forget(&id).map(drop)] {
5746 let error = result.unwrap_err();
5747 assert_eq!(error.kind(), io::ErrorKind::PermissionDenied, "{error}");
5748 assert!(error.to_string().contains("s000001"), "{error}");
5749 assert!(error.to_string().contains("could not be read"), "{error}");
5750 }
5751 }
5752 assert_eq!(mode(&older), 0o000);
5753 close(&older, 0o700);
5755 close(&newer, 0o000);
5756 let later = seal(2_000);
5757 assert!(dir.join("s000003").join(archive_name(&later.id)).is_file());
5758 assert_eq!(store.fetch(&ids[5], 0, 1).unwrap(), text("archive 5"));
5759 assert_eq!(mode(&newer), 0o000);
5760 close(&newer, 0o700);
5761 assert_eq!(std::fs::read_dir(&newer).unwrap().count(), 3);
5762 assert_eq!(std::fs::read_dir(&older).unwrap().count(), SHELF_FILES);
5763 for (id, index) in [(&ids[5], 5), (&ids[SHELF_FILES], SHELF_FILES), (&later.id, 2_000)] {
5764 assert_eq!(store.fetch(id, 0, 1).unwrap(), text(&format!("archive {index}")));
5765 }
5766 }
5767
5768 #[test]
5769 fn a_directory_with_an_absurd_shelf_number_is_not_a_shelf() {
5770 let (tmp, store) = setup();
5771 let dir = directory(&tmp, &store);
5772 let seal = |index: usize| store.seal(&text(&format!("archive {index}")), "").unwrap();
5773 let first = seal(0);
5774 let file = only_archive_file(&dir);
5775 let absurd = ["s18446744073709551615", "s1000000", "s0999999", "s99999999999999999999"];
5779 for name in absurd {
5780 std::fs::create_dir(dir.join(name)).unwrap();
5781 std::fs::copy(&file, dir.join(name).join(archive_name(&first.id))).unwrap();
5782 }
5783 let before = snapshot(&dir);
5784 assert_eq!(store.shelves().unwrap(), ["s000001"]);
5785 let second = seal(1);
5786 assert!(dir.join("s000001").join(archive_name(&second.id)).is_file());
5787 assert_eq!(store.search("", 8).unwrap().len(), 2);
5788 let after = snapshot(&dir);
5789 assert!(before.iter().all(|(path, file)| after.get(path) == Some(file)));
5790 assert_eq!(after.len(), before.len() + 1);
5791
5792 let last = dir.join("s999999");
5795 std::fs::create_dir(&last).unwrap();
5796 let ids: Vec<_> = (2..SHELF_FILES + 5).map(|index| seal(index).id).collect();
5797 assert_eq!(std::fs::read_dir(&last).unwrap().count(), SHELF_FILES + 3);
5798 assert_eq!(mode(&last), 0o700);
5799 assert_eq!(store.shelves().unwrap(), ["s999999", "s000001"]);
5800 for (id, index) in [(&ids[0], 2), (&ids[SHELF_FILES + 2], SHELF_FILES + 4)] {
5801 assert_eq!(store.fetch(id, 0, 1).unwrap(), text(&format!("archive {index}")));
5802 }
5803 assert_eq!(store.seal(&text("archive 0"), "").unwrap(), first);
5804 std::fs::rename(&last, tmp.path().join("moved-away")).unwrap();
5807 std::fs::write(&last, b"not a directory").unwrap();
5808 let fallback = seal(1_000);
5809 assert!(dir.join("s000001").join(archive_name(&fallback.id)).is_file());
5810 assert_eq!(std::fs::read(&last).unwrap(), b"not a directory");
5811 }
5812
5813 fn record_bytes(
5816 store: &ArchiveStore,
5817 id: &str,
5818 messages: &[SharedMessage],
5819 change: impl FnOnce(&mut Record),
5820 ) -> Vec<u8> {
5821 let projection = project_hashed(&store.logical, messages, "", None, Reduce::None).unwrap();
5822 let mut record = Record {
5823 version: 1,
5824 scope: store.scope.clone(),
5825 logical: store.logical.clone(),
5826 id: id.to_owned(),
5827 digest: projection.digest,
5828 created_ms: None,
5829 body: Body::Live {
5830 source_message_count: projection.source_message_count,
5831 messages: projection.messages,
5832 note: projection.note,
5833 },
5834 };
5835 change(&mut record);
5836 bounded_json(&record, MAX_SEGMENT_BYTES).unwrap()
5837 }
5838
5839 #[test]
5840 fn a_file_under_the_id_that_is_not_the_segment_is_left_and_the_other_id_is_used() {
5841 let (tmp, store) = setup();
5842 let dir = directory(&tmp, &store);
5843 let shelf = dir.join("s000001");
5844 store.seal(&text("a shelf to begin with"), "").unwrap();
5845 let digest = |words: &str| {
5846 let projection = project_archive("synthetic-logical", &text(words), "", None);
5847 projection.unwrap().digest
5848 };
5849 let other = |id: &str| record_bytes(&store, id, &text("another window"), |_| {});
5853 let forged = |id: &str, digest: &str| {
5854 record_bytes(&store, id, &text("another window"), |record| record.digest = digest.into())
5855 };
5856 for (case, words) in ["torn", "another projection", "forged digest"].into_iter().enumerate() {
5857 let digest = digest(words);
5858 let (first, second) = (&digest[..32], &digest[32..]);
5859 let taken = shelf.join(archive_name(first));
5860 let bytes = match case {
5861 0 => b"{\"version\":1,".to_vec(),
5862 1 => other(first),
5863 _ => forged(first, &digest),
5864 };
5865 std::fs::write(&taken, &bytes).unwrap();
5866 let before = snapshot(&dir);
5867 let (sealed, note) = store.seal_with_note(&text(words), "first note").unwrap();
5868 assert_eq!((sealed.id.as_str(), note.as_str()), (second, "first note"), "{words}");
5869 assert_eq!(store.fetch(second, 0, 1).unwrap(), text(words), "{words}");
5870 assert!(shelf.join(archive_name(second)).is_file(), "{words}");
5871 let after = snapshot(&dir);
5873 assert!(before.iter().all(|(path, file)| after.get(path) == Some(file)), "{words}");
5874 assert_eq!(after.len(), before.len() + 1, "{words}");
5875 let again = store.seal_with_note(&text(words), "second note").unwrap();
5878 assert_eq!(again, (sealed.clone(), "first note".to_owned()), "{words}");
5879 assert_eq!(snapshot(&dir), after, "{words}");
5880 match store.fetch(first, 0, 1) {
5882 Ok(found) => assert_eq!((case, found), (1, text("another window"))),
5883 Err(error) => assert_eq!(error.kind(), io::ErrorKind::InvalidData, "{words}"),
5884 }
5885 store.forget(second).unwrap();
5887 let refused = store.seal(&text(words), "").unwrap_err();
5888 assert_eq!(refused.kind(), io::ErrorKind::PermissionDenied, "{words}");
5889 assert_eq!(std::fs::read(&taken).unwrap(), bytes, "{words}");
5890 }
5891 let digest = digest("no name left");
5894 for id in [&digest[..32], &digest[32..]] {
5895 std::fs::write(shelf.join(archive_name(id)), other(id)).unwrap();
5896 }
5897 let before = snapshot(&dir);
5898 let refused = store.seal(&text("no name left"), "").unwrap_err();
5899 assert_eq!(refused.kind(), io::ErrorKind::AlreadyExists);
5900 assert_eq!(snapshot(&dir), before);
5901 assert!(store.seal(&text("any other window"), "").is_ok());
5902 }
5903
5904 #[test]
5907 fn a_tombstone_that_no_longer_validates_still_forbids_its_projection() {
5908 let (tmp, store) = setup();
5909 let dir = directory(&tmp, &store);
5910 let words = "forgotten words";
5911 let gone = seal_legacy_slot(&store, 0, &text(words), "");
5912 store.forget(&gone.id).unwrap();
5913 let digest = project_archive("synthetic-logical", &text(words), "", None);
5914 let digest = digest.unwrap().digest;
5915 let tombstone = std::fs::read_to_string(dir.join("000.json")).unwrap();
5916 let altered = [
5920 tombstone.replace("\"version\":1", "\"version\":2"),
5921 tombstone.replacen('{', "{\"added\":true,", 1),
5922 tombstone.replace(&store.scope, &"0".repeat(64)),
5923 ];
5924 let refused = |kind: &str| {
5925 let error = store.seal(&text(words), "").unwrap_err();
5926 assert_eq!(error.kind(), io::ErrorKind::PermissionDenied, "{kind}");
5927 assert!(error.to_string().contains("was forgotten"), "{kind}");
5928 };
5929 let shelf = dir.join("s000001");
5931 let places = [
5932 dir.join("000.json"),
5933 shelf.join(archive_name(&digest[..32])),
5934 shelf.join(archive_name(&digest[32..])),
5935 ];
5936 std::fs::remove_file(&places[0]).unwrap();
5939 std::fs::remove_file(&places[1]).unwrap();
5940 for (index, place) in places.iter().enumerate() {
5942 for bytes in &altered {
5943 assert!(store.record("000.json", bytes.as_bytes()).is_err());
5944 std::fs::write(place, bytes).unwrap();
5945 let before = snapshot(&dir);
5946 refused(bytes);
5947 assert_eq!(snapshot(&dir), before);
5948 }
5949 if index < 2 {
5950 std::fs::remove_file(place).unwrap();
5951 }
5952 }
5953 std::fs::write(&places[1], b"not a record").unwrap();
5954 refused("with the first name taken");
5955 assert!(store.seal(&text("other words"), "").is_ok());
5957 std::fs::write(&places[2], tombstone.replace("tombstone", "tombstoned")).unwrap();
5958 let error = store.seal(&text(words), "").unwrap_err();
5959 assert_eq!(error.kind(), io::ErrorKind::AlreadyExists, "no name left: {error}");
5960 std::fs::remove_file(&places[2]).unwrap();
5961 let again = store.seal(&text(words), "").unwrap();
5962 assert_eq!(again.id, digest[32..]);
5963 }
5964
5965 struct Fault;
5968 impl Fault {
5969 fn on(call: &'static str, prefix: &str, code: i32) -> Self {
5970 FAULTS.with_borrow_mut(|faults| faults.push((call, prefix.to_owned(), code)));
5971 Fault
5972 }
5973 }
5974 impl Drop for Fault {
5975 fn drop(&mut self) {
5976 FAULTS.with_borrow_mut(Vec::clear);
5977 }
5978 }
5979 fn digest_of(words: &str) -> String {
5980 let projection = project_archive("synthetic-logical", &text(words), "", None);
5981 projection.unwrap().digest
5982 }
5983
5984 #[test]
5987 fn a_segment_under_its_second_id_is_found_when_the_first_name_is_free_again() {
5988 let (tmp, store) = setup();
5989 let dir = directory(&tmp, &store);
5990 let shelf = dir.join("s000001");
5991 store.seal(&text("a shelf to begin with"), "").unwrap();
5992 let digest = digest_of("stored once");
5993 let (first, second) = (&digest[..32], &digest[32..]);
5994 let odd = shelf.join(archive_name(first));
5995 std::fs::write(&odd, b"{\"version\":1,").unwrap();
5996 let sealed = store.seal_with_note(&text("stored once"), "first note").unwrap();
5997 assert_eq!(sealed.0.id, second);
5998 std::fs::remove_file(&odd).unwrap();
6001 let before = snapshot(&dir);
6002 let again = store.seal_with_note(&text("stored once"), "second note").unwrap();
6003 assert_eq!(again, sealed);
6004 assert_eq!(snapshot(&dir), before);
6005 assert_eq!(store.search("stored once", 8).unwrap().len(), 1);
6006 store.forget(second).unwrap();
6009 let forgotten = snapshot(&dir);
6010 let refused = store.seal(&text("stored once"), "").unwrap_err();
6011 assert_eq!(refused.kind(), io::ErrorKind::PermissionDenied);
6012 assert!(refused.to_string().contains("was forgotten"));
6013 assert_eq!(snapshot(&dir), forgotten);
6014 assert!(!odd.exists());
6015 }
6016
6017 #[test]
6021 fn a_tombstone_under_either_name_refuses_whatever_the_other_holds() {
6022 let (tmp, store) = setup();
6023 let dir = directory(&tmp, &store);
6024 let shelf = dir.join("s000001");
6025 store.seal(&text("a shelf to begin with"), "").unwrap();
6026 for (words, forgotten) in [("tombstone first", 0), ("tombstone second", 1)] {
6027 let digest = digest_of(words);
6028 let ids = [&digest[..32], &digest[32..]];
6029 for (index, id) in ids.into_iter().enumerate() {
6030 let bytes = record_bytes(&store, id, &text(words), |record| {
6031 if index == forgotten {
6032 record.body = Body::Tombstone;
6033 }
6034 });
6035 std::fs::write(shelf.join(archive_name(id)), bytes).unwrap();
6036 }
6037 assert_eq!(store.fetch(ids[1 - forgotten], 0, 1).unwrap(), text(words));
6038 let before = snapshot(&dir);
6039 let refused = store.seal(&text(words), "").unwrap_err();
6040 assert_eq!(refused.kind(), io::ErrorKind::PermissionDenied, "{words}");
6041 assert_eq!(snapshot(&dir), before, "{words}");
6042 }
6043 }
6044
6045 #[test]
6049 fn a_file_under_the_name_on_a_newer_shelf_does_not_hide_an_older_tombstone_or_copy() {
6050 let (tmp, store) = setup();
6051 let dir = directory(&tmp, &store);
6052 let (older, newer) = (dir.join("s000001"), dir.join("s000002"));
6053 store.seal(&text("a shelf to begin with"), "").unwrap();
6054 std::fs::create_dir(&newer).unwrap();
6055 std::fs::write(tmp.path().join("elsewhere"), b"not in the store").unwrap();
6056 let odd = |name: &str, link: bool| {
6059 if link {
6060 symlink(tmp.path().join("elsewhere"), newer.join(name)).unwrap();
6061 } else {
6062 std::fs::write(newer.join(name), b"not a record").unwrap();
6063 }
6064 };
6065 let refused = |words: &str| {
6066 let before = snapshot(&dir);
6067 let error = store.seal(&text(words), "").unwrap_err();
6068 assert_eq!(error.kind(), io::ErrorKind::PermissionDenied, "{words}: {error}");
6069 assert_eq!(snapshot(&dir), before, "{words}");
6070 assert!(store.search(words, 8).unwrap().is_empty(), "{words}");
6071 };
6072 let imported = ["0123456789abcdef0123456789abcdef", "fedcba9876543210fedcba9876543210"];
6073 for (link, imported) in [false, true].into_iter().zip(imported) {
6074 let words = format!("forgotten behind an odd file, link {link}");
6077 let sealed = store.seal(&text(&words), "").unwrap();
6078 let name = archive_name(&sealed.id);
6079 assert_eq!(sealed.id, digest_of(&words)[..32]);
6080 std::fs::rename(newer.join(&name), older.join(&name)).unwrap();
6081 let copy = record_bytes(&store, imported, &text(&words), |_| {});
6082 std::fs::write(older.join(archive_name(imported)), copy).unwrap();
6083 odd(&name, link);
6084 let mut ids = store.forget(imported).unwrap().ids;
6085 ids.sort_unstable();
6086 let mut expected = [imported.to_owned(), sealed.id.clone()];
6087 expected.sort_unstable();
6088 assert_eq!(ids, expected);
6089 assert!(is_tombstone_of(&store, &older.join(&name), &digest_of(&words)));
6090 refused(&words);
6091
6092 for whole in [true, false] {
6095 let words = format!("an older tombstone, link {link}, whole {whole}");
6096 let digest = digest_of(&words);
6097 let id = &digest[..32];
6098 let bytes = record_bytes(&store, id, &text(&words), |record| {
6099 record.body = Body::Tombstone;
6100 if !whole {
6101 record.version = 2;
6102 }
6103 });
6104 std::fs::write(older.join(archive_name(id)), bytes).unwrap();
6105 odd(&archive_name(id), link);
6106 refused(&words);
6107 }
6108
6109 let words = format!("an older copy, link {link}");
6111 let sealed = store.seal_with_note(&text(&words), "first note").unwrap();
6112 let name = archive_name(&sealed.0.id);
6113 std::fs::rename(newer.join(&name), older.join(&name)).unwrap();
6114 odd(&name, link);
6115 let before = snapshot(&dir);
6116 assert_eq!(store.seal_with_note(&text(&words), "a later note").unwrap(), sealed);
6117 assert_eq!(snapshot(&dir), before);
6118 }
6119 let words = "only an odd file";
6121 let digest = digest_of(words);
6122 odd(&archive_name(&digest[..32]), false);
6123 assert_eq!(store.seal(&text(words), "").unwrap().id, digest[32..]);
6124 }
6125
6126 #[test]
6130 fn a_failed_read_of_the_file_under_the_id_fails_the_seal_and_stores_no_second_copy() {
6131 let (tmp, store) = setup();
6132 let dir = directory(&tmp, &store);
6133 let sealed = store.seal_with_note(&text("stored once"), "first note").unwrap();
6134 let before = snapshot(&dir);
6135 for code in [libc::EIO, libc::EMFILE, libc::ENFILE, libc::ENOMEM, libc::EINTR] {
6136 let failing = Fault::on("open", &sealed.0.id, code);
6137 let error = store.seal(&text("stored once"), "").unwrap_err();
6138 assert_eq!(error.raw_os_error(), Some(code));
6139 drop(failing);
6140 assert_eq!(snapshot(&dir), before, "error {code}");
6141 }
6142 let again = store.seal_with_note(&text("stored once"), "a later note").unwrap();
6143 assert_eq!(again, sealed);
6144 assert_eq!(snapshot(&dir), before);
6145 for code in [libc::ELOOP, libc::EACCES, libc::EPERM, libc::ENXIO] {
6148 assert!(PrivateDir::not_a_segment(&io::Error::from_raw_os_error(code)));
6149 }
6150 assert!(PrivateDir::not_a_segment(&corrupt()));
6151 if unsafe { libc::geteuid() } == 0 {
6152 return; }
6154 let file = only_archive_file(&dir);
6155 std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o000)).unwrap();
6156 let other = store.seal(&text("stored once"), "").unwrap();
6157 assert_eq!(other.id, digest_of("stored once")[32..]);
6158 assert_eq!(mode(&file), 0o000);
6159 }
6160
6161 fn is_tombstone_of(store: &ArchiveStore, file: &Path, digest: &str) -> bool {
6163 let name = file.file_name().unwrap().to_str().unwrap();
6164 let record = store.record(name, &std::fs::read(file).unwrap()).unwrap();
6165 record.digest == digest && matches!(record.body, Body::Tombstone)
6166 }
6167 fn forgotten(ids: &[&str]) -> Forgotten {
6168 Forgotten {
6169 ids: ids.iter().map(|id| (*id).to_owned()).collect(),
6170 unread: None,
6171 }
6172 }
6173
6174 #[test]
6178 fn forget_reaches_every_copy_of_the_window() {
6179 let (tmp, store) = setup();
6180 let dir = directory(&tmp, &store);
6181 let (older, newer) = (dir.join("s000001"), dir.join("s000002"));
6182 let gone = |words: &str| {
6183 assert!(store.search(words, 8).unwrap().is_empty(), "{words}");
6184 let refused = store.seal(&text(words), "").unwrap_err();
6185 assert_eq!(refused.kind(), io::ErrorKind::PermissionDenied, "{words}");
6186 };
6187 let words = "under both halves";
6190 let digest = digest_of(words);
6191 let (first, second) = (&digest[..32], &digest[32..]);
6192 assert_eq!(store.seal(&text(words), "").unwrap().id, first);
6193 let copy = record_bytes(&store, second, &text(words), |_| {});
6194 std::fs::write(older.join(archive_name(second)), copy).unwrap();
6195 assert_eq!(store.search(words, 8).unwrap().len(), 2);
6196 assert_eq!(store.forget(second).unwrap(), forgotten(&[second, first]));
6197 for id in [first, second] {
6198 assert_eq!(store.fetch(id, 0, 1).unwrap_err().kind(), io::ErrorKind::NotFound);
6199 assert!(is_tombstone_of(&store, &older.join(archive_name(id)), &digest));
6200 }
6201 gone(words);
6202 let before = snapshot(&dir);
6204 assert_eq!(store.forget(first).unwrap(), forgotten(&[]));
6205 assert_eq!(snapshot(&dir), before);
6206
6207 let words = "one id on two shelves";
6210 let twice = store.seal(&text(words), "").unwrap();
6211 let name = archive_name(&twice.id);
6212 std::fs::create_dir(&newer).unwrap();
6213 std::fs::copy(older.join(&name), newer.join(&name)).unwrap();
6214 assert_eq!(store.search(words, 8).unwrap().len(), 2);
6215 assert_eq!(store.forget(&twice.id).unwrap(), forgotten(&[&twice.id, &twice.id]));
6216 for shelf in [&older, &newer] {
6217 assert!(is_tombstone_of(&store, &shelf.join(&name), &digest_of(words)));
6218 }
6219 gone(words);
6220
6221 let words = "in a slot and on a shelf";
6223 let slot = seal_legacy_slot(&store, 0, &text(words), "");
6224 let digest = digest_of(words);
6225 let copy = record_bytes(&store, &digest[32..], &text(words), |_| {});
6226 std::fs::write(older.join(archive_name(&digest[32..])), copy).unwrap();
6227 let done = store.forget(&slot.id).unwrap();
6229 assert_eq!(done, forgotten(&[&slot.id, &digest[32..]]));
6230 assert!(is_tombstone_of(&store, &dir.join("000.json"), &digest));
6231 gone(words);
6232 assert!(temporary_files(&dir).is_empty());
6233 }
6234
6235 #[test]
6239 fn a_forget_leaves_a_tombstone_where_a_seal_looks() {
6240 let (tmp, store) = setup();
6241 let dir = directory(&tmp, &store);
6242 let shelf = dir.join("s000001");
6243 let imported = "0123456789abcdef0123456789abcdef";
6244 let words = "only in a slot";
6246 let digest = digest_of(words);
6247 let slot = seal_legacy_slot(&store, 0, &text(words), "");
6248 assert_eq!(store.forget(&slot.id).unwrap(), forgotten(&[&slot.id]));
6249 let guard = shelf.join(archive_name(&digest[..32]));
6250 assert!(is_tombstone_of(&store, &guard, &digest));
6251 assert!(is_tombstone_of(&store, &dir.join("000.json"), &digest));
6252 std::fs::write(dir.join("000.json"), b"{\"version\":1,").unwrap();
6254 let refused = store.seal(&text(words), "").unwrap_err();
6255 assert_eq!(refused.kind(), io::ErrorKind::PermissionDenied);
6256 std::fs::remove_file(dir.join("000.json")).unwrap();
6257
6258 let words = "under an imported id";
6261 let digest = digest_of(words);
6262 let file = shelf.join(archive_name(imported));
6263 std::fs::write(&file, record_bytes(&store, imported, &text(words), |_| {})).unwrap();
6264 std::fs::write(shelf.join(archive_name(&digest[..32])), b"not a record").unwrap();
6265 assert_eq!(store.fetch(imported, 0, 1).unwrap(), text(words));
6266 let failing = Fault::on("rename", imported, libc::EIO);
6270 assert_eq!(store.forget(imported).unwrap_err().raw_os_error(), Some(libc::EIO));
6271 drop(failing);
6272 let guard = shelf.join(archive_name(&digest[32..]));
6273 assert!(is_tombstone_of(&store, &guard, &digest));
6274 assert_eq!(store.fetch(imported, 0, 1).unwrap(), text(words));
6275 let refused = store.seal(&text(words), "").unwrap_err();
6276 assert_eq!(refused.kind(), io::ErrorKind::PermissionDenied);
6277 let files = shelf_files(&dir).len();
6279 let place = format!("1 place(s) could not be read, first s000001/{}: ", archive_name(&digest[..32]));
6282 let done = store.forget(imported).unwrap();
6283 assert_eq!(done.ids, [imported]);
6284 assert!(done.unread.unwrap().starts_with(&place));
6285 assert!(is_tombstone_of(&store, &file, &digest));
6286 assert_eq!(shelf_files(&dir).len(), files);
6287 assert_eq!(std::fs::read(shelf.join(archive_name(&digest[..32]))).unwrap(), b"not a record");
6288 std::fs::remove_file(&guard).unwrap();
6291 let done = store.forget(imported).unwrap();
6292 assert!(done.ids.is_empty());
6293 assert!(done.unread.unwrap().starts_with(&place));
6294 assert!(is_tombstone_of(&store, &guard, &digest));
6295
6296 let native = store.seal(&text("under its own id"), "").unwrap();
6299 let files = shelf_files(&dir).len();
6300 assert_eq!(store.forget(&native.id).unwrap(), forgotten(&[&native.id]));
6301 assert_eq!(shelf_files(&dir).len(), files);
6302 assert!(temporary_files(&dir).is_empty());
6303 }
6304
6305 #[test]
6308 fn a_forget_says_which_places_it_could_not_read() {
6309 let (tmp, store) = setup();
6310 let dir = directory(&tmp, &store);
6311 let (older, newer) = (dir.join("s000001"), dir.join("s000002"));
6312 let words = "one id on two shelves";
6313 let twice = store.seal(&text(words), "").unwrap();
6314 let name = archive_name(&twice.id);
6315 std::fs::create_dir(&newer).unwrap();
6316 std::fs::copy(older.join(&name), newer.join(&name)).unwrap();
6317 let failing = Fault::on("open", "s000001", libc::EIO);
6319 let done = store.forget(&twice.id).unwrap();
6320 drop(failing);
6321 assert_eq!(done.ids, std::slice::from_ref(&twice.id));
6322 let unread = done.unread.unwrap();
6323 assert!(unread.starts_with("1 place(s) could not be read, first s000001: "), "{unread}");
6324 assert!(is_tombstone_of(&store, &newer.join(&name), &digest_of(words)));
6325 let rows = store.search(words, 8).unwrap();
6326 assert_eq!(rows.len(), 1, "the copy that was not reached is still found");
6327 assert_eq!(store.forget(&twice.id).unwrap(), forgotten(&[&twice.id]));
6329 assert!(store.search(words, 8).unwrap().is_empty());
6330
6331 let words = "beside a file with two names";
6334 let digest = digest_of(words);
6335 let sealed = store.seal(&text(words), "").unwrap();
6336 let linked = newer.join(archive_name(&digest[32..]));
6337 std::fs::hard_link(newer.join(archive_name(&sealed.id)), tmp.path().join("a-second-name")).unwrap();
6338 std::fs::write(&linked, b"x").unwrap();
6339 std::fs::hard_link(&linked, tmp.path().join("another-second-name")).unwrap();
6340 let error = store.forget(&sealed.id).unwrap_err();
6341 assert_eq!(error.kind(), io::ErrorKind::PermissionDenied, "its own file has two names");
6342 std::fs::remove_file(tmp.path().join("a-second-name")).unwrap();
6343 let done = store.forget(&sealed.id).unwrap();
6344 assert_eq!(done.ids, std::slice::from_ref(&sealed.id));
6345 let place = format!("first s000002/{}: ", archive_name(&digest[32..]));
6346 assert!(done.unread.unwrap().contains(&place));
6347 assert_eq!(std::fs::read(&linked).unwrap(), b"x");
6348 }
6349
6350 #[test]
6355 fn a_lookup_by_id_passes_over_a_file_that_is_not_a_record_to_an_older_shelf() {
6356 let (tmp, store) = setup();
6357 let dir = directory(&tmp, &store);
6358 let (older, newer) = (dir.join("s000001"), dir.join("s000002"));
6359 std::fs::create_dir(&older).unwrap();
6360 std::fs::create_dir(&newer).unwrap();
6361 std::fs::write(tmp.path().join("elsewhere"), b"not in the store").unwrap();
6362 let base = tmp.path();
6363 for link in [false, true] {
6364 let words = format!("behind an odd file of its name, link {link}");
6365 let digest = digest_of(&words);
6366 let sealed = store.seal_with_note(&text(&words), "its note").unwrap().0;
6367 let name = archive_name(&sealed.id);
6368 assert_eq!(sealed.id, digest[..32]);
6369 std::fs::rename(newer.join(&name), older.join(&name)).unwrap();
6370 if link {
6371 symlink(tmp.path().join("elsewhere"), newer.join(&name)).unwrap();
6372 } else {
6373 std::fs::write(newer.join(&name), b"not a record").unwrap();
6374 }
6375 assert_eq!(store.fetch(&sealed.id, 0, 1).unwrap(), text(&words));
6377 assert_eq!(store.fetch_note(&sealed.id).unwrap(), "its note");
6378 let file = archive_file(base, "synthetic-project", &sealed.id).unwrap();
6379 assert_eq!(file, Some(older.join(&name)));
6380 let done = store.forget(&sealed.id).unwrap();
6382 assert_eq!(done.ids, std::slice::from_ref(&sealed.id));
6383 let unread = done.unread.unwrap();
6384 let place = format!("1 place(s) could not be read, first s000002/{name}: ");
6385 assert!(unread.starts_with(&place), "{unread}");
6386 assert!(is_tombstone_of(&store, &older.join(&name), &digest));
6387 let kind = std::fs::symlink_metadata(newer.join(&name)).unwrap().file_type();
6388 assert_eq!(kind.is_symlink(), link, "the odd file is left as it is");
6389 let error = store.fetch(&sealed.id, 0, 1).unwrap_err();
6390 assert_eq!(error.kind(), io::ErrorKind::NotFound);
6391 let before = snapshot(&dir);
6392 let refused = store.seal(&text(&words), "").unwrap_err();
6393 assert_eq!(refused.kind(), io::ErrorKind::PermissionDenied);
6394 assert_eq!(snapshot(&dir), before);
6395 assert!(store.search(&words, 8).unwrap().is_empty());
6396 }
6397
6398 let lone = "0123456789abcdef0123456789abcdef";
6401 std::fs::write(newer.join(archive_name(lone)), b"not a record").unwrap();
6402 std::fs::write(older.join(archive_name(lone)), b"{}").unwrap();
6403 let named = format!(
6404 "the newest file under this id is not its record: s000002/{}: invalid archive record",
6405 archive_name(lone)
6406 );
6407 for error in [store.fetch(lone, 0, 1).unwrap_err(), store.forget(lone).unwrap_err()] {
6408 assert_eq!(error.kind(), io::ErrorKind::InvalidData);
6409 assert_eq!(error.to_string(), named);
6410 }
6411 assert!(archive_file(base, "synthetic-project", lone).is_err());
6412 let words = "behind a file that cannot be read now";
6414 let sealed = store.seal(&text(words), "").unwrap();
6415 let name = archive_name(&sealed.id);
6416 std::fs::copy(newer.join(&name), older.join(&name)).unwrap();
6417 let failing = Fault::on("open", &sealed.id, libc::EIO);
6418 assert_eq!(store.fetch(&sealed.id, 0, 1).unwrap_err().raw_os_error(), Some(libc::EIO));
6419 assert_eq!(store.forget(&sealed.id).unwrap_err().raw_os_error(), Some(libc::EIO));
6420 drop(failing);
6421
6422 let words = "behind a damaged tombstone";
6426 let digest = digest_of(words);
6427 let sealed = store.seal(&text(words), "").unwrap();
6428 let name = archive_name(&sealed.id);
6429 std::fs::rename(newer.join(&name), older.join(&name)).unwrap();
6430 let damaged = record_bytes(&store, &sealed.id, &text(words), |record| {
6431 record.body = Body::Tombstone;
6432 record.version = 2;
6433 });
6434 std::fs::write(newer.join(&name), damaged).unwrap();
6435 assert_eq!(store.fetch(&sealed.id, 0, 1).unwrap_err().kind(), io::ErrorKind::NotFound);
6436 assert_eq!(store.fetch_note(&sealed.id).unwrap_err().kind(), io::ErrorKind::NotFound);
6437 assert!(archive_file(base, "synthetic-project", &sealed.id).is_err());
6438 assert_eq!(store.forget(&sealed.id).unwrap(), forgotten(&[&sealed.id]));
6439 assert!(is_tombstone_of(&store, &older.join(&name), &digest));
6440 }
6441
6442 #[test]
6447 fn a_forget_is_not_complete_over_a_damaged_copy_under_the_windows_name() {
6448 let (tmp, store) = setup();
6449 let dir = directory(&tmp, &store);
6450 let shelf = dir.join("s000001");
6451 let words = "a window with a damaged copy";
6452 let digest = digest_of(words);
6453 let (first, second) = (&digest[..32], &digest[32..]);
6454 assert_eq!(store.seal(&text(words), "").unwrap().id, first);
6455 let copy = record_bytes(&store, second, &text(words), |_| {});
6457 let damaged = ©[..copy.len() - 2];
6458 assert!(String::from_utf8_lossy(damaged).contains(words));
6459 let file = shelf.join(archive_name(second));
6460 std::fs::write(&file, damaged).unwrap();
6461
6462 let done = store.forget(first).unwrap();
6463 assert_eq!(done.ids, [first]);
6464 assert_ne!(done, forgotten(&[first]), "not a plain 'forgot'");
6465 let unread = done.unread.unwrap();
6466 let place = format!("1 place(s) could not be read, first s000001/{}: ", archive_name(second));
6467 assert!(unread.starts_with(&place), "{unread}");
6468 assert!(is_tombstone_of(&store, &shelf.join(archive_name(first)), &digest));
6469 assert_eq!(std::fs::read(&file).unwrap(), damaged, "left as it is");
6470 assert!(store.forget(first).unwrap().unread.is_some());
6472
6473 let other = record_bytes(&store, second, &text("another window"), |_| {});
6477 std::fs::write(&file, other).unwrap();
6478 assert_eq!(store.forget(first).unwrap(), forgotten(&[]));
6479 let tombstone = record_bytes(&store, second, &text(words), |record| {
6480 record.body = Body::Tombstone;
6481 record.version = 2;
6482 });
6483 std::fs::write(&file, tombstone).unwrap();
6484 assert_eq!(store.forget(first).unwrap(), forgotten(&[]));
6485 }
6486
6487 const PASSING: [i32; 5] = [libc::EMFILE, libc::ENFILE, libc::ENOMEM, libc::EINTR, libc::EAGAIN];
6488
6489 #[test]
6494 fn a_passing_error_on_a_shelf_or_a_slot_fails_the_seal_and_changes_nothing() {
6495 let (tmp, store) = setup();
6496 let dir = directory(&tmp, &store);
6497 let slot = seal_legacy_slot(&store, 0, &text("in a slot"), "");
6498 store.forget(&slot.id).unwrap();
6499 std::fs::remove_file(only_archive_file(&dir)).unwrap();
6502 let kept = store.seal(&text("kept"), "").unwrap();
6503 let gone = store.seal(&text("forgotten"), "").unwrap();
6504 store.forget(&gone.id).unwrap();
6505 std::fs::create_dir(dir.join("s000002")).unwrap();
6507 let before = snapshot(&dir);
6508 let entries = || std::fs::read_dir(&dir).unwrap().count();
6509 let listed = entries();
6510 let refused = |words: &str, code: i32| {
6511 let error = store.seal(&text(words), "").unwrap_err();
6512 assert_eq!(error.raw_os_error(), Some(code), "{words}: {error}");
6513 };
6514 for code in PASSING {
6515 assert!(PrivateDir::passing(&io::Error::from_raw_os_error(code)));
6516 for place in ["s000001", "s000002", "s", "000.json"] {
6518 let failing = Fault::on("open", place, code);
6519 if place != "000.json" {
6521 refused("kept", code);
6522 refused("forgotten", code);
6523 }
6524 refused("in a slot", code);
6525 refused("something new", code);
6526 drop(failing);
6527 assert_eq!(snapshot(&dir), before, "{place}, error {code}");
6528 assert_eq!(entries(), listed, "{place}, error {code}: a shelf was started");
6529 }
6530 }
6531 assert_eq!(store.seal(&text("kept"), "").unwrap(), kept);
6533 for words in ["forgotten", "in a slot"] {
6534 let error = store.seal(&text(words), "").unwrap_err();
6535 assert_eq!(error.kind(), io::ErrorKind::PermissionDenied, "{words}");
6536 }
6537 assert_eq!(snapshot(&dir), before);
6538 }
6539
6540 #[test]
6543 fn an_io_error_on_a_shelf_or_a_slot_is_passed_over() {
6544 let (tmp, store) = setup();
6545 let dir = directory(&tmp, &store);
6546 seal_legacy_slot(&store, 0, &text("in a slot"), "");
6547 let kept = store.seal(&text("kept"), "").unwrap();
6548 for code in [libc::EIO, libc::EACCES, libc::ENOTDIR, libc::ELOOP, libc::ESTALE] {
6549 assert!(!PrivateDir::passing(&io::Error::from_raw_os_error(code)));
6550 }
6551 std::fs::create_dir(dir.join("s000002")).unwrap();
6553 let failing = Fault::on("open", "s000001", libc::EIO);
6554 let new = store.seal(&text("something new"), "").unwrap();
6555 assert!(dir.join("s000002").join(archive_name(&new.id)).is_file());
6556 assert_eq!(store.seal(&text("kept"), "").unwrap(), kept);
6558 assert!(dir.join("s000002").join(archive_name(&kept.id)).is_file());
6559 drop(failing);
6560 let failing = Fault::on("open", "s000002", libc::EIO);
6562 let next = store.seal(&text("after the newest"), "").unwrap();
6563 assert!(dir.join("s000003").join(archive_name(&next.id)).is_file());
6564 drop(failing);
6565 let _failing = Fault::on("open", "000.json", libc::EIO);
6567 let again = store.seal(&text("in a slot"), "").unwrap();
6568 assert!(dir.join("s000003").join(archive_name(&again.id)).is_file());
6569 }
6570
6571 #[test]
6574 fn a_failed_attempt_leaves_at_most_one_empty_shelf_and_the_next_seal_uses_it() {
6575 let (tmp, store) = setup();
6576 let dir = directory(&tmp, &store);
6577 store.seal(&text("a lock to begin with"), "").unwrap();
6578 std::fs::write(dir.join("s000002"), b"not a directory").unwrap();
6581 let shelves = || {
6582 let mut names: Vec<_> = std::fs::read_dir(&dir).unwrap().collect();
6583 names.retain(|entry| entry.as_ref().unwrap().file_name() != "lock");
6584 names.len()
6585 };
6586 let failing = Fault::on("open", "s000003", libc::EMFILE);
6587 for _ in 0..3 {
6588 let error = store.seal(&text("after the trouble"), "").unwrap_err();
6589 assert_eq!(error.raw_os_error(), Some(libc::EMFILE));
6590 assert_eq!(shelves(), 3, "s000001, the file s000002 and the empty s000003");
6591 }
6592 assert_eq!(std::fs::read_dir(dir.join("s000003")).unwrap().count(), 0);
6593 drop(failing);
6594 let sealed = store.seal(&text("after the trouble"), "").unwrap();
6595 assert!(dir.join("s000003").join(archive_name(&sealed.id)).is_file());
6596 assert_eq!(shelves(), 3);
6597 }
6598
6599 #[test]
6600 fn multi_mib_context_roundtrips_without_truncation() {
6601 let (_tmp, store) = setup();
6602 let text = "eligible exact evidence\n".repeat(400_000);
6603 assert!(text.len() > 8 * 1024 * 1024);
6604 let messages = vec![msg("user", json!(text))];
6605 let reference = store.seal(&messages, "large source window").unwrap();
6606 assert_eq!(store.fetch(&reference.id, 0, 1).unwrap(), messages);
6607 assert_eq!(store.seal(&messages, "retry").unwrap(), reference);
6608 }
6609
6610 #[test]
6611 fn private_reasoning_disguised_as_assistant_text_is_omitted() {
6612 let (_tmp, store) = setup();
6613 let messages = vec![
6614 Arc::new(json!({"role":"assistant","channel":"analysis","content":"private analysis"})),
6615 Arc::new(
6616 json!({"role":"assistant","content_class":"private_reasoning","content":"private reasoning"}),
6617 ),
6618 Arc::new(json!({"role":"assistant","private":true,"content":"private text"})),
6619 Arc::new(json!({"role":"assistant","channel":"final","content":"public evidence"})),
6620 ];
6621 let reference = store.seal(&messages, "").unwrap();
6622 assert_eq!(reference.message_count, 1);
6623 let fetched = store.fetch_with_provenance(&reference.id, 0, 1).unwrap();
6624 assert_eq!(fetched[0].source_index, 3);
6625 assert_eq!(fetched[0].message["content"], "public evidence");
6626 }
6627
6628 #[test]
6629 fn after_redaction_requires_host_redactor_including_parent_class() {
6630 let (_tmp, store) = setup();
6631 let messages = vec![Arc::new(json!({
6632 "role":"assistant", "disclosure":"model_visible_after_redaction",
6633 "content":[{"type":"text", "text":"sensitive host classified material"}]
6634 }))];
6635 let reference = store.seal(&messages, "").unwrap();
6636 let fetched = store.fetch(&reference.id, 0, 1).unwrap();
6637 assert_eq!(fetched[0]["content"][0]["text"], WITHHELD);
6638 }
6639
6640 #[test]
6641 fn bounded_search_utf8() {
6642 let (_tmp, store) = setup();
6643 for index in 0..MAX_SEARCH_RESULTS + 8 {
6644 let words = format!("{index} {}", "é".repeat(200));
6645 store.seal(&text(&words), "").unwrap();
6646 }
6647 let descriptors = store.search("", MAX_SEARCH_RESULTS).unwrap();
6648 assert_eq!(descriptors.len(), MAX_SEARCH_RESULTS);
6649 assert!(descriptors.iter().all(|d| d.snippet.len() <= 256));
6650 }
6651
6652 #[test]
6653 fn seal_129_succeed() {
6654 let (tmp, store) = setup();
6655 let dir = directory(&tmp, &store);
6656 let seal = |index: usize| store.seal(&text(&format!("archive {index}")), "").unwrap();
6657 let mut ids = vec![seal(0).id];
6658 let first = only_archive_file(&dir);
6659 let bytes = std::fs::read(&first).unwrap();
6660 ids.extend((1..=SHELF_FILES).map(|index| seal(index).id));
6661 assert_eq!(ids.len(), 129);
6662 assert_eq!(first, dir.join("s000001").join(archive_name(&ids[0])));
6663 for (index, id) in ids.iter().enumerate() {
6664 assert_eq!(
6665 store.fetch(id, 0, 1).unwrap(),
6666 text(&format!("archive {index}"))
6667 );
6668 }
6669 assert_eq!(std::fs::read(&first).unwrap(), bytes);
6670 let files = shelf_files(&dir);
6672 assert_eq!(files.len(), 129);
6673 assert_eq!(files[128], dir.join("s000002").join(archive_name(&ids[128])));
6674 assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 3, "lock and two shelves");
6675 store.forget(&ids[0]).unwrap();
6677 let next = seal(129);
6678 assert_eq!(shelf_files(&dir).len(), 130);
6679 assert!(dir.join("s000002").join(archive_name(&next.id)).is_file());
6680 }
6681
6682 #[test]
6683 #[ignore = "seals 10,000 archives (30,000 fsyncs): 4 s on tmpfs, far longer on a real disk"]
6684 fn seal_10000_succeed() {
6685 let (tmp, store) = setup();
6686 let dir = directory(&tmp, &store);
6687 let seal = |index: usize| store.seal(&text(&format!("archive {index}")), "").unwrap();
6688 let ids: Vec<_> = (0..10_000).map(|index| seal(index).id).collect();
6689 let shelves = store.shelves().unwrap();
6690 assert_eq!(shelves.len(), 79);
6691 assert_eq!(shelves[0], "s000079");
6692 assert_eq!(shelf_files(&dir).len(), 10_000);
6693 for index in [0, 9_999] {
6694 assert_eq!(
6695 store.fetch(&ids[index], 0, 1).unwrap(),
6696 text(&format!("archive {index}"))
6697 );
6698 }
6699 let started = Instant::now();
6700 store.fetch(&ids[0], 0, 1).unwrap();
6701 eprintln!("fetch of the oldest of 10,000: {:?}", started.elapsed());
6702 }
6703
6704 #[test]
6705 fn no_seal_replaces_a_file() {
6706 let (tmp, store) = setup();
6707 let dir = directory(&tmp, &store);
6708 seal_legacy_slot(&store, 0, &text("legacy kept"), "");
6709 let forgotten = seal_legacy_slot(&store, 1, &text("legacy forgotten"), "");
6710 store.forget(&forgotten.id).unwrap();
6711 let mut before = snapshot(&dir);
6712 assert_eq!(before.len(), 4, "lock, two legacy slots and the forget's tombstone on a shelf");
6713 for index in 0..300 {
6714 store.seal(&text(&format!("archive {index}")), "").unwrap();
6715 let after = snapshot(&dir);
6716 for (path, hash) in &before {
6717 assert_eq!(after.get(path), Some(hash), "seal {index} touched {path:?}");
6718 }
6719 assert_eq!(after.len(), before.len() + 1);
6720 before = after;
6721 }
6722 store.seal(&text("archive 7"), "changed note").unwrap();
6724 assert!(store.seal(&text("legacy forgotten"), "").is_err());
6725 assert_eq!(snapshot(&dir), before);
6726 }
6727
6728 #[test]
6729 #[ignore = "writes and re-reads about 280 MiB: about 55 s in a debug build"]
6730 fn byte_cap_is_gone() {
6731 let (tmp, store) = setup();
6732 let large = |index: usize| {
6733 let filler = "x".repeat(15 * 1024 * 1024 + 512 * 1024);
6734 text(&format!("archive-{index:02} {filler}"))
6735 };
6736 let ids: Vec<_> = (0..18)
6738 .map(|index| store.seal(&large(index), "").unwrap().id)
6739 .collect();
6740 let files = shelf_files(&directory(&tmp, &store));
6741 let stored: u64 = files.iter().map(|f| f.metadata().unwrap().len()).sum();
6742 assert!(stored > SEARCH_SCAN_BYTES as u64 + MAX_SEGMENT_BYTES as u64);
6743 for (index, id) in ids.iter().enumerate() {
6744 let fetched = store.fetch(id, 0, 1).unwrap();
6745 let content = fetched[0]["content"].as_str().unwrap();
6746 assert!(content.starts_with(&format!("archive-{index:02} ")));
6747 assert_eq!(content.len(), large(index)[0]["content"].as_str().unwrap().len());
6748 }
6749 let (rows, stopped) = store.search_scan("archive-00", 1).unwrap();
6751 assert!(rows.is_empty());
6752 assert_eq!(stopped, Some(Unscanned { stopped_after: Some(17), unread: None }));
6753 let (rows, stopped) = store.search_scan("archive-17", 1).unwrap();
6754 assert_eq!((rows[0].id.as_str(), stopped), (ids[17].as_str(), None));
6755 }
6756
6757 #[test]
6758 fn legacy_slots_read_by_new_code() {
6759 let (tmp, store) = setup();
6760 let dir = directory(&tmp, &store);
6761 let a = seal_legacy_slot(&store, 0, &text("legacy alpha"), "note a");
6762 let b = seal_legacy_slot(&store, 1, &text("legacy beta"), "note b");
6763 let c = seal_legacy_slot(&store, 2, &text("legacy gamma"), "note c");
6764 store.forget(&c.id).unwrap();
6765 let slots = ["000.json", "001.json", "002.json"].map(|name| dir.join(name));
6766 assert!(std::fs::read_to_string(&slots[2]).unwrap().contains("tombstone"));
6767 let kept = std::fs::read(&slots[1]).unwrap();
6768 assert_eq!(store.fetch(&a.id, 0, 1).unwrap(), text("legacy alpha"));
6769 assert_eq!(store.fetch_note(&b.id).unwrap(), "note b");
6770 assert_eq!(
6771 store.fetch(&c.id, 0, 1).unwrap_err().kind(),
6772 io::ErrorKind::NotFound
6773 );
6774 let found = |query: &str| -> Vec<String> {
6775 let rows = store.search(query, 10).unwrap();
6776 rows.into_iter().map(|row| row.id).collect()
6777 };
6778 assert_eq!(found("legacy"), [b.id.as_str(), a.id.as_str()]);
6779 assert_eq!(
6780 archive_file(tmp.path(), "synthetic-project", &b.id).unwrap(),
6781 Some(slots[1].clone())
6782 );
6783 store.forget(&a.id).unwrap();
6784 store.forget(&a.id).unwrap();
6785 assert!(std::fs::read_to_string(&slots[0]).unwrap().contains("tombstone"));
6786 assert_eq!(found(""), [b.id.as_str()]);
6787 let shelf = dir.join("s000001");
6790 let guards = ["legacy alpha", "legacy gamma"].map(|words| {
6791 let digest = digest_of(words);
6792 let guard = shelf.join(archive_name(&digest[..32]));
6793 assert!(is_tombstone_of(&store, &guard, &digest), "{words}");
6794 guard
6795 });
6796 let mut guards = guards.to_vec();
6797 guards.sort();
6798 assert_eq!(shelf_files(&dir), guards);
6799 let new = store.seal(&text("after shelves"), "").unwrap();
6801 assert!(shelf.join(archive_name(&new.id)).is_file());
6802 assert_eq!(shelf_files(&dir).len(), 3);
6803 assert_eq!(found(""), [new.id.as_str(), b.id.as_str()]);
6804 assert_eq!(std::fs::read(&slots[1]).unwrap(), kept);
6805 assert!(slots.iter().all(|slot| slot.is_file()));
6806 assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 5);
6807 }
6808
6809 #[test]
6810 fn legacy_full_store_accepts_seal() {
6811 let (tmp, store) = setup();
6812 let dir = directory(&tmp, &store);
6813 let legacy: Vec<_> = (0..MAX_SEGMENTS)
6814 .map(|slot| seal_legacy_slot(&store, slot, &text(&format!("legacy {slot}")), ""))
6815 .collect();
6816 let before = snapshot(&dir);
6817 let new = store.seal(&text("the 129th"), "").unwrap();
6818 assert_eq!(store.fetch(&new.id, 0, 1).unwrap(), text("the 129th"));
6819 assert_eq!(
6820 only_archive_file(&dir),
6821 dir.join("s000001").join(archive_name(&new.id))
6822 );
6823 for slot in [0, MAX_SEGMENTS - 1] {
6824 assert_eq!(
6825 store.fetch(&legacy[slot].id, 0, 1).unwrap(),
6826 text(&format!("legacy {slot}"))
6827 );
6828 }
6829 let after = snapshot(&dir);
6830 assert!(before.iter().all(|(path, hash)| after.get(path) == Some(hash)));
6831 let rows = store.search("", 2).unwrap();
6833 assert_eq!(rows[0].id, new.id);
6834 assert_eq!(rows[1].id, legacy[MAX_SEGMENTS - 1].id);
6835 }
6836
6837 #[test]
6838 fn legacy_duplicate_and_tombstone_still_honoured() {
6839 let (tmp, store) = setup();
6840 let dir = directory(&tmp, &store);
6841 let live = seal_legacy_slot(&store, 0, &text("sealed long ago"), "first note");
6842 let gone = seal_legacy_slot(&store, 1, &text("forgotten long ago"), "");
6843 store.forget(&gone.id).unwrap();
6844 let (again, note) = store
6845 .seal_with_note(&text("sealed long ago"), "second note")
6846 .unwrap();
6847 assert_eq!((again, note.as_str()), (live, "first note"));
6848 assert_eq!(
6849 store.seal(&text("forgotten long ago"), "").unwrap_err().kind(),
6850 io::ErrorKind::PermissionDenied
6851 );
6852 let guard = only_archive_file(&dir);
6855 assert!(is_tombstone_of(&store, &guard, &digest_of("forgotten long ago")));
6856 assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 4, "lock, two slots, one shelf");
6857 }
6858
6859 #[test]
6861 fn legacy_fetch_waits_for_the_lock() {
6862 let (_tmp, mut store) = setup();
6863 let legacy = seal_legacy_slot(&store, 0, &text("sealed long ago"), "old note");
6864 let shelved = store.seal(&text("sealed today"), "").unwrap();
6865 let hold = Duration::from_millis(300);
6866 let started = Instant::now();
6867 let foreign = store.fs.lock().unwrap();
6868 let holder = std::thread::spawn(move || {
6869 std::thread::sleep(hold);
6870 drop(foreign);
6871 });
6872 assert_eq!(store.fetch(&legacy.id, 0, 1).unwrap(), text("sealed long ago"));
6873 assert!(started.elapsed() >= hold);
6874 holder.join().unwrap();
6875
6876 store.process_wait = Duration::from_millis(100);
6878 let _foreign = store.fs.lock().unwrap();
6879 let started = Instant::now();
6880 for result in [
6881 store.fetch(&legacy.id, 0, 1).map(drop),
6882 store.fetch_note(&legacy.id).map(drop),
6883 store.fetch(&"a".repeat(32), 0, 1).map(drop),
6885 store.search("", 2).map(drop),
6887 ] {
6888 let error = result.unwrap_err();
6889 assert_eq!(error.kind(), io::ErrorKind::WouldBlock);
6890 assert_eq!(error.to_string(), BUSY);
6891 }
6892 assert!(started.elapsed() >= store.process_wait * 4);
6893 assert_eq!(store.fetch(&shelved.id, 0, 1).unwrap(), text("sealed today"));
6895 assert_eq!(store.search("", 1).unwrap()[0].id, shelved.id);
6896 }
6897
6898 #[test]
6899 fn duplicate_seal_returns_same_id_across_shelves() {
6900 let (tmp, store) = setup();
6901 let dir = directory(&tmp, &store);
6902 let seal = |index: usize, note: &str| {
6903 let words = format!("archive {index}");
6904 store.seal_with_note(&text(&words), note).unwrap()
6905 };
6906 let first = seal(0, "first note");
6907 for index in 1..=SHELF_FILES {
6908 seal(index, "");
6909 }
6910 let before = snapshot(&dir);
6911 assert_eq!(shelf_files(&dir).len(), 129);
6912 assert_eq!(seal(0, "another note"), first);
6914 assert_eq!(seal(SHELF_FILES, "another note").1, "");
6915 assert_eq!(snapshot(&dir), before);
6916 assert_eq!(first.0.id.len(), 32);
6917 let digest = project_archive("synthetic-logical", &text("archive 0"), "", None)
6918 .unwrap()
6919 .digest;
6920 assert_eq!(first.0.id, digest[..32]);
6921 }
6922
6923 #[test]
6924 fn forgotten_projection_cannot_be_resealed() {
6925 let (tmp, store) = setup();
6926 let dir = directory(&tmp, &store);
6927 let reference = store.seal(&text("forget me"), "note").unwrap();
6928 store.forget(&reference.id).unwrap();
6929 let before = snapshot(&dir);
6930 for note in ["note", "another note"] {
6931 assert_eq!(
6932 store.seal(&text("forget me"), note).unwrap_err().kind(),
6933 io::ErrorKind::PermissionDenied
6934 );
6935 }
6936 assert_eq!(snapshot(&dir), before);
6937 let other = ArchiveStore::new(tmp.path(), "synthetic-project", "other-logical").unwrap();
6939 assert!(other.seal(&text("forget me"), "").is_ok());
6940 assert!(store.seal(&text("remember me"), "").is_ok());
6941 }
6942
6943 #[test]
6944 fn forget_is_in_place_and_idempotent() {
6945 let (tmp, store) = setup();
6946 let dir = directory(&tmp, &store);
6947 let kept = store.seal(&text("keep me"), "").unwrap();
6948 let reference = store.seal(&text("forget me"), "note").unwrap();
6949 let file = dir.join("s000001").join(archive_name(&reference.id));
6950 let neighbour = dir.join("s000001").join(archive_name(&kept.id));
6951 let untouched = snapshot(&dir)[&neighbour].clone();
6952 store.forget(&reference.id).unwrap();
6953 assert_eq!(shelf_files(&dir).len(), 2, "same name, same shelf");
6954 let tombstone = std::fs::read_to_string(&file).unwrap();
6955 assert!(tombstone.contains("tombstone") && !tombstone.contains("forget me"));
6956 let written = file.metadata().unwrap().modified().unwrap();
6957 store.forget(&reference.id).unwrap();
6959 assert_eq!(std::fs::read_to_string(&file).unwrap(), tombstone);
6960 assert_eq!(file.metadata().unwrap().modified().unwrap(), written);
6961 assert!(temporary_files(&dir).is_empty());
6962 assert_eq!(std::fs::read_dir(dir.join("s000001")).unwrap().count(), 2);
6963 assert_eq!(snapshot(&dir)[&neighbour], untouched);
6964 assert_eq!(store.fetch(&kept.id, 0, 1).unwrap(), text("keep me"));
6965 assert_eq!(
6966 store.forget(&"a".repeat(32)).unwrap_err().kind(),
6967 io::ErrorKind::NotFound
6968 );
6969 }
6970
6971 #[test]
6972 fn fetch_reads_one_file() {
6973 let (tmp, store) = setup();
6974 let dir = directory(&tmp, &store);
6975 let ids: Vec<_> = (0..300)
6976 .map(|index| store.seal(&text(&format!("archive {index}")), "note").unwrap().id)
6977 .collect();
6978 let target = dir.join("s000001").join(archive_name(&ids[5]));
6980 for file in shelf_files(&dir) {
6981 if file != target {
6982 std::fs::write(file, b"corrupt").unwrap();
6983 }
6984 }
6985 assert_eq!(store.fetch(&ids[5], 0, 1).unwrap(), text("archive 5"));
6986 assert_eq!(store.fetch_note(&ids[5]).unwrap(), "note");
6987 assert_eq!(store.seal(&text("archive 5"), "").unwrap().id, ids[5]);
6988 assert!(store.seal(&text("archive 300"), "").is_ok());
6989 store.forget(&ids[5]).unwrap();
6990 assert_eq!(
6991 store.fetch(&ids[6], 0, 1).unwrap_err().kind(),
6992 io::ErrorKind::InvalidData
6993 );
6994 }
6995
6996 #[test]
6997 fn search_is_newest_first_and_budgeted() {
6998 let (tmp, store) = setup();
6999 let dir = directory(&tmp, &store);
7000 let epoch = std::time::UNIX_EPOCH + Duration::from_secs(1_000_000_000);
7001 let seal = |index: usize| {
7004 let words = match index {
7005 0 => "needle oldest-only".to_owned(),
7006 _ => format!("needle {index}"),
7007 };
7008 let id = store.seal(&text(&words), "").unwrap().id;
7009 let shelf = dir.join(shelf_name((index / SHELF_FILES + 1) as u64));
7010 let file = std::fs::File::options()
7011 .write(true)
7012 .open(shelf.join(archive_name(&id)))
7013 .unwrap();
7014 file.set_modified(epoch + Duration::from_secs(index as u64))
7015 .unwrap();
7016 id
7017 };
7018 let mut ids: Vec<_> = (0..100).map(&seal).collect();
7019 let (rows, stopped) = store.search_scan("oldest-only", 32).unwrap();
7020 assert_eq!((rows.len(), rows[0].id.as_str(), stopped), (1, ids[0].as_str(), None));
7021
7022 ids.extend((100..300).map(&seal));
7023 let rows = store.search("needle", 32).unwrap();
7024 let newest: Vec<_> = rows.iter().map(|row| row.id.as_str()).collect();
7025 let expected: Vec<_> = ids.iter().rev().take(32).map(String::as_str).collect();
7026 assert_eq!(newest, expected);
7027 assert_eq!(store.search_scan("needle", 32).unwrap().1, None);
7029
7030 ids.extend((300..SEARCH_SCAN_FILES).map(&seal));
7032 let (rows, stopped) = store.search_scan("oldest-only", 32).unwrap();
7033 assert_eq!((rows.len(), stopped), (1, None));
7034 ids.push(seal(SEARCH_SCAN_FILES));
7036 let (rows, stopped) = store.search_scan("oldest-only", 32).unwrap();
7037 assert!(rows.is_empty());
7038 let budget = Unscanned { stopped_after: Some(SEARCH_SCAN_FILES), unread: None };
7039 assert_eq!(stopped, Some(budget));
7040 assert_eq!(store.search("needle", 1).unwrap()[0].id, ids[SEARCH_SCAN_FILES]);
7041 assert_eq!(store.fetch(&ids[0], 0, 1).unwrap(), text("needle oldest-only"));
7042 }
7043
7044 #[test]
7050 fn search_reads_files_with_the_same_modification_time_in_name_order() {
7051 let (tmp, store) = setup();
7052 let shelf = directory(&tmp, &store).join("s000001");
7053 let ids: Vec<_> = (0..8)
7054 .map(|index| store.seal(&text(&format!("tied {index}")), "").unwrap().id)
7055 .collect();
7056 let epoch = std::time::UNIX_EPOCH + Duration::from_secs(1_000_000_000);
7057 let written = |id: &str, seconds: u64| {
7058 let path = shelf.join(archive_name(id));
7059 let file = std::fs::File::options().write(true).open(path).unwrap();
7060 file.set_modified(epoch + Duration::from_secs(seconds)).unwrap();
7061 };
7062 for id in &ids[2..] {
7064 written(id, 100);
7065 }
7066 written(&ids[0], 50);
7067 written(&ids[1], 200);
7068 let mut tied = ids[2..].to_vec();
7069 tied.sort();
7070 assert_ne!(tied, ids[2..], "the order of sealing is not the order of names");
7071 let mut expected = vec![ids[1].clone()];
7072 expected.extend(tied);
7073 expected.push(ids[0].clone());
7074 let order = |limit: usize| -> Vec<String> {
7075 let rows = store.search("tied", limit).unwrap();
7076 rows.into_iter().map(|row| row.id).collect()
7077 };
7078 assert_eq!(order(8), expected);
7079 for limit in 1..8 {
7081 assert_eq!(order(limit), expected[..limit]);
7082 assert_eq!(order(limit), expected[..limit]);
7083 }
7084 let late = store.seal(&text("tied late"), "").unwrap().id;
7087 written(&late, 100);
7088 expected.insert(1 + expected[1..7].partition_point(|id| *id < late), late);
7089 assert_eq!(order(9), expected);
7090 }
7091
7092 #[test]
7093 fn search_skips_a_corrupt_file() {
7094 let (tmp, store) = setup();
7095 let dir = directory(&tmp, &store);
7096 let ids: Vec<_> = ["first", "second", "third"]
7097 .iter()
7098 .map(|words| store.seal(&text(words), "").unwrap().id)
7099 .collect();
7100 let legacy = seal_legacy_slot(&store, 0, &text("legacy first"), "");
7101 seal_legacy_slot(&store, 1, &text("legacy second"), "");
7102 std::fs::write(dir.join("s000001").join(archive_name(&ids[1])), b"corrupt").unwrap();
7103 std::fs::write(dir.join("001.json"), b"{}").unwrap();
7104 let huge = std::fs::File::create(dir.join("s000001").join(archive_name(&"f".repeat(32))));
7106 huge.unwrap().set_len(MAX_SEGMENT_BYTES as u64 + 1).unwrap();
7107 std::fs::write(dir.join("s000001").join("notes.json"), b"stray").unwrap();
7109 std::fs::write(dir.join("s0000001"), b"stray").unwrap();
7110 let (rows, unscanned) = store.search_scan("", 10).unwrap();
7111 let mut found: Vec<_> = rows.into_iter().map(|row| row.id).collect();
7112 found.sort();
7113 let mut expected = vec![ids[0].clone(), ids[2].clone(), legacy.id];
7114 expected.sort();
7115 assert_eq!(found, expected);
7116 let unscanned = unscanned.unwrap();
7119 assert_eq!(unscanned.stopped_after, None);
7120 let unread = unscanned.unread.unwrap();
7121 assert!(unread.starts_with("3 place(s) could not be read, first s000001/"), "{unread}");
7122 assert_eq!(store.search("second", 10).unwrap(), []);
7123 }
7124
7125 #[test]
7128 fn a_search_passes_over_what_it_cannot_read_and_says_so() {
7129 let (tmp, store) = setup();
7130 let dir = directory(&tmp, &store);
7131 let old = store.seal(&text("on the oldest shelf"), "").unwrap();
7132 let slot = seal_legacy_slot(&store, 0, &text("in a slot"), "");
7133 let mut all = vec![old.id.clone(), slot.id.clone()];
7134 let searched = |all: &[String], limit: usize| {
7136 let (rows, unscanned) = store.search_scan("", limit).unwrap();
7137 let mut found: Vec<_> = rows.into_iter().map(|row| row.id).collect();
7138 found.sort();
7139 let mut all = all.to_vec();
7140 all.sort();
7141 if limit >= all.len() {
7142 assert_eq!(found, all);
7143 } else {
7144 assert_eq!(found.len(), limit);
7145 }
7146 match unscanned {
7147 Some(unscanned) => {
7148 assert_eq!(unscanned.stopped_after, None);
7149 unscanned.unread.unwrap()
7150 }
7151 None => String::new(),
7152 }
7153 };
7154 assert_eq!(searched(&all, 8), "");
7155
7156 std::fs::write(dir.join("s000002"), b"not a directory").unwrap();
7158 let unread = searched(&all, 8);
7159 assert!(unread.starts_with("1 place(s) could not be read, first s000002: "), "{unread}");
7160 let new = store.seal(&text("on the newest shelf"), "").unwrap();
7161 let newest = dir.join("s000003");
7162 assert!(newest.join(archive_name(&new.id)).is_file());
7163 all.push(new.id.clone());
7164 std::fs::remove_file(dir.join("s000002")).unwrap();
7166 symlink(&newest, dir.join("s000002")).unwrap();
7167 let unread = searched(&all, 8);
7168 assert!(unread.starts_with("1 place(s) could not be read, first s000002: "), "{unread}");
7169
7170 let victim = tmp.path().join("victim");
7173 std::fs::write(&victim, b"do not change").unwrap();
7174 let odd = |letter: &str| newest.join(archive_name(&letter.repeat(32)));
7175 symlink(&victim, odd("a")).unwrap();
7176 let fifo = std::ffi::CString::new(odd("b").to_str().unwrap()).unwrap();
7177 assert_eq!(unsafe { libc::mkfifo(fifo.as_ptr(), 0o600) }, 0);
7178 std::fs::write(odd("c"), record_bytes(&store, &"c".repeat(32), &text("two names"), |_| {})).unwrap();
7179 std::fs::hard_link(odd("c"), tmp.path().join("a-second-name")).unwrap();
7180 std::fs::write(odd("d"), b"corrupt").unwrap();
7181 let unread = searched(&all, 8);
7182 assert!(unread.starts_with("5 place(s) could not be read, first s000003/"), "{unread}");
7183 symlink(&victim, dir.join("001.json")).unwrap();
7185 let unread = searched(&all, 8);
7186 assert!(unread.starts_with("6 place(s) could not be read, first s000003/"), "{unread}");
7187 let rest = [old.id.clone(), slot.id.clone()];
7190 let failing = Fault::on("open", "s000003", libc::EMFILE);
7191 let unread = searched(&rest, 8);
7192 assert!(unread.starts_with("3 place(s) could not be read, first s000003: "), "{unread}");
7193 let unread = searched(&rest, 1);
7195 assert!(unread.starts_with("2 place(s) could not be read, first s000003: "), "{unread}");
7196 drop(failing);
7197 if unsafe { libc::geteuid() } != 0 {
7199 let _reopen = Reopen(vec![newest.clone()]);
7200 std::fs::set_permissions(&newest, std::fs::Permissions::from_mode(0o000)).unwrap();
7201 let unread = searched(&rest, 8);
7202 assert!(unread.starts_with("3 place(s) could not be read, first s000003: "), "{unread}");
7203 }
7204 assert_eq!(std::fs::read(&victim).unwrap(), b"do not change");
7206 assert!(std::fs::symlink_metadata(odd("a")).unwrap().file_type().is_symlink());
7207 assert_eq!(std::fs::read(odd("d")).unwrap(), b"corrupt");
7208 assert_eq!(store.search("", 8).unwrap().len(), 3);
7210 }
7211
7212 #[test]
7213 fn file_name_must_match_record_id() {
7214 let (tmp, store) = setup();
7215 let dir = directory(&tmp, &store);
7216 let a = store.seal(&text("the original"), "").unwrap();
7217 let b = "b".repeat(32);
7218 let copy = dir.join("s000001").join(archive_name(&b));
7219 std::fs::copy(only_archive_file(&dir), ©).unwrap();
7220 for result in [
7221 store.fetch(&b, 0, 1).map(drop),
7222 store.fetch_note(&b).map(drop),
7223 store.forget(&b).map(drop),
7224 archive_file(tmp.path(), "synthetic-project", &b).map(drop),
7225 ] {
7226 assert_eq!(result.unwrap_err().kind(), io::ErrorKind::InvalidData);
7227 }
7228 let original = dir.join("s000001").join(archive_name(&a.id));
7229 assert_eq!(std::fs::read(©).unwrap(), std::fs::read(original).unwrap());
7230 let rows = store.search("", 10).unwrap();
7232 assert_eq!(rows.len(), 1);
7233 assert_eq!(rows[0].id, a.id);
7234 assert_eq!(store.fetch(&a.id, 0, 1).unwrap(), text("the original"));
7235 }
7236
7237 #[test]
7238 fn shelf_gap_is_tolerated() {
7239 let (tmp, store) = setup();
7240 let dir = directory(&tmp, &store);
7241 let seal = |index: usize| store.seal(&text(&format!("archive {index}")), "").unwrap().id;
7242 let ids: Vec<_> = (0..2 * SHELF_FILES + 1).map(seal).collect();
7243 assert_eq!(store.shelves().unwrap(), ["s000003", "s000002", "s000001"]);
7244 std::fs::remove_dir_all(dir.join("s000002")).unwrap();
7245 assert_eq!(store.fetch(&ids[256], 0, 1).unwrap(), text("archive 256"));
7246 assert_eq!(store.fetch(&ids[0], 0, 1).unwrap(), text("archive 0"));
7247 assert_eq!(
7248 store.fetch(&ids[200], 0, 1).unwrap_err().kind(),
7249 io::ErrorKind::NotFound
7250 );
7251 let next = seal(1_000);
7253 assert!(dir.join("s000003").join(archive_name(&next)).is_file());
7254 assert_eq!(store.shelves().unwrap(), ["s000003", "s000001"]);
7255 assert_eq!(store.search("", 1).unwrap().len(), 1);
7256 }
7257
7258 #[test]
7259 fn crash_leftovers() {
7260 let (tmp, store) = setup();
7261 let dir = directory(&tmp, &store);
7262 let first = store.seal(&text("before the crash"), "").unwrap();
7263 let empty = dir.join("s000002");
7267 std::fs::create_dir(&empty).unwrap();
7268 let stale = [empty.join(temp_name()), empty.join("pending.tmp")];
7269 let fresh = [empty.join(temp_name()), empty.join(temp_name())];
7272 for path in stale.iter().chain(&fresh) {
7273 std::fs::write(path, b"unpublished partial").unwrap();
7274 }
7275 let minute = Duration::from_secs(60);
7276 written_ago(&stale[0], STALE_TEMP + minute);
7277 written_ago(&stale[1], STALE_TEMP * 24);
7278 written_ago(&fresh[1], STALE_TEMP - minute);
7279 assert_eq!(store.search("", 10).unwrap().len(), 1);
7281 assert_eq!(temporary_files(&dir).len(), 4);
7282 let next = store.seal(&text("after the crash"), "").unwrap();
7283 assert!(empty.join(archive_name(&next.id)).is_file());
7284 assert!(stale.iter().all(|path| !path.exists()));
7286 for path in &fresh {
7287 assert_eq!(std::fs::read(path).unwrap(), b"unpublished partial");
7288 }
7289 assert_eq!(temporary_files(&dir).len(), 2);
7290 assert_eq!(store.fetch(&first.id, 0, 1).unwrap(), text("before the crash"));
7291 assert_eq!(store.fetch(&next.id, 0, 1).unwrap(), text("after the crash"));
7292 assert_eq!(store.search("", 10).unwrap().len(), 2);
7293 store.forget(&next.id).unwrap();
7295 assert_eq!(temporary_files(&dir).len(), 2);
7296 }
7297
7298 #[test]
7303 fn a_temporary_name_that_is_a_directory_a_fifo_or_a_link_is_left_alone() {
7304 use std::os::unix::fs::FileTypeExt;
7305 fn aged(path: &Path) {
7308 let then = std::time::SystemTime::now() - STALE_TEMP * 2;
7309 let since = then.duration_since(std::time::UNIX_EPOCH).unwrap();
7310 let mut time: libc::timespec = unsafe { std::mem::zeroed() };
7311 time.tv_sec = since.as_secs() as libc::time_t;
7312 let name = std::ffi::CString::new(path.to_str().unwrap()).unwrap();
7313 let (times, flags) = ([time; 2], libc::AT_SYMLINK_NOFOLLOW);
7314 let set = unsafe { libc::utimensat(libc::AT_FDCWD, name.as_ptr(), times.as_ptr(), flags) };
7315 assert_eq!(set, 0);
7316 }
7317 let (tmp, store) = setup();
7318 let dir = directory(&tmp, &store);
7319 let first = store.seal(&text("first"), "").unwrap();
7320 let legacy = seal_legacy_slot(&store, 0, &text("legacy"), "");
7321 let victim = tmp.path().join("synthetic-victim");
7322 std::fs::write(&victim, "do not change").unwrap();
7323 written_ago(&victim, STALE_TEMP * 2);
7324 let unchanged = snapshot(tmp.path());
7325 let places = [dir.join("s000001").join("pending.tmp"), dir.join("pending.tmp")];
7328 let kind = |path: &Path| std::fs::symlink_metadata(path).unwrap().file_type();
7329 for shape in ["directory", "FIFO", "symlink", "hard link"] {
7330 for path in &places {
7331 match shape {
7332 "directory" => std::fs::create_dir(path).unwrap(),
7333 "FIFO" => {
7334 let name = std::ffi::CString::new(path.to_str().unwrap()).unwrap();
7335 assert_eq!(unsafe { libc::mkfifo(name.as_ptr(), 0o600) }, 0);
7336 }
7337 "symlink" => symlink(&victim, path).unwrap(),
7338 _ => std::fs::hard_link(&victim, path).unwrap(),
7339 }
7340 aged(path);
7341 }
7342 let words = format!("sealed beside a {shape}");
7343 let sealed = store.seal(&text(&words), "").unwrap();
7344 assert_eq!(store.fetch(&sealed.id, 0, 1).unwrap(), text(&words), "{shape}");
7345 assert_eq!(store.fetch(&first.id, 0, 1).unwrap(), text("first"), "{shape}");
7346 assert_eq!(store.search(&words, 2).unwrap().len(), 1, "{shape}");
7347 store.forget(&sealed.id).unwrap();
7348 let old = seal_legacy_slot(&store, 1, &text(&words), "");
7350 store.forget(&old.id).unwrap();
7351 assert_eq!(store.fetch(&legacy.id, 0, 1).unwrap(), text("legacy"), "{shape}");
7352 std::fs::remove_file(dir.join("001.json")).unwrap();
7353 for path in &places {
7354 let found = kind(path);
7355 match shape {
7356 "directory" => assert!(found.is_dir()),
7357 "FIFO" => assert!(found.is_fifo()),
7358 "symlink" => assert!(found.is_symlink()),
7359 _ => assert_eq!(identity(path), identity(&victim)),
7362 }
7363 if found.is_dir() {
7364 std::fs::remove_dir(path).unwrap();
7365 } else {
7366 std::fs::remove_file(path).unwrap();
7367 }
7368 }
7369 assert_eq!(std::fs::read_to_string(&victim).unwrap(), "do not change");
7370 }
7371 let after = snapshot(tmp.path());
7373 assert!(unchanged.iter().all(|(path, file)| after.get(path) == Some(file)));
7374 assert_eq!(after.len(), unchanged.len() + 4);
7375 assert!(temporary_files(&dir).is_empty());
7376 }
7377
7378 #[test]
7381 fn publish_create_new_never_replaces() {
7382 let (tmp, store) = setup();
7383 let shelf_path = directory(&tmp, &store).join("s000001");
7384 let shelf = store.fs.child("s000001", true).unwrap();
7385 let name = archive_name(&"a".repeat(32));
7386 let path = shelf_path.join(&name);
7387 let victim = tmp.path().join("synthetic-victim");
7388 std::fs::write(&victim, "do not change").unwrap();
7389 shelf.publish(&name, b"first", true).unwrap();
7390 let first = identity(&path);
7391 let refused = |bytes: &[u8]| {
7392 let error = shelf.publish(&name, bytes, true).unwrap_err();
7393 assert_eq!(error.kind(), io::ErrorKind::AlreadyExists);
7394 assert_eq!(std::fs::read_dir(&shelf_path).unwrap().count(), 1, "no temporary file");
7395 };
7396 for bytes in [&b"first"[..], b"second"] {
7397 refused(bytes);
7398 assert_eq!(std::fs::read(&path).unwrap(), b"first");
7399 assert_eq!(identity(&path), first, "replaced by a file with the same bytes");
7400 }
7401 std::fs::remove_file(&path).unwrap();
7403 for target in [victim.clone(), tmp.path().join("missing")] {
7404 symlink(&target, &path).unwrap();
7405 let link = identity(&path);
7406 refused(b"through the link");
7407 assert_eq!(identity(&path), link);
7408 assert!(!tmp.path().join("missing").exists());
7409 std::fs::remove_file(&path).unwrap();
7410 }
7411 assert_eq!(std::fs::read_to_string(&victim).unwrap(), "do not change");
7412 std::fs::create_dir(&path).unwrap();
7413 refused(b"over the directory");
7414 assert!(path.is_dir());
7415 std::fs::remove_dir(&path).unwrap();
7416 let from = c_name(&temp_name()).unwrap();
7419 let to = c_name(&name).unwrap();
7420 let temporary = shelf_path.join(from.to_str().unwrap());
7421 std::fs::write(&temporary, b"probed").unwrap();
7422 let written = identity(&temporary);
7423 shelf.rename_probed(&from, &to).unwrap();
7424 assert_eq!((identity(&path), temporary.exists()), (written, false));
7425 std::fs::write(&temporary, b"probed again").unwrap();
7426 let error = shelf.rename_probed(&from, &to).unwrap_err();
7427 assert_eq!(error.kind(), io::ErrorKind::AlreadyExists);
7428 assert_eq!(identity(&path), written);
7429 assert_eq!(std::fs::read(&temporary).unwrap(), b"probed again");
7430 shelf.publish(&name, b"tombstone", false).unwrap();
7432 assert_ne!(identity(&path), written);
7433 assert_eq!(std::fs::read(&path).unwrap(), b"tombstone");
7434 }
7435
7436 #[test]
7437 fn archive_file_returns_path_and_creates_nothing() {
7438 let tmp = tempfile::tempdir().unwrap();
7439 let unknown = "a".repeat(32);
7440 for base in [tmp.path().join("missing"), tmp.path().to_path_buf()] {
7441 assert_eq!(archive_file(&base, "synthetic-project", &unknown).unwrap(), None);
7442 }
7443 assert_eq!(std::fs::read_dir(tmp.path()).unwrap().count(), 0);
7444 let store =
7445 ArchiveStore::new(tmp.path(), "synthetic-project", "synthetic-logical").unwrap();
7446 let dir = directory(&tmp, &store);
7447 let reference = store.seal(&text("where am I"), "").unwrap();
7448 let file = only_archive_file(&dir);
7449 let locate = |project: &str, id: &str| archive_file(tmp.path(), project, id);
7450 let held = store.fs.lock().unwrap();
7452 assert_eq!(locate("synthetic-project", &reference.id).unwrap(), Some(file.clone()));
7453 drop(held);
7454 std::fs::remove_file(dir.join("lock")).unwrap();
7455 let before = snapshot(tmp.path());
7456 assert_eq!(locate("synthetic-project", &reference.id).unwrap(), Some(file.clone()));
7457 assert_eq!(locate("synthetic-project", &unknown).unwrap(), None);
7458 assert_eq!(locate("another-project", &reference.id).unwrap(), None);
7459 assert_eq!(
7460 locate("synthetic-project", "../s000001/x").unwrap_err().kind(),
7461 io::ErrorKind::InvalidInput
7462 );
7463 assert_eq!(snapshot(tmp.path()), before);
7464 assert_eq!(std::fs::read_dir(tmp.path().join("context-archives")).unwrap().count(), 1);
7465 store.forget(&reference.id).unwrap();
7467 assert_eq!(locate("synthetic-project", &reference.id).unwrap(), Some(file));
7468 }
7469
7470 fn exact(store: &ArchiveStore, messages: &[SharedMessage]) -> Vec<ArchivedMessage> {
7473 let mut expected = Vec::new();
7474 for (index, message) in messages.iter().enumerate() {
7475 let one = std::slice::from_ref(message);
7476 let redactor = store.redactor.clone();
7477 let alone = project_hashed(&store.logical, one, "", redactor, Reduce::None).unwrap();
7478 expected.extend(alone.messages.into_iter().map(|mut archived| {
7479 archived.source_index = index;
7480 archived
7481 }));
7482 }
7483 expected
7484 }
7485 fn reassemble(
7489 store: &ArchiveStore,
7490 window: &SealedWindow,
7491 len: usize,
7492 ) -> Vec<ArchivedMessage> {
7493 let mut messages = Vec::new();
7494 let mut next = 0;
7495 for part in &window.parts {
7496 let ArchiveRef {
7497 id,
7498 message_count,
7499 source_message_count,
7500 } = &part.archive;
7501 assert_eq!(part.first_source_index, next);
7502 assert!(*source_message_count <= MAX_MESSAGES);
7503 next += source_message_count;
7504 let mut start = 0;
7505 while start < *message_count {
7506 let page = store
7507 .fetch_with_provenance(id, start, MAX_FETCH_MESSAGES)
7508 .unwrap();
7509 assert!(!page.is_empty());
7510 start += page.len();
7511 messages.extend(page.into_iter().map(|mut archived| {
7512 assert!(archived.source_index < *source_message_count);
7513 archived.source_index += part.first_source_index;
7514 archived
7515 }));
7516 }
7517 assert_eq!(start, *message_count);
7518 }
7519 assert_eq!(next, len, "the parts cover the window");
7520 messages
7521 }
7522 fn assert_whole(read: &[ArchivedMessage], messages: &[SharedMessage]) {
7525 assert_eq!(read.len(), messages.len());
7526 for (index, archived) in read.iter().enumerate() {
7527 assert_eq!(archived.source_index, index);
7528 assert!(archived.message == messages[index], "message {index} differs");
7529 }
7530 }
7531 fn lines(len: usize) -> Vec<SharedMessage> {
7532 (0..len)
7533 .map(|index| msg("user", json!(format!("line {index} of {len}"))))
7534 .collect()
7535 }
7536 fn deep_input() -> Value {
7538 let mut deep = json!("leaf");
7539 for _ in 0..40 {
7540 deep = json!([deep]);
7541 }
7542 deep
7543 }
7544 const OVER_LIMIT: &str = "over the per-archive limit]";
7545
7546 #[test]
7547 fn window_within_caps_is_one_part_identical_to_seal() {
7548 let messages = vec![
7549 msg("system", json!("not eligible")),
7550 msg("user", json!("a question")),
7551 msg("assistant", json!([{"type":"text","text":"an answer"}])),
7552 ];
7553 let (strict_tmp, strict) = setup();
7554 let (tmp, store) = setup();
7555 let (reference, note) = strict.seal_with_note(&messages, "the note").unwrap();
7556 let window = store.seal_window(&messages, "the note").unwrap();
7557 let part = ArchivePart {
7558 archive: reference.clone(),
7559 first_source_index: 0,
7560 reduced_messages: 0,
7561 };
7562 let expected = SealedWindow {
7563 parts: vec![part],
7564 note,
7565 };
7566 assert_eq!(window, expected);
7567 let undated = |dir: PathBuf| {
7569 let mut record: Value = serde_json::from_slice(&std::fs::read(dir).unwrap()).unwrap();
7570 assert!(record["created_ms"].take().is_u64());
7571 record
7572 };
7573 assert_eq!(
7574 undated(only_archive_file(&directory(&tmp, &store))),
7575 undated(only_archive_file(&directory(&strict_tmp, &strict)))
7576 );
7577 assert_eq!(strict.seal_window(&messages, "another note").unwrap(), window);
7579 assert_eq!(
7580 store.seal_with_note(&messages, "another note").unwrap(),
7581 (reference, "the note".to_owned())
7582 );
7583 let empty = store.seal_window(&[], "").unwrap();
7585 assert_eq!(empty.parts.len(), 1);
7586 assert_eq!(empty.parts[0].archive, store.seal(&[], "").unwrap());
7587 }
7588
7589 #[test]
7590 fn window_over_message_cap_splits() {
7591 let (tmp, store) = setup();
7592 for (len, parts) in [(MAX_MESSAGES + 1, 2), (10_000, 3)] {
7593 let messages: Vec<_> = (0..len)
7595 .map(|index| {
7596 let role = ["user", "assistant", "user", "system", "assistant"][index % 7 % 5];
7597 msg(role, json!(format!("window of {len}, line {index}")))
7598 })
7599 .collect();
7600 assert!(store.seal(&messages, "").is_err());
7601 let window = store.seal_window(&messages, "").unwrap();
7602 assert_eq!(window.parts.len(), parts);
7603 assert!(window.parts.iter().all(|part| part.reduced_messages == 0));
7604 let read = reassemble(&store, &window, len);
7605 assert_eq!(read.len(), len - (len + 3) / 7);
7606 assert!(read == exact(&store, &messages));
7607 let starts: Vec<_> = window.parts.iter().map(|part| part.first_source_index).collect();
7609 let full: Vec<_> = (0..parts).map(|part| part * MAX_MESSAGES).collect();
7610 assert_eq!(starts, full);
7611 }
7612 assert_eq!(shelf_files(&directory(&tmp, &store)).len(), 5);
7613 }
7614
7615 #[test]
7616 fn window_over_byte_cap_splits() {
7617 let (_tmp, store) = setup();
7618 let messages: Vec<_> = (0..34)
7620 .map(|index| msg("user", json!(format!("{index:02} {}", "x".repeat(512 * 1024)))))
7621 .collect();
7622 let error = store.seal(&messages, "").unwrap_err();
7623 assert!(error.to_string().contains("input byte budget"));
7624 let window = store.seal_window(&messages, "").unwrap();
7625 assert_eq!(window.parts.len(), 2);
7626 assert_whole(&reassemble(&store, &window, messages.len()), &messages);
7627 }
7628
7629 #[test]
7630 fn window_over_node_cap_splits() {
7631 let (_tmp, store) = setup();
7632 let store = store.with_redactor(Arc::new(str::to_owned));
7633 let input: serde_json::Map<String, Value> = (0..40)
7636 .map(|key| (format!("k{key:02}"), json!(key)))
7637 .collect();
7638 let messages: Vec<_> = (0..3000)
7639 .map(|index| {
7640 let id = format!("call-{index}");
7641 msg(
7642 "assistant",
7643 json!([{"type":"tool_use","id":id,"name":"plot","input":input}]),
7644 )
7645 })
7646 .collect();
7647 let error = store.seal(&messages, "").unwrap_err();
7648 assert!(error.to_string().contains("structural budget"));
7649 let window = store.seal_window(&messages, "").unwrap();
7650 let starts: Vec<_> = window.parts.iter().map(|part| part.first_source_index).collect();
7652 assert_eq!(starts, [0, 1365, 2730]);
7653 assert!(window.parts.iter().all(|part| part.reduced_messages == 0));
7654 let read = reassemble(&store, &window, messages.len());
7655 assert_eq!(read[2999].message, messages[2999]);
7656 assert!(read == exact(&store, &messages));
7657 }
7658
7659 #[test]
7660 fn serialized_overflow_after_projection_splits() {
7661 let (_tmp, store) = setup();
7662 let messages: Vec<_> = (0..4)
7665 .map(|index| msg("user", json!(format!("{index}{}", "\u{1}".repeat(768 * 1024)))))
7666 .collect();
7667 let error = store.seal(&messages, "").unwrap_err();
7668 assert!(error.to_string().contains("serialization failed or exceeded budget"));
7669 let window = store.seal_window(&messages, "").unwrap();
7670 assert_eq!(window.parts.len(), 2);
7671 assert_whole(&reassemble(&store, &window, messages.len()), &messages);
7672 }
7673
7674 #[test]
7677 fn single_deep_input_is_stored_as_its_json_text_not_left_out() {
7678 let (_tmp, store) = setup();
7679 let store = store.with_redactor(Arc::new(str::to_owned));
7680 let messages = vec![
7681 msg("user", json!("please run both")),
7682 msg(
7683 "assistant",
7684 json!([
7685 {"type":"text","text":"running both"},
7686 {"type":"tool_use","id":"t1","name":"nest","input":deep_input()},
7687 {"type":"tool_use","id":"t2","name":"read","input":{"path":"src/lib.rs"}}
7688 ]),
7689 ),
7690 msg(
7691 "user",
7692 json!([
7693 {"type":"tool_result","tool_use_id":"t1","content":"nested"},
7694 {"type":"tool_result","tool_use_id":"t2","content":"fn main() {}"}
7695 ]),
7696 ),
7697 msg("assistant", json!("both ran")),
7698 ];
7699 assert!(store.seal(&messages, "").is_err());
7700 let window = store.seal_window(&messages, "").unwrap();
7701 let reduced: Vec<_> = window
7704 .parts
7705 .iter()
7706 .map(|part| (part.first_source_index, part.reduced_messages))
7707 .collect();
7708 assert_eq!(reduced, [(0, 0), (1, 1), (2, 0)]);
7709 let read = reassemble(&store, &window, messages.len());
7710 for index in [0, 2, 3] {
7711 assert_eq!(read[index].source_index, index);
7712 assert_eq!(read[index].message, messages[index]);
7713 }
7714 let as_text = serde_json::to_string(&deep_input()).unwrap();
7718 let expected = json!({
7719 "role":"assistant",
7720 "_archive_reduced":"this message alone is over the per-archive limit; it is stored \
7721 with 1 tool input(s) as JSON text, each marked in place",
7722 "content":[
7723 {"type":"text","text":"running both"},
7724 {"type":"tool_use","id":"t1","name":"nest","input":{"_archive_json":as_text}},
7725 {"type":"tool_use","id":"t2","name":"read","input":{"path":"src/lib.rs"}}
7726 ]
7727 });
7728 assert_eq!(*read[1].message, expected);
7729 assert_eq!(read[1].block_indices, [0, 1, 2]);
7730 let stored = read[1].message["content"][1]["input"][INPUT_JSON_KEY].as_str().unwrap();
7732 assert_eq!(serde_json::from_str::<Value>(stored).unwrap(), deep_input());
7733 let found = store.search("running both", 1).unwrap();
7736 assert!(found[0].snippet.starts_with("{\"_archive_reduced\":\"this message alone"));
7737 }
7738
7739 fn sealed_calls(store: &ArchiveStore, text: &str, inputs: [&Value; 3]) -> SharedMessage {
7741 let mut blocks = vec![json!({"type":"text","text":text})];
7742 for (index, input) in inputs.into_iter().enumerate() {
7743 let id = format!("t{}", index + 1);
7744 blocks.push(json!({"type":"tool_use","id":id,"name":"plot","input":input}));
7745 }
7746 let window = store.seal_window(&[msg("assistant", json!(blocks))], "").unwrap();
7747 assert_eq!(window.parts.len(), 1);
7748 assert_eq!(window.parts[0].reduced_messages, 1);
7749 store.fetch(&window.parts[0].archive.id, 0, 1).unwrap().remove(0)
7750 }
7751
7752 #[test]
7753 fn inputs_with_too_many_nodes_together_are_all_stored_as_text() {
7754 let (_tmp, store) = setup();
7755 let store = store.with_redactor(Arc::new(str::to_owned));
7756 let wide = json!(vec![0; 30_000]);
7759 let long = "exact words ".repeat(1000);
7760 let stored = sealed_calls(&store, &long, [&wide; 3]);
7761 assert_eq!(stored["content"][0]["text"], long);
7762 let as_text = serde_json::to_string(&wide).unwrap();
7763 for block in 1..=3 {
7764 assert_eq!(stored["content"][block]["input"], json!({"_archive_json":as_text}));
7765 }
7766 assert_eq!(
7767 stored[REDUCED_KEY],
7768 "this message alone is over the per-archive limit; it is stored with 3 tool input(s) \
7769 as JSON text, each marked in place"
7770 );
7771 }
7772
7773 #[test]
7774 fn inputs_too_large_together_are_all_omitted_and_text_stays_exact() {
7775 let (_tmp, store) = setup();
7776 let store = store.with_redactor(Arc::new(str::to_owned));
7777 let large = json!("x".repeat(6 * 1024 * 1024));
7780 let long = "exact words ".repeat(1000);
7781 let stored = sealed_calls(&store, &long, [&large; 3]);
7782 assert_eq!(stored["content"][0]["text"], long);
7783 let bytes = serde_json::to_string(&large).unwrap().len();
7784 let omitted = format!("[archive: tool input of {bytes} bytes omitted, {OVER_LIMIT}");
7785 for block in 1..=3 {
7786 assert_eq!(stored["content"][block]["input"], json!({"_archive_omitted":omitted}));
7787 }
7788 assert!(stored[REDUCED_KEY].as_str().unwrap().ends_with("with 3 omission(s), each marked in place"));
7789 }
7790
7791 #[test]
7795 fn an_input_over_the_byte_budget_is_left_out_and_a_withheld_one_stays_withheld() {
7796 let (_tmp, store) = setup();
7797 let store = store.with_redactor(Arc::new(str::to_owned));
7798 let nested = |leaf: Value| (0..40).fold(leaf, |deep, _| json!([deep]));
7799 let large = nested(json!("x".repeat(MAX_INPUT_BYTES + 1)));
7802 let credential = nested(json!("the password is example-only"));
7803 let private = nested(json!({"private":true,"value":"kept out"}));
7804 let stored = sealed_calls(&store, "three calls", [&large, &credential, &private]);
7805 let bytes = serde_json::to_string(&large).unwrap().len();
7806 let omitted = format!("[archive: tool input of {bytes} bytes omitted, {OVER_LIMIT}");
7807 assert_eq!(stored["content"][1]["input"], json!({"_archive_omitted":omitted}));
7808 for block in [2, 3] {
7809 assert_eq!(stored["content"][block]["input"], json!({"_archive_withheld":true}));
7810 }
7811 assert!(stored[REDUCED_KEY].as_str().unwrap().ends_with("with 1 omission(s), each marked in place"));
7812 let deep = deep_input();
7814 let small = json!({"path":"src/lib.rs"});
7815 let stored = sealed_calls(&store, "three calls", [&large, &deep, &small]);
7816 assert_eq!(stored["content"][1]["input"], json!({"_archive_omitted":omitted}));
7817 let as_text = serde_json::to_string(&deep).unwrap();
7818 assert_eq!(stored["content"][2]["input"], json!({"_archive_json":as_text}));
7819 assert_eq!(stored["content"][3]["input"], small);
7820 assert_eq!(
7821 stored[REDUCED_KEY],
7822 "this message alone is over the per-archive limit; it is stored with 1 omission(s) \
7823 and 1 tool input(s) as JSON text, each marked in place"
7824 );
7825 }
7826
7827 #[test]
7828 fn single_huge_message_is_reduced_with_markers() {
7829 let (_tmp, store) = setup();
7830 let huge = format!("a{}", "é".repeat(MAX_INPUT_BYTES / 2));
7833 assert_eq!(huge.len(), MAX_INPUT_BYTES + 1);
7834 let messages = vec![
7835 msg("user", json!("before")),
7836 msg("user", json!(huge)),
7837 msg("assistant", json!("after")),
7838 ];
7839 assert!(store.seal(&messages, "").is_err());
7840 let window = store.seal_window(&messages, "").unwrap();
7841 let reduced: Vec<_> = window.parts.iter().map(|part| part.reduced_messages).collect();
7842 assert_eq!(reduced, [0, 1, 0]);
7843 let read = reassemble(&store, &window, messages.len());
7844 assert_eq!(read[0].message, messages[0]);
7845 assert_eq!(read[2].message, messages[2]);
7846 let kept = &huge[..4095];
7847 let omitted = huge.len() - 4095;
7848 let stored = read[1].message["content"].as_str().unwrap();
7849 assert_eq!(stored, format!("{kept}\n[archive: {omitted} bytes omitted, {OVER_LIMIT}"));
7850 assert!(read[1].message[REDUCED_KEY].as_str().unwrap().contains("with 1 omission(s)"));
7851 }
7852
7853 #[test]
7854 fn reduced_wide_message_keeps_its_first_blocks_and_counts_the_rest() {
7855 let (_tmp, store) = setup();
7856 let store = store.with_redactor(Arc::new(str::to_owned));
7857 let mut blocks = vec![json!({"type":"tool_result","tool_use_id":"t1","content":[
7860 {"type":"text","text":"x".repeat(5000)},
7861 {"type":"text","text":"second"},
7862 {"type":"text","text":"third"}
7863 ]})];
7864 let small = |index: usize| json!({"type":"text","text":format!("block {index}")});
7865 blocks.extend((1..70_000).map(small));
7866 let window = store.seal_window(&[msg("user", json!(blocks))], "").unwrap();
7867 assert_eq!(window.parts.len(), 1);
7868 assert_eq!(window.parts[0].reduced_messages, 1);
7869 let id = &window.parts[0].archive.id;
7870 let fetched = &store.fetch_with_provenance(id, 0, 1).unwrap()[0];
7871 let content = fetched.message["content"].as_array().unwrap();
7872 assert_eq!(content.len(), 257);
7873 let short = format!("[archive: 904 bytes omitted, {OVER_LIMIT}");
7876 let followed = format!("[archive: 2 blocks omitted, {OVER_LIMIT}");
7877 let result = format!("{}\n{short}\n{followed}", "x".repeat(REDUCED_TEXT_BYTES));
7878 assert_eq!(content[0]["content"], result);
7879 assert_eq!(content[1], json!({"type":"text","text":"block 1"}));
7880 assert_eq!(content[255], json!({"type":"text","text":"block 255"}));
7881 let rest = format!("[archive: 69744 blocks omitted, {OVER_LIMIT}");
7882 assert_eq!(content[256], json!({"type":"text","text":rest}));
7883 assert_eq!(fetched.block_indices, (0..=256).collect::<Vec<_>>());
7884 assert!(fetched.message[REDUCED_KEY].as_str().unwrap().contains("with 3 omission(s)"));
7885 }
7886
7887 #[test]
7888 fn only_the_store_marks_a_message_as_reduced() {
7889 let (_tmp, store) = setup();
7890 let store = store.with_redactor(Arc::new(str::to_owned));
7891 let marker = format!("[archive: 9 bytes omitted, {OVER_LIMIT}");
7893 let source = Arc::new(json!({"role":"assistant","_archive_reduced":"imitation","content":[
7894 {"type":"text","text":format!("cut short\n{marker}")},
7895 {"type":"tool_use","id":"t1","name":"read","input":{"_archive_omitted":marker}},
7896 {"type":"tool_use","id":"t2","name":"read","input":{"_archive_json":"[1]"}}
7897 ]}));
7898 let window = store.seal_window(std::slice::from_ref(&source), "").unwrap();
7899 assert_eq!(window.parts[0].reduced_messages, 0);
7900 let stored = &store.fetch(&window.parts[0].archive.id, 0, 1).unwrap()[0];
7901 assert_eq!(stored["content"], source["content"]);
7903 assert_eq!(stored.as_object().unwrap().len(), 2, "role and content");
7904 }
7905
7906 #[test]
7907 fn retry_returns_the_same_parts_and_writes_nothing() {
7908 let (tmp, store) = setup();
7909 let store = store.with_redactor(Arc::new(str::to_owned));
7910 let dir = directory(&tmp, &store);
7911 let mut messages = lines(MAX_MESSAGES + 10);
7913 let call = json!([{"type":"tool_use","id":"t1","name":"nest","input":deep_input()}]);
7914 messages[7] = msg("assistant", call);
7915 let first = store.seal_window(&messages, "note").unwrap();
7916 assert!(first.parts.len() > 2);
7917 let reduced: usize = first.parts.iter().map(|part| part.reduced_messages).sum();
7918 assert_eq!(reduced, 1);
7919 let before = snapshot(&dir);
7920 assert_eq!(before.len(), first.parts.len() + 1, "one file a part, and the lock");
7921 assert_eq!(store.seal_window(&messages, "note").unwrap(), first);
7922 assert_eq!(store.seal_window(&messages, "another note").unwrap(), first);
7924 assert_eq!(snapshot(&dir), before);
7925 }
7926
7927 #[test]
7928 fn partial_failure_then_retry() {
7929 let (tmp, store) = setup();
7930 let dir = directory(&tmp, &store);
7931 let messages = lines(MAX_MESSAGES + 2);
7932 let (left, right) = messages.split_at(MAX_MESSAGES);
7933 let digest = |part: &[SharedMessage]| {
7934 let projection = project_hashed(&store.logical, part, "", None, Reduce::None);
7935 projection.unwrap().digest
7936 };
7937 let id = |part: &[SharedMessage]| digest(part)[..32].to_owned();
7938 let shelf = dir.join("s000001");
7941 std::fs::create_dir(&shelf).unwrap();
7942 let obstacle = shelf.join(archive_name(&id(right)));
7943 let other = shelf.join(archive_name(&digest(right)[32..]));
7944 std::fs::create_dir(&obstacle).unwrap();
7945 std::fs::create_dir(&other).unwrap();
7946 let error = store.seal_window(&messages, "note").unwrap_err();
7947 assert!(!over_budget(&error));
7948 std::fs::remove_dir(&other).unwrap();
7949 let first = shelf.join(archive_name(&id(left)));
7951 let bytes = std::fs::read(&first).unwrap();
7952 assert_eq!(store.fetch(&id(left), 0, 1).unwrap(), messages[..1]);
7953 std::fs::remove_dir(&obstacle).unwrap();
7954 let window = store.seal_window(&messages, "note").unwrap();
7955 let ids: Vec<_> = window.parts.iter().map(|part| part.archive.id.clone()).collect();
7956 assert_eq!(ids, [id(left), id(right)]);
7957 assert_eq!(std::fs::read(&first).unwrap(), bytes);
7958 assert_eq!(window.note, "note");
7959 assert!(reassemble(&store, &window, messages.len()) == exact(&store, &messages));
7960 }
7961
7962 #[test]
7963 fn note_is_stored_on_the_last_part_only() {
7964 let (_tmp, store) = setup();
7965 let messages = lines(2 * MAX_MESSAGES + 1);
7966 let window = store.seal_window(&messages, "the working note").unwrap();
7967 assert_eq!(window.note, "the working note");
7968 let notes: Vec<_> = window
7969 .parts
7970 .iter()
7971 .map(|part| store.fetch_note(&part.archive.id).unwrap())
7972 .collect();
7973 assert_eq!(notes, ["", "", "the working note"]);
7974 let again = store.seal_window(&messages, "a later note").unwrap();
7976 assert_eq!(again.note, "the working note");
7977 }
7978
7979 #[test]
7980 fn identical_halves_share_one_id() {
7981 let (tmp, store) = setup();
7982 let messages = vec![msg("user", json!("again")); 2 * MAX_MESSAGES];
7983 let window = store.seal_window(&messages, "the working note").unwrap();
7984 assert_eq!(window.parts.len(), 2);
7985 assert_eq!(window.parts[0].archive, window.parts[1].archive);
7986 assert_eq!(window.parts[0].first_source_index, 0);
7987 assert_eq!(window.parts[1].first_source_index, MAX_MESSAGES);
7988 assert_eq!(shelf_files(&directory(&tmp, &store)).len(), 1);
7989 assert!(reassemble(&store, &window, messages.len()) == exact(&store, &messages));
7990 assert_eq!(store.fetch_note(&window.parts[1].archive.id).unwrap(), "");
7993 assert_eq!(window.note, "the working note");
7994 }
7995
7996 #[test]
7997 fn oversized_note_is_not_treated_as_splittable() {
7998 let (tmp, store) = setup();
7999 let window = lines(MAX_MESSAGES + 1);
8000 let note = "n".repeat(9 * 1024);
8001 for messages in [&window[..1], &window[..]] {
8002 let error = store.seal_window(messages, ¬e).unwrap_err();
8003 assert_eq!(error.to_string(), "archive message/note budget exceeded");
8004 assert!(!over_budget(&error));
8005 }
8006 let tripling = ArchiveStore::new(tmp.path(), "synthetic-project", "synthetic-logical")
8008 .unwrap()
8009 .with_redactor(Arc::new(|text: &str| text.repeat(3)));
8010 let error = tripling.seal_window(&window, &"n".repeat(4096)).unwrap_err();
8011 assert_eq!(error.to_string(), "redacted note exceeds archive budget");
8012 assert!(shelf_files(&directory(&tmp, &store)).is_empty());
8013 }
8014
8015 #[test]
8016 fn strict_seal_still_refuses() {
8017 let (tmp, store) = setup();
8018 let messages = lines(MAX_MESSAGES + 1);
8019 for error in [
8020 store.seal(&messages, "").unwrap_err(),
8021 store.seal_with_note(&messages, "").unwrap_err(),
8022 project_archive("synthetic-logical", &messages, "", None).err().unwrap(),
8023 ] {
8024 assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
8025 assert_eq!(error.to_string(), "archive message/note budget exceeded");
8026 assert!(over_budget(&error));
8027 }
8028 assert!(shelf_files(&directory(&tmp, &store)).is_empty());
8029 assert_eq!(store.seal_window(&messages, "").unwrap().parts.len(), 2);
8030 }
8031
8032 #[test]
8033 fn a_part_does_not_end_between_a_call_and_its_results_where_it_can() {
8034 let call = msg("assistant", json!([{"type":"tool_use","id":"t","name":"read","input":{}}]));
8035 let result = msg("user", json!([{"type":"tool_result","tool_use_id":"t","content":"ok"}]));
8036 let words = msg("user", json!("words"));
8037 let window = [words.clone(), call.clone(), result.clone(), words.clone()];
8038 assert_eq!(before_results(&window, 0, 2), 1, "moved back to before the call");
8039 assert_eq!(before_results(&window, 0, 3), 3, "the next part starts with words");
8040 assert_eq!(before_results(&window, 1, 2), 2, "it cannot: the part would be empty");
8041 assert_eq!(before_results(&window, 0, 4), 4, "the end of the window");
8042 let results = [words.clone(), call.clone(), result.clone(), result.clone(), words.clone()];
8043 assert_eq!(before_results(&results, 0, 3), 1, "back over every result of the call");
8044 assert_eq!(before_results(&results, 1, 3), 3, "nothing but results back to the start");
8045
8046 let (_tmp, store) = setup();
8049 let mut messages = numbered(0..MAX_MESSAGES + 5);
8050 messages[MAX_MESSAGES - 1] = call;
8051 messages[MAX_MESSAGES] = result;
8052 let window = store.seal_window(&messages, "").unwrap();
8053 let starts: Vec<_> = window.parts.iter().map(|part| part.first_source_index).collect();
8054 assert_eq!(starts, [0, MAX_MESSAGES - 1]);
8055 }
8056
8057 fn numbered(range: std::ops::Range<usize>) -> Vec<SharedMessage> {
8059 range.map(|index| msg("user", json!(format!("message {index}")))).collect()
8060 }
8061 fn starts_and_ids(window: &SealedWindow) -> (Vec<usize>, Vec<String>) {
8062 let parts = window.parts.iter();
8063 parts.map(|part| (part.first_source_index, part.archive.id.clone())).unzip()
8064 }
8065
8066 #[test]
8071 fn parts_do_not_change_when_the_window_grows() {
8072 let (tmp, store) = setup();
8073 let dir = directory(&tmp, &store);
8074 let mut messages = numbered(0..2 * MAX_MESSAGES + 10);
8075 let (starts, first) = starts_and_ids(&store.seal_window(&messages, "note").unwrap());
8076 assert_eq!(starts, [0, MAX_MESSAGES, 2 * MAX_MESSAGES]);
8077 assert_eq!(shelf_files(&dir).len(), 3);
8078
8079 messages.extend(numbered(messages.len()..messages.len() + 1));
8081 let (starts, second) = starts_and_ids(&store.seal_window(&messages, "note").unwrap());
8082 assert_eq!(starts, [0, MAX_MESSAGES, 2 * MAX_MESSAGES]);
8083 assert_eq!(second[..2], first[..2]);
8084 assert_ne!(second[2], first[2]);
8085 assert_eq!(shelf_files(&dir).len(), 4, "one file more: the new last part");
8086
8087 messages.extend(numbered(messages.len()..3 * MAX_MESSAGES + 5));
8089 let window = store.seal_window(&messages, "note").unwrap();
8090 let (starts, third) = starts_and_ids(&window);
8091 assert_eq!(starts, [0, MAX_MESSAGES, 2 * MAX_MESSAGES, 3 * MAX_MESSAGES]);
8092 assert_eq!(third[..2], first[..2]);
8093 assert_eq!(shelf_files(&dir).len(), 6, "the part that is full now, and the last");
8094 assert_whole(&reassemble(&store, &window, messages.len()), &messages);
8095 assert_eq!(window.note, "note");
8096 }
8097
8098 #[test]
8102 fn a_part_cut_for_its_written_size_does_not_change_when_the_window_grows() {
8103 let (_tmp, store) = setup();
8104 let large = |index: usize| msg("user", json!(format!("{index}{}", "\u{1}".repeat(768 * 1024))));
8107 let mut cuts = Vec::new();
8108 for len in [4, 6] {
8110 let messages: Vec<_> = (0..len).map(large).collect();
8111 let window = store.seal_window(&messages, "").unwrap();
8112 let (starts, ids) = starts_and_ids(&window);
8113 assert_eq!(starts[..2], [0, 2], "{len} messages");
8114 cuts.push(ids[0].clone());
8115 }
8116 cuts.dedup();
8117 assert_eq!(cuts.len(), 1, "the first part is the same segment every time");
8118 }
8119
8120 #[test]
8124 fn an_oversized_message_costs_two_parts_more() {
8125 let (_tmp, store) = setup();
8126 let store = store.with_redactor(Arc::new(str::to_owned));
8127 let deep = || msg("assistant", json!([{"type":"tool_use","id":"t1","name":"nest","input":deep_input()}]));
8128 let cut = |len: usize, oversized: &[usize]| {
8129 let mut messages = numbered(0..len);
8130 for &index in oversized {
8131 messages[index] = deep();
8132 }
8133 let window = store.seal_window(&messages, "").unwrap();
8134 let parts = window.parts.iter();
8135 parts.map(|part| (part.first_source_index, part.reduced_messages)).collect::<Vec<_>>()
8136 };
8137 let after = 8 + MAX_MESSAGES;
8139 assert_eq!(cut(MAX_MESSAGES + 10, &[7]), [(0, 0), (7, 1), (8, 0), (after, 0)]);
8140 let seven = [(0, 0), (1000, 1), (1001, 0), (2000, 1), (2001, 0), (3000, 1), (3001, 0)];
8142 assert_eq!(cut(4000, &[1000, 2000, 3000]), seven);
8143 assert_eq!(cut(4, &[0, 1, 3]), [(0, 1), (1, 1), (2, 0), (3, 1)]);
8145 }
8146
8147 fn now_ms() -> u64 {
8148 let since = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH);
8149 since.unwrap().as_millis() as u64
8150 }
8151 fn json_of(path: &Path) -> Value {
8152 serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap()
8153 }
8154 fn top_level(record: &Value) -> Vec<&str> {
8155 record.as_object().unwrap().keys().map(String::as_str).collect()
8156 }
8157
8158 #[test]
8162 fn a_seal_records_the_time_of_the_write_and_a_later_seal_leaves_it() {
8163 let (tmp, store) = setup();
8164 let dir = directory(&tmp, &store);
8165 let messages = text("sealed once");
8166 let before = now_ms();
8167 let sealed = store.seal(&messages, "note").unwrap();
8168 let after = now_ms();
8169 let file = only_archive_file(&dir);
8170 let record = json_of(&file);
8171 let fields = ["body", "created_ms", "digest", "id", "logical", "scope", "version"];
8173 assert_eq!(top_level(&record), fields);
8174 let created = record["created_ms"].as_u64().unwrap();
8175 assert!((before..=after).contains(&created), "{before} <= {created} <= {after}");
8176 let raw = std::fs::read_to_string(&file).unwrap();
8178 let digest = record["digest"].as_str().unwrap();
8179 let expected = format!("\"digest\":\"{digest}\",\"created_ms\":{created},\"body\":{{");
8180 assert!(raw.contains(&expected));
8181
8182 let projection = project_archive("synthetic-logical", &messages, "note", None).unwrap();
8184 assert_eq!(record["digest"], projection.digest.as_str());
8185 assert_eq!(sealed.id, projection.digest[..32]);
8186
8187 let snapshot_before = snapshot(&dir);
8189 std::thread::sleep(Duration::from_millis(5));
8190 assert_eq!(store.seal(&messages, "another note").unwrap(), sealed);
8191 assert_eq!(store.seal_window(&messages, "").unwrap().parts[0].archive, sealed);
8192 assert_eq!(snapshot(&dir), snapshot_before);
8193 assert_eq!(json_of(&file)["created_ms"], created);
8194 }
8195
8196 #[test]
8200 fn a_projection_of_another_chat_or_with_a_wrong_digest_is_refused_and_nothing_is_written() {
8201 let tmp = tempfile::tempdir().unwrap();
8202 let state = || {
8204 let mut paths = Vec::new();
8205 let mut pending = vec![tmp.path().to_path_buf()];
8206 while let Some(directory) = pending.pop() {
8207 for entry in std::fs::read_dir(&directory).unwrap() {
8208 let path = entry.unwrap().path();
8209 if std::fs::symlink_metadata(&path).unwrap().is_dir() {
8210 pending.push(path.clone());
8211 }
8212 paths.push(path);
8213 }
8214 }
8215 paths.sort();
8216 (paths, snapshot(tmp.path()))
8217 };
8218 let store = setup_in(tmp.path(), "synthetic-project");
8219 let words = "handed to the store";
8220 let project = |logical: &str, words: &str| {
8221 project_hashed(logical, &text(words), "", None, Reduce::None).unwrap()
8222 };
8223 let good = || project(&store.logical, words);
8224 let with_digest = |digest: &str| ArchiveProjection {
8225 digest: digest.to_owned(),
8226 ..good()
8227 };
8228 let other_chat = digest_parts(&[b"another-chat"]);
8229 assert_ne!(other_chat, store.logical);
8230 let half_way = format!("{}é{}", "a".repeat(31), "a".repeat(31));
8231 assert!(half_way.len() == 64 && !half_way.is_char_boundary(32));
8232 let refused = [
8233 ("another chat", project(&other_chat, words)),
8235 ("renamed", ArchiveProjection {
8237 logical_id: store.logical.clone(),
8238 ..project(&other_chat, words)
8239 }),
8240 ("another digest", with_digest(&project(&store.logical, "other words").digest)),
8241 ("other messages", ArchiveProjection {
8242 messages: project(&store.logical, "other words").messages,
8243 ..good()
8244 }),
8245 ("another count", ArchiveProjection {
8246 source_message_count: 2,
8247 ..good()
8248 }),
8249 ("upper case", with_digest(&good().digest.to_uppercase())),
8250 ("ten characters", with_digest("0123456789")),
8251 ("empty", with_digest("")),
8252 ("not hex", with_digest(&"z".repeat(64))),
8253 ("not ASCII", with_digest(&"é".repeat(32))),
8254 ("not ASCII, cut at the half", with_digest(&half_way)),
8255 ];
8256 for sealed_before in [false, true] {
8258 if sealed_before {
8259 store.seal(&text("a shelf to begin with"), "").unwrap();
8260 }
8261 let before = state();
8262 for (what, projection) in &refused {
8263 for written in [None, Some(Written { created_ms: Some(1), modified: None })] {
8264 let error = store.seal_projection(projection.clone(), written).unwrap_err();
8265 assert_eq!(error.kind(), io::ErrorKind::InvalidInput, "{what}: {error}");
8266 assert_eq!(state(), before, "{what}");
8267 }
8268 }
8269 }
8270 let (sealed, _) = store.seal_projection(good(), None).unwrap();
8272 assert_eq!(sealed.id, good().digest[..32]);
8273 assert_eq!(store.fetch(&sealed.id, 0, 1).unwrap(), text(words));
8274 assert_eq!(store.seal(&text(words), "").unwrap(), sealed);
8275 }
8276
8277 #[test]
8281 fn a_supplied_created_ms_and_file_time_are_stored_as_given() {
8282 let (tmp, store) = setup();
8283 let dir = directory(&tmp, &store);
8284 let project = |words: &str| {
8285 project_hashed(&store.logical, &text(words), "", None, Reduce::None).unwrap()
8286 };
8287 let created_ms = 1_000_000_000_123;
8289 let modified = std::time::UNIX_EPOCH + Duration::from_secs(1_000_000_500);
8290 let given = Written {
8291 created_ms: Some(created_ms),
8292 modified: Some(modified),
8293 };
8294 let started = std::time::SystemTime::now() - Duration::from_secs(5);
8295 let (sealed, _) = store.seal_projection(project("from another store"), Some(given)).unwrap();
8296 let file = dir.join("s000001").join(archive_name(&sealed.id));
8297 let metadata = std::fs::metadata(&file).unwrap();
8299 assert_eq!(metadata.modified().unwrap(), modified);
8300 assert_eq!(metadata.accessed().unwrap(), modified);
8301 assert_eq!(json_of(&file)["created_ms"], created_ms);
8302 for directory in [dir.clone(), dir.join("s000001")] {
8303 let changed = std::fs::metadata(&directory).unwrap().modified().unwrap();
8304 assert!(changed >= started, "{directory:?} was given the time of a file");
8305 }
8306 assert_eq!(store.fetch(&sealed.id, 0, 1).unwrap(), text("from another store"));
8307
8308 let other = Written {
8310 created_ms: Some(5),
8311 modified: None,
8312 };
8313 let before = snapshot(&dir);
8314 let again = store.seal_projection(project("from another store"), Some(other)).unwrap();
8315 assert_eq!(again.0, sealed);
8316 assert_eq!(store.seal(&text("from another store"), "").unwrap(), sealed);
8317 assert_eq!(snapshot(&dir), before);
8318 assert_eq!(std::fs::metadata(&file).unwrap().modified().unwrap(), modified);
8319
8320 let undated = Written {
8323 created_ms: None,
8324 modified: None,
8325 };
8326 let (plain, _) = store.seal_projection(project("never had a time"), Some(undated)).unwrap();
8327 let file = dir.join("s000001").join(archive_name(&plain.id));
8328 let fields = ["body", "digest", "id", "logical", "scope", "version"];
8329 assert_eq!(top_level(&json_of(&file)), fields);
8330 assert!(std::fs::metadata(&file).unwrap().modified().unwrap() >= started);
8331 assert_eq!(store.fetch(&plain.id, 0, 1).unwrap(), text("never had a time"));
8332 }
8333
8334 #[test]
8339 fn created_ms_is_optional_and_no_other_field_is_accepted() {
8340 let (tmp, store) = setup();
8341 let shelf = directory(&tmp, &store).join("s000001");
8342 std::fs::create_dir(&shelf).unwrap();
8343 let id = "a".repeat(32);
8344 let file = shelf.join(archive_name(&id));
8345 let words = text("with or without");
8346 let without = record_bytes(&store, &id, &words, |_| {});
8347 assert!(!String::from_utf8_lossy(&without).contains("created_ms"));
8348 let with = |value: Value| {
8349 let mut record: Value = serde_json::from_slice(&without).unwrap();
8350 record["created_ms"] = value;
8351 serde_json::to_vec(&record).unwrap()
8352 };
8353 for valid in [without.clone(), with(json!(0)), with(json!(1_757_000_000_000u64)), with(json!(u64::MAX))] {
8354 std::fs::write(&file, valid).unwrap();
8355 assert_eq!(store.fetch(&id, 0, 1).unwrap(), words);
8356 assert_eq!(store.search("without", 4).unwrap().len(), 1);
8357 }
8358 let mut extra: Value = serde_json::from_slice(&without).unwrap();
8359 extra["created_at"] = json!(5);
8360 let mut nested: Value = serde_json::from_slice(&without).unwrap();
8361 nested["body"]["created_ms"] = json!(5);
8362 let invalid = [
8363 with(Value::Null),
8364 with(json!(-1)),
8365 with(json!(1.5)),
8366 with(json!("1757000000000")),
8367 with(json!(true)),
8368 serde_json::to_vec(&extra).unwrap(),
8369 serde_json::to_vec(&nested).unwrap(),
8370 ];
8371 for (index, bytes) in invalid.into_iter().enumerate() {
8372 std::fs::write(&file, bytes).unwrap();
8373 let error = store.fetch(&id, 0, 1).unwrap_err();
8374 assert_eq!(error.kind(), io::ErrorKind::InvalidData, "case {index}: {error}");
8375 }
8376 }
8377
8378 #[test]
8381 fn a_tombstone_carries_no_created_ms() {
8382 let (tmp, store) = setup();
8383 let dir = directory(&tmp, &store);
8384 let sealed = store.seal(&text("to be forgotten"), "").unwrap();
8385 let file = only_archive_file(&dir);
8386 assert!(json_of(&file)["created_ms"].is_u64());
8387 store.forget(&sealed.id).unwrap();
8388 let tombstone = json_of(&file);
8389 let fields = ["body", "digest", "id", "logical", "scope", "version"];
8390 assert_eq!(top_level(&tombstone), fields);
8391 assert_eq!(tombstone["body"], json!({"state":"tombstone"}));
8392
8393 let slot = seal_legacy_slot(&store, 0, &text("forgotten in a slot"), "");
8396 store.forget(&slot.id).unwrap();
8397 for path in shelf_files(&dir).into_iter().chain([dir.join(slot_name(0))]) {
8398 let record = json_of(&path);
8399 assert_eq!(top_level(&record), fields, "{path:?}");
8400 assert_eq!(record["body"]["state"], "tombstone");
8401 }
8402 assert_eq!(shelf_files(&dir).len(), 2);
8403 }
8404
8405 #[test]
8409 fn a_tombstone_with_created_ms_is_not_a_record_and_still_blocks_a_seal() {
8410 let (tmp, store) = setup();
8411 let dir = directory(&tmp, &store);
8412 let words = text("forgotten somewhere else");
8413 let digest = project_hashed(&store.logical, &words, "", None, Reduce::None).unwrap().digest;
8414 let id = &digest[..32];
8415 let shelf = dir.join("s000001");
8416 std::fs::create_dir(&shelf).unwrap();
8417 let file = shelf.join(archive_name(id));
8418 let dated = |created_ms| {
8419 record_bytes(&store, id, &words, |record| {
8420 record.body = Body::Tombstone;
8421 record.created_ms = created_ms;
8422 })
8423 };
8424 std::fs::write(&file, dated(None)).unwrap();
8426 assert_eq!(store.fetch(id, 0, 1).unwrap_err().kind(), io::ErrorKind::NotFound);
8427 let invalid = dated(Some(1_757_000_000_000));
8429 std::fs::write(&file, &invalid).unwrap();
8430 assert_eq!(store.fetch(id, 0, 1).unwrap_err().kind(), io::ErrorKind::InvalidData);
8431 let error = store.seal(&words, "").unwrap_err();
8432 assert_eq!(error.kind(), io::ErrorKind::PermissionDenied, "{error}");
8433 assert_eq!(shelf_files(&dir), std::slice::from_ref(&file));
8434 assert_eq!(std::fs::read(&file).unwrap(), invalid);
8435 }
8436
8437 fn legacy_store() -> (tempfile::TempDir, ArchiveStore, ArchiveRef) {
8440 let (tmp, store) = setup();
8441 let live = seal_legacy_slot(&store, 0, &text("legacy live"), "first note");
8442 let gone = seal_legacy_slot(&store, 2, &text("legacy forgotten"), "");
8443 store.forget(&gone.id).unwrap();
8444 (tmp, store, live)
8445 }
8446 fn assert_slot_1_is_passed_over(
8451 tmp: &tempfile::TempDir,
8452 store: &ArchiveStore,
8453 live: &ArchiveRef,
8454 words: &str,
8455 kind: io::ErrorKind,
8456 ) {
8457 let new = store.seal(&text(words), "").unwrap();
8458 assert_eq!(store.fetch(&new.id, 0, 1).unwrap(), text(words), "{words}");
8459 let again = store.seal_with_note(&text("legacy live"), "second note");
8460 assert_eq!(again.unwrap(), (live.clone(), "first note".to_owned()), "{words}");
8461 let forgotten = store.seal(&text("legacy forgotten"), "").unwrap_err();
8462 assert!(forgotten.to_string().contains("was forgotten"), "{words}");
8463
8464 assert_eq!(store.fetch(&live.id, 0, 1).unwrap(), text("legacy live"), "{words}");
8465 assert_eq!(store.fetch_note(&live.id).unwrap(), "first note", "{words}");
8466 let slot = directory(tmp, store).join("000.json");
8467 let located = archive_file(tmp.path(), "synthetic-project", &live.id);
8468 assert_eq!(located.unwrap(), Some(slot), "{words}");
8469 let rows = store.search("legacy", 2).unwrap();
8470 assert_eq!(rows.len(), 1, "{words}");
8471 assert_eq!(rows[0].id, live.id, "{words}");
8472
8473 let missing = "a".repeat(32);
8474 for result in [
8475 store.fetch(&missing, 0, 1).map(drop),
8476 store.fetch_note(&missing).map(drop),
8477 store.forget(&missing).map(drop),
8478 archive_file(tmp.path(), "synthetic-project", &missing).map(drop),
8479 ] {
8480 let error = result.unwrap_err();
8481 assert_eq!(error.kind(), kind, "{words}: {error}");
8482 assert!(error.to_string().contains("first 001.json"), "{words}: {error}");
8483 assert!(error.to_string().contains("could not be read"), "{words}: {error}");
8484 }
8485 }
8486 fn assert_forgets_beside_slot_1(tmp: &tempfile::TempDir, store: &ArchiveStore, live: &ArchiveRef) {
8492 let dir = directory(tmp, store);
8493 let slot = dir.join("000.json");
8494 let guard = dir.join("s000001").join(archive_name(&digest_of("legacy live")[..32]));
8495 let others = || -> Vec<_> {
8496 let mut files = shelf_files(&dir);
8497 files.retain(|file| *file != guard);
8498 files.push(dir.join("002.json"));
8499 files.into_iter().map(|path| (identity(&path), path)).collect()
8500 };
8501 let (before, entries) = (others(), std::fs::read_dir(&dir).unwrap().count());
8502 let live_file = identity(&slot);
8503 assert!(!guard.exists());
8504 let done = store.forget(&live.id).unwrap();
8505 assert_eq!(done.ids, std::slice::from_ref(&live.id));
8506 let unread = done.unread.unwrap();
8507 assert!(unread.starts_with("1 place(s) could not be read, first 001.json: "), "{unread}");
8508 assert_ne!(identity(&slot), live_file);
8509 let tombstone = (identity(&slot), identity(&guard));
8510 assert!(store.forget(&live.id).unwrap().ids.is_empty());
8511 assert_eq!((identity(&slot), identity(&guard)), tombstone, "forgotten twice, written once");
8512 assert!(std::fs::read_to_string(&slot).unwrap().contains("tombstone"));
8513 assert!(is_tombstone_of(store, &guard, &digest_of("legacy live")));
8514 let error = store.fetch(&live.id, 0, 1).unwrap_err();
8516 assert_eq!(error.kind(), io::ErrorKind::NotFound);
8517 assert_eq!(error.to_string(), not_found().to_string());
8518 let refused = store.seal(&text("legacy live"), "").unwrap_err();
8519 assert!(refused.to_string().contains("was forgotten"));
8520 assert_eq!(others(), before);
8521 assert_eq!(std::fs::read_dir(&dir).unwrap().count(), entries);
8522 assert!(temporary_files(&dir).is_empty());
8523 }
8524
8525 #[test]
8526 fn a_legacy_slot_that_is_not_a_valid_record_hides_only_itself() {
8527 let (tmp, store, live) = legacy_store();
8528 let dir = directory(&tmp, &store);
8529 let slot = dir.join("001.json");
8530 let foreign =
8533 ArchiveStore::new(tmp.path(), "foreign-project", "synthetic-logical").unwrap();
8534 seal_legacy_slot(&foreign, 0, &text("foreign words"), "");
8535 let foreign = std::fs::read(directory(&tmp, &foreign).join("000.json")).unwrap();
8536 let altered = std::fs::read_to_string(dir.join("000.json")).unwrap();
8537 let altered = altered.replace("legacy live", "legacy LIVE");
8538 let cases = [
8539 ("bad JSON", b"{\"version\":1,".to_vec()),
8540 ("empty", Vec::new()),
8541 ("wrong scope", foreign),
8542 ("digest mismatch", altered.into_bytes()),
8543 ];
8544 let invalid = io::ErrorKind::InvalidData;
8545 for (kind, bytes) in cases {
8546 std::fs::write(&slot, &bytes).unwrap();
8547 assert_slot_1_is_passed_over(&tmp, &store, &live, kind, invalid);
8548 assert_eq!(std::fs::read(&slot).unwrap(), bytes, "{kind}");
8549 }
8550 let large = MAX_SEGMENT_BYTES as u64 + 1;
8552 std::fs::File::create(&slot).unwrap().set_len(large).unwrap();
8553 assert_slot_1_is_passed_over(&tmp, &store, &live, "over the size bound", invalid);
8554 assert_eq!(std::fs::metadata(&slot).unwrap().len(), large);
8555 assert_eq!(shelf_files(&dir).len(), 6);
8558 assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 5, "lock, 3 slots, 1 shelf");
8559 assert_forgets_beside_slot_1(&tmp, &store, &live);
8560 assert_eq!(std::fs::metadata(&slot).unwrap().len(), large);
8561 }
8562
8563 #[test]
8564 fn a_legacy_slot_that_is_not_a_private_file_hides_only_itself() {
8565 use std::os::unix::fs::FileTypeExt;
8566 let (tmp, store, live) = legacy_store();
8567 let slot = directory(&tmp, &store).join("001.json");
8568 let kind = || std::fs::symlink_metadata(&slot).unwrap().file_type();
8569 let victim = tmp.path().join("synthetic-victim");
8570 std::fs::write(&victim, "do not change").unwrap();
8571 std::fs::set_permissions(&victim, std::fs::Permissions::from_mode(0o644)).unwrap();
8572 let passed_over = |words: &str, error: io::ErrorKind| {
8575 assert_slot_1_is_passed_over(&tmp, &store, &live, words, error);
8576 assert_eq!(std::fs::read_to_string(&victim).unwrap(), "do not change");
8577 assert_eq!(mode(&victim), 0o644, "{words}");
8578 };
8579 let denied = io::ErrorKind::PermissionDenied;
8580 symlink(&victim, &slot).unwrap();
8581 let looped = store.fs.read_with("001.json", MAX_SEGMENT_BYTES, true).unwrap_err().kind();
8583 passed_over("past a symlink", looped);
8584 assert!(kind().is_symlink());
8585 std::fs::remove_file(&slot).unwrap();
8586 std::fs::hard_link(&victim, &slot).unwrap();
8587 passed_over("past a hard link", denied);
8588 std::fs::remove_file(&slot).unwrap();
8589 let fifo = std::ffi::CString::new(slot.to_str().unwrap()).unwrap();
8590 assert_eq!(unsafe { libc::mkfifo(fifo.as_ptr(), 0o600) }, 0);
8591 passed_over("past a FIFO", denied);
8592 assert!(kind().is_fifo());
8593 std::fs::remove_file(&slot).unwrap();
8594 std::fs::create_dir(&slot).unwrap();
8595 passed_over("past a directory", denied);
8596 assert!(kind().is_dir());
8597 assert_forgets_beside_slot_1(&tmp, &store, &live);
8598 assert!(kind().is_dir());
8599 assert_eq!(std::fs::read_to_string(&victim).unwrap(), "do not change");
8600 }
8601
8602 #[test]
8605 fn a_legacy_tombstone_that_cannot_be_read_no_longer_forbids_its_projection() {
8606 let (tmp, store) = setup();
8607 let dir = directory(&tmp, &store);
8608 let slot = dir.join("000.json");
8609 let gone = seal_legacy_slot(&store, 0, &text("forgotten words"), "");
8610 store.forget(&gone.id).unwrap();
8611 let refused = store.seal(&text("forgotten words"), "").unwrap_err();
8612 assert_eq!(refused.kind(), io::ErrorKind::PermissionDenied);
8613 let tombstone = std::fs::read(&slot).unwrap();
8616 let torn = &tombstone[..tombstone.len() / 2];
8617 std::fs::write(&slot, torn).unwrap();
8618 let refused = store.seal(&text("forgotten words"), "").unwrap_err();
8619 assert_eq!(refused.kind(), io::ErrorKind::PermissionDenied);
8620 std::fs::remove_file(only_archive_file(&dir)).unwrap();
8623 let again = store.seal(&text("forgotten words"), "").unwrap();
8624 assert_eq!(store.fetch(&again.id, 0, 1).unwrap(), text("forgotten words"));
8625 assert_eq!(only_archive_file(&dir), dir.join("s000001").join(archive_name(&again.id)));
8626 assert_eq!(std::fs::read(&slot).unwrap(), torn);
8627 }
8628
8629 #[test]
8633 fn a_failed_directory_read_is_an_error_not_a_shorter_listing() {
8634 use std::os::fd::{AsRawFd, IntoRawFd};
8635 let (tmp, store) = setup();
8636 store.seal(&text("listed"), "").unwrap();
8637 let shelf = store.fs.open_child("s000001", true).unwrap().unwrap();
8638 assert_eq!(shelf.names().unwrap().len(), 1);
8639 let handle = shelf.open(c".", libc::O_RDONLY | libc::O_DIRECTORY);
8641 let fd = handle.unwrap().into_raw_fd();
8642 let stream = unsafe { libc::fdopendir(fd) };
8643 assert!(!stream.is_null());
8644 let file = std::fs::File::open(only_archive_file(&directory(&tmp, &store))).unwrap();
8646 assert_eq!(unsafe { libc::dup2(file.as_raw_fd(), fd) }, fd);
8647 let listed = shelf.read_names(stream);
8648 unsafe { libc::closedir(stream) };
8649 let error = listed.unwrap_err();
8650 #[cfg(target_os = "linux")]
8651 assert_eq!(error.raw_os_error(), Some(libc::ENOTDIR));
8652 assert_ne!(error.raw_os_error(), Some(0));
8653 }
8654
8655 #[test]
8658 fn a_listing_is_not_failed_by_an_error_from_before_it() {
8659 let (_tmp, store) = setup();
8660 store.seal(&text("listed"), "").unwrap();
8661 let shelf = store.fs.open_child("s000001", true).unwrap().unwrap();
8662 assert_eq!(unsafe { libc::close(-1) }, -1);
8663 assert_eq!(io::Error::last_os_error().raw_os_error(), Some(libc::EBADF));
8664 assert_eq!(shelf.names().unwrap().len(), 1);
8665 let empty = store.fs.child("s000002", true).unwrap();
8667 assert_eq!(unsafe { libc::close(-1) }, -1);
8668 assert!(empty.names().unwrap().is_empty());
8669 }
8670
8671 #[test]
8674 fn a_directory_that_cannot_be_opened_again_is_an_error_not_an_empty_listing() {
8675 if unsafe { libc::geteuid() } == 0 {
8676 return; }
8678 let (tmp, store) = setup();
8679 store.seal(&text("listed"), "").unwrap();
8680 let path = directory(&tmp, &store).join("s000001");
8681 let shelf = store.fs.open_child("s000001", true).unwrap().unwrap();
8682 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap();
8683 let listed = shelf.names();
8684 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap();
8685 assert_eq!(listed.unwrap_err().kind(), io::ErrorKind::PermissionDenied);
8686 assert_eq!(shelf.names().unwrap().len(), 1);
8687 }
8688
8689 #[test]
8690 fn names_that_are_not_store_names_are_ignored_whatever_they_look_like() {
8691 use std::os::unix::ffi::OsStringExt;
8692 let (tmp, store) = setup();
8693 let dir = directory(&tmp, &store);
8694 let first = store.seal(&text("first"), "").unwrap();
8695 let legacy = seal_legacy_slot(&store, 0, &text("legacy"), "");
8696 let id = &first.id;
8697 let odd: Vec<std::ffi::OsString> = vec![
8698 "x".repeat(255).into(),
8701 format!("{}.json", "9".repeat(250)).into(),
8702 std::ffi::OsString::from_vec(b"\xff\xfe.json".to_vec()),
8703 "back\\slash.json".into(),
8704 "new\nline.json".into(),
8705 "s00001".into(),
8707 "s0000001".into(),
8708 "S000001".into(),
8709 "s000001.json".into(),
8710 "0.json".into(),
8711 "128.json".into(),
8712 "0000.json".into(),
8713 format!("{}.json", &id[1..]).into(),
8714 format!("{}.json", id.to_uppercase()).into(),
8715 format!("{id}.json.tmp").into(),
8716 format!("{id}.JSON").into(),
8717 format!(".{id}.json").into(),
8718 ".json".into(),
8719 "lock.json".into(),
8720 ];
8721 let mut strays = Vec::new();
8722 for parent in [dir.clone(), dir.join("s000001")] {
8723 for name in &odd {
8724 let path = parent.join(name);
8726 if std::fs::write(&path, b"stray").is_ok() {
8727 strays.push(path);
8728 }
8729 }
8730 }
8731 #[cfg(target_os = "linux")]
8732 assert_eq!(strays.len(), 2 * odd.len());
8733 assert!(strays.len() >= 2 * 10);
8734
8735 let second = store.seal(&text("second"), "").unwrap();
8736 assert!(dir.join("s000001").join(archive_name(&second.id)).is_file());
8737 assert_eq!(store.seal(&text("first"), "").unwrap(), first);
8738 assert_eq!(store.fetch(&first.id, 0, 1).unwrap(), text("first"));
8739 assert_eq!(store.fetch(&legacy.id, 0, 1).unwrap(), text("legacy"));
8740 let mut found: Vec<_> = store.search("", 32).unwrap().into_iter().map(|row| row.id).collect();
8741 found.sort();
8742 let mut expected = vec![first.id.clone(), second.id.clone(), legacy.id.clone()];
8743 expected.sort();
8744 assert_eq!(found, expected);
8745 store.forget(&second.id).unwrap();
8746 assert_eq!(store.shelves().unwrap(), ["s000001"]);
8747 for stray in strays {
8748 assert_eq!(std::fs::read(&stray).unwrap(), b"stray", "{stray:?}");
8749 }
8750 }
8751
8752 #[test]
8755 fn a_failed_check_has_a_name_and_a_read_does_not_say_it() {
8756 let (_tmp, store) = setup();
8757 let id = "0123456789abcdef0123456789abcdef";
8758 let name = archive_name(id);
8759 let bytes = |change: &dyn Fn(&mut Record)| {
8760 record_bytes(&store, id, &[msg("user", json!("a")), msg("user", json!("b"))], change)
8761 };
8762 let swap = |record: &mut Record| {
8763 if let Body::Live { messages, .. } = &mut record.body {
8764 messages.swap(0, 1);
8765 }
8766 };
8767 let many = |record: &mut Record| {
8768 if let Body::Live { source_message_count, .. } = &mut record.body {
8769 *source_message_count = MAX_MESSAGES + 1;
8770 }
8771 };
8772 let long = |record: &mut Record| {
8773 if let Body::Live { note, .. } = &mut record.body {
8774 *note = "n".repeat(MAX_NOTE_BYTES + 1);
8775 }
8776 };
8777 let cases: Vec<(Vec<u8>, &str)> = vec![
8778 (bytes(&|record| record.version = 2), "version 2 is not supported"),
8779 (bytes(&|record| record.scope = "0".repeat(64)), "scope is not this project's"),
8780 (bytes(&|record| record.id = id.to_uppercase()), "invalid id"),
8781 (bytes(&|record| record.logical = "z".repeat(64)), "invalid logical"),
8782 (bytes(&|record| record.digest.truncate(63)), "invalid digest"),
8783 (bytes(&|record| record.id = "f".repeat(32)), "file name is not the record's id"),
8784 (bytes(&many), "over a per-archive limit"),
8785 (bytes(&long), "over a per-archive limit"),
8786 (bytes(&swap), "source_index out of order"),
8787 (
8788 bytes(&|record| {
8789 record.body = Body::Tombstone;
8790 record.created_ms = Some(1);
8791 }),
8792 "a tombstone carries no created_ms",
8793 ),
8794 (b"{\"version\":1".to_vec(), "not an archive record: the JSON ends early (column 12)"),
8795 (b"not json".to_vec(), "not an archive record: not JSON (column 2)"),
8796 ];
8797 for (raw, reason) in cases {
8798 let fault = ArchiveStore::parsed(&store.scope, &name, &raw).err().expect(reason);
8799 assert_eq!(fault.to_string(), reason);
8800 let error = store.record(&name, &raw).err().expect(reason);
8801 assert_eq!(error.kind(), io::ErrorKind::InvalidData, "{reason}");
8802 assert_eq!(error.to_string(), "invalid archive record", "{reason}");
8803 }
8804 let other = digest_of("something else");
8806 let raw = bytes(&|record| record.digest = other.clone());
8807 let reason = ArchiveStore::parsed(&store.scope, &name, &raw).err().unwrap().to_string();
8808 let prefix = format!("digest does not match content (record {other}, content ");
8809 assert!(reason.starts_with(&prefix), "{reason}");
8810 assert_eq!(reason.len(), prefix.len() + 64 + 1);
8811 assert_eq!(store.record(&name, &raw).err().unwrap().to_string(), "invalid archive record");
8812 let mut unknown: Value = serde_json::from_slice(&bytes(&|_| {})).unwrap();
8814 unknown["private words"] = json!("more private words");
8815 let raw = serde_json::to_vec(&unknown).unwrap();
8816 let reason = ArchiveStore::parsed(&store.scope, &name, &raw).err().unwrap().to_string();
8817 assert!(reason.starts_with("not an archive record: an unknown field (column "), "{reason}");
8818 assert!(!reason.contains("private"), "{reason}");
8819 let mut wrong: Value = serde_json::from_slice(&bytes(&|_| {})).unwrap();
8820 wrong["version"] = json!("private words");
8821 let raw = serde_json::to_vec(&wrong).unwrap();
8822 let reason = ArchiveStore::parsed(&store.scope, &name, &raw).err().unwrap().to_string();
8823 assert!(reason.starts_with("not an archive record: a field of the wrong type"), "{reason}");
8824 assert!(!reason.contains("private"), "{reason}");
8825 assert!(ArchiveStore::parsed(&store.scope, &name, &bytes(&|_| {})).is_ok());
8827 assert!(ArchiveStore::parsed(&store.scope, "000.json", &bytes(&|_| {})).is_ok());
8828 }
8829
8830 #[test]
8835 fn a_projection_no_read_would_accept_is_refused_before_it_is_written() {
8836 let (tmp, store) = setup();
8837 let dir = directory(&tmp, &store);
8838 store.seal(&text("a shelf to begin with"), "").unwrap();
8840 let project = |words: &[&str]| {
8841 let messages: Vec<_> = words.iter().map(|words| msg("user", json!(words))).collect();
8842 project_hashed(&store.logical, &messages, "", None, Reduce::None).unwrap()
8843 };
8844 let mut long_note = project(&["a long note"]);
8845 long_note.note = "n".repeat(9_000);
8846 let mut falling = project(&["first", "second"]);
8848 falling.messages.swap(0, 1);
8849 falling.digest = projection_digest(&store.logical, 2, &falling.messages).unwrap();
8850 let mut beyond = project(&["only"]);
8851 beyond.messages[0].source_index = 1;
8852 beyond.digest = projection_digest(&store.logical, 1, &beyond.messages).unwrap();
8853 let before = snapshot(&dir);
8854 let mut names: Vec<_> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().path()).collect();
8855 names.sort();
8856 for (projection, check) in [
8857 (long_note, "over a per-archive limit"),
8858 (falling, "source_index out of order"),
8859 (beyond, "source_index out of order"),
8860 ] {
8861 store.admit(&projection).expect("admit does not check this");
8862 let error = store.seal_projection(projection, None).unwrap_err();
8863 assert_eq!(error.kind(), io::ErrorKind::InvalidInput, "{check}: {error}");
8864 assert!(error.to_string().ends_with(check), "{check}: {error}");
8865 assert_eq!(snapshot(&dir), before, "{check}");
8866 let mut after: Vec<_> =
8867 std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().path()).collect();
8868 after.sort();
8869 assert_eq!(after, names, "{check}");
8870 }
8871 let sealed = store.seal(&text("ordinary"), "a note").unwrap();
8873 assert_eq!(sealed.id, digest_of("ordinary")[..32]);
8874 assert_eq!(store.fetch(&sealed.id, 0, 1).unwrap(), text("ordinary"));
8875 assert_eq!(store.fetch_note(&sealed.id).unwrap(), "a note");
8876 }
8877
8878 fn random_id() -> String {
8881 uuid::Uuid::new_v4().simple().to_string()
8882 }
8883 fn import(store: &ArchiveStore, line: &[u8]) -> Result<Imported, ImportRefused> {
8885 store.import(line, false, false, &mut ImportSeen::default()).unwrap()
8886 }
8887 fn reason(store: &ArchiveStore, line: &[u8]) -> String {
8888 import(store, line).expect_err("refused").reason
8889 }
8890 fn tombstone(record: &mut Record) {
8891 record.body = Body::Tombstone;
8892 }
8893 fn raw_line(store: &ArchiveStore, id: &str, messages: Vec<Value>, note: &str) -> Vec<u8> {
8896 let messages: Vec<_> = messages
8897 .into_iter()
8898 .enumerate()
8899 .map(|(source_index, message)| ArchivedMessage {
8900 source_index,
8901 block_indices: Vec::new(),
8902 message: Arc::new(message),
8903 })
8904 .collect();
8905 let record = Record {
8906 version: 1,
8907 scope: store.scope.clone(),
8908 logical: store.logical.clone(),
8909 id: id.to_owned(),
8910 digest: projection_digest(&store.logical, messages.len(), &messages).unwrap(),
8911 created_ms: None,
8912 body: Body::Live {
8913 source_message_count: messages.len(),
8914 messages,
8915 note: note.to_owned(),
8916 },
8917 };
8918 serde_json::to_vec(&record).unwrap()
8919 }
8920 fn entries(dir: &Path) -> Vec<String> {
8921 let mut names: Vec<_> = std::fs::read_dir(dir)
8922 .unwrap()
8923 .map(|entry| entry.unwrap().file_name().into_string().unwrap())
8924 .collect();
8925 names.sort();
8926 names
8927 }
8928 fn at_ms(ms: u64) -> std::time::SystemTime {
8929 std::time::UNIX_EPOCH + Duration::from_millis(ms)
8930 }
8931
8932 #[test]
8937 fn t_a1_an_imported_record_is_the_file_it_was() {
8938 let (tmp, first) = setup();
8939 let sealed = first.seal_with_note(&text("round trip words"), "a hidden note").unwrap();
8940 let source = only_archive_file(&directory(&tmp, &first));
8941 let line = std::fs::read(&source).unwrap();
8942 let created_ms = json_of(&source)["created_ms"].as_u64().unwrap();
8943
8944 let other = tempfile::tempdir().unwrap();
8945 let second = ArchiveStore::for_scope(other.path(), &first.scope).unwrap();
8946 assert_eq!(second.scope(), first.scope);
8947 assert_eq!(record_scope(&line).unwrap(), first.scope);
8948 let done = import(&second, &line).unwrap();
8949 assert_eq!(
8950 done,
8951 Imported {
8952 outcome: ImportOutcome::Stored,
8953 id: sealed.0.id.clone(),
8954 live: true,
8955 message_count: 1,
8956 shelf: Some("s000001".to_owned()),
8957 same_content_as: None,
8958 unread: None,
8959 }
8960 );
8961 let copy = only_archive_file(&other.path().join("context-archives").join(&first.scope));
8962 let metadata = std::fs::metadata(©).unwrap();
8964 assert_eq!(metadata.modified().unwrap(), at_ms(created_ms));
8965 assert_eq!(metadata.accessed().unwrap(), at_ms(created_ms));
8966 assert_eq!(copy.file_name(), source.file_name());
8967 assert_eq!(std::fs::read(©).unwrap(), line);
8968 assert_eq!(mode(©), 0o600);
8969 let id = &sealed.0.id;
8970 assert_eq!(second.fetch(id, 0, 8).unwrap(), first.fetch(id, 0, 8).unwrap());
8971 assert_eq!(second.fetch_note(id).unwrap(), "a hidden note");
8972 assert_eq!(second.search("round", 8).unwrap(), first.search("round", 8).unwrap());
8973 assert_eq!(second.search("round", 8).unwrap().len(), 1);
8974
8975 let day = 24 * 60 * 60 * 1000;
8977 let ids = [random_id(), random_id(), random_id()];
8978 for index in [1, 2, 0] {
8979 let words = format!("daily entry {index}");
8980 let line = record_bytes(&first, &ids[index], &text(&words), |record| {
8981 record.created_ms = Some(1_600_000_000_000 + index as u64 * day);
8982 });
8983 assert_eq!(import(&second, &line).unwrap().outcome, ImportOutcome::Stored);
8984 }
8985 let found = second.search("daily entry", 8).unwrap();
8986 let found: Vec<_> = found.iter().map(|row| row.id.as_str()).collect();
8987 assert_eq!(found, [&ids[2], &ids[1], &ids[0]], "newest first");
8988 }
8989
8990 #[test]
8994 fn t_a2_a_foreign_id_is_kept_and_no_time_is_invented() {
8995 let (tmp, store) = setup();
8996 let dir = directory(&tmp, &store);
8997 let id = random_id();
8998 let digest = digest_of("kept under its own id");
8999 assert!(id != digest[..32] && id != digest[32..]);
9000 let line = record_bytes(&store, &id, &text("kept under its own id"), |_| {});
9001 let started = std::time::SystemTime::now() - Duration::from_secs(5);
9002 let done = import(&store, &line).unwrap();
9003 assert_eq!((done.outcome, done.id.as_str()), (ImportOutcome::Stored, id.as_str()));
9004 let file = dir.join("s000001").join(archive_name(&id));
9005 assert_eq!(shelf_files(&dir), std::slice::from_ref(&file));
9006 assert!(std::fs::metadata(&file).unwrap().modified().unwrap() >= started);
9007 assert_eq!(std::fs::read(&file).unwrap(), line);
9008 let fields = ["body", "digest", "id", "logical", "scope", "version"];
9009 assert_eq!(top_level(&json_of(&file)), fields);
9010 assert_eq!(store.fetch(&id, 0, 8).unwrap(), text("kept under its own id"));
9011 let found = store.search("its own id", 8).unwrap();
9012 assert_eq!(found.iter().map(|row| row.id.as_str()).collect::<Vec<_>>(), [id.as_str()]);
9013 let project = ArchiveStore::for_scope(tmp.path(), &store.scope).unwrap();
9015 assert_eq!(project.fetch(&id, 0, 8).unwrap(), text("kept under its own id"));
9016 let error = project.seal(&text("a project store has no chat"), "").unwrap_err();
9017 assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
9018 assert_eq!(shelf_files(&dir), [file]);
9019 let sealed = store.seal(&text("kept under its own id"), "").unwrap();
9022 assert_eq!(sealed.id, digest[..32]);
9023 let other = record_bytes(&store, &random_id(), &text("kept under its own id"), |_| {});
9024 let done = import(&store, &other).unwrap();
9025 assert_eq!(done.outcome, ImportOutcome::Stored);
9026 assert_eq!(done.same_content_as, Some(sealed.id));
9027 }
9028
9029 #[test]
9031 fn t_a3_the_same_line_twice_changes_nothing() {
9032 let (tmp, store) = setup();
9033 let dir = directory(&tmp, &store);
9034 let id = random_id();
9035 let line = record_bytes(&store, &id, &text("imported twice"), |record| {
9036 record.created_ms = Some(1_600_000_000_000);
9037 });
9038 assert_eq!(import(&store, &line).unwrap().outcome, ImportOutcome::Stored);
9039 let file = dir.join("s000001").join(archive_name(&id));
9040 let state = || (snapshot(&dir), std::fs::metadata(&file).unwrap().modified().unwrap());
9041 let before = state();
9042 assert_eq!(before.1, at_ms(1_600_000_000_000));
9043 let again = import(&store, &line).unwrap();
9044 assert_eq!(again.outcome, ImportOutcome::Present { note_differs: false });
9045 assert_eq!(again.shelf.as_deref(), Some("s000001"));
9046 assert_eq!(state(), before);
9047 let noted = record_bytes(&store, &id, &text("imported twice"), |record| {
9050 record.created_ms = Some(5);
9051 if let Body::Live { note, .. } = &mut record.body {
9052 *note = "another note".to_owned();
9053 }
9054 });
9055 let again = import(&store, ¬ed).unwrap();
9056 assert_eq!(again.outcome, ImportOutcome::Present { note_differs: true });
9057 assert_eq!(state(), before);
9058 assert_eq!(store.fetch_note(&id).unwrap(), "");
9059 }
9060
9061 #[test]
9064 fn t_a4_a_digest_that_is_not_the_contents_is_refused() {
9065 let (tmp, chat) = setup();
9066 let line = record_bytes(&chat, &random_id(), &text("one byte of this changes"), |_| {});
9067 let text_of = String::from_utf8(line.clone()).unwrap();
9068 let changed = text_of.replace("one byte of this changes", "one byte of this chanGes");
9069 assert_ne!(changed, text_of);
9070 let other = tempfile::tempdir().unwrap();
9071 let store = ArchiveStore::for_scope(other.path(), &chat.scope).unwrap();
9072 let dir = other.path().join("context-archives").join(&chat.scope);
9073 let refused = import(&store, changed.as_bytes()).unwrap_err();
9074 let recorded = digest_of("one byte of this changes");
9075 let content = digest_of("one byte of this chanGes");
9076 assert_eq!(
9077 refused.reason,
9078 format!("digest does not match content (record {recorded}, content {content})")
9079 );
9080 assert!(refused.id.is_some());
9081 assert_eq!(entries(&dir), Vec::<String>::new());
9083 assert_eq!(import(&store, &line).unwrap().outcome, ImportOutcome::Stored);
9085 drop(tmp);
9086 }
9087
9088 #[test]
9091 fn t_a5_each_refusal_has_its_reason_and_writes_nothing() {
9092 let (_tmp, chat) = setup();
9093 let other = tempfile::tempdir().unwrap();
9094 let store = ArchiveStore::for_scope(other.path(), &chat.scope).unwrap();
9095 let dir = other.path().join("context-archives").join(&chat.scope);
9096 let id = random_id();
9097 let two = [msg("user", json!("a")), msg("user", json!("b"))];
9098 let line = |change: &dyn Fn(&mut Record)| record_bytes(&chat, &id, &two, change);
9099 let with = |key: &str, value: Value| {
9100 let mut record: Value = serde_json::from_slice(&line(&|_| {})).unwrap();
9101 record[key] = value;
9102 serde_json::to_vec(&record).unwrap()
9103 };
9104 let note = |length: usize| {
9105 line(&move |record: &mut Record| {
9106 if let Body::Live { note, .. } = &mut record.body {
9107 *note = "n".repeat(length);
9108 }
9109 })
9110 };
9111 let many = vec![json!({"role": "user", "content": "m"}); MAX_MESSAGES + 1];
9112 let mut huge: Value = serde_json::from_slice(&line(&|_| {})).unwrap();
9114 huge["body"]["messages"][0]["message"]["content"] = json!("x".repeat(MAX_SEGMENT_BYTES));
9115 let huge = serde_json::to_vec(&huge).unwrap();
9116 let cases: Vec<(&str, Vec<u8>, &str)> = vec![
9117 ("version", line(&|record| record.version = 2), "version 2 is not supported"),
9118 ("unknown field", with("extra", json!(1)), "not an archive record: an unknown field"),
9119 ("a missing field", b"{\"version\":1}".to_vec(), "not an archive record: a field is"),
9120 ("not JSON", b"not json".to_vec(), "not an archive record: not JSON"),
9121 ("uppercase id", line(&|record| record.id = id.to_uppercase()), "invalid id"),
9122 ("logical", line(&|record| record.logical = "z".repeat(64)), "invalid logical"),
9123 ("digest", line(&|record| record.digest.truncate(60)), "invalid digest"),
9124 (
9125 "an uppercase digest on a tombstone",
9126 line(&|record| {
9127 record.digest = record.digest.to_uppercase();
9128 record.body = Body::Tombstone;
9129 }),
9130 "invalid digest",
9131 ),
9132 ("scope shape", line(&|record| record.scope = "Z".repeat(64)), "invalid scope"),
9133 (
9134 "another project",
9135 line(&|record| record.scope = "0".repeat(64)),
9136 "scope is not this project's",
9137 ),
9138 ("4,097 messages", raw_line(&chat, &id, many, ""), "over a per-archive limit"),
9139 ("a note of 8,193 bytes", note(MAX_NOTE_BYTES + 1), "over a per-archive limit"),
9140 (
9141 "source_index out of order",
9142 line(&|record| {
9143 if let Body::Live { messages, .. } = &mut record.body {
9144 messages.swap(0, 1);
9145 }
9146 }),
9147 "source_index out of order",
9148 ),
9149 (
9150 "a message that is a string",
9151 raw_line(&chat, &id, vec![json!("just text")], ""),
9152 "message is not an object",
9153 ),
9154 (
9155 "a tombstone with created_ms",
9156 line(&|record| {
9157 record.body = Body::Tombstone;
9158 record.created_ms = Some(1);
9159 }),
9160 "a tombstone carries no created_ms",
9161 ),
9162 (
9163 "over 16 MiB",
9164 huge,
9165 "over the 16 MiB archive size",
9166 ),
9167 (
9168 "a note tombstone line",
9169 b"{\"tombstone\":\"mem-0123\",\"timestamp_ms\":1}".to_vec(),
9170 "not an archive record: an unknown field",
9171 ),
9172 ];
9173 for (what, line, expected) in &cases {
9174 let reason = reason(&store, line);
9175 assert!(reason.starts_with(expected), "{what}: {reason}");
9176 let mut seen = ImportSeen::default();
9178 let dry = store.import(line, false, true, &mut seen).unwrap().unwrap_err();
9179 assert_eq!(dry.reason, reason, "{what}");
9180 assert_eq!(entries(&dir), Vec::<String>::new(), "{what}");
9181 }
9182 assert_eq!(import(&store, ¬e(MAX_NOTE_BYTES)).unwrap().outcome, ImportOutcome::Stored);
9184 let system = random_id();
9186 let line = raw_line(&chat, &system, vec![json!({"role": "system", "content": "s"})], "");
9187 assert_eq!(import(&store, &line).unwrap().outcome, ImportOutcome::Stored);
9188 assert_eq!(store.fetch(&system, 0, 1).unwrap(), [msg("system", json!("s"))]);
9189 let routed = record_scope(b"{\"tombstone\":\"mem-0123\",\"timestamp_ms\":1}").unwrap_err();
9191 assert_eq!(routed.reason, "not an archive record: a field is missing (column 41)");
9192 let routed = record_scope(&with("scope", json!("../../etc"))).unwrap_err();
9193 assert_eq!(routed.reason, "invalid scope");
9194 for scope in ["../../etc", "", &"A".repeat(64)] {
9195 assert!(ArchiveStore::for_scope(other.path(), scope).is_err());
9196 assert!(ArchiveStore::existing_scope(other.path(), scope).is_err());
9197 }
9198 assert_eq!(project_scope("synthetic-project").unwrap(), chat.scope);
9199 }
9200
9201 #[test]
9204 fn t_a6_an_id_that_is_taken_is_never_written_over() {
9205 let (tmp, store) = setup();
9206 let dir = directory(&tmp, &store);
9207 let id = random_id();
9208 assert_eq!(
9209 import(&store, &record_bytes(&store, &id, &text("first content"), |_| {}))
9210 .unwrap()
9211 .outcome,
9212 ImportOutcome::Stored
9213 );
9214 let before = snapshot(&dir);
9215 for change in [(|_: &mut Record| {}) as fn(&mut Record), tombstone] {
9216 let other = record_bytes(&store, &id, &text("second content"), change);
9217 let refused = import(&store, &other).unwrap_err();
9218 assert_eq!(refused.reason, "id already holds different content");
9219 assert_eq!(refused.id.as_deref(), Some(id.as_str()));
9220 assert_eq!(snapshot(&dir), before);
9221 }
9222 assert_eq!(store.fetch(&id, 0, 1).unwrap(), text("first content"));
9223
9224 let odd = random_id();
9226 let file = dir.join("s000001").join(archive_name(&odd));
9227 std::fs::write(&file, b"{\"cut\": \"short").unwrap();
9228 std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o600)).unwrap();
9229 let before = snapshot(&dir);
9230 let line = record_bytes(&store, &odd, &text("under a damaged name"), |_| {});
9231 for dry_run in [true, false] {
9232 let stopped = store.import(&line, false, dry_run, &mut ImportSeen::default());
9233 let error = stopped.expect_err("the run stops");
9234 assert_eq!(error.kind(), io::ErrorKind::InvalidData, "{error}");
9235 assert_eq!(snapshot(&dir), before);
9236 }
9237 std::fs::write(dir.join("s000002"), b"not a directory").unwrap();
9239 let line = record_bytes(&store, &random_id(), &text("behind an odd shelf"), |_| {});
9240 let stopped = store.import(&line, false, false, &mut ImportSeen::default());
9241 assert!(stopped.is_err());
9242 assert_eq!(shelf_files(&dir).len(), 2);
9243 }
9244
9245 #[test]
9248 fn t_a7_tombstones_win_in_both_directions() {
9249 let (tmp, store) = setup();
9250 let dir = directory(&tmp, &store);
9251 let id = random_id();
9252 let live = record_bytes(&store, &id, &text("to be forgotten"), |record| {
9253 record.created_ms = Some(1_600_000_000_000);
9254 });
9255 let dead = record_bytes(&store, &id, &text("to be forgotten"), tombstone);
9256 assert_eq!(import(&store, &live).unwrap().outcome, ImportOutcome::Stored);
9257 let done = import(&store, &dead).unwrap();
9258 assert_eq!((done.outcome, done.live, done.message_count), (ImportOutcome::Forgotten, false, 0));
9259 assert_eq!(done.unread, None);
9260 let file = dir.join("s000001").join(archive_name(&id));
9261 assert!(is_tombstone_of(&store, &file, &digest_of("to be forgotten")));
9262 let fields = ["body", "digest", "id", "logical", "scope", "version"];
9263 assert_eq!(top_level(&json_of(&file)), fields);
9264 assert_eq!(store.fetch(&id, 0, 1).unwrap_err().kind(), io::ErrorKind::NotFound);
9265 let before = snapshot(&dir);
9267 let kept = import(&store, &live).unwrap();
9268 assert_eq!((kept.outcome, kept.live), (ImportOutcome::KeptForgotten, true));
9269 let again = import(&store, &dead).unwrap();
9270 assert_eq!(again.outcome, ImportOutcome::Present { note_differs: false });
9271 assert_eq!(snapshot(&dir), before);
9272
9273 let (tmp, store) = setup();
9275 let dir = directory(&tmp, &store);
9276 let done = import(&store, &dead).unwrap();
9277 assert_eq!((done.outcome, done.shelf.as_deref()), (ImportOutcome::Stored, Some("s000001")));
9278 let file = dir.join("s000001").join(archive_name(&id));
9279 assert_eq!(std::fs::read(&file).unwrap(), dead);
9280 assert_eq!(store.fetch(&id, 0, 1).unwrap_err().kind(), io::ErrorKind::NotFound);
9281 assert_eq!(import(&store, &live).unwrap().outcome, ImportOutcome::KeptForgotten);
9282
9283 let sealed = store.seal(&text("sealed and forgotten"), "").unwrap();
9286 store.forget(&sealed.id).unwrap();
9287 let before = snapshot(&dir);
9288 let line = record_bytes(&store, &random_id(), &text("sealed and forgotten"), |_| {});
9289 assert_eq!(reason(&store, &line), "this content was forgotten in this store");
9290 assert_eq!(snapshot(&dir), before);
9291 let sealed = store.seal(&text("live under its own id"), "").unwrap();
9294 let before = snapshot(&dir);
9295 let line = record_bytes(&store, &random_id(), &text("live under its own id"), tombstone);
9296 let refused = reason(&store, &line);
9297 assert!(refused.contains(&sealed.id) && refused.starts_with("a tombstone of content"));
9298 assert_eq!(snapshot(&dir), before);
9299 assert_eq!(store.fetch(&sealed.id, 0, 1).unwrap(), text("live under its own id"));
9300 }
9301
9302 fn a_run(chat: &ArchiveStore, scope: &str) -> Vec<Vec<u8>> {
9305 let ids: Vec<_> = (0..7).map(|_| random_id()).collect();
9306 let line = |id: &String, words: &str, change: fn(&mut Record)| {
9307 record_bytes(chat, id, &text(words), |record| {
9308 scope.clone_into(&mut record.scope);
9309 change(record);
9310 })
9311 };
9312 let live = |_: &mut Record| {};
9313 vec![
9314 line(&ids[0], "first", live),
9315 line(&ids[0], "first", live),
9316 line(&ids[0], "other content under the first id", live),
9317 line(&ids[1], "second", live),
9318 line(&ids[1], "second", tombstone),
9319 line(&ids[1], "second", live),
9320 line(&ids[2], "third", tombstone),
9321 line(&ids[2], "third", live),
9322 line(&ids[2], "third", tombstone),
9323 line(&ids[3], "fourth", tombstone),
9324 line(&ids[4], "fourth", live),
9325 line(&ids[5], "fifth", live),
9326 line(&ids[6], "fifth", tombstone),
9327 b"not a record".to_vec(),
9328 ]
9329 }
9330
9331 #[test]
9334 fn t_a8_rehoming_and_a_dry_run_that_writes_nothing() {
9335 let (tmp, chat) = setup();
9336 let target = project_scope("another-project").unwrap();
9337 assert_ne!(target, chat.scope);
9338 let line = record_bytes(&chat, &random_id(), &text("moved between projects"), |_| {});
9339 let id = serde_json::from_slice::<Value>(&line).unwrap()["id"].as_str().unwrap().to_owned();
9340 let store = ArchiveStore::for_scope(tmp.path(), &target).unwrap();
9341 let mut seen = ImportSeen::default();
9342 let refused = store.import(&line, false, false, &mut seen).unwrap().unwrap_err();
9343 assert_eq!(refused.reason, "scope is not this project's");
9344 let done = store.import(&line, true, false, &mut seen).unwrap().unwrap();
9345 assert_eq!(done.outcome, ImportOutcome::Stored);
9346 let dir = tmp.path().join("context-archives").join(&target);
9347 let file = only_archive_file(&dir);
9348 let mut expected: Value = serde_json::from_slice(&line).unwrap();
9349 expected["scope"] = json!(target);
9350 assert_eq!(json_of(&file), expected, "only the scope changed");
9351 assert_eq!(store.fetch(&id, 0, 1).unwrap(), text("moved between projects"));
9352 assert_eq!(shelf_files(&directory(&tmp, &chat)), Vec::<PathBuf>::new());
9353
9354 let base = tmp.path().join("no such base");
9356 let lines = a_run(&chat, &chat.scope);
9357 assert!(ArchiveStore::existing_scope(&base, &chat.scope).unwrap().is_none());
9358 let mut seen = ImportSeen::default();
9359 let dry: Vec<_> = lines
9360 .iter()
9361 .map(|line| ArchiveStore::import_dry_absent(&chat.scope, line, false, &mut seen).unwrap())
9362 .collect();
9363 assert!(!base.exists());
9364 let store = ArchiveStore::for_scope(&base, &chat.scope).unwrap();
9365 let mut unused = ImportSeen::default();
9366 let real: Vec<_> =
9367 lines.iter().map(|line| store.import(line, false, false, &mut unused).unwrap()).collect();
9368 assert_eq!(dry, real);
9369 let outcomes: Vec<_> = real
9370 .iter()
9371 .map(|done| match done {
9372 Ok(done) => format!("{:?}", done.outcome),
9373 Err(refused) => refused.reason.clone(),
9374 })
9375 .collect();
9376 assert_eq!(
9377 outcomes[..9],
9378 [
9379 "Stored",
9380 "Present { note_differs: false }",
9381 "id already holds different content",
9382 "Stored",
9383 "Forgotten",
9384 "KeptForgotten",
9385 "Stored",
9386 "KeptForgotten",
9387 "Present { note_differs: false }",
9388 ]
9389 );
9390 assert_eq!(outcomes[9], "Stored");
9391 assert_eq!(outcomes[10], "this content was forgotten in this store");
9393 assert_eq!(outcomes[11], "Stored");
9395 assert!(outcomes[12].starts_with("a tombstone of content that is live in this store as"));
9396 assert!(outcomes[13].starts_with("not an archive record"));
9397
9398 let dir = base.join("context-archives").join(&chat.scope);
9402 std::fs::remove_file(dir.join("lock")).unwrap();
9403 let open = std::fs::Permissions::from_mode(0o755);
9404 let files = shelf_files(&dir);
9405 for path in [dir.clone(), dir.join("s000001")] {
9406 std::fs::set_permissions(&path, open.clone()).unwrap();
9407 }
9408 for file in &files {
9409 std::fs::set_permissions(file, std::fs::Permissions::from_mode(0o640)).unwrap();
9410 }
9411 let before = (snapshot(&dir), entries(&dir));
9412 let existing = ArchiveStore::existing_scope(&base, &chat.scope).unwrap().unwrap();
9413 let mut seen = ImportSeen::default();
9414 let dry: Vec<_> =
9415 lines.iter().map(|line| existing.import(line, false, true, &mut seen).unwrap()).collect();
9416 assert_eq!((snapshot(&dir), entries(&dir)), before);
9417 assert!(!dir.join("lock").exists());
9418 assert_eq!(mode(&dir), 0o755);
9419 assert_eq!(mode(&dir.join("s000001")), 0o755);
9420 for file in &files {
9421 assert_eq!(mode(file), 0o640);
9422 }
9423 for file in &files {
9424 std::fs::set_permissions(file, std::fs::Permissions::from_mode(0o600)).unwrap();
9425 }
9426 let real: Vec<_> =
9427 lines.iter().map(|line| store.import(line, false, false, &mut unused).unwrap()).collect();
9428 assert_eq!(dry, real);
9429 assert!(real[..2].iter().all(|done| {
9430 done.as_ref().unwrap().outcome == ImportOutcome::Present { note_differs: false }
9431 }));
9432 }
9433
9434 #[test]
9437 fn t_a_shelves_an_import_goes_on_in_the_next_shelf() {
9438 let (tmp, chat) = setup();
9439 let dir = directory(&tmp, &chat);
9440 let store = ArchiveStore::for_scope(tmp.path(), &chat.scope).unwrap();
9441 let total = SHELF_FILES + 2;
9442 let ids: Vec<_> = (0..total).map(|_| random_id()).collect();
9443 let lines: Vec<_> = ids
9444 .iter()
9445 .enumerate()
9446 .map(|(index, id)| {
9447 record_bytes(&chat, id, &text(&format!("archive {index}")), |record| {
9448 record.created_ms = Some(1_600_000_000_000 + index as u64);
9449 })
9450 })
9451 .collect();
9452 let mut seen = ImportSeen::default();
9453 let run = |dry_run: bool, seen: &mut ImportSeen| -> Vec<String> {
9454 let mut shelves = Vec::new();
9455 for line in &lines {
9456 let done = store.import(line, false, dry_run, seen).unwrap().unwrap();
9457 shelves.push(done.shelf.unwrap());
9458 }
9459 shelves
9460 };
9461 let before = (snapshot(&dir), entries(&dir));
9462 let dry = run(true, &mut seen);
9463 assert_eq!((snapshot(&dir), entries(&dir)), before);
9464 let real = run(false, &mut seen);
9465 assert_eq!(dry, real);
9466 assert!(real[..SHELF_FILES].iter().all(|shelf| shelf == "s000001"));
9467 assert_eq!(real[SHELF_FILES..], ["s000002", "s000002"]);
9468 let on = |shelf: &str| {
9469 let mut names = entries(&dir.join(shelf));
9470 names.retain(|name| name.ends_with(".json"));
9471 names
9472 };
9473 let mut first: Vec<_> = ids[..SHELF_FILES].iter().map(|id| archive_name(id)).collect();
9474 let mut second: Vec<_> = ids[SHELF_FILES..].iter().map(|id| archive_name(id)).collect();
9475 first.sort();
9476 second.sort();
9477 assert_eq!(on("s000001"), first);
9478 assert_eq!(on("s000002"), second);
9479 for (index, id) in ids.iter().enumerate() {
9481 assert_eq!(store.fetch(id, 0, 1).unwrap(), text(&format!("archive {index}")));
9482 }
9483 assert!(run(false, &mut seen).iter().zip(&real).all(|(again, first)| again == first));
9486 let more = record_bytes(&chat, &random_id(), &text("one more"), |_| {});
9487 let mut seen = ImportSeen::default();
9488 let planned = store.import(&more, false, true, &mut seen).unwrap().unwrap();
9489 assert_eq!(planned.shelf.as_deref(), Some("s000002"));
9490 let sealed = chat.seal(&text("sealed after the import"), "").unwrap();
9492 assert!(dir.join("s000002").join(archive_name(&sealed.id)).is_file());
9493 assert_eq!(on("s000002").len(), 3);
9494 }
9495
9496 #[test]
9500 fn t_a9_a_forgotten_import_cannot_be_sealed_again() {
9501 let words = "imported and then forgotten";
9502 let digest = digest_of(words);
9503 let refused = |store: &ArchiveStore| {
9504 let error = store.seal(&text(words), "").unwrap_err();
9505 assert_eq!(error.kind(), io::ErrorKind::PermissionDenied, "{error}");
9506 assert!(error.to_string().contains("was forgotten"), "{error}");
9507 };
9508
9509 let (tmp, store) = setup();
9511 let dir = directory(&tmp, &store);
9512 let id = random_id();
9513 let line = record_bytes(&store, &id, &text(words), |_| {});
9514 assert_eq!(import(&store, &line).unwrap().outcome, ImportOutcome::Stored);
9515 std::fs::create_dir(dir.join("s000002")).unwrap();
9516 std::fs::set_permissions(dir.join("s000002"), std::fs::Permissions::from_mode(0o700))
9517 .unwrap();
9518 assert_eq!(store.forget(&id).unwrap(), forgotten(&[&id]));
9519 let guard = dir.join("s000002").join(archive_name(&digest[..32]));
9520 let file = dir.join("s000001").join(archive_name(&id));
9521 assert_eq!(shelf_files(&dir), [file.clone(), guard.clone()]);
9522 assert!(is_tombstone_of(&store, &guard, &digest));
9523 assert!(is_tombstone_of(&store, &file, &digest));
9524 refused(&store);
9525 assert_eq!(import(&store, &line).unwrap().outcome, ImportOutcome::KeptForgotten);
9527 let other = record_bytes(&store, &random_id(), &text(words), |_| {});
9528 assert_eq!(reason(&store, &other), "this content was forgotten in this store");
9529 assert_eq!(shelf_files(&dir).len(), 2);
9530
9531 let (tmp, store) = setup();
9534 let dir = directory(&tmp, &store);
9535 let dead = record_bytes(&store, &id, &text(words), tombstone);
9536 let done = import(&store, &dead).unwrap();
9537 assert_eq!((done.outcome, done.shelf.as_deref()), (ImportOutcome::Stored, Some("s000001")));
9538 let guard = dir.join("s000001").join(archive_name(&digest[..32]));
9539 let file = dir.join("s000001").join(archive_name(&id));
9540 let mut both = [file.clone(), guard.clone()];
9541 both.sort();
9542 assert_eq!(shelf_files(&dir), both);
9543 assert!(is_tombstone_of(&store, &guard, &digest));
9544 assert_eq!(std::fs::read(&file).unwrap(), dead);
9545 refused(&store);
9546 assert_eq!(reason(&store, &other), "this content was forgotten in this store");
9547 assert_eq!(import(&store, &dead).unwrap().outcome, ImportOutcome::Present { note_differs: false });
9550 let again = record_bytes(&store, &random_id(), &text(words), tombstone);
9551 assert_eq!(import(&store, &again).unwrap().outcome, ImportOutcome::Stored);
9552 assert_eq!(shelf_files(&dir).len(), 3);
9553 let (tmp, store) = setup();
9555 let own = record_bytes(&store, &digest[32..], &text(words), tombstone);
9556 assert_eq!(import(&store, &own).unwrap().outcome, ImportOutcome::Stored);
9557 assert_eq!(shelf_files(&directory(&tmp, &store)).len(), 1);
9558 refused(&store);
9559
9560 let (tmp, store) = setup();
9563 let dir = directory(&tmp, &store);
9564 store.seal(&text("a shelf to begin with"), "").unwrap();
9565 let file = dir.join("s000001").join(archive_name(&id));
9566 std::fs::write(&file, &dead).unwrap();
9567 std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o600)).unwrap();
9568 assert_eq!(store.seal(&text(words), "").unwrap().id, digest[..32], "not seen by a seal");
9569 let (tmp, store) = (drop(tmp), setup()).1;
9570 let dir = directory(&tmp, &store);
9571 store.seal(&text("a shelf to begin with"), "").unwrap();
9572 let file = dir.join("s000001").join(archive_name(&id));
9573 std::fs::write(&file, &dead).unwrap();
9574 std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o600)).unwrap();
9575 assert_eq!(store.forget(&id).unwrap(), forgotten(&[]));
9576 assert!(is_tombstone_of(&store, &dir.join("s000001").join(archive_name(&digest[..32])), &digest));
9577 refused(&store);
9578
9579 let (tmp, store) = setup();
9581 let sealed = store.seal(&text(words), "").unwrap();
9582 store.forget(&sealed.id).unwrap();
9583 assert_eq!(shelf_files(&directory(&tmp, &store)).len(), 1);
9584 refused(&store);
9585 }
9586
9587 #[test]
9591 fn t_a9_forgetting_either_of_a_native_and_an_imported_copy_forgets_both() {
9592 let words = "sealed here and imported too";
9593 for forget_the_import in [true, false] {
9594 let (tmp, store) = setup();
9595 let dir = directory(&tmp, &store);
9596 let sealed = store.seal(&text(words), "").unwrap();
9597 for index in 0..SHELF_FILES {
9600 store.seal(&text(&format!("filler {index}")), "").unwrap();
9601 }
9602 let id = random_id();
9603 let done = import(&store, &record_bytes(&store, &id, &text(words), |_| {})).unwrap();
9604 assert_eq!(done.same_content_as.as_deref(), Some(sealed.id.as_str()));
9605 assert_eq!(done.shelf.as_deref(), Some("s000002"));
9606 let before = shelf_files(&dir);
9607 let asked = if forget_the_import { &id } else { &sealed.id };
9608 let done = store.forget(asked).unwrap();
9609 let mut ids = done.ids.clone();
9610 ids.sort();
9611 let mut expected = [id.clone(), sealed.id.clone()];
9612 expected.sort();
9613 assert_eq!(ids, expected, "forgetting {asked}");
9614 assert_eq!(done.ids[0], *asked);
9615 assert_eq!(done.unread, None);
9616 assert_eq!(shelf_files(&dir), before, "no third file");
9617 let digest = digest_of(words);
9618 assert!(is_tombstone_of(&store, &dir.join("s000001").join(archive_name(&sealed.id)), &digest));
9619 assert!(is_tombstone_of(&store, &dir.join("s000002").join(archive_name(&id)), &digest));
9620 for gone in [&id, &sealed.id] {
9621 assert_eq!(store.fetch(gone, 0, 1).unwrap_err().kind(), io::ErrorKind::NotFound);
9622 }
9623 assert!(store.search("imported too", 8).unwrap().is_empty());
9624 assert_eq!(
9625 store.seal(&text(words), "").unwrap_err().kind(),
9626 io::ErrorKind::PermissionDenied
9627 );
9628 assert_eq!(store.fetch(&store.seal(&text("filler 0"), "").unwrap().id, 0, 1).unwrap(), text("filler 0"));
9630 assert_eq!(store.forget(asked).unwrap(), forgotten(&[]));
9632 }
9633 }
9634
9635 #[test]
9639 fn a_forget_says_when_a_file_under_another_id_names_its_content_and_is_no_record() {
9640 let (tmp, store) = setup();
9641 let dir = directory(&tmp, &store);
9642 let sealed = store.seal(&text("with a damaged twin"), "").unwrap();
9643 let shelf = dir.join("s000001");
9644 let private = std::fs::Permissions::from_mode(0o600);
9645 let stray = shelf.join(archive_name(&random_id()));
9646 std::fs::write(&stray, b"not json at all").unwrap();
9647 std::fs::set_permissions(&stray, private.clone()).unwrap();
9648 let twin = shelf.join(archive_name(&random_id()));
9649 let damaged = format!("{{\"digest\":\"{}\",\"body\":\"cut\"}}", digest_of("with a damaged twin"));
9650 std::fs::write(&twin, damaged).unwrap();
9651 std::fs::set_permissions(&twin, private).unwrap();
9652 let done = store.forget(&sealed.id).unwrap();
9653 assert_eq!(done.ids, std::slice::from_ref(&sealed.id));
9654 let unread = done.unread.expect("not complete");
9655 let name = twin.file_name().unwrap().to_str().unwrap();
9656 assert!(unread.starts_with(&format!("1 place(s) could not be read, first s000001/{name}: ")), "{unread}");
9657 assert!(unread.ends_with(&format!(" (in {})", dir.display())), "{unread}");
9659 assert_eq!(std::fs::read(&stray).unwrap(), b"not json at all");
9660 }
9661
9662 #[test]
9667 fn a_tombstone_line_is_refused_while_its_content_is_live_under_another_imported_id() {
9668 let words = "live under an imported id";
9669 let (a, b) = (random_id(), random_id());
9670 let refusal = format!(
9671 "a tombstone of content that is live in this store as {a}: forget that archive \
9672 here, then import again"
9673 );
9674 for dry_first in [false, true] {
9675 let (tmp, store) = setup();
9676 let dir = directory(&tmp, &store);
9677 let live = record_bytes(&store, &a, &text(words), |_| {});
9678 let dead = record_bytes(&store, &b, &text(words), tombstone);
9679 let mut seen = ImportSeen::default();
9680 if dry_first {
9681 let planned = store.import(&live, false, true, &mut seen).unwrap().unwrap();
9683 assert_eq!(planned.outcome, ImportOutcome::Stored);
9684 let refused = store.import(&dead, false, true, &mut seen).unwrap().unwrap_err();
9685 assert_eq!(refused.reason, refusal);
9686 assert_eq!(shelf_files(&dir), Vec::<PathBuf>::new());
9687 }
9688 assert_eq!(import(&store, &live).unwrap().outcome, ImportOutcome::Stored);
9689 let before = snapshot(&dir);
9690 for dry_run in [true, false] {
9692 let mut seen = ImportSeen::default();
9693 let refused = store.import(&dead, false, dry_run, &mut seen).unwrap().unwrap_err();
9694 assert_eq!((refused.reason.as_str(), refused.id.as_deref()), (&*refusal, Some(&*b)));
9695 assert_eq!(snapshot(&dir), before);
9696 }
9697 assert_eq!(store.fetch(&a, 0, 1).unwrap(), text(words));
9698 std::fs::create_dir(dir.join("s000002")).unwrap();
9700 assert_eq!(reason(&store, &dead), refusal);
9701 assert_eq!(store.fetch(&a, 0, 1).unwrap(), text(words));
9702 }
9703 let (_tmp, store) = setup();
9705 let live = record_bytes(&store, &a, &text(words), |_| {});
9706 let dead = record_bytes(&store, &b, &text(words), tombstone);
9707 for dry_run in [true, false] {
9708 let mut seen = ImportSeen::default();
9709 let stored = store.import(&dead, false, dry_run, &mut seen).unwrap().unwrap();
9710 assert_eq!(stored.outcome, ImportOutcome::Stored);
9711 let refused = match dry_run {
9712 true => store.import(&live, false, true, &mut seen).unwrap().unwrap_err(),
9713 false => import(&store, &live).unwrap_err(),
9714 };
9715 assert_eq!(refused.reason, "this content was forgotten in this store");
9716 }
9717 assert_eq!(store.fetch(&a, 0, 1).unwrap_err().kind(), io::ErrorKind::NotFound);
9718 }
9719
9720 #[test]
9725 fn a_forget_is_not_complete_over_a_file_under_another_id_it_failed_to_read() {
9726 let words = "a twin that is not read";
9727 let (tmp, store) = setup();
9728 let dir = directory(&tmp, &store);
9729 let sealed = store.seal(&text(words), "").unwrap();
9730 let twin = random_id();
9731 let line = record_bytes(&store, &twin, &text(words), |_| {});
9732 assert_eq!(import(&store, &line).unwrap().outcome, ImportOutcome::Stored);
9733 let name = archive_name(&twin);
9734 let dead = record_bytes(&store, &random_id(), &text("only the twin"), tombstone);
9735 let lone_id = random_id();
9736 let lone = record_bytes(&store, &lone_id, &text("only the twin"), |_| {});
9737 assert_eq!(import(&store, &lone).unwrap().outcome, ImportOutcome::Stored);
9738 for code in [libc::EIO, libc::EMFILE, libc::ENOMEM] {
9739 let failing = Fault::on("open", &twin, code);
9740 let done = store.forget(&sealed.id).unwrap();
9741 let unread = done.unread.expect("not complete");
9742 let start = format!("1 place(s) could not be read, first s000001/{name}: ");
9743 assert!(unread.starts_with(&start), "{unread}");
9744 drop(failing);
9745 assert_eq!(store.fetch(&twin, 0, 1).unwrap(), text(words), "error {code}");
9746 let failing = Fault::on("open", &lone_id, code);
9748 let before = snapshot(&dir);
9749 let stopped = store.import(&dead, false, false, &mut ImportSeen::default());
9750 let error = stopped.expect_err("the run stops").to_string();
9751 assert!(error.starts_with("a tombstone is not imported while"), "{error}");
9752 drop(failing);
9753 assert_eq!(snapshot(&dir), before);
9754 }
9755 let done = store.forget(&sealed.id).unwrap();
9757 assert_eq!((done.ids, done.unread), (vec![twin.clone()], None));
9758 assert_eq!(store.fetch(&twin, 0, 1).unwrap_err().kind(), io::ErrorKind::NotFound);
9759
9760 let (tmp, store) = setup();
9762 let dir = directory(&tmp, &store);
9763 let sealed = store.seal(&text(words), "").unwrap();
9764 let link = dir.join("s000001").join(archive_name(&random_id()));
9765 std::os::unix::fs::symlink(only_archive_file(&dir), &link).unwrap();
9766 let closed = dir.join("s000001").join(archive_name(&random_id()));
9768 if unsafe { libc::geteuid() } != 0 {
9769 std::fs::write(&closed, record_bytes(&store, &random_id(), &text("x"), |_| {})).unwrap();
9770 std::fs::set_permissions(&closed, std::fs::Permissions::from_mode(0o000)).unwrap();
9771 }
9772 let done = store.forget(&sealed.id).unwrap();
9773 assert_eq!((done.ids, done.unread), (vec![sealed.id.clone()], None));
9774 }
9775
9776 #[test]
9779 fn a_created_ms_that_has_not_come_is_refused() {
9780 let (tmp, store) = setup();
9781 let dir = directory(&tmp, &store);
9782 let hour = 60 * 60 * 1000;
9783 let at = |ahead_ms: u64| {
9784 record_bytes(&store, &random_id(), &text("dated ahead"), |record| {
9785 record.created_ms = Some(now_ms() + ahead_ms);
9786 })
9787 };
9788 for line in [at(25 * hour), at(u64::MAX / 2), at(366 * 24 * hour)] {
9789 let refused = import(&store, &line).unwrap_err();
9790 assert_eq!(refused.reason, "created_ms is more than a day after this machine's clock");
9791 let mut seen = ImportSeen::default();
9792 let dry = store.import(&line, false, true, &mut seen).unwrap().unwrap_err();
9793 assert_eq!(dry, refused);
9794 }
9795 assert_eq!(shelf_files(&dir), Vec::<PathBuf>::new());
9796 assert_eq!(import(&store, &at(23 * hour)).unwrap().outcome, ImportOutcome::Stored);
9797 }
9798
9799 #[test]
9804 fn an_imported_file_gets_its_old_time_after_its_name() {
9805 let (tmp, store) = setup();
9806 let dir = directory(&tmp, &store);
9807 let id = random_id();
9808 let line = record_bytes(&store, &id, &text("dated after the rename"), |record| {
9809 record.created_ms = Some(1_600_000_000_000);
9810 });
9811 let started = std::time::SystemTime::now() - Duration::from_secs(5);
9812 let stopping = Fault::on("date", &id, libc::EIO);
9813 let stopped = store.import(&line, false, false, &mut ImportSeen::default());
9814 assert_eq!(stopped.expect_err("stopped").raw_os_error(), Some(libc::EIO));
9815 drop(stopping);
9816 let file = dir.join("s000001").join(archive_name(&id));
9817 assert_eq!(shelf_files(&dir), std::slice::from_ref(&file));
9818 assert_eq!(entries(&dir.join("s000001")), [archive_name(&id)]);
9819 assert!(std::fs::metadata(&file).unwrap().modified().unwrap() >= started);
9820 assert_eq!(std::fs::read(&file).unwrap(), line);
9821 assert_eq!(store.fetch(&id, 0, 1).unwrap(), text("dated after the rename"));
9822 let again = import(&store, &line).unwrap();
9823 assert_eq!(again.outcome, ImportOutcome::Present { note_differs: false });
9824 let other = random_id();
9826 let line = record_bytes(&store, &other, &text("dated and not stopped"), |record| {
9827 record.created_ms = Some(1_600_000_000_000);
9828 });
9829 assert_eq!(import(&store, &line).unwrap().outcome, ImportOutcome::Stored);
9830 let file = dir.join("s000001").join(archive_name(&other));
9831 assert_eq!(std::fs::metadata(&file).unwrap().modified().unwrap(), at_ms(1_600_000_000_000));
9832 }
9833
9834 #[test]
9838 fn an_import_that_finds_its_record_confirms_it_on_disk() {
9839 let (_tmp, store) = setup();
9840 let id = random_id();
9841 let live = record_bytes(&store, &id, &text("found and confirmed"), |_| {});
9842 let gone = random_id();
9843 let dead = record_bytes(&store, &gone, &text("kept forgotten"), tombstone);
9844 let kept = record_bytes(&store, &gone, &text("kept forgotten"), |_| {});
9845 assert_eq!(import(&store, &live).unwrap().outcome, ImportOutcome::Stored);
9846 assert_eq!(import(&store, &dead).unwrap().outcome, ImportOutcome::Stored);
9847 let present = ImportOutcome::Present { note_differs: false };
9848 for (line, outcome) in [(&live, present), (&kept, ImportOutcome::KeptForgotten)] {
9849 let failing = Fault::on("sync", "", libc::EIO);
9850 let mut seen = ImportSeen::default();
9851 let stopped = store.import(line, false, false, &mut seen);
9852 assert_eq!(stopped.expect_err("synced").raw_os_error(), Some(libc::EIO));
9853 let dry = store.import(line, false, true, &mut seen).unwrap().unwrap();
9854 assert_eq!(dry.outcome, outcome);
9855 drop(failing);
9856 assert_eq!(import(&store, line).unwrap().outcome, outcome);
9857 }
9858 }
9859
9860 #[test]
9864 fn a_base_that_links_to_a_folder_that_is_away_is_an_error_not_an_empty_store() {
9865 let tmp = tempfile::tempdir().unwrap();
9866 let root = std::fs::canonicalize(tmp.path()).unwrap();
9867 let disk = root.join("disk");
9868 let base = root.join("base");
9869 std::fs::create_dir(&disk).unwrap();
9870 symlink(&disk, &base).unwrap();
9871 let store = setup_in(&base, "synthetic-project");
9872 let sealed = store.seal(&text("on the other disk"), "").unwrap();
9873 drop(store);
9874 std::fs::rename(&disk, root.join("unmounted")).unwrap();
9875 let away = ArchiveStore::open_existing(&base, "synthetic-project", "l").err().expect("away");
9876 assert_ne!(away.kind(), io::ErrorKind::NotFound);
9877 assert!(away.to_string().starts_with("the archive base is a link to a folder that is not there"));
9878 assert!(ArchiveStore::existing_scope(&base, &project_scope("synthetic-project").unwrap()).is_err());
9879 assert!(project_search(&base, "synthetic-project", "", 8).is_err());
9880 let fetched = project_fetch(&base, "synthetic-project", &sealed.id, 0, 1).unwrap_err();
9881 assert_ne!(fetched.kind(), io::ErrorKind::NotFound);
9882 assert!(archive_file(&base, "synthetic-project", &sealed.id).is_err());
9883 std::fs::rename(root.join("unmounted"), &disk).unwrap();
9885 assert_eq!(project_search(&base, "synthetic-project", "", 8).unwrap().len(), 1);
9886 for missing in [root.join("no-base"), root.join("no-folder/base")] {
9888 assert!(ArchiveStore::open_existing(&missing, "synthetic-project", "l").unwrap().is_none());
9889 assert!(project_search(&missing, "synthetic-project", "", 8).unwrap().is_empty());
9890 }
9891 }
9892}
9893
9894#[cfg(all(test, unix))]
9895mod lock_tests {
9896 use super::*;
9897 use std::time::{Duration, Instant};
9898
9899 fn msg(role: &str, content: Value) -> SharedMessage {
9900 Arc::new(json!({"role":role,"content":content}))
9901 }
9902 fn setup() -> (tempfile::TempDir, ArchiveStore) {
9903 let tmp = tempfile::tempdir().unwrap();
9904 let store =
9905 ArchiveStore::new(tmp.path(), "synthetic-project", "synthetic-logical").unwrap();
9906 (tmp, store)
9907 }
9908
9909 fn wait_until(condition: impl Fn() -> bool) {
9911 let started = Instant::now();
9912 while !condition() {
9913 assert!(started.elapsed() < Duration::from_secs(30), "timed out");
9914 std::thread::sleep(Duration::from_millis(1));
9915 }
9916 }
9917
9918 fn gate_of(store: &ArchiveStore) -> Arc<Gate> {
9920 Gate::for_directory(store.fs.identity().unwrap())
9921 }
9922
9923 #[test]
9929 fn a_descriptor_inherited_by_a_forked_child_does_not_keep_the_archive_locked() {
9930 use std::io::{Read, Write};
9931 use std::os::fd::AsRawFd;
9932 use std::os::unix::process::CommandExt;
9933 use std::process::ExitStatus;
9934 use std::thread::JoinHandle;
9935 struct Child {
9937 release: Option<io::PipeWriter>,
9938 spawner: Option<JoinHandle<io::Result<ExitStatus>>>,
9939 }
9940 impl Child {
9941 fn release_and_reap(&mut self) -> Option<std::thread::Result<io::Result<ExitStatus>>> {
9942 if let Some(mut release) = self.release.take() {
9944 let _ = release.write_all(b"r");
9945 }
9946 self.spawner.take().map(JoinHandle::join)
9948 }
9949 }
9950 impl Drop for Child {
9951 fn drop(&mut self) {
9952 let _ = self.release_and_reap();
9953 }
9954 }
9955 let (_tmp, mut store) = setup();
9956 store.process_wait = Duration::ZERO;
9957 let reference = store
9958 .seal(&[msg("user", json!("evidence"))], "note")
9959 .unwrap();
9960 let (mut forked, signal) = io::pipe().unwrap();
9962 let (hold, release) = io::pipe().unwrap();
9963 let (signal_fd, hold_fd) = (signal.as_raw_fd(), hold.as_raw_fd());
9964 let held = store.fs.lock().unwrap();
9965 let mut child = Child {
9966 release: Some(release),
9967 spawner: Some(std::thread::spawn(move || {
9968 let mut command = std::process::Command::new("true");
9969 unsafe {
9971 command.pre_exec(move || {
9972 let interrupted =
9973 || io::Error::last_os_error().raw_os_error() == Some(libc::EINTR);
9974 loop {
9975 match libc::write(signal_fd, b"f".as_ptr().cast(), 1) {
9976 1 => break,
9977 -1 if interrupted() => {}
9978 _ => libc::_exit(1),
9980 }
9981 }
9982 let mut byte = 0u8;
9983 while libc::read(hold_fd, (&mut byte as *mut u8).cast(), 1) < 0
9984 && interrupted()
9985 {}
9986 Ok(())
9987 });
9988 }
9989 let status = command.status();
9990 drop((signal, hold));
9991 status
9992 })),
9993 };
9994 let deadline = Instant::now() + Duration::from_secs(30);
9997 loop {
9998 let mut ready = libc::pollfd {
9999 fd: forked.as_raw_fd(),
10000 events: libc::POLLIN,
10001 revents: 0,
10002 };
10003 let left = deadline.saturating_duration_since(Instant::now());
10004 let timeout = left.as_millis().try_into().unwrap_or(i32::MAX);
10005 match unsafe { libc::poll(&mut ready, 1, timeout) } {
10006 1 => break,
10007 0 => panic!("the forked child never signaled"),
10008 _ => assert_eq!(io::Error::last_os_error().raw_os_error(), Some(libc::EINTR)),
10009 }
10010 }
10011 forked.read_exact(&mut [0; 1]).unwrap();
10012 drop(held);
10013 let sealed = store.seal(&[msg("user", json!("more evidence"))], "");
10015 let status = child.release_and_reap().unwrap().unwrap().unwrap();
10016 assert!(status.success());
10017 assert_ne!(sealed.unwrap(), reference);
10018 }
10019
10020 #[test]
10023 fn an_expired_waiter_is_not_admitted_when_the_gate_frees_late() {
10024 let gate = Gate::for_directory((u64::MAX, u64::MAX));
10025 let holder = Gate::enter(gate.clone(), Duration::ZERO, None, BUSY).unwrap();
10026 let wait = Duration::from_millis(50);
10027 let waiter = {
10028 let gate = gate.clone();
10029 std::thread::spawn(move || Gate::enter(gate, wait, None, BUSY).map(drop))
10030 };
10031 wait_until(|| gate.waited.load(Ordering::Acquire) == 1);
10032 {
10033 let mut held = gate.held.lock().unwrap();
10036 *held = false;
10037 std::thread::sleep(wait * 2);
10038 }
10039 let error = waiter.join().unwrap().unwrap_err();
10040 assert_eq!(error.kind(), io::ErrorKind::WouldBlock);
10041 assert_eq!(error.to_string(), BUSY);
10042 drop(holder);
10043 Gate::enter(gate, Duration::ZERO, None, BUSY).unwrap();
10045 }
10046
10047 #[test]
10048 fn concurrent_operations_in_one_process_wait_instead_of_reporting_busy() {
10049 let tmp = tempfile::tempdir().unwrap();
10050 let workers = 8;
10051 let barrier = Arc::new(std::sync::Barrier::new(workers));
10052 let handles: Vec<_> = (0..workers)
10053 .map(|worker| {
10054 let base = tmp.path().to_path_buf();
10055 let barrier = barrier.clone();
10056 std::thread::spawn(move || {
10057 let logical = format!("synthetic-logical-{worker}");
10058 let store = ArchiveStore::new(&base, "synthetic-project", &logical)?;
10059 barrier.wait();
10060 for round in 0..8 {
10061 let text = format!("worker {worker} round {round}");
10062 let reference = store.seal(&[msg("user", json!(text))], "note")?;
10063 assert_eq!(store.fetch(&reference.id, 0, 1)?.len(), 1);
10064 assert_eq!(store.fetch_note(&reference.id)?, "note");
10065 store.search("worker", 4)?;
10066 }
10067 Ok::<_, io::Error>(())
10068 })
10069 })
10070 .collect();
10071 let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
10072 for result in results {
10073 result.unwrap();
10074 }
10075 let store = ArchiveStore::new(tmp.path(), "synthetic-project", "synthetic-reader").unwrap();
10076 let dir = tmp.path().join("context-archives").join(&store.scope);
10078 let segments: usize = std::fs::read_dir(&dir)
10079 .unwrap()
10080 .map(|entry| entry.unwrap().path())
10081 .filter(|path| path.is_dir())
10082 .map(|shelf| std::fs::read_dir(shelf).unwrap().count())
10083 .sum();
10084 assert_eq!(segments, workers * 8);
10085 }
10086
10087 #[test]
10090 fn a_lock_held_outside_this_process_fails_closed_at_once_when_the_wait_is_zero() {
10091 let (_tmp, mut store) = setup();
10092 store.process_wait = Duration::ZERO;
10093 let reference = store
10094 .seal(&[msg("user", json!("evidence"))], "note")
10095 .unwrap();
10096 let foreign = store.fs.lock().unwrap();
10097 let started = Instant::now();
10098 let results = [
10099 store
10100 .seal(&[msg("user", json!("other evidence"))], "")
10101 .map(drop),
10102 store.forget(&reference.id).map(drop),
10103 ];
10104 for result in results {
10105 assert_eq!(result.unwrap_err().kind(), io::ErrorKind::WouldBlock);
10106 }
10107 assert!(started.elapsed() < PROCESS_LOCK_WAIT);
10108 drop(foreign);
10109 assert_eq!(store.fetch(&reference.id, 0, 1).unwrap().len(), 1);
10110 }
10111
10112 fn other(tmp: &tempfile::TempDir) -> ArchiveStore {
10113 ArchiveStore::new(tmp.path(), "synthetic-project", "synthetic-other").unwrap()
10114 }
10115
10116 #[test]
10117 fn an_operation_queues_behind_the_one_in_progress_in_this_process() {
10118 let (tmp, store) = setup();
10119 let held = store.exclusive().unwrap();
10120 let queued = other(&tmp);
10121 let waiter =
10122 std::thread::spawn(move || queued.seal(&[msg("user", json!("queued evidence"))], ""));
10123 let gate = gate_of(&store);
10124 wait_until(|| gate.waited.load(Ordering::Acquire) == 1);
10125 assert!(!waiter.is_finished());
10126 drop(held);
10127 let reference = waiter.join().unwrap().unwrap();
10128 assert_eq!(store.fetch(&reference.id, 0, 1).unwrap().len(), 1);
10129 }
10130
10131 #[test]
10132 fn a_stuck_operation_in_this_process_fails_closed_after_the_bounded_wait() {
10133 let (tmp, store) = setup();
10134 let held = store.exclusive().unwrap();
10135 let mut queued = other(&tmp);
10136 queued.wait = Duration::from_millis(50);
10137 let started = Instant::now();
10138 let evidence = [msg("user", json!("queued evidence"))];
10139 let error = queued.seal(&evidence, "").unwrap_err();
10140 assert_eq!(error.kind(), io::ErrorKind::WouldBlock);
10141 assert_eq!(error.to_string(), BUSY);
10142 assert!(started.elapsed() >= queued.wait);
10143 drop(held);
10144 assert!(queued.search("", 1).unwrap().is_empty());
10145 queued.seal(&evidence, "").unwrap();
10146 }
10147
10148 #[test]
10149 fn an_aborted_operation_never_starts_even_while_queued() {
10150 let (tmp, store) = setup();
10151 let abort = Arc::new(AtomicBool::new(true));
10152 let error = other(&tmp)
10153 .with_abort(abort.clone())
10154 .seal(&[msg("user", json!("abandoned before start"))], "")
10155 .unwrap_err();
10156 assert_eq!(error.kind(), io::ErrorKind::Interrupted);
10157
10158 abort.store(false, Ordering::Release);
10159 let held = store.exclusive().unwrap();
10160 let queued = other(&tmp).with_abort(abort.clone());
10161 let waiter = std::thread::spawn(move || {
10162 queued.seal(&[msg("user", json!("abandoned while queued"))], "")
10163 });
10164 let gate = gate_of(&store);
10165 wait_until(|| gate.waited.load(Ordering::Acquire) == 1);
10166 assert!(!waiter.is_finished());
10167 abort.store(true, Ordering::Release);
10168 let error = waiter.join().unwrap().unwrap_err();
10170 assert_eq!(error.kind(), io::ErrorKind::Interrupted);
10171 drop(held);
10172 assert!(store.search("", 1).unwrap().is_empty());
10173 }
10174
10175 #[test]
10176 fn seal_with_note_returns_the_note_stored_with_the_segment() {
10177 let (_tmp, store) = setup();
10178 let messages = [msg("user", json!("evidence"))];
10179 let (first, note) = store.seal_with_note(&messages, "first note").unwrap();
10180 assert_eq!(note, "first note");
10181 let (again, note) = store.seal_with_note(&messages, "second note").unwrap();
10182 assert_eq!(again, first);
10183 assert_eq!(note, "first note");
10184 assert_eq!(store.fetch_note(&first.id).unwrap(), "first note");
10185 store.forget(&first.id).unwrap();
10186 let error = store.seal_with_note(&messages, "").unwrap_err();
10187 assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
10188 }
10189
10190 fn project_entries(tmp: &tempfile::TempDir, store: &ArchiveStore) -> Vec<String> {
10192 let dir = tmp.path().join("context-archives").join(&store.scope);
10193 let entries = std::fs::read_dir(dir).unwrap();
10194 let mut names: Vec<_> = entries
10195 .map(|entry| entry.unwrap().file_name().into_string().unwrap())
10196 .collect();
10197 names.sort();
10198 names
10199 }
10200
10201 fn foreign_lock_for(store: &ArchiveStore, hold: Duration) -> std::thread::JoinHandle<()> {
10204 let foreign = store.fs.lock().unwrap();
10205 std::thread::spawn(move || {
10206 std::thread::sleep(hold);
10207 drop(foreign);
10208 })
10209 }
10210
10211 struct Raise<'a>(&'a AtomicBool);
10214 impl Drop for Raise<'_> {
10215 fn drop(&mut self) {
10216 self.0.store(true, Ordering::Release);
10217 }
10218 }
10219
10220 fn evidence() -> [SharedMessage; 1] {
10221 [msg("user", json!("evidence"))]
10222 }
10223
10224 fn assert_reads(reader: &ArchiveStore, reference: &ArchiveRef) {
10226 assert_eq!(reader.fetch(&reference.id, 0, 8).unwrap(), evidence());
10227 assert_eq!(reader.fetch_note(&reference.id).unwrap(), "note");
10228 let rows = reader.search("evidence", 8).unwrap();
10229 assert_eq!(rows.len(), 1);
10230 assert_eq!(rows[0].id, reference.id);
10231 }
10232
10233 fn assert_busy(result: io::Result<()>) {
10234 let error = result.unwrap_err();
10235 assert_eq!(error.kind(), io::ErrorKind::WouldBlock);
10236 assert_eq!(error.to_string(), BUSY);
10237 }
10238
10239 #[test]
10240 fn seal_waits_for_a_foreign_lock_then_succeeds() {
10241 let (_tmp, store) = setup();
10242 let hold = Duration::from_millis(300);
10243 let started = Instant::now();
10244 let holder = foreign_lock_for(&store, hold);
10245 let reference = store.seal(&evidence(), "note").unwrap();
10246 assert!(started.elapsed() >= hold);
10247 holder.join().unwrap();
10248 assert_reads(&store, &reference);
10249 }
10250
10251 #[test]
10252 fn forget_waits_for_a_foreign_lock_then_succeeds() {
10253 let (_tmp, store) = setup();
10254 let reference = store.seal(&evidence(), "note").unwrap();
10255 let hold = Duration::from_millis(300);
10256 let started = Instant::now();
10257 let holder = foreign_lock_for(&store, hold);
10258 store.forget(&reference.id).unwrap();
10259 assert!(started.elapsed() >= hold);
10260 holder.join().unwrap();
10261 let error = store.fetch(&reference.id, 0, 1).unwrap_err();
10262 assert_eq!(error.kind(), io::ErrorKind::NotFound);
10263 }
10264
10265 #[test]
10266 fn seal_reports_busy_after_the_wait() {
10267 let (tmp, mut store) = setup();
10268 assert_eq!(store.process_wait, PROCESS_LOCK_WAIT);
10269 assert_eq!(PROCESS_LOCK_WAIT, Duration::from_secs(5));
10270 store.process_wait = Duration::from_millis(200);
10271 let foreign = store.fs.lock().unwrap();
10272 let started = Instant::now();
10273 assert_busy(store.seal(&evidence(), "note").map(drop));
10274 assert!(started.elapsed() >= store.process_wait);
10275 assert_eq!(project_entries(&tmp, &store), ["lock"]);
10277 drop(foreign);
10278 let reference = store.seal(&evidence(), "note").unwrap();
10279 let foreign = store.fs.lock().unwrap();
10281 let started = Instant::now();
10282 assert_busy(store.forget(&reference.id).map(drop));
10283 assert!(started.elapsed() >= store.process_wait);
10284 drop(foreign);
10285 assert_reads(&store, &reference);
10286 }
10287
10288 #[test]
10289 fn abandon_while_polling_returns_interrupted() {
10290 let (tmp, store) = setup();
10291 let foreign = store.fs.lock().unwrap();
10292 let abort = Arc::new(AtomicBool::new(false));
10293 let mut abandoned = other(&tmp).with_abort(abort.clone());
10294 abandoned.process_wait = Duration::from_secs(30);
10296 let started = Instant::now();
10297 let waiter = std::thread::spawn(move || abandoned.seal(&evidence(), ""));
10298 let gate = gate_of(&store);
10299 wait_until(|| *gate.held.lock().unwrap());
10301 abort.store(true, Ordering::Release);
10302 let error = waiter.join().unwrap().unwrap_err();
10303 assert_eq!(error.kind(), io::ErrorKind::Interrupted);
10304 assert!(started.elapsed() < Duration::from_secs(30));
10305 assert_eq!(project_entries(&tmp, &store), ["lock"]);
10307 assert!(!*gate.held.lock().unwrap());
10308 drop(foreign);
10309 let reference = store.seal(&evidence(), "note").unwrap();
10310 assert_reads(&store, &reference);
10311 }
10312
10313 #[test]
10314 fn fetch_and_search_ignore_a_foreign_lock() {
10315 let (_tmp, mut store) = setup();
10316 let reference = store.seal(&evidence(), "note").unwrap();
10317 store.process_wait = Duration::ZERO;
10319 let _foreign = store.fs.lock().unwrap();
10320 assert_reads(&store, &reference);
10321 assert_busy(store.forget(&reference.id).map(drop));
10322 }
10323
10324 #[test]
10325 fn fetch_and_search_ignore_the_in_process_gate() {
10326 let (tmp, store) = setup();
10327 let reference = store.seal(&evidence(), "note").unwrap();
10328 let _held = store.exclusive().unwrap();
10329 let mut reader = other(&tmp);
10330 reader.wait = Duration::ZERO;
10332 assert_reads(&reader, &reference);
10333 assert_busy(reader.forget(&reference.id).map(drop));
10334 }
10335
10336 #[test]
10337 fn missing_id_without_legacy_files_is_not_found_without_locking() {
10338 let (tmp, store) = setup();
10339 let mut reader = other(&tmp);
10340 (reader.wait, reader.process_wait) = (Duration::ZERO, Duration::ZERO);
10341 let missing = "a".repeat(32);
10342 let absent = |id: &str| {
10344 let _held = store.exclusive().unwrap();
10345 for result in [reader.fetch(id, 0, 1).map(drop), reader.fetch_note(id).map(drop)] {
10346 assert_eq!(result.unwrap_err().kind(), io::ErrorKind::NotFound);
10347 }
10348 assert!(reader.search("", 8).unwrap().is_empty());
10349 };
10350 absent(&missing);
10352 let reference = store.seal(&evidence(), "note").unwrap();
10353 store.forget(&reference.id).unwrap();
10354 absent(&missing);
10355 absent(&reference.id);
10356 }
10357
10358 #[test]
10363 fn reader_racing_forget_sees_live_or_not_found() {
10364 let (tmp, store) = setup();
10365 let reader = other(&tmp);
10366 for round in 0..100 {
10367 let words = format!("evidence {round};");
10368 let messages = [msg("user", json!(words)), msg("assistant", json!("reply"))];
10369 let id = store.seal(&messages, "note").unwrap().id;
10370 let live = |kind: usize| {
10372 let found = match kind {
10373 8 => reader.fetch_note(&id).map(|note| assert_eq!(note, "note")),
10374 9 => reader.search(&words, 2).and_then(|rows| match rows.as_slice() {
10375 [] => Err(not_found()),
10376 [row] => {
10377 assert_eq!((row.id.as_str(), row.message_count), (id.as_str(), 2));
10378 Ok(())
10379 }
10380 rows => panic!("one segment, {} rows", rows.len()),
10381 }),
10382 _ => reader
10383 .fetch(&id, 0, MAX_FETCH_MESSAGES)
10384 .map(|found| assert_eq!(found, messages)),
10385 };
10386 match found {
10387 Ok(()) => true,
10388 Err(error) => {
10389 assert_eq!(error.kind(), io::ErrorKind::NotFound, "{error}");
10390 false
10391 }
10392 }
10393 };
10394 let forgotten = AtomicBool::new(false);
10395 let start = std::sync::Barrier::new(12);
10396 std::thread::scope(|scope| {
10397 scope.spawn(|| {
10399 start.wait();
10400 loop {
10401 let after = forgotten.load(Ordering::Acquire);
10402 let located = archive_file(tmp.path(), "synthetic-project", &id);
10403 assert!(located.unwrap().is_some());
10404 if after {
10405 break;
10406 }
10407 }
10408 });
10409 for kind in 0..10 {
10410 let (live, forgotten, start) = (&live, &forgotten, &start);
10411 scope.spawn(move || {
10412 start.wait();
10413 let mut gone = false;
10414 loop {
10415 let after = forgotten.load(Ordering::Acquire);
10417 if live(kind) {
10418 assert!(!gone && !after, "read after it was forgotten");
10419 } else if after {
10420 break;
10421 } else {
10422 gone = true;
10423 }
10424 }
10425 });
10426 }
10427 start.wait();
10428 let _returned = Raise(&forgotten);
10430 store.forget(&id).unwrap();
10431 });
10432 }
10433 }
10434
10435 #[test]
10438 fn reader_holding_the_old_file_reads_the_whole_live_record() {
10439 let (tmp, store) = setup();
10440 let reference = store.seal(&evidence(), "note").unwrap();
10441 let name = archive_name(&reference.id);
10442 let project = tmp.path().join("context-archives").join(&store.scope);
10443 let path = project.join("s000001").join(&name);
10444 let original = std::fs::read(&path).unwrap();
10445 let shelf = store.fs.open_child("s000001", true).unwrap().unwrap();
10447 let open = || shelf.open(&c_name(&name).unwrap(), libc::O_RDONLY).unwrap();
10448 let (unlocked, locked) = (open(), open());
10449 store.forget(&reference.id).unwrap();
10450 let bytes = PrivateDir::read_open(unlocked, MAX_SEGMENT_BYTES, true).unwrap();
10453 assert_eq!(bytes, original);
10454 let Body::Live { messages, note, .. } = store.record(&name, &bytes).unwrap().body else {
10455 panic!("not the live record");
10456 };
10457 let messages: Vec<_> = messages.into_iter().map(|m| m.message).collect();
10458 assert_eq!((messages.as_slice(), note.as_str()), (&evidence()[..], "note"));
10459 let error = PrivateDir::read_open(locked, MAX_SEGMENT_BYTES, false).unwrap_err();
10462 assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
10463 assert_ne!(std::fs::read(&path).unwrap(), original);
10465 let error = store.fetch(&reference.id, 0, 1).unwrap_err();
10466 assert_eq!(error.kind(), io::ErrorKind::NotFound);
10467 let located = archive_file(tmp.path(), "synthetic-project", &reference.id);
10468 assert_eq!(located.unwrap(), Some(path));
10469 }
10470
10471 #[test]
10477 fn searcher_racing_seals_never_errors() {
10478 let (tmp, store) = setup();
10479 let reader = other(&tmp);
10480 let words = |index: usize| format!("racing seal {index};");
10481 let window = |index: usize| [msg("user", json!(words(index)))];
10482 let ids: Vec<String> = (0..300)
10485 .map(|index| {
10486 let projection = project_archive("synthetic-logical", &window(index), "", None);
10487 projection.unwrap().digest[..32].to_owned()
10488 })
10489 .collect();
10490 let sealed = std::sync::atomic::AtomicUsize::new(0);
10491 let done = AtomicBool::new(false);
10492 let (searching, fetching) = (AtomicBool::new(false), AtomicBool::new(false));
10493 std::thread::scope(|scope| {
10494 scope.spawn(|| loop {
10495 let finished = done.load(Ordering::Acquire);
10496 let count = sealed.load(Ordering::Acquire);
10498 if let Some(newest) = count.checked_sub(1) {
10499 let (rows, unread) = reader.search_scan(&words(newest), 2).unwrap();
10500 let found: Vec<_> = rows.iter().map(|row| row.id.as_str()).collect();
10501 assert_eq!((found, unread), (vec![ids[newest].as_str()], None));
10502 }
10503 let rows = reader.search("racing seal", MAX_SEARCH_RESULTS).unwrap();
10504 assert!(rows.len() >= count.min(MAX_SEARCH_RESULTS));
10505 for (index, row) in rows.iter().enumerate() {
10506 assert!(ids.contains(&row.id));
10507 assert_eq!((row.message_count, row.source_message_count), (1, 1));
10508 assert!(rows[..index].iter().all(|earlier| earlier.id != row.id));
10509 }
10510 searching.store(true, Ordering::Release);
10511 if finished {
10512 break;
10513 }
10514 });
10515 let fetcher = scope.spawn(|| {
10516 let mut next = 0;
10517 while next < ids.len() {
10518 let finished = done.load(Ordering::Acquire);
10519 match reader.fetch(&ids[next], 0, 2) {
10520 Ok(found) => {
10521 assert_eq!(found, window(next));
10522 next += 1;
10523 }
10524 Err(error) => {
10525 assert_eq!(error.kind(), io::ErrorKind::NotFound, "{error}");
10526 fetching.store(true, Ordering::Release);
10527 if finished {
10528 break;
10529 }
10530 }
10531 }
10532 }
10533 next
10534 });
10535 let stop = Raise(&done);
10536 wait_until(|| searching.load(Ordering::Acquire) && fetching.load(Ordering::Acquire));
10538 for (index, id) in ids.iter().enumerate() {
10539 assert_eq!(&store.seal(&window(index), "").unwrap().id, id);
10540 sealed.store(index + 1, Ordering::Release);
10541 }
10542 drop(stop);
10543 assert_eq!(fetcher.join().unwrap(), ids.len());
10544 });
10545 assert_eq!(store.shelves().unwrap().len(), 3);
10546 }
10547
10548 fn shelved(tmp: &tempfile::TempDir, store: &ArchiveStore) -> Vec<String> {
10550 let project = tmp.path().join("context-archives").join(&store.scope);
10551 let mut names = Vec::new();
10552 for shelf in store.shelves().unwrap() {
10553 let entries = std::fs::read_dir(project.join(shelf)).unwrap();
10554 names.extend(entries.map(|entry| entry.unwrap().file_name().into_string().unwrap()));
10555 }
10556 names
10557 }
10558
10559 #[test]
10565 fn writers_inside_publish_at_once_without_the_lock() {
10566 const WRITERS: usize = 4;
10567 const ROUNDS: usize = 150;
10568 let (tmp, store) = setup();
10569 let shelf = store.fs.child("s000001", true).unwrap();
10570 let bytes = |writer: usize, round: usize| {
10572 format!("writer {writer} round {round};").repeat(4096).into_bytes()
10573 };
10574 let own = |writer: usize, round: usize| format!("{:032x}.json", writer * ROUNDS + round);
10575 let wanted = |round: usize| format!("{:032x}.json", WRITERS * ROUNDS + round);
10576 let start = std::sync::Barrier::new(WRITERS);
10577 let won: Vec<Vec<usize>> = std::thread::scope(|scope| {
10578 let writers: Vec<_> = (0..WRITERS)
10579 .map(|writer| {
10580 let (shelf, start) = (&shelf, &start);
10581 scope.spawn(move || {
10582 start.wait();
10583 let mut won = Vec::new();
10584 for round in 0..ROUNDS {
10585 let stored = shelf.publish(&own(writer, round), &bytes(writer, round), true);
10587 stored.unwrap();
10588 match shelf.publish(&wanted(round), &bytes(writer, round), true) {
10590 Ok(()) => won.push(round),
10591 Err(error) => assert_eq!(error.kind(), io::ErrorKind::AlreadyExists),
10592 }
10593 }
10594 won
10595 })
10596 })
10597 .collect();
10598 writers.into_iter().map(|writer| writer.join().unwrap()).collect()
10599 });
10600 let read = |name: &str| shelf.read_with(name, MAX_SEGMENT_BYTES, true).unwrap().unwrap();
10601 for round in 0..ROUNDS {
10602 for writer in 0..WRITERS {
10603 assert!(read(&own(writer, round)) == bytes(writer, round), "{writer} {round}");
10604 }
10605 let winners: Vec<_> = (0..WRITERS).filter(|writer| won[*writer].contains(&round)).collect();
10606 assert_eq!(winners.len(), 1, "round {round}");
10607 assert!(read(&wanted(round)) == bytes(winners[0], round), "round {round}");
10608 }
10609 assert_eq!(shelved(&tmp, &store).len(), (WRITERS + 1) * ROUNDS);
10611 }
10612
10613 #[test]
10617 fn seals_at_once_without_the_lock_each_store_their_window() {
10618 const WRITERS: usize = 4;
10619 let (tmp, store) = setup();
10620 let window = |words: String| [msg("user", json!(words))];
10621 let project = |messages: &[SharedMessage], note: &str| {
10622 project_hashed(&store.logical, messages, note, None, Reduce::None).unwrap()
10623 };
10624 let seal_all = |same: bool, rounds: usize| -> Vec<Vec<(ArchiveRef, String)>> {
10627 let start = std::sync::Barrier::new(WRITERS);
10628 std::thread::scope(|scope| {
10629 let writers: Vec<_> = (0..WRITERS)
10630 .map(|writer| {
10631 let (store, start) = (&store, &start);
10632 scope.spawn(move || {
10633 start.wait();
10634 let note = format!("note of writer {writer}");
10635 let name = if same { "any".to_owned() } else { format!("writer {writer}") };
10636 let seal = |round| {
10637 let window = window(format!("{name} window {round};"));
10638 store.store(project(&window, ¬e), Written::now())
10639 };
10640 (0..rounds).map(|round| seal(round).unwrap()).collect()
10641 })
10642 })
10643 .collect();
10644 writers.into_iter().map(|writer| writer.join().unwrap()).collect()
10645 })
10646 };
10647 let same = seal_all(true, 30);
10651 for (round, (reference, note)) in same[0].iter().enumerate() {
10652 let words = format!("any window {round};");
10653 assert_eq!(store.fetch(&reference.id, 0, 2).unwrap(), window(words));
10654 assert_eq!(&store.fetch_note(&reference.id).unwrap(), note);
10655 assert!(note.starts_with("note of writer "));
10656 for other in &same[1..] {
10657 assert_eq!(other[round], (reference.clone(), note.clone()));
10658 }
10659 }
10660 assert_eq!(shelved(&tmp, &store).len(), 30);
10661 let own = seal_all(false, 80);
10664 for (writer, sealed) in own.iter().enumerate() {
10665 for (round, (reference, note)) in sealed.iter().enumerate() {
10666 let words = format!("writer {writer} window {round};");
10667 assert_eq!(store.fetch(&reference.id, 0, 2).unwrap(), window(words));
10668 assert_eq!(note, &format!("note of writer {writer}"));
10669 }
10670 }
10671 let mut names = shelved(&tmp, &store);
10673 assert_eq!(names.len(), 30 + WRITERS * 80);
10674 names.sort();
10675 names.dedup();
10676 assert_eq!(names.len(), 30 + WRITERS * 80);
10677 assert!(names.iter().all(|name| archive_id(name).is_some()));
10678 assert!(store.shelves().unwrap().len() >= 3);
10679 let (reference, note) = store.seal_with_note(&window("any window 0;".into()), "").unwrap();
10681 assert_eq!((&reference, ¬e), (&same[0][0].0, &same[0][0].1));
10682 }
10683
10684 #[test]
10689 fn concurrent_seals_across_shelf_boundaries_fill_each_shelf_exactly() {
10690 const WRITERS: usize = 4;
10691 const EACH: usize = 80;
10692 let (tmp, store) = setup();
10693 let window = |writer: usize, round: usize| {
10694 [msg("user", json!(format!("writer {writer} window {round};")))]
10695 };
10696 let start = std::sync::Barrier::new(WRITERS);
10697 let sealed: Vec<Vec<String>> = std::thread::scope(|scope| {
10698 let writers: Vec<_> = (0..WRITERS)
10699 .map(|writer| {
10700 let (tmp, start) = (&tmp, &start);
10701 scope.spawn(move || {
10702 let name = format!("writer-{writer}");
10703 let store = ArchiveStore::new(tmp.path(), "synthetic-project", &name);
10704 let store = store.unwrap();
10705 start.wait();
10706 let seal = |round| store.seal(&window(writer, round), "note").unwrap().id;
10707 (0..EACH).map(seal).collect()
10708 })
10709 })
10710 .collect();
10711 writers.into_iter().map(|writer| writer.join().unwrap()).collect()
10712 });
10713 assert_eq!(store.shelves().unwrap(), ["s000003", "s000002", "s000001"]);
10714 let project = tmp.path().join("context-archives").join(&store.scope);
10715 let held = |shelf: &str| std::fs::read_dir(project.join(shelf)).unwrap().count();
10716 let rest = WRITERS * EACH - 2 * SHELF_FILES;
10717 assert_eq!(
10718 (held("s000001"), held("s000002"), held("s000003")),
10719 (SHELF_FILES, SHELF_FILES, rest)
10720 );
10721 for (writer, ids) in sealed.iter().enumerate() {
10722 for (round, id) in ids.iter().enumerate() {
10723 assert_eq!(store.fetch(id, 0, 2).unwrap(), window(writer, round));
10724 }
10725 }
10726 let mut names = shelved(&tmp, &store);
10727 names.sort();
10728 names.dedup();
10729 assert_eq!(names.len(), WRITERS * EACH);
10730 assert!(names.iter().all(|name| archive_id(name).is_some()));
10731 }
10732
10733 const SEAL_CHILD: &str = "SYNTHETIC_ARCHIVE_SEAL_CHILD";
10734 const CHILD_SEALS: usize = 100;
10735
10736 #[test]
10740 fn two_processes_seal_child() {
10741 let Some(base) = std::env::var_os(SEAL_CHILD).map(PathBuf::from) else {
10742 return;
10743 };
10744 let name = format!("child-{}", std::process::id());
10745 let store = ArchiveStore::new(&base, "synthetic-project", &name).unwrap();
10746 std::fs::write(base.join(format!("ready-{}", std::process::id())), b"").unwrap();
10747 wait_until(|| base.join("go").exists());
10748 for round in 0..CHILD_SEALS {
10749 let words = format!("{name} round {round};");
10750 store.seal(&[msg("user", json!(words))], "note").unwrap();
10751 }
10752 }
10753
10754 #[test]
10757 #[serial_test::parallel(process_spawn)]
10758 fn two_processes_seal_concurrently() {
10759 let tmp = tempfile::tempdir().unwrap();
10760 let (_, module) = module_path!().split_once("::").unwrap();
10761 let child = format!("{module}::two_processes_seal_child");
10762 let mut children: Vec<_> = (0..2)
10763 .map(|_| {
10764 std::process::Command::new(std::env::current_exe().unwrap())
10765 .args(["--exact", &child, "--nocapture"])
10766 .env(SEAL_CHILD, tmp.path())
10767 .stdin(std::process::Stdio::null())
10768 .stdout(std::process::Stdio::null())
10769 .spawn()
10770 .unwrap()
10771 })
10772 .collect();
10773 let ready = |child: &std::process::Child| format!("ready-{}", child.id());
10775 wait_until(|| children.iter().all(|child| tmp.path().join(ready(child)).exists()));
10776 std::fs::write(tmp.path().join("go"), b"").unwrap();
10777 for child in &mut children {
10778 assert!(child.wait().unwrap().success());
10779 }
10780 let store = ArchiveStore::new(tmp.path(), "synthetic-project", "synthetic-reader").unwrap();
10781 let project = tmp.path().join("context-archives").join(&store.scope);
10782 let held = |shelf: &str| std::fs::read_dir(project.join(shelf)).unwrap().count();
10787 assert_eq!(
10788 (held("s000001"), held("s000002")),
10789 (SHELF_FILES, 2 * CHILD_SEALS - SHELF_FILES)
10790 );
10791 assert_eq!(store.shelves().unwrap().len(), 2);
10792 for child in &children {
10794 let name = format!("child-{}", child.id());
10795 for round in 0..CHILD_SEALS {
10796 let window = [msg("user", json!(format!("{name} round {round};")))];
10797 let projection = project_archive(&name, &window, "", None).unwrap();
10798 let found = store.fetch(&projection.digest[..32], 0, 2);
10799 assert_eq!(found.unwrap(), window, "{name} round {round}");
10800 }
10801 for round in [0, CHILD_SEALS - 1] {
10802 let words = format!("{name} round {round};");
10803 assert_eq!(store.search(&words, 2).unwrap().len(), 1, "{words}");
10804 }
10805 }
10806 }
10807}