Files
lanspread/crates/lanspread-peer/src/migration.rs
T
ddidderr 60fd7ba0c2 feat(peer)!: cut over to authenticated catalog sharing
Replace address-only trust and pushed peer state with installation identities,
SPKI-pinned QUIC, candidate-only discovery, and bounded responder-owned
protocol-8 pulls. The runtime now owns each network generation and all admitted
work through shutdown.

Add exact bundled content identities, reproducible manifest publishing,
capability-confined downloads, streaming BLAKE3 verification, quarantine and
retry, and crash-recoverable download and install transactions. Ship generated
fixture catalogs and fail closed when production manifests are absent.

The Tauri backend exposes durable sharing policy, redacted identity state, and
attempt-keyed transfer snapshots. Frontend consumption follows in the next
commit. Repository-wide test certificates and protocol-7 paths are removed.

BREAKING CHANGE: peers must use protocol 8 and exact catalog content artifacts;
protocol-7 frames and shared-certificate identities are no longer accepted.

Test Plan:
- `just test` -- passed on the completed stack (708 workspace tests)
- `just clippy` -- passed on the completed stack
- `just build` -- passed with fixture catalogs on the completed stack
- `just catalog-check-production` -- failed closed because the external
  production manifest corpus is absent
- `git diff --cached --check` -- passed
2026-08-10 13:59:18 +02:00

591 lines
19 KiB
Rust

