mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-08-14 22:50:18 +00:00
- Implemented chunked deletion for data retention, allowing for smoother cleanup processes without monopolizing SQLite's writer lock. - Configured retention settings to delete in batches with pauses, improving performance on SD-card installations. - Updated Linux service installers to allocate 1GB of memory and 200% CPU, providing better resource management for Raspberry Pi workloads. - Added detailed configuration examples for Raspberry Pi in documentation to guide users on optimal settings.
493 lines
18 KiB
Python
493 lines
18 KiB
Python
"""Regression tests for wheel contents and hardened service deployment policy."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import configparser
|
|
import os
|
|
import shutil
|
|
import sqlite3
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
from scripts.migrate_service_layout import migrate_service_layout
|
|
from scripts.preserve_service_alternatives import (
|
|
backup_installed_only,
|
|
restore_backup,
|
|
)
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
def test_service_units_do_not_make_code_tree_writable():
|
|
unit = (REPO_ROOT / "meshcore-bot.service").read_text(encoding="utf-8")
|
|
assert "--config /etc/meshcore-bot/config.ini" in unit
|
|
assert "ReadWritePaths=/opt/meshcore-bot" not in unit
|
|
assert "ReadWritePaths=/var/lib/meshcore-bot" in unit
|
|
assert "ReadWritePaths=/etc/meshcore-bot" in unit
|
|
assert "UMask=0077" in unit
|
|
assert "MemoryMax=1G" in unit
|
|
assert "CPUQuota=200%" in unit
|
|
assert "MemoryMax=512M" not in unit
|
|
assert "CPUQuota=50%" not in unit
|
|
|
|
deb_builder = (REPO_ROOT / "scripts/build-deb.sh").read_text(encoding="utf-8")
|
|
assert "MemoryMax=1G" in deb_builder
|
|
assert "CPUQuota=200%" in deb_builder
|
|
assert "MemoryMax=512M" not in deb_builder
|
|
assert "CPUQuota=50%" not in deb_builder
|
|
assert 'chown -R "${SERVICE_USER}:${SERVICE_USER}" "${INSTALL_ROOT}"' not in deb_builder
|
|
assert "migrate_service_layout.py" in deb_builder
|
|
assert 'chmod 0600 "${BUILD_DIR}${CONF_DIR}/config.ini"' in deb_builder
|
|
assert (
|
|
'chown -R "${SERVICE_USER}:${SERVICE_USER}" "${CONF_DIR}" "${DATA_DIR}" "${LOG_DIR}"'
|
|
in deb_builder
|
|
)
|
|
assert 'find "${CONF_DIR}" "${DATA_DIR}" -type f -exec chmod 0600' in deb_builder
|
|
assert 'VENV_BUILD="${INSTALL_ROOT}/.venv-build-$$"' in deb_builder
|
|
assert 'if [ ! -d "${INSTALL_ROOT}/venv" ]' not in deb_builder
|
|
assert 'scripts/debian_service_state.sh" "${BUILD_DIR}/DEBIAN/preinst"' in deb_builder
|
|
assert 'debian_service_state.sh" restore "${2:-}"' in deb_builder
|
|
assert 'debian_service_state.sh preremove "${1:-}"' in deb_builder
|
|
|
|
|
|
def test_standalone_installer_separates_code_and_private_state():
|
|
installer = (REPO_ROOT / "install-service.sh").read_text(encoding="utf-8")
|
|
assert 'chown -R "root:$CODE_GROUP" "$INSTALL_DIR"' in installer
|
|
assert 'chown -R "$SERVICE_USER:$SERVICE_GROUP" "$CONF_DIR" "$STATE_DIR"' in installer
|
|
assert 'find "$CONF_DIR" "$STATE_DIR" -type f -exec chmod 600' in installer
|
|
assert 'systemctl stop "$SERVICE_NAME"' in installer
|
|
assert "migrate_service_layout.py" in installer
|
|
assert "rsync -a --delete" in installer
|
|
assert "rsync -a --update" not in installer
|
|
assert "source-authoritative secure install" in installer
|
|
assert "preserve_service_alternatives.py" in installer
|
|
assert 'VENV_BUILD="$INSTALL_DIR/.venv-build-$$"' in installer
|
|
assert 'Preserving existing virtual environment' not in installer
|
|
assert "Python 3.10+ installed" in installer
|
|
assert "sys.version_info < (3, 10)" in installer
|
|
assert installer.index("command -v rsync") < installer.index(
|
|
"# Stop a running legacy service"
|
|
)
|
|
assert "trap restore_active_service_on_failure EXIT" in installer
|
|
assert "trap - EXIT" in installer
|
|
assert 'SERVICE_RESTART_PENDING=true' in installer
|
|
assert "SERVICE_RESTART_SAFE=false" in installer
|
|
assert "restart_previously_active_service" in installer
|
|
assert "Previously active service was restarted after the failed upgrade" in installer
|
|
assert 'sync_executable_tree "$dest_dir" "$rollback_backup"' in installer
|
|
assert 'sync_executable_tree "$rollback_backup" "$dest_dir"' in installer
|
|
assert "Refusing to restart a potentially partial code tree" in installer
|
|
|
|
|
|
def test_service_documentation_matches_hardened_layout() -> None:
|
|
readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8")
|
|
getting_started = (REPO_ROOT / "docs/getting-started.md").read_text(encoding="utf-8")
|
|
service_docs = (REPO_ROOT / "docs/service-installation.md").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
upgrade_docs = (REPO_ROOT / "docs/upgrade.md").read_text(encoding="utf-8")
|
|
|
|
for text in (readme, getting_started, service_docs):
|
|
assert "sudo nano /opt/meshcore-bot/config.ini" not in text
|
|
assert "sudo nano /etc/meshcore-bot/config.ini" in text
|
|
|
|
assert "Python 3.10+" in service_docs
|
|
assert "`rsync`" in service_docs
|
|
assert "sudo chown -R meshcore:meshcore /opt/meshcore-bot" not in service_docs
|
|
assert "patches the meshcore file" not in service_docs
|
|
assert "/var/lib/meshcore-bot" in service_docs
|
|
assert "Upgrading from v0.9.3 to v1.0.0" in upgrade_docs
|
|
|
|
|
|
def test_service_sync_preserves_only_installed_only_alternatives(tmp_path: Path) -> None:
|
|
source = tmp_path / "source"
|
|
installed = tmp_path / "installed"
|
|
backup = tmp_path / "backup"
|
|
(source / "nested").mkdir(parents=True)
|
|
(installed / "nested").mkdir(parents=True)
|
|
|
|
(source / "shipped.py").write_text("SOURCE = 2\n", encoding="utf-8")
|
|
(installed / "shipped.py").write_text("LOCAL = 1\n", encoding="utf-8")
|
|
(installed / "custom.py").write_text("CUSTOM = 1\n", encoding="utf-8")
|
|
(installed / "nested" / "custom.py").write_text("NESTED = 1\n", encoding="utf-8")
|
|
(installed / "ignored.pyc").write_bytes(b"cache")
|
|
(installed / "custom-link.py").symlink_to("custom.py")
|
|
|
|
preserved = backup_installed_only(source, installed, backup)
|
|
|
|
assert preserved == [
|
|
Path("custom-link.py"),
|
|
Path("custom.py"),
|
|
Path("nested/custom.py"),
|
|
]
|
|
assert not (backup / "shipped.py").exists()
|
|
assert (backup / "custom-link.py").is_symlink()
|
|
assert (backup / "custom-link.py").readlink() == Path("custom.py")
|
|
assert not (backup / "ignored.pyc").exists()
|
|
|
|
shutil.rmtree(installed)
|
|
installed.mkdir()
|
|
(installed / "shipped.py").write_text("SOURCE = 2\n", encoding="utf-8")
|
|
(installed / "stale.py").write_text("STALE = 1\n", encoding="utf-8")
|
|
|
|
restored = restore_backup(backup, installed)
|
|
|
|
assert restored == preserved
|
|
assert (installed / "shipped.py").read_text(encoding="utf-8") == "SOURCE = 2\n"
|
|
assert (installed / "custom.py").read_text(encoding="utf-8") == "CUSTOM = 1\n"
|
|
assert (installed / "custom-link.py").is_symlink()
|
|
assert (installed / "custom-link.py").readlink() == Path("custom.py")
|
|
assert (installed / "nested" / "custom.py").read_text(encoding="utf-8") == "NESTED = 1\n"
|
|
assert not backup.exists()
|
|
|
|
|
|
def test_service_sync_rolls_back_when_alternative_restore_fails(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
installer = (REPO_ROOT / "install-service.sh").read_text(encoding="utf-8")
|
|
function_block = installer.split("sync_executable_tree() {", 1)[1].split(
|
|
"# Copy files using smart copy function", 1
|
|
)[0]
|
|
|
|
fake_bin = tmp_path / "bin"
|
|
fake_bin.mkdir()
|
|
rsync_count = tmp_path / "rsync-count"
|
|
rsync_count.write_text("0\n", encoding="utf-8")
|
|
fake_rsync = fake_bin / "rsync"
|
|
fake_rsync.write_text(
|
|
"""#!/bin/bash
|
|
count_file="${FAKE_RSYNC_COUNT:?}"
|
|
count="$(cat "$count_file")"
|
|
printf '%s\\n' "$((count + 1))" > "$count_file"
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
fake_rsync.chmod(0o755)
|
|
fake_python = fake_bin / "python3"
|
|
fake_python.write_text(
|
|
"""#!/bin/bash
|
|
case " $* " in
|
|
*" backup "*) exit 0 ;;
|
|
*" restore "*) exit 1 ;;
|
|
*) exit 2 ;;
|
|
esac
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
fake_python.chmod(0o755)
|
|
|
|
harness = tmp_path / "sync-harness.sh"
|
|
harness.write_text(
|
|
"""#!/bin/bash
|
|
print_info() { :; }
|
|
print_warning() { :; }
|
|
print_error() { :; }
|
|
print_success() { :; }
|
|
SCRIPT_DIR="${FAKE_SCRIPT_DIR:?}"
|
|
SERVICE_RESTART_SAFE=true
|
|
sync_executable_tree() {
|
|
"""
|
|
+ function_block
|
|
+ """
|
|
copy_files_smart "$FAKE_SOURCE" "$FAKE_INSTALLED"
|
|
result=$?
|
|
printf 'result=%s safe=%s\\n' "$result" "$SERVICE_RESTART_SAFE"
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
harness.chmod(0o755)
|
|
|
|
env = os.environ.copy()
|
|
env["PATH"] = f"{fake_bin}{os.pathsep}{env['PATH']}"
|
|
env["FAKE_RSYNC_COUNT"] = str(rsync_count)
|
|
env["FAKE_SCRIPT_DIR"] = str(REPO_ROOT)
|
|
env["FAKE_SOURCE"] = str(tmp_path / "source")
|
|
env["FAKE_INSTALLED"] = str(tmp_path / "installed")
|
|
env["TMPDIR"] = str(tmp_path)
|
|
result = subprocess.run(
|
|
["bash", str(harness)],
|
|
check=True,
|
|
env=env,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
assert result.stdout.strip() == "result=1 safe=true"
|
|
assert rsync_count.read_text(encoding="utf-8").strip() == "3"
|
|
|
|
|
|
def _service_state_test_environment(tmp_path: Path) -> tuple[dict[str, str], Path, Path]:
|
|
fake_bin = tmp_path / "bin"
|
|
fake_state = tmp_path / "systemctl-state"
|
|
maint_state = tmp_path / "maint-state"
|
|
fake_bin.mkdir()
|
|
fake_state.mkdir()
|
|
maint_state.mkdir()
|
|
systemctl = fake_bin / "systemctl"
|
|
systemctl.write_text(
|
|
"""#!/bin/bash
|
|
set -e
|
|
state="${FAKE_SYSTEMCTL_STATE:?}"
|
|
case "${1:-}" in
|
|
is-active) [ -f "$state/active" ] ;;
|
|
is-enabled) [ -f "$state/enabled" ] ;;
|
|
start) touch "$state/active" ;;
|
|
stop) rm -f "$state/active" ;;
|
|
enable) touch "$state/enabled" ;;
|
|
disable) rm -f "$state/enabled" ;;
|
|
daemon-reload) : ;;
|
|
*) exit 2 ;;
|
|
esac
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
systemctl.chmod(0o755)
|
|
env = os.environ.copy()
|
|
env["PATH"] = f"{fake_bin}{os.pathsep}{env['PATH']}"
|
|
env["FAKE_SYSTEMCTL_STATE"] = str(fake_state)
|
|
env["MESHCORE_MAINT_STATE_DIR"] = str(maint_state)
|
|
env["MESHCORE_MAINT_POLICY_MARKER"] = str(tmp_path / "policy-marker")
|
|
return env, fake_state, maint_state
|
|
|
|
|
|
def _run_service_state(script: Path, *args: str, env: dict[str, str]) -> None:
|
|
subprocess.run(
|
|
["bash", str(script), *args],
|
|
check=True,
|
|
env=env,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
|
|
def test_first_hardened_deb_upgrade_survives_legacy_prerm(tmp_path: Path) -> None:
|
|
"""Model Debian's old prerm -> new preinst -> new postinst ordering."""
|
|
env, service_state, maint_state = _service_state_test_environment(tmp_path)
|
|
helper = REPO_ROOT / "scripts/debian_service_state.sh"
|
|
preinst = tmp_path / "preinst"
|
|
shutil.copy2(helper, preinst)
|
|
|
|
# The baseline package's old prerm destroys state before new code runs.
|
|
# The one-time migration policy favors availability and records completion.
|
|
(service_state / "enabled").touch()
|
|
(service_state / "active").touch()
|
|
(service_state / "enabled").unlink()
|
|
(service_state / "active").unlink()
|
|
_run_service_state(preinst, "upgrade", "0.9.2", env=env)
|
|
assert not list(maint_state.iterdir())
|
|
|
|
_run_service_state(helper, "restore", "0.9.2", env=env)
|
|
|
|
assert (service_state / "enabled").is_file()
|
|
assert (service_state / "active").is_file()
|
|
assert not list(maint_state.iterdir())
|
|
assert Path(env["MESHCORE_MAINT_POLICY_MARKER"]).is_file()
|
|
|
|
|
|
def test_deb_upgrade_preserves_intentionally_disabled_stopped_service(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
env, service_state, maint_state = _service_state_test_environment(tmp_path)
|
|
helper = REPO_ROOT / "scripts/debian_service_state.sh"
|
|
preinst = tmp_path / "preinst"
|
|
shutil.copy2(helper, preinst)
|
|
Path(env["MESHCORE_MAINT_POLICY_MARKER"]).touch()
|
|
|
|
# Hardened old prerm runs first and writes no state markers because this
|
|
# service was intentionally disabled/stopped. New preinst preserves that.
|
|
_run_service_state(helper, "preremove", "upgrade", env=env)
|
|
_run_service_state(preinst, "upgrade", "0.9.2", env=env)
|
|
_run_service_state(helper, "restore", "0.9.2", env=env)
|
|
|
|
assert not (service_state / "enabled").exists()
|
|
assert not (service_state / "active").exists()
|
|
assert not list(maint_state.iterdir())
|
|
|
|
|
|
def test_later_deb_upgrade_preserves_enabled_active_service(tmp_path: Path) -> None:
|
|
env, service_state, maint_state = _service_state_test_environment(tmp_path)
|
|
helper = REPO_ROOT / "scripts/debian_service_state.sh"
|
|
preinst = tmp_path / "preinst"
|
|
shutil.copy2(helper, preinst)
|
|
Path(env["MESHCORE_MAINT_POLICY_MARKER"]).touch()
|
|
(service_state / "enabled").touch()
|
|
(service_state / "active").touch()
|
|
|
|
# Real Debian order: hardened old prerm, new preinst, new postinst.
|
|
_run_service_state(helper, "preremove", "upgrade", env=env)
|
|
assert not (service_state / "active").exists()
|
|
assert (maint_state / "meshcore-bot-was-enabled").is_file()
|
|
assert (maint_state / "meshcore-bot-was-active").is_file()
|
|
_run_service_state(preinst, "upgrade", "0.9.2", env=env)
|
|
_run_service_state(helper, "restore", "0.9.2", env=env)
|
|
|
|
assert (service_state / "enabled").is_file()
|
|
assert (service_state / "active").is_file()
|
|
assert not list(maint_state.iterdir())
|
|
|
|
|
|
def test_relative_legacy_paths_are_migrated_without_losing_data(tmp_path):
|
|
# URI-significant characters prove the read-only SQLite URI is encoded.
|
|
legacy = tmp_path / "legacy #1?"
|
|
state = tmp_path / "state"
|
|
logs = tmp_path / "logs"
|
|
config_dir = tmp_path / "config"
|
|
legacy.mkdir()
|
|
config_dir.mkdir()
|
|
(legacy / "local" / "commands").mkdir(parents=True)
|
|
(legacy / "local" / "commands" / "custom.py").write_text("VALUE = 1\n", encoding="utf-8")
|
|
(legacy / "meshcore_bot.log").write_text("old log\n", encoding="utf-8")
|
|
|
|
db_path = legacy / "meshcore_bot.db"
|
|
connection = sqlite3.connect(db_path)
|
|
connection.execute("PRAGMA journal_mode=WAL")
|
|
connection.execute("CREATE TABLE messages (body TEXT)")
|
|
connection.execute("INSERT INTO messages VALUES ('preserved')")
|
|
connection.commit()
|
|
|
|
config_path = config_dir / "config.ini"
|
|
config_path.write_text(
|
|
"# keep this comment\n"
|
|
"[Bot]\n"
|
|
"db_path = meshcore_bot.db\n"
|
|
"local_dir_path = local\n"
|
|
"\n[Logging]\n"
|
|
"log_file = meshcore_bot.log\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
try:
|
|
updates = migrate_service_layout(config_path, legacy, state, logs)
|
|
finally:
|
|
connection.close()
|
|
|
|
assert set(updates) == {"Bot", "Logging"}
|
|
migrated = sqlite3.connect(state / "meshcore_bot.db")
|
|
try:
|
|
assert migrated.execute("SELECT body FROM messages").fetchall() == [("preserved",)]
|
|
finally:
|
|
migrated.close()
|
|
assert (state / "local" / "commands" / "custom.py").is_file()
|
|
assert (logs / "meshcore_bot.log").read_text(encoding="utf-8") == "old log\n"
|
|
assert config_path.read_text(encoding="utf-8").startswith("# keep this comment\n")
|
|
|
|
parsed = configparser.ConfigParser()
|
|
parsed.read(config_path)
|
|
assert parsed.get("Bot", "db_path") == str((state / "meshcore_bot.db").resolve())
|
|
assert parsed.get("Bot", "local_dir_path") == str((state / "local").resolve())
|
|
assert parsed.get("Logging", "log_file") == str((logs / "meshcore_bot.log").resolve())
|
|
|
|
|
|
def test_absolute_custom_paths_are_preserved(tmp_path):
|
|
config_path = tmp_path / "config.ini"
|
|
config_path.write_text(
|
|
"[Bot]\n"
|
|
f"db_path = {tmp_path / 'custom.db'}\n"
|
|
f"local_dir_path = {tmp_path / 'custom-local'}\n"
|
|
"[Logging]\nlog_file = \n",
|
|
encoding="utf-8",
|
|
)
|
|
before = config_path.read_text(encoding="utf-8")
|
|
|
|
updates = migrate_service_layout(
|
|
config_path,
|
|
tmp_path / "legacy",
|
|
tmp_path / "state",
|
|
tmp_path / "logs",
|
|
)
|
|
|
|
assert updates == {}
|
|
assert config_path.read_text(encoding="utf-8") == before
|
|
|
|
|
|
def test_relative_legacy_database_replaces_but_preserves_stale_target(tmp_path):
|
|
legacy = tmp_path / "legacy"
|
|
state = tmp_path / "state"
|
|
logs = tmp_path / "logs"
|
|
legacy.mkdir()
|
|
state.mkdir()
|
|
logs.mkdir()
|
|
|
|
def create_db(path: Path, marker: str) -> None:
|
|
with sqlite3.connect(path) as conn:
|
|
conn.execute("CREATE TABLE marker (value TEXT)")
|
|
conn.execute("INSERT INTO marker VALUES (?)", (marker,))
|
|
conn.commit()
|
|
|
|
create_db(legacy / "meshcore_bot.db", "current-legacy")
|
|
create_db(state / "meshcore_bot.db", "stale-target")
|
|
config_path = tmp_path / "config.ini"
|
|
config_path.write_text("[Bot]\ndb_path = meshcore_bot.db\n", encoding="utf-8")
|
|
|
|
migrate_service_layout(config_path, legacy, state, logs)
|
|
|
|
with sqlite3.connect(state / "meshcore_bot.db") as conn:
|
|
assert conn.execute("SELECT value FROM marker").fetchone() == ("current-legacy",)
|
|
backups = list(state.glob("meshcore_bot.pre-layout-*.db"))
|
|
assert len(backups) == 1
|
|
with sqlite3.connect(backups[0]) as conn:
|
|
assert conn.execute("SELECT value FROM marker").fetchone() == ("stale-target",)
|
|
|
|
|
|
def test_split_bot_and_viewer_databases_migrate_without_filename_collision(tmp_path):
|
|
legacy = tmp_path / "legacy"
|
|
state = tmp_path / "state"
|
|
logs = tmp_path / "logs"
|
|
(legacy / "bot").mkdir(parents=True)
|
|
(legacy / "viewer").mkdir(parents=True)
|
|
|
|
for path, marker in (
|
|
(legacy / "bot" / "shared.db", "bot"),
|
|
(legacy / "viewer" / "shared.db", "viewer"),
|
|
):
|
|
with sqlite3.connect(path) as conn:
|
|
conn.execute("CREATE TABLE marker (value TEXT)")
|
|
conn.execute("INSERT INTO marker VALUES (?)", (marker,))
|
|
conn.commit()
|
|
|
|
config_path = tmp_path / "config.ini"
|
|
config_path.write_text(
|
|
"[Bot]\n"
|
|
"db_path = bot/shared.db\n"
|
|
"[Web_Viewer]\n"
|
|
"db_path = viewer/shared.db\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
migrate_service_layout(config_path, legacy, state, logs)
|
|
|
|
parsed = configparser.ConfigParser()
|
|
parsed.read(config_path)
|
|
bot_target = Path(parsed.get("Bot", "db_path"))
|
|
viewer_target = Path(parsed.get("Web_Viewer", "db_path"))
|
|
assert bot_target != viewer_target
|
|
with sqlite3.connect(bot_target) as conn:
|
|
assert conn.execute("SELECT value FROM marker").fetchone() == ("bot",)
|
|
with sqlite3.connect(viewer_target) as conn:
|
|
assert conn.execute("SELECT value FROM marker").fetchone() == ("viewer",)
|
|
|
|
|
|
def test_legacy_local_plugins_replace_and_preserve_stale_target_tree(tmp_path):
|
|
legacy = tmp_path / "legacy"
|
|
state = tmp_path / "state"
|
|
logs = tmp_path / "logs"
|
|
(legacy / "local" / "commands").mkdir(parents=True)
|
|
(legacy / "local" / "commands" / "current.py").write_text("CURRENT = 1\n")
|
|
(state / "local" / "commands").mkdir(parents=True)
|
|
(state / "local" / "commands" / "stale.py").write_text("STALE = 1\n")
|
|
config_path = tmp_path / "config.ini"
|
|
config_path.write_text(
|
|
"[Bot]\n"
|
|
f"db_path = {tmp_path / 'absolute.db'}\n"
|
|
"local_dir_path = local\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
migrate_service_layout(config_path, legacy, state, logs)
|
|
|
|
assert (state / "local" / "commands" / "current.py").is_file()
|
|
assert not (state / "local" / "commands" / "stale.py").exists()
|
|
backups = list(state.glob("local.pre-layout-*"))
|
|
assert len(backups) == 1
|
|
assert (backups[0] / "commands" / "stale.py").is_file()
|