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, ModelRegistry, 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 verified model registry from catalog/model-registry.json.
333///
334/// A missing file is not an error — the picker degrades to free-text entry
335/// and the harness-level effort union, exactly as it behaved before the
336/// registry was wired in. A malformed file is reported and treated the same
337/// way, so a bad catalog can never wedge the builder.
338pub fn load_model_registry(workspace_root: &Path) -> (Option<ModelRegistry>, Vec<TuiError>) {
339    let file_path = workspace_root.join("catalog").join("model-registry.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::<ModelRegistry>(&data) {
356        Ok(registry) => {
357            if check_model_registry_tainted(&registry) {
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(registry), 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/// Load the workflow catalog from catalog/workflows.json.
380///
381/// A missing file is not an error — the workflow catalog is additive and a checkout
382/// without it simply renders no workflows. The file is generated from the `meta` block
383/// of each script in `.claude/workflows/` by
384/// `scripts/generate-workflow-catalog.mjs`; the TUI never parses the scripts itself.
385pub fn load_workflows(workspace_root: &Path) -> (Option<WorkflowCatalog>, Vec<TuiError>) {
386    let file_path = workspace_root.join("catalog").join("workflows.json");
387    let path = file_path.display().to_string();
388    let mut errors = Vec::new();
389
390    if !file_path.exists() {
391        return (None, errors);
392    }
393
394    let data = match read_catalog_file(&file_path) {
395        Ok(d) => d,
396        Err(e) => {
397            errors.push(e);
398            return (None, errors);
399        }
400    };
401
402    match serde_json::from_str::<WorkflowCatalog>(&data) {
403        Ok(catalog) => {
404            if check_workflows_tainted(&catalog) {
405                errors.push(TuiError::TaintedEntry {
406                    path,
407                    offset: 0,
408                    field: "control bytes detected".to_string(),
409                });
410                (None, errors)
411            } else {
412                (Some(catalog), errors)
413            }
414        }
415        Err(e) => {
416            errors.push(TuiError::CatalogParse {
417                path,
418                offset: e.column(),
419                detail: e.to_string(),
420            });
421            (None, errors)
422        }
423    }
424}
425
426// Taint checks for each model type.
427
428pub(crate) fn check_workflows_tainted(catalog: &WorkflowCatalog) -> bool {
429    serde_json::to_value(catalog)
430        .map(|value| value_has_control_bytes(&value))
431        .unwrap_or(true)
432}
433
434pub(crate) fn check_model_registry_tainted(registry: &ModelRegistry) -> bool {
435    serde_json::to_value(registry)
436        .map(|v| value_has_control_bytes(&v))
437        .unwrap_or(true)
438}
439
440pub(crate) fn check_model_assignments_tainted(assignments: &ModelAssignments) -> bool {
441    serde_json::to_value(assignments)
442        .map(|value| value_has_control_bytes(&value))
443        .unwrap_or(true)
444}
445
446fn check_agent_tainted(agent: &Agent) -> bool {
447    serde_json::to_value(agent)
448        .map(|value| value_has_control_bytes(&value))
449        .unwrap_or(true)
450}
451
452fn check_skill_tainted(skill: &Skill) -> bool {
453    serde_json::to_value(skill)
454        .map(|value| value_has_control_bytes(&value))
455        .unwrap_or(true)
456}
457
458fn check_mcp_ref_tainted(mcp_ref: &McpReference) -> bool {
459    serde_json::to_value(mcp_ref)
460        .map(|value| value_has_control_bytes(&value))
461        .unwrap_or(true)
462}
463
464fn check_rule_tainted(rule: &Rule) -> bool {
465    serde_json::to_value(rule)
466        .map(|value| value_has_control_bytes(&value))
467        .unwrap_or(true)
468}
469
470fn check_role_catalog_tainted(role_catalog: &RoleCatalog) -> bool {
471    serde_json::to_value(role_catalog)
472        .map(|value| value_has_control_bytes(&value))
473        .unwrap_or(true)
474}
475
476fn check_integrity_tainted(integrity: &AssetIntegrity) -> bool {
477    serde_json::to_value(integrity)
478        .map(|value| value_has_control_bytes(&value))
479        .unwrap_or(true)
480}
481
482fn value_has_control_bytes(value: &serde_json::Value) -> bool {
483    match value {
484        serde_json::Value::String(s) => has_control_bytes(s),
485        serde_json::Value::Array(items) => items.iter().any(value_has_control_bytes),
486        serde_json::Value::Object(map) => map
487            .iter()
488            .any(|(key, value)| has_control_bytes(key) || value_has_control_bytes(value)),
489        _ => false,
490    }
491}
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496    use std::path::Path;
497
498    fn workspace_root() -> &'static Path {
499        let manifest_dir = env!("CARGO_MANIFEST_DIR");
500        // tools/vfa-tui -> project root
501        let p = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
502        // Leak to get static lifetime for testing
503        Box::leak(Box::new(p.to_path_buf()))
504    }
505
506    #[test]
507    fn load_agents_succeeds() {
508        let (agents, errors) = load_agents(workspace_root());
509        assert!(errors.is_empty(), "errors: {errors:?}");
510        assert!(!agents.is_empty());
511    }
512
513    #[test]
514    fn load_skills_succeeds() {
515        let (skills, errors) = load_skills(workspace_root());
516        assert!(errors.is_empty(), "errors: {errors:?}");
517        assert!(!skills.is_empty());
518    }
519
520    #[test]
521    fn load_workflows_parses_the_real_catalog() {
522        // Loads the committed catalog/workflows.json, so a generator change that emits a
523        // key this struct does not declare fails here rather than silently vanishing
524        // from the display. `deny_unknown_fields` only bites if something exercises it
525        // against real data.
526        let (catalog, errors) = load_workflows(workspace_root());
527        assert!(errors.is_empty(), "errors: {errors:?}");
528        let catalog = catalog.expect("catalog/workflows.json should be present");
529        assert!(
530            !catalog.workflows.is_empty(),
531            "expected at least one workflow in the catalog",
532        );
533        for wf in &catalog.workflows {
534            assert!(!wf.id.is_empty(), "workflow id must not be empty");
535            assert_eq!(wf.id, wf.name, "id and name are the same identifier");
536            assert!(
537                wf.path.starts_with(".claude/workflows/"),
538                "workflow path should be repo-relative: {}",
539                wf.path,
540            );
541        }
542    }
543
544    #[test]
545    fn load_workflows_tolerates_a_missing_file() {
546        // Additive feature: a checkout without any workflow renders none rather than
547        // reporting a load error.
548        let tmp = tempfile::tempdir().unwrap();
549        std::fs::create_dir_all(tmp.path().join("catalog")).unwrap();
550        let (catalog, errors) = load_workflows(tmp.path());
551        assert!(catalog.is_none());
552        assert!(errors.is_empty(), "missing file must not be an error");
553    }
554
555    #[test]
556    fn load_mcp_refs_succeeds() {
557        let (refs, errors) = load_mcp_refs(workspace_root());
558        assert!(errors.is_empty(), "errors: {errors:?}");
559        assert!(!refs.is_empty());
560    }
561
562    #[test]
563    fn load_rules_succeeds() {
564        let (rules, errors) = load_rules(workspace_root());
565        assert!(errors.is_empty(), "errors: {errors:?}");
566        assert!(!rules.is_empty());
567    }
568
569    #[test]
570    fn load_roles_succeeds() {
571        let (roles, errors) = load_roles(workspace_root());
572        assert!(errors.is_empty(), "errors: {errors:?}");
573        assert!(roles.is_some());
574        let catalog = roles.unwrap();
575        assert!(!catalog.roles.is_empty());
576    }
577
578    #[test]
579    fn load_integrity_succeeds() {
580        let (integrity, errors) = load_integrity(workspace_root());
581        assert!(errors.is_empty(), "errors: {errors:?}");
582        assert!(integrity.is_some());
583    }
584
585    #[test]
586    fn load_roles_rejects_tainted_strings() {
587        let tmp = tempfile::TempDir::new().unwrap();
588        let catalog_dir = tmp.path().join("catalog");
589        std::fs::create_dir(&catalog_dir).unwrap();
590        std::fs::write(
591            catalog_dir.join("install-roles.json"),
592            r#"{
593              "version": "1",
594              "description": "roles",
595              "roles": {
596                "security": {
597                  "label": "Security\u001b[31m",
598                  "description": "bad",
599                  "agents": []
600                }
601              }
602            }"#,
603        )
604        .unwrap();
605
606        let (roles, errors) = load_roles(tmp.path());
607        assert!(roles.is_none());
608        assert!(matches!(
609            errors.first(),
610            Some(TuiError::TaintedEntry { .. })
611        ));
612    }
613
614    #[test]
615    fn load_integrity_rejects_tainted_strings() {
616        let tmp = tempfile::TempDir::new().unwrap();
617        let catalog_dir = tmp.path().join("catalog");
618        std::fs::create_dir(&catalog_dir).unwrap();
619        std::fs::write(
620            catalog_dir.join("asset-integrity.json"),
621            r#"{
622              "manifest_version": 1,
623              "algorithm": "sha256",
624              "scope": { "trees": [], "root_files": [] },
625              "trees": [{
626                "tree": "assets\u001b[31m",
627                "aggregate_sha256": "abc",
628                "files": []
629              }],
630              "root_files": [],
631              "aggregate_sha256": "abc"
632            }"#,
633        )
634        .unwrap();
635
636        let (integrity, errors) = load_integrity(tmp.path());
637        assert!(integrity.is_none());
638        assert!(matches!(
639            errors.first(),
640            Some(TuiError::TaintedEntry { .. })
641        ));
642    }
643
644    #[test]
645    fn load_agents_missing_file() {
646        let tmp = tempfile::TempDir::new().unwrap();
647        let (agents, errors) = load_agents(tmp.path());
648        assert!(agents.is_empty());
649        assert_eq!(errors.len(), 1);
650        match &errors[0] {
651            TuiError::CatalogNotFound { .. } => {}
652            other => panic!("unexpected error: {other:?}"),
653        }
654    }
655
656    #[test]
657    fn read_catalog_file_rejects_oversized() {
658        // Use the configurable limit function to test the rejection path
659        // without creating a 100MB file
660        let tmp = tempfile::TempDir::new().unwrap();
661        let file = tmp.path().join("small.json");
662        std::fs::write(&file, "[1,2,3]").unwrap(); // 7 bytes
663
664        // With a limit smaller than the file, it should be rejected
665        let result = read_catalog_file_with_limit(&file, 5);
666        assert!(result.is_err());
667        match result.unwrap_err() {
668            TuiError::CatalogParse { detail, .. } => {
669                assert!(
670                    detail.contains("file too large"),
671                    "unexpected detail: {detail}"
672                );
673                assert!(
674                    detail.contains("7 bytes"),
675                    "should report actual size: {detail}"
676                );
677            }
678            other => panic!("expected CatalogParse error, got: {other:?}"),
679        }
680
681        // With a limit larger than the file, it should succeed
682        let result = read_catalog_file_with_limit(&file, 100);
683        assert!(result.is_ok());
684        assert_eq!(result.unwrap(), "[1,2,3]");
685    }
686
687    #[test]
688    fn read_catalog_file_rejects_missing() {
689        let tmp = tempfile::TempDir::new().unwrap();
690        let missing = tmp.path().join("missing.json");
691        let result = read_catalog_file(&missing);
692        assert!(result.is_err());
693    }
694}