1use crate::github::types::{
2 Artifact, ArtifactsResponse, Release, ReleaseAsset, Repository, WorkflowRunsResponse,
3};
4use crate::github::{GithubClient, OtaProgress};
5use crate::http::ChunkedDownloadError;
6use crate::version::GitVersion;
7use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
8use std::fs::File;
9use std::io::{Read, Write};
10use std::path::{Path, PathBuf};
11use zip::ZipArchive;
12
13#[cfg(all(not(test), not(feature = "emulator")))]
14use crate::settings::INTERNAL_CARD_ROOT;
15
16pub struct OtaClient {
22 github: GithubClient,
23 tmp_dir: PathBuf,
24}
25
26#[derive(Debug, Clone)]
28pub enum ArtifactSource {
29 PullRequest(u32),
31 DefaultBranch,
33 WorkflowRun(String),
35 ReleaseAsset(String),
37}
38
39impl std::fmt::Display for ArtifactSource {
40 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41 match self {
42 ArtifactSource::PullRequest(pr) => write!(f, "No build artifacts found for PR #{}", pr),
43 ArtifactSource::DefaultBranch => {
44 write!(f, "No build artifacts found for default branch")
45 }
46 ArtifactSource::WorkflowRun(pattern) => {
47 write!(
48 f,
49 "No artifact matching '{}' found in workflow run",
50 pattern
51 )
52 }
53 ArtifactSource::ReleaseAsset(name) => write!(f, "No release asset '{}' found", name),
54 }
55 }
56}
57
58#[derive(thiserror::Error, Debug)]
60pub enum OtaError {
61 #[error("GitHub API error: {0}")]
63 Api(String),
64
65 #[error("HTTP request error: {0}")]
67 Request(#[from] reqwest::Error),
68
69 #[error("PR #{0} not found")]
71 PrNotFound(u32),
72
73 #[error("{0}")]
75 ArtifactsNotFound(ArtifactSource),
76
77 #[error("GitHub token not configured")]
79 NoToken,
80
81 #[error("GitHub token is invalid or revoked")]
83 Unauthorized,
84
85 #[error(transparent)]
90 InsufficientScopes(#[from] crate::github::ScopeError),
91
92 #[error("Insufficient disk space: need 100MB, have {0}MB")]
94 InsufficientSpace(u64),
95
96 #[error("I/O error: {0}")]
98 Io(#[from] std::io::Error),
99
100 #[error("System error: {0}")]
102 Nix(#[from] nix::errno::Errno),
103
104 #[error("TLS configuration error: {0}")]
106 TlsConfig(String),
107
108 #[error("ZIP extraction error: {0}")]
110 ZipError(#[from] zip::result::ZipError),
111
112 #[error("Deployment error: {0}")]
114 DeploymentError(String),
115
116 #[error(transparent)]
118 VersionParse(#[from] crate::version::VersionError),
119}
120
121impl From<ChunkedDownloadError> for OtaError {
122 fn from(e: ChunkedDownloadError) -> Self {
123 match e {
124 ChunkedDownloadError::Request(r) if r.status().is_some() => api_error(r),
125 ChunkedDownloadError::Request(r) => OtaError::Request(r),
126 ChunkedDownloadError::Io(e) => OtaError::Io(e),
127 }
128 }
129}
130
131impl OtaClient {
132 pub fn new(github: GithubClient, tmp_dir: PathBuf) -> Self {
138 Self { github, tmp_dir }
139 }
140
141 pub fn download_pr_artifact<F>(
171 &self,
172 pr_number: u32,
173 mut progress_callback: F,
174 ) -> Result<PathBuf, OtaError>
175 where
176 F: FnMut(OtaProgress),
177 {
178 check_disk_space(&self.tmp_dir)?;
179 verify_scopes(&self.github)?;
180
181 progress_callback(OtaProgress::CheckingPr);
182 tracing::info!(pr_number, "Starting PR build download");
183 tracing::debug!(pr_number, "Checking PR");
184
185 let pr_url = format!(
186 "https://api.github.com/repos/ogkevin/cadmus/pulls/{}",
187 pr_number
188 );
189 tracing::debug!(url = %pr_url, "Fetching PR");
190
191 let response = self
192 .github
193 .get(&pr_url)
194 .send()?
195 .error_for_status()
196 .map_err(|e| {
197 tracing::error!(pr_number, status = ?e.status(), error = %e, "PR fetch failed");
198 if e.status() == Some(reqwest::StatusCode::UNAUTHORIZED) {
199 OtaError::Unauthorized
200 } else {
201 OtaError::PrNotFound(pr_number)
202 }
203 })?;
204
205 let pr: crate::github::types::PullRequest = response.json()?;
206 tracing::debug!("Successfully parsed PR response");
207 let head_sha = pr.head.sha;
208 tracing::debug!(pr_number, head_sha = %head_sha, "Retrieved PR head SHA");
209
210 progress_callback(OtaProgress::FindingWorkflow);
211 tracing::debug!(head_sha = %head_sha, "Finding workflow runs");
212
213 let runs_url = format!(
214 "https://api.github.com/repos/ogkevin/cadmus/actions/runs?head_sha={}&event=pull_request",
215 head_sha
216 );
217 tracing::debug!(url = %runs_url, "Fetching workflow runs");
218
219 let runs: WorkflowRunsResponse = self
220 .github
221 .get(&runs_url)
222 .send()?
223 .error_for_status()
224 .map_err(|e| {
225 tracing::error!(head_sha = %head_sha, status = ?e.status(), error = %e, "Workflow runs fetch failed");
226 api_error(e)
227 })?
228 .json()?;
229
230 tracing::debug!(count = runs.workflow_runs.len(), "Found workflow runs");
231
232 #[cfg(feature = "tracing")]
233 if tracing::enabled!(tracing::Level::DEBUG) {
234 for (idx, run) in runs.workflow_runs.iter().enumerate() {
235 tracing::debug!(
236 index = idx,
237 name = %run.name,
238 id = run.id,
239 "Workflow run"
240 );
241 }
242 }
243
244 let run = runs
245 .workflow_runs
246 .iter()
247 .find(|r| r.name == "Cargo")
248 .ok_or_else(|| {
249 tracing::error!(pr_number, "No Cargo workflow run found");
250 OtaError::ArtifactsNotFound(ArtifactSource::PullRequest(pr_number))
251 })?;
252
253 tracing::debug!(run_id = run.id, "Found Cargo workflow run");
254
255 let artifact_name_pattern = cfg_select! {
256 feature = "test" => { format!("cadmus-kobo-test-pr{}", pr_number) }
257 _ => { format!("cadmus-kobo-pr{}", pr_number) }
258 };
259
260 let artifact = self
261 .find_artifact_in_run(run.id, &artifact_name_pattern)
262 .map_err(|e| match e {
263 OtaError::ArtifactsNotFound(ArtifactSource::WorkflowRun(_)) => {
264 OtaError::ArtifactsNotFound(ArtifactSource::PullRequest(pr_number))
265 }
266 other => other,
267 })?;
268
269 tracing::debug!(
270 name = %artifact.name,
271 id = artifact.id,
272 size_bytes = artifact.size_in_bytes,
273 "Found artifact"
274 );
275
276 let download_path = self.tmp_dir.join(format!("cadmus-ota-{}.zip", pr_number));
277
278 self.download_artifact_to_path(&artifact, &download_path, &mut progress_callback)?;
279
280 progress_callback(OtaProgress::Complete {
281 path: download_path.clone(),
282 });
283
284 tracing::info!(pr_number, "PR build download completed");
285 Ok(download_path)
286 }
287
288 pub fn download_default_branch_artifact<F>(
315 &self,
316 mut progress_callback: F,
317 ) -> Result<PathBuf, OtaError>
318 where
319 F: FnMut(OtaProgress),
320 {
321 check_disk_space(&self.tmp_dir)?;
322 verify_scopes(&self.github)?;
323
324 progress_callback(OtaProgress::FindingLatestBuild);
325 tracing::info!("Starting main branch build download");
326 tracing::debug!("Finding latest default branch build");
327
328 let default_branch = self.fetch_default_branch()?;
329
330 let encoded_branch = utf8_percent_encode(&default_branch, NON_ALPHANUMERIC);
331 let runs_url = format!(
332 "https://api.github.com/repos/ogkevin/cadmus/actions/workflows/cargo.yml/runs?branch={}&event=push&status=success&per_page=1",
333 encoded_branch
334 );
335 tracing::debug!(url = %runs_url, "Fetching Cargo workflow runs on default branch");
336
337 let runs: WorkflowRunsResponse = self
338 .github
339 .get(&runs_url)
340 .send()?
341 .error_for_status()
342 .map_err(|e| {
343 tracing::error!(status = ?e.status(), error = %e, "Cargo workflow runs fetch failed");
344 api_error(e)
345 })?
346 .json()?;
347
348 let cargo_run = runs.workflow_runs.first().ok_or_else(|| {
349 tracing::error!("No successful Cargo workflow run found on default branch");
350 OtaError::ArtifactsNotFound(ArtifactSource::DefaultBranch)
351 })?;
352
353 tracing::debug!(run_id = cargo_run.id, "Found Cargo workflow run");
354
355 let head_sha = cargo_run.head_sha.as_deref().ok_or_else(|| {
356 tracing::error!(run_id = cargo_run.id, "Workflow run missing head_sha");
357 OtaError::Api(format!("Workflow run {} missing head_sha", cargo_run.id))
358 })?;
359 let short_sha = &head_sha[..7.min(head_sha.len())];
360
361 let artifact_name_prefix = cfg_select! {
362 feature = "test" => { format!("cadmus-kobo-test-{}", short_sha) }
363 _ => { format!("cadmus-kobo-{}", short_sha) }
364 };
365
366 tracing::debug!(pattern = %artifact_name_prefix, "Looking for artifact");
367
368 progress_callback(OtaProgress::FindingWorkflow);
369
370 let artifact = self
371 .find_artifact_in_run(cargo_run.id, &artifact_name_prefix)
372 .map_err(|e| match e {
373 OtaError::ArtifactsNotFound(ArtifactSource::WorkflowRun(pattern)) => {
374 tracing::error!(pattern = %pattern, "No matching artifact found on default branch");
375 OtaError::ArtifactsNotFound(ArtifactSource::DefaultBranch)
376 }
377 other => other,
378 })?;
379
380 tracing::debug!(
381 name = %artifact.name,
382 id = artifact.id,
383 size_bytes = artifact.size_in_bytes,
384 "Found default branch artifact"
385 );
386
387 let download_path = self.tmp_dir.join(format!("cadmus-ota-{}.zip", short_sha));
388
389 self.download_artifact_to_path(&artifact, &download_path, &mut progress_callback)?;
390
391 progress_callback(OtaProgress::Complete {
392 path: download_path.clone(),
393 });
394
395 tracing::info!(sha = %short_sha, "Main branch build download completed");
396 Ok(download_path)
397 }
398
399 #[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
426 pub fn download_stable_release_artifact<F>(
427 &self,
428 mut progress_callback: F,
429 ) -> Result<PathBuf, OtaError>
430 where
431 F: FnMut(OtaProgress),
432 {
433 check_disk_space(&self.tmp_dir)?;
434
435 progress_callback(OtaProgress::FindingLatestBuild);
436 tracing::info!("Starting stable release download");
437 tracing::debug!("Finding latest stable release");
438
439 let releases_url = "https://api.github.com/repos/ogkevin/cadmus/releases/latest";
440 tracing::debug!(url = %releases_url, "Fetching latest release");
441
442 let release: Release = self
443 .github
444 .get_unauthenticated(releases_url)
445 .send()?
446 .error_for_status()
447 .map_err(|e| {
448 tracing::error!(status = ?e.status(), error = %e, "Latest release fetch failed");
449 api_error(e)
450 })?
451 .json()?;
452
453 tracing::debug!(asset_count = release.assets.len(), "Found release assets");
454
455 #[cfg(feature = "tracing")]
456 for (idx, asset) in release.assets.iter().enumerate() {
457 tracing::debug!(
458 index = idx,
459 name = %asset.name,
460 size_bytes = asset.size,
461 "Asset"
462 );
463 }
464
465 let asset_name = "KoboRoot.tgz";
466
467 let asset = release
468 .assets
469 .iter()
470 .find(|a| a.name == asset_name)
471 .ok_or_else(|| {
472 tracing::error!(
473 target_asset = asset_name,
474 "Asset not found in latest release"
475 );
476 OtaError::ArtifactsNotFound(ArtifactSource::ReleaseAsset(asset_name.to_owned()))
477 })?;
478
479 tracing::debug!(
480 name = %asset.name,
481 url = %asset.browser_download_url,
482 size_bytes = asset.size,
483 "Found release asset"
484 );
485
486 let download_path = self.tmp_dir.join("cadmus-ota-stable-release.tgz");
487
488 self.download_release_asset(asset, &download_path, &mut progress_callback)?;
489
490 progress_callback(OtaProgress::Complete {
491 path: download_path.clone(),
492 });
493
494 tracing::info!("Stable release download completed");
495 Ok(download_path)
496 }
497
498 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
527 pub fn fetch_latest_release_version(&self) -> Result<GitVersion, OtaError> {
528 let releases_url = "https://api.github.com/repos/ogkevin/cadmus/releases/latest";
529 tracing::debug!(url = %releases_url, "Fetching latest release version");
530
531 let release: Release = self
532 .github
533 .get_unauthenticated(releases_url)
534 .send()?
535 .error_for_status()
536 .map_err(|e| {
537 tracing::error!(status = ?e.status(), error = %e, "Latest release fetch failed");
538 api_error(e)
539 })?
540 .json()?;
541
542 tracing::info!(version = %release.tag_name, "Fetched latest release version");
543
544 let version: GitVersion = release.tag_name.parse()?;
545 Ok(version)
546 }
547
548 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
567 pub fn deploy(&self, kobo_root_path: PathBuf) -> Result<PathBuf, OtaError> {
568 tracing::info!(path = ?kobo_root_path, "Deploying KoboRoot.tgz");
569
570 let deploy_path = self.deploy_path();
571 self.ensure_deploy_dir(&deploy_path)?;
572
573 let mut src = File::open(&kobo_root_path)?;
574 let mut dst = File::create(&deploy_path)?;
575 let bytes_copied = std::io::copy(&mut src, &mut dst)?;
576
577 tracing::debug!(
578 bytes = bytes_copied,
579 src = ?kobo_root_path,
580 dst = ?deploy_path,
581 "Streamed KoboRoot.tgz to deploy path"
582 );
583
584 if kobo_root_path != deploy_path {
585 if let Err(e) = std::fs::remove_file(&kobo_root_path) {
586 tracing::error!(path = ?kobo_root_path, error = %e, "Failed to remove source file");
587 }
588 }
589
590 tracing::info!(path = ?deploy_path, "Update deployed successfully");
591 Ok(deploy_path)
592 }
593
594 fn deploy_path(&self) -> PathBuf {
602 let path = cfg_select! {
603 test => {
604 std::env::temp_dir()
605 .join("test-kobo-deployment")
606 .join("KoboRoot.tgz")
607 }
608 feature = "emulator" => { PathBuf::from("/tmp/.kobo/KoboRoot.tgz") }
609 _ => { PathBuf::from(format!("{}/.kobo/KoboRoot.tgz", INTERNAL_CARD_ROOT)) }
610 };
611
612 tracing::debug!(path = ?path, "Deploy destination");
613 path
614 }
615
616 fn ensure_deploy_dir(&self, deploy_path: &Path) -> Result<(), OtaError> {
617 #[cfg(any(test, feature = "emulator"))]
618 {
619 if let Some(parent) = deploy_path.parent() {
620 tracing::debug!(directory = ?parent, "Creating parent directory");
621 std::fs::create_dir_all(parent)?;
622 }
623 }
624
625 let _ = deploy_path;
626 Ok(())
627 }
628
629 fn deploy_bytes(&self, data: &[u8]) -> Result<PathBuf, OtaError> {
630 let deploy_path = self.deploy_path();
631 self.ensure_deploy_dir(&deploy_path)?;
632
633 tracing::debug!(bytes = data.len(), path = ?deploy_path, "Writing file");
634 let mut file = File::create(&deploy_path)?;
635 file.write_all(data)?;
636
637 tracing::debug!(path = ?deploy_path, "Deployment complete");
638 tracing::info!(path = ?deploy_path, "Update deployed successfully");
639
640 Ok(deploy_path)
641 }
642
643 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
664 pub fn extract_and_deploy(&self, zip_path: PathBuf) -> Result<PathBuf, OtaError> {
665 tracing::info!(path = ?zip_path, "Extracting and deploying update");
666 tracing::debug!(path = ?zip_path, "Starting extraction");
667
668 let file = File::open(&zip_path)?;
669 let mut archive = ZipArchive::new(file)?;
670
671 tracing::debug!(file_count = archive.len(), "Opened ZIP archive");
672
673 let mut kobo_root_data = Vec::new();
674 let mut found = false;
675
676 let kobo_root_name = cfg_select! {
677 feature = "test" => { "KoboRoot-test.tgz" }
678 _ => { "KoboRoot.tgz" }
679 };
680
681 tracing::debug!(target_file = kobo_root_name, "Looking for file");
682
683 for i in 0..archive.len() {
684 let mut entry = archive.by_index(i)?;
685 let entry_name = entry.name().to_string();
686
687 tracing::debug!(index = i, name = %entry_name, "Checking entry");
688
689 if entry_name.eq(kobo_root_name) {
690 tracing::debug!(name = %entry_name, "Found target file");
691 entry.read_to_end(&mut kobo_root_data)?;
692 found = true;
693 break;
694 }
695 }
696
697 if !found {
698 tracing::error!(
699 target_file = kobo_root_name,
700 "Target file not found in artifact"
701 );
702 return Err(OtaError::DeploymentError(format!(
703 "{} not found in artifact",
704 kobo_root_name
705 )));
706 }
707
708 tracing::debug!(
709 bytes = kobo_root_data.len(),
710 file = kobo_root_name,
711 "Extracted file"
712 );
713
714 let deploy_path = self.deploy_bytes(&kobo_root_data)?;
715 if let Err(e) = std::fs::remove_file(&zip_path) {
716 tracing::error!(path = ?zip_path, error = %e, "Failed to remove source file");
717 }
718
719 Ok(deploy_path)
720 }
721
722 fn fetch_default_branch(&self) -> Result<String, OtaError> {
724 let repo_url = "https://api.github.com/repos/ogkevin/cadmus";
725 tracing::debug!(url = %repo_url, "Fetching repository metadata");
726
727 let repo: Repository = self
728 .github
729 .get(repo_url)
730 .send()?
731 .error_for_status()
732 .map_err(|e| {
733 tracing::error!(status = ?e.status(), error = %e, "Repository metadata fetch failed");
734 api_error(e)
735 })?
736 .json()?;
737
738 tracing::debug!(default_branch = %repo.default_branch, "Resolved default branch");
739 Ok(repo.default_branch)
740 }
741
742 fn find_artifact_in_run(&self, run_id: u64, name_prefix: &str) -> Result<Artifact, OtaError> {
744 let artifacts_url = format!(
745 "https://api.github.com/repos/ogkevin/cadmus/actions/runs/{}/artifacts?per_page=50",
746 run_id
747 );
748 tracing::debug!(url = %artifacts_url, "Fetching artifacts");
749
750 let artifacts: ArtifactsResponse = self
751 .github
752 .get(&artifacts_url)
753 .send()?
754 .error_for_status()
755 .map_err(|e| {
756 tracing::error!(run_id, status = ?e.status(), error = %e, "Artifacts fetch failed");
757 api_error(e)
758 })?
759 .json()?;
760
761 tracing::debug!(count = artifacts.artifacts.len(), "Found artifacts");
762
763 #[cfg(feature = "tracing")]
764 if tracing::enabled!(tracing::Level::DEBUG) {
765 for (idx, artifact) in artifacts.artifacts.iter().enumerate() {
766 tracing::debug!(
767 index = idx,
768 name = %artifact.name,
769 id = artifact.id,
770 size_bytes = artifact.size_in_bytes,
771 "Artifact"
772 );
773 }
774 }
775
776 tracing::debug!(pattern = %name_prefix, "Looking for artifact");
777
778 artifacts
779 .artifacts
780 .into_iter()
781 .find(|a| a.name.starts_with(name_prefix))
782 .ok_or_else(|| {
783 tracing::error!(run_id, pattern = %name_prefix, "No matching artifact found");
784 OtaError::ArtifactsNotFound(ArtifactSource::WorkflowRun(name_prefix.to_owned()))
785 })
786 }
787
788 fn download_artifact_to_path<F>(
792 &self,
793 artifact: &Artifact,
794 download_path: &PathBuf,
795 progress_callback: &mut F,
796 ) -> Result<(), OtaError>
797 where
798 F: FnMut(OtaProgress),
799 {
800 let download_url = format!(
801 "https://api.github.com/repos/ogkevin/cadmus/actions/artifacts/{}/zip",
802 artifact.id
803 );
804
805 self.github.download(
806 &download_url,
807 artifact.size_in_bytes,
808 download_path,
809 |url| self.github.get(url),
810 &mut |downloaded, total| {
811 progress_callback(OtaProgress::DownloadingArtifact { downloaded, total })
812 },
813 )?;
814 Ok(())
815 }
816
817 #[inline]
822 #[cfg_attr(
823 feature = "tracing",
824 tracing::instrument(skip(self, progress_callback))
825 )]
826 fn download_release_asset<F>(
827 &self,
828 asset: &ReleaseAsset,
829 download_path: &PathBuf,
830 progress_callback: &mut F,
831 ) -> Result<(), OtaError>
832 where
833 F: FnMut(OtaProgress),
834 {
835 self.github.download(
836 &asset.browser_download_url,
837 asset.size,
838 download_path,
839 |url| self.github.get_unauthenticated(url),
840 &mut |downloaded, total| {
841 progress_callback(OtaProgress::DownloadingArtifact { downloaded, total })
842 },
843 )?;
844 Ok(())
845 }
846}
847
848fn verify_scopes(github: &crate::github::GithubClient) -> Result<(), OtaError> {
858 github.verify_token_scopes().map_err(|e| match e {
859 crate::github::VerifyScopesError::Request(e) => api_error(e),
860 crate::github::VerifyScopesError::InsufficientScopes(e) => OtaError::InsufficientScopes(e),
861 })
862}
863
864fn api_error(e: reqwest::Error) -> OtaError {
870 if e.status() == Some(reqwest::StatusCode::UNAUTHORIZED) {
871 tracing::warn!("GitHub API returned 401 — token invalid or revoked");
872 OtaError::Unauthorized
873 } else {
874 OtaError::Api(e.to_string())
875 }
876}
877
878fn check_disk_space(path: &Path) -> Result<(), OtaError> {
879 use nix::sys::statvfs::statvfs;
880
881 let stat = statvfs(path)?;
882 let available_mb = (stat.blocks_available() as u64 * stat.block_size() as u64) / (1024 * 1024);
883 tracing::debug!(path = ?path, available_mb, "Checking disk space");
884
885 if available_mb < 100 {
886 tracing::error!(
887 path = ?path,
888 available_mb,
889 required_mb = 100,
890 "Insufficient disk space"
891 );
892 return Err(OtaError::InsufficientSpace(available_mb));
893 }
894 Ok(())
895}
896
897#[cfg(test)]
898mod tests {
899 use super::*;
900 use crate::github::GithubClient;
901 use secrecy::SecretString;
902
903 fn make_client(tmp_dir: PathBuf) -> OtaClient {
904 crate::crypto::init_crypto_provider();
905 let github =
906 GithubClient::new(Some(SecretString::from("test_token"))).expect("client build");
907 OtaClient::new(github, tmp_dir)
908 }
909
910 #[test]
911 fn test_extract_and_deploy_success() {
912 let temp_dir = tempfile::tempdir().unwrap();
913 let client = make_client(temp_dir.path().to_path_buf());
914 let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
915 .join("src/ota/tests/fixtures/test_artifact.zip");
916 let artifact_path = temp_dir.path().join("test_artifact.zip");
917 std::fs::copy(&fixture_path, &artifact_path).unwrap();
918
919 let result = client.extract_and_deploy(artifact_path.clone());
920
921 assert!(
922 result.is_ok(),
923 "Deployment should succeed: {:?}",
924 result.err()
925 );
926
927 let deploy_path = result.unwrap();
928 assert!(
929 deploy_path.exists(),
930 "Deployed file should exist at {:?}",
931 deploy_path
932 );
933
934 let content = std::fs::read_to_string(&deploy_path).unwrap();
935 assert!(
936 content.contains("Mock KoboRoot.tgz"),
937 "Deployed file should contain mock content"
938 );
939
940 std::fs::remove_file(&deploy_path).ok();
941 assert!(
942 !artifact_path.exists(),
943 "Downloaded artifact should be removed after successful deployment"
944 );
945 }
946
947 #[test]
948 fn test_extract_and_deploy_missing_koboroot() {
949 let temp_dir = tempfile::tempdir().unwrap();
950 let client = make_client(temp_dir.path().to_path_buf());
951 let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
952 .join("src/ota/tests/fixtures/empty_artifact.zip");
953 let artifact_path = temp_dir.path().join("empty_artifact.zip");
954 std::fs::copy(&fixture_path, &artifact_path).unwrap();
955
956 let result = client.extract_and_deploy(artifact_path.clone());
957 assert!(result.is_err(), "Should fail when KoboRoot.tgz is missing");
958
959 if let Err(OtaError::DeploymentError(msg)) = result {
960 assert!(
961 msg.contains("not found in artifact"),
962 "Error should mention missing file"
963 );
964 } else {
965 panic!("Expected DeploymentError");
966 }
967
968 assert!(
969 artifact_path.exists(),
970 "Source artifact should be retained when deployment fails"
971 );
972 }
973
974 #[test]
975 fn test_check_disk_space_sufficient() {
976 use tempfile::TempDir;
977 let temp_dir = TempDir::new().unwrap();
978 let result = check_disk_space(temp_dir.path());
979 assert!(
980 result.is_ok(),
981 "Should have sufficient disk space in temp directory"
982 );
983 }
984
985 fn create_external_client(tmp_dir: PathBuf) -> OtaClient {
986 crate::crypto::init_crypto_provider();
987 let token = std::env::var("GH_TOKEN").expect("GH_TOKEN must be set");
988 let github = GithubClient::new(Some(SecretString::from(token))).expect("client build");
989 OtaClient::new(github, tmp_dir)
990 }
991
992 #[test]
993 #[ignore]
994 fn test_external_download_default_branch_and_deploy() {
995 let temp_dir = tempfile::tempdir().unwrap();
996 let client = create_external_client(temp_dir.path().to_path_buf());
997 let mut last_progress = None;
998
999 let download_result = client.download_default_branch_artifact(|progress| {
1000 last_progress = Some(format!("{:?}", progress));
1001 });
1002
1003 assert!(
1004 download_result.is_ok(),
1005 "Default branch artifact download should succeed: {:?}",
1006 download_result.err()
1007 );
1008
1009 let zip_path = download_result.unwrap();
1010 assert!(
1011 zip_path.exists(),
1012 "Downloaded ZIP should exist at {:?}",
1013 zip_path
1014 );
1015 assert!(
1016 zip_path.metadata().unwrap().len() > 0,
1017 "Downloaded ZIP should not be empty"
1018 );
1019
1020 let deploy_result = client.extract_and_deploy(zip_path.clone());
1021
1022 assert!(
1023 deploy_result.is_ok(),
1024 "Deployment should succeed: {:?}",
1025 deploy_result.err()
1026 );
1027
1028 let deploy_path = deploy_result.unwrap();
1029 assert!(
1030 deploy_path.exists(),
1031 "Deployed file should exist at {:?}",
1032 deploy_path
1033 );
1034
1035 std::fs::remove_file(&deploy_path).ok();
1036 }
1037
1038 #[test]
1039 #[ignore]
1040 fn test_external_download_stable_release_and_deploy() {
1041 let temp_dir = tempfile::tempdir().unwrap();
1042 let client = create_external_client(temp_dir.path().to_path_buf());
1043 let download_result = client.download_stable_release_artifact(|_| {});
1044
1045 assert!(
1046 download_result.is_ok(),
1047 "Stable release artifact download should succeed: {:?}",
1048 download_result.err()
1049 );
1050
1051 let asset_path = download_result.unwrap();
1052 assert!(
1053 asset_path.exists(),
1054 "Downloaded asset should exist at {:?}",
1055 asset_path
1056 );
1057 assert!(
1058 asset_path.metadata().unwrap().len() > 0,
1059 "Downloaded asset should not be empty"
1060 );
1061
1062 let deploy_result = client.deploy(asset_path.clone());
1063
1064 assert!(
1065 deploy_result.is_ok(),
1066 "Deployment should succeed: {:?}",
1067 deploy_result.err()
1068 );
1069
1070 let deploy_path = deploy_result.unwrap();
1071 assert!(
1072 deploy_path.exists(),
1073 "Deployed file should exist at {:?}",
1074 deploy_path
1075 );
1076
1077 std::fs::remove_file(&deploy_path).ok();
1078 }
1079}