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