Skip to main content

memstead_base/validator/
config.rs

1//! Strict config parsing for the archive's `.memstead/config.json`.
2//!
3//! Whitelist projection: `deny_unknown_fields`, required slug-shaped
4//! `name`, required semver `version`, required `schema: "<name>@<ver>"`.
5//! Schema resolution against the engine's registry happens at load, not
6//! here — this module only enforces shape and format integer.
7
8use memstead_schema::{PUBLISHED_MEM_FORMAT, PublishedMemConfig};
9use regex::Regex;
10use std::sync::OnceLock;
11
12use super::ValidationError;
13
14/// The only shape the archive's `.memstead/config.json` is allowed to
15/// take. Author-only fields (writeGuidance, mediums, projections,
16/// rules, publish, readMems, vcs, language, community,
17/// defaultSchema) are stripped at export. Their presence here means
18/// the archive was built before the whitelist export landed, or by
19/// hand — surface that specifically so the user knows to re-export
20/// rather than debug a raw serde error.
21const 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
34/// Parse strict config bytes into a `PublishedMemConfig`. Runs every
35/// check in this module (shape, legacy detection, format, name,
36/// version, schema resolution).
37pub 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    // Readers accept the current format AND format 3 (pre-title/subject:
71    // such an archive simply has neither field) — the single shared
72    // predicate keeps acceptance from drifting per surface.
73    if memstead_schema::published_format_accepted(config.format) {
74        return Ok(());
75    }
76    // `format: 2` archives (top-level `schema/` tree, pre-relocation)
77    // are rejected with an actionable re-export hint. Any other
78    // mismatch falls through to the generic `UnsupportedFormat`.
79    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
104/// Guard against a pathological pre-release or build-metadata string
105/// that is otherwise free to be arbitrarily long per the semver spec.
106const 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    /// Forge refusal: a publisher cannot smuggle a first-party trust claim
192    /// into a mem archive. Trust origin is decided by the consuming
193    /// engine (built-in catalogue + writable-mount adoption), never read
194    /// from publisher-supplied config, so an archive config that carries an
195    /// `origin` (or any first-party-claim) key is an unknown field and the
196    /// `deny_unknown_fields` whitelist refuses it with a typed
197    /// `InvalidConfig` — no path admits the claim.
198    #[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        // Missing `schema` is a serde error (field required) — surfaces as
304        // InvalidConfig before anything else runs.
305        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        // Legacy `types` is an unknown field under `deny_unknown_fields`.
327        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    /// Format compatibility across the title/subject bump: readers
351    /// accept 3 (pre-title/subject — the two live-registry mems) and 4
352    /// (current); 1 and 2 keep refusing with their existing errors; an
353    /// unknown future format refuses; and an unknown key is still
354    /// refused whatever the format says.
355    #[test]
356    fn format_three_and_four_accepted_older_and_unknown_refused() {
357        // Format 3 installs cleanly and simply has no title/subject.
358        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        // Format 4 with title + subject parses them.
365        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        // Formats 1 and 2 keep their current refusals.
389        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        // An unknown future format refuses — a format-4 archive is not
404        // accepted by an un-updated reader, symmetrically.
405        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        // An unknown key is still refused (deny_unknown_fields holds).
413        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}