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::services::integration::SymlinkManager;
11use crate::services::storage::rollback_storage::RollbackRecord;
12use crate::utils::filesystem::{atomic_ops::write_atomic, safe_move};
13use crate::utils::static_paths::UpstreamPaths;
14
15const PACKAGE_STORAGE_VERSION: u32 = 1;
16const ROLLBACK_STORAGE_VERSION: u32 = 1;
17
18#[derive(Debug, Default, Clone, PartialEq, Eq)]
19pub struct MigrationReport {
20    pub created_dirs: usize,
21    pub moved_entries: usize,
22    pub updated_packages: usize,
23    pub updated_rollback_records: usize,
24    pub refreshed_symlinks: usize,
25    pub skipped_symlinks: usize,
26}
27
28#[derive(Debug, Clone)]
29struct PathRewrite {
30    old: PathBuf,
31    new: PathBuf,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35struct PackageStorageFile {
36    version: u32,
37    packages: Vec<Package>,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
41struct RollbackStorageFile {
42    version: u32,
43    records: HashMap<String, Vec<RollbackRecord>>,
44}
45
46#[derive(Debug, Clone, Deserialize)]
47struct LegacyRollbackStorageFile {
48    version: u32,
49    records: HashMap<String, RollbackRecord>,
50}
51
52pub fn run(paths: &UpstreamPaths) -> Result<MigrationReport> {
53    let rewrites = package_path_rewrites(paths);
54    let mut report = MigrationReport::default();
55
56    create_required_dirs(paths, &mut report)?;
57    move_legacy_package_dirs(&rewrites, &mut report)?;
58    let packages = migrate_package_metadata(paths, &rewrites, &mut report)?;
59    migrate_rollback_metadata(paths, &rewrites, &mut report)?;
60    refresh_symlinks(paths, &packages, &mut report)?;
61
62    Ok(report)
63}
64
65fn create_required_dirs(paths: &UpstreamPaths, report: &mut MigrationReport) -> Result<()> {
66    for dir in [
67        paths.dirs.config_dir.as_path(),
68        paths.dirs.data_dir.as_path(),
69        paths.dirs.packages_dir.as_path(),
70        paths.dirs.cache_dir.as_path(),
71        paths.dirs.metadata_dir.as_path(),
72        paths.install.appimages_dir.as_path(),
73        paths.install.binaries_dir.as_path(),
74        paths.install.archives_dir.as_path(),
75        paths.install.rollback_dir.as_path(),
76        paths.install.tmp_dir.as_path(),
77        paths.integration.icons_dir.as_path(),
78        paths.integration.symlinks_dir.as_path(),
79    ] {
80        if !dir.exists() {
81            report.created_dirs += 1;
82        }
83        fs::create_dir_all(dir)
84            .with_context(|| format!("Failed to create directory '{}'", dir.display()))?;
85    }
86    Ok(())
87}
88
89fn package_path_rewrites(paths: &UpstreamPaths) -> Vec<PathRewrite> {
90    vec![
91        PathRewrite {
92            old: paths.dirs.data_dir.join("appimages"),
93            new: paths.install.appimages_dir.clone(),
94        },
95        PathRewrite {
96            old: paths.dirs.data_dir.join("binaries"),
97            new: paths.install.binaries_dir.clone(),
98        },
99        PathRewrite {
100            old: paths.dirs.data_dir.join("archives"),
101            new: paths.install.archives_dir.clone(),
102        },
103    ]
104}
105
106fn move_legacy_package_dirs(rewrites: &[PathRewrite], report: &mut MigrationReport) -> Result<()> {
107    for rewrite in rewrites {
108        if !rewrite.old.exists() {
109            continue;
110        }
111        move_into_layout(&rewrite.old, &rewrite.new, report).with_context(|| {
112            format!(
113                "Failed to migrate '{}' to '{}'",
114                rewrite.old.display(),
115                rewrite.new.display()
116            )
117        })?;
118    }
119    Ok(())
120}
121
122fn move_into_layout(src: &Path, dst: &Path, report: &mut MigrationReport) -> Result<()> {
123    if paths_are_same(src, dst)? {
124        return Ok(());
125    }
126
127    if !dst.exists() {
128        if let Some(parent) = dst.parent() {
129            fs::create_dir_all(parent)
130                .with_context(|| format!("Failed to create directory '{}'", parent.display()))?;
131        }
132        safe_move::move_file_or_dir(src, dst)?;
133        report.moved_entries += 1;
134        return Ok(());
135    }
136
137    merge_directory_contents(src, dst, report)?;
138    remove_dir_if_empty(src)?;
139    Ok(())
140}
141
142fn merge_directory_contents(src: &Path, dst: &Path, report: &mut MigrationReport) -> Result<()> {
143    for entry in fs::read_dir(src)
144        .with_context(|| format!("Failed to read directory '{}'", src.display()))?
145    {
146        let entry =
147            entry.with_context(|| format!("Failed to read entry in '{}'", src.display()))?;
148        let from = entry.path();
149        let to = dst.join(entry.file_name());
150        let file_type = entry
151            .file_type()
152            .with_context(|| format!("Failed to inspect '{}'", from.display()))?;
153
154        if to.exists() {
155            if file_type.is_dir() && to.is_dir() {
156                merge_directory_contents(&from, &to, report)?;
157                remove_dir_if_empty(&from)?;
158                continue;
159            }
160            return Err(anyhow!(
161                "Refusing to overwrite existing migrated path '{}'",
162                to.display()
163            ));
164        }
165
166        safe_move::move_file_or_dir(&from, &to)?;
167        report.moved_entries += 1;
168    }
169    Ok(())
170}
171
172fn remove_dir_if_empty(path: &Path) -> Result<()> {
173    if path.exists()
174        && path
175            .read_dir()
176            .map(|mut entries| entries.next().is_none())
177            .unwrap_or(false)
178    {
179        fs::remove_dir(path)
180            .with_context(|| format!("Failed to remove empty directory '{}'", path.display()))?;
181    }
182    Ok(())
183}
184
185fn paths_are_same(a: &Path, b: &Path) -> io::Result<bool> {
186    if !a.exists() || !b.exists() {
187        return Ok(false);
188    }
189    Ok(fs::canonicalize(a)? == fs::canonicalize(b)?)
190}
191
192fn migrate_package_metadata(
193    paths: &UpstreamPaths,
194    rewrites: &[PathRewrite],
195    report: &mut MigrationReport,
196) -> Result<Vec<Package>> {
197    if !paths.config.packages_file.exists() {
198        return Ok(Vec::new());
199    }
200
201    let json = fs::read_to_string(&paths.config.packages_file).with_context(|| {
202        format!(
203            "Failed to read package metadata '{}'",
204            paths.config.packages_file.display()
205        )
206    })?;
207    if json.trim().is_empty() {
208        return Ok(Vec::new());
209    }
210
211    let mut storage: PackageStorageFile = serde_json::from_str(&json).with_context(|| {
212        format!(
213            "Failed to parse package metadata '{}'",
214            paths.config.packages_file.display()
215        )
216    })?;
217    if storage.version != PACKAGE_STORAGE_VERSION {
218        return Err(anyhow!(
219            "Unsupported package storage version {} in '{}'. Expected version {}.",
220            storage.version,
221            paths.config.packages_file.display(),
222            PACKAGE_STORAGE_VERSION
223        ));
224    }
225
226    let mut changed = false;
227    for package in &mut storage.packages {
228        let package_changed = rewrite_package_paths(package, rewrites);
229        if package_changed {
230            changed = true;
231            report.updated_packages += 1;
232        }
233    }
234
235    if changed {
236        write_json(&paths.config.packages_file, &storage)?;
237    }
238
239    Ok(storage.packages)
240}
241
242fn migrate_rollback_metadata(
243    paths: &UpstreamPaths,
244    rewrites: &[PathRewrite],
245    report: &mut MigrationReport,
246) -> Result<()> {
247    let rollback_file = paths.dirs.metadata_dir.join("rollback.json");
248    if !rollback_file.exists() {
249        return Ok(());
250    }
251
252    let json = fs::read_to_string(&rollback_file).with_context(|| {
253        format!(
254            "Failed to read rollback metadata '{}'",
255            rollback_file.display()
256        )
257    })?;
258    if json.trim().is_empty() {
259        return Ok(());
260    }
261
262    let mut storage: RollbackStorageFile = serde_json::from_str(&json)
263        .or_else(|_| parse_legacy_rollback_storage(&json))
264        .with_context(|| {
265            format!(
266                "Failed to parse rollback metadata '{}'",
267                rollback_file.display()
268            )
269        })?;
270    if storage.version != ROLLBACK_STORAGE_VERSION {
271        return Err(anyhow!(
272            "Unsupported rollback storage version {} in '{}'. Expected version {}.",
273            storage.version,
274            rollback_file.display(),
275            ROLLBACK_STORAGE_VERSION
276        ));
277    }
278
279    let mut changed = false;
280    for records in storage.records.values_mut() {
281        for record in records {
282            if rewrite_package_paths(&mut record.package_snapshot, rewrites) {
283                changed = true;
284                report.updated_rollback_records += 1;
285            }
286        }
287    }
288
289    if changed {
290        write_json(&rollback_file, &storage)?;
291    }
292
293    Ok(())
294}
295
296fn parse_legacy_rollback_storage(json: &str) -> serde_json::Result<RollbackStorageFile> {
297    let legacy: LegacyRollbackStorageFile = serde_json::from_str(json)?;
298    Ok(RollbackStorageFile {
299        version: legacy.version,
300        records: legacy
301            .records
302            .into_iter()
303            .map(|(name, record)| (name, vec![record]))
304            .collect(),
305    })
306}
307
308fn rewrite_package_paths(package: &mut Package, rewrites: &[PathRewrite]) -> bool {
309    let mut changed = false;
310    changed |= rewrite_optional_path(&mut package.install_path, rewrites);
311    changed |= rewrite_optional_path(&mut package.exec_path, rewrites);
312    changed
313}
314
315fn rewrite_optional_path(path: &mut Option<PathBuf>, rewrites: &[PathRewrite]) -> bool {
316    let Some(current) = path.as_ref() else {
317        return false;
318    };
319
320    for rewrite in rewrites {
321        if let Ok(relative) = current.strip_prefix(&rewrite.old) {
322            *path = Some(rewrite.new.join(relative));
323            return true;
324        }
325    }
326
327    false
328}
329
330fn refresh_symlinks(
331    paths: &UpstreamPaths,
332    packages: &[Package],
333    report: &mut MigrationReport,
334) -> Result<()> {
335    let symlink_manager = SymlinkManager::new(&paths.integration.symlinks_dir);
336
337    for package in packages {
338        let target = package.exec_path.as_ref().or(package.install_path.as_ref());
339        let Some(target) = target else {
340            report.skipped_symlinks += 1;
341            continue;
342        };
343        if !target.exists() {
344            report.skipped_symlinks += 1;
345            continue;
346        }
347
348        symlink_manager
349            .add_link(target, &package.name)
350            .with_context(|| format!("Failed to refresh symlink for '{}'", package.name))?;
351        report.refreshed_symlinks += 1;
352    }
353
354    Ok(())
355}
356
357fn write_json<T: Serialize>(path: &Path, value: &T) -> Result<()> {
358    let json = serde_json::to_string_pretty(value).context("Failed to serialize migration data")?;
359    write_atomic(path, json.as_bytes())
360        .with_context(|| format!("Failed to write '{}'", path.display()))
361}
362
363#[cfg(test)]
364mod tests {
365    use super::run;
366    use crate::models::common::enums::{Channel, Filetype, Provider};
367    use crate::models::upstream::Package;
368    use crate::services::storage::rollback_storage::{
369        RollbackArtifactFormat, RollbackRecord, RollbackSource,
370    };
371    use crate::utils::test_support;
372    use chrono::Utc;
373    use serde_json::json;
374    use std::path::{Path, PathBuf};
375    use std::{fs, io};
376
377    fn temp_root(name: &str) -> PathBuf {
378        test_support::temp_root("upstream-migrate-test", name)
379    }
380
381    fn cleanup(path: &Path) -> io::Result<()> {
382        fs::remove_dir_all(path)
383    }
384
385    fn test_package(name: &str, install_path: PathBuf, exec_path: PathBuf) -> Package {
386        let mut package = Package::with_defaults(
387            name.to_string(),
388            format!("owner/{name}"),
389            Filetype::Binary,
390            None,
391            None,
392            Channel::Stable,
393            Provider::Github,
394            None,
395        );
396        package.install_path = Some(install_path);
397        package.exec_path = Some(exec_path);
398        package
399    }
400
401    #[test]
402    fn migrate_moves_package_dirs_and_rewrites_metadata() {
403        let root = temp_root("layout");
404        let paths = test_support::upstream_paths(&root);
405        let old_binary = paths.dirs.data_dir.join("binaries").join("tool");
406        let new_binary = paths.dirs.packages_dir.join("binaries").join("tool");
407        fs::create_dir_all(old_binary.parent().expect("old binary parent"))
408            .expect("create old binary parent");
409        fs::write(&old_binary, b"tool").expect("write old binary");
410
411        #[cfg(unix)]
412        {
413            fs::create_dir_all(&paths.integration.symlinks_dir).expect("create symlinks");
414            std::os::unix::fs::symlink(&old_binary, paths.integration.symlinks_dir.join("tool"))
415                .expect("create old symlink");
416        }
417
418        let package = test_package("tool", old_binary.clone(), old_binary.clone());
419        fs::create_dir_all(&paths.dirs.metadata_dir).expect("create metadata");
420        fs::write(
421            &paths.config.packages_file,
422            serde_json::to_vec_pretty(&json!({
423                "version": 1,
424                "packages": [package],
425            }))
426            .expect("serialize packages"),
427        )
428        .expect("write packages");
429
430        let report = run(&paths).expect("migrate");
431
432        assert!(!old_binary.exists());
433        assert_eq!(
434            fs::read(&new_binary).expect("read migrated binary"),
435            b"tool"
436        );
437        assert_eq!(report.updated_packages, 1);
438        assert_eq!(report.refreshed_symlinks, 1);
439
440        let migrated: serde_json::Value = serde_json::from_slice(
441            &fs::read(&paths.config.packages_file).expect("read migrated packages"),
442        )
443        .expect("parse migrated packages");
444        assert_eq!(
445            migrated["packages"][0]["install_path"].as_str(),
446            Some(new_binary.to_str().expect("utf8 path"))
447        );
448        assert_eq!(
449            migrated["packages"][0]["exec_path"].as_str(),
450            Some(new_binary.to_str().expect("utf8 path"))
451        );
452
453        #[cfg(unix)]
454        assert_eq!(
455            fs::read_link(paths.integration.symlinks_dir.join("tool")).expect("read symlink"),
456            new_binary
457        );
458
459        cleanup(&root).expect("cleanup");
460    }
461
462    #[test]
463    fn migrate_rewrites_rollback_package_snapshots() {
464        let root = temp_root("rollback");
465        let paths = test_support::upstream_paths(&root);
466        let old_archive = paths
467            .dirs
468            .data_dir
469            .join("archives")
470            .join("tool")
471            .join("bin")
472            .join("tool");
473        let new_archive = paths
474            .dirs
475            .packages_dir
476            .join("archives")
477            .join("tool")
478            .join("bin")
479            .join("tool");
480        fs::create_dir_all(old_archive.parent().expect("old archive parent"))
481            .expect("create old archive parent");
482        fs::write(&old_archive, b"tool").expect("write old archive executable");
483        fs::create_dir_all(&paths.dirs.metadata_dir).expect("create metadata");
484
485        let package = test_package(
486            "tool",
487            paths.dirs.data_dir.join("archives").join("tool"),
488            old_archive.clone(),
489        );
490        let record = RollbackRecord {
491            package_snapshot: package,
492            artifact_relative_path: PathBuf::from("tool/archive.tgz"),
493            icon_relative_path: None,
494            artifact_format: RollbackArtifactFormat::Tgz,
495            artifact_entry_path: Some(PathBuf::from("artifact/tool")),
496            icon_entry_path: None,
497            source: RollbackSource::Upgrade,
498            created_at: Utc::now(),
499        };
500        fs::write(
501            paths.dirs.metadata_dir.join("rollback.json"),
502            serde_json::to_vec_pretty(&json!({
503                "version": 1,
504                "records": {
505                    "tool": [record],
506                },
507            }))
508            .expect("serialize rollback"),
509        )
510        .expect("write rollback");
511
512        let report = run(&paths).expect("migrate");
513
514        assert_eq!(
515            fs::read(&new_archive).expect("read migrated archive"),
516            b"tool"
517        );
518        assert_eq!(report.updated_rollback_records, 1);
519        let migrated: serde_json::Value = serde_json::from_slice(
520            &fs::read(paths.dirs.metadata_dir.join("rollback.json")).expect("read rollback"),
521        )
522        .expect("parse rollback");
523        assert_eq!(
524            migrated["records"]["tool"][0]["package_snapshot"]["install_path"].as_str(),
525            Some(
526                paths
527                    .dirs
528                    .packages_dir
529                    .join("archives")
530                    .join("tool")
531                    .to_str()
532                    .expect("utf8 path")
533            )
534        );
535        assert_eq!(
536            migrated["records"]["tool"][0]["package_snapshot"]["exec_path"].as_str(),
537            Some(new_archive.to_str().expect("utf8 path"))
538        );
539
540        cleanup(&root).expect("cleanup");
541    }
542}