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
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_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
379pub(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 let p = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
449 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 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 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 let tmp = tempfile::TempDir::new().unwrap();
608 let file = tmp.path().join("small.json");
609 std::fs::write(&file, "[1,2,3]").unwrap(); 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 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}