Skip to main content

lfsx_server/
error.rs

1use axum::Json;
2use axum::http::{HeaderValue, StatusCode, header};
3use axum::response::{IntoResponse, Response};
4use serde_json::json;
5
6#[derive(Debug, thiserror::Error)]
7pub enum Error {
8    #[error("object id is not a lowercase hex sha256 digest")]
9    MalformedOid,
10
11    #[error("organisation and repository must be plain names")]
12    MalformedNamespace,
13
14    #[error("content hashes to {actual}, which does not match the declared object id {declared}")]
15    OidMismatch { declared: String, actual: String },
16
17    #[error("content is {actual} bytes, but {declared} were declared")]
18    SizeMismatch { declared: u64, actual: u64 },
19
20    #[error("object exceeds the {limit} byte limit this server accepts")]
21    TooLarge { limit: u64 },
22
23    #[error(
24        "a batch asks about {asked} objects, and this server answers at most {limit} at a time"
25    )]
26    BatchTooLarge { asked: usize, limit: usize },
27
28    #[error("this repository holds {used} bytes of its {limit} byte budget")]
29    OverQuota { used: u64, limit: u64 },
30
31    #[error("this server does not compress objects — set LFSX_COMPRESSION first")]
32    CompressionDisabled,
33
34    #[error("this object is encrypted and this server holds no key — set LFSX_ENCRYPTION_KEY_FILE")]
35    NotDecryptable,
36
37    #[error("this object was encrypted with a key this server does not hold")]
38    UnknownKey,
39
40    #[error("this object failed its integrity check — the bytes on disk are not what was stored")]
41    Tampered,
42
43    #[error("{0}")]
44    Misconfigured(&'static str),
45
46    #[error("{0}")]
47    Unsupported(&'static str),
48
49    #[error("credentials are required for this repository")]
50    Unauthenticated,
51
52    #[error("these credentials do not grant that access to this repository")]
53    Forbidden,
54
55    #[error("the forge could not be reached to check permissions")]
56    Forge,
57
58    // Distinct from `Forge` on purpose. A throttled forge is not a broken one:
59    // it is working, it has said when to come back, and the answer has to carry
60    // that so a client waits instead of spending the next request on the same
61    // exhausted quota.
62    #[error("the forge is rate-limiting this server — retry in {retry_after} seconds")]
63    RateLimited { retry_after: u64 },
64
65    #[error("lock path must not be empty")]
66    MalformedLockPath,
67
68    #[error("the file is already locked")]
69    LockHeld(Box<crate::locks::Lock>),
70
71    #[error("lock not found")]
72    LockNotFound,
73
74    #[error("object not found")]
75    NotFound,
76
77    #[error("storage failure: {0}")]
78    Storage(#[from] std::io::Error),
79
80    #[error("could not serialise: {0}")]
81    Serialisation(#[from] serde_json::Error),
82}
83
84const CHALLENGE: HeaderValue = HeaderValue::from_static("Basic realm=\"Git LFS\"");
85
86impl Error {
87    fn status(&self) -> StatusCode {
88        match self {
89            Self::MalformedOid
90            | Self::MalformedLockPath
91            | Self::MalformedNamespace
92            | Self::OidMismatch { .. }
93            | Self::SizeMismatch { .. }
94            | Self::BatchTooLarge { .. } => StatusCode::UNPROCESSABLE_ENTITY,
95            Self::TooLarge { .. } => StatusCode::PAYLOAD_TOO_LARGE,
96            Self::OverQuota { .. } => StatusCode::INSUFFICIENT_STORAGE,
97            Self::CompressionDisabled => StatusCode::CONFLICT,
98            // The object is there and the request was fine; this server cannot
99            // serve it, which is a fact about the deployment.
100            Self::NotDecryptable | Self::UnknownKey => StatusCode::INTERNAL_SERVER_ERROR,
101            Self::Tampered => StatusCode::INTERNAL_SERVER_ERROR,
102            Self::Misconfigured(_) => StatusCode::INTERNAL_SERVER_ERROR,
103            Self::Unsupported(_) => StatusCode::NOT_IMPLEMENTED,
104            Self::Unauthenticated => StatusCode::UNAUTHORIZED,
105            Self::Forbidden => StatusCode::FORBIDDEN,
106            Self::LockHeld(_) => StatusCode::CONFLICT,
107            Self::NotFound | Self::LockNotFound => StatusCode::NOT_FOUND,
108            Self::Forge => StatusCode::BAD_GATEWAY,
109            // Not 502: a bad gateway invites an immediate retry, which is the
110            // one thing that must not happen here.
111            Self::RateLimited { .. } => StatusCode::SERVICE_UNAVAILABLE,
112            Self::Storage(_) | Self::Serialisation(_) => StatusCode::INTERNAL_SERVER_ERROR,
113        }
114    }
115}
116
117impl Error {
118    fn cause(&self) -> &'static str {
119        match self {
120            Self::MalformedOid => "malformed_oid",
121            Self::MalformedNamespace => "malformed_namespace",
122            Self::MalformedLockPath => "malformed_lock_path",
123            Self::OidMismatch { .. } => "oid_mismatch",
124            Self::SizeMismatch { .. } => "size_mismatch",
125            Self::TooLarge { .. } => "too_large",
126            Self::BatchTooLarge { .. } => "batch_too_large",
127            Self::OverQuota { .. } => "over_quota",
128            Self::CompressionDisabled => "compression_disabled",
129            Self::NotDecryptable => "not_decryptable",
130            Self::UnknownKey => "unknown_key",
131            Self::Tampered => "tampered",
132            Self::Misconfigured(_) => "misconfigured",
133            Self::Unsupported(_) => "unsupported",
134            Self::Unauthenticated => "unauthenticated",
135            Self::Forbidden => "forbidden",
136            Self::Forge => "forge_unreachable",
137            // "the forge is throttling us" and "the forge is broken" are
138            // different afternoons, and sharing one label hides which.
139            Self::RateLimited { .. } => "forge_rate_limited",
140            Self::LockHeld(_) => "lock_held",
141            Self::LockNotFound => "lock_not_found",
142            Self::NotFound => "not_found",
143            Self::Storage(_) => "storage",
144            Self::Serialisation(_) => "serialisation",
145        }
146    }
147}
148
149impl IntoResponse for Error {
150    fn into_response(self) -> Response {
151        let status = self.status();
152        let cause = crate::metrics::Cause(self.cause());
153
154        if status.is_server_error() {
155            tracing::error!(error = %self, "request failed");
156        }
157
158        if let Self::RateLimited { retry_after } = &self {
159            let mut response = (
160                status,
161                [(header::RETRY_AFTER, retry_after.to_string())],
162                Json(json!({ "message": self.to_string() })),
163            )
164                .into_response();
165            response.extensions_mut().insert(cause);
166            return response;
167        }
168
169        if let Self::LockHeld(lock) = &self {
170            let mut response = (
171                status,
172                Json(json!({ "lock": lock, "message": self.to_string() })),
173            )
174                .into_response();
175            response.extensions_mut().insert(cause);
176            return response;
177        }
178
179        let body = Json(json!({ "message": self.to_string() }));
180        let mut response = if status == StatusCode::UNAUTHORIZED {
181            (
182                status,
183                [
184                    (header::WWW_AUTHENTICATE, CHALLENGE),
185                    (
186                        header::HeaderName::from_static("lfs-authenticate"),
187                        CHALLENGE,
188                    ),
189                ],
190                body,
191            )
192                .into_response()
193        } else {
194            (status, body).into_response()
195        };
196
197        response.extensions_mut().insert(cause);
198        response
199    }
200}