Skip to main content

agent_uri/
query.rs

1//! Query parameters type for agent URIs.
2
3use std::cmp::Ordering;
4use std::collections::BTreeMap;
5use std::fmt;
6use std::str::FromStr;
7
8use crate::constants::MAX_URI_LENGTH;
9use crate::error::QueryError;
10
11/// Query parameters from an agent URI.
12///
13/// Stores key-value pairs from the query string, sorted lexicographically
14/// by key for consistent normalization. Percent escapes in values decode to
15/// octets, and the complete decoded sequence must be valid UTF-8. Invalid UTF-8
16/// is rejected rather than replaced. Programmatically constructed values may
17/// contain any characters. [`fmt::Display`] re-encodes every value octet outside
18/// the ASCII alphanumeric, `-`, `_`, and `.` set using uppercase `%XX`, so its
19/// output parses to the same values.
20///
21/// # Reserved Parameters
22///
23/// - `version`: Capability version constraint (semver)
24/// - `attestation`: Inline PASETO attestation token
25/// - `resolver`: Hint for resolution endpoint
26/// - `ttl`: Cache TTL hint in seconds
27///
28/// Parameters are held sorted by name, so two queries that differ only in the
29/// order they were written are equal and hash equally.
30#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
31pub struct QueryParams {
32    params: BTreeMap<String, String>,
33}
34
35impl QueryParams {
36    /// Creates an empty query params instance.
37    #[must_use]
38    pub fn new() -> Self {
39        Self::default()
40    }
41
42    /// Parses query parameters from a query string (without leading '?').
43    ///
44    /// Percent escapes decode to octets, and the complete decoded sequence must
45    /// be valid UTF-8. Invalid sequences are rejected rather than replaced.
46    /// Rendering with [`fmt::Display`] re-encodes every value octet outside the
47    /// ASCII alphanumeric, `-`, `_`, and `.` set as uppercase `%XX`, preserving
48    /// values across parse-display-parse round trips.
49    /// This standalone entry point enforces the same total-URI byte limit that
50    /// [`crate::AgentUri::parse`] applies.
51    ///
52    /// # Errors
53    ///
54    /// Input longer than 512 bytes ([`crate::MAX_URI_LENGTH`]) is rejected with
55    /// [`QueryError::TooLong`]. Other invalid parameters return their
56    /// corresponding `QueryError` variant.
57    pub fn parse(input: &str) -> Result<Self, QueryError> {
58        if input.len() > MAX_URI_LENGTH {
59            return Err(QueryError::TooLong {
60                max: MAX_URI_LENGTH,
61                actual: input.len(),
62            });
63        }
64
65        if input.is_empty() {
66            return Ok(Self::new());
67        }
68
69        let mut params = BTreeMap::new();
70
71        for pair in input.split('&') {
72            if pair.is_empty() {
73                continue;
74            }
75
76            let (name, value) = if let Some(eq_idx) = pair.find('=') {
77                let name = &pair[..eq_idx];
78                let value = &pair[eq_idx + 1..];
79                (name, value)
80            } else {
81                (pair, "")
82            };
83
84            // Validate name
85            Self::validate_param_name(name)?;
86
87            // Validate and decode value
88            let decoded_value = Self::decode_value(name, value)?;
89
90            // Check for duplicates
91            if params.contains_key(name) {
92                return Err(QueryError::DuplicateParam {
93                    name: name.to_string(),
94                });
95            }
96
97            params.insert(name.to_string(), decoded_value);
98        }
99
100        Ok(Self { params })
101    }
102
103    /// Returns the value for a parameter, if present.
104    #[must_use]
105    pub fn get(&self, name: &str) -> Option<&str> {
106        self.params.get(name).map(String::as_str)
107    }
108
109    /// Returns true if the query is empty.
110    #[must_use]
111    pub fn is_empty(&self) -> bool {
112        self.params.is_empty()
113    }
114
115    /// Returns the number of parameters.
116    #[must_use]
117    pub fn len(&self) -> usize {
118        self.params.len()
119    }
120
121    /// Returns an iterator over the parameters.
122    pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
123        self.params.iter().map(|(k, v)| (k.as_str(), v.as_str()))
124    }
125
126    /// Returns the version parameter, if present.
127    #[must_use]
128    pub fn version(&self) -> Option<&str> {
129        self.get("version")
130    }
131
132    /// Returns the TTL parameter, if present.
133    #[must_use]
134    pub fn ttl(&self) -> Option<u64> {
135        self.get("ttl").and_then(|s| s.parse().ok())
136    }
137
138    /// Returns a new query with the given parameter added or updated.
139    ///
140    /// The value may contain any characters. [`fmt::Display`] percent-encodes
141    /// every UTF-8 octet outside the ASCII alphanumeric, `-`, `_`, and `.` set.
142    ///
143    /// # Errors
144    ///
145    /// Returns `QueryError` if the parameter name is invalid.
146    ///
147    /// # Examples
148    ///
149    /// ```
150    /// use agent_uri::QueryParams;
151    ///
152    /// let query = QueryParams::new();
153    /// let updated = query.with_param("version", "2.0").unwrap();
154    /// assert_eq!(updated.get("version"), Some("2.0"));
155    ///
156    /// let with_filter = updated.with_param("filter", "&b=c").unwrap();
157    /// assert_eq!(with_filter.get("filter"), Some("&b=c"));
158    /// let rendered = with_filter.to_string();
159    /// assert!(rendered.contains("filter=%26b%3Dc"));
160    /// assert_eq!(
161    ///     QueryParams::parse(&rendered).unwrap().get("filter"),
162    ///     Some("&b=c")
163    /// );
164    /// ```
165    pub fn with_param(&self, key: &str, value: &str) -> Result<Self, QueryError> {
166        Self::validate_param_name(key)?;
167
168        let mut params = self.params.clone();
169        params.insert(key.to_string(), value.to_string());
170        Ok(Self { params })
171    }
172
173    /// Returns a new query without the given parameter.
174    ///
175    /// # Examples
176    ///
177    /// ```
178    /// use agent_uri::QueryParams;
179    ///
180    /// let query = QueryParams::parse("version=2.0&ttl=300").unwrap();
181    /// let updated = query.without_param("ttl");
182    /// assert!(updated.get("ttl").is_none());
183    /// assert_eq!(updated.get("version"), Some("2.0"));
184    /// ```
185    #[must_use]
186    pub fn without_param(&self, key: &str) -> Self {
187        let mut params = self.params.clone();
188        params.remove(key);
189        Self { params }
190    }
191
192    /// Returns a new query with the version parameter set.
193    ///
194    /// # Errors
195    ///
196    /// The return type is retained for API stability. This helper currently
197    /// cannot fail because its fixed parameter name is valid and values may
198    /// contain any characters.
199    ///
200    /// # Examples
201    ///
202    /// ```
203    /// use agent_uri::QueryParams;
204    ///
205    /// let query = QueryParams::new();
206    /// let updated = query.with_version("2.0").unwrap();
207    /// assert_eq!(updated.version(), Some("2.0"));
208    /// ```
209    pub fn with_version(&self, v: &str) -> Result<Self, QueryError> {
210        self.with_param("version", v)
211    }
212
213    /// Returns a new query with the TTL parameter set.
214    ///
215    /// # Errors
216    ///
217    /// The return type is retained for API stability. This helper currently
218    /// cannot fail because its fixed parameter name and generated value are
219    /// valid.
220    ///
221    /// # Examples
222    ///
223    /// ```
224    /// use agent_uri::QueryParams;
225    ///
226    /// let query = QueryParams::new();
227    /// let updated = query.with_ttl(300).unwrap();
228    /// assert_eq!(updated.ttl(), Some(300));
229    /// ```
230    pub fn with_ttl(&self, ttl: u64) -> Result<Self, QueryError> {
231        self.with_param("ttl", &ttl.to_string())
232    }
233
234    fn validate_param_name(name: &str) -> Result<(), QueryError> {
235        if name.is_empty() {
236            return Err(QueryError::InvalidParamName {
237                name: name.to_string(),
238                reason: "parameter name cannot be empty",
239            });
240        }
241
242        for c in name.chars() {
243            if !c.is_ascii_alphanumeric() && c != '-' && c != '_' {
244                return Err(QueryError::InvalidParamName {
245                    name: name.to_string(),
246                    reason: "name must be alphanumeric, hyphen, or underscore",
247                });
248            }
249        }
250
251        Ok(())
252    }
253
254    fn decode_value(name: &str, value: &str) -> Result<String, QueryError> {
255        let encoded = value.as_bytes();
256        let mut decoded = Vec::with_capacity(value.len());
257        let mut index = 0;
258
259        while let Some(&byte) = encoded.get(index) {
260            match byte {
261                b'%' => {
262                    let digits = encoded
263                        .get(index + 1)
264                        .copied()
265                        .and_then(Self::hex_digit)
266                        .zip(encoded.get(index + 2).copied().and_then(Self::hex_digit));
267                    let Some((high, low)) = digits else {
268                        return Err(QueryError::InvalidPercentEncoding {
269                            value: value.to_string(),
270                        });
271                    };
272                    decoded.push((high << 4) | low);
273                    index += 3;
274                }
275                byte if Self::is_unreserved(byte) => {
276                    decoded.push(byte);
277                    index += 1;
278                }
279                _ => {
280                    return Err(QueryError::InvalidParamValue {
281                        name: name.to_string(),
282                        value: value.to_string(),
283                        reason: "contains invalid unencoded character",
284                    });
285                }
286            }
287        }
288
289        String::from_utf8(decoded).map_err(|_| QueryError::InvalidUtf8 {
290            value: value.to_string(),
291        })
292    }
293
294    fn encode_value(value: &str) -> String {
295        const HEX_DIGITS: &[u8; 16] = b"0123456789ABCDEF";
296
297        let mut encoded = String::with_capacity(value.len());
298        for &byte in value.as_bytes() {
299            if Self::is_unreserved(byte) {
300                encoded.push(char::from(byte));
301            } else {
302                encoded.push('%');
303                encoded.push(char::from(HEX_DIGITS[usize::from(byte >> 4)]));
304                encoded.push(char::from(HEX_DIGITS[usize::from(byte & 0x0f)]));
305            }
306        }
307        encoded
308    }
309
310    const fn hex_digit(byte: u8) -> Option<u8> {
311        match byte {
312            b'0'..=b'9' => Some(byte - b'0'),
313            b'A'..=b'F' => Some(byte - b'A' + 10),
314            b'a'..=b'f' => Some(byte - b'a' + 10),
315            _ => None,
316        }
317    }
318
319    const fn is_unreserved(byte: u8) -> bool {
320        byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')
321    }
322}
323
324impl fmt::Display for QueryParams {
325    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
326        let pairs: Vec<String> = self
327            .params
328            .iter()
329            .map(|(k, v)| {
330                if v.is_empty() {
331                    k.clone()
332                } else {
333                    let encoded_value = Self::encode_value(v);
334                    format!("{k}={encoded_value}")
335                }
336            })
337            .collect();
338        write!(f, "{}", pairs.join("&"))
339    }
340}
341
342impl FromStr for QueryParams {
343    type Err = QueryError;
344
345    fn from_str(s: &str) -> Result<Self, Self::Err> {
346        Self::parse(s)
347    }
348}
349
350impl TryFrom<&str> for QueryParams {
351    type Error = QueryError;
352
353    fn try_from(s: &str) -> Result<Self, Self::Error> {
354        Self::parse(s)
355    }
356}
357
358impl PartialOrd for QueryParams {
359    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
360        Some(self.cmp(other))
361    }
362}
363
364/// Orders by the parameters themselves rather than by their rendering.
365///
366/// Rendering both sides to compare them allocated two `String`s per
367/// comparison, and percent-encoded every value on the way to throwing it away.
368/// The map is already sorted by key, so comparing it directly is the same
369/// walk without the encoding: pairs in key order, first difference wins.
370/// Values that differ only in how they encode are the same value, and now sort
371/// as one.
372impl Ord for QueryParams {
373    fn cmp(&self, other: &Self) -> Ordering {
374        self.params.cmp(&other.params)
375    }
376}
377
378#[cfg(feature = "serde")]
379impl serde::Serialize for QueryParams {
380    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
381    where
382        S: serde::Serializer,
383    {
384        serializer.serialize_str(&self.to_string())
385    }
386}
387
388#[cfg(feature = "serde")]
389impl<'de> serde::Deserialize<'de> for QueryParams {
390    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
391    where
392        D: serde::Deserializer<'de>,
393    {
394        let s = String::deserialize(deserializer)?;
395        Self::parse(&s).map_err(serde::de::Error::custom)
396    }
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402
403    #[test]
404    fn parse_empty() {
405        let params = QueryParams::parse("").unwrap();
406        assert!(params.is_empty());
407    }
408
409    #[test]
410    fn query_at_maximum_length_parses() {
411        let input = format!("v={}", "a".repeat(MAX_URI_LENGTH - 2));
412
413        let params = QueryParams::parse(&input).unwrap();
414
415        assert_eq!(input.len(), MAX_URI_LENGTH);
416        assert_eq!(params.get("v").unwrap().len(), MAX_URI_LENGTH - 2);
417    }
418
419    #[test]
420    fn query_over_maximum_length_reports_limit_and_actual_length() {
421        let input = format!("v={}", "a".repeat(MAX_URI_LENGTH - 1));
422
423        let error = QueryParams::parse(&input).unwrap_err();
424        let QueryError::TooLong { max, actual } = error else {
425            panic!("expected QueryError::TooLong");
426        };
427
428        assert_eq!(max, MAX_URI_LENGTH);
429        assert_eq!(actual, input.len());
430    }
431
432    #[test]
433    fn query_length_cap_precedes_character_validation() {
434        let input = format!("v={}", "@".repeat(MAX_URI_LENGTH));
435
436        assert!(matches!(
437            QueryParams::parse(&input),
438            Err(QueryError::TooLong {
439                max: MAX_URI_LENGTH,
440                actual
441            }) if actual == input.len()
442        ));
443    }
444
445    #[test]
446    fn whole_uri_length_cap_still_precedes_query_parsing() {
447        let base = "agent://example.com/chat/llm_01h455vb4pex5vsknk084sn02q?v=";
448        let input = format!("{base}{}", "a".repeat(MAX_URI_LENGTH + 1 - base.len()));
449
450        let error = crate::AgentUri::parse(&input).unwrap_err();
451        let crate::ParseErrorKind::TooLong { max, actual } = error.kind else {
452            panic!("expected ParseErrorKind::TooLong");
453        };
454
455        assert_eq!(input.len(), MAX_URI_LENGTH + 1);
456        assert_eq!(max, MAX_URI_LENGTH);
457        assert_eq!(actual, input.len());
458    }
459
460    #[test]
461    fn parse_single_param() {
462        let params = QueryParams::parse("version=2.0").unwrap();
463        assert_eq!(params.get("version"), Some("2.0"));
464        assert_eq!(params.len(), 1);
465    }
466
467    #[test]
468    fn parse_multiple_params() {
469        let params = QueryParams::parse("version=2.0&ttl=300").unwrap();
470        assert_eq!(params.get("version"), Some("2.0"));
471        assert_eq!(params.get("ttl"), Some("300"));
472        assert_eq!(params.len(), 2);
473    }
474
475    #[test]
476    fn parse_param_without_value() {
477        let params = QueryParams::parse("flag").unwrap();
478        assert_eq!(params.get("flag"), Some(""));
479    }
480
481    #[test]
482    fn ascii_percent_escapes_still_decode() {
483        let params = QueryParams::parse("name=%41%42%43").unwrap();
484        assert_eq!(params.get("name"), Some("ABC"));
485    }
486
487    #[test]
488    fn percent_decoded_multibyte_sequences_are_utf8() {
489        // The old code pushed each decoded byte as a char (Latin-1), producing "é".
490        let acute = QueryParams::parse("name=%C3%A9").unwrap();
491        let euro = QueryParams::parse("name=%E2%82%AC").unwrap();
492        let crab = QueryParams::parse("name=%F0%9F%A6%80").unwrap();
493
494        assert_eq!(acute.get("name"), Some("é"));
495        assert_eq!(euro.get("name"), Some("€"));
496        assert_eq!(crab.get("name"), Some("🦀"));
497    }
498
499    #[test]
500    fn invalid_utf8_percent_escapes_are_rejected() {
501        for input in ["name=%FF", "name=%80", "name=%C3", "name=%ED%A0%80"] {
502            let result = QueryParams::parse(input);
503
504            assert!(
505                matches!(result, Err(QueryError::InvalidUtf8 { .. })),
506                "expected invalid UTF-8 error for {input}"
507            );
508        }
509    }
510
511    #[test]
512    fn version_helper() {
513        let params = QueryParams::parse("version=2.0").unwrap();
514        assert_eq!(params.version(), Some("2.0"));
515    }
516
517    #[test]
518    fn ttl_helper() {
519        let params = QueryParams::parse("ttl=300").unwrap();
520        assert_eq!(params.ttl(), Some(300));
521    }
522
523    #[test]
524    fn ttl_invalid_returns_none() {
525        let params = QueryParams::parse("ttl=invalid").unwrap();
526        assert_eq!(params.ttl(), None);
527    }
528
529    #[test]
530    fn parse_duplicate_fails() {
531        let result = QueryParams::parse("version=1.0&version=2.0");
532        assert!(matches!(result, Err(QueryError::DuplicateParam { .. })));
533    }
534
535    #[test]
536    fn parse_invalid_name_fails() {
537        let result = QueryParams::parse("invalid@name=value");
538        assert!(matches!(result, Err(QueryError::InvalidParamName { .. })));
539    }
540
541    #[test]
542    fn malformed_percent_escapes_still_report_encoding_error() {
543        for input in ["name=%GG", "name=%4"] {
544            let result = QueryParams::parse(input);
545
546            assert!(
547                matches!(result, Err(QueryError::InvalidPercentEncoding { .. })),
548                "expected invalid percent encoding error for {input}"
549            );
550        }
551    }
552
553    #[test]
554    fn parse_invalid_value_char_fails() {
555        let result = QueryParams::parse("name=value with space");
556        assert!(matches!(result, Err(QueryError::InvalidParamValue { .. })));
557    }
558
559    #[test]
560    fn display_sorted() {
561        let params = QueryParams::parse("z=1&a=2").unwrap();
562        // BTreeMap sorts by key
563        assert_eq!(params.to_string(), "a=2&z=1");
564    }
565
566    #[test]
567    fn display_reencodes_non_unreserved_octets() {
568        let unicode = QueryParams::parse("name=%C3%A9").unwrap();
569        let space = QueryParams::parse("name=%20").unwrap();
570
571        assert_eq!(unicode.to_string(), "name=%C3%A9");
572        assert_eq!(space.to_string(), "name=%20");
573    }
574
575    #[test]
576    fn reserved_delimiters_survive_the_display_round_trip() {
577        let params = QueryParams::parse("a=%26b%3Dc").unwrap();
578        assert_eq!(params.get("a"), Some("&b=c"));
579        assert_eq!(params.len(), 1);
580
581        let rendered = params.to_string();
582        assert_eq!(rendered, "a=%26b%3Dc");
583
584        // A consumer that stores the canonical string and re-parses it must not
585        // see attacker-controlled parameter splitting.
586        let reparsed = QueryParams::parse(&rendered).unwrap();
587        assert_eq!(reparsed.get("a"), Some("&b=c"));
588        assert_eq!(reparsed.len(), 1);
589    }
590
591    #[test]
592    fn with_param_accepts_values_that_parse_accepts() {
593        for value in ["&b=c", "é", "a b", "?x=1", "100%"] {
594            let params = QueryParams::new().with_param("v", value).unwrap();
595            assert_eq!(params.get("v"), Some(value));
596        }
597    }
598
599    #[test]
600    fn with_param_values_round_trip_through_display() {
601        for value in ["&b=c", "é", "a b", "?x=1", "100%"] {
602            let params = QueryParams::new().with_param("v", value).unwrap();
603            let rendered = params.to_string();
604            let reparsed = QueryParams::parse(&rendered).unwrap();
605
606            assert_eq!(reparsed.get("v"), Some(value));
607            assert_eq!(reparsed.len(), 1);
608        }
609    }
610
611    #[test]
612    fn with_param_still_rejects_invalid_names() {
613        let invalid_space = QueryParams::new().with_param("bad name", "1");
614        let empty = QueryParams::new().with_param("", "1");
615
616        assert!(matches!(
617            invalid_space,
618            Err(QueryError::InvalidParamName { .. })
619        ));
620        assert!(matches!(empty, Err(QueryError::InvalidParamName { .. })));
621    }
622
623    #[test]
624    fn oversized_query_values_are_still_rejected_at_the_uri_boundary() {
625        let params = QueryParams::new()
626            .with_param("v", &"&".repeat(300))
627            .unwrap();
628        let uri = crate::AgentUri::parse("agent://example.com/chat/llm_01h455vb4pex5vsknk084sn02q")
629            .unwrap();
630
631        let error = uri.with_query(params).unwrap_err();
632        assert!(matches!(
633            error.kind,
634            crate::ParseErrorKind::TooLong { max, actual }
635                if max == crate::MAX_URI_LENGTH && actual > max
636        ));
637    }
638
639    #[test]
640    fn uri_with_encoded_query_round_trips() {
641        let uri = crate::AgentUri::parse(
642            "agent://example.com/chat/llm_01h455vb4pex5vsknk084sn02q?name=%C3%A9",
643        )
644        .unwrap();
645
646        assert_eq!(uri.query().get("name"), Some("é"));
647
648        let reparsed = crate::AgentUri::parse(uri.as_str()).unwrap();
649        assert_eq!(reparsed.query().get("name"), Some("é"));
650        assert_eq!(reparsed.query().to_string(), uri.query().to_string());
651    }
652
653    #[test]
654    fn iter_returns_all_params() {
655        let params = QueryParams::parse("a=1&b=2").unwrap();
656        let items: Vec<_> = params.iter().collect();
657        assert_eq!(items, vec![("a", "1"), ("b", "2")]);
658    }
659
660    #[test]
661    fn ordering_agrees_with_equality_and_walks_keys_in_order() {
662        let empty = QueryParams::new();
663        let a = QueryParams::parse("a=1").unwrap();
664        let a_b = QueryParams::parse("a=1&b=2").unwrap();
665        let b = QueryParams::parse("b=1").unwrap();
666        let a_two = QueryParams::parse("a=2").unwrap();
667
668        assert!(empty < a);
669        assert!(a < a_b);
670        assert!(a_b < b);
671        assert!(a < a_two);
672
673        for left in [&empty, &a, &a_b, &b, &a_two] {
674            for right in [&empty, &a, &a_b, &b, &a_two] {
675                assert_eq!(left.cmp(right) == Ordering::Equal, left == right);
676                assert_eq!(left.cmp(right).reverse(), right.cmp(left));
677            }
678        }
679    }
680
681    #[test]
682    fn ordering_is_by_value_and_not_by_the_way_it_renders() {
683        // Rendering escapes `~` as `%7E`, and `%` sorts before every letter,
684        // so comparing renderings put `~` ahead of `z` while the values
685        // themselves go the other way. The values are what a caller set.
686        let tilde = QueryParams::new().with_param("note", "~").unwrap();
687        let zed = QueryParams::new().with_param("note", "z").unwrap();
688
689        assert!(zed < tilde);
690        assert!(tilde.to_string() < zed.to_string());
691    }
692
693    #[test]
694    fn two_spellings_of_one_value_order_as_one() {
695        let written_encoded = QueryParams::parse("note=a%20b").unwrap();
696        let built_directly = QueryParams::new().with_param("note", "a b").unwrap();
697
698        assert_eq!(written_encoded, built_directly);
699        assert_eq!(written_encoded.cmp(&built_directly), Ordering::Equal);
700    }
701}