1use std::fs;
2use std::fs::File;
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result, anyhow};
6use chrono::Utc;
7use flate2::{Compression, read::GzDecoder, write::GzEncoder};
8use tar::{Archive, Builder};
9
10use crate::models::common::enums::CompressionLevel;
11use crate::models::upstream::Package;
12use crate::models::upstream::app_config::RollbackConfig;
13use crate::services::packaging::PackageRemover;
14use crate::services::packaging::disk_impact::{
15 ByteEstimate, DiskImpact, SignedByteEstimate, estimate_path_size,
16};
17use crate::services::storage::{
18 config_storage::ConfigStorage,
19 metadata_storage::MetadataStorage,
20 package_storage::PackageStorage,
21 rollback_storage::{RollbackArtifactFormat, RollbackRecord, RollbackSource, RollbackStorage},
22};
23use crate::utils::filesystem::safe_move;
24use crate::utils::static_paths::UpstreamPaths;
25
26macro_rules! message {
27 ($cb:expr, $($arg:tt)*) => {{
28 if let Some(cb) = $cb.as_mut() {
29 cb(&format!($($arg)*));
30 }
31 }};
32}
33
34pub struct RollbackManager<'a> {
35 paths: &'a UpstreamPaths,
36 package_storage: &'a mut PackageStorage,
37 metadata_storage: &'a mut MetadataStorage,
38 rollback_storage: &'a mut RollbackStorage,
39}
40
41#[derive(Debug, Clone, Copy)]
42struct RollbackCaptureOptions {
43 compression_level: CompressionLevel,
44 stored_artifacts: usize,
45}
46
47impl<'a> RollbackManager<'a> {
48 pub fn rollback_file_path(paths: &UpstreamPaths) -> PathBuf {
49 paths.dirs.metadata_dir.join("rollback.json")
50 }
51
52 fn capture_options(paths: &UpstreamPaths) -> Result<RollbackCaptureOptions> {
53 let config = ConfigStorage::new(&paths.config.config_file)?;
54 let rollback = &config.get_config().rollback;
55 Ok(RollbackCaptureOptions {
56 compression_level: rollback.compression_level,
57 stored_artifacts: effective_stored_artifacts(rollback),
58 })
59 }
60
61 pub fn new(
62 paths: &'a UpstreamPaths,
63 package_storage: &'a mut PackageStorage,
64 metadata_storage: &'a mut MetadataStorage,
65 rollback_storage: &'a mut RollbackStorage,
66 ) -> Self {
67 Self {
68 paths,
69 package_storage,
70 metadata_storage,
71 rollback_storage,
72 }
73 }
74
75 pub fn capture_from_installed<H>(
76 &mut self,
77 package: &Package,
78 source: RollbackSource,
79 message_callback: &mut Option<H>,
80 ) -> Result<()>
81 where
82 H: FnMut(&str),
83 {
84 let install_path = package
85 .install_path
86 .as_ref()
87 .ok_or_else(|| anyhow!("Package '{}' has no install path recorded", package.name))?;
88
89 if !install_path.exists() {
90 return Err(anyhow!(
91 "Package '{}' install path does not exist: {}",
92 package.name,
93 install_path.display()
94 ));
95 }
96
97 let options = Self::capture_options(self.paths)?;
98 let record = Self::capture_artifact_from_path(
99 self.paths,
100 package,
101 install_path,
102 source,
103 options,
104 message_callback,
105 )?;
106 let pruned =
107 self.rollback_storage
108 .push_record(&package.name, record, options.stored_artifacts)?;
109 for record in pruned {
110 delete_record_artifacts(self.paths, &package.name, &record)?;
111 }
112 Ok(())
113 }
114
115 pub fn capture_backup_path<H>(
116 paths: &UpstreamPaths,
117 rollback_storage: &mut RollbackStorage,
118 package: &Package,
119 backup_path: &Path,
120 source: RollbackSource,
121 message_callback: &mut Option<H>,
122 ) -> Result<()>
123 where
124 H: FnMut(&str),
125 {
126 let options = Self::capture_options(paths)?;
127 let record = Self::capture_artifact_from_path(
128 paths,
129 package,
130 backup_path,
131 source,
132 options,
133 message_callback,
134 )?;
135 let pruned =
136 rollback_storage.push_record(&package.name, record, options.stored_artifacts)?;
137 for record in pruned {
138 delete_record_artifacts(paths, &package.name, &record)?;
139 }
140 Ok(())
141 }
142
143 fn capture_artifact_from_path<H>(
144 paths: &UpstreamPaths,
145 package: &Package,
146 artifact_path: &Path,
147 source: RollbackSource,
148 options: RollbackCaptureOptions,
149 message_callback: &mut Option<H>,
150 ) -> Result<RollbackRecord>
151 where
152 H: FnMut(&str),
153 {
154 let artifact_name = artifact_path.file_name().ok_or_else(|| {
155 anyhow!(
156 "Rollback artifact path '{}' has no final file name",
157 artifact_path.display()
158 )
159 })?;
160 let package_rollback_dir = paths.install.rollback_dir.join(&package.name);
161 fs::create_dir_all(&package_rollback_dir).context(format!(
162 "Failed to create rollback directory '{}'",
163 package_rollback_dir.display()
164 ))?;
165
166 let capture_id = rollback_capture_id(&source);
167 let capture_dir = package_rollback_dir.join(&capture_id);
168 fs::create_dir_all(&capture_dir).context(format!(
169 "Failed to create rollback capture directory '{}'",
170 capture_dir.display()
171 ))?;
172
173 let artifact_entry_path = PathBuf::from("artifact").join(artifact_name);
174 let rollback_artifact = capture_dir.join(&artifact_entry_path);
175 if let Some(parent) = rollback_artifact.parent() {
176 fs::create_dir_all(parent).context(format!(
177 "Failed to create rollback artifact parent '{}'",
178 parent.display()
179 ))?;
180 }
181
182 message!(
183 message_callback,
184 "Capturing rollback artifact for '{}' at '{}'",
185 package.name,
186 rollback_artifact.display()
187 );
188 safe_move::move_file_or_dir(artifact_path, &rollback_artifact)?;
189
190 let icon_entry_path = capture_icon(paths, package, &capture_dir)?;
191
192 let created_at = Utc::now();
193 if matches!(options.compression_level, CompressionLevel::None) {
194 return Ok(RollbackRecord {
195 package_snapshot: package.clone(),
196 artifact_relative_path: path_relative_to(
197 &paths.install.rollback_dir,
198 &rollback_artifact,
199 )?,
200 icon_relative_path: icon_entry_path
201 .as_ref()
202 .map(|entry| {
203 path_relative_to(&paths.install.rollback_dir, &capture_dir.join(entry))
204 })
205 .transpose()?,
206 artifact_format: RollbackArtifactFormat::Raw,
207 artifact_entry_path: None,
208 icon_entry_path: None,
209 source,
210 created_at,
211 });
212 }
213
214 let archive_path = package_rollback_dir.join(format!("{capture_id}.tgz"));
215 compress_capture_dir(&capture_dir, &archive_path, options.compression_level)?;
216 fs::remove_dir_all(&capture_dir).context(format!(
217 "Failed to remove rollback staging directory '{}'",
218 capture_dir.display()
219 ))?;
220
221 Ok(RollbackRecord {
222 package_snapshot: package.clone(),
223 artifact_relative_path: path_relative_to(&paths.install.rollback_dir, &archive_path)?,
224 icon_relative_path: None,
225 artifact_format: RollbackArtifactFormat::Tgz,
226 artifact_entry_path: Some(artifact_entry_path),
227 icon_entry_path,
228 source,
229 created_at,
230 })
231 }
232
233 pub fn restore_package<H>(
234 &mut self,
235 package_name: &str,
236 message_callback: &mut Option<H>,
237 ) -> Result<()>
238 where
239 H: FnMut(&str),
240 {
241 let Some(record) = self.rollback_storage.get_record(package_name).cloned() else {
242 return Err(anyhow!("No rollback data found for '{}'", package_name));
243 };
244
245 if let Some(current) = self
246 .package_storage
247 .get_package_by_name(package_name)
248 .cloned()
249 {
250 message!(
251 message_callback,
252 "Removing current installation for '{}' before rollback ...",
253 package_name
254 );
255 let remover = PackageRemover::new(self.paths);
256 remover.remove_package_files(¤t, message_callback)?;
257 self.package_storage.remove_package_by_name(package_name)?;
258 self.metadata_storage.remove_package(package_name)?;
259 }
260
261 let target_install_path = record
262 .package_snapshot
263 .install_path
264 .as_ref()
265 .ok_or_else(|| {
266 anyhow!(
267 "Rollback snapshot for '{}' has no install path",
268 package_name
269 )
270 })?
271 .clone();
272 if let Some(parent) = target_install_path.parent() {
273 fs::create_dir_all(parent).context(format!(
274 "Failed to create install parent '{}'",
275 parent.display()
276 ))?;
277 }
278
279 message!(
280 message_callback,
281 "Restoring rollback artifact for '{}' ...",
282 package_name
283 );
284 let extracted_dir = match record.artifact_format {
285 RollbackArtifactFormat::Raw => None,
286 RollbackArtifactFormat::Tgz => {
287 Some(extract_record_archive(self.paths, package_name, &record)?)
288 }
289 };
290 let source_path =
291 record_artifact_source_path(self.paths, &record, extracted_dir.as_deref())?;
292 if !source_path.exists() {
293 return Err(anyhow!(
294 "Rollback artifact is missing for '{}': {}",
295 package_name,
296 source_path.display()
297 ));
298 }
299
300 safe_move::move_file_or_dir(&source_path, &target_install_path)?;
301
302 let icon_source = record_icon_source_path(self.paths, &record, extracted_dir.as_deref())?;
303 if let (Some(icon_source), Some(icon_target)) = (
304 icon_source.as_ref(),
305 record.package_snapshot.icon_path.as_ref(),
306 ) && icon_source.exists()
307 {
308 if let Some(parent) = icon_target.parent() {
309 fs::create_dir_all(parent).context(format!(
310 "Failed to create icon parent '{}'",
311 parent.display()
312 ))?;
313 }
314 fs::copy(icon_source, icon_target).context(format!(
315 "Failed to restore icon from '{}' to '{}'",
316 icon_source.display(),
317 icon_target.display()
318 ))?;
319 }
320
321 self.package_storage
322 .add_or_update_package(record.package_snapshot.clone())?;
323 let remover = PackageRemover::new(self.paths);
324 remover.restore_runtime_integrations(&record.package_snapshot, message_callback)?;
325
326 self.rollback_storage.remove_record(package_name)?;
327 delete_record_artifacts(self.paths, package_name, &record)?;
328 if let Some(extracted_dir) = extracted_dir
329 && extracted_dir.exists()
330 {
331 let _ = fs::remove_dir_all(extracted_dir);
332 }
333
334 Ok(())
335 }
336
337 pub fn prune_package(&mut self, package_name: &str) -> Result<bool> {
338 let removed = self.rollback_storage.remove_all_records(package_name)?;
339 for record in &removed {
340 delete_record_artifacts(self.paths, package_name, record)?;
341 }
342 cleanup_empty_package_rollback_dir(self.paths, package_name)?;
343 Ok(!removed.is_empty())
344 }
345
346 pub fn rollback_packages(&self) -> Vec<String> {
347 let mut names: Vec<String> = self
348 .rollback_storage
349 .list_records()
350 .keys()
351 .cloned()
352 .collect();
353 names.sort();
354 names
355 }
356
357 pub fn rollback_record(&self, package_name: &str) -> Option<&RollbackRecord> {
358 self.rollback_storage.get_record(package_name)
359 }
360
361 pub fn estimate_restore_impact(&self, package_name: &str) -> Option<DiskImpact> {
362 self.rollback_storage.get_record(package_name)?;
363 let current_size = self
364 .package_storage
365 .get_package_by_name(package_name)
366 .map(|package| {
367 PackageRemover::new(self.paths)
368 .estimate_active_size(package)
369 .unwrap_or(0)
370 })
371 .unwrap_or(0);
372 Some(DiskImpact {
373 download: ByteEstimate::exact(0),
374 net: SignedByteEstimate::exact(-i128::from(current_size)),
375 })
376 }
377
378 pub fn estimate_prune_impact(&self, package_name: &str) -> Option<DiskImpact> {
379 self.rollback_storage.get_record(package_name)?;
380 let rollback_dir_size =
381 estimate_path_size(&self.paths.install.rollback_dir.join(package_name)).unwrap_or(0);
382
383 Some(DiskImpact {
384 download: ByteEstimate::exact(0),
385 net: SignedByteEstimate::exact(-i128::from(rollback_dir_size)),
386 })
387 }
388}
389
390fn path_relative_to(base: &Path, full: &Path) -> Result<PathBuf> {
391 full.strip_prefix(base).map(Path::to_path_buf).map_err(|_| {
392 anyhow!(
393 "Path '{}' is not under '{}'",
394 full.display(),
395 base.display()
396 )
397 })
398}
399
400fn effective_stored_artifacts(config: &RollbackConfig) -> usize {
401 config.stored_artifacts.max(1) as usize
402}
403
404fn rollback_capture_id(source: &RollbackSource) -> String {
405 let source_label = match source {
406 RollbackSource::Upgrade => "upgrade",
407 RollbackSource::Reinstall => "reinstall",
408 RollbackSource::Remove => "remove",
409 };
410 let timestamp = Utc::now()
411 .timestamp_nanos_opt()
412 .unwrap_or_else(|| Utc::now().timestamp_micros() * 1_000);
413 format!("{timestamp}-{source_label}")
414}
415
416fn capture_icon(
417 paths: &UpstreamPaths,
418 package: &Package,
419 capture_dir: &Path,
420) -> Result<Option<PathBuf>> {
421 let Some(icon_path) = package.icon_path.as_ref() else {
422 return Ok(None);
423 };
424 if !icon_path.exists() {
425 return Ok(None);
426 }
427
428 let icon_name = icon_path
429 .file_name()
430 .ok_or_else(|| anyhow!("Icon path '{}' has no file name", icon_path.display()))?;
431 let icon_entry_path =
432 PathBuf::from("icon").join(format!("icon-{}", icon_name.to_string_lossy()));
433 let icon_backup = capture_dir.join(&icon_entry_path);
434 if let Some(parent) = icon_backup.parent() {
435 fs::create_dir_all(parent).context(format!(
436 "Failed to create rollback icon parent '{}'",
437 parent.display()
438 ))?;
439 }
440 fs::copy(icon_path, &icon_backup).context(format!(
441 "Failed to copy icon '{}' to '{}'",
442 icon_path.display(),
443 icon_backup.display()
444 ))?;
445
446 path_relative_to(&paths.install.rollback_dir, &icon_backup)?;
447 Ok(Some(icon_entry_path))
448}
449
450fn gzip_level(level: CompressionLevel) -> Compression {
451 match level {
452 CompressionLevel::None => Compression::none(),
453 CompressionLevel::Low => Compression::fast(),
454 CompressionLevel::High => Compression::best(),
455 }
456}
457
458fn compress_capture_dir(
459 capture_dir: &Path,
460 archive_path: &Path,
461 level: CompressionLevel,
462) -> Result<()> {
463 let archive_file = File::create(archive_path).with_context(|| {
464 format!(
465 "Failed to create rollback archive '{}'",
466 archive_path.display()
467 )
468 })?;
469 let encoder = GzEncoder::new(archive_file, gzip_level(level));
470 let mut builder = Builder::new(encoder);
471
472 append_capture_entry(&mut builder, capture_dir, Path::new("artifact"))?;
473 let icon_dir = capture_dir.join("icon");
474 if icon_dir.exists() {
475 append_capture_entry(&mut builder, capture_dir, Path::new("icon"))?;
476 }
477
478 let encoder = builder
479 .into_inner()
480 .context("Failed to finish rollback tar archive")?;
481 encoder
482 .finish()
483 .context("Failed to finish rollback gzip archive")?;
484 Ok(())
485}
486
487fn append_capture_entry(
488 builder: &mut Builder<GzEncoder<File>>,
489 capture_dir: &Path,
490 entry: &Path,
491) -> Result<()> {
492 let full_path = capture_dir.join(entry);
493 if full_path.is_dir() {
494 builder
495 .append_dir_all(entry, &full_path)
496 .with_context(|| format!("Failed to archive '{}'", full_path.display()))?;
497 } else if full_path.is_file() {
498 builder
499 .append_path_with_name(&full_path, entry)
500 .with_context(|| format!("Failed to archive '{}'", full_path.display()))?;
501 }
502 Ok(())
503}
504
505fn extract_record_archive(
506 paths: &UpstreamPaths,
507 package_name: &str,
508 record: &RollbackRecord,
509) -> Result<PathBuf> {
510 let archive_path = paths
511 .install
512 .rollback_dir
513 .join(&record.artifact_relative_path);
514 if !archive_path.exists() {
515 return Err(anyhow!(
516 "Rollback archive is missing for '{}': {}",
517 package_name,
518 archive_path.display()
519 ));
520 }
521
522 let extract_dir = paths.install.rollback_dir.join(format!(
523 ".restore-{}-{}",
524 package_name,
525 std::process::id()
526 ));
527 if extract_dir.exists() {
528 fs::remove_dir_all(&extract_dir).context(format!(
529 "Failed to clear rollback extraction directory '{}'",
530 extract_dir.display()
531 ))?;
532 }
533 fs::create_dir_all(&extract_dir).context(format!(
534 "Failed to create rollback extraction directory '{}'",
535 extract_dir.display()
536 ))?;
537
538 let archive_file = File::open(&archive_path).with_context(|| {
539 format!(
540 "Failed to open rollback archive '{}'",
541 archive_path.display()
542 )
543 })?;
544 let decoder = GzDecoder::new(archive_file);
545 let mut archive = Archive::new(decoder);
546 for entry in archive
547 .entries()
548 .context("Failed to read rollback archive entries")?
549 {
550 let mut entry = entry.context("Failed to read rollback archive entry")?;
551 let entry_path = entry
552 .path()
553 .context("Failed to read rollback archive entry path")?
554 .into_owned();
555 if !is_safe_archive_entry(&entry_path) {
556 return Err(anyhow!(
557 "Rollback archive contains unsafe path '{}'",
558 entry_path.display()
559 ));
560 }
561 entry.unpack_in(&extract_dir).with_context(|| {
562 format!(
563 "Failed to extract rollback archive entry '{}' into '{}'",
564 entry_path.display(),
565 extract_dir.display()
566 )
567 })?;
568 }
569
570 Ok(extract_dir)
571}
572
573fn is_safe_archive_entry(path: &Path) -> bool {
574 path.is_relative()
575 && !path
576 .components()
577 .any(|component| matches!(component, std::path::Component::ParentDir))
578}
579
580fn record_artifact_source_path(
581 paths: &UpstreamPaths,
582 record: &RollbackRecord,
583 extracted_dir: Option<&Path>,
584) -> Result<PathBuf> {
585 match record.artifact_format {
586 RollbackArtifactFormat::Raw => Ok(paths
587 .install
588 .rollback_dir
589 .join(&record.artifact_relative_path)),
590 RollbackArtifactFormat::Tgz => {
591 let extract_dir =
592 extracted_dir.ok_or_else(|| anyhow!("Rollback archive was not extracted"))?;
593 let entry = record
594 .artifact_entry_path
595 .as_ref()
596 .ok_or_else(|| anyhow!("Rollback archive record is missing artifact entry path"))?;
597 Ok(extract_dir.join(entry))
598 }
599 }
600}
601
602fn record_icon_source_path(
603 paths: &UpstreamPaths,
604 record: &RollbackRecord,
605 extracted_dir: Option<&Path>,
606) -> Result<Option<PathBuf>> {
607 match record.artifact_format {
608 RollbackArtifactFormat::Raw => Ok(record
609 .icon_relative_path
610 .as_ref()
611 .map(|path| paths.install.rollback_dir.join(path))),
612 RollbackArtifactFormat::Tgz => {
613 let Some(entry) = record.icon_entry_path.as_ref() else {
614 return Ok(None);
615 };
616 let extract_dir =
617 extracted_dir.ok_or_else(|| anyhow!("Rollback archive was not extracted"))?;
618 Ok(Some(extract_dir.join(entry)))
619 }
620 }
621}
622
623fn delete_record_artifacts(
624 paths: &UpstreamPaths,
625 package_name: &str,
626 record: &RollbackRecord,
627) -> Result<()> {
628 match record.artifact_format {
629 RollbackArtifactFormat::Raw => {
630 let artifact_path = paths
631 .install
632 .rollback_dir
633 .join(&record.artifact_relative_path);
634 remove_file_or_dir_if_exists(&artifact_path)?;
635 if let Some(icon_path) = record.icon_relative_path.as_ref() {
636 let icon_path = paths.install.rollback_dir.join(icon_path);
637 remove_file_or_dir_if_exists(&icon_path)?;
638 cleanup_empty_rollback_ancestors(
639 &paths.install.rollback_dir.join(package_name),
640 icon_path.parent(),
641 )?;
642 }
643 cleanup_empty_rollback_ancestors(
644 &paths.install.rollback_dir.join(package_name),
645 artifact_path.parent(),
646 )?;
647 }
648 RollbackArtifactFormat::Tgz => {
649 remove_file_or_dir_if_exists(
650 &paths
651 .install
652 .rollback_dir
653 .join(&record.artifact_relative_path),
654 )?;
655 }
656 }
657 cleanup_empty_package_rollback_dir(paths, package_name)
658}
659
660fn cleanup_empty_rollback_ancestors(package_dir: &Path, start: Option<&Path>) -> Result<()> {
661 let Some(mut current) = start else {
662 return Ok(());
663 };
664 while current.starts_with(package_dir) && current != package_dir {
665 if current.exists()
666 && current
667 .read_dir()
668 .map(|mut entries| entries.next().is_none())
669 .unwrap_or(false)
670 {
671 fs::remove_dir(current).with_context(|| {
672 format!(
673 "Failed to remove empty rollback directory '{}'",
674 current.display()
675 )
676 })?;
677 }
678 let Some(parent) = current.parent() else {
679 break;
680 };
681 current = parent;
682 }
683 Ok(())
684}
685
686fn cleanup_empty_package_rollback_dir(paths: &UpstreamPaths, package_name: &str) -> Result<()> {
687 let package_dir = paths.install.rollback_dir.join(package_name);
688 if package_dir.exists()
689 && package_dir
690 .read_dir()
691 .map(|mut entries| entries.next().is_none())
692 .unwrap_or(false)
693 {
694 fs::remove_dir(&package_dir).context(format!(
695 "Failed to remove empty rollback directory '{}'",
696 package_dir.display()
697 ))?;
698 }
699 Ok(())
700}
701
702fn remove_file_or_dir_if_exists(path: &Path) -> Result<()> {
703 if path.is_dir() {
704 fs::remove_dir_all(path)
705 .with_context(|| format!("Failed to remove directory '{}'", path.display()))?;
706 } else if path.is_file() {
707 fs::remove_file(path)
708 .with_context(|| format!("Failed to remove file '{}'", path.display()))?;
709 }
710 Ok(())
711}
712
713#[cfg(test)]
714mod tests {
715 use super::RollbackManager;
716 use crate::models::common::enums::{Channel, Filetype, Provider};
717 use crate::models::upstream::Package;
718 use crate::services::storage::rollback_storage::{RollbackArtifactFormat, RollbackSource};
719 use crate::services::storage::{
720 metadata_storage::MetadataStorage, package_storage::PackageStorage,
721 rollback_storage::RollbackStorage,
722 };
723 use crate::utils::test_support;
724 use std::fs;
725 use std::io;
726 use std::path::{Path, PathBuf};
727
728 fn temp_root(name: &str) -> PathBuf {
729 test_support::temp_root("upstream-rollback-manager-test", name)
730 }
731
732 fn cleanup(path: &Path) -> io::Result<()> {
733 fs::remove_dir_all(path)
734 }
735
736 fn write_rollback_config(root: &Path, compression_level: &str, stored_artifacts: u32) {
737 let paths = test_support::upstream_paths(root);
738 fs::create_dir_all(paths.config.config_file.parent().expect("config parent"))
739 .expect("create config parent");
740 fs::write(
741 &paths.config.config_file,
742 format!(
743 "[rollback]\ncompression_level = \"{compression_level}\"\nstored_artifacts = {stored_artifacts}\n"
744 ),
745 )
746 .expect("write rollback config");
747 }
748
749 fn test_package(root: &Path, name: &str) -> Package {
750 let paths = test_support::upstream_paths(root);
751 let mut package = Package::with_defaults(
752 name.to_string(),
753 format!("owner/{name}"),
754 Filetype::Binary,
755 None,
756 None,
757 Channel::Stable,
758 Provider::Github,
759 None,
760 );
761 package.install_path = Some(paths.install.binaries_dir.join(name));
762 package
763 }
764
765 #[test]
766 fn capture_from_installed_retains_multiple_compressed_artifacts() {
767 let root = temp_root("compressed-retention");
768 write_rollback_config(&root, "low", 2);
769 let paths = test_support::upstream_paths(&root);
770 let mut package_storage =
771 PackageStorage::new(&paths.config.packages_file).expect("package storage");
772 let mut metadata_storage =
773 MetadataStorage::new(&paths.config.metadata_file).expect("metadata storage");
774 let rollback_file = RollbackManager::rollback_file_path(&paths);
775 let mut rollback_storage = RollbackStorage::new(&rollback_file).expect("rollback storage");
776 let package = test_package(&root, "tool");
777 let install_path = package.install_path.as_ref().expect("install path");
778 fs::create_dir_all(install_path.parent().expect("install parent"))
779 .expect("create install parent");
780
781 {
782 let mut manager = RollbackManager::new(
783 &paths,
784 &mut package_storage,
785 &mut metadata_storage,
786 &mut rollback_storage,
787 );
788 for contents in ["one", "two", "three"] {
789 fs::write(install_path, contents).expect("write install artifact");
790 manager
791 .capture_from_installed(
792 &package,
793 RollbackSource::Upgrade,
794 &mut None::<fn(&str)>,
795 )
796 .expect("capture rollback");
797 }
798 }
799
800 let records = rollback_storage.get_records("tool");
801 assert_eq!(records.len(), 2);
802 assert!(
803 records
804 .iter()
805 .all(|record| record.artifact_format == RollbackArtifactFormat::Tgz)
806 );
807 assert!(records.iter().all(|record| {
808 record
809 .artifact_relative_path
810 .extension()
811 .is_some_and(|extension| extension == "tgz")
812 }));
813 assert_eq!(
814 fs::read_dir(paths.install.rollback_dir.join("tool"))
815 .expect("rollback package dir")
816 .count(),
817 2
818 );
819
820 cleanup(&root).expect("cleanup");
821 }
822
823 #[test]
824 fn restore_package_decompresses_tgz_artifact() {
825 let root = temp_root("compressed-restore");
826 write_rollback_config(&root, "high", 1);
827 let paths = test_support::upstream_paths(&root);
828 let mut package_storage =
829 PackageStorage::new(&paths.config.packages_file).expect("package storage");
830 let mut metadata_storage =
831 MetadataStorage::new(&paths.config.metadata_file).expect("metadata storage");
832 let rollback_file = RollbackManager::rollback_file_path(&paths);
833 let mut rollback_storage = RollbackStorage::new(&rollback_file).expect("rollback storage");
834 let package = test_package(&root, "tool");
835 let install_path = package.install_path.as_ref().expect("install path").clone();
836 fs::create_dir_all(install_path.parent().expect("install parent"))
837 .expect("create install parent");
838 fs::write(&install_path, "before-upgrade").expect("write install artifact");
839
840 {
841 let mut manager = RollbackManager::new(
842 &paths,
843 &mut package_storage,
844 &mut metadata_storage,
845 &mut rollback_storage,
846 );
847 manager
848 .capture_from_installed(&package, RollbackSource::Upgrade, &mut None::<fn(&str)>)
849 .expect("capture rollback");
850 assert!(!install_path.exists());
851 assert_eq!(
852 manager
853 .rollback_record("tool")
854 .expect("record")
855 .artifact_format,
856 RollbackArtifactFormat::Tgz
857 );
858
859 manager
860 .restore_package("tool", &mut None::<fn(&str)>)
861 .expect("restore rollback");
862 }
863
864 assert_eq!(
865 fs::read_to_string(&install_path).expect("restored artifact"),
866 "before-upgrade"
867 );
868 assert!(rollback_storage.get_record("tool").is_none());
869
870 cleanup(&root).expect("cleanup");
871 }
872}