cadmus_core/ota/
client.rs

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