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    if config.format == PUBLISHED_MEM_FORMAT {
71        return Ok(());
72    }
73    // `format: 2` archives (top-level `schema/` tree, pre-relocation)
74    // are rejected with an actionable re-export hint. Any other
75    // mismatch falls through to the generic `UnsupportedFormat`.
76    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
101/// Guard against a pathological pre-release or build-metadata string
102/// that is otherwise free to be arbitrarily long per the semver spec.
103const 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    /// Forge refusal: a publisher cannot smuggle a first-party trust claim
189    /// into a mem archive. Trust origin is decided by the consuming
190    /// engine (built-in catalogue + writable-mount adoption), never read
191    /// from publisher-supplied config, so an archive config that carries an
192    /// `origin` (or any first-party-claim) key is an unknown field and the
193    /// `deny_unknown_fields` whitelist refuses it with a typed
194    /// `InvalidConfig` — no path admits the claim.
195    #[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        // Missing `schema` is a serde error (field required) — surfaces as
301        // InvalidConfig before anything else runs.
302        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        // Legacy `types` is an unknown field under `deny_unknown_fields`.
324        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}