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}