Skip to main content

git_lfs_api/
batch.rs

1//! Batch API: request the ability to transfer LFS objects.
2//!
3//! See `docs/api/batch.md` for the wire-protocol contract.
4
5use std::collections::HashMap;
6use std::time::{Duration, SystemTime};
7
8use serde::{Deserialize, Deserializer, Serialize};
9
10use crate::client::Client;
11use crate::error::ApiError;
12use crate::models::Ref;
13
14/// Deserialize a size field as `u64`, but reject negative values with the
15/// exact wording upstream's `git-lfs` emits so test fixtures keyed on it
16/// (notably `t-push.sh::push (with invalid object size)`) keep matching.
17fn deserialize_object_size<'de, D: Deserializer<'de>>(d: D) -> Result<u64, D::Error> {
18    use serde::de::Error;
19    let v = i64::deserialize(d)?;
20    if v < 0 {
21        return Err(D::Error::custom(format!("invalid size (got: {v})")));
22    }
23    Ok(v as u64)
24}
25
26/// `ObjectResult.size` is missing entirely from some servers (see the
27/// `serde(default)` comment), so the deserializer must also tolerate
28/// absence. `#[serde(default, deserialize_with = ...)]` requires the
29/// `deserialize_with` to handle the present case only — defaulting
30/// happens before this runs — so this is structurally the same as
31/// `deserialize_object_size` but kept separate to keep the call sites
32/// self-documenting.
33fn deserialize_optional_object_size<'de, D: Deserializer<'de>>(d: D) -> Result<u64, D::Error> {
34    deserialize_object_size(d)
35}
36
37/// Operation requested from the batch endpoint.
38#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
39#[serde(rename_all = "lowercase")]
40pub enum Operation {
41    /// Fetching objects from the server.
42    Download,
43    /// Sending objects to the server.
44    Upload,
45}
46
47/// One object the client wants to transfer.
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
49pub struct ObjectSpec {
50    /// SHA-256 of the object's content, as 64-character lowercase hex.
51    pub oid: String,
52    /// Size of the object in bytes.
53    pub size: u64,
54}
55
56/// A POST body for `/objects/batch`.
57#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
58pub struct BatchRequest {
59    /// Whether this batch is for uploading or downloading objects.
60    pub operation: Operation,
61    /// Transfer adapter identifiers the client supports. If empty, the spec
62    /// says the server MUST assume `basic`. We send the field unconditionally
63    /// so the server's preferred adapter is well-defined.
64    #[serde(default, skip_serializing_if = "Vec::is_empty")]
65    pub transfers: Vec<String>,
66    /// Optional ref scope for the batch. Some servers grant or deny
67    /// access based on the ref being pushed or fetched.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub r#ref: Option<Ref>,
70    /// The objects to transfer.
71    pub objects: Vec<ObjectSpec>,
72    /// Optional hash algorithm. Defaults to `sha256` per the spec.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub hash_algo: Option<String>,
75}
76
77impl BatchRequest {
78    /// Build a request for `operation` over the given `objects`.
79    ///
80    /// `transfers`, `r#ref`, and `hash_algo` are left empty; set them
81    /// via the builder methods below.
82    pub fn new(operation: Operation, objects: Vec<ObjectSpec>) -> Self {
83        Self {
84            operation,
85            transfers: Vec::new(),
86            r#ref: None,
87            objects,
88            hash_algo: None,
89        }
90    }
91
92    /// Set the list of supported transfer-adapter identifiers.
93    pub fn with_transfers(mut self, transfers: impl IntoIterator<Item = String>) -> Self {
94        self.transfers = transfers.into_iter().collect();
95        self
96    }
97
98    /// Set the ref scope for the batch.
99    pub fn with_ref(mut self, r: Ref) -> Self {
100        self.r#ref = Some(r);
101        self
102    }
103}
104
105/// Response body from `/objects/batch`.
106#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
107pub struct BatchResponse {
108    /// Transfer adapter the server picked. `None` means the server omitted
109    /// it; per the spec the client should assume `basic`.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub transfer: Option<String>,
112    /// Per-object results, one per [`ObjectSpec`] in the request.
113    pub objects: Vec<ObjectResult>,
114    /// Hash algorithm the server expects. Absent means `sha256` per the spec.
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub hash_algo: Option<String>,
117}
118
119/// Per-object result inside a batch response.
120///
121/// Either `actions` or `error` is populated; both being absent means
122/// "server already has this object" (an upload no-op).
123#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
124pub struct ObjectResult {
125    /// Echo of the OID from the corresponding [`ObjectSpec`].
126    pub oid: String,
127    /// Size in bytes.
128    ///
129    /// Per the spec this is required, but the upstream
130    /// `lfstest-gitserver` (and at least one production server in
131    /// the wild) omit it on the action path: they assume the client
132    /// already knows. Defaults to 0 so we don't refuse the response;
133    /// callers that need the real size should look it up from the
134    /// matching request entry.
135    #[serde(default, deserialize_with = "deserialize_optional_object_size")]
136    pub size: u64,
137    /// `Some(true)` if the server authenticated this response (and
138    /// the action URLs are pre-signed). Optional in the spec.
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub authenticated: Option<bool>,
141    /// The transfer URLs to use. `None` when `error` is set or when
142    /// the server already has the object.
143    #[serde(default, alias = "_links", skip_serializing_if = "Option::is_none")]
144    pub actions: Option<Actions>,
145    /// Per-object error from the server. `None` on the success path.
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub error: Option<ObjectError>,
148}
149
150/// Per-object error inside a batch response.
151///
152/// Codes mirror HTTP status codes per the batch spec: 404 = not
153/// found, 409 = hash-algo mismatch, 410 = removed, 422 = invalid.
154#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
155pub struct ObjectError {
156    /// HTTP-style status code classifying the error.
157    pub code: u32,
158    /// Human-readable error description.
159    pub message: String,
160}
161
162/// The set of next-step actions the server returned for one object.
163///
164/// Field set depends on `operation`: `download` populates `download`;
165/// `upload` populates `upload` and optionally `verify`.
166#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
167pub struct Actions {
168    /// Action to GET the object bytes. Populated on download batches.
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub download: Option<Action>,
171    /// Action to PUT the object bytes. Populated on upload batches.
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub upload: Option<Action>,
174    /// Optional callback to POST after a successful upload. Lets the
175    /// server confirm the bytes landed before declaring the upload
176    /// complete.
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub verify: Option<Action>,
179}
180
181/// One concrete HTTP request the transfer adapter should make.
182#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
183pub struct Action {
184    /// Absolute URL to dial.
185    pub href: String,
186    /// Headers to include with the request (typically `Authorization`).
187    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
188    pub header: HashMap<String, String>,
189    /// Seconds until the action URL stops being valid. Preferred over
190    /// `expires_at` when both are given. Per the spec, range is roughly
191    /// ±2^31 seconds.
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub expires_in: Option<i64>,
194    /// Absolute uppercase RFC 3339 timestamp at which the action URL
195    /// stops being valid. Carried as a string (see [`Lock`](crate::Lock)).
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub expires_at: Option<String>,
198}
199
200impl Action {
201    /// Has this action expired (or will it within `buffer`)?
202    ///
203    /// Mirrors upstream's `tq.Action.IsExpiredWithin` /
204    /// `tools.IsExpiredAtOrIn`: `expires_in` is taken relative to
205    /// `now` (preferred when non-zero), otherwise `expires_at` is
206    /// parsed as RFC 3339. An action without either field never
207    /// expires. The check is `expiration < now + buffer` — i.e. the
208    /// action must have at least `buffer` of validity left.
209    pub fn is_expired_within(&self, now: SystemTime, buffer: Duration) -> bool {
210        let expiration = match (self.expires_in, self.expires_at.as_deref()) {
211            (Some(secs), _) if secs != 0 => {
212                // Negative `expires_in` means "already expired" — saturate
213                // at UNIX_EPOCH so the comparison below trivially fires.
214                if secs < 0 {
215                    SystemTime::UNIX_EPOCH
216                } else {
217                    now.checked_add(Duration::from_secs(secs as u64))
218                        .unwrap_or(SystemTime::UNIX_EPOCH)
219                }
220            }
221            (_, Some(s)) => match parse_rfc3339(s) {
222                Some(t) => t,
223                None => return false,
224            },
225            _ => return false,
226        };
227        expiration < now + buffer
228    }
229}
230
231/// Minimal RFC 3339 parser — accepts `YYYY-MM-DDThh:mm:ss[.fff][Z|±hh:mm]`.
232/// Pre-epoch / malformed → `None` (treated as "no expiration set" by
233/// callers, matching upstream's `IsZero` short-circuit).
234fn parse_rfc3339(s: &str) -> Option<SystemTime> {
235    let bytes = s.as_bytes();
236    if bytes.len() < 20
237        || bytes[4] != b'-'
238        || bytes[7] != b'-'
239        || bytes[10] != b'T'
240        || bytes[13] != b':'
241        || bytes[16] != b':'
242    {
243        return None;
244    }
245    let year: i32 = s.get(0..4)?.parse().ok()?;
246    let month: u32 = s.get(5..7)?.parse().ok()?;
247    let day: u32 = s.get(8..10)?.parse().ok()?;
248    let hour: u32 = s.get(11..13)?.parse().ok()?;
249    let min: u32 = s.get(14..16)?.parse().ok()?;
250    let sec: u32 = s.get(17..19)?.parse().ok()?;
251
252    let mut idx = 19;
253    if bytes.get(idx) == Some(&b'.') {
254        idx += 1;
255        while bytes.get(idx).is_some_and(|b| b.is_ascii_digit()) {
256            idx += 1;
257        }
258    }
259    let tz_secs: i64 = match bytes.get(idx) {
260        Some(b'Z') | Some(b'z') => 0,
261        Some(b'+') | Some(b'-') => {
262            let sign = if bytes[idx] == b'+' { 1 } else { -1 };
263            let h: i64 = s.get(idx + 1..idx + 3)?.parse().ok()?;
264            let m: i64 = s.get(idx + 4..idx + 6)?.parse().ok()?;
265            sign * (h * 3600 + m * 60)
266        }
267        _ => return None,
268    };
269
270    let days = days_from_civil(year, month, day);
271    let secs_of_day = (hour as i64) * 3600 + (min as i64) * 60 + (sec as i64);
272    let unix = days * 86400 + secs_of_day - tz_secs;
273    if unix < 0 {
274        return None;
275    }
276    Some(SystemTime::UNIX_EPOCH + Duration::from_secs(unix as u64))
277}
278
279/// Days since 1970-01-01 for the proleptic Gregorian date `(y, m, d)`.
280/// Howard Hinnant's days-from-civil algorithm.
281fn days_from_civil(year: i32, month: u32, day: u32) -> i64 {
282    let y = (if month <= 2 { year - 1 } else { year }) as i64;
283    let era = (if y >= 0 { y } else { y - 399 }) / 400;
284    let yoe = y - era * 400;
285    let m = month as i64;
286    let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + day as i64 - 1;
287    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
288    era * 146097 + doe - 719468
289}
290
291impl Client {
292    /// POST `/objects/batch` to negotiate transfer URLs.
293    pub async fn batch(&self, req: &BatchRequest) -> Result<BatchResponse, ApiError> {
294        // Match the SSH `git-lfs-authenticate` operation to the batch
295        // operation: an upload batch needs upload-scoped auth, a
296        // download batch needs download-scoped auth.
297        let op = match req.operation {
298            Operation::Upload => crate::ssh::SshOperation::Upload,
299            Operation::Download => crate::ssh::SshOperation::Download,
300        };
301        self.post_json("objects/batch", req, op).await
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    #[test]
310    fn operation_serializes_lowercase() {
311        let s = serde_json::to_string(&Operation::Download).unwrap();
312        assert_eq!(s, "\"download\"");
313    }
314
315    #[test]
316    fn request_skips_empty_optional_fields() {
317        let req = BatchRequest::new(
318            Operation::Download,
319            vec![ObjectSpec {
320                oid: "abc".into(),
321                size: 10,
322            }],
323        );
324        let v = serde_json::to_value(&req).unwrap();
325        assert!(v.get("transfers").is_none());
326        assert!(v.get("ref").is_none());
327        assert!(v.get("hash_algo").is_none());
328    }
329
330    #[test]
331    fn parses_canonical_download_response() {
332        let body = r#"{
333            "transfer": "basic",
334            "objects": [{
335                "oid": "1111111",
336                "size": 123,
337                "authenticated": true,
338                "actions": {
339                    "download": {
340                        "href": "https://some-download.com",
341                        "header": { "Key": "value" },
342                        "expires_at": "2016-11-10T15:29:07Z"
343                    }
344                }
345            }],
346            "hash_algo": "sha256"
347        }"#;
348        let resp: BatchResponse = serde_json::from_str(body).unwrap();
349        assert_eq!(resp.transfer.as_deref(), Some("basic"));
350        let obj = &resp.objects[0];
351        assert_eq!(obj.authenticated, Some(true));
352        let action = obj.actions.as_ref().unwrap().download.as_ref().unwrap();
353        assert_eq!(action.href, "https://some-download.com");
354        assert_eq!(action.header.get("Key").unwrap(), "value");
355        assert!(action.expires_in.is_none());
356    }
357
358    #[test]
359    fn action_with_no_expiry_never_expires() {
360        let action = Action::default();
361        // Default is `expires_in: None`, `expires_at: None` (after
362        // adding manual construction); a SystemTime in the distant
363        // future doesn't matter either way.
364        let action = Action {
365            href: "x".into(),
366            ..action
367        };
368        assert!(!action.is_expired_within(SystemTime::now(), Duration::from_secs(5)));
369    }
370
371    #[test]
372    fn action_with_negative_expires_in_is_expired() {
373        let action = Action {
374            href: "x".into(),
375            expires_in: Some(-5),
376            ..Default::default()
377        };
378        assert!(action.is_expired_within(SystemTime::now(), Duration::from_secs(5)));
379    }
380
381    #[test]
382    fn action_with_past_expires_at_is_expired() {
383        let action = Action {
384            href: "x".into(),
385            expires_at: Some("2016-11-10T15:29:07Z".into()),
386            ..Default::default()
387        };
388        assert!(action.is_expired_within(SystemTime::now(), Duration::from_secs(5)));
389    }
390
391    #[test]
392    fn action_with_far_future_expires_at_is_not_expired() {
393        let action = Action {
394            href: "x".into(),
395            expires_at: Some("2099-01-01T00:00:00Z".into()),
396            ..Default::default()
397        };
398        assert!(!action.is_expired_within(SystemTime::now(), Duration::from_secs(5)));
399    }
400
401    #[test]
402    fn action_expires_in_takes_precedence_over_expires_at() {
403        // Past expires_at, future expires_in → not expired (expires_in wins).
404        let action = Action {
405            href: "x".into(),
406            expires_in: Some(3600),
407            expires_at: Some("2016-11-10T15:29:07Z".into()),
408            ..Default::default()
409        };
410        assert!(!action.is_expired_within(SystemTime::now(), Duration::from_secs(5)));
411    }
412
413    #[test]
414    fn parses_per_object_error() {
415        let body = r#"{
416            "transfer": "basic",
417            "objects": [{
418                "oid": "1111111", "size": 123,
419                "error": { "code": 404, "message": "Object does not exist" }
420            }]
421        }"#;
422        let resp: BatchResponse = serde_json::from_str(body).unwrap();
423        let err = resp.objects[0].error.as_ref().unwrap();
424        assert_eq!(err.code, 404);
425        assert_eq!(err.message, "Object does not exist");
426    }
427
428    #[test]
429    fn parses_upload_already_present_no_actions() {
430        let body = r#"{
431            "objects": [{ "oid": "1111111", "size": 123 }]
432        }"#;
433        let resp: BatchResponse = serde_json::from_str(body).unwrap();
434        let obj = &resp.objects[0];
435        assert!(obj.actions.is_none());
436        assert!(obj.error.is_none());
437    }
438}