1use std::path::{Path, PathBuf};
27
28use crate::core::context_admission::origin::{InputOrigin, SourceClass};
29use crate::core::context_admission::{OutputCompleteness, SourceAdmission};
30use crate::core::rooted_read::AdmittedRootedRead;
31
32#[derive(Debug)]
34pub enum SourceRead {
35 Admitted(AdmittedRootedRead),
37 Withheld(String),
40 Unavailable(String),
43}
44
45impl SourceRead {
46 pub fn text(&self) -> Option<&str> {
48 match self {
49 Self::Admitted(read) => Some(&read.content),
50 Self::Withheld(_) | Self::Unavailable(_) => None,
51 }
52 }
53
54 pub fn storable(&self) -> Option<&str> {
57 match self {
58 Self::Admitted(read) if read.storable && read.complete => Some(&read.content),
59 Self::Admitted(_) | Self::Withheld(_) | Self::Unavailable(_) => None,
60 }
61 }
62
63 #[cfg(any(test, feature = "test-support"))]
65 pub fn withheld(&self) -> Option<&str> {
66 match self {
67 Self::Withheld(reason) => Some(reason),
68 Self::Admitted(_) | Self::Unavailable(_) => None,
69 }
70 }
71}
72
73pub fn read(path: &str, root: &str, tool: &str, remaining: &mut usize) -> SourceRead {
81 read_under(path, root, tool, remaining, None)
82}
83
84pub fn read_with_admission(
99 root: &str,
100 path: &str,
101 tool: &str,
102 admission: &crate::core::context_admission::stores::StoreAdmission,
103) -> SourceRead {
104 let mut remaining = crate::core::limits::max_read_bytes();
105 read_under(path, root, tool, &mut remaining, Some(admission))
106}
107
108fn read_under(
109 path: &str,
110 root: &str,
111 tool: &str,
112 remaining: &mut usize,
113 gateway: Option<&crate::core::context_admission::stores::StoreAdmission>,
114) -> SourceRead {
115 if root.trim().is_empty() {
116 return SourceRead::Unavailable(NO_PROJECT_ROOT.to_owned());
117 }
118 match crate::core::rooted_read::read_file_for_tool_rooted_admitted_with(
119 path, root, tool, remaining, gateway,
120 ) {
121 Ok(read) => SourceRead::Admitted(read),
122 Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
123 SourceRead::Withheld(error.to_string())
124 }
125 Err(error) => SourceRead::Unavailable(error.to_string()),
126 }
127}
128
129pub fn as_agent_acquisition<T>(root: &str, operation: impl FnOnce() -> T) -> T {
138 let authority = crate::core::policy::runtime::REQUEST_PROJECT
139 .try_with(|slot| slot.borrow().clone())
140 .ok()
141 .flatten()
142 .unwrap_or_else(|| PathBuf::from(root));
143 crate::core::policy::runtime::REQUEST_PROJECT
144 .sync_scope(std::cell::RefCell::new(Some(authority)), operation)
145}
146
147pub fn request_root() -> String {
151 crate::core::policy::runtime::REQUEST_PROJECT
152 .try_with(|slot| slot.borrow().clone())
153 .ok()
154 .flatten()
155 .map(|project| project.to_string_lossy().into_owned())
156 .unwrap_or_default()
157}
158
159pub fn serve_originless(text: &str, subject: &str) -> Result<String, String> {
166 crate::core::context_admission::stored::serve_stored(text, None, None)
167 .map_err(|withheld| withheld.message(subject))
168}
169
170pub fn released_cache_content(
178 cache: &crate::core::cache::SessionCache,
179 path: &str,
180) -> Option<String> {
181 cache
182 .current_full_content(path)
183 .and_then(Result::ok)
184 .map(|(text, _)| text)
185}
186
187pub fn withheld_line(label: &str, reason: &str) -> String {
191 format!("[withheld] {label}: {reason}")
192}
193
194pub const NO_PROJECT_ROOT: &str = "project root unavailable";
196
197pub fn root_for(path: &str, roots: &[String]) -> String {
207 let mut named = roots.iter().filter(|root| !root.trim().is_empty());
208 let Some(first) = named.clone().next() else {
209 return String::new();
210 };
211 if Path::new(path).is_absolute() {
212 let target = canonical(Path::new(path));
213 if let Some(containing) = named.find(|root| target.starts_with(canonical(Path::new(root))))
214 {
215 return containing.clone();
216 }
217 }
218 first.clone()
219}
220
221#[cfg(any(test, feature = "test-support"))]
225pub fn fixture_root(path: &str) -> String {
226 let target = canonical(Path::new(path));
227 if target.is_dir() {
228 return target.to_string_lossy().into_owned();
229 }
230 target
231 .parent()
232 .map_or_else(String::new, |parent| parent.to_string_lossy().into_owned())
233}
234
235pub fn canonical(path: &Path) -> PathBuf {
236 crate::core::pathutil::safe_canonicalize_bounded(path, 2000)
237}
238
239fn project_policy_allows(text: &str, complete: bool, tool: &str) -> Result<(), String> {
243 if let Some(policy) = crate::core::policy::runtime::active() {
244 let outcome = crate::core::policy::content::evaluate_text(text, &policy);
245 crate::core::policy::audit_filter::audit_filter(tool, &outcome.audit, outcome.blocked);
246 if outcome.blocked || !complete {
247 return Err("source content withheld by policy".to_owned());
248 }
249 }
250 Ok(())
251}
252
253pub fn admit_remote_text(text: &str, tool: &str) -> Result<SourceAdmission, String> {
259 admit_remote_class(text, tool, &REMOTE_UNKNOWN)
260}
261
262pub fn admit_remote_text_as(
265 text: &str,
266 tool: &str,
267 origin: &InputOrigin,
268) -> Result<SourceAdmission, String> {
269 admit_remote_class(text, tool, &SourceClass::External(origin.clone()))
270}
271
272const REMOTE_UNKNOWN: SourceClass = SourceClass::Unclassified { tool: "remote" };
273
274fn admit_remote_class(
275 text: &str,
276 tool: &str,
277 class: &SourceClass,
278) -> Result<SourceAdmission, String> {
279 crate::core::context_admission::capture::note_origin(class);
281 project_policy_allows(text, true, tool)?;
282 crate::core::context_admission::admit_context_source(text, class)
283 .map_err(|error| error.to_string())
284}
285
286pub fn admit_remote_file(
295 bytes: Vec<u8>,
296 origin_path: &str,
297 tool: &str,
298) -> Result<SourceAdmission, String> {
299 admit_remote_file_class(bytes, origin_path, tool, &REMOTE_UNKNOWN)
300}
301
302pub fn admit_remote_file_as(
304 bytes: Vec<u8>,
305 origin_path: &str,
306 tool: &str,
307 origin: &InputOrigin,
308) -> Result<SourceAdmission, String> {
309 admit_remote_file_class(
310 bytes,
311 origin_path,
312 tool,
313 &SourceClass::External(origin.clone()),
314 )
315}
316
317fn admit_remote_file_class(
318 bytes: Vec<u8>,
319 origin_path: &str,
320 tool: &str,
321 class: &SourceClass,
322) -> Result<SourceAdmission, String> {
323 crate::core::context_admission::capture::note_origin(class);
324 if crate::core::binary_detect::has_binary_extension(origin_path)
325 || crate::core::text_decode::looks_binary(&bytes[..bytes.len().min(8192)])
326 {
327 return Err("source format is not inspectable as text".to_owned());
328 }
329 let decoded = crate::core::text_decode::decode_checked(bytes);
330 let complete = decoded.complete();
331 project_policy_allows(&decoded.text, complete, tool)?;
332 crate::core::context_admission::admit_source_with_storage(
333 &decoded.text,
334 origin_path,
335 false,
336 OutputCompleteness {
337 decode_lossy: !complete,
338 ..Default::default()
339 },
340 class,
341 )
342 .inspect(|_| crate::core::context_admission::capture::note_source_id(origin_path))
343 .map_err(|error| error.to_string())
344}
345
346#[cfg(test)]
347mod tests {
348 use super::*;
349
350 const AWS_KEY: &str = concat!("AK", "IAIOSFODNN7EXAMPLE");
351
352 fn policy(body: &str) -> crate::core::policy::ResolvedPolicy {
353 crate::core::policy::load(&format!(
354 "name = \"admitted-sources-test\"\nversion = \"1.0.0\"\ndescription = \"test\"\n{body}"
355 ))
356 .unwrap()
357 }
358
359 #[test]
360 fn a_rooted_read_is_sorted_into_admitted_withheld_and_unavailable() {
361 let _data = crate::core::data_dir::isolated_data_dir();
362 let _policy = crate::core::policy::runtime::TestPolicyOverride::set(Some(policy(
363 "[filters]\nclassification = \"block\"\nblocked_labels = [\"CONFIDENTIAL\"]\n",
364 )));
365 let project = tempfile::tempdir().unwrap();
366 let root = project.path().to_string_lossy().into_owned();
367 let clean = project.path().join("clean.rs");
368 let blocked = project.path().join("blocked.rs");
369 std::fs::write(&clean, "pub fn clean() {}\n").unwrap();
370 std::fs::write(&blocked, "CONFIDENTIAL\npub fn hidden_marker() {}\n").unwrap();
371
372 let mut remaining = crate::core::limits::max_read_bytes();
373 let read = read(
374 &clean.to_string_lossy(),
375 &root,
376 "ctx_outline",
377 &mut remaining,
378 );
379 assert_eq!(read.text(), Some("pub fn clean() {}\n"));
380 assert_eq!(read.storable(), Some("pub fn clean() {}\n"));
381
382 let read = super::read(
383 &blocked.to_string_lossy(),
384 &root,
385 "ctx_outline",
386 &mut remaining,
387 );
388 let reason = read.withheld().expect("a blocked source is withheld");
389 assert!(!reason.contains("hidden_marker"), "{reason}");
390 assert!(read.text().is_none() && read.storable().is_none());
391
392 let missing = project.path().join("missing.rs");
393 let read = super::read(
394 &missing.to_string_lossy(),
395 &root,
396 "ctx_outline",
397 &mut remaining,
398 );
399 assert!(read.withheld().is_none() && read.text().is_none());
400 }
401
402 #[test]
403 fn a_restricted_original_is_delivered_but_never_storable() {
404 let _data = crate::core::data_dir::isolated_data_dir();
405 let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
406 let project = tempfile::tempdir().unwrap();
407 let root = project.path().to_string_lossy().into_owned();
408 let marked = project.path().join("marked.rs");
409 std::fs::write(&marked, "// TOP SECRET\npub fn plan() {}\n").unwrap();
410 let mut remaining = crate::core::limits::max_read_bytes();
411 let read = read(
412 &marked.to_string_lossy(),
413 &root,
414 "ctx_preload",
415 &mut remaining,
416 );
417 assert!(read.text().is_some_and(|text| text.contains("pub fn plan")));
418 assert!(
419 read.storable().is_none(),
420 "a restricted original must not enter a derived store"
421 );
422 }
423
424 #[test]
425 fn remote_text_is_admitted_with_its_origin_and_clean_text_keeps_its_bytes() {
426 let _data = crate::core::data_dir::isolated_data_dir();
427 let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
428
429 let clean = "# Title\n\nplain remote text\n";
430 let admitted =
431 admit_remote_file(clean.as_bytes().to_vec(), "README.md", "ctx_git_read").unwrap();
432 assert_eq!(admitted.text, clean);
433 assert!(admitted.storable);
434 assert_eq!(
435 admit_remote_text(clean, "ctx_url_read").unwrap().text,
436 clean
437 );
438
439 let keyed = format!("aws_access_key_id = {AWS_KEY}\n");
440 let admitted = admit_remote_text(&keyed, "ctx_url_read").unwrap();
441 assert!(!admitted.text.contains(AWS_KEY), "{}", admitted.text);
442
443 let admitted = admit_remote_file(b"PORT=8080\n".to_vec(), ".env", "ctx_git_read").unwrap();
445 assert!(!admitted.storable);
446
447 let binary = admit_remote_file(vec![0x7f, b'E', b'L', b'F', 0, 0, 0, 0], "tool", "x");
449 assert!(binary.is_err());
450 }
451
452 #[test]
453 fn remote_text_blocked_by_the_project_policy_is_refused_without_content() {
454 let _data = crate::core::data_dir::isolated_data_dir();
455 let _policy = crate::core::policy::runtime::TestPolicyOverride::set(Some(policy(
456 "[filters]\nclassification = \"block\"\nblocked_labels = [\"CONFIDENTIAL\"]\n",
457 )));
458 let reason = admit_remote_text("CONFIDENTIAL\nroadmap-marker\n", "ctx_url_read")
459 .expect_err("blocked content is refused");
460 assert!(!reason.contains("roadmap-marker"), "{reason}");
461 let reason = admit_remote_file(
462 b"CONFIDENTIAL\nroadmap-marker\n".to_vec(),
463 "docs/plan.md",
464 "ctx_git_read",
465 )
466 .expect_err("blocked content is refused");
467 assert!(!reason.contains("roadmap-marker"), "{reason}");
468 }
469
470 #[test]
471 fn an_agent_acquisition_binds_the_project_and_keeps_one_already_bound() {
472 let bound = || {
473 crate::core::policy::runtime::REQUEST_PROJECT
474 .try_with(|slot| slot.borrow().clone())
475 .ok()
476 .flatten()
477 };
478 assert_eq!(bound(), None, "no request scope outside an acquisition");
479 assert_eq!(
480 as_agent_acquisition("/project/a", bound),
481 Some(PathBuf::from("/project/a"))
482 );
483 let nested =
484 as_agent_acquisition("/project/a", || as_agent_acquisition("/project/b", bound));
485 assert_eq!(nested, Some(PathBuf::from("/project/a")));
486 }
487
488 #[test]
489 fn a_path_is_read_under_the_root_that_contains_it() {
490 let project = tempfile::tempdir().unwrap();
491 let other = tempfile::tempdir().unwrap();
492 let root = project.path().to_string_lossy().into_owned();
493 let extra = other.path().to_string_lossy().into_owned();
494 let roots = vec![root.clone(), extra.clone()];
495 let inside = project.path().join("a.rs");
496 let in_extra = other.path().join("b.rs");
497 std::fs::write(&inside, "fn a() {}\n").unwrap();
498 std::fs::write(&in_extra, "fn b() {}\n").unwrap();
499
500 assert_eq!(root_for(&inside.to_string_lossy(), &roots), root);
501 assert_eq!(root_for(&in_extra.to_string_lossy(), &roots), extra);
502 assert_eq!(root_for("src/lib.rs", &roots), root);
503 }
504
505 #[test]
509 fn a_path_outside_every_root_is_not_read() {
510 let _data = crate::core::data_dir::isolated_data_dir();
511 let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
512 let project = tempfile::tempdir().unwrap();
513 let elsewhere = tempfile::tempdir().unwrap();
514 let root = project.path().to_string_lossy().into_owned();
515 let outside = elsewhere.path().join("notes.txt");
516 std::fs::write(&outside, "outside-marker\n").unwrap();
517 let outside = outside.to_string_lossy().into_owned();
518
519 assert_eq!(root_for(&outside, std::slice::from_ref(&root)), root);
520 let mut remaining = crate::core::limits::max_read_bytes();
521 let refused = read(&outside, &root, "ctx_analyze", &mut remaining);
522 assert!(refused.text().is_none(), "a path outside the root is read");
523 let (SourceRead::Withheld(reason) | SourceRead::Unavailable(reason)) = &refused else {
524 panic!("a path outside the root is read");
525 };
526 assert!(!reason.contains("outside-marker"), "{reason}");
527
528 assert_eq!(root_for(&outside, &[]), "");
529 assert_eq!(root_for(&outside, &[String::new()]), "");
530 let SourceRead::Unavailable(reason) = read(&outside, "", "ctx_analyze", &mut remaining)
531 else {
532 panic!("an absent project root must withhold");
533 };
534 assert_eq!(reason, NO_PROJECT_ROOT);
535 }
536}