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}