Skip to main content

mcproto_codec/
error.rs

1//! Errors reported by Minecraft protocol codecs.
2//!
3//! This module provides structured context for failures while reading and
4//! writing the protocol values implemented by `mcproto-codec`.
5
6use std::{error::Error, fmt, io};
7
8type BoxedError = Box<dyn Error + Send + Sync + 'static>;
9
10/// Identifies the protocol codec that reported an error.
11///
12/// A [`CodecError`] stores the codec that originally reported the error and may
13/// also store enclosing codecs as additional context. Protocol descriptions are
14/// based on the [Minecraft Java Edition protocol packet format].
15///
16/// Signed integer codecs use [two's-complement] representation.
17///
18/// [Minecraft Java Edition protocol packet format]: https://minecraft.wiki/w/Java_Edition_protocol/Packets
19/// [two's-complement]: https://en.wikipedia.org/wiki/Two%27s_complement
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21#[non_exhaustive]
22pub enum CodecKind {
23    /// A variable-length, two's-complement signed 32-bit integer.
24    ///
25    /// Values range from -2,147,483,648 through 2,147,483,647.
26    VarInt,
27    /// A variable-length, two's-complement signed 64-bit integer.
28    ///
29    /// Values range from -9,223,372,036,854,775,808 through
30    /// 9,223,372,036,854,775,807.
31    VarLong,
32    /// A complete Named Binary Tag value.
33    ///
34    /// The value is encoded and decoded using `fastnbt`.
35    Nbt,
36    /// A boolean encoded as `0x00` for false or `0x01` for true.
37    Boolean,
38    /// A two's-complement signed 8-bit integer from -128 through 127.
39    Byte,
40    /// An unsigned 8-bit integer from 0 through 255.
41    UnsignedByte,
42    /// A two's-complement signed 16-bit integer from -32,768 through 32,767.
43    Short,
44    /// An unsigned 16-bit integer from 0 through 65,535.
45    UnsignedShort,
46    /// A two's-complement signed 32-bit integer from -2,147,483,648 through
47    /// 2,147,483,647.
48    Int,
49    /// A two's-complement signed 64-bit integer from -9,223,372,036,854,775,808
50    /// through 9,223,372,036,854,775,807.
51    Long,
52    /// A UTF-8 string prefixed by its byte length as a VarInt.
53    ///
54    /// The protocol limits both the UTF-8 payload size and the number of UTF-16
55    /// code units. Supplementary [Unicode scalar values] count as two UTF-16
56    /// code units. The general protocol limit is 32,767 UTF-16 code units and
57    /// three UTF-8 bytes per permitted code unit; a particular field may impose
58    /// a lower limit.
59    ///
60    /// [Unicode scalar values]: https://www.unicode.org/glossary/#unicode_scalar_value
61    String,
62    /// A resource identifier encoded as a [`String`](Self::String).
63    ///
64    /// The namespace permits `[a-z0-9._-]`; the value permits
65    /// `[a-z0-9._/-]`. See the protocol's [identifier format] for details.
66    ///
67    /// [identifier format]: https://minecraft.wiki/w/Java_Edition_protocol/Packets#Identifier
68    Identifier,
69    /// A text component encoded as an NBT tag.
70    ///
71    /// Plain text-only components may use an NBT string tag. Components with
72    /// styling, events, or other data use an NBT compound tag. See the
73    /// [text component format] and [NBT specification].
74    ///
75    /// [text component format]: https://minecraft.wiki/w/Text_component_format
76    /// [NBT specification]: https://minecraft.wiki/w/NBT_format
77    TextComponent,
78    /// A text component encoded as JSON in a protocol string.
79    ///
80    /// Since Java Edition 1.20.3, the vanilla implementation permits up to
81    /// 262,144 UTF-16 code units when decoding but refuses to encode more than
82    /// 32,767. See the [text component format].
83    ///
84    /// [text component format]: https://minecraft.wiki/w/Text_component_format
85    JsonTextComponent,
86}
87
88/// Formats a codec kind using its protocol name, such as `VarInt` or `Boolean`.
89impl fmt::Display for CodecKind {
90    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
91        match self {
92            Self::VarInt => formatter.write_str("VarInt"),
93            Self::VarLong => formatter.write_str("VarLong"),
94            Self::Nbt => formatter.write_str("Nbt"),
95            Self::Boolean => formatter.write_str("Boolean"),
96            Self::Byte => formatter.write_str("Byte"),
97            Self::UnsignedByte => formatter.write_str("UnsignedByte"),
98            Self::Short => formatter.write_str("Short"),
99            Self::UnsignedShort => formatter.write_str("UnsignedShort"),
100            Self::Int => formatter.write_str("Int"),
101            Self::Long => formatter.write_str("Long"),
102            Self::String => formatter.write_str("String"),
103            Self::Identifier => formatter.write_str("Identifier"),
104            Self::TextComponent => formatter.write_str("TextComponent"),
105            Self::JsonTextComponent => formatter.write_str("JsonTextComponent"),
106        }
107    }
108}
109
110/// Identifies whether an error occurred while decoding or encoding data.
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
112#[non_exhaustive]
113pub enum CodecOperation {
114    /// A read (decoding) operation.
115    Read,
116    /// A write (encoding) operation.
117    Write,
118}
119
120/// Formats an operation as `reading` or `writing`.
121impl fmt::Display for CodecOperation {
122    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
123        match self {
124            Self::Read => formatter.write_str("reading"),
125            Self::Write => formatter.write_str("writing"),
126        }
127    }
128}
129/// Describes why encoded protocol data is invalid.
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
131#[non_exhaustive]
132pub enum InvalidEncodingReason {
133    /// The encoding exceeds the maximum allowed length in bytes.
134    TooLong {
135        /// The maximum number of bytes permitted for this encoding.
136        max_bytes: usize,
137    },
138    /// The terminal byte of the encoding contains bits outside the allowed mask.
139    ValueOutOfRange {
140        /// The final byte that contains disallowed bits.
141        terminal_byte: u8,
142        /// A mask whose set bits identify the permitted bits in the final byte.
143        allowed_mask: u8,
144    },
145    /// The boolean value is invalid (not 0x00 or 0x01).
146    InvalidBooleanValue {
147        /// The byte read instead of the permitted `0x00` or `0x01`.
148        value: u8,
149    },
150    /// The string exceeds the maximum allowed length in bytes when encoded in UTF-8.
151    StringTooLong {
152        /// The maximum permitted size of the UTF-8 payload, excluding its
153        /// VarInt length prefix.
154        max_bytes: usize,
155    },
156    /// The string exceeds the maximum allowed length in UTF-16 code units.
157    TooManyUtf16CodeUnits {
158        /// The maximum permitted number of UTF-16 code units.
159        max_code_units: usize,
160    },
161    /// The length of the data is negative, which is invalid.
162    NegativeLength {
163        /// The negative length decoded from the data.
164        value: i32,
165    },
166    /// The data contains an invalid UTF-8 sequence.
167    InvalidUtf8 {
168        /// The byte offset in the UTF-8 payload up to which the data is valid.
169        valid_up_to: usize,
170        /// The length of the invalid sequence, or `None` if the input ends in
171        /// an incomplete sequence.
172        error_len: Option<usize>,
173    },
174    /// The data is not a valid Minecraft identifier.
175    InvalidIdentifier,
176    /// The data is not valid NBT (Named Binary Tag) data.
177    InvalidNbt,
178    /// The data is not valid JSON.
179    InvalidJson,
180    /// The root tag of a text component is invalid (not TAG_String or TAG_Compound).
181    InvalidTextComponentRootTag {
182        /// The unsupported NBT root tag identifier.
183        tag: u8,
184    },
185}
186/// Formats an invalid encoding reason as a diagnostic message.
187impl fmt::Display for InvalidEncodingReason {
188    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
189        match self {
190            Self::TooLong { max_bytes } => {
191                write!(formatter, "encoding exceeds the {max_bytes}-byte limit")
192            }
193            Self::ValueOutOfRange {
194                terminal_byte,
195                allowed_mask,
196            } => write!(
197                formatter,
198                "terminal byte 0x{terminal_byte:02X} contains bits outside mask 0x{allowed_mask:02X}"
199            ),
200            Self::InvalidBooleanValue { value } => {
201                write!(formatter, "invalid boolean value 0x{value:02X}")
202            }
203            Self::StringTooLong { max_bytes } => {
204                write!(formatter, "string exceeds the {max_bytes}-byte UTF-8 limit")
205            }
206            Self::TooManyUtf16CodeUnits { max_code_units } => write!(
207                formatter,
208                "string exceeds the {max_code_units}-code-unit UTF-16 limit"
209            ),
210            Self::NegativeLength { value } => {
211                write!(formatter, "length cannot be negative: {value}")
212            }
213            Self::InvalidUtf8 {
214                valid_up_to,
215                error_len: Some(error_len),
216            } => write!(
217                formatter,
218                "invalid UTF-8 sequence of {error_len} bytes at byte {valid_up_to}"
219            ),
220            Self::InvalidUtf8 {
221                valid_up_to,
222                error_len: None,
223            } => write!(
224                formatter,
225                "incomplete UTF-8 sequence starting at byte {valid_up_to}"
226            ),
227            Self::InvalidIdentifier => formatter.write_str("invalid Minecraft identifier"),
228            Self::InvalidNbt => formatter.write_str("invalid NBT data"),
229            Self::InvalidJson => formatter.write_str("invalid JSON data"),
230            Self::InvalidTextComponentRootTag { tag } => write!(
231                formatter,
232                "text component root tag must be TAG_String (8) or TAG_Compound (10), got {tag}"
233            ),
234        }
235    }
236}
237/// Classifies an error reported by a protocol codec.
238#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
239#[non_exhaustive]
240pub enum CodecErrorKind {
241    /// An I/O error other than an unexpected end of input occurred.
242    Io,
243    /// A read ended before the codec received all required bytes.
244    UnexpectedEof,
245    /// The data could not be decoded or encoded according to the codec's
246    /// format or limits.
247    InvalidEncoding(InvalidEncodingReason),
248}
249
250/// An error produced while reading or writing protocol data.
251///
252/// The error records the originating [`CodecKind`], the [`CodecOperation`], the
253/// progress within that codec, and optional enclosing codec contexts. I/O and
254/// parser errors are retained as an error [`source`](Error::source).
255///
256/// Error enums are non-exhaustive, so downstream matches must include a
257/// wildcard arm.
258///
259/// # Example
260///
261/// ```
262/// use mcproto_codec::{
263///     error::{CodecErrorKind, CodecKind, CodecOperation},
264///     varint::VarIntRead,
265/// };
266///
267/// let mut input = [0x80].as_slice();
268/// let error = input
269///     .read_varint()
270///     .unwrap_err()
271///     .with_context(CodecKind::String);
272///
273/// assert_eq!(error.codec(), CodecKind::VarInt);
274/// assert_eq!(error.operation(), CodecOperation::Read);
275/// assert_eq!(error.bytes_processed(), 1);
276/// assert_eq!(error.contexts(), &[CodecKind::String]);
277///
278/// match error.kind() {
279///     CodecErrorKind::UnexpectedEof => {}
280///     _ => panic!("unexpected error: {error}"),
281/// }
282/// ```
283#[derive(Debug)]
284pub struct CodecError {
285    /// The error classification.
286    ///
287    /// This field and [`kind`](Self::kind) expose the same value. The accessor
288    /// is convenient when working through a shared reference.
289    pub kind: CodecErrorKind,
290    codec: CodecKind,
291    contexts: Contexts,
292    operation: CodecOperation,
293    bytes_processed: usize,
294    source: Option<BoxedError>,
295}
296
297/// Stores the enclosing codec contexts of a [`CodecError`].
298///
299/// The common cases of zero or one context are stored without heap allocation;
300/// only longer chains fall back to a [`Vec`].
301#[derive(Debug, Default)]
302enum Contexts {
303    /// No enclosing contexts.
304    #[default]
305    None,
306    /// A single context, stored inline.
307    One(CodecKind),
308    /// Two or more contexts, stored in a heap-allocated vector.
309    Many(Vec<CodecKind>),
310}
311
312impl CodecError {
313    /// Returns the error classification.
314    pub const fn kind(&self) -> CodecErrorKind {
315        self.kind
316    }
317    /// Returns the codec that originally reported the error.
318    pub const fn codec(&self) -> CodecKind {
319        self.codec
320    }
321    /// Returns the outermost enclosing codec context, if one was added.
322    ///
323    /// This is the last element of [`contexts`](Self::contexts), not the
324    /// originating codec returned by [`codec`](Self::codec).
325    pub fn context(&self) -> Option<CodecKind> {
326        self.contexts().last().copied()
327    }
328    /// Returns all enclosing codec contexts, ordered from nearest to outermost.
329    ///
330    /// The originating codec is not included. Each call to
331    /// [`with_context`](Self::with_context) appends one element.
332    pub fn contexts(&self) -> &[CodecKind] {
333        match &self.contexts {
334            Contexts::None => &[],
335            Contexts::One(context) => std::slice::from_ref(context),
336            Contexts::Many(contexts) => contexts,
337        }
338    }
339    /// Returns the operation being performed when the error occurred.
340    pub const fn operation(&self) -> CodecOperation {
341        self.operation
342    }
343    /// Returns the byte progress reported by the originating codec.
344    ///
345    /// Built-in codecs count bytes from the start of their encoded value. Bytes
346    /// successfully read or written before an I/O failure are included. A byte
347    /// that was read and then found to be invalid is also included. For a
348    /// length-prefixed value, the originating codec determines whether its
349    /// prefix is part of the count.
350    ///
351    /// Adding an outer context does not translate this value into an offset
352    /// within the enclosing codec.
353    pub const fn bytes_processed(&self) -> usize {
354        self.bytes_processed
355    }
356    /// Returns the underlying [`io::Error`], if the source is an I/O error.
357    ///
358    /// Invalid NBT or JSON errors may have a non-I/O source; access those
359    /// through [`Error::source`] instead.
360    pub fn io_error(&self) -> Option<&io::Error> {
361        self.source.as_deref()?.downcast_ref::<io::Error>()
362    }
363
364    /// Adds an enclosing codec to the error's context chain.
365    ///
366    /// Contexts should be added as the error propagates outward. Repeated calls
367    /// therefore order [`contexts`](Self::contexts) from nearest to outermost,
368    /// and [`context`](Self::context) returns the most recently added context.
369    pub fn with_context(mut self, context: CodecKind) -> Self {
370        self.contexts = match self.contexts {
371            Contexts::None => Contexts::One(context),
372            Contexts::One(first) => Contexts::Many(vec![first, context]),
373            Contexts::Many(mut contexts) => {
374                contexts.push(context);
375                Contexts::Many(contexts)
376            }
377        };
378        self
379    }
380    /// Creates an error from an I/O failure that occurred while reading.
381    ///
382    /// [`io::ErrorKind::UnexpectedEof`] maps to
383    /// [`CodecErrorKind::UnexpectedEof`]; every other error kind maps to
384    /// [`CodecErrorKind::Io`]. The source error is retained.
385    ///
386    /// `bytes_processed` is the number of bytes read before `source` occurred.
387    pub fn from_read_error(codec: CodecKind, bytes_processed: usize, source: io::Error) -> Self {
388        let kind = if source.kind() == io::ErrorKind::UnexpectedEof {
389            CodecErrorKind::UnexpectedEof
390        } else {
391            CodecErrorKind::Io
392        };
393
394        Self {
395            kind,
396            codec,
397            contexts: Contexts::None,
398            operation: CodecOperation::Read,
399            bytes_processed,
400            source: Some(Box::new(source)),
401        }
402    }
403    /// Creates an error from an I/O failure that occurred while writing.
404    ///
405    /// All write errors map to [`CodecErrorKind::Io`], and the source error is
406    /// retained. `bytes_processed` is the number of bytes written before
407    /// `source` occurred.
408    pub fn from_write_error(codec: CodecKind, bytes_processed: usize, source: io::Error) -> Self {
409        Self {
410            kind: CodecErrorKind::Io,
411            codec,
412            contexts: Contexts::None,
413            operation: CodecOperation::Write,
414            bytes_processed,
415            source: Some(Box::new(source)),
416        }
417    }
418    /// Creates an invalid encoding error for a read operation.
419    ///
420    /// Use [`invalid_encoding_for_operation`](Self::invalid_encoding_for_operation)
421    /// when the operation is not necessarily [`CodecOperation::Read`].
422    pub const fn invalid_encoding(
423        codec: CodecKind,
424        bytes_processed: usize,
425        reason: InvalidEncodingReason,
426    ) -> Self {
427        Self::invalid_encoding_for_operation(codec, CodecOperation::Read, bytes_processed, reason)
428    }
429
430    /// Creates an invalid encoding error for the specified operation.
431    ///
432    /// Unlike [`invalid_encoding`](Self::invalid_encoding), this constructor
433    /// does not assume that the error occurred while reading.
434    pub const fn invalid_encoding_for_operation(
435        codec: CodecKind,
436        operation: CodecOperation,
437        bytes_processed: usize,
438        reason: InvalidEncodingReason,
439    ) -> Self {
440        Self {
441            kind: CodecErrorKind::InvalidEncoding(reason),
442            codec,
443            contexts: Contexts::None,
444            operation,
445            bytes_processed,
446            source: None,
447        }
448    }
449    /// Creates an invalid encoding error with an underlying source error.
450    ///
451    /// `operation` may be either reading or writing. The supplied error is
452    /// available through [`Error::source`]; if it is an [`io::Error`], it is
453    /// also available through [`io_error`](Self::io_error).
454    pub fn invalid_encoding_for_operation_with_source(
455        codec: CodecKind,
456        operation: CodecOperation,
457        bytes_processed: usize,
458        reason: InvalidEncodingReason,
459        source: impl Error + Send + Sync + 'static,
460    ) -> Self {
461        Self {
462            kind: CodecErrorKind::InvalidEncoding(reason),
463            codec,
464            contexts: Contexts::None,
465            operation,
466            bytes_processed,
467            source: Some(Box::new(source)),
468        }
469    }
470}
471
472impl fmt::Display for CodecError {
473    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
474        match self.kind {
475            CodecErrorKind::Io => write!(
476                formatter,
477                "I/O error while {} {} after {} bytes",
478                self.operation, self.codec, self.bytes_processed
479            )?,
480            CodecErrorKind::UnexpectedEof => write!(
481                formatter,
482                "unexpected end of input while reading {} after {} bytes",
483                self.codec, self.bytes_processed
484            )?,
485            CodecErrorKind::InvalidEncoding(reason) => write!(
486                formatter,
487                "invalid {} encoding after {} bytes: {reason}",
488                self.codec, self.bytes_processed
489            )?,
490        }
491
492        for context in self.contexts() {
493            write!(formatter, " while processing {context}")?;
494        }
495
496        if let Some(source) = &self.source {
497            write!(formatter, ": {source}")?;
498        }
499
500        Ok(())
501    }
502}
503
504impl Error for CodecError {
505    fn source(&self) -> Option<&(dyn Error + 'static)> {
506        self.source
507            .as_deref()
508            .map(|source| source as &(dyn Error + 'static))
509    }
510}
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515
516    fn read_error() -> CodecError {
517        CodecError::from_read_error(
518            CodecKind::VarInt,
519            3,
520            io::Error::new(io::ErrorKind::UnexpectedEof, "stream ended"),
521        )
522    }
523
524    fn write_error() -> CodecError {
525        CodecError::from_write_error(CodecKind::String, 5, io::Error::other("disk full"))
526    }
527
528    fn invalid_encoding_error() -> CodecError {
529        CodecError::invalid_encoding_for_operation(
530            CodecKind::Boolean,
531            CodecOperation::Read,
532            1,
533            InvalidEncodingReason::InvalidBooleanValue { value: 2 },
534        )
535    }
536
537    fn invalid_encoding_with_source() -> CodecError {
538        CodecError::invalid_encoding_for_operation_with_source(
539            CodecKind::JsonTextComponent,
540            CodecOperation::Read,
541            4,
542            InvalidEncodingReason::InvalidJson,
543            io::Error::new(io::ErrorKind::InvalidData, "bad json"),
544        )
545    }
546
547    #[test]
548    fn display_reports_unexpected_eof_operation_and_progress() {
549        assert_eq!(
550            read_error().to_string(),
551            "unexpected end of input while reading VarInt after 3 bytes: stream ended"
552        );
553    }
554
555    #[test]
556    fn display_reports_write_io_errors() {
557        assert_eq!(
558            write_error().to_string(),
559            "I/O error while writing String after 5 bytes: disk full"
560        );
561    }
562
563    #[test]
564    fn display_reports_invalid_encoding_reason() {
565        assert_eq!(
566            invalid_encoding_error().to_string(),
567            "invalid Boolean encoding after 1 bytes: invalid boolean value 0x02"
568        );
569    }
570
571    #[test]
572    fn display_appends_contexts_and_source_in_order() {
573        let error = invalid_encoding_with_source()
574            .with_context(CodecKind::String)
575            .with_context(CodecKind::Identifier)
576            .with_context(CodecKind::TextComponent);
577        assert_eq!(
578            error.to_string(),
579            "invalid JsonTextComponent encoding after 4 bytes: invalid JSON data \
580             while processing String while processing Identifier while processing TextComponent: bad json"
581        );
582    }
583
584    #[test]
585    fn display_omits_contexts_and_source_when_absent() {
586        let error = invalid_encoding_error();
587        assert!(!error.to_string().contains("while processing"));
588        assert!(
589            !error.to_string().ends_with(": invalid boolean value 0x02:"),
590            "a source was rendered when none is stored"
591        );
592    }
593
594    #[test]
595    fn contexts_are_empty_by_default() {
596        let error = read_error();
597        assert!(error.contexts().is_empty());
598        assert_eq!(error.context(), None);
599    }
600
601    #[test]
602    fn single_context_is_reported_inline() {
603        let error = read_error().with_context(CodecKind::String);
604        assert_eq!(error.contexts(), &[CodecKind::String]);
605        assert_eq!(error.context(), Some(CodecKind::String));
606    }
607
608    #[test]
609    fn many_contexts_are_reported_nearest_to_outermost() {
610        let error = invalid_encoding_error()
611            .with_context(CodecKind::String)
612            .with_context(CodecKind::Identifier)
613            .with_context(CodecKind::TextComponent);
614        assert_eq!(
615            error.contexts(),
616            &[
617                CodecKind::String,
618                CodecKind::Identifier,
619                CodecKind::TextComponent
620            ]
621        );
622        assert_eq!(error.context(), Some(CodecKind::TextComponent));
623        assert_eq!(error.codec(), CodecKind::Boolean);
624    }
625
626    #[test]
627    fn io_error_returns_the_underlying_io_error() {
628        let error = read_error();
629        let io_error = error.io_error().expect("io_error() should be Some");
630        assert_eq!(io_error.kind(), io::ErrorKind::UnexpectedEof);
631        assert_eq!(io_error.to_string(), "stream ended");
632        assert_eq!(
633            error
634                .source()
635                .and_then(|source| source.downcast_ref::<io::Error>())
636                .map(io::Error::kind),
637            Some(io::ErrorKind::UnexpectedEof)
638        );
639    }
640
641    #[test]
642    fn io_error_returns_none_for_non_io_sources() {
643        let error = CodecError::invalid_encoding_for_operation_with_source(
644            CodecKind::TextComponent,
645            CodecOperation::Read,
646            0,
647            InvalidEncodingReason::InvalidNbt,
648            NonIoSource,
649        );
650        assert!(error.io_error().is_none());
651        assert!(error.source().is_some());
652    }
653
654    #[derive(Debug)]
655    struct NonIoSource;
656
657    impl fmt::Display for NonIoSource {
658        fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
659            formatter.write_str("non-io source")
660        }
661    }
662
663    impl Error for NonIoSource {}
664}