cadmus_core/ota/
client.rs

1use crate::github::types::{
2    Artifact, ArtifactsResponse, Release, ReleaseAsset, Repository, WorkflowRunsResponse,
3};
4use crate::github::{GithubClient, OtaProgress};
5use crate::version::GitVersion;
6use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
7use std::fs::File;
8use std::io::{Read, Write};
9use std::path::PathBuf;
10use std::time::Duration;
11use zip::ZipArchive;
12
13#[cfg(all(not(test), not(feature = "emulator")))]
14use crate::settings::INTERNAL_CARD_ROOT;
15
16const MIN_CHUNK_SIZE: usize = 256 * 1024;
17const MAX_CHUNK_SIZE: usize = 10 * 1024 * 1024;
18const INITIAL_CHUNK_SIZE: usize = 1024 * 1024;
19/// Target 80% of the HTTP timeout to leave headroom for throughput variance.
20const TARGET_CHUNK_SECS: f64 = crate::github::CLIENT_TIMEOUT_SECS as f64 * 0.8;
21
22/// Maximum number of retry attempts for failed chunks
23const MAX_RETRIES: usize = 3;
24
25/// Downloads and deploys OTA updates from GitHub.
26///
27/// Delegates all HTTP communication to [`GithubClient`] and focuses solely on
28/// the OTA-specific workflow: finding artifacts, chunked downloading, ZIP
29/// extraction, and deploying `KoboRoot.tgz` to the Kobo device.
30pub struct OtaClient {
31    github: GithubClient,
32}
33
34/// Indicates where artifacts were expected but not found.
35#[derive(Debug, Clone)]
36pub enum ArtifactSource {
37    /// No artifacts found for a specific pull request
38    PullRequest(u32),
39    /// No artifacts found for the default branch
40    DefaultBranch,
41    /// No artifact matching the expected name pattern in a workflow run
42    WorkflowRun(String),
43    /// No release asset found with the expected name
44    ReleaseAsset(String),
45}
46
47impl std::fmt::Display for ArtifactSource {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        match self {
50            ArtifactSource::PullRequest(pr) => write!(f, "No build artifacts found for PR #{}", pr),
51            ArtifactSource::DefaultBranch => {
52                write!(f, "No build artifacts found for default branch")
53            }
54            ArtifactSource::WorkflowRun(pattern) => {
55                write!(
56                    f,
57                    "No artifact matching '{}' found in workflow run",
58                    pattern
59                )
60            }
61            ArtifactSource::ReleaseAsset(name) => write!(f, "No release asset '{}' found", name),
62        }
63    }
64}
65
66/// Error types that can occur during OTA operations.
67#[derive(thiserror::Error, Debug)]
68pub enum OtaError {
69    /// GitHub API returned an error response
70    #[error("GitHub API error: {0}")]
71    Api(String),
72
73    /// HTTP request failed during communication with GitHub
74    #[error("HTTP request error: {0}")]
75    Request(#[from] reqwest::Error),
76
77    /// The specified pull request number was not found in the repository
78    #[error("PR #{0} not found")]
79    PrNotFound(u32),
80
81    /// No build artifacts found for the specified source
82    #[error("{0}")]
83    ArtifactsNotFound(ArtifactSource),
84
85    /// GitHub token was not provided
86    #[error("GitHub token not configured")]
87    NoToken,
88
89    /// GitHub token is invalid or has been revoked — re-authentication required
90    #[error("GitHub token is invalid or revoked")]
91    Unauthorized,
92
93    /// GitHub token is missing one or more required OAuth scopes
94    ///
95    /// The token was accepted by GitHub but lacks the permissions needed for
96    /// OTA operations. Re-authentication with the correct scopes is required.
97    #[error(transparent)]
98    InsufficientScopes(#[from] crate::github::ScopeError),
99
100    /// Insufficient disk space available for download (requires 100MB minimum)
101    #[error("Insufficient disk space: need 100MB, have {0}MB")]
102    InsufficientSpace(u64),
103
104    /// File system I/O operation failed
105    #[error("I/O error: {0}")]
106    Io(#[from] std::io::Error),
107
108    /// System-level error from nix library
109    #[error("System error: {0}")]
110    Nix(#[from] nix::errno::Errno),
111
112    /// TLS/SSL configuration failed when setting up HTTPS client
113    #[error("TLS configuration error: {0}")]
114    TlsConfig(String),
115
116    /// Failed to extract files from ZIP archive
117    #[error("ZIP extraction error: {0}")]
118    ZipError(#[from] zip::result::ZipError),
119
120    /// Deployment process failed after successful download
121    #[error("Deployment error: {0}")]
122    DeploymentError(String),
123
124    /// Failed to parse version string
125    #[error(transparent)]
126    VersionParse(#[from] crate::version::VersionError),
127}
128
129impl OtaClient {
130    /// Creates a new OTA client wrapping the provided GitHub client.
131    ///
132    /// # Errors
133    ///
134    /// Returns `OtaError::TlsConfig` if the underlying HTTP client fails to build.
135    pub fn new(github: GithubClient) -> Self {
136        Self { github }
137    }
138
139    /// Downloads the build artifact from a GitHub pull request.
140    ///
141    /// This performs the complete download workflow:
142    /// 1. Verifies sufficient disk space (100MB required)
143    /// 2. Fetches PR metadata to get the commit SHA
144    /// 3. Finds the associated "Cargo" workflow run
145    /// 4. Locates artifacts matching "cadmus-kobo-pr*" pattern
146    /// 5. Downloads the artifact ZIP file to `/tmp/cadmus-ota-{pr_number}.zip`
147    ///
148    /// GitHub authentication is required for this operation.
149    ///
150    /// # Arguments
151    ///
152    /// * `pr_number` - The pull request number from ogkevin/cadmus repository
153    /// * `progress_callback` - Function called with progress updates during download
154    ///
155    /// # Returns
156    ///
157    /// The path to the downloaded ZIP file on success.
158    ///
159    /// # Errors
160    ///
161    /// * `OtaError::InsufficientSpace` - Less than 100MB available in /tmp
162    /// * `OtaError::NoToken` - GitHub token not configured
163    /// * `OtaError::PrNotFound` - PR number doesn't exist in repository
164    /// * `OtaError::ArtifactsNotFound` - No matching build artifacts found for the PR
165    /// * `OtaError::Api` - GitHub API request failed
166    /// * `OtaError::Request` - Network communication failed
167    /// * `OtaError::Io` - Failed to write downloaded file to disk
168    pub fn download_pr_artifact<F>(
169        &self,
170        pr_number: u32,
171        mut progress_callback: F,
172    ) -> Result<PathBuf, OtaError>
173    where
174        F: FnMut(OtaProgress),
175    {
176        check_disk_space("/tmp")?;
177        verify_scopes(&self.github)?;
178
179        progress_callback(OtaProgress::CheckingPr);
180        tracing::info!(pr_number, "Starting PR build download");
181        tracing::debug!(pr_number, "Checking PR");
182
183        let pr_url = format!(
184            "https://api.github.com/repos/ogkevin/cadmus/pulls/{}",
185            pr_number
186        );
187        tracing::debug!(url = %pr_url, "Fetching PR");
188
189        let response = self
190            .github
191            .get(&pr_url)
192            .send()?
193            .error_for_status()
194            .map_err(|e| {
195                tracing::error!(pr_number, status = ?e.status(), error = %e, "PR fetch failed");
196                if e.status() == Some(reqwest::StatusCode::UNAUTHORIZED) {
197                    OtaError::Unauthorized
198                } else {
199                    OtaError::PrNotFound(pr_number)
200                }
201            })?;
202
203        let pr: crate::github::types::PullRequest = response.json()?;
204        tracing::debug!("Successfully parsed PR response");
205        let head_sha = pr.head.sha;
206        tracing::debug!(pr_number, head_sha = %head_sha, "Retrieved PR head SHA");
207
208        progress_callback(OtaProgress::FindingWorkflow);
209        tracing::debug!(head_sha = %head_sha, "Finding workflow runs");
210
211        let runs_url = format!(
212            "https://api.github.com/repos/ogkevin/cadmus/actions/runs?head_sha={}&event=pull_request",
213            head_sha
214        );
215        tracing::debug!(url = %runs_url, "Fetching workflow runs");
216
217        let runs: WorkflowRunsResponse = self
218            .github
219            .get(&runs_url)
220            .send()?
221            .error_for_status()
222            .map_err(|e| {
223                tracing::error!(head_sha = %head_sha, status = ?e.status(), error = %e, "Workflow runs fetch failed");
224                api_error(e)
225            })?
226            .json()?;
227
228        tracing::debug!(count = runs.workflow_runs.len(), "Found workflow runs");
229
230        #[cfg(feature = "otel")]
231        if tracing::enabled!(tracing::Level::DEBUG) {
232            for (idx, run) in runs.workflow_runs.iter().enumerate() {
233                tracing::debug!(
234                    index = idx,
235                    name = %run.name,
236                    id = run.id,
237                    "Workflow run"
238                );
239            }
240        }
241
242        let run = runs
243            .workflow_runs
244            .iter()
245            .find(|r| r.name == "Cargo")
246            .ok_or_else(|| {
247                tracing::error!(pr_number, "No Cargo workflow run found");
248                OtaError::ArtifactsNotFound(ArtifactSource::PullRequest(pr_number))
249            })?;
250
251        tracing::debug!(run_id = run.id, "Found Cargo workflow run");
252
253        #[cfg(feature = "test")]
254        let artifact_name_pattern = format!("cadmus-kobo-test-pr{}", pr_number);
255        #[cfg(not(feature = "test"))]
256        let artifact_name_pattern = format!("cadmus-kobo-pr{}", pr_number);
257
258        let artifact = self
259            .find_artifact_in_run(run.id, &artifact_name_pattern)
260            .map_err(|e| match e {
261                OtaError::ArtifactsNotFound(ArtifactSource::WorkflowRun(_)) => {
262                    OtaError::ArtifactsNotFound(ArtifactSource::PullRequest(pr_number))
263                }
264                other => other,
265            })?;
266
267        tracing::debug!(
268            name = %artifact.name,
269            id = artifact.id,
270            size_bytes = artifact.size_in_bytes,
271            "Found artifact"
272        );
273
274        let download_path = PathBuf::from(format!("/tmp/cadmus-ota-{}.zip", pr_number));
275
276        self.download_artifact_to_path(&artifact, &download_path, &mut progress_callback)?;
277
278        progress_callback(OtaProgress::Complete {
279            path: download_path.clone(),
280        });
281
282        tracing::info!(pr_number, "PR build download completed");
283        Ok(download_path)
284    }
285
286    /// Downloads the latest build artifact from the default branch.
287    ///
288    /// This performs the complete download workflow for default branch builds:
289    /// 1. Verifies sufficient disk space (100MB required)
290    /// 2. Queries GitHub API for the latest successful `cargo.yml` workflow run on the default branch
291    /// 3. Locates artifacts matching "cadmus-kobo-{sha}" pattern (or "cadmus-kobo-test-{sha}" with `test` feature)
292    /// 4. Downloads the artifact ZIP file to `/tmp/cadmus-ota-{sha}.zip`
293    ///
294    /// GitHub authentication is required for this operation.
295    ///
296    /// # Arguments
297    ///
298    /// * `progress_callback` - Function called with progress updates during download
299    ///
300    /// # Returns
301    ///
302    /// The path to the downloaded ZIP file on success.
303    ///
304    /// # Errors
305    ///
306    /// * `OtaError::InsufficientSpace` - Less than 100MB available in /tmp
307    /// * `OtaError::NoToken` - GitHub token not configured
308    /// * `OtaError::ArtifactsNotFound` - No matching build artifacts found for default branch
309    /// * `OtaError::Api` - GitHub API request failed
310    /// * `OtaError::Request` - Network communication failed
311    /// * `OtaError::Io` - Failed to write downloaded file to disk
312    pub fn download_default_branch_artifact<F>(
313        &self,
314        mut progress_callback: F,
315    ) -> Result<PathBuf, OtaError>
316    where
317        F: FnMut(OtaProgress),
318    {
319        check_disk_space("/tmp")?;
320        verify_scopes(&self.github)?;
321
322        progress_callback(OtaProgress::FindingLatestBuild);
323        tracing::info!("Starting main branch build download");
324        tracing::debug!("Finding latest default branch build");
325
326        let default_branch = self.fetch_default_branch()?;
327
328        let encoded_branch = utf8_percent_encode(&default_branch, NON_ALPHANUMERIC);
329        let runs_url = format!(
330            "https://api.github.com/repos/ogkevin/cadmus/actions/workflows/cargo.yml/runs?branch={}&event=push&status=success&per_page=1",
331            encoded_branch
332        );
333        tracing::debug!(url = %runs_url, "Fetching Cargo workflow runs on default branch");
334
335        let runs: WorkflowRunsResponse = self
336            .github
337            .get(&runs_url)
338            .send()?
339            .error_for_status()
340            .map_err(|e| {
341                tracing::error!(status = ?e.status(), error = %e, "Cargo workflow runs fetch failed");
342                api_error(e)
343            })?
344            .json()?;
345
346        let cargo_run = runs.workflow_runs.first().ok_or_else(|| {
347            tracing::error!("No successful Cargo workflow run found on default branch");
348            OtaError::ArtifactsNotFound(ArtifactSource::DefaultBranch)
349        })?;
350
351        tracing::debug!(run_id = cargo_run.id, "Found Cargo workflow run");
352
353        let head_sha = cargo_run.head_sha.as_deref().ok_or_else(|| {
354            tracing::error!(run_id = cargo_run.id, "Workflow run missing head_sha");
355            OtaError::Api(format!("Workflow run {} missing head_sha", cargo_run.id))
356        })?;
357        let short_sha = &head_sha[..7.min(head_sha.len())];
358
359        #[cfg(feature = "test")]
360        let artifact_name_prefix = format!("cadmus-kobo-test-{}", short_sha);
361        #[cfg(not(feature = "test"))]
362        let artifact_name_prefix = format!("cadmus-kobo-{}", short_sha);
363
364        tracing::debug!(pattern = %artifact_name_prefix, "Looking for artifact");
365
366        progress_callback(OtaProgress::FindingWorkflow);
367
368        let artifact = self
369            .find_artifact_in_run(cargo_run.id, &artifact_name_prefix)
370            .map_err(|e| match e {
371                OtaError::ArtifactsNotFound(ArtifactSource::WorkflowRun(pattern)) => {
372                    tracing::error!(pattern = %pattern, "No matching artifact found on default branch");
373                    OtaError::ArtifactsNotFound(ArtifactSource::DefaultBranch)
374                }
375                other => other,
376            })?;
377
378        tracing::debug!(
379            name = %artifact.name,
380            id = artifact.id,
381            size_bytes = artifact.size_in_bytes,
382            "Found default branch artifact"
383        );
384
385        let download_path = PathBuf::from(format!("/tmp/cadmus-ota-{}.zip", short_sha));
386
387        self.download_artifact_to_path(&artifact, &download_path, &mut progress_callback)?;
388
389        progress_callback(OtaProgress::Complete {
390            path: download_path.clone(),
391        });
392
393        tracing::info!(sha = %short_sha, "Main branch build download completed");
394        Ok(download_path)
395    }
396
397    /// Downloads the latest stable release artifact from GitHub releases.
398    ///
399    /// This performs the complete download workflow for stable releases:
400    /// 1. Verifies sufficient disk space (100MB required)
401    /// 2. Fetches the latest release from GitHub API
402    /// 3. Locates the `KoboRoot.tgz` asset in the release
403    /// 4. Downloads the file to `/tmp/cadmus-ota-stable-release.tgz`
404    ///
405    /// GitHub authentication is not required for this operation as release
406    /// assets are downloaded from public URLs without Authorization headers.
407    ///
408    /// # Arguments
409    ///
410    /// * `progress_callback` - Function called with progress updates during download
411    ///
412    /// # Returns
413    ///
414    /// The path to the downloaded KoboRoot.tgz file on success.
415    ///
416    /// # Errors
417    ///
418    /// * `OtaError::InsufficientSpace` - Less than 100MB available in /tmp
419    /// * `OtaError::Api` - GitHub API request failed
420    /// * `OtaError::Request` - Network communication failed
421    /// * `OtaError::ArtifactsNotFound` - KoboRoot.tgz not found in latest release
422    /// * `OtaError::Io` - Failed to write downloaded file to disk
423    #[cfg_attr(feature = "otel", tracing::instrument(skip_all))]
424    pub fn download_stable_release_artifact<F>(
425        &self,
426        mut progress_callback: F,
427    ) -> Result<PathBuf, OtaError>
428    where
429        F: FnMut(OtaProgress),
430    {
431        check_disk_space("/tmp")?;
432
433        progress_callback(OtaProgress::FindingLatestBuild);
434        tracing::info!("Starting stable release download");
435        tracing::debug!("Finding latest stable release");
436
437        let releases_url = "https://api.github.com/repos/ogkevin/cadmus/releases/latest";
438        tracing::debug!(url = %releases_url, "Fetching latest release");
439
440        let release: Release = self
441            .github
442            .get_unauthenticated(releases_url)
443            .send()?
444            .error_for_status()
445            .map_err(|e| {
446                tracing::error!(status = ?e.status(), error = %e, "Latest release fetch failed");
447                api_error(e)
448            })?
449            .json()?;
450
451        tracing::debug!(asset_count = release.assets.len(), "Found release assets");
452
453        #[cfg(feature = "otel")]
454        for (idx, asset) in release.assets.iter().enumerate() {
455            tracing::debug!(
456                index = idx,
457                name = %asset.name,
458                size_bytes = asset.size,
459                "Asset"
460            );
461        }
462
463        let asset_name = "KoboRoot.tgz";
464
465        let asset = release
466            .assets
467            .iter()
468            .find(|a| a.name == asset_name)
469            .ok_or_else(|| {
470                tracing::error!(
471                    target_asset = asset_name,
472                    "Asset not found in latest release"
473                );
474                OtaError::ArtifactsNotFound(ArtifactSource::ReleaseAsset(asset_name.to_owned()))
475            })?;
476
477        tracing::debug!(
478            name = %asset.name,
479            url = %asset.browser_download_url,
480            size_bytes = asset.size,
481            "Found release asset"
482        );
483
484        let download_path = PathBuf::from("/tmp/cadmus-ota-stable-release.tgz");
485
486        self.download_release_asset(asset, &download_path, &mut progress_callback)?;
487
488        progress_callback(OtaProgress::Complete {
489            path: download_path.clone(),
490        });
491
492        tracing::info!("Stable release download completed");
493        Ok(download_path)
494    }
495
496    /// Fetches the latest stable release version from GitHub.
497    ///
498    /// Retrieves and parses the version from the most recent stable release.
499    /// Returns the version as a `GitVersion` struct for easy comparison and display.
500    ///
501    /// GitHub authentication is not required for this operation as releases are public.
502    ///
503    /// # Errors
504    ///
505    /// * `OtaError::Api` - GitHub API request failed
506    /// * `OtaError::Request` - Network communication failed
507    /// * `OtaError::VersionParse` - Failed to parse the release tag as a valid version
508    ///
509    /// # Example
510    ///
511    /// ```no_run
512    /// use cadmus_core::github::GithubClient;
513    /// use cadmus_core::ota::OtaClient;
514    ///
515    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
516    /// # rustls::crypto::ring::default_provider().install_default().ok();
517    /// # let github = GithubClient::new(None)?;
518    /// # let client = OtaClient::new(github);
519    /// let version = client.fetch_latest_release_version()?;
520    /// println!("Latest version: {}", version);
521    /// # Ok(())
522    /// # }
523    /// ```
524    #[cfg_attr(feature = "otel", tracing::instrument(skip(self)))]
525    pub fn fetch_latest_release_version(&self) -> Result<GitVersion, OtaError> {
526        let releases_url = "https://api.github.com/repos/ogkevin/cadmus/releases/latest";
527        tracing::debug!(url = %releases_url, "Fetching latest release version");
528
529        let release: Release = self
530            .github
531            .get_unauthenticated(releases_url)
532            .send()?
533            .error_for_status()
534            .map_err(|e| {
535                tracing::error!(status = ?e.status(), error = %e, "Latest release fetch failed");
536                api_error(e)
537            })?
538            .json()?;
539
540        tracing::info!(version = %release.tag_name, "Fetched latest release version");
541
542        let version: GitVersion = release.tag_name.parse()?;
543        Ok(version)
544    }
545
546    /// Deploys KoboRoot.tgz from the specified path directly without extraction.
547    ///
548    /// Used when the artifact is already in the correct format (e.g., stable releases
549    /// that are distributed as bare KoboRoot.tgz files).
550    ///
551    /// # Arguments
552    ///
553    /// * `kobo_root_path` - Path to the KoboRoot.tgz file to deploy
554    ///
555    /// # Returns
556    ///
557    /// The path where the file was deployed, or an error if deployment fails.
558    ///
559    /// # Errors
560    ///
561    /// * `OtaError::Io` - Failed to read or write files
562    #[cfg_attr(feature = "otel", tracing::instrument(skip(self)))]
563    pub fn deploy(&self, kobo_root_path: PathBuf) -> Result<PathBuf, OtaError> {
564        tracing::info!(path = ?kobo_root_path, "Deploying KoboRoot.tgz");
565
566        let mut kobo_root_data = Vec::new();
567        {
568            let mut file = File::open(&kobo_root_path)?;
569            file.read_to_end(&mut kobo_root_data)?;
570        }
571
572        tracing::debug!(
573            bytes = kobo_root_data.len(),
574            path = ?kobo_root_path,
575            "Read KoboRoot.tgz"
576        );
577
578        self.deploy_bytes(&kobo_root_data)
579    }
580
581    /// Deploys KoboRoot.tgz data to the appropriate location.
582    ///
583    /// Writes the provided data to the deployment path determined by the build configuration:
584    /// - Test builds: temp directory
585    /// - Emulator builds: /tmp/.kobo/KoboRoot.tgz
586    /// - Production builds: {INTERNAL_CARD_ROOT}/.kobo/KoboRoot.tgz
587    ///
588    /// # Arguments
589    ///
590    /// * `data` - The KoboRoot.tgz file contents to deploy
591    ///
592    /// # Returns
593    ///
594    /// The deployment path where KoboRoot.tgz was written.
595    ///
596    /// # Errors
597    ///
598    /// * `OtaError::Io` - Failed to create directories or write deployment file
599    fn deploy_bytes(&self, data: &[u8]) -> Result<PathBuf, OtaError> {
600        #[cfg(test)]
601        let deploy_path = {
602            std::env::temp_dir()
603                .join("test-kobo-deployment")
604                .join("KoboRoot.tgz")
605        };
606
607        #[cfg(all(feature = "emulator", not(test)))]
608        let deploy_path = PathBuf::from("/tmp/.kobo/KoboRoot.tgz");
609
610        #[cfg(all(not(feature = "emulator"), not(test)))]
611        let deploy_path = PathBuf::from(format!("{}/.kobo/KoboRoot.tgz", INTERNAL_CARD_ROOT));
612
613        tracing::debug!(path = ?deploy_path, "Deploy destination");
614
615        #[cfg(any(test, feature = "emulator"))]
616        {
617            if let Some(parent) = deploy_path.parent() {
618                tracing::debug!(directory = ?parent, "Creating parent directory");
619                std::fs::create_dir_all(parent)?;
620            }
621        }
622
623        tracing::debug!(bytes = data.len(), path = ?deploy_path, "Writing file");
624        let mut file = File::create(&deploy_path)?;
625        file.write_all(data)?;
626
627        tracing::debug!(path = ?deploy_path, "Deployment complete");
628        tracing::info!(path = ?deploy_path, "Update deployed successfully");
629
630        Ok(deploy_path)
631    }
632
633    /// Extracts KoboRoot.tgz from the artifact and deploys it for installation.
634    ///
635    /// Opens the downloaded ZIP archive, locates the `KoboRoot.tgz` file,
636    /// extracts it, and writes it to `/mnt/onboard/.kobo/KoboRoot.tgz`
637    /// where the Kobo device will automatically install it on next reboot.
638    ///
639    /// # Arguments
640    ///
641    /// * `zip_path` - Path to the downloaded artifact ZIP file
642    ///
643    /// # Returns
644    ///
645    /// The deployment path where KoboRoot.tgz was written.
646    ///
647    /// # Errors
648    ///
649    /// * `OtaError::ZipError` - Failed to open or read ZIP archive
650    /// * `OtaError::DeploymentError` - KoboRoot.tgz not found in archive
651    /// * `OtaError::Io` - Failed to write deployment file
652    #[cfg_attr(feature = "otel", tracing::instrument(skip(self)))]
653    pub fn extract_and_deploy(&self, zip_path: PathBuf) -> Result<PathBuf, OtaError> {
654        tracing::info!(path = ?zip_path, "Extracting and deploying update");
655        tracing::debug!(path = ?zip_path, "Starting extraction");
656
657        let file = File::open(&zip_path)?;
658        let mut archive = ZipArchive::new(file)?;
659
660        tracing::debug!(file_count = archive.len(), "Opened ZIP archive");
661
662        let mut kobo_root_data = Vec::new();
663        let mut found = false;
664
665        #[cfg(not(feature = "test"))]
666        let kobo_root_name = "KoboRoot.tgz";
667        #[cfg(feature = "test")]
668        let kobo_root_name = "KoboRoot-test.tgz";
669
670        tracing::debug!(target_file = kobo_root_name, "Looking for file");
671
672        for i in 0..archive.len() {
673            let mut entry = archive.by_index(i)?;
674            let entry_name = entry.name().to_string();
675
676            tracing::debug!(index = i, name = %entry_name, "Checking entry");
677
678            if entry_name.eq(kobo_root_name) {
679                tracing::debug!(name = %entry_name, "Found target file");
680                entry.read_to_end(&mut kobo_root_data)?;
681                found = true;
682                break;
683            }
684        }
685
686        if !found {
687            tracing::error!(
688                target_file = kobo_root_name,
689                "Target file not found in artifact"
690            );
691            return Err(OtaError::DeploymentError(format!(
692                "{} not found in artifact",
693                kobo_root_name
694            )));
695        }
696
697        tracing::debug!(
698            bytes = kobo_root_data.len(),
699            file = kobo_root_name,
700            "Extracted file"
701        );
702
703        self.deploy_bytes(&kobo_root_data)
704    }
705
706    /// Queries the GitHub API for the repository's default branch name.
707    fn fetch_default_branch(&self) -> Result<String, OtaError> {
708        let repo_url = "https://api.github.com/repos/ogkevin/cadmus";
709        tracing::debug!(url = %repo_url, "Fetching repository metadata");
710
711        let repo: Repository = self
712            .github
713            .get(repo_url)
714            .send()?
715            .error_for_status()
716            .map_err(|e| {
717                tracing::error!(status = ?e.status(), error = %e, "Repository metadata fetch failed");
718                api_error(e)
719            })?
720            .json()?;
721
722        tracing::debug!(default_branch = %repo.default_branch, "Resolved default branch");
723        Ok(repo.default_branch)
724    }
725
726    /// Fetches artifacts for a workflow run and finds one matching the given prefix.
727    fn find_artifact_in_run(&self, run_id: u64, name_prefix: &str) -> Result<Artifact, OtaError> {
728        let artifacts_url = format!(
729            "https://api.github.com/repos/ogkevin/cadmus/actions/runs/{}/artifacts",
730            run_id
731        );
732        tracing::debug!(url = %artifacts_url, "Fetching artifacts");
733
734        let artifacts: ArtifactsResponse = self
735            .github
736            .get(&artifacts_url)
737            .send()?
738            .error_for_status()
739            .map_err(|e| {
740                tracing::error!(run_id, status = ?e.status(), error = %e, "Artifacts fetch failed");
741                api_error(e)
742            })?
743            .json()?;
744
745        tracing::debug!(count = artifacts.artifacts.len(), "Found artifacts");
746
747        #[cfg(feature = "otel")]
748        if tracing::enabled!(tracing::Level::DEBUG) {
749            for (idx, artifact) in artifacts.artifacts.iter().enumerate() {
750                tracing::debug!(
751                    index = idx,
752                    name = %artifact.name,
753                    id = artifact.id,
754                    size_bytes = artifact.size_in_bytes,
755                    "Artifact"
756                );
757            }
758        }
759
760        tracing::debug!(pattern = %name_prefix, "Looking for artifact");
761
762        artifacts
763            .artifacts
764            .into_iter()
765            .find(|a| a.name.starts_with(name_prefix))
766            .ok_or_else(|| {
767                tracing::error!(run_id, pattern = %name_prefix, "No matching artifact found");
768                OtaError::ArtifactsNotFound(ArtifactSource::WorkflowRun(name_prefix.to_owned()))
769            })
770    }
771
772    /// Downloads a file from a URL with chunked transfer and progress reporting.
773    ///
774    /// Uses HTTP Range headers to request the file in chunks for resilience
775    /// against network interruptions.
776    ///
777    /// # Arguments
778    ///
779    /// * `url` - The complete download URL
780    /// * `total_size` - Total file size in bytes
781    /// * `download_path` - Path where the file should be saved
782    /// * `progress_callback` - Function called with progress updates
783    /// * `use_auth` - Whether to include Authorization header in requests
784    ///
785    /// # Returns
786    ///
787    /// Success if the file is written to disk, error otherwise.
788    #[cfg_attr(feature = "otel", tracing::instrument(skip(self, progress_callback)))]
789    fn download_by_url_to_path<F>(
790        &self,
791        url: &str,
792        total_size: u64,
793        download_path: &PathBuf,
794        progress_callback: &mut F,
795        use_auth: bool,
796    ) -> Result<(), OtaError>
797    where
798        F: FnMut(OtaProgress),
799    {
800        progress_callback(OtaProgress::DownloadingArtifact {
801            downloaded: 0,
802            total: total_size,
803        });
804
805        tracing::debug!(url = %url, "Downloading file");
806        tracing::debug!(path = ?download_path, "Download destination");
807
808        let mut file = File::create(download_path)?;
809
810        let mut downloaded = 0u64;
811        let mut chunk_size = INITIAL_CHUNK_SIZE;
812
813        tracing::debug!(
814            initial_chunk_size = INITIAL_CHUNK_SIZE,
815            "Starting chunked download"
816        );
817
818        while downloaded < total_size {
819            let chunk_start = downloaded;
820            let chunk_end = std::cmp::min(downloaded + chunk_size as u64 - 1, total_size - 1);
821
822            tracing::debug!(
823                chunk_start,
824                chunk_end,
825                chunk_size,
826                total_size,
827                "Downloading chunk"
828            );
829
830            let start = std::time::Instant::now();
831            let chunk_data =
832                self.download_chunk_with_retries(url, chunk_start, chunk_end, use_auth)?;
833            let elapsed_secs = start.elapsed().as_secs_f64();
834
835            file.write_all(&chunk_data)?;
836            downloaded += chunk_data.len() as u64;
837
838            if elapsed_secs > 0.0 {
839                let throughput = chunk_data.len() as f64 / elapsed_secs;
840                chunk_size = ((throughput * TARGET_CHUNK_SECS) as usize)
841                    .clamp(MIN_CHUNK_SIZE, MAX_CHUNK_SIZE);
842                tracing::debug!(
843                    elapsed_secs,
844                    throughput_bytes_per_sec = throughput as u64,
845                    next_chunk_size = chunk_size,
846                    "Adjusted chunk size"
847                );
848            }
849
850            progress_callback(OtaProgress::DownloadingArtifact {
851                downloaded,
852                total: total_size,
853            });
854
855            tracing::debug!(
856                downloaded,
857                total_size,
858                progress_percent = (downloaded as f64 / total_size as f64) * 100.0,
859                "Download progress"
860            );
861        }
862
863        tracing::debug!(bytes = downloaded, "Download complete");
864        tracing::debug!(path = ?download_path, "Saved file");
865
866        Ok(())
867    }
868
869    /// Downloads an artifact ZIP to the specified path with chunked transfer and progress reporting.
870    ///
871    /// GitHub authentication is required for this operation.
872    fn download_artifact_to_path<F>(
873        &self,
874        artifact: &Artifact,
875        download_path: &PathBuf,
876        progress_callback: &mut F,
877    ) -> Result<(), OtaError>
878    where
879        F: FnMut(OtaProgress),
880    {
881        let download_url = format!(
882            "https://api.github.com/repos/ogkevin/cadmus/actions/artifacts/{}/zip",
883            artifact.id
884        );
885
886        self.download_by_url_to_path(
887            &download_url,
888            artifact.size_in_bytes,
889            download_path,
890            progress_callback,
891            true,
892        )
893    }
894
895    /// Downloads a specific byte range of a file with automatic retry logic.
896    ///
897    /// Uses HTTP Range headers to request a specific chunk of the artifact.
898    /// Implements exponential backoff retry strategy for failed downloads.
899    ///
900    /// # Arguments
901    ///
902    /// * `url` - The download URL
903    /// * `start` - Starting byte offset (inclusive)
904    /// * `end` - Ending byte offset (inclusive)
905    ///
906    /// # Returns
907    ///
908    /// The downloaded chunk data as a byte vector.
909    ///
910    /// # Errors
911    ///
912    /// Returns an error if all retry attempts fail.
913    fn download_chunk_with_retries(
914        &self,
915        url: &str,
916        start: u64,
917        end: u64,
918        use_auth: bool,
919    ) -> Result<Vec<u8>, OtaError> {
920        let mut last_error = None;
921
922        for attempt in 1..=MAX_RETRIES {
923            match self.download_chunk(url, start, end, use_auth) {
924                Ok(data) => {
925                    if attempt > 1 {
926                        tracing::debug!(
927                            attempt,
928                            max_retries = MAX_RETRIES,
929                            "Chunk download succeeded after retry"
930                        );
931                    }
932                    return Ok(data);
933                }
934                Err(e) => {
935                    tracing::warn!(
936                        attempt,
937                        max_retries = MAX_RETRIES,
938                        error = %e,
939                        "Chunk download failed"
940                    );
941                    last_error = Some(e);
942
943                    if attempt < MAX_RETRIES {
944                        let backoff_ms = 1000 * (2u64.pow(attempt as u32 - 1));
945                        tracing::debug!(backoff_ms, "Retrying after backoff");
946                        std::thread::sleep(Duration::from_millis(backoff_ms));
947                    }
948                }
949            }
950        }
951
952        Err(last_error.unwrap_or_else(|| {
953            OtaError::Api("Failed to download chunk after all retries".to_string())
954        }))
955    }
956
957    /// Downloads a specific byte range from a URL using HTTP Range header.
958    ///
959    /// # Arguments
960    ///
961    /// * `url` - The download URL
962    /// * `start` - Starting byte offset (inclusive)
963    /// * `end` - Ending byte offset (inclusive)
964    /// * `use_auth` - Whether to include Authorization header
965    ///
966    /// # Returns
967    ///
968    /// The downloaded chunk data as a byte vector.
969    ///
970    /// # Errors
971    ///
972    /// Returns an error if the download fails or times out.
973    fn download_chunk(
974        &self,
975        url: &str,
976        start: u64,
977        end: u64,
978        use_auth: bool,
979    ) -> Result<Vec<u8>, OtaError> {
980        let range_header = format!("bytes={}-{}", start, end);
981
982        let builder = if use_auth {
983            self.github.get(url)
984        } else {
985            self.github.get_unauthenticated(url)
986        };
987
988        let bytes = builder
989            .header("Range", range_header)
990            .send()?
991            .error_for_status()
992            .map_err(api_error)?
993            .bytes()?;
994
995        Ok(bytes.to_vec())
996    }
997
998    /// Downloads a release asset to the specified path with chunked transfer and progress reporting.
999    ///
1000    /// GitHub authentication is not required for this operation as release
1001    /// assets are downloaded from public URLs.
1002    #[inline]
1003    #[cfg_attr(feature = "otel", tracing::instrument(skip(self, progress_callback)))]
1004    fn download_release_asset<F>(
1005        &self,
1006        asset: &ReleaseAsset,
1007        download_path: &PathBuf,
1008        progress_callback: &mut F,
1009    ) -> Result<(), OtaError>
1010    where
1011        F: FnMut(OtaProgress),
1012    {
1013        self.download_by_url_to_path(
1014            &asset.browser_download_url,
1015            asset.size,
1016            download_path,
1017            progress_callback,
1018            false,
1019        )
1020    }
1021}
1022
1023/// Verifies that the GitHub token has all scopes required for OTA operations.
1024///
1025/// Delegates to [`GithubClient::verify_token_scopes`], which reads the
1026/// `X-OAuth-Scopes` header from a lightweight `/user` request and compares
1027/// against [`crate::github::REQUIRED_SCOPES`].
1028///
1029/// Returns `Ok(())` if all scopes are present, or an `OtaError` that is
1030/// either a transport failure or missing scopes, so the caller can trigger
1031/// re-authentication.
1032fn verify_scopes(github: &crate::github::GithubClient) -> Result<(), OtaError> {
1033    github.verify_token_scopes().map_err(|e| match e {
1034        crate::github::VerifyScopesError::Request(e) => api_error(e),
1035        crate::github::VerifyScopesError::InsufficientScopes(e) => OtaError::InsufficientScopes(e),
1036    })
1037}
1038
1039/// Maps a failed `reqwest` response to the appropriate `OtaError`.
1040///
1041/// A 401 Unauthorized response means the saved token has been revoked or
1042/// expired — the caller should re-authenticate via device flow rather than
1043/// treating this as a generic API error.
1044fn api_error(e: reqwest::Error) -> OtaError {
1045    if e.status() == Some(reqwest::StatusCode::UNAUTHORIZED) {
1046        tracing::warn!("GitHub API returned 401 — token invalid or revoked");
1047        OtaError::Unauthorized
1048    } else {
1049        OtaError::Api(e.to_string())
1050    }
1051}
1052
1053fn check_disk_space(path: &str) -> Result<(), OtaError> {
1054    use nix::sys::statvfs::statvfs;
1055
1056    let stat = statvfs(path)?;
1057    let available_mb = (stat.blocks_available() as u64 * stat.block_size() as u64) / (1024 * 1024);
1058    tracing::debug!(path, available_mb, "Checking disk space");
1059
1060    if available_mb < 100 {
1061        tracing::error!(
1062            path,
1063            available_mb,
1064            required_mb = 100,
1065            "Insufficient disk space"
1066        );
1067        return Err(OtaError::InsufficientSpace(available_mb));
1068    }
1069    Ok(())
1070}
1071
1072#[cfg(test)]
1073mod tests {
1074    use super::*;
1075    use crate::github::GithubClient;
1076    use secrecy::SecretString;
1077
1078    fn make_client() -> OtaClient {
1079        crate::crypto::init_crypto_provider();
1080        let github =
1081            GithubClient::new(Some(SecretString::from("test_token"))).expect("client build");
1082        OtaClient::new(github)
1083    }
1084
1085    #[test]
1086    fn test_extract_and_deploy_success() {
1087        let client = make_client();
1088        let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1089            .join("src/ota/tests/fixtures/test_artifact.zip");
1090
1091        let result = client.extract_and_deploy(fixture_path);
1092
1093        assert!(
1094            result.is_ok(),
1095            "Deployment should succeed: {:?}",
1096            result.err()
1097        );
1098
1099        let deploy_path = result.unwrap();
1100        assert!(
1101            deploy_path.exists(),
1102            "Deployed file should exist at {:?}",
1103            deploy_path
1104        );
1105
1106        let content = std::fs::read_to_string(&deploy_path).unwrap();
1107        assert!(
1108            content.contains("Mock KoboRoot.tgz"),
1109            "Deployed file should contain mock content"
1110        );
1111
1112        std::fs::remove_file(&deploy_path).ok();
1113    }
1114
1115    #[test]
1116    fn test_extract_and_deploy_missing_koboroot() {
1117        let client = make_client();
1118        let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1119            .join("src/ota/tests/fixtures/empty_artifact.zip");
1120
1121        let result = client.extract_and_deploy(fixture_path);
1122        assert!(result.is_err(), "Should fail when KoboRoot.tgz is missing");
1123
1124        if let Err(OtaError::DeploymentError(msg)) = result {
1125            assert!(
1126                msg.contains("not found in artifact"),
1127                "Error should mention missing file"
1128            );
1129        } else {
1130            panic!("Expected DeploymentError");
1131        }
1132    }
1133
1134    #[test]
1135    fn test_check_disk_space_sufficient() {
1136        use tempfile::TempDir;
1137        let temp_dir = TempDir::new().unwrap();
1138        let result = check_disk_space(temp_dir.path().to_str().unwrap());
1139        assert!(
1140            result.is_ok(),
1141            "Should have sufficient disk space in temp directory"
1142        );
1143    }
1144
1145    fn external_test_enabled() -> bool {
1146        std::env::var("CADMUS_TEST_OTA_EXTERNAL").is_ok() && std::env::var("GH_TOKEN").is_ok()
1147    }
1148
1149    fn create_external_client() -> OtaClient {
1150        crate::crypto::init_crypto_provider();
1151        let token = std::env::var("GH_TOKEN").expect("GH_TOKEN must be set");
1152        let github = GithubClient::new(Some(SecretString::from(token))).expect("client build");
1153        OtaClient::new(github)
1154    }
1155
1156    #[test]
1157    #[ignore]
1158    fn test_external_download_default_branch_and_deploy() {
1159        if !external_test_enabled() {
1160            return;
1161        }
1162
1163        let client = create_external_client();
1164        let mut last_progress = None;
1165
1166        let download_result = client.download_default_branch_artifact(|progress| {
1167            last_progress = Some(format!("{:?}", progress));
1168        });
1169
1170        assert!(
1171            download_result.is_ok(),
1172            "Default branch artifact download should succeed: {:?}",
1173            download_result.err()
1174        );
1175
1176        let zip_path = download_result.unwrap();
1177        assert!(
1178            zip_path.exists(),
1179            "Downloaded ZIP should exist at {:?}",
1180            zip_path
1181        );
1182        assert!(
1183            zip_path.metadata().unwrap().len() > 0,
1184            "Downloaded ZIP should not be empty"
1185        );
1186
1187        let deploy_result = client.extract_and_deploy(zip_path.clone());
1188
1189        assert!(
1190            deploy_result.is_ok(),
1191            "Deployment should succeed: {:?}",
1192            deploy_result.err()
1193        );
1194
1195        let deploy_path = deploy_result.unwrap();
1196        assert!(
1197            deploy_path.exists(),
1198            "Deployed file should exist at {:?}",
1199            deploy_path
1200        );
1201
1202        std::fs::remove_file(&zip_path).ok();
1203        std::fs::remove_file(&deploy_path).ok();
1204    }
1205
1206    #[test]
1207    #[ignore]
1208    fn test_external_download_stable_release_and_deploy() {
1209        if !external_test_enabled() {
1210            return;
1211        }
1212
1213        let client = create_external_client();
1214        let download_result = client.download_stable_release_artifact(|_| {});
1215
1216        assert!(
1217            download_result.is_ok(),
1218            "Stable release artifact download should succeed: {:?}",
1219            download_result.err()
1220        );
1221
1222        let asset_path = download_result.unwrap();
1223        assert!(
1224            asset_path.exists(),
1225            "Downloaded asset should exist at {:?}",
1226            asset_path
1227        );
1228        assert!(
1229            asset_path.metadata().unwrap().len() > 0,
1230            "Downloaded asset should not be empty"
1231        );
1232
1233        let deploy_result = client.deploy(asset_path.clone());
1234
1235        assert!(
1236            deploy_result.is_ok(),
1237            "Deployment should succeed: {:?}",
1238            deploy_result.err()
1239        );
1240
1241        let deploy_path = deploy_result.unwrap();
1242        assert!(
1243            deploy_path.exists(),
1244            "Deployed file should exist at {:?}",
1245            deploy_path
1246        );
1247
1248        std::fs::remove_file(&asset_path).ok();
1249        std::fs::remove_file(&deploy_path).ok();
1250    }
1251}