Skip to main content

mcp_execution_core/
provenance.rs

1//! Generation provenance for the `_meta.json` sidecar: when, and against what server state,
2//! a `generate` run produced its output.
3//!
4//! `mcp-execution-codegen` writes an exported TypeScript bindings tree alongside a `_meta.json`
5//! sidecar, but nothing recorded when that generation happened or what the connected server
6//! looked like at the time. This module gives [`crate::metadata::ServerMetadata`] a
7//! [`GenerationProvenance`] field so a future comparison mechanism can detect that a server's
8//! configuration or tool surface has changed since the bindings were generated — see
9//! `.local/specs/016-meta-json-generation-provenance/spec.md` for the full design record.
10//!
11//! # What provenance does and does not answer
12//!
13//! Provenance answers "has the server's exposed surface, or the identity of the endpoint we
14//! generated from, changed since generation?" It does **not** answer "would re-running
15//! `generate` today produce byte-identical files?" — collision-disambiguating TypeScript names
16//! and tool categorization both affect generated output without affecting either digest below.
17//!
18//! # Hashing approach
19//!
20//! Both [`ConfigFingerprint`] and [`ToolDigest`] are SHA-256 digests (hex-encoded, lowercase)
21//! over a hand-built preimage — never over `Debug` output (no stability guarantee) or a
22//! `serde_json::Value`'s serialized bytes (this workspace enables `serde_json/preserve_order`
23//! transitively via `handlebars`, and even without that, a `HashMap`-backed preimage would vary
24//! by process). Every preimage is built from the `Preimage` length-framing primitive over an
25//! explicitly sorted, fixed field order, so two semantically identical inputs always hash
26//! identically regardless of map iteration order — see [`ConfigFingerprint::compute`] and
27//! [`ToolDigest::compute`] for the exact field lists.
28//!
29//! [`ConfigFingerprint`] deliberately excludes every secret-bearing *value* (argument values,
30//! environment variable values, header values, query-parameter values, userinfo) from its
31//! preimage — only structural signal (names, counts, the URL's scheme/authority/path) goes in.
32//! Rotating a credential must never register as configuration drift.
33
34use crate::redact::{UrlTailKind, split_url};
35use crate::server_config::{ServerConfig, Transport};
36use crate::untrusted::sanitize_untrusted_inline;
37use chrono::{DateTime, Utc};
38use serde::{Deserialize, Serialize};
39use sha2::{Digest, Sha256};
40use thiserror::Error;
41
42/// Domain-separation tag for [`ConfigFingerprint::compute`]'s preimage.
43const CONFIG_FINGERPRINT_DOMAIN: &str = "mcp-execution:config-fingerprint:v1";
44
45/// Domain-separation tag for a single tool entry's preimage, hashed inside
46/// [`ToolDigest::compute`].
47const TOOL_ENTRY_DOMAIN: &str = "mcp-execution:tool-entry:v1";
48
49/// Domain-separation tag for [`ToolDigest::compute`]'s aggregate preimage.
50const TOOL_DIGEST_DOMAIN: &str = "mcp-execution:tool-digest:v1";
51
52/// Tag byte marking a URL that [`split_url`] parsed successfully, in
53/// [`push_url_and_headers`]'s preimage contribution.
54const URL_PARSED: u8 = 0;
55/// Tag byte marking a URL [`split_url`] could not parse, standing in for the whole
56/// scheme/authority/path/query contribution (N3: a distinct tag, not a literal `<unparseable>`
57/// string, so no real URL content can collide with it).
58const URL_UNPARSEABLE: u8 = 1;
59
60/// Tag byte marking a named query parameter in [`push_query_param_names`]'s preimage
61/// contribution, followed by the framed name.
62const QUERY_PARAM_NAMED: u8 = 0;
63/// Tag byte marking a bare query parameter (no `=`) — carries no following bytes, so it can
64/// never collide with a real parameter literally named `<bare>` (N3).
65const QUERY_PARAM_BARE: u8 = 1;
66
67/// Type tag for [`hash_value_into`]'s recursive walk over a `serde_json::Value`.
68mod value_tag {
69    pub(super) const NULL: u8 = 0;
70    pub(super) const BOOL: u8 = 1;
71    pub(super) const NUMBER: u8 = 2;
72    pub(super) const STRING: u8 = 3;
73    pub(super) const ARRAY: u8 = 4;
74    pub(super) const OBJECT: u8 = 5;
75}
76
77/// Accumulates a hash preimage as a byte buffer under one framing convention: every
78/// variable-length value is prefixed with its length as a big-endian `u64` before its bytes
79/// ([`Self::str`]/[`Self::bytes`]); every fixed-width value (a count, a presence/tag byte) is
80/// written raw ([`Self::u64`]/[`Self::byte`]/[`Self::raw_32`]). Fixed field order plus this
81/// framing makes the encoding unambiguous without any separator or escaping — see the module
82/// docs for why this replaces both `Debug` output and JSON serialization as a preimage source.
83struct Preimage(Vec<u8>);
84
85impl Preimage {
86    const fn new() -> Self {
87        Self(Vec::new())
88    }
89
90    /// Length-prefixed raw bytes.
91    fn bytes(&mut self, bytes: &[u8]) -> &mut Self {
92        let len = bytes.len() as u64;
93        self.0.extend_from_slice(&len.to_be_bytes());
94        self.0.extend_from_slice(bytes);
95        self
96    }
97
98    /// Length-prefixed UTF-8 text.
99    fn str(&mut self, s: &str) -> &mut Self {
100        self.bytes(s.as_bytes())
101    }
102
103    /// A fixed-width count. Not length-prefixed: a `u64`'s width is already fixed, so framing
104    /// it would only waste bytes without resolving any ambiguity.
105    fn u64(&mut self, n: u64) -> &mut Self {
106        self.0.extend_from_slice(&n.to_be_bytes());
107        self
108    }
109
110    /// A single presence/tag byte.
111    fn byte(&mut self, b: u8) -> &mut Self {
112        self.0.push(b);
113        self
114    }
115
116    /// 32 raw bytes (a nested SHA-256 digest). Fixed-width by construction, so — like
117    /// [`Self::u64`] — no length prefix is needed.
118    fn raw_32(&mut self, bytes: [u8; 32]) -> &mut Self {
119        self.0.extend_from_slice(&bytes);
120        self
121    }
122
123    /// Hashes the accumulated buffer with SHA-256.
124    fn finish(&self) -> [u8; 32] {
125        Sha256::digest(&self.0).into()
126    }
127}
128
129/// Hex-encodes `bytes` as lowercase hex, one `{:02x}` pair per byte.
130///
131/// `sha2`'s digest output type does not implement [`std::fmt::LowerHex`], so `format!("{:x}",
132/// ...)` is not available — this explicit loop is the documented replacement (see spec §10)
133/// rather than pulling in a dedicated hex-encoding crate for eight lines.
134fn hex_encode(bytes: [u8; 32]) -> String {
135    use std::fmt::Write as _;
136    let mut s = String::with_capacity(bytes.len() * 2);
137    for b in bytes {
138        // Infallible: `String`'s `fmt::Write` impl never returns `Err`.
139        let _ = write!(s, "{b:02x}");
140    }
141    s
142}
143
144/// Error returned when a candidate string is not a well-formed digest: exactly 64 lowercase
145/// ASCII hex characters (a hex-encoded SHA-256 digest).
146///
147/// Returned by both [`ConfigFingerprint`] and [`ToolDigest`]'s `TryFrom<String>` impl, which
148/// their `#[serde(try_from = "String")]` `Deserialize` routes every deserialization through —
149/// mirroring [`crate::ServerId`]/[`crate::ToolName`]'s validated-newtype pattern (see their own
150/// doc comments): no separate, unvalidated deserialization path exists for either type, so a
151/// hand-edited `_meta.json` cannot produce a value violating `as_str`'s documented
152/// "64-character lowercase-hex" contract.
153///
154/// # Examples
155///
156/// ```
157/// use mcp_execution_core::provenance::{ConfigFingerprint, DigestFormatError};
158///
159/// let err = ConfigFingerprint::try_from("not-a-digest".to_string()).unwrap_err();
160/// assert!(matches!(err, DigestFormatError { .. }));
161/// ```
162#[derive(Debug, Clone, PartialEq, Eq, Error)]
163#[error("invalid digest {value:?}: must be exactly 64 lowercase hex characters")]
164pub struct DigestFormatError {
165    /// Sanitized form of the rejected input (see
166    /// [`sanitize_untrusted_inline`](crate::untrusted::sanitize_untrusted_inline)): this value
167    /// comes from an on-disk `_meta.json` a caller may have hand-edited, so it is treated as
168    /// untrusted the same way `ServerIdError`/`ToolNameError` treat their own rejected input.
169    value: String,
170}
171
172/// Validates that `candidate` is exactly 64 lowercase ASCII hex characters, the shape every
173/// [`ConfigFingerprint`]/[`ToolDigest`] produced by [`hex_encode`] always has.
174fn validate_digest_string(candidate: String) -> Result<String, DigestFormatError> {
175    let is_valid = candidate.len() == 64
176        && candidate
177            .bytes()
178            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
179    if is_valid {
180        Ok(candidate)
181    } else {
182        Err(DigestFormatError {
183            value: sanitize_untrusted_inline(&candidate),
184        })
185    }
186}
187
188/// A stable fingerprint of the [`ServerConfig`] used to connect to and introspect a server,
189/// recorded so a later comparison can detect that connection parameters changed.
190///
191/// Newtype over a 64-character lowercase-hex `String` (a SHA-256 digest), rather than a bare
192/// `String`, for the same reason [`crate::ServerId`]/[`crate::ToolName`] are newtypes: two
193/// same-shaped hex strings sitting adjacent in `crate::metadata::GenerationProvenance` are
194/// trivially swappable by accident, and a single-field newtype still serializes transparently
195/// as a JSON string. [`Deserialize`] is routed through [`TryFrom<String>`] (via
196/// `#[serde(try_from = "String")]`), so a value read back from disk is validated the same way
197/// [`crate::ServerId`]/[`crate::ToolName`] are — see [`DigestFormatError`].
198///
199/// # Examples
200///
201/// ```
202/// use mcp_execution_core::provenance::ConfigFingerprint;
203/// use mcp_execution_core::ServerConfig;
204///
205/// let config = ServerConfig::builder().command("docker".to_string()).build().unwrap();
206/// let fingerprint = ConfigFingerprint::compute(&config);
207/// assert_eq!(fingerprint.as_str().len(), 64);
208/// assert!(fingerprint.as_str().chars().all(|c| c.is_ascii_hexdigit()));
209/// ```
210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
211#[serde(try_from = "String")]
212pub struct ConfigFingerprint(String);
213
214impl TryFrom<String> for ConfigFingerprint {
215    type Error = DigestFormatError;
216
217    /// Delegates to `validate_digest_string` — the sole entry point [`Deserialize`] uses.
218    fn try_from(value: String) -> Result<Self, Self::Error> {
219        validate_digest_string(value).map(Self)
220    }
221}
222
223impl ConfigFingerprint {
224    /// Computes a fingerprint of `config`, sufficient to detect that connection parameters
225    /// changed, without persisting any secret-bearing value.
226    ///
227    /// The preimage carries, in a fixed order: a domain tag; the transport discriminant
228    /// (`stdio`/`http`/`sse`); for `stdio`, `command`, `cwd` presence, argument *count*, and
229    /// every environment variable *name* (sorted); for `http`/`sse`, the URL's canonical
230    /// `scheme://authority/path` form, its query-parameter *names* (deduplicated, sorted), a
231    /// userinfo-present marker, and every header *name* (ASCII-lowercased, sorted). No argument
232    /// value, environment/header value, query-parameter value, or userinfo is ever fed — see
233    /// the module docs.
234    ///
235    /// `ServerConfig::connect_timeout`/`discover_timeout` are deliberately excluded: they bound
236    /// how long the client waits for a response, not what the server exposes, so changing one
237    /// must not register as a change to the server's identity or tool surface.
238    ///
239    /// Residual collision, documented rather than fixed: every URL `split_url` cannot parse —
240    /// including two configs whose only difference is inside the ambiguous userinfo case it
241    /// rejects, e.g. `https://u:p/w@a.com` vs. `https://u:p/w@b.com` — collapses onto the same
242    /// `URL_UNPARSEABLE` marker and therefore the same fingerprint. This is the same class of
243    /// secrecy-over-precision tradeoff as the other residual collisions in this family (query
244    /// values, userinfo credentials): the input a real fingerprint would need to distinguish
245    /// them is exactly the text this function refuses to hash.
246    ///
247    /// # Examples
248    ///
249    /// Configs differing only in secret-bearing values fingerprint identically:
250    ///
251    /// ```
252    /// use mcp_execution_core::provenance::ConfigFingerprint;
253    /// use mcp_execution_core::ServerConfig;
254    ///
255    /// let a = ServerConfig::builder()
256    ///     .command("docker".to_string())
257    ///     .env("TOKEN".to_string(), "secret-a".to_string())
258    ///     .build()
259    ///     .unwrap();
260    /// let b = ServerConfig::builder()
261    ///     .command("docker".to_string())
262    ///     .env("TOKEN".to_string(), "secret-b".to_string())
263    ///     .build()
264    ///     .unwrap();
265    ///
266    /// assert_eq!(ConfigFingerprint::compute(&a), ConfigFingerprint::compute(&b));
267    /// ```
268    #[must_use]
269    pub fn compute(config: &ServerConfig) -> Self {
270        let mut pre = Preimage::new();
271        pre.str(CONFIG_FINGERPRINT_DOMAIN);
272
273        match config.transport() {
274            Transport::Stdio {
275                command,
276                args,
277                env,
278                cwd,
279            } => {
280                pre.str("stdio");
281                pre.str(command);
282                match cwd {
283                    Some(path) => {
284                        pre.byte(1);
285                        pre.bytes(path.as_os_str().as_encoded_bytes());
286                    }
287                    None => {
288                        pre.byte(0);
289                    }
290                }
291                pre.u64(args.len() as u64);
292
293                let mut names: Vec<&str> = env.keys().map(String::as_str).collect();
294                names.sort_unstable();
295                pre.u64(names.len() as u64);
296                for name in names {
297                    pre.str(name);
298                }
299            }
300            Transport::Http { url, headers } => {
301                pre.str("http");
302                push_url_and_headers(&mut pre, url, headers);
303            }
304            Transport::Sse { url, headers } => {
305                pre.str("sse");
306                push_url_and_headers(&mut pre, url, headers);
307            }
308        }
309
310        Self(hex_encode(pre.finish()))
311    }
312
313    /// Returns the fingerprint as a 64-character lowercase-hex string slice.
314    ///
315    /// # Examples
316    ///
317    /// ```
318    /// use mcp_execution_core::provenance::ConfigFingerprint;
319    /// use mcp_execution_core::ServerConfig;
320    ///
321    /// let config = ServerConfig::builder().command("docker".to_string()).build().unwrap();
322    /// let fingerprint = ConfigFingerprint::compute(&config);
323    /// assert!(!fingerprint.as_str().is_empty());
324    /// ```
325    #[must_use]
326    pub fn as_str(&self) -> &str {
327        &self.0
328    }
329}
330
331/// Query-parameter name classification fed into [`push_query_param_names`]'s preimage
332/// contribution. `Bare` sorts before every `Named` variant (see the derived [`Ord`]), giving a
333/// deterministic total order regardless of how many bare parameters a query string carries —
334/// they collapse to a single deduplicated entry.
335#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
336enum QueryParamName<'a> {
337    /// A `?token` segment with no `=`: the text is indistinguishable from a value, so only the
338    /// fact that *some* bare parameter existed is preserved (see spec §6).
339    Bare,
340    /// A `name=value` segment's `name` half.
341    Named(&'a str),
342}
343
344/// Parses `query`'s `&`-separated segments into deduplicated, sorted [`QueryParamName`]s.
345///
346/// Only the segment layout (`name=value` vs. a bare token) is inspected; every value is
347/// discarded before it ever reaches the caller, let alone the hash preimage.
348///
349/// `query` is truncated at the first `#`, if any, before splitting on `&`. This matters because
350/// [`split_url`]'s `Query` tail runs verbatim to the end of the URL by design — a `?`-triggered
351/// tail does not stop at a later `#`, since [`crate::RedactedUrl`]'s `Debug` impl (which shares
352/// that parser) only needs to know *that* a separator was hit, not parse what follows it. This
353/// function does need to parse what follows, so without this truncation a URL fragment
354/// containing its own `&`-separated text would be misread as query parameters — collapsing two
355/// configs with different query strings (`?a=1&b=2` vs `?a=1#&b=2`) onto the same fingerprint,
356/// exactly the false-negative direction NFR-004 forbids.
357fn parse_query_param_names(query: &str) -> Vec<QueryParamName<'_>> {
358    let query = query.find('#').map_or(query, |pos| &query[..pos]);
359
360    let mut names: Vec<QueryParamName<'_>> = query
361        .split('&')
362        .filter(|segment| !segment.is_empty())
363        .map(|segment| match segment.split_once('=') {
364            Some((name, _value)) => QueryParamName::Named(name),
365            None => QueryParamName::Bare,
366        })
367        .collect();
368    names.sort_unstable();
369    names.dedup();
370    names
371}
372
373/// Writes each `name`'s preimage contribution: a type-tag byte (N3) followed by the framed
374/// name for [`QueryParamName::Named`], or nothing further for [`QueryParamName::Bare`] — the
375/// tag byte alone is enough to distinguish a bare marker from a parameter literally named
376/// `<bare>`, which would instead be tagged `QUERY_PARAM_NAMED` and carry that literal text.
377fn push_query_param_names(pre: &mut Preimage, names: &[QueryParamName<'_>]) {
378    pre.u64(names.len() as u64);
379    for name in names {
380        match name {
381            QueryParamName::Named(n) => {
382                pre.byte(QUERY_PARAM_NAMED);
383                pre.str(n);
384            }
385            QueryParamName::Bare => {
386                pre.byte(QUERY_PARAM_BARE);
387            }
388        }
389    }
390}
391
392/// Writes the shared `http`/`sse` portion of [`ConfigFingerprint::compute`]'s preimage: the
393/// URL's canonical form, query-parameter names, a userinfo-present marker, and header names.
394///
395/// Uses [`split_url`] rather than [`crate::RedactedUrl`]'s `Debug` impl (module docs explain
396/// why a `Debug` rendering is unfit as a preimage source) — one parser, two independent
397/// renderings, so the fingerprint's own wire format is pinned by its own tests instead of
398/// inheriting `RedactedUrl`'s.
399fn push_url_and_headers(
400    pre: &mut Preimage,
401    url: &str,
402    headers: &std::collections::HashMap<String, String>,
403) {
404    match split_url(url) {
405        Some(parts) => {
406            pre.byte(URL_PARSED);
407            let canonical = format!("{}://{}{}", parts.scheme, parts.authority, parts.path);
408            pre.str(&canonical);
409
410            let query_names = match parts.tail {
411                Some((UrlTailKind::Query, query)) => parse_query_param_names(query),
412                Some((UrlTailKind::Fragment, _)) | None => Vec::new(),
413            };
414            push_query_param_names(pre, &query_names);
415
416            pre.byte(u8::from(parts.userinfo_present));
417        }
418        None => {
419            pre.byte(URL_UNPARSEABLE);
420        }
421    }
422
423    let mut header_names: Vec<String> = headers.keys().map(|h| h.to_ascii_lowercase()).collect();
424    header_names.sort_unstable();
425    pre.u64(header_names.len() as u64);
426    for name in &header_names {
427        pre.str(name);
428    }
429}
430
431/// A borrowed view over one discovered tool, shaped for [`ToolDigest::compute`] without coupling
432/// `mcp-execution-core` to `mcp-execution-introspector`'s `ToolInfo`.
433///
434/// # Examples
435///
436/// ```
437/// use mcp_execution_core::provenance::ToolDigestEntry;
438/// use serde_json::json;
439///
440/// let schema = json!({"type": "object"});
441/// let entry = ToolDigestEntry {
442///     name: "create_issue",
443///     description: "Creates a new issue",
444///     input_schema: &schema,
445///     output_schema: None,
446/// };
447/// assert_eq!(entry.name, "create_issue");
448/// ```
449#[derive(Debug, Clone, Copy)]
450pub struct ToolDigestEntry<'a> {
451    /// The tool's MCP name (the call identifier).
452    pub name: &'a str,
453    /// The tool's description, as reported by the server.
454    pub description: &'a str,
455    /// The tool's input JSON Schema.
456    pub input_schema: &'a serde_json::Value,
457    /// The tool's output JSON Schema, if the server reported one.
458    pub output_schema: Option<&'a serde_json::Value>,
459}
460
461/// A stable digest of the discovered tool list (names, descriptions, and input/output schemas)
462/// at generation time, recorded so a later comparison can detect that the server's tool surface
463/// changed.
464///
465/// Newtype over a 64-character lowercase-hex `String` — see [`ConfigFingerprint`]'s doc comment
466/// for why this is a newtype rather than a bare `String`, and for the `TryFrom<String>`/
467/// [`DigestFormatError`] validation its [`Deserialize`] is routed through.
468///
469/// # Examples
470///
471/// ```
472/// use mcp_execution_core::provenance::{ToolDigest, ToolDigestEntry};
473/// use serde_json::json;
474///
475/// let schema = json!({"type": "object"});
476/// let entries = vec![ToolDigestEntry {
477///     name: "create_issue",
478///     description: "Creates a new issue",
479///     input_schema: &schema,
480///     output_schema: None,
481/// }];
482///
483/// let digest = ToolDigest::compute(&entries);
484/// assert_eq!(digest.as_str().len(), 64);
485/// ```
486#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
487#[serde(try_from = "String")]
488pub struct ToolDigest(String);
489
490impl TryFrom<String> for ToolDigest {
491    type Error = DigestFormatError;
492
493    /// Delegates to `validate_digest_string` — the sole entry point [`Deserialize`] uses.
494    fn try_from(value: String) -> Result<Self, Self::Error> {
495        validate_digest_string(value).map(Self)
496    }
497}
498
499impl ToolDigest {
500    /// Computes an aggregate digest of `entries`, insensitive to input order (including two
501    /// tools sharing the same name in swapped order) but sensitive to any schema/name/
502    /// description edit, tool addition, or tool removal.
503    ///
504    /// Two-level construction: each entry is hashed on its own (domain tag, framed `name`,
505    /// framed `description`, then `input_schema`/`output_schema` via the recursive
506    /// `hash_value_into` walk — `output_schema`'s presence is tagged explicitly, so `None`
507    /// and `Some(Value::Null)` hash differently), the resulting 32-byte digests are sorted, and
508    /// the sorted sequence is hashed into the final aggregate. Sorting digests rather than tool
509    /// names gives a total order even for duplicate tool names, which name-sorting alone would
510    /// leave ambiguous.
511    ///
512    /// # Examples
513    ///
514    /// Reordering the input tool list does not change the digest:
515    ///
516    /// ```
517    /// use mcp_execution_core::provenance::{ToolDigest, ToolDigestEntry};
518    /// use serde_json::json;
519    ///
520    /// let schema_a = json!({"type": "object"});
521    /// let schema_b = json!({"type": "string"});
522    /// let a = ToolDigestEntry { name: "a", description: "", input_schema: &schema_a, output_schema: None };
523    /// let b = ToolDigestEntry { name: "b", description: "", input_schema: &schema_b, output_schema: None };
524    ///
525    /// assert_eq!(
526    ///     ToolDigest::compute(&[a, b]),
527    ///     ToolDigest::compute(&[b, a]),
528    /// );
529    /// ```
530    #[must_use]
531    pub fn compute(entries: &[ToolDigestEntry<'_>]) -> Self {
532        let mut entry_digests: Vec<[u8; 32]> = entries.iter().map(hash_tool_entry).collect();
533        entry_digests.sort_unstable();
534
535        let mut pre = Preimage::new();
536        pre.str(TOOL_DIGEST_DOMAIN);
537        pre.u64(entries.len() as u64);
538        for digest in entry_digests {
539            pre.raw_32(digest);
540        }
541
542        Self(hex_encode(pre.finish()))
543    }
544
545    /// Returns the digest as a 64-character lowercase-hex string slice.
546    ///
547    /// # Examples
548    ///
549    /// ```
550    /// use mcp_execution_core::provenance::{ToolDigest, ToolDigestEntry};
551    ///
552    /// let digest = ToolDigest::compute(&[] as &[ToolDigestEntry<'_>]);
553    /// assert!(!digest.as_str().is_empty());
554    /// ```
555    #[must_use]
556    pub fn as_str(&self) -> &str {
557        &self.0
558    }
559}
560
561/// Hashes a single [`ToolDigestEntry`] into its own 32-byte digest, before the aggregate
562/// [`ToolDigest::compute`] sorts and re-hashes every entry's digest together.
563fn hash_tool_entry(entry: &ToolDigestEntry<'_>) -> [u8; 32] {
564    let mut pre = Preimage::new();
565    pre.str(TOOL_ENTRY_DOMAIN);
566    pre.str(entry.name);
567    pre.str(entry.description);
568    hash_value_into(&mut pre, entry.input_schema);
569    match entry.output_schema {
570        Some(schema) => {
571            pre.byte(1);
572            hash_value_into(&mut pre, schema);
573        }
574        None => {
575            pre.byte(0);
576        }
577    }
578    pre.finish()
579}
580
581/// Recursively feeds a `serde_json::Value` into `pre`, sorting object keys at hash time so the
582/// result never depends on whether `serde_json/preserve_order` is enabled, and never calling
583/// the serializer at all.
584///
585/// Every variant is prefixed with a one-byte type tag ([`value_tag`]) so, for example, the
586/// string `"5"` and the number `5` cannot collide even though their framed bytes would
587/// otherwise be identical. Numbers are hashed via their `to_string()` bytes — `serde_json`'s
588/// `arbitrary_precision` feature is not enabled anywhere in this workspace, so `Value::Number`
589/// is the ordinary, deterministic enum. Array order is preserved (JSON arrays are ordered in
590/// general, e.g. `enum`/`prefixItems`), so reordering one is treated as drift; object key order
591/// is normalized, since JSON objects are unordered by the spec.
592fn hash_value_into(pre: &mut Preimage, value: &serde_json::Value) {
593    match value {
594        serde_json::Value::Null => {
595            pre.byte(value_tag::NULL);
596        }
597        serde_json::Value::Bool(b) => {
598            pre.byte(value_tag::BOOL);
599            pre.byte(u8::from(*b));
600        }
601        serde_json::Value::Number(n) => {
602            pre.byte(value_tag::NUMBER);
603            pre.str(&n.to_string());
604        }
605        serde_json::Value::String(s) => {
606            pre.byte(value_tag::STRING);
607            pre.str(s);
608        }
609        serde_json::Value::Array(items) => {
610            pre.byte(value_tag::ARRAY);
611            pre.u64(items.len() as u64);
612            for item in items {
613                hash_value_into(pre, item);
614            }
615        }
616        serde_json::Value::Object(map) => {
617            pre.byte(value_tag::OBJECT);
618            pre.u64(map.len() as u64);
619            let mut keys: Vec<&String> = map.keys().collect();
620            keys.sort_unstable();
621            for key in keys {
622                pre.str(key);
623                hash_value_into(pre, &map[key]);
624            }
625        }
626    }
627}
628
629/// Generation provenance recorded in [`crate::metadata::ServerMetadata`].
630///
631/// Records when a `_meta.json` sidecar was produced, and a fingerprint/digest pair sufficient
632/// for a later comparison to detect that the server changed.
633///
634/// Deliberately not wrapped in `Option` anywhere it's stored: a schema-version check rejects a
635/// pre-provenance (`schema_version: 1`) sidecar before a consumer ever constructs a
636/// [`crate::metadata::ServerMetadata`], so every value that exists already carries real
637/// provenance — see `mcp-execution-skill`'s parser.
638///
639/// # Examples
640///
641/// ```
642/// use mcp_execution_core::provenance::{GenerationProvenance, ToolDigestEntry};
643/// use mcp_execution_core::ServerConfig;
644///
645/// let config = ServerConfig::builder().command("docker".to_string()).build().unwrap();
646/// let provenance = GenerationProvenance::capture(&config, &[]);
647/// assert_eq!(provenance.tool_digest.as_str().len(), 64);
648/// ```
649#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
650pub struct GenerationProvenance {
651    /// Wall-clock time generation completed, from the `generate` command's own clock.
652    pub generated_at: DateTime<Utc>,
653    /// Fingerprint of the [`ServerConfig`] used to connect to and introspect the server.
654    pub config_fingerprint: ConfigFingerprint,
655    /// Digest of the discovered tool list at generation time.
656    pub tool_digest: ToolDigest,
657}
658
659impl GenerationProvenance {
660    /// Captures provenance for a `generate` run: stamps the current time and computes both the
661    /// config fingerprint and tool digest from the same inputs the run is generating from, so
662    /// the digest can never drift from the emitted files.
663    ///
664    /// # Examples
665    ///
666    /// ```
667    /// use mcp_execution_core::provenance::{GenerationProvenance, ToolDigestEntry};
668    /// use mcp_execution_core::ServerConfig;
669    ///
670    /// let config = ServerConfig::builder().command("docker".to_string()).build().unwrap();
671    /// let provenance = GenerationProvenance::capture(&config, &[]);
672    /// assert!(provenance.generated_at <= chrono::Utc::now());
673    /// ```
674    #[must_use]
675    pub fn capture(config: &ServerConfig, tools: &[ToolDigestEntry<'_>]) -> Self {
676        Self {
677            generated_at: Utc::now(),
678            config_fingerprint: ConfigFingerprint::compute(config),
679            tool_digest: ToolDigest::compute(tools),
680        }
681    }
682}
683
684#[cfg(test)]
685mod tests {
686    use super::*;
687    use std::collections::HashMap;
688
689    fn stdio_config(command: &str) -> ServerConfig {
690        ServerConfig::builder()
691            .command(command.to_string())
692            .build()
693            .unwrap()
694    }
695
696    // -- Config fingerprint: determinism --
697
698    /// The mandated test: two `HashMap`s built with *different insertion order* must yield
699    /// equal fingerprints. A same-process repeat call cannot catch a `HashMap`-iteration-order
700    /// bug, since a single process's iteration order for a given map is stable across repeated
701    /// reads of the *same* map instance.
702    #[test]
703    fn fingerprint_determinism_env_insertion_order() {
704        let mut env_a = HashMap::new();
705        env_a.insert("ALPHA".to_string(), "1".to_string());
706        env_a.insert("BETA".to_string(), "2".to_string());
707        env_a.insert("GAMMA".to_string(), "3".to_string());
708
709        let mut env_b = HashMap::new();
710        env_b.insert("GAMMA".to_string(), "3".to_string());
711        env_b.insert("ALPHA".to_string(), "1".to_string());
712        env_b.insert("BETA".to_string(), "2".to_string());
713
714        let a = ServerConfig::builder()
715            .command("docker".to_string())
716            .environment(env_a)
717            .build()
718            .unwrap();
719        let b = ServerConfig::builder()
720            .command("docker".to_string())
721            .environment(env_b)
722            .build()
723            .unwrap();
724
725        assert_eq!(
726            ConfigFingerprint::compute(&a),
727            ConfigFingerprint::compute(&b)
728        );
729    }
730
731    #[test]
732    fn fingerprint_determinism_header_insertion_order() {
733        let mut headers_a = HashMap::new();
734        headers_a.insert("X-One".to_string(), "1".to_string());
735        headers_a.insert("X-Two".to_string(), "2".to_string());
736
737        let mut headers_b = HashMap::new();
738        headers_b.insert("X-Two".to_string(), "2".to_string());
739        headers_b.insert("X-One".to_string(), "1".to_string());
740
741        let a = ServerConfig::builder()
742            .http_transport("https://api.example.com/mcp".to_string())
743            .headers(headers_a)
744            .build()
745            .unwrap();
746        let b = ServerConfig::builder()
747            .http_transport("https://api.example.com/mcp".to_string())
748            .headers(headers_b)
749            .build()
750            .unwrap();
751
752        assert_eq!(
753            ConfigFingerprint::compute(&a),
754            ConfigFingerprint::compute(&b)
755        );
756    }
757
758    // -- Config fingerprint: security guarantee (positive assertion) --
759
760    #[test]
761    fn fingerprint_equal_when_only_env_values_differ() {
762        let a = ServerConfig::builder()
763            .command("docker".to_string())
764            .env("TOKEN".to_string(), "secret-a".to_string())
765            .build()
766            .unwrap();
767        let b = ServerConfig::builder()
768            .command("docker".to_string())
769            .env("TOKEN".to_string(), "secret-b".to_string())
770            .build()
771            .unwrap();
772        assert_eq!(
773            ConfigFingerprint::compute(&a),
774            ConfigFingerprint::compute(&b)
775        );
776    }
777
778    #[test]
779    fn fingerprint_equal_when_only_header_values_differ() {
780        let a = ServerConfig::builder()
781            .http_transport("https://api.example.com/mcp".to_string())
782            .header("Authorization".to_string(), "Bearer a".to_string())
783            .build()
784            .unwrap();
785        let b = ServerConfig::builder()
786            .http_transport("https://api.example.com/mcp".to_string())
787            .header("Authorization".to_string(), "Bearer b".to_string())
788            .build()
789            .unwrap();
790        assert_eq!(
791            ConfigFingerprint::compute(&a),
792            ConfigFingerprint::compute(&b)
793        );
794    }
795
796    #[test]
797    fn fingerprint_equal_when_only_arg_values_differ() {
798        let a = ServerConfig::builder()
799            .command("npx".to_string())
800            .arg("pkg-a".to_string())
801            .build()
802            .unwrap();
803        let b = ServerConfig::builder()
804            .command("npx".to_string())
805            .arg("pkg-b".to_string())
806            .build()
807            .unwrap();
808        assert_eq!(
809            ConfigFingerprint::compute(&a),
810            ConfigFingerprint::compute(&b)
811        );
812    }
813
814    #[test]
815    fn fingerprint_equal_when_only_query_param_values_differ() {
816        let a = ServerConfig::builder()
817            .http_transport("https://api.example.com/mcp?tenant=alpha".to_string())
818            .build()
819            .unwrap();
820        let b = ServerConfig::builder()
821            .http_transport("https://api.example.com/mcp?tenant=beta".to_string())
822            .build()
823            .unwrap();
824        assert_eq!(
825            ConfigFingerprint::compute(&a),
826            ConfigFingerprint::compute(&b)
827        );
828    }
829
830    #[test]
831    fn fingerprint_equal_when_only_userinfo_differs() {
832        let a = ServerConfig::builder()
833            .http_transport("https://user:pass-a@api.example.com/mcp".to_string())
834            .build()
835            .unwrap();
836        let b = ServerConfig::builder()
837            .http_transport("https://user:pass-b@api.example.com/mcp".to_string())
838            .build()
839            .unwrap();
840        assert_eq!(
841            ConfigFingerprint::compute(&a),
842            ConfigFingerprint::compute(&b)
843        );
844    }
845
846    // -- Config fingerprint: sensitivity --
847
848    #[test]
849    fn fingerprint_differs_on_command() {
850        assert_ne!(
851            ConfigFingerprint::compute(&stdio_config("docker")),
852            ConfigFingerprint::compute(&stdio_config("npx")),
853        );
854    }
855
856    #[test]
857    fn fingerprint_differs_on_cwd() {
858        let a = ServerConfig::builder()
859            .command("docker".to_string())
860            .cwd("/tmp/a".into())
861            .build()
862            .unwrap();
863        let b = ServerConfig::builder()
864            .command("docker".to_string())
865            .cwd("/tmp/b".into())
866            .build()
867            .unwrap();
868        assert_ne!(
869            ConfigFingerprint::compute(&a),
870            ConfigFingerprint::compute(&b)
871        );
872    }
873
874    #[test]
875    fn fingerprint_differs_on_arg_count() {
876        let a = ServerConfig::builder()
877            .command("docker".to_string())
878            .arg("run".to_string())
879            .build()
880            .unwrap();
881        let b = ServerConfig::builder()
882            .command("docker".to_string())
883            .arg("run".to_string())
884            .arg("--rm".to_string())
885            .build()
886            .unwrap();
887        assert_ne!(
888            ConfigFingerprint::compute(&a),
889            ConfigFingerprint::compute(&b)
890        );
891    }
892
893    #[test]
894    fn fingerprint_differs_on_env_key() {
895        let a = ServerConfig::builder()
896            .command("docker".to_string())
897            .env("ALPHA".to_string(), "1".to_string())
898            .build()
899            .unwrap();
900        let b = ServerConfig::builder()
901            .command("docker".to_string())
902            .env("BETA".to_string(), "1".to_string())
903            .build()
904            .unwrap();
905        assert_ne!(
906            ConfigFingerprint::compute(&a),
907            ConfigFingerprint::compute(&b)
908        );
909    }
910
911    #[test]
912    fn fingerprint_differs_on_header_name() {
913        let a = ServerConfig::builder()
914            .http_transport("https://api.example.com/mcp".to_string())
915            .header("X-One".to_string(), "1".to_string())
916            .build()
917            .unwrap();
918        let b = ServerConfig::builder()
919            .http_transport("https://api.example.com/mcp".to_string())
920            .header("X-Two".to_string(), "1".to_string())
921            .build()
922            .unwrap();
923        assert_ne!(
924            ConfigFingerprint::compute(&a),
925            ConfigFingerprint::compute(&b)
926        );
927    }
928
929    #[test]
930    fn fingerprint_header_name_case_does_not_change_it() {
931        let a = ServerConfig::builder()
932            .http_transport("https://api.example.com/mcp".to_string())
933            .header("Authorization".to_string(), "1".to_string())
934            .build()
935            .unwrap();
936        let b = ServerConfig::builder()
937            .http_transport("https://api.example.com/mcp".to_string())
938            .header("authorization".to_string(), "1".to_string())
939            .build()
940            .unwrap();
941        assert_eq!(
942            ConfigFingerprint::compute(&a),
943            ConfigFingerprint::compute(&b)
944        );
945    }
946
947    #[test]
948    fn fingerprint_differs_on_scheme() {
949        let a = ServerConfig::builder()
950            .http_transport("https://api.example.com/mcp".to_string())
951            .build()
952            .unwrap();
953        let b = ServerConfig::builder()
954            .sse_transport("https://api.example.com/mcp".to_string())
955            .build()
956            .unwrap();
957        assert_ne!(
958            ConfigFingerprint::compute(&a),
959            ConfigFingerprint::compute(&b)
960        );
961    }
962
963    /// Unlike `fingerprint_differs_on_scheme` above (which varies the *transport discriminant*,
964    /// http vs sse, while holding the URL's own scheme fixed at `https://` in both branches),
965    /// this isolates the URL's own scheme component — `http://` vs `https://` on an otherwise
966    /// identical `http_transport` config. `ServerConfig` accepts both schemes, so this case is
967    /// reachable and must be covered independently.
968    #[test]
969    fn fingerprint_differs_on_url_scheme_itself() {
970        let a = ServerConfig::builder()
971            .http_transport("http://api.example.com/mcp".to_string())
972            .build()
973            .unwrap();
974        let b = ServerConfig::builder()
975            .http_transport("https://api.example.com/mcp".to_string())
976            .build()
977            .unwrap();
978        assert_ne!(
979            ConfigFingerprint::compute(&a),
980            ConfigFingerprint::compute(&b)
981        );
982    }
983
984    #[test]
985    fn fingerprint_differs_on_authority() {
986        let a = ServerConfig::builder()
987            .http_transport("https://api-a.example.com/mcp".to_string())
988            .build()
989            .unwrap();
990        let b = ServerConfig::builder()
991            .http_transport("https://api-b.example.com/mcp".to_string())
992            .build()
993            .unwrap();
994        assert_ne!(
995            ConfigFingerprint::compute(&a),
996            ConfigFingerprint::compute(&b)
997        );
998    }
999
1000    #[test]
1001    fn fingerprint_differs_on_path() {
1002        let a = ServerConfig::builder()
1003            .http_transport("https://api.example.com/mcp-a".to_string())
1004            .build()
1005            .unwrap();
1006        let b = ServerConfig::builder()
1007            .http_transport("https://api.example.com/mcp-b".to_string())
1008            .build()
1009            .unwrap();
1010        assert_ne!(
1011            ConfigFingerprint::compute(&a),
1012            ConfigFingerprint::compute(&b)
1013        );
1014    }
1015
1016    #[test]
1017    fn fingerprint_differs_on_query_param_name() {
1018        let a = ServerConfig::builder()
1019            .http_transport("https://api.example.com/mcp?alpha=1".to_string())
1020            .build()
1021            .unwrap();
1022        let b = ServerConfig::builder()
1023            .http_transport("https://api.example.com/mcp?beta=1".to_string())
1024            .build()
1025            .unwrap();
1026        assert_ne!(
1027            ConfigFingerprint::compute(&a),
1028            ConfigFingerprint::compute(&b)
1029        );
1030    }
1031
1032    // -- URL splitter: exact-string pin + `<bare>`/`<unparseable>` marker cases --
1033
1034    /// Pins the fingerprint's canonical URL rendering as a wire-format contract: any change to
1035    /// this preimage requires a `METADATA_SCHEMA_VERSION` bump.
1036    #[test]
1037    fn url_canonical_form_exact_string() {
1038        let parts = split_url("https://user:pass@api.example.com:8443/mcp/v1?a=1&b=2#frag")
1039            .expect("parses");
1040        let canonical = format!("{}://{}{}", parts.scheme, parts.authority, parts.path);
1041        assert_eq!(canonical, "https://api.example.com:8443/mcp/v1");
1042        assert!(parts.userinfo_present);
1043    }
1044
1045    #[test]
1046    fn fingerprint_bare_query_param_uses_marker_not_text() {
1047        let bare = ServerConfig::builder()
1048            .http_transport("https://api.example.com/mcp?sk-live-token".to_string())
1049            .build()
1050            .unwrap();
1051        let named = ServerConfig::builder()
1052            .http_transport("https://api.example.com/mcp?<bare>=1".to_string())
1053            .build()
1054            .unwrap();
1055
1056        // The bare marker's tag byte, not literal text, means an actual bare secret-shaped
1057        // token and a parameter literally named `<bare>` must fingerprint differently (N3).
1058        assert_ne!(
1059            ConfigFingerprint::compute(&bare),
1060            ConfigFingerprint::compute(&named),
1061        );
1062    }
1063
1064    /// Regression for a critic-found collision: `split_url`'s `Query` tail runs verbatim to the
1065    /// end of the URL (by design — see its own doc comment), so it can contain a later `#`.
1066    /// Without truncating at that `#` before splitting on `&`, `?a=1&b=2` and `?a=1#&b=2` parsed
1067    /// to the identical `[Named("a"), Named("b")]` list and therefore the identical fingerprint,
1068    /// despite being different query strings (a fragment-only edit registering as false-positive
1069    /// query drift, and a real second query parameter hiding behind `#` as a false negative —
1070    /// the direction NFR-004 forbids). `parse_query_param_names` must now stop at the first `#`.
1071    #[test]
1072    fn fingerprint_query_param_names_stop_at_fragment_boundary() {
1073        let two_query_params = ServerConfig::builder()
1074            .http_transport("https://h.example.com/p?a=1&b=2".to_string())
1075            .build()
1076            .unwrap();
1077        let one_query_param_plus_fragment = ServerConfig::builder()
1078            .http_transport("https://h.example.com/p?a=1#&b=2".to_string())
1079            .build()
1080            .unwrap();
1081
1082        assert_ne!(
1083            ConfigFingerprint::compute(&two_query_params),
1084            ConfigFingerprint::compute(&one_query_param_plus_fragment),
1085        );
1086
1087        // The fragment-bearing config must fingerprint the same as one with no `#&b=2` tail at
1088        // all — proving the fragment text is excluded entirely, not merely hashed differently.
1089        let one_query_param_no_fragment = ServerConfig::builder()
1090            .http_transport("https://h.example.com/p?a=1".to_string())
1091            .build()
1092            .unwrap();
1093        assert_eq!(
1094            ConfigFingerprint::compute(&one_query_param_plus_fragment),
1095            ConfigFingerprint::compute(&one_query_param_no_fragment),
1096        );
1097    }
1098
1099    #[test]
1100    fn fingerprint_unparseable_url_uses_marker() {
1101        // `ServerConfig::build` only enforces an `http`/`https` scheme prefix, so the only way
1102        // to reach `split_url`'s `None` case through a validated config is the userinfo
1103        // ambiguity it documents: an unencoded '/' inside the password moves the authority
1104        // terminator into the middle of the credentials.
1105        let config = ServerConfig::builder()
1106            .http_transport("https://user:pa/ssw0rd@api.example.com/mcp".to_string())
1107            .build()
1108            .unwrap();
1109        // Must not panic, and must produce a stable digest distinct from a parseable URL.
1110        let fingerprint = ConfigFingerprint::compute(&config);
1111        assert_eq!(fingerprint.as_str().len(), 64);
1112
1113        let parseable = ServerConfig::builder()
1114            .http_transport("https://api.example.com/mcp".to_string())
1115            .build()
1116            .unwrap();
1117        assert_ne!(fingerprint, ConfigFingerprint::compute(&parseable));
1118    }
1119
1120    // -- Tool digest --
1121
1122    fn entry<'a>(name: &'a str, schema: &'a serde_json::Value) -> ToolDigestEntry<'a> {
1123        ToolDigestEntry {
1124            name,
1125            description: "desc",
1126            input_schema: schema,
1127            output_schema: None,
1128        }
1129    }
1130
1131    #[test]
1132    fn tool_digest_equal_under_reordered_input() {
1133        let schema_a = serde_json::json!({"type": "object"});
1134        let schema_b = serde_json::json!({"type": "string"});
1135        let a = entry("a", &schema_a);
1136        let b = entry("b", &schema_b);
1137
1138        assert_eq!(ToolDigest::compute(&[a, b]), ToolDigest::compute(&[b, a]),);
1139    }
1140
1141    #[test]
1142    fn tool_digest_equal_for_duplicate_names_swapped_order() {
1143        let schema_1 = serde_json::json!({"variant": 1});
1144        let schema_2 = serde_json::json!({"variant": 2});
1145        let first = ToolDigestEntry {
1146            name: "dup",
1147            description: "",
1148            input_schema: &schema_1,
1149            output_schema: None,
1150        };
1151        let second = ToolDigestEntry {
1152            name: "dup",
1153            description: "",
1154            input_schema: &schema_2,
1155            output_schema: None,
1156        };
1157
1158        assert_eq!(
1159            ToolDigest::compute(&[first, second]),
1160            ToolDigest::compute(&[second, first]),
1161        );
1162    }
1163
1164    #[test]
1165    fn tool_digest_differs_on_schema_edit() {
1166        let schema_a = serde_json::json!({"type": "object"});
1167        let schema_b = serde_json::json!({"type": "string"});
1168        assert_ne!(
1169            ToolDigest::compute(&[entry("a", &schema_a)]),
1170            ToolDigest::compute(&[entry("a", &schema_b)]),
1171        );
1172    }
1173
1174    #[test]
1175    fn tool_digest_differs_on_tool_added() {
1176        let schema = serde_json::json!({"type": "object"});
1177        assert_ne!(
1178            ToolDigest::compute(&[entry("a", &schema)]),
1179            ToolDigest::compute(&[entry("a", &schema), entry("b", &schema)]),
1180        );
1181    }
1182
1183    #[test]
1184    fn tool_digest_differs_on_tool_removed() {
1185        let schema = serde_json::json!({"type": "object"});
1186        assert_ne!(
1187            ToolDigest::compute(&[entry("a", &schema), entry("b", &schema)]),
1188            ToolDigest::compute(&[entry("a", &schema)]),
1189        );
1190    }
1191
1192    #[test]
1193    fn tool_digest_equal_for_nested_object_key_reordering() {
1194        let schema_a = serde_json::json!({
1195            "type": "object",
1196            "properties": {"b": {"type": "string"}, "a": {"type": "number"}}
1197        });
1198        let schema_b = serde_json::json!({
1199            "properties": {"a": {"type": "number"}, "b": {"type": "string"}},
1200            "type": "object"
1201        });
1202
1203        assert_eq!(
1204            ToolDigest::compute(&[entry("t", &schema_a)]),
1205            ToolDigest::compute(&[entry("t", &schema_b)]),
1206        );
1207    }
1208
1209    /// N1: `output_schema`'s `None` and `Some(Value::Null)` must hash differently — the
1210    /// presence byte, not the value walk alone, is what distinguishes them.
1211    #[test]
1212    fn tool_digest_distinguishes_absent_output_schema_from_null_output_schema() {
1213        let input_schema = serde_json::json!({"type": "object"});
1214        let null_schema = serde_json::Value::Null;
1215
1216        let without = ToolDigestEntry {
1217            name: "t",
1218            description: "",
1219            input_schema: &input_schema,
1220            output_schema: None,
1221        };
1222        let with_null = ToolDigestEntry {
1223            name: "t",
1224            description: "",
1225            input_schema: &input_schema,
1226            output_schema: Some(&null_schema),
1227        };
1228
1229        assert_ne!(
1230            ToolDigest::compute(&[without]),
1231            ToolDigest::compute(&[with_null]),
1232        );
1233    }
1234
1235    #[test]
1236    fn generation_provenance_capture_stamps_current_time() {
1237        let config = stdio_config("docker");
1238        let before = Utc::now();
1239        let provenance = GenerationProvenance::capture(&config, &[]);
1240        let after = Utc::now();
1241        assert!(provenance.generated_at >= before && provenance.generated_at <= after);
1242    }
1243
1244    #[test]
1245    fn provenance_types_are_send_sync() {
1246        fn assert_send<T: Send>() {}
1247        fn assert_sync<T: Sync>() {}
1248        assert_send::<GenerationProvenance>();
1249        assert_sync::<GenerationProvenance>();
1250        assert_send::<ConfigFingerprint>();
1251        assert_sync::<ConfigFingerprint>();
1252        assert_send::<ToolDigest>();
1253        assert_sync::<ToolDigest>();
1254    }
1255
1256    // -- Digest format validation (ConfigFingerprint/ToolDigest TryFrom<String>) --
1257
1258    #[test]
1259    fn digest_try_from_accepts_valid_lowercase_hex() {
1260        let valid = "a".repeat(64);
1261        assert!(ConfigFingerprint::try_from(valid.clone()).is_ok());
1262        assert!(ToolDigest::try_from(valid).is_ok());
1263    }
1264
1265    #[test]
1266    fn digest_try_from_rejects_wrong_length() {
1267        assert!(ConfigFingerprint::try_from("a".repeat(63)).is_err());
1268        assert!(ConfigFingerprint::try_from("a".repeat(65)).is_err());
1269        assert!(ConfigFingerprint::try_from(String::new()).is_err());
1270    }
1271
1272    #[test]
1273    fn digest_try_from_rejects_uppercase_hex() {
1274        let uppercase = "A".repeat(64);
1275        assert!(ConfigFingerprint::try_from(uppercase).is_err());
1276    }
1277
1278    #[test]
1279    fn digest_try_from_rejects_non_hex_characters() {
1280        let mut candidate = "a".repeat(63);
1281        candidate.push('g');
1282        assert!(ConfigFingerprint::try_from(candidate).is_err());
1283    }
1284
1285    /// A `ConfigFingerprint`/`ToolDigest` deserialized from JSON goes through the same
1286    /// validation as direct `TryFrom<String>` construction — there is no bypass via `serde`.
1287    #[test]
1288    fn digest_deserialize_rejects_malformed_value() {
1289        let result: Result<ConfigFingerprint, _> = serde_json::from_str(r#""not-a-digest""#);
1290        assert!(result.is_err());
1291    }
1292
1293    #[test]
1294    fn digest_deserialize_accepts_valid_value() {
1295        let valid = "b".repeat(64);
1296        let json = serde_json::to_string(&valid).unwrap();
1297        let fingerprint: ConfigFingerprint = serde_json::from_str(&json).unwrap();
1298        assert_eq!(fingerprint.as_str(), valid);
1299    }
1300
1301    #[test]
1302    fn digest_format_error_sanitizes_rejected_value() {
1303        let err = ConfigFingerprint::try_from("bad&value".to_string()).unwrap_err();
1304        let message = err.to_string();
1305        assert!(message.contains("bad&amp;value"));
1306        assert!(!message.contains("bad&value"));
1307    }
1308}