Skip to main content

upstream_rs/services/packaging/
rollback_manager.rs

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(&current, 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}