1use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
2use reqwest::blocking::Client;
3use rustls::RootCertStore;
4use secrecy::{ExposeSecret, SecretString};
5use serde::Deserialize;
6use std::fs::File;
7use std::io::{Read, Write};
8use std::path::PathBuf;
9use std::time::Duration;
10use zip::ZipArchive;
11
12#[cfg(all(not(test), not(feature = "emulator")))]
13use crate::settings::INTERNAL_CARD_ROOT;
14
15const CHUNK_SIZE: usize = 10 * 1024 * 1024;
17
18const CHUNK_TIMEOUT_SECS: u64 = 30;
20
21const MAX_RETRIES: usize = 3;
23
24pub struct OtaClient {
46 client: Client,
47 token: Option<SecretString>,
48}
49
50#[derive(thiserror::Error, Debug)]
52pub enum OtaError {
53 #[error("GitHub API error: {0}")]
55 Api(String),
56
57 #[error("HTTP request error: {0}")]
59 Request(#[from] reqwest::Error),
60
61 #[error("PR #{0} not found")]
63 PrNotFound(u32),
64
65 #[error("No build artifacts found for PR #{0}")]
67 NoArtifacts(u32),
68
69 #[error("No build artifacts found for default branch")]
71 NoDefaultBranchArtifacts,
72
73 #[error("No artifact matching '{0}' found in workflow run")]
75 ArtifactNotFound(String),
76
77 #[error("GitHub token not configured")]
79 NoToken,
80
81 #[error("Insufficient disk space: need 100MB, have {0}MB")]
83 InsufficientSpace(u64),
84
85 #[error("I/O error: {0}")]
87 Io(#[from] std::io::Error),
88
89 #[error("System error: {0}")]
91 Nix(#[from] nix::errno::Errno),
92
93 #[error("TLS configuration error: {0}")]
95 TlsConfig(String),
96
97 #[error("ZIP extraction error: {0}")]
99 ZipError(#[from] zip::result::ZipError),
100
101 #[error("Deployment error: {0}")]
103 DeploymentError(String),
104}
105
106#[derive(Debug, Clone)]
110pub enum OtaProgress {
111 CheckingPr,
113 FindingLatestBuild,
115 FindingWorkflow,
117 DownloadingArtifact { downloaded: u64, total: u64 },
119 Complete { path: PathBuf },
121}
122
123#[derive(Debug, Deserialize)]
124struct PullRequest {
125 head: PrHead,
126}
127
128#[derive(Debug, Deserialize)]
129struct PrHead {
130 sha: String,
131}
132
133#[derive(Debug, Deserialize)]
134struct WorkflowRunsResponse {
135 workflow_runs: Vec<WorkflowRun>,
136}
137
138#[derive(Debug, Deserialize)]
139struct WorkflowRun {
140 name: String,
141 id: u64,
142 #[serde(default)]
143 head_sha: Option<String>,
144}
145
146#[derive(Debug, Deserialize)]
147struct Repository {
148 default_branch: String,
149}
150
151#[derive(Debug, Deserialize)]
152struct ArtifactsResponse {
153 artifacts: Vec<Artifact>,
154}
155
156#[derive(Debug, Deserialize)]
157struct Artifact {
158 name: String,
159 id: u64,
160 size_in_bytes: u64,
161}
162
163#[derive(Debug, Deserialize)]
164struct Release {
165 assets: Vec<ReleaseAsset>,
166}
167
168#[derive(Debug, Deserialize)]
169struct ReleaseAsset {
170 name: String,
171 browser_download_url: String,
172 size: u64,
173}
174
175impl OtaClient {
176 pub fn new(github_token: Option<SecretString>) -> Result<Self, OtaError> {
197 tracing::debug!("Initializing OTA client with webpki-roots certificates");
198
199 let root_store = create_webpki_root_store();
200 tracing::debug!(
201 certificate_count = root_store.len(),
202 "Created root certificate store"
203 );
204
205 let tls_config = rustls::ClientConfig::builder()
206 .with_root_certificates(root_store)
207 .with_no_client_auth();
208
209 tracing::debug!("Built TLS configuration");
210
211 let client = Client::builder()
212 .use_preconfigured_tls(tls_config)
213 .user_agent("cadmus-ota")
214 .timeout(Duration::from_secs(CHUNK_TIMEOUT_SECS))
215 .build()
216 .map_err(|e| OtaError::TlsConfig(format!("Failed to build HTTP client: {}", e)))?;
217
218 tracing::debug!("Successfully initialized OTA client");
219
220 Ok(Self {
221 client,
222 token: github_token,
223 })
224 }
225
226 fn get_token(&self) -> Result<&SecretString, OtaError> {
232 self.token.as_ref().ok_or_else(|| OtaError::NoToken)
233 }
234
235 pub fn download_pr_artifact<F>(
265 &self,
266 pr_number: u32,
267 mut progress_callback: F,
268 ) -> Result<PathBuf, OtaError>
269 where
270 F: FnMut(OtaProgress),
271 {
272 check_disk_space("/tmp")?;
273
274 progress_callback(OtaProgress::CheckingPr);
275 tracing::info!(pr_number, "Starting PR build download");
276 tracing::debug!(pr_number, "Checking PR");
277
278 let pr_url = format!(
279 "https://api.github.com/repos/ogkevin/cadmus/pulls/{}",
280 pr_number
281 );
282 tracing::debug!(url = %pr_url, "Fetching PR");
283
284 let response = self
285 .client
286 .get(&pr_url)
287 .header(
288 "Authorization",
289 format!("Bearer {}", self.get_token()?.expose_secret()),
290 )
291 .send()?;
292
293 tracing::debug!(
294 status = %response.status(),
295 headers = ?response.headers(),
296 "PR fetch response"
297 );
298
299 let response = response.error_for_status().map_err(|e| {
300 tracing::error!(
301 pr_number,
302 status = ?e.status(),
303 error = %e,
304 "PR fetch failed"
305 );
306 OtaError::PrNotFound(pr_number)
307 })?;
308
309 let pr: PullRequest = response.json()?;
310 tracing::debug!("Successfully parsed PR response");
311
312 let head_sha = pr.head.sha;
313 tracing::debug!(pr_number, head_sha = %head_sha, "Retrieved PR head SHA");
314
315 progress_callback(OtaProgress::FindingWorkflow);
316 tracing::debug!(head_sha = %head_sha, "Finding workflow runs");
317
318 let runs_url = format!(
319 "https://api.github.com/repos/ogkevin/cadmus/actions/runs?head_sha={}&event=pull_request",
320 head_sha
321 );
322 tracing::debug!(url = %runs_url, "Fetching workflow runs");
323
324 let response = self
325 .client
326 .get(&runs_url)
327 .header(
328 "Authorization",
329 format!("Bearer {}", self.get_token()?.expose_secret()),
330 )
331 .send()?;
332
333 tracing::debug!(
334 status = %response.status(),
335 headers = ?response.headers(),
336 "Workflow runs response"
337 );
338
339 let response = response.error_for_status().map_err(|e| {
340 tracing::error!(
341 head_sha = %head_sha,
342 status = ?e.status(),
343 error = %e,
344 "Workflow runs fetch failed"
345 );
346 OtaError::Api(format!("Failed to fetch workflow runs: {}", e))
347 })?;
348
349 let runs: WorkflowRunsResponse = response.json()?;
350 tracing::debug!(count = runs.workflow_runs.len(), "Found workflow runs");
351
352 #[cfg(feature = "otel")]
353 if tracing::enabled!(tracing::Level::DEBUG) {
354 for (idx, run) in runs.workflow_runs.iter().enumerate() {
355 tracing::debug!(
356 index = idx,
357 name = %run.name,
358 id = run.id,
359 "Workflow run"
360 );
361 }
362 }
363
364 let run = runs
365 .workflow_runs
366 .iter()
367 .find(|r| r.name == "Cargo")
368 .ok_or_else(|| {
369 tracing::error!(
370 pr_number,
371 workflow_name = "Cargo",
372 "No matching workflow run found"
373 );
374 OtaError::NoArtifacts(pr_number)
375 })?;
376
377 tracing::debug!(run_id = run.id, "Found Cargo workflow run");
378
379 #[cfg(feature = "test")]
380 let artifact_name_pattern = format!("cadmus-kobo-test-pr{}", pr_number);
381 #[cfg(not(feature = "test"))]
382 let artifact_name_pattern = format!("cadmus-kobo-pr{}", pr_number);
383
384 let artifact = self
385 .find_artifact_in_run(run.id, &artifact_name_pattern)
386 .map_err(|e| match e {
387 OtaError::ArtifactNotFound(_) => OtaError::NoArtifacts(pr_number),
388 other => other,
389 })?;
390
391 tracing::debug!(
392 name = %artifact.name,
393 id = artifact.id,
394 size_bytes = artifact.size_in_bytes,
395 "Found artifact"
396 );
397
398 let download_path = PathBuf::from(format!("/tmp/cadmus-ota-{}.zip", pr_number));
399
400 self.download_artifact_to_path(&artifact, &download_path, &mut progress_callback)?;
401
402 progress_callback(OtaProgress::Complete {
403 path: download_path.clone(),
404 });
405
406 tracing::info!(pr_number, "PR build download completed");
407 Ok(download_path)
408 }
409
410 pub fn download_default_branch_artifact<F>(
437 &self,
438 mut progress_callback: F,
439 ) -> Result<PathBuf, OtaError>
440 where
441 F: FnMut(OtaProgress),
442 {
443 check_disk_space("/tmp")?;
444
445 progress_callback(OtaProgress::FindingLatestBuild);
446 tracing::info!("Starting main branch build download");
447 tracing::debug!("Finding latest default branch build");
448
449 let default_branch = self.fetch_default_branch()?;
450
451 let encoded_branch = utf8_percent_encode(&default_branch, NON_ALPHANUMERIC);
452 let runs_url = format!(
453 "https://api.github.com/repos/ogkevin/cadmus/actions/workflows/cargo.yml/runs?branch={}&event=push&status=success&per_page=1",
454 encoded_branch
455 );
456 tracing::debug!(url = %runs_url, "Fetching Cargo workflow runs on default branch");
457
458 let response = self
459 .client
460 .get(runs_url)
461 .header(
462 "Authorization",
463 format!("Bearer {}", self.get_token()?.expose_secret()),
464 )
465 .send()?;
466
467 tracing::debug!(
468 status = %response.status(),
469 headers = ?response.headers(),
470 "Cargo workflow runs response"
471 );
472
473 let response = response.error_for_status().map_err(|e| {
474 tracing::error!(
475 status = ?e.status(),
476 error = %e,
477 "Cargo workflow runs fetch failed"
478 );
479 OtaError::Api(format!("Failed to fetch Cargo workflow runs: {}", e))
480 })?;
481
482 let runs: WorkflowRunsResponse = response.json()?;
483 tracing::debug!(
484 count = runs.workflow_runs.len(),
485 "Found Cargo workflow runs"
486 );
487
488 let cargo_run = runs.workflow_runs.first().ok_or_else(|| {
489 tracing::error!("No successful Cargo workflow run found on default branch");
490 OtaError::NoDefaultBranchArtifacts
491 })?;
492
493 tracing::debug!(run_id = cargo_run.id, "Found Cargo workflow run");
494
495 let head_sha = cargo_run.head_sha.as_deref().ok_or_else(|| {
496 tracing::error!(run_id = cargo_run.id, "Workflow run missing head_sha");
497 OtaError::Api(format!("Workflow run {} missing head_sha", cargo_run.id))
498 })?;
499 let short_sha = &head_sha[..7.min(head_sha.len())];
500
501 #[cfg(feature = "test")]
502 let artifact_name_prefix = format!("cadmus-kobo-test-{}", short_sha);
503 #[cfg(not(feature = "test"))]
504 let artifact_name_prefix = format!("cadmus-kobo-{}", short_sha);
505
506 tracing::debug!(pattern = %artifact_name_prefix, "Looking for artifact");
507
508 progress_callback(OtaProgress::FindingWorkflow);
509
510 let artifact = self
511 .find_artifact_in_run(cargo_run.id, &artifact_name_prefix)
512 .map_err(|e| match e {
513 OtaError::ArtifactNotFound(pattern) => {
514 tracing::error!(
515 pattern = %pattern,
516 "No matching artifact found on default branch"
517 );
518 OtaError::NoDefaultBranchArtifacts
519 }
520 other => other,
521 })?;
522
523 tracing::debug!(
524 name = %artifact.name,
525 id = artifact.id,
526 size_bytes = artifact.size_in_bytes,
527 "Found default branch artifact"
528 );
529
530 let download_path = PathBuf::from(format!("/tmp/cadmus-ota-{}.zip", short_sha));
531
532 self.download_artifact_to_path(&artifact, &download_path, &mut progress_callback)?;
533
534 progress_callback(OtaProgress::Complete {
535 path: download_path.clone(),
536 });
537
538 tracing::info!(sha = %short_sha, "Main branch build download completed");
539 Ok(download_path)
540 }
541
542 #[cfg_attr(feature = "otel", tracing::instrument(skip_all))]
569 pub fn download_stable_release_artifact<F>(
570 &self,
571 mut progress_callback: F,
572 ) -> Result<PathBuf, OtaError>
573 where
574 F: FnMut(OtaProgress),
575 {
576 check_disk_space("/tmp")?;
577
578 progress_callback(OtaProgress::FindingLatestBuild);
579 tracing::info!("Starting stable release download");
580 tracing::debug!("Finding latest stable release");
581
582 let releases_url = "https://api.github.com/repos/ogkevin/cadmus/releases/latest";
583 tracing::debug!(url = %releases_url, "Fetching latest release");
584
585 let mut request = self.client.get(releases_url);
586 if let Some(ref token) = self.token {
587 request = request.header("Authorization", format!("Bearer {}", token.expose_secret()));
588 }
589 let response = request.send()?;
590
591 tracing::debug!(
592 status = %response.status(),
593 headers = ?response.headers(),
594 "Latest release response"
595 );
596
597 let response = response.error_for_status().map_err(|e| {
598 tracing::error!(
599 status = ?e.status(),
600 error = %e,
601 "Latest release fetch failed"
602 );
603 OtaError::Api(format!("Failed to fetch latest release: {}", e))
604 })?;
605
606 let release: Release = response.json()?;
607 tracing::debug!(asset_count = release.assets.len(), "Found release assets");
608
609 #[cfg(feature = "otel")]
610 for (idx, asset) in release.assets.iter().enumerate() {
611 tracing::debug!(
612 index = idx,
613 name = %asset.name,
614 size_bytes = asset.size,
615 "Asset"
616 );
617 }
618
619 let asset_name = "KoboRoot.tgz";
620
621 let asset = release
622 .assets
623 .iter()
624 .find(|a| a.name == asset_name)
625 .ok_or_else(|| {
626 tracing::error!(
627 target_asset = asset_name,
628 "Asset not found in latest release"
629 );
630 OtaError::ArtifactNotFound(asset_name.to_owned())
631 })?;
632
633 tracing::debug!(
634 name = %asset.name,
635 url = %asset.browser_download_url,
636 size_bytes = asset.size,
637 "Found release asset"
638 );
639
640 let download_path = PathBuf::from("/tmp/cadmus-ota-stable-release.tgz");
641
642 self.download_release_asset(asset, &download_path, &mut progress_callback)?;
643
644 progress_callback(OtaProgress::Complete {
645 path: download_path.clone(),
646 });
647
648 tracing::info!("Stable release download completed");
649 Ok(download_path)
650 }
651
652 #[cfg_attr(feature = "otel", tracing::instrument(skip(self)))]
669 pub fn deploy(&self, kobo_root_path: PathBuf) -> Result<PathBuf, OtaError> {
670 tracing::info!(path = ?kobo_root_path, "Deploying KoboRoot.tgz");
671
672 let mut kobo_root_data = Vec::new();
673 {
674 let mut file = File::open(&kobo_root_path)?;
675 file.read_to_end(&mut kobo_root_data)?;
676 }
677
678 tracing::debug!(
679 bytes = kobo_root_data.len(),
680 path = ?kobo_root_path,
681 "Read KoboRoot.tgz"
682 );
683
684 self.deploy_bytes(&kobo_root_data)
685 }
686
687 fn deploy_bytes(&self, data: &[u8]) -> Result<PathBuf, OtaError> {
706 #[cfg(test)]
707 let deploy_path = {
708 std::env::temp_dir()
709 .join("test-kobo-deployment")
710 .join("KoboRoot.tgz")
711 };
712
713 #[cfg(all(feature = "emulator", not(test)))]
714 let deploy_path = PathBuf::from("/tmp/.kobo/KoboRoot.tgz");
715
716 #[cfg(all(not(feature = "emulator"), not(test)))]
717 let deploy_path = PathBuf::from(format!("{}/.kobo/KoboRoot.tgz", INTERNAL_CARD_ROOT));
718
719 tracing::debug!(path = ?deploy_path, "Deploy destination");
720
721 #[cfg(any(test, feature = "emulator"))]
722 {
723 if let Some(parent) = deploy_path.parent() {
724 tracing::debug!(directory = ?parent, "Creating parent directory");
725 std::fs::create_dir_all(parent)?;
726 }
727 }
728
729 tracing::debug!(bytes = data.len(), path = ?deploy_path, "Writing file");
730 let mut file = File::create(&deploy_path)?;
731 file.write_all(data)?;
732
733 tracing::debug!(path = ?deploy_path, "Deployment complete");
734 tracing::info!(path = ?deploy_path, "Update deployed successfully");
735
736 Ok(deploy_path)
737 }
738
739 #[cfg_attr(feature = "otel", tracing::instrument(skip(self)))]
759 pub fn extract_and_deploy(&self, zip_path: PathBuf) -> Result<PathBuf, OtaError> {
760 tracing::info!(path = ?zip_path, "Extracting and deploying update");
761 tracing::debug!(path = ?zip_path, "Starting extraction");
762
763 let file = File::open(&zip_path)?;
764 let mut archive = ZipArchive::new(file)?;
765
766 tracing::debug!(file_count = archive.len(), "Opened ZIP archive");
767
768 let mut kobo_root_data = Vec::new();
769 let mut found = false;
770
771 #[cfg(not(feature = "test"))]
772 let kobo_root_name = "KoboRoot.tgz";
773 #[cfg(feature = "test")]
774 let kobo_root_name = "KoboRoot-test.tgz";
775
776 tracing::debug!(target_file = kobo_root_name, "Looking for file");
777
778 for i in 0..archive.len() {
779 let mut entry = archive.by_index(i)?;
780 let entry_name = entry.name().to_string();
781
782 tracing::debug!(index = i, name = %entry_name, "Checking entry");
783
784 if entry_name.eq(kobo_root_name) {
785 tracing::debug!(name = %entry_name, "Found target file");
786 entry.read_to_end(&mut kobo_root_data)?;
787 found = true;
788 break;
789 }
790 }
791
792 if !found {
793 tracing::error!(
794 target_file = kobo_root_name,
795 "Target file not found in artifact"
796 );
797 return Err(OtaError::DeploymentError(format!(
798 "{} not found in artifact",
799 kobo_root_name
800 )));
801 }
802
803 tracing::debug!(
804 bytes = kobo_root_data.len(),
805 file = kobo_root_name,
806 "Extracted file"
807 );
808
809 self.deploy_bytes(&kobo_root_data)
810 }
811
812 fn fetch_default_branch(&self) -> Result<String, OtaError> {
814 let repo_url = "https://api.github.com/repos/ogkevin/cadmus";
815 tracing::debug!(url = %repo_url, "Fetching repository metadata");
816
817 let response = self
818 .client
819 .get(repo_url)
820 .header(
821 "Authorization",
822 format!("Bearer {}", self.get_token()?.expose_secret()),
823 )
824 .send()?;
825
826 let response = response.error_for_status().map_err(|e| {
827 tracing::error!(
828 status = ?e.status(),
829 error = %e,
830 "Repository metadata fetch failed"
831 );
832 OtaError::Api(format!("Failed to fetch repository metadata: {}", e))
833 })?;
834
835 let repo: Repository = response.json()?;
836 tracing::debug!(default_branch = %repo.default_branch, "Resolved default branch");
837
838 Ok(repo.default_branch)
839 }
840
841 fn find_artifact_in_run(&self, run_id: u64, name_prefix: &str) -> Result<Artifact, OtaError> {
843 let artifacts_url = format!(
844 "https://api.github.com/repos/ogkevin/cadmus/actions/runs/{}/artifacts",
845 run_id
846 );
847 tracing::debug!(url = %artifacts_url, "Fetching artifacts");
848
849 let response = self
850 .client
851 .get(&artifacts_url)
852 .header(
853 "Authorization",
854 format!("Bearer {}", self.get_token()?.expose_secret()),
855 )
856 .send()?;
857
858 tracing::debug!(
859 status = %response.status(),
860 headers = ?response.headers(),
861 "Artifacts response"
862 );
863
864 let response = response.error_for_status().map_err(|e| {
865 tracing::error!(
866 run_id,
867 status = ?e.status(),
868 error = %e,
869 "Artifacts fetch failed"
870 );
871 OtaError::Api(format!("Failed to fetch artifacts: {}", e))
872 })?;
873
874 let artifacts: ArtifactsResponse = response.json()?;
875 tracing::debug!(count = artifacts.artifacts.len(), "Found artifacts");
876
877 #[cfg(feature = "otel")]
878 if tracing::enabled!(tracing::Level::DEBUG) {
879 for (idx, artifact) in artifacts.artifacts.iter().enumerate() {
880 tracing::debug!(
881 index = idx,
882 name = %artifact.name,
883 id = artifact.id,
884 size_bytes = artifact.size_in_bytes,
885 "Artifact"
886 );
887 }
888 }
889
890 tracing::debug!(pattern = %name_prefix, "Looking for artifact");
891
892 artifacts
893 .artifacts
894 .into_iter()
895 .find(|a| a.name.starts_with(name_prefix))
896 .ok_or_else(|| {
897 tracing::error!(
898 run_id,
899 pattern = %name_prefix,
900 "No matching artifact found"
901 );
902 OtaError::ArtifactNotFound(name_prefix.to_owned())
903 })
904 }
905
906 #[cfg_attr(feature = "otel", tracing::instrument(skip(self, progress_callback)))]
923 fn download_by_url_to_path<F>(
924 &self,
925 url: &str,
926 total_size: u64,
927 download_path: &PathBuf,
928 progress_callback: &mut F,
929 use_auth: bool,
930 ) -> Result<(), OtaError>
931 where
932 F: FnMut(OtaProgress),
933 {
934 progress_callback(OtaProgress::DownloadingArtifact {
935 downloaded: 0,
936 total: total_size,
937 });
938
939 tracing::debug!(url = %url, "Downloading file");
940 tracing::debug!(path = ?download_path, "Download destination");
941
942 let mut file = File::create(download_path)?;
943
944 let mut downloaded = 0u64;
945
946 tracing::debug!(
947 chunk_size_mb = CHUNK_SIZE / (1024 * 1024),
948 "Starting chunked download"
949 );
950
951 while downloaded < total_size {
952 let chunk_start = downloaded;
953 let chunk_end = std::cmp::min(downloaded + CHUNK_SIZE as u64 - 1, total_size - 1);
954
955 tracing::debug!(chunk_start, chunk_end, total_size, "Downloading chunk");
956
957 let chunk_data =
958 self.download_chunk_with_retries(url, chunk_start, chunk_end, use_auth)?;
959
960 file.write_all(&chunk_data)?;
961 downloaded += chunk_data.len() as u64;
962
963 progress_callback(OtaProgress::DownloadingArtifact {
964 downloaded,
965 total: total_size,
966 });
967
968 tracing::debug!(
969 downloaded,
970 total_size,
971 progress_percent = (downloaded as f64 / total_size as f64) * 100.0,
972 "Download progress"
973 );
974 }
975
976 tracing::debug!(bytes = downloaded, "Download complete");
977 tracing::debug!(path = ?download_path, "Saved file");
978
979 Ok(())
980 }
981
982 fn download_artifact_to_path<F>(
986 &self,
987 artifact: &Artifact,
988 download_path: &PathBuf,
989 progress_callback: &mut F,
990 ) -> Result<(), OtaError>
991 where
992 F: FnMut(OtaProgress),
993 {
994 let download_url = format!(
995 "https://api.github.com/repos/ogkevin/cadmus/actions/artifacts/{}/zip",
996 artifact.id
997 );
998
999 self.download_by_url_to_path(
1000 &download_url,
1001 artifact.size_in_bytes,
1002 download_path,
1003 progress_callback,
1004 true,
1005 )
1006 }
1007
1008 fn download_chunk_with_retries(
1027 &self,
1028 url: &str,
1029 start: u64,
1030 end: u64,
1031 use_auth: bool,
1032 ) -> Result<Vec<u8>, OtaError> {
1033 let mut last_error = None;
1034
1035 for attempt in 1..=MAX_RETRIES {
1036 match self.download_chunk(url, start, end, use_auth) {
1037 Ok(data) => {
1038 if attempt > 1 {
1039 tracing::debug!(
1040 attempt,
1041 max_retries = MAX_RETRIES,
1042 "Chunk download succeeded after retry"
1043 );
1044 }
1045 return Ok(data);
1046 }
1047 Err(e) => {
1048 tracing::warn!(
1049 attempt,
1050 max_retries = MAX_RETRIES,
1051 error = %e,
1052 "Chunk download failed"
1053 );
1054 last_error = Some(e);
1055
1056 if attempt < MAX_RETRIES {
1057 let backoff_ms = 1000 * (2u64.pow(attempt as u32 - 1));
1058 tracing::debug!(backoff_ms, "Retrying after backoff");
1059 std::thread::sleep(Duration::from_millis(backoff_ms));
1060 }
1061 }
1062 }
1063 }
1064
1065 Err(last_error.unwrap_or_else(|| {
1066 OtaError::Api("Failed to download chunk after all retries".to_string())
1067 }))
1068 }
1069
1070 fn download_chunk(
1087 &self,
1088 url: &str,
1089 start: u64,
1090 end: u64,
1091 use_auth: bool,
1092 ) -> Result<Vec<u8>, OtaError> {
1093 let range_header = format!("bytes={}-{}", start, end);
1094
1095 let mut request = self.client.get(url).header("Range", range_header);
1096
1097 if use_auth {
1098 if let Some(ref token) = self.token {
1099 request =
1100 request.header("Authorization", format!("Bearer {}", token.expose_secret()));
1101 } else {
1102 tracing::error!("Attempted authenticated download without token");
1103 return Err(OtaError::NoToken);
1104 }
1105 }
1106
1107 let response = request
1108 .send()?
1109 .error_for_status()
1110 .map_err(|e| OtaError::Api(format!("Failed to download chunk: {}", e)))?;
1111
1112 let bytes = response.bytes()?;
1113 Ok(bytes.to_vec())
1114 }
1115
1116 #[inline]
1121 #[cfg_attr(feature = "otel", tracing::instrument(skip(self, progress_callback)))]
1122 fn download_release_asset<F>(
1123 &self,
1124 asset: &ReleaseAsset,
1125 download_path: &PathBuf,
1126 progress_callback: &mut F,
1127 ) -> Result<(), OtaError>
1128 where
1129 F: FnMut(OtaProgress),
1130 {
1131 self.download_by_url_to_path(
1132 &asset.browser_download_url,
1133 asset.size,
1134 download_path,
1135 progress_callback,
1136 false,
1137 )
1138 }
1139}
1140
1141fn check_disk_space(path: &str) -> Result<(), OtaError> {
1153 use nix::sys::statvfs::statvfs;
1154
1155 let stat = statvfs(path)?;
1156 let available_mb = (stat.blocks_available() as u64 * stat.block_size() as u64) / (1024 * 1024);
1157 tracing::debug!(path, available_mb, "Checking disk space");
1158
1159 if available_mb < 100 {
1160 tracing::error!(
1161 path,
1162 available_mb,
1163 required_mb = 100,
1164 "Insufficient disk space"
1165 );
1166 return Err(OtaError::InsufficientSpace(available_mb as u64));
1167 }
1168 Ok(())
1169}
1170
1171fn create_webpki_root_store() -> RootCertStore {
1176 tracing::debug!("Loading Mozilla root certificates from webpki-roots");
1177 let mut root_store = RootCertStore::empty();
1178
1179 root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
1180
1181 tracing::debug!(
1182 certificate_count = root_store.len(),
1183 "Loaded root certificates from webpki-roots"
1184 );
1185 root_store
1186}
1187
1188#[cfg(test)]
1189mod tests {
1190 use super::*;
1191 use tempfile::TempDir;
1192
1193 #[test]
1194 fn test_create_webpki_root_store() {
1195 let root_store = create_webpki_root_store();
1196 assert!(
1197 !root_store.is_empty(),
1198 "Root certificate store should not be empty"
1199 );
1200 }
1201
1202 #[test]
1203 fn test_create_webpki_root_store_has_certificates() {
1204 let root_store = create_webpki_root_store();
1205 assert!(
1206 root_store.len() > 50,
1207 "Expected at least 50 root certificates, got {}",
1208 root_store.len()
1209 );
1210 }
1211
1212 #[test]
1213 fn test_ota_error_from_reqwest_error() {
1214 let reqwest_error = reqwest::blocking::Client::new()
1215 .get("http://invalid-url-that-does-not-exist-12345.com")
1216 .send()
1217 .unwrap_err();
1218 let ota_error: OtaError = reqwest_error.into();
1219 assert!(matches!(ota_error, OtaError::Request(_)));
1220 }
1221
1222 #[test]
1223 fn test_check_disk_space_sufficient() {
1224 let temp_dir = TempDir::new().unwrap();
1225 let result = check_disk_space(temp_dir.path().to_str().unwrap());
1226 assert!(
1227 result.is_ok(),
1228 "Should have sufficient disk space in temp directory"
1229 );
1230 }
1231
1232 #[test]
1233 fn test_extract_and_deploy_success() {
1234 rustls::crypto::ring::default_provider()
1235 .install_default()
1236 .ok();
1237
1238 let client = OtaClient::new(Some(SecretString::from("test_token".to_string()))).unwrap();
1239 let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1240 .join("src/ota/tests/fixtures/test_artifact.zip");
1241
1242 let result = client.extract_and_deploy(fixture_path);
1243
1244 assert!(
1245 result.is_ok(),
1246 "Deployment should succeed: {:?}",
1247 result.err()
1248 );
1249
1250 let deploy_path = result.unwrap();
1251 assert!(
1252 deploy_path.exists(),
1253 "Deployed file should exist at {:?}",
1254 deploy_path
1255 );
1256
1257 let content = std::fs::read_to_string(&deploy_path).unwrap();
1258 assert!(
1259 content.contains("Mock KoboRoot.tgz"),
1260 "Deployed file should contain mock content"
1261 );
1262
1263 std::fs::remove_file(&deploy_path).ok();
1264 }
1265
1266 #[test]
1267 fn test_extract_and_deploy_missing_koboroot() {
1268 rustls::crypto::ring::default_provider()
1269 .install_default()
1270 .ok();
1271
1272 let client = OtaClient::new(Some(SecretString::from("test_token".to_string()))).unwrap();
1273 let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1274 .join("src/ota/tests/fixtures/empty_artifact.zip");
1275
1276 let result = client.extract_and_deploy(fixture_path);
1277 assert!(result.is_err(), "Should fail when KoboRoot.tgz is missing");
1278
1279 if let Err(OtaError::DeploymentError(msg)) = result {
1280 assert!(
1281 msg.contains("not found in artifact"),
1282 "Error should mention missing file"
1283 );
1284 } else {
1285 panic!("Expected DeploymentError");
1286 }
1287 }
1288
1289 fn external_test_enabled() -> bool {
1290 std::env::var("CADMUS_TEST_OTA_EXTERNAL").is_ok() && std::env::var("GH_TOKEN").is_ok()
1291 }
1292
1293 fn create_external_client() -> OtaClient {
1294 rustls::crypto::ring::default_provider()
1295 .install_default()
1296 .ok();
1297
1298 let token = std::env::var("GH_TOKEN").expect("GH_TOKEN must be set");
1299 OtaClient::new(Some(SecretString::from(token))).expect("Failed to create OtaClient")
1300 }
1301
1302 #[test]
1303 #[ignore]
1304 fn test_external_download_default_branch_and_deploy() {
1305 if !external_test_enabled() {
1306 return;
1307 }
1308
1309 let client = create_external_client();
1310 let mut last_progress = None;
1311
1312 let download_result = client.download_default_branch_artifact(|progress| {
1313 last_progress = Some(format!("{:?}", progress));
1314 });
1315
1316 assert!(
1317 download_result.is_ok(),
1318 "Default branch artifact download should succeed: {:?}",
1319 download_result.err()
1320 );
1321
1322 let zip_path = download_result.unwrap();
1323 assert!(
1324 zip_path.exists(),
1325 "Downloaded ZIP should exist at {:?}",
1326 zip_path
1327 );
1328 assert!(
1329 zip_path.metadata().unwrap().len() > 0,
1330 "Downloaded ZIP should not be empty"
1331 );
1332
1333 let deploy_result = client.extract_and_deploy(zip_path.clone());
1334
1335 assert!(
1336 deploy_result.is_ok(),
1337 "Deployment should succeed: {:?}",
1338 deploy_result.err()
1339 );
1340
1341 let deploy_path = deploy_result.unwrap();
1342 assert!(
1343 deploy_path.exists(),
1344 "Deployed file should exist at {:?}",
1345 deploy_path
1346 );
1347
1348 std::fs::remove_file(&zip_path).ok();
1349 std::fs::remove_file(&deploy_path).ok();
1350 }
1351
1352 #[test]
1353 #[ignore]
1354 fn test_external_download_stable_release_and_deploy() {
1355 if !external_test_enabled() {
1356 return;
1357 }
1358
1359 let client = create_external_client();
1360 let mut last_progress = None;
1361
1362 let download_result = client.download_stable_release_artifact(|progress| {
1363 last_progress = Some(format!("{:?}", progress));
1364 });
1365
1366 assert!(
1367 download_result.is_ok(),
1368 "Stable release artifact download should succeed: {:?}",
1369 download_result.err()
1370 );
1371
1372 let asset_path = download_result.unwrap();
1373 assert!(
1374 asset_path.exists(),
1375 "Downloaded asset should exist at {:?}",
1376 asset_path
1377 );
1378 assert!(
1379 asset_path.metadata().unwrap().len() > 0,
1380 "Downloaded asset should not be empty"
1381 );
1382
1383 let deploy_result = client.deploy(asset_path.clone());
1384
1385 assert!(
1386 deploy_result.is_ok(),
1387 "Deployment should succeed: {:?}",
1388 deploy_result.err()
1389 );
1390
1391 let deploy_path = deploy_result.unwrap();
1392 assert!(
1393 deploy_path.exists(),
1394 "Deployed file should exist at {:?}",
1395 deploy_path
1396 );
1397
1398 std::fs::remove_file(&asset_path).ok();
1399 std::fs::remove_file(&deploy_path).ok();
1400 }
1401}