Skip to main content

quilt_rs/
error.rs

1use std::path::PathBuf;
2use std::path::StripPrefixError;
3use std::str::Utf8Error;
4
5use aws_smithy_types::byte_stream;
6use reqwest::header::ToStrError;
7use thiserror::Error;
8
9use crate::io::remote::HostChecksums;
10use crate::object_hash::Error as ObjectHashError;
11use crate::workflow::WorkflowValidationError;
12use quilt_uri::Host;
13use quilt_uri::Namespace;
14use quilt_uri::UriError;
15
16#[derive(Error, Debug)]
17#[error("S3 error{}: {kind}", .host.as_ref().map_or(String::new(), |h| format!(" for {h}")))]
18pub struct S3Error {
19    pub host: Option<Host>,
20    #[source]
21    pub kind: S3ErrorKind,
22}
23
24impl S3Error {
25    #[must_use]
26    pub fn new(kind: S3ErrorKind) -> Self {
27        Self { host: None, kind }
28    }
29
30    #[must_use]
31    pub fn is_not_found(&self) -> bool {
32        matches!(self.kind, S3ErrorKind::NotFound(_))
33    }
34
35    /// True when S3 refused the call with the `AccessDenied` code — under a
36    /// vended credential that proves the session is healthy, this means the
37    /// active role cannot reach the object, not that the user is logged out.
38    #[must_use]
39    pub fn is_access_denied(&self) -> bool {
40        matches!(self.kind, S3ErrorKind::AccessDenied(_))
41    }
42}
43
44#[derive(Error, Debug, PartialEq)]
45pub enum S3ErrorKind {
46    #[error("Failed to check object existence: {0}")]
47    Exists(String),
48
49    #[error("Failed to get object: {0}")]
50    GetObject(String),
51
52    #[error("Failed to get object attributes: {0}")]
53    GetObjectAttributes(String),
54
55    #[error("Failed to get object stream: {0}")]
56    GetObjectStream(String),
57
58    #[error("Failed to initialize S3 client: {0}")]
59    Client(String),
60
61    #[error("Failed to list objects: {0}")]
62    ListObjects(String),
63
64    #[error("Failed to put object: {0}")]
65    PutObject(String),
66
67    #[error("Failed to resolve object URL: {0}")]
68    ResolveUrl(String),
69
70    #[error("Failed to upload object: {0}")]
71    UploadFile(String),
72
73    #[error("S3 not found: {0}")]
74    NotFound(String),
75
76    #[error("S3 access denied: {0}")]
77    AccessDenied(String),
78
79    #[error("S3 error: {0}")]
80    Raw(String),
81
82    #[error("Failed to initialize S3 Remote")]
83    RemoteInit,
84
85    #[error("Object key expected to be present")]
86    ObjectKey,
87
88    #[error("Error with upload id: {0}")]
89    UploadId(String),
90
91    #[error("Failed to read RwLock: {0}")]
92    PoisonLock(String),
93}
94
95#[derive(Error, Debug, PartialEq)]
96pub enum AuthError {
97    #[error("Failed to read credentials: {0}")]
98    CredentialsRead(String),
99
100    #[error("Failed to refresh credentials: {0}")]
101    CredentialsRefresh(String),
102
103    #[error("Failed to read tokens: {0}")]
104    TokensRead(String),
105
106    #[error("Failed to refresh tokens: {0}")]
107    TokensRefresh(String),
108
109    #[error("Failed to exchange authorization code for tokens: {0}")]
110    TokensExchange(String),
111}
112
113#[derive(Error, Debug, PartialEq)]
114pub enum RoleError {
115    #[error("Not authenticated with {0}")]
116    NotAuthenticated(Host),
117
118    #[error("Registry rejected the request: {0}")]
119    GraphQl(String),
120
121    #[error("Role switch rejected: {0}")]
122    SwitchRejected(String),
123}
124
125#[derive(Error, Debug, PartialEq)]
126pub enum InstallPackageError {
127    #[error("The package {0} is already installed")]
128    AlreadyInstalled(Namespace),
129
130    #[error("The given package is not installed: {0}")]
131    NotInstalled(Namespace),
132}
133
134#[derive(Error, Debug, PartialEq)]
135pub enum InstallPathError {
136    #[error("Failed to install path: {}", .0.display())]
137    Install(PathBuf),
138
139    #[error("Some paths are already installed")]
140    AlreadyInstalled,
141
142    #[error("Failed to uninstall path: {}", .0.display())]
143    Uninstall(PathBuf),
144}
145
146#[derive(Error, Debug)]
147pub enum ChecksumError {
148    #[error("Checksum error: {0}")]
149    Mismatch(String),
150
151    #[error("Missing checksum: {0:?}")]
152    Missing(HostChecksums),
153
154    #[error("Malformed checksum: {0}")]
155    Malformed(String),
156
157    #[error("Failed to get checksum from S3: {0}")]
158    NoS3Checksum(String),
159}
160
161#[derive(Error, Debug)]
162pub enum ManifestError {
163    #[error("Manifest header: {0}")]
164    Header(String),
165
166    #[error("Failed to load manifest from {path}: {source}")]
167    Load {
168        path: PathBuf,
169        source: Box<crate::Error>,
170    },
171
172    #[error("Table error: {0}")]
173    Table(String),
174}
175
176#[derive(Error, Debug)]
177pub enum LineageError {
178    #[error("Domain lineage missing, including missing Home directory")]
179    Missing,
180
181    #[error("Domain lineage missing Home directory")]
182    MissingHome,
183
184    #[error("Failed to parse lineage file: {0}")]
185    Parse(serde_json::Error),
186
187    #[error("Operation requires a remote origin, but this is a local-only package")]
188    NoRemote,
189}
190
191#[derive(Error, Debug, PartialEq)]
192pub enum RemoteCatalogError {
193    #[error("Workflow error: {0}")]
194    Workflow(String),
195
196    /// The bucket's `.quilt/workflows/config.yml` is malformed — it violates the
197    /// vendored quilt3 config schema, or its YAML could not be converted for
198    /// validation. Distinct from [`RemoteCatalogError::Workflow`] (a workflow
199    /// that resolved against a *valid* config could not be found/applied): an
200    /// invalid config means every commit to the bucket will fail until it is
201    /// fixed, so callers can present that honestly instead of "couldn't load".
202    #[error("Invalid workflows config: {0}")]
203    InvalidWorkflowsConfig(String),
204
205    #[error("Failed to fetch host config: {0}")]
206    HostConfig(String),
207
208    #[error("S3 bucket '{0}' is not reachable — verify the bucket name")]
209    BucketUnreachable(String),
210}
211
212#[derive(Error, Debug, PartialEq)]
213pub enum LoginError {
214    #[error("Login required{}", .0.as_ref().map_or(String::new(), |h| format!(": {h}")))]
215    Required(Option<Host>),
216
217    #[error("Failed to get registry URL from {0}. Does {0}/config.json have it?")]
218    RequiredRegistryUrl(Host),
219}
220
221#[derive(Error, Debug)]
222pub enum FsError {
223    #[error("Failed to read file {path}: {source}")]
224    Read {
225        path: PathBuf,
226        source: std::io::Error,
227    },
228
229    #[error("Failed to write file {path}: {source}")]
230    Write {
231        path: PathBuf,
232        source: std::io::Error,
233    },
234
235    #[error("Failed to copy file from {from} to {to}: {source}")]
236    Copy {
237        from: PathBuf,
238        to: PathBuf,
239        source: std::io::Error,
240    },
241
242    #[error("Failed to create directory {path}: {source}")]
243    DirectoryCreate {
244        path: PathBuf,
245        source: std::io::Error,
246    },
247
248    #[error("File not found: {path}")]
249    NotFound { path: PathBuf },
250
251    #[error("Path prefix not found: {0}")]
252    PathPrefixNotFound(StripPrefixError),
253
254    #[error("ByteStream error: {0}")]
255    ByteStream(#[from] byte_stream::error::Error),
256}
257
258#[derive(Error, Debug, PartialEq)]
259pub enum PackageOpError {
260    #[error("Commit error: {0}")]
261    Commit(String),
262
263    #[error("Push error: {0}")]
264    Push(String),
265
266    #[error("Publish error: {0}")]
267    Publish(String),
268
269    #[error("General error regarding package: {0}")]
270    Package(String),
271
272    #[error("Pull blocked by conflicting local changes: {0:?}")]
273    PullConflict(Vec<PathBuf>),
274
275    #[error("package is already up-to-date")]
276    AlreadyUpToDate,
277}
278
279/// The error type for this library
280#[derive(Error, Debug)]
281pub enum Error {
282    #[error("Authentication failed for {0}: {1}")]
283    Auth(Host, AuthError),
284
285    #[error(transparent)]
286    Checksum(#[from] ChecksumError),
287
288    #[error(transparent)]
289    Fs(#[from] FsError),
290
291    #[error(transparent)]
292    InstallPackage(InstallPackageError),
293
294    #[error(transparent)]
295    InstallPath(InstallPathError),
296
297    #[error("IO error: {0}")]
298    Io(#[from] std::io::Error),
299
300    #[error("JSON error: {0}")]
301    Json(#[from] serde_json::Error),
302
303    #[error(transparent)]
304    Lineage(#[from] LineageError),
305
306    #[error(transparent)]
307    Login(#[from] LoginError),
308
309    #[error(transparent)]
310    Manifest(#[from] ManifestError),
311
312    #[error(transparent)]
313    ObjectHash(#[from] ObjectHashError),
314
315    #[error(transparent)]
316    PackageOp(#[from] PackageOpError),
317
318    #[error("Reqwest error: {0}")]
319    Reqwest(#[from] reqwest::Error),
320
321    #[error(transparent)]
322    Role(#[from] RoleError),
323
324    #[error(transparent)]
325    RemoteCatalog(#[from] RemoteCatalogError),
326
327    #[error(transparent)]
328    S3(#[from] S3Error),
329
330    #[error("Cannot convert to string: {0}")]
331    ToString(#[from] ToStrError),
332
333    #[error("Integer conversion error: {0}")]
334    TryFromIntError(#[from] std::num::TryFromIntError),
335
336    #[error("Unimplemented")]
337    Unimplemented,
338
339    #[error(transparent)]
340    Uri(#[from] UriError),
341
342    #[error("Error parsing URL: {0}")]
343    UrlParse(#[from] url::ParseError),
344
345    #[error("UTF-8 error: {0}")]
346    Utf8(#[from] Utf8Error),
347
348    #[error(transparent)]
349    WorkflowValidation(#[from] WorkflowValidationError),
350
351    #[error("YAML error: {0}")]
352    Yaml(#[from] serde_yaml::Error),
353}
354
355/// Map a workflow config error onto the existing `Error` variants so the
356/// consumer-visible `Display` and variant matching are byte-identical to before
357/// the crate extraction. The mapping is cross-variant (a single `ConfigError`
358/// splits across `RemoteCatalog` and `Uri`), so it cannot be a `#[from]` field
359/// attribute.
360impl From<crate::workflow::ConfigError> for Error {
361    fn from(err: crate::workflow::ConfigError) -> Self {
362        use crate::workflow::ConfigError;
363        match err {
364            ConfigError::Workflow(msg) => Error::RemoteCatalog(RemoteCatalogError::Workflow(msg)),
365            ConfigError::InvalidWorkflowsConfig(msg) => {
366                Error::RemoteCatalog(RemoteCatalogError::InvalidWorkflowsConfig(msg))
367            }
368            ConfigError::Uri(err) => Error::Uri(err),
369        }
370    }
371}
372
373impl Error {
374    /// Returns `true` if this error represents a "not found" — an S3
375    /// `NoSuchKey` response, or a local filesystem miss (a working-tree file
376    /// that was deleted). Lets callers tell an absent object/file apart from a
377    /// genuine I/O failure (permission denied, transient storage error).
378    #[must_use]
379    pub fn is_not_found(&self) -> bool {
380        match self {
381            Error::S3(s3) => s3.is_not_found(),
382            Error::Fs(FsError::NotFound { .. }) => true,
383            Error::Fs(FsError::Read { source, .. }) => {
384                source.kind() == std::io::ErrorKind::NotFound
385            }
386            Error::Io(e) => e.kind() == std::io::ErrorKind::NotFound,
387            _ => false,
388        }
389    }
390
391    /// Returns `true` if S3 refused the call with the `AccessDenied` code.
392    ///
393    /// The mirror of [`Error::is_not_found`] for the role dimension: callers
394    /// that only ever see an [`enum@Error`] need this to tell "the active role
395    /// cannot reach this object" apart from a generic storage failure, so
396    /// every layer that re-wraps an S3 error must let this variant through
397    /// unchanged.
398    #[must_use]
399    pub fn is_access_denied(&self) -> bool {
400        matches!(self, Error::S3(s3) if s3.is_access_denied())
401    }
402}
403
404// Compose `?` across two From hops: external error → focused enum → Error.
405// Rust's `?` only runs one `From::from`, so these bridges make call sites
406// keep working without `.map_err(..)`.
407
408impl From<multihash::Error> for Error {
409    fn from(err: multihash::Error) -> Self {
410        Error::ObjectHash(err.into())
411    }
412}
413
414impl From<multibase::Error> for Error {
415    fn from(err: multibase::Error) -> Self {
416        Error::ObjectHash(err.into())
417    }
418}
419
420impl From<byte_stream::error::Error> for Error {
421    fn from(err: byte_stream::error::Error) -> Self {
422        FsError::ByteStream(err).into()
423    }
424}
425
426impl From<StripPrefixError> for Error {
427    fn from(err: StripPrefixError) -> Self {
428        Error::Fs(FsError::PathPrefixNotFound(err))
429    }
430}