Files
craft-libs/crates/settings/tests/recovery.rs
2026-11-02 22:48:25 -05:00

220 lines
9.4 KiB
Rust

mod common;
use common::{Theme, config_path, open_in, read_config, write_config};
use craft_settings::{ReadOnlyReason, SettingsError, Source};
#[test]
fn syntax_error_moves_the_file_aside_and_uses_defaults() {
let tmp = tempfile::tempdir().unwrap();
write_config(tmp.path(), "schema_version = 2\n[general\nlanguage = \"de\"\n");
let (mut store, report) = open_in(tmp.path());
assert_eq!(report.source, Source::Defaults);
assert!(!store.is_read_only());
let moved = report.broken_moved_to.clone().expect("moved aside");
assert!(moved.file_name().unwrap().to_str().unwrap().starts_with("config.broken-"), "{moved:?}");
assert!(moved.exists());
assert!(!config_path(tmp.path()).exists());
assert_eq!(store.get().general.language, "en");
store.flush().unwrap();
assert!(moved.exists(), "the damaged copy is never deleted");
assert!(read_config(tmp.path()).starts_with("schema_version = 2\n"));
}
#[test]
fn two_broken_files_get_different_names() {
let tmp = tempfile::tempdir().unwrap();
write_config(tmp.path(), "not = [toml");
let (_, first) = open_in(tmp.path());
write_config(tmp.path(), "still = [broken");
let (_, second) = open_in(tmp.path());
assert_ne!(first.broken_moved_to, second.broken_moved_to);
assert!(second.broken_moved_to.unwrap().exists());
}
#[test]
fn bad_value_only_resets_its_section() {
let tmp = tempfile::tempdir().unwrap();
write_config(tmp.path(), "schema_version = 2\n[general]\nlanguage = \"de\"\n\n[appearance]\ntheme = \"sepia\"\nui_scale = 2.0\n");
let (store, report) = open_in(tmp.path());
assert_eq!(report.source, Source::File);
assert!(report.broken_moved_to.is_none());
assert!(!store.is_read_only());
assert_eq!(report.section_errors.len(), 1, "{report:?}");
assert_eq!(report.section_errors[0].section, "appearance");
assert_eq!(store.get().general.language, "de", "the good section survives");
assert_eq!(store.get().appearance.theme, Theme::Light, "the bad section falls back to defaults");
assert!(config_path(tmp.path()).exists(), "the file is not moved aside");
}
#[test]
fn many_unknown_keys_load_in_linear_time() {
// Each key was once tried against every key accepted before it.
let tmp = tempfile::tempdir().unwrap();
let mut text = String::from("schema_version = 2\n");
for i in 0..20_000 {
text.push_str(&format!("unknown_{i} = \"{}\"\n", "x".repeat(64)));
}
text.push_str("[general]\nlanguage = \"de\"\n");
write_config(tmp.path(), &text);
let started = std::time::Instant::now();
let (store, report) = open_in(tmp.path());
assert!(report.section_errors.is_empty(), "{report:?}");
assert_eq!(store.get().general.language, "de");
assert!(started.elapsed() < std::time::Duration::from_secs(20), "{:?}", started.elapsed());
}
#[test]
fn newer_schema_opens_read_only_and_never_overwrites() {
let tmp = tempfile::tempdir().unwrap();
let original = "schema_version = 9\n[general]\nlanguage = \"de\"\n";
write_config(tmp.path(), original);
let (mut store, report) = open_in(tmp.path());
assert!(matches!(report.read_only, Some(ReadOnlyReason::NewerSchema { file: 9, app: 2 })), "{report:?}");
assert_eq!(store.get().general.language, "de", "values are still shown");
store.update(|p| p.general.language = "fr".into());
assert!(matches!(store.flush(), Err(SettingsError::ReadOnly(_))));
assert_eq!(read_config(tmp.path()), original);
}
#[test]
fn failed_migration_opens_read_only_and_leaves_the_file() {
let tmp = tempfile::tempdir().unwrap();
let original = "schema_version = 0\n[general]\nlanguage = \"de\"\n";
write_config(tmp.path(), original);
let (mut store, report) = open_in(tmp.path());
assert!(matches!(report.read_only, Some(ReadOnlyReason::Migration(_))), "{report:?}");
assert!(matches!(store.flush(), Err(SettingsError::ReadOnly(_))));
assert_eq!(read_config(tmp.path()), original);
}
#[cfg(unix)]
#[test]
fn damaged_file_that_cannot_be_moved_is_left_alone() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
write_config(tmp.path(), "not = [toml");
let dir = tmp.path();
if !common::restrict_directory_writes(dir) {
return;
}
let (mut store, report) = open_in(dir);
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)).unwrap();
assert!(store.is_read_only(), "{report:?}");
assert!(matches!(store.flush(), Err(SettingsError::ReadOnly(_))));
assert_eq!(read_config(dir), "not = [toml");
}
// --- Review on #22: the backup must never be replaced by a damaged or emptied file. ---
#[test]
fn file_damaged_while_running_is_moved_aside_and_backup_kept() {
let tmp = tempfile::tempdir().unwrap();
let (mut store, _) = open_in(tmp.path());
store.update(|p| p.general.language = "fr".into());
store.flush().unwrap();
store.update(|p| p.general.language = "es".into());
store.flush().unwrap();
let good_backup = std::fs::read_to_string(tmp.path().join("config.toml.bak")).unwrap();
assert!(good_backup.contains("language = \"fr\""));
// A hand edit with a typo while the app is running.
write_config(tmp.path(), "schema_version = 2\n[general\nlanguage = \"it\"\n");
store.update(|p| p.general.language = "pt".into());
store.flush().unwrap();
assert_eq!(std::fs::read_to_string(tmp.path().join("config.toml.bak")).unwrap(), good_backup, "backup untouched");
let moved = store.take_moved_aside();
assert_eq!(moved.len(), 1);
assert_eq!(std::fs::read_to_string(&moved[0]).unwrap(), "schema_version = 2\n[general\nlanguage = \"it\"\n", "the user's edit is kept");
assert!(read_config(tmp.path()).contains("language = \"pt\""));
assert!(store.take_moved_aside().is_empty(), "drained");
}
#[test]
fn emptied_file_does_not_replace_the_backup() {
let tmp = tempfile::tempdir().unwrap();
let (mut store, _) = open_in(tmp.path());
store.update(|p| p.general.language = "fr".into());
store.flush().unwrap();
store.update(|p| p.general.language = "es".into());
store.flush().unwrap();
let good_backup = std::fs::read_to_string(tmp.path().join("config.toml.bak")).unwrap();
write_config(tmp.path(), "");
store.update(|p| p.general.language = "pt".into());
store.flush().unwrap();
assert_eq!(std::fs::read_to_string(tmp.path().join("config.toml.bak")).unwrap(), good_backup);
assert!(read_config(tmp.path()).contains("language = \"pt\""));
}
#[test]
fn newer_build_writing_while_running_is_not_overwritten() {
let tmp = tempfile::tempdir().unwrap();
let (mut store, _) = open_in(tmp.path());
store.flush().unwrap();
let newer = "schema_version = 9\n[general]\nlanguage = \"de\"\n";
write_config(tmp.path(), newer);
store.update(|p| p.general.language = "fr".into());
assert!(matches!(store.flush(), Err(SettingsError::ReadOnly(_))));
assert!(store.is_read_only());
assert_eq!(read_config(tmp.path()), newer);
}
// --- Review on #22: a failed migration must not leave half-migrated values in memory. ---
#[cfg(unix)]
#[test]
fn failed_migration_in_an_unwritable_folder_shows_defaults() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
// Version 0 has no migration step, so upgrading fails.
write_config(tmp.path(), "schema_version = 0\n[general]\nlanguage = \"de\"\n");
if !common::restrict_directory_writes(tmp.path()) {
return;
}
let (store, report) = open_in(tmp.path());
std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
assert!(matches!(report.read_only, Some(ReadOnlyReason::DirNotWritable(_))), "{report:?}");
assert_eq!(store.get().general.language, "en", "defaults, not half-migrated values");
assert_eq!(report.source, Source::Defaults);
}
// --- Review on #22 (skylartaylor): an outside file whose migration fails must not be overwritten. ---
#[test]
fn outside_file_with_failing_migration_is_not_overwritten() {
let tmp = tempfile::tempdir().unwrap();
let (mut store, _) = open_in(tmp.path());
store.flush().unwrap();
// Another writer leaves a file at a version this build cannot migrate (0 has no step).
let external = "schema_version = 0\n[general]\nlanguage = 'external'\n";
write_config(tmp.path(), external);
store.update(|p| p.general.language = "local".into());
let result = store.flush();
assert!(matches!(result, Err(SettingsError::ReadOnly(_))), "{result:?}");
assert!(store.is_read_only());
assert_eq!(read_config(tmp.path()), external, "the outside file is left untouched");
assert!(matches!(store.tick(std::time::Instant::now()).status, craft_settings::SaveStatus::Idle), "no retries once read-only");
}
#[test]
fn an_emptied_file_is_not_adopted_over_the_loaded_document() {
let tmp = tempfile::tempdir().unwrap();
write_config(tmp.path(), "schema_version = 2\n\n# my note\n[plugins]\nenabled = true\n\n[general]\nlanguage = \"de\"\n");
let (mut store, _) = open_in(tmp.path());
store.update(|p| p.general.language = "fr".into());
store.flush().unwrap();
// Emptied while the app runs: an editor crash, a sync tool, a hand `truncate`.
write_config(tmp.path(), "");
store.update(|p| p.general.language = "es".into());
store.flush().unwrap();
let text = read_config(tmp.path());
assert!(text.contains("# my note"), "{text}");
assert!(text.contains("[plugins]"), "{text}");
assert!(text.contains("language = \"es\""), "{text}");
}