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