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    // And distinct from that one, because the two read the same to a client and
66    // mean opposite things to an operator. `RateLimited` is the forge saying it
67    // has had enough of this server. This is the server saying it has had enough
68    // on the forge's behalf, before the forge is given a reason to refuse
69    // everybody at once.
70    #[error("this server is not asking the forge again yet, retry in {retry_after} seconds")]
71    LookupBudgetSpent { retry_after: u64 },
72
73    #[error("this server is at its concurrent transfer limit, retry in {retry_after} seconds")]
74    TransfersSaturated { retry_after: u64 },
75
76    #[error("lock path must not be empty")]
77    MalformedLockPath,
78
79    #[error("lock path is {actual} bytes, and this server accepts at most {limit}")]
80    LockPathTooLong { actual: usize, limit: usize },
81
82    #[error("this repository already holds {limit} locks, and this server refuses to add more")]
83    LockLimitReached { limit: usize },
84
85    #[error("the file is already locked")]
86    LockHeld(Box<crate::locks::Lock>),
87
88    #[error("lock not found")]
89    LockNotFound,
90
91    #[error("object not found")]
92    NotFound,
93
94    #[error("this server does not serve that repository")]
95    NotServed,
96
97    #[error("storage failure: {0}")]
98    Storage(#[from] std::io::Error),
99
100    #[error("could not serialise: {0}")]
101    Serialisation(#[from] serde_json::Error),
102}
103
104const CHALLENGE: HeaderValue = HeaderValue::from_static("Basic realm=\"Git LFS\"");
105
106impl Error {
107    fn status(&self) -> StatusCode {
108        match self {
109            Self::MalformedOid
110            | Self::MalformedLockPath
111            | Self::LockPathTooLong { .. }
112            | Self::MalformedNamespace
113            | Self::OidMismatch { .. }
114            | Self::SizeMismatch { .. }
115            | Self::BatchTooLarge { .. } => StatusCode::UNPROCESSABLE_ENTITY,
116            Self::TooLarge { .. } => StatusCode::PAYLOAD_TOO_LARGE,
117            Self::OverQuota { .. } | Self::LockLimitReached { .. } => {
118                StatusCode::INSUFFICIENT_STORAGE
119            }
120            Self::CompressionDisabled => StatusCode::CONFLICT,
121            // The object is there and the request was fine; this server cannot
122            // serve it, which is a fact about the deployment.
123            Self::NotDecryptable | Self::UnknownKey => StatusCode::INTERNAL_SERVER_ERROR,
124            Self::Tampered => StatusCode::INTERNAL_SERVER_ERROR,
125            Self::Misconfigured(_) => StatusCode::INTERNAL_SERVER_ERROR,
126            Self::Unsupported(_) => StatusCode::NOT_IMPLEMENTED,
127            Self::Unauthenticated => StatusCode::UNAUTHORIZED,
128            Self::Forbidden => StatusCode::FORBIDDEN,
129            Self::LockHeld(_) => StatusCode::CONFLICT,
130            Self::NotFound | Self::LockNotFound | Self::NotServed => StatusCode::NOT_FOUND,
131            Self::Forge => StatusCode::BAD_GATEWAY,
132            // Not 502: a bad gateway invites an immediate retry, which is the
133            // one thing that must not happen here.
134            Self::RateLimited { .. }
135            | Self::LookupBudgetSpent { .. }
136            | Self::TransfersSaturated { .. } => StatusCode::SERVICE_UNAVAILABLE,
137            Self::Storage(_) | Self::Serialisation(_) => StatusCode::INTERNAL_SERVER_ERROR,
138        }
139    }
140}
141
142impl Error {
143    fn cause(&self) -> &'static str {
144        match self {
145            Self::MalformedOid => "malformed_oid",
146            Self::MalformedNamespace => "malformed_namespace",
147            Self::MalformedLockPath => "malformed_lock_path",
148            Self::LockPathTooLong { .. } => "lock_path_too_long",
149            Self::LockLimitReached { .. } => "lock_limit_reached",
150            Self::OidMismatch { .. } => "oid_mismatch",
151            Self::SizeMismatch { .. } => "size_mismatch",
152            Self::TooLarge { .. } => "too_large",
153            Self::BatchTooLarge { .. } => "batch_too_large",
154            Self::OverQuota { .. } => "over_quota",
155            Self::CompressionDisabled => "compression_disabled",
156            Self::NotDecryptable => "not_decryptable",
157            Self::UnknownKey => "unknown_key",
158            Self::Tampered => "tampered",
159            Self::Misconfigured(_) => "misconfigured",
160            Self::Unsupported(_) => "unsupported",
161            Self::Unauthenticated => "unauthenticated",
162            Self::Forbidden => "forbidden",
163            Self::Forge => "forge_unreachable",
164            // "the forge is throttling us" and "the forge is broken" are
165            // different afternoons, and sharing one label hides which.
166            Self::RateLimited { .. } => "forge_rate_limited",
167            Self::LookupBudgetSpent { .. } => "lookup_budget_spent",
168            Self::TransfersSaturated { .. } => "transfers_saturated",
169            Self::LockHeld(_) => "lock_held",
170            Self::LockNotFound => "lock_not_found",
171            Self::NotFound => "not_found",
172            Self::NotServed => "not_served",
173            Self::Storage(_) => "storage",
174            Self::Serialisation(_) => "serialisation",
175        }
176    }
177}
178
179impl IntoResponse for Error {
180    fn into_response(self) -> Response {
181        let status = self.status();
182        let cause = crate::metrics::Cause(self.cause());
183
184        if status.is_server_error() {
185            tracing::error!(error = %self, "request failed");
186        }
187
188        // Everything else in this enum speaks in sentences written here, for
189        // the person holding the curl. These two carry whatever the operating
190        // system or the JSON parser said, which can name paths and offsets
191        // that are the log's business: the line above already has the detail,
192        // and the client gets the only fact that is theirs.
193        if let Self::Storage(_) | Self::Serialisation(_) = &self {
194            let mut response = (
195                status,
196                Json(json!({ "message": "the server could not complete this request" })),
197            )
198                .into_response();
199            response.extensions_mut().insert(cause);
200            return response;
201        }
202
203        if let Self::RateLimited { retry_after }
204        | Self::LookupBudgetSpent { retry_after }
205        | Self::TransfersSaturated { retry_after } = &self
206        {
207            let mut response = (
208                status,
209                [(header::RETRY_AFTER, retry_after.to_string())],
210                Json(json!({ "message": self.to_string() })),
211            )
212                .into_response();
213            response.extensions_mut().insert(cause);
214            return response;
215        }
216
217        if let Self::LockHeld(lock) = &self {
218            let mut response = (
219                status,
220                Json(json!({ "lock": lock, "message": self.to_string() })),
221            )
222                .into_response();
223            response.extensions_mut().insert(cause);
224            return response;
225        }
226
227        let body = Json(json!({ "message": self.to_string() }));
228        let mut response = if status == StatusCode::UNAUTHORIZED {
229            (
230                status,
231                [
232                    (header::WWW_AUTHENTICATE, CHALLENGE),
233                    (
234                        header::HeaderName::from_static("lfs-authenticate"),
235                        CHALLENGE,
236                    ),
237                ],
238                body,
239            )
240                .into_response()
241        } else {
242            (status, body).into_response()
243        };
244
245        response.extensions_mut().insert(cause);
246        response
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    async fn body_of(error: Error) -> (StatusCode, String) {
255        let response = error.into_response();
256        let status = response.status();
257        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
258            .await
259            .unwrap();
260
261        (status, String::from_utf8(bytes.to_vec()).unwrap())
262    }
263
264    // The property is that nothing the operating system said crosses into the
265    // HTTP body: an io::Error can carry a path, and this is the only test that
266    // fails if somebody puts `self.to_string()` back on this arm.
267    #[tokio::test]
268    async fn a_storage_failure_does_not_quote_the_operating_system() {
269        let inner = std::io::Error::new(
270            std::io::ErrorKind::NotFound,
271            "/var/lib/lfsx/org/repo/ab/cd/abcdef (No such file or directory)",
272        );
273
274        let (status, body) = body_of(Error::Storage(inner)).await;
275
276        assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
277        assert!(!body.contains("/var/lib/lfsx"), "{body}");
278        assert!(!body.contains("No such file"), "{body}");
279        assert!(
280            body.contains("the server could not complete this request"),
281            "{body}"
282        );
283    }
284
285    #[tokio::test]
286    async fn a_serialisation_failure_does_not_quote_the_parser() {
287        let inner = serde_json::from_str::<serde_json::Value>("{broken").unwrap_err();
288
289        let (status, body) = body_of(Error::Serialisation(inner)).await;
290
291        assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
292        assert!(!body.contains("line 1"), "{body}");
293        assert!(
294            body.contains("the server could not complete this request"),
295            "{body}"
296        );
297    }
298
299    // The refusals whose wording is the feature keep it: this arm must never
300    // widen into a blanket 5xx scrub, because these sentences are how an
301    // operator learns which deployment fact bit them.
302    #[tokio::test]
303    async fn a_deployment_refusal_keeps_its_own_words() {
304        let (status, body) = body_of(Error::NotDecryptable).await;
305
306        assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
307        assert!(body.contains("LFSX_ENCRYPTION_KEY_FILE"), "{body}");
308    }
309}