Skip to main content

git_lfs_api/
locks.rs

1//! Locking API: create, list, verify, and delete file locks.
2//!
3//! See `docs/api/locking.md` for the wire-protocol contract.
4
5use serde::{Deserialize, Serialize};
6
7use crate::client::{Client, decode};
8use crate::error::ApiError;
9use crate::models::{Lock, Ref};
10
11// ---- create ---------------------------------------------------------------
12
13/// POST `/locks` body.
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
15pub struct CreateLockRequest {
16    pub path: String,
17    #[serde(default, skip_serializing_if = "Option::is_none")]
18    pub r#ref: Option<Ref>,
19}
20
21impl CreateLockRequest {
22    pub fn new(path: impl Into<String>) -> Self {
23        Self {
24            path: path.into(),
25            r#ref: None,
26        }
27    }
28
29    pub fn with_ref(mut self, r: Ref) -> Self {
30        self.r#ref = Some(r);
31        self
32    }
33}
34
35#[derive(Debug, Deserialize)]
36struct LockEnvelope {
37    lock: Lock,
38}
39
40/// Flexible POST `/locks` response decoder. The reference test server
41/// returns `{"message": "lock already created"}` at HTTP 200 for the
42/// "path is already locked" case (no `lock` field, no 409 status), so a
43/// strict envelope deserialize would blow up with a missing-field
44/// error. We accept `lock` and `message` independently and let
45/// [`Client::create_lock`] interpret which arrived.
46#[derive(Debug, Deserialize)]
47struct CreateLockResponse {
48    #[serde(default)]
49    lock: Option<Lock>,
50    #[serde(default)]
51    message: Option<String>,
52}
53
54/// Errors specific to [`Client::create_lock`].
55///
56/// Wraps [`ApiError`] but adds a typed `Conflict` for the in-band
57/// "already locked" case. `existing` is `Some` for servers that return
58/// HTTP 409 with the conflicting lock attached; `None` for servers that
59/// only ship a message.
60#[derive(Debug, thiserror::Error)]
61pub enum CreateLockError {
62    #[error("lock conflict: {message}")]
63    Conflict {
64        existing: Option<Lock>,
65        message: String,
66    },
67
68    #[error(transparent)]
69    Api(#[from] ApiError),
70}
71
72// ---- list -----------------------------------------------------------------
73
74/// Filter for `GET /locks`. All fields are optional; absent ones are not
75/// sent on the wire.
76#[derive(Debug, Default, Clone, Serialize)]
77pub struct ListLocksFilter {
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub path: Option<String>,
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub id: Option<String>,
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub cursor: Option<String>,
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub limit: Option<u32>,
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub refspec: Option<String>,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
91pub struct LockList {
92    /// Go LFS servers serialize an empty result as `"locks": null`
93    /// rather than `"locks": []`; treat null as the empty list.
94    #[serde(default, deserialize_with = "deserialize_null_as_default")]
95    pub locks: Vec<Lock>,
96    /// Opaque cursor; pass back as `cursor` in the next request to continue.
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub next_cursor: Option<String>,
99}
100
101// ---- verify ---------------------------------------------------------------
102
103#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
104pub struct VerifyLocksRequest {
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub r#ref: Option<Ref>,
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub cursor: Option<String>,
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub limit: Option<u32>,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
114pub struct VerifyLocksResponse {
115    /// Locks owned by the authenticated user. Servers may serialize an
116    /// empty list as `null`; `deserialize_null_as_default` normalizes
117    /// that to `Vec::new()`.
118    #[serde(default, deserialize_with = "deserialize_null_as_default")]
119    pub ours: Vec<Lock>,
120    /// Locks owned by other users. Same null-handling as `ours`.
121    #[serde(default, deserialize_with = "deserialize_null_as_default")]
122    pub theirs: Vec<Lock>,
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub next_cursor: Option<String>,
125}
126
127// ---- delete ---------------------------------------------------------------
128
129#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
130pub struct DeleteLockRequest {
131    /// True to delete a lock owned by another user. Server enforces auth.
132    #[serde(default, skip_serializing_if = "is_false")]
133    pub force: bool,
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub r#ref: Option<Ref>,
136}
137
138fn is_false(b: &bool) -> bool {
139    !*b
140}
141
142/// Treat a JSON `null` as `T::default()`. Go's `encoding/json` serializes
143/// a `nil` slice as `null` rather than `[]`, and the LFS reference server
144/// (and lfstest-gitserver) inherits that — so a request that legitimately
145/// returns "no locks" looks like `{"ours": null}`. Without this, our
146/// `Vec<Lock>` deserialize bombs on the null.
147fn deserialize_null_as_default<'de, D, T>(d: D) -> Result<T, D::Error>
148where
149    D: serde::Deserializer<'de>,
150    T: Default + serde::Deserialize<'de>,
151{
152    let opt = Option::<T>::deserialize(d)?;
153    Ok(opt.unwrap_or_default())
154}
155
156// ---- client ---------------------------------------------------------------
157
158impl Client {
159    /// POST `/locks` to create a new lock.
160    ///
161    /// Body decoding is flexible to accommodate both spec'd 409 → existing
162    /// lock responses and the reference test server's "200 with `message`
163    /// but no `lock`" in-band-conflict pattern.
164    pub async fn create_lock(&self, req: &CreateLockRequest) -> Result<Lock, CreateLockError> {
165        // SSH resolution defaults to "upload" for POST-style mutations,
166        // matching upstream's `endpointOperation` (POST → upload).
167        let (base, ssh) = self
168            .resolve_ssh(crate::ssh::SshOperation::Upload)
169            .map_err(CreateLockError::Api)?;
170        let url = Client::join(&base, "locks").map_err(CreateLockError::Api)?;
171        // Serialize once so the closure (which may run twice — once
172        // with current auth, once after a 401 → fill) doesn't re-encode
173        // the body each time.
174        let body_bytes = serde_json::to_vec(req)
175            .map_err(|e| CreateLockError::Api(ApiError::Decode(e.to_string())))?;
176        let resp = self
177            .send_with_auth_retry_response(|| {
178                self.request_with_headers(reqwest::Method::POST, url.clone(), &ssh)
179                    .header(reqwest::header::CONTENT_TYPE, crate::client::LFS_MEDIA_TYPE)
180                    .body(body_bytes.clone())
181            })
182            .await
183            .map_err(CreateLockError::Api)?;
184
185        let status = resp.status();
186        let request_url = resp.url().to_string();
187        let bytes = resp
188            .bytes()
189            .await
190            .map_err(|e| CreateLockError::Api(ApiError::Transport(e)))?;
191
192        // 409 = standard conflict, with the existing lock spelled out in
193        // the body. Decode flexibly: server may or may not include a
194        // `message` alongside the lock.
195        if status.as_u16() == 409 {
196            let parsed: CreateLockResponse = serde_json::from_slice(&bytes)
197                .map_err(|e| CreateLockError::Api(ApiError::Decode(e.to_string())))?;
198            return Err(CreateLockError::Conflict {
199                existing: parsed.lock,
200                message: parsed.message.unwrap_or_else(|| "lock conflict".into()),
201            });
202        }
203
204        // Other non-success statuses fall through as plain ApiError::Status.
205        if !status.is_success() {
206            let body: Option<crate::error::ServerError> = serde_json::from_slice(&bytes).ok();
207            return Err(CreateLockError::Api(ApiError::Status {
208                status: status.as_u16(),
209                url: Some(request_url),
210                lfs_authenticate: None,
211                body,
212                retry_after: None,
213            }));
214        }
215
216        // 2xx — could be {lock: ...} success or {message: ...}
217        // in-band conflict.
218        let parsed: CreateLockResponse = serde_json::from_slice(&bytes)
219            .map_err(|e| CreateLockError::Api(ApiError::Decode(e.to_string())))?;
220        if let Some(lock) = parsed.lock {
221            return Ok(lock);
222        }
223        if let Some(message) = parsed.message {
224            return Err(CreateLockError::Conflict {
225                existing: None,
226                message,
227            });
228        }
229        Err(CreateLockError::Api(ApiError::Decode(
230            "create-lock response had neither lock nor message".into(),
231        )))
232    }
233
234    /// GET `/locks` with optional filters. SSH-resolved as a download
235    /// operation (read-only listing).
236    pub async fn list_locks(&self, filter: &ListLocksFilter) -> Result<LockList, ApiError> {
237        self.get_json("locks", filter, crate::ssh::SshOperation::Download)
238            .await
239    }
240
241    /// POST `/locks/verify` to list locks partitioned into ours/theirs.
242    ///
243    /// Per the spec, servers that don't implement locking can return 404
244    /// here; that surfaces as `ApiError::Status { status: 404, .. }`. The
245    /// caller (typically push) should treat that as "no locks to verify"
246    /// rather than a hard failure — see `is_not_found()`.
247    pub async fn verify_locks(
248        &self,
249        req: &VerifyLocksRequest,
250    ) -> Result<VerifyLocksResponse, ApiError> {
251        // POST default = upload, matching upstream's `endpointOperation`.
252        // verify_locks is called pre-push (upload context), so this also
253        // matches the caller's intent.
254        self.post_json("locks/verify", req, crate::ssh::SshOperation::Upload)
255            .await
256    }
257
258    /// POST `/locks/{id}/unlock` to delete a lock.
259    pub async fn delete_lock(&self, id: &str, req: &DeleteLockRequest) -> Result<Lock, ApiError> {
260        // Percent-encode the id to keep nested path segments safe.
261        let encoded = url_path_segment(id);
262        let path = format!("locks/{encoded}/unlock");
263        // POST → upload SSH operation (matches `endpointOperation`).
264        let (base, ssh) = self.resolve_ssh(crate::ssh::SshOperation::Upload)?;
265        let url = Client::join(&base, &path)?;
266        let body_bytes = serde_json::to_vec(req).map_err(|e| ApiError::Decode(e.to_string()))?;
267        let resp = self
268            .send_with_auth_retry_response(|| {
269                self.request_with_headers(reqwest::Method::POST, url.clone(), &ssh)
270                    .header(reqwest::header::CONTENT_TYPE, crate::client::LFS_MEDIA_TYPE)
271                    .body(body_bytes.clone())
272            })
273            .await?;
274        let env: LockEnvelope = decode(resp).await?;
275        Ok(env.lock)
276    }
277}
278
279/// Minimal percent-encoder for one URL path segment. Encodes anything that
280/// isn't an unreserved character per RFC 3986.
281fn url_path_segment(s: &str) -> String {
282    let mut out = String::with_capacity(s.len());
283    for b in s.bytes() {
284        let unreserved = b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~');
285        if unreserved {
286            out.push(b as char);
287        } else {
288            out.push_str(&format!("%{b:02X}"));
289        }
290    }
291    out
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    #[test]
299    fn list_filter_omits_none_fields() {
300        // serde_json round-trip keeps only the fields we actually want on
301        // the wire — same omission rule reqwest applies when building the
302        // query string.
303        let f = ListLocksFilter {
304            path: Some("a.bin".into()),
305            ..Default::default()
306        };
307        let v = serde_json::to_value(&f).unwrap();
308        assert_eq!(v["path"], "a.bin");
309        assert!(v.get("id").is_none());
310        assert!(v.get("cursor").is_none());
311        assert!(v.get("limit").is_none());
312        assert!(v.get("refspec").is_none());
313    }
314
315    #[test]
316    fn delete_request_omits_force_when_false() {
317        let r = DeleteLockRequest::default();
318        let v = serde_json::to_value(&r).unwrap();
319        assert!(v.get("force").is_none());
320    }
321
322    #[test]
323    fn delete_request_includes_force_when_true() {
324        let r = DeleteLockRequest {
325            force: true,
326            ..Default::default()
327        };
328        assert_eq!(serde_json::to_value(&r).unwrap()["force"], true);
329    }
330
331    #[test]
332    fn parses_create_lock_envelope() {
333        let body = r#"{
334            "lock": {
335                "id": "some-uuid", "path": "foo/bar.zip",
336                "locked_at": "2016-05-17T15:49:06+00:00",
337                "owner": { "name": "Jane Doe" }
338            }
339        }"#;
340        let env: LockEnvelope = serde_json::from_str(body).unwrap();
341        assert_eq!(env.lock.path, "foo/bar.zip");
342        assert_eq!(env.lock.owner.unwrap().name, "Jane Doe");
343    }
344
345    #[test]
346    fn parses_create_lock_response_with_lock() {
347        let body = r#"{
348            "lock": { "id": "x", "path": "foo", "locked_at": "2016-01-01T00:00:00Z" }
349        }"#;
350        let parsed: CreateLockResponse = serde_json::from_str(body).unwrap();
351        assert!(parsed.lock.is_some());
352        assert_eq!(parsed.lock.unwrap().id, "x");
353        assert!(parsed.message.is_none());
354    }
355
356    #[test]
357    fn parses_create_lock_response_message_only() {
358        // Reference test server's "already locked" response shape.
359        let body = r#"{"message":"lock already created"}"#;
360        let parsed: CreateLockResponse = serde_json::from_str(body).unwrap();
361        assert!(parsed.lock.is_none());
362        assert_eq!(parsed.message.as_deref(), Some("lock already created"));
363    }
364
365    #[test]
366    fn url_path_segment_encodes_special() {
367        assert_eq!(url_path_segment("abc-123_xyz.~"), "abc-123_xyz.~");
368        assert_eq!(url_path_segment("a/b"), "a%2Fb");
369        assert_eq!(url_path_segment("hello world"), "hello%20world");
370    }
371}