Files
2026-07-13 13:12:33 +08:00

748 lines
26 KiB
Python

from __future__ import annotations
import errno
import json
import os
import subprocess
import sys
import uuid
from pathlib import Path
import pytest
from opensquilla.recovery import inspect_profile
from opensquilla.recovery.errors import AtomicStateUnknownError, RecoveryError
from opensquilla.recovery.settings_transaction import (
apply_desktop_settings,
recover_desktop_settings,
settings_transaction_journal,
)
class SimulatedProcessCrash(BaseException):
pass
@pytest.fixture(autouse=True)
def _isolated_profile_locks(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setenv("OPENSQUILLA_USER_STATE_DIR", str(tmp_path / "user-state"))
monkeypatch.setenv("OPENSQUILLA_PROFILE_KIND", "desktop-primary")
def _profile(tmp_path: Path) -> tuple[Path, Path, str, str]:
user_data = tmp_path / "user-data"
home = user_data / "opensquilla"
workspace = home / "workspace"
state = home / "state"
workspace.mkdir(parents=True)
state.mkdir()
(workspace / "SOUL.md").write_text("synthetic identity\n", encoding="utf-8")
config = (
f"state_dir = {json.dumps(str(state))}\n"
f"workspace_dir = {json.dumps(str(workspace))}\n"
'search_provider = "duckduckgo"\n\n'
'[llm]\nprovider = "ollama"\nmodel = "old-model"\n'
)
credential = json.dumps(
{
"provider": "ollama",
"model": "old-model",
"encryptedApiKey": "synthetic-old-ciphertext",
},
sort_keys=True,
)
# The settings protocol performs a byte-exact CAS. Keep fixture bytes
# platform-independent instead of letting Windows text I/O inject CRLF.
(home / "config.toml").write_bytes(config.encode())
(user_data / "desktop-credential.json").write_bytes(credential.encode())
return home, user_data / "desktop-credential.json", config, credential
def _new_pair(home: Path) -> tuple[str, str]:
old_config = (home / "config.toml").read_text(encoding="utf-8")
config = old_config.replace('model = "old-model"', 'model = "new-model"')
credential = json.dumps(
{
"provider": "ollama",
"model": "new-model",
"encryptedApiKey": "synthetic-new-ciphertext",
},
sort_keys=True,
)
return config, credential
def _apply(
home: Path,
*,
old_config: str | None,
old_credential: str | None,
new_config: str,
new_credential: str,
failpoint=None,
):
report = inspect_profile(home, profile_kind="desktop-primary")
return apply_desktop_settings(
home,
transaction_id=report.transaction_id,
expected_revision=report.revision,
payload={
"expected_config": old_config,
"config": new_config,
"expected_credential": old_credential,
"credential": new_credential,
},
_failpoint=failpoint,
)
def _imported_credential(transaction_id: str) -> str:
return json.dumps(
{
"provider": "ollama",
"model": "old-model",
"baseUrl": "http://127.0.0.1:11434/v1",
"apiKeyEnv": "",
"encryptedApiKey": "synthetic-imported-ciphertext",
"configAuthority": "profile",
"importTransactionId": transaction_id,
},
sort_keys=True,
)
def test_settings_pair_is_committed_without_secret_bearing_journal(tmp_path: Path) -> None:
home, credential_path, old_config, old_credential = _profile(tmp_path)
new_config, new_credential = _new_pair(home)
report = _apply(
home,
old_config=old_config,
old_credential=old_credential,
new_config=new_config,
new_credential=new_credential,
)
assert report.outcome == "ready"
assert (home / "config.toml").read_text(encoding="utf-8") == new_config
assert credential_path.read_text(encoding="utf-8") == new_credential
assert not settings_transaction_journal(home).exists()
assert not list(home.glob(".config.toml.*"))
assert not list(credential_path.parent.glob(".desktop-credential.json.*"))
def test_imported_settings_retain_exact_owner_only_credential_backup(tmp_path: Path) -> None:
home, credential_path, old_config, old_credential = _profile(tmp_path)
import_transaction_id = str(uuid.uuid4())
new_credential = _imported_credential(import_transaction_id)
report = _apply(
home,
old_config=old_config,
old_credential=old_credential,
new_config=old_config,
new_credential=new_credential,
)
backup = credential_path.with_name(
f"desktop-credential.import-backup.{import_transaction_id}.json"
)
assert report.outcome == "ready"
assert credential_path.read_text(encoding="utf-8") == new_credential
assert backup.read_text(encoding="utf-8") == old_credential
if callable(getattr(os, "fchmod", None)):
assert backup.stat().st_mode & 0o777 == 0o600
assert not settings_transaction_journal(home).exists()
def test_imported_settings_backup_survives_failpoint_recovery(tmp_path: Path) -> None:
home, credential_path, old_config, old_credential = _profile(tmp_path)
import_transaction_id = str(uuid.uuid4())
new_credential = _imported_credential(import_transaction_id)
def crash_after_parking(phase: str) -> None:
if phase == "prepared":
raise SimulatedProcessCrash
with pytest.raises(SimulatedProcessCrash):
_apply(
home,
old_config=old_config,
old_credential=old_credential,
new_config=old_config,
new_credential=new_credential,
failpoint=crash_after_parking,
)
backup = credential_path.with_name(
f"desktop-credential.import-backup.{import_transaction_id}.json"
)
assert backup.read_text(encoding="utf-8") == old_credential
recovered = recover_desktop_settings(home)
assert recovered.outcome == "ready"
assert credential_path.read_text(encoding="utf-8") == new_credential
assert backup.read_text(encoding="utf-8") == old_credential
if callable(getattr(os, "fchmod", None)):
assert backup.stat().st_mode & 0o777 == 0o600
assert not settings_transaction_journal(home).exists()
def test_imported_settings_reject_preexisting_backup_before_journal(tmp_path: Path) -> None:
home, credential_path, old_config, old_credential = _profile(tmp_path)
import_transaction_id = str(uuid.uuid4())
new_credential = _imported_credential(import_transaction_id)
backup = credential_path.with_name(
f"desktop-credential.import-backup.{import_transaction_id}.json"
)
backup.write_text("synthetic-existing-backup\n", encoding="utf-8")
with pytest.raises(RecoveryError) as caught:
_apply(
home,
old_config=old_config,
old_credential=old_credential,
new_config=old_config,
new_credential=new_credential,
)
assert caught.value.stable_code == "settings_credential_backup_exists"
assert (home / "config.toml").read_text(encoding="utf-8") == old_config
assert credential_path.read_text(encoding="utf-8") == old_credential
assert backup.read_text(encoding="utf-8") == "synthetic-existing-backup\n"
assert not settings_transaction_journal(home).exists()
def test_imported_settings_preserve_windows_acl_when_fchmod_is_unavailable(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
import opensquilla.recovery.settings_transaction as transaction
home, credential_path, old_config, old_credential = _profile(tmp_path)
credential_path.chmod(0o666)
import_transaction_id = str(uuid.uuid4())
monkeypatch.setattr(transaction.os, "fchmod", None, raising=False)
_apply(
home,
old_config=old_config,
old_credential=old_credential,
new_config=old_config,
new_credential=_imported_credential(import_transaction_id),
)
backup = credential_path.with_name(
f"desktop-credential.import-backup.{import_transaction_id}.json"
)
assert backup.read_text(encoding="utf-8") == old_credential
assert backup.stat().st_mode & 0o777 == 0o666
def test_credential_hardening_does_not_fsync_read_only_windows_descriptor(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
import opensquilla.recovery.settings_transaction as transaction
_, credential_path, _, old_credential = _profile(tmp_path)
credential_path.chmod(0o666)
monkeypatch.setattr(transaction.os, "fchmod", None, raising=False)
monkeypatch.setattr(
transaction.os,
"fsync",
lambda _fd: pytest.fail("Windows-like credential hardening must not fsync O_RDONLY"),
)
transaction._restrict_existing_credential_to_owner(credential_path)
assert credential_path.read_text(encoding="utf-8") == old_credential
assert credential_path.stat().st_mode & 0o777 == 0o666
@pytest.mark.parametrize(
"credential_payload",
[
{"configAuthority": "profile", "importTransactionId": ""},
{"configAuthority": "profile", "importTransactionId": "not-a-uuid"},
{"configAuthority": "generated", "importTransactionId": str(uuid.uuid4())},
],
)
def test_settings_reject_invalid_credential_authority_binding(
tmp_path: Path,
credential_payload: dict[str, str],
) -> None:
home, credential_path, old_config, old_credential = _profile(tmp_path)
credential_payload.update(
{
"provider": "ollama",
"model": "synthetic",
"encryptedApiKey": "synthetic-ciphertext",
}
)
with pytest.raises(RecoveryError) as caught:
_apply(
home,
old_config=old_config,
old_credential=old_credential,
new_config=old_config,
new_credential=json.dumps(credential_payload, sort_keys=True),
)
assert caught.value.stable_code == "settings_credential_invalid"
assert credential_path.read_text(encoding="utf-8") == old_credential
assert not settings_transaction_journal(home).exists()
def test_imported_credential_must_match_the_exact_candidate_config(tmp_path: Path) -> None:
home, credential_path, old_config, old_credential = _profile(tmp_path)
import_transaction_id = str(uuid.uuid4())
mismatched = json.loads(_imported_credential(import_transaction_id))
mismatched["provider"] = "openai"
with pytest.raises(RecoveryError) as caught:
_apply(
home,
old_config=old_config,
old_credential=old_credential,
new_config=old_config,
new_credential=json.dumps(mismatched, sort_keys=True),
)
assert caught.value.stable_code == "settings_credential_config_mismatch"
assert (home / "config.toml").read_text(encoding="utf-8") == old_config
assert credential_path.read_text(encoding="utf-8") == old_credential
assert not settings_transaction_journal(home).exists()
@pytest.mark.parametrize("crash_phase", ["prepared", "credential_published", "config_published"])
def test_crash_is_detected_and_identity_proven_pair_is_recovered(
tmp_path: Path,
crash_phase: str,
) -> None:
home, credential_path, old_config, old_credential = _profile(tmp_path)
new_config, new_credential = _new_pair(home)
def crash(phase: str) -> None:
if phase == crash_phase:
raise SimulatedProcessCrash
with pytest.raises(SimulatedProcessCrash):
_apply(
home,
old_config=old_config,
old_credential=old_credential,
new_config=new_config,
new_credential=new_credential,
failpoint=crash,
)
journal = settings_transaction_journal(home)
journal_text = journal.read_text(encoding="utf-8")
assert "synthetic-new-ciphertext" not in journal_text
blocked = inspect_profile(home, profile_kind="desktop-primary")
assert blocked.outcome == "recovery_required"
assert blocked.stable_code == "settings_transaction_incomplete"
assert "recover-settings" in blocked.allowed_actions
recovered = recover_desktop_settings(home)
assert recovered.outcome == "ready"
assert (home / "config.toml").read_text(encoding="utf-8") == new_config
assert credential_path.read_text(encoding="utf-8") == new_credential
assert not journal.exists()
def test_enospc_during_publication_rolls_back_both_files(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
import opensquilla.recovery.settings_transaction as transaction
home, credential_path, old_config, old_credential = _profile(tmp_path)
new_config, new_credential = _new_pair(home)
original_publish = transaction._publish
calls = 0
def fail_second_publish(*args, **kwargs) -> None:
nonlocal calls
calls += 1
if calls == 2:
raise OSError(errno.ENOSPC, "synthetic disk full during config publication")
original_publish(*args, **kwargs)
monkeypatch.setattr(transaction, "_publish", fail_second_publish)
with pytest.raises(RecoveryError) as caught:
_apply(
home,
old_config=old_config,
old_credential=old_credential,
new_config=new_config,
new_credential=new_credential,
)
assert caught.value.stable_code == "settings_apply_failed"
assert (home / "config.toml").read_text(encoding="utf-8") == old_config
assert credential_path.read_text(encoding="utf-8") == old_credential
assert not settings_transaction_journal(home).exists()
def test_stale_electron_preflight_never_overwrites_new_gateway_config(tmp_path: Path) -> None:
home, credential_path, old_config, old_credential = _profile(tmp_path)
new_config, new_credential = _new_pair(home)
report = inspect_profile(home, profile_kind="desktop-primary")
gateway_config = old_config + "\nlog_level = \"debug\"\n"
(home / "config.toml").write_text(gateway_config, encoding="utf-8")
with pytest.raises(RecoveryError) as caught:
apply_desktop_settings(
home,
transaction_id=report.transaction_id,
expected_revision=report.revision,
payload={
"expected_config": old_config,
"config": new_config,
"expected_credential": old_credential,
"credential": new_credential,
},
)
assert caught.value.stable_code == "stale_recovery_transaction"
assert (home / "config.toml").read_text(encoding="utf-8") == gateway_config
assert credential_path.read_text(encoding="utf-8") == old_credential
def test_settings_publication_preserves_a_mutation_during_old_file_parking(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
import opensquilla.recovery.settings_transaction as transaction
home, credential_path, old_config, old_credential = _profile(tmp_path)
config_path = home / "config.toml"
new_config, new_credential = _new_pair(home)
concurrent_config = old_config + "\nexternal_edit = true\n"
original_move = transaction._durable_move_no_replace
def mutate_during_park(source: Path, destination: Path) -> None:
if source == config_path and destination.name.endswith(".old"):
source.write_text(concurrent_config, encoding="utf-8")
original_move(source, destination)
monkeypatch.setattr(transaction, "_durable_move_no_replace", mutate_during_park)
with pytest.raises(AtomicStateUnknownError):
_apply(
home,
old_config=old_config,
old_credential=old_credential,
new_config=new_config,
new_credential=new_credential,
)
journal = settings_transaction_journal(home)
payload = json.loads(journal.read_text(encoding="utf-8"))
config_backup = Path(payload["paths"]["config_backup"])
credential_backup = Path(payload["paths"]["credential_backup"])
assert not config_path.exists()
assert config_backup.read_text(encoding="utf-8") == concurrent_config
assert not credential_path.exists()
assert credential_backup.read_text(encoding="utf-8") == old_credential
assert inspect_profile(home).stable_code == "settings_transaction_incomplete"
def test_settings_save_cannot_redirect_workspace_or_chat_state(tmp_path: Path) -> None:
home, credential_path, old_config, old_credential = _profile(tmp_path)
new_config, new_credential = _new_pair(home)
redirected = new_config.replace(
json.dumps(str(home / "state")),
json.dumps(str(tmp_path / "other-state")),
)
assert redirected != new_config
with pytest.raises(RecoveryError) as caught:
_apply(
home,
old_config=old_config,
old_credential=old_credential,
new_config=redirected,
new_credential=new_credential,
)
assert caught.value.stable_code == "settings_data_root_changed"
assert (home / "config.toml").read_text(encoding="utf-8") == old_config
assert credential_path.read_text(encoding="utf-8") == old_credential
def test_settings_save_cannot_redirect_attachment_media_root(tmp_path: Path) -> None:
home, credential_path, old_config, old_credential = _profile(tmp_path)
media_root = home / "media"
protected_config = (
old_config
+ "\n[attachments]\n"
+ f"media_root = {json.dumps(str(media_root))}\n"
)
(home / "config.toml").write_bytes(protected_config.encode())
new_config, new_credential = _new_pair(home)
redirected = new_config.replace(
json.dumps(str(media_root)),
json.dumps(str(tmp_path / "other-media")),
)
assert redirected != new_config
with pytest.raises(RecoveryError) as caught:
_apply(
home,
old_config=protected_config,
old_credential=old_credential,
new_config=redirected,
new_credential=new_credential,
)
assert caught.value.stable_code == "settings_data_root_changed"
assert (home / "config.toml").read_text(encoding="utf-8") == protected_config
assert credential_path.read_text(encoding="utf-8") == old_credential
def test_unknown_settings_journal_phase_is_preserved_for_manual_recovery(
tmp_path: Path,
) -> None:
home, credential_path, old_config, old_credential = _profile(tmp_path)
new_config, new_credential = _new_pair(home)
def crash_after_prepare(phase: str) -> None:
if phase == "prepared":
raise SimulatedProcessCrash
with pytest.raises(SimulatedProcessCrash):
_apply(
home,
old_config=old_config,
old_credential=old_credential,
new_config=new_config,
new_credential=new_credential,
failpoint=crash_after_prepare,
)
journal = settings_transaction_journal(home)
payload = json.loads(journal.read_text(encoding="utf-8"))
payload["phase"] = "future-phase"
journal.write_text(json.dumps(payload, sort_keys=True) + "\n", encoding="utf-8")
artifacts_before = sorted(path.name for path in home.parent.glob(".*-transaction.json"))
with pytest.raises(RecoveryError) as caught:
recover_desktop_settings(home)
assert caught.value.stable_code == "settings_transaction_invalid"
assert journal.exists()
assert sorted(path.name for path in home.parent.glob(".*-transaction.json")) == artifacts_before
assert not (home / "config.toml").exists()
assert not credential_path.exists()
assert Path(payload["paths"]["config_backup"]).read_text(encoding="utf-8") == old_config
assert (
Path(payload["paths"]["credential_backup"]).read_text(encoding="utf-8")
== old_credential
)
def test_settings_recovery_rejects_a_phase_impossible_publication_order(
tmp_path: Path,
) -> None:
home, credential_path, old_config, old_credential = _profile(tmp_path)
new_config, new_credential = _new_pair(home)
def crash_after_prepare(phase: str) -> None:
if phase == "prepared":
raise SimulatedProcessCrash
with pytest.raises(SimulatedProcessCrash):
_apply(
home,
old_config=old_config,
old_credential=old_credential,
new_config=new_config,
new_credential=new_credential,
failpoint=crash_after_prepare,
)
journal = settings_transaction_journal(home)
payload = json.loads(journal.read_text(encoding="utf-8"))
config_new = Path(payload["paths"]["config_new"])
os.replace(config_new, home / "config.toml")
with pytest.raises(AtomicStateUnknownError):
recover_desktop_settings(home)
assert journal.exists()
assert not credential_path.exists()
assert (
Path(payload["paths"]["credential_backup"]).read_text(encoding="utf-8")
== old_credential
)
assert (home / "config.toml").read_text(encoding="utf-8") == new_config
def test_fresh_onboarding_initializes_only_canonical_roots(tmp_path: Path) -> None:
user_data = tmp_path / "user-data"
user_data.mkdir()
home = user_data / "opensquilla"
config = (
f"state_dir = {json.dumps(str(home / 'state'))}\n"
'search_provider = "duckduckgo"\n\n'
'[llm]\nprovider = "ollama"\nmodel = "synthetic"\n'
)
credential = json.dumps({"provider": "ollama", "model": "synthetic"})
report = _apply(
home,
old_config=None,
old_credential=None,
new_config=config,
new_credential=credential,
)
assert report.outcome == "ready"
assert (home / "workspace").is_dir()
assert (home / "state").is_dir()
assert (home / "config.toml").read_text(encoding="utf-8") == config
assert (user_data / "desktop-credential.json").read_text(encoding="utf-8") == credential
def test_crashed_fresh_onboarding_recovers_canonical_roots_and_pair(tmp_path: Path) -> None:
user_data = tmp_path / "user-data"
user_data.mkdir()
home = user_data / "opensquilla"
config = (
f"state_dir = {json.dumps(str(home / 'state'))}\n"
'search_provider = "duckduckgo"\n\n'
'[llm]\nprovider = "ollama"\nmodel = "synthetic"\n'
)
credential = json.dumps({"provider": "ollama", "model": "synthetic"})
def crash_after_prepare(phase: str) -> None:
if phase == "prepared":
raise SimulatedProcessCrash
with pytest.raises(SimulatedProcessCrash):
_apply(
home,
old_config=None,
old_credential=None,
new_config=config,
new_credential=credential,
failpoint=crash_after_prepare,
)
assert inspect_profile(home).stable_code == "settings_transaction_incomplete"
recovered = recover_desktop_settings(home)
assert recovered.outcome == "ready"
assert (home / "workspace").is_dir()
assert (home / "state").is_dir()
assert (home / "config.toml").read_text(encoding="utf-8") == config
assert (user_data / "desktop-credential.json").read_text(encoding="utf-8") == credential
def test_settings_writer_refuses_cross_process_profile_lock(
tmp_path: Path,
) -> None:
home, credential_path, old_config, old_credential = _profile(tmp_path)
new_config, new_credential = _new_pair(home)
report = inspect_profile(home, profile_kind="desktop-primary")
child = subprocess.Popen(
[
sys.executable,
"-c",
(
"import sys\n"
"from opensquilla.recovery.locking import ProfileOperationLock\n"
"with ProfileOperationLock(sys.argv[1]):\n"
" print('locked', flush=True)\n"
" sys.stdin.readline()\n"
),
str(home),
],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
env={
**dict(os.environ),
"OPENSQUILLA_USER_STATE_DIR": str(tmp_path / "user-state"),
"OPENSQUILLA_TEST": "1",
},
)
try:
assert child.stdout is not None
assert child.stdout.readline().strip() == "locked"
with pytest.raises(RecoveryError) as caught:
apply_desktop_settings(
home,
transaction_id=report.transaction_id,
expected_revision=report.revision,
payload={
"expected_config": old_config,
"config": new_config,
"expected_credential": old_credential,
"credential": new_credential,
},
)
assert caught.value.stable_code == "profile_lock_busy"
assert (home / "config.toml").read_text(encoding="utf-8") == old_config
assert credential_path.read_text(encoding="utf-8") == old_credential
finally:
if child.stdin is not None:
child.stdin.write("done\n")
child.stdin.flush()
child.communicate(timeout=10)
def test_settings_transaction_never_mutates_an_ordinary_cli_profile(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
home, credential_path, old_config, old_credential = _profile(tmp_path)
new_config, new_credential = _new_pair(home)
report = inspect_profile(home, profile_kind="desktop-primary")
monkeypatch.delenv("OPENSQUILLA_PROFILE_KIND", raising=False)
with pytest.raises(RecoveryError) as caught:
apply_desktop_settings(
home,
transaction_id=report.transaction_id,
expected_revision=report.revision,
payload={
"expected_config": old_config,
"config": new_config,
"expected_credential": old_credential,
"credential": new_credential,
},
)
assert caught.value.stable_code == "settings_profile_kind_invalid"
assert (home / "config.toml").read_text(encoding="utf-8") == old_config
assert credential_path.read_text(encoding="utf-8") == old_credential
def test_windows_settings_moves_delegate_to_hardened_no_replace_primitive(
monkeypatch: pytest.MonkeyPatch,
) -> None:
import opensquilla.recovery.settings_transaction as transaction
calls: list[tuple[Path, Path]] = []
source = Path("C:/synthetic/source")
destination = Path("C:/synthetic/destination")
monkeypatch.setattr(transaction.os, "name", "nt")
monkeypatch.setattr(
transaction,
"native_move_no_replace",
lambda source, destination: calls.append((source, destination)),
)
transaction._durable_move_no_replace(
source,
destination,
)
assert calls == [(source, destination)]