lean_ctx/core/policy/
diagnostics.rs1use 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 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 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 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 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 #[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
271pub 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}