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