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
13pub 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 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 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 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 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 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 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
145async 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 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
201pub 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 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
286pub 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 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 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 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
362pub 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#[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 {
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 {
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 {
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}