1133 lines
41 KiB
Rust
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, ¤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::<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"
|
|
);
|
|
}
|
|
}
|