1use std::collections::{HashMap, HashSet};
19
20use super::bundle_export::{parse_json_field_or_warn, sanitize_context_relative_path, ContextFileEntry};
21use super::providers;
22use super::storage::store::Bundle;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, ts_rs::TS)]
25#[ts(export, export_to = "../../../frontend/types/rpc/")]
26#[serde(rename_all = "lowercase")]
27pub enum IssueSeverity {
28 Error,
29 Warning,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, ts_rs::TS)]
33#[ts(export, export_to = "../../../frontend/types/rpc/")]
34pub struct ValidationIssue {
35 pub severity: IssueSeverity,
36 pub field: String,
39 pub message: String,
40}
41
42#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, ts_rs::TS)]
43#[ts(export, export_to = "../../../frontend/types/rpc/")]
44pub struct ValidationReport {
45 pub is_valid: bool,
48 pub issues: Vec<ValidationIssue>,
49}
50
51pub fn validate_bundle(bundle: &Bundle) -> ValidationReport {
55 let mut issues = Vec::new();
56 validate_instructions_by_provider(bundle, &mut issues);
57 validate_context_files(bundle, &mut issues);
58 let is_valid = !issues.iter().any(|i| i.severity == IssueSeverity::Error);
64 ValidationReport { is_valid, issues }
65}
66
67fn push_error(issues: &mut Vec<ValidationIssue>, field: &str, message: String) {
68 issues.push(ValidationIssue {
69 severity: IssueSeverity::Error,
70 field: field.to_string(),
71 message,
72 });
73}
74
75fn validate_instructions_by_provider(bundle: &Bundle, issues: &mut Vec<ValidationIssue>) {
82 const FIELD: &str = "instructions_by_provider";
83 if bundle.instructions_by_provider.trim().is_empty() {
84 return;
85 }
86 let parsed: HashMap<String, String> = match serde_json::from_str(&bundle.instructions_by_provider) {
87 Ok(v) => v,
88 Err(e) => {
89 push_error(issues, FIELD, format!("malformed JSON: {e}"));
90 return;
91 }
92 };
93 let mut sorted: Vec<&String> = parsed.keys().collect();
95 sorted.sort();
96 for key in sorted {
97 if providers::get_provider(key).is_none() {
98 push_error(issues, FIELD, format!("\"{key}\" is not a known harness provider"));
99 }
100 }
101}
102
103fn validate_context_files(bundle: &Bundle, issues: &mut Vec<ValidationIssue>) {
109 const FIELD: &str = "context_files";
110 let mut warnings = Vec::new();
111 let entries: Vec<ContextFileEntry> =
112 parse_json_field_or_warn(&bundle.context_files, FIELD, &mut warnings);
113 for w in warnings {
114 push_error(issues, FIELD, w);
115 }
116
117 let mut used_paths: HashSet<String> = HashSet::new();
118 for entry in entries {
119 match sanitize_context_relative_path(&entry.path) {
120 None => push_error(
121 issues,
122 FIELD,
123 format!(
124 "\"{}\" is not a safe relative path (absolute, contains \"..\", \
125 a drive letter, or empty)",
126 entry.path
127 ),
128 ),
129 Some(safe) => {
130 if !used_paths.insert(safe.to_lowercase()) {
131 push_error(
132 issues,
133 FIELD,
134 format!(
135 "\"{}\" collides with another context file after path \
136 normalization",
137 entry.path
138 ),
139 );
140 }
141 }
142 }
143 }
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149
150 fn make_bundle(
151 instructions_by_provider: &str,
152 context_files: &str,
153 mcp_servers: &str,
154 skills: &str,
155 ) -> Bundle {
156 Bundle {
157 id: "bundle-1".to_string(),
158 name: "Backend Dev Bundle".to_string(),
159 description: "Backend dev conventions".to_string(),
160 is_blank: false,
161 is_global: false,
162 provider: String::new(),
163 model: String::new(),
164 instructions: "Be terse.".to_string(),
165 instructions_by_provider: instructions_by_provider.to_string(),
166 context_files: context_files.to_string(),
167 mcp_servers: mcp_servers.to_string(),
168 skills: skills.to_string(),
169 sort_order: 0,
170 created_at: 1_700_000_000_000,
171 updated_at: 1_700_000_000_000,
172 is_system: false,
173 }
174 }
175
176 #[test]
177 fn empty_bundle_is_valid_with_no_issues() {
178 let bundle = make_bundle("{}", "[]", "[]", "[]");
179 let report = validate_bundle(&bundle);
180 assert!(report.is_valid);
181 assert!(report.issues.is_empty());
182 }
183
184 #[test]
185 fn known_provider_keys_pass() {
186 let bundle = make_bundle(r#"{"claude":"x","codex":"y"}"#, "[]", "[]", "[]");
187 let report = validate_bundle(&bundle);
188 assert!(report.is_valid, "{:?}", report.issues);
189 }
190
191 #[test]
192 fn unknown_provider_key_is_an_error() {
193 let bundle = make_bundle(r#"{"chatgpt-desktop":"x"}"#, "[]", "[]", "[]");
194 let report = validate_bundle(&bundle);
195 assert!(!report.is_valid);
196 assert!(report.issues.iter().any(|i| i.severity == IssueSeverity::Error
197 && i.field == "instructions_by_provider"
198 && i.message.contains("chatgpt-desktop")));
199 }
200
201 #[test]
202 fn malformed_instructions_by_provider_json_is_an_error() {
203 let bundle = make_bundle("not json", "[]", "[]", "[]");
204 let report = validate_bundle(&bundle);
205 assert!(!report.is_valid);
206 assert!(report.issues.iter().any(|i| i.field == "instructions_by_provider"
207 && i.message.contains("malformed JSON")));
208 }
209
210 #[test]
211 fn provider_alias_resolves_like_the_canonical_id() {
212 let bundle = make_bundle(r#"{"claude-code":"x"}"#, "[]", "[]", "[]");
216 let report = validate_bundle(&bundle);
217 assert!(report.is_valid, "{:?}", report.issues);
218 }
219
220 #[test]
221 fn multiple_unknown_provider_keys_are_each_reported() {
222 let bundle = make_bundle(r#"{"foo":"a","bar":"b"}"#, "[]", "[]", "[]");
223 let report = validate_bundle(&bundle);
224 assert!(!report.is_valid);
225 let provider_errors: Vec<_> = report
226 .issues
227 .iter()
228 .filter(|i| i.field == "instructions_by_provider")
229 .collect();
230 assert_eq!(provider_errors.len(), 2, "{:?}", report.issues);
231 }
232
233 #[test]
234 fn unsafe_context_file_path_is_an_error() {
235 let bundle = make_bundle("{}", r#"[{"path":"../../etc/passwd","content":"x"}]"#, "[]", "[]");
236 let report = validate_bundle(&bundle);
237 assert!(!report.is_valid);
238 assert!(report.issues.iter().any(|i| i.field == "context_files"
239 && i.message.contains("not a safe relative path")));
240 }
241
242 #[test]
243 fn colliding_context_file_paths_are_an_error() {
244 let bundle = make_bundle(
245 "{}",
246 r#"[{"path":"docs/a.md","content":"one"},{"path":"Docs/A.md","content":"two"}]"#,
247 "[]",
248 "[]",
249 );
250 let report = validate_bundle(&bundle);
251 assert!(!report.is_valid);
252 assert!(report.issues.iter().any(|i| i.field == "context_files" && i.message.contains("collides")));
253 }
254
255 #[test]
256 fn malformed_context_files_json_is_an_error() {
257 let bundle = make_bundle("{}", "not json", "[]", "[]");
258 let report = validate_bundle(&bundle);
259 assert!(!report.is_valid);
260 assert!(report.issues.iter().any(|i| i.field == "context_files" && i.message.contains("malformed")));
261 }
262
263 #[test]
264 fn mcp_servers_are_not_validated_because_a_bundle_carries_none() {
265 let bundle = make_bundle("{}", "[]", r#"["not-an-object"]"#, "[]");
267 let report = validate_bundle(&bundle);
268 assert!(report.is_valid, "{:?}", report.issues);
269 assert!(report.issues.is_empty());
270 }
271
272 #[test]
273 fn skills_are_no_longer_validated_because_both_checks_became_impossible() {
274 let bundle = make_bundle("{}", "[]", "[]", r#"["skill-1","skill-1"]"#);
286 let report = validate_bundle(&bundle);
287 assert!(report.is_valid, "{:?}", report.issues);
288 assert!(
289 !report.issues.iter().any(|i| i.field == "skills"),
290 "the inline skills column must no longer produce issues: {:?}",
291 report.issues
292 );
293 }
294}