1use anyhow::bail;
2use fs_err as fs;
3use serde::{Deserialize, Serialize};
4use std::io::Read;
5use std::path::PathBuf;
6use std::sync::OnceLock;
7use tempfile::NamedTempFile;
8use ureq::Body;
9use ureq::http::Response;
10use url::Url;
11
12use crate::audit::AuditEntry;
13use crate::auth::get_token;
14use crate::error::{Error, ErrorCode};
15use crate::utils::http_agent;
16use crate::{Compression, Hashes, ProjectPath, RetrieveRequest, StoreRequest, StoreResult};
17
18const NOT_ALLOWED: &str =
19 "You are not allowed to access this project, make sure you are logged in with the right user.";
20const LOGIN_EXPIRED: &str = "Your login is no longer valid, run `dvs login`";
21
22pub(crate) fn join_url(base: &Url, relative: &str) -> anyhow::Result<Url> {
26 debug_assert!(!relative.starts_with('/'), "{relative} must be relative");
27 if base.cannot_be_a_base() {
28 bail!("{base} cannot be used as a dvs server URL");
29 }
30 let mut base = base.clone();
31 if !base.path().ends_with('/') {
32 base.set_path(&format!("{}/", base.path()));
33 }
34 base.join(relative)
35 .map_err(|e| anyhow::anyhow!("{base} cannot be used as a dvs server URL: {e}"))
36}
37
38pub(crate) fn http_status_code(status: u16, body: &str) -> Option<ErrorCode> {
44 match status {
45 401 => Some(ErrorCode::AuthExpired),
46 403 => Some(ErrorCode::AccessDenied),
47 404 if body.trim_start().starts_with("unknown project") => Some(ErrorCode::ProjectNotFound),
48 404 | 405 => Some(ErrorCode::ServerTooOld),
49 500.. => Some(ErrorCode::ServerUnavailable),
50 _ => None,
51 }
52}
53
54fn coded(code: ErrorCode, message: impl Into<String>) -> anyhow::Error {
55 Error::new(code, message).into()
56}
57
58pub(crate) fn unreachable(base_url: &Url, e: ureq::Error) -> anyhow::Error {
59 coded(
60 ErrorCode::ServerUnavailable,
61 format!("cannot reach the dvs server at {base_url}: {e}"),
62 )
63}
64
65pub(crate) fn invalid_response(base_url: &Url, e: impl std::fmt::Display) -> anyhow::Error {
66 coded(
67 ErrorCode::ServerUnavailable,
68 format!("Invalid response from the dvs server at {base_url}: {e}"),
69 )
70}
71
72pub(crate) fn status_error(base_url: &Url, status: u16, body: &str, what: &str) -> anyhow::Error {
74 let Some(code) = http_status_code(status, body) else {
75 return anyhow::anyhow!("{what} failed ({status}): {body}");
76 };
77 let message = match code {
78 ErrorCode::AuthExpired => LOGIN_EXPIRED.to_string(),
79 ErrorCode::AccessDenied => NOT_ALLOWED.to_string(),
80 ErrorCode::ProjectNotFound => format!(
81 "the dvs server at {base_url} does not have this project ({})",
82 body.trim()
83 ),
84 ErrorCode::ServerTooOld => format!(
85 "the dvs server at {base_url} does not support this request ({status}); it is older than this dvs"
86 ),
87 _ => format!("the dvs server at {base_url} is not working ({status})"),
88 };
89 coded(code, message)
90}
91
92pub(crate) fn response_error(
93 base_url: &Url,
94 resp: &mut Response<Body>,
95 what: &str,
96) -> anyhow::Error {
97 let body = resp.body_mut().read_to_string().unwrap_or_default();
98 status_error(base_url, resp.status().as_u16(), &body, what)
99}
100
101struct CountingReader<'a, R> {
107 inner: R,
108 on_bytes: Option<&'a (dyn Fn(u64) + Send + Sync)>,
109 error: Option<std::io::Error>,
110}
111
112impl<'a, R> CountingReader<'a, R> {
113 fn new(inner: R, on_bytes: Option<&'a (dyn Fn(u64) + Send + Sync)>) -> Self {
114 Self {
115 inner,
116 on_bytes,
117 error: None,
118 }
119 }
120
121 fn take_error(&mut self) -> Option<std::io::Error> {
122 self.error.take()
123 }
124}
125
126impl<R: Read> Read for CountingReader<'_, R> {
127 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
128 let n = match self.inner.read(buf) {
129 Ok(n) => n,
130 Err(e) => {
131 if self.error.is_none() && e.kind() != std::io::ErrorKind::Interrupted {
132 self.error = Some(std::io::Error::new(e.kind(), e.to_string()));
133 }
134 return Err(e);
135 }
136 };
137 if n > 0 {
138 if let Some(cb) = self.on_bytes {
139 cb(n as u64);
140 }
141 }
142 Ok(n)
143 }
144}
145
146#[derive(Serialize, Deserialize)]
147pub struct InitPayload {
148 pub name: String,
149 pub group: String,
150 pub compression: Compression,
151}
152
153#[derive(Serialize, Deserialize)]
154pub struct UploadResponse {
155 pub stored_size: u64,
156 pub compression: Compression,
157}
158
159#[derive(Serialize, Deserialize)]
160pub struct HistoryPayload {
161 #[serde(default)]
162 pub paths: Vec<ProjectPath>,
163}
164
165#[derive(Serialize, Deserialize)]
166pub struct UploadManifestEntry {
167 pub path: ProjectPath,
168 pub size: u64,
169 pub hash: String,
170}
171
172#[derive(Serialize, Deserialize)]
173pub struct UploadManifest {
174 pub operation_id: uuid::Uuid,
175 pub message: Option<String>,
176 pub entries: Vec<UploadManifestEntry>,
177 #[serde(default)]
181 pub git_branch: Option<String>,
182 #[serde(default)]
183 pub git_commit: Option<String>,
184}
185
186#[derive(Serialize, Deserialize)]
187pub struct UploadManifestResponse {
188 pub missing: Vec<String>,
190 pub compression: Compression,
191}
192
193#[derive(Serialize, Deserialize)]
194pub struct AccessUser {
195 pub username: String,
196 pub email: Option<String>,
197}
198
199#[derive(Serialize, Deserialize)]
200pub struct AccessResponse {
201 pub access: String,
202 pub user: AccessUser,
203 pub groups: Vec<String>,
204}
205
206#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
207pub struct ServerBackend {
208 pub name: String,
209 pub url: Url,
210 #[serde(skip)]
213 pub group: Option<String>,
214 #[serde(skip)]
215 access_token: OnceLock<Option<String>>,
216}
217
218impl ServerBackend {
219 pub fn new(name: String, group: Option<String>, url: Url) -> Self {
220 Self {
221 name,
222 group,
223 url,
224 access_token: OnceLock::new(),
225 }
226 }
227
228 fn init_url(&self) -> anyhow::Result<Url> {
229 join_url(&self.url, "api/init")
230 }
231
232 fn project_url(&self) -> anyhow::Result<Url> {
233 join_url(&self.url, &format!("api/project/{}", self.name))
234 }
235
236 fn blob_url(&self, hash: &str) -> anyhow::Result<Url> {
237 self.read_url(&format!("blobs/{hash}"))
238 }
239
240 fn manifest_url(&self) -> anyhow::Result<Url> {
241 self.read_url("manifest")
242 }
243
244 fn access_url(&self) -> anyhow::Result<Url> {
245 self.read_url("access")
246 }
247
248 fn history_url(&self) -> anyhow::Result<Url> {
249 self.read_url("history")
250 }
251
252 fn bearer(&self) -> anyhow::Result<String> {
253 let token = match self.access_token.get() {
254 Some(token) => token,
255 None => {
256 let read = get_token(&self.url)?;
257 self.access_token.get_or_init(|| read)
258 }
259 };
260 match token {
261 Some(token) => Ok(format!("Bearer {token}")),
262 None => Err(coded(
263 ErrorCode::AuthRequired,
264 "Could not find access token. Log in first",
265 )),
266 }
267 }
268
269 fn read_url(&self, endpoint: &str) -> anyhow::Result<Url> {
270 join_url(&self.url, &format!("api/{}/{}", self.name, endpoint))
271 }
272
273 pub fn init(&self, compression: Compression) -> anyhow::Result<bool> {
274 let Some(group) = self.group.clone() else {
275 bail!("A group is required to create a project on the server");
276 };
277 let url = self.init_url()?;
278 let payload = InitPayload {
279 name: self.name.clone(),
280 group,
281 compression,
282 };
283 let mut resp = http_agent()
284 .post(url.as_str())
285 .header("authorization", self.bearer()?)
286 .send_json(&payload)
287 .map_err(|e| unreachable(&self.url, e))?;
288 match resp.status().as_u16() {
289 201 => Ok(false), 200 => Ok(true), 409 => {
292 bail!(
293 "Server initialization failed: the project already exists with a different group or compression"
294 )
295 }
296 _ => Err(response_error(&self.url, &mut resp, "server init")),
297 }
298 }
299
300 pub fn attach(&self) -> anyhow::Result<()> {
301 let url = self.project_url()?;
302 let mut resp = http_agent()
303 .get(url.as_str())
304 .header("authorization", self.bearer()?)
305 .call()
306 .map_err(|e| unreachable(&self.url, e))?;
307
308 match resp.status().as_u16() {
309 200 => Ok(()),
310 _ => Err(response_error(
311 &self.url,
312 &mut resp,
313 "getting project information",
314 )),
315 }
316 }
317
318 pub fn access(&self) -> anyhow::Result<Option<AccessResponse>> {
320 let mut resp = http_agent()
321 .get(self.access_url()?.as_str())
322 .header("authorization", self.bearer()?)
323 .call()
324 .map_err(|e| unreachable(&self.url, e))?;
325
326 match resp.status().as_u16() {
327 200 => Ok(Some(
328 resp.body_mut()
329 .read_json()
330 .map_err(|e| invalid_response(&self.url, e))?,
331 )),
332 403 => Ok(None),
333 _ => Err(response_error(&self.url, &mut resp, "access check")),
334 }
335 }
336
337 pub fn check_access(&self) -> anyhow::Result<()> {
338 if self.access()?.is_none() {
339 return Err(coded(ErrorCode::AccessDenied, NOT_ALLOWED));
340 }
341 Ok(())
342 }
343
344 pub fn manifest(&self, manifest: &UploadManifest) -> anyhow::Result<UploadManifestResponse> {
347 let mut resp = http_agent()
348 .post(self.manifest_url()?.as_str())
349 .header("authorization", self.bearer()?)
350 .send_json(manifest)
351 .map_err(|e| unreachable(&self.url, e))?;
352
353 match resp.status().as_u16() {
354 200 => resp
355 .body_mut()
356 .read_json()
357 .map_err(|e| invalid_response(&self.url, e)),
358 _ => Err(response_error(&self.url, &mut resp, "manifest")),
359 }
360 }
361
362 pub fn store(&self, req: StoreRequest<'_>) -> anyhow::Result<StoreResult> {
363 let mut file = CountingReader::new(fs::File::open(req.source)?, req.on_bytes);
364
365 let mut url = self.blob_url(req.hashes.get_blake3())?;
366 url.query_pairs_mut()
367 .append_pair("operation_id", &req.operation_id.to_string());
368 let sent = http_agent()
369 .put(url.as_str())
370 .header("authorization", self.bearer()?)
371 .header("content-type", "application/octet-stream")
372 .send(ureq::SendBody::from_reader(&mut file));
373 let mut resp = match sent {
374 Ok(resp) => resp,
375 Err(e) => match file.take_error() {
376 Some(local) => return Err(local.into()),
377 None => return Err(unreachable(&self.url, e)),
378 },
379 };
380
381 match resp.status().as_u16() {
382 status @ (200 | 201) => {
383 let uploaded: UploadResponse = resp
384 .body_mut()
385 .read_json()
386 .map_err(|e| invalid_response(&self.url, e))?;
387 Ok(StoreResult {
388 stored_size: uploaded.stored_size,
389 compression: uploaded.compression,
390 created: status == 201,
391 })
392 }
393 _ => Err(response_error(&self.url, &mut resp, "upload")),
394 }
395 }
396
397 pub fn retrieve(&self, req: RetrieveRequest<'_>) -> anyhow::Result<bool> {
398 let mut url = self.blob_url(req.hashes.get_blake3())?;
399 url.query_pairs_mut()
400 .append_pair("path", req.path.as_str())
401 .append_pair("operation_id", &req.operation_id.to_string());
402
403 let mut resp = http_agent()
404 .get(url.as_str())
405 .header("authorization", self.bearer()?)
406 .call()
407 .map_err(|e| unreachable(&self.url, e))?;
408
409 match resp.status().as_u16() {
410 200 => {}
411 code => {
412 let body = resp.body_mut().read_to_string().unwrap_or_default();
413 if code == 404 && http_status_code(code, &body) != Some(ErrorCode::ProjectNotFound)
415 {
416 return Ok(false);
417 }
418 return Err(status_error(&self.url, code, &body, "download"));
419 }
420 }
421
422 let tmp = NamedTempFile::new()?.into_temp_path();
423 {
424 let mut reader = CountingReader::new(resp.body_mut().as_reader(), req.on_bytes);
425 let mut out = fs::File::create(&tmp)?;
426 self.download(&mut reader, &mut out)?;
427 }
428
429 if let Some(parent) = req.target.parent() {
430 fs::create_dir_all(parent)?;
431 }
432 req.compression.decompress(&tmp, req.target, None)?;
433 Ok(true)
434 }
435
436 fn download<R: Read>(
437 &self,
438 body: &mut CountingReader<'_, R>,
439 out: &mut impl std::io::Write,
440 ) -> anyhow::Result<u64> {
441 std::io::copy(body, out).map_err(|e| match body.take_error() {
442 Some(network) => coded(
443 ErrorCode::ServerUnavailable,
444 format!("the download from {} was cut off: {network}", self.url),
445 ),
446 None => e.into(),
447 })
448 }
449
450 pub fn exists(&self, hashes: &Hashes) -> anyhow::Result<bool> {
451 let url = self.blob_url(hashes.get_blake3())?;
452
453 match http_agent()
454 .head(url.as_str())
455 .header("authorization", self.bearer()?)
456 .call()
457 .map_err(|e| unreachable(&self.url, e))?
458 .status()
459 .as_u16()
460 {
461 200 => Ok(true),
462 404 => Ok(false),
463 code => Err(status_error(
464 &self.url,
465 code,
466 "",
467 "checking whether a file exists",
468 )),
469 }
470 }
471
472 pub fn remove(&self, _: &Hashes) -> anyhow::Result<()> {
473 Ok(())
475 }
476
477 pub fn get_audit_entries(&self, files: &[PathBuf]) -> anyhow::Result<Vec<AuditEntry>> {
478 let url = self.history_url()?;
479
480 let mut paths = Vec::with_capacity(files.len());
481 for p in files {
482 paths.push(ProjectPath::from_path(p).map_err(|e| anyhow::anyhow!(e))?);
483 }
484
485 let payload = HistoryPayload { paths };
486
487 let mut resp = http_agent()
488 .post(url.as_str())
489 .header("authorization", self.bearer()?)
490 .send_json(&payload)
491 .map_err(|e| unreachable(&self.url, e))?;
492
493 match resp.status().as_u16() {
494 200 => resp
495 .body_mut()
496 .read_json::<Vec<AuditEntry>>()
497 .map_err(|e| invalid_response(&self.url, e)),
498 _ => Err(response_error(&self.url, &mut resp, "history")),
499 }
500 }
501}
502
503#[cfg(test)]
504mod tests {
505 use super::*;
506
507 #[test]
508 fn server_urls_keep_the_path_prefix() {
509 for base in ["https://h/dvs", "https://h/dvs/"] {
510 let url = Url::parse(base).unwrap();
511 let server = ServerBackend::new("project".into(), None, url.clone());
512 assert_eq!(
513 server.init_url().unwrap().as_str(),
514 "https://h/dvs/api/init"
515 );
516 assert_eq!(
517 server.access_url().unwrap().as_str(),
518 "https://h/dvs/api/project/access"
519 );
520 assert_eq!(
521 crate::auth::get_client_approve_url(&url, "ABCD")
522 .unwrap()
523 .as_str(),
524 "https://h/dvs/device/approve?user_code=ABCD"
525 );
526 }
527 }
528
529 #[test]
530 fn statuses_map_to_the_shared_codes() {
531 for (status, body, expected) in [
532 (401, "", Some(ErrorCode::AuthExpired)),
533 (403, "", Some(ErrorCode::AccessDenied)),
534 (
535 404,
536 "unknown project \"project\"",
537 Some(ErrorCode::ProjectNotFound),
538 ),
539 (404, "", Some(ErrorCode::ServerTooOld)),
540 (405, "", Some(ErrorCode::ServerTooOld)),
541 (503, "", Some(ErrorCode::ServerUnavailable)),
542 (409, "", None),
543 ] {
544 assert_eq!(http_status_code(status, body), expected, "{status} {body}");
545 }
546 }
547}