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&value"));
1306 assert!(!message.contains("bad&value"));
1307 }
1308}