thunderstore_engine/error.rs
1#![deny(missing_docs)]
2
3use std::io;
4use std::num::ParseIntError;
5use thiserror::Error;
6
7/// A type alias for `Result<T, Error>` used throughout the engine.
8pub type Result<T> = std::result::Result<T, Error>;
9
10/// Represents all possible errors that can occur within the Thunderstore engine.
11///
12/// This enum is `#[non_exhaustive]`: new variants may be added in future
13/// releases without a breaking change, so downstream `match`es must include a
14/// wildcard arm.
15#[derive(Error, Debug)]
16#[non_exhaustive]
17pub enum Error {
18 /// Error from the IO system.
19 #[error("IO error: {0}")]
20 Io(#[from] io::Error),
21
22 /// Error from HTTP operations.
23 #[error("HTTP error: {0}")]
24 Http(#[from] reqwest::Error),
25
26 /// Error from JSON parsing.
27 #[error("JSON error: {0}")]
28 Json(#[from] serde_json::Error),
29
30 /// Error from ZIP operations.
31 #[error("ZIP error: {0}")]
32 Zip(#[from] zip::result::ZipError),
33
34 /// Error from header parsing.
35 #[error("Header error: {0}")]
36 Header(#[from] reqwest::header::ToStrError),
37
38 /// Error from parsing integers.
39 #[error("Parse integer error: {0}")]
40 ParseInt(#[from] ParseIntError),
41
42 /// Missing header in response.
43 #[error("Missing header: {0}")]
44 MissingHeader(String),
45
46 /// Error from builder or configuration validation.
47 #[error("Configuration error: {0}")]
48 Config(String),
49
50 /// Error parsing a per-mod `manifest.json`.
51 #[error("manifest parse error: {0}")]
52 ManifestParse(String),
53
54 /// Error reading, writing, or validating the on-disk package-index cache.
55 ///
56 /// Covers cache load/decode/encode, a missing cache, an incompatible or
57 /// corrupt cache layout, and the integrity checks in
58 /// [`PackageIndex::validate`](crate::models::PackageIndex) — which run at the
59 /// cache boundary.
60 #[error("cache error: {0}")]
61 Cache(String),
62
63 /// A network body, manifest file, or decompressed payload exceeded a
64 /// configured size limit.
65 ///
66 /// `actual` is a structured field for programmatic inspection; it is
67 /// deliberately not rendered in `Display` because `thiserror` cannot
68 /// conditionally format an `Option`.
69 #[error("{context} exceeds size limit of {limit} bytes")]
70 SizeLimitExceeded {
71 /// A short label for what was being read (e.g. `"download body"`).
72 context: String,
73 /// The configured byte ceiling.
74 limit: u64,
75 /// The observed size, when known.
76 actual: Option<u64>,
77 },
78
79 /// A download ended before the declared number of bytes arrived.
80 #[error("incomplete download: expected {expected} bytes, received {actual}")]
81 IncompleteDownload {
82 /// The number of bytes the server declared.
83 expected: u64,
84 /// The number of bytes actually received.
85 actual: u64,
86 },
87
88 /// A download filename was unsafe (empty, absolute, or path-traversing).
89 #[error("unsafe name: {0}")]
90 UnsafeName(String),
91
92 /// The on-disk cache was written by an incompatible version of the cache
93 /// format. The client treats this as a cache miss and re-fetches.
94 #[error("unsupported cache format version {found} (expected {expected})")]
95 UnsupportedCacheVersion {
96 /// The format version read from the cache file.
97 found: u32,
98 /// The format version this build understands.
99 expected: u32,
100 },
101
102 /// Error from ecosystem-schema parsing.
103 #[error("Ecosystem error: {0}")]
104 Ecosystem(String),
105
106 /// Error from install routing, e.g. a package that would escape the install root.
107 #[error("Install error: {0}")]
108 Install(String),
109
110 /// Error from the profile layer: an invalid profile name, a malformed
111 /// `mods.yml`/state file, or a profile operation that could not complete.
112 #[error("Profile error: {0}")]
113 Profile(String),
114
115 /// A profile name that could not be used, from any operation that gates on
116 /// [`layout::require_profile`](crate::profile::layout::require_profile).
117 ///
118 /// Carries the reason rather than a sentence so an operation with other
119 /// failure modes, such as
120 /// [`layout::use_profile`](crate::profile::layout::use_profile), can still
121 /// return one `Error` without a caller losing the ability to word the refusal
122 /// itself. Renders exactly as [`Error::Profile`] did for the same refusal.
123 #[error("Profile error: {0}")]
124 BadProfile(#[from] crate::profile::layout::BadProfile),
125
126 /// One or more files in a batch download failed.
127 #[error("{failed} of {total} downloads failed; first error: {first}")]
128 Download {
129 /// How many files failed.
130 failed: usize,
131 /// How many files were attempted.
132 total: usize,
133 /// The first failure's message.
134 first: String,
135 },
136
137 /// A response header was present but could not be parsed, e.g. an
138 /// unparseable `Last-Modified` value.
139 #[error("invalid header: {0}")]
140 InvalidHeader(String),
141
142 /// An archive exceeded a configured extraction limit (entry count or
143 /// uncompressed size), e.g. a zip bomb.
144 #[error("Archive extraction limit exceeded: {0}")]
145 ExtractLimitExceeded(String),
146
147 /// CRC32 verification failed while extracting an archive entry.
148 #[error("CRC32 verification failed for {path}: expected {expected:08x}, got {actual:08x}")]
149 ChecksumMismatch {
150 /// The archive-relative path of the entry that failed verification.
151 path: String,
152 /// The CRC32 the archive recorded for the entry.
153 expected: u32,
154 /// The CRC32 computed over the bytes actually read.
155 actual: u32,
156 },
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162
163 #[test]
164 fn profile_error_displays_message() {
165 let err = Error::Profile("bad profile".to_string());
166
167 assert_eq!(err.to_string(), "Profile error: bad profile");
168 }
169}