use std::path::{Path, PathBuf};
use lingshu_security::path_policy::PathPolicy;
#[derive(Debug, Clone, PartialEq)]
pub enum ContextRef {
File {
path: PathBuf,
line_start: Option<usize>,
line_end: Option<usize>,
},
Folder(PathBuf),
Url(String),
Diff,
Staged,
Git(String),
}
#[derive(Debug)]
pub struct ExpansionResult {
pub expanded: String,
pub refs_found: Vec<ContextRef>,
pub errors: Vec<String>,
pub budget_blocked: bool,
pub budget_warning: bool,
}
const BLOCKED_SEGMENTS: &[&str] = &[
".ssh",
".aws",
".gnupg",
".pgp",
".gpg",
".kube",
".netrc",
".npmrc",
".pypirc",
".pgpass",
"id_rsa",
"id_ed25519",
"id_ecdsa",
"authorized_keys",
"credentials",
".bashrc",
".zshrc",
".profile",
".bash_profile",
".zprofile",
];
const MAX_FILE_BYTES: u64 = 512 * 1024;
pub fn expand_context_refs(text: &str, cwd: &Path) -> ExpansionResult {
let policy = PathPolicy::new(cwd.to_path_buf());
expand_context_refs_with_policy(text, cwd, &policy)
}
pub fn expand_context_refs_with_policy(
text: &str,
cwd: &Path,
policy: &PathPolicy,
) -> ExpansionResult {
let refs = find_refs(text);
let mut expanded = text.to_string();
let mut refs_found = Vec::new();
let mut errors = Vec::new();
let mut context_sections: Vec<(String, String)> = Vec::new();
for ctx_ref in &refs {
let (placeholder, label, replacement) = match ctx_ref {
ContextRef::File {
path,
line_start,
line_end,
} => {
let raw = match (line_start, line_end) {
(Some(s), Some(e)) if s == e => format!("@file:{}:{s}", path.display()),
(Some(s), Some(e)) => format!("@file:{}:{s}-{e}", path.display()),
(Some(s), None) => format!("@file:{}:{s}", path.display()),
_ => format!("@file:{}", path.display()),
};
let label = raw.clone();
let content = expand_file(path, policy, *line_start, *line_end);
(raw, label, content)
}
ContextRef::Folder(path) => {
let raw = format!("@folder:{}", path.display());
let label = raw.clone();
let content = expand_folder(path, policy, cwd);
(raw, label, content)
}
ContextRef::Url(url) => {
let raw = format!("@url:{url}");
let label = raw.clone();
let note = format!("[URL context for {url} — fetch deferred to runtime]");
(raw, label, Ok(note))
}
ContextRef::Diff => {
let raw = "@diff".to_string();
let content = run_git_command(&["diff"]);
(raw, "git diff (unstaged)".to_string(), content)
}
ContextRef::Staged => {
let raw = "@staged".to_string();
let content = run_git_command(&["diff", "--staged"]);
(raw, "git diff --staged".to_string(), content)
}
ContextRef::Git(git_ref) => {
let raw = format!("@git:{git_ref}");
let content = expand_git_log(git_ref);
(raw.clone(), raw, content)
}
};
match replacement {
Ok(content) => {
refs_found.push(ctx_ref.clone());
expanded = expanded.replacen(&placeholder, "", 1);
context_sections.push((label, content));
}
Err(e) => {
errors.push(format!("{placeholder}: {e}"));
}
}
}
let expanded = expanded.trim().to_string();
let expanded = if context_sections.is_empty() {
expanded
} else {
let mut buf = expanded;
buf.push_str("\n\n--- Attached Context ---\n");
for (label, content) in &context_sections {
buf.push_str(&format!("\n### {label}\n\n```\n{content}\n```\n"));
}
buf
};
ExpansionResult {
expanded,
refs_found,
errors,
budget_blocked: false,
budget_warning: false,
}
}
fn find_refs(text: &str) -> Vec<ContextRef> {
let mut refs = Vec::new();
for token in text.split_whitespace() {
let token = token.trim_start_matches(['(', '"', '\'']);
let token = token.trim_end_matches(|c: char| {
matches!(c, ')' | '"' | '\'' | ',' | '.' | ':' | ';' | '?' | '!')
});
if !token.starts_with('@') {
continue;
}
let after = &token[1..];
if let Some(path_str) = after.strip_prefix("file:") {
let (path_part, line_start, line_end) = parse_file_ref(path_str);
refs.push(ContextRef::File {
path: PathBuf::from(path_part),
line_start,
line_end,
});
} else if let Some(path_str) = after.strip_prefix("folder:") {
refs.push(ContextRef::Folder(PathBuf::from(path_str)));
} else if let Some(url) = after.strip_prefix("url:") {
refs.push(ContextRef::Url(url.to_string()));
} else if after == "diff" {
refs.push(ContextRef::Diff);
} else if after == "staged" {
refs.push(ContextRef::Staged);
} else if let Some(git_ref) = after.strip_prefix("git:") {
refs.push(ContextRef::Git(git_ref.to_string()));
}
}
refs.dedup_by(|a, b| a == b); refs
}
fn parse_file_ref(s: &str) -> (&str, Option<usize>, Option<usize>) {
if let Some(colon_pos) = s.rfind(':') {
let suffix = &s[colon_pos + 1..];
if !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit() || c == '-') {
let path_part = &s[..colon_pos];
if let Some(dash) = suffix.find('-') {
let start_str = &suffix[..dash];
let end_str = &suffix[dash + 1..];
if let (Ok(start), Ok(end)) = (start_str.parse::<usize>(), end_str.parse::<usize>())
&& start > 0
&& end >= start
{
return (path_part, Some(start), Some(end));
}
} else if let Ok(line) = suffix.parse::<usize>()
&& line > 0
{
return (path_part, Some(line), Some(line));
}
}
}
(s, None, None)
}
fn expand_file(
path: &Path,
policy: &PathPolicy,
line_start: Option<usize>,
line_end: Option<usize>,
) -> Result<String, String> {
let abs = security_check_path(path, policy)?;
let metadata =
std::fs::metadata(&abs).map_err(|e| format!("cannot stat '{}': {e}", abs.display()))?;
if metadata.len() > MAX_FILE_BYTES {
return Err(format!(
"file '{}' is {} bytes — exceeds {} KB limit",
abs.display(),
metadata.len(),
MAX_FILE_BYTES / 1024
));
}
let bytes = std::fs::read(&abs).map_err(|e| format!("cannot read '{}': {e}", abs.display()))?;
if bytes.iter().take(8192).any(|&b| b == 0) {
return Err(format!("'{}' appears to be a binary file", abs.display()));
}
let full_text =
String::from_utf8(bytes).map_err(|_| format!("'{}' is not valid UTF-8", abs.display()))?;
match (line_start, line_end) {
(Some(start), Some(end)) => {
let start0 = start.saturating_sub(1);
let lines: Vec<&str> = full_text.lines().collect();
let end0 = end.min(lines.len()); if start0 >= lines.len() {
return Ok(full_text);
}
Ok(lines[start0..end0].join("\n"))
}
_ => Ok(full_text),
}
}
fn expand_git_log(git_ref: &str) -> Result<String, String> {
if let Ok(n) = git_ref.parse::<usize>() {
let n = n.clamp(1, 10);
run_git_command(&["log", &format!("-{n}"), "--patch", "--format=%H %s"])
} else {
run_git_command(&["show", git_ref])
}
}
fn expand_folder(path: &Path, policy: &PathPolicy, _cwd: &Path) -> Result<String, String> {
let abs = security_check_path(path, policy)?;
let entries =
std::fs::read_dir(&abs).map_err(|e| format!("cannot list '{}': {e}", abs.display()))?;
let mut names: Vec<String> = entries
.filter_map(|e| e.ok())
.map(|e| {
let name = e.file_name().to_string_lossy().into_owned();
if e.path().is_dir() {
format!("{name}/")
} else {
name
}
})
.collect();
names.sort();
if names.is_empty() {
return Ok(format!("Directory '{}' is empty.", abs.display()));
}
Ok(format!(
"Directory listing for '{}':\n{}",
abs.display(),
names.join("\n")
))
}
fn run_git_command(args: &[&str]) -> Result<String, String> {
let output = std::process::Command::new("git")
.args(args)
.output()
.map_err(|e| format!("git {}: {e}", args.join(" ")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("git {} failed: {}", args.join(" "), stderr.trim()));
}
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
if stdout.is_empty() {
Ok("(no output)".to_string())
} else {
Ok(stdout)
}
}
fn security_check_path(path: &Path, policy: &PathPolicy) -> Result<PathBuf, String> {
let path_str = path.to_string_lossy();
for blocked in BLOCKED_SEGMENTS {
if path_str.contains(blocked) {
return Err(format!(
"access to '{}' blocked — path contains sensitive segment '{blocked}'",
path.display()
));
}
}
policy
.resolve_read_path(path, &[])
.map_err(|e| format!("access to '{}' blocked — {}", path.display(), e))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn temp_dir_with_file(name: &str, contents: &str) -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join(name);
std::fs::write(&path, contents).expect("write");
(dir, path)
}
#[test]
fn find_refs_file() {
let refs = find_refs("please look at @file:src/main.rs and fix it");
assert_eq!(refs.len(), 1);
assert_eq!(
refs[0],
ContextRef::File {
path: PathBuf::from("src/main.rs"),
line_start: None,
line_end: None
}
);
}
#[test]
fn find_refs_file_single_line() {
let refs = find_refs("look at @file:src/main.rs:42");
assert_eq!(refs.len(), 1);
assert_eq!(
refs[0],
ContextRef::File {
path: PathBuf::from("src/main.rs"),
line_start: Some(42),
line_end: Some(42)
}
);
}
#[test]
fn find_refs_file_line_range() {
let refs = find_refs("look at @file:src/main.rs:10-25");
assert_eq!(refs.len(), 1);
assert_eq!(
refs[0],
ContextRef::File {
path: PathBuf::from("src/main.rs"),
line_start: Some(10),
line_end: Some(25)
}
);
}
#[test]
fn find_refs_diff() {
let refs = find_refs("check @diff and @staged");
assert_eq!(refs.len(), 2);
assert!(refs.contains(&ContextRef::Diff));
assert!(refs.contains(&ContextRef::Staged));
}
#[test]
fn find_refs_git() {
let refs = find_refs("what changed in @git:HEAD~1?");
assert_eq!(refs.len(), 1);
assert_eq!(refs[0], ContextRef::Git("HEAD~1".to_string()));
}
#[test]
fn find_refs_url() {
let refs = find_refs("see @url:https://example.com/api");
assert_eq!(refs.len(), 1);
assert_eq!(
refs[0],
ContextRef::Url("https://example.com/api".to_string())
);
}
#[test]
fn find_refs_deduplicates() {
let refs = find_refs("@diff @diff");
assert_eq!(refs.len(), 1);
}
#[test]
fn expand_file_reads_contents() {
let (dir, _path) = temp_dir_with_file("hello.txt", "hello world");
let policy = PathPolicy::new(dir.path().to_path_buf());
let rel = PathBuf::from("hello.txt");
let result = expand_file(&rel, &policy, None, None);
assert_eq!(result.expect("ok"), "hello world");
}
#[test]
fn expand_file_line_range() {
let (dir, _path) = temp_dir_with_file("lines.txt", "line1\nline2\nline3\nline4\nline5");
let policy = PathPolicy::new(dir.path().to_path_buf());
let rel = PathBuf::from("lines.txt");
let result = expand_file(&rel, &policy, Some(2), Some(4));
let content = result.expect("ok");
assert!(content.contains("line2"));
assert!(content.contains("line4"));
assert!(!content.contains("line1"));
assert!(!content.contains("line5"));
}
#[test]
fn expand_file_blocks_ssh_path() {
let cwd = std::env::current_dir().expect("cwd");
let policy = PathPolicy::new(cwd.clone());
let path = PathBuf::from(".ssh/id_rsa");
let result = expand_file(&path, &policy, None, None);
assert!(result.is_err());
let err = result.expect_err("err");
assert!(err.contains("blocked"));
}
#[test]
fn expand_folder_lists_entries() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join("a.txt"), "").expect("write");
std::fs::write(dir.path().join("b.txt"), "").expect("write");
let cwd = dir.path().parent().expect("parent").to_path_buf();
let policy = PathPolicy::new(cwd.clone());
let rel = dir.path().file_name().expect("name").to_os_string();
let result = expand_folder(&PathBuf::from(&rel), &policy, &cwd);
let listing = result.expect("ok");
assert!(listing.contains("a.txt"));
assert!(listing.contains("b.txt"));
}
#[test]
fn expand_context_refs_inlines_file() {
let (dir, _) = temp_dir_with_file("greet.txt", "Hello!");
let text = "read @file:greet.txt please";
let result = expand_context_refs(text, dir.path());
assert!(
result.expanded.contains("Hello!"),
"expanded: {}",
result.expanded
);
assert!(
result.expanded.contains("Attached Context"),
"should use Attached Context format"
);
assert_eq!(result.refs_found.len(), 1);
assert!(result.errors.is_empty());
}
#[test]
fn expand_context_refs_handles_missing_file() {
let dir = tempfile::tempdir().expect("tempdir");
let text = "read @file:nonexistent.txt please";
let result = expand_context_refs(text, dir.path());
assert_eq!(result.errors.len(), 1);
assert!(result.refs_found.is_empty());
}
#[test]
fn expand_context_refs_no_refs_unchanged() {
let dir = tempfile::tempdir().expect("tempdir");
let text = "just a regular message with no refs";
let result = expand_context_refs(text, dir.path());
assert_eq!(result.expanded, text);
assert!(result.refs_found.is_empty());
}
#[test]
fn security_check_blocks_traversal() {
let dir = tempfile::tempdir().expect("tempdir");
let policy = PathPolicy::new(dir.path().to_path_buf());
let evil = PathBuf::from("../../etc/passwd");
let result = security_check_path(&evil, &policy);
assert!(result.is_err());
}
#[test]
fn binary_file_is_blocked() {
let dir = tempfile::tempdir().expect("tempdir");
let policy = PathPolicy::new(dir.path().to_path_buf());
let path = dir.path().join("binary.bin");
let mut f = std::fs::File::create(&path).expect("create");
f.write_all(&[0x7f, 0x45, 0x4c, 0x46, 0x00, 0x01])
.expect("write");
drop(f);
let result = expand_file(&PathBuf::from("binary.bin"), &policy, None, None);
assert!(result.is_err());
assert!(result.expect_err("err").contains("binary"));
}
#[test]
fn expand_context_refs_with_policy_allows_explicit_extra_root() {
let workspace = tempfile::tempdir().expect("workspace");
let shared = tempfile::tempdir().expect("shared");
let shared_file = shared.path().join("shared.txt");
std::fs::write(&shared_file, "shared context").expect("write shared");
let policy = PathPolicy::new(workspace.path().to_path_buf())
.with_allowed_roots(vec![shared.path().to_path_buf()]);
let text = format!("check @file:{}", shared_file.display());
let result = expand_context_refs_with_policy(&text, workspace.path(), &policy);
assert!(result.errors.is_empty(), "{:?}", result.errors);
assert!(result.expanded.contains("shared context"));
}
#[test]
fn expand_context_refs_with_policy_blocks_denylisted_subtree() {
let workspace = tempfile::tempdir().expect("workspace");
let secrets_dir = workspace.path().join("secrets");
std::fs::create_dir_all(&secrets_dir).expect("create secrets");
std::fs::write(secrets_dir.join("token.txt"), "token").expect("write token");
let policy = PathPolicy::new(workspace.path().to_path_buf())
.with_denied_roots(vec![PathBuf::from("secrets")]);
let result = expand_context_refs_with_policy(
"read @file:secrets/token.txt",
workspace.path(),
&policy,
);
assert_eq!(result.errors.len(), 1);
assert!(result.refs_found.is_empty());
}
}