Skip to main content

cadmus_core/ota/
client.rs

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
16/// Downloads and deploys OTA updates from GitHub.
17///
18/// Delegates all HTTP communication to [`GithubClient`] and focuses solely on
19/// the OTA-specific workflow: finding artifacts, chunked downloading, ZIP
20/// extraction, and deploying `KoboRoot.tgz` to the Kobo device.
21pub struct OtaClient {
22    github: GithubClient,
23    tmp_dir: PathBuf,
24}
25
26/// Indicates where artifacts were expected but not found.
27#[derive(Debug, Clone)]
28pub enum ArtifactSource {
29    /// No artifacts found for a specific pull request
30    PullRequest(u32),
31    /// No artifacts found for the default branch
32    DefaultBranch,
33    /// No artifact matching the expected name pattern in a workflow run
34    WorkflowRun(String),
35    /// No release asset found with the expected name
36    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/// Error types that can occur during OTA operations.
59#[derive(thiserror::Error, Debug)]
60pub enum OtaError {
61    /// GitHub API returned an error response
62    #[error("GitHub API error: {0}")]
63    Api(String),
64
65    /// HTTP request failed during communication with GitHub
66    #[error("HTTP request error: {0}")]
67    Request(#[from] reqwest::Error),
68
69    /// The specified pull request number was not found in the repository
70    #[error("PR #{0} not found")]
71    PrNotFound(u32),
72
73    /// No build artifacts found for the specified source
74    #[error("{0}")]
75    ArtifactsNotFound(ArtifactSource),
76
77    /// GitHub token was not provided
78    #[error("GitHub token not configured")]
79    NoToken,
80
81    /// GitHub token is invalid or has been revoked — re-authentication required
82    #[error("GitHub token is invalid or revoked")]
83    Unauthorized,
84
85    /// GitHub token is missing one or more required OAuth scopes
86    ///
87    /// The token was accepted by GitHub but lacks the permissions needed for
88    /// OTA operations. Re-authentication with the correct scopes is required.
89    #[error(transparent)]
90    InsufficientScopes(#[from] crate::github::ScopeError),
91
92    /// Insufficient disk space available for download (requires 100MB minimum)
93    #[error("Insufficient disk space: need 100MB, have {0}MB")]
94    InsufficientSpace(u64),
95
96    /// File system I/O operation failed
97    #[error("I/O error: {0}")]
98    Io(#[from] std::io::Error),
99
100    /// System-level error from nix library
101    #[error("System error: {0}")]
102    Nix(#[from] nix::errno::Errno),
103
104    /// TLS/SSL configuration failed when setting up HTTPS client
105    #[error("TLS configuration error: {0}")]
106    TlsConfig(String),
107
108    /// Failed to extract files from ZIP archive
109    #[error("ZIP extraction error: {0}")]
110    ZipError(#[from] zip::result::ZipError),
111
112    /// Deployment process failed after successful download
113    #[error("Deployment error: {0}")]
114    DeploymentError(String),
115
116    /// Failed to parse version string
117    #[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    /// Creates a new OTA client wrapping the provided GitHub client.
133    ///
134    /// # Errors
135    ///
136    /// Returns `OtaError::TlsConfig` if the underlying HTTP client fails to build.
137    pub fn new(github: GithubClient, tmp_dir: PathBuf) -> Self {
138        Self { github, tmp_dir }
139    }
140
141    /// Downloads the build artifact from a GitHub pull request.
142    ///
143    /// This performs the complete download workflow:
144    /// 1. Verifies sufficient disk space (100MB required)
145    /// 2. Fetches PR metadata to get the commit SHA
146    /// 3. Finds the associated "Cargo" workflow run
147    /// 4. Locates artifacts matching "cadmus-kobo-pr*" pattern
148    /// 5. Downloads the artifact ZIP file to `tmp_dir/cadmus-ota-{pr_number}.zip`
149    ///
150    /// GitHub authentication is required for this operation.
151    ///
152    /// # Arguments
153    ///
154    /// * `pr_number` - The pull request number from ogkevin/cadmus repository
155    /// * `progress_callback` - Function called with progress updates during download
156    ///
157    /// # Returns
158    ///
159    /// The path to the downloaded ZIP file on success.
160    ///
161    /// # Errors
162    ///
163    /// * `OtaError::InsufficientSpace` - Less than 100MB available in the configured temp directory
164    /// * `OtaError::NoToken` - GitHub token not configured
165    /// * `OtaError::PrNotFound` - PR number doesn't exist in repository
166    /// * `OtaError::ArtifactsNotFound` - No matching build artifacts found for the PR
167    /// * `OtaError::Api` - GitHub API request failed
168    /// * `OtaError::Request` - Network communication failed
169    /// * `OtaError::Io` - Failed to write downloaded file to disk
170    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    /// Downloads the latest build artifact from the default branch.
289    ///
290    /// This performs the complete download workflow for default branch builds:
291    /// 1. Verifies sufficient disk space (100MB required)
292    /// 2. Queries GitHub API for the latest successful `cargo.yml` workflow run on the default branch
293    /// 3. Locates artifacts matching "cadmus-kobo-{sha}" pattern (or "cadmus-kobo-test-{sha}" with `test` feature)
294    /// 4. Downloads the artifact ZIP file to `tmp_dir/cadmus-ota-{sha}.zip`
295    ///
296    /// GitHub authentication is required for this operation.
297    ///
298    /// # Arguments
299    ///
300    /// * `progress_callback` - Function called with progress updates during download
301    ///
302    /// # Returns
303    ///
304    /// The path to the downloaded ZIP file on success.
305    ///
306    /// # Errors
307    ///
308    /// * `OtaError::InsufficientSpace` - Less than 100MB available in the configured temp directory
309    /// * `OtaError::NoToken` - GitHub token not configured
310    /// * `OtaError::ArtifactsNotFound` - No matching build artifacts found for default branch
311    /// * `OtaError::Api` - GitHub API request failed
312    /// * `OtaError::Request` - Network communication failed
313    /// * `OtaError::Io` - Failed to write downloaded file to disk
314    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    /// Downloads the latest stable release artifact from GitHub releases.
400    ///
401    /// This performs the complete download workflow for stable releases:
402    /// 1. Verifies sufficient disk space (100MB required)
403    /// 2. Fetches the latest release from GitHub API
404    /// 3. Locates the `KoboRoot.tgz` asset in the release
405    /// 4. Downloads the file to `tmp_dir/cadmus-ota-stable-release.tgz`
406    ///
407    /// GitHub authentication is not required for this operation as release
408    /// assets are downloaded from public URLs without Authorization headers.
409    ///
410    /// # Arguments
411    ///
412    /// * `progress_callback` - Function called with progress updates during download
413    ///
414    /// # Returns
415    ///
416    /// The path to the downloaded KoboRoot.tgz file on success.
417    ///
418    /// # Errors
419    ///
420    /// * `OtaError::InsufficientSpace` - Less than 100MB available in the configured temp directory
421    /// * `OtaError::Api` - GitHub API request failed
422    /// * `OtaError::Request` - Network communication failed
423    /// * `OtaError::ArtifactsNotFound` - KoboRoot.tgz not found in latest release
424    /// * `OtaError::Io` - Failed to write downloaded file to disk
425    #[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    /// Fetches the latest stable release version from GitHub.
499    ///
500    /// Retrieves and parses the version from the most recent stable release.
501    /// Returns the version as a `GitVersion` struct for easy comparison and display.
502    ///
503    /// GitHub authentication is not required for this operation as releases are public.
504    ///
505    /// # Errors
506    ///
507    /// * `OtaError::Api` - GitHub API request failed
508    /// * `OtaError::Request` - Network communication failed
509    /// * `OtaError::VersionParse` - Failed to parse the release tag as a valid version
510    ///
511    /// # Example
512    ///
513    /// ```no_run
514    /// use cadmus_core::github::GithubClient;
515    /// use cadmus_core::ota::OtaClient;
516    ///
517    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
518    /// # rustls::crypto::ring::default_provider().install_default().ok();
519    /// # let github = GithubClient::new(None)?;
520    /// # let client = OtaClient::new(github, std::path::PathBuf::from("/tmp"));
521    /// let version = client.fetch_latest_release_version()?;
522    /// println!("Latest version: {}", version);
523    /// # Ok(())
524    /// # }
525    /// ```
526    #[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    /// Deploys KoboRoot.tgz from the specified path directly without extraction.
549    ///
550    /// Used when the artifact is already in the correct format (e.g., stable releases
551    /// that are distributed as bare KoboRoot.tgz files).
552    ///
553    /// On success, the source file is deleted as a best-effort cleanup step.
554    ///
555    /// # Arguments
556    ///
557    /// * `kobo_root_path` - Path to the KoboRoot.tgz file to deploy
558    ///
559    /// # Returns
560    ///
561    /// The path where the file was deployed, or an error if deployment fails.
562    ///
563    /// # Errors
564    ///
565    /// * `OtaError::Io` - Failed to read or write files
566    #[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    /// Returns the platform-specific deployment path for KoboRoot.tgz.
595    ///
596    /// | Build context        | Path                                              |
597    /// |----------------------|---------------------------------------------------|
598    /// | During `cargo test`  | `<temp_dir>/test-kobo-deployment/KoboRoot.tgz`    |
599    /// | Emulator builds      | `/tmp/.kobo/KoboRoot.tgz`                         |
600    /// | Kobo builds          | `{INTERNAL_CARD_ROOT}/.kobo/KoboRoot.tgz`         |
601    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    /// Extracts KoboRoot.tgz from the artifact and deploys it for installation.
644    ///
645    /// Opens the downloaded ZIP archive, locates the `KoboRoot.tgz` file,
646    /// extracts it, and writes it to `/mnt/onboard/.kobo/KoboRoot.tgz`
647    /// where the Kobo device will automatically install it on next reboot.
648    /// On success, the source artifact ZIP is deleted as a best-effort cleanup step.
649    ///
650    /// # Arguments
651    ///
652    /// * `zip_path` - Path to the downloaded artifact ZIP file
653    ///
654    /// # Returns
655    ///
656    /// The deployment path where KoboRoot.tgz was written.
657    ///
658    /// # Errors
659    ///
660    /// * `OtaError::ZipError` - Failed to open or read ZIP archive
661    /// * `OtaError::DeploymentError` - KoboRoot.tgz not found in archive
662    /// * `OtaError::Io` - Failed to write deployment file
663    #[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    /// Queries the GitHub API for the repository's default branch name.
723    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    /// Fetches artifacts for a workflow run and finds one matching the given prefix.
743    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    /// Downloads an artifact ZIP to the specified path with chunked transfer and progress reporting.
789    ///
790    /// GitHub authentication is required for this operation.
791    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    /// Downloads a release asset to the specified path with chunked transfer and progress reporting.
818    ///
819    /// GitHub authentication is not required for this operation as release
820    /// assets are downloaded from public URLs.
821    #[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
848/// Verifies that the GitHub token has all scopes required for OTA operations.
849///
850/// Delegates to [`GithubClient::verify_token_scopes`], which reads the
851/// `X-OAuth-Scopes` header from a lightweight `/user` request and compares
852/// against [`crate::github::REQUIRED_SCOPES`].
853///
854/// Returns `Ok(())` if all scopes are present, or an `OtaError` that is
855/// either a transport failure or missing scopes, so the caller can trigger
856/// re-authentication.
857fn 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
864/// Maps a failed `reqwest` response to the appropriate `OtaError`.
865///
866/// A 401 Unauthorized response means the saved token has been revoked or
867/// expired — the caller should re-authenticate via device flow rather than
868/// treating this as a generic API error.
869fn 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}