1#![forbid(unsafe_code)]
39#![warn(missing_docs, clippy::pedantic)]
40#![allow(clippy::module_name_repetitions)]
41
42#[cfg(any(feature = "postgres", feature = "redis", test))]
43use mcp_usage_core::{TaskAttribution, TaskOriginKind};
44#[cfg(any(feature = "postgres", feature = "redis", test))]
45use mcp_usage_tower::TaskStoreError;
46#[cfg(any(feature = "postgres", feature = "redis", test))]
47use sha2::{Digest, Sha256};
48use thiserror::Error;
49
50#[cfg(feature = "postgres")]
51mod postgres;
52#[cfg(feature = "redis")]
53mod redis;
54
55#[cfg(feature = "postgres")]
56pub use postgres::PostgresTaskStore;
57#[cfg(feature = "redis")]
58pub use redis::RedisTaskStore;
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
62pub enum StoreConfigError {
63 #[error("task-attribution TTL is outside the supported range")]
65 InvalidTtl,
66 #[error("task-attribution operation timeout must be greater than zero")]
68 InvalidTimeout,
69 #[error("task-attribution key prefix is invalid")]
71 InvalidPrefix,
72 #[error("task-attribution backend connection failed")]
74 Connection,
75 #[error("task-attribution backend schema initialization failed")]
77 Schema,
78}
79
80#[cfg(any(feature = "postgres", feature = "redis", test))]
81fn identifier_hash(value: &str) -> [u8; 32] {
82 Sha256::digest(value.as_bytes()).into()
83}
84
85#[cfg(any(feature = "postgres", feature = "redis", test))]
86fn encode_attribution(attribution: TaskAttribution) -> [u8; 10] {
87 let mut encoded = [0_u8; 10];
88 encoded[0] = 1;
89 encoded[1] = match attribution.origin_kind() {
90 TaskOriginKind::ToolsCall => 1,
91 TaskOriginKind::ResourcesRead => 2,
92 TaskOriginKind::PromptsGet => 3,
93 TaskOriginKind::Other => 255,
94 };
95 encoded[2..].copy_from_slice(&attribution.units().to_be_bytes());
96 encoded
97}
98
99#[cfg(any(feature = "postgres", feature = "redis", test))]
100fn decode_attribution(payload: &[u8]) -> Result<TaskAttribution, TaskStoreError> {
101 let [1, kind, units @ ..] = payload else {
102 return Err(TaskStoreError::InvalidRecord);
103 };
104 let origin_kind = match kind {
105 1 => TaskOriginKind::ToolsCall,
106 2 => TaskOriginKind::ResourcesRead,
107 3 => TaskOriginKind::PromptsGet,
108 255 => TaskOriginKind::Other,
109 _ => return Err(TaskStoreError::InvalidRecord),
110 };
111 let units = u64::from_be_bytes(
112 units
113 .try_into()
114 .map_err(|_| TaskStoreError::InvalidRecord)?,
115 );
116 Ok(TaskAttribution::new(origin_kind, units))
117}
118
119#[cfg(any(feature = "redis", test))]
120fn encode_hash(value: &str) -> String {
121 const HEX: &[u8; 16] = b"0123456789abcdef";
122 let digest = identifier_hash(value);
123 let mut encoded = String::with_capacity(digest.len() * 2);
124 for byte in digest {
125 encoded.push(char::from(HEX[usize::from(byte >> 4)]));
126 encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
127 }
128 encoded
129}
130
131#[cfg(any(feature = "redis", test))]
132fn valid_prefix(prefix: &str) -> bool {
133 !prefix.is_empty()
134 && prefix.len() <= 64
135 && prefix
136 .bytes()
137 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b':' | b'_' | b'-'))
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143
144 #[test]
145 fn identifiers_are_fixed_length_and_absent_from_keys() {
146 let encoded = encode_hash("tenant:private@example.test");
147 assert_eq!(encoded.len(), 64);
148 assert!(!encoded.contains("private"));
149 }
150
151 #[test]
152 fn prefixes_are_bounded_and_ascii_only() {
153 assert!(valid_prefix("mcp-usage:tasks"));
154 assert!(!valid_prefix(""));
155 assert!(!valid_prefix("spaces are rejected"));
156 assert!(!valid_prefix(&"a".repeat(65)));
157 }
158
159 #[test]
160 fn attribution_encoding_is_fixed_length_and_validated() {
161 for kind in [
162 TaskOriginKind::ToolsCall,
163 TaskOriginKind::ResourcesRead,
164 TaskOriginKind::PromptsGet,
165 TaskOriginKind::Other,
166 ] {
167 let attribution = TaskAttribution::new(kind, u64::MAX);
168 let encoded = encode_attribution(attribution);
169 assert_eq!(encoded.len(), 10);
170 assert_eq!(decode_attribution(&encoded).unwrap(), attribution);
171 }
172 assert_eq!(
173 decode_attribution(&[1, 2]),
174 Err(TaskStoreError::InvalidRecord)
175 );
176 assert_eq!(
177 decode_attribution(&[2, 1, 0, 0, 0, 0, 0, 0, 0, 1]),
178 Err(TaskStoreError::InvalidRecord)
179 );
180 }
181}