use crate::redact::{UrlTailKind, split_url};
use crate::server_config::{ServerConfig, Transport};
use crate::untrusted::sanitize_untrusted_inline;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use thiserror::Error;
const CONFIG_FINGERPRINT_DOMAIN: &str = "mcp-execution:config-fingerprint:v1";
const TOOL_ENTRY_DOMAIN: &str = "mcp-execution:tool-entry:v1";
const TOOL_DIGEST_DOMAIN: &str = "mcp-execution:tool-digest:v1";
const URL_PARSED: u8 = 0;
const URL_UNPARSEABLE: u8 = 1;
const QUERY_PARAM_NAMED: u8 = 0;
const QUERY_PARAM_BARE: u8 = 1;
mod value_tag {
pub(super) const NULL: u8 = 0;
pub(super) const BOOL: u8 = 1;
pub(super) const NUMBER: u8 = 2;
pub(super) const STRING: u8 = 3;
pub(super) const ARRAY: u8 = 4;
pub(super) const OBJECT: u8 = 5;
}
struct Preimage(Vec<u8>);
impl Preimage {
const fn new() -> Self {
Self(Vec::new())
}
fn bytes(&mut self, bytes: &[u8]) -> &mut Self {
let len = bytes.len() as u64;
self.0.extend_from_slice(&len.to_be_bytes());
self.0.extend_from_slice(bytes);
self
}
fn str(&mut self, s: &str) -> &mut Self {
self.bytes(s.as_bytes())
}
fn u64(&mut self, n: u64) -> &mut Self {
self.0.extend_from_slice(&n.to_be_bytes());
self
}
fn byte(&mut self, b: u8) -> &mut Self {
self.0.push(b);
self
}
fn raw_32(&mut self, bytes: [u8; 32]) -> &mut Self {
self.0.extend_from_slice(&bytes);
self
}
fn finish(&self) -> [u8; 32] {
Sha256::digest(&self.0).into()
}
}
fn hex_encode(bytes: [u8; 32]) -> String {
use std::fmt::Write as _;
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
let _ = write!(s, "{b:02x}");
}
s
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
#[error("invalid digest {value:?}: must be exactly 64 lowercase hex characters")]
pub struct DigestFormatError {
value: String,
}
fn validate_digest_string(candidate: String) -> Result<String, DigestFormatError> {
let is_valid = candidate.len() == 64
&& candidate
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
if is_valid {
Ok(candidate)
} else {
Err(DigestFormatError {
value: sanitize_untrusted_inline(&candidate),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "String")]
pub struct ConfigFingerprint(String);
impl TryFrom<String> for ConfigFingerprint {
type Error = DigestFormatError;
fn try_from(value: String) -> Result<Self, Self::Error> {
validate_digest_string(value).map(Self)
}
}
impl ConfigFingerprint {
#[must_use]
pub fn compute(config: &ServerConfig) -> Self {
let mut pre = Preimage::new();
pre.str(CONFIG_FINGERPRINT_DOMAIN);
match config.transport() {
Transport::Stdio {
command,
args,
env,
cwd,
} => {
pre.str("stdio");
pre.str(command);
match cwd {
Some(path) => {
pre.byte(1);
pre.bytes(path.as_os_str().as_encoded_bytes());
}
None => {
pre.byte(0);
}
}
pre.u64(args.len() as u64);
let mut names: Vec<&str> = env.keys().map(String::as_str).collect();
names.sort_unstable();
pre.u64(names.len() as u64);
for name in names {
pre.str(name);
}
}
Transport::Http { url, headers } => {
pre.str("http");
push_url_and_headers(&mut pre, url, headers);
}
Transport::Sse { url, headers } => {
pre.str("sse");
push_url_and_headers(&mut pre, url, headers);
}
}
Self(hex_encode(pre.finish()))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum QueryParamName<'a> {
Bare,
Named(&'a str),
}
fn parse_query_param_names(query: &str) -> Vec<QueryParamName<'_>> {
let query = query.find('#').map_or(query, |pos| &query[..pos]);
let mut names: Vec<QueryParamName<'_>> = query
.split('&')
.filter(|segment| !segment.is_empty())
.map(|segment| match segment.split_once('=') {
Some((name, _value)) => QueryParamName::Named(name),
None => QueryParamName::Bare,
})
.collect();
names.sort_unstable();
names.dedup();
names
}
fn push_query_param_names(pre: &mut Preimage, names: &[QueryParamName<'_>]) {
pre.u64(names.len() as u64);
for name in names {
match name {
QueryParamName::Named(n) => {
pre.byte(QUERY_PARAM_NAMED);
pre.str(n);
}
QueryParamName::Bare => {
pre.byte(QUERY_PARAM_BARE);
}
}
}
}
fn push_url_and_headers(
pre: &mut Preimage,
url: &str,
headers: &std::collections::HashMap<String, String>,
) {
match split_url(url) {
Some(parts) => {
pre.byte(URL_PARSED);
let canonical = format!("{}://{}{}", parts.scheme, parts.authority, parts.path);
pre.str(&canonical);
let query_names = match parts.tail {
Some((UrlTailKind::Query, query)) => parse_query_param_names(query),
Some((UrlTailKind::Fragment, _)) | None => Vec::new(),
};
push_query_param_names(pre, &query_names);
pre.byte(u8::from(parts.userinfo_present));
}
None => {
pre.byte(URL_UNPARSEABLE);
}
}
let mut header_names: Vec<String> = headers.keys().map(|h| h.to_ascii_lowercase()).collect();
header_names.sort_unstable();
pre.u64(header_names.len() as u64);
for name in &header_names {
pre.str(name);
}
}
#[derive(Debug, Clone, Copy)]
pub struct ToolDigestEntry<'a> {
pub name: &'a str,
pub description: &'a str,
pub input_schema: &'a serde_json::Value,
pub output_schema: Option<&'a serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "String")]
pub struct ToolDigest(String);
impl TryFrom<String> for ToolDigest {
type Error = DigestFormatError;
fn try_from(value: String) -> Result<Self, Self::Error> {
validate_digest_string(value).map(Self)
}
}
impl ToolDigest {
#[must_use]
pub fn compute(entries: &[ToolDigestEntry<'_>]) -> Self {
let mut entry_digests: Vec<[u8; 32]> = entries.iter().map(hash_tool_entry).collect();
entry_digests.sort_unstable();
let mut pre = Preimage::new();
pre.str(TOOL_DIGEST_DOMAIN);
pre.u64(entries.len() as u64);
for digest in entry_digests {
pre.raw_32(digest);
}
Self(hex_encode(pre.finish()))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
fn hash_tool_entry(entry: &ToolDigestEntry<'_>) -> [u8; 32] {
let mut pre = Preimage::new();
pre.str(TOOL_ENTRY_DOMAIN);
pre.str(entry.name);
pre.str(entry.description);
hash_value_into(&mut pre, entry.input_schema);
match entry.output_schema {
Some(schema) => {
pre.byte(1);
hash_value_into(&mut pre, schema);
}
None => {
pre.byte(0);
}
}
pre.finish()
}
fn hash_value_into(pre: &mut Preimage, value: &serde_json::Value) {
match value {
serde_json::Value::Null => {
pre.byte(value_tag::NULL);
}
serde_json::Value::Bool(b) => {
pre.byte(value_tag::BOOL);
pre.byte(u8::from(*b));
}
serde_json::Value::Number(n) => {
pre.byte(value_tag::NUMBER);
pre.str(&n.to_string());
}
serde_json::Value::String(s) => {
pre.byte(value_tag::STRING);
pre.str(s);
}
serde_json::Value::Array(items) => {
pre.byte(value_tag::ARRAY);
pre.u64(items.len() as u64);
for item in items {
hash_value_into(pre, item);
}
}
serde_json::Value::Object(map) => {
pre.byte(value_tag::OBJECT);
pre.u64(map.len() as u64);
let mut keys: Vec<&String> = map.keys().collect();
keys.sort_unstable();
for key in keys {
pre.str(key);
hash_value_into(pre, &map[key]);
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GenerationProvenance {
pub generated_at: DateTime<Utc>,
pub config_fingerprint: ConfigFingerprint,
pub tool_digest: ToolDigest,
}
impl GenerationProvenance {
#[must_use]
pub fn capture(config: &ServerConfig, tools: &[ToolDigestEntry<'_>]) -> Self {
Self {
generated_at: Utc::now(),
config_fingerprint: ConfigFingerprint::compute(config),
tool_digest: ToolDigest::compute(tools),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn stdio_config(command: &str) -> ServerConfig {
ServerConfig::builder()
.command(command.to_string())
.build()
.unwrap()
}
#[test]
fn fingerprint_determinism_env_insertion_order() {
let mut env_a = HashMap::new();
env_a.insert("ALPHA".to_string(), "1".to_string());
env_a.insert("BETA".to_string(), "2".to_string());
env_a.insert("GAMMA".to_string(), "3".to_string());
let mut env_b = HashMap::new();
env_b.insert("GAMMA".to_string(), "3".to_string());
env_b.insert("ALPHA".to_string(), "1".to_string());
env_b.insert("BETA".to_string(), "2".to_string());
let a = ServerConfig::builder()
.command("docker".to_string())
.environment(env_a)
.build()
.unwrap();
let b = ServerConfig::builder()
.command("docker".to_string())
.environment(env_b)
.build()
.unwrap();
assert_eq!(
ConfigFingerprint::compute(&a),
ConfigFingerprint::compute(&b)
);
}
#[test]
fn fingerprint_determinism_header_insertion_order() {
let mut headers_a = HashMap::new();
headers_a.insert("X-One".to_string(), "1".to_string());
headers_a.insert("X-Two".to_string(), "2".to_string());
let mut headers_b = HashMap::new();
headers_b.insert("X-Two".to_string(), "2".to_string());
headers_b.insert("X-One".to_string(), "1".to_string());
let a = ServerConfig::builder()
.http_transport("https://api.example.com/mcp".to_string())
.headers(headers_a)
.build()
.unwrap();
let b = ServerConfig::builder()
.http_transport("https://api.example.com/mcp".to_string())
.headers(headers_b)
.build()
.unwrap();
assert_eq!(
ConfigFingerprint::compute(&a),
ConfigFingerprint::compute(&b)
);
}
#[test]
fn fingerprint_equal_when_only_env_values_differ() {
let a = ServerConfig::builder()
.command("docker".to_string())
.env("TOKEN".to_string(), "secret-a".to_string())
.build()
.unwrap();
let b = ServerConfig::builder()
.command("docker".to_string())
.env("TOKEN".to_string(), "secret-b".to_string())
.build()
.unwrap();
assert_eq!(
ConfigFingerprint::compute(&a),
ConfigFingerprint::compute(&b)
);
}
#[test]
fn fingerprint_equal_when_only_header_values_differ() {
let a = ServerConfig::builder()
.http_transport("https://api.example.com/mcp".to_string())
.header("Authorization".to_string(), "Bearer a".to_string())
.build()
.unwrap();
let b = ServerConfig::builder()
.http_transport("https://api.example.com/mcp".to_string())
.header("Authorization".to_string(), "Bearer b".to_string())
.build()
.unwrap();
assert_eq!(
ConfigFingerprint::compute(&a),
ConfigFingerprint::compute(&b)
);
}
#[test]
fn fingerprint_equal_when_only_arg_values_differ() {
let a = ServerConfig::builder()
.command("npx".to_string())
.arg("pkg-a".to_string())
.build()
.unwrap();
let b = ServerConfig::builder()
.command("npx".to_string())
.arg("pkg-b".to_string())
.build()
.unwrap();
assert_eq!(
ConfigFingerprint::compute(&a),
ConfigFingerprint::compute(&b)
);
}
#[test]
fn fingerprint_equal_when_only_query_param_values_differ() {
let a = ServerConfig::builder()
.http_transport("https://api.example.com/mcp?tenant=alpha".to_string())
.build()
.unwrap();
let b = ServerConfig::builder()
.http_transport("https://api.example.com/mcp?tenant=beta".to_string())
.build()
.unwrap();
assert_eq!(
ConfigFingerprint::compute(&a),
ConfigFingerprint::compute(&b)
);
}
#[test]
fn fingerprint_equal_when_only_userinfo_differs() {
let a = ServerConfig::builder()
.http_transport("https://user:pass-a@api.example.com/mcp".to_string())
.build()
.unwrap();
let b = ServerConfig::builder()
.http_transport("https://user:pass-b@api.example.com/mcp".to_string())
.build()
.unwrap();
assert_eq!(
ConfigFingerprint::compute(&a),
ConfigFingerprint::compute(&b)
);
}
#[test]
fn fingerprint_differs_on_command() {
assert_ne!(
ConfigFingerprint::compute(&stdio_config("docker")),
ConfigFingerprint::compute(&stdio_config("npx")),
);
}
#[test]
fn fingerprint_differs_on_cwd() {
let a = ServerConfig::builder()
.command("docker".to_string())
.cwd("/tmp/a".into())
.build()
.unwrap();
let b = ServerConfig::builder()
.command("docker".to_string())
.cwd("/tmp/b".into())
.build()
.unwrap();
assert_ne!(
ConfigFingerprint::compute(&a),
ConfigFingerprint::compute(&b)
);
}
#[test]
fn fingerprint_differs_on_arg_count() {
let a = ServerConfig::builder()
.command("docker".to_string())
.arg("run".to_string())
.build()
.unwrap();
let b = ServerConfig::builder()
.command("docker".to_string())
.arg("run".to_string())
.arg("--rm".to_string())
.build()
.unwrap();
assert_ne!(
ConfigFingerprint::compute(&a),
ConfigFingerprint::compute(&b)
);
}
#[test]
fn fingerprint_differs_on_env_key() {
let a = ServerConfig::builder()
.command("docker".to_string())
.env("ALPHA".to_string(), "1".to_string())
.build()
.unwrap();
let b = ServerConfig::builder()
.command("docker".to_string())
.env("BETA".to_string(), "1".to_string())
.build()
.unwrap();
assert_ne!(
ConfigFingerprint::compute(&a),
ConfigFingerprint::compute(&b)
);
}
#[test]
fn fingerprint_differs_on_header_name() {
let a = ServerConfig::builder()
.http_transport("https://api.example.com/mcp".to_string())
.header("X-One".to_string(), "1".to_string())
.build()
.unwrap();
let b = ServerConfig::builder()
.http_transport("https://api.example.com/mcp".to_string())
.header("X-Two".to_string(), "1".to_string())
.build()
.unwrap();
assert_ne!(
ConfigFingerprint::compute(&a),
ConfigFingerprint::compute(&b)
);
}
#[test]
fn fingerprint_header_name_case_does_not_change_it() {
let a = ServerConfig::builder()
.http_transport("https://api.example.com/mcp".to_string())
.header("Authorization".to_string(), "1".to_string())
.build()
.unwrap();
let b = ServerConfig::builder()
.http_transport("https://api.example.com/mcp".to_string())
.header("authorization".to_string(), "1".to_string())
.build()
.unwrap();
assert_eq!(
ConfigFingerprint::compute(&a),
ConfigFingerprint::compute(&b)
);
}
#[test]
fn fingerprint_differs_on_scheme() {
let a = ServerConfig::builder()
.http_transport("https://api.example.com/mcp".to_string())
.build()
.unwrap();
let b = ServerConfig::builder()
.sse_transport("https://api.example.com/mcp".to_string())
.build()
.unwrap();
assert_ne!(
ConfigFingerprint::compute(&a),
ConfigFingerprint::compute(&b)
);
}
#[test]
fn fingerprint_differs_on_url_scheme_itself() {
let a = ServerConfig::builder()
.http_transport("http://api.example.com/mcp".to_string())
.build()
.unwrap();
let b = ServerConfig::builder()
.http_transport("https://api.example.com/mcp".to_string())
.build()
.unwrap();
assert_ne!(
ConfigFingerprint::compute(&a),
ConfigFingerprint::compute(&b)
);
}
#[test]
fn fingerprint_differs_on_authority() {
let a = ServerConfig::builder()
.http_transport("https://api-a.example.com/mcp".to_string())
.build()
.unwrap();
let b = ServerConfig::builder()
.http_transport("https://api-b.example.com/mcp".to_string())
.build()
.unwrap();
assert_ne!(
ConfigFingerprint::compute(&a),
ConfigFingerprint::compute(&b)
);
}
#[test]
fn fingerprint_differs_on_path() {
let a = ServerConfig::builder()
.http_transport("https://api.example.com/mcp-a".to_string())
.build()
.unwrap();
let b = ServerConfig::builder()
.http_transport("https://api.example.com/mcp-b".to_string())
.build()
.unwrap();
assert_ne!(
ConfigFingerprint::compute(&a),
ConfigFingerprint::compute(&b)
);
}
#[test]
fn fingerprint_differs_on_query_param_name() {
let a = ServerConfig::builder()
.http_transport("https://api.example.com/mcp?alpha=1".to_string())
.build()
.unwrap();
let b = ServerConfig::builder()
.http_transport("https://api.example.com/mcp?beta=1".to_string())
.build()
.unwrap();
assert_ne!(
ConfigFingerprint::compute(&a),
ConfigFingerprint::compute(&b)
);
}
#[test]
fn url_canonical_form_exact_string() {
let parts = split_url("https://user:pass@api.example.com:8443/mcp/v1?a=1&b=2#frag")
.expect("parses");
let canonical = format!("{}://{}{}", parts.scheme, parts.authority, parts.path);
assert_eq!(canonical, "https://api.example.com:8443/mcp/v1");
assert!(parts.userinfo_present);
}
#[test]
fn fingerprint_bare_query_param_uses_marker_not_text() {
let bare = ServerConfig::builder()
.http_transport("https://api.example.com/mcp?sk-live-token".to_string())
.build()
.unwrap();
let named = ServerConfig::builder()
.http_transport("https://api.example.com/mcp?<bare>=1".to_string())
.build()
.unwrap();
assert_ne!(
ConfigFingerprint::compute(&bare),
ConfigFingerprint::compute(&named),
);
}
#[test]
fn fingerprint_query_param_names_stop_at_fragment_boundary() {
let two_query_params = ServerConfig::builder()
.http_transport("https://h.example.com/p?a=1&b=2".to_string())
.build()
.unwrap();
let one_query_param_plus_fragment = ServerConfig::builder()
.http_transport("https://h.example.com/p?a=1#&b=2".to_string())
.build()
.unwrap();
assert_ne!(
ConfigFingerprint::compute(&two_query_params),
ConfigFingerprint::compute(&one_query_param_plus_fragment),
);
let one_query_param_no_fragment = ServerConfig::builder()
.http_transport("https://h.example.com/p?a=1".to_string())
.build()
.unwrap();
assert_eq!(
ConfigFingerprint::compute(&one_query_param_plus_fragment),
ConfigFingerprint::compute(&one_query_param_no_fragment),
);
}
#[test]
fn fingerprint_unparseable_url_uses_marker() {
let config = ServerConfig::builder()
.http_transport("https://user:pa/ssw0rd@api.example.com/mcp".to_string())
.build()
.unwrap();
let fingerprint = ConfigFingerprint::compute(&config);
assert_eq!(fingerprint.as_str().len(), 64);
let parseable = ServerConfig::builder()
.http_transport("https://api.example.com/mcp".to_string())
.build()
.unwrap();
assert_ne!(fingerprint, ConfigFingerprint::compute(&parseable));
}
fn entry<'a>(name: &'a str, schema: &'a serde_json::Value) -> ToolDigestEntry<'a> {
ToolDigestEntry {
name,
description: "desc",
input_schema: schema,
output_schema: None,
}
}
#[test]
fn tool_digest_equal_under_reordered_input() {
let schema_a = serde_json::json!({"type": "object"});
let schema_b = serde_json::json!({"type": "string"});
let a = entry("a", &schema_a);
let b = entry("b", &schema_b);
assert_eq!(ToolDigest::compute(&[a, b]), ToolDigest::compute(&[b, a]),);
}
#[test]
fn tool_digest_equal_for_duplicate_names_swapped_order() {
let schema_1 = serde_json::json!({"variant": 1});
let schema_2 = serde_json::json!({"variant": 2});
let first = ToolDigestEntry {
name: "dup",
description: "",
input_schema: &schema_1,
output_schema: None,
};
let second = ToolDigestEntry {
name: "dup",
description: "",
input_schema: &schema_2,
output_schema: None,
};
assert_eq!(
ToolDigest::compute(&[first, second]),
ToolDigest::compute(&[second, first]),
);
}
#[test]
fn tool_digest_differs_on_schema_edit() {
let schema_a = serde_json::json!({"type": "object"});
let schema_b = serde_json::json!({"type": "string"});
assert_ne!(
ToolDigest::compute(&[entry("a", &schema_a)]),
ToolDigest::compute(&[entry("a", &schema_b)]),
);
}
#[test]
fn tool_digest_differs_on_tool_added() {
let schema = serde_json::json!({"type": "object"});
assert_ne!(
ToolDigest::compute(&[entry("a", &schema)]),
ToolDigest::compute(&[entry("a", &schema), entry("b", &schema)]),
);
}
#[test]
fn tool_digest_differs_on_tool_removed() {
let schema = serde_json::json!({"type": "object"});
assert_ne!(
ToolDigest::compute(&[entry("a", &schema), entry("b", &schema)]),
ToolDigest::compute(&[entry("a", &schema)]),
);
}
#[test]
fn tool_digest_equal_for_nested_object_key_reordering() {
let schema_a = serde_json::json!({
"type": "object",
"properties": {"b": {"type": "string"}, "a": {"type": "number"}}
});
let schema_b = serde_json::json!({
"properties": {"a": {"type": "number"}, "b": {"type": "string"}},
"type": "object"
});
assert_eq!(
ToolDigest::compute(&[entry("t", &schema_a)]),
ToolDigest::compute(&[entry("t", &schema_b)]),
);
}
#[test]
fn tool_digest_distinguishes_absent_output_schema_from_null_output_schema() {
let input_schema = serde_json::json!({"type": "object"});
let null_schema = serde_json::Value::Null;
let without = ToolDigestEntry {
name: "t",
description: "",
input_schema: &input_schema,
output_schema: None,
};
let with_null = ToolDigestEntry {
name: "t",
description: "",
input_schema: &input_schema,
output_schema: Some(&null_schema),
};
assert_ne!(
ToolDigest::compute(&[without]),
ToolDigest::compute(&[with_null]),
);
}
#[test]
fn generation_provenance_capture_stamps_current_time() {
let config = stdio_config("docker");
let before = Utc::now();
let provenance = GenerationProvenance::capture(&config, &[]);
let after = Utc::now();
assert!(provenance.generated_at >= before && provenance.generated_at <= after);
}
#[test]
fn provenance_types_are_send_sync() {
fn assert_send<T: Send>() {}
fn assert_sync<T: Sync>() {}
assert_send::<GenerationProvenance>();
assert_sync::<GenerationProvenance>();
assert_send::<ConfigFingerprint>();
assert_sync::<ConfigFingerprint>();
assert_send::<ToolDigest>();
assert_sync::<ToolDigest>();
}
#[test]
fn digest_try_from_accepts_valid_lowercase_hex() {
let valid = "a".repeat(64);
assert!(ConfigFingerprint::try_from(valid.clone()).is_ok());
assert!(ToolDigest::try_from(valid).is_ok());
}
#[test]
fn digest_try_from_rejects_wrong_length() {
assert!(ConfigFingerprint::try_from("a".repeat(63)).is_err());
assert!(ConfigFingerprint::try_from("a".repeat(65)).is_err());
assert!(ConfigFingerprint::try_from(String::new()).is_err());
}
#[test]
fn digest_try_from_rejects_uppercase_hex() {
let uppercase = "A".repeat(64);
assert!(ConfigFingerprint::try_from(uppercase).is_err());
}
#[test]
fn digest_try_from_rejects_non_hex_characters() {
let mut candidate = "a".repeat(63);
candidate.push('g');
assert!(ConfigFingerprint::try_from(candidate).is_err());
}
#[test]
fn digest_deserialize_rejects_malformed_value() {
let result: Result<ConfigFingerprint, _> = serde_json::from_str(r#""not-a-digest""#);
assert!(result.is_err());
}
#[test]
fn digest_deserialize_accepts_valid_value() {
let valid = "b".repeat(64);
let json = serde_json::to_string(&valid).unwrap();
let fingerprint: ConfigFingerprint = serde_json::from_str(&json).unwrap();
assert_eq!(fingerprint.as_str(), valid);
}
#[test]
fn digest_format_error_sanitizes_rejected_value() {
let err = ConfigFingerprint::try_from("bad&value".to_string()).unwrap_err();
let message = err.to_string();
assert!(message.contains("bad&value"));
assert!(!message.contains("bad&value"));
}
}