Skip to main content

lean_ctx/core/policy/
diagnostics.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Project-bound diagnostic storage. Inspect complete fields before rendering
3//! previews, and resolve current rules only after the writer owns its lock.
4
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7use std::time::{Duration, Instant};
8
9use serde_json::{Map, Value};
10
11use super::{content, runtime};
12
13pub(crate) struct Target {
14    pub(crate) path: PathBuf,
15    root: PathBuf,
16    scoped: bool,
17}
18
19pub fn request_project() -> Option<PathBuf> {
20    runtime::REQUEST_PROJECT
21        .try_with(|slot| slot.borrow().clone())
22        .ok()
23        .flatten()
24}
25
26pub fn project() -> Option<PathBuf> {
27    request_project().or_else(|| std::env::current_dir().ok())
28}
29
30pub(crate) fn target(base: PathBuf) -> Option<Target> {
31    let root = project()?;
32    let policy = runtime::for_project(&root).ok()?;
33    let canonical = crate::core::pathutil::safe_canonicalize_bounded(&root, 2000);
34    let key = blake3::hash(canonical.as_os_str().as_encoded_bytes()).to_hex();
35    let projects = base.parent()?.join("projects");
36    let project_dir = projects.join(key.as_str());
37    // Windows reports ENOENT for a descendant below a regular file as well as
38    // for a genuinely missing path. Validate existing scope parents first so
39    // a corrupt `projects` entry cannot be mistaken for an absent scoped log
40    // and silently fall back to the readable global log.
41    for parent in [&projects, &project_dir] {
42        match std::fs::symlink_metadata(parent) {
43            Ok(metadata) if is_plain_directory(&metadata) => {}
44            Err(error) if error.kind() == std::io::ErrorKind::NotFound => break,
45            Ok(_) | Err(_) => return None,
46        }
47    }
48    let scoped_path = project_dir.join(base.file_name()?);
49    let scoped_present = match std::fs::symlink_metadata(&scoped_path) {
50        Ok(_) => true,
51        Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
52        Err(_) => return None,
53    };
54    let scoped = policy.is_some() || scoped_present;
55    Some(Target {
56        path: if scoped { scoped_path } else { base },
57        root,
58        scoped,
59    })
60}
61
62fn is_plain_directory(metadata: &std::fs::Metadata) -> bool {
63    if !metadata.is_dir() || metadata.file_type().is_symlink() {
64        return false;
65    }
66    #[cfg(windows)]
67    {
68        use std::os::windows::fs::MetadataExt;
69        use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT;
70        if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
71            return false;
72        }
73    }
74    true
75}
76
77impl Target {
78    fn policy(&self, tool: Option<&str>) -> Result<Option<Arc<runtime::ActivePolicy>>, ()> {
79        let policy = runtime::for_project(&self.root).map_err(|_| ())?;
80        if policy
81            .as_ref()
82            .is_some_and(|p| !self.scoped || tool.is_some_and(|tool| !p.tool_allowed(tool)))
83        {
84            return Err(());
85        }
86        Ok(policy)
87    }
88
89    pub(crate) fn inspect(&self, tool: Option<&str>, value: &Value) -> Option<Value> {
90        inspect(value, self.policy(tool).ok()?.as_deref())
91    }
92
93    /// Diagnostic contention loses an optional log entry, never a security rule.
94    pub(crate) fn with_lock(&self, action: impl FnOnce()) {
95        use fs2::FileExt;
96        let Some(parent) = self.path.parent() else {
97            return;
98        };
99        if std::fs::create_dir_all(parent).is_err() {
100            return;
101        }
102        #[cfg(unix)]
103        if self.scoped && crate::core::secure_fs::restrict_to_owner(parent).is_err() {
104            return;
105        }
106        let lock_path = self.path.with_extension("lock");
107        let Ok(lock) = open_append(&lock_path) else {
108            return;
109        };
110        // Diagnostics must never stall a tool call, so a contended write is
111        // dropped after 50 ms. Tests that queue a writer behind a held lock
112        // cannot meet that bound on a loaded runner and saw the write vanish.
113        let wait = if cfg!(test) {
114            Duration::from_secs(10)
115        } else {
116            Duration::from_millis(50)
117        };
118        let deadline = Instant::now() + wait;
119        loop {
120            match lock.try_lock_exclusive() {
121                Ok(()) => break,
122                Err(e) if crate::core::file_lock::is_contended(&e) && Instant::now() < deadline => {
123                    std::thread::sleep(Duration::from_millis(5));
124                }
125                Err(_) => return,
126            }
127        }
128        if regular_or_absent(&self.path) && self.refresh_existing() {
129            action();
130        }
131        let _ = FileExt::unlock(&lock);
132    }
133
134    pub(crate) fn read(&self) -> Option<String> {
135        self.policy(None).ok()?;
136        let text = Self::read_file(&self.path)?;
137        let Value::String(text) = self.inspect(None, &Value::String(text))? else {
138            return None;
139        };
140        Some(text)
141    }
142
143    fn refresh_existing(&self) -> bool {
144        use std::io::Write;
145        let Ok(policy) = self.policy(None) else {
146            return false;
147        };
148        if policy.is_none() || !self.path.exists() {
149            return true;
150        }
151        let Some(original) = Self::read_file(&self.path) else {
152            return false;
153        };
154        let Some(Value::String(safe)) = self.inspect(None, &Value::String(original.clone())) else {
155            return false;
156        };
157        if safe == original {
158            return true;
159        }
160        let Some(parent) = self.path.parent() else {
161            return false;
162        };
163        let Ok(mut file) = tempfile::NamedTempFile::new_in(parent) else {
164            return false;
165        };
166        file.write_all(safe.as_bytes()).is_ok() && file.persist(&self.path).is_ok()
167    }
168
169    pub(crate) fn read_file(path: &Path) -> Option<String> {
170        Self::read_file_limited(path, Some(content::MAX_PROTECTED_CONTENT_BYTES))
171    }
172
173    /// [`Self::read_file`] without the protected-content size cap, for a
174    /// durable store that grows without limit (the knowledge store: owner
175    /// decision 24.10., no knowledge limits). A store over the cap used to be
176    /// unreadable here, which refused every later write to it.
177    pub(crate) fn read_store_file(path: &Path) -> Option<String> {
178        Self::read_file_limited(path, None)
179    }
180
181    fn read_file_limited(path: &Path, limit: Option<usize>) -> Option<String> {
182        use std::io::Read;
183        if !regular_or_absent(path) {
184            return None;
185        }
186        let mut options = std::fs::OpenOptions::new();
187        options.read(true);
188        no_follow(&mut options);
189        let mut file = options.open(path).ok()?;
190        let metadata = file.metadata().ok()?;
191        if !metadata.is_file() {
192            return None;
193        }
194        #[cfg(windows)]
195        {
196            use std::os::windows::fs::MetadataExt;
197            if metadata.file_attributes() & 0x0400 != 0 {
198                return None;
199            }
200        }
201        let mut text = String::new();
202        match limit {
203            Some(limit) => {
204                file.take(limit as u64 + 1).read_to_string(&mut text).ok()?;
205                if text.len() > limit {
206                    return None;
207                }
208            }
209            None => {
210                file.read_to_string(&mut text).ok()?;
211            }
212        }
213        Some(text)
214    }
215}
216
217fn regular_or_absent(path: &Path) -> bool {
218    match std::fs::symlink_metadata(path) {
219        Ok(metadata) => metadata.is_file(),
220        Err(error) => error.kind() == std::io::ErrorKind::NotFound,
221    }
222}
223
224fn no_follow(options: &mut std::fs::OpenOptions) {
225    #[cfg(unix)]
226    {
227        use std::os::unix::fs::OpenOptionsExt;
228        options
229            .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC)
230            .mode(0o600);
231    }
232    #[cfg(windows)]
233    {
234        use std::os::windows::fs::OpenOptionsExt;
235        options.custom_flags(0x0020_0000);
236    }
237}
238
239pub(crate) fn open_append(path: &Path) -> std::io::Result<std::fs::File> {
240    if !regular_or_absent(path) {
241        return Err(std::io::Error::other("diagnostic target is not regular"));
242    }
243    let mut options = std::fs::OpenOptions::new();
244    options.create(true).append(true);
245    // An append-only Windows handle lacks FILE_READ_ATTRIBUTES (so the
246    // metadata check below fails) and the read/write access LockFileEx needs
247    // for the lock files opened through here. Appends still go to the end.
248    #[cfg(windows)]
249    options.read(true);
250    no_follow(&mut options);
251    let file = options.open(path)?;
252    let metadata = file.metadata()?;
253    #[cfg(windows)]
254    {
255        use std::os::windows::fs::MetadataExt;
256        if metadata.file_attributes() & 0x0400 != 0 {
257            return Err(std::io::Error::other("diagnostic reparse target rejected"));
258        }
259    }
260    if !metadata.is_file() {
261        return Err(std::io::Error::other("diagnostic target is not regular"));
262    }
263    #[cfg(unix)]
264    {
265        use std::os::unix::fs::PermissionsExt;
266        file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
267    }
268    Ok(file)
269}
270
271/// Bound borrowed inputs before allocating a diagnostic record. All fields
272/// remain intact for a second, current-policy inspection inside the writer.
273pub fn fields(text: &[(&str, &str)], args: Option<&Map<String, Value>>) -> Option<Value> {
274    let mut inspector = Inspector {
275        policy: None,
276        bytes: 0,
277        nodes: 0,
278    };
279    let mut result = Map::new();
280    for (key, value) in text {
281        result.insert((*key).into(), Value::String(inspector.text(value)?));
282    }
283    if let Some(args) = args {
284        result.insert("args".into(), inspector.object(args, 1)?);
285    }
286    Some(Value::Object(result))
287}
288
289pub fn inspect(value: &Value, policy: Option<&runtime::ActivePolicy>) -> Option<Value> {
290    Inspector {
291        policy,
292        bytes: 0,
293        nodes: 0,
294    }
295    .value(value, 0)
296}
297
298struct Inspector<'a> {
299    policy: Option<&'a runtime::ActivePolicy>,
300    bytes: usize,
301    nodes: usize,
302}
303
304#[cfg(test)]
305#[path = "diagnostics_tests.rs"]
306mod tests;
307
308impl Inspector<'_> {
309    fn object(&mut self, values: &Map<String, Value>, depth: usize) -> Option<Value> {
310        let mut result = Map::new();
311        for (key, value) in values {
312            let key = self.text(key)?;
313            if result.insert(key, self.value(value, depth + 1)?).is_some() {
314                return None;
315            }
316        }
317        Some(Value::Object(result))
318    }
319    fn text(&mut self, text: &str) -> Option<String> {
320        self.bytes = self.bytes.checked_add(text.len())?;
321        if self.bytes > content::MAX_PROTECTED_CONTENT_BYTES {
322            return None;
323        }
324        match self.policy {
325            Some(policy) => {
326                let result = content::evaluate_text(text, policy);
327                self.bytes = self
328                    .bytes
329                    .checked_add(result.text.len().saturating_sub(text.len()))?;
330                (!result.blocked && self.bytes <= content::MAX_PROTECTED_CONTENT_BYTES)
331                    .then_some(result.text)
332            }
333            None => Some(text.to_owned()),
334        }
335    }
336
337    fn value(&mut self, value: &Value, depth: usize) -> Option<Value> {
338        self.nodes += 1;
339        if self.nodes > 10_000 || depth > 64 {
340            return None;
341        }
342        match value {
343            Value::String(text) => Some(Value::String(self.text(text)?)),
344            Value::Array(values) => values
345                .iter()
346                .map(|v| self.value(v, depth + 1))
347                .collect::<Option<Vec<_>>>()
348                .map(Value::Array),
349            Value::Object(values) => self.object(values, depth),
350            _ => {
351                let text = value.to_string();
352                let safe = self.text(&text)?;
353                Some(if safe == text {
354                    value.clone()
355                } else {
356                    Value::String(safe)
357                })
358            }
359        }
360    }
361}