Skip to main content

lean_ctx/core/ccr/
storage.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Legacy tee files have no source authority. They are Community recovery only;
3//! protected file recovery uses the existing source-bound archive store.
4//!
5//! "Protected" is the one shared rule every store follows
6//! (`context_admission::stored::source_authority_required`): a project policy
7//! pack, the global gateway in governed or sovereign mode, or a global
8//! configuration that could not be loaded. Under any of them an originless
9//! tee file is neither written nor handed back — by the proxy, by in-band
10//! recovery, by `ctx_expand` or by the `tee` CLI, which all read through
11//! [`read_detailed`].
12
13use crate::core::context_admission::stored::source_authority_required;
14use crate::core::policy::{content, runtime};
15use std::path::Path;
16
17fn limit() -> usize {
18    crate::core::limits::max_read_bytes().min(content::MAX_PROTECTED_CONTENT_BYTES)
19}
20
21fn community<T>(operation: impl FnOnce() -> Option<T>) -> Option<T> {
22    runtime::with_source_view(|| {
23        if source_authority_required() {
24            return None;
25        }
26        operation()
27    })
28    .ok()
29    .flatten()
30}
31
32fn store_path(path: &Path) -> Option<()> {
33    let directory = crate::core::paths::state_dir_read_only().ok()?.join("tee");
34    if path.parent()? != directory
35        || crate::core::pathutil::is_symlink_or_reparse(
36            &std::fs::symlink_metadata(&directory).ok()?,
37        )
38    {
39        return None;
40    }
41    Some(())
42}
43
44/// One bounded, no-follow read shared by selectors, HTTP, CLI and in-band
45/// recovery. Policy is freshly resolved before I/O and rechecked at publication.
46pub fn read(path: &Path) -> Option<String> {
47    read_detailed(path).ok()
48}
49
50pub fn read_detailed(path: &Path) -> Result<String, &'static str> {
51    runtime::with_source_view(|| {
52        // Checked here, at the one read every caller shares — not only where
53        // a caller thought of it, and not only for a policy pack.
54        if source_authority_required() {
55            return Err(
56                "stored output has no source authority; repeat the original authorized operation",
57            );
58        }
59        const UNAVAILABLE: &str =
60            "stored output is unavailable or is not a regular UTF-8 text file";
61        const TOO_LARGE: &str = "stored output exceeds the configured recovery byte limit";
62        store_path(path).ok_or(UNAVAILABLE)?;
63        let mut options = std::fs::OpenOptions::new();
64        options.read(true);
65        #[cfg(unix)]
66        {
67            use std::os::unix::fs::OpenOptionsExt;
68            options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC);
69        }
70        #[cfg(windows)]
71        {
72            use std::os::windows::fs::OpenOptionsExt;
73            options.custom_flags(0x0020_0000); // FILE_FLAG_OPEN_REPARSE_POINT
74        }
75        let file = options.open(path).map_err(|_| UNAVAILABLE)?;
76        let metadata = file.metadata().map_err(|_| UNAVAILABLE)?;
77        if !metadata.is_file() || crate::core::pathutil::is_symlink_or_reparse(&metadata) {
78            return Err(UNAVAILABLE);
79        }
80        if metadata.len() > limit() as u64 {
81            return Err(TOO_LARGE);
82        }
83        #[cfg(unix)]
84        {
85            use std::os::unix::fs::MetadataExt;
86            if metadata.nlink() != 1 {
87                return Err(UNAVAILABLE);
88            }
89        }
90        use std::io::Read;
91        let mut text = String::new();
92        file.take(limit() as u64 + 1)
93            .read_to_string(&mut text)
94            .map_err(|_| UNAVAILABLE)?;
95        if text.len() > limit() {
96            return Err(TOO_LARGE);
97        }
98        if text.contains('\0') {
99            return Err(UNAVAILABLE);
100        }
101        let checked = content::protect_active(&text)
102            .map_err(|_| "stored output is withheld by current content rules")?;
103        // Recovery is a new delivery: re-admitted under the current policy
104        // before any selector sees it (G5).
105        crate::core::context_admission::recovery::admit_recovered(&checked, "tee")
106            .map_err(|_| "stored output is withheld by the context gateway")
107    })
108    .map_err(|_| "current policy cannot be verified; retry the authorized operation")?
109}
110
111/// Never publish an incomplete, oversized, unfiltered or protected originless
112/// copy. Atomic replacement preserves shell's latest-output handle contract.
113pub fn write(path: &Path, text: &str) -> Option<()> {
114    community(|| {
115        if text.len() > limit() || text.contains('\0') {
116            return None;
117        }
118        let checked = content::protect_active(text).ok()?;
119        // Derived store: only admitted text, never withheld or restricted
120        // content (G5, E3).
121        let admitted = crate::core::context_admission::recovery::admit_for_storage(&checked)?;
122        let masked = crate::core::redaction::redact_text(&admitted);
123        let (redacted, _) = crate::core::secret_detection::scan_and_redact_from_config(&masked);
124        if redacted.len() > limit() {
125            return None;
126        }
127        let directory = crate::core::paths::state_dir_read_only().ok()?.join("tee");
128        if path.parent()? != directory {
129            return None;
130        }
131        std::fs::create_dir_all(&directory).ok()?;
132        store_path(path)?;
133        // Content-addressed: the proxy re-persists every pruned message on
134        // every turn, and an atomic replace costs two fsyncs each time. When
135        // the published bytes already match, only refresh the mtime so the
136        // age-based tee cleanup still sees the entry as live.
137        // A symlink never takes this path: the atomic replace below swaps it
138        // for a regular file instead of following it.
139        if std::fs::symlink_metadata(path).is_ok_and(|meta| meta.file_type().is_file())
140            && std::fs::read(path).is_ok_and(|existing| existing == redacted.as_bytes())
141        {
142            return std::fs::File::options()
143                .write(true)
144                .open(path)
145                .and_then(|file| file.set_modified(std::time::SystemTime::now()))
146                .ok();
147        }
148        #[cfg(unix)]
149        let permissions = {
150            use std::os::unix::fs::PermissionsExt;
151            Some(std::fs::Permissions::from_mode(0o600))
152        };
153        #[cfg(not(unix))]
154        let permissions = None;
155        crate::core::atomic_fs::try_atomic_write(path, redacted.as_bytes(), permissions.as_ref())
156            .ok()
157    })
158}