1use memstead_schema::{PUBLISHED_MEM_FORMAT, PublishedMemConfig};
9use regex::Regex;
10use std::sync::OnceLock;
11
12use super::ValidationError;
13
14const LEGACY_AUTHOR_FIELDS: &[&str] = &[
22 "writeGuidance",
23 "mediums",
24 "projections",
25 "rules",
26 "publish",
27 "readMems",
28 "vcs",
29 "language",
30 "community",
31 "defaultSchema",
32];
33
34pub fn parse_config_bytes(bytes: &[u8]) -> Result<PublishedMemConfig, ValidationError> {
38 let value: serde_json::Value =
39 serde_json::from_slice(bytes).map_err(|e| ValidationError::InvalidConfig {
40 reason: format!("malformed JSON: {e}"),
41 })?;
42
43 if let Some(obj) = value.as_object() {
44 for &field in LEGACY_AUTHOR_FIELDS {
45 if obj.contains_key(field) {
46 return Err(ValidationError::InvalidConfig {
47 reason: "legacy mem format — re-export via `memstead export`".to_string(),
48 });
49 }
50 }
51 } else {
52 return Err(ValidationError::InvalidConfig {
53 reason: "expected a JSON object".to_string(),
54 });
55 }
56
57 let config: PublishedMemConfig =
58 serde_json::from_value(value).map_err(|e| ValidationError::InvalidConfig {
59 reason: e.to_string(),
60 })?;
61
62 check_format(&config)?;
63 check_name(&config.name)?;
64 check_version(&config.version)?;
65
66 Ok(config)
67}
68
69fn check_format(config: &PublishedMemConfig) -> Result<(), ValidationError> {
70 if memstead_schema::published_format_accepted(config.format) {
74 return Ok(());
75 }
76 if config.format == 2 {
80 return Err(ValidationError::InvalidConfig {
81 reason: "legacy mem format (format: 2) — re-export via `memstead export`".to_string(),
82 });
83 }
84 Err(ValidationError::UnsupportedFormat {
85 got: config.format,
86 expected: PUBLISHED_MEM_FORMAT,
87 })
88}
89
90fn name_regex() -> &'static Regex {
91 static RE: OnceLock<Regex> = OnceLock::new();
92 RE.get_or_init(|| Regex::new(r"^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$").unwrap())
93}
94
95fn check_name(name: &str) -> Result<(), ValidationError> {
96 if !name_regex().is_match(name) {
97 return Err(ValidationError::InvalidName {
98 reason: format!("name must match ^[a-z0-9][a-z0-9-]{{0,62}}[a-z0-9]$, got {name:?}"),
99 });
100 }
101 Ok(())
102}
103
104const MAX_VERSION_PRE_BUILD: usize = 128;
107
108fn check_version(version: &semver::Version) -> Result<(), ValidationError> {
109 let pre_len = version.pre.as_str().len();
110 let build_len = version.build.as_str().len();
111 if pre_len + build_len > MAX_VERSION_PRE_BUILD {
112 return Err(ValidationError::InvalidVersion {
113 reason: format!(
114 "pre-release + build metadata length {} exceeds cap {}",
115 pre_len + build_len,
116 MAX_VERSION_PRE_BUILD
117 ),
118 });
119 }
120 Ok(())
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126
127 fn ok_config() -> serde_json::Value {
128 serde_json::json!({
129 "format": PUBLISHED_MEM_FORMAT,
130 "name": "example-mem",
131 "version": "0.1.0",
132 "schema": "default@1.0.0",
133 })
134 }
135
136 fn parse(value: serde_json::Value) -> Result<PublishedMemConfig, ValidationError> {
137 parse_config_bytes(value.to_string().as_bytes())
138 }
139
140 #[test]
141 fn accepts_minimal_valid_config() {
142 let config = parse(ok_config()).unwrap();
143 assert_eq!(config.format, PUBLISHED_MEM_FORMAT);
144 assert_eq!(config.name, "example-mem");
145 assert_eq!(config.version.to_string(), "0.1.0");
146 }
147
148 #[test]
149 fn rejects_malformed_json() {
150 let err = parse_config_bytes(b"not json").unwrap_err();
151 assert!(matches!(err, ValidationError::InvalidConfig { .. }));
152 }
153
154 #[test]
155 fn rejects_non_object_root() {
156 let err = parse(serde_json::json!([])).unwrap_err();
157 assert!(
158 matches!(err, ValidationError::InvalidConfig { reason } if reason.contains("object"))
159 );
160 }
161
162 #[test]
163 fn rejects_legacy_author_fields_with_actionable_message() {
164 let mut v = ok_config();
165 v["writeGuidance"] = serde_json::json!({});
166 let err = parse(v).unwrap_err();
167 match err {
168 ValidationError::InvalidConfig { reason } => {
169 assert!(reason.contains("legacy mem format"), "reason={reason}");
170 }
171 other => panic!("expected InvalidConfig, got {other:?}"),
172 }
173 }
174
175 #[test]
176 fn rejects_unknown_top_level_key() {
177 let mut v = ok_config();
178 v["unexpected"] = serde_json::json!(42);
179 let err = parse(v).unwrap_err();
180 assert!(matches!(err, ValidationError::InvalidConfig { .. }));
181 }
182
183 #[test]
184 fn rejects_missing_format() {
185 let mut v = ok_config();
186 v.as_object_mut().unwrap().remove("format");
187 let err = parse(v).unwrap_err();
188 assert!(matches!(err, ValidationError::InvalidConfig { .. }));
189 }
190
191 #[test]
199 fn rejects_forged_first_party_origin_claim() {
200 let mut v = ok_config();
201 v["origin"] = serde_json::json!("first-party");
202 let err = parse(v).unwrap_err();
203 assert!(
204 matches!(err, ValidationError::InvalidConfig { .. }),
205 "a forged first-party origin claim must be refused with a typed InvalidConfig, got {err:?}"
206 );
207 }
208
209 #[test]
210 fn rejects_wrong_format_version() {
211 let mut v = ok_config();
212 v["format"] = serde_json::json!(1);
213 let err = parse(v).unwrap_err();
214 assert!(
215 matches!(
216 err,
217 ValidationError::UnsupportedFormat {
218 got: 1,
219 expected: _
220 }
221 ),
222 "unexpected err: {err:?}"
223 );
224 }
225
226 #[test]
227 fn rejects_format_2_with_actionable_reexport_hint() {
228 let mut v = ok_config();
229 v["format"] = serde_json::json!(2);
230 let err = parse(v).unwrap_err();
231 match err {
232 ValidationError::InvalidConfig { reason } => {
233 assert!(
234 reason.contains("format: 2") && reason.contains("memstead export"),
235 "reason={reason}"
236 );
237 }
238 other => panic!("expected InvalidConfig with re-export hint, got {other:?}"),
239 }
240 }
241
242 #[test]
243 fn rejects_name_uppercase() {
244 let mut v = ok_config();
245 v["name"] = serde_json::json!("Upper-Case");
246 let err = parse(v).unwrap_err();
247 assert!(matches!(err, ValidationError::InvalidName { .. }));
248 }
249
250 #[test]
251 fn rejects_name_with_space() {
252 let mut v = ok_config();
253 v["name"] = serde_json::json!("has space");
254 let err = parse(v).unwrap_err();
255 assert!(matches!(err, ValidationError::InvalidName { .. }));
256 }
257
258 #[test]
259 fn rejects_name_leading_hyphen() {
260 let mut v = ok_config();
261 v["name"] = serde_json::json!("-leading");
262 let err = parse(v).unwrap_err();
263 assert!(matches!(err, ValidationError::InvalidName { .. }));
264 }
265
266 #[test]
267 fn rejects_name_trailing_hyphen() {
268 let mut v = ok_config();
269 v["name"] = serde_json::json!("trailing-");
270 let err = parse(v).unwrap_err();
271 assert!(matches!(err, ValidationError::InvalidName { .. }));
272 }
273
274 #[test]
275 fn rejects_name_too_long() {
276 let mut v = ok_config();
277 v["name"] = serde_json::json!("a".repeat(65));
278 let err = parse(v).unwrap_err();
279 assert!(matches!(err, ValidationError::InvalidName { .. }));
280 }
281
282 #[test]
283 fn rejects_name_with_path_separator() {
284 let mut v = ok_config();
285 v["name"] = serde_json::json!("scope/name");
286 let err = parse(v).unwrap_err();
287 assert!(matches!(err, ValidationError::InvalidName { .. }));
288 }
289
290 #[test]
291 fn rejects_name_with_at_prefix() {
292 let mut v = ok_config();
293 v["name"] = serde_json::json!("@scope");
294 let err = parse(v).unwrap_err();
295 assert!(matches!(err, ValidationError::InvalidName { .. }));
296 }
297
298 #[test]
299 fn rejects_missing_schema() {
300 let mut v = ok_config();
301 v.as_object_mut().unwrap().remove("schema");
302 let err = parse(v).unwrap_err();
303 assert!(matches!(err, ValidationError::InvalidConfig { .. }));
306 }
307
308 #[test]
309 fn rejects_schema_range_syntax() {
310 let mut v = ok_config();
311 v["schema"] = serde_json::json!("default@^1.0.0");
312 let err = parse(v).unwrap_err();
313 assert!(matches!(err, ValidationError::InvalidConfig { .. }));
314 }
315
316 #[test]
317 fn rejects_schema_without_version() {
318 let mut v = ok_config();
319 v["schema"] = serde_json::json!("default");
320 let err = parse(v).unwrap_err();
321 assert!(matches!(err, ValidationError::InvalidConfig { .. }));
322 }
323
324 #[test]
325 fn rejects_legacy_types_field() {
326 let mut v = ok_config();
328 v["types"] = serde_json::json!(["spec"]);
329 let err = parse(v).unwrap_err();
330 assert!(matches!(err, ValidationError::InvalidConfig { .. }));
331 }
332
333 #[test]
334 fn rejects_invalid_semver() {
335 let mut v = ok_config();
336 v["version"] = serde_json::json!("not-a-version");
337 let err = parse(v).unwrap_err();
338 assert!(matches!(err, ValidationError::InvalidConfig { .. }));
339 }
340
341 #[test]
342 fn rejects_pre_release_plus_build_too_long() {
343 let mut v = ok_config();
344 let long = "a".repeat(130);
345 v["version"] = serde_json::json!(format!("0.1.0-{long}"));
346 let err = parse(v).unwrap_err();
347 assert!(matches!(err, ValidationError::InvalidVersion { .. }));
348 }
349
350 #[test]
356 fn format_three_and_four_accepted_older_and_unknown_refused() {
357 let mut v3 = ok_config();
359 v3["format"] = serde_json::json!(3);
360 let parsed = parse(v3).expect("format 3 accepted");
361 assert_eq!(parsed.title, None);
362 assert_eq!(parsed.subject, None);
363
364 let mut v4 = ok_config();
366 v4["title"] = serde_json::json!("Einrichtungsbezogene Impfpflicht Deutschland");
367 v4["subject"] = serde_json::json!({
368 "scope": "Die Impfpflicht in Einrichtungen",
369 "method": "Primärquellen, händisch geprüft",
370 "exclusions": ["Länderverordnungen nach 2023", "Presseberichte"],
371 });
372 let parsed = parse(v4).expect("format 4 accepted");
373 assert_eq!(
374 parsed.title.as_deref(),
375 Some("Einrichtungsbezogene Impfpflicht Deutschland")
376 );
377 let subject = parsed.subject.expect("subject parsed");
378 assert_eq!(subject.scope, "Die Impfpflicht in Einrichtungen");
379 assert_eq!(
380 subject.method.as_deref(),
381 Some("Primärquellen, händisch geprüft")
382 );
383 assert_eq!(
384 subject.exclusions,
385 vec!["Länderverordnungen nach 2023", "Presseberichte"]
386 );
387
388 let mut v2 = ok_config();
390 v2["format"] = serde_json::json!(2);
391 let err = parse(v2).unwrap_err();
392 assert!(
393 matches!(err, ValidationError::InvalidConfig { ref reason } if reason.contains("format: 2")),
394 "format 2 keeps its actionable refusal: {err:?}"
395 );
396 let mut v1 = ok_config();
397 v1["format"] = serde_json::json!(1);
398 assert!(matches!(
399 parse(v1).unwrap_err(),
400 ValidationError::UnsupportedFormat { got: 1, .. }
401 ));
402
403 let mut v5 = ok_config();
406 v5["format"] = serde_json::json!(5);
407 assert!(matches!(
408 parse(v5).unwrap_err(),
409 ValidationError::UnsupportedFormat { got: 5, .. }
410 ));
411
412 let mut smuggle = ok_config();
414 smuggle["writeGuidance"] = serde_json::json!({"note": "leak"});
415 assert!(matches!(
416 parse(smuggle).unwrap_err(),
417 ValidationError::InvalidConfig { .. }
418 ));
419 }
420}