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