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}