lean_ctx/core/
artifacts.rs1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct ArtifactRegistry {
7 pub artifacts: Vec<ArtifactSpec>,
8}
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct ArtifactSpec {
12 pub name: String,
13 pub path: String,
14 pub description: String,
15 #[serde(default)]
16 pub tags: Vec<String>,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct ResolvedArtifact {
21 pub name: String,
22 pub path: String,
23 pub description: String,
24 #[serde(default)]
25 pub tags: Vec<String>,
26 pub exists: bool,
27 pub is_dir: bool,
28}
29
30#[derive(Debug, Default, Serialize)]
31pub struct ResolvedArtifacts {
32 pub artifacts: Vec<ResolvedArtifact>,
33 pub warnings: Vec<String>,
34}
35
36pub fn load_resolved(project_root: &Path) -> ResolvedArtifacts {
37 let mut out = ResolvedArtifacts::default();
38 let root_canon = crate::core::pathutil::canonicalize_secure_or_self(project_root);
42
43 let Some((registry_path, content)) = read_registry_file(project_root) else {
44 return out;
45 };
46
47 let parsed = parse_registry_json(&content).unwrap_or_else(|e| {
48 out.warnings.push(format!(
49 "artifact registry parse failed ({}): {e}",
50 registry_path.display()
51 ));
52 ArtifactRegistry { artifacts: vec![] }
53 });
54
55 let mut seen = std::collections::HashSet::<String>::new();
56 for spec in parsed.artifacts {
57 let name = spec.name.trim().to_string();
58 if name.is_empty() {
59 continue;
60 }
61 if !seen.insert(name.clone()) {
62 continue;
63 }
64
65 let raw = spec.path.trim();
66 if raw.is_empty() {
67 continue;
68 }
69 let candidate = resolve_candidate(project_root, raw);
70
71 let abs = match crate::core::io_boundary::jail_and_check_path(
72 "artifacts",
73 &candidate,
74 project_root,
75 ) {
76 Ok((p, _)) => p,
77 Err(e) => {
78 out.warnings
79 .push(format!("artifact path rejected ({name}): {raw} ({e})"));
80 continue;
81 }
82 };
83
84 let role = crate::core::roles::active_role();
87 if !role.io.allow_secret_paths
88 && let Some(reason) = crate::core::io_boundary::is_secret_like(&abs)
89 {
90 let role_name = crate::core::roles::active_role_name();
91 let msg = format!(
92 "artifact rejected ({name}): {raw} (secret-like path: {reason}; role: {role_name})"
93 );
94 crate::core::events::emit_policy_violation(&role_name, "artifacts", &msg);
95 out.warnings.push(msg);
96 continue;
97 }
98
99 let (exists, is_dir) = match abs.metadata() {
100 Ok(m) => (true, m.is_dir()),
101 Err(_) => (false, false),
102 };
103
104 let rel_out = abs
106 .strip_prefix(&root_canon)
107 .unwrap_or(&abs)
108 .to_string_lossy()
109 .replace('\\', "/");
110
111 out.artifacts.push(ResolvedArtifact {
112 name,
113 path: rel_out,
114 description: spec.description.trim().to_string(),
115 tags: spec.tags,
116 exists,
117 is_dir,
118 });
119 }
120
121 out
122}
123
124fn read_registry_file(project_root: &Path) -> Option<(PathBuf, String)> {
125 let new = project_root.join(".lean-ctx-artifacts.json");
126 if let Ok(s) = std::fs::read_to_string(&new) {
127 return Some((new, s));
128 }
129 let legacy = project_root.join(".leanctxcontextartifacts.json");
130 if let Ok(s) = std::fs::read_to_string(&legacy) {
131 return Some((legacy, s));
132 }
133 let socrati = project_root.join(".socraticodecontextartifacts.json");
134 if let Ok(s) = std::fs::read_to_string(&socrati) {
135 return Some((socrati, s));
136 }
137 None
138}
139
140fn parse_registry_json(content: &str) -> Result<ArtifactRegistry, String> {
141 if let Ok(reg) = serde_json::from_str::<ArtifactRegistry>(content) {
142 return Ok(reg);
143 }
144 if let Ok(list) = serde_json::from_str::<Vec<ArtifactSpec>>(content) {
145 return Ok(ArtifactRegistry { artifacts: list });
146 }
147 Err("invalid JSON schema (expected { artifacts: [...] } or [...])".to_string())
148}
149
150fn normalize_rel_path(raw: &str) -> String {
151 let mut s = raw.trim().to_string();
152 while let Some(rest) = s.strip_prefix("./") {
153 s = rest.to_string();
154 }
155 s.trim_start_matches(['/', '\\']).to_string()
156}
157
158fn resolve_candidate(project_root: &Path, raw: &str) -> PathBuf {
168 let expanded = crate::core::pathjail::expand_user_path(raw.trim());
169 if expanded.is_absolute() {
170 let legacy = project_root.join(normalize_rel_path(raw));
171 if legacy.exists() {
172 return legacy;
173 }
174 return expanded;
175 }
176 project_root.join(normalize_rel_path(raw))
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182
183 #[cfg(not(feature = "no-jail"))]
184 fn write_registry(root: &Path, body: &str) {
185 std::fs::write(root.join(".lean-ctx-artifacts.json"), body).unwrap();
186 }
187
188 #[test]
189 fn resolve_candidate_keeps_relative_paths_project_scoped() {
190 let root = Path::new("/proj");
191 assert_eq!(
192 resolve_candidate(root, "./docs/notes"),
193 PathBuf::from("/proj/docs/notes")
194 );
195 assert_eq!(resolve_candidate(root, "docs"), PathBuf::from("/proj/docs"));
196 }
197
198 #[test]
199 fn resolve_candidate_prefers_legacy_reading_for_existing_project_paths() {
200 let dir = tempfile::tempdir().unwrap();
201 std::fs::create_dir_all(dir.path().join("docs")).unwrap();
202 assert_eq!(
205 resolve_candidate(dir.path(), "/docs"),
206 dir.path().join("docs")
207 );
208 }
209
210 #[test]
211 fn resolve_candidate_keeps_external_absolute_paths() {
212 let project = tempfile::tempdir().unwrap();
213 let external = tempfile::tempdir().unwrap();
214 let raw = external.path().join("vault");
215 std::fs::create_dir_all(&raw).unwrap();
216 let got = resolve_candidate(project.path(), raw.to_str().unwrap());
217 assert_eq!(got, raw);
218 }
219
220 #[cfg(not(feature = "no-jail"))]
223 #[test]
224 fn external_corpus_requires_allow_list() {
225 let _g = crate::core::data_dir::test_env_lock();
226 let project = tempfile::tempdir().unwrap();
227 let external = tempfile::tempdir().unwrap();
228 let vault = external.path().join("vault");
229 std::fs::create_dir_all(&vault).unwrap();
230 std::fs::write(vault.join("note.md"), "# Vault note\nrotation policy").unwrap();
231
232 write_registry(
233 project.path(),
234 &format!(
235 r#"{{"artifacts":[{{"name":"vault","path":"{}","description":"notes"}}]}}"#,
236 vault.display()
237 ),
238 );
239
240 crate::test_env::remove_var("LEAN_CTX_ALLOW_PATH");
242 let denied = load_resolved(project.path());
243 assert!(denied.artifacts.is_empty(), "{:?}", denied.artifacts);
244 assert!(
245 denied.warnings.iter().any(|w| w.contains("rejected")),
246 "{:?}",
247 denied.warnings
248 );
249
250 crate::test_env::set_var("LEAN_CTX_ALLOW_PATH", external.path());
253 let allowed = load_resolved(project.path());
254 crate::test_env::remove_var("LEAN_CTX_ALLOW_PATH");
255
256 assert_eq!(allowed.artifacts.len(), 1, "{:?}", allowed.warnings);
257 let a = &allowed.artifacts[0];
258 assert!(a.exists);
259 assert!(a.is_dir);
260 assert!(Path::new(&a.path).is_absolute());
261 }
262}