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
12fn 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
34fn read_catalog_file(path: &std::path::Path) -> Result<String, TuiError> {
36 read_catalog_file_with_limit(path, MAX_CATALOG_FILE_SIZE)
37}
38
39pub 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
82pub 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
124pub 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
166pub 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
208pub 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
247pub 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
285pub 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
332pub 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(®istry) {
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
379pub 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
426pub(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 let p = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
502 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 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 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 let tmp = tempfile::TempDir::new().unwrap();
661 let file = tmp.path().join("small.json");
662 std::fs::write(&file, "[1,2,3]").unwrap(); 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 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}