Skip to main content

smbcloud_deploy/
artifact.rs

1//! Static deploy over HTTPS: build a manifest from a directory, then upload it.
2//!
3//! The client builds, hashes the output and uploads blobs the server does not
4//! have yet, using only an app-scoped deploy token. The server picks the
5//! target and publishes it; nothing here names a remote path or runs a command
6//! on the box.
7
8use crate::{error::DeployError, report::Reporter, transport::Transport};
9use anyhow::{anyhow, bail, Context};
10use ignore::WalkBuilder;
11use reqwest::blocking::{Body, Client};
12use reqwest::header::AUTHORIZATION;
13use sha2::{Digest, Sha256};
14use smbcloud_model::artifact_deployment::{
15    ArtifactDeployment, ArtifactFile, CreateArtifactDeployment,
16};
17use smbcloud_network::network::bearer_value;
18use std::{
19    fs::File,
20    io::Read,
21    path::{Path, PathBuf},
22    sync::{
23        atomic::{AtomicUsize, Ordering},
24        Mutex,
25    },
26    time::{Duration, Instant},
27};
28
29/// Server-side quotas, checked here too so a refusal is local and early.
30pub const MAX_FILES: usize = 20_000;
31pub const MAX_FILE_BYTES: u64 = 50 * 1024 * 1024;
32pub const MAX_TOTAL_BYTES: u64 = 500 * 1024 * 1024;
33const UPLOAD_CONCURRENCY: usize = 4;
34const UPLOAD_ATTEMPTS: u32 = 3;
35/// Never uploaded, whatever the ignore files say.
36const ALWAYS_EXCLUDED_DIRS: [&str; 2] = [".git", ".smb"];
37const IGNORE_FILES: [&str; 2] = [".gitignore", ".smbignore"];
38
39/// A file to ship: its manifest entry plus where to read it from.
40#[derive(Debug, Clone)]
41pub struct ManifestFile {
42    pub entry: ArtifactFile,
43    pub source: PathBuf,
44}
45
46/// Everything under a directory that should be published.
47#[derive(Debug, Clone)]
48pub struct ArtifactManifest {
49    pub files: Vec<ManifestFile>,
50}
51
52impl ArtifactManifest {
53    pub fn total_bytes(&self) -> u64 {
54        self.files.iter().map(|f| f.entry.size).sum()
55    }
56
57    pub fn entries(&self) -> Vec<ArtifactFile> {
58        self.files.iter().map(|f| f.entry.clone()).collect()
59    }
60}
61
62/// Walks `root`, honouring `.gitignore` and `.smbignore` found inside it (not
63/// in parent directories), always skipping `.git` and `.smb`, and refusing
64/// symlinks and special files. Entries are sorted by path so the manifest is
65/// stable.
66pub fn build_manifest(root: &Path) -> Result<ArtifactManifest, DeployError> {
67    if !root.is_dir() {
68        return Err(anyhow!("{} is not a directory", root.display()).into());
69    }
70    let walker = WalkBuilder::new(root)
71        .hidden(false)
72        .parents(false)
73        .git_global(false)
74        .git_exclude(false)
75        .require_git(false)
76        .follow_links(false)
77        .add_custom_ignore_filename(".smbignore")
78        .filter_entry(|entry| {
79            let is_dir = entry.file_type().is_some_and(|t| t.is_dir());
80            !(is_dir
81                && entry.depth() > 0
82                && ALWAYS_EXCLUDED_DIRS
83                    .iter()
84                    .any(|name| entry.file_name() == *name))
85        })
86        .build();
87
88    let mut files = Vec::new();
89    for result in walker {
90        let entry = result.map_err(|e| anyhow!("walking {}: {e}", root.display()))?;
91        if entry.depth() == 0 {
92            continue;
93        }
94        let file_type = entry
95            .file_type()
96            .ok_or_else(|| anyhow!("cannot read {}", entry.path().display()))?;
97        if file_type.is_dir() {
98            continue;
99        }
100        let relative = entry.path().strip_prefix(root).unwrap_or(entry.path());
101        if file_type.is_symlink() {
102            return Err(anyhow!(
103                "{} is a symlink; symlinks are not published. Remove it or add it to .smbignore",
104                relative.display()
105            )
106            .into());
107        }
108        if !file_type.is_file() {
109            return Err(anyhow!(
110                "{} is not a regular file; add it to .smbignore",
111                relative.display()
112            )
113            .into());
114        }
115        let name = entry.file_name().to_string_lossy();
116        if IGNORE_FILES.iter().any(|f| *f == name) {
117            continue;
118        }
119
120        let path = relative_path(relative)?;
121        let (sha256, size) = hash_file(entry.path())
122            .with_context(|| format!("hashing {}", relative.display()))
123            .map_err(DeployError::Other)?;
124        if size > MAX_FILE_BYTES {
125            return Err(
126                anyhow!("{path} is {size} bytes; the limit is {MAX_FILE_BYTES} per file").into(),
127            );
128        }
129        files.push(ManifestFile {
130            entry: ArtifactFile { path, sha256, size },
131            source: entry.path().to_path_buf(),
132        });
133    }
134
135    files.sort_by(|a, b| a.entry.path.cmp(&b.entry.path));
136    if files.len() > MAX_FILES {
137        return Err(anyhow!("{} files; the limit is {MAX_FILES}", files.len()).into());
138    }
139    let manifest = ArtifactManifest { files };
140    if manifest.total_bytes() > MAX_TOTAL_BYTES {
141        return Err(anyhow!(
142            "{} bytes in total; the limit is {MAX_TOTAL_BYTES}",
143            manifest.total_bytes()
144        )
145        .into());
146    }
147    Ok(manifest)
148}
149
150fn relative_path(relative: &Path) -> Result<String, DeployError> {
151    let mut parts = Vec::new();
152    for component in relative.components() {
153        let part = component
154            .as_os_str()
155            .to_str()
156            .ok_or_else(|| anyhow!("{} is not valid UTF-8", relative.display()))?;
157        if part.contains('\\') || part.chars().any(|c| c.is_control()) {
158            return Err(anyhow!(
159                "{} has a backslash or control character in its name",
160                relative.display()
161            )
162            .into());
163        }
164        parts.push(part);
165    }
166    Ok(parts.join("/"))
167}
168
169fn hash_file(path: &Path) -> anyhow::Result<(String, u64)> {
170    let mut file = File::open(path)?;
171    let mut hasher = Sha256::new();
172    let mut buffer = vec![0u8; 64 * 1024];
173    let mut size = 0u64;
174    loop {
175        let read = file.read(&mut buffer)?;
176        if read == 0 {
177            break;
178        }
179        hasher.update(&buffer[..read]);
180        size += read as u64;
181    }
182    let hex = hasher
183        .finalize()
184        .iter()
185        .map(|b| format!("{b:02x}"))
186        .collect();
187    Ok((hex, size))
188}
189
190/// Uploads a directory as an artifact deployment and waits for it to go live.
191///
192/// Blocking on purpose, like the rest of the [`Transport`] trait: async
193/// callers run `ship` on a blocking thread.
194pub struct HttpsArtifactTransport {
195    api_base: String,
196    app_id: String,
197    token: String,
198    commit_sha: Option<String>,
199    poll_interval: Duration,
200    publish_timeout: Duration,
201    retry_delay: Duration,
202    deployment: Mutex<Option<ArtifactDeployment>>,
203}
204
205impl HttpsArtifactTransport {
206    /// `api_base` is the scheme and host, e.g. `https://api.smbcloud.xyz`.
207    pub fn new(
208        api_base: String,
209        app_id: String,
210        token: String,
211        commit_sha: Option<String>,
212    ) -> Self {
213        Self {
214            api_base: api_base.trim_end_matches('/').to_string(),
215            app_id,
216            token,
217            commit_sha,
218            poll_interval: Duration::from_secs(2),
219            publish_timeout: Duration::from_secs(600),
220            retry_delay: Duration::from_millis(500),
221            deployment: Mutex::new(None),
222        }
223    }
224
225    /// Shortens the waits; used by tests.
226    pub fn with_timing(
227        mut self,
228        poll: Duration,
229        publish_timeout: Duration,
230        retry: Duration,
231    ) -> Self {
232        self.poll_interval = poll;
233        self.publish_timeout = publish_timeout;
234        self.retry_delay = retry;
235        self
236    }
237
238    /// The deployment as last seen, available after `ship`.
239    pub fn deployment(&self) -> Option<ArtifactDeployment> {
240        self.deployment.lock().unwrap().clone()
241    }
242
243    fn url(&self, tail: &str) -> String {
244        format!(
245            "{}/v1/apps/{}/artifact_deployments{tail}",
246            self.api_base, self.app_id
247        )
248    }
249
250    fn create(
251        &self,
252        http: &Client,
253        manifest: &ArtifactManifest,
254    ) -> anyhow::Result<ArtifactDeployment> {
255        let body = CreateArtifactDeployment {
256            manifest: manifest.entries(),
257            commit_sha: self.commit_sha.clone(),
258        };
259        let response = http
260            .post(self.url(""))
261            .header(AUTHORIZATION, bearer_value(&self.token))
262            .json(&body)
263            .send()
264            .context("creating the deployment")?;
265        read_json(response, "creating the deployment")
266    }
267
268    fn upload(
269        &self,
270        http: &Client,
271        id: &str,
272        sha256: &str,
273        source: &Path,
274        size: u64,
275    ) -> anyhow::Result<()> {
276        let mut last = anyhow!("upload never attempted");
277        for attempt in 1..=UPLOAD_ATTEMPTS {
278            let file =
279                File::open(source).with_context(|| format!("opening {}", source.display()))?;
280            let result = http
281                .put(self.url(&format!("/{id}/blobs/{sha256}")))
282                .header(AUTHORIZATION, bearer_value(&self.token))
283                .header("Content-Type", "application/octet-stream")
284                .body(Body::sized(file, size))
285                .send();
286            match result {
287                Ok(r) if r.status().is_success() => return Ok(()),
288                Ok(r) => {
289                    let status = r.status();
290                    let detail = r.text().unwrap_or_default();
291                    last = anyhow!("upload of {sha256} failed with {status}: {detail}");
292                    // A client error will not change on retry, except throttling.
293                    if status.is_client_error() && status.as_u16() != 429 {
294                        return Err(last);
295                    }
296                }
297                Err(e) => last = anyhow!("upload of {sha256} failed: {e}"),
298            }
299            if attempt < UPLOAD_ATTEMPTS {
300                std::thread::sleep(self.retry_delay * attempt);
301            }
302        }
303        Err(last)
304    }
305
306    fn upload_missing(
307        &self,
308        http: &Client,
309        id: &str,
310        manifest: &ArtifactManifest,
311        missing: &[String],
312        reporter: &dyn Reporter,
313    ) -> anyhow::Result<()> {
314        // One upload per distinct hash, from the first file that has it.
315        let mut jobs: Vec<&ManifestFile> = Vec::new();
316        for sha in missing {
317            if let Some(file) = manifest.files.iter().find(|f| &f.entry.sha256 == sha) {
318                jobs.push(file);
319            }
320        }
321        let next = AtomicUsize::new(0);
322        let failure: Mutex<Option<anyhow::Error>> = Mutex::new(None);
323        std::thread::scope(|scope| {
324            for _ in 0..UPLOAD_CONCURRENCY.min(jobs.len()) {
325                scope.spawn(|| loop {
326                    if failure.lock().unwrap().is_some() {
327                        return;
328                    }
329                    let index = next.fetch_add(1, Ordering::SeqCst);
330                    let Some(file) = jobs.get(index) else { return };
331                    match self.upload(http, id, &file.entry.sha256, &file.source, file.entry.size) {
332                        Ok(()) => reporter.info(&format!("uploaded {}", file.entry.path)),
333                        Err(e) => {
334                            failure.lock().unwrap().get_or_insert(e);
335                            return;
336                        }
337                    }
338                });
339            }
340        });
341        match failure.into_inner().unwrap() {
342            Some(e) => Err(e),
343            None => Ok(()),
344        }
345    }
346
347    fn finalize(&self, http: &Client, id: &str) -> anyhow::Result<ArtifactDeployment> {
348        let response = http
349            .post(self.url(&format!("/{id}/finalize")))
350            .header(AUTHORIZATION, bearer_value(&self.token))
351            .send()
352            .context("finalizing the deployment")?;
353        read_json(response, "finalizing the deployment")
354    }
355
356    fn wait_until_published(&self, http: &Client, id: &str) -> anyhow::Result<ArtifactDeployment> {
357        let started = Instant::now();
358        loop {
359            let response = http
360                .get(self.url(&format!("/{id}")))
361                .header(AUTHORIZATION, bearer_value(&self.token))
362                .send()
363                .context("reading the deployment status")?;
364            let deployment: ArtifactDeployment =
365                read_json(response, "reading the deployment status")?;
366            if deployment.is_terminal() {
367                return Ok(deployment);
368            }
369            if started.elapsed() > self.publish_timeout {
370                bail!(
371                    "the deployment is still {} after {}s",
372                    deployment.status,
373                    self.publish_timeout.as_secs()
374                );
375            }
376            std::thread::sleep(self.poll_interval);
377        }
378    }
379}
380
381fn read_json<T: serde::de::DeserializeOwned>(
382    response: reqwest::blocking::Response,
383    what: &str,
384) -> anyhow::Result<T> {
385    let status = response.status();
386    if !status.is_success() {
387        let body = response.text().unwrap_or_default();
388        bail!("{what} was refused ({status}): {body}");
389    }
390    response
391        .json()
392        .with_context(|| format!("parsing the response from {what}"))
393}
394
395impl Transport for HttpsArtifactTransport {
396    fn ship(&self, source: &Path, reporter: &dyn Reporter) -> Result<(), DeployError> {
397        let http = Client::builder()
398            .timeout(Duration::from_secs(300))
399            .build()
400            .map_err(|e| anyhow!("building the HTTP client: {e}"))?;
401
402        reporter.step_start(&format!("Hashing {}", source.display()));
403        let manifest = build_manifest(source)?;
404        reporter.step_done(&format!(
405            "{} files, {} bytes",
406            manifest.files.len(),
407            manifest.total_bytes()
408        ));
409
410        let created = self.create(&http, &manifest)?;
411        *self.deployment.lock().unwrap() = Some(created.clone());
412        reporter.step_start(&format!("Uploading {} new blob(s)", created.missing.len()));
413        self.upload_missing(&http, &created.id, &manifest, &created.missing, reporter)?;
414        reporter.step_done("Upload complete");
415
416        let queued = self.finalize(&http, &created.id)?;
417        *self.deployment.lock().unwrap() = Some(queued);
418        reporter.step_start("Publishing");
419        let finished = self.wait_until_published(&http, &created.id)?;
420        *self.deployment.lock().unwrap() = Some(finished.clone());
421        match finished.status.as_str() {
422            "live" => {
423                reporter.step_done("Live");
424                Ok(())
425            }
426            other => {
427                let reason = finished.error.unwrap_or_else(|| other.to_string());
428                reporter.step_fail(&reason);
429                Err(anyhow!("the deployment ended as {other}: {reason}").into())
430            }
431        }
432    }
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438    use crate::report::NoopReporter;
439    use std::fs;
440    use wiremock::{
441        matchers::{header, method, path, path_regex},
442        Mock, MockServer, Request, Respond, ResponseTemplate,
443    };
444
445    fn write(dir: &Path, rel: &str, data: &str) {
446        let target = dir.join(rel);
447        fs::create_dir_all(target.parent().unwrap()).unwrap();
448        fs::write(target, data).unwrap();
449    }
450
451    fn paths(manifest: &ArtifactManifest) -> Vec<String> {
452        manifest
453            .files
454            .iter()
455            .map(|f| f.entry.path.clone())
456            .collect()
457    }
458
459    #[test]
460    fn honours_ignore_files_and_always_skips_git_and_smb() {
461        let dir = tempfile::tempdir().unwrap();
462        write(dir.path(), "index.html", "hi");
463        write(dir.path(), "assets/app.js", "js");
464        write(dir.path(), ".well-known/security.txt", "x");
465        write(dir.path(), "secret.env", "s");
466        write(dir.path(), "notes/DEPLOY.md", "d");
467        write(dir.path(), ".git/config", "g");
468        write(dir.path(), ".smb/config.toml", "c");
469        write(dir.path(), "sub/.git/HEAD", "g");
470        write(dir.path(), ".gitignore", "secret.env\n");
471        write(dir.path(), ".smbignore", "notes/\n");
472
473        let manifest = build_manifest(dir.path()).unwrap();
474
475        assert_eq!(
476            paths(&manifest),
477            [".well-known/security.txt", "assets/app.js", "index.html"]
478        );
479    }
480
481    #[test]
482    fn parent_gitignore_does_not_hide_the_output_dir() {
483        let repo = tempfile::tempdir().unwrap();
484        write(repo.path(), ".gitignore", "dist/\n");
485        write(repo.path(), "dist/index.html", "hi");
486
487        let manifest = build_manifest(&repo.path().join("dist")).unwrap();
488
489        assert_eq!(paths(&manifest), ["index.html"]);
490    }
491
492    #[cfg(unix)]
493    #[test]
494    fn refuses_symlinks() {
495        let dir = tempfile::tempdir().unwrap();
496        write(dir.path(), "index.html", "hi");
497        std::os::unix::fs::symlink("/etc/passwd", dir.path().join("link")).unwrap();
498
499        let error = build_manifest(dir.path()).unwrap_err().to_string();
500
501        assert!(error.contains("symlink"), "{error}");
502    }
503
504    #[test]
505    fn hashes_are_stable_and_correct() {
506        let dir = tempfile::tempdir().unwrap();
507        write(dir.path(), "a.txt", "hello");
508
509        let first = build_manifest(dir.path()).unwrap();
510        let second = build_manifest(dir.path()).unwrap();
511
512        assert_eq!(
513            first.files[0].entry.sha256,
514            "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
515        );
516        assert_eq!(first.files[0].entry, second.files[0].entry);
517        assert_eq!(first.total_bytes(), 5);
518    }
519
520    struct Statuses(Mutex<Vec<&'static str>>);
521
522    impl Respond for Statuses {
523        fn respond(&self, _: &Request) -> ResponseTemplate {
524            let mut list = self.0.lock().unwrap();
525            let status = if list.len() > 1 {
526                list.remove(0)
527            } else {
528                list[0]
529            };
530            ResponseTemplate::new(200).set_body_json(serde_json::json!({
531                "id": "d1", "status": status, "error": if status == "failed" { Some("disk full") } else { None },
532                "url": "https://site.example"
533            }))
534        }
535    }
536
537    fn transport(server: &MockServer) -> HttpsArtifactTransport {
538        HttpsArtifactTransport::new(
539            server.uri(),
540            "app1".into(),
541            "smb_deploy_x".into(),
542            Some("abc1234".into()),
543        )
544        .with_timing(
545            Duration::from_millis(5),
546            Duration::from_secs(5),
547            Duration::from_millis(1),
548        )
549    }
550
551    async fn ship(
552        transport: HttpsArtifactTransport,
553        dir: PathBuf,
554    ) -> (HttpsArtifactTransport, Result<(), DeployError>) {
555        tokio::task::spawn_blocking(move || {
556            let result = transport.ship(&dir, &NoopReporter);
557            (transport, result)
558        })
559        .await
560        .unwrap()
561    }
562
563    #[tokio::test(flavor = "multi_thread")]
564    async fn uploads_only_the_missing_blobs_then_waits_for_live() {
565        let dir = tempfile::tempdir().unwrap();
566        write(dir.path(), "a.txt", "hello");
567        write(dir.path(), "b.txt", "world");
568        let known = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
569        let new = "486ea46224d1bb4fb680f34f7c9ad96a8f24ec88be73ea8e5a6c65260e9cb8a7";
570
571        let server = MockServer::start().await;
572        Mock::given(method("POST"))
573            .and(path("/v1/apps/app1/artifact_deployments"))
574            .and(header("authorization", "Bearer smb_deploy_x"))
575            .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
576                "id": "d1", "status": "uploading", "missing": [new]
577            })))
578            .expect(1)
579            .mount(&server)
580            .await;
581        Mock::given(method("PUT"))
582            .and(path(format!(
583                "/v1/apps/app1/artifact_deployments/d1/blobs/{new}"
584            )))
585            .respond_with(ResponseTemplate::new(204))
586            .expect(1)
587            .mount(&server)
588            .await;
589        Mock::given(method("PUT"))
590            .and(path(format!(
591                "/v1/apps/app1/artifact_deployments/d1/blobs/{known}"
592            )))
593            .respond_with(ResponseTemplate::new(204))
594            .expect(0)
595            .mount(&server)
596            .await;
597        Mock::given(method("POST"))
598            .and(path("/v1/apps/app1/artifact_deployments/d1/finalize"))
599            .respond_with(
600                ResponseTemplate::new(202)
601                    .set_body_json(serde_json::json!({"id":"d1","status":"queued"})),
602            )
603            .expect(1)
604            .mount(&server)
605            .await;
606        Mock::given(method("GET"))
607            .and(path("/v1/apps/app1/artifact_deployments/d1"))
608            .respond_with(Statuses(Mutex::new(vec!["queued", "publishing", "live"])))
609            .mount(&server)
610            .await;
611
612        let (transport, result) = ship(transport(&server), dir.path().to_path_buf()).await;
613
614        result.unwrap();
615        assert_eq!(
616            transport.deployment().unwrap().url.as_deref(),
617            Some("https://site.example")
618        );
619    }
620
621    #[tokio::test(flavor = "multi_thread")]
622    async fn retries_a_flaky_upload() {
623        let dir = tempfile::tempdir().unwrap();
624        write(dir.path(), "a.txt", "hello");
625        let sha = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
626        let server = MockServer::start().await;
627        Mock::given(method("POST"))
628            .and(path("/v1/apps/app1/artifact_deployments"))
629            .respond_with(
630                ResponseTemplate::new(201).set_body_json(
631                    serde_json::json!({"id":"d1","status":"uploading","missing":[sha]}),
632                ),
633            )
634            .mount(&server)
635            .await;
636        Mock::given(method("PUT"))
637            .and(path_regex("/blobs/"))
638            .respond_with(ResponseTemplate::new(503))
639            .up_to_n_times(2)
640            .with_priority(1)
641            .mount(&server)
642            .await;
643        Mock::given(method("PUT"))
644            .and(path_regex("/blobs/"))
645            .respond_with(ResponseTemplate::new(204))
646            .with_priority(2)
647            .expect(1)
648            .mount(&server)
649            .await;
650        Mock::given(method("POST"))
651            .and(path("/v1/apps/app1/artifact_deployments/d1/finalize"))
652            .respond_with(
653                ResponseTemplate::new(202)
654                    .set_body_json(serde_json::json!({"id":"d1","status":"queued"})),
655            )
656            .mount(&server)
657            .await;
658        Mock::given(method("GET"))
659            .and(path("/v1/apps/app1/artifact_deployments/d1"))
660            .respond_with(Statuses(Mutex::new(vec!["live"])))
661            .mount(&server)
662            .await;
663
664        let (_, result) = ship(transport(&server), dir.path().to_path_buf()).await;
665
666        result.unwrap();
667    }
668
669    #[tokio::test(flavor = "multi_thread")]
670    async fn a_stored_login_header_still_sends_one_bearer() {
671        let dir = tempfile::tempdir().unwrap();
672        write(dir.path(), "a.txt", "hello");
673        let server = MockServer::start().await;
674        Mock::given(method("POST"))
675            .and(path("/v1/apps/app1/artifact_deployments"))
676            .and(header("authorization", "Bearer jwt.value"))
677            .respond_with(
678                ResponseTemplate::new(201).set_body_json(
679                    serde_json::json!({"id":"d1","status":"uploading","missing":[]}),
680                ),
681            )
682            .expect(1)
683            .mount(&server)
684            .await;
685        Mock::given(method("POST"))
686            .and(path("/v1/apps/app1/artifact_deployments/d1/finalize"))
687            .and(header("authorization", "Bearer jwt.value"))
688            .respond_with(
689                ResponseTemplate::new(202)
690                    .set_body_json(serde_json::json!({"id":"d1","status":"queued"})),
691            )
692            .expect(1)
693            .mount(&server)
694            .await;
695        Mock::given(method("GET"))
696            .and(path("/v1/apps/app1/artifact_deployments/d1"))
697            .and(header("authorization", "Bearer jwt.value"))
698            .respond_with(Statuses(Mutex::new(vec!["live"])))
699            .mount(&server)
700            .await;
701
702        let login = HttpsArtifactTransport::new(
703            server.uri(),
704            "app1".into(),
705            "Bearer jwt.value".into(),
706            None,
707        )
708        .with_timing(
709            Duration::from_millis(5),
710            Duration::from_secs(5),
711            Duration::from_millis(1),
712        );
713        let (_, result) = ship(login, dir.path().to_path_buf()).await;
714
715        result.unwrap();
716    }
717
718    #[tokio::test(flavor = "multi_thread")]
719    async fn a_failed_status_surfaces_the_server_error() {
720        let dir = tempfile::tempdir().unwrap();
721        write(dir.path(), "a.txt", "hello");
722        let server = MockServer::start().await;
723        Mock::given(method("POST"))
724            .and(path("/v1/apps/app1/artifact_deployments"))
725            .respond_with(
726                ResponseTemplate::new(201).set_body_json(
727                    serde_json::json!({"id":"d1","status":"uploading","missing":[]}),
728                ),
729            )
730            .mount(&server)
731            .await;
732        Mock::given(method("POST"))
733            .and(path("/v1/apps/app1/artifact_deployments/d1/finalize"))
734            .respond_with(
735                ResponseTemplate::new(202)
736                    .set_body_json(serde_json::json!({"id":"d1","status":"queued"})),
737            )
738            .mount(&server)
739            .await;
740        Mock::given(method("GET"))
741            .and(path("/v1/apps/app1/artifact_deployments/d1"))
742            .respond_with(Statuses(Mutex::new(vec!["publishing", "failed"])))
743            .mount(&server)
744            .await;
745
746        let (_, result) = ship(transport(&server), dir.path().to_path_buf()).await;
747
748        let error = result.unwrap_err().to_string();
749        assert!(error.contains("disk full"), "{error}");
750    }
751
752    #[tokio::test(flavor = "multi_thread")]
753    async fn a_refused_create_is_an_error_and_uploads_nothing() {
754        let dir = tempfile::tempdir().unwrap();
755        write(dir.path(), "a.txt", "hello");
756        let server = MockServer::start().await;
757        Mock::given(method("POST"))
758            .and(path("/v1/apps/app1/artifact_deployments"))
759            .respond_with(ResponseTemplate::new(401))
760            .mount(&server)
761            .await;
762
763        let (_, result) = ship(transport(&server), dir.path().to_path_buf()).await;
764
765        assert!(result.unwrap_err().to_string().contains("401"));
766    }
767}