Files
dokril efda8eb98f
CI / Windows baseline (push) Canceled after 0s
Release v2.0.0
2026-09-10 20:59:52 +03:00

1133 lines
41 KiB
Rust

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, &current)
.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, &current)
.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::<Vec<_>>();
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<u8> {
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::<Vec<_>>();
assert_eq!(matches.len(), 1, "expected one {suffix} journal");
matches[0].clone()
}
fn snapshot_files(root: &Path) -> BTreeMap<PathBuf, Vec<u8>> {
let mut files = BTreeMap::new();
collect_files(root, root, &mut files);
files
}
fn snapshot_without_meta(root: &Path) -> BTreeMap<PathBuf, Vec<u8>> {
snapshot_files(root)
.into_iter()
.filter(|(path, _)| path != Path::new("storage-meta.json"))
.collect()
}
fn collect_files(base: &Path, current: &Path, output: &mut BTreeMap<PathBuf, Vec<u8>>) {
let Ok(entries) = fs::read_dir(current) else {
return;
};
let mut paths = entries
.map(|entry| entry.expect("read snapshot entry").path())
.collect::<Vec<_>>();
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<PathBuf> {
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<PathBuf, Option<Vec<u8>>> {
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"
);
}
}