Skip to main content

upstream_rs/
migrate.rs

1use std::collections::HashMap;
2use std::fs;
3use std::io;
4use std::path::{Path, PathBuf};
5
6use anyhow::{Context, Result, anyhow};
7use serde::{Deserialize, Serialize};
8
9use crate::models::upstream::Package;
10use crate::models::upstream::app_config::CONFIG_STORAGE_VERSION;
11use crate::services::integration::SymlinkManager;
12use crate::services::storage::manifest_storage::{CURRENT_LAYOUT_VERSION, ManifestStorage};
13use crate::services::storage::rollback_storage::RollbackRecord;
14use crate::services::storage::trust_storage::TrustStorage;
15use crate::services::trust::{CosignPublicKey, MinisignPublicKey};
16use crate::utils::filesystem::{atomic_ops::write_atomic, safe_move};
17use crate::utils::static_paths::UpstreamPaths;
18
19const PACKAGE_STORAGE_VERSION: u32 = 1;
20const ROLLBACK_STORAGE_VERSION: u32 = 1;
21
22#[derive(Debug, Default, Clone, PartialEq, Eq)]
23pub struct MigrationReport {
24    pub created_dirs: usize,
25    pub moved_entries: usize,
26    pub updated_packages: usize,
27    pub updated_rollback_records: usize,
28    pub migrated_trusted_keys: usize,
29    pub deduped_trusted_keys: usize,
30    pub refreshed_symlinks: usize,
31    pub skipped_symlinks: usize,
32}
33
34#[derive(Debug, Clone)]
35struct PathRewrite {
36    old: PathBuf,
37    new: PathBuf,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
41struct PackageStorageFile {
42    version: u32,
43    packages: Vec<Package>,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
47struct RollbackStorageFile {
48    version: u32,
49    records: HashMap<String, Vec<RollbackRecord>>,
50}
51
52#[derive(Debug, Clone, Deserialize)]
53struct LegacyRollbackStorageFile {
54    version: u32,
55    records: HashMap<String, RollbackRecord>,
56}
57
58#[derive(Debug, Clone, Deserialize, Default)]
59#[serde(default)]
60struct LegacyTrustConfig {
61    minisign_public_keys: Vec<MinisignPublicKey>,
62    cosign_public_keys: Vec<CosignPublicKey>,
63}
64
65pub fn run(paths: &UpstreamPaths) -> Result<MigrationReport> {
66    let rewrites = package_path_rewrites(paths);
67    let legacy_layout_detected = legacy_package_dirs_exist(&rewrites);
68    let mut manifest_storage =
69        ManifestStorage::new(&ManifestStorage::path_for_root(&paths.dirs.data_dir))?;
70    let previous_layout_version = manifest_storage
71        .manifest()
72        .map(|manifest| manifest.layout_version)
73        .or_else(|| legacy_layout_detected.then_some(1));
74    let mut report = MigrationReport::default();
75
76    create_required_dirs(paths, &mut report)?;
77    move_legacy_package_dirs(&rewrites, &mut report)?;
78    let packages = migrate_package_metadata(paths, &rewrites, &mut report)?;
79    migrate_rollback_metadata(paths, &rewrites, &mut report)?;
80    migrate_trust_config(paths, &mut report)?;
81    refresh_symlinks(paths, &packages, &mut report)?;
82    manifest_storage.record_migration_from(previous_layout_version, CURRENT_LAYOUT_VERSION)?;
83
84    Ok(report)
85}
86
87fn migrate_trust_config(paths: &UpstreamPaths, report: &mut MigrationReport) -> Result<()> {
88    let mut trust_storage = TrustStorage::new(&paths.config.trust_file)?;
89
90    if !paths.config.config_file.exists() {
91        trust_storage.ensure_exists()?;
92        return Ok(());
93    }
94
95    let raw_config = fs::read_to_string(&paths.config.config_file).with_context(|| {
96        format!(
97            "Failed to read config '{}'",
98            paths.config.config_file.display()
99        )
100    })?;
101    if raw_config.trim().is_empty() {
102        trust_storage.ensure_exists()?;
103        return Ok(());
104    }
105
106    let mut config_value: toml::Value = toml::from_str(&raw_config).with_context(|| {
107        format!(
108            "Failed to parse config '{}'",
109            paths.config.config_file.display()
110        )
111    })?;
112    let config_table = config_value.as_table_mut().ok_or_else(|| {
113        anyhow!(
114            "Config '{}' must be a TOML table",
115            paths.config.config_file.display()
116        )
117    })?;
118
119    let mut changed_config = false;
120    if let Some(trust_value) = config_table.remove("trust") {
121        let legacy_trust: LegacyTrustConfig = trust_value
122            .try_into()
123            .context("Failed to parse legacy config trust keys")?;
124        let summary = trust_storage.merge_trusted_keys(
125            &legacy_trust.minisign_public_keys,
126            &legacy_trust.cosign_public_keys,
127        )?;
128        report.migrated_trusted_keys += summary.minisign.imported + summary.cosign.imported;
129        report.deduped_trusted_keys += summary.minisign.deduped + summary.cosign.deduped;
130        changed_config = true;
131    } else {
132        trust_storage.ensure_exists()?;
133    }
134
135    let version = config_table
136        .get("version")
137        .and_then(toml::Value::as_integer);
138    if version != Some(i64::from(CONFIG_STORAGE_VERSION)) {
139        config_table.insert(
140            "version".to_string(),
141            toml::Value::Integer(i64::from(CONFIG_STORAGE_VERSION)),
142        );
143        changed_config = true;
144    }
145
146    if changed_config {
147        let rendered =
148            toml::to_string_pretty(&config_value).context("Failed to serialize migrated config")?;
149        write_atomic(&paths.config.config_file, rendered.as_bytes()).with_context(|| {
150            format!(
151                "Failed to write migrated config '{}'",
152                paths.config.config_file.display()
153            )
154        })?;
155    }
156
157    Ok(())
158}
159
160fn create_required_dirs(paths: &UpstreamPaths, report: &mut MigrationReport) -> Result<()> {
161    for dir in [
162        paths.dirs.config_dir.as_path(),
163        paths.dirs.data_dir.as_path(),
164        paths.dirs.packages_dir.as_path(),
165        paths.dirs.cache_dir.as_path(),
166        paths.dirs.metadata_dir.as_path(),
167        paths.install.appimages_dir.as_path(),
168        paths.install.binaries_dir.as_path(),
169        paths.install.archives_dir.as_path(),
170        paths.install.rollback_dir.as_path(),
171        paths.install.tmp_dir.as_path(),
172        paths.integration.icons_dir.as_path(),
173        paths.integration.symlinks_dir.as_path(),
174    ] {
175        if !dir.exists() {
176            report.created_dirs += 1;
177        }
178        fs::create_dir_all(dir)
179            .with_context(|| format!("Failed to create directory '{}'", dir.display()))?;
180    }
181    Ok(())
182}
183
184fn package_path_rewrites(paths: &UpstreamPaths) -> Vec<PathRewrite> {
185    vec![
186        PathRewrite {
187            old: paths.dirs.data_dir.join("appimages"),
188            new: paths.install.appimages_dir.clone(),
189        },
190        PathRewrite {
191            old: paths.dirs.data_dir.join("binaries"),
192            new: paths.install.binaries_dir.clone(),
193        },
194        PathRewrite {
195            old: paths.dirs.data_dir.join("archives"),
196            new: paths.install.archives_dir.clone(),
197        },
198    ]
199}
200
201fn legacy_package_dirs_exist(rewrites: &[PathRewrite]) -> bool {
202    rewrites.iter().any(|rewrite| rewrite.old.exists())
203}
204
205fn move_legacy_package_dirs(rewrites: &[PathRewrite], report: &mut MigrationReport) -> Result<()> {
206    for rewrite in rewrites {
207        if !rewrite.old.exists() {
208            continue;
209        }
210        move_into_layout(&rewrite.old, &rewrite.new, report).with_context(|| {
211            format!(
212                "Failed to migrate '{}' to '{}'",
213                rewrite.old.display(),
214                rewrite.new.display()
215            )
216        })?;
217    }
218    Ok(())
219}
220
221fn move_into_layout(src: &Path, dst: &Path, report: &mut MigrationReport) -> Result<()> {
222    if paths_are_same(src, dst)? {
223        return Ok(());
224    }
225
226    if !dst.exists() {
227        if let Some(parent) = dst.parent() {
228            fs::create_dir_all(parent)
229                .with_context(|| format!("Failed to create directory '{}'", parent.display()))?;
230        }
231        safe_move::move_file_or_dir(src, dst)?;
232        report.moved_entries += 1;
233        return Ok(());
234    }
235
236    merge_directory_contents(src, dst, report)?;
237    remove_dir_if_empty(src)?;
238    Ok(())
239}
240
241fn merge_directory_contents(src: &Path, dst: &Path, report: &mut MigrationReport) -> Result<()> {
242    for entry in fs::read_dir(src)
243        .with_context(|| format!("Failed to read directory '{}'", src.display()))?
244    {
245        let entry =
246            entry.with_context(|| format!("Failed to read entry in '{}'", src.display()))?;
247        let from = entry.path();
248        let to = dst.join(entry.file_name());
249        let file_type = entry
250            .file_type()
251            .with_context(|| format!("Failed to inspect '{}'", from.display()))?;
252
253        if to.exists() {
254            if file_type.is_dir() && to.is_dir() {
255                merge_directory_contents(&from, &to, report)?;
256                remove_dir_if_empty(&from)?;
257                continue;
258            }
259            return Err(anyhow!(
260                "Refusing to overwrite existing migrated path '{}'",
261                to.display()
262            ));
263        }
264
265        safe_move::move_file_or_dir(&from, &to)?;
266        report.moved_entries += 1;
267    }
268    Ok(())
269}
270
271fn remove_dir_if_empty(path: &Path) -> Result<()> {
272    if path.exists()
273        && path
274            .read_dir()
275            .map(|mut entries| entries.next().is_none())
276            .unwrap_or(false)
277    {
278        fs::remove_dir(path)
279            .with_context(|| format!("Failed to remove empty directory '{}'", path.display()))?;
280    }
281    Ok(())
282}
283
284fn paths_are_same(a: &Path, b: &Path) -> io::Result<bool> {
285    if !a.exists() || !b.exists() {
286        return Ok(false);
287    }
288    Ok(fs::canonicalize(a)? == fs::canonicalize(b)?)
289}
290
291fn migrate_package_metadata(
292    paths: &UpstreamPaths,
293    rewrites: &[PathRewrite],
294    report: &mut MigrationReport,
295) -> Result<Vec<Package>> {
296    if !paths.config.packages_file.exists() {
297        return Ok(Vec::new());
298    }
299
300    let json = fs::read_to_string(&paths.config.packages_file).with_context(|| {
301        format!(
302            "Failed to read package metadata '{}'",
303            paths.config.packages_file.display()
304        )
305    })?;
306    if json.trim().is_empty() {
307        return Ok(Vec::new());
308    }
309
310    let mut storage: PackageStorageFile = serde_json::from_str(&json).with_context(|| {
311        format!(
312            "Failed to parse package metadata '{}'",
313            paths.config.packages_file.display()
314        )
315    })?;
316    if storage.version != PACKAGE_STORAGE_VERSION {
317        return Err(anyhow!(
318            "Unsupported package storage version {} in '{}'. Expected version {}.",
319            storage.version,
320            paths.config.packages_file.display(),
321            PACKAGE_STORAGE_VERSION
322        ));
323    }
324
325    let mut changed = false;
326    for package in &mut storage.packages {
327        let package_changed = rewrite_package_paths(package, rewrites);
328        if package_changed {
329            changed = true;
330            report.updated_packages += 1;
331        }
332    }
333
334    if changed {
335        write_json(&paths.config.packages_file, &storage)?;
336    }
337
338    Ok(storage.packages)
339}
340
341fn migrate_rollback_metadata(
342    paths: &UpstreamPaths,
343    rewrites: &[PathRewrite],
344    report: &mut MigrationReport,
345) -> Result<()> {
346    let rollback_file = paths.dirs.metadata_dir.join("rollback.json");
347    if !rollback_file.exists() {
348        return Ok(());
349    }
350
351    let json = fs::read_to_string(&rollback_file).with_context(|| {
352        format!(
353            "Failed to read rollback metadata '{}'",
354            rollback_file.display()
355        )
356    })?;
357    if json.trim().is_empty() {
358        return Ok(());
359    }
360
361    let mut storage: RollbackStorageFile = serde_json::from_str(&json)
362        .or_else(|_| parse_legacy_rollback_storage(&json))
363        .with_context(|| {
364            format!(
365                "Failed to parse rollback metadata '{}'",
366                rollback_file.display()
367            )
368        })?;
369    if storage.version != ROLLBACK_STORAGE_VERSION {
370        return Err(anyhow!(
371            "Unsupported rollback storage version {} in '{}'. Expected version {}.",
372            storage.version,
373            rollback_file.display(),
374            ROLLBACK_STORAGE_VERSION
375        ));
376    }
377
378    let mut changed = false;
379    for records in storage.records.values_mut() {
380        for record in records {
381            if rewrite_package_paths(&mut record.package_snapshot, rewrites) {
382                changed = true;
383                report.updated_rollback_records += 1;
384            }
385        }
386    }
387
388    if changed {
389        write_json(&rollback_file, &storage)?;
390    }
391
392    Ok(())
393}
394
395fn parse_legacy_rollback_storage(json: &str) -> serde_json::Result<RollbackStorageFile> {
396    let legacy: LegacyRollbackStorageFile = serde_json::from_str(json)?;
397    Ok(RollbackStorageFile {
398        version: legacy.version,
399        records: legacy
400            .records
401            .into_iter()
402            .map(|(name, record)| (name, vec![record]))
403            .collect(),
404    })
405}
406
407fn rewrite_package_paths(package: &mut Package, rewrites: &[PathRewrite]) -> bool {
408    let mut changed = false;
409    changed |= rewrite_optional_path(&mut package.install_path, rewrites);
410    changed |= rewrite_optional_path(&mut package.exec_path, rewrites);
411    changed
412}
413
414fn rewrite_optional_path(path: &mut Option<PathBuf>, rewrites: &[PathRewrite]) -> bool {
415    let Some(current) = path.as_ref() else {
416        return false;
417    };
418
419    for rewrite in rewrites {
420        if let Ok(relative) = current.strip_prefix(&rewrite.old) {
421            *path = Some(rewrite.new.join(relative));
422            return true;
423        }
424    }
425
426    false
427}
428
429fn refresh_symlinks(
430    paths: &UpstreamPaths,
431    packages: &[Package],
432    report: &mut MigrationReport,
433) -> Result<()> {
434    let symlink_manager = SymlinkManager::new(&paths.integration.symlinks_dir);
435
436    for package in packages {
437        let target = package.exec_path.as_ref().or(package.install_path.as_ref());
438        let Some(target) = target else {
439            report.skipped_symlinks += 1;
440            continue;
441        };
442        if !target.exists() {
443            report.skipped_symlinks += 1;
444            continue;
445        }
446
447        symlink_manager
448            .add_link(target, &package.name)
449            .with_context(|| format!("Failed to refresh symlink for '{}'", package.name))?;
450        report.refreshed_symlinks += 1;
451    }
452
453    Ok(())
454}
455
456fn write_json<T: Serialize>(path: &Path, value: &T) -> Result<()> {
457    let json = serde_json::to_string_pretty(value).context("Failed to serialize migration data")?;
458    write_atomic(path, json.as_bytes())
459        .with_context(|| format!("Failed to write '{}'", path.display()))
460}
461
462#[cfg(test)]
463mod tests {
464    use super::run;
465    use crate::models::common::enums::{Channel, Filetype, Provider};
466    use crate::models::upstream::Package;
467    use crate::services::storage::manifest_storage::{
468        CURRENT_LAYOUT_VERSION, MANIFEST_STORAGE_VERSION, ManifestStorage,
469    };
470    use crate::services::storage::rollback_storage::{
471        RollbackArtifactFormat, RollbackRecord, RollbackSource,
472    };
473    use crate::utils::test_support;
474    use chrono::Utc;
475    use serde_json::json;
476    use std::path::{Path, PathBuf};
477    use std::{fs, io};
478
479    fn temp_root(name: &str) -> PathBuf {
480        test_support::temp_root("upstream-migrate-test", name)
481    }
482
483    fn cleanup(path: &Path) -> io::Result<()> {
484        fs::remove_dir_all(path)
485    }
486
487    fn test_package(name: &str, install_path: PathBuf, exec_path: PathBuf) -> Package {
488        let mut package = Package::with_defaults(
489            name.to_string(),
490            format!("owner/{name}"),
491            Filetype::Binary,
492            None,
493            None,
494            Channel::Stable,
495            Provider::Github,
496            None,
497        );
498        package.install_path = Some(install_path);
499        package.exec_path = Some(exec_path);
500        package
501    }
502
503    #[test]
504    fn migrate_moves_package_dirs_and_rewrites_metadata() {
505        let root = temp_root("layout");
506        let paths = test_support::upstream_paths(&root);
507        let old_binary = paths.dirs.data_dir.join("binaries").join("tool");
508        let new_binary = paths.dirs.packages_dir.join("binaries").join("tool");
509        fs::create_dir_all(old_binary.parent().expect("old binary parent"))
510            .expect("create old binary parent");
511        fs::write(&old_binary, b"tool").expect("write old binary");
512
513        #[cfg(unix)]
514        {
515            fs::create_dir_all(&paths.integration.symlinks_dir).expect("create symlinks");
516            std::os::unix::fs::symlink(&old_binary, paths.integration.symlinks_dir.join("tool"))
517                .expect("create old symlink");
518        }
519
520        let package = test_package("tool", old_binary.clone(), old_binary.clone());
521        fs::create_dir_all(&paths.dirs.metadata_dir).expect("create metadata");
522        fs::write(
523            &paths.config.packages_file,
524            serde_json::to_vec_pretty(&json!({
525                "version": 1,
526                "packages": [package],
527            }))
528            .expect("serialize packages"),
529        )
530        .expect("write packages");
531
532        let report = run(&paths).expect("migrate");
533
534        assert!(!old_binary.exists());
535        assert_eq!(
536            fs::read(&new_binary).expect("read migrated binary"),
537            b"tool"
538        );
539        assert_eq!(report.updated_packages, 1);
540        assert_eq!(report.refreshed_symlinks, 1);
541
542        let migrated: serde_json::Value = serde_json::from_slice(
543            &fs::read(&paths.config.packages_file).expect("read migrated packages"),
544        )
545        .expect("parse migrated packages");
546        assert_eq!(
547            migrated["packages"][0]["install_path"].as_str(),
548            Some(new_binary.to_str().expect("utf8 path"))
549        );
550        assert_eq!(
551            migrated["packages"][0]["exec_path"].as_str(),
552            Some(new_binary.to_str().expect("utf8 path"))
553        );
554        let migration_manifest: serde_json::Value = serde_json::from_slice(
555            &fs::read(ManifestStorage::path_for_root(&paths.dirs.data_dir))
556                .expect("read migration manifest"),
557        )
558        .expect("parse migration manifest");
559        assert_eq!(
560            migration_manifest["manifest_version"].as_u64(),
561            Some(MANIFEST_STORAGE_VERSION as u64)
562        );
563        assert_eq!(
564            migration_manifest["layout_version"].as_u64(),
565            Some(CURRENT_LAYOUT_VERSION as u64)
566        );
567        assert_eq!(
568            migration_manifest["previous_layout_version"].as_u64(),
569            Some(1)
570        );
571
572        #[cfg(unix)]
573        assert_eq!(
574            fs::read_link(paths.integration.symlinks_dir.join("tool")).expect("read symlink"),
575            new_binary
576        );
577
578        cleanup(&root).expect("cleanup");
579    }
580
581    #[test]
582    fn migrate_rewrites_rollback_package_snapshots() {
583        let root = temp_root("rollback");
584        let paths = test_support::upstream_paths(&root);
585        let old_archive = paths
586            .dirs
587            .data_dir
588            .join("archives")
589            .join("tool")
590            .join("bin")
591            .join("tool");
592        let new_archive = paths
593            .dirs
594            .packages_dir
595            .join("archives")
596            .join("tool")
597            .join("bin")
598            .join("tool");
599        fs::create_dir_all(old_archive.parent().expect("old archive parent"))
600            .expect("create old archive parent");
601        fs::write(&old_archive, b"tool").expect("write old archive executable");
602        fs::create_dir_all(&paths.dirs.metadata_dir).expect("create metadata");
603
604        let package = test_package(
605            "tool",
606            paths.dirs.data_dir.join("archives").join("tool"),
607            old_archive.clone(),
608        );
609        let record = RollbackRecord {
610            package_snapshot: package,
611            artifact_relative_path: PathBuf::from("tool/archive.tgz"),
612            icon_relative_path: None,
613            artifact_format: RollbackArtifactFormat::Tgz,
614            artifact_entry_path: Some(PathBuf::from("artifact/tool")),
615            icon_entry_path: None,
616            source: RollbackSource::Upgrade,
617            created_at: Utc::now(),
618        };
619        fs::write(
620            paths.dirs.metadata_dir.join("rollback.json"),
621            serde_json::to_vec_pretty(&json!({
622                "version": 1,
623                "records": {
624                    "tool": [record],
625                },
626            }))
627            .expect("serialize rollback"),
628        )
629        .expect("write rollback");
630
631        let report = run(&paths).expect("migrate");
632
633        assert_eq!(
634            fs::read(&new_archive).expect("read migrated archive"),
635            b"tool"
636        );
637        assert_eq!(report.updated_rollback_records, 1);
638        let migrated: serde_json::Value = serde_json::from_slice(
639            &fs::read(paths.dirs.metadata_dir.join("rollback.json")).expect("read rollback"),
640        )
641        .expect("parse rollback");
642        assert_eq!(
643            migrated["records"]["tool"][0]["package_snapshot"]["install_path"].as_str(),
644            Some(
645                paths
646                    .dirs
647                    .packages_dir
648                    .join("archives")
649                    .join("tool")
650                    .to_str()
651                    .expect("utf8 path")
652            )
653        );
654        assert_eq!(
655            migrated["records"]["tool"][0]["package_snapshot"]["exec_path"].as_str(),
656            Some(new_archive.to_str().expect("utf8 path"))
657        );
658
659        cleanup(&root).expect("cleanup");
660    }
661
662    #[test]
663    fn migrate_moves_legacy_config_trust_keys_to_trust_storage() {
664        let root = temp_root("trust-config");
665        let paths = test_support::upstream_paths(&root);
666        fs::create_dir_all(&paths.dirs.config_dir).expect("create config");
667        fs::write(
668            &paths.config.config_file,
669            r#"
670[github]
671api_token = "ghp_abc"
672
673[trust]
674minisign_public_keys = [{ id = "mini", key = "RWabc" }]
675cosign_public_keys = [{ id = "cosign", key = "-----BEGIN PUBLIC KEY-----\nkey\n-----END PUBLIC KEY-----" }]
676"#,
677        )
678        .expect("write legacy config");
679
680        let report = run(&paths).expect("migrate");
681
682        assert_eq!(report.migrated_trusted_keys, 2);
683        let migrated_config =
684            fs::read_to_string(&paths.config.config_file).expect("read migrated config");
685        assert!(migrated_config.contains("version = 2"));
686        assert!(!migrated_config.contains("[trust]"));
687
688        let trust_json: serde_json::Value = serde_json::from_slice(
689            &fs::read(&paths.config.trust_file).expect("read trust storage"),
690        )
691        .expect("parse trust storage");
692        assert_eq!(
693            trust_json["minisign_public_keys"][0]["id"].as_str(),
694            Some("mini")
695        );
696        assert_eq!(
697            trust_json["cosign_public_keys"][0]["id"].as_str(),
698            Some("cosign")
699        );
700
701        cleanup(&root).expect("cleanup");
702    }
703}