use proxywarden_lib::command_dto::{CommandError, StorageMigrationStatusDto}; use proxywarden_lib::component_cutover::{ ComponentCutoverObservation, CutoverDisplayState, CutoverPhase, LegacyServiceState, CUTOVER_OBSERVATION_SCHEMA_VERSION, }; use proxywarden_lib::component_inventory::{ classify_component_candidates, BinaryIdentityEvidence, CandidateRole, ComponentCandidateProbe, ComponentInventory, MarkerEvidence, ServiceEvidence, }; use proxywarden_lib::migration::{ prepare_storage, prepare_storage_with_hook, reconcile_component_layout, record_component_cutover_startup_evidence, recover_incomplete_migration, MigrationHook, MigrationPhase, CURRENT_COMPONENT_LAYOUT, CURRENT_STORAGE_SCHEMA, }; use proxywarden_lib::models::{ ComponentId, Profile, ProfileItemType, Protocol, StorageMeta, StorageMigrationOutcome, Target, }; #[cfg(windows)] use proxywarden_lib::safe_fs; use proxywarden_lib::storage::{backup_path, JsonStorage}; use serde_json::Value; use std::collections::BTreeMap; use std::fs; use std::io; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; const ACTIVE_JOURNAL_FILE: &str = "active-storage-migration.json"; const SECRET_SENTINEL: &str = "MIGRATION_SECRET_SENTINEL_7d39"; #[test] fn current_meta_is_a_byte_for_byte_no_op() { let root = TestRoot::new("current-no-op"); let storage = JsonStorage::new(root.path()); copy_split_fixture(&storage, "profiles.json"); copy_split_fixture(&storage, "targets.json"); storage .write_storage_meta(&StorageMeta { storage_schema_version: CURRENT_STORAGE_SCHEMA, outcome: StorageMigrationOutcome::AdoptedWithoutLegacyImport, migration_id: "already-current".to_string(), completed_at_epoch_seconds: 1, }) .expect("write current storage meta"); let legacy = root.path().join("legacy-app-config.json"); write_bytes( &legacy, &fixture_bytes("proxifyre-generated/app-config.json"), ); let before = snapshot_files(root.path()); let status = prepare_storage(&storage, &[legacy]).expect("read current storage"); assert!(!status.changed); assert!(!status.blocking); assert_eq!(status.storage_schema_version, CURRENT_STORAGE_SCHEMA); assert_eq!(snapshot_files(root.path()), before); } #[test] fn any_split_source_including_an_intentional_empty_file_blocks_legacy_import() { for (label, relative, bytes) in [ ("profiles", "profiles.json", b"[]".as_slice()), ("targets", "targets.json", b"[]".as_slice()), ("components", "components.json", b"[]".as_slice()), ("local-singbox", "local-singbox.json", b"{}".as_slice()), ] { let root = TestRoot::new(label); let storage = JsonStorage::new(root.path().join("data")); let split_path = storage.paths().config_dir.join(relative); write_bytes(&split_path, bytes); let split_before = fs::read(&split_path).expect("read intentional split source"); let legacy = copy_supported_legacy(root.path()); let legacy_before = fs::read(&legacy).expect("read legacy source"); let status = prepare_storage(&storage, std::slice::from_ref(&legacy)).expect("adopt split storage"); assert_eq!(status.outcome, "adopted_without_legacy_import", "{label}"); assert_eq!( status.notice_code.as_deref(), Some("legacy_preserved_current_wins"), "{label}" ); assert_eq!( fs::read(&split_path).expect("read adopted source"), split_before ); assert_eq!( fs::read(&legacy).expect("read preserved legacy"), legacy_before ); assert!(!generated_config_path(&storage).exists(), "{label}"); assert!(storage.paths().storage_meta_file.is_file(), "{label}"); } } #[test] fn imports_exactly_one_supported_fixture_then_second_start_is_a_no_op() { let root = TestRoot::new("supported-import"); let storage = JsonStorage::new(root.path().join("data")); let legacy = copy_supported_legacy(root.path()); let legacy_before = fs::read(&legacy).expect("read legacy source"); let legacy_json: Value = serde_json::from_slice(&legacy_before).expect("parse supported legacy fixture"); let first = prepare_storage(&storage, std::slice::from_ref(&legacy)).expect("import supported fixture"); assert_eq!(first.outcome, "imported_legacy_config"); assert!(first.changed); assert!(!first.blocking); let profiles = storage.read_profiles().expect("read imported profiles"); let targets = storage.read_targets().expect("read imported targets"); assert_imported_fixture(&profiles, &targets); let generated: Value = serde_json::from_slice( &fs::read(generated_config_path(&storage)).expect("read regenerated config"), ) .expect("parse regenerated config"); assert_eq!(generated, legacy_json); assert_eq!( fs::read(&legacy).expect("read preserved legacy"), legacy_before ); let meta = storage .read_storage_meta() .expect("read storage meta") .expect("storage meta exists"); let completed: Value = serde_json::from_slice( &fs::read(archived_journal_path( &storage, &meta.migration_id, "complete", )) .expect("read completed journal"), ) .expect("parse completed journal"); assert_eq!(completed["phase"], "storage_complete"); assert!(completed["lastErrorCode"].is_null()); let after_first = snapshot_files(storage.paths().root.as_path()); let second = prepare_storage(&storage, &[legacy]).expect("repeat migration startup"); assert!(!second.changed); assert_eq!(second.outcome, "imported_legacy_config"); assert_eq!(snapshot_files(storage.paths().root.as_path()), after_first); } #[test] fn populated_current_split_wins_without_merging_or_rewriting_legacy() { let root = TestRoot::new("current-wins"); let storage = JsonStorage::new(root.path().join("data")); for file in [ "profiles.json", "targets.json", "components.json", "local-singbox.json", ] { copy_split_fixture(&storage, file); } let source_before = snapshot_files(&storage.paths().config_dir); let legacy = copy_supported_legacy(root.path()); let legacy_before = fs::read(&legacy).expect("read legacy source"); let status = prepare_storage(&storage, std::slice::from_ref(&legacy)).expect("adopt current storage"); assert_eq!(status.outcome, "adopted_without_legacy_import"); assert_eq!( status.notice_code.as_deref(), Some("legacy_preserved_current_wins") ); assert_eq!( snapshot_without_meta(&storage.paths().config_dir), source_before ); assert_eq!( fs::read(&legacy).expect("read preserved legacy"), legacy_before ); assert!(!generated_config_path(&storage).exists()); assert_eq!( storage.read_profiles().expect("read current profiles")[0].id, "fixture-profile" ); } #[test] fn partial_split_never_falls_back_to_legacy_import() { let root = TestRoot::new("partial-split"); let storage = JsonStorage::new(root.path().join("data")); copy_split_fixture(&storage, "targets.json"); let target_before = fs::read(&storage.paths().targets_file).expect("read partial target"); let legacy = copy_supported_legacy(root.path()); let legacy_before = fs::read(&legacy).expect("read legacy source"); let status = prepare_storage(&storage, std::slice::from_ref(&legacy)).expect("adopt partial split"); assert_eq!(status.outcome, "adopted_without_legacy_import"); assert!(!storage.paths().profiles_file.exists()); assert!(!generated_config_path(&storage).exists()); assert_eq!( fs::read(&storage.paths().targets_file).expect("read target"), target_before ); assert_eq!( fs::read(&legacy).expect("read preserved legacy"), legacy_before ); } #[test] fn corrupt_split_recovers_only_from_a_valid_backup() { let root = TestRoot::new("corrupt-with-backup"); let storage = JsonStorage::new(root.path().join("data")); for file in [ "profiles.json", "targets.json", "components.json", "local-singbox.json", ] { copy_split_fixture(&storage, file); } let valid_profiles = fixture_bytes("pre-1.2-split/config/profiles.json"); write_bytes( &backup_path(&storage.paths().profiles_file), &valid_profiles, ); write_bytes(&storage.paths().profiles_file, b"{corrupt-json"); let status = prepare_storage(&storage, &[]).expect("recover valid split backup"); assert_eq!(status.outcome, "adopted_without_legacy_import"); assert_eq!( status.notice_code.as_deref(), Some("split_recovered_from_backup") ); assert_eq!( fs::read(&storage.paths().profiles_file).expect("read recovered profiles"), valid_profiles ); assert_eq!( fs::read(backup_path(&storage.paths().profiles_file)).expect("read preserved backup"), valid_profiles ); assert_eq!( storage.read_profiles().expect("parse recovered profiles")[0].id, "fixture-profile" ); } #[test] fn current_meta_never_bypasses_strict_split_validation() { let root = TestRoot::new("current-meta-corrupt-split"); let storage = JsonStorage::new(root.path().join("data")); write_bytes(&storage.paths().profiles_file, b"{corrupt-json"); storage .write_storage_meta(&StorageMeta { storage_schema_version: CURRENT_STORAGE_SCHEMA, outcome: StorageMigrationOutcome::AdoptedWithoutLegacyImport, migration_id: "current-with-corrupt-split".to_string(), completed_at_epoch_seconds: 1, }) .expect("write current storage meta"); let before = snapshot_files(root.path()); let status = prepare_storage(&storage, &[]).expect("return blocked split status"); assert!(status.blocking); assert_eq!(status.notice_code.as_deref(), Some("split_storage_invalid")); assert_eq!(snapshot_files(root.path()), before); } #[test] fn corrupt_split_without_backup_blocks_and_preserves_all_source_bytes() { let root = TestRoot::new("corrupt-without-backup"); let storage = JsonStorage::new(root.path().join("private-data")); let corrupt = b"{corrupt-json".to_vec(); write_bytes(&storage.paths().profiles_file, &corrupt); let legacy = copy_supported_legacy(root.path()); let legacy_before = fs::read(&legacy).expect("read legacy source"); let status = prepare_storage(&storage, std::slice::from_ref(&legacy)).expect("return blocked status"); assert!(status.blocking); assert_eq!(status.notice_code.as_deref(), Some("split_storage_invalid")); assert_eq!( fs::read(&storage.paths().profiles_file).expect("read corrupt source"), corrupt ); assert_eq!( fs::read(&legacy).expect("read preserved legacy"), legacy_before ); assert!(!storage.paths().storage_meta_file.exists()); assert!(!generated_config_path(&storage).exists()); assert_public_status_safe(&status, &[SECRET_SENTINEL, &legacy.display().to_string()]); } #[test] fn corrupt_legacy_components_file_is_preserved_but_not_treated_as_source_truth() { let root = TestRoot::new("corrupt-legacy-components"); let storage = JsonStorage::new(root.path().join("data")); let corrupt = b"{corrupt-component-status".to_vec(); write_bytes(&storage.paths().components_file, &corrupt); let status = prepare_storage(&storage, &[]).expect("adopt legacy component evidence"); assert_eq!(status.outcome, "adopted_without_legacy_import"); assert_eq!( status.notice_code.as_deref(), Some("legacy_components_ignored_invalid") ); assert_eq!( fs::read(&storage.paths().components_file).expect("read preserved components"), corrupt ); assert!(storage.paths().storage_meta_file.exists()); } #[test] fn missing_or_corrupt_storage_meta_recovers_from_a_valid_current_backup_without_import() { for live_state in ["missing", "corrupt"] { let root = TestRoot::new(&format!("meta-{live_state}")); let storage = JsonStorage::new(root.path().join("data")); let legacy = copy_supported_legacy(root.path()); let legacy_before = fs::read(&legacy).expect("read legacy source"); let backup_meta = StorageMeta { storage_schema_version: CURRENT_STORAGE_SCHEMA, outcome: StorageMigrationOutcome::AdoptedWithoutLegacyImport, migration_id: "valid-backup-meta".to_string(), completed_at_epoch_seconds: 1, }; write_bytes( &backup_path(&storage.paths().storage_meta_file), &serde_json::to_vec_pretty(&backup_meta).expect("serialize backup meta"), ); if live_state == "corrupt" { write_bytes(&storage.paths().storage_meta_file, b"{corrupt-meta"); } let status = prepare_storage(&storage, std::slice::from_ref(&legacy)).expect("recover storage meta"); assert_eq!(status.outcome, "adopted_without_legacy_import"); assert_eq!( status.notice_code.as_deref(), Some("storage_meta_recovered") ); assert!(status.changed); let recovered = storage .read_storage_meta() .expect("read recovered storage meta") .expect("recovered storage meta exists"); assert_eq!(recovered.storage_schema_version, CURRENT_STORAGE_SCHEMA); assert_eq!( recovered.outcome, StorageMigrationOutcome::AdoptedWithoutLegacyImport ); assert_eq!( fs::read(&legacy).expect("read preserved legacy"), legacy_before ); assert!(!storage.paths().profiles_file.exists()); assert!(!storage.paths().targets_file.exists()); assert!(!generated_config_path(&storage).exists()); } } #[test] fn component_layout_advances_only_after_inventory_proves_no_managed_legacy() { let root = TestRoot::new("component-layout"); let storage = JsonStorage::new(root.path().join("data")); let legacy_root = PathBuf::from(r"C:\Tools\ProxiFyre"); let legacy_inventory = classify_component_candidates( ComponentId::Proxyfier, vec![ComponentCandidateProbe { component_id: ComponentId::Proxyfier, role: CandidateRole::Legacy, root: legacy_root.clone(), root_exists: true, has_reparse_point: false, executable_path: Some(legacy_root.join("ProxiFyre.exe")), missing_files: Vec::new(), marker: MarkerEvidence::NotRequired, marker_required: false, binary_identity: BinaryIdentityEvidence::KnownPackage, binary_version: Some("2.2.1".to_string()), service: Some(ServiceEvidence { name: "ProxiFyreService".to_string(), status: "stopped".to_string(), path_name: Some(format!( r#""{}" --service"#, legacy_root.join("ProxiFyre.exe").display() )), executable_path: Some(legacy_root.join("ProxiFyre.exe")), path_matches_candidate: true, binary_version: Some("2.2.1".to_string()), }), service_required: true, legacy_identity_complete: true, }], ); let missing_singbox = ComponentInventory::missing(ComponentId::Singbox); assert_eq!( reconcile_component_layout(&storage, &legacy_inventory, &missing_singbox) .expect("legacy layout check"), None ); assert!(!storage.paths().component_layout_file.exists()); let missing_proxyfier = ComponentInventory::missing(ComponentId::Proxyfier); assert_eq!( reconcile_component_layout(&storage, &missing_proxyfier, &missing_singbox) .expect("current layout proof"), Some(CURRENT_COMPONENT_LAYOUT) ); let before = fs::read(&storage.paths().component_layout_file).expect("read layout meta"); assert_eq!( reconcile_component_layout(&storage, &missing_proxyfier, &missing_singbox) .expect("repeat layout proof"), Some(CURRENT_COMPONENT_LAYOUT) ); assert_eq!( fs::read(&storage.paths().component_layout_file).expect("read repeated layout meta"), before ); } #[test] fn startup_cutover_evidence_binds_new_session_inventory_and_preserves_confirmation() { let root = TestRoot::new("cutover-startup-evidence"); let storage = JsonStorage::new(root.path().join("data")); let cutover_id = "11111111-1111-4111-8111-111111111111"; let session_id = "22222222-2222-4222-8222-222222222222"; storage .write_component_cutover_observation(&ComponentCutoverObservation { schema_version: CUTOVER_OBSERVATION_SCHEMA_VERSION, component: "proxifyre".to_string(), cutover_id: cutover_id.to_string(), state: CutoverDisplayState::AwaitingNextStart, phase: CutoverPhase::LegacyQuarantined, original_service_state: LegacyServiceState::Stopped, legacy_version: "2.2.1".to_string(), bundled_version: "2.4.0".to_string(), operation_fingerprint: "1".repeat(64), transaction_fingerprint: "2".repeat(64), evidence_fingerprint: None, next_start_verified: false, route_smoke_confirmed: false, legacy_path_label: "legacy ProxiFyre installation".to_string(), current_path_label: "ProxyWarden managed components".to_string(), can_recover: false, can_cleanup: false, disabled_code: None, updated_at_epoch_seconds: 1, }) .expect("write cutover observation"); let current_root = PathBuf::from(r"C:\Program Files\ProxyWarden\components\ProxiFyre"); let current = classify_component_candidates( ComponentId::Proxyfier, vec![ComponentCandidateProbe { component_id: ComponentId::Proxyfier, role: CandidateRole::Current, root: current_root.clone(), root_exists: true, has_reparse_point: false, executable_path: Some(current_root.join("ProxiFyre.exe")), missing_files: Vec::new(), marker: MarkerEvidence::Valid, marker_required: true, binary_identity: BinaryIdentityEvidence::KnownPackage, binary_version: Some("2.4.0".to_string()), service: Some(ServiceEvidence { name: "ProxiFyreService".to_string(), status: "stopped".to_string(), path_name: Some( current_root .join("ProxiFyre.exe") .as_os_str() .to_string_lossy() .into_owned(), ), executable_path: Some(current_root.join("ProxiFyre.exe")), path_matches_candidate: true, binary_version: Some("2.4.0".to_string()), }), service_required: true, legacy_identity_complete: true, }], ); assert!( record_component_cutover_startup_evidence(&storage, session_id, ¤t) .expect("record startup evidence") ); let mut evidence = storage .read_component_cutover_user_evidence() .expect("read evidence") .expect("evidence exists"); assert_eq!(evidence.cutover_id, cutover_id); assert_eq!(evidence.startup_session_id, session_id); assert!(!evidence.route_smoke_confirmed); evidence.route_smoke_confirmed = true; evidence.confirmed_at_epoch_seconds = Some(evidence.observed_at_epoch_seconds); storage .write_component_cutover_user_evidence(&evidence) .expect("confirm evidence"); assert!( record_component_cutover_startup_evidence(&storage, session_id, ¤t) .expect("repeat startup evidence") ); assert!( storage .read_component_cutover_user_evidence() .expect("read repeated evidence") .expect("repeated evidence exists") .route_smoke_confirmed ); let mut wrong_state = current.clone(); wrong_state.candidates[0] .service .as_mut() .expect("current service") .status = "running".to_string(); assert!(!record_component_cutover_startup_evidence( &storage, "33333333-3333-4333-8333-333333333333", &wrong_state, ) .expect("reject wrong service state")); } #[test] fn unknown_and_credential_fields_block_before_source_or_meta_writes() { for (label, mut legacy_json) in [ ("unknown", supported_legacy_json()), ("credential", supported_legacy_json()), ] { if label == "unknown" { legacy_json["unknownRootField"] = SECRET_SENTINEL.into(); } else { legacy_json["proxies"][0]["password"] = SECRET_SENTINEL.into(); } let root = TestRoot::new(label); let storage = JsonStorage::new(root.path().join("data")); let legacy = root.path().join("private-source").join("app-config.json"); let legacy_before = serde_json::to_vec_pretty(&legacy_json).expect("serialize variant"); write_bytes(&legacy, &legacy_before); let status = prepare_storage(&storage, std::slice::from_ref(&legacy)) .expect("return blocked status"); assert!(status.blocking, "{label}"); assert_eq!( status.notice_code.as_deref(), Some("legacy_config_unsupported"), "{label}" ); assert_eq!( fs::read(&legacy).expect("read preserved source"), legacy_before ); for path in [ &storage.paths().profiles_file, &storage.paths().targets_file, &storage.paths().storage_meta_file, &generated_config_path(&storage), &active_journal_path(&storage), ] { assert!( !path.exists(), "unexpected write for {label}: {}", path.display() ); } assert_public_status_safe(&status, &[SECRET_SENTINEL, &legacy.display().to_string()]); } } #[test] fn multiple_supported_legacy_configs_block_without_any_migration_write() { let root = TestRoot::new("ambiguous-legacy-configs"); let storage = JsonStorage::new(root.path().join("data")); let first = root.path().join("legacy-a").join("app-config.json"); let second = root.path().join("legacy-b").join("app-config.json"); let bytes = fixture_bytes("proxifyre-generated/app-config.json"); write_bytes(&first, &bytes); write_bytes(&second, &bytes); let before = snapshot_files(root.path()); let status = prepare_storage(&storage, &[first, second]).expect("return ambiguous status"); assert!(status.blocking); assert_eq!( status.notice_code.as_deref(), Some("legacy_config_ambiguous") ); assert_eq!(snapshot_files(root.path()), before); } #[test] fn every_interrupted_phase_rolls_back_and_archives_the_typed_error() { for phase in all_phases() { let root = TestRoot::new(phase_name(phase)); let storage = JsonStorage::new(root.path().join("data")); let legacy = copy_supported_legacy(root.path()); let legacy_before = fs::read(&legacy).expect("read legacy source"); let tracked = import_tracked_paths(&storage); let generated_backup = backup_path(&generated_config_path(&storage)); for (index, path) in tracked .iter() .filter(|path| is_backup(path) && **path == generated_backup) .enumerate() { write_bytes(path, format!("preexisting-backup-{index}").as_bytes()); } let before = file_states(&tracked); assert!( !storage.paths().storage_meta_file.exists(), "unexpected pre-existing meta for {phase:?}" ); let error = match prepare_storage_with_hook( &storage, std::slice::from_ref(&legacy), &FailAt(phase), ) { Err(error) => error, Ok(status) => panic!("phase hook did not interrupt {phase:?}: {status:?}"), }; assert_eq!(error.code, "migration_interrupted", "{phase:?}"); let active = active_journal_path(&storage); assert!(!active.exists(), "active journal survived {phase:?}"); assert_eq!(file_states(&tracked), before, "{phase:?}"); #[cfg(windows)] safe_fs::verify_path_protected_for_owner_admin_system(&backup_path( &generated_config_path(&storage), )) .expect("migration rollback keeps generated backup restricted"); assert_eq!( fs::read(&legacy).expect("read preserved legacy"), legacy_before ); let archived: Value = serde_json::from_slice( &fs::read(single_archived_journal(&storage, "rolled-back")) .expect("read rolled-back journal"), ) .expect("parse rolled-back journal"); assert_eq!(archived["phase"], phase_name(phase), "{phase:?}"); assert_eq!( archived["lastErrorCode"], "migration_interrupted", "{phase:?}" ); assert!(!recover_incomplete_migration(&storage).expect("repeat recovery")); } } #[test] fn recovery_marks_a_crash_journal_as_unclean_shutdown() { let root = TestRoot::new("unclean-shutdown-journal"); let storage = JsonStorage::new(root.path().join("data")); let legacy = copy_supported_legacy(root.path()); prepare_storage_with_hook( &storage, std::slice::from_ref(&legacy), &FailAt(MigrationPhase::SourceConverted), ) .expect_err("interrupt migration"); let active = active_journal_path(&storage); let rolled_back = single_archived_journal(&storage, "rolled-back"); let mut journal: Value = serde_json::from_slice(&fs::read(&rolled_back).expect("read rolled-back journal")) .expect("parse rolled-back journal"); fs::remove_file(&rolled_back).expect("remove prior archive for crash simulation"); journal["lastErrorCode"] = Value::Null; write_bytes( &active, &serde_json::to_vec_pretty(&journal).expect("serialize crash journal"), ); let migration_id = journal["migrationId"] .as_str() .expect("journal migration id") .to_string(); assert!(recover_incomplete_migration(&storage).expect("recover crash journal")); let archived: Value = serde_json::from_slice( &fs::read(archived_journal_path( &storage, &migration_id, "rolled-back", )) .expect("read rolled-back crash journal"), ) .expect("parse rolled-back crash journal"); assert_eq!(archived["lastErrorCode"], "migration_unclean_shutdown"); } #[test] fn validation_failure_is_archived_with_a_typed_redacted_error_code() { let root = TestRoot::new("validation-error-journal"); let storage = JsonStorage::new(root.path().join("data")); let legacy = copy_supported_legacy(root.path()); let error = prepare_storage_with_hook( &storage, std::slice::from_ref(&legacy), &ReplaceProfilesAfterConversion(storage.paths().profiles_file.clone()), ) .expect_err("validation mismatch must roll back"); assert_eq!(error.code, "migration_failed"); assert!(!active_journal_path(&storage).exists()); let archived: Value = serde_json::from_slice( &fs::read(single_archived_journal(&storage, "rolled-back")) .expect("read validation journal"), ) .expect("parse validation journal"); assert_eq!(archived["lastErrorCode"], "migration_validation_failed"); assert!(!archived.to_string().contains(SECRET_SENTINEL)); } #[test] fn rollback_validates_every_snapshot_before_mutating_any_target() { let root = TestRoot::new("rollback-prevalidation"); let storage = JsonStorage::new(root.path().join("data")); let legacy = copy_supported_legacy(root.path()); write_bytes( &generated_config_path(&storage), b"preexisting-derived-config", ); let generated_backup = backup_path(&generated_config_path(&storage)); for (index, path) in import_tracked_paths(&storage) .into_iter() .filter(|path| is_backup(path) && *path == generated_backup) .enumerate() { write_bytes(&path, format!("preexisting-backup-{index}").as_bytes()); } let error = prepare_storage_with_hook( &storage, std::slice::from_ref(&legacy), &CorruptRollbackSnapshot(active_journal_path(&storage)), ) .expect_err("interrupt migration"); assert_eq!(error.code, "migration_rollback_failed"); let active = active_journal_path(&storage); let journal: Value = serde_json::from_slice(&fs::read(&active).expect("read active journal")) .expect("parse active journal"); let (early_target, _) = rollback_snapshot_pair(&journal); assert_eq!( fs::read(&early_target).expect("read untouched early target"), CORRUPT_ROLLBACK_SENTINEL ); assert!(active.exists()); } #[test] fn next_normal_start_recovers_interruption_retries_and_then_becomes_no_op() { let root = TestRoot::new("automatic-retry"); let storage = JsonStorage::new(root.path().join("data")); let legacy = copy_supported_legacy(root.path()); let legacy_before = fs::read(&legacy).expect("read legacy source"); let first = prepare_storage_with_hook( &storage, std::slice::from_ref(&legacy), &FailAt(MigrationPhase::SourceConverted), ) .expect_err("interrupt first migration"); assert_eq!(first.code, "migration_interrupted"); assert!(!active_journal_path(&storage).exists()); let retry = prepare_storage(&storage, std::slice::from_ref(&legacy)).expect("recover and retry"); assert_eq!(retry.outcome, "imported_legacy_config"); assert!(retry.changed); assert!(!active_journal_path(&storage).exists()); assert_imported_fixture( &storage.read_profiles().expect("read retried profiles"), &storage.read_targets().expect("read retried targets"), ); assert_eq!( fs::read(&legacy).expect("read preserved legacy"), legacy_before ); let after_retry = snapshot_files(storage.paths().root.as_path()); let settled = prepare_storage(&storage, &[legacy]).expect("settled startup"); assert!(!settled.changed); assert_eq!(snapshot_files(storage.paths().root.as_path()), after_retry); } #[test] fn outward_error_never_contains_secret_or_full_legacy_path() { let root = TestRoot::new("redacted-error"); let storage = JsonStorage::new(root.path().join("data")); let legacy = root .path() .join("private-source-path") .join("app-config.json"); let mut value = supported_legacy_json(); value["proxies"][0]["appNames"][0] = SECRET_SENTINEL.into(); write_bytes( &legacy, &serde_json::to_vec_pretty(&value).expect("serialize supported secret fixture"), ); let error = prepare_storage_with_hook( &storage, std::slice::from_ref(&legacy), &FailAt(MigrationPhase::SourceConverted), ) .expect_err("interrupt migration"); assert_public_error_safe(&error, &[SECRET_SENTINEL, &legacy.display().to_string()]); assert!(!recover_incomplete_migration(&storage).expect("migration already rolled back")); } struct FailAt(MigrationPhase); impl MigrationHook for FailAt { fn after_phase(&self, phase: MigrationPhase) -> io::Result<()> { if phase == self.0 { Err(io::Error::other("injected migration interruption")) } else { Ok(()) } } } struct ReplaceProfilesAfterConversion(PathBuf); impl MigrationHook for ReplaceProfilesAfterConversion { fn after_phase(&self, phase: MigrationPhase) -> io::Result<()> { if phase == MigrationPhase::SourceConverted { fs::write(&self.0, b"[]")?; } Ok(()) } } const CORRUPT_ROLLBACK_SENTINEL: &[u8] = b"must-remain-after-failed-prevalidation"; struct CorruptRollbackSnapshot(PathBuf); impl MigrationHook for CorruptRollbackSnapshot { fn after_phase(&self, phase: MigrationPhase) -> io::Result<()> { if phase != MigrationPhase::SourceConverted { return Ok(()); } let journal: Value = serde_json::from_slice(&fs::read(&self.0)?)?; let (early_target, late_snapshot) = rollback_snapshot_pair(&journal); write_bytes(&early_target, CORRUPT_ROLLBACK_SENTINEL); write_bytes(&late_snapshot, b"corrupt-snapshot"); Err(io::Error::other("injected migration interruption")) } } fn rollback_snapshot_pair(journal: &Value) -> (PathBuf, PathBuf) { let existing = journal["trackedFiles"] .as_array() .expect("tracked files") .iter() .filter(|tracked| tracked["existed"] == true) .collect::>(); assert!(existing.len() >= 2, "test requires multiple snapshots"); let early_target = PathBuf::from( existing[0]["targetPath"] .as_str() .expect("early target path"), ); let late_snapshot = PathBuf::from(journal["snapshotDir"].as_str().expect("snapshot directory")) .join( existing.last().expect("late tracked file")["snapshotName"] .as_str() .expect("late snapshot name"), ); (early_target, late_snapshot) } struct TestRoot { path: PathBuf, } impl TestRoot { fn new(label: &str) -> Self { static NEXT_ID: AtomicU64 = AtomicU64::new(0); let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("system clock before unix epoch") .as_nanos(); let path = std::env::temp_dir().join(format!( "proxywarden-migration-{label}-{}-{timestamp}-{}", std::process::id(), NEXT_ID.fetch_add(1, Ordering::Relaxed) )); fs::create_dir(&path).expect("create isolated migration root"); Self { path } } fn path(&self) -> &Path { &self.path } } impl Drop for TestRoot { fn drop(&mut self) { let expected_prefix = "proxywarden-migration-"; if self.path.starts_with(std::env::temp_dir()) && self .path .file_name() .and_then(|name| name.to_str()) .is_some_and(|name| name.starts_with(expected_prefix)) { let _ = fs::remove_dir_all(&self.path); } } } fn fixture_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/legacy") } fn fixture_bytes(relative: &str) -> Vec { fs::read(fixture_root().join(relative)).expect("read Task 0 fixture") } fn supported_legacy_json() -> Value { serde_json::from_slice(&fixture_bytes("proxifyre-generated/app-config.json")) .expect("parse supported Task 0 fixture") } fn copy_supported_legacy(root: &Path) -> PathBuf { let path = root.join("legacy").join("app-config.json"); write_bytes(&path, &fixture_bytes("proxifyre-generated/app-config.json")); path } fn copy_split_fixture(storage: &JsonStorage, file: &str) { write_bytes( &storage.paths().config_dir.join(file), &fixture_bytes(&format!("pre-1.2-split/config/{file}")), ); } fn write_bytes(path: &Path, bytes: &[u8]) { if let Some(parent) = path.parent() { fs::create_dir_all(parent).expect("create fixture parent"); } fs::write(path, bytes).expect("write test fixture"); } fn generated_config_path(storage: &JsonStorage) -> PathBuf { storage .paths() .generated_dir .join("proxifyre-app-config.json") } fn active_journal_path(storage: &JsonStorage) -> PathBuf { storage.paths().migrations_dir.join(ACTIVE_JOURNAL_FILE) } fn archived_journal_path(storage: &JsonStorage, migration_id: &str, suffix: &str) -> PathBuf { storage .paths() .migrations_dir .join(format!("{migration_id}.{suffix}.json")) } fn single_archived_journal(storage: &JsonStorage, suffix: &str) -> PathBuf { let ending = format!(".{suffix}.json"); let matches = fs::read_dir(&storage.paths().migrations_dir) .expect("read migrations directory") .map(|entry| entry.expect("read migration entry").path()) .filter(|path| { path.file_name() .and_then(|name| name.to_str()) .is_some_and(|name| name.ends_with(&ending)) }) .collect::>(); assert_eq!(matches.len(), 1, "expected one {suffix} journal"); matches[0].clone() } fn snapshot_files(root: &Path) -> BTreeMap> { let mut files = BTreeMap::new(); collect_files(root, root, &mut files); files } fn snapshot_without_meta(root: &Path) -> BTreeMap> { snapshot_files(root) .into_iter() .filter(|(path, _)| path != Path::new("storage-meta.json")) .collect() } fn collect_files(base: &Path, current: &Path, output: &mut BTreeMap>) { let Ok(entries) = fs::read_dir(current) else { return; }; let mut paths = entries .map(|entry| entry.expect("read snapshot entry").path()) .collect::>(); paths.sort(); for path in paths { // Coordination metadata may be created by a read; source and journal bytes must remain unchanged. if path.ends_with(Path::new("state/migrations/storage-migration.lock")) { continue; } if path.is_dir() { collect_files(base, &path, output); } else { output.insert( path.strip_prefix(base) .expect("snapshot path below root") .to_path_buf(), fs::read(&path).expect("read snapshot file"), ); } } } fn import_tracked_paths(storage: &JsonStorage) -> Vec { let originals = [ storage.paths().profiles_file.clone(), storage.paths().targets_file.clone(), storage.paths().storage_meta_file.clone(), generated_config_path(storage), ]; originals .into_iter() .flat_map(|path| [path.clone(), backup_path(&path)]) .collect() } fn file_states(paths: &[PathBuf]) -> BTreeMap>> { paths .iter() .map(|path| { let value = match fs::read(path) { Ok(bytes) => Some(bytes), Err(error) if error.kind() == io::ErrorKind::NotFound => None, Err(error) => panic!("failed to read {}: {error}", path.display()), }; (path.clone(), value) }) .collect() } fn is_backup(path: &Path) -> bool { path.file_name() .and_then(|name| name.to_str()) .is_some_and(|name| name.ends_with(".bak")) } fn all_phases() -> [MigrationPhase; 5] { [ MigrationPhase::BackedUp, MigrationPhase::SourceConverted, MigrationPhase::GeneratedValidated, MigrationPhase::StorageVerified, MigrationPhase::StorageComplete, ] } fn phase_name(phase: MigrationPhase) -> &'static str { match phase { MigrationPhase::BackedUp => "backed_up", MigrationPhase::SourceConverted => "source_converted", MigrationPhase::GeneratedValidated => "generated_validated", MigrationPhase::StorageVerified => "storage_verified", MigrationPhase::StorageComplete => "storage_complete", } } fn assert_imported_fixture(profiles: &[Profile], targets: &[Target]) { assert_eq!(profiles.len(), 1); assert_eq!(targets.len(), 1); let profile = &profiles[0]; let target = &targets[0]; assert_eq!(profile.id, "main-profile"); assert_eq!(profile.target_id, "main-proxy"); assert_eq!(profile.protocols, vec![Protocol::Tcp, Protocol::Udp]); assert_eq!(profile.items.len(), 3); assert_eq!(profile.items[0].item_type, ProfileItemType::Process); assert_eq!(profile.items[1].item_type, ProfileItemType::Exe); assert_eq!(profile.items[2].item_type, ProfileItemType::Folder); assert!(profile.items[2].recursive); assert_eq!(target.id, "main-proxy"); assert_eq!(target.host, "proxy.example.test"); assert_eq!(target.port, 1080); } fn assert_public_status_safe(status: &StorageMigrationStatusDto, forbidden: &[&str]) { let text = format!( "{} {} {} {}", status.outcome, status.notice_code.as_deref().unwrap_or_default(), status.message, status.storage_schema_version ); assert_forbidden_absent(&text, forbidden); } fn assert_public_error_safe(error: &CommandError, forbidden: &[&str]) { let mut text = format!("{} {}", error.code, error.message); for detail in &error.details { text.push_str(&detail.field); text.push_str(&detail.message); } assert_forbidden_absent(&text, forbidden); } fn assert_forbidden_absent(text: &str, forbidden: &[&str]) { for value in forbidden.iter().filter(|value| !value.is_empty()) { assert!( !text.contains(value), "public migration text leaked a forbidden value" ); } }