This repository has no description
1use serde::{Deserialize, Deserializer, Serialize, Serializer};
2use url::Url;
3
4use knot_types::{HttpStatus, RefName};
5
6use crate::{ClaimedSize, LfsOid};
7
8pub const BATCH_MEDIA_TYPE: &str = "application/vnd.git-lfs+json";
9pub const HASH_ALGO: &str = "sha256";
10pub const BASIC_TRANSFER: &str = "basic";
11pub const MAX_BATCH_OBJECTS: usize = 1000;
12
13// Unknown adapters parse instead of failing the whole body,
14// so that a client offering something we don't serve
15// will at least get a 422 that shows a mismatch not a serde error!
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum TransferAdapter {
18 Basic,
19 Other(String),
20}
21
22impl TransferAdapter {
23 pub fn is_basic(&self) -> bool {
24 matches!(self, Self::Basic)
25 }
26
27 pub fn as_str(&self) -> &str {
28 match self {
29 Self::Basic => BASIC_TRANSFER,
30 Self::Other(value) => value,
31 }
32 }
33}
34
35impl Serialize for TransferAdapter {
36 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
37 serializer.serialize_str(self.as_str())
38 }
39}
40
41impl<'de> Deserialize<'de> for TransferAdapter {
42 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
43 let raw = String::deserialize(deserializer)?;
44 Ok(if raw == BASIC_TRANSFER {
45 Self::Basic
46 } else {
47 Self::Other(raw)
48 })
49 }
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum HashAlgo {
54 Sha256,
55 Other(String),
56}
57
58impl HashAlgo {
59 pub fn is_sha256(&self) -> bool {
60 matches!(self, Self::Sha256)
61 }
62
63 pub fn as_str(&self) -> &str {
64 match self {
65 Self::Sha256 => HASH_ALGO,
66 Self::Other(value) => value,
67 }
68 }
69}
70
71impl Serialize for HashAlgo {
72 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
73 serializer.serialize_str(self.as_str())
74 }
75}
76
77impl<'de> Deserialize<'de> for HashAlgo {
78 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
79 let raw = String::deserialize(deserializer)?;
80 Ok(if raw == HASH_ALGO {
81 Self::Sha256
82 } else {
83 Self::Other(raw)
84 })
85 }
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "lowercase")]
90pub enum BatchOperation {
91 Download,
92 Upload,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct BatchRef {
97 pub name: RefName,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101pub struct BatchObject {
102 pub oid: LfsOid,
103 pub size: ClaimedSize,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct BatchRequest {
108 pub operation: BatchOperation,
109 #[serde(default, skip_serializing_if = "Vec::is_empty")]
110 pub transfers: Vec<TransferAdapter>,
111 #[serde(default, rename = "ref", skip_serializing_if = "Option::is_none")]
112 pub reference: Option<BatchRef>,
113 pub objects: Vec<BatchObject>,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub hash_algo: Option<HashAlgo>,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct BatchAction {
120 pub href: Url,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct BatchActions {
125 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub download: Option<BatchAction>,
127 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub upload: Option<BatchAction>,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct BatchObjectError {
133 pub code: HttpStatus,
134 pub message: String,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct BatchResponseObject {
139 pub oid: LfsOid,
140 pub size: ClaimedSize,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
142 pub authenticated: Option<bool>,
143 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub actions: Option<BatchActions>,
145 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub error: Option<BatchObjectError>,
147}
148
149fn basic_transfer() -> TransferAdapter {
150 TransferAdapter::Basic
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct BatchResponse {
155 #[serde(default = "basic_transfer")]
156 pub transfer: TransferAdapter,
157 pub objects: Vec<BatchResponseObject>,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub hash_algo: Option<HashAlgo>,
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165
166 const OID: &str = "6c17f2007cbe934aee6e309b28b2fba3c119d98be6ea4156da3aa3173456ad16";
167
168 #[test]
169 fn a_client_batch_request_round_trips() {
170 let body = format!(
171 r#"{{"operation":"download","transfers":["basic","ssh"],"ref":{{"name":"refs/heads/main"}},"objects":[{{"oid":"{OID}","size":42}}],"hash_algo":"sha256"}}"#
172 );
173 let request: BatchRequest = serde_json::from_str(&body).unwrap();
174 assert_eq!(request.operation, BatchOperation::Download);
175 assert_eq!(request.objects[0].oid.as_str(), OID);
176 assert_eq!(request.objects[0].size, ClaimedSize::new(42));
177 assert_eq!(
178 request.reference.as_ref().unwrap().name.as_str(),
179 "refs/heads/main"
180 );
181 }
182
183 #[test]
184 fn a_hostile_oid_fails_the_whole_parse() {
185 let body = r#"{"operation":"download","objects":[{"oid":"../../etc/passwd","size":1}]}"#;
186 assert!(serde_json::from_str::<BatchRequest>(body).is_err());
187 }
188
189 #[test]
190 fn a_response_serializes_the_lfs_shape() {
191 let response = BatchResponse {
192 transfer: TransferAdapter::Basic,
193 objects: vec![BatchResponseObject {
194 oid: LfsOid::new(OID).unwrap(),
195 size: ClaimedSize::new(42),
196 authenticated: Some(true),
197 actions: Some(BatchActions {
198 download: Some(BatchAction {
199 href: Url::parse(&format!(
200 "https://nel.pet/did:plc:squid/media/info/lfs/objects/{OID}"
201 ))
202 .unwrap(),
203 }),
204 upload: None,
205 }),
206 error: None,
207 }],
208 hash_algo: Some(HashAlgo::Sha256),
209 };
210 let json = serde_json::to_value(&response).unwrap();
211 assert_eq!(json["transfer"], "basic");
212 assert_eq!(json["objects"][0]["oid"], OID);
213 assert_eq!(json["objects"][0]["authenticated"], true);
214 assert!(
215 json["objects"][0]["actions"]["download"]["href"]
216 .as_str()
217 .unwrap()
218 .ends_with(OID)
219 );
220 assert_eq!(json["objects"][0].get("error"), None);
221 }
222}