Skip to main content

agent_uri/
error.rs

1//! Error types for agent URI parsing.
2//!
3//! Every enum here is `#[non_exhaustive]`, because a parser learns to be more
4//! specific over time and naming a failure precisely should not cost a major
5//! release. The module is private and its types are re-exported from the crate
6//! root, so the rationale a caller needs lives in the crate-level docs, under
7//! "Breaking changes in 0.6.0"; keep the two in step.
8
9use std::fmt;
10
11use crate::constants::MAX_URI_LENGTH;
12
13/// How much of a rejected input an error keeps.
14///
15/// Set to the longest URI that could ever have parsed, so an input the parser
16/// might plausibly have accepted is kept whole and only what could not be a
17/// URI at all is cut.
18const MAX_ERROR_INPUT_LENGTH: usize = MAX_URI_LENGTH;
19
20/// Errors that can occur when parsing an agent URI.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct ParseError {
23    /// The input that failed to parse, cut to the first 512 characters and
24    /// marked with `…` if there was more.
25    ///
26    /// A parser reads what it is given, and what it is given may be a
27    /// megabyte. Keeping all of it hands a caller who logs or collects errors
28    /// a full copy of an input that was rejected precisely for being
29    /// unusable — the rejection itself becomes the cost. Anything within the
30    /// length a URI may have is kept verbatim, so this is only ever a cut of
31    /// something that could not have parsed at any length; when the length was
32    /// the complaint, [`ParseErrorKind::TooLong`] carries what it actually was.
33    pub input: String,
34    /// The specific error that occurred
35    pub kind: ParseErrorKind,
36}
37
38impl ParseError {
39    /// Builds an error, keeping only as much of the input as is worth keeping.
40    pub(crate) fn new(input: &str, kind: ParseErrorKind) -> Self {
41        Self {
42            input: truncate_input(input),
43            kind,
44        }
45    }
46}
47
48/// Appended to a cut input so a reader can tell a cut string from a short one.
49const TRUNCATION_MARKER: char = '…';
50
51/// Keeps the first [`MAX_ERROR_INPUT_LENGTH`] characters of `input`.
52///
53/// Characters, not bytes. Cutting at a byte offset panics in the middle of a
54/// character, which is the mistake this crate keeps having to unmake (issues
55/// #33 and #89), and it is not a mistake worth making inside the type whose
56/// whole job is reporting that something was wrong.
57fn truncate_input(input: &str) -> String {
58    let mut chars = input.chars();
59    let mut kept: String = chars.by_ref().take(MAX_ERROR_INPUT_LENGTH).collect();
60
61    if chars.next().is_some() {
62        kept.push(TRUNCATION_MARKER);
63    }
64
65    kept
66}
67
68/// Specific parsing error types.
69#[derive(Debug, Clone, PartialEq, Eq)]
70#[non_exhaustive]
71pub enum ParseErrorKind {
72    /// URI is empty
73    Empty,
74    /// URI exceeds maximum length
75    TooLong {
76        /// Maximum allowed length
77        max: usize,
78        /// Actual length
79        actual: usize,
80    },
81    /// Missing or invalid scheme (expected "agent://")
82    InvalidScheme {
83        /// The scheme that was found, if any
84        found: Option<String>,
85    },
86    /// Trust root parsing failed
87    InvalidTrustRoot(TrustRootError),
88    /// Capability path parsing failed
89    InvalidCapabilityPath(CapabilityPathError),
90    /// Agent ID parsing failed
91    InvalidAgentId(AgentIdError),
92    /// Query string parsing failed
93    InvalidQuery(QueryError),
94    /// Fragment parsing failed
95    InvalidFragment(FragmentError),
96    /// Missing required component
97    MissingComponent {
98        /// Name of the missing component
99        component: &'static str,
100    },
101    /// Unexpected character at position
102    UnexpectedChar {
103        /// The unexpected character
104        char: char,
105        /// Position in the input
106        position: usize,
107    },
108}
109
110impl fmt::Display for ParseError {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        write!(f, "failed to parse agent URI '{}': ", self.input)?;
113        match &self.kind {
114            ParseErrorKind::Empty => write!(f, "input is empty"),
115            ParseErrorKind::TooLong { max, actual } => {
116                write!(
117                    f,
118                    "URI length {actual} exceeds maximum {max}; consider shorter capability path or trust root"
119                )
120            }
121            ParseErrorKind::InvalidScheme { found } => match found {
122                Some(s) => write!(f, "expected scheme 'agent://', found '{s}'"),
123                None => write!(f, "missing scheme; URI must start with 'agent://'"),
124            },
125            ParseErrorKind::InvalidTrustRoot(e) => write!(f, "invalid trust root: {e}"),
126            ParseErrorKind::InvalidCapabilityPath(e) => write!(f, "invalid capability path: {e}"),
127            ParseErrorKind::InvalidAgentId(e) => write!(f, "invalid agent ID: {e}"),
128            ParseErrorKind::InvalidQuery(e) => write!(f, "invalid query string: {e}"),
129            ParseErrorKind::InvalidFragment(e) => write!(f, "invalid fragment: {e}"),
130            ParseErrorKind::MissingComponent { component } => {
131                write!(f, "missing required component: {component}")
132            }
133            ParseErrorKind::UnexpectedChar { char, position } => {
134                write!(f, "unexpected character '{char}' at position {position}")
135            }
136        }
137    }
138}
139
140impl std::error::Error for ParseError {}
141
142/// Errors for trust root parsing.
143#[derive(Debug, Clone, PartialEq, Eq)]
144#[non_exhaustive]
145pub enum TrustRootError {
146    /// Trust root is empty
147    Empty,
148    /// Trust root exceeds maximum length
149    TooLong {
150        /// Maximum allowed length
151        max: usize,
152        /// Actual length
153        actual: usize,
154    },
155    /// Invalid domain name
156    InvalidDomain {
157        /// The invalid domain
158        domain: String,
159        /// Reason for invalidity
160        reason: &'static str,
161    },
162    /// Invalid IP address
163    InvalidIpAddress {
164        /// The invalid value
165        value: String,
166        /// Reason for invalidity
167        reason: &'static str,
168    },
169    /// Invalid port number
170    InvalidPort {
171        /// The invalid value
172        value: String,
173        /// Reason for invalidity
174        reason: &'static str,
175    },
176    /// DNS label too long
177    LabelTooLong {
178        /// The too-long label
179        label: String,
180        /// Maximum allowed length
181        max: usize,
182        /// Actual length
183        actual: usize,
184    },
185    /// Invalid character in domain
186    InvalidChar {
187        /// The invalid character
188        char: char,
189        /// Position in the input
190        position: usize,
191    },
192}
193
194impl fmt::Display for TrustRootError {
195    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
196        match self {
197            Self::Empty => write!(f, "trust root cannot be empty"),
198            Self::TooLong { max, actual } => {
199                write!(f, "trust root length {actual} exceeds maximum {max}")
200            }
201            Self::InvalidDomain { domain, reason } => {
202                write!(f, "invalid domain '{domain}': {reason}")
203            }
204            Self::InvalidIpAddress { value, reason } => {
205                write!(f, "invalid IP address '{value}': {reason}")
206            }
207            Self::InvalidPort { value, reason } => {
208                write!(f, "invalid port '{value}': {reason}")
209            }
210            Self::LabelTooLong { label, max, actual } => {
211                write!(f, "DNS label '{label}' is {actual} chars, max is {max}")
212            }
213            Self::InvalidChar { char, position } => {
214                write!(f, "invalid character '{char}' at position {position}")
215            }
216        }
217    }
218}
219
220impl std::error::Error for TrustRootError {}
221
222/// Errors for capability path parsing.
223#[derive(Debug, Clone, PartialEq, Eq)]
224#[non_exhaustive]
225pub enum CapabilityPathError {
226    /// Path is empty
227    Empty,
228    /// Path exceeds maximum length
229    TooLong {
230        /// Maximum allowed length
231        max: usize,
232        /// Actual length
233        actual: usize,
234    },
235    /// Too many segments
236    TooManySegments {
237        /// Maximum allowed segments
238        max: usize,
239        /// Actual segment count
240        actual: usize,
241    },
242    /// Invalid segment
243    InvalidSegment {
244        /// The invalid segment
245        segment: String,
246        /// Index of the segment
247        index: usize,
248        /// Reason for invalidity
249        reason: PathSegmentError,
250    },
251}
252
253impl fmt::Display for CapabilityPathError {
254    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
255        match self {
256            Self::Empty => write!(
257                f,
258                "capability path cannot be empty; at least one segment required"
259            ),
260            Self::TooLong { max, actual } => {
261                write!(f, "capability path length {actual} exceeds maximum {max}")
262            }
263            Self::TooManySegments { max, actual } => {
264                write!(f, "path has {actual} segments, maximum is {max}")
265            }
266            Self::InvalidSegment {
267                segment,
268                index,
269                reason,
270            } => {
271                write!(f, "invalid segment '{segment}' at index {index}: {reason}")
272            }
273        }
274    }
275}
276
277impl std::error::Error for CapabilityPathError {}
278
279/// Errors for path segment parsing.
280#[derive(Debug, Clone, PartialEq, Eq)]
281#[non_exhaustive]
282pub enum PathSegmentError {
283    /// Segment is empty
284    Empty,
285    /// Segment exceeds maximum length
286    TooLong {
287        /// Maximum allowed length
288        max: usize,
289        /// Actual length
290        actual: usize,
291    },
292    /// Invalid character (not lowercase alphanumeric or hyphen)
293    InvalidChar {
294        /// The invalid character
295        char: char,
296        /// Position in the input
297        position: usize,
298    },
299}
300
301impl fmt::Display for PathSegmentError {
302    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
303        match self {
304            Self::Empty => write!(f, "segment cannot be empty"),
305            Self::TooLong { max, actual } => {
306                write!(f, "segment length {actual} exceeds maximum {max}")
307            }
308            Self::InvalidChar { char, position } => {
309                write!(
310                    f,
311                    "invalid character '{char}' at position {position}; only lowercase letters, digits, and hyphens allowed"
312                )
313            }
314        }
315    }
316}
317
318impl std::error::Error for PathSegmentError {}
319
320/// Errors for agent ID parsing.
321#[derive(Debug, Clone, PartialEq, Eq)]
322#[non_exhaustive]
323pub enum AgentIdError {
324    /// Agent ID is empty
325    Empty,
326    /// Agent ID exceeds maximum length
327    TooLong {
328        /// Maximum allowed length
329        max: usize,
330        /// Actual length
331        actual: usize,
332    },
333    /// Invalid prefix
334    InvalidPrefix(AgentPrefixError),
335    /// Invalid `TypeID` suffix
336    InvalidSuffix {
337        /// The invalid value
338        value: String,
339        /// Reason for invalidity
340        reason: &'static str,
341    },
342    /// Missing underscore separator
343    MissingSeparator,
344    /// `TypeID` parsing failed
345    TypeIdError(String),
346}
347
348impl fmt::Display for AgentIdError {
349    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
350        match self {
351            Self::Empty => write!(f, "agent ID cannot be empty"),
352            Self::TooLong { max, actual } => {
353                write!(f, "agent ID length {actual} exceeds maximum {max}")
354            }
355            Self::InvalidPrefix(e) => write!(f, "invalid prefix: {e}"),
356            Self::InvalidSuffix { value, reason } => {
357                write!(f, "invalid suffix '{value}': {reason}")
358            }
359            Self::MissingSeparator => {
360                write!(f, "missing underscore separator between prefix and suffix")
361            }
362            Self::TypeIdError(msg) => write!(f, "`TypeID` error: {msg}"),
363        }
364    }
365}
366
367impl std::error::Error for AgentIdError {}
368
369/// Errors for agent prefix parsing.
370#[derive(Debug, Clone, PartialEq, Eq)]
371#[non_exhaustive]
372pub enum AgentPrefixError {
373    /// Prefix is empty
374    Empty,
375    /// Prefix exceeds maximum length
376    TooLong {
377        /// Maximum allowed length
378        max: usize,
379        /// Actual length
380        actual: usize,
381    },
382    /// The leading type class is not a valid core or extension class
383    InvalidTypeClass {
384        /// The rejected type-class text
385        class: String,
386        /// Why the type class was rejected
387        reason: TypeClassError,
388    },
389    /// Invalid character (not lowercase letter or underscore)
390    InvalidChar {
391        /// The invalid character
392        char: char,
393        /// Position in the input
394        position: usize,
395    },
396    /// Prefix must start with a letter
397    MustStartWithLetter {
398        /// The character found
399        found: char,
400    },
401    /// Prefix must end with a letter
402    MustEndWithLetter {
403        /// The character found
404        found: char,
405    },
406    /// Contains digits (not allowed per `TypeID` spec)
407    ContainsDigit {
408        /// Position of the digit
409        position: usize,
410    },
411}
412
413impl fmt::Display for AgentPrefixError {
414    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
415        match self {
416            Self::Empty => write!(f, "prefix cannot be empty"),
417            Self::TooLong { max, actual } => {
418                write!(f, "prefix length {actual} exceeds maximum {max}")
419            }
420            Self::InvalidTypeClass { class, reason } => {
421                write!(f, "invalid type class '{class}': {reason}")
422            }
423            Self::InvalidChar { char, position } => {
424                write!(
425                    f,
426                    "invalid character '{char}' at position {position}; only lowercase letters and underscores allowed"
427                )
428            }
429            Self::MustStartWithLetter { found } => {
430                write!(f, "prefix must start with a letter, found '{found}'")
431            }
432            Self::MustEndWithLetter { found } => {
433                write!(f, "prefix must end with a letter, found '{found}'")
434            }
435            Self::ContainsDigit { position } => {
436                write!(
437                    f,
438                    "prefix cannot contain digits (found at position {position})"
439                )
440            }
441        }
442    }
443}
444
445impl std::error::Error for AgentPrefixError {
446    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
447        match self {
448            Self::InvalidTypeClass { reason, .. } => Some(reason),
449            _ => None,
450        }
451    }
452}
453
454/// Errors for query string parsing.
455#[derive(Debug, Clone, PartialEq, Eq)]
456#[non_exhaustive]
457pub enum QueryError {
458    /// Input exceeds maximum length
459    TooLong {
460        /// Maximum allowed length
461        max: usize,
462        /// Actual length
463        actual: usize,
464    },
465    /// Invalid parameter name
466    InvalidParamName {
467        /// The invalid name
468        name: String,
469        /// Reason for invalidity
470        reason: &'static str,
471    },
472    /// Invalid parameter value
473    InvalidParamValue {
474        /// Parameter name
475        name: String,
476        /// The invalid value
477        value: String,
478        /// Reason for invalidity
479        reason: &'static str,
480    },
481    /// Duplicate parameter
482    DuplicateParam {
483        /// The duplicated name
484        name: String,
485    },
486    /// Invalid percent encoding
487    InvalidPercentEncoding {
488        /// The invalid value
489        value: String,
490    },
491    /// Percent-decoded octets did not form valid UTF-8
492    InvalidUtf8 {
493        /// The offending value, as written in the URI
494        value: String,
495    },
496}
497
498impl fmt::Display for QueryError {
499    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
500        match self {
501            Self::TooLong { max, actual } => {
502                write!(f, "query string length {actual} exceeds maximum {max}")
503            }
504            Self::InvalidParamName { name, reason } => {
505                write!(f, "invalid parameter name '{name}': {reason}")
506            }
507            Self::InvalidParamValue {
508                name,
509                value,
510                reason,
511            } => {
512                write!(
513                    f,
514                    "invalid value '{value}' for parameter '{name}': {reason}"
515                )
516            }
517            Self::DuplicateParam { name } => {
518                write!(f, "duplicate parameter '{name}'")
519            }
520            Self::InvalidPercentEncoding { value } => {
521                write!(f, "invalid percent encoding in '{value}'")
522            }
523            Self::InvalidUtf8 { value } => {
524                write!(f, "percent-encoded value '{value}' is not valid UTF-8")
525            }
526        }
527    }
528}
529
530impl std::error::Error for QueryError {}
531
532/// Errors for fragment parsing.
533#[derive(Debug, Clone, PartialEq, Eq)]
534#[non_exhaustive]
535pub enum FragmentError {
536    /// Input exceeds maximum length
537    TooLong {
538        /// Maximum allowed length
539        max: usize,
540        /// Actual length
541        actual: usize,
542    },
543    /// Invalid character in fragment
544    InvalidChar {
545        /// The invalid character
546        char: char,
547        /// Position in the input
548        position: usize,
549    },
550    /// The fragment is empty; a bare '#' carries no fragment.
551    Empty,
552}
553
554impl fmt::Display for FragmentError {
555    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
556        match self {
557            Self::TooLong { max, actual } => {
558                write!(f, "fragment length {actual} exceeds maximum {max}")
559            }
560            Self::InvalidChar { char, position } => {
561                write!(
562                    f,
563                    "invalid character '{char}' at position {position}; allowed: alphanumeric, hyphen, underscore, dot, slash"
564                )
565            }
566            Self::Empty => write!(f, "fragment must not be empty; omit the '#' instead"),
567        }
568    }
569}
570
571impl std::error::Error for FragmentError {}
572
573/// Errors that can occur when building an agent URI.
574#[derive(Debug, Clone, PartialEq, Eq)]
575#[non_exhaustive]
576pub enum BuilderError {
577    /// The resulting URI would exceed the maximum length.
578    UriTooLong {
579        /// Maximum allowed length
580        max: usize,
581        /// Actual length
582        actual: usize,
583    },
584    /// The components could not be assembled into a valid URI
585    InvalidUri(ParseError),
586}
587
588impl fmt::Display for BuilderError {
589    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
590        match self {
591            Self::UriTooLong { max, actual } => {
592                write!(
593                    f,
594                    "resulting URI length {actual} exceeds maximum {max}; \
595                     consider shorter component values"
596                )
597            }
598            Self::InvalidUri(e) => write!(f, "cannot build agent URI: {e}"),
599        }
600    }
601}
602
603impl std::error::Error for BuilderError {
604    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
605        match self {
606            Self::InvalidUri(e) => Some(e),
607            Self::UriTooLong { .. } => None,
608        }
609    }
610}
611
612/// Errors for type-class parsing.
613///
614/// Produced by [`ExtensionClass::new`] and by `TypeClass`'s [`FromStr`]
615/// implementation, which reaches an extension class for anything outside the
616/// core set.
617///
618/// [`ExtensionClass::new`]: crate::ExtensionClass::new
619/// [`FromStr`]: std::str::FromStr
620#[derive(Debug, Clone, PartialEq, Eq)]
621#[non_exhaustive]
622pub enum TypeClassError {
623    /// The class name is empty.
624    Empty,
625    /// The name contains something other than a lowercase ASCII letter.
626    InvalidChar {
627        /// The invalid character
628        char: char,
629        /// Position in the input, in characters
630        position: usize,
631    },
632}
633
634impl fmt::Display for TypeClassError {
635    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
636        match self {
637            Self::Empty => write!(f, "type class name cannot be empty"),
638            Self::InvalidChar { char, position } => {
639                write!(
640                    f,
641                    "invalid character '{char}' at position {position}; \
642                     a type class name is lowercase letters only"
643                )
644            }
645        }
646    }
647}
648
649impl std::error::Error for TypeClassError {}
650
651#[cfg(test)]
652mod tests {
653    use super::*;
654
655    #[test]
656    fn an_input_a_uri_could_have_been_is_kept_whole() {
657        // Truncation must never reach an input the parser might plausibly
658        // have accepted, or the error stops describing what was rejected.
659        let input = format!("agent://{}", "a".repeat(MAX_ERROR_INPUT_LENGTH - 8));
660        assert_eq!(input.chars().count(), MAX_ERROR_INPUT_LENGTH);
661
662        let error = ParseError::new(&input, ParseErrorKind::Empty);
663
664        assert_eq!(error.input, input);
665        assert!(!error.input.ends_with(TRUNCATION_MARKER));
666    }
667
668    #[test]
669    fn a_hostile_input_is_not_copied_into_the_error() {
670        // The whole point: rejecting a megabyte must not cost a megabyte.
671        let input = "a".repeat(4 * 1024 * 1024);
672
673        let error = ParseError::new(
674            &input,
675            ParseErrorKind::TooLong {
676                max: MAX_URI_LENGTH,
677                actual: input.len(),
678            },
679        );
680
681        assert_eq!(error.input.chars().count(), MAX_ERROR_INPUT_LENGTH + 1);
682        assert!(error.input.ends_with(TRUNCATION_MARKER));
683
684        // What was cut is not lost, because the length was the complaint and
685        // the complaint carries it.
686        let ParseErrorKind::TooLong { actual, .. } = error.kind else {
687            panic!("wrong kind");
688        };
689        assert_eq!(actual, 4 * 1024 * 1024);
690    }
691
692    #[test]
693    fn truncation_counts_characters_and_not_bytes() {
694        // Cutting at a byte offset lands inside a character and panics. Each
695        // of these is four bytes, so a byte-counting cut would slice one in
696        // half well before the character limit.
697        let input = "\u{1f300}".repeat(MAX_ERROR_INPUT_LENGTH * 2);
698        assert!(input.len() > MAX_ERROR_INPUT_LENGTH * 4);
699
700        let error = ParseError::new(&input, ParseErrorKind::Empty);
701
702        assert_eq!(error.input.chars().count(), MAX_ERROR_INPUT_LENGTH + 1);
703        assert!(
704            error
705                .input
706                .chars()
707                .take(MAX_ERROR_INPUT_LENGTH)
708                .all(|c| c == '\u{1f300}'),
709            "a character was cut in half"
710        );
711    }
712
713    #[test]
714    fn an_input_one_character_over_the_limit_is_marked() {
715        let input = "a".repeat(MAX_ERROR_INPUT_LENGTH + 1);
716
717        let error = ParseError::new(&input, ParseErrorKind::Empty);
718
719        assert!(error.input.ends_with(TRUNCATION_MARKER));
720        assert_eq!(
721            error.input.chars().filter(|c| *c == 'a').count(),
722            MAX_ERROR_INPUT_LENGTH
723        );
724    }
725
726    #[test]
727    fn a_truncated_input_still_renders() {
728        // `Display` interpolates the input, so a cut that produced invalid
729        // UTF-8 would surface here rather than in the field.
730        let error = ParseError::new(
731            &"\u{1f300}".repeat(MAX_ERROR_INPUT_LENGTH + 4),
732            ParseErrorKind::Empty,
733        );
734
735        assert!(error.to_string().contains(TRUNCATION_MARKER));
736    }
737}