Skip to main content

vfa_tui/catalog/
loader.rs

1use std::path::Path;
2
3use crate::error::TuiError;
4use crate::models::{
5    Agent, AssetIntegrity, McpReference, ModelAssignments, RoleCatalog, Rule, Skill,
6    WorkflowCatalog,
7};
8use crate::security::sanitize::has_control_bytes;
9
10const MAX_CATALOG_FILE_SIZE: u64 = 20 * 1024 * 1024;
11
12/// Read a catalog file with size validation.
13/// Returns an error if the file exceeds `max_size`.
14fn read_catalog_file_with_limit(path: &std::path::Path, max_size: u64) -> Result<String, TuiError> {
15    let metadata = std::fs::metadata(path).map_err(|_| TuiError::CatalogNotFound {
16        path: path.display().to_string(),
17    })?;
18    if metadata.len() > max_size {
19        return Err(TuiError::CatalogParse {
20            path: path.display().to_string(),
21            offset: 0,
22            detail: format!(
23                "file too large: {} bytes exceeds maximum of {} bytes",
24                metadata.len(),
25                max_size
26            ),
27        });
28    }
29    std::fs::read_to_string(path).map_err(|_| TuiError::CatalogNotFound {
30        path: path.display().to_string(),
31    })
32}
33
34/// Read a catalog file with the default size limit (100MB).
35fn read_catalog_file(path: &std::path::Path) -> Result<String, TuiError> {
36    read_catalog_file_with_limit(path, MAX_CATALOG_FILE_SIZE)
37}
38
39/// Load agents from catalog/agents.json.
40/// Returns loaded agents and any errors encountered.
41pub fn load_agents(workspace_root: &Path) -> (Vec<Agent>, Vec<TuiError>) {
42    let file_path = workspace_root.join("catalog").join("agents.json");
43    let path = file_path.display().to_string();
44    let mut errors = Vec::new();
45
46    let data = match read_catalog_file(&file_path) {
47        Ok(d) => d,
48        Err(e) => {
49            errors.push(e);
50            return (Vec::new(), errors);
51        }
52    };
53
54    let all_agents: Vec<Agent> = match serde_json::from_str(&data) {
55        Ok(a) => a,
56        Err(e) => {
57            errors.push(TuiError::CatalogParse {
58                path,
59                offset: e.column(),
60                detail: e.to_string(),
61            });
62            return (Vec::new(), errors);
63        }
64    };
65
66    let mut agents = Vec::new();
67    for (idx, agent) in all_agents.into_iter().enumerate() {
68        if check_agent_tainted(&agent) {
69            errors.push(TuiError::TaintedEntry {
70                path: path.clone(),
71                offset: idx,
72                field: "control bytes detected".to_string(),
73            });
74        } else {
75            agents.push(agent);
76        }
77    }
78
79    (agents, errors)
80}
81
82/// Load skills from catalog/skills.json.
83pub fn load_skills(workspace_root: &Path) -> (Vec<Skill>, Vec<TuiError>) {
84    let file_path = workspace_root.join("catalog").join("skills.json");
85    let path = file_path.display().to_string();
86    let mut errors = Vec::new();
87
88    let data = match read_catalog_file(&file_path) {
89        Ok(d) => d,
90        Err(e) => {
91            errors.push(e);
92            return (Vec::new(), errors);
93        }
94    };
95
96    let all_skills: Vec<Skill> = match serde_json::from_str(&data) {
97        Ok(s) => s,
98        Err(e) => {
99            errors.push(TuiError::CatalogParse {
100                path,
101                offset: e.column(),
102                detail: e.to_string(),
103            });
104            return (Vec::new(), errors);
105        }
106    };
107
108    let mut skills = Vec::new();
109    for (idx, skill) in all_skills.into_iter().enumerate() {
110        if check_skill_tainted(&skill) {
111            errors.push(TuiError::TaintedEntry {
112                path: path.clone(),
113                offset: idx,
114                field: "control bytes detected".to_string(),
115            });
116        } else {
117            skills.push(skill);
118        }
119    }
120
121    (skills, errors)
122}
123
124/// Load MCP references from catalog/mcp-references.json.
125pub fn load_mcp_refs(workspace_root: &Path) -> (Vec<McpReference>, Vec<TuiError>) {
126    let file_path = workspace_root.join("catalog").join("mcp-references.json");
127    let path = file_path.display().to_string();
128    let mut errors = Vec::new();
129
130    let data = match read_catalog_file(&file_path) {
131        Ok(d) => d,
132        Err(e) => {
133            errors.push(e);
134            return (Vec::new(), errors);
135        }
136    };
137
138    let all_refs: Vec<McpReference> = match serde_json::from_str(&data) {
139        Ok(r) => r,
140        Err(e) => {
141            errors.push(TuiError::CatalogParse {
142                path,
143                offset: e.column(),
144                detail: e.to_string(),
145            });
146            return (Vec::new(), errors);
147        }
148    };
149
150    let mut refs = Vec::new();
151    for (idx, mcp_ref) in all_refs.into_iter().enumerate() {
152        if check_mcp_ref_tainted(&mcp_ref) {
153            errors.push(TuiError::TaintedEntry {
154                path: path.clone(),
155                offset: idx,
156                field: "control bytes detected".to_string(),
157            });
158        } else {
159            refs.push(mcp_ref);
160        }
161    }
162
163    (refs, errors)
164}
165
166/// Load rules from catalog/rules.json.
167pub fn load_rules(workspace_root: &Path) -> (Vec<Rule>, Vec<TuiError>) {
168    let file_path = workspace_root.join("catalog").join("rules.json");
169    let path = file_path.display().to_string();
170    let mut errors = Vec::new();
171
172    let data = match read_catalog_file(&file_path) {
173        Ok(d) => d,
174        Err(e) => {
175            errors.push(e);
176            return (Vec::new(), errors);
177        }
178    };
179
180    let all_rules: Vec<Rule> = match serde_json::from_str(&data) {
181        Ok(r) => r,
182        Err(e) => {
183            errors.push(TuiError::CatalogParse {
184                path,
185                offset: e.column(),
186                detail: e.to_string(),
187            });
188            return (Vec::new(), errors);
189        }
190    };
191
192    let mut rules = Vec::new();
193    for (idx, rule) in all_rules.into_iter().enumerate() {
194        if check_rule_tainted(&rule) {
195            errors.push(TuiError::TaintedEntry {
196                path: path.clone(),
197                offset: idx,
198                field: "control bytes detected".to_string(),
199            });
200        } else {
201            rules.push(rule);
202        }
203    }
204
205    (rules, errors)
206}
207
208/// Load roles from catalog/install-roles.json.
209/// This is an object, not an array.
210pub fn load_roles(workspace_root: &Path) -> (Option<RoleCatalog>, Vec<TuiError>) {
211    let file_path = workspace_root.join("catalog").join("install-roles.json");
212    let path = file_path.display().to_string();
213    let mut errors = Vec::new();
214
215    let data = match read_catalog_file(&file_path) {
216        Ok(d) => d,
217        Err(e) => {
218            errors.push(e);
219            return (None, errors);
220        }
221    };
222
223    match serde_json::from_str::<RoleCatalog>(&data) {
224        Ok(catalog) => {
225            if check_role_catalog_tainted(&catalog) {
226                errors.push(TuiError::TaintedEntry {
227                    path,
228                    offset: 0,
229                    field: "control bytes detected".to_string(),
230                });
231                (None, errors)
232            } else {
233                (Some(catalog), errors)
234            }
235        }
236        Err(e) => {
237            errors.push(TuiError::CatalogParse {
238                path,
239                offset: e.column(),
240                detail: e.to_string(),
241            });
242            (None, errors)
243        }
244    }
245}
246
247/// Load asset integrity from catalog/asset-integrity.json.
248pub fn load_integrity(workspace_root: &Path) -> (Option<AssetIntegrity>, Vec<TuiError>) {
249    let file_path = workspace_root.join("catalog").join("asset-integrity.json");
250    let path = file_path.display().to_string();
251    let mut errors = Vec::new();
252
253    let data = match read_catalog_file(&file_path) {
254        Ok(d) => d,
255        Err(e) => {
256            errors.push(e);
257            return (None, errors);
258        }
259    };
260
261    match serde_json::from_str::<AssetIntegrity>(&data) {
262        Ok(integrity) => {
263            if check_integrity_tainted(&integrity) {
264                errors.push(TuiError::TaintedEntry {
265                    path,
266                    offset: 0,
267                    field: "control bytes detected".to_string(),
268                });
269                (None, errors)
270            } else {
271                (Some(integrity), errors)
272            }
273        }
274        Err(e) => {
275            errors.push(TuiError::CatalogParse {
276                path,
277                offset: e.column(),
278                detail: e.to_string(),
279            });
280            (None, errors)
281        }
282    }
283}
284
285/// Load resolved model assignments from catalog/model-assignments.json.
286///
287/// A missing file is not an error — the model-policy feature is additive and
288/// older checkouts simply render without model information.
289pub fn load_model_assignments(workspace_root: &Path) -> (Option<ModelAssignments>, Vec<TuiError>) {
290    let file_path = workspace_root
291        .join("catalog")
292        .join("model-assignments.json");
293    let path = file_path.display().to_string();
294    let mut errors = Vec::new();
295
296    if !file_path.exists() {
297        return (None, errors);
298    }
299
300    let data = match read_catalog_file(&file_path) {
301        Ok(d) => d,
302        Err(e) => {
303            errors.push(e);
304            return (None, errors);
305        }
306    };
307
308    match serde_json::from_str::<ModelAssignments>(&data) {
309        Ok(assignments) => {
310            if check_model_assignments_tainted(&assignments) {
311                errors.push(TuiError::TaintedEntry {
312                    path,
313                    offset: 0,
314                    field: "control bytes detected".to_string(),
315                });
316                (None, errors)
317            } else {
318                (Some(assignments), errors)
319            }
320        }
321        Err(e) => {
322            errors.push(TuiError::CatalogParse {
323                path,
324                offset: e.column(),
325                detail: e.to_string(),
326            });
327            (None, errors)
328        }
329    }
330}
331
332/// Load the workflow catalog from catalog/workflows.json.
333///
334/// A missing file is not an error — the workflow catalog is additive and a checkout
335/// without it simply renders no workflows. The file is generated from the `meta` block
336/// of each script in `.claude/workflows/` by
337/// `scripts/generate-workflow-catalog.mjs`; the TUI never parses the scripts itself.
338pub fn load_workflows(workspace_root: &Path) -> (Option<WorkflowCatalog>, Vec<TuiError>) {
339    let file_path = workspace_root.join("catalog").join("workflows.json");
340    let path = file_path.display().to_string();
341    let mut errors = Vec::new();
342
343    if !file_path.exists() {
344        return (None, errors);
345    }
346
347    let data = match read_catalog_file(&file_path) {
348        Ok(d) => d,
349        Err(e) => {
350            errors.push(e);
351            return (None, errors);
352        }
353    };
354
355    match serde_json::from_str::<WorkflowCatalog>(&data) {
356        Ok(catalog) => {
357            if check_workflows_tainted(&catalog) {
358                errors.push(TuiError::TaintedEntry {
359                    path,
360                    offset: 0,
361                    field: "control bytes detected".to_string(),
362                });
363                (None, errors)
364            } else {
365                (Some(catalog), errors)
366            }
367        }
368        Err(e) => {
369            errors.push(TuiError::CatalogParse {
370                path,
371                offset: e.column(),
372                detail: e.to_string(),
373            });
374            (None, errors)
375        }
376    }
377}
378
379// Taint checks for each model type.
380
381pub(crate) fn check_workflows_tainted(catalog: &WorkflowCatalog) -> bool {
382    serde_json::to_value(catalog)
383        .map(|value| value_has_control_bytes(&value))
384        .unwrap_or(true)
385}
386
387pub(crate) fn check_model_assignments_tainted(assignments: &ModelAssignments) -> bool {
388    serde_json::to_value(assignments)
389        .map(|value| value_has_control_bytes(&value))
390        .unwrap_or(true)
391}
392
393fn check_agent_tainted(agent: &Agent) -> bool {
394    serde_json::to_value(agent)
395        .map(|value| value_has_control_bytes(&value))
396        .unwrap_or(true)
397}
398
399fn check_skill_tainted(skill: &Skill) -> bool {
400    serde_json::to_value(skill)
401        .map(|value| value_has_control_bytes(&value))
402        .unwrap_or(true)
403}
404
405fn check_mcp_ref_tainted(mcp_ref: &McpReference) -> bool {
406    serde_json::to_value(mcp_ref)
407        .map(|value| value_has_control_bytes(&value))
408        .unwrap_or(true)
409}
410
411fn check_rule_tainted(rule: &Rule) -> bool {
412    serde_json::to_value(rule)
413        .map(|value| value_has_control_bytes(&value))
414        .unwrap_or(true)
415}
416
417fn check_role_catalog_tainted(role_catalog: &RoleCatalog) -> bool {
418    serde_json::to_value(role_catalog)
419        .map(|value| value_has_control_bytes(&value))
420        .unwrap_or(true)
421}
422
423fn check_integrity_tainted(integrity: &AssetIntegrity) -> bool {
424    serde_json::to_value(integrity)
425        .map(|value| value_has_control_bytes(&value))
426        .unwrap_or(true)
427}
428
429fn value_has_control_bytes(value: &serde_json::Value) -> bool {
430    match value {
431        serde_json::Value::String(s) => has_control_bytes(s),
432        serde_json::Value::Array(items) => items.iter().any(value_has_control_bytes),
433        serde_json::Value::Object(map) => map
434            .iter()
435            .any(|(key, value)| has_control_bytes(key) || value_has_control_bytes(value)),
436        _ => false,
437    }
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443    use std::path::Path;
444
445    fn workspace_root() -> &'static Path {
446        let manifest_dir = env!("CARGO_MANIFEST_DIR");
447        // tools/vfa-tui -> project root
448        let p = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
449        // Leak to get static lifetime for testing
450        Box::leak(Box::new(p.to_path_buf()))
451    }
452
453    #[test]
454    fn load_agents_succeeds() {
455        let (agents, errors) = load_agents(workspace_root());
456        assert!(errors.is_empty(), "errors: {errors:?}");
457        assert!(!agents.is_empty());
458    }
459
460    #[test]
461    fn load_skills_succeeds() {
462        let (skills, errors) = load_skills(workspace_root());
463        assert!(errors.is_empty(), "errors: {errors:?}");
464        assert!(!skills.is_empty());
465    }
466
467    #[test]
468    fn load_workflows_parses_the_real_catalog() {
469        // Loads the committed catalog/workflows.json, so a generator change that emits a
470        // key this struct does not declare fails here rather than silently vanishing
471        // from the display. `deny_unknown_fields` only bites if something exercises it
472        // against real data.
473        let (catalog, errors) = load_workflows(workspace_root());
474        assert!(errors.is_empty(), "errors: {errors:?}");
475        let catalog = catalog.expect("catalog/workflows.json should be present");
476        assert!(
477            !catalog.workflows.is_empty(),
478            "expected at least one workflow in the catalog",
479        );
480        for wf in &catalog.workflows {
481            assert!(!wf.id.is_empty(), "workflow id must not be empty");
482            assert_eq!(wf.id, wf.name, "id and name are the same identifier");
483            assert!(
484                wf.path.starts_with(".claude/workflows/"),
485                "workflow path should be repo-relative: {}",
486                wf.path,
487            );
488        }
489    }
490
491    #[test]
492    fn load_workflows_tolerates_a_missing_file() {
493        // Additive feature: a checkout without any workflow renders none rather than
494        // reporting a load error.
495        let tmp = tempfile::tempdir().unwrap();
496        std::fs::create_dir_all(tmp.path().join("catalog")).unwrap();
497        let (catalog, errors) = load_workflows(tmp.path());
498        assert!(catalog.is_none());
499        assert!(errors.is_empty(), "missing file must not be an error");
500    }
501
502    #[test]
503    fn load_mcp_refs_succeeds() {
504        let (refs, errors) = load_mcp_refs(workspace_root());
505        assert!(errors.is_empty(), "errors: {errors:?}");
506        assert!(!refs.is_empty());
507    }
508
509    #[test]
510    fn load_rules_succeeds() {
511        let (rules, errors) = load_rules(workspace_root());
512        assert!(errors.is_empty(), "errors: {errors:?}");
513        assert!(!rules.is_empty());
514    }
515
516    #[test]
517    fn load_roles_succeeds() {
518        let (roles, errors) = load_roles(workspace_root());
519        assert!(errors.is_empty(), "errors: {errors:?}");
520        assert!(roles.is_some());
521        let catalog = roles.unwrap();
522        assert!(!catalog.roles.is_empty());
523    }
524
525    #[test]
526    fn load_integrity_succeeds() {
527        let (integrity, errors) = load_integrity(workspace_root());
528        assert!(errors.is_empty(), "errors: {errors:?}");
529        assert!(integrity.is_some());
530    }
531
532    #[test]
533    fn load_roles_rejects_tainted_strings() {
534        let tmp = tempfile::TempDir::new().unwrap();
535        let catalog_dir = tmp.path().join("catalog");
536        std::fs::create_dir(&catalog_dir).unwrap();
537        std::fs::write(
538            catalog_dir.join("install-roles.json"),
539            r#"{
540              "version": "1",
541              "description": "roles",
542              "roles": {
543                "security": {
544                  "label": "Security\u001b[31m",
545                  "description": "bad",
546                  "agents": []
547                }
548              }
549            }"#,
550        )
551        .unwrap();
552
553        let (roles, errors) = load_roles(tmp.path());
554        assert!(roles.is_none());
555        assert!(matches!(
556            errors.first(),
557            Some(TuiError::TaintedEntry { .. })
558        ));
559    }
560
561    #[test]
562    fn load_integrity_rejects_tainted_strings() {
563        let tmp = tempfile::TempDir::new().unwrap();
564        let catalog_dir = tmp.path().join("catalog");
565        std::fs::create_dir(&catalog_dir).unwrap();
566        std::fs::write(
567            catalog_dir.join("asset-integrity.json"),
568            r#"{
569              "manifest_version": 1,
570              "algorithm": "sha256",
571              "scope": { "trees": [], "root_files": [] },
572              "trees": [{
573                "tree": "assets\u001b[31m",
574                "aggregate_sha256": "abc",
575                "files": []
576              }],
577              "root_files": [],
578              "aggregate_sha256": "abc"
579            }"#,
580        )
581        .unwrap();
582
583        let (integrity, errors) = load_integrity(tmp.path());
584        assert!(integrity.is_none());
585        assert!(matches!(
586            errors.first(),
587            Some(TuiError::TaintedEntry { .. })
588        ));
589    }
590
591    #[test]
592    fn load_agents_missing_file() {
593        let tmp = tempfile::TempDir::new().unwrap();
594        let (agents, errors) = load_agents(tmp.path());
595        assert!(agents.is_empty());
596        assert_eq!(errors.len(), 1);
597        match &errors[0] {
598            TuiError::CatalogNotFound { .. } => {}
599            other => panic!("unexpected error: {other:?}"),
600        }
601    }
602
603    #[test]
604    fn read_catalog_file_rejects_oversized() {
605        // Use the configurable limit function to test the rejection path
606        // without creating a 100MB file
607        let tmp = tempfile::TempDir::new().unwrap();
608        let file = tmp.path().join("small.json");
609        std::fs::write(&file, "[1,2,3]").unwrap(); // 7 bytes
610
611        // With a limit smaller than the file, it should be rejected
612        let result = read_catalog_file_with_limit(&file, 5);
613        assert!(result.is_err());
614        match result.unwrap_err() {
615            TuiError::CatalogParse { detail, .. } => {
616                assert!(
617                    detail.contains("file too large"),
618                    "unexpected detail: {detail}"
619                );
620                assert!(
621                    detail.contains("7 bytes"),
622                    "should report actual size: {detail}"
623                );
624            }
625            other => panic!("expected CatalogParse error, got: {other:?}"),
626        }
627
628        // With a limit larger than the file, it should succeed
629        let result = read_catalog_file_with_limit(&file, 100);
630        assert!(result.is_ok());
631        assert_eq!(result.unwrap(), "[1,2,3]");
632    }
633
634    #[test]
635    fn read_catalog_file_rejects_missing() {
636        let tmp = tempfile::TempDir::new().unwrap();
637        let missing = tmp.path().join("missing.json");
638        let result = read_catalog_file(&missing);
639        assert!(result.is_err());
640    }
641}