backpak_b2/
lib.rs

1use base64::prelude::*;
2use serde_json as json;
3use thiserror::Error;
4
5use std::io::{prelude::*, Cursor};
6
7#[derive(Error, Debug)]
8pub enum Error {
9    #[error("B2 I/O failure: {0}")]
10    Io(#[from] std::io::Error),
11    #[error("B2 HTTP transport error: {0}")]
12    Http(Box<ureq::Transport>),
13    #[error("B2 HTTP {code}: {reason}")]
14    BadReply { code: u16, reason: String },
15    #[error("B2 {why}: {response}")]
16    UnexpectedResponse { why: String, response: String },
17    #[error("B2: Couldn't find {what}")]
18    NotFound { what: String },
19}
20
21impl From<ureq::Error> for Error {
22    fn from(e: ureq::Error) -> Self {
23        match e {
24            ureq::Error::Status(code, resp) => {
25                let reason = resp.into_string().unwrap_or_else(|e| e.to_string());
26                Self::BadReply { code, reason }
27            }
28            ureq::Error::Transport(t) => Self::Http(Box::new(t)),
29        }
30    }
31}
32
33fn unexpected(w: &str, r: &json::Value) -> Error {
34    Error::UnexpectedResponse {
35        why: w.to_string(),
36        response: r.to_string(),
37    }
38}
39
40pub type Result<T> = std::result::Result<T, Error>;
41
42#[derive(Debug)]
43pub struct Session {
44    token: String,
45    url: String,
46    upload_url: String,
47    upload_token: String,
48    bucket_name: String,
49    bucket_id: String,
50}
51
52// Once we authenticate in Session::new,
53// we shouldn't have any redirects as the API gives us URLs to use.
54fn noredir() -> ureq::Agent {
55    ureq::builder().redirects(0).build()
56}
57
58impl Session {
59    pub fn new<S: Into<String>>(key_id: &str, application_key: &str, bucket: S) -> Result<Self> {
60        let bucket = bucket.into();
61
62        let creds = String::from(key_id) + ":" + application_key;
63        let auth = String::from("Basic") + &BASE64_STANDARD.encode(creds);
64        let v: json::Value = ureq::get("https://api.backblazeb2.com/b2api/v3/b2_authorize_account")
65            .set("Authorization", &auth)
66            .call()?
67            .into_json()?;
68
69        let bad = |s| unexpected(s, &v);
70
71        let id: String = v["accountId"]
72            .as_str()
73            .ok_or_else(|| bad("login response missing authorization token"))?
74            .to_owned();
75
76        let token: String = v["authorizationToken"]
77            .as_str()
78            .ok_or_else(|| bad("login response missing authorization token"))?
79            .to_owned();
80
81        let url = v["apiInfo"]["storageApi"]["apiUrl"]
82            .as_str()
83            .ok_or_else(|| bad("login response missing API URL"))?
84            .to_owned();
85
86        let capes = v["apiInfo"]["storageApi"]["capabilities"]
87            .as_array()
88            .ok_or_else(|| bad("login response missing capabilities"))?;
89        let capes = capes
90            .iter()
91            .map(|v| {
92                v.as_str()
93                    .ok_or_else(|| bad("login response had malformed capabilities"))
94            })
95            .collect::<Result<Vec<&str>>>()?;
96
97        if !capes.iter().any(|c| *c == "listKeys") {
98            return Err(bad("credentials can not list files"));
99        }
100        if !capes.iter().any(|c| *c == "readFiles") {
101            return Err(bad("credentials can not read files"));
102        }
103        if !capes.iter().any(|c| *c == "writeFiles") {
104            return Err(bad("credentials can not write files"));
105        }
106        if !capes.iter().any(|c| *c == "deleteFiles") {
107            return Err(bad("credentials can not delete files"));
108        }
109
110        let br: json::Value = ureq::get(&(url.clone() + "/b2api/v2/b2_list_buckets"))
111            .set("Authorization", &token)
112            .query("accountId", &id)
113            .query("bucketName", &bucket)
114            .call()?
115            .into_json()?;
116
117        let bucket_id = match br["buckets"].as_array() {
118            Some(bs) => {
119                match bs
120                    .iter()
121                    .find(|b| b["bucketName"].as_str() == Some(&bucket))
122                {
123                    Some(mah_bukkit) => mah_bukkit["bucketId"]
124                        .as_str()
125                        .ok_or_else(|| unexpected("bucket was missing ID", &br))?
126                        .to_owned(),
127                    None => return Err(Error::NotFound { what: bucket }),
128                }
129            }
130            None => return Err(Error::NotFound { what: bucket }),
131        };
132
133        let ur: json::Value = ureq::get(&(url.clone() + "/b2api/v2/b2_get_upload_url"))
134            .set("Authorization", &token)
135            .query("bucketId", &bucket_id)
136            .call()?
137            .into_json()?;
138
139        let upload_url = ur["uploadUrl"]
140            .as_str()
141            .ok_or_else(|| unexpected("couldn't get bucket upload URL", &ur))?
142            .to_owned();
143
144        let upload_token = ur["authorizationToken"]
145            .as_str()
146            .ok_or_else(|| unexpected("couldn't get bucket upload token", &ur))?
147            .to_owned();
148
149        Ok(Session {
150            token,
151            url,
152            upload_url,
153            upload_token,
154            bucket_name: bucket,
155            bucket_id,
156        })
157    }
158
159    pub fn list(&self, prefix: Option<&str>) -> Result<Vec<(String, u64)>> {
160        let mut fs = vec![];
161        let mut start_name: Option<String> = None;
162        loop {
163            let mut req = noredir()
164                .get(&(self.url.clone() + "/b2api/v2/b2_list_file_names"))
165                .set("Authorization", &self.token)
166                .query("bucketId", &self.bucket_id)
167                .query("maxFileCount", "10000");
168            if let Some(p) = prefix {
169                req = req.query("prefix", p);
170            }
171            if let Some(sn) = &start_name {
172                req = req.query("startFileName", sn);
173            }
174
175            let lfn = req.call()?.into_json()?;
176
177            let bad = |s| unexpected(s, &lfn);
178
179            let files = lfn["files"]
180                .as_array()
181                .ok_or_else(|| bad("didn't list file names"))?;
182            for fj in files
183                .iter()
184                .filter(|f| f["action"].as_str() == Some("upload"))
185                .filter_map(
186                    |f| match (f["fileName"].as_str(), f["contentLength"].as_u64()) {
187                        (Some(n), Some(l)) => Some((n.to_owned(), l)),
188                        _ => None,
189                    },
190                )
191            {
192                fs.push(fj);
193            }
194
195            start_name = lfn["nextFileName"].as_str().map(|s| s.to_owned());
196            if start_name.is_none() {
197                break;
198            }
199        }
200        fs.shrink_to_fit(); // We won't be growing this any more.
201        Ok(fs)
202    }
203
204    pub fn get(&self, name: &str) -> Result<impl Read> {
205        let r = noredir()
206            .get(&(self.url.clone() + "/file/" + &self.bucket_name + "/" + name))
207            .set("Authorization", &self.token)
208            .call()?;
209
210        Ok(r.into_reader())
211    }
212
213    pub fn put(&self, name: &str, len: u64, contents: &mut dyn Read) -> Result<()> {
214        use data_encoding::HEXLOWER;
215        use sha1::{Digest, Sha1};
216
217        // B2 wants the SHA1 hash (as hex), but we can provide it at the end.
218        // Very nice.
219        enum HashAppendingReader<R> {
220            Contents { inner: R, hasher: Option<Sha1> },
221            HashSuffix(Cursor<Vec<u8>>),
222        }
223
224        impl<R> HashAppendingReader<R> {
225            fn new(inner: R) -> Self {
226                Self::Contents {
227                    inner,
228                    hasher: Some(Sha1::new()),
229                }
230            }
231        }
232
233        impl<R: Read> Read for HashAppendingReader<R> {
234            fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
235                match self {
236                    Self::Contents { inner, hasher } => {
237                        // Read some bytes from the inner Read trait object.
238                        let bytes_read = inner.read(buf)?;
239                        if bytes_read > 0 {
240                            // If we got some bytes, update the SHA1 hash with those and return.
241                            hasher.as_mut().unwrap().update(&buf[..bytes_read]);
242                            Ok(bytes_read)
243                        } else {
244                            // Otherwise we're done reading.
245                            // Consume the hasher, get the hash,
246                            // and start feeding that to whoever's reading.
247                            let sha = hasher.take().unwrap().finalize();
248                            let sha_hex = HEXLOWER.encode(&sha).to_string().into_bytes();
249                            *self = Self::HashSuffix(Cursor::new(sha_hex));
250                            // Recurse (to the HashSuffix match arm).
251                            self.read(buf)
252                        }
253                    }
254                    Self::HashSuffix(c) => c.read(buf),
255                }
256            }
257        }
258
259        let hr = HashAppendingReader::new(contents);
260
261        noredir()
262            .post(&self.upload_url)
263            .set("Authorization", &self.upload_token)
264            .set("Content-Length", &(len + 40).to_string()) // SHA1 is 40 hex digits long.
265            .set("X-Bz-File-Name", name) // No need to URL-encode, our names are boring
266            .set("Content-Type", "b2/x-auto") // Go ahead and guess
267            .set("X-Bz-Content-Sha1", "hex_digits_at_end")
268            .send(hr)?;
269
270        Ok(())
271    }
272
273    pub fn delete(&self, name: &str) -> Result<()> {
274        let req = noredir()
275            .get(&(self.url.clone() + "/b2api/v2/b2_list_file_versions"))
276            .set("Authorization", &self.token)
277            .query("bucketId", &self.bucket_id)
278            .query("prefix", name);
279
280        let lfv = req.call()?.into_json()?;
281        let where_name = || unexpected(&format!("couldn't find {name}"), &lfv);
282
283        let versions = lfv["files"].as_array().ok_or_else(where_name)?;
284
285        if versions.is_empty() {
286            return Err(where_name());
287        }
288        if versions.len() != 1 {
289            return Err(unexpected(
290                &format!("found multiple versions of {name}"),
291                &lfv,
292            ));
293        }
294        let id = versions[0]["fileId"]
295            .as_str()
296            .ok_or_else(|| unexpected(&format!("couldn't find ID for {name}"), &lfv))?;
297
298        ureq::post(&(self.url.clone() + "/b2api/v2/b2_delete_file_version"))
299            .set("Authorization", &self.token)
300            .send_json(json::json!({
301                "fileName": name,
302                "fileId": id
303            }))?;
304
305        Ok(())
306    }
307}