Skip to main content

mcpact_manifest/
lib.rs

1//! Manifest parser, schema, and validator.
2
3mod audit_paths;
4mod conformance;
5mod signature;
6
7pub use audit_paths::{resolve_audit_jsonl_path, resolve_audit_jsonl_path_beside_manifest};
8pub use conformance::{ConformanceCorpus, CorpusError, ReplayError, ReplayOutcome};
9
10pub use signature::{
11    parse_signing_seed, sign_manifest, signing_digest, signing_payload, verify_package_signature,
12    verify_package_signature_trusted, verify_package_signature_with_anchor,
13    verify_signature_fields, SignatureTrust, TrustAnchor, ALGORITHM_ED25519, TRUSTED_KEYS_ENV,
14};
15
16use camino::Utf8PathBuf;
17use indexmap::IndexMap;
18use mcpact_core::{ApprovalMode, AuthorityClass, TrustCeiling};
19use schemars::JsonSchema;
20use serde::{Deserialize, Serialize};
21use std::{fs, path::Path};
22use thiserror::Error;
23
24/// Current manifest schema version.
25pub const SCHEMA_VERSION: &str = "mcpact.manifest.v1";
26
27/// McPact manifest.
28#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
29pub struct Manifest {
30    /// Schema version. Must be `mcpact.manifest.v1`.
31    pub schema_version: String,
32    /// Generated package settings.
33    pub package: PackageSpec,
34    /// Source CLI settings.
35    pub cli: CliSpec,
36    /// Optional audit settings.
37    #[serde(default)]
38    pub audit: AuditSpec,
39    /// Tool contracts.
40    #[serde(default)]
41    pub tools: Vec<ToolSpec>,
42}
43
44/// Generated package metadata.
45#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
46pub struct PackageSpec {
47    /// Generated crate name.
48    pub name: String,
49    /// Version string.
50    #[serde(default = "default_version")]
51    pub version: String,
52    /// Description.
53    #[serde(default)]
54    pub description: String,
55    /// Trust ceiling for this manifest.
56    #[serde(default = "default_trust_ceiling")]
57    pub trust: TrustCeiling,
58    /// Optional ed25519 signature over canonical unsigned manifest TOML.
59    #[serde(default)]
60    pub signature: Option<PackageSignature>,
61}
62
63/// Ed25519 manifest signature metadata.
64#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
65pub struct PackageSignature {
66    /// Signature algorithm (`ed25519`).
67    pub algorithm: String,
68    /// Base64-encoded verifying key (32 bytes).
69    pub public_key: String,
70    /// Base64-encoded signature (64 bytes) over SHA-256(canonical TOML).
71    pub signature: String,
72}
73
74/// CLI binary metadata.
75#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
76pub struct CliSpec {
77    /// Logical name.
78    pub name: String,
79    /// Binary path or name to execute.
80    pub binary: String,
81    /// Command used to get version.
82    #[serde(default)]
83    pub version_command: Vec<String>,
84    /// Optional working directory relative to launch workspace.
85    #[serde(default)]
86    #[schemars(with = "Option<String>")]
87    pub default_cwd: Option<Utf8PathBuf>,
88}
89
90/// Audit settings.
91#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
92pub struct AuditSpec {
93    /// Sink kind: `jsonl`, `cloudevents` (otel/cortex deferred).
94    #[serde(default = "default_audit_sink")]
95    pub sink: String,
96    /// Path for local JSONL evidence (relative to server cwd unless `xdg_state`).
97    #[serde(default = "default_audit_path")]
98    pub path: String,
99    /// When true, JSONL path is `$XDG_STATE_HOME/mcpact/<package>/audit.jsonl`.
100    #[serde(default)]
101    pub xdg_state: bool,
102    /// Optional CloudEvents HTTP endpoint when `sink = "cloudevents"`.
103    #[serde(default)]
104    pub url: Option<String>,
105}
106
107impl AuditSpec {
108    /// Validate sink kind is supported at runtime.
109    pub fn validate_sink(&self, report: &mut ValidationReport) {
110        match self.sink.as_str() {
111            "jsonl" | "cloudevents" => {}
112            "otel" | "cortex" => {
113                report.errors.push(format!(
114                    "audit.sink `{}` is not yet supported (see ADR-0027)",
115                    self.sink
116                ));
117            }
118            other => report.errors.push(format!(
119                "unknown audit.sink `{other}` (expected jsonl or cloudevents)"
120            )),
121        }
122    }
123}
124
125impl Default for AuditSpec {
126    fn default() -> Self {
127        Self {
128            sink: default_audit_sink(),
129            path: default_audit_path(),
130            xdg_state: false,
131            url: None,
132        }
133    }
134}
135
136/// Tool contract.
137#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
138pub struct ToolSpec {
139    /// MCP tool name.
140    pub name: String,
141    /// Optional UI title.
142    #[serde(default)]
143    pub title: Option<String>,
144    /// Tool description.
145    #[serde(default)]
146    pub description: String,
147    /// Fixed argv prefix after binary.
148    #[serde(default)]
149    pub command: Vec<String>,
150    /// Args keyed by input name.
151    #[serde(default)]
152    pub args: IndexMap<String, ArgSpec>,
153    /// Output policy.
154    #[serde(default)]
155    pub output: OutputSpec,
156    /// Authority and policy declaration.
157    #[serde(default)]
158    pub policy: PolicySpec,
159    /// Environment policy.
160    #[serde(default)]
161    pub env: EnvSpec,
162}
163
164/// Argument contract.
165#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
166pub struct ArgSpec {
167    /// Argument type.
168    #[serde(rename = "type")]
169    pub arg_type: ArgType,
170    /// Whether value is required.
171    #[serde(default)]
172    pub required: bool,
173    /// Help text.
174    #[serde(default)]
175    pub description: String,
176    /// CLI flag, e.g. `--format`.
177    #[serde(default)]
178    pub flag: Option<String>,
179    /// Positional index.
180    #[serde(default)]
181    pub position: Option<u32>,
182    /// Allowed enum values.
183    #[serde(default)]
184    pub values: Vec<String>,
185    /// Default value.
186    #[serde(default)]
187    pub default: Option<serde_json::Value>,
188    /// Permit multiple values.
189    #[serde(default)]
190    pub multiple: bool,
191    /// For path args, require existence.
192    #[serde(default)]
193    pub must_exist: bool,
194    /// For path args, require path to stay inside workspace.
195    #[serde(default)]
196    pub within_workspace: bool,
197    /// Redact this arg from audit records.
198    #[serde(default)]
199    pub redact: bool,
200}
201
202/// Supported argument types.
203#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
204#[serde(rename_all = "kebab-case")]
205pub enum ArgType {
206    /// UTF-8 string argument.
207    String,
208    /// Filesystem path argument.
209    Path,
210    /// Enumerated string argument.
211    Enum,
212    /// Boolean flag.
213    Bool,
214    /// Signed integer argument.
215    Integer,
216    /// Floating-point argument.
217    Number,
218    /// JSON value argument.
219    Json,
220}
221
222/// Output contract.
223#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
224pub struct OutputSpec {
225    /// Output mode: text, json, sarif.
226    #[serde(default = "default_output_mode")]
227    pub mode: String,
228    /// Max stdout bytes.
229    #[serde(default = "default_output_cap")]
230    pub max_bytes: usize,
231    /// Whether stderr is returned to the model.
232    #[serde(default)]
233    pub expose_stderr: bool,
234}
235
236impl Default for OutputSpec {
237    fn default() -> Self {
238        Self {
239            mode: default_output_mode(),
240            max_bytes: default_output_cap(),
241            expose_stderr: false,
242        }
243    }
244}
245
246/// Policy declaration.
247#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
248pub struct PolicySpec {
249    /// Tool authority class.
250    #[serde(default = "default_authority")]
251    pub authority: AuthorityClass,
252    /// Declared read-only marker.
253    #[serde(default)]
254    pub read_only: bool,
255    /// Whether command can use network.
256    #[serde(default)]
257    pub network: bool,
258    /// Whether command writes files.
259    #[serde(default)]
260    pub writes_files: bool,
261    /// Approval mode.
262    #[serde(default = "default_approval")]
263    pub approval: ApprovalMode,
264    /// Backcompat convenience; true maps to Always at validation time.
265    #[serde(default)]
266    pub requires_approval: bool,
267    /// Timeout seconds.
268    #[serde(default = "default_timeout")]
269    pub timeout_seconds: u64,
270}
271
272impl Default for PolicySpec {
273    fn default() -> Self {
274        Self {
275            authority: default_authority(),
276            read_only: false,
277            network: false,
278            writes_files: false,
279            approval: default_approval(),
280            requires_approval: false,
281            timeout_seconds: default_timeout(),
282        }
283    }
284}
285
286/// Environment policy.
287#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
288pub struct EnvSpec {
289    /// Whether to inherit parent environment.
290    #[serde(default)]
291    pub inherit: bool,
292    /// Allowed variables when not inheriting all.
293    #[serde(default)]
294    pub allow: Vec<String>,
295    /// Explicit variables to set.
296    #[serde(default)]
297    pub set: IndexMap<String, String>,
298}
299
300impl Default for EnvSpec {
301    fn default() -> Self {
302        Self {
303            inherit: false,
304            allow: Vec::new(),
305            set: IndexMap::new(),
306        }
307    }
308}
309
310/// Validation report.
311#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
312pub struct ValidationReport {
313    /// Errors block generation.
314    pub errors: Vec<String>,
315    /// Warnings do not block generation but lower confidence.
316    pub warnings: Vec<String>,
317}
318
319impl ValidationReport {
320    /// True when no errors are present.
321    #[must_use]
322    pub fn is_ok(&self) -> bool {
323        self.errors.is_empty()
324    }
325}
326
327/// Manifest errors.
328#[derive(Debug, Error)]
329pub enum ManifestError {
330    /// File IO failed.
331    #[error("manifest I/O failed: {0}")]
332    Io(#[from] std::io::Error),
333    /// TOML parse failed.
334    #[error("manifest parse failed: {0}")]
335    Toml(#[from] toml::de::Error),
336    /// Validation failed.
337    #[error("manifest validation failed: {0:?}")]
338    Validation(Vec<String>),
339}
340
341impl Manifest {
342    /// Load a manifest from TOML file and validate strictly.
343    pub fn load(path: impl AsRef<Path>) -> Result<Self, ManifestError> {
344        let text = fs::read_to_string(path)?;
345        Self::parse(&text)
346    }
347
348    /// Parse TOML manifest and validate strictly.
349    pub fn parse(text: &str) -> Result<Self, ManifestError> {
350        let manifest: Self = toml::from_str(text)?;
351        manifest.validate_strict()?;
352        Ok(manifest)
353    }
354
355    /// Validate and return a report.
356    #[must_use]
357    pub fn validate(&self) -> ValidationReport {
358        let mut report = ValidationReport::default();
359        if self.schema_version != SCHEMA_VERSION {
360            report
361                .errors
362                .push(format!("schema_version must be {SCHEMA_VERSION}"));
363        }
364        if self.package.name.trim().is_empty() {
365            report.errors.push("package.name is required".into());
366        }
367        if self.cli.binary.trim().is_empty() {
368            report.errors.push("cli.binary is required".into());
369        }
370        if self.tools.is_empty() {
371            report.errors.push("at least one tool is required".into());
372        }
373        for tool in &self.tools {
374            validate_tool(tool, &mut report);
375        }
376        self.audit.validate_sink(&mut report);
377        match self.package.trust {
378            TrustCeiling::Signed | TrustCeiling::Verified if self.package.signature.is_none() => {
379                report.errors.push(format!(
380                    "package.trust {:?} requires package.signature",
381                    self.package.trust
382                ));
383            }
384            _ => {}
385        }
386        if self.package.signature.is_some() {
387            if let Err(err) = verify_package_signature(self) {
388                report
389                    .errors
390                    .push(format!("package.signature invalid: {err}"));
391            }
392        }
393        report
394    }
395
396    /// Verified-pack layout checks (requires manifest path on disk).
397    ///
398    /// A `Verified` pack must ship a `README.md` and a `tests/conformance.jsonl`
399    /// beside its manifest. The conformance corpus is no longer accepted on
400    /// existence alone: it is parsed and structurally validated via
401    /// [`ConformanceCorpus::parse`], so an empty or malformed corpus — which
402    /// could never be replayed and proves nothing — now fails validation
403    /// fail-closed. A full *replay* of the corpus against a built server is the
404    /// caller's job (see [`ConformanceCorpus::replay`] and `mcpact verify
405    /// --replay`), since that requires building the generated crate; this keeps
406    /// the always-on `validate` path cheap while still refusing to call a pack
407    /// `Verified` when its replay contract is not a real one.
408    pub fn validate_pack_layout(&self, path: impl AsRef<Path>, report: &mut ValidationReport) {
409        if self.package.trust != TrustCeiling::Verified {
410            return;
411        }
412        let base = path.as_ref().parent().unwrap_or_else(|| Path::new("."));
413        if !base.join("README.md").is_file() {
414            report
415                .errors
416                .push("Verified pack missing `README.md` beside manifest".into());
417        }
418        let corpus_path = base.join("tests/conformance.jsonl");
419        if !corpus_path.is_file() {
420            report
421                .errors
422                .push("Verified pack missing `tests/conformance.jsonl` beside manifest".into());
423        } else if let Err(err) = ConformanceCorpus::parse_file(&corpus_path) {
424            report.errors.push(format!(
425                "Verified pack `tests/conformance.jsonl` is not a replayable conformance corpus: {err}"
426            ));
427        }
428    }
429
430    /// Validate and return an error when blocking issues exist.
431    pub fn validate_strict(&self) -> Result<(), ManifestError> {
432        let report = self.validate();
433        if report.is_ok() {
434            Ok(())
435        } else {
436            Err(ManifestError::Validation(report.errors))
437        }
438    }
439}
440
441fn validate_tool(tool: &ToolSpec, report: &mut ValidationReport) {
442    if !is_valid_tool_name(&tool.name) {
443        report.errors.push(format!(
444            "tool name `{}` must be snake_case ASCII",
445            tool.name
446        ));
447    }
448    if tool
449        .command
450        .iter()
451        .any(|a| a == "sh" || a == "bash" || a == "-c")
452    {
453        report.errors.push(format!(
454            "tool `{}` appears to request shell execution",
455            tool.name
456        ));
457    }
458    if tool.policy.authority.is_high_risk()
459        && !tool.policy.requires_approval
460        && tool.policy.approval == ApprovalMode::Never
461    {
462        report.errors.push(format!(
463            "tool `{}` has high-risk authority `{}` but no approval policy",
464            tool.name, tool.policy.authority
465        ));
466    }
467    if tool.policy.read_only && (tool.policy.writes_files || tool.policy.authority.is_high_risk()) {
468        report.warnings.push(format!(
469            "tool `{}` is read_only but declares writes/high-risk authority",
470            tool.name
471        ));
472    }
473    for (name, arg) in &tool.args {
474        if !is_valid_arg_name(name) {
475            report.errors.push(format!(
476                "tool `{}` arg `{}` has invalid name",
477                tool.name, name
478            ));
479        }
480        if matches!(arg.arg_type, ArgType::Enum) && arg.values.is_empty() {
481            report.errors.push(format!(
482                "tool `{}` enum arg `{}` has no values",
483                tool.name, name
484            ));
485        }
486        if arg.flag.as_deref().is_some_and(|f| !f.starts_with('-')) {
487            report.errors.push(format!(
488                "tool `{}` arg `{}` flag must start with -",
489                tool.name, name
490            ));
491        }
492    }
493}
494
495/// Validate MCP-compatible tool name subset.
496#[must_use]
497pub fn is_valid_tool_name(name: &str) -> bool {
498    !name.is_empty()
499        && name
500            .chars()
501            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
502        && name.chars().next().is_some_and(|c| c.is_ascii_lowercase())
503}
504
505/// Validate generated Rust field name subset.
506#[must_use]
507pub fn is_valid_arg_name(name: &str) -> bool {
508    is_valid_tool_name(name)
509}
510
511fn default_version() -> String {
512    "0.1.0".into()
513}
514fn default_trust_ceiling() -> TrustCeiling {
515    TrustCeiling::Reviewed
516}
517fn default_audit_sink() -> String {
518    "jsonl".into()
519}
520fn default_audit_path() -> String {
521    ".mcpact/audit.jsonl".into()
522}
523fn default_output_mode() -> String {
524    "text".into()
525}
526fn default_output_cap() -> usize {
527    1_048_576
528}
529fn default_authority() -> AuthorityClass {
530    AuthorityClass::Observe
531}
532fn default_approval() -> ApprovalMode {
533    ApprovalMode::Never
534}
535fn default_timeout() -> u64 {
536    60
537}
538
539#[cfg(test)]
540mod tests {
541    use super::*;
542    use std::path::PathBuf;
543
544    const VALID: &str = r#"
545schema_version = "mcpact.manifest.v1"
546
547[package]
548name = "mcp-taudit"
549trust = "Reviewed"
550
551[cli]
552name = "taudit"
553binary = "taudit"
554version_command = ["--version"]
555
556[[tools]]
557name = "scan_pipeline"
558description = "scan a pipeline"
559command = ["scan"]
560
561[tools.args.path]
562type = "path"
563required = true
564position = 1
565must_exist = true
566within_workspace = true
567
568[tools.args.format]
569type = "enum"
570required = false
571flag = "--format"
572values = ["json", "sarif"]
573default = "json"
574
575[tools.output]
576mode = "json"
577max_bytes = 1048576
578
579[tools.policy]
580authority = "Analyze"
581read_only = true
582network = false
583writes_files = false
584approval = "never"
585timeout_seconds = 60
586"#;
587
588    #[test]
589    fn parses_valid_manifest() {
590        let manifest = Manifest::parse(VALID).unwrap();
591        assert_eq!(manifest.tools[0].name, "scan_pipeline");
592        assert!(manifest.validate().is_ok());
593    }
594
595    #[test]
596    fn rejects_high_risk_without_approval() {
597        let text = VALID.replace("Analyze", "Destroy");
598        let err = Manifest::parse(&text).unwrap_err();
599        assert!(format!("{err}").contains("high-risk"));
600    }
601
602    #[test]
603    fn rejects_shell_in_command() {
604        let text = VALID.replace("command = [\"scan\"]", "command = [\"sh\", \"-c\"]");
605        let err = Manifest::parse(&text).unwrap_err();
606        assert!(format!("{err}").contains("shell"));
607    }
608
609    #[test]
610    fn rejects_wrong_schema_version() {
611        let text = VALID.replace("mcpact.manifest.v1", "mcpact.manifest.v0");
612        let err = Manifest::parse(&text).unwrap_err();
613        assert!(format!("{err}").contains("schema_version"));
614    }
615
616    #[test]
617    fn rejects_invalid_tool_name() {
618        let mut manifest = Manifest::parse(VALID).unwrap();
619        manifest.tools[0].name = "Bad-Name".into();
620        assert!(!manifest.validate().is_ok());
621    }
622
623    #[test]
624    fn rejects_enum_without_values() {
625        let text = VALID.replace("values = [\"json\", \"sarif\"]", "values = []");
626        let err = Manifest::parse(&text).unwrap_err();
627        assert!(format!("{err}").contains("enum"));
628    }
629
630    #[test]
631    fn all_example_adapters_validate() {
632        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../examples/adapters");
633        for name in [
634            "taudit",
635            "cargo",
636            "gh-readonly",
637            "terraform-plan",
638            "fixture-echo",
639            "signed-fixture-echo",
640        ] {
641            let path = root.join(name).join("mcpact.toml");
642            let manifest = Manifest::load(&path).unwrap_or_else(|e| panic!("{name}: {e}"));
643            assert!(manifest.validate().is_ok(), "{name} manifest invalid");
644        }
645    }
646
647    // -----------------------------------------------------------------------
648    // Failure-mode test M-1: env_clear default invariant
649    //
650    // Documents and locks in the verdict from
651    // value-sheet/18-cross-product-test/v2/results/per-tool-failure-mode-tests-results/composite.md
652    // (test M-1, 2026-05-19): EnvSpec::default().inherit MUST be false so the
653    // mcpact-runtime non-inherit branch unconditionally clears the env. A
654    // regression that flips this default would silently re-enable env
655    // inheritance for every generated tool.
656    // -----------------------------------------------------------------------
657
658    #[test]
659    fn env_spec_default_does_not_inherit_env() {
660        // Failure-mode test M-1 — see value-sheet/18-cross-product-test/v2/results/per-tool-failure-mode-tests-results/composite.md
661        let env = EnvSpec::default();
662        assert!(
663            !env.inherit,
664            "EnvSpec::default().inherit MUST be false; a regression would silently re-enable env inheritance"
665        );
666        assert!(
667            env.allow.is_empty(),
668            "EnvSpec::default().allow MUST be empty so allow-list path is also a no-op"
669        );
670        assert!(env.set.is_empty(), "EnvSpec::default().set MUST be empty");
671    }
672
673    #[test]
674    fn env_spec_inherit_default_in_serde_is_false() {
675        // Failure-mode test M-1 — guards the #[serde(default)] on `inherit`.
676        // A manifest with no `[tools.env]` block should produce inherit=false.
677        let m = Manifest::parse(VALID).unwrap();
678        assert!(
679            !m.tools[0].env.inherit,
680            "tool with no [tools.env] block should default to inherit=false"
681        );
682    }
683
684    // -----------------------------------------------------------------------
685    // Verified-pack layout enforcement: `Verified` no longer collapses to
686    // file-existence. The conformance corpus must be a structurally-valid,
687    // replayable MCP request sequence or validation fails fail-closed.
688    // -----------------------------------------------------------------------
689
690    fn scratch_dir(tag: &str) -> PathBuf {
691        let dir = std::env::temp_dir().join(format!(
692            "mcpact-layout-test-{tag}-{}-{:?}",
693            std::process::id(),
694            std::time::SystemTime::now()
695                .duration_since(std::time::UNIX_EPOCH)
696                .unwrap()
697                .as_nanos()
698        ));
699        fs::create_dir_all(dir.join("tests")).unwrap();
700        dir
701    }
702
703    fn verified_manifest() -> Manifest {
704        let mut m = Manifest::parse(VALID).unwrap();
705        m.package.trust = TrustCeiling::Verified;
706        m
707    }
708
709    const GOOD_CORPUS: &str = concat!(
710        r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#,
711        "\n",
712        r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"scan_pipeline"}}"#,
713    );
714
715    #[test]
716    fn verified_layout_passes_with_valid_corpus() {
717        let dir = scratch_dir("good");
718        fs::write(dir.join("README.md"), "# pack").unwrap();
719        fs::write(dir.join("tests/conformance.jsonl"), GOOD_CORPUS).unwrap();
720        let manifest_path = dir.join("mcpact.toml");
721
722        let mut report = ValidationReport::default();
723        verified_manifest().validate_pack_layout(&manifest_path, &mut report);
724        assert!(
725            report.is_ok(),
726            "valid verified layout should pass: {:?}",
727            report.errors
728        );
729        let _ = fs::remove_dir_all(&dir);
730    }
731
732    #[test]
733    fn verified_layout_fails_closed_on_empty_corpus() {
734        let dir = scratch_dir("empty");
735        fs::write(dir.join("README.md"), "# pack").unwrap();
736        // File EXISTS but is empty — the old existence-only check passed here.
737        fs::write(dir.join("tests/conformance.jsonl"), "\n   \n").unwrap();
738        let manifest_path = dir.join("mcpact.toml");
739
740        let mut report = ValidationReport::default();
741        verified_manifest().validate_pack_layout(&manifest_path, &mut report);
742        assert!(
743            !report.is_ok(),
744            "an empty conformance corpus must NOT satisfy Verified"
745        );
746        assert!(
747            report.errors.iter().any(|e| e.contains("replayable")),
748            "error should explain the corpus is not replayable: {:?}",
749            report.errors
750        );
751        let _ = fs::remove_dir_all(&dir);
752    }
753
754    #[test]
755    fn verified_layout_fails_closed_on_malformed_corpus() {
756        let dir = scratch_dir("malformed");
757        fs::write(dir.join("README.md"), "# pack").unwrap();
758        // Present, non-empty, but not a JSON-RPC request sequence.
759        fs::write(dir.join("tests/conformance.jsonl"), "this is not json\n").unwrap();
760        let manifest_path = dir.join("mcpact.toml");
761
762        let mut report = ValidationReport::default();
763        verified_manifest().validate_pack_layout(&manifest_path, &mut report);
764        assert!(
765            !report.is_ok(),
766            "a malformed conformance corpus must NOT satisfy Verified"
767        );
768        let _ = fs::remove_dir_all(&dir);
769    }
770}
771
772#[cfg(test)]
773mod proptest_roundtrip {
774    use super::*;
775    use proptest::prelude::*;
776
777    proptest! {
778        #[test]
779        fn audit_spec_toml_roundtrip(
780            path in r"(\.?/?[a-zA-Z0-9_./-]{0,48})",
781            xdg in any::<bool>(),
782            sink in prop_oneof!["jsonl", "cloudevents"],
783        ) {
784            let spec = AuditSpec {
785                sink: sink.to_string(),
786                path,
787                xdg_state: xdg,
788                url: None,
789            };
790            let encoded = toml::to_string(&spec).expect("encode audit spec");
791            let decoded: AuditSpec = toml::from_str(&encoded).expect("decode audit spec");
792            prop_assert_eq!(spec, decoded);
793        }
794
795        #[test]
796        fn manifest_json_roundtrip(name in r"[a-z][a-z0-9_]{2,24}") {
797            let manifest = Manifest {
798                schema_version: SCHEMA_VERSION.into(),
799                package: PackageSpec {
800                    name: format!("mcp-{name}"),
801                    version: "0.1.0".into(),
802                    description: "proptest".into(),
803                    trust: TrustCeiling::Reviewed,
804                    signature: None,
805                },
806                cli: CliSpec {
807                    name: name.clone(),
808                    binary: name,
809                    version_command: vec!["--version".into()],
810                    default_cwd: None,
811                },
812                tools: vec![ToolSpec {
813                    name: "probe_tool".into(),
814                    title: None,
815                    description: String::new(),
816                    command: vec!["scan".into()],
817                    args: IndexMap::new(),
818                    output: OutputSpec::default(),
819                    policy: PolicySpec {
820                        authority: AuthorityClass::Observe,
821                        read_only: true,
822                        network: false,
823                        writes_files: false,
824                        approval: ApprovalMode::Never,
825                        requires_approval: false,
826                        timeout_seconds: 30,
827                    },
828                    env: EnvSpec::default(),
829                }],
830                audit: AuditSpec::default(),
831            };
832            let json = serde_json::to_string(&manifest).expect("serialize");
833            let back: Manifest = serde_json::from_str(&json).expect("deserialize");
834            prop_assert_eq!(&manifest.package.name, &back.package.name);
835            prop_assert_eq!(&manifest.tools[0].name, &back.tools[0].name);
836        }
837    }
838}