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::{utf8_percent_encode, NON_ALPHANUMERIC};
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        #[cfg(feature = "test")]
256        let artifact_name_pattern = format!("cadmus-kobo-test-pr{}", pr_number);
257        #[cfg(not(feature = "test"))]
258        let artifact_name_pattern = format!("cadmus-kobo-pr{}", pr_number);
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        #[cfg(feature = "test")]
362        let artifact_name_prefix = format!("cadmus-kobo-test-{}", short_sha);
363        #[cfg(not(feature = "test"))]
364        let artifact_name_prefix = format!("cadmus-kobo-{}", short_sha);
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        #[cfg(test)]
603        let path = std::env::temp_dir()
604            .join("test-kobo-deployment")
605            .join("KoboRoot.tgz");
606
607        #[cfg(all(feature = "emulator", not(test)))]
608        let path = PathBuf::from("/tmp/.kobo/KoboRoot.tgz");
609
610        #[cfg(all(not(feature = "emulator"), not(test)))]
611        let path = PathBuf::from(format!("{}/.kobo/KoboRoot.tgz", INTERNAL_CARD_ROOT));
612
613        tracing::debug!(path = ?path, "Deploy destination");
614        path
615    }
616
617    fn ensure_deploy_dir(&self, deploy_path: &Path) -> Result<(), OtaError> {
618        #[cfg(any(test, feature = "emulator"))]
619        {
620            if let Some(parent) = deploy_path.parent() {
621                tracing::debug!(directory = ?parent, "Creating parent directory");
622                std::fs::create_dir_all(parent)?;
623            }
624        }
625
626        let _ = deploy_path;
627        Ok(())
628    }
629
630    fn deploy_bytes(&self, data: &[u8]) -> Result<PathBuf, OtaError> {
631        let deploy_path = self.deploy_path();
632        self.ensure_deploy_dir(&deploy_path)?;
633
634        tracing::debug!(bytes = data.len(), path = ?deploy_path, "Writing file");
635        let mut file = File::create(&deploy_path)?;
636        file.write_all(data)?;
637
638        tracing::debug!(path = ?deploy_path, "Deployment complete");
639        tracing::info!(path = ?deploy_path, "Update deployed successfully");
640
641        Ok(deploy_path)
642    }
643
644    /// Extracts KoboRoot.tgz from the artifact and deploys it for installation.
645    ///
646    /// Opens the downloaded ZIP archive, locates the `KoboRoot.tgz` file,
647    /// extracts it, and writes it to `/mnt/onboard/.kobo/KoboRoot.tgz`
648    /// where the Kobo device will automatically install it on next reboot.
649    /// On success, the source artifact ZIP is deleted as a best-effort cleanup step.
650    ///
651    /// # Arguments
652    ///
653    /// * `zip_path` - Path to the downloaded artifact ZIP file
654    ///
655    /// # Returns
656    ///
657    /// The deployment path where KoboRoot.tgz was written.
658    ///
659    /// # Errors
660    ///
661    /// * `OtaError::ZipError` - Failed to open or read ZIP archive
662    /// * `OtaError::DeploymentError` - KoboRoot.tgz not found in archive
663    /// * `OtaError::Io` - Failed to write deployment file
664    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
665    pub fn extract_and_deploy(&self, zip_path: PathBuf) -> Result<PathBuf, OtaError> {
666        tracing::info!(path = ?zip_path, "Extracting and deploying update");
667        tracing::debug!(path = ?zip_path, "Starting extraction");
668
669        let file = File::open(&zip_path)?;
670        let mut archive = ZipArchive::new(file)?;
671
672        tracing::debug!(file_count = archive.len(), "Opened ZIP archive");
673
674        let mut kobo_root_data = Vec::new();
675        let mut found = false;
676
677        #[cfg(not(feature = "test"))]
678        let kobo_root_name = "KoboRoot.tgz";
679        #[cfg(feature = "test")]
680        let kobo_root_name = "KoboRoot-test.tgz";
681
682        tracing::debug!(target_file = kobo_root_name, "Looking for file");
683
684        for i in 0..archive.len() {
685            let mut entry = archive.by_index(i)?;
686            let entry_name = entry.name().to_string();
687
688            tracing::debug!(index = i, name = %entry_name, "Checking entry");
689
690            if entry_name.eq(kobo_root_name) {
691                tracing::debug!(name = %entry_name, "Found target file");
692                entry.read_to_end(&mut kobo_root_data)?;
693                found = true;
694                break;
695            }
696        }
697
698        if !found {
699            tracing::error!(
700                target_file = kobo_root_name,
701                "Target file not found in artifact"
702            );
703            return Err(OtaError::DeploymentError(format!(
704                "{} not found in artifact",
705                kobo_root_name
706            )));
707        }
708
709        tracing::debug!(
710            bytes = kobo_root_data.len(),
711            file = kobo_root_name,
712            "Extracted file"
713        );
714
715        let deploy_path = self.deploy_bytes(&kobo_root_data)?;
716        if let Err(e) = std::fs::remove_file(&zip_path) {
717            tracing::error!(path = ?zip_path, error = %e, "Failed to remove source file");
718        }
719
720        Ok(deploy_path)
721    }
722
723    /// Queries the GitHub API for the repository's default branch name.
724    fn fetch_default_branch(&self) -> Result<String, OtaError> {
725        let repo_url = "https://api.github.com/repos/ogkevin/cadmus";
726        tracing::debug!(url = %repo_url, "Fetching repository metadata");
727
728        let repo: Repository = self
729            .github
730            .get(repo_url)
731            .send()?
732            .error_for_status()
733            .map_err(|e| {
734                tracing::error!(status = ?e.status(), error = %e, "Repository metadata fetch failed");
735                api_error(e)
736            })?
737            .json()?;
738
739        tracing::debug!(default_branch = %repo.default_branch, "Resolved default branch");
740        Ok(repo.default_branch)
741    }
742
743    /// Fetches artifacts for a workflow run and finds one matching the given prefix.
744    fn find_artifact_in_run(&self, run_id: u64, name_prefix: &str) -> Result<Artifact, OtaError> {
745        let artifacts_url = format!(
746            "https://api.github.com/repos/ogkevin/cadmus/actions/runs/{}/artifacts",
747            run_id
748        );
749        tracing::debug!(url = %artifacts_url, "Fetching artifacts");
750
751        let artifacts: ArtifactsResponse = self
752            .github
753            .get(&artifacts_url)
754            .send()?
755            .error_for_status()
756            .map_err(|e| {
757                tracing::error!(run_id, status = ?e.status(), error = %e, "Artifacts fetch failed");
758                api_error(e)
759            })?
760            .json()?;
761
762        tracing::debug!(count = artifacts.artifacts.len(), "Found artifacts");
763
764        #[cfg(feature = "tracing")]
765        if tracing::enabled!(tracing::Level::DEBUG) {
766            for (idx, artifact) in artifacts.artifacts.iter().enumerate() {
767                tracing::debug!(
768                    index = idx,
769                    name = %artifact.name,
770                    id = artifact.id,
771                    size_bytes = artifact.size_in_bytes,
772                    "Artifact"
773                );
774            }
775        }
776
777        tracing::debug!(pattern = %name_prefix, "Looking for artifact");
778
779        artifacts
780            .artifacts
781            .into_iter()
782            .find(|a| a.name.starts_with(name_prefix))
783            .ok_or_else(|| {
784                tracing::error!(run_id, pattern = %name_prefix, "No matching artifact found");
785                OtaError::ArtifactsNotFound(ArtifactSource::WorkflowRun(name_prefix.to_owned()))
786            })
787    }
788
789    /// Downloads an artifact ZIP to the specified path with chunked transfer and progress reporting.
790    ///
791    /// GitHub authentication is required for this operation.
792    fn download_artifact_to_path<F>(
793        &self,
794        artifact: &Artifact,
795        download_path: &PathBuf,
796        progress_callback: &mut F,
797    ) -> Result<(), OtaError>
798    where
799        F: FnMut(OtaProgress),
800    {
801        let download_url = format!(
802            "https://api.github.com/repos/ogkevin/cadmus/actions/artifacts/{}/zip",
803            artifact.id
804        );
805
806        self.github.download(
807            &download_url,
808            artifact.size_in_bytes,
809            download_path,
810            |url| self.github.get(url),
811            &mut |downloaded, total| {
812                progress_callback(OtaProgress::DownloadingArtifact { downloaded, total })
813            },
814        )?;
815        Ok(())
816    }
817
818    /// Downloads a release asset to the specified path with chunked transfer and progress reporting.
819    ///
820    /// GitHub authentication is not required for this operation as release
821    /// assets are downloaded from public URLs.
822    #[inline]
823    #[cfg_attr(
824        feature = "tracing",
825        tracing::instrument(skip(self, progress_callback))
826    )]
827    fn download_release_asset<F>(
828        &self,
829        asset: &ReleaseAsset,
830        download_path: &PathBuf,
831        progress_callback: &mut F,
832    ) -> Result<(), OtaError>
833    where
834        F: FnMut(OtaProgress),
835    {
836        self.github.download(
837            &asset.browser_download_url,
838            asset.size,
839            download_path,
840            |url| self.github.get_unauthenticated(url),
841            &mut |downloaded, total| {
842                progress_callback(OtaProgress::DownloadingArtifact { downloaded, total })
843            },
844        )?;
845        Ok(())
846    }
847}
848
849/// Verifies that the GitHub token has all scopes required for OTA operations.
850///
851/// Delegates to [`GithubClient::verify_token_scopes`], which reads the
852/// `X-OAuth-Scopes` header from a lightweight `/user` request and compares
853/// against [`crate::github::REQUIRED_SCOPES`].
854///
855/// Returns `Ok(())` if all scopes are present, or an `OtaError` that is
856/// either a transport failure or missing scopes, so the caller can trigger
857/// re-authentication.
858fn verify_scopes(github: &crate::github::GithubClient) -> Result<(), OtaError> {
859    github.verify_token_scopes().map_err(|e| match e {
860        crate::github::VerifyScopesError::Request(e) => api_error(e),
861        crate::github::VerifyScopesError::InsufficientScopes(e) => OtaError::InsufficientScopes(e),
862    })
863}
864
865/// Maps a failed `reqwest` response to the appropriate `OtaError`.
866///
867/// A 401 Unauthorized response means the saved token has been revoked or
868/// expired — the caller should re-authenticate via device flow rather than
869/// treating this as a generic API error.
870fn api_error(e: reqwest::Error) -> OtaError {
871    if e.status() == Some(reqwest::StatusCode::UNAUTHORIZED) {
872        tracing::warn!("GitHub API returned 401 — token invalid or revoked");
873        OtaError::Unauthorized
874    } else {
875        OtaError::Api(e.to_string())
876    }
877}
878
879fn check_disk_space(path: &Path) -> Result<(), OtaError> {
880    use nix::sys::statvfs::statvfs;
881
882    let stat = statvfs(path)?;
883    let available_mb = (stat.blocks_available() as u64 * stat.block_size() as u64) / (1024 * 1024);
884    tracing::debug!(path = ?path, available_mb, "Checking disk space");
885
886    if available_mb < 100 {
887        tracing::error!(
888            path = ?path,
889            available_mb,
890            required_mb = 100,
891            "Insufficient disk space"
892        );
893        return Err(OtaError::InsufficientSpace(available_mb));
894    }
895    Ok(())
896}
897
898#[cfg(test)]
899mod tests {
900    use super::*;
901    use crate::github::GithubClient;
902    use secrecy::SecretString;
903
904    fn make_client(tmp_dir: PathBuf) -> OtaClient {
905        crate::crypto::init_crypto_provider();
906        let github =
907            GithubClient::new(Some(SecretString::from("test_token"))).expect("client build");
908        OtaClient::new(github, tmp_dir)
909    }
910
911    #[test]
912    fn test_extract_and_deploy_success() {
913        let temp_dir = tempfile::tempdir().unwrap();
914        let client = make_client(temp_dir.path().to_path_buf());
915        let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
916            .join("src/ota/tests/fixtures/test_artifact.zip");
917        let artifact_path = temp_dir.path().join("test_artifact.zip");
918        std::fs::copy(&fixture_path, &artifact_path).unwrap();
919
920        let result = client.extract_and_deploy(artifact_path.clone());
921
922        assert!(
923            result.is_ok(),
924            "Deployment should succeed: {:?}",
925            result.err()
926        );
927
928        let deploy_path = result.unwrap();
929        assert!(
930            deploy_path.exists(),
931            "Deployed file should exist at {:?}",
932            deploy_path
933        );
934
935        let content = std::fs::read_to_string(&deploy_path).unwrap();
936        assert!(
937            content.contains("Mock KoboRoot.tgz"),
938            "Deployed file should contain mock content"
939        );
940
941        std::fs::remove_file(&deploy_path).ok();
942        assert!(
943            !artifact_path.exists(),
944            "Downloaded artifact should be removed after successful deployment"
945        );
946    }
947
948    #[test]
949    fn test_extract_and_deploy_missing_koboroot() {
950        let temp_dir = tempfile::tempdir().unwrap();
951        let client = make_client(temp_dir.path().to_path_buf());
952        let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
953            .join("src/ota/tests/fixtures/empty_artifact.zip");
954        let artifact_path = temp_dir.path().join("empty_artifact.zip");
955        std::fs::copy(&fixture_path, &artifact_path).unwrap();
956
957        let result = client.extract_and_deploy(artifact_path.clone());
958        assert!(result.is_err(), "Should fail when KoboRoot.tgz is missing");
959
960        if let Err(OtaError::DeploymentError(msg)) = result {
961            assert!(
962                msg.contains("not found in artifact"),
963                "Error should mention missing file"
964            );
965        } else {
966            panic!("Expected DeploymentError");
967        }
968
969        assert!(
970            artifact_path.exists(),
971            "Source artifact should be retained when deployment fails"
972        );
973    }
974
975    #[test]
976    fn test_check_disk_space_sufficient() {
977        use tempfile::TempDir;
978        let temp_dir = TempDir::new().unwrap();
979        let result = check_disk_space(temp_dir.path());
980        assert!(
981            result.is_ok(),
982            "Should have sufficient disk space in temp directory"
983        );
984    }
985
986    fn create_external_client(tmp_dir: PathBuf) -> OtaClient {
987        crate::crypto::init_crypto_provider();
988        let token = std::env::var("GH_TOKEN").expect("GH_TOKEN must be set");
989        let github = GithubClient::new(Some(SecretString::from(token))).expect("client build");
990        OtaClient::new(github, tmp_dir)
991    }
992
993    #[test]
994    #[ignore]
995    fn test_external_download_default_branch_and_deploy() {
996        let temp_dir = tempfile::tempdir().unwrap();
997        let client = create_external_client(temp_dir.path().to_path_buf());
998        let mut last_progress = None;
999
1000        let download_result = client.download_default_branch_artifact(|progress| {
1001            last_progress = Some(format!("{:?}", progress));
1002        });
1003
1004        assert!(
1005            download_result.is_ok(),
1006            "Default branch artifact download should succeed: {:?}",
1007            download_result.err()
1008        );
1009
1010        let zip_path = download_result.unwrap();
1011        assert!(
1012            zip_path.exists(),
1013            "Downloaded ZIP should exist at {:?}",
1014            zip_path
1015        );
1016        assert!(
1017            zip_path.metadata().unwrap().len() > 0,
1018            "Downloaded ZIP should not be empty"
1019        );
1020
1021        let deploy_result = client.extract_and_deploy(zip_path.clone());
1022
1023        assert!(
1024            deploy_result.is_ok(),
1025            "Deployment should succeed: {:?}",
1026            deploy_result.err()
1027        );
1028
1029        let deploy_path = deploy_result.unwrap();
1030        assert!(
1031            deploy_path.exists(),
1032            "Deployed file should exist at {:?}",
1033            deploy_path
1034        );
1035
1036        std::fs::remove_file(&deploy_path).ok();
1037    }
1038
1039    #[test]
1040    #[ignore]
1041    fn test_external_download_stable_release_and_deploy() {
1042        let temp_dir = tempfile::tempdir().unwrap();
1043        let client = create_external_client(temp_dir.path().to_path_buf());
1044        let download_result = client.download_stable_release_artifact(|_| {});
1045
1046        assert!(
1047            download_result.is_ok(),
1048            "Stable release artifact download should succeed: {:?}",
1049            download_result.err()
1050        );
1051
1052        let asset_path = download_result.unwrap();
1053        assert!(
1054            asset_path.exists(),
1055            "Downloaded asset should exist at {:?}",
1056            asset_path
1057        );
1058        assert!(
1059            asset_path.metadata().unwrap().len() > 0,
1060            "Downloaded asset should not be empty"
1061        );
1062
1063        let deploy_result = client.deploy(asset_path.clone());
1064
1065        assert!(
1066            deploy_result.is_ok(),
1067            "Deployment should succeed: {:?}",
1068            deploy_result.err()
1069        );
1070
1071        let deploy_path = deploy_result.unwrap();
1072        assert!(
1073            deploy_path.exists(),
1074            "Deployed file should exist at {:?}",
1075            deploy_path
1076        );
1077
1078        std::fs::remove_file(&deploy_path).ok();
1079    }
1080}