Skip to main content

update_kit/applier/
native.rs

1use std::path::{Path, PathBuf};
2
3use tokio::io::AsyncWriteExt;
4
5use crate::applier::types::ApplyOptions;
6use crate::applier::verify::{verify_checksum, ChecksumInfo, VerifyOptions};
7use crate::errors::UpdateKitError;
8use crate::platform::replace::atomic_replace;
9use crate::types::{ApplyProgress, ApplyResult, PlanKind, UpdatePlan};
10use crate::utils::http::fetch_with_timeout;
11use crate::utils::security::require_https;
12
13/// Apply a native in-place update by downloading, verifying, extracting,
14/// and atomically replacing the target binary.
15pub async fn apply_native_update(
16    plan: &UpdatePlan,
17    target_path: &str,
18    options: Option<&ApplyOptions>,
19) -> ApplyResult {
20    let (download_url, checksum_url, expected_checksum) = match &plan.kind {
21        PlanKind::NativeInPlace {
22            download_url,
23            checksum_url,
24            expected_checksum,
25        } => (
26            download_url.as_str(),
27            checksum_url.clone(),
28            expected_checksum.clone(),
29        ),
30        _ => {
31            return ApplyResult::Failed {
32                error: Box::new(UpdateKitError::ApplyFailed(
33                    "apply_native_update called with non-NativeInPlace plan".into(),
34                )),
35                rollback_succeeded: false,
36            };
37        }
38    };
39
40    let target = Path::new(target_path);
41    let parent = target.parent().unwrap_or(Path::new("."));
42
43    // Create temp dir in the same parent directory for same-filesystem operations
44    let temp_dir = match tempfile::tempdir_in(parent) {
45        Ok(d) => d,
46        Err(e) => {
47            return ApplyResult::Failed {
48                error: Box::new(UpdateKitError::ApplyFailed(format!(
49                    "Failed to create temp directory: {e}"
50                ))),
51                rollback_succeeded: false,
52            };
53        }
54    };
55
56    let progress_cb = options.and_then(|o| o.on_progress.as_deref());
57    let skip_checksum = options.is_some_and(|o| o.skip_checksum);
58
59    // Step 1: Download
60    let archive_path = match download_artifact(download_url, temp_dir.path(), progress_cb).await {
61        Ok(p) => p,
62        Err(e) => {
63            return ApplyResult::Failed {
64                error: Box::new(e),
65                rollback_succeeded: false,
66            };
67        }
68    };
69
70    // Step 2: Verify checksum
71    if !skip_checksum {
72        if let Some(cb) = progress_cb {
73            cb(ApplyProgress::Verifying);
74        }
75
76        let checksum_info = ChecksumInfo {
77            expected_checksum,
78            checksum_url,
79        };
80
81        let verify_opts = VerifyOptions {
82            filename: archive_path
83                .file_name()
84                .map(|n| n.to_string_lossy().into_owned()),
85        };
86
87        if let Err(e) = verify_checksum(&archive_path, &checksum_info, Some(&verify_opts)).await {
88            return ApplyResult::Failed {
89                error: Box::new(e),
90                rollback_succeeded: false,
91            };
92        }
93    }
94
95    // Step 3: Extract
96    if let Some(cb) = progress_cb {
97        cb(ApplyProgress::Extracting);
98    }
99
100    let extract_dir = temp_dir.path().join("extracted");
101    if let Err(e) = tokio::fs::create_dir_all(&extract_dir).await {
102        return ApplyResult::Failed {
103            error: Box::new(UpdateKitError::ExtractFailed(format!(
104                "Failed to create extraction directory: {e}"
105            ))),
106            rollback_succeeded: false,
107        };
108    }
109
110    let binary_path = match extract_binary(&archive_path, &extract_dir).await {
111        Ok(p) => p,
112        Err(e) => {
113            return ApplyResult::Failed {
114                error: Box::new(e),
115                rollback_succeeded: false,
116            };
117        }
118    };
119
120    // Step 4: Atomic replace
121    if let Some(cb) = progress_cb {
122        cb(ApplyProgress::Replacing);
123    }
124
125    if let Err(e) = atomic_replace(&binary_path, target).await {
126        return ApplyResult::Failed {
127            error: Box::new(e),
128            rollback_succeeded: false,
129        };
130    }
131
132    // temp_dir is dropped here, cleaning up automatically
133
134    if let Some(cb) = progress_cb {
135        cb(ApplyProgress::Done);
136    }
137
138    ApplyResult::Success {
139        from_version: plan.from_version.clone(),
140        to_version: plan.to_version.clone(),
141        post_action: plan.post_action,
142    }
143}
144
145/// Download an artifact from a URL to a destination directory.
146/// Returns the path to the downloaded file.
147async fn download_artifact(
148    url: &str,
149    dest_dir: &Path,
150    on_progress: Option<&(dyn Fn(ApplyProgress) + Send + Sync)>,
151) -> Result<PathBuf, UpdateKitError> {
152    require_https(url)?;
153
154    let response = fetch_with_timeout(url, None).await?;
155
156    let total_bytes = response.content_length();
157
158    // Derive filename from URL
159    let filename = url
160        .rsplit('/')
161        .next()
162        .unwrap_or("download")
163        .split('?')
164        .next()
165        .unwrap_or("download");
166
167    let dest_path = dest_dir.join(filename);
168    let mut file = tokio::fs::File::create(&dest_path).await.map_err(|e| {
169        UpdateKitError::DownloadFailed(format!("Failed to create download file: {e}"))
170    })?;
171
172    let mut bytes_downloaded: u64 = 0;
173    let mut stream = response.bytes_stream();
174
175    use futures_util::StreamExt;
176    while let Some(chunk) = stream.next().await {
177        let chunk = chunk.map_err(|e| {
178            UpdateKitError::DownloadFailed(format!("Error reading download stream: {e}"))
179        })?;
180        file.write_all(&chunk).await.map_err(|e| {
181            UpdateKitError::DownloadFailed(format!("Error writing download data: {e}"))
182        })?;
183
184        bytes_downloaded += chunk.len() as u64;
185
186        if let Some(cb) = on_progress {
187            cb(ApplyProgress::Downloading {
188                bytes_downloaded,
189                total_bytes,
190            });
191        }
192    }
193
194    file.flush().await.map_err(|e| {
195        UpdateKitError::DownloadFailed(format!("Error flushing download file: {e}"))
196    })?;
197
198    Ok(dest_path)
199}
200
201/// Extract a binary from an archive file. Returns the path to the extracted binary.
202///
203/// Supports `.tar.gz`, `.tgz`, `.zip`, and bare binaries.
204pub async fn extract_binary(
205    archive_path: &Path,
206    dest_dir: &Path,
207) -> Result<PathBuf, UpdateKitError> {
208    let filename = archive_path
209        .file_name()
210        .map(|n| n.to_string_lossy().to_lowercase())
211        .unwrap_or_default();
212
213    if filename.ends_with(".tar.gz") || filename.ends_with(".tgz") {
214        extract_tar_gz(archive_path, dest_dir).await?;
215    } else if filename.ends_with(".zip") {
216        extract_zip(archive_path, dest_dir).await?;
217    } else {
218        // Treat as bare binary
219        let dest = dest_dir.join(
220            archive_path
221                .file_name()
222                .unwrap_or(std::ffi::OsStr::new("binary")),
223        );
224        tokio::fs::copy(archive_path, &dest).await.map_err(|e| {
225            UpdateKitError::ExtractFailed(format!("Failed to copy bare binary: {e}"))
226        })?;
227
228        #[cfg(unix)]
229        {
230            use std::os::unix::fs::PermissionsExt;
231            let perms = std::fs::Permissions::from_mode(0o755);
232            tokio::fs::set_permissions(&dest, perms).await.ok();
233        }
234
235        return Ok(dest);
236    }
237
238    find_binary_in_dir(dest_dir).await
239}
240
241async fn extract_tar_gz(archive_path: &Path, dest_dir: &Path) -> Result<(), UpdateKitError> {
242    let archive_path = archive_path.to_owned();
243    let dest_dir = dest_dir.to_owned();
244
245    tokio::task::spawn_blocking(move || {
246        let file = std::fs::File::open(&archive_path).map_err(|e| {
247            UpdateKitError::ExtractFailed(format!("Failed to open archive: {e}"))
248        })?;
249
250        let gz = flate2::read::GzDecoder::new(file);
251        let mut archive = tar::Archive::new(gz);
252
253        archive.unpack(&dest_dir).map_err(|e| {
254            UpdateKitError::ExtractFailed(format!("Failed to extract tar.gz: {e}"))
255        })?;
256
257        Ok(())
258    })
259    .await
260    .map_err(|e| UpdateKitError::ExtractFailed(format!("Task join error: {e}")))?
261}
262
263async fn extract_zip(archive_path: &Path, dest_dir: &Path) -> Result<(), UpdateKitError> {
264    let archive_path = archive_path.to_owned();
265    let dest_dir = dest_dir.to_owned();
266
267    tokio::task::spawn_blocking(move || {
268        let file = std::fs::File::open(&archive_path).map_err(|e| {
269            UpdateKitError::ExtractFailed(format!("Failed to open archive: {e}"))
270        })?;
271
272        let mut archive = zip::ZipArchive::new(file).map_err(|e| {
273            UpdateKitError::ExtractFailed(format!("Failed to read zip archive: {e}"))
274        })?;
275
276        archive.extract(&dest_dir).map_err(|e| {
277            UpdateKitError::ExtractFailed(format!("Failed to extract zip: {e}"))
278        })?;
279
280        Ok(())
281    })
282    .await
283    .map_err(|e| UpdateKitError::ExtractFailed(format!("Task join error: {e}")))?
284}
285
286/// Find the first executable file in a directory (recursively).
287pub fn find_binary_in_dir(
288    dir: &Path,
289) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<PathBuf, UpdateKitError>> + Send + '_>> {
290    Box::pin(find_binary_in_dir_inner(dir))
291}
292
293async fn find_binary_in_dir_inner(dir: &Path) -> Result<PathBuf, UpdateKitError> {
294    let mut entries = tokio::fs::read_dir(dir)
295        .await
296        .map_err(|e| UpdateKitError::ExtractFailed(format!("Failed to read directory: {e}")))?;
297
298    let mut candidates: Vec<PathBuf> = Vec::new();
299
300    while let Some(entry) = entries
301        .next_entry()
302        .await
303        .map_err(|e| UpdateKitError::ExtractFailed(format!("Failed to read dir entry: {e}")))?
304    {
305        let path = entry.path();
306        let metadata = tokio::fs::metadata(&path).await.map_err(|e| {
307            UpdateKitError::ExtractFailed(format!("Failed to read metadata: {e}"))
308        })?;
309
310        if metadata.is_dir() {
311            // Recurse into subdirectories
312            if let Ok(found) = find_binary_in_dir(&path).await {
313                candidates.push(found);
314            }
315        } else if metadata.is_file() && is_executable(&path, &metadata) {
316            candidates.push(path);
317        }
318    }
319
320    // Prefer files without common non-binary extensions
321    candidates.sort_by(|a, b| {
322        let a_ext = a.extension().map(|e| e.to_string_lossy().to_lowercase());
323        let b_ext = b.extension().map(|e| e.to_string_lossy().to_lowercase());
324        let a_is_archive = matches!(a_ext.as_deref(), Some("gz" | "zip" | "tar" | "tgz"));
325        let b_is_archive = matches!(b_ext.as_deref(), Some("gz" | "zip" | "tar" | "tgz"));
326        a_is_archive.cmp(&b_is_archive)
327    });
328
329    candidates.first().cloned().ok_or_else(|| {
330        UpdateKitError::ExtractFailed(format!(
331            "No executable binary found in {}",
332            dir.display()
333        ))
334    })
335}
336
337fn is_executable(_path: &Path, metadata: &std::fs::Metadata) -> bool {
338    #[cfg(unix)]
339    {
340        use std::os::unix::fs::PermissionsExt;
341        let mode = metadata.permissions().mode();
342        mode & 0o111 != 0
343    }
344
345    #[cfg(windows)]
346    {
347        // On Windows, check for common executable extensions
348        let ext = _path
349            .extension()
350            .map(|e| e.to_string_lossy().to_lowercase());
351        matches!(ext.as_deref(), Some("exe" | "cmd" | "bat" | "com"))
352    }
353
354    #[cfg(not(any(unix, windows)))]
355    {
356        let _ = _path;
357        let _ = metadata;
358        true
359    }
360}
361
362/// Detect archive type from filename.
363pub fn detect_archive_type(filename: &str) -> ArchiveType {
364    let lower = filename.to_lowercase();
365    if lower.ends_with(".tar.gz") || lower.ends_with(".tgz") {
366        ArchiveType::TarGz
367    } else if lower.ends_with(".zip") {
368        ArchiveType::Zip
369    } else {
370        ArchiveType::Bare
371    }
372}
373
374/// The type of archive format.
375#[derive(Debug, Clone, Copy, PartialEq, Eq)]
376pub enum ArchiveType {
377    TarGz,
378    Zip,
379    Bare,
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385    use tempfile::TempDir;
386
387    #[test]
388    fn test_detect_archive_type_tar_gz() {
389        assert_eq!(detect_archive_type("app-v1.tar.gz"), ArchiveType::TarGz);
390        assert_eq!(detect_archive_type("app-v1.tgz"), ArchiveType::TarGz);
391        assert_eq!(detect_archive_type("APP.TAR.GZ"), ArchiveType::TarGz);
392    }
393
394    #[test]
395    fn test_detect_archive_type_zip() {
396        assert_eq!(detect_archive_type("app-v1.zip"), ArchiveType::Zip);
397        assert_eq!(detect_archive_type("APP.ZIP"), ArchiveType::Zip);
398    }
399
400    #[test]
401    fn test_detect_archive_type_bare() {
402        assert_eq!(detect_archive_type("app"), ArchiveType::Bare);
403        assert_eq!(detect_archive_type("app.exe"), ArchiveType::Bare);
404    }
405
406    #[tokio::test]
407    async fn test_extract_bare_binary() {
408        let dir = TempDir::new().unwrap();
409        let archive_path = dir.path().join("myapp");
410        let extract_dir = dir.path().join("extracted");
411        tokio::fs::create_dir_all(&extract_dir).await.unwrap();
412        tokio::fs::write(&archive_path, b"fake binary content")
413            .await
414            .unwrap();
415
416        let result = extract_binary(&archive_path, &extract_dir).await;
417        assert!(result.is_ok());
418        let binary = result.unwrap();
419        assert!(binary.exists());
420        let content = tokio::fs::read_to_string(&binary).await.unwrap();
421        assert_eq!(content, "fake binary content");
422    }
423
424    #[tokio::test]
425    async fn test_extract_tar_gz() {
426        let dir = TempDir::new().unwrap();
427        let archive_path = dir.path().join("test.tar.gz");
428        let extract_dir = dir.path().join("extracted");
429        tokio::fs::create_dir_all(&extract_dir).await.unwrap();
430
431        // Create a tar.gz archive with a binary inside
432        {
433            let file = std::fs::File::create(&archive_path).unwrap();
434            let gz = flate2::write::GzEncoder::new(file, flate2::Compression::default());
435            let mut tar_builder = tar::Builder::new(gz);
436
437            let content = b"#!/bin/sh\necho hello";
438            let mut header = tar::Header::new_gnu();
439            header.set_size(content.len() as u64);
440            header.set_mode(0o755);
441            header.set_cksum();
442            tar_builder
443                .append_data(&mut header, "myapp", &content[..])
444                .unwrap();
445            tar_builder.finish().unwrap();
446        }
447
448        let result = extract_binary(&archive_path, &extract_dir).await;
449        assert!(result.is_ok());
450        let binary = result.unwrap();
451        let content = tokio::fs::read_to_string(&binary).await.unwrap();
452        assert_eq!(content, "#!/bin/sh\necho hello");
453    }
454
455    #[tokio::test]
456    async fn test_extract_zip() {
457        let dir = TempDir::new().unwrap();
458        let archive_path = dir.path().join("test.zip");
459        let extract_dir = dir.path().join("extracted");
460        tokio::fs::create_dir_all(&extract_dir).await.unwrap();
461
462        // Create a zip archive with a file inside
463        {
464            let file = std::fs::File::create(&archive_path).unwrap();
465            let mut zip = zip::ZipWriter::new(file);
466            let options = zip::write::SimpleFileOptions::default()
467                .unix_permissions(0o755);
468            zip.start_file("myapp", options).unwrap();
469            use std::io::Write;
470            zip.write_all(b"binary content").unwrap();
471            zip.finish().unwrap();
472        }
473
474        let result = extract_binary(&archive_path, &extract_dir).await;
475        assert!(result.is_ok());
476        let binary = result.unwrap();
477        let content = tokio::fs::read_to_string(&binary).await.unwrap();
478        assert_eq!(content, "binary content");
479    }
480
481    #[tokio::test]
482    async fn test_find_binary_in_dir_empty() {
483        let dir = TempDir::new().unwrap();
484        let result = find_binary_in_dir(dir.path()).await;
485        assert!(result.is_err());
486    }
487
488    #[cfg(unix)]
489    #[tokio::test]
490    async fn test_find_binary_in_dir_with_executable() {
491        use std::os::unix::fs::PermissionsExt;
492
493        let dir = TempDir::new().unwrap();
494        let bin_path = dir.path().join("myapp");
495        tokio::fs::write(&bin_path, b"binary").await.unwrap();
496        tokio::fs::set_permissions(&bin_path, std::fs::Permissions::from_mode(0o755))
497            .await
498            .unwrap();
499
500        let result = find_binary_in_dir(dir.path()).await;
501        assert!(result.is_ok());
502        assert_eq!(result.unwrap(), bin_path);
503    }
504
505    #[tokio::test]
506    async fn apply_native_wrong_plan_type_fails() {
507        let plan = UpdatePlan {
508            kind: PlanKind::DelegateCommand {
509                channel: crate::types::Channel::NpmGlobal,
510                command: vec!["npm".into()],
511                mode: crate::types::DelegateMode::PrintOnly,
512            },
513            from_version: "1.0.0".into(),
514            to_version: "2.0.0".into(),
515            post_action: crate::types::PostAction::None,
516        };
517        let result = apply_native_update(&plan, "/tmp/test", None).await;
518        match result {
519            ApplyResult::Failed { error, .. } => {
520                assert_eq!(error.code(), "APPLY_FAILED");
521            }
522            other => panic!("Expected Failed, got: {other:?}"),
523        }
524    }
525
526    #[test]
527    fn detect_archive_type_case_insensitive() {
528        assert_eq!(detect_archive_type("APP.TAR.GZ"), ArchiveType::TarGz);
529        assert_eq!(detect_archive_type("File.TGZ"), ArchiveType::TarGz);
530        assert_eq!(detect_archive_type("Archive.ZIP"), ArchiveType::Zip);
531    }
532
533    #[test]
534    fn detect_archive_type_with_path() {
535        assert_eq!(
536            detect_archive_type("path/to/app.tar.gz"),
537            ArchiveType::TarGz
538        );
539        assert_eq!(detect_archive_type("/downloads/app.zip"), ArchiveType::Zip);
540        assert_eq!(detect_archive_type("/usr/bin/myapp"), ArchiveType::Bare);
541    }
542
543    #[test]
544    fn detect_archive_type_exe_is_bare() {
545        assert_eq!(detect_archive_type("app.exe"), ArchiveType::Bare);
546        assert_eq!(detect_archive_type("app.msi"), ArchiveType::Bare);
547    }
548
549    #[tokio::test]
550    async fn extract_binary_empty_dir_error_message() {
551        let dir = TempDir::new().unwrap();
552        let result = find_binary_in_dir(dir.path()).await;
553        assert!(result.is_err());
554        let err = result.unwrap_err();
555        assert!(err.to_string().contains("No executable binary found"));
556    }
557
558    #[tokio::test]
559    async fn extract_tar_gz_with_nested_dir() {
560        let dir = TempDir::new().unwrap();
561        let archive_path = dir.path().join("test.tar.gz");
562        let extract_dir = dir.path().join("extracted");
563        tokio::fs::create_dir_all(&extract_dir).await.unwrap();
564
565        // Create tar.gz with binary in subdirectory
566        {
567            let file = std::fs::File::create(&archive_path).unwrap();
568            let gz = flate2::write::GzEncoder::new(file, flate2::Compression::default());
569            let mut tar_builder = tar::Builder::new(gz);
570
571            let content = b"#!/bin/sh\necho nested";
572            let mut header = tar::Header::new_gnu();
573            header.set_size(content.len() as u64);
574            header.set_mode(0o755);
575            header.set_cksum();
576            tar_builder
577                .append_data(&mut header, "subdir/myapp", &content[..])
578                .unwrap();
579            tar_builder.finish().unwrap();
580        }
581
582        let result = extract_binary(&archive_path, &extract_dir).await;
583        assert!(result.is_ok());
584        let binary = result.unwrap();
585        let content = tokio::fs::read_to_string(&binary).await.unwrap();
586        assert_eq!(content, "#!/bin/sh\necho nested");
587    }
588
589    #[cfg(unix)]
590    #[tokio::test]
591    async fn extract_bare_sets_executable_permission() {
592        use std::os::unix::fs::PermissionsExt;
593
594        let dir = TempDir::new().unwrap();
595        let archive_path = dir.path().join("myapp");
596        let extract_dir = dir.path().join("extracted");
597        tokio::fs::create_dir_all(&extract_dir).await.unwrap();
598        tokio::fs::write(&archive_path, b"binary content")
599            .await
600            .unwrap();
601
602        let result = extract_binary(&archive_path, &extract_dir).await.unwrap();
603        let metadata = tokio::fs::metadata(&result).await.unwrap();
604        let mode = metadata.permissions().mode();
605        assert!(
606            mode & 0o111 != 0,
607            "Binary should be executable, mode: {mode:o}"
608        );
609    }
610
611    #[tokio::test]
612    async fn extract_nonexistent_archive_fails() {
613        let dir = TempDir::new().unwrap();
614        let extract_dir = dir.path().join("extracted");
615        tokio::fs::create_dir_all(&extract_dir).await.unwrap();
616
617        let result =
618            extract_binary(&dir.path().join("nonexistent.tar.gz"), &extract_dir).await;
619        assert!(result.is_err());
620    }
621}