Skip to main content

agent_core/core/
context_archive.rs

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