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    let root_canon = project_root
39        .canonicalize()
40        .unwrap_or_else(|_| project_root.to_path_buf());
41
42    let Some((registry_path, content)) = read_registry_file(project_root) else {
43        return out;
44    };
45
46    let parsed = parse_registry_json(&content).unwrap_or_else(|e| {
47        out.warnings.push(format!(
48            "artifact registry parse failed ({}): {e}",
49            registry_path.display()
50        ));
51        ArtifactRegistry { artifacts: vec![] }
52    });
53
54    let mut seen = std::collections::HashSet::<String>::new();
55    for spec in parsed.artifacts {
56        let name = spec.name.trim().to_string();
57        if name.is_empty() {
58            continue;
59        }
60        if !seen.insert(name.clone()) {
61            continue;
62        }
63
64        let raw = spec.path.trim();
65        if raw.is_empty() {
66            continue;
67        }
68        let rel = normalize_rel_path(raw);
69        let candidate = if Path::new(&rel).is_absolute() {
70            PathBuf::from(&rel)
71        } else {
72            project_root.join(&rel)
73        };
74
75        let abs = match crate::core::io_boundary::jail_and_check_path(
76            "artifacts",
77            &candidate,
78            project_root,
79        ) {
80            Ok((p, _)) => p,
81            Err(e) => {
82                out.warnings
83                    .push(format!("artifact path rejected ({name}): {rel} ({e})"));
84                continue;
85            }
86        };
87
88        // Secret-like paths are denied by default for artifacts unless explicitly allowed.
89        // Artifacts tend to be indexed/shared; prefer safety over convenience.
90        let role = crate::core::roles::active_role();
91        if !role.io.allow_secret_paths
92            && let Some(reason) = crate::core::io_boundary::is_secret_like(&abs)
93        {
94            let role_name = crate::core::roles::active_role_name();
95            let msg = format!(
96                "artifact rejected ({name}): {rel} (secret-like path: {reason}; role: {role_name})"
97            );
98            crate::core::events::emit_policy_violation(&role_name, "artifacts", &msg);
99            out.warnings.push(msg);
100            continue;
101        }
102
103        let (exists, is_dir) = match abs.metadata() {
104            Ok(m) => (true, m.is_dir()),
105            Err(_) => (false, false),
106        };
107
108        let rel_out = abs
109            .strip_prefix(&root_canon)
110            .unwrap_or(&abs)
111            .to_string_lossy()
112            .to_string();
113
114        out.artifacts.push(ResolvedArtifact {
115            name,
116            path: rel_out,
117            description: spec.description.trim().to_string(),
118            tags: spec.tags,
119            exists,
120            is_dir,
121        });
122    }
123
124    out
125}
126
127fn read_registry_file(project_root: &Path) -> Option<(PathBuf, String)> {
128    let new = project_root.join(".lean-ctx-artifacts.json");
129    if let Ok(s) = std::fs::read_to_string(&new) {
130        return Some((new, s));
131    }
132    let legacy = project_root.join(".leanctxcontextartifacts.json");
133    if let Ok(s) = std::fs::read_to_string(&legacy) {
134        return Some((legacy, s));
135    }
136    let socrati = project_root.join(".socraticodecontextartifacts.json");
137    if let Ok(s) = std::fs::read_to_string(&socrati) {
138        return Some((socrati, s));
139    }
140    None
141}
142
143fn parse_registry_json(content: &str) -> Result<ArtifactRegistry, String> {
144    if let Ok(reg) = serde_json::from_str::<ArtifactRegistry>(content) {
145        return Ok(reg);
146    }
147    if let Ok(list) = serde_json::from_str::<Vec<ArtifactSpec>>(content) {
148        return Ok(ArtifactRegistry { artifacts: list });
149    }
150    Err("invalid JSON schema (expected { artifacts: [...] } or [...])".to_string())
151}
152
153fn normalize_rel_path(raw: &str) -> String {
154    let mut s = raw.trim().to_string();
155    while let Some(rest) = s.strip_prefix("./") {
156        s = rest.to_string();
157    }
158    s.trim_start_matches(['/', '\\']).to_string()
159}