1use serde_json::Value;
9
10use crate::context::TraceContext;
11
12#[derive(Clone, Copy, Debug)]
30pub(super) enum Declaration {
31 Declared,
34 Withheld,
37 Unknowable,
42}
43
44pub(super) fn server_capability(context: &TraceContext<'_>, path: &[&str]) -> Declaration {
47 capability_in(context.server_capabilities(), path, context)
48}
49
50pub(super) fn client_capability(context: &TraceContext<'_>, path: &[&str]) -> Declaration {
53 capability_in(context.client_capabilities(), path, context)
54}
55
56fn capability_in(
57 capabilities: Option<&Value>,
58 path: &[&str],
59 context: &TraceContext<'_>,
60) -> Declaration {
61 if context.initialize().result.is_none() {
64 return Declaration::Unknowable;
65 }
66 let Some(mut current) = capabilities else {
67 return Declaration::Withheld;
68 };
69 for segment in path {
70 match current.get(segment) {
71 Some(next) => current = next,
72 None => return Declaration::Withheld,
73 }
74 }
75 if current.is_null() || matches!(current, Value::Bool(false)) {
76 Declaration::Withheld
77 } else {
78 Declaration::Declared
79 }
80}
81
82pub(super) fn is_base64(text: &str) -> bool {
93 let bytes = text.as_bytes();
94 if !bytes.len().is_multiple_of(4) {
95 return false;
96 }
97 let padding = bytes.iter().rev().take_while(|&&b| b == b'=').count();
98 if padding > 2 {
99 return false;
100 }
101 let content = &bytes[..bytes.len() - padding];
102 content
103 .iter()
104 .all(|&b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/')
105}
106
107#[cfg(feature = "draft-2026-07-28")]
117pub(super) fn decode_base64(text: &str) -> Option<String> {
118 if !is_base64(text) {
119 return None;
120 }
121 let mut bytes = Vec::with_capacity(text.len() / 4 * 3);
122 let mut accumulator: u32 = 0;
123 let mut bits: u32 = 0;
124 for byte in text.bytes().take_while(|&byte| byte != b'=') {
127 let sextet = match byte {
128 b'A'..=b'Z' => u32::from(byte - b'A'),
129 b'a'..=b'z' => u32::from(byte - b'a') + 26,
130 b'0'..=b'9' => u32::from(byte - b'0') + 52,
131 b'+' => 62,
132 b'/' => 63,
133 _ => return None,
134 };
135 accumulator = (accumulator << 6) + sextet;
139 bits += 6;
140 if bits >= 8 {
141 bits -= 8;
142 bytes.push(u8::try_from((accumulator >> bits) & 0xff).ok()?);
143 }
144 }
145 String::from_utf8(bytes).ok()
146}
147
148pub(super) fn has_rfc3986_scheme(uri: &str) -> bool {
152 let Some((scheme, _)) = uri.split_once(':') else {
153 return false;
154 };
155 let mut chars = scheme.chars();
156 let Some(first) = chars.next() else {
157 return false;
158 };
159 first.is_ascii_alphabetic()
160 && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
161}
162
163#[cfg(test)]
164#[allow(clippy::unwrap_used)]
165mod tests {
166 use super::*;
167
168 use crate::checks;
169 use crate::reader::{Limits, parse_trace};
170
171 const CAPABILITY_CHECKS: [&str; 7] = [
174 "tools.capability-declared",
175 "tools.embedded-resource-capability",
176 "resources.capability-declared",
177 "prompts.capability-declared",
178 "logging.capability-declared",
179 "completion.capability-declared",
180 "lifecycle.negotiated-capabilities-only",
181 ];
182
183 fn session(handshake: bool) -> String {
186 let mut lines: Vec<String> = Vec::new();
187 if handshake {
188 lines.push(r#"{"seq":0,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}}"#.to_owned());
189 lines.push(r#"{"seq":1,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{},"resources":{},"prompts":{},"logging":{},"completions":{}},"serverInfo":{"name":"s","version":"0"}}}}"#.to_owned());
190 }
191 for line in [
192 r#"{"seq":2,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"method":"tools/list"}}"#,
193 r#"{"seq":3,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"result":{"tools":[]}}}"#,
194 r#"{"seq":4,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":3,"method":"resources/read","params":{"uri":"file:///a"}}}"#,
195 r#"{"seq":5,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":3,"result":{"contents":[]}}}"#,
196 r#"{"seq":6,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":4,"method":"prompts/get","params":{"name":"p"}}}"#,
197 r#"{"seq":7,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":4,"result":{"messages":[]}}}"#,
198 r#"{"seq":8,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":5,"method":"completion/complete","params":{}}}"#,
199 r#"{"seq":9,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":5,"result":{"completion":{"values":[]}}}}"#,
200 r#"{"seq":10,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","method":"notifications/message","params":{"level":"info","data":"x"}}}"#,
201 r#"{"seq":11,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"t"}}}"#,
202 r#"{"seq":12,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":6,"result":{"content":[{"type":"resource","resource":{"uri":"file:///a","text":"x"}}]}}}"#,
203 ] {
204 lines.push(line.to_owned());
205 }
206 lines.join("\n")
207 }
208
209 fn subjects_and_findings(check: &str, trace: &str) -> (u32, usize) {
210 let events = parse_trace(trace, &Limits::default()).unwrap();
211 let context = TraceContext::new(&events);
212 let outcome = checks::find(check).unwrap().run(&context);
213 (outcome.subjects, outcome.findings.len())
214 }
215
216 #[test]
228 fn no_declaration_surface_means_no_verdict() {
229 let trace = session(false);
230 for check in CAPABILITY_CHECKS {
231 let (subjects, findings) = subjects_and_findings(check, &trace);
232 assert_eq!(
233 subjects, 0,
234 "{check} counted a subject in a session with no initialize result, \
235 so the clause it backs reports a pass it cannot support"
236 );
237 assert_eq!(findings, 0, "{check} judged an unjudgeable session");
238 }
239 }
240
241 #[test]
245 fn a_declaration_surface_is_judged() {
246 let trace = session(true);
247 for check in CAPABILITY_CHECKS {
248 let (subjects, findings) = subjects_and_findings(check, &trace);
249 assert!(subjects > 0, "{check} found nothing to judge");
250 assert_eq!(
251 findings, 0,
252 "{check} faulted a session that declared everything it used"
253 );
254 }
255 }
256
257 #[test]
258 fn a_present_surface_that_withholds_the_capability_is_a_violation() {
259 let trace = session(true).replace(
262 r#""capabilities":{"tools":{},"resources":{},"prompts":{},"logging":{},"completions":{}}"#,
263 r#""capabilities":{}"#,
264 );
265 for check in CAPABILITY_CHECKS {
266 let (subjects, findings) = subjects_and_findings(check, &trace);
267 assert!(subjects > 0, "{check} found nothing to judge");
268 assert!(findings > 0, "{check} excused an undeclared capability");
269 }
270 }
271
272 #[test]
273 fn base64_validation_is_exact() {
274 for valid in ["", "aGk=", "aGV5", "aGV5bw==", "AB+/", "QUJDRA=="] {
275 assert!(is_base64(valid), "{valid:?} should validate");
276 }
277 for invalid in [
278 "aGk", "aGk =", "aGk!", "====", "aG=k", "aGV5bw=", ] {
285 assert!(!is_base64(invalid), "{invalid:?} should not validate");
286 }
287 }
288
289 #[cfg(feature = "draft-2026-07-28")]
290 #[test]
291 fn base64_decoding_round_trips_the_specification_examples() {
292 for (encoded, original) in [
295 ("SGVsbG8sIOS4lueVjA==", "Hello, 世界"),
296 ("IHBhZGRlZCA=", " padded "),
297 ("bGluZTEKbGluZTI=", "line1\nline2"),
298 ("PT9iYXNlNjQ/bGl0ZXJhbD89", "=?base64?literal?="),
299 ] {
300 assert_eq!(
301 decode_base64(encoded).as_deref(),
302 Some(original),
303 "{encoded:?} should decode to {original:?}"
304 );
305 }
306 assert_eq!(decode_base64("").as_deref(), Some(""));
307 }
308
309 #[cfg(feature = "draft-2026-07-28")]
310 #[test]
311 fn base64_decoding_covers_the_whole_alphabet_and_every_padding_length() {
312 assert_eq!(decode_base64("fn5+").as_deref(), Some("~~~"));
314 assert_eq!(decode_base64("fn4/").as_deref(), Some("~~?"));
315 assert_eq!(decode_base64("YQ==").as_deref(), Some("a")); assert_eq!(decode_base64("YWI=").as_deref(), Some("ab")); assert_eq!(decode_base64("YWJj").as_deref(), Some("abc")); assert_eq!(decode_base64("YmFj").as_deref(), Some("bac"));
322 }
323
324 #[cfg(feature = "draft-2026-07-28")]
325 #[test]
326 fn base64_decoding_refuses_what_it_cannot_represent() {
327 assert_eq!(decode_base64("aGk"), None);
329 assert_eq!(decode_base64("aG=k"), None);
330 assert_eq!(decode_base64("/w=="), None);
332 }
333
334 #[test]
335 fn rfc3986_scheme_validation_is_exact() {
336 for valid in ["https://x", "file:///a", "git://r", "a:", "z+ssh.2-x:rest"] {
337 assert!(has_rfc3986_scheme(valid), "{valid:?} should validate");
338 }
339 for invalid in [
340 "", "no-colon", ":rest", "1https://x", "ht tp://x", "ht_tp://x", ] {
347 assert!(
348 !has_rfc3986_scheme(invalid),
349 "{invalid:?} should not validate"
350 );
351 }
352 }
353}