memstead_base/validator/
config.rs1use 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 config.format == PUBLISHED_MEM_FORMAT {
71 return Ok(());
72 }
73 if config.format == 2 {
77 return Err(ValidationError::InvalidConfig {
78 reason: "legacy mem format (format: 2) — re-export via `memstead export`".to_string(),
79 });
80 }
81 Err(ValidationError::UnsupportedFormat {
82 got: config.format,
83 expected: PUBLISHED_MEM_FORMAT,
84 })
85}
86
87fn name_regex() -> &'static Regex {
88 static RE: OnceLock<Regex> = OnceLock::new();
89 RE.get_or_init(|| Regex::new(r"^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$").unwrap())
90}
91
92fn check_name(name: &str) -> Result<(), ValidationError> {
93 if !name_regex().is_match(name) {
94 return Err(ValidationError::InvalidName {
95 reason: format!("name must match ^[a-z0-9][a-z0-9-]{{0,62}}[a-z0-9]$, got {name:?}"),
96 });
97 }
98 Ok(())
99}
100
101const MAX_VERSION_PRE_BUILD: usize = 128;
104
105fn check_version(version: &semver::Version) -> Result<(), ValidationError> {
106 let pre_len = version.pre.as_str().len();
107 let build_len = version.build.as_str().len();
108 if pre_len + build_len > MAX_VERSION_PRE_BUILD {
109 return Err(ValidationError::InvalidVersion {
110 reason: format!(
111 "pre-release + build metadata length {} exceeds cap {}",
112 pre_len + build_len,
113 MAX_VERSION_PRE_BUILD
114 ),
115 });
116 }
117 Ok(())
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123
124 fn ok_config() -> serde_json::Value {
125 serde_json::json!({
126 "format": PUBLISHED_MEM_FORMAT,
127 "name": "example-mem",
128 "version": "0.1.0",
129 "schema": "default@1.0.0",
130 })
131 }
132
133 fn parse(value: serde_json::Value) -> Result<PublishedMemConfig, ValidationError> {
134 parse_config_bytes(value.to_string().as_bytes())
135 }
136
137 #[test]
138 fn accepts_minimal_valid_config() {
139 let config = parse(ok_config()).unwrap();
140 assert_eq!(config.format, PUBLISHED_MEM_FORMAT);
141 assert_eq!(config.name, "example-mem");
142 assert_eq!(config.version.to_string(), "0.1.0");
143 }
144
145 #[test]
146 fn rejects_malformed_json() {
147 let err = parse_config_bytes(b"not json").unwrap_err();
148 assert!(matches!(err, ValidationError::InvalidConfig { .. }));
149 }
150
151 #[test]
152 fn rejects_non_object_root() {
153 let err = parse(serde_json::json!([])).unwrap_err();
154 assert!(
155 matches!(err, ValidationError::InvalidConfig { reason } if reason.contains("object"))
156 );
157 }
158
159 #[test]
160 fn rejects_legacy_author_fields_with_actionable_message() {
161 let mut v = ok_config();
162 v["writeGuidance"] = serde_json::json!({});
163 let err = parse(v).unwrap_err();
164 match err {
165 ValidationError::InvalidConfig { reason } => {
166 assert!(reason.contains("legacy mem format"), "reason={reason}");
167 }
168 other => panic!("expected InvalidConfig, got {other:?}"),
169 }
170 }
171
172 #[test]
173 fn rejects_unknown_top_level_key() {
174 let mut v = ok_config();
175 v["unexpected"] = serde_json::json!(42);
176 let err = parse(v).unwrap_err();
177 assert!(matches!(err, ValidationError::InvalidConfig { .. }));
178 }
179
180 #[test]
181 fn rejects_missing_format() {
182 let mut v = ok_config();
183 v.as_object_mut().unwrap().remove("format");
184 let err = parse(v).unwrap_err();
185 assert!(matches!(err, ValidationError::InvalidConfig { .. }));
186 }
187
188 #[test]
196 fn rejects_forged_first_party_origin_claim() {
197 let mut v = ok_config();
198 v["origin"] = serde_json::json!("first-party");
199 let err = parse(v).unwrap_err();
200 assert!(
201 matches!(err, ValidationError::InvalidConfig { .. }),
202 "a forged first-party origin claim must be refused with a typed InvalidConfig, got {err:?}"
203 );
204 }
205
206 #[test]
207 fn rejects_wrong_format_version() {
208 let mut v = ok_config();
209 v["format"] = serde_json::json!(1);
210 let err = parse(v).unwrap_err();
211 assert!(
212 matches!(
213 err,
214 ValidationError::UnsupportedFormat {
215 got: 1,
216 expected: _
217 }
218 ),
219 "unexpected err: {err:?}"
220 );
221 }
222
223 #[test]
224 fn rejects_format_2_with_actionable_reexport_hint() {
225 let mut v = ok_config();
226 v["format"] = serde_json::json!(2);
227 let err = parse(v).unwrap_err();
228 match err {
229 ValidationError::InvalidConfig { reason } => {
230 assert!(
231 reason.contains("format: 2") && reason.contains("memstead export"),
232 "reason={reason}"
233 );
234 }
235 other => panic!("expected InvalidConfig with re-export hint, got {other:?}"),
236 }
237 }
238
239 #[test]
240 fn rejects_name_uppercase() {
241 let mut v = ok_config();
242 v["name"] = serde_json::json!("Upper-Case");
243 let err = parse(v).unwrap_err();
244 assert!(matches!(err, ValidationError::InvalidName { .. }));
245 }
246
247 #[test]
248 fn rejects_name_with_space() {
249 let mut v = ok_config();
250 v["name"] = serde_json::json!("has space");
251 let err = parse(v).unwrap_err();
252 assert!(matches!(err, ValidationError::InvalidName { .. }));
253 }
254
255 #[test]
256 fn rejects_name_leading_hyphen() {
257 let mut v = ok_config();
258 v["name"] = serde_json::json!("-leading");
259 let err = parse(v).unwrap_err();
260 assert!(matches!(err, ValidationError::InvalidName { .. }));
261 }
262
263 #[test]
264 fn rejects_name_trailing_hyphen() {
265 let mut v = ok_config();
266 v["name"] = serde_json::json!("trailing-");
267 let err = parse(v).unwrap_err();
268 assert!(matches!(err, ValidationError::InvalidName { .. }));
269 }
270
271 #[test]
272 fn rejects_name_too_long() {
273 let mut v = ok_config();
274 v["name"] = serde_json::json!("a".repeat(65));
275 let err = parse(v).unwrap_err();
276 assert!(matches!(err, ValidationError::InvalidName { .. }));
277 }
278
279 #[test]
280 fn rejects_name_with_path_separator() {
281 let mut v = ok_config();
282 v["name"] = serde_json::json!("scope/name");
283 let err = parse(v).unwrap_err();
284 assert!(matches!(err, ValidationError::InvalidName { .. }));
285 }
286
287 #[test]
288 fn rejects_name_with_at_prefix() {
289 let mut v = ok_config();
290 v["name"] = serde_json::json!("@scope");
291 let err = parse(v).unwrap_err();
292 assert!(matches!(err, ValidationError::InvalidName { .. }));
293 }
294
295 #[test]
296 fn rejects_missing_schema() {
297 let mut v = ok_config();
298 v.as_object_mut().unwrap().remove("schema");
299 let err = parse(v).unwrap_err();
300 assert!(matches!(err, ValidationError::InvalidConfig { .. }));
303 }
304
305 #[test]
306 fn rejects_schema_range_syntax() {
307 let mut v = ok_config();
308 v["schema"] = serde_json::json!("default@^1.0.0");
309 let err = parse(v).unwrap_err();
310 assert!(matches!(err, ValidationError::InvalidConfig { .. }));
311 }
312
313 #[test]
314 fn rejects_schema_without_version() {
315 let mut v = ok_config();
316 v["schema"] = serde_json::json!("default");
317 let err = parse(v).unwrap_err();
318 assert!(matches!(err, ValidationError::InvalidConfig { .. }));
319 }
320
321 #[test]
322 fn rejects_legacy_types_field() {
323 let mut v = ok_config();
325 v["types"] = serde_json::json!(["spec"]);
326 let err = parse(v).unwrap_err();
327 assert!(matches!(err, ValidationError::InvalidConfig { .. }));
328 }
329
330 #[test]
331 fn rejects_invalid_semver() {
332 let mut v = ok_config();
333 v["version"] = serde_json::json!("not-a-version");
334 let err = parse(v).unwrap_err();
335 assert!(matches!(err, ValidationError::InvalidConfig { .. }));
336 }
337
338 #[test]
339 fn rejects_pre_release_plus_build_too_long() {
340 let mut v = ok_config();
341 let long = "a".repeat(130);
342 v["version"] = serde_json::json!(format!("0.1.0-{long}"));
343 let err = parse(v).unwrap_err();
344 assert!(matches!(err, ValidationError::InvalidVersion { .. }));
345 }
346}