Skip to main content

lean_ctx/core/
artifacts.rs

1use 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    // Must go through the same canonicalizer as the jail below, otherwise the
39    // two sides disagree on Windows (verbatim prefix, 8.3 short names, case)
40    // and `strip_prefix` silently keeps absolute paths as index keys.
41    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        // Secret-like paths are denied by default for artifacts unless explicitly allowed.
85        // Artifacts tend to be indexed/shared; prefer safety over convenience.
86        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        // Forward slashes for index keys / display on every platform.
105        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
158/// Resolve a registry `path` to the filesystem candidate handed to the jail.
159///
160/// Absolute paths and `~/…` stay absolute so external doc corpora (an Obsidian
161/// vault, a shared `~/notes`) can be declared directly (GL#1132) — the PathJail
162/// allow-list (`read_only_roots` / `extra_roots` / `LEAN_CTX_ALLOW_PATH`)
163/// remains the security gate, so an absolute entry outside the allowed roots is
164/// still rejected. Legacy compatibility: a leading slash historically meant
165/// "project-relative" (it was stripped), so when the stripped reading matches
166/// an existing path inside the project it wins over the absolute reading.
167fn 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        // "/docs" used to mean "<root>/docs"; that reading still wins while
203        // the project path exists.
204        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    // Verifies jail *behavior*, so it cannot run when the `no-jail` feature
221    // compiles the jail out entirely (CI runs tests with --all-features).
222    #[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        // Without an allow-list entry the external path is rejected by the jail.
241        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        // Allow-listing the folder (config `read_only_roots`/`extra_roots` or
251        // this env var) makes it a first-class corpus.
252        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}