use std::{
fs,
io::{ErrorKind, Write as _},
path::{Path, PathBuf},
sync::atomic::{AtomicUsize, Ordering},
thread,
time::Instant,
};
use crate::{
game_paths::{
LEGACY_FIRST_START_DONE_FILE,
LEGACY_INTENT_FILE,
LEGACY_INTENT_TMP_FILE,
LEGACY_LIBRARY_INDEX_DIR,
LEGACY_SOFTLAN_INSTALL_MARKER,
is_ignored_games_root_name,
},
local_games::legacy_library_index_path,
scoped_blocking::scoped_blocking,
state_paths::{local_library_index_path, setup_done_path},
};
const MIGRATION_CONCURRENCY: usize = 16;
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Serialize)]
pub struct MigrationReport {
pub games_checked: usize,
pub library_index_migrated: bool,
pub install_intents_migrated: usize,
pub setup_markers_migrated: usize,
pub legacy_files_deleted: usize,
pub unknown_softlan_files: usize,
pub failures: usize,
}
impl MigrationReport {
fn merge(&mut self, other: Self) {
self.games_checked += other.games_checked;
self.library_index_migrated |= other.library_index_migrated;
self.install_intents_migrated += other.install_intents_migrated;
self.setup_markers_migrated += other.setup_markers_migrated;
self.legacy_files_deleted += other.legacy_files_deleted;
self.unknown_softlan_files += other.unknown_softlan_files;
self.failures += other.failures;
}
}
/// Migrates legacy app-owned files out of the configured game directory.
///
/// This is intentionally separate from normal operation: callers should run it
/// before starting the peer runtime for a game directory.
pub async fn migrate_legacy_state(game_dir: &Path, state_dir: &Path) -> MigrationReport {
let started = Instant::now();
let mut report = MigrationReport::default();
report.merge(migrate_library_index(game_dir, state_dir));
let game_roots = match scoped_blocking(|| collect_game_roots(game_dir)) {
Ok(game_roots) => game_roots,
Err(err) => {
if err.kind() != ErrorKind::NotFound {
log::warn!(
"Failed to enumerate game roots for legacy state migration in {}: {err}",
game_dir.display()
);
report.failures += 1;
}
log_migration_report(&report, started);
return report;
}
};
report.merge(scoped_blocking(|| {
migrate_game_roots(state_dir, &game_roots)
}));
log_migration_report(&report, started);
report
}
fn collect_game_roots(game_dir: &Path) -> std::io::Result<Vec<(String, PathBuf)>> {
let mut roots = Vec::new();
for entry in fs::read_dir(game_dir)? {
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}
let Some(id) = entry.file_name().to_str().map(ToOwned::to_owned) else {
continue;
};
if is_ignored_games_root_name(&id) {
continue;
}
roots.push((id, entry.path()));
}
Ok(roots)
}
fn migrate_game_roots(state_dir: &Path, game_roots: &[(String, PathBuf)]) -> MigrationReport {
if game_roots.is_empty() {
return MigrationReport::default();
}
let next_root = AtomicUsize::new(0);
let worker_count = game_roots.len().min(MIGRATION_CONCURRENCY);
// Scoped workers preserve the former bounded overlap while guaranteeing
// that success, cancellation, and panic cannot leave filesystem work behind.
thread::scope(|scope| {
let mut workers = Vec::with_capacity(worker_count);
for _ in 0..worker_count {
workers.push(scope.spawn(|| {
let mut report = MigrationReport::default();
loop {
let index = next_root.fetch_add(1, Ordering::Relaxed);
let Some((id, root)) = game_roots.get(index) else {
break;
};
report.merge(migrate_game_root(state_dir, id, root));
}
report
}));
}
let mut report = MigrationReport::default();
for worker in workers {
match worker.join() {
Ok(worker_report) => report.merge(worker_report),
Err(payload) => std::panic::resume_unwind(payload),
}
}
report
})
}
fn migrate_library_index(game_dir: &Path, state_dir: &Path) -> MigrationReport {
let mut report = MigrationReport::default();
let legacy_path = legacy_library_index_path(game_dir);
let target_path = local_library_index_path(state_dir);
match scoped_blocking(|| migrate_raw_file(&legacy_path, &target_path)) {
Ok(MigrationOutcome::Migrated) => {
report.library_index_migrated = true;
report.legacy_files_deleted += 1;
}
Ok(MigrationOutcome::TargetAlreadyExists) => {
report.legacy_files_deleted += 1;
}
Ok(MigrationOutcome::SourceMissing) => {}
Err(err) => {
log::warn!(
"Failed to migrate legacy library index {} to {}: {err}",
legacy_path.display(),
target_path.display()
);
report.failures += 1;
}
}
report.merge(delete_if_exists(&library_index_tmp_path(&legacy_path)));
report.merge(remove_empty_legacy_library_dir(game_dir));
report
}
fn migrate_game_root(state_dir: &Path, id: &str, root: &Path) -> MigrationReport {
let mut report = MigrationReport {
games_checked: 1,
..MigrationReport::default()
};
report.merge(note_legacy_install_intent(root));
report.merge(migrate_setup_marker(state_dir, id, root));
report.merge(delete_if_exists(&root.join(LEGACY_SOFTLAN_INSTALL_MARKER)));
report.merge(note_unknown_softlan_files(root));
report
}
fn note_legacy_install_intent(root: &Path) -> MigrationReport {
let mut report = MigrationReport::default();
for name in [LEGACY_INTENT_FILE, LEGACY_INTENT_TMP_FILE] {
let path = root.join(name);
match scoped_blocking(|| path_exists(&path)) {
Ok(false) => {}
Ok(true) => {
log::warn!(
"Leaving unsupported legacy install intent in place: {}",
path.display()
);
report.failures += 1;
}
Err(error) => {
log::warn!(
"Failed to inspect legacy install intent {}: {error}",
path.display()
);
report.failures += 1;
}
}
}
report
}
fn migrate_setup_marker(state_dir: &Path, id: &str, root: &Path) -> MigrationReport {
let mut report = MigrationReport::default();
let legacy_path = root.join("local").join(LEGACY_FIRST_START_DONE_FILE);
let target_path = setup_done_path(state_dir, id);
match scoped_blocking(|| migrate_empty_marker(&legacy_path, &target_path)) {
Ok(MigrationOutcome::Migrated) => {
report.setup_markers_migrated += 1;
report.legacy_files_deleted += 1;
}
Ok(MigrationOutcome::TargetAlreadyExists) => {
report.legacy_files_deleted += 1;
}
Ok(MigrationOutcome::SourceMissing) => {}
Err(err) => {
log::warn!(
"Failed to migrate legacy setup marker {} to {}: {err}",
legacy_path.display(),
target_path.display()
);
report.failures += 1;
}
}
report
}
fn note_unknown_softlan_files(root: &Path) -> MigrationReport {
MigrationReport {
unknown_softlan_files: scoped_blocking(|| {
count_unknown_softlan_files(root) + count_unknown_softlan_files(&root.join("local"))
}),
..MigrationReport::default()
}
}
fn count_unknown_softlan_files(dir: &Path) -> usize {
let mut count = 0;
let entries = match fs::read_dir(dir) {
Ok(entries) => entries,
Err(err) if err.kind() == ErrorKind::NotFound => return 0,
Err(err) => {
log::warn!(
"Failed to inspect {} for legacy .softlan files: {err}",
dir.display()
);
return 0;
}
};
for entry in entries {
let Ok(entry) = entry else { break };
let Some(name) = entry.file_name().to_str().map(ToOwned::to_owned) else {
continue;
};
if !name.starts_with(".softlan_")
|| name == LEGACY_SOFTLAN_INSTALL_MARKER
|| name == LEGACY_FIRST_START_DONE_FILE
{
continue;
}
count += 1;
log::info!(
"Leaving unknown legacy .softlan file in place: {}",
entry.path().display()
);
}
count
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum MigrationOutcome {
SourceMissing,
TargetAlreadyExists,
Migrated,
}
fn migrate_raw_file(legacy_path: &Path, target_path: &Path) -> std::io::Result<MigrationOutcome> {
if !path_exists(legacy_path)? {
return Ok(MigrationOutcome::SourceMissing);
}
if path_exists(target_path)? {
remove_file_if_exists(legacy_path)?;
return Ok(MigrationOutcome::TargetAlreadyExists);
}
let data = fs::read(legacy_path)?;
write_bytes_atomically(target_path, &data)?;
remove_file_if_exists(legacy_path)?;
Ok(MigrationOutcome::Migrated)
}
fn migrate_empty_marker(
legacy_path: &Path,
target_path: &Path,
) -> std::io::Result<MigrationOutcome> {
if !path_exists(legacy_path)? {
return Ok(MigrationOutcome::SourceMissing);
}
if path_exists(target_path)? {
remove_file_if_exists(legacy_path)?;
return Ok(MigrationOutcome::TargetAlreadyExists);
}
if let Some(parent) = target_path.parent() {
fs::create_dir_all(parent)?;
}
fs::File::create(target_path)?.sync_all()?;
remove_file_if_exists(legacy_path)?;
Ok(MigrationOutcome::Migrated)
}
fn write_bytes_atomically(path: &Path, data: &[u8]) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let tmp_path = library_index_tmp_path(path);
let mut file = fs::File::create(&tmp_path)?;
file.write_all(data)?;
file.sync_all()?;
drop(file);
fs::rename(&tmp_path, path)?;
sync_parent_dir(path)
}
fn library_index_tmp_path(path: &Path) -> PathBuf {
let Some(file_name) = path.file_name() else {
return path.with_extension("tmp");
};
let mut tmp_name = file_name.to_os_string();
tmp_name.push(".tmp");
path.with_file_name(tmp_name)
}
fn path_exists(path: &Path) -> std::io::Result<bool> {
match fs::metadata(path) {
Ok(_) => Ok(true),
Err(err) if err.kind() == ErrorKind::NotFound => Ok(false),
Err(err) => Err(err),
}
}
fn delete_if_exists(path: &Path) -> MigrationReport {
match scoped_blocking(|| remove_file_if_exists(path)) {
Ok(true) => MigrationReport {
legacy_files_deleted: 1,
..MigrationReport::default()
},
Ok(false) => MigrationReport::default(),
Err(err) => {
log::warn!("Failed to delete legacy file {}: {err}", path.display());
MigrationReport {
failures: 1,
..MigrationReport::default()
}
}
}
}
fn remove_file_if_exists(path: &Path) -> std::io::Result<bool> {
if !path_exists(path)? {
return Ok(false);
}
match fs::remove_file(path) {
Ok(()) => Ok(true),
Err(err) if err.kind() == ErrorKind::NotFound => Ok(false),
Err(err) => Err(err),
}
}
fn remove_empty_legacy_library_dir(game_dir: &Path) -> MigrationReport {
let path = game_dir.join(LEGACY_LIBRARY_INDEX_DIR);
scoped_blocking(|| {
let exists = match path_exists(&path) {
Ok(exists) => exists,
Err(err) => {
log::warn!(
"Failed to inspect legacy library index directory {}: {err}",
path.display()
);
return MigrationReport {
failures: 1,
..MigrationReport::default()
};
}
};
if !exists {
return MigrationReport::default();
}
match fs::remove_dir(&path) {
Ok(()) => MigrationReport {
legacy_files_deleted: 1,
..MigrationReport::default()
},
Err(err)
if err.kind() == ErrorKind::NotFound
|| err.kind() == ErrorKind::DirectoryNotEmpty =>
{
MigrationReport::default()
}
Err(err) => {
log::warn!(
"Failed to remove empty legacy library index directory {}: {err}",
path.display()
);
MigrationReport {
failures: 1,
..MigrationReport::default()
}
}
}
})
}
fn log_migration_report(report: &MigrationReport, started: Instant) {
log::info!(
"Legacy state migration finished in {:?}: games_checked={}, library_index_migrated={}, \
install_intents_migrated={}, setup_markers_migrated={}, legacy_files_deleted={}, \
unknown_softlan_files={}, failures={}",
started.elapsed(),
report.games_checked,
report.library_index_migrated,
report.install_intents_migrated,
report.setup_markers_migrated,
report.legacy_files_deleted,
report.unknown_softlan_files,
report.failures
);
}
#[cfg(unix)]
fn sync_parent_dir(path: &Path) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::File::open(parent)?.sync_all()?;
}
Ok(())
}
#[cfg(not(unix))]
fn sync_parent_dir(_path: &Path) -> std::io::Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
install::intent::{
InstallIntent,
InstallIntentState,
LoadedInstallIntent,
read_intent,
write_intent,
},
test_support::TempDir,
};
fn write_file(path: &Path, bytes: &[u8]) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("parent dir should be created");
}
std::fs::write(path, bytes).expect("file should be written");
}
#[tokio::test]
async fn migrates_legacy_library_index_to_app_state() {
let games = TempDir::new("lanspread-migration-games");
let state = TempDir::new("lanspread-migration-state");
let legacy_path = legacy_library_index_path(games.path());
let target_path = local_library_index_path(state.path());
let legacy_tmp_path = library_index_tmp_path(&legacy_path);
write_file(&legacy_path, br#"{"revision":7,"games":{}}"#);
write_file(&legacy_tmp_path, b"tmp");
let report = migrate_legacy_state(games.path(), state.path()).await;
assert!(report.library_index_migrated);
assert_eq!(
std::fs::read_to_string(&target_path).expect("index should migrate"),
r#"{"revision":7,"games":{}}"#
);
assert!(!legacy_path.exists());
assert!(!legacy_tmp_path.exists());
assert!(!games.path().join(LEGACY_LIBRARY_INDEX_DIR).exists());
}
#[tokio::test]
async fn legacy_install_intent_is_rejected_without_deletion() {
let games = TempDir::new("lanspread-migration-games");
let state = TempDir::new("lanspread-migration-state");
let root = games.path().join("game");
let legacy_intent = root.join(LEGACY_INTENT_FILE);
let legacy_tmp = root.join(LEGACY_INTENT_TMP_FILE);
let legacy_setup = root.join("local").join(LEGACY_FIRST_START_DONE_FILE);
let legacy_marker = root.join(LEGACY_SOFTLAN_INSTALL_MARKER);
write_file(
&legacy_intent,
br#"{"schema_version":1,"state":"Updating"}"#,
);
write_file(&legacy_tmp, b"tmp");
write_file(&legacy_setup, b"");
write_file(&legacy_marker, b"");
let report = migrate_legacy_state(games.path(), state.path()).await;
assert_eq!(report.install_intents_migrated, 0);
assert_eq!(report.failures, 2);
assert_eq!(report.setup_markers_migrated, 1);
assert!(setup_done_path(state.path(), "game").is_file());
assert!(legacy_intent.exists());
assert!(legacy_tmp.exists());
assert!(!legacy_setup.exists());
assert!(!legacy_marker.exists());
}
#[tokio::test]
async fn migrates_multiple_roots_and_second_run_is_idempotent() {
let games = TempDir::new("lanspread-migration-games");
let state = TempDir::new("lanspread-migration-state");
let root_count = MIGRATION_CONCURRENCY + 3;
for index in 0..root_count {
write_file(
&games
.path()
.join(format!("game-{index}"))
.join(LEGACY_SOFTLAN_INSTALL_MARKER),
b"",
);
}
let first = migrate_legacy_state(games.path(), state.path()).await;
assert_eq!(first.games_checked, root_count);
assert_eq!(first.legacy_files_deleted, root_count);
assert_eq!(first.failures, 0);
let second = migrate_legacy_state(games.path(), state.path()).await;
assert_eq!(second.games_checked, root_count);
assert_eq!(second.legacy_files_deleted, 0);
assert_eq!(second.failures, 0);
}
#[tokio::test]
async fn app_state_wins_over_legacy_per_game_state() {
let games = TempDir::new("lanspread-migration-games");
let state = TempDir::new("lanspread-migration-state");
let root = games.path().join("game");
let app_intent = InstallIntent::none(&root, "game", Some("app".to_string()))
.expect("intent root should resolve");
let legacy_intent_path = root.join(LEGACY_INTENT_FILE);
let legacy_setup = root.join("local").join(LEGACY_FIRST_START_DONE_FILE);
write_intent(state.path(), "game", &app_intent)
.expect("app-state intent should be written");
write_file(
&legacy_intent_path,
br#"{"schema_version":1,"state":"Installing"}"#,
);
write_file(&setup_done_path(state.path(), "game"), b"");
write_file(&legacy_setup, b"");
let report = migrate_legacy_state(games.path(), state.path()).await;
assert_eq!(report.install_intents_migrated, 0);
assert_eq!(report.failures, 1);
assert_eq!(report.setup_markers_migrated, 0);
let LoadedInstallIntent::Valid(intent) = read_intent(state.path(), &root, "game") else {
panic!("current app-state intent should remain valid");
};
assert_eq!(intent.state, InstallIntentState::None);
assert_eq!(intent.eti_version.as_deref(), Some("app"));
assert!(legacy_intent_path.exists());
assert!(!legacy_setup.exists());
}
}