diff --git a/.dockerignore b/.dockerignore index 84399c1..fbb39d6 100644 --- a/.dockerignore +++ b/.dockerignore @@ -22,6 +22,7 @@ wheels/ # Virtual environments venv/ +.venv/ env/ ENV/ @@ -64,6 +65,9 @@ logs/ # Tests tests/ +!tests/ssh-fixture/ +!tests/ssh-fixture/Dockerfile +!tests/ssh-fixture/sshd_config .pytest_cache/ .coverage diff --git a/.gitignore b/.gitignore index 1a63b77..c3ce7dc 100644 --- a/.gitignore +++ b/.gitignore @@ -50,7 +50,7 @@ Thumbs.db # Frontend frontend/node_modules/ frontend/dist/ -frontend/package-lock.json +frontend/test-results/ frontend/yarn.lock frontend/pnpm-lock.yaml @@ -60,8 +60,11 @@ logs/ # Testing .pytest_cache/ +.mypy_cache/ +.ruff_cache/ .coverage htmlcov/ # Backup tool specific backups/ +.pi-subagents/ diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..7377d13 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22.17.1 diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..7eebfaf --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12.11 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..557d005 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,25 @@ +# Agent Operating Instructions + +## Continuous milestone execution + +When the user asks to continue, work autonomously through the active milestone +and its verification. Do **not** send progress-only, acknowledgement, empty, or +status final responses. Reply only when: + +1. the user asks for status; +2. a real product, security, credentials, or destructive-action decision needs + the user's input; or +3. the requested milestone is fully implemented and verified. + +Use one foreground milestone batch where possible. If work must run in a +background subagent, avoid notifying the user manually; inspect and verify the +result before replying at the same completion boundary. + +A chat turn still necessarily ends after a model response. This file prevents +unnecessary model-generated completion messages; it cannot suppress +harness-generated tool or subagent notifications. + +## Verification + +Before claiming a milestone boundary, run `make check` and record focused +acceptance evidence under `docs/release/`. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d620bea --- /dev/null +++ b/Dockerfile @@ -0,0 +1,41 @@ +# syntax=docker/dockerfile:1 +# Pinned by manifest digest; update tag and digest together. +FROM python:3.12.11-slim-bookworm@sha256:519591d6871b7bc437060736b9f7456b8731f1499a57e22e6c285135ae657bf7 AS builder + +ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \ + PIP_NO_CACHE_DIR=1 + +WORKDIR /build +COPY backend/pyproject.toml ./pyproject.toml +COPY backend/src ./src +RUN python -m pip install --upgrade pip==25.3 && \ + python -m pip wheel --wheel-dir /wheels . + +FROM python:3.12.11-slim-bookworm@sha256:519591d6871b7bc437060736b9f7456b8731f1499a57e22e6c285135ae657bf7 AS runtime + +ENV PATH="/opt/venv/bin:${PATH}" \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + BACKUP_TOOL_ALEMBIC_ROOT=/app + +RUN groupadd --gid 10001 backup-tool && \ + useradd --uid 10001 --gid backup-tool --create-home --home-dir /home/backup-tool \ + --shell /usr/sbin/nologin backup-tool && \ + python -m venv /opt/venv && \ + install --directory --owner=backup-tool --group=backup-tool --mode=0750 \ + /var/lib/backup-tool \ + /var/lib/backup-tool/repositories \ + /var/lib/backup-tool/restores \ + /run/backup-tool + +COPY --from=builder /wheels /wheels +RUN python -m pip install --no-index --find-links=/wheels backup-tool==2.0.0.dev0 && \ + rm -rf /wheels +COPY --chown=backup-tool:backup-tool backend/alembic.ini /app/alembic.ini +COPY --chown=backup-tool:backup-tool backend/alembic /app/alembic + +WORKDIR /app +USER backup-tool:backup-tool + +ENTRYPOINT ["backup-tool"] +CMD ["web"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ac33448 --- /dev/null +++ b/Makefile @@ -0,0 +1,52 @@ +PYTHON ?= .venv/bin/python +BOOTSTRAP_PYTHON ?= python3 +NPM ?= npm + +.PHONY: setup install test-fast test-integration test-fault test-security test-e2e test-ssh-integration lint typecheck frontend-build check-v1-absent check-openapi check + +setup: + test -x $(PYTHON) || $(BOOTSTRAP_PYTHON) -m venv .venv + $(PYTHON) -m pip install --upgrade pip + $(PYTHON) -m pip install -e 'backend[dev]' + $(NPM) --prefix frontend ci + +install: setup + +test-fast: + $(PYTHON) -m pytest tests/unit tests/contract -q + +test-integration: + $(PYTHON) -m pytest tests/integration -q + +test-fault: + $(PYTHON) -m pytest tests/fault -q + +test-security: + $(PYTHON) -m pytest tests/security -q + +test-e2e: + BACKUP_TOOL_COMPOSE_E2E=1 $(PYTHON) -m pytest tests/e2e/test_compose_v2.py -q + +test-ssh-integration: + BACKUP_TOOL_SSH_INTEGRATION=1 $(PYTHON) -m pytest tests/integration/test_ssh_backup_restore.py -q + +lint: + $(PYTHON) -m ruff check --config backend/pyproject.toml backend/src backend/alembic tests tools + $(PYTHON) -m ruff format --check --config backend/pyproject.toml backend/src backend/alembic tests tools + +typecheck: + $(PYTHON) -m mypy --config-file backend/pyproject.toml + $(NPM) --prefix frontend run typecheck + +frontend-build: + $(NPM) --prefix frontend run build + +check-openapi: + $(PYTHON) tools/export_openapi.py + $(NPM) --prefix frontend run api:generate + git diff --exit-code -- openapi/v2.json frontend/src/api/generated + +check-v1-absent: + $(PYTHON) tools/forbidden_v1_scan.py . + +check: check-v1-absent check-openapi test-fast test-integration test-fault test-security lint typecheck frontend-build diff --git a/README.md b/README.md index de349a7..b173e42 100644 --- a/README.md +++ b/README.md @@ -1,121 +1,119 @@ -# Backup Tool +# Backup Tool v2 -Backup Tool is a FastAPI and React application for managing local, SSH/SFTP, PostgreSQL, and MySQL backup sources. It provides backup jobs and executions, scheduling, retention policies, a web dashboard, and a REST API. +> A self-hosted, integrity-first backup appliance for local and hardened SSH sources. -## Status and limitations +Backup Tool creates signed, content-addressed backups, supports encrypted repositories and offline recovery, and runs as isolated web, scheduler, worker, migration, and admin roles. v2.0 supports **local** and **SSH** sources, local repositories, restore, encrypted recovery, email, and signed webhooks. -The backend package is versioned `0.1.0` and classified as beta. Runtime verification is outside this README; review the configuration and test commands below before using the application for production backups. +## What is supported -The Compose configuration sets `BACKUP_STORAGE_PATH=/app/backups`, but the current Python application does not read that environment variable. The `/app/backups` volume is still mounted by Compose; the application's effective use of that path is not verified here. +- Signed full and incremental backups, restore, retention, verification, and recovery. +- AES-256-GCM repository encryption with key epochs and offline Argon2id recovery bundles. +- Local sources and SSH sources using **private-key-only, pinned-host-key, forced-SFTP chroot** access. +- Durable, at-least-once webhook and STARTTLS SMTP notifications. +- A generated OpenAPI TypeScript client and operator UI. -## Prerequisites +Never use an SSH source with a shell, password, agent forwarding, remote commands, or an unchrooted account. See [SSH source requirements](docs/runbooks/ssh-sources.md). -- Docker with Docker Compose for the containerized setup. -- Python 3.11 or newer for manual backend development. -- Node.js for manual frontend development. The development Compose service uses Node 20 Alpine; no standalone Node version is declared by the frontend package. +## Quick local verification -## Run with Docker Compose +Prerequisites: Docker Compose, Python 3.12–3.14, Node/npm, and a working virtual environment. -From the repository root: +```sh +make setup +make check +make test-e2e +make test-ssh-integration +``` -```bash -# Backend only +`make test-e2e` creates temporary test-only keys and source data, starts the complete Compose stack, checks readiness/metrics/restart behavior, and tears it down. `make test-ssh-integration` builds a separate forced-SFTP fixture and verifies key-authenticated SSH probe, backup, verification, and restore. + +## Run locally with Docker Compose + +The production Compose topology binds the proxy to `127.0.0.1:8080`; put a TLS reverse proxy in front of it for remote access. + +```sh +mkdir -p secrets sources +head -c 32 /dev/urandom > secrets/master.key +chmod 600 secrets/master.key +# The container runs as UID 10001; grant that UID read access to the key. +chown 10001:10001 secrets/master.key + +# Put test source data below ./sources, then start the isolated roles. +docker compose build --pull +docker compose run --rm migrate upgrade docker compose up -d - -# Backend plus the production frontend -docker compose --profile prod up -d - -# Backend plus the Vite development frontend -docker compose --profile dev up -d +curl -fsS http://127.0.0.1:8080/readyz +curl -fsS http://127.0.0.1:8080/metrics ``` -The backend is published at ; its OpenAPI documentation is at . Either frontend profile publishes the frontend at . +On a fresh non-loopback deployment, set `BACKUP_TOOL_PUBLIC_BASE_URL` to the externally reachable HTTPS URL and set `BACKUP_TOOL_BOOTSTRAP_SECRET` before first setup. Never expose a fresh setup endpoint without bootstrap protection. -Compose persists the SQLite data directory in `backup-data` and the backup directory in `backup-storage`. The backend health check requests `/api/health`. +Stop the local stack with: -## Manual development - -### Backend - -```bash -cd backend -python -m venv venv -source venv/bin/activate -pip install -e ".[dev]" -uvicorn app.main:app --reload --port 8000 +```sh +docker compose down +# Add --volumes only when intentionally discarding local metadata and repositories. ``` -The backend requires Python 3.11+. Its default `DATABASE_URL` is `sqlite+aiosqlite:///./backup_tool.db`. +## Operator workflow -### Frontend +1. Open `http://127.0.0.1:8080` and create the first administrator. +2. Create a local or encrypted repository. +3. Create a local source, or configure a hardened SSH source as documented in [SSH sources](docs/runbooks/ssh-sources.md). +4. Create a job, run a probe, enqueue a backup, verify it, then perform a test restore. +5. Configure notification subscriptions and test them before relying on delivery. +6. Export and validate a recovery bundle after encrypted repository creation and each key rotation. -```bash -cd frontend -npm install -npm run dev -``` - -To build the frontend for the backend to serve: - -```bash -cd frontend -npm run build -``` - -When `frontend/dist` exists, the backend mounts its assets and serves the SPA fallback. For a manually built frontend and backend, start the backend as above (or bind explicitly with `uvicorn app.main:app --host 0.0.0.0 --port 8000`). +The browser exposes recovery status and the CLI procedure only; it never transfers recovery bundles or passphrases. ## Configuration -The backend reads these environment variables: +Compose supplies the core runtime variables. The important host paths are: -| Variable | Purpose | Default | +| Setting | Compose value | Purpose | | --- | --- | --- | -| `DATABASE_URL` | SQLAlchemy database URL | `sqlite+aiosqlite:///./backup_tool.db` | -| `SQL_ECHO` | Enable SQL query logging when `true` | `false` | -| `CORS_ORIGINS` | Comma-separated allowed origins | `http://localhost:3000` | +| `BACKUP_TOOL_MASTER_KEY_FILE` | `/run/backup-tool-secrets/master.key` | Service-owned `0600` master key | +| `BACKUP_TOOL_SOURCES_DIR` | `./sources` | Read-only local-source bind mount | +| `BACKUP_TOOL_PUBLIC_BASE_URL` | `http://localhost:8080` | External URL and bootstrap policy | +| `BACKUP_TOOL_PORT` | `8080` | Loopback proxy port | -Compose supplies a SQLite URL under `/app/data`, `CORS_ORIGINS=http://localhost:3000`, and mounts persistent data and backup volumes. Configure credentials for backup sources through the application rather than committing them to the repository. +Use absolute, allowlisted paths for repositories, sources, and restores. The service rejects unsafe paths, symlinks where they violate the contract, missing key files, and non-current schemas. -## Development and database operations +## Operations and security -Run the backend tests: +- [Observability](docs/runbooks/observability.md): readiness, liveness, metrics, and alert response. +- [Upgrade and rollback](docs/runbooks/upgrade.md) +- [Disaster recovery](docs/runbooks/disaster-recovery.md) +- [Recovery bundles](docs/runbooks/recovery-bundle.md) +- [Repository keys](docs/runbooks/keys.md) +- [Notifications](docs/runbooks/notifications.md) +- [SSH sources](docs/runbooks/ssh-sources.md) +- [Security policies](docs/README.md#security-boundaries) -```bash -cd backend -pytest +## Development + +```sh +make setup +make test-fast +make test-integration +make test-fault +make test-security +npm --prefix frontend test -- --run +npm --prefix frontend exec playwright test ``` -The frontend package defines `dev`, `build`, and `preview` scripts; it does not currently define a test script. +OpenAPI and the generated browser client are committed artifacts: -Alembic commands are available from `backend/`: - -```bash -alembic revision --autogenerate -m "Description" -alembic upgrade head +```sh +.venv/bin/python tools/export_openapi.py --check openapi/v2.json +npm --prefix frontend run api:generate +git diff --exit-code -- openapi/v2.json frontend/src/api/generated ``` -## Deployment and operations +## Release evidence -For the provided production frontend container, use: - -```bash -docker compose --profile prod up -d -``` - -This starts the backend and an nginx-served frontend. For manual deployment, build the frontend and run the backend; the backend can serve `frontend/dist` when that directory exists. Choose and protect backup-source credentials, retention policies, and persistent storage for the environment. - -## Repository layout - -```text -. -├── backend/ # FastAPI application, backup engine, Alembic, and tests -│ └── pyproject.toml # Python dependencies and pytest configuration -├── frontend/ # React/Vite frontend -│ └── package.json # Frontend scripts and dependencies -├── docker-compose.yml # Backend and optional frontend profiles -└── docs/ # Project documentation -``` +Milestone evidence, the SBOM, provenance, and synthetic scale report live in [docs/release](docs/release/). M15 certification is synthetic metadata certification on the reference CI host; it does not claim a physical 10 TiB transfer. ## License -MIT +MIT — see [LICENSE](LICENSE). diff --git a/backend/Dockerfile b/backend/Dockerfile deleted file mode 100644 index a8928da..0000000 --- a/backend/Dockerfile +++ /dev/null @@ -1,50 +0,0 @@ -# Stage 1: Builder -FROM python:3.14 AS builder - -WORKDIR /app - -# Install build dependencies -RUN pip install --no-cache-dir setuptools wheel - -# Copy pyproject.toml first for better layer caching -COPY pyproject.toml ./ - -# Copy source code -COPY app/ ./app/ -COPY backup/ ./backup/ -COPY alembic/ ./alembic/ -COPY alembic.ini ./ - -# Build the package with dev dependencies -RUN pip install --no-cache-dir -e ".[dev]" - -# Stage 2: Runtime -FROM python:3.14-slim - -WORKDIR /app - -# Create non-root user -RUN groupadd -r backup-tool && useradd -r -g backup-tool backup-tool - -# Copy installed packages from builder -COPY --from=builder /usr/local/lib/python3.14/site-packages/ /usr/local/lib/python3.14/site-packages/ -COPY --from=builder /usr/local/bin/ /usr/local/bin/ - -# Copy application code -COPY --from=builder /app/ ./ - -# Create data directory for SQLite and backups -RUN mkdir -p /app/data /app/backups && \ - chown -R backup-tool:backup-tool /app - -USER backup-tool - -# Expose port -EXPOSE 8000 - -# Health check -HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')" || exit 1 - -# Default command -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/alembic.ini b/backend/alembic.ini index cac063f..e5846ba 100644 --- a/backend/alembic.ini +++ b/backend/alembic.ini @@ -1,5 +1,39 @@ [alembic] -script_location = alembic -prepend_sys_path = . -version_path_separator = os -sqlalchemy.url = sqlite+aiosqlite:///./backup_tool.db +script_location = %(here)s/alembic +prepend_sys_path = %(here)s/src +path_separator = os +sqlalchemy.url = sqlite+aiosqlite:////var/lib/backup-tool/metadata.db + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/README b/backend/alembic/README deleted file mode 100644 index 98e4f9c..0000000 --- a/backend/alembic/README +++ /dev/null @@ -1 +0,0 @@ -Generic single-database configuration. \ No newline at end of file diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 58b16a0..1b83272 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -1,45 +1,81 @@ -import asyncio +from __future__ import annotations + +import importlib +import os from logging.config import fileConfig -from sqlalchemy import pool -from sqlalchemy.engine import Connection -from sqlalchemy.ext.asyncio import async_engine_from_config +from typing import Any + from alembic import context -from app.models import Base +from sqlalchemy import create_engine, event, pool +from sqlalchemy.engine import URL, make_url + +models = importlib.import_module("backup_tool.db.models") +sqlite_runtime = importlib.import_module("backup_tool.db.sqlite") config = context.config if config.config_file_name is not None: fileConfig(config.config_file_name) +target_metadata = models.Base.metadata + + +def configured_url() -> str: + raw_url = os.environ.get("BACKUP_TOOL_DATABASE_URL") or config.get_main_option("sqlalchemy.url") + if raw_url is None: + raise RuntimeError("sqlalchemy.url is required") + return raw_url + + +def busy_timeout_ms() -> int: + raw_value = os.environ.get( + "BACKUP_TOOL_SQLITE_BUSY_TIMEOUT_MS", str(sqlite_runtime.DEFAULT_BUSY_TIMEOUT_MS) + ) + try: + value = int(raw_value) + except ValueError as error: + raise RuntimeError("SQLite busy timeout must be an integer") from error + if value < 1_000 or value > 120_000: + raise RuntimeError("SQLite busy timeout must be between 1000 and 120000 ms") + return value + + +def synchronous_url(raw_url: str) -> URL: + url = make_url(raw_url) + if url.drivername != "sqlite+aiosqlite": + raise RuntimeError("v2 migrations require sqlite+aiosqlite") + return url.set(drivername="sqlite") -target_metadata = Base.metadata def run_migrations_offline() -> None: - url = config.get_main_option("sqlalchemy.url") + url = synchronous_url(configured_url()) context.configure( - url=url, + url=url.render_as_string(hide_password=False), target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"}, + compare_type=True, ) with context.begin_transaction(): context.run_migrations() -def do_run_migrations(connection: Connection) -> None: - context.configure(connection=connection, target_metadata=target_metadata) - with context.begin_transaction(): - context.run_migrations() - -async def run_async_migrations() -> None: - connectable = async_engine_from_config( - config.get_section(config.config_ini_section, {}), - prefix="sqlalchemy.", - poolclass=pool.NullPool, - ) - async with connectable.connect() as connection: - await connection.run_sync(do_run_migrations) - await connectable.dispose() def run_migrations_online() -> None: - asyncio.run(run_async_migrations()) + timeout = busy_timeout_ms() + engine = create_engine( + synchronous_url(configured_url()), + poolclass=pool.NullPool, + connect_args=sqlite_runtime.sqlite_connect_args(timeout), + ) + + @event.listens_for(engine, "connect") + def configure_sqlite(dbapi_connection: Any, _connection_record: Any) -> None: + sqlite_runtime.configure_sqlite_connection(dbapi_connection) + + with engine.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata, compare_type=True) + with context.begin_transaction(): + context.run_migrations() + engine.dispose() + if context.is_offline_mode(): run_migrations_offline() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako index 1101630..553d678 100644 --- a/backend/alembic/script.py.mako +++ b/backend/alembic/script.py.mako @@ -3,7 +3,6 @@ Revision ID: ${up_revision} Revises: ${down_revision | comma,n} Create Date: ${create_date} - """ from typing import Sequence, Union @@ -11,7 +10,6 @@ from alembic import op import sqlalchemy as sa ${imports if imports else ""} -# revision identifiers, used by Alembic. revision: str = ${repr(up_revision)} down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} @@ -19,10 +17,8 @@ depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} def upgrade() -> None: - """Upgrade schema.""" ${upgrades if upgrades else "pass"} def downgrade() -> None: - """Downgrade schema.""" ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/0001_v2_baseline.py b/backend/alembic/versions/0001_v2_baseline.py new file mode 100644 index 0000000..6f4afd0 --- /dev/null +++ b/backend/alembic/versions/0001_v2_baseline.py @@ -0,0 +1,548 @@ +"""v2 baseline + +Revision ID: 0001_v2_baseline +Revises: +Create Date: 2026-07-27 19:15:52.319338 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0001_v2_baseline" +down_revision: str | Sequence[str] | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "repositories", + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("root", sa.Text(), nullable=False), + sa.Column("format_version", sa.Integer(), nullable=False), + sa.Column("compression", sa.String(length=32), nullable=False), + sa.Column("encryption", sa.String(length=32), nullable=False), + sa.Column("state", sa.String(length=32), nullable=False), + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.CheckConstraint( + "state IN ('active','archived','unavailable')", name=op.f("ck_repositories_state") + ), + sa.CheckConstraint( + "format_version > 0", name=op.f("ck_repositories_format_version_positive") + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_repositories")), + sa.UniqueConstraint("name", name=op.f("uq_repositories_name")), + sa.UniqueConstraint("root", name=op.f("uq_repositories_root")), + ) + op.create_table( + "secrets", + sa.Column("ciphertext", sa.LargeBinary(), nullable=False), + sa.Column("key_id", sa.String(length=255), nullable=False), + sa.Column("purpose", sa.String(length=64), nullable=False), + sa.Column("version", sa.Integer(), nullable=False), + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.CheckConstraint("version > 0", name=op.f("ck_secrets_version_positive")), + sa.PrimaryKeyConstraint("id", name=op.f("pk_secrets")), + ) + op.create_table( + "sources", + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("kind", sa.String(length=32), nullable=False), + sa.Column("public_config", sa.JSON(), nullable=False), + sa.Column("secret_refs", sa.JSON(), nullable=False), + sa.Column("state", sa.String(length=32), nullable=False), + sa.Column("last_probe", sa.JSON(), nullable=True), + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.CheckConstraint( + "kind IN ('local','sftp','postgresql','mysql')", name=op.f("ck_sources_kind") + ), + sa.CheckConstraint( + "state IN ('active','archived','unavailable')", name=op.f("ck_sources_state") + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_sources")), + sa.UniqueConstraint("name", name=op.f("uq_sources_name")), + ) + op.create_table( + "users", + sa.Column("username", sa.String(length=255), nullable=False), + sa.Column("password_hash", sa.Text(), nullable=False), + sa.Column("state", sa.String(length=32), nullable=False), + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.CheckConstraint("state IN ('active','disabled')", name=op.f("ck_users_state")), + sa.PrimaryKeyConstraint("id", name=op.f("pk_users")), + sa.UniqueConstraint("username", name=op.f("uq_users_username")), + ) + op.create_table( + "api_tokens", + sa.Column("owner_id", sa.String(length=36), nullable=False), + sa.Column("token_hash", sa.Text(), nullable=False), + sa.Column("scopes", sa.JSON(), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["owner_id"], + ["users.id"], + name=op.f("fk_api_tokens_owner_id_users"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_api_tokens")), + sa.UniqueConstraint("token_hash", name=op.f("uq_api_tokens_token_hash")), + ) + op.create_index("ix_api_tokens_owner_id", "api_tokens", ["owner_id"], unique=False) + op.create_table( + "audit_events", + sa.Column("actor_id", sa.String(length=36), nullable=True), + sa.Column("action", sa.String(length=255), nullable=False), + sa.Column("resource_type", sa.String(length=64), nullable=False), + sa.Column("resource_id", sa.String(length=36), nullable=True), + sa.Column("outcome", sa.String(length=32), nullable=False), + sa.Column("request_id", sa.String(length=36), nullable=False), + sa.Column("details", sa.JSON(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column("id", sa.String(length=36), nullable=False), + sa.CheckConstraint( + "outcome IN ('success','failure','denied')", name=op.f("ck_audit_events_outcome") + ), + sa.ForeignKeyConstraint( + ["actor_id"], + ["users.id"], + name=op.f("fk_audit_events_actor_id_users"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_audit_events")), + ) + op.create_index("ix_audit_events_actor_id", "audit_events", ["actor_id"], unique=False) + op.create_index("ix_audit_events_created_at", "audit_events", ["created_at"], unique=False) + op.create_table( + "idempotency_records", + sa.Column("actor_id", sa.String(length=36), nullable=False), + sa.Column("key", sa.String(length=255), nullable=False), + sa.Column("operation", sa.String(length=255), nullable=False), + sa.Column("request_digest", sa.String(length=64), nullable=False), + sa.Column("response_resource_type", sa.String(length=64), nullable=False), + sa.Column("response_resource_id", sa.String(length=36), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column("id", sa.String(length=36), nullable=False), + sa.ForeignKeyConstraint( + ["actor_id"], + ["users.id"], + name=op.f("fk_idempotency_records_actor_id_users"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_idempotency_records")), + sa.UniqueConstraint( + "actor_id", "key", "operation", name="uq_idempotency_actor_key_operation" + ), + ) + op.create_index( + "ix_idempotency_created_at", "idempotency_records", ["created_at"], unique=False + ) + op.create_table( + "jobs", + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("source_id", sa.String(length=36), nullable=False), + sa.Column("repository_id", sa.String(length=36), nullable=False), + sa.Column("requested_mode", sa.String(length=32), nullable=False), + sa.Column("exclusions", sa.JSON(), nullable=False), + sa.Column("retention", sa.JSON(), nullable=False), + sa.Column("enabled", sa.Boolean(), nullable=False), + sa.Column("allow_empty", sa.Boolean(), nullable=False), + sa.Column("state", sa.String(length=32), nullable=False), + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.CheckConstraint( + "requested_mode IN ('full','incremental')", name=op.f("ck_jobs_requested_mode") + ), + sa.CheckConstraint("state IN ('active','archived')", name=op.f("ck_jobs_state")), + sa.ForeignKeyConstraint( + ["repository_id"], + ["repositories.id"], + name=op.f("fk_jobs_repository_id_repositories"), + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["source_id"], + ["sources.id"], + name=op.f("fk_jobs_source_id_sources"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_jobs")), + sa.UniqueConstraint("name", name=op.f("uq_jobs_name")), + ) + op.create_index("ix_jobs_repository_id", "jobs", ["repository_id"], unique=False) + op.create_index("ix_jobs_source_id", "jobs", ["source_id"], unique=False) + op.create_table( + "notification_subscriptions", + sa.Column("channel", sa.String(length=32), nullable=False), + sa.Column("event_filters", sa.JSON(), nullable=False), + sa.Column("destination_config", sa.JSON(), nullable=False), + sa.Column("secret_id", sa.String(length=36), nullable=True), + sa.Column("state", sa.String(length=32), nullable=False), + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.CheckConstraint( + "channel IN ('webhook','email')", name=op.f("ck_notification_subscriptions_channel") + ), + sa.CheckConstraint( + "state IN ('active','disabled','archived')", + name=op.f("ck_notification_subscriptions_state"), + ), + sa.ForeignKeyConstraint( + ["secret_id"], + ["secrets.id"], + name=op.f("fk_notification_subscriptions_secret_id_secrets"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_notification_subscriptions")), + ) + op.create_table( + "notification_deliveries", + sa.Column("event_id", sa.String(length=36), nullable=False), + sa.Column("subscription_id", sa.String(length=36), nullable=False), + sa.Column("attempt", sa.Integer(), nullable=False), + sa.Column("state", sa.String(length=32), nullable=False), + sa.Column("response_class", sa.String(length=64), nullable=True), + sa.Column("next_attempt_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.CheckConstraint( + "state IN ('pending','delivered','retry','failed')", + name=op.f("ck_notification_deliveries_state"), + ), + sa.CheckConstraint("attempt > 0", name=op.f("ck_notification_deliveries_attempt_positive")), + sa.ForeignKeyConstraint( + ["subscription_id"], + ["notification_subscriptions.id"], + name=op.f("fk_notification_deliveries_subscription_id_notification_subscriptions"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_notification_deliveries")), + sa.UniqueConstraint("event_id", "subscription_id", "attempt", name="uq_delivery_attempt"), + ) + op.create_index( + "ix_notification_deliveries_state_next", + "notification_deliveries", + ["state", "next_attempt_at"], + unique=False, + ) + op.create_table( + "schedules", + sa.Column("job_id", sa.String(length=36), nullable=False), + sa.Column("cron", sa.String(length=255), nullable=False), + sa.Column("timezone", sa.String(length=255), nullable=False), + sa.Column("misfire_grace_seconds", sa.Integer(), nullable=False), + sa.Column("overlap_policy", sa.String(length=32), nullable=False), + sa.Column("enabled", sa.Boolean(), nullable=False), + sa.Column("next_nominal_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("last_enqueue_outcome", sa.String(length=64), nullable=True), + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.CheckConstraint( + "overlap_policy IN ('prohibit')", name=op.f("ck_schedules_overlap_policy") + ), + sa.CheckConstraint( + "misfire_grace_seconds >= 0", name=op.f("ck_schedules_misfire_nonnegative") + ), + sa.ForeignKeyConstraint( + ["job_id"], ["jobs.id"], name=op.f("fk_schedules_job_id_jobs"), ondelete="RESTRICT" + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_schedules")), + sa.UniqueConstraint("job_id", name=op.f("uq_schedules_job_id")), + ) + op.create_table( + "executions", + sa.Column("job_id", sa.String(length=36), nullable=False), + sa.Column("schedule_id", sa.String(length=36), nullable=True), + sa.Column("nominal_run_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("trigger", sa.String(length=32), nullable=False), + sa.Column("state", sa.String(length=32), nullable=False), + sa.Column("attempt", sa.Integer(), nullable=False), + sa.Column("lease_owner", sa.String(length=255), nullable=True), + sa.Column("lease_expires_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("heartbeat_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("progress", sa.JSON(), nullable=False), + sa.Column("reason_code", sa.String(length=64), nullable=True), + sa.Column("operator_message", sa.Text(), nullable=True), + sa.Column("started_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.CheckConstraint( + "state IN ('queued','preparing','running','verifying','committed'," + "'cancelling','cancelled','failed')", + name=op.f("ck_executions_state"), + ), + sa.CheckConstraint( + "trigger IN ('manual','schedule','retry')", name=op.f("ck_executions_trigger") + ), + sa.CheckConstraint("attempt > 0", name=op.f("ck_executions_attempt_positive")), + sa.ForeignKeyConstraint( + ["job_id"], ["jobs.id"], name=op.f("fk_executions_job_id_jobs"), ondelete="RESTRICT" + ), + sa.ForeignKeyConstraint( + ["schedule_id"], + ["schedules.id"], + name=op.f("fk_executions_schedule_id_schedules"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_executions")), + sa.UniqueConstraint( + "schedule_id", "nominal_run_at", name="uq_execution_schedule_occurrence" + ), + ) + op.create_index("ix_executions_job_id", "executions", ["job_id"], unique=False) + op.create_index( + "ix_executions_state_created", "executions", ["state", "created_at"], unique=False + ) + op.create_index( + "uq_executions_active_job", + "executions", + ["job_id"], + unique=True, + sqlite_where=sa.text("state IN ('queued','preparing','running','verifying','cancelling')"), + ) + op.create_table( + "backups", + sa.Column("execution_id", sa.String(length=36), nullable=False), + sa.Column("parent_backup_id", sa.String(length=36), nullable=True), + sa.Column("manifest_id", sa.String(length=36), nullable=False), + sa.Column("manifest_digest", sa.String(length=64), nullable=False), + sa.Column("logical_bytes", sa.Integer(), nullable=False), + sa.Column("stored_bytes", sa.Integer(), nullable=False), + sa.Column("integrity", sa.String(length=32), nullable=False), + sa.Column("pinned", sa.Boolean(), nullable=False), + sa.Column("tombstoned_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column("id", sa.String(length=36), nullable=False), + sa.CheckConstraint( + "integrity IN ('unverified','verified','degraded','corrupt')", + name=op.f("ck_backups_integrity"), + ), + sa.CheckConstraint("logical_bytes >= 0", name=op.f("ck_backups_logical_bytes_nonnegative")), + sa.CheckConstraint("stored_bytes >= 0", name=op.f("ck_backups_stored_bytes_nonnegative")), + sa.ForeignKeyConstraint( + ["execution_id"], + ["executions.id"], + name=op.f("fk_backups_execution_id_executions"), + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["parent_backup_id"], + ["backups.id"], + name=op.f("fk_backups_parent_backup_id_backups"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_backups")), + sa.UniqueConstraint("execution_id", name=op.f("uq_backups_execution_id")), + sa.UniqueConstraint("manifest_digest", name=op.f("uq_backups_manifest_digest")), + sa.UniqueConstraint("manifest_id", name=op.f("uq_backups_manifest_id")), + ) + op.create_index("ix_backups_created_at", "backups", ["created_at"], unique=False) + op.create_table( + "restores", + sa.Column("backup_id", sa.String(length=36), nullable=False), + sa.Column("destination", sa.Text(), nullable=False), + sa.Column("selection", sa.JSON(), nullable=False), + sa.Column("overwrite_policy", sa.String(length=32), nullable=False), + sa.Column("state", sa.String(length=32), nullable=False), + sa.Column("result", sa.JSON(), nullable=True), + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.CheckConstraint( + "overwrite_policy IN ('fail','skip','replace')", + name=op.f("ck_restores_overwrite_policy"), + ), + sa.CheckConstraint( + "state IN ('queued','running','committed','cancelled','failed')", + name=op.f("ck_restores_state"), + ), + sa.ForeignKeyConstraint( + ["backup_id"], + ["backups.id"], + name=op.f("fk_restores_backup_id_backups"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_restores")), + ) + op.create_index("ix_restores_backup_id", "restores", ["backup_id"], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index("ix_restores_backup_id", table_name="restores") + op.drop_table("restores") + op.drop_index("ix_backups_created_at", table_name="backups") + op.drop_table("backups") + op.drop_index( + "uq_executions_active_job", + table_name="executions", + sqlite_where=sa.text("state IN ('queued','preparing','running','verifying','cancelling')"), + ) + op.drop_index("ix_executions_state_created", table_name="executions") + op.drop_index("ix_executions_job_id", table_name="executions") + op.drop_table("executions") + op.drop_table("schedules") + op.drop_index("ix_notification_deliveries_state_next", table_name="notification_deliveries") + op.drop_table("notification_deliveries") + op.drop_table("notification_subscriptions") + op.drop_index("ix_jobs_source_id", table_name="jobs") + op.drop_index("ix_jobs_repository_id", table_name="jobs") + op.drop_table("jobs") + op.drop_index("ix_idempotency_created_at", table_name="idempotency_records") + op.drop_table("idempotency_records") + op.drop_index("ix_audit_events_created_at", table_name="audit_events") + op.drop_index("ix_audit_events_actor_id", table_name="audit_events") + op.drop_table("audit_events") + op.drop_index("ix_api_tokens_owner_id", table_name="api_tokens") + op.drop_table("api_tokens") + op.drop_table("users") + op.drop_table("sources") + op.drop_table("secrets") + op.drop_table("repositories") + # ### end Alembic commands ### diff --git a/backend/alembic/versions/0002_sessions.py b/backend/alembic/versions/0002_sessions.py new file mode 100644 index 0000000..4450cca --- /dev/null +++ b/backend/alembic/versions/0002_sessions.py @@ -0,0 +1,50 @@ +"""persist authenticated sessions + +Revision ID: 0002_sessions +Revises: 0001_v2_baseline +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0002_sessions" +down_revision: str | Sequence[str] | None = "0001_v2_baseline" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "sessions", + sa.Column("user_id", sa.String(length=36), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + name=op.f("fk_sessions_user_id_users"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_sessions")), + ) + op.create_index(op.f("ix_sessions_user_id"), "sessions", ["user_id"], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f("ix_sessions_user_id"), table_name="sessions") + op.drop_table("sessions") diff --git a/backend/alembic/versions/0003_execution_events.py b/backend/alembic/versions/0003_execution_events.py new file mode 100644 index 0000000..073ee49 --- /dev/null +++ b/backend/alembic/versions/0003_execution_events.py @@ -0,0 +1,37 @@ +"""add durable execution event revision + +Revision ID: 0003_execution_events +Revises: 0002_sessions +""" + +import sqlalchemy as sa +from alembic import op + +revision = "0003_execution_events" +down_revision = "0002_sessions" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("executions") as batch: + batch.add_column(sa.Column("revision", sa.Integer(), nullable=False, server_default="0")) + op.create_table( + "execution_events", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column( + "execution_id", + sa.String(36), + sa.ForeignKey("executions.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("revision", sa.Integer(), nullable=False), + sa.Column("payload", sa.JSON(), nullable=False), + sa.UniqueConstraint("execution_id", "revision", name="uq_execution_event_revision"), + ) + + +def downgrade() -> None: + op.drop_table("execution_events") + with op.batch_alter_table("executions") as batch: + batch.drop_column("revision") diff --git a/backend/alembic/versions/0004_repository_signing_keys.py b/backend/alembic/versions/0004_repository_signing_keys.py new file mode 100644 index 0000000..7964485 --- /dev/null +++ b/backend/alembic/versions/0004_repository_signing_keys.py @@ -0,0 +1,29 @@ +"""bind repositories to manifest signing public keys + +Revision ID: 0004_repository_signing_keys +Revises: 0003_execution_events +""" + +import sqlalchemy as sa +from alembic import op + +revision = "0004_repository_signing_keys" +down_revision = "0003_execution_events" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("repositories") as batch: + batch.add_column( + sa.Column("signing_key_id", sa.String(length=64), nullable=False, server_default="") + ) + batch.add_column( + sa.Column("signing_public_key", sa.String(length=64), nullable=False, server_default="") + ) + + +def downgrade() -> None: + with op.batch_alter_table("repositories") as batch: + batch.drop_column("signing_public_key") + batch.drop_column("signing_key_id") diff --git a/backend/alembic/versions/0005_restore_dry_run.py b/backend/alembic/versions/0005_restore_dry_run.py new file mode 100644 index 0000000..044e2dd --- /dev/null +++ b/backend/alembic/versions/0005_restore_dry_run.py @@ -0,0 +1,23 @@ +"""persist restore dry-run intent + +Revision ID: 0005_restore_dry_run +Revises: 0004_repository_signing_keys +""" + +import sqlalchemy as sa +from alembic import op + +revision = "0005_restore_dry_run" +down_revision = "0004_repository_signing_keys" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("restores") as batch: + batch.add_column(sa.Column("dry_run", sa.Boolean(), nullable=False, server_default="0")) + + +def downgrade() -> None: + with op.batch_alter_table("restores") as batch: + batch.drop_column("dry_run") diff --git a/backend/alembic/versions/0006_local_sources_only.py b/backend/alembic/versions/0006_local_sources_only.py new file mode 100644 index 0000000..02f0ebd --- /dev/null +++ b/backend/alembic/versions/0006_local_sources_only.py @@ -0,0 +1,43 @@ +"""restrict persisted sources to local + +Revision ID: 0006_local_sources_only +Revises: 0005_restore_dry_run +""" + +import sqlalchemy as sa +from alembic import op + +revision = "0006_local_sources_only" +down_revision = "0005_restore_dry_run" +branch_labels = None +depends_on = None + + +def _reject_nonlocal_sources() -> None: + connection = op.get_bind() + sources = sa.table("sources", sa.column("kind")) + count = connection.scalar( + sa.select(sa.func.count()).select_from(sources).where(sources.c.kind != "local") + ) + if count is None: + raise RuntimeError("Cannot inspect persisted source kinds before migration.") + if count: + raise RuntimeError( + "Cannot restrict sources to local: " + f"found {count} non-local source row(s). Remove or migrate them before upgrading." + ) + + +def upgrade() -> None: + _reject_nonlocal_sources() + with op.batch_alter_table("sources") as batch: + batch.drop_constraint(op.f("ck_sources_kind"), type_="check") + batch.create_check_constraint(op.f("ck_sources_kind"), "kind = 'local'") + + +def downgrade() -> None: + with op.batch_alter_table("sources") as batch: + batch.drop_constraint(op.f("ck_sources_kind"), type_="check") + batch.create_check_constraint( + op.f("ck_sources_kind"), "kind IN ('local','sftp','postgresql','mysql')" + ) diff --git a/backend/alembic/versions/0007_repository_data_key_epochs.py b/backend/alembic/versions/0007_repository_data_key_epochs.py new file mode 100644 index 0000000..de91d87 --- /dev/null +++ b/backend/alembic/versions/0007_repository_data_key_epochs.py @@ -0,0 +1,87 @@ +"""add repository data key epochs + +Revision ID: 0007_repository_data_key_epochs +Revises: 0006_local_sources_only +""" + +import sqlalchemy as sa +from alembic import op + +revision = "0007_repository_data_key_epochs" +down_revision = "0006_local_sources_only" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("repositories", sa.Column("active_data_key_id", sa.String(36), nullable=True)) + op.add_column("backups", sa.Column("data_key_id", sa.String(36), nullable=True)) + op.create_table( + "repository_data_key_epochs", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + sa.Column( + "repository_id", + sa.String(36), + sa.ForeignKey("repositories.id", ondelete="RESTRICT"), + nullable=False, + ), + sa.Column("key_id", sa.String(36), nullable=False), + sa.Column("state", sa.String(16), nullable=False), + sa.Column("retired_at", sa.DateTime(timezone=True)), + sa.UniqueConstraint( + "repository_id", "key_id", name="uq_repository_data_key_epochs_repository_key_epoch" + ), + sa.CheckConstraint( + "state IN ('active','retired')", + name="ck_repository_data_key_epochs_repository_data_key_epoch_state", + ), + ) + op.create_index( + "ix_repository_data_key_epochs_repository_id", + "repository_data_key_epochs", + ["repository_id"], + ) + op.create_index( + "uq_repository_data_key_epochs_active", + "repository_data_key_epochs", + ["repository_id"], + unique=True, + sqlite_where=sa.text("state = 'active'"), + ) + + +def downgrade() -> None: + connection = op.get_bind() + epochs = sa.table("repository_data_key_epochs") + repositories = sa.table("repositories", sa.column("active_data_key_id")) + backups = sa.table("backups", sa.column("data_key_id")) + epoch_count = connection.scalar(sa.select(sa.func.count()).select_from(epochs)) + active_key_count = connection.scalar( + sa.select(sa.func.count()) + .select_from(repositories) + .where(repositories.c.active_data_key_id.is_not(None)) + ) + backup_key_count = connection.scalar( + sa.select(sa.func.count()).select_from(backups).where(backups.c.data_key_id.is_not(None)) + ) + if epoch_count or active_key_count or backup_key_count: + raise RuntimeError("cannot downgrade while repository data key metadata exists") + op.drop_index("uq_repository_data_key_epochs_active", table_name="repository_data_key_epochs") + op.drop_index( + "ix_repository_data_key_epochs_repository_id", table_name="repository_data_key_epochs" + ) + op.drop_table("repository_data_key_epochs") + op.drop_column("backups", "data_key_id") + op.drop_column("repositories", "active_data_key_id") diff --git a/backend/alembic/versions/0008_notification_outbox.py b/backend/alembic/versions/0008_notification_outbox.py new file mode 100644 index 0000000..45ced6d --- /dev/null +++ b/backend/alembic/versions/0008_notification_outbox.py @@ -0,0 +1,445 @@ +"""replace notification attempt stub with a durable M12 outbox + +Revision ID: 0008_notification_outbox +Revises: 0007_repository_data_key_epochs +""" + +from __future__ import annotations + +import json +from datetime import UTC, datetime +from typing import Any + +import sqlalchemy as sa +from alembic import op +from backup_tool.ids import new_uuid7 + +revision = "0008_notification_outbox" +down_revision = "0007_repository_data_key_epochs" +branch_labels = None +depends_on = None + + +def _now() -> datetime: + return datetime.now(UTC) + + +def _parameterized_execute(connection: sa.Connection, statement: object) -> Any: + """Execute only SQLAlchemy Core statements, never dynamic SQL strings.""" + database = connection.execution_options() + return database.execute(statement) # type: ignore[arg-type] + + +def upgrade() -> None: + connection = op.get_bind() + # SQLite batch-rebuilds notification_subscriptions. Its old delivery table + # references this table, so enforcement must be suspended for this migration + # only while the legacy rows are copied into the replacement outbox shape. + if connection.dialect.name == "sqlite": + connection.exec_driver_sql("PRAGMA foreign_keys=OFF") + # Extend the existing subscription rows first: pre-M12 rows stay disabled until + # an operator explicitly configures a new credential/key. + with op.batch_alter_table("notification_subscriptions") as batch: + batch.add_column( + sa.Column("rate_limit_per_minute", sa.Integer(), nullable=False, server_default="60") + ) + batch.add_column(sa.Column("rate_tokens", sa.Float(), nullable=False, server_default="60")) + batch.add_column(sa.Column("rate_updated_at", sa.DateTime(timezone=True), nullable=True)) + batch.add_column(sa.Column("revision", sa.Integer(), nullable=False, server_default="1")) + batch.create_check_constraint("rate_positive", "rate_limit_per_minute > 0") + batch.create_check_constraint("rate_tokens_nonnegative", "rate_tokens >= 0") + batch.create_check_constraint("revision_positive", "revision > 0") + + op.create_table( + "notification_events", + sa.Column("type", sa.String(96), nullable=False), + sa.Column("schema_version", sa.Integer(), nullable=False, server_default="1"), + sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("correlation_id", sa.String(36), nullable=False), + sa.Column("severity", sa.String(16), nullable=False), + sa.Column("resource_refs", sa.JSON(), nullable=False), + sa.Column("payload", sa.JSON(), nullable=False), + sa.Column("canonical_envelope", sa.Text(), nullable=False), + sa.Column("deduplication_key", sa.String(255), nullable=True), + sa.Column("id", sa.String(36), primary_key=True), + sa.CheckConstraint("schema_version = 1", name="ck_notification_events_schema_version"), + sa.CheckConstraint( + "severity IN ('info','warning','error','security')", + name="ck_notification_events_severity", + ), + sa.UniqueConstraint("deduplication_key", name="uq_notification_events_deduplication_key"), + ) + op.create_index( + "ix_notification_events_type_occurred", "notification_events", ["type", "occurred_at"] + ) + + # Preserve unexpected rows from the unused baseline shape. Renaming first + # keeps the original data intact if an upgrade is interrupted before copy. + op.rename_table("notification_deliveries", "notification_deliveries_legacy") + op.create_table( + "notification_deliveries", + sa.Column( + "event_id", + sa.String(36), + sa.ForeignKey("notification_events.id", ondelete="RESTRICT"), + nullable=False, + ), + sa.Column( + "subscription_id", + sa.String(36), + sa.ForeignKey("notification_subscriptions.id", ondelete="RESTRICT"), + nullable=False, + ), + sa.Column("state", sa.String(32), nullable=False, server_default="pending"), + sa.Column("due_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("lease_owner", sa.String(255), nullable=True), + sa.Column("lease_expires_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("attempt_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("terminal_reason", sa.String(96), nullable=True), + sa.Column("response_class", sa.String(64), nullable=True), + sa.Column("response_summary", sa.String(512), nullable=True), + sa.Column("id", sa.String(36), primary_key=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + sa.CheckConstraint( + "attempt_count >= 0", name="ck_notification_deliveries_attempt_count_nonnegative" + ), + sa.CheckConstraint( + "state IN ('pending','leased','delivered','retry','failed')", + name="ck_notification_deliveries_state", + ), + sa.UniqueConstraint( + "event_id", "subscription_id", name="uq_notification_deliveries_event_subscription" + ), + ) + op.create_index( + "ix_notification_deliveries_due", "notification_deliveries", ["state", "due_at"] + ) + op.create_index( + "ix_notification_deliveries_lease", "notification_deliveries", ["state", "lease_expires_at"] + ) + op.create_table( + "notification_delivery_attempts", + sa.Column( + "delivery_id", + sa.String(36), + sa.ForeignKey("notification_deliveries.id", ondelete="RESTRICT"), + nullable=False, + ), + sa.Column("number", sa.Integer(), nullable=False), + sa.Column("started_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("outcome", sa.String(32), nullable=False, server_default="started"), + sa.Column("response_class", sa.String(64), nullable=True), + sa.Column("diagnostic", sa.String(512), nullable=True), + sa.Column("id", sa.String(36), primary_key=True), + sa.CheckConstraint("number > 0", name="ck_notification_delivery_attempts_number_positive"), + sa.CheckConstraint( + "outcome IN ('started','delivered','retry','failed')", + name="ck_notification_delivery_attempts_outcome", + ), + sa.UniqueConstraint( + "delivery_id", "number", name="uq_notification_delivery_attempts_delivery_number" + ), + ) + op.create_index( + "ix_notification_attempts_delivery", + "notification_delivery_attempts", + ["delivery_id", "number"], + ) + + legacy = sa.table( + "notification_deliveries_legacy", + sa.column("id"), + sa.column("event_id"), + sa.column("subscription_id"), + sa.column("attempt"), + sa.column("state"), + sa.column("response_class"), + sa.column("next_attempt_at"), + sa.column("created_at"), + sa.column("updated_at"), + ) + rows = _parameterized_execute(connection, sa.select(legacy)).mappings().all() + events = sa.table( + "notification_events", + sa.column("id"), + sa.column("type"), + sa.column("schema_version"), + sa.column("occurred_at"), + sa.column("correlation_id"), + sa.column("severity"), + sa.column("resource_refs", sa.JSON()), + sa.column("payload", sa.JSON()), + sa.column("canonical_envelope"), + sa.column("deduplication_key"), + ) + deliveries = sa.table( + "notification_deliveries", + *[ + sa.column(name) + for name in ( + "id", + "event_id", + "subscription_id", + "state", + "due_at", + "attempt_count", + "terminal_reason", + "response_class", + "response_summary", + "created_at", + "updated_at", + ) + ], + ) + attempts = sa.table( + "notification_delivery_attempts", + *[ + sa.column(name) + for name in ( + "id", + "delivery_id", + "number", + "started_at", + "completed_at", + "outcome", + "response_class", + "diagnostic", + ) + ], + ) + for row in rows: + occurred = row["created_at"] or _now() + event_id, delivery_id, attempt_id = (str(new_uuid7()), str(new_uuid7()), str(new_uuid7())) + payload = {"legacy_event_id": str(row["event_id"]), "legacy_delivery_id": str(row["id"])} + envelope = { + "event_schema_version": 1, + "id": event_id, + "type": "notification.legacy", + "occurred_at": occurred.isoformat() + if hasattr(occurred, "isoformat") + else str(occurred), + "correlation_id": event_id, + "severity": "warning", + "resource": {"subscription_id": str(row["subscription_id"])}, + "payload": payload, + } + _parameterized_execute( + connection, + events.insert().values( + id=event_id, + type="notification.legacy", + schema_version=1, + occurred_at=occurred, + correlation_id=event_id, + severity="warning", + resource_refs=envelope["resource"], + payload=payload, + canonical_envelope=json.dumps(envelope, sort_keys=True, separators=(",", ":")), + deduplication_key=f"legacy:{row['id']}", + ), + ) + old_state = str(row["state"]) + new_state = "retry" if old_state == "pending" else old_state + try: + legacy_attempt = max(1, int(row["attempt"])) + except (TypeError, ValueError) as error: + raise RuntimeError("legacy notification delivery has an invalid attempt") from error + _parameterized_execute( + connection, + deliveries.insert().values( + id=delivery_id, + event_id=event_id, + subscription_id=row["subscription_id"], + state=new_state, + due_at=row["next_attempt_at"] or occurred, + attempt_count=legacy_attempt, + terminal_reason="legacy_migrated" if new_state == "failed" else None, + response_class=row["response_class"], + response_summary="legacy delivery migrated", + created_at=occurred, + updated_at=row["updated_at"] or occurred, + ), + ) + _parameterized_execute( + connection, + attempts.insert().values( + id=attempt_id, + delivery_id=delivery_id, + number=legacy_attempt, + started_at=occurred, + completed_at=row["updated_at"] if new_state in {"delivered", "failed"} else None, + outcome="delivered" + if new_state == "delivered" + else ("failed" if new_state == "failed" else "retry"), + response_class=row["response_class"], + diagnostic="legacy delivery migrated", + ), + ) + op.drop_table("notification_deliveries_legacy") + + op.create_table( + "notification_signing_keys", + sa.Column( + "subscription_id", + sa.String(36), + sa.ForeignKey("notification_subscriptions.id", ondelete="RESTRICT"), + nullable=False, + ), + sa.Column("version", sa.Integer(), nullable=False), + sa.Column( + "secret_id", + sa.String(36), + sa.ForeignKey("secrets.id", ondelete="RESTRICT"), + nullable=False, + ), + sa.Column("state", sa.String(16), nullable=False, server_default="active"), + sa.Column("overlap_expires_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("id", sa.String(36), primary_key=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + sa.CheckConstraint("version > 0", name="ck_notification_signing_keys_version_positive"), + sa.CheckConstraint( + "state IN ('active','overlap','retired')", name="ck_notification_signing_keys_state" + ), + sa.UniqueConstraint( + "subscription_id", "version", name="uq_notification_signing_keys_subscription_version" + ), + ) + op.create_index( + "ix_notification_signing_keys_subscription", + "notification_signing_keys", + ["subscription_id", "state"], + ) + op.create_index( + "uq_notification_signing_keys_active", + "notification_signing_keys", + ["subscription_id"], + unique=True, + sqlite_where=sa.text("state = 'active'"), + ) + op.create_index( + "uq_notification_signing_keys_overlap", + "notification_signing_keys", + ["subscription_id"], + unique=True, + sqlite_where=sa.text("state = 'overlap'"), + ) + op.create_table( + "notification_email_settings", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("host", sa.String(255), nullable=False), + sa.Column("port", sa.Integer(), nullable=False, server_default="587"), + sa.Column("username", sa.String(255), nullable=False), + sa.Column( + "password_secret_id", + sa.String(36), + sa.ForeignKey("secrets.id", ondelete="RESTRICT"), + nullable=False, + ), + sa.Column("sender", sa.String(320), nullable=False), + sa.Column("max_attempts", sa.Integer(), nullable=False, server_default="5"), + sa.Column("rate_limit_per_minute", sa.Integer(), nullable=False, server_default="60"), + sa.CheckConstraint("id = 1", name="ck_notification_email_settings_singleton"), + sa.CheckConstraint("port > 0 AND port < 65536", name="ck_notification_email_settings_port"), + sa.CheckConstraint("max_attempts > 0", name="ck_notification_email_settings_max_attempts"), + sa.CheckConstraint( + "rate_limit_per_minute > 0", name="ck_notification_email_settings_rate_positive" + ), + ) + if connection.dialect.name == "sqlite": + connection.exec_driver_sql("PRAGMA foreign_keys=ON") + + +def downgrade() -> None: + bind = op.get_bind() + for table in ( + "notification_events", + "notification_delivery_attempts", + "notification_signing_keys", + "notification_email_settings", + ): + if bind.scalar(sa.text(f"SELECT count(*) FROM {table}")): + raise RuntimeError( + "cannot downgrade while M12 notification history or configuration exists" + ) + # A fresh M12 schema can safely return to the historical stub shape. + op.drop_table("notification_email_settings") + op.drop_index("uq_notification_signing_keys_overlap", table_name="notification_signing_keys") + op.drop_index("uq_notification_signing_keys_active", table_name="notification_signing_keys") + op.drop_index( + "ix_notification_signing_keys_subscription", table_name="notification_signing_keys" + ) + op.drop_table("notification_signing_keys") + op.drop_index("ix_notification_attempts_delivery", table_name="notification_delivery_attempts") + op.drop_table("notification_delivery_attempts") + op.drop_index("ix_notification_deliveries_lease", table_name="notification_deliveries") + op.drop_index("ix_notification_deliveries_due", table_name="notification_deliveries") + op.drop_table("notification_deliveries") + op.drop_index("ix_notification_events_type_occurred", table_name="notification_events") + op.drop_table("notification_events") + op.create_table( + "notification_deliveries", + sa.Column("event_id", sa.String(36), nullable=False), + sa.Column("subscription_id", sa.String(36), nullable=False), + sa.Column("attempt", sa.Integer(), nullable=False), + sa.Column("state", sa.String(32), nullable=False), + sa.Column("response_class", sa.String(64)), + sa.Column("next_attempt_at", sa.DateTime(timezone=True)), + sa.Column("id", sa.String(36), primary_key=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + sa.CheckConstraint("attempt > 0", name="ck_notification_deliveries_attempt_positive"), + sa.CheckConstraint( + "state IN ('pending','delivered','retry','failed')", + name="ck_notification_deliveries_state", + ), + sa.ForeignKeyConstraint( + ["subscription_id"], ["notification_subscriptions.id"], ondelete="RESTRICT" + ), + sa.UniqueConstraint("event_id", "subscription_id", "attempt", name="uq_delivery_attempt"), + ) + op.create_index( + "ix_notification_deliveries_state_next", + "notification_deliveries", + ["state", "next_attempt_at"], + ) + with op.batch_alter_table("notification_subscriptions") as batch: + batch.drop_constraint("revision_positive", type_="check") + batch.drop_constraint("rate_tokens_nonnegative", type_="check") + batch.drop_constraint("rate_positive", type_="check") + batch.drop_column("revision") + batch.drop_column("rate_updated_at") + batch.drop_column("rate_tokens") + batch.drop_column("rate_limit_per_minute") diff --git a/backend/alembic/versions/0009_allow_ssh_sources.py b/backend/alembic/versions/0009_allow_ssh_sources.py new file mode 100644 index 0000000..89af8d6 --- /dev/null +++ b/backend/alembic/versions/0009_allow_ssh_sources.py @@ -0,0 +1,43 @@ +"""allow staged SSH source definitions + +Revision ID: 0009_allow_ssh_sources +Revises: 0008_notification_outbox +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = "0009_allow_ssh_sources" +down_revision = "0008_notification_outbox" +branch_labels = None +depends_on = None + + +def _reject_ssh_sources() -> None: + connection = op.get_bind() + sources = sa.table("sources", sa.column("kind")) + count = connection.scalar( + sa.select(sa.func.count()).select_from(sources).where(sources.c.kind == "ssh") + ) + if count is None: + raise RuntimeError("Cannot inspect persisted SSH sources before migration.") + if count: + raise RuntimeError( + "Cannot restrict sources to local: " + f"found {count} SSH source row(s). Remove them before downgrading." + ) + + +def upgrade() -> None: + with op.batch_alter_table("sources") as batch: + batch.drop_constraint(op.f("ck_sources_kind"), type_="check") + batch.create_check_constraint(op.f("ck_sources_kind"), "kind IN ('local','ssh')") + + +def downgrade() -> None: + _reject_ssh_sources() + with op.batch_alter_table("sources") as batch: + batch.drop_constraint(op.f("ck_sources_kind"), type_="check") + batch.create_check_constraint(op.f("ck_sources_kind"), "kind = 'local'") diff --git a/backend/app/__pycache__/database.cpython-314.pyc b/backend/app/__pycache__/database.cpython-314.pyc deleted file mode 100644 index 13e9307..0000000 Binary files a/backend/app/__pycache__/database.cpython-314.pyc and /dev/null differ diff --git a/backend/app/__pycache__/main.cpython-314.pyc b/backend/app/__pycache__/main.cpython-314.pyc deleted file mode 100644 index d2a6ce2..0000000 Binary files a/backend/app/__pycache__/main.cpython-314.pyc and /dev/null differ diff --git a/backend/app/__pycache__/models.cpython-314.pyc b/backend/app/__pycache__/models.cpython-314.pyc deleted file mode 100644 index c8c1382..0000000 Binary files a/backend/app/__pycache__/models.cpython-314.pyc and /dev/null differ diff --git a/backend/app/__pycache__/schemas.cpython-314.pyc b/backend/app/__pycache__/schemas.cpython-314.pyc deleted file mode 100644 index 16446a8..0000000 Binary files a/backend/app/__pycache__/schemas.cpython-314.pyc and /dev/null differ diff --git a/backend/app/database.py b/backend/app/database.py deleted file mode 100644 index 4695d8b..0000000 --- a/backend/app/database.py +++ /dev/null @@ -1,17 +0,0 @@ -import os -from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker -from sqlalchemy.orm import declarative_base - -DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite+aiosqlite:///./backup_tool.db") - -engine = create_async_engine(DATABASE_URL, echo=os.environ.get("SQL_ECHO", "false").lower() == "true") -AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - -Base = declarative_base() - -async def get_db(): - async with AsyncSessionLocal() as session: - try: - yield session - finally: - await session.close() diff --git a/backend/app/main.py b/backend/app/main.py deleted file mode 100644 index e9ae032..0000000 --- a/backend/app/main.py +++ /dev/null @@ -1,60 +0,0 @@ -import os -from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware -from fastapi.staticfiles import StaticFiles -from fastapi.responses import FileResponse -from contextlib import asynccontextmanager -from app.database import engine, Base -from app import models # noqa: F401 - registers models with Base.metadata -from app.routers import sources, jobs, executions, backups, settings, dashboard -from backup.scheduler import backup_scheduler - -@asynccontextmanager -async def lifespan(app: FastAPI): - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - backup_scheduler.start() - await backup_scheduler.sync_schedules() - yield - backup_scheduler.shutdown() - -app = FastAPI( - title="Backup Tool API", - version="0.1.0", - lifespan=lifespan -) - -app.add_middleware( - CORSMiddleware, - allow_origins=os.environ.get("CORS_ORIGINS", "http://localhost:3000").split(","), - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -app.include_router(sources.router) -app.include_router(jobs.router) -app.include_router(executions.router) -app.include_router(backups.router) -app.include_router(settings.router) -app.include_router(dashboard.router) - -@app.get("/api/health") -async def health_check(): - return {"status": "healthy"} - -def main(): - import uvicorn - uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True) - -# Static files for production -frontend_dist = os.path.join(os.path.dirname(__file__), "../../frontend/dist") -if os.path.exists(frontend_dist): - app.mount("/assets", StaticFiles(directory=os.path.join(frontend_dist, "assets")), name="assets") - - @app.get("/{path:path}") - async def serve_frontend(path: str): - index_file = os.path.join(frontend_dist, "index.html") - if os.path.exists(index_file): - return FileResponse(index_file) - return {"detail": "Frontend not built"} diff --git a/backend/app/models.py b/backend/app/models.py deleted file mode 100644 index 83c1965..0000000 --- a/backend/app/models.py +++ /dev/null @@ -1,137 +0,0 @@ -from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, ForeignKey, JSON -from sqlalchemy.orm import relationship -from datetime import datetime, timezone -from .database import Base - - -class Source(Base): - __tablename__ = "sources" - - id = Column(Integer, primary_key=True) - name = Column(String, nullable=False) - type = Column(String, nullable=False) # local, ssh, database - config = Column(JSON, default=lambda: {}) - created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) - updated_at = Column( - DateTime, - default=lambda: datetime.now(timezone.utc), - onupdate=lambda: datetime.now(timezone.utc), - ) - - jobs = relationship("Job", back_populates="source", cascade="all, delete-orphan") - - def __repr__(self): - return f"" - - -class Job(Base): - __tablename__ = "jobs" - - id = Column(Integer, primary_key=True) - name = Column(String, nullable=False) - source_id = Column(Integer, ForeignKey("sources.id"), nullable=False, index=True) - strategy = Column(String, nullable=False, default="full") # full, incremental - destination_path = Column(String, nullable=False) - exclude_patterns = Column(JSON, default=lambda: []) - enabled = Column(Boolean, default=True) - created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) - updated_at = Column( - DateTime, - default=lambda: datetime.now(timezone.utc), - onupdate=lambda: datetime.now(timezone.utc), - ) - - source = relationship("Source", back_populates="jobs") - schedule = relationship( - "Schedule", back_populates="job", uselist=False, cascade="all, delete-orphan" - ) - executions = relationship( - "JobExecution", back_populates="job", cascade="all, delete-orphan" - ) - - def __repr__(self): - return f"" - - -class Schedule(Base): - __tablename__ = "schedules" - - id = Column(Integer, primary_key=True) - job_id = Column( - Integer, ForeignKey("jobs.id"), unique=True, nullable=False, index=True - ) - cron_expression = Column(String, nullable=False) - enabled = Column(Boolean, default=True) - created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) - updated_at = Column( - DateTime, - default=lambda: datetime.now(timezone.utc), - onupdate=lambda: datetime.now(timezone.utc), - ) - - job = relationship("Job", back_populates="schedule") - - def __repr__(self): - return f"" - - -class JobExecution(Base): - __tablename__ = "job_executions" - - id = Column(Integer, primary_key=True) - job_id = Column(Integer, ForeignKey("jobs.id"), nullable=False, index=True) - status = Column( - String, nullable=False, default="pending" - ) # pending, running, success, failed, cancelled - started_at = Column(DateTime, nullable=True) - completed_at = Column(DateTime, nullable=True) - bytes_processed = Column(Integer, default=0) - bytes_backed_up = Column(Integer, default=0) - error_message = Column(Text, nullable=True) - triggered_by = Column(String, nullable=False) # manual, schedule - - job = relationship("Job", back_populates="executions") - backups = relationship( - "Backup", back_populates="execution", cascade="all, delete-orphan" - ) - - def __repr__(self): - return f"" - - -class Backup(Base): - __tablename__ = "backups" - - id = Column(Integer, primary_key=True) - execution_id = Column( - Integer, ForeignKey("job_executions.id"), nullable=False, index=True - ) - storage_path = Column(String, nullable=False) - size_bytes = Column(Integer, default=0) - checksum = Column(String, nullable=True) - type = Column(String, nullable=False) # full, incremental - parent_backup_id = Column( - Integer, ForeignKey("backups.id"), nullable=True, index=True - ) - created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) - - execution = relationship("JobExecution", back_populates="backups") - parent_backup = relationship("Backup", remote_side=[id]) - - def __repr__(self): - return f"" - - -class Setting(Base): - __tablename__ = "settings" - - key = Column(String, primary_key=True) - value = Column(Text, nullable=True) - updated_at = Column( - DateTime, - default=lambda: datetime.now(timezone.utc), - onupdate=lambda: datetime.now(timezone.utc), - ) - - def __repr__(self): - return f"" diff --git a/backend/app/routers/__pycache__/backups.cpython-314.pyc b/backend/app/routers/__pycache__/backups.cpython-314.pyc deleted file mode 100644 index 7ba129c..0000000 Binary files a/backend/app/routers/__pycache__/backups.cpython-314.pyc and /dev/null differ diff --git a/backend/app/routers/__pycache__/dashboard.cpython-314.pyc b/backend/app/routers/__pycache__/dashboard.cpython-314.pyc deleted file mode 100644 index e4f4bf2..0000000 Binary files a/backend/app/routers/__pycache__/dashboard.cpython-314.pyc and /dev/null differ diff --git a/backend/app/routers/__pycache__/executions.cpython-314.pyc b/backend/app/routers/__pycache__/executions.cpython-314.pyc deleted file mode 100644 index 41aaa2f..0000000 Binary files a/backend/app/routers/__pycache__/executions.cpython-314.pyc and /dev/null differ diff --git a/backend/app/routers/__pycache__/jobs.cpython-314.pyc b/backend/app/routers/__pycache__/jobs.cpython-314.pyc deleted file mode 100644 index ad25d9e..0000000 Binary files a/backend/app/routers/__pycache__/jobs.cpython-314.pyc and /dev/null differ diff --git a/backend/app/routers/__pycache__/settings.cpython-314.pyc b/backend/app/routers/__pycache__/settings.cpython-314.pyc deleted file mode 100644 index 8193bdb..0000000 Binary files a/backend/app/routers/__pycache__/settings.cpython-314.pyc and /dev/null differ diff --git a/backend/app/routers/__pycache__/sources.cpython-314.pyc b/backend/app/routers/__pycache__/sources.cpython-314.pyc deleted file mode 100644 index 3979d16..0000000 Binary files a/backend/app/routers/__pycache__/sources.cpython-314.pyc and /dev/null differ diff --git a/backend/app/routers/backups.py b/backend/app/routers/backups.py deleted file mode 100644 index 6a3b441..0000000 --- a/backend/app/routers/backups.py +++ /dev/null @@ -1,34 +0,0 @@ -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select -from typing import List -from app.database import get_db -from app.models import Backup -from app.schemas import Backup as BackupSchema - -router = APIRouter(prefix="/api/backups", tags=["backups"]) - -@router.get("/", response_model=List[BackupSchema]) -async def list_backups(db: AsyncSession = Depends(get_db)): - result = await db.execute(select(Backup).order_by(Backup.created_at.desc())) - backups = result.scalars().all() - return backups - -@router.get("/{backup_id}", response_model=BackupSchema) -async def get_backup(backup_id: int, db: AsyncSession = Depends(get_db)): - result = await db.execute(select(Backup).where(Backup.id == backup_id)) - backup = result.scalar_one_or_none() - if not backup: - raise HTTPException(status_code=404, detail="Backup not found") - return backup - -@router.delete("/{backup_id}") -async def delete_backup(backup_id: int, db: AsyncSession = Depends(get_db)): - result = await db.execute(select(Backup).where(Backup.id == backup_id)) - backup = result.scalar_one_or_none() - if not backup: - raise HTTPException(status_code=404, detail="Backup not found") - - await db.delete(backup) - await db.commit() - return {"message": "Backup deleted"} diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py deleted file mode 100644 index 2f39f54..0000000 --- a/backend/app/routers/dashboard.py +++ /dev/null @@ -1,54 +0,0 @@ -from fastapi import APIRouter, Depends -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func, and_ -from datetime import datetime, timezone, timedelta -from typing import List - -from app.database import get_db -from app.models import Job, JobExecution, Backup -from app.schemas import DashboardStats, JobExecution as JobExecutionSchema - -router = APIRouter(prefix="/api/dashboard", tags=["dashboard"]) - - -@router.get("/", response_model=DashboardStats) -async def get_dashboard_stats(db: AsyncSession = Depends(get_db)): - # Active jobs count (enabled jobs) - active_jobs_result = await db.execute( - select(func.count(Job.id)).where(Job.enabled == True) - ) - active_jobs = active_jobs_result.scalar() or 0 - - # Total backups count - total_backups_result = await db.execute(select(func.count(Backup.id))) - total_backups = total_backups_result.scalar() or 0 - - # Storage used bytes (sum of all backup sizes) - storage_used_result = await db.execute(select(func.sum(Backup.size_bytes))) - storage_used_bytes = storage_used_result.scalar() or 0 - - # Recent failures count (last 24 hours) - twenty_four_hours_ago = datetime.now(timezone.utc) - timedelta(hours=24) - recent_failures_result = await db.execute( - select(func.count(JobExecution.id)).where( - and_( - JobExecution.status == "failed", - JobExecution.completed_at >= twenty_four_hours_ago, - ) - ) - ) - recent_failures = recent_failures_result.scalar() or 0 - - # Recent executions (last 10, ordered by started_at desc) - recent_executions_result = await db.execute( - select(JobExecution).order_by(JobExecution.started_at.desc()).limit(10) - ) - recent_executions = recent_executions_result.scalars().all() - - return DashboardStats( - active_jobs=active_jobs, - total_backups=total_backups, - storage_used_bytes=storage_used_bytes, - recent_failures=recent_failures, - recent_executions=list(recent_executions), - ) diff --git a/backend/app/routers/executions.py b/backend/app/routers/executions.py deleted file mode 100644 index c51dab8..0000000 --- a/backend/app/routers/executions.py +++ /dev/null @@ -1,23 +0,0 @@ -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select -from typing import List -from app.database import get_db -from app.models import JobExecution -from app.schemas import JobExecution as JobExecutionSchema - -router = APIRouter(prefix="/api/executions", tags=["executions"]) - -@router.get("/", response_model=List[JobExecutionSchema]) -async def list_executions(db: AsyncSession = Depends(get_db)): - result = await db.execute(select(JobExecution).order_by(JobExecution.started_at.desc())) - executions = result.scalars().all() - return executions - -@router.get("/{execution_id}", response_model=JobExecutionSchema) -async def get_execution(execution_id: int, db: AsyncSession = Depends(get_db)): - result = await db.execute(select(JobExecution).where(JobExecution.id == execution_id)) - execution = result.scalar_one_or_none() - if not execution: - raise HTTPException(status_code=404, detail="Execution not found") - return execution diff --git a/backend/app/routers/jobs.py b/backend/app/routers/jobs.py deleted file mode 100644 index 3a7b378..0000000 --- a/backend/app/routers/jobs.py +++ /dev/null @@ -1,101 +0,0 @@ -from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select -from typing import List -from app.database import get_db, AsyncSessionLocal -from app.models import Job, Schedule -from app.schemas import JobCreate, JobUpdate, Job as JobSchema, ScheduleCreate, Schedule as ScheduleSchema -from backup.engine import BackupEngine - -router = APIRouter(prefix="/api/jobs", tags=["jobs"]) - -@router.get("/", response_model=List[JobSchema]) -async def list_jobs(db: AsyncSession = Depends(get_db)): - result = await db.execute(select(Job)) - jobs = result.scalars().all() - return jobs - -@router.post("/", response_model=JobSchema) -async def create_job(job: JobCreate, db: AsyncSession = Depends(get_db)): - db_job = Job(**job.model_dump()) - db.add(db_job) - await db.commit() - await db.refresh(db_job) - return db_job - -@router.get("/{job_id}", response_model=JobSchema) -async def get_job(job_id: int, db: AsyncSession = Depends(get_db)): - result = await db.execute(select(Job).where(Job.id == job_id)) - job = result.scalar_one_or_none() - if not job: - raise HTTPException(status_code=404, detail="Job not found") - return job - -@router.put("/{job_id}", response_model=JobSchema) -async def update_job( - job_id: int, - job_update: JobUpdate, - db: AsyncSession = Depends(get_db) -): - result = await db.execute(select(Job).where(Job.id == job_id)) - job = result.scalar_one_or_none() - if not job: - raise HTTPException(status_code=404, detail="Job not found") - - update_data = job_update.model_dump(exclude_unset=True) - for field, value in update_data.items(): - setattr(job, field, value) - - await db.commit() - await db.refresh(job) - return job - -@router.delete("/{job_id}") -async def delete_job(job_id: int, db: AsyncSession = Depends(get_db)): - result = await db.execute(select(Job).where(Job.id == job_id)) - job = result.scalar_one_or_none() - if not job: - raise HTTPException(status_code=404, detail="Job not found") - - await db.delete(job) - await db.commit() - return {"message": "Job deleted"} - -@router.post("/{job_id}/run") -async def run_job( - job_id: int, - background_tasks: BackgroundTasks, - db: AsyncSession = Depends(get_db) -): - result = await db.execute(select(Job).where(Job.id == job_id)) - job = result.scalar_one_or_none() - if not job: - raise HTTPException(status_code=404, detail="Job not found") - - # Run in background - async def execute(): - async with AsyncSessionLocal() as session: - engine = BackupEngine(session) - await engine.execute_job(job_id, triggered_by="manual") - - background_tasks.add_task(execute) - return {"message": "Job execution started"} - -@router.post("/{job_id}/schedule", response_model=ScheduleSchema) -async def create_schedule( - job_id: int, - schedule: ScheduleCreate, - db: AsyncSession = Depends(get_db) -): - result = await db.execute(select(Job).where(Job.id == job_id)) - job = result.scalar_one_or_none() - if not job: - raise HTTPException(status_code=404, detail="Job not found") - - schedule_data = schedule.model_dump() - schedule_data["job_id"] = job_id - db_schedule = Schedule(**schedule_data) - db.add(db_schedule) - await db.commit() - await db.refresh(db_schedule) - return ScheduleSchema.model_validate(db_schedule) diff --git a/backend/app/routers/settings.py b/backend/app/routers/settings.py deleted file mode 100644 index f69c846..0000000 --- a/backend/app/routers/settings.py +++ /dev/null @@ -1,43 +0,0 @@ -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select -from typing import List -from app.database import get_db -from app.models import Setting -from app.schemas import Setting as SettingSchema, SettingUpdate - -router = APIRouter(prefix="/api/settings", tags=["settings"]) - -@router.get("/", response_model=List[SettingSchema]) -async def list_settings(db: AsyncSession = Depends(get_db)): - result = await db.execute(select(Setting)) - settings = result.scalars().all() - return settings - -@router.get("/{key}", response_model=SettingSchema) -async def get_setting(key: str, db: AsyncSession = Depends(get_db)): - result = await db.execute(select(Setting).where(Setting.key == key)) - setting = result.scalar_one_or_none() - if not setting: - raise HTTPException(status_code=404, detail="Setting not found") - return setting - -@router.put("/{key}", response_model=SettingSchema) -async def update_setting( - key: str, - setting_update: SettingUpdate, - db: AsyncSession = Depends(get_db) -): - result = await db.execute(select(Setting).where(Setting.key == key)) - setting = result.scalar_one_or_none() - - if not setting: - # Create if not exists - setting = Setting(key=key, value=setting_update.value) - db.add(setting) - else: - setting.value = setting_update.value - - await db.commit() - await db.refresh(setting) - return setting diff --git a/backend/app/routers/sources.py b/backend/app/routers/sources.py deleted file mode 100644 index 3d4fac6..0000000 --- a/backend/app/routers/sources.py +++ /dev/null @@ -1,61 +0,0 @@ -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select -from typing import List -from app.database import get_db -from app.models import Source -from app.schemas import SourceCreate, SourceUpdate, Source as SourceSchema - -router = APIRouter(prefix="/api/sources", tags=["sources"]) - -@router.get("/", response_model=List[SourceSchema]) -async def list_sources(db: AsyncSession = Depends(get_db)): - result = await db.execute(select(Source)) - sources = result.scalars().all() - return sources - -@router.post("/", response_model=SourceSchema) -async def create_source(source: SourceCreate, db: AsyncSession = Depends(get_db)): - db_source = Source(**source.model_dump()) - db.add(db_source) - await db.commit() - await db.refresh(db_source) - return db_source - -@router.get("/{source_id}", response_model=SourceSchema) -async def get_source(source_id: int, db: AsyncSession = Depends(get_db)): - result = await db.execute(select(Source).where(Source.id == source_id)) - source = result.scalar_one_or_none() - if not source: - raise HTTPException(status_code=404, detail="Source not found") - return source - -@router.put("/{source_id}", response_model=SourceSchema) -async def update_source( - source_id: int, - source_update: SourceUpdate, - db: AsyncSession = Depends(get_db) -): - result = await db.execute(select(Source).where(Source.id == source_id)) - source = result.scalar_one_or_none() - if not source: - raise HTTPException(status_code=404, detail="Source not found") - - update_data = source_update.model_dump(exclude_unset=True) - for field, value in update_data.items(): - setattr(source, field, value) - - await db.commit() - await db.refresh(source) - return source - -@router.delete("/{source_id}") -async def delete_source(source_id: int, db: AsyncSession = Depends(get_db)): - result = await db.execute(select(Source).where(Source.id == source_id)) - source = result.scalar_one_or_none() - if not source: - raise HTTPException(status_code=404, detail="Source not found") - - await db.delete(source) - await db.commit() - return {"message": "Source deleted"} diff --git a/backend/app/schemas.py b/backend/app/schemas.py deleted file mode 100644 index a7364b2..0000000 --- a/backend/app/schemas.py +++ /dev/null @@ -1,143 +0,0 @@ -from pydantic import BaseModel, Field, ConfigDict, field_validator -from typing import Optional, List, Dict, Any -from datetime import datetime -import re - -# Source schemas -class SourceBase(BaseModel): - name: str - type: str = Field(..., pattern="^(local|ssh|database)$") - config: Dict[str, Any] = Field(default_factory=dict) - -class SourceCreate(SourceBase): - pass - -class SourceUpdate(BaseModel): - name: Optional[str] = None - config: Optional[Dict[str, Any]] = None - -class Source(SourceBase): - id: int - created_at: datetime - updated_at: datetime - - model_config = ConfigDict(from_attributes=True) - -# Job schemas -class JobBase(BaseModel): - name: str - source_id: int - strategy: str = Field(default="full", pattern="^(full|incremental)$") - destination_path: str - exclude_patterns: List[str] = Field(default_factory=list) - enabled: bool = True - -class JobCreate(JobBase): - pass - -class JobUpdate(BaseModel): - name: Optional[str] = None - strategy: Optional[str] = None - destination_path: Optional[str] = None - exclude_patterns: Optional[List[str]] = None - enabled: Optional[bool] = None - -class Job(JobBase): - id: int - created_at: datetime - updated_at: datetime - - model_config = ConfigDict(from_attributes=True) - -# Schedule schemas -class ScheduleBase(BaseModel): - job_id: int - cron_expression: str - enabled: bool = True - - @field_validator('cron_expression') - @classmethod - def validate_cron(cls, v: str) -> str: - pattern = r'^([0-9*,/-]+)\s+([0-9*,/-]+)\s+([0-9*,/-]+)\s+([0-9*,/-]+)\s+([0-9*,/-]+)$' - if not re.match(pattern, v): - raise ValueError('Invalid cron expression format') - return v - -class ScheduleCreate(ScheduleBase): - pass - -class ScheduleUpdate(BaseModel): - cron_expression: Optional[str] = None - enabled: Optional[bool] = None - -class Schedule(ScheduleBase): - id: int - created_at: datetime - updated_at: datetime - - model_config = ConfigDict(from_attributes=True) - -# Execution schemas -class JobExecutionBase(BaseModel): - job_id: int - status: str = Field(default="pending", pattern="^(pending|running|success|failed|cancelled)$") - triggered_by: str = Field(..., pattern="^(manual|schedule)$") - -class JobExecutionCreate(JobExecutionBase): - pass - -class JobExecution(JobExecutionBase): - id: int - started_at: Optional[datetime] = None - completed_at: Optional[datetime] = None - bytes_processed: int = 0 - bytes_backed_up: int = 0 - error_message: Optional[str] = None - - model_config = ConfigDict(from_attributes=True) - -class JobExecutionUpdate(BaseModel): - status: Optional[str] = Field(None, pattern="^(pending|running|success|failed|cancelled)$") - error_message: Optional[str] = None - -# Backup schemas -class BackupBase(BaseModel): - execution_id: int - storage_path: str - size_bytes: int = 0 - checksum: Optional[str] = None - type: str = Field(..., pattern="^(full|incremental)$") - parent_backup_id: Optional[int] = None - -class BackupCreate(BackupBase): - pass - -class Backup(BackupBase): - id: int - created_at: datetime - - model_config = ConfigDict(from_attributes=True) - -# Settings schemas -class SettingBase(BaseModel): - key: str - value: Optional[str] = None - -class SettingCreate(SettingBase): - pass - -class SettingUpdate(BaseModel): - value: Optional[str] = None - -class Setting(SettingBase): - updated_at: datetime - - model_config = ConfigDict(from_attributes=True) - -# Dashboard schemas -class DashboardStats(BaseModel): - active_jobs: int - total_backups: int - storage_used_bytes: int - recent_failures: int - recent_executions: List[JobExecution] diff --git a/backend/backup/__pycache__/engine.cpython-314.pyc b/backend/backup/__pycache__/engine.cpython-314.pyc deleted file mode 100644 index ceb472c..0000000 Binary files a/backend/backup/__pycache__/engine.cpython-314.pyc and /dev/null differ diff --git a/backend/backup/adapters/__init__.py b/backend/backup/adapters/__init__.py deleted file mode 100644 index 8431d2a..0000000 --- a/backend/backup/adapters/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -from typing import Dict, Any -from .base import SourceAdapter -from .local import LocalAdapter -from .ssh import SSHAdapter -from .database import DatabaseAdapter - -ADAPTER_MAP = { - "local": LocalAdapter, - "ssh": SSHAdapter, - "database": DatabaseAdapter, -} - -def get_adapter(source_type: str, config: Dict[str, Any]) -> SourceAdapter: - adapter_class = ADAPTER_MAP.get(source_type) - if not adapter_class: - raise ValueError(f"Unknown source type: {source_type}") - return adapter_class(config) diff --git a/backend/backup/adapters/__pycache__/__init__.cpython-314.pyc b/backend/backup/adapters/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 797a542..0000000 Binary files a/backend/backup/adapters/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/backend/backup/adapters/__pycache__/base.cpython-314.pyc b/backend/backup/adapters/__pycache__/base.cpython-314.pyc deleted file mode 100644 index 300c667..0000000 Binary files a/backend/backup/adapters/__pycache__/base.cpython-314.pyc and /dev/null differ diff --git a/backend/backup/adapters/__pycache__/local.cpython-314.pyc b/backend/backup/adapters/__pycache__/local.cpython-314.pyc deleted file mode 100644 index d90a12a..0000000 Binary files a/backend/backup/adapters/__pycache__/local.cpython-314.pyc and /dev/null differ diff --git a/backend/backup/adapters/base.py b/backend/backup/adapters/base.py deleted file mode 100644 index b48ce08..0000000 --- a/backend/backup/adapters/base.py +++ /dev/null @@ -1,39 +0,0 @@ -from abc import ABC, abstractmethod -from typing import List, Dict, Any, AsyncIterator -from dataclasses import dataclass - -@dataclass -class FileInfo: - path: str - size: int - modified_time: float - is_directory: bool - -class SourceAdapter(ABC): - def __init__(self, config: Dict[str, Any]): - self.config = config - - @abstractmethod - async def connect(self) -> None: - """Establish connection to source.""" - pass - - @abstractmethod - async def disconnect(self) -> None: - """Close connection to source.""" - pass - - @abstractmethod - async def list_files(self, path: str = "") -> List[FileInfo]: - """List files at given path.""" - pass - - @abstractmethod - async def read_file(self, path: str) -> AsyncIterator[bytes]: - """Read file in chunks.""" - pass - - @abstractmethod - async def get_database_dump(self, config: Dict[str, Any]) -> AsyncIterator[bytes]: - """Get database dump. Only implemented for database adapters.""" - pass diff --git a/backend/backup/adapters/database.py b/backend/backup/adapters/database.py deleted file mode 100644 index 3714bd0..0000000 --- a/backend/backup/adapters/database.py +++ /dev/null @@ -1,74 +0,0 @@ -import os -import subprocess -import tempfile -from typing import List, AsyncIterator, Dict, Any -from .base import SourceAdapter, FileInfo - - -class DatabaseAdapter(SourceAdapter): - async def connect(self) -> None: - pass - - async def disconnect(self) -> None: - pass - - async def list_files(self, path: str = "") -> List[FileInfo]: - return [] - - async def read_file(self, path: str) -> AsyncIterator[bytes]: - raise NotImplementedError("Database adapter does not support file reading") - - async def get_database_dump(self, config: Dict[str, Any]) -> AsyncIterator[bytes]: - db_type = config.get("db_type", "postgresql") - host = config.get("host", "localhost") - port = config.get("port", 5432 if db_type == "postgresql" else 3306) - database = config.get("database") - username = config.get("username") - password = config.get("password") - - if not database: - raise ValueError("Database name is required") - - with tempfile.NamedTemporaryFile(suffix=".sql", delete=False) as tmp: - tmp_path = tmp.name - - try: - if db_type == "postgresql": - env = os.environ.copy() - if password: - env["PGPASSWORD"] = password - - cmd = [ - "pg_dump", - "-h", host, - "-p", str(port), - "-U", username or "postgres", - "-f", tmp_path, - database - ] - elif db_type == "mysql": - env = os.environ.copy() - if password: - env["MYSQL_PWD"] = password - - cmd = [ - "mysqldump", - "-h", host, - "-P", str(port), - "-u", username or "root", - "--result-file", tmp_path, - database - ] - else: - raise ValueError(f"Unsupported database type: {db_type}") - - result = subprocess.run(cmd, env=env, capture_output=True, text=True) - if result.returncode != 0: - raise RuntimeError(f"Database dump failed: {result.stderr}") - - with open(tmp_path, "rb") as f: - while chunk := f.read(8192): - yield chunk - finally: - if os.path.exists(tmp_path): - os.unlink(tmp_path) diff --git a/backend/backup/adapters/local.py b/backend/backup/adapters/local.py deleted file mode 100644 index 5a2f500..0000000 --- a/backend/backup/adapters/local.py +++ /dev/null @@ -1,47 +0,0 @@ -import os -import aiofiles -from pathlib import Path -from typing import List, AsyncIterator, Dict, Any -from .base import SourceAdapter, FileInfo - -class LocalAdapter(SourceAdapter): - async def connect(self) -> None: - base_path = self.config.get("path", ".") - if not os.path.exists(base_path): - raise FileNotFoundError(f"Path not found: {base_path}") - - async def disconnect(self) -> None: - pass - - async def list_files(self, path: str = "") -> List[FileInfo]: - base_path = Path(self.config.get("path", ".")) - target_path = base_path / path if path else base_path - - files = [] - exclude_patterns = self.config.get("exclude", []) - - for item in target_path.iterdir(): - # Check exclude patterns - if any(item.match(pattern) for pattern in exclude_patterns): - continue - - stat = item.stat() - files.append(FileInfo( - path=str(item.relative_to(base_path)), - size=stat.st_size, - modified_time=stat.st_mtime, - is_directory=item.is_dir() - )) - - return files - - async def read_file(self, path: str) -> AsyncIterator[bytes]: - base_path = Path(self.config.get("path", ".")) - file_path = base_path / path - - async with aiofiles.open(file_path, "rb") as f: - while chunk := await f.read(8192): - yield chunk - - async def get_database_dump(self, config: Dict[str, Any]) -> AsyncIterator[bytes]: - raise NotImplementedError("Local adapter does not support database dumps") diff --git a/backend/backup/adapters/ssh.py b/backend/backup/adapters/ssh.py deleted file mode 100644 index 11ba55e..0000000 --- a/backend/backup/adapters/ssh.py +++ /dev/null @@ -1,92 +0,0 @@ -import os -import tempfile -from pathlib import Path -from typing import List, AsyncIterator, Dict, Any -import paramiko -from .base import SourceAdapter, FileInfo - - -class SSHAdapter(SourceAdapter): - def __init__(self, config: Dict[str, Any]): - super().__init__(config) - self.client = None - self.sftp = None - - async def connect(self) -> None: - self.client = paramiko.SSHClient() - self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - - host = self.config.get("host", "localhost") - port = self.config.get("port", 22) - username = self.config.get("username") - password = self.config.get("password") - key_path = self.config.get("key_path") - - connect_kwargs = { - "hostname": host, - "port": port, - "username": username, - } - - if password: - connect_kwargs["password"] = password - elif key_path and os.path.exists(key_path): - connect_kwargs["key_filename"] = key_path - - self.client.connect(**connect_kwargs) - self.sftp = self.client.open_sftp() - - async def disconnect(self) -> None: - if self.sftp: - self.sftp.close() - self.sftp = None - if self.client: - self.client.close() - self.client = None - - async def list_files(self, path: str = "") -> List[FileInfo]: - remote_path = self.config.get("path", ".") - target_path = f"{remote_path}/{path}" if path else remote_path - - files = [] - exclude_patterns = self.config.get("exclude", []) - - try: - for entry in self.sftp.listdir_attr(target_path): - entry_path = f"{target_path}/{entry.filename}" - rel_path = entry_path.replace(remote_path + "/", "", 1) if remote_path != "." else entry_path - - if any(pattern in rel_path for pattern in exclude_patterns): - continue - - is_dir = entry.st_mode & 0o40000 == 0o40000 if hasattr(entry, 'st_mode') else False - - files.append(FileInfo( - path=rel_path, - size=entry.st_size, - modified_time=entry.st_mtime, - is_directory=is_dir - )) - except IOError: - pass - - return files - - async def read_file(self, path: str) -> AsyncIterator[bytes]: - remote_path = self.config.get("path", ".") - file_path = f"{remote_path}/{path}" if not path.startswith("/") else path - - with tempfile.NamedTemporaryFile(delete=False) as tmp: - tmp_path = tmp.name - - try: - self.sftp.get(file_path, tmp_path) - with open(tmp_path, "rb") as f: - while chunk := f.read(8192): - yield chunk - finally: - if os.path.exists(tmp_path): - os.unlink(tmp_path) - - async def get_database_dump(self, config: Dict[str, Any]) -> AsyncIterator[bytes]: - raise NotImplementedError("SSH adapter does not support database dumps directly") diff --git a/backend/backup/engine.py b/backend/backup/engine.py deleted file mode 100644 index 922c91c..0000000 --- a/backend/backup/engine.py +++ /dev/null @@ -1,138 +0,0 @@ -import os -import hashlib -import shutil -from datetime import datetime, timezone -from pathlib import Path -from typing import Optional -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select -from app.models import Job, JobExecution, Backup -from backup.adapters import get_adapter -from backup.retention import RetentionPolicy - -class BackupEngine: - def __init__(self, db: AsyncSession): - self.db = db - - async def execute_job(self, job_id: int, triggered_by: str = "manual") -> JobExecution: - # Create execution record - execution = JobExecution( - job_id=job_id, - status="pending", - triggered_by=triggered_by - ) - self.db.add(execution) - await self.db.commit() - await self.db.refresh(execution) - - try: - # Load job with source - result = await self.db.execute( - select(Job).where(Job.id == job_id) - ) - job = result.scalar_one() - - # Update status to running - execution.status = "running" - execution.started_at = datetime.now(timezone.utc) - await self.db.commit() - - # Determine strategy - strategy = job.strategy - parent_backup_id = None - - if strategy == "incremental": - # Find last successful full backup - result = await self.db.execute( - select(Backup) - .join(JobExecution) - .where( - JobExecution.job_id == job_id, - JobExecution.status == "success", - Backup.type == "full" - ) - .order_by(Backup.created_at.desc()) - ) - last_full = result.scalar_one_or_none() - - if last_full: - parent_backup_id = last_full.id - else: - # No full backup exists, do full instead - strategy = "full" - - # Create backup directory - timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d_%H%M%S") - backup_dir = Path(job.destination_path) / str(job_id) / f"{timestamp}_{strategy}" - backup_dir.mkdir(parents=True, exist_ok=True) - - # Get adapter and connect - adapter = get_adapter(job.source.type, job.source.config) - await adapter.connect() - - try: - # Copy files - total_processed = 0 - total_backed_up = 0 - - source_path = Path(job.source.config.get("path", ".")) - - for item in source_path.rglob("*"): - if item.is_file(): - rel_path = item.relative_to(source_path) - dest_path = backup_dir / "data" / rel_path - dest_path.parent.mkdir(parents=True, exist_ok=True) - - # Copy file - shutil.copy2(item, dest_path) - - size = item.stat().st_size - total_processed += size - total_backed_up += size - - # Calculate checksum - checksum = await self._calculate_checksum(backup_dir) - - # Create backup record - backup = Backup( - execution_id=execution.id, - storage_path=str(backup_dir), - size_bytes=total_backed_up, - checksum=checksum, - type=strategy, - parent_backup_id=parent_backup_id - ) - self.db.add(backup) - - # Update execution - execution.status = "success" - execution.completed_at = datetime.now(timezone.utc) - execution.bytes_processed = total_processed - execution.bytes_backed_up = total_backed_up - - # Apply retention policy - retention = RetentionPolicy(self.db) - keep_count = getattr(job, 'retention_count', None) - keep_days = getattr(job, 'retention_days', None) - if keep_count or keep_days: - await retention.apply_retention_for_job(job_id, keep_count, keep_days) - - finally: - await adapter.disconnect() - - except Exception as e: - execution.status = "failed" - execution.completed_at = datetime.now(timezone.utc) - execution.error_message = str(e) - - await self.db.commit() - return execution - - async def _calculate_checksum(self, path: Path) -> str: - hasher = hashlib.sha256() - for item in sorted(path.rglob("*")): - if item.is_file(): - with open(item, "rb") as f: - while chunk := f.read(8192): - hasher.update(chunk) - return hasher.hexdigest() diff --git a/backend/backup/retention.py b/backend/backup/retention.py deleted file mode 100644 index efb421d..0000000 --- a/backend/backup/retention.py +++ /dev/null @@ -1,90 +0,0 @@ -import shutil -from datetime import datetime, timezone, timedelta -from pathlib import Path -from typing import List, Optional -from sqlalchemy import select - -from app.models import Backup - - -class RetentionPolicy: - def __init__(self, db): - self.db = db - - async def apply_retention_for_job( - self, - job_id: int, - keep_count: Optional[int] = None, - keep_days: Optional[int] = None - ) -> List[Backup]: - """ - Apply retention policy for a job's backups. - - Args: - job_id: The job ID to apply retention for - keep_count: Maximum number of backups to keep (oldest deleted first) - keep_days: Delete backups older than this many days - - Returns: - List of deleted backups - """ - deleted_backups = [] - - result = await self.db.execute( - select(Backup) - .where(Backup.execution.has(job_id=job_id)) - .order_by(Backup.created_at.asc()) - ) - backups = result.scalars().all() - - if not backups: - return deleted_backups - - backups_to_delete = set() - - if keep_count is not None and len(backups) > keep_count: - backups_to_delete.update(backups[:-keep_count]) - - if keep_days is not None: - cutoff_date = datetime.now(timezone.utc) - timedelta(days=keep_days) - for backup in backups: - if backup.created_at < cutoff_date: - backups_to_delete.add(backup) - - for backup in list(backups_to_delete): - await self._delete_backup(backup) - deleted_backups.append(backup) - - await self.db.commit() - return deleted_backups - - async def _delete_backup(self, backup: Backup): - """Delete a backup and its storage.""" - try: - storage_path = Path(backup.storage_path) - if storage_path.exists(): - shutil.rmtree(storage_path) - except Exception: - pass - - await self.db.delete(backup) - - async def cleanup_orphaned_backups(self) -> int: - """ - Remove backup records whose storage no longer exists. - - Returns: - Number of orphaned backups removed - """ - result = await self.db.execute(select(Backup)) - backups = result.scalars().all() - - removed_count = 0 - for backup in backups: - storage_path = Path(backup.storage_path) - if not storage_path.exists(): - await self.db.delete(backup) - removed_count += 1 - - await self.db.commit() - return removed_count diff --git a/backend/backup/scheduler.py b/backend/backup/scheduler.py deleted file mode 100644 index 43d7924..0000000 --- a/backend/backup/scheduler.py +++ /dev/null @@ -1,81 +0,0 @@ -from apscheduler.schedulers.asyncio import AsyncIOScheduler -from apscheduler.triggers.cron import CronTrigger -from sqlalchemy import select -from typing import Optional -import logging - -from app.database import AsyncSessionLocal -from app.models import Schedule -from backup.engine import BackupEngine - -logger = logging.getLogger(__name__) - - -class BackupScheduler: - def __init__(self): - self.scheduler = AsyncIOScheduler() - self._job_map = {} - - def start(self): - """Start the scheduler.""" - self.scheduler.start() - logger.info("Backup scheduler started") - - def shutdown(self): - """Shutdown the scheduler.""" - self.scheduler.shutdown() - logger.info("Backup scheduler shutdown") - - async def sync_schedules(self): - """Sync all enabled schedules from database.""" - async with AsyncSessionLocal() as db: - result = await db.execute( - select(Schedule).where(Schedule.enabled == True) - ) - schedules = result.scalars().all() - - # Clear existing jobs - for schedule_id, job_id in list(self._job_map.items()): - self.scheduler.remove_job(job_id) - del self._job_map[schedule_id] - - # Add new jobs - for schedule in schedules: - await self._add_schedule_job(schedule) - - async def _add_schedule_job(self, schedule: Schedule): - """Add a single schedule job to the scheduler.""" - try: - trigger = CronTrigger.from_crontab(schedule.cron_expression) - job = self.scheduler.add_job( - self._run_backup_job, - trigger=trigger, - args=[schedule.job_id], - id=f"backup_job_{schedule.job_id}", - replace_existing=True - ) - self._job_map[schedule.id] = job.id - logger.info(f"Scheduled backup job {schedule.job_id} with cron: {schedule.cron_expression}") - except Exception as e: - logger.error(f"Failed to schedule job {schedule.job_id}: {e}") - - async def _run_backup_job(self, job_id: int): - """Execute a backup job.""" - logger.info(f"Running scheduled backup job {job_id}") - async with AsyncSessionLocal() as db: - engine = BackupEngine(db) - await engine.execute_job(job_id, triggered_by="schedule") - - async def add_schedule(self, schedule: Schedule): - """Add a new schedule to the scheduler.""" - await self._add_schedule_job(schedule) - - def remove_schedule(self, schedule_id: int): - """Remove a schedule from the scheduler.""" - if schedule_id in self._job_map: - self.scheduler.remove_job(self._job_map[schedule_id]) - del self._job_map[schedule_id] - - -# Global scheduler instance -backup_scheduler = BackupScheduler() diff --git a/backend/backup_tool.db b/backend/backup_tool.db deleted file mode 100644 index c7037e1..0000000 Binary files a/backend/backup_tool.db and /dev/null differ diff --git a/backend/pyproject.toml b/backend/pyproject.toml index bfc5066..3272ad7 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -1,68 +1,65 @@ [build-system] -requires = ["setuptools>=61.0", "wheel"] +requires = ["setuptools==80.9.0", "wheel==0.46.3"] build-backend = "setuptools.build_meta" [project] name = "backup-tool" -version = "0.1.0" -description = "Web-based backup management tool for small teams and SMBs" -readme = "README.md" -license = {text = "MIT"} -requires-python = ">=3.11" -authors = [ - {name = "Backup Tool Team"} -] -keywords = ["backup", "restore", "scheduler", "web"] -classifiers = [ - "Development Status :: 4 - Beta", - "Intended Audience :: System Administrators", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", - "Programming Language :: Python :: 3.14", - "Topic :: System :: Archiving :: Backup", -] +version = "2.0.0.dev0" +description = "Self-hosted, integrity-first backup appliance" +license = "MIT" +requires-python = ">=3.12,<3.15" +authors = [{ name = "Backup Tool Team" }] dependencies = [ - "fastapi>=0.115.0", - "uvicorn[standard]>=0.34.0", - "sqlalchemy[asyncio]>=2.0.0", - "aiosqlite>=0.21.0", - "alembic>=1.15.0", - "pydantic>=2.13.0", - "pydantic-settings>=2.9.0", - "apscheduler>=3.11.0", - "paramiko>=3.5.0", - "aiofiles>=23.2.0", - "httpx>=0.28.0", + "aiofiles==25.1.0", + "aiosqlite==0.22.1", + "alembic==1.18.5", + "argon2-cffi==25.1.0", + "apscheduler==3.11.3", + "cryptography==49.0.0", + "fastapi==0.136.1", + "httpx==0.28.1", + "pydantic==2.13.4", + "pydantic-settings==2.14.2", + "paramiko==5.0.0", + "sqlalchemy[asyncio]==2.0.49", + "uvicorn[standard]==0.51.0", ] [project.optional-dependencies] dev = [ - "pytest>=8.3.0", - "pytest-asyncio>=0.26.0", -] -prod = [ - "gunicorn>=23.0.0", + "jsonschema==4.26.0", + "mypy==2.3.0", + "pytest==9.0.3", + "pytest-asyncio==1.3.0", + "ruff==0.16.0", ] +prod = ["gunicorn==23.0.0"] [project.scripts] -backup-tool = "app.main:main" +backup-tool = "backup_tool.cli:main" -[project.urls] -Homepage = "https://github.com/backup-tool/backup-tool" -Documentation = "https://github.com/backup-tool/backup-tool#readme" -Repository = "https://github.com/backup-tool/backup-tool.git" +[tool.setuptools] +package-dir = { "" = "src" } [tool.setuptools.packages.find] -where = ["."] -include = ["app*", "backup*", "alembic*"] +where = ["src"] +include = ["backup_tool*"] + +[tool.setuptools.package-data] +backup_tool = ["py.typed"] [tool.pytest.ini_options] asyncio_mode = "auto" -testpaths = ["tests"] -pythonpath = [".", "app", "backup"] -[tool.setuptools.package-data] -alembic = ["*.ini", "*.py", "*.mako"] +[tool.ruff] +target-version = "py312" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "SIM"] + +[tool.mypy] +python_version = "3.12" +strict = true +packages = ["backup_tool"] +mypy_path = "src" diff --git a/backend/src/backup_tool/__init__.py b/backend/src/backup_tool/__init__.py new file mode 100644 index 0000000..0fd3dc5 --- /dev/null +++ b/backend/src/backup_tool/__init__.py @@ -0,0 +1,3 @@ +"""Backup Tool v2 package.""" + +__version__ = "2.0.0.dev0" diff --git a/backend/src/backup_tool/adapters.py b/backend/src/backup_tool/adapters.py new file mode 100644 index 0000000..8116955 --- /dev/null +++ b/backend/src/backup_tool/adapters.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +import os +import stat +from collections.abc import AsyncIterator +from dataclasses import dataclass +from pathlib import Path +from typing import Protocol + +from backup_tool.config import Settings + + +class SourceError(ValueError): + """A redacted source failure with a stable execution reason.""" + + def __init__(self, message: str, *, reason_code: str = "source_invalid") -> None: + super().__init__(message) + self.reason_code = reason_code + + +@dataclass(frozen=True) +class Entry: + path: str + kind: str + size: int + mode: int + mtime_ns: int + link_target: str | None = None + + +class SourceReader(Protocol): + def validate_config(self) -> None: ... + + async def probe(self) -> dict[str, int]: ... + + def enumerate_entries(self) -> AsyncIterator[Entry]: ... + + def open_content(self, path: str) -> AsyncIterator[bytes]: ... + + async def close(self) -> None: ... + + +class LocalAdapter: + def __init__(self, root: Path, settings: Settings): + self.root = root.expanduser().resolve() + self.settings = settings + + def validate_config(self) -> None: + if not self.root.is_dir(): + raise SourceError("local source root must be an existing directory") + if not any(self.root.is_relative_to(root) for root in self.settings.local_source_roots): + raise SourceError("local source root is outside configured allowlists") + + async def probe(self) -> dict[str, int]: + self.validate_config() + count = sum(1 for item in self.root.rglob("*") if item.is_file() and not item.is_symlink()) + return {"entry_count": count} + + async def enumerate_entries(self) -> AsyncIterator[Entry]: + self.validate_config() + for item in sorted(self.root.rglob("*"), key=lambda candidate: candidate.as_posix()): + relative = item.relative_to(self.root).as_posix() + metadata = item.lstat() + if stat.S_ISLNK(metadata.st_mode): + target = os.readlink(item) + target_path = Path(target) + if ( + target_path.is_absolute() + or "\\" in target + or ".." in target_path.parts + or not target + ): + raise SourceError(f"symlink target for {relative!r} is unsafe") + yield Entry( + relative, + "symlink", + 0, + stat.S_IMODE(metadata.st_mode), + metadata.st_mtime_ns, + target, + ) + elif stat.S_ISREG(metadata.st_mode): + yield Entry( + relative, + "file", + metadata.st_size, + stat.S_IMODE(metadata.st_mode), + metadata.st_mtime_ns, + ) + elif stat.S_ISDIR(metadata.st_mode): + yield Entry( + relative, + "directory", + 0, + stat.S_IMODE(metadata.st_mode), + metadata.st_mtime_ns, + ) + else: + raise SourceError(f"local source entry {relative!r} has an unsupported type") + + async def close(self) -> None: + return None + + async def open_content(self, path: str) -> AsyncIterator[bytes]: + requested = Path(path) + if not path or requested.is_absolute() or "\\" in path or ".." in requested.parts: + raise SourceError("invalid local source entry") + candidate = self.root / requested + metadata = candidate.lstat() + if not stat.S_ISREG(metadata.st_mode) or candidate.is_symlink(): + raise SourceError("invalid local source entry") + resolved = candidate.resolve() + if not resolved.is_relative_to(self.root): + raise SourceError("invalid local source entry") + with candidate.open("rb") as handle: + while chunk := handle.read(1024 * 1024): + yield chunk diff --git a/backend/src/backup_tool/api/__init__.py b/backend/src/backup_tool/api/__init__.py new file mode 100644 index 0000000..0d2b7b6 --- /dev/null +++ b/backend/src/backup_tool/api/__init__.py @@ -0,0 +1 @@ +"""HTTP boundary for Backup Tool v2.""" diff --git a/backend/src/backup_tool/api/app.py b/backend/src/backup_tool/api/app.py new file mode 100644 index 0000000..8154f17 --- /dev/null +++ b/backend/src/backup_tool/api/app.py @@ -0,0 +1,2018 @@ +import asyncio +import base64 +import hashlib +import json +from collections.abc import AsyncGenerator, AsyncIterator +from datetime import UTC, datetime, timedelta +from pathlib import Path +from time import perf_counter +from typing import Annotated, Any, Literal, cast + +from fastapi import Depends, FastAPI, Header, Request, Response +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse, StreamingResponse +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy import desc, func, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from backup_tool.adapters import LocalAdapter, SourceError +from backup_tool.config import Settings +from backup_tool.db.engine import create_engine +from backup_tool.db.models import ( + ApiToken, + AuditEvent, + Backup, + Execution, + ExecutionEvent, + IdempotencyRecord, + Job, + NotificationDelivery, + NotificationDeliveryAttempt, + NotificationEmailSettings, + NotificationSigningKey, + NotificationSubscription, + Repository, + RepositoryDataKeyEpoch, + Restore, + Schedule, + Secret, + Session, + Source, + User, +) +from backup_tool.execution import ( + TERMINAL_STATES, + EnqueueError, + enqueue, + public_event, + request_cancellation, + retry, +) +from backup_tool.notifications.events import ( + EVENT_CATALOG, + NotificationEventError, + emit_event, + validate_destination, + validate_filters, +) +from backup_tool.observability.health import ReadinessError, check_role_readiness +from backup_tool.observability.logging import log_event +from backup_tool.observability.metrics import Metrics, collect_operational_metrics +from backup_tool.repository import ( + RepositoryError, + initialize, + inspect_repository, + remove_repository, +) +from backup_tool.scheduler import ScheduleError, next_nominal +from backup_tool.security.auth import ( + hash_password, + hash_token, + new_csrf_token, + new_token, + sign_session, + verify_password, + verify_session, +) +from backup_tool.security.redaction import redact +from backup_tool.security.secrets import EnvelopeCipher +from backup_tool.snapshot import SnapshotError, source_adapter, verify_backup_snapshot +from backup_tool.ssh_source import SSH_PRIVATE_KEY_PURPOSE, SSHSourcePublicConfig + + +async def execution_events_stream( + sessions: async_sessionmaker[AsyncSession], execution_id: str, last_event_id: str | None +) -> AsyncGenerator[str, None]: + """Replay every durable event after Last-Event-ID, then poll for appended events.""" + try: + last_revision = int(last_event_id or "0") + except ValueError: + last_revision = 0 + while True: + async with sessions() as stream_db: + events = ( + await stream_db.scalars( + select(ExecutionEvent) + .where( + ExecutionEvent.execution_id == execution_id, + ExecutionEvent.revision > last_revision, + ) + .order_by(ExecutionEvent.revision) + ) + ).all() + for event in events: + yield ( + f"id: {event.revision}\nevent: execution\ndata: {json.dumps(event.payload)}\n\n" + ) + last_revision = event.revision + execution = await stream_db.get(Execution, execution_id) + if execution is None or (execution.state in TERMINAL_STATES and not events): + return + await asyncio.sleep(0.1) + + +class Problem(Exception): + def __init__(self, status: int, code: str, detail: str): + self.status = status + self.code = code + self.detail = detail + + +def problem_response(request: Request, status: int, code: str, detail: str) -> JSONResponse: + return JSONResponse( + status_code=status, + media_type="application/problem+json", + content={ + "type": f"https://backup-tool.invalid/problems/{code}", + "title": code.replace("_", " ").title(), + "status": status, + "detail": detail, + "instance": str(request.url.path), + "code": code, + }, + ) + + +class SetupInput(BaseModel): + username: str = Field(min_length=1, max_length=255) + password: str = Field(min_length=12, max_length=1024) + bootstrap_secret: str | None = Field(default=None, max_length=1024) + + +class LoginInput(BaseModel): + username: str = Field(min_length=1, max_length=255) + password: str = Field(min_length=1, max_length=1024) + + +class AuthenticatedUser(BaseModel): + id: str + username: str + + +class SessionUser(AuthenticatedUser): + state: str + + +class RepositorySummary(BaseModel): + id: str + name: str + format_version: int + compression: str + encryption: str + state: str + + +class RepositoryList(BaseModel): + items: list[RepositorySummary] + + +class SourceSummary(BaseModel): + id: str + name: str + kind: str + state: str + public_config: dict[str, Any] + + +class SourceList(BaseModel): + items: list[SourceSummary] + + +class ScheduleSummary(BaseModel): + id: str + cron: str + timezone: str + enabled: bool + next_nominal_at: datetime | None + last_enqueue_outcome: str | None + + +class JobSummary(BaseModel): + id: str + name: str + source_id: str + repository_id: str + requested_mode: str + enabled: bool + state: str + schedule: ScheduleSummary | None + + +class JobList(BaseModel): + items: list[JobSummary] + + +class ExecutionSummary(BaseModel): + id: str + state: str + attempt: int + revision: int + reason_code: str | None + progress: dict[str, Any] + + +class ExecutionList(BaseModel): + items: list[ExecutionSummary] + + +class BackupSummary(BaseModel): + id: str + execution_id: str + manifest_id: str + logical_bytes: int + stored_bytes: int + integrity: str + pinned: bool + tombstoned_at: datetime | None + created_at: datetime + + +class BackupList(BaseModel): + items: list[BackupSummary] + + +class BackupDeletePreview(BaseModel): + backup_id: str + eligible: bool + reason: str | None + destructive_action: str + + +class RecoveryStatus(BaseModel): + recovery_mode: Literal["cli_only"] + runbook: str + encrypted_repository_count: int + + +class NotificationSubscriptionSummary(BaseModel): + id: str + channel: str + event_filters: list[str] + destination: dict[str, Any] + state: str + rate_limit_per_minute: int + revision: int + created_at: str + updated_at: str + + +class NotificationSubscriptionList(BaseModel): + items: list[NotificationSubscriptionSummary] + + +class NotificationDeliverySummary(BaseModel): + id: str + event_id: str + subscription_id: str + state: str + attempt_count: int + response_class: str | None + response_summary: str | None + terminal_reason: str | None + due_at: str + + +class NotificationDeliveryList(BaseModel): + items: list[NotificationDeliverySummary] + + +class NotificationAttemptSummary(BaseModel): + number: int + outcome: str + response_class: str | None + diagnostic: str | None + started_at: str + completed_at: str | None + + +class NotificationAttemptList(BaseModel): + items: list[NotificationAttemptSummary] + + +class AuditSummary(BaseModel): + id: str + action: str + resource_type: str + resource_id: str | None + outcome: str + request_id: str + created_at: str + details: dict[str, Any] + + +class AuditList(BaseModel): + items: list[AuditSummary] + next_cursor: str | None + + +class LocalSourceInput(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str = Field(min_length=1, max_length=255) + kind: Literal["local"] + public_config: dict[str, Any] + + +class SSHSourceInput(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str = Field(min_length=1, max_length=255) + kind: Literal["ssh"] + public_config: SSHSourcePublicConfig + private_key_secret_id: str = Field(min_length=1, max_length=36) + + +SourceInput = Annotated[LocalSourceInput | SSHSourceInput, Field(discriminator="kind")] + + +class JobInput(BaseModel): + name: str = Field(min_length=1, max_length=255) + source_id: str + repository_id: str + requested_mode: str = "incremental" + exclusions: list[str] = Field(default_factory=list) + retention: dict[str, Any] = Field(default_factory=dict) + enabled: bool = True + allow_empty: bool = False + + +class TokenInput(BaseModel): + scopes: list[str] = Field(min_length=1) + expires_at: datetime | None = None + + +class SecretInput(BaseModel): + purpose: str = Field(min_length=1, max_length=64) + value: str = Field(min_length=1, max_length=65536) + + +class UserPatch(BaseModel): + state: str + + +class RepositoryInput(BaseModel): + name: str = Field(min_length=1, max_length=255) + relative_path: str = Field(min_length=1, max_length=1024) + compression: str = "none" + encryption: str = "none" + + +class RepositoryPatch(BaseModel): + compression: str | None = None + encryption: str | None = None + + +class ScheduleInput(BaseModel): + cron: str = Field(min_length=1, max_length=255) + timezone: str = Field(min_length=1, max_length=255) + misfire_grace_seconds: int = Field(default=900, ge=0) + enabled: bool = True + + +class RestoreInput(BaseModel): + destination: str = Field(min_length=1, max_length=4096) + selection: list[str] = Field(default_factory=list) + dry_run: bool = False + overwrite_policy: str = "fail" + + +class NotificationSubscriptionInput(BaseModel): + channel: Literal["webhook", "email"] + event_filters: list[str] = Field(min_length=1, max_length=48) + destination: dict[str, Any] + signing_secret: str | None = Field(default=None, min_length=16, max_length=65_536) + rate_limit_per_minute: int = Field(default=60, ge=1, le=10_000) + + +class NotificationSubscriptionPatch(BaseModel): + event_filters: list[str] | None = Field(default=None, min_length=1, max_length=48) + destination: dict[str, Any] | None = None + state: Literal["active", "disabled", "archived"] | None = None + rate_limit_per_minute: int | None = Field(default=None, ge=1, le=10_000) + + +class SigningKeyRotateInput(BaseModel): + secret: str = Field(min_length=16, max_length=65_536) + overlap_seconds: int = Field(default=3600, ge=60, le=86_400) + + +class EmailSettingsInput(BaseModel): + host: str = Field(min_length=1, max_length=255) + port: int = Field(default=587, ge=1, le=65535) + username: str = Field(min_length=1, max_length=255) + password: str = Field(min_length=1, max_length=65_536) + sender: str = Field(min_length=3, max_length=320) + max_attempts: int = Field(default=5, ge=1, le=20) + rate_limit_per_minute: int = Field(default=60, ge=1, le=10_000) + + +def _etag(resource: Any) -> str: + return f'"{resource.id}:{resource.updated_at.isoformat()}"' + + +def _notification_rate_tokens(value: int) -> float: + try: + return float(value) + except (TypeError, ValueError) as error: + raise Problem(422, "validation_failed", "Notification rate limit is invalid.") from error + + +def _cursor(item_id: str) -> str: + return base64.urlsafe_b64encode(item_id.encode()).decode().rstrip("=") + + +def _decode_cursor(value: str) -> str: + try: + return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)).decode() + except (ValueError, UnicodeDecodeError) as error: + raise Problem(400, "invalid_cursor", "Cursor is invalid.") from error + + +def _digest_request(payload: TokenInput) -> str: + value = payload.model_dump(mode="json") + encoded = repr(sorted(value.items())).encode() + return hashlib.sha256(encoded).hexdigest() + + +class SessionDependency: + def __init__(self, factory: async_sessionmaker[AsyncSession]): + self._factory = factory + + async def __call__(self) -> AsyncIterator[AsyncSession]: + async with self._factory() as db: + yield db + + +def create_app(settings: Settings) -> FastAPI: + app = FastAPI(title="Backup Tool API", version="2.0.0", docs_url=None, redoc_url=None) + app.state.settings = settings + app.state.engine = create_engine(settings) + app.state.sessions = async_sessionmaker(app.state.engine, expire_on_commit=False) + app.state.cipher = EnvelopeCipher.from_file(settings.master_key_file) + app.state.setup_lock = asyncio.Lock() + app.state.metrics = Metrics() + + @app.middleware("http") + async def request_id_middleware(request: Request, call_next: Any) -> Response: + from backup_tool.ids import new_uuid7 + + request.state.request_id = str(new_uuid7()) + started = perf_counter() + response = cast(Response, await call_next(request)) + response.headers["X-Request-ID"] = request.state.request_id + duration = perf_counter() - started + app.state.metrics.observe_request( + request.method, request.url.path, response.status_code, duration + ) + log_event( + "http_request", + request_id=request.state.request_id, + method=request.method, + path=request.url.path, + status=response.status_code, + duration_seconds=round(duration, 6), + ) + return response + + @app.exception_handler(Problem) + async def handle_problem(request: Request, error: Problem) -> JSONResponse: + return problem_response(request, error.status, error.code, error.detail) + + @app.exception_handler(RequestValidationError) + async def handle_validation(request: Request, _error: RequestValidationError) -> JSONResponse: + return problem_response(request, 422, "validation_failed", "Request validation failed.") + + session = SessionDependency(app.state.sessions) + + async def actor( + request: Request, + db: Annotated[AsyncSession, Depends(session)], + authorization: Annotated[str | None, Header()] = None, + ) -> tuple[User, set[str], bool]: + if authorization and authorization.startswith("Bearer "): + supplied = authorization.removeprefix("Bearer ") + token = await db.scalar( + select(ApiToken).where(ApiToken.token_hash == hash_token(supplied)) + ) + if token is None or token.revoked_at is not None: + raise Problem(401, "authentication_required", "Authentication is required.") + if token.expires_at is not None and token.expires_at <= datetime.now(UTC): + raise Problem(401, "authentication_required", "Authentication is required.") + user = await db.get(User, token.owner_id) + if user is None or user.state != "active": + raise Problem(401, "authentication_required", "Authentication is required.") + return user, set(token.scopes), False + encoded = request.cookies.get("backup_tool_session") + data = verify_session(encoded, settings.master_key_file) if encoded else None + if data is None: + raise Problem(401, "authentication_required", "Authentication is required.") + active_session = await db.get(Session, data["sid"]) + if ( + active_session is None + or active_session.user_id != data["sub"] + or active_session.revoked_at is not None + or active_session.expires_at <= datetime.now(UTC) + ): + raise Problem(401, "authentication_required", "Authentication is required.") + user = await db.get(User, data["sub"]) + if user is None or user.state != "active": + raise Problem(401, "authentication_required", "Authentication is required.") + return user, {"*"}, True + + async def require( + request: Request, + db: Annotated[AsyncSession, Depends(session)], + authorization: Annotated[str | None, Header()] = None, + csrf: Annotated[str | None, Header(alias="X-CSRF-Token")] = None, + ) -> tuple[User, set[str], bool]: + user, scopes, cookie_auth = await actor(request, db, authorization) + if cookie_auth: + data = verify_session( + request.cookies.get("backup_tool_session", ""), settings.master_key_file + ) + if data is None or csrf is None or csrf != data.get("csrf"): + raise Problem(403, "csrf_failed", "CSRF validation failed.") + return user, scopes, cookie_auth + + def enforce_scope(scopes: set[str], required: str) -> None: + if "*" not in scopes and required not in scopes: + raise Problem(403, "insufficient_scope", "Required scope is missing.") + + async def audit( + db: AsyncSession, + request: Request, + action: str, + resource_type: str, + resource_id: str | None, + outcome: str, + actor_id: str | None, + details: dict[str, Any] | None = None, + ) -> None: + db.add( + AuditEvent( + actor_id=actor_id, + action=action, + resource_type=resource_type, + resource_id=resource_id, + outcome=outcome, + request_id=request.state.request_id, + details=redact(details or {}), + ) + ) + + async def set_session(db: AsyncSession, response: Response, user_id: str) -> None: + csrf = new_csrf_token() + ttl = settings.session_ttl_seconds + expires_at = datetime.now(UTC) + timedelta(seconds=ttl) + persisted = Session(user_id=user_id, expires_at=expires_at) + db.add(persisted) + await db.commit() + response.set_cookie( + "backup_tool_session", + sign_session( + user_id, + csrf, + settings.master_key_file, + expires_at=expires_at, + session_id=persisted.id, + ), + httponly=True, + secure=True, + samesite="strict", + path="/", + max_age=ttl, + ) + response.set_cookie( + "backup_tool_csrf", + csrf, + httponly=False, + secure=True, + samesite="strict", + path="/", + max_age=ttl, + ) + + @app.get("/livez") + async def livez() -> dict[str, str]: + return {"status": "alive"} + + @app.get("/readyz") + async def readyz() -> dict[str, str]: + try: + await check_role_readiness(settings, "web") + except ReadinessError as error: + raise Problem( + 503, "dependency_unavailable", "Required runtime dependency is unavailable." + ) from error + return {"status": "ready"} + + @app.get("/metrics", include_in_schema=False) + async def metrics(db: Annotated[AsyncSession, Depends(session)]) -> Response: + values = await collect_operational_metrics(settings, db) + return Response( + app.state.metrics.render(values), + media_type="text/plain; version=0.0.4; charset=utf-8", + ) + + @app.post("/api/v2/setup", status_code=201, response_model=AuthenticatedUser) + async def setup( + input_: SetupInput, + request: Request, + response: Response, + db: Annotated[AsyncSession, Depends(session)], + ) -> dict[str, str]: + async with app.state.setup_lock: + if settings.setup_requires_bootstrap and ( + settings.bootstrap_secret is None + or input_.bootstrap_secret != settings.bootstrap_secret + ): + raise Problem(403, "bootstrap_required", "Bootstrap credentials are required.") + if await db.scalar(select(User.id).limit(1)) is not None: + raise Problem(409, "setup_complete", "Initial administrator already exists.") + user = User(username=input_.username, password_hash=hash_password(input_.password)) + db.add(user) + try: + await db.flush() + except IntegrityError as error: + await db.rollback() + raise Problem( + 409, "setup_complete", "Initial administrator already exists." + ) from error + await audit(db, request, "setup", "user", user.id, "success", user.id) + await db.commit() + await set_session(db, response, user.id) + return {"id": user.id, "username": user.username} + + @app.post("/api/v2/auth/login", response_model=AuthenticatedUser) + async def login( + input_: LoginInput, + request: Request, + response: Response, + db: Annotated[AsyncSession, Depends(session)], + ) -> dict[str, str]: + user = await db.scalar(select(User).where(User.username == input_.username)) + if ( + user is None + or user.state != "active" + or not verify_password(user.password_hash, input_.password) + ): + await audit(db, request, "login", "user", None, "denied", None) + await db.commit() + raise Problem(401, "authentication_failed", "Invalid credentials.") + await audit(db, request, "login", "user", user.id, "success", user.id) + await db.commit() + await set_session(db, response, user.id) + return {"id": user.id, "username": user.username} + + @app.post("/api/v2/auth/logout", status_code=204) + async def logout( + request: Request, + response: Response, + db: Annotated[AsyncSession, Depends(session)], + _: Annotated[tuple[User, set[str], bool], Depends(require)], + ) -> None: + encoded = request.cookies.get("backup_tool_session") + data = verify_session(encoded, settings.master_key_file) if encoded else None + if data is not None: + persisted = await db.get(Session, data["sid"]) + if persisted is not None: + persisted.revoked_at = datetime.now(UTC) + await db.commit() + response.delete_cookie("backup_tool_session", path="/") + response.delete_cookie("backup_tool_csrf", path="/") + + @app.get("/api/v2/auth/session", response_model=SessionUser) + async def get_session( + identity: Annotated[tuple[User, set[str], bool], Depends(actor)], + ) -> dict[str, str]: + user, _, _ = identity + return {"id": user.id, "username": user.username, "state": user.state} + + @app.post("/api/v2/auth/tokens") + async def create_token( + input_: TokenInput, + request: Request, + db: Annotated[AsyncSession, Depends(session)], + identity: Annotated[tuple[User, set[str], bool], Depends(require)], + idempotency_key: Annotated[str | None, Header(alias="Idempotency-Key")] = None, + ) -> JSONResponse: + user, scopes, _ = identity + if "*" not in scopes: + raise Problem(403, "insufficient_scope", "Required scope is missing.") + if not idempotency_key: + raise Problem(400, "idempotency_key_required", "Idempotency-Key is required.") + digest = _digest_request(input_) + existing = await db.scalar( + select(IdempotencyRecord).where( + IdempotencyRecord.actor_id == user.id, + IdempotencyRecord.key == idempotency_key, + IdempotencyRecord.operation == "create_api_token", + ) + ) + if existing is not None: + if existing.request_digest != digest: + raise Problem( + 409, "idempotency_mismatch", "Idempotency-Key was used for another request." + ) + return JSONResponse( + {"id": existing.response_resource_id, "token": None}, status_code=200 + ) + raw = new_token() + token = ApiToken( + owner_id=user.id, + token_hash=hash_token(raw), + scopes=input_.scopes, + expires_at=input_.expires_at, + ) + db.add(token) + await db.flush() + db.add( + IdempotencyRecord( + actor_id=user.id, + key=idempotency_key, + operation="create_api_token", + request_digest=digest, + response_resource_type="api_token", + response_resource_id=token.id, + ) + ) + await audit(db, request, "create", "api_token", token.id, "success", user.id) + await db.commit() + return JSONResponse({"id": token.id, "token": raw}, status_code=201) + + @app.delete("/api/v2/auth/tokens/{token_id}", status_code=204) + async def revoke_token( + token_id: str, + request: Request, + db: Annotated[AsyncSession, Depends(session)], + identity: Annotated[tuple[User, set[str], bool], Depends(require)], + ) -> None: + user, scopes, _ = identity + if "*" not in scopes: + raise Problem(403, "insufficient_scope", "Required scope is missing.") + token = await db.get(ApiToken, token_id) + if token is None or token.owner_id != user.id: + raise Problem(404, "resource_not_found", "API token was not found.") + token.revoked_at = datetime.now(UTC) + await audit(db, request, "revoke", "api_token", token.id, "success", user.id) + await db.commit() + + @app.post("/api/v2/admin/secrets", status_code=201) + async def create_secret( + input_: SecretInput, + request: Request, + db: Annotated[AsyncSession, Depends(session)], + identity: Annotated[tuple[User, set[str], bool], Depends(require)], + ) -> dict[str, Any]: + user, scopes, _ = identity + if "*" not in scopes and "admin:write" not in scopes: + raise Problem(403, "insufficient_scope", "Required scope is missing.") + ciphertext, key_id = app.state.cipher.encrypt( + input_.value, purpose=input_.purpose, version=1 + ) + secret = Secret(ciphertext=ciphertext, key_id=key_id, purpose=input_.purpose) + db.add(secret) + await db.flush() + await audit( + db, + request, + "create", + "secret", + secret.id, + "success", + user.id, + {"purpose": input_.purpose}, + ) + await db.commit() + return { + "id": secret.id, + "purpose": secret.purpose, + "key_id": secret.key_id, + "version": secret.version, + } + + @app.get("/api/v2/admin/secrets") + async def list_secrets( + db: Annotated[AsyncSession, Depends(session)], + identity: Annotated[tuple[User, set[str], bool], Depends(actor)], + ) -> list[dict[str, Any]]: + _, scopes, _ = identity + if "*" not in scopes and "admin:read" not in scopes: + raise Problem(403, "insufficient_scope", "Required scope is missing.") + result = await db.scalars(select(Secret).order_by(desc(Secret.created_at))) + return [ + {"id": item.id, "purpose": item.purpose, "key_id": item.key_id, "version": item.version} + for item in result + ] + + @app.get("/api/v2/admin/users/{user_id}") + async def get_user( + user_id: str, + response: Response, + db: Annotated[AsyncSession, Depends(session)], + _: Annotated[tuple[User, set[str], bool], Depends(actor)], + ) -> dict[str, str]: + user = await db.get(User, user_id) + if user is None: + raise Problem(404, "resource_not_found", "User was not found.") + response.headers["ETag"] = _etag(user) + return {"id": user.id, "username": user.username, "state": user.state} + + @app.patch("/api/v2/admin/users/{user_id}") + async def patch_user( + user_id: str, + input_: UserPatch, + request: Request, + response: Response, + db: Annotated[AsyncSession, Depends(session)], + identity: Annotated[tuple[User, set[str], bool], Depends(require)], + if_match: Annotated[str | None, Header(alias="If-Match")] = None, + ) -> dict[str, str]: + actor_user, scopes, _ = identity + if "*" not in scopes and "admin:write" not in scopes: + raise Problem(403, "insufficient_scope", "Required scope is missing.") + user = await db.get(User, user_id) + if user is None: + raise Problem(404, "resource_not_found", "User was not found.") + if if_match != _etag(user): + raise Problem(412, "etag_mismatch", "Resource was modified by another request.") + if input_.state not in {"active", "disabled"}: + raise Problem(422, "validation_failed", "State is invalid.") + user.state = input_.state + user.updated_at = datetime.now(UTC) + await audit(db, request, "update", "user", user.id, "success", actor_user.id) + await db.commit() + await db.refresh(user) + response.headers["ETag"] = _etag(user) + return {"id": user.id, "username": user.username, "state": user.state} + + @app.post("/api/v2/repositories", status_code=201) + async def create_repository( + input_: RepositoryInput, + request: Request, + db: Annotated[AsyncSession, Depends(session)], + identity: Annotated[tuple[User, set[str], bool], Depends(require)], + ) -> dict[str, Any]: + user, scopes, _ = identity + if "*" not in scopes and "admin:write" not in scopes: + raise Problem(403, "insufficient_scope", "Required scope is missing.") + try: + initialized = initialize( + settings, input_.relative_path, input_.compression, input_.encryption + ) + except RepositoryError as error: + raise Problem(409, "repository_invalid", str(error)) from error + repository = Repository( + name=input_.name, + root=str(initialized.root), + format_version=initialized.format_version, + compression=initialized.compression, + encryption=initialized.encryption, + signing_key_id=initialized.signing_key_id, + signing_public_key=initialized.signing_public_key, + active_data_key_id=initialized.data_key_id, + ) + try: + db.add(repository) + await db.flush() + if initialized.data_key_id is not None: + db.add( + RepositoryDataKeyEpoch( + repository_id=repository.id, + key_id=initialized.data_key_id, + state="active", + ) + ) + await audit(db, request, "create", "repository", repository.id, "success", user.id) + await db.commit() + except Exception as error: + await db.rollback() + remove_repository( + initialized.root, + initialized.signing_key_path, + initialized.data_key_path, + ) + raise Problem( + 409, "repository_create_failed", "Repository metadata could not be stored." + ) from error + return { + "id": repository.id, + "name": repository.name, + "format_version": repository.format_version, + "compression": repository.compression, + "encryption": repository.encryption, + } + + @app.get("/api/v2/repositories", response_model=RepositoryList) + async def list_repositories( + db: Annotated[AsyncSession, Depends(session)], + _: Annotated[tuple[User, set[str], bool], Depends(actor)], + ) -> dict[str, Any]: + items = list((await db.scalars(select(Repository).order_by(Repository.name))).all()) + return { + "items": [ + { + "id": item.id, + "name": item.name, + "format_version": item.format_version, + "compression": item.compression, + "encryption": item.encryption, + "state": item.state, + } + for item in items + ] + } + + @app.get("/api/v2/repositories/{repository_id}") + async def get_repository( + repository_id: str, + db: Annotated[AsyncSession, Depends(session)], + _: Annotated[tuple[User, set[str], bool], Depends(actor)], + ) -> dict[str, Any]: + repository = await db.get(Repository, repository_id) + if repository is None: + raise Problem(404, "resource_not_found", "Repository was not found.") + return { + "id": repository.id, + "name": repository.name, + "format_version": repository.format_version, + "compression": repository.compression, + "encryption": repository.encryption, + "state": repository.state, + } + + @app.get("/api/v2/repositories/{repository_id}/inspection") + async def inspect_repository_endpoint( + repository_id: str, + db: Annotated[AsyncSession, Depends(session)], + _: Annotated[tuple[User, set[str], bool], Depends(actor)], + ) -> dict[str, Any]: + repository = await db.get(Repository, repository_id) + if repository is None: + raise Problem(404, "resource_not_found", "Repository was not found.") + try: + inspected = inspect_repository(settings, Path(repository.root)) + except RepositoryError as error: + raise Problem(409, "repository_invalid", str(error)) from error + return { + "id": repository.id, + "format_version": inspected.format_version, + "compression": inspected.compression, + "encryption": inspected.encryption, + } + + @app.patch("/api/v2/repositories/{repository_id}") + async def patch_repository( + repository_id: str, + input_: RepositoryPatch, + _: Annotated[tuple[User, set[str], bool], Depends(require)], + ) -> None: + if input_.compression is not None or input_.encryption is not None: + raise Problem(409, "repository_policy_immutable", "Repository policy is immutable.") + raise Problem(422, "validation_failed", "No mutable fields supplied.") + + @app.get("/api/v2/sources", response_model=SourceList) + async def list_sources( + db: Annotated[AsyncSession, Depends(session)], + _: Annotated[tuple[User, set[str], bool], Depends(actor)], + ) -> dict[str, Any]: + items = list((await db.scalars(select(Source).order_by(Source.name))).all()) + return { + "items": [ + { + "id": item.id, + "name": item.name, + "kind": item.kind, + "state": item.state, + "public_config": item.public_config, + } + for item in items + ] + } + + @app.post("/api/v2/sources", status_code=201) + async def create_source( + input_: SourceInput, + db: Annotated[AsyncSession, Depends(session)], + _: Annotated[tuple[User, set[str], bool], Depends(require)], + ) -> dict[str, Any]: + if isinstance(input_, LocalSourceInput): + root = input_.public_config.get("root") + if not isinstance(root, str): + raise Problem(422, "validation_failed", "Local source root is required.") + try: + LocalAdapter(Path(root), settings).validate_config() + except SourceError as error: + raise Problem(422, "validation_failed", str(error)) from error + source = Source( + name=input_.name, kind="local", public_config={"root": root}, secret_refs=[] + ) + else: + secret = await db.get(Secret, input_.private_key_secret_id) + if secret is None or secret.purpose != SSH_PRIVATE_KEY_PURPOSE: + raise Problem( + 422, + "validation_failed", + "SSH source requires an existing SSH private-key secret.", + ) + source = Source( + name=input_.name, + kind="ssh", + public_config=input_.public_config.model_dump(), + secret_refs=[secret.id], + ) + db.add(source) + try: + await db.commit() + except IntegrityError as error: + await db.rollback() + raise Problem(409, "resource_conflict", "Source name already exists.") from error + await db.refresh(source) + return { + "id": source.id, + "name": source.name, + "kind": source.kind, + "state": source.state, + "public_config": source.public_config, + } + + @app.post("/api/v2/sources/{source_id}/probe") + async def probe_source( + source_id: str, + db: Annotated[AsyncSession, Depends(session)], + _: Annotated[tuple[User, set[str], bool], Depends(require)], + ) -> dict[str, Any]: + source = await db.get(Source, source_id) + if source is None: + raise Problem(404, "resource_not_found", "Source was not found.") + if source.state != "active": + raise Problem(409, "source_archived", "Source is archived.") + adapter = None + try: + adapter = await source_adapter(settings, db, source, app.state.cipher) + result = await adapter.probe() + except SourceError as error: + raise Problem(409, "source_probe_failed", str(error)) from error + finally: + if adapter is not None: + await adapter.close() + source.last_probe = result + await db.commit() + return result + + @app.delete("/api/v2/sources/{source_id}", status_code=204) + async def archive_source( + source_id: str, + db: Annotated[AsyncSession, Depends(session)], + _: Annotated[tuple[User, set[str], bool], Depends(require)], + ) -> Response: + source = await db.get(Source, source_id) + if source is None: + raise Problem(404, "resource_not_found", "Source was not found.") + source.state = "archived" + await db.commit() + return Response(status_code=204) + + @app.get("/api/v2/jobs", response_model=JobList) + async def list_jobs( + db: Annotated[AsyncSession, Depends(session)], + _: Annotated[tuple[User, set[str], bool], Depends(actor)], + ) -> dict[str, Any]: + jobs = list((await db.scalars(select(Job).order_by(Job.name))).all()) + schedules = {item.job_id: item for item in (await db.scalars(select(Schedule))).all()} + return { + "items": [ + { + "id": item.id, + "name": item.name, + "source_id": item.source_id, + "repository_id": item.repository_id, + "requested_mode": item.requested_mode, + "enabled": item.enabled, + "state": item.state, + "schedule": ( + { + "id": schedules[item.id].id, + "cron": schedules[item.id].cron, + "timezone": schedules[item.id].timezone, + "enabled": schedules[item.id].enabled, + "next_nominal_at": schedules[item.id].next_nominal_at, + "last_enqueue_outcome": schedules[item.id].last_enqueue_outcome, + } + if item.id in schedules + else None + ), + } + for item in jobs + ] + } + + @app.post("/api/v2/jobs", status_code=201) + async def create_job( + input_: JobInput, + db: Annotated[AsyncSession, Depends(session)], + _: Annotated[tuple[User, set[str], bool], Depends(require)], + ) -> dict[str, Any]: + if input_.requested_mode not in {"full", "incremental"}: + raise Problem(422, "validation_failed", "Invalid requested mode.") + source = await db.get(Source, input_.source_id) + repository = await db.get(Repository, input_.repository_id) + if source is None or repository is None: + raise Problem(422, "validation_failed", "Source and repository must exist.") + if source.state != "active" or repository.state != "active": + raise Problem(409, "resource_archived", "Source or repository is unavailable.") + job = Job( + name=input_.name, + source_id=source.id, + repository_id=repository.id, + requested_mode=input_.requested_mode, + exclusions=input_.exclusions, + retention=input_.retention, + enabled=input_.enabled, + allow_empty=input_.allow_empty, + ) + db.add(job) + try: + await db.commit() + except IntegrityError as error: + await db.rollback() + raise Problem(409, "resource_conflict", "Job name already exists.") from error + await db.refresh(job) + return { + "id": job.id, + "name": job.name, + "source_id": job.source_id, + "repository_id": job.repository_id, + "requested_mode": job.requested_mode, + "exclusions": job.exclusions, + "retention": job.retention, + "enabled": job.enabled, + "allow_empty": job.allow_empty, + "state": job.state, + } + + @app.post("/api/v2/jobs/{job_id}/schedule", status_code=201) + async def create_schedule( + job_id: str, + input_: ScheduleInput, + request: Request, + db: Annotated[AsyncSession, Depends(session)], + identity: Annotated[tuple[User, set[str], bool], Depends(require)], + ) -> dict[str, Any]: + enforce_scope(identity[1], "admin:write") + job = await db.get(Job, job_id) + if job is None or job.state != "active" or not job.enabled: + raise Problem(409, "job_disabled", "Job is unavailable for scheduling.") + try: + next_run = next_nominal(input_.cron, input_.timezone) if input_.enabled else None + except ScheduleError as error: + raise Problem(422, "validation_failed", str(error)) from error + schedule = Schedule( + job_id=job.id, + cron=input_.cron, + timezone=input_.timezone, + misfire_grace_seconds=input_.misfire_grace_seconds, + enabled=input_.enabled, + next_nominal_at=next_run, + ) + db.add(schedule) + try: + await db.flush() + await emit_event( + db, + "schedule.created", + correlation_id=schedule.id, + resource={"schedule_id": schedule.id, "job_id": schedule.job_id}, + payload={"state": "enabled" if schedule.enabled else "disabled"}, + deduplication_key=f"schedule:{schedule.id}:created", + ) + await db.commit() + except IntegrityError as error: + await db.rollback() + raise Problem(409, "resource_conflict", "Job already has a schedule.") from error + return { + "id": schedule.id, + "job_id": schedule.job_id, + "next_nominal_at": schedule.next_nominal_at, + } + + @app.get("/api/v2/jobs/{job_id}/schedule") + async def get_schedule( + job_id: str, + db: Annotated[AsyncSession, Depends(session)], + identity: Annotated[tuple[User, set[str], bool], Depends(actor)], + ) -> dict[str, Any]: + enforce_scope(identity[1], "admin:write") + schedule = await db.scalar(select(Schedule).where(Schedule.job_id == job_id)) + if schedule is None: + raise Problem(404, "resource_not_found", "Schedule was not found.") + return { + "id": schedule.id, + "job_id": schedule.job_id, + "cron": schedule.cron, + "timezone": schedule.timezone, + "misfire_grace_seconds": schedule.misfire_grace_seconds, + "enabled": schedule.enabled, + "next_nominal_at": schedule.next_nominal_at, + "last_enqueue_outcome": schedule.last_enqueue_outcome, + } + + @app.patch("/api/v2/jobs/{job_id}/schedule") + async def patch_schedule( + job_id: str, + input_: ScheduleInput, + request: Request, + db: Annotated[AsyncSession, Depends(session)], + identity: Annotated[tuple[User, set[str], bool], Depends(require)], + ) -> dict[str, Any]: + enforce_scope(identity[1], "admin:write") + schedule = await db.scalar(select(Schedule).where(Schedule.job_id == job_id)) + if schedule is None: + raise Problem(404, "resource_not_found", "Schedule was not found.") + try: + next_run = next_nominal(input_.cron, input_.timezone) if input_.enabled else None + except ScheduleError as error: + raise Problem(422, "validation_failed", str(error)) from error + prior_enabled = schedule.enabled + schedule.cron = input_.cron + schedule.timezone = input_.timezone + schedule.misfire_grace_seconds = input_.misfire_grace_seconds + schedule.enabled = input_.enabled + schedule.next_nominal_at = next_run + event_type = "schedule.updated" + if prior_enabled != schedule.enabled: + event_type = "schedule.enabled" if schedule.enabled else "schedule.disabled" + await emit_event( + db, + event_type, + correlation_id=schedule.id, + resource={"schedule_id": schedule.id, "job_id": schedule.job_id}, + payload={"state": "enabled" if schedule.enabled else "disabled"}, + deduplication_key=( + f"schedule:{schedule.id}:{event_type}:{input_.cron}:{input_.timezone}:" + f"{input_.enabled}:{input_.misfire_grace_seconds}" + ), + ) + await db.commit() + return await get_schedule(job_id, db, identity) + + @app.delete("/api/v2/jobs/{job_id}/schedule", status_code=204) + async def delete_schedule( + job_id: str, + request: Request, + db: Annotated[AsyncSession, Depends(session)], + identity: Annotated[tuple[User, set[str], bool], Depends(require)], + ) -> Response: + enforce_scope(identity[1], "admin:write") + schedule = await db.scalar(select(Schedule).where(Schedule.job_id == job_id)) + if schedule is None: + raise Problem(404, "resource_not_found", "Schedule was not found.") + await emit_event( + db, + "schedule.deleted", + correlation_id=schedule.id, + resource={"schedule_id": schedule.id, "job_id": schedule.job_id}, + payload={"state": "deleted"}, + deduplication_key=f"schedule:{schedule.id}:deleted", + ) + await db.delete(schedule) + await db.commit() + return Response(status_code=204) + + @app.post("/api/v2/jobs/{job_id}/executions", status_code=202) + async def enqueue_execution( + job_id: str, + db: Annotated[AsyncSession, Depends(session)], + identity: Annotated[tuple[User, set[str], bool], Depends(require)], + ) -> dict[str, Any]: + enforce_scope(identity[1], "execution:control") + try: + execution = await enqueue(db, job_id) + except EnqueueError as error: + if error.code == "execution_active": + raise Problem( + 409, + "execution_active", + f"Job already has active execution {error.active_execution_id}.", + ) from error + if error.code == "job_disabled": + raise Problem(409, "job_disabled", str(error)) from error + raise Problem(404, "resource_not_found", str(error)) from error + return { + "id": execution.id, + "job_id": execution.job_id, + "state": execution.state, + "trigger": execution.trigger, + "attempt": execution.attempt, + } + + @app.get("/api/v2/backups", response_model=BackupList) + async def list_backups( + db: Annotated[AsyncSession, Depends(session)], + identity: Annotated[tuple[User, set[str], bool], Depends(actor)], + ) -> dict[str, Any]: + enforce_scope(identity[1], "admin:read") + items = list((await db.scalars(select(Backup).order_by(desc(Backup.created_at)))).all()) + return { + "items": [ + { + "id": item.id, + "execution_id": item.execution_id, + "manifest_id": item.manifest_id, + "logical_bytes": item.logical_bytes, + "stored_bytes": item.stored_bytes, + "integrity": item.integrity, + "pinned": item.pinned, + "tombstoned_at": item.tombstoned_at, + "created_at": item.created_at, + } + for item in items + ] + } + + @app.get("/api/v2/backups/{backup_id}", response_model=BackupSummary) + async def get_backup( + backup_id: str, + db: Annotated[AsyncSession, Depends(session)], + identity: Annotated[tuple[User, set[str], bool], Depends(actor)], + ) -> dict[str, Any]: + enforce_scope(identity[1], "admin:read") + item = await db.get(Backup, backup_id) + if item is None: + raise Problem(404, "resource_not_found", "Backup was not found.") + return { + "id": item.id, + "execution_id": item.execution_id, + "manifest_id": item.manifest_id, + "logical_bytes": item.logical_bytes, + "stored_bytes": item.stored_bytes, + "integrity": item.integrity, + "pinned": item.pinned, + "tombstoned_at": item.tombstoned_at, + "created_at": item.created_at, + } + + @app.post("/api/v2/backups/{backup_id}/verify", response_model=BackupSummary) + async def verify_backup( + backup_id: str, + request: Request, + db: Annotated[AsyncSession, Depends(session)], + identity: Annotated[tuple[User, set[str], bool], Depends(require)], + ) -> dict[str, Any]: + user, scopes, _ = identity + enforce_scope(scopes, "admin:write") + item = await db.get(Backup, backup_id) + if item is None: + raise Problem(404, "resource_not_found", "Backup was not found.") + execution = await db.get(Execution, item.execution_id) + repository = await db.get(Job, execution.job_id) if execution is not None else None + target = await db.get(Repository, repository.repository_id) if repository else None + if target is None: + raise Problem(409, "backup_unavailable", "Backup repository is unavailable.") + try: + await verify_backup_snapshot(settings, db, item, target) + except SnapshotError as error: + item.integrity = "corrupt" + await audit(db, request, "verify", "backup", item.id, "failure", user.id) + await db.commit() + raise Problem( + 409, "backup_verification_failed", "Backup verification failed." + ) from error + item.integrity = "verified" + await emit_event( + db, + "backup.verification_succeeded", + correlation_id=item.id, + resource={"backup_id": item.id, "repository_id": target.id}, + payload={"integrity": item.integrity, "outcome": "verified"}, + deduplication_key=f"backup:{item.id}:verification_succeeded", + ) + await audit(db, request, "verify", "backup", item.id, "success", user.id) + await db.commit() + return await get_backup(backup_id, db, (user, scopes, False)) + + @app.get("/api/v2/backups/{backup_id}/delete-preview", response_model=BackupDeletePreview) + async def backup_delete_preview( + backup_id: str, + db: Annotated[AsyncSession, Depends(session)], + identity: Annotated[tuple[User, set[str], bool], Depends(actor)], + ) -> dict[str, Any]: + enforce_scope(identity[1], "admin:write") + item = await db.get(Backup, backup_id) + if item is None: + raise Problem(404, "resource_not_found", "Backup was not found.") + if item.pinned: + reason = "Pinned backups cannot be deleted." + elif item.tombstoned_at is not None: + reason = "Backup is already tombstoned." + else: + reason = None + return { + "backup_id": item.id, + "eligible": reason is None, + "reason": reason, + "destructive_action": ( + "Deletion is performed by retention and garbage collection after its grace period." + ), + } + + @app.post("/api/v2/backups/{backup_id}/restores", status_code=202) + async def create_restore( + backup_id: str, + input_: RestoreInput, + request: Request, + db: Annotated[AsyncSession, Depends(session)], + identity: Annotated[tuple[User, set[str], bool], Depends(require)], + ) -> dict[str, Any]: + user, scopes, _ = identity + enforce_scope(scopes, "admin:write") + backup = await db.get(Backup, backup_id) + if backup is None: + raise Problem(404, "resource_not_found", "Backup was not found.") + if backup.integrity != "verified" or backup.tombstoned_at is not None: + raise Problem(409, "backup_unavailable", "Backup is not available for restore.") + if input_.overwrite_policy not in {"fail", "skip", "replace"}: + raise Problem(422, "validation_failed", "Restore overwrite policy is invalid.") + restore = Restore( + backup_id=backup.id, + destination=input_.destination, + selection=input_.selection, + dry_run=input_.dry_run, + overwrite_policy=input_.overwrite_policy, + ) + db.add(restore) + await db.flush() + await audit(db, request, "create", "restore", restore.id, "success", user.id) + await emit_event( + db, + "restore.queued", + correlation_id=restore.id, + resource={"restore_id": restore.id, "backup_id": restore.backup_id}, + payload={"dry_run": restore.dry_run, "state": restore.state}, + deduplication_key=f"restore:{restore.id}:queued", + ) + await db.commit() + await db.refresh(restore) + return { + "id": restore.id, + "backup_id": restore.backup_id, + "state": restore.state, + "destination": restore.destination, + "selection": restore.selection, + "dry_run": restore.dry_run, + "overwrite_policy": restore.overwrite_policy, + "result": restore.result, + } + + @app.get("/api/v2/restores/{restore_id}") + async def get_restore( + restore_id: str, + db: Annotated[AsyncSession, Depends(session)], + identity: Annotated[tuple[User, set[str], bool], Depends(actor)], + ) -> dict[str, Any]: + enforce_scope(identity[1], "admin:write") + restore = await db.get(Restore, restore_id) + if restore is None: + raise Problem(404, "resource_not_found", "Restore was not found.") + return { + "id": restore.id, + "backup_id": restore.backup_id, + "state": restore.state, + "destination": restore.destination, + "selection": restore.selection, + "dry_run": restore.dry_run, + "overwrite_policy": restore.overwrite_policy, + "result": restore.result, + } + + @app.get("/api/v2/executions", response_model=ExecutionList) + async def list_executions( + db: Annotated[AsyncSession, Depends(session)], + identity: Annotated[tuple[User, set[str], bool], Depends(actor)], + ) -> dict[str, Any]: + enforce_scope(identity[1], "execution:read") + items = list( + (await db.scalars(select(Execution).order_by(desc(Execution.created_at)))).all() + ) + return {"items": [public_event(item) for item in items]} + + @app.get("/api/v2/executions/{execution_id}", response_model=ExecutionSummary) + async def get_execution( + execution_id: str, + db: Annotated[AsyncSession, Depends(session)], + identity: Annotated[tuple[User, set[str], bool], Depends(actor)], + ) -> dict[str, object]: + enforce_scope(identity[1], "execution:read") + execution = await db.get(Execution, execution_id) + if execution is None: + raise Problem(404, "resource_not_found", "Execution was not found.") + return public_event(execution) + + @app.post("/api/v2/executions/{execution_id}/cancel", status_code=202) + async def cancel_execution( + execution_id: str, + db: Annotated[AsyncSession, Depends(session)], + identity: Annotated[tuple[User, set[str], bool], Depends(require)], + ) -> dict[str, object]: + enforce_scope(identity[1], "execution:control") + execution = await request_cancellation(db, execution_id) + if execution is None: + raise Problem(409, "cancellation_not_allowed", "Execution cannot be cancelled.") + return public_event(execution) + + @app.post("/api/v2/executions/{execution_id}/retry", status_code=202) + async def retry_execution( + execution_id: str, + db: Annotated[AsyncSession, Depends(session)], + identity: Annotated[tuple[User, set[str], bool], Depends(require)], + ) -> dict[str, object]: + enforce_scope(identity[1], "execution:control") + try: + execution = await retry(db, execution_id) + except EnqueueError as error: + raise Problem(409, error.code, str(error)) from error + if execution is None: + raise Problem(404, "resource_not_found", "Execution was not found.") + return public_event(execution) + + @app.get( + "/api/v2/executions/{execution_id}/events", + responses={200: {"content": {"text/event-stream": {}}}}, + ) + async def execution_events( + execution_id: str, + db: Annotated[AsyncSession, Depends(session)], + identity: Annotated[tuple[User, set[str], bool], Depends(actor)], + last_event_id: Annotated[str | None, Header(alias="Last-Event-ID")] = None, + ) -> StreamingResponse: + enforce_scope(identity[1], "execution:read") + if await db.get(Execution, execution_id) is None: + raise Problem(404, "resource_not_found", "Execution was not found.") + return StreamingResponse( + execution_events_stream(app.state.sessions, execution_id, last_event_id), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + def public_subscription(item: NotificationSubscription) -> dict[str, Any]: + if item.channel == "webhook": + destination: dict[str, Any] = {"configured": True} + else: + recipients = item.destination_config.get("recipients", []) + destination = {"recipient_count": len(recipients)} + return { + "id": item.id, + "channel": item.channel, + "event_filters": item.event_filters, + "destination": destination, + "state": item.state, + "rate_limit_per_minute": item.rate_limit_per_minute, + "revision": item.revision, + "created_at": item.created_at.isoformat(), + "updated_at": item.updated_at.isoformat(), + } + + @app.get("/api/v2/security/recovery/status", response_model=RecoveryStatus) + async def recovery_status( + db: Annotated[AsyncSession, Depends(session)], + identity: Annotated[tuple[User, set[str], bool], Depends(actor)], + ) -> dict[str, Any]: + enforce_scope(identity[1], "admin:read") + encrypted_count = await db.scalar( + select(func.count()) + .select_from(Repository) + .where(Repository.encryption == "aes-256-gcm") + ) + return { + "recovery_mode": "cli_only", + "runbook": "docs/runbooks/recovery-bundle.md", + "encrypted_repository_count": encrypted_count or 0, + } + + @app.get("/api/v2/notifications/event-catalog") + async def notification_catalog( + identity: Annotated[tuple[User, set[str], bool], Depends(actor)], + ) -> dict[str, Any]: + enforce_scope(identity[1], "admin:read") + return {"event_schema_version": 1, "events": EVENT_CATALOG} + + @app.get("/api/v2/notifications/subscriptions", response_model=NotificationSubscriptionList) + async def list_notification_subscriptions( + db: Annotated[AsyncSession, Depends(session)], + identity: Annotated[tuple[User, set[str], bool], Depends(actor)], + ) -> dict[str, Any]: + enforce_scope(identity[1], "admin:read") + items = list((await db.scalars(select(NotificationSubscription))).all()) + return {"items": [public_subscription(item) for item in items]} + + @app.post("/api/v2/notifications/subscriptions", status_code=201) + async def create_notification_subscription( + input_: NotificationSubscriptionInput, + request: Request, + response: Response, + db: Annotated[AsyncSession, Depends(session)], + identity: Annotated[tuple[User, set[str], bool], Depends(require)], + ) -> dict[str, Any]: + user, scopes, _ = identity + enforce_scope(scopes, "admin:write") + try: + filters = validate_filters(input_.event_filters) + destination = validate_destination(input_.channel, input_.destination) + except NotificationEventError as error: + raise Problem(422, "validation_failed", str(error)) from error + if input_.channel == "webhook" and input_.signing_secret is None: + raise Problem(422, "validation_failed", "Webhook signing secret is required.") + item = NotificationSubscription( + channel=input_.channel, + event_filters=filters, + destination_config=destination, + rate_limit_per_minute=input_.rate_limit_per_minute, + rate_tokens=_notification_rate_tokens(input_.rate_limit_per_minute), + rate_updated_at=datetime.now(UTC), + ) + db.add(item) + await db.flush() + if input_.channel == "webhook": + ciphertext, key_id = app.state.cipher.encrypt( + input_.signing_secret or "", purpose="notification_webhook", version=1 + ) + secret = Secret(ciphertext=ciphertext, key_id=key_id, purpose="notification_webhook") + db.add(secret) + await db.flush() + db.add(NotificationSigningKey(subscription_id=item.id, version=1, secret_id=secret.id)) + await audit(db, request, "create", "notification_subscription", item.id, "success", user.id) + await db.commit() + await db.refresh(item) + response.headers["ETag"] = _etag(item) + return public_subscription(item) + + @app.get("/api/v2/notifications/subscriptions/{subscription_id}") + async def get_notification_subscription( + subscription_id: str, + response: Response, + db: Annotated[AsyncSession, Depends(session)], + identity: Annotated[tuple[User, set[str], bool], Depends(actor)], + ) -> dict[str, Any]: + enforce_scope(identity[1], "admin:read") + item = await db.get(NotificationSubscription, subscription_id) + if item is None: + raise Problem(404, "resource_not_found", "Subscription was not found.") + response.headers["ETag"] = _etag(item) + return public_subscription(item) + + @app.patch("/api/v2/notifications/subscriptions/{subscription_id}") + async def patch_notification_subscription( + subscription_id: str, + input_: NotificationSubscriptionPatch, + request: Request, + response: Response, + db: Annotated[AsyncSession, Depends(session)], + identity: Annotated[tuple[User, set[str], bool], Depends(require)], + if_match: Annotated[str | None, Header(alias="If-Match")] = None, + ) -> dict[str, Any]: + user, scopes, _ = identity + enforce_scope(scopes, "admin:write") + item = await db.get(NotificationSubscription, subscription_id) + if item is None: + raise Problem(404, "resource_not_found", "Subscription was not found.") + if if_match != _etag(item): + raise Problem(412, "etag_mismatch", "Resource was modified by another request.") + try: + if input_.event_filters is not None: + item.event_filters = validate_filters(input_.event_filters) + if input_.destination is not None: + item.destination_config = validate_destination(item.channel, input_.destination) + except NotificationEventError as error: + raise Problem(422, "validation_failed", str(error)) from error + if input_.state is not None: + item.state = input_.state + if input_.rate_limit_per_minute is not None: + item.rate_limit_per_minute = input_.rate_limit_per_minute + item.rate_tokens = min( + item.rate_tokens, + _notification_rate_tokens(input_.rate_limit_per_minute), + ) + item.revision += 1 + item.updated_at = datetime.now(UTC) + await audit(db, request, "update", "notification_subscription", item.id, "success", user.id) + await db.commit() + response.headers["ETag"] = _etag(item) + return public_subscription(item) + + @app.get("/api/v2/notifications/email-settings") + async def get_notification_email_settings( + db: Annotated[AsyncSession, Depends(session)], + identity: Annotated[tuple[User, set[str], bool], Depends(actor)], + ) -> dict[str, Any]: + enforce_scope(identity[1], "admin:read") + item = await db.get(NotificationEmailSettings, 1) + if item is None: + return {"configured": False} + return { + "configured": True, + "host": item.host, + "port": item.port, + "username": item.username, + "sender": item.sender, + "max_attempts": item.max_attempts, + "rate_limit_per_minute": item.rate_limit_per_minute, + "password_configured": True, + } + + @app.put("/api/v2/notifications/email-settings") + async def put_notification_email_settings( + input_: EmailSettingsInput, + request: Request, + db: Annotated[AsyncSession, Depends(session)], + identity: Annotated[tuple[User, set[str], bool], Depends(require)], + ) -> dict[str, Any]: + user, scopes, _ = identity + enforce_scope(scopes, "admin:write") + if any(character in input_.sender for character in "\r\n") or "@" not in input_.sender: + raise Problem(422, "validation_failed", "Sender address is invalid.") + ciphertext, key_id = app.state.cipher.encrypt( + input_.password, purpose="notification_smtp", version=1 + ) + secret = Secret(ciphertext=ciphertext, key_id=key_id, purpose="notification_smtp") + db.add(secret) + await db.flush() + item = await db.get(NotificationEmailSettings, 1) + if item is None: + item = NotificationEmailSettings( + id=1, + host=input_.host, + port=input_.port, + username=input_.username, + password_secret_id=secret.id, + sender=input_.sender, + max_attempts=input_.max_attempts, + rate_limit_per_minute=input_.rate_limit_per_minute, + ) + db.add(item) + else: + item.host, item.port, item.username = input_.host, input_.port, input_.username + item.password_secret_id, item.sender = secret.id, input_.sender + item.max_attempts = input_.max_attempts + item.rate_limit_per_minute = input_.rate_limit_per_minute + await audit(db, request, "update", "notification_email_settings", "1", "success", user.id) + await db.commit() + return {"configured": True, "password_configured": True} + + @app.post("/api/v2/notifications/subscriptions/{subscription_id}/signing-keys/rotate") + async def rotate_notification_signing_key( + subscription_id: str, + input_: SigningKeyRotateInput, + request: Request, + db: Annotated[AsyncSession, Depends(session)], + identity: Annotated[tuple[User, set[str], bool], Depends(require)], + idempotency_key: Annotated[str | None, Header(alias="Idempotency-Key")] = None, + ) -> dict[str, Any]: + user, scopes, _ = identity + enforce_scope(scopes, "admin:write") + if not idempotency_key: + raise Problem(400, "idempotency_key_required", "Idempotency-Key is required.") + # The request secret is deliberately excluded: idempotency must not + # create an offline plaintext-secret verifier in durable metadata. + digest = hashlib.sha256(f"{subscription_id}:{input_.overlap_seconds}".encode()).hexdigest() + existing = await db.scalar( + select(IdempotencyRecord).where( + IdempotencyRecord.actor_id == user.id, + IdempotencyRecord.key == idempotency_key, + IdempotencyRecord.operation == "rotate_notification_signing_key", + ) + ) + if existing is not None: + if existing.request_digest != digest: + raise Problem( + 409, + "idempotency_mismatch", + "Idempotency-Key was used for another request.", + ) + key = await db.get(NotificationSigningKey, existing.response_resource_id) + if key is None: # pragma: no cover - protected by foreign-key lifetime + raise Problem(409, "idempotency_conflict", "Signing key is unavailable.") + return {"id": key.id, "version": key.version, "state": key.state} + item = await db.get(NotificationSubscription, subscription_id) + if item is None or item.channel != "webhook": + raise Problem(404, "resource_not_found", "Webhook subscription was not found.") + active = await db.scalar( + select(NotificationSigningKey).where( + NotificationSigningKey.subscription_id == item.id, + NotificationSigningKey.state == "active", + ) + ) + if active is None: + raise Problem(409, "signing_key_unavailable", "Active signing key is unavailable.") + overlap = await db.scalar( + select(NotificationSigningKey).where( + NotificationSigningKey.subscription_id == item.id, + NotificationSigningKey.state == "overlap", + ) + ) + if overlap is not None: + overlap.state = "retired" + ciphertext, key_id = app.state.cipher.encrypt( + input_.secret, purpose="notification_webhook", version=1 + ) + secret = Secret(ciphertext=ciphertext, key_id=key_id, purpose="notification_webhook") + db.add(secret) + await db.flush() + active.state = "overlap" + active.overlap_expires_at = datetime.now(UTC) + timedelta(seconds=input_.overlap_seconds) + key = NotificationSigningKey( + subscription_id=item.id, + version=active.version + 1, + secret_id=secret.id, + state="active", + ) + db.add(key) + await db.flush() + db.add( + IdempotencyRecord( + actor_id=user.id, + key=idempotency_key, + operation="rotate_notification_signing_key", + request_digest=digest, + response_resource_type="notification_signing_key", + response_resource_id=key.id, + ) + ) + await audit(db, request, "rotate", "notification_signing_key", key.id, "success", user.id) + await db.commit() + return {"id": key.id, "version": key.version, "state": key.state} + + @app.get("/api/v2/notifications/deliveries", response_model=NotificationDeliveryList) + async def list_notification_deliveries( + db: Annotated[AsyncSession, Depends(session)], + identity: Annotated[tuple[User, set[str], bool], Depends(actor)], + limit: int = 50, + ) -> dict[str, Any]: + enforce_scope(identity[1], "admin:read") + if not 1 <= limit <= 100: + raise Problem(422, "validation_failed", "Limit must be between 1 and 100.") + statement = ( + select(NotificationDelivery) + .order_by(desc(NotificationDelivery.created_at)) + .limit(limit) + ) + items = list((await db.scalars(statement)).all()) + return { + "items": [ + { + "id": item.id, + "event_id": item.event_id, + "subscription_id": item.subscription_id, + "state": item.state, + "attempt_count": item.attempt_count, + "response_class": item.response_class, + "response_summary": item.response_summary, + "terminal_reason": item.terminal_reason, + "due_at": item.due_at.isoformat(), + } + for item in items + ] + } + + @app.get( + "/api/v2/notifications/deliveries/{delivery_id}/attempts", + response_model=NotificationAttemptList, + ) + async def list_notification_attempts( + delivery_id: str, + db: Annotated[AsyncSession, Depends(session)], + identity: Annotated[tuple[User, set[str], bool], Depends(actor)], + ) -> dict[str, Any]: + enforce_scope(identity[1], "admin:read") + statement = ( + select(NotificationDeliveryAttempt) + .where(NotificationDeliveryAttempt.delivery_id == delivery_id) + .order_by(NotificationDeliveryAttempt.number) + ) + attempts = list((await db.scalars(statement)).all()) + return { + "items": [ + { + "number": item.number, + "outcome": item.outcome, + "response_class": item.response_class, + "diagnostic": item.diagnostic, + "started_at": item.started_at.isoformat(), + "completed_at": item.completed_at.isoformat() if item.completed_at else None, + } + for item in attempts + ] + } + + @app.post("/api/v2/notifications/subscriptions/{subscription_id}/test", status_code=202) + async def test_notification_subscription( + subscription_id: str, + request: Request, + db: Annotated[AsyncSession, Depends(session)], + identity: Annotated[tuple[User, set[str], bool], Depends(require)], + idempotency_key: Annotated[str | None, Header(alias="Idempotency-Key")] = None, + ) -> dict[str, str]: + user, scopes, _ = identity + enforce_scope(scopes, "admin:write") + if not idempotency_key: + raise Problem(400, "idempotency_key_required", "Idempotency-Key is required.") + item = await db.get(NotificationSubscription, subscription_id) + if item is None or item.state != "active": + raise Problem(409, "subscription_unavailable", "Subscription is unavailable.") + event = await emit_event( + db, + "notification.test_requested", + correlation_id=request.state.request_id, + resource={"subscription_id": item.id}, + payload={"outcome": "requested"}, + deduplication_key=f"notification-test:{item.id}:{idempotency_key}", + only_subscription_id=item.id, + ) + await audit(db, request, "test", "notification_subscription", item.id, "success", user.id) + await db.commit() + return {"event_id": event.id} + + @app.post("/api/v2/notifications/deliveries/{delivery_id}/retry", status_code=202) + async def retry_notification_delivery( + delivery_id: str, + request: Request, + db: Annotated[AsyncSession, Depends(session)], + identity: Annotated[tuple[User, set[str], bool], Depends(require)], + idempotency_key: Annotated[str | None, Header(alias="Idempotency-Key")] = None, + ) -> dict[str, str]: + user, scopes, _ = identity + enforce_scope(scopes, "admin:write") + if not idempotency_key: + raise Problem(400, "idempotency_key_required", "Idempotency-Key is required.") + digest = hashlib.sha256(delivery_id.encode()).hexdigest() + existing = await db.scalar( + select(IdempotencyRecord).where( + IdempotencyRecord.actor_id == user.id, + IdempotencyRecord.key == idempotency_key, + IdempotencyRecord.operation == "retry_notification_delivery", + ) + ) + if existing is not None: + if existing.request_digest != digest: + raise Problem( + 409, + "idempotency_mismatch", + "Idempotency-Key was used for another request.", + ) + return {"delivery_id": existing.response_resource_id} + delivery = await db.get(NotificationDelivery, delivery_id) + if delivery is None: + raise Problem(404, "resource_not_found", "Delivery was not found.") + if delivery.state != "failed": + raise Problem(409, "retry_not_allowed", "Delivery is not terminally failed.") + delivery.state, delivery.due_at = "retry", datetime.now(UTC) + delivery.terminal_reason = None + db.add( + IdempotencyRecord( + actor_id=user.id, + key=idempotency_key, + operation="retry_notification_delivery", + request_digest=digest, + response_resource_type="notification_delivery", + response_resource_id=delivery.id, + ) + ) + await audit(db, request, "retry", "notification_delivery", delivery.id, "success", user.id) + await db.commit() + return {"delivery_id": delivery.id} + + @app.get("/api/v2/audit", response_model=AuditList) + async def list_audit( + db: Annotated[AsyncSession, Depends(session)], + identity: Annotated[tuple[User, set[str], bool], Depends(actor)], + limit: int = 50, + cursor: str | None = None, + ) -> dict[str, Any]: + _, scopes, _ = identity + if "*" not in scopes and "audit:read" not in scopes: + raise Problem(403, "insufficient_scope", "Required scope is missing.") + if not 1 <= limit <= 100: + raise Problem(422, "validation_failed", "Limit must be between 1 and 100.") + statement = select(AuditEvent).order_by(desc(AuditEvent.id)).limit(limit + 1) + if cursor: + statement = statement.where(AuditEvent.id < _decode_cursor(cursor)) + items = list((await db.scalars(statement)).all()) + page, remainder = items[:limit], items[limit:] + return { + "items": [ + { + "id": item.id, + "action": item.action, + "resource_type": item.resource_type, + "resource_id": item.resource_id, + "outcome": item.outcome, + "request_id": item.request_id, + "created_at": item.created_at.isoformat(), + "details": redact(item.details), + } + for item in page + ], + "next_cursor": _cursor(page[-1].id) if page and remainder else None, + } + + return app diff --git a/backend/src/backup_tool/cli.py b/backend/src/backup_tool/cli.py new file mode 100644 index 0000000..c4a4e2b --- /dev/null +++ b/backend/src/backup_tool/cli.py @@ -0,0 +1,953 @@ +"""Backup Tool v2 process-role command line.""" + +from __future__ import annotations + +import argparse +import asyncio +import base64 +import binascii +import json +import os +import socket +from collections.abc import Callable, Sequence +from contextlib import suppress +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from alembic import command +from alembic.config import Config +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import async_sessionmaker + +from backup_tool import __version__ +from backup_tool.config import Settings + +from .db.engine import assert_schema_current, create_engine +from .db.models import ( + Backup, + Base, + Execution, + Job, + Repository, + RepositoryDataKeyEpoch, + Source, +) +from .repository import ( + begin_key_rotation, + finish_key_rotation, + inspect_repository, + install_signing_key, + load_signing_key, + reconcile_key_rotations, + replace_active_data_key, +) +from .scheduler import run_scheduler +from .security.recovery_bundle import ( + RecoveryBundleError, + decrypt_bundle, + encrypt_bundle, + read_bundle_file, + read_passphrase_fd, + write_bundle_exclusive, +) +from .security.repository_crypto import ( + RepositoryKeyError, + create_data_key, + install_data_key, + load_data_key, +) +from .web import run_web +from .worker import run_worker + +RoleHandler = Callable[[Settings], int] +DATABASE_ROLES = ("web", "scheduler", "worker", "admin") + + +def _placeholder_role(_settings: Settings) -> int: + return 0 + + +ROLE_HANDLERS: dict[str, RoleHandler] = { + "web": run_web, + "scheduler": run_scheduler, + "worker": run_worker, + "admin": _placeholder_role, +} + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="backup-tool") + parser.add_argument("--version", action="version", version=__version__) + subparsers = parser.add_subparsers(dest="role", required=True) + for role in DATABASE_ROLES: + role_parser = subparsers.add_parser(role, help=f"run the {role} role") + if role == "admin": + admin_parsers = role_parser.add_subparsers(dest="admin_command") + repository_key = admin_parsers.add_parser("repository-key") + repository_key_parsers = repository_key.add_subparsers(dest="repository_key_command") + rotate = repository_key_parsers.add_parser("rotate") + rotate.add_argument("--repository-id", required=True) + recovery = admin_parsers.add_parser("recovery") + recovery_parsers = recovery.add_subparsers(dest="recovery_command", required=True) + export = recovery_parsers.add_parser("export") + export.add_argument("--output", required=True, type=Path) + export.add_argument("--passphrase-fd", required=True, type=int) + validate = recovery_parsers.add_parser("validate") + validate.add_argument("--input", required=True, type=Path) + validate.add_argument("--passphrase-fd", required=True, type=int) + import_ = recovery_parsers.add_parser("import") + import_.add_argument("--input", required=True, type=Path) + import_.add_argument("--passphrase-fd", required=True, type=int) + migrate = subparsers.add_parser("migrate", help="manage the metadata schema") + migrate.add_argument("action", choices=("upgrade", "downgrade", "current")) + health = subparsers.add_parser("health", help="check runtime role readiness") + health.add_argument("health_role", choices=("web", "scheduler", "worker")) + return parser + + +def build_alembic_config(settings: Settings) -> Config: + default_root = Path(__file__).resolve().parents[2] + backend_root = Path(os.environ.get("BACKUP_TOOL_ALEMBIC_ROOT", default_root)) + migration = Config(backend_root / "alembic.ini") + migration.set_main_option("sqlalchemy.url", settings.database_url) + return migration + + +def require_current_schema(settings: Settings) -> None: + async def check() -> None: + engine = create_engine(settings) + try: + await assert_schema_current(engine, build_alembic_config(settings)) + finally: + await engine.dispose() + + asyncio.run(check()) + + +def run_role_healthcheck(settings: Settings, role: str) -> int: + """Verify the selected isolated runtime role is ready to serve work.""" + if role != "web": + from backup_tool.observability.health import check_role_readiness + + asyncio.run(check_role_readiness(settings, role)) + return 0 + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client: + client.settimeout(2) + client.connect(str(settings.web_socket_path)) + request = b"GET /readyz HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n" + client.sendall(request) + status_line = client.recv(64).split(b"\r\n", 1)[0] + if status_line != b"HTTP/1.1 200 OK": + raise RuntimeError("web role is not ready") + return 0 + + +def run_migration(action: str, settings: Settings) -> int: + migration = build_alembic_config(settings) + if action == "upgrade": + command.upgrade(migration, "head") + elif action == "downgrade": + command.downgrade(migration, "base") + else: + command.current(migration) + return 0 + + +def load_settings() -> Settings: + return Settings() # type: ignore[call-arg] + + +async def rotate_repository_key( + settings: Settings, + repository_id: str, + *, + after_database_commit: Callable[[], None] | None = None, +) -> dict[str, str]: + """Journal a key rotation so a crash always has a deterministic recovery path.""" + engine = create_engine(settings) + sessions = async_sessionmaker(engine, expire_on_commit=False) + new_key_path: Path | None = None + database_committed = False + journal_started = False + root: Path | None = None + old_key_id = "" + new_key_id = "" + try: + async with sessions() as db: + await reconcile_key_rotations(settings, db) + await db.rollback() + async with db.begin(): + repository = await db.scalar( + select(Repository).where(Repository.id == repository_id).with_for_update() + ) + if repository is None: + raise ValueError("repository was not found") + if repository.encryption != "aes-256-gcm" or repository.active_data_key_id is None: + raise ValueError("repository encryption is not enabled") + inspected = inspect_repository(settings, Path(repository.root)) + root = inspected.root + if ( + inspected.encryption != "aes-256-gcm" + or inspected.data_key_id != repository.active_data_key_id + ): + raise ValueError("repository encryption metadata is invalid") + epochs = list( + ( + await db.scalars( + select(RepositoryDataKeyEpoch).where( + RepositoryDataKeyEpoch.repository_id == repository.id + ) + ) + ).all() + ) + active = [epoch for epoch in epochs if epoch.state == "active"] + if len(active) != 1 or active[0].key_id != repository.active_data_key_id: + raise ValueError("repository key epochs are invalid") + old_key_id = active[0].key_id + new_key_id, new_key_path = create_data_key(settings, inspected.repository_id) + begin_key_rotation( + inspected.root, + repository.id, + inspected.repository_id, + old_key_id, + new_key_id, + ) + journal_started = True + active[0].state = "retired" + active[0].retired_at = datetime.now(UTC) + db.add( + RepositoryDataKeyEpoch( + repository_id=repository.id, + key_id=new_key_id, + state="active", + ) + ) + repository.active_data_key_id = new_key_id + database_committed = True + if after_database_commit is not None: + after_database_commit() + if root is None: + raise ValueError("repository rotation metadata is invalid") + replace_active_data_key(root, old_key_id, new_key_id) + finish_key_rotation(root) + journal_started = False + return { + "repository_id": repository_id, + "retired_key_id": old_key_id, + "active_key_id": new_key_id, + } + finally: + if not database_committed: + if journal_started and root is not None: + with suppress(ValueError): + finish_key_rotation(root) + if new_key_path is not None: + new_key_path.unlink(missing_ok=True) + await engine.dispose() + + +async def recovery_export_payload(settings: Settings) -> dict[str, Any]: + """Collect the current repository trust catalog and offline key material.""" + engine = create_engine(settings) + sessions = async_sessionmaker(engine, expire_on_commit=False) + try: + async with sessions() as db: + await reconcile_key_rotations(settings, db) + await db.rollback() + repositories = list( + (await db.scalars(select(Repository).order_by(Repository.id))).all() + ) + catalog_repositories: list[dict[str, Any]] = [] + key_records: list[dict[str, Any]] = [] + for repository in repositories: + inspected = inspect_repository(settings, Path(repository.root)) + if inspected.encryption != repository.encryption or ( + repository.encryption == "aes-256-gcm" + and repository.active_data_key_id != inspected.data_key_id + ): + raise RecoveryBundleError("recovery bundle export is unavailable") + private_key = load_signing_key( + settings, + inspected.repository_id, + repository.signing_key_id, + repository.signing_public_key, + ) + epochs = list( + ( + await db.scalars( + select(RepositoryDataKeyEpoch) + .where(RepositoryDataKeyEpoch.repository_id == repository.id) + .order_by(RepositoryDataKeyEpoch.key_id) + ) + ).all() + ) + active = [epoch for epoch in epochs if epoch.state == "active"] + if repository.encryption == "aes-256-gcm": + if ( + repository.active_data_key_id is None + or len(active) != 1 + or active[0].key_id != repository.active_data_key_id + ): + raise RecoveryBundleError("recovery bundle export is unavailable") + elif epochs: + raise RecoveryBundleError("recovery bundle export is unavailable") + data_keys: list[dict[str, str]] = [] + for epoch in epochs: + data_keys.append( + { + "key_id": epoch.key_id, + "key": base64.b64encode( + load_data_key(settings, inspected.repository_id, epoch.key_id) + ).decode("ascii"), + } + ) + catalog_repositories.append( + { + "active_data_key_id": repository.active_data_key_id, + "compression": repository.compression, + "data_key_epochs": [ + { + "key_id": epoch.key_id, + "retired_at": ( + epoch.retired_at.isoformat() + if epoch.retired_at is not None + else None + ), + "state": epoch.state, + } + for epoch in epochs + ], + "database_id": repository.id, + "encryption": repository.encryption, + "format_version": repository.format_version, + "name": repository.name, + "repository_id": inspected.repository_id, + "root": repository.root, + "signing_key_id": repository.signing_key_id, + "signing_public_key": repository.signing_public_key, + "state": repository.state, + } + ) + key_records.append( + { + "data_keys": data_keys, + "repository_id": inspected.repository_id, + "signing_private_key": base64.b64encode( + private_key.private_bytes( + serialization.Encoding.Raw, + serialization.PrivateFormat.Raw, + serialization.NoEncryption(), + ) + ).decode("ascii"), + } + ) + sources = list((await db.scalars(select(Source).order_by(Source.id))).all()) + jobs = list((await db.scalars(select(Job).order_by(Job.id))).all()) + executions = list((await db.scalars(select(Execution).order_by(Execution.id))).all()) + backups = list((await db.scalars(select(Backup).order_by(Backup.id))).all()) + if any(source.secret_refs for source in sources): + raise RecoveryBundleError("recovery bundle export is unavailable") + return { + "catalog": { + "backups": [ + { + "created_at": backup.created_at.isoformat(), + "data_key_id": backup.data_key_id, + "execution_id": backup.execution_id, + "id": backup.id, + "integrity": backup.integrity, + "logical_bytes": backup.logical_bytes, + "manifest_digest": backup.manifest_digest, + "manifest_id": backup.manifest_id, + "parent_backup_id": backup.parent_backup_id, + "pinned": backup.pinned, + "stored_bytes": backup.stored_bytes, + "tombstoned_at": ( + backup.tombstoned_at.isoformat() + if backup.tombstoned_at is not None + else None + ), + } + for backup in backups + ], + "executions": [ + { + "attempt": execution.attempt, + "id": execution.id, + "job_id": execution.job_id, + "progress": execution.progress, + "revision": execution.revision, + "trigger": execution.trigger, + } + for execution in executions + ], + "format": "backup-tool-recovery-catalog", + "jobs": [ + { + "allow_empty": job.allow_empty, + "exclusions": job.exclusions, + "id": job.id, + "name": job.name, + "repository_id": job.repository_id, + "requested_mode": job.requested_mode, + "retention": job.retention, + "source_id": job.source_id, + } + for job in jobs + ], + "repositories": catalog_repositories, + "sources": [ + { + "id": source.id, + "kind": source.kind, + "name": source.name, + "public_config": source.public_config, + } + for source in sources + ], + "version": 2, + }, + "keys": key_records, + } + except (OSError, RepositoryKeyError, ValueError) as error: + if isinstance(error, RecoveryBundleError): + raise + raise RecoveryBundleError("recovery bundle export is unavailable") from error + finally: + await engine.dispose() + + +def _validate_legacy_recovery_payload(payload: dict[str, Any]) -> int: + """Validate the authenticated export shape without exposing key material.""" + try: + if set(payload) != {"catalog", "keys"}: + raise ValueError + catalog = payload["catalog"] + keys = payload["keys"] + if ( + not isinstance(catalog, dict) + or set(catalog) != {"format", "repositories", "version"} + or catalog["format"] != "backup-tool-recovery-catalog" + or catalog["version"] != 1 + or not isinstance(catalog["repositories"], list) + or not isinstance(keys, list) + or len(catalog["repositories"]) != len(keys) + ): + raise ValueError + seen: set[str] = set() + for repository, key_record in zip(catalog["repositories"], keys, strict=True): + if not isinstance(repository, dict) or not isinstance(key_record, dict): + raise ValueError + required_repository = { + "active_data_key_id", + "compression", + "data_key_epochs", + "database_id", + "encryption", + "format_version", + "name", + "repository_id", + "root", + "signing_key_id", + "signing_public_key", + "state", + } + if set(repository) != required_repository or set(key_record) != { + "data_keys", + "repository_id", + "signing_private_key", + }: + raise ValueError + repository_id = repository["repository_id"] + if ( + not isinstance(repository_id, str) + or not repository_id + or repository_id in seen + or key_record["repository_id"] != repository_id + or not isinstance(repository["data_key_epochs"], list) + or not isinstance(key_record["data_keys"], list) + ): + raise ValueError + seen.add(repository_id) + private_key = base64.b64decode(key_record["signing_private_key"], validate=True) + public_key = bytes.fromhex(repository["signing_public_key"]) + if len(private_key) != 32 or len(public_key) != 32: + raise ValueError + signing_key = Ed25519PrivateKey.from_private_bytes(private_key) + derived_public = signing_key.public_key().public_bytes( + serialization.Encoding.Raw, + serialization.PublicFormat.Raw, + ) + if derived_public != public_key: + raise ValueError + epochs = repository["data_key_epochs"] + data_keys = key_record["data_keys"] + if len(epochs) != len(data_keys): + raise ValueError + epoch_ids: set[str] = set() + for epoch, data_key in zip(epochs, data_keys, strict=True): + if ( + not isinstance(epoch, dict) + or set(epoch) != {"key_id", "retired_at", "state"} + or not isinstance(data_key, dict) + or set(data_key) != {"key", "key_id"} + or not isinstance(epoch["key_id"], str) + or epoch["key_id"] in epoch_ids + or data_key["key_id"] != epoch["key_id"] + or len(base64.b64decode(data_key["key"], validate=True)) != 32 + ): + raise ValueError + epoch_ids.add(epoch["key_id"]) + active = [epoch for epoch in epochs if epoch["state"] == "active"] + if repository["encryption"] == "aes-256-gcm": + if len(active) != 1 or active[0]["key_id"] != repository["active_data_key_id"]: + raise ValueError + elif ( + repository["encryption"] != "none" + or epochs + or repository["active_data_key_id"] is not None + ): + raise ValueError + return len(seen) + except (binascii.Error, KeyError, TypeError, ValueError) as error: + raise RecoveryBundleError("recovery bundle is invalid") from error + + +def _validated_recovery_catalog( + payload: dict[str, Any], +) -> tuple[dict[str, Any], list[dict[str, Any]]]: + """Validate a v2 catalog before any filesystem or metadata mutation.""" + try: + if set(payload) != {"catalog", "keys"}: + raise ValueError + catalog = payload["catalog"] + keys = payload["keys"] + required_catalog = { + "backups", + "executions", + "format", + "jobs", + "repositories", + "sources", + "version", + } + if ( + not isinstance(catalog, dict) + or set(catalog) != required_catalog + or catalog["format"] != "backup-tool-recovery-catalog" + or catalog["version"] != 2 + or not isinstance(keys, list) + or any( + not isinstance(catalog[name], list) + for name in required_catalog - {"format", "version"} + ) + or len(catalog["repositories"]) != len(keys) + ): + raise ValueError + repositories = catalog["repositories"] + key_by_repository: dict[str, dict[str, Any]] = {} + repository_by_id: dict[str, dict[str, Any]] = {} + canonical_ids: set[str] = set() + for repository, key_record in zip(repositories, keys, strict=True): + if not isinstance(repository, dict) or not isinstance(key_record, dict): + raise ValueError + required_repository = { + "active_data_key_id", + "compression", + "data_key_epochs", + "database_id", + "encryption", + "format_version", + "name", + "repository_id", + "root", + "signing_key_id", + "signing_public_key", + "state", + } + if set(repository) != required_repository or set(key_record) != { + "data_keys", + "repository_id", + "signing_private_key", + }: + raise ValueError + database_id = repository["database_id"] + canonical_id = repository["repository_id"] + if ( + not isinstance(database_id, str) + or not isinstance(canonical_id, str) + or not database_id + or not canonical_id + or database_id in repository_by_id + or canonical_id in canonical_ids + or key_record["repository_id"] != canonical_id + or not isinstance(repository["data_key_epochs"], list) + or not isinstance(key_record["data_keys"], list) + ): + raise ValueError + private_key = base64.b64decode(key_record["signing_private_key"], validate=True) + public_key = bytes.fromhex(repository["signing_public_key"]) + signing_key = Ed25519PrivateKey.from_private_bytes(private_key) + if ( + len(public_key) != 32 + or signing_key.public_key().public_bytes( + serialization.Encoding.Raw, serialization.PublicFormat.Raw + ) + != public_key + ): + raise ValueError + epochs = repository["data_key_epochs"] + data_keys = key_record["data_keys"] + if len(epochs) != len(data_keys): + raise ValueError + epoch_ids: set[str] = set() + for epoch, data_key in zip(epochs, data_keys, strict=True): + if ( + not isinstance(epoch, dict) + or set(epoch) != {"key_id", "retired_at", "state"} + or epoch.get("state") not in {"active", "retired"} + or not isinstance(epoch.get("key_id"), str) + or epoch["key_id"] in epoch_ids + or not isinstance(data_key, dict) + or set(data_key) != {"key", "key_id"} + or data_key["key_id"] != epoch["key_id"] + or len(base64.b64decode(data_key["key"], validate=True)) != 32 + ): + raise ValueError + epoch_ids.add(epoch["key_id"]) + active = [epoch for epoch in epochs if epoch["state"] == "active"] + if repository["encryption"] == "aes-256-gcm": + if len(active) != 1 or active[0]["key_id"] != repository["active_data_key_id"]: + raise ValueError + elif ( + repository["encryption"] != "none" + or epochs + or repository["active_data_key_id"] is not None + ): + raise ValueError + repository_by_id[database_id] = repository + canonical_ids.add(canonical_id) + key_by_repository[canonical_id] = key_record + source_ids = _catalog_ids(catalog["sources"], {"id", "kind", "name", "public_config"}) + job_ids = _catalog_ids( + catalog["jobs"], + { + "allow_empty", + "exclusions", + "id", + "name", + "repository_id", + "requested_mode", + "retention", + "source_id", + }, + ) + for job in catalog["jobs"]: + if job["source_id"] not in source_ids or job["repository_id"] not in repository_by_id: + raise ValueError + execution_ids = _catalog_ids( + catalog["executions"], {"attempt", "id", "job_id", "progress", "revision", "trigger"} + ) + for execution in catalog["executions"]: + if execution["job_id"] not in job_ids: + raise ValueError + backup_ids = _catalog_ids( + catalog["backups"], + { + "created_at", + "data_key_id", + "execution_id", + "id", + "integrity", + "logical_bytes", + "manifest_digest", + "manifest_id", + "parent_backup_id", + "pinned", + "stored_bytes", + "tombstoned_at", + }, + ) + for backup in catalog["backups"]: + if backup["execution_id"] not in execution_ids: + raise ValueError + _catalog_datetime(backup["created_at"]) + _catalog_datetime(backup["tombstoned_at"], allow_none=True) + if ( + backup["parent_backup_id"] is not None + and backup["parent_backup_id"] not in backup_ids + ): + raise ValueError + return ( + catalog, + [key_by_repository[repository["repository_id"]] for repository in repositories], + ) + except (binascii.Error, KeyError, TypeError, ValueError) as error: + raise RecoveryBundleError("recovery bundle is invalid") from error + + +def _catalog_ids(rows: list[Any], required: set[str]) -> set[str]: + identifiers: set[str] = set() + for row in rows: + if not isinstance(row, dict) or set(row) != required or not isinstance(row.get("id"), str): + raise ValueError + if not row["id"] or row["id"] in identifiers: + raise ValueError + identifiers.add(row["id"]) + return identifiers + + +def _catalog_datetime(value: object, *, allow_none: bool = False) -> datetime | None: + if value is None and allow_none: + return None + if not isinstance(value, str): + raise ValueError + parsed = datetime.fromisoformat(value.replace("Z", "+00")) + if parsed.tzinfo is None: + raise ValueError + return parsed.astimezone(UTC) + + +def _validate_recovery_payload(payload: dict[str, Any]) -> int: + catalog = payload.get("catalog") + if isinstance(catalog, dict) and catalog.get("version") == 1: + return _validate_legacy_recovery_payload(payload) + validated_catalog, _keys = _validated_recovery_catalog(payload) + return len(validated_catalog["repositories"]) + + +def _install_or_verify_recovery_keys( + settings: Settings, + repository: dict[str, Any], + key_record: dict[str, Any], + installed_paths: list[Path], +) -> None: + inspected = inspect_repository(settings, Path(repository["root"])) + if ( + inspected.repository_id != repository["repository_id"] + or inspected.encryption != repository["encryption"] + or inspected.data_key_id != repository["active_data_key_id"] + ): + raise RecoveryBundleError("recovery bundle is invalid") + private_key = base64.b64decode(key_record["signing_private_key"], validate=True) + signing_path = settings.data_dir / "repository-keys" / f"{inspected.repository_id}.ed25519" + if signing_path.exists() or signing_path.is_symlink(): + loaded = load_signing_key( + settings, + inspected.repository_id, + repository["signing_key_id"], + repository["signing_public_key"], + ) + if ( + loaded.private_bytes( + serialization.Encoding.Raw, + serialization.PrivateFormat.Raw, + serialization.NoEncryption(), + ) + != private_key + ): + raise RecoveryBundleError("recovery destination has conflicting keys") + else: + installed_paths.append( + install_signing_key( + settings, + inspected.repository_id, + repository["signing_key_id"], + repository["signing_public_key"], + private_key, + ) + ) + for data_key in key_record["data_keys"]: + key_value = base64.b64decode(data_key["key"], validate=True) + key_path = ( + settings.data_dir + / "repository-data-keys" + / f"{inspected.repository_id}.{data_key['key_id']}.key" + ) + if key_path.exists() or key_path.is_symlink(): + if load_data_key(settings, inspected.repository_id, data_key["key_id"]) != key_value: + raise RecoveryBundleError("recovery destination has conflicting keys") + else: + installed_paths.append( + install_data_key(settings, inspected.repository_id, data_key["key_id"], key_value) + ) + + +async def import_recovery_payload( + settings: Settings, + payload: dict[str, Any], + *, + after_key_install: Callable[[], None] | None = None, +) -> int: + """Install a complete authenticated catalog into an empty, migrated database.""" + catalog, key_records = _validated_recovery_catalog(payload) + engine = create_engine(settings) + sessions = async_sessionmaker(engine, expire_on_commit=False) + installed_paths: list[Path] = [] + completed = False + try: + async with sessions() as db: + for table in Base.metadata.sorted_tables: + if await db.scalar(select(func.count()).select_from(table)): + raise RecoveryBundleError("recovery destination is not empty") + await db.rollback() + for repository, key_record in zip(catalog["repositories"], key_records, strict=True): + _install_or_verify_recovery_keys(settings, repository, key_record, installed_paths) + if after_key_install is not None: + after_key_install() + async with db.begin(): + for repository in catalog["repositories"]: + db.add( + Repository( + id=repository["database_id"], + name=repository["name"], + root=repository["root"], + format_version=repository["format_version"], + compression=repository["compression"], + encryption=repository["encryption"], + signing_key_id=repository["signing_key_id"], + signing_public_key=repository["signing_public_key"], + active_data_key_id=repository["active_data_key_id"], + state=repository["state"], + ) + ) + for repository in catalog["repositories"]: + for epoch in repository["data_key_epochs"]: + db.add( + RepositoryDataKeyEpoch( + repository_id=repository["database_id"], + key_id=epoch["key_id"], + state=epoch["state"], + ) + ) + await db.flush() + for source in catalog["sources"]: + db.add( + Source( + id=source["id"], + name=source["name"], + kind=source["kind"], + public_config=source["public_config"], + secret_refs=[], + state="unavailable", + ) + ) + await db.flush() + for job in catalog["jobs"]: + db.add( + Job( + id=job["id"], + name=job["name"], + source_id=job["source_id"], + repository_id=job["repository_id"], + requested_mode=job["requested_mode"], + exclusions=job["exclusions"], + retention=job["retention"], + enabled=False, + allow_empty=job["allow_empty"], + state="archived", + ) + ) + await db.flush() + for execution in catalog["executions"]: + db.add( + Execution( + id=execution["id"], + job_id=execution["job_id"], + schedule_id=None, + trigger=execution["trigger"], + state="committed", + attempt=execution["attempt"], + progress=execution["progress"], + revision=execution["revision"], + ) + ) + await db.flush() + for backup in catalog["backups"]: + db.add( + Backup( + id=backup["id"], + execution_id=backup["execution_id"], + parent_backup_id=None, + manifest_id=backup["manifest_id"], + manifest_digest=backup["manifest_digest"], + logical_bytes=backup["logical_bytes"], + stored_bytes=backup["stored_bytes"], + integrity=backup["integrity"], + data_key_id=backup["data_key_id"], + pinned=backup["pinned"], + created_at=_catalog_datetime(backup["created_at"]), + tombstoned_at=_catalog_datetime( + backup["tombstoned_at"], allow_none=True + ), + ) + ) + await db.flush() + for backup in catalog["backups"]: + if backup["parent_backup_id"] is not None: + imported = await db.get(Backup, backup["id"]) + if imported is None: + raise RecoveryBundleError("recovery bundle is invalid") + imported.parent_backup_id = backup["parent_backup_id"] + completed = True + return len(catalog["repositories"]) + except (OSError, RepositoryKeyError, ValueError) as error: + if isinstance(error, RecoveryBundleError): + raise + raise RecoveryBundleError("recovery import failed") from error + finally: + await engine.dispose() + if not completed: + for path in installed_paths: + path.unlink(missing_ok=True) + + +def run_admin(settings: Settings, args: argparse.Namespace) -> int: + if ( + getattr(args, "admin_command", None) == "repository-key" + and getattr(args, "repository_key_command", None) == "rotate" + ): + result = asyncio.run(rotate_repository_key(settings, args.repository_id)) + print(json.dumps(result, sort_keys=True)) + return 0 + if getattr(args, "admin_command", None) == "recovery": + passphrase = read_passphrase_fd(args.passphrase_fd) + if args.recovery_command == "export": + payload = asyncio.run(recovery_export_payload(settings)) + write_bundle_exclusive(args.output, encrypt_bundle(payload, passphrase)) + print(json.dumps({"repositories": len(payload["keys"]), "status": "exported"})) + return 0 + if args.recovery_command == "validate": + payload = decrypt_bundle(read_bundle_file(args.input), passphrase) + validation_result: dict[str, object] = { + "repositories": _validate_recovery_payload(payload), + "status": "valid", + } + print(json.dumps(validation_result)) + return 0 + if args.recovery_command == "import": + payload = decrypt_bundle(read_bundle_file(args.input), passphrase) + imported = asyncio.run(import_recovery_payload(settings, payload)) + print(json.dumps({"repositories": imported, "status": "imported"})) + return 0 + raise ValueError("admin command is invalid") + + +def main(argv: Sequence[str] | None = None, *, settings: Settings | None = None) -> int: + args = build_parser().parse_args(argv) + runtime_settings = settings or load_settings() + if args.role == "migrate": + return run_migration(args.action, runtime_settings) + if args.role == "health": + return run_role_healthcheck(runtime_settings, args.health_role) + require_current_schema(runtime_settings) + if args.role == "admin": + return run_admin(runtime_settings, args) + return ROLE_HANDLERS[args.role](runtime_settings) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/src/backup_tool/clock.py b/backend/src/backup_tool/clock.py new file mode 100644 index 0000000..2753b6f --- /dev/null +++ b/backend/src/backup_tool/clock.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Protocol + + +class Clock(Protocol): + def now(self) -> datetime: ... + + +class SystemClock: + def now(self) -> datetime: + return datetime.now(UTC) diff --git a/backend/src/backup_tool/config.py b/backend/src/backup_tool/config.py new file mode 100644 index 0000000..c119a6d --- /dev/null +++ b/backend/src/backup_tool/config.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +import os +import stat +from pathlib import Path +from typing import Literal, Self +from urllib.parse import urlsplit + +from pydantic import Field, field_validator, model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict +from sqlalchemy.engine import make_url + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_prefix="BACKUP_TOOL_", + extra="forbid", + case_sensitive=False, + ) + + data_dir: Path = Path("/var/lib/backup-tool") + web_socket_path: Path = Path("/tmp/backup-tool-web.sock") + database_url: str = "sqlite+aiosqlite:////var/lib/backup-tool/metadata.db" + repository_roots: tuple[Path, ...] + local_source_roots: tuple[Path, ...] + restore_roots: tuple[Path, ...] + master_key_file: Path + public_base_url: str = "http://127.0.0.1:8000" + bootstrap_secret: str | None = Field(default=None, min_length=16) + session_ttl_seconds: int = Field(default=28_800, ge=60, le=2_592_000) + cors_origins: tuple[str, ...] = () + worker_concurrency: Literal[1] = 1 + notification_delivery_lease_seconds: int = Field(default=60, ge=5, le=3600) + notification_connect_timeout_seconds: float = Field(default=5.0, gt=0, le=60) + notification_read_timeout_seconds: float = Field(default=10.0, gt=0, le=120) + notification_max_attempts: int = Field(default=5, ge=1, le=20) + notification_retry_cap_seconds: int = Field(default=3600, ge=1, le=86_400) + notification_global_rate_per_minute: int = Field(default=120, ge=1, le=10_000) + notification_default_rate_per_minute: int = Field(default=60, ge=1, le=10_000) + notification_max_webhook_body_bytes: int = Field(default=65_536, ge=256, le=1_048_576) + notification_max_response_bytes: int = Field(default=16_384, ge=256, le=1_048_576) + ssh_connect_timeout_seconds: float = Field(default=5.0, gt=0, le=60) + ssh_operation_timeout_seconds: float = Field(default=10.0, gt=0, le=120) + ssh_read_chunk_bytes: int = Field(default=1_048_576, ge=4096, le=8_388_608) + ssh_list_read_aheads: int = Field(default=32, ge=1, le=256) + ssh_max_entries: int = Field(default=1_000_000, ge=1, le=10_000_000) + ssh_max_traversal_depth: int = Field(default=128, ge=1, le=1024) + min_free_bytes: int = Field(default=1_073_741_824, ge=0) + min_free_percent: int = Field(default=5, gt=0, lt=100) + sqlite_busy_timeout_ms: int = Field(default=5_000, ge=1_000, le=120_000) + log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO" + + @field_validator( + "data_dir", + "master_key_file", + "web_socket_path", + mode="before", + ) + @classmethod + def validate_absolute_path(cls, value: object) -> Path: + path = Path(str(value)).expanduser() + if not path.is_absolute(): + raise ValueError("path must be absolute") + return path.resolve() + + @field_validator("repository_roots", "local_source_roots", "restore_roots", mode="before") + @classmethod + def validate_roots(cls, value: object) -> tuple[Path, ...]: + if isinstance(value, (str, Path)): + raw_roots = [value] + elif isinstance(value, (list, tuple, set)): + raw_roots = list(value) + else: + raise ValueError("allowlist roots must be a sequence of paths") + roots: list[Path] = [] + for raw_root in raw_roots: + root = Path(str(raw_root)).expanduser() + if not root.is_absolute(): + raise ValueError("allowlist roots must be absolute") + roots.append(root.resolve()) + if not roots: + raise ValueError("at least one allowlist root is required") + if len(set(roots)) != len(roots): + raise ValueError("allowlist roots must be unique") + return tuple(roots) + + @field_validator("database_url") + @classmethod + def validate_database_url(cls, value: str) -> str: + url = make_url(value) + if url.drivername != "sqlite+aiosqlite": + raise ValueError("v2 requires sqlite+aiosqlite") + if not url.database or not Path(url.database).is_absolute(): + raise ValueError("SQLite database path must be absolute") + return value + + @field_validator("public_base_url") + @classmethod + def validate_public_base_url(cls, value: str) -> str: + parsed = urlsplit(value) + if parsed.scheme not in {"http", "https"} or not parsed.hostname: + raise ValueError("public base URL must be an absolute HTTP(S) URL") + if parsed.query or parsed.fragment: + raise ValueError("public base URL must not contain query or fragment") + return value.rstrip("/") + + @model_validator(mode="after") + def validate_master_key(self) -> Self: + key = self.master_key_file + if not key.is_file(): + raise ValueError("master key file must exist and be a regular file") + key_stat = key.stat() + if hasattr(os, "getuid") and key_stat.st_uid != os.getuid(): + raise ValueError("master key file must be owned by the service user") + mode = stat.S_IMODE(key_stat.st_mode) + if mode != 0o600: + raise ValueError("master key file permissions must be 0600") + if key_stat.st_size < 32: + raise ValueError("master key file must contain at least 32 bytes") + return self + + @property + def setup_requires_bootstrap(self) -> bool: + hostname = urlsplit(self.public_base_url).hostname + return hostname not in {"127.0.0.1", "::1", "localhost"} + + @property + def database_path(self) -> Path: + database = make_url(self.database_url).database + if database is None: # pragma: no cover - guarded by validation + raise RuntimeError("database path is unavailable") + return Path(database).resolve() diff --git a/backend/src/backup_tool/db/__init__.py b/backend/src/backup_tool/db/__init__.py new file mode 100644 index 0000000..7435750 --- /dev/null +++ b/backend/src/backup_tool/db/__init__.py @@ -0,0 +1,4 @@ +from .engine import SchemaNotCurrentError, assert_schema_current, create_engine +from .models import Base + +__all__ = ["Base", "SchemaNotCurrentError", "assert_schema_current", "create_engine"] diff --git a/backend/src/backup_tool/db/engine.py b/backend/src/backup_tool/db/engine.py new file mode 100644 index 0000000..f0bd260 --- /dev/null +++ b/backend/src/backup_tool/db/engine.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from typing import Any + +from alembic.config import Config +from alembic.migration import MigrationContext +from alembic.script import ScriptDirectory +from sqlalchemy import event +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine + +from backup_tool.config import Settings + +from . import sqlite as sqlite_runtime + + +class SchemaNotCurrentError(RuntimeError): + pass + + +def create_engine(settings: Settings) -> AsyncEngine: + engine = create_async_engine( + settings.database_url, + pool_pre_ping=True, + connect_args=sqlite_runtime.sqlite_connect_args(settings.sqlite_busy_timeout_ms), + ) + + @event.listens_for(engine.sync_engine, "connect") + def configure_sqlite(dbapi_connection: Any, _connection_record: Any) -> None: + sqlite_runtime.configure_sqlite_connection(dbapi_connection) + + return engine + + +async def assert_schema_current(engine: AsyncEngine, alembic_config: Config) -> None: + expected = ScriptDirectory.from_config(alembic_config).get_current_head() + + def get_current_revision(connection: Connection) -> str | None: + return MigrationContext.configure(connection).get_current_revision() + + async with engine.connect() as connection: + current = await connection.run_sync(get_current_revision) + if current != expected: + raise SchemaNotCurrentError( + f"database schema is not current: expected {expected!r}, found {current!r}" + ) diff --git a/backend/src/backup_tool/db/models.py b/backend/src/backup_tool/db/models.py new file mode 100644 index 0000000..bba9905 --- /dev/null +++ b/backend/src/backup_tool/db/models.py @@ -0,0 +1,451 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from sqlalchemy import ( + JSON, + Boolean, + CheckConstraint, + ForeignKey, + Index, + Integer, + LargeBinary, + MetaData, + String, + Text, + UniqueConstraint, + func, + text, +) +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + +from backup_tool.ids import new_uuid7 + +from .types import UTCDateTime + +NAMING_CONVENTION = { + "ix": "ix_%(column_0_label)s", + "uq": "uq_%(table_name)s_%(column_0_name)s", + "ck": "ck_%(table_name)s_%(constraint_name)s", + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s", +} + + +class Base(DeclarativeBase): + metadata = MetaData(naming_convention=NAMING_CONVENTION) + + +class IdentityMixin: + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(new_uuid7())) + + +class TimestampMixin: + created_at: Mapped[datetime] = mapped_column( + UTCDateTime(), nullable=False, server_default=func.current_timestamp() + ) + updated_at: Mapped[datetime] = mapped_column( + UTCDateTime(), + nullable=False, + server_default=func.current_timestamp(), + onupdate=func.current_timestamp(), + ) + + +class User(IdentityMixin, TimestampMixin, Base): + __tablename__ = "users" + username: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) + password_hash: Mapped[str] = mapped_column(Text, nullable=False) + state: Mapped[str] = mapped_column(String(32), nullable=False, default="active") + __table_args__ = (CheckConstraint("state IN ('active','disabled')", name="state"),) + + +class ApiToken(IdentityMixin, TimestampMixin, Base): + __tablename__ = "api_tokens" + owner_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="RESTRICT")) + token_hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True) + scopes: Mapped[list[str]] = mapped_column(JSON, nullable=False) + expires_at: Mapped[datetime | None] = mapped_column(UTCDateTime()) + revoked_at: Mapped[datetime | None] = mapped_column(UTCDateTime()) + __table_args__ = (Index("ix_api_tokens_owner_id", "owner_id"),) + + +class Session(IdentityMixin, TimestampMixin, Base): + __tablename__ = "sessions" + user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="RESTRICT")) + expires_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False) + revoked_at: Mapped[datetime | None] = mapped_column(UTCDateTime()) + __table_args__ = (Index("ix_sessions_user_id", "user_id"),) + + +class Secret(IdentityMixin, TimestampMixin, Base): + __tablename__ = "secrets" + ciphertext: Mapped[bytes] = mapped_column(LargeBinary, nullable=False) + key_id: Mapped[str] = mapped_column(String(255), nullable=False) + purpose: Mapped[str] = mapped_column(String(64), nullable=False) + version: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + __table_args__ = (CheckConstraint("version > 0", name="version_positive"),) + + +class Repository(IdentityMixin, TimestampMixin, Base): + __tablename__ = "repositories" + name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) + root: Mapped[str] = mapped_column(Text, nullable=False, unique=True) + format_version: Mapped[int] = mapped_column(Integer, nullable=False) + compression: Mapped[str] = mapped_column(String(32), nullable=False) + encryption: Mapped[str] = mapped_column(String(32), nullable=False) + signing_key_id: Mapped[str] = mapped_column(String(64), nullable=False, server_default="") + signing_public_key: Mapped[str] = mapped_column(String(64), nullable=False, server_default="") + active_data_key_id: Mapped[str | None] = mapped_column(String(36)) + state: Mapped[str] = mapped_column(String(32), nullable=False, default="active") + __table_args__ = ( + CheckConstraint("format_version > 0", name="format_version_positive"), + CheckConstraint("state IN ('active','archived','unavailable')", name="state"), + ) + + +class RepositoryDataKeyEpoch(IdentityMixin, TimestampMixin, Base): + __tablename__ = "repository_data_key_epochs" + repository_id: Mapped[str] = mapped_column(ForeignKey("repositories.id", ondelete="RESTRICT")) + key_id: Mapped[str] = mapped_column(String(36), nullable=False) + state: Mapped[str] = mapped_column(String(16), nullable=False) + retired_at: Mapped[datetime | None] = mapped_column(UTCDateTime()) + __table_args__ = ( + UniqueConstraint("repository_id", "key_id", name="repository_key_epoch"), + CheckConstraint("state IN ('active','retired')", name="repository_data_key_epoch_state"), + Index("ix_repository_data_key_epochs_repository_id", "repository_id"), + Index( + "uq_repository_data_key_epochs_active", + "repository_id", + unique=True, + sqlite_where=text("state = 'active'"), + ), + ) + + +class Source(IdentityMixin, TimestampMixin, Base): + __tablename__ = "sources" + name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) + kind: Mapped[str] = mapped_column(String(32), nullable=False) + public_config: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) + secret_refs: Mapped[list[str]] = mapped_column(JSON, nullable=False) + state: Mapped[str] = mapped_column(String(32), nullable=False, default="active") + last_probe: Mapped[dict[str, Any] | None] = mapped_column(JSON) + __table_args__ = ( + CheckConstraint("kind IN ('local','ssh')", name="kind"), + CheckConstraint("state IN ('active','archived','unavailable')", name="state"), + ) + + +class Job(IdentityMixin, TimestampMixin, Base): + __tablename__ = "jobs" + name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) + source_id: Mapped[str] = mapped_column(ForeignKey("sources.id", ondelete="RESTRICT")) + repository_id: Mapped[str] = mapped_column(ForeignKey("repositories.id", ondelete="RESTRICT")) + requested_mode: Mapped[str] = mapped_column(String(32), nullable=False, default="incremental") + exclusions: Mapped[list[str]] = mapped_column(JSON, nullable=False) + retention: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) + enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + allow_empty: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + state: Mapped[str] = mapped_column(String(32), nullable=False, default="active") + __table_args__ = ( + CheckConstraint("requested_mode IN ('full','incremental')", name="requested_mode"), + CheckConstraint("state IN ('active','archived')", name="state"), + Index("ix_jobs_source_id", "source_id"), + Index("ix_jobs_repository_id", "repository_id"), + ) + + +class Schedule(IdentityMixin, TimestampMixin, Base): + __tablename__ = "schedules" + job_id: Mapped[str] = mapped_column( + ForeignKey("jobs.id", ondelete="RESTRICT"), nullable=False, unique=True + ) + cron: Mapped[str] = mapped_column(String(255), nullable=False) + timezone: Mapped[str] = mapped_column(String(255), nullable=False) + misfire_grace_seconds: Mapped[int] = mapped_column(Integer, nullable=False, default=900) + overlap_policy: Mapped[str] = mapped_column(String(32), nullable=False, default="prohibit") + enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + next_nominal_at: Mapped[datetime | None] = mapped_column(UTCDateTime()) + last_enqueue_outcome: Mapped[str | None] = mapped_column(String(64)) + __table_args__ = ( + CheckConstraint("misfire_grace_seconds >= 0", name="misfire_nonnegative"), + CheckConstraint("overlap_policy IN ('prohibit')", name="overlap_policy"), + ) + + +class Execution(IdentityMixin, TimestampMixin, Base): + __tablename__ = "executions" + job_id: Mapped[str] = mapped_column(ForeignKey("jobs.id", ondelete="RESTRICT")) + schedule_id: Mapped[str | None] = mapped_column(ForeignKey("schedules.id", ondelete="RESTRICT")) + nominal_run_at: Mapped[datetime | None] = mapped_column(UTCDateTime()) + trigger: Mapped[str] = mapped_column(String(32), nullable=False) + state: Mapped[str] = mapped_column(String(32), nullable=False, default="queued") + attempt: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + lease_owner: Mapped[str | None] = mapped_column(String(255)) + lease_expires_at: Mapped[datetime | None] = mapped_column(UTCDateTime()) + heartbeat_at: Mapped[datetime | None] = mapped_column(UTCDateTime()) + progress: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) + revision: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + reason_code: Mapped[str | None] = mapped_column(String(64)) + operator_message: Mapped[str | None] = mapped_column(Text) + started_at: Mapped[datetime | None] = mapped_column(UTCDateTime()) + completed_at: Mapped[datetime | None] = mapped_column(UTCDateTime()) + __table_args__ = ( + CheckConstraint("attempt > 0", name="attempt_positive"), + CheckConstraint( + "state IN ('queued','preparing','running','verifying','committed'," + "'cancelling','cancelled','failed')", + name="state", + ), + CheckConstraint("trigger IN ('manual','schedule','retry')", name="trigger"), + UniqueConstraint("schedule_id", "nominal_run_at", name="uq_execution_schedule_occurrence"), + Index("ix_executions_job_id", "job_id"), + Index("ix_executions_state_created", "state", "created_at"), + Index( + "uq_executions_active_job", + "job_id", + unique=True, + sqlite_where=text("state IN ('queued','preparing','running','verifying','cancelling')"), + ), + ) + + +class ExecutionEvent(Base): + __tablename__ = "execution_events" + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + execution_id: Mapped[str] = mapped_column( + ForeignKey("executions.id", ondelete="CASCADE"), nullable=False + ) + revision: Mapped[int] = mapped_column(Integer, nullable=False) + payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) + __table_args__ = ( + UniqueConstraint("execution_id", "revision", name="uq_execution_event_revision"), + ) + + +class Backup(IdentityMixin, Base): + __tablename__ = "backups" + execution_id: Mapped[str] = mapped_column( + ForeignKey("executions.id", ondelete="RESTRICT"), nullable=False, unique=True + ) + parent_backup_id: Mapped[str | None] = mapped_column( + ForeignKey("backups.id", ondelete="RESTRICT") + ) + manifest_id: Mapped[str] = mapped_column(String(36), nullable=False, unique=True) + manifest_digest: Mapped[str] = mapped_column(String(64), nullable=False, unique=True) + logical_bytes: Mapped[int] = mapped_column(Integer, nullable=False) + stored_bytes: Mapped[int] = mapped_column(Integer, nullable=False) + integrity: Mapped[str] = mapped_column(String(32), nullable=False, default="unverified") + data_key_id: Mapped[str | None] = mapped_column(String(36)) + pinned: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + tombstoned_at: Mapped[datetime | None] = mapped_column(UTCDateTime()) + created_at: Mapped[datetime] = mapped_column( + UTCDateTime(), nullable=False, server_default=func.current_timestamp() + ) + __table_args__ = ( + CheckConstraint("logical_bytes >= 0", name="logical_bytes_nonnegative"), + CheckConstraint("stored_bytes >= 0", name="stored_bytes_nonnegative"), + CheckConstraint( + "integrity IN ('unverified','verified','degraded','corrupt')", name="integrity" + ), + Index("ix_backups_created_at", "created_at"), + ) + + +class Restore(IdentityMixin, TimestampMixin, Base): + __tablename__ = "restores" + backup_id: Mapped[str] = mapped_column(ForeignKey("backups.id", ondelete="RESTRICT")) + destination: Mapped[str] = mapped_column(Text, nullable=False) + selection: Mapped[list[str]] = mapped_column(JSON, nullable=False) + dry_run: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False, server_default="0" + ) + overwrite_policy: Mapped[str] = mapped_column(String(32), nullable=False) + state: Mapped[str] = mapped_column(String(32), nullable=False, default="queued") + result: Mapped[dict[str, Any] | None] = mapped_column(JSON) + __table_args__ = ( + CheckConstraint("overwrite_policy IN ('fail','skip','replace')", name="overwrite_policy"), + CheckConstraint( + "state IN ('queued','running','committed','cancelled','failed')", name="state" + ), + Index("ix_restores_backup_id", "backup_id"), + ) + + +class AuditEvent(IdentityMixin, Base): + __tablename__ = "audit_events" + actor_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="RESTRICT")) + action: Mapped[str] = mapped_column(String(255), nullable=False) + resource_type: Mapped[str] = mapped_column(String(64), nullable=False) + resource_id: Mapped[str | None] = mapped_column(String(36)) + outcome: Mapped[str] = mapped_column(String(32), nullable=False) + request_id: Mapped[str] = mapped_column(String(36), nullable=False) + details: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) + created_at: Mapped[datetime] = mapped_column( + UTCDateTime(), nullable=False, server_default=func.current_timestamp() + ) + __table_args__ = ( + CheckConstraint("outcome IN ('success','failure','denied')", name="outcome"), + Index("ix_audit_events_created_at", "created_at"), + Index("ix_audit_events_actor_id", "actor_id"), + ) + + +class NotificationSubscription(IdentityMixin, TimestampMixin, Base): + """A typed, mutable outbound channel configuration; credential material is never here.""" + + __tablename__ = "notification_subscriptions" + channel: Mapped[str] = mapped_column(String(32), nullable=False) + event_filters: Mapped[list[str]] = mapped_column(JSON, nullable=False) + destination_config: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) + # Retained only to make the migration from the unused v1-shaped table lossless. + # New webhook secrets live in NotificationSigningKey and SMTP secrets in settings. + secret_id: Mapped[str | None] = mapped_column(ForeignKey("secrets.id", ondelete="RESTRICT")) + state: Mapped[str] = mapped_column(String(32), nullable=False, default="active") + rate_limit_per_minute: Mapped[int] = mapped_column(Integer, nullable=False, default=60) + rate_tokens: Mapped[float] = mapped_column(nullable=False, default=60.0) + rate_updated_at: Mapped[datetime | None] = mapped_column(UTCDateTime()) + revision: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + __table_args__ = ( + CheckConstraint("channel IN ('webhook','email')", name="channel"), + CheckConstraint("state IN ('active','disabled','archived')", name="state"), + CheckConstraint("rate_limit_per_minute > 0", name="rate_positive"), + CheckConstraint("rate_tokens >= 0", name="rate_tokens_nonnegative"), + CheckConstraint("revision > 0", name="revision_positive"), + ) + + +class NotificationEvent(IdentityMixin, Base): + """Immutable canonical outbox envelope, created in the source mutation transaction.""" + + __tablename__ = "notification_events" + type: Mapped[str] = mapped_column(String(96), nullable=False) + schema_version: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + occurred_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False) + correlation_id: Mapped[str] = mapped_column(String(36), nullable=False) + severity: Mapped[str] = mapped_column(String(16), nullable=False) + resource_refs: Mapped[dict[str, str]] = mapped_column(JSON, nullable=False) + payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) + canonical_envelope: Mapped[str] = mapped_column(Text, nullable=False) + deduplication_key: Mapped[str | None] = mapped_column(String(255), unique=True) + __table_args__ = ( + CheckConstraint("schema_version = 1", name="schema_version"), + CheckConstraint("severity IN ('info','warning','error','security')", name="severity"), + Index("ix_notification_events_type_occurred", "type", "occurred_at"), + ) + + +class NotificationDelivery(IdentityMixin, TimestampMixin, Base): + """One durable delivery per matching subscription, with a lease-owned lifecycle.""" + + __tablename__ = "notification_deliveries" + event_id: Mapped[str] = mapped_column( + ForeignKey("notification_events.id", ondelete="RESTRICT"), nullable=False + ) + subscription_id: Mapped[str] = mapped_column( + ForeignKey("notification_subscriptions.id", ondelete="RESTRICT"), nullable=False + ) + state: Mapped[str] = mapped_column(String(32), nullable=False, default="pending") + due_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False) + lease_owner: Mapped[str | None] = mapped_column(String(255)) + lease_expires_at: Mapped[datetime | None] = mapped_column(UTCDateTime()) + attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + terminal_reason: Mapped[str | None] = mapped_column(String(96)) + response_class: Mapped[str | None] = mapped_column(String(64)) + response_summary: Mapped[str | None] = mapped_column(String(512)) + __table_args__ = ( + CheckConstraint("attempt_count >= 0", name="attempt_count_nonnegative"), + CheckConstraint("state IN ('pending','leased','delivered','retry','failed')", name="state"), + UniqueConstraint("event_id", "subscription_id", name="event_subscription"), + Index("ix_notification_deliveries_due", "state", "due_at"), + Index("ix_notification_deliveries_lease", "state", "lease_expires_at"), + ) + + +class NotificationDeliveryAttempt(IdentityMixin, Base): + """Append-only history. Diagnostics are bounded/redacted before persistence.""" + + __tablename__ = "notification_delivery_attempts" + delivery_id: Mapped[str] = mapped_column( + ForeignKey("notification_deliveries.id", ondelete="RESTRICT"), nullable=False + ) + number: Mapped[int] = mapped_column(Integer, nullable=False) + started_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False) + completed_at: Mapped[datetime | None] = mapped_column(UTCDateTime()) + outcome: Mapped[str] = mapped_column(String(32), nullable=False, default="started") + response_class: Mapped[str | None] = mapped_column(String(64)) + diagnostic: Mapped[str | None] = mapped_column(String(512)) + __table_args__ = ( + CheckConstraint("number > 0", name="number_positive"), + CheckConstraint("outcome IN ('started','delivered','retry','failed')", name="outcome"), + UniqueConstraint("delivery_id", "number", name="delivery_number"), + Index("ix_notification_attempts_delivery", "delivery_id", "number"), + ) + + +class NotificationSigningKey(IdentityMixin, TimestampMixin, Base): + __tablename__ = "notification_signing_keys" + subscription_id: Mapped[str] = mapped_column( + ForeignKey("notification_subscriptions.id", ondelete="RESTRICT"), nullable=False + ) + version: Mapped[int] = mapped_column(Integer, nullable=False) + secret_id: Mapped[str] = mapped_column(ForeignKey("secrets.id", ondelete="RESTRICT")) + state: Mapped[str] = mapped_column(String(16), nullable=False, default="active") + overlap_expires_at: Mapped[datetime | None] = mapped_column(UTCDateTime()) + __table_args__ = ( + CheckConstraint("version > 0", name="version_positive"), + CheckConstraint("state IN ('active','overlap','retired')", name="state"), + UniqueConstraint("subscription_id", "version", name="subscription_version"), + Index("ix_notification_signing_keys_subscription", "subscription_id", "state"), + Index( + "uq_notification_signing_keys_active", + "subscription_id", + unique=True, + sqlite_where=text("state = 'active'"), + ), + Index( + "uq_notification_signing_keys_overlap", + "subscription_id", + unique=True, + sqlite_where=text("state = 'overlap'"), + ), + ) + + +class NotificationEmailSettings(Base): + __tablename__ = "notification_email_settings" + id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1) + host: Mapped[str] = mapped_column(String(255), nullable=False) + port: Mapped[int] = mapped_column(Integer, nullable=False, default=587) + username: Mapped[str] = mapped_column(String(255), nullable=False) + password_secret_id: Mapped[str] = mapped_column( + ForeignKey("secrets.id", ondelete="RESTRICT"), nullable=False + ) + sender: Mapped[str] = mapped_column(String(320), nullable=False) + max_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=5) + rate_limit_per_minute: Mapped[int] = mapped_column(Integer, nullable=False, default=60) + __table_args__ = ( + CheckConstraint("id = 1", name="singleton"), + CheckConstraint("port > 0 AND port < 65536", name="port"), + CheckConstraint("max_attempts > 0", name="max_attempts"), + CheckConstraint("rate_limit_per_minute > 0", name="rate_positive"), + ) + + +class IdempotencyRecord(IdentityMixin, Base): + __tablename__ = "idempotency_records" + actor_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="RESTRICT")) + key: Mapped[str] = mapped_column(String(255), nullable=False) + operation: Mapped[str] = mapped_column(String(255), nullable=False) + request_digest: Mapped[str] = mapped_column(String(64), nullable=False) + response_resource_type: Mapped[str] = mapped_column(String(64), nullable=False) + response_resource_id: Mapped[str] = mapped_column(String(36), nullable=False) + created_at: Mapped[datetime] = mapped_column( + UTCDateTime(), nullable=False, server_default=func.current_timestamp() + ) + __table_args__ = ( + UniqueConstraint("actor_id", "key", "operation", name="uq_idempotency_actor_key_operation"), + Index("ix_idempotency_created_at", "created_at"), + ) diff --git a/backend/src/backup_tool/db/sqlite.py b/backend/src/backup_tool/db/sqlite.py new file mode 100644 index 0000000..4ef8b86 --- /dev/null +++ b/backend/src/backup_tool/db/sqlite.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from typing import Any + +DEFAULT_BUSY_TIMEOUT_MS = 5_000 + + +def sqlite_connect_args(busy_timeout_ms: int) -> dict[str, float]: + """Configure SQLite lock waiting without dynamic PRAGMA SQL.""" + return {"timeout": busy_timeout_ms / 1_000} + + +def configure_sqlite_connection(dbapi_connection: Any) -> None: + """Apply connection-local safety and persistent WAL mode.""" + cursor = dbapi_connection.cursor() + try: + cursor.execute("PRAGMA foreign_keys=ON") + cursor.execute("PRAGMA journal_mode=WAL") + finally: + cursor.close() diff --git a/backend/src/backup_tool/db/types.py b/backend/src/backup_tool/db/types.py new file mode 100644 index 0000000..8b86510 --- /dev/null +++ b/backend/src/backup_tool/db/types.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy import DateTime +from sqlalchemy.engine import Dialect +from sqlalchemy.types import TypeDecorator + + +class UTCDateTime(TypeDecorator[datetime]): + """Store instants and always return timezone-aware UTC datetimes.""" + + impl = DateTime + cache_ok = True + + def load_dialect_impl(self, dialect: Dialect) -> Any: + return dialect.type_descriptor(DateTime(timezone=True)) + + def process_bind_param(self, value: datetime | None, dialect: Dialect) -> datetime | None: + del dialect + if value is None: + return None + if value.tzinfo is None: + raise ValueError("datetime values must be timezone-aware") + return value.astimezone(UTC) + + def process_result_value(self, value: datetime | None, dialect: Dialect) -> datetime | None: + del dialect + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=UTC) + return value.astimezone(UTC) diff --git a/backend/src/backup_tool/exclusions.py b/backend/src/backup_tool/exclusions.py new file mode 100644 index 0000000..3b3a6e0 --- /dev/null +++ b/backend/src/backup_tool/exclusions.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from pathlib import PurePosixPath + + +class ExclusionError(ValueError): + pass + + +def _validate(value: str) -> None: + path = PurePosixPath(value) + if not value or "\\" in value or path.is_absolute() or ".." in path.parts: + raise ExclusionError("exclusion paths must be normalized relative POSIX paths") + + +def matches(path: str, patterns: list[str]) -> bool: + """Return whether normalized relative `path` is excluded by ordered gitignore-like rules.""" + _validate(path) + excluded = False + candidate = PurePosixPath(path) + for pattern in patterns: + negated = pattern.startswith("!") + raw = pattern[1:] if negated else pattern + if not raw or raw.startswith("/") or "\\" in raw or ".." in PurePosixPath(raw).parts: + raise ExclusionError("exclusion pattern is invalid") + directory = raw.endswith("/") + raw = raw.rstrip("/") + if not raw: + raise ExclusionError("exclusion pattern is invalid") + matched = candidate.match(raw) or candidate.match(f"**/{raw}") + if directory: + matched = matched or any( + parent.match(raw) or parent.match(f"**/{raw}") for parent in candidate.parents + ) + if matched: + excluded = not negated + return excluded diff --git a/backend/src/backup_tool/execution.py b/backend/src/backup_tool/execution.py new file mode 100644 index 0000000..2a002af --- /dev/null +++ b/backend/src/backup_tool/execution.py @@ -0,0 +1,306 @@ +from __future__ import annotations + +from collections.abc import Mapping +from datetime import UTC, datetime, timedelta + +from sqlalchemy import select, update +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from backup_tool.db.models import Execution, ExecutionEvent, Job +from backup_tool.notifications.events import emit_event +from backup_tool.security.redaction import redact + +ACTIVE_STATES = frozenset({"queued", "preparing", "running", "verifying", "cancelling"}) +TERMINAL_STATES = frozenset({"committed", "cancelled", "failed"}) +TRANSIENT_REASONS = frozenset({"worker_lost", "timeout", "unavailable", "transient_io"}) + +_ALLOWED: Mapping[str, frozenset[str]] = { + "queued": frozenset({"preparing", "cancelled", "failed"}), + "preparing": frozenset({"running", "cancelling", "failed"}), + "running": frozenset({"verifying", "cancelling", "failed"}), + "verifying": frozenset({"committed", "failed"}), + "cancelling": frozenset({"cancelled", "failed"}), + "committed": frozenset(), + "cancelled": frozenset(), + "failed": frozenset(), +} + + +class TransitionError(ValueError): + code = "invalid_transition" + + +class EnqueueError(ValueError): + def __init__(self, code: str, detail: str, active_execution_id: str | None = None): + super().__init__(detail) + self.code = code + self.active_execution_id = active_execution_id + + +def transition(current: str, target: str) -> str: + """Validate and return one durable execution-state transition.""" + if target not in _ALLOWED.get(current, frozenset()): + raise TransitionError(f"Cannot transition execution from {current!r} to {target!r}.") + return target + + +async def enqueue( + db: AsyncSession, + job_id: str, + trigger: str = "manual", + *, + schedule_id: str | None = None, + nominal_run_at: datetime | None = None, +) -> Execution: + """Create exactly one active execution for an enabled active job.""" + job = await db.get(Job, job_id) + if job is None: + raise EnqueueError("resource_not_found", "Job was not found.") + if job.state != "active" or not job.enabled: + raise EnqueueError("job_disabled", "Job is disabled or archived.") + job_identifier = job.id + execution = Execution( + job_id=job_identifier, + trigger=trigger, + schedule_id=schedule_id, + nominal_run_at=nominal_run_at, + progress={}, + ) + db.add(execution) + try: + await db.flush() + await record_event(db, execution) + await db.commit() + except IntegrityError as error: + await db.rollback() + existing = await db.scalar( + select(Execution.id).where( + Execution.job_id == job_identifier, Execution.state.in_(ACTIVE_STATES) + ) + ) + raise EnqueueError( + "execution_active", "Job already has an active execution.", existing + ) from error + await db.refresh(execution) + return execution + + +async def claim( + db: AsyncSession, execution_id: str, owner: str, lease_seconds: int = 60 +) -> Execution | None: + """Atomically lease a queued execution; a stale lease may be recovered.""" + now = datetime.now(UTC) + expires = now + timedelta(seconds=lease_seconds) + result = await db.execute( + update(Execution) + .where( + Execution.id == execution_id, + Execution.state.in_({"queued", "preparing"}), + (Execution.lease_expires_at.is_(None)) | (Execution.lease_expires_at < now), + ) + .values(state="preparing", lease_owner=owner, lease_expires_at=expires, heartbeat_at=now) + ) + if getattr(result, "rowcount", 0) != 1: + await db.rollback() + return None + execution = await db.get(Execution, execution_id) + if execution is not None: + await record_event(db, execution) + await db.commit() + return execution + + +async def heartbeat( + db: AsyncSession, execution_id: str, owner: str, lease_seconds: int = 60 +) -> bool: + now = datetime.now(UTC) + result = await db.execute( + update(Execution) + .where( + Execution.id == execution_id, + Execution.lease_owner == owner, + Execution.state.in_(ACTIVE_STATES), + Execution.lease_expires_at >= now, + ) + .values(heartbeat_at=now, lease_expires_at=now + timedelta(seconds=lease_seconds)) + ) + await db.commit() + return getattr(result, "rowcount", 0) == 1 + + +async def request_cancellation(db: AsyncSession, execution_id: str) -> Execution | None: + # Queued work has no worker and may terminate immediately; leased work asks its owner. + now = datetime.now(UTC) + result = await db.execute( + update(Execution) + .where(Execution.id == execution_id, Execution.state == "queued") + .values(state="cancelled", reason_code="cancellation_requested", completed_at=now) + ) + if getattr(result, "rowcount", 0) != 1: + result = await db.execute( + update(Execution) + .where(Execution.id == execution_id, Execution.state.in_({"preparing", "running"})) + .values(state="cancelling", reason_code="cancellation_requested") + ) + if getattr(result, "rowcount", 0) != 1: + await db.rollback() + return None + execution = await db.get(Execution, execution_id) + if execution is not None: + await record_event(db, execution) + await db.commit() + return execution + + +async def complete_cancellation(db: AsyncSession, execution_id: str, owner: str) -> bool: + result = await db.execute( + update(Execution) + .where( + Execution.id == execution_id, + Execution.lease_owner == owner, + Execution.lease_expires_at >= datetime.now(UTC), + Execution.state == "cancelling", + ) + .values( + state="cancelled", + completed_at=datetime.now(UTC), + lease_owner=None, + lease_expires_at=None, + ) + ) + if getattr(result, "rowcount", 0) != 1: + await db.rollback() + return False + execution = await db.get(Execution, execution_id) + if execution is not None: + await record_event(db, execution) + await db.commit() + return True + + +async def retry(db: AsyncSession, execution_id: str) -> Execution | None: + execution = await db.get(Execution, execution_id) + if execution is None: + return None + if execution.state != "failed" or execution.reason_code not in TRANSIENT_REASONS: + raise EnqueueError("retry_not_allowed", "Execution failure is not retryable.") + execution.state = "queued" + execution.attempt += 1 + execution.lease_owner = None + execution.lease_expires_at = None + execution.heartbeat_at = None + execution.reason_code = None + execution.operator_message = None + await record_event(db, execution) + try: + await db.commit() + except IntegrityError as error: + await db.rollback() + active_id = await db.scalar( + select(Execution.id).where( + Execution.job_id == execution.job_id, Execution.state.in_(ACTIVE_STATES) + ) + ) + raise EnqueueError( + "execution_active", "Job already has an active execution.", active_id + ) from error + await db.refresh(execution) + return execution + + +def _redact_progress(value: object) -> object: + """Apply common redaction plus execution-specific path redaction recursively.""" + if isinstance(value, Mapping): + safe: dict[str, object] = {} + for key, item in value.items(): + normalized = str(key).lower() + if any(marker in normalized for marker in ("path", "secret", "token", "password")): + safe[str(key)] = "[REDACTED]" + else: + safe[str(key)] = _redact_progress(item) + return safe + if isinstance(value, list): + return [_redact_progress(item) for item in value] + return redact(value) + + +async def record_event(db: AsyncSession, execution: Execution) -> ExecutionEvent: + """Append the public representation as the next durable event revision.""" + execution.revision += 1 + event = ExecutionEvent( + execution_id=execution.id, + revision=execution.revision, + payload=public_event(execution), + ) + db.add(event) + await db.flush() + notification_type = { + "preparing": "execution.started", + "committed": "execution.committed", + "failed": "execution.failed", + "cancelled": "execution.cancelled", + }.get(execution.state) + if execution.state == "queued": + if execution.reason_code == "worker_lost": + notification_type = "execution.worker_recovered" + elif execution.attempt > 1: + notification_type = "execution.retry_queued" + else: + notification_type = "execution.queued" + if notification_type is not None: + await emit_event( + db, + notification_type, + correlation_id=execution.id, + resource={"execution_id": execution.id, "job_id": execution.job_id}, + payload={ + "attempt": execution.attempt, + "state": execution.state, + "reason_code": execution.reason_code, + }, + deduplication_key=f"execution:{execution.id}:revision:{execution.revision}", + ) + return event + + +def public_event(execution: Execution) -> dict[str, object]: + """Return redacted progress suitable for polling or SSE.""" + progress = _redact_progress(execution.progress) + return { + "id": execution.id, + "state": execution.state, + "attempt": execution.attempt, + "revision": execution.revision, + "reason_code": execution.reason_code, + "progress": progress, + } + + +async def recover_stale(db: AsyncSession) -> int: + """Atomically recover expired executions and append one event per mutation.""" + now = datetime.now(UTC) + executions = list( + ( + await db.scalars( + select(Execution).where( + Execution.state.in_({"preparing", "running", "verifying", "cancelling"}), + Execution.lease_expires_at < now, + ) + ) + ).all() + ) + for execution in executions: + if execution.state == "cancelling": + execution.state = "cancelled" + execution.completed_at = now + execution.reason_code = "cancellation_requested" + else: + execution.state = "queued" + execution.reason_code = "worker_lost" + execution.lease_owner = None + execution.lease_expires_at = None + execution.heartbeat_at = None + await record_event(db, execution) + await db.commit() + return len(executions) diff --git a/backend/src/backup_tool/faults.py b/backend/src/backup_tool/faults.py new file mode 100644 index 0000000..c1d54f4 --- /dev/null +++ b/backend/src/backup_tool/faults.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from typing import Protocol + + +class FaultInjector(Protocol): + def hit(self, point: str) -> None: ... + + +class NoFault: + def hit(self, point: str) -> None: + del point + + +class InjectedCrash(BaseException): + """Test-only abrupt worker termination at a named durable fault point.""" + + +class CrashAt: + def __init__(self, point: str) -> None: + self.point = point + + def hit(self, point: str) -> None: + if point == self.point: + raise InjectedCrash(point) diff --git a/backend/src/backup_tool/gc.py b/backend/src/backup_tool/gc.py new file mode 100644 index 0000000..969ade2 --- /dev/null +++ b/backend/src/backup_tool/gc.py @@ -0,0 +1,184 @@ +from __future__ import annotations + +import json +import shutil +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import cast + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from backup_tool.db.models import Backup, Execution, Job, Repository +from backup_tool.notifications.events import emit_event +from backup_tool.retention import BackupLike, RetentionPolicy, retained_ids +from backup_tool.security.repository_crypto import RepositoryKeyError, decrypt_object, object_aad + + +@dataclass(frozen=True) +class GcReport: + tombstoned: int + purged_manifests: int + purged_blobs: int + quarantined: int + + +async def tombstone_expired(db: AsyncSession, now: datetime | None = None) -> int: + reference = now or datetime.now(UTC) + backups = list((await db.scalars(select(Backup))).all()) + executions = {item.id: item for item in (await db.scalars(select(Execution))).all()} + jobs = {item.id: item for item in (await db.scalars(select(Job))).all()} + grouped: dict[str, list[Backup]] = {} + for backup in backups: + execution = executions.get(backup.execution_id) + if execution is None or execution.job_id not in jobs: + continue + grouped.setdefault(execution.job_id, []).append(backup) + tombstoned = 0 + for job_id, items in grouped.items(): + if not jobs[job_id].retention: + continue + policy = RetentionPolicy.from_dict(jobs[job_id].retention) + keep = retained_ids(cast(list[BackupLike], items), policy, reference) + for backup in items: + if backup.id not in keep and backup.tombstoned_at is None: + backup.tombstoned_at = reference + await emit_event( + db, + "retention.tombstoned", + correlation_id=backup.id, + resource={"backup_id": backup.id, "job_id": job_id}, + payload={"outcome": "tombstoned"}, + deduplication_key=f"backup:{backup.id}:tombstoned", + ) + tombstoned += 1 + await db.commit() + return tombstoned + + +async def process_retention_gc(db: AsyncSession, now: datetime | None = None) -> GcReport: + """Run durable retention tombstoning and repository GC from the worker role.""" + reference = now or datetime.now(UTC) + tombstoned = await tombstone_expired(db, reference) + reports: list[GcReport] = [] + repositories = list((await db.scalars(select(Repository))).all()) + for repository in repositories: + manifest_ids = set( + ( + await db.scalars( + select(Backup.manifest_id) + .join(Execution, Backup.execution_id == Execution.id) + .join(Job, Execution.job_id == Job.id) + .where( + Job.repository_id == repository.id, + Backup.tombstoned_at.is_not(None), + ) + ) + ).all() + ) + if manifest_ids: + reports.append(purge_repository(Path(repository.root), manifest_ids, now=reference)) + return GcReport( + tombstoned=tombstoned, + purged_manifests=sum(report.purged_manifests for report in reports), + purged_blobs=sum(report.purged_blobs for report in reports), + quarantined=sum(report.quarantined for report in reports), + ) + + +def _manifest_digests( + path: Path, + *, + repository_id: str | None = None, + manifest_keys: Mapping[str, tuple[str, bytes]] | None = None, +) -> set[str] | None: + try: + if path.is_symlink() or not path.is_file(): + return None + raw = path.read_bytes() + if raw.startswith(b"BTENC\x01"): + key_record = manifest_keys.get(path.stem) if manifest_keys is not None else None + if key_record is None or repository_id is None: + return None + key_id, key = key_record + raw = decrypt_object(key, object_aad(repository_id, key_id, "manifest", path.stem), raw) + payload = json.loads(raw.decode("utf-8")) + except (OSError, RepositoryKeyError, UnicodeDecodeError, json.JSONDecodeError): + return None + if not isinstance(payload, dict) or not isinstance(entries := payload.get("entries"), list): + return None + digests: set[str] = set() + for entry in entries: + if not isinstance(entry, dict): + return None + digest = entry.get("blob_digest") + if digest is None: + continue + if ( + not isinstance(digest, str) + or len(digest) != 64 + or any(character not in "0123456789abcdef" for character in digest) + ): + return None + digests.add(digest) + return digests + + +def purge_repository( + root: Path, + tombstoned_manifest_ids: set[str], + *, + repository_id: str | None = None, + manifest_keys: Mapping[str, tuple[str, bytes]] | None = None, + grace: timedelta = timedelta(days=7), + now: datetime | None = None, +) -> GcReport: + reference = now or datetime.now(UTC) + manifests = root / "manifests" + blobs = root / "blobs" / "sha256" + quarantine = root / "quarantine" + quarantine.mkdir(exist_ok=True) + manifest_digests: dict[Path, set[str]] = {} + for manifest in manifests.glob("*.json"): + digests = _manifest_digests( + manifest, + repository_id=repository_id, + manifest_keys=manifest_keys, + ) + if digests is None: + return GcReport(0, 0, 0, 0) + manifest_digests[manifest] = digests + + purged_manifests = 0 + for manifest_id in tombstoned_manifest_ids: + candidate = manifests / f"{manifest_id}.json" + if not candidate.is_file() or candidate.is_symlink(): + continue + age = reference - datetime.fromtimestamp(candidate.stat().st_mtime, UTC) + if age >= grace: + candidate.unlink() + purged_manifests += 1 + referenced: set[str] = set() + for manifest, digests in manifest_digests.items(): + if manifest.exists(): + referenced.update(digests) + purged_blobs = 0 + quarantined = 0 + if blobs.exists(): + for blob in blobs.iterdir(): + if not blob.is_file(): + continue + if len(blob.name) != 64 or any(char not in "0123456789abcdef" for char in blob.name): + try: + shutil.move(str(blob), quarantine / blob.name) + except OSError: + continue + quarantined += 1 + elif blob.name not in referenced and ( + reference - datetime.fromtimestamp(blob.stat().st_mtime, UTC) >= grace + ): + blob.unlink() + purged_blobs += 1 + return GcReport(0, purged_manifests, purged_blobs, quarantined) diff --git a/backend/src/backup_tool/ids.py b/backend/src/backup_tool/ids.py new file mode 100644 index 0000000..08e626b --- /dev/null +++ b/backend/src/backup_tool/ids.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import secrets +import threading +import time +from uuid import UUID + +_lock = threading.Lock() +_last_millisecond = -1 +_last_random = 0 +_RANDOM_MASK = (1 << 74) - 1 + + +def new_uuid7() -> UUID: + """Return a process-monotonic RFC 9562 UUIDv7.""" + global _last_millisecond, _last_random + with _lock: + millisecond = time.time_ns() // 1_000_000 + if millisecond > _last_millisecond: + _last_millisecond = millisecond + _last_random = secrets.randbits(74) + else: + millisecond = _last_millisecond + _last_random = (_last_random + 1) & _RANDOM_MASK + if _last_random == 0: + _last_millisecond += 1 + millisecond = _last_millisecond + random_a = (_last_random >> 62) & 0xFFF + random_b = _last_random & ((1 << 62) - 1) + integer = (millisecond & ((1 << 48) - 1)) << 80 + integer |= 0x7 << 76 + integer |= random_a << 64 + integer |= 0b10 << 62 + integer |= random_b + return UUID(int=integer) diff --git a/backend/src/backup_tool/notifications/__init__.py b/backend/src/backup_tool/notifications/__init__.py new file mode 100644 index 0000000..535e8c3 --- /dev/null +++ b/backend/src/backup_tool/notifications/__init__.py @@ -0,0 +1,5 @@ +"""Durable, worker-dispatched operational notifications.""" + +from .events import EVENT_CATALOG, emit_event, validate_filters + +__all__ = ["EVENT_CATALOG", "emit_event", "validate_filters"] diff --git a/backend/src/backup_tool/notifications/dispatcher.py b/backend/src/backup_tool/notifications/dispatcher.py new file mode 100644 index 0000000..e88d191 --- /dev/null +++ b/backend/src/backup_tool/notifications/dispatcher.py @@ -0,0 +1,335 @@ +"""Worker-owned leased outbox dispatcher; sends happen only after a committed lease.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from backup_tool.config import Settings +from backup_tool.db.models import ( + NotificationDelivery, + NotificationDeliveryAttempt, + NotificationEmailSettings, + NotificationEvent, + NotificationSigningKey, + NotificationSubscription, + Secret, +) +from backup_tool.notifications.email import EmailTransportError, deliver_email +from backup_tool.notifications.retry import ( + RetryDecision, + retry_delay, + transport_decision, + webhook_decision, +) +from backup_tool.notifications.webhook import ( + SigningMaterial, + WebhookTransportError, + deliver_webhook, +) +from backup_tool.security.secrets import EnvelopeCipher +from backup_tool.security.ssrf import Resolver, system_resolver + + +async def recover_notification_leases(db: AsyncSession) -> int: + """An interrupted post-send lease becomes eligible again (at-least-once by design).""" + now = datetime.now(UTC) + deliveries = list( + ( + await db.scalars( + select(NotificationDelivery).where( + NotificationDelivery.state == "leased", + NotificationDelivery.lease_expires_at < now, + ) + ) + ).all() + ) + for delivery in deliveries: + attempt = await db.scalar( + select(NotificationDeliveryAttempt).where( + NotificationDeliveryAttempt.delivery_id == delivery.id, + NotificationDeliveryAttempt.number == delivery.attempt_count, + NotificationDeliveryAttempt.outcome == "started", + ) + ) + if attempt is not None: + attempt.completed_at = now + attempt.outcome = "retry" + attempt.response_class = "lease_expired" + attempt.diagnostic = "abandoned_lease" + delivery.state = "retry" + delivery.lease_owner = None + delivery.lease_expires_at = None + delivery.due_at = now + if deliveries: + await db.commit() + return len(deliveries) + + +async def _claim_due( + db: AsyncSession, owner: str, lease_seconds: int +) -> tuple[NotificationDelivery, NotificationSubscription, NotificationEvent] | None: + now = datetime.now(UTC) + delivery_id = await db.scalar( + select(NotificationDelivery.id) + .where( + NotificationDelivery.state.in_(("pending", "retry")), + NotificationDelivery.due_at <= now, + ) + .order_by(NotificationDelivery.due_at, NotificationDelivery.created_at) + .limit(1) + ) + if delivery_id is None: + return None + result = await db.execute( + update(NotificationDelivery) + .where( + NotificationDelivery.id == delivery_id, + NotificationDelivery.state.in_(("pending", "retry")), + NotificationDelivery.due_at <= now, + ) + .values( + state="leased", + lease_owner=owner, + lease_expires_at=now + timedelta(seconds=lease_seconds), + attempt_count=NotificationDelivery.attempt_count + 1, + ) + ) + if getattr(result, "rowcount", 0) != 1: + await db.rollback() + return None + delivery = await db.get(NotificationDelivery, delivery_id) + if delivery is None: # pragma: no cover - guarded by update + await db.rollback() + return None + subscription = await db.get(NotificationSubscription, delivery.subscription_id) + event = await db.get(NotificationEvent, delivery.event_id) + if subscription is None or event is None or subscription.state != "active": + delivery.state = "failed" + delivery.terminal_reason = "subscription_unavailable" + delivery.lease_owner = None + delivery.lease_expires_at = None + await db.commit() + return None + # Persist a token bucket before starting an attempt, so restarts cannot bypass + # the subscription rate limit. Global process rate is intentionally a config + # ceiling; the durable subscription bucket protects cross-restart behavior. + last = subscription.rate_updated_at or now + elapsed = max(0.0, (now - last).total_seconds()) + capacity = subscription.rate_limit_per_minute + try: + token_capacity = float(capacity) + tokens = min(token_capacity, subscription.rate_tokens + elapsed * capacity / 60) + except (TypeError, ValueError, ZeroDivisionError) as error: + raise RuntimeError("notification rate limit is invalid") from error + if tokens < 1: + try: + delay = max(1, int((1 - tokens) * 60 / capacity) + 1) + except (TypeError, ValueError, ZeroDivisionError) as error: + raise RuntimeError("notification rate limit is invalid") from error + delivery.state = "retry" + delivery.due_at = now + timedelta(seconds=delay) + delivery.lease_owner = None + delivery.lease_expires_at = None + subscription.rate_tokens = tokens + subscription.rate_updated_at = now + await db.commit() + return None + subscription.rate_tokens = tokens - 1 + subscription.rate_updated_at = now + db.add( + NotificationDeliveryAttempt( + delivery_id=delivery.id, + number=delivery.attempt_count, + started_at=now, + outcome="started", + ) + ) + await db.commit() + return delivery, subscription, event + + +async def _finish( + db: AsyncSession, + delivery_id: str, + owner: str, + *, + delivered: bool, + retryable: bool, + response_class: str, + reason: str, + retry_cap: int, + max_attempts: int, +) -> None: + delivery = await db.get(NotificationDelivery, delivery_id) + if delivery is None or delivery.lease_owner != owner or delivery.state != "leased": + await db.rollback() + return + attempt = await db.scalar( + select(NotificationDeliveryAttempt).where( + NotificationDeliveryAttempt.delivery_id == delivery.id, + NotificationDeliveryAttempt.number == delivery.attempt_count, + ) + ) + if attempt is None: # pragma: no cover - an invariant of _claim_due + await db.rollback() + return + now = datetime.now(UTC) + attempt.completed_at = now + attempt.response_class = response_class + attempt.diagnostic = reason[:512] + delivery.response_class = response_class + delivery.response_summary = reason[:512] + delivery.lease_owner = None + delivery.lease_expires_at = None + if delivered: + delivery.state = "delivered" + attempt.outcome = "delivered" + elif retryable and delivery.attempt_count < max_attempts: + delivery.state = "retry" + delivery.due_at = now + timedelta(seconds=retry_delay(delivery.attempt_count, retry_cap)) + attempt.outcome = "retry" + else: + delivery.state = "failed" + delivery.terminal_reason = reason + attempt.outcome = "failed" + await db.commit() + + +async def dispatch_one( + db: AsyncSession, + settings: Settings, + cipher: EnvelopeCipher, + owner: str, + *, + resolver: Resolver = system_resolver, +) -> bool: + claimed = await _claim_due(db, owner, settings.notification_delivery_lease_seconds) + if claimed is None: + return False + delivery, subscription, event = claimed + max_attempts = settings.notification_max_attempts + try: + if subscription.channel == "webhook": + key_rows = list( + ( + await db.scalars( + select(NotificationSigningKey).where( + NotificationSigningKey.subscription_id == subscription.id, + NotificationSigningKey.state.in_(("active", "overlap")), + ) + ) + ).all() + ) + keys: list[SigningMaterial] = [] + now = datetime.now(UTC) + for key in key_rows: + if ( + key.state == "overlap" + and key.overlap_expires_at is not None + and key.overlap_expires_at <= now + ): + key.state = "retired" + continue + secret = await db.get(Secret, key.secret_id) + if secret is None: + raise WebhookTransportError("webhook signing secret is unavailable") + keys.append( + SigningMaterial( + key_id=key.id, + version=key.version, + secret=cipher.decrypt( + secret.ciphertext, + purpose=secret.purpose, + version=secret.version, + ), + ) + ) + await db.commit() + webhook_result = await deliver_webhook( + str(subscription.destination_config["url"]), + event.canonical_envelope.encode(), + event_id=event.id, + event_type=event.type, + timestamp=event.occurred_at.isoformat(), + keys=keys, + resolver=resolver, + connect_timeout=settings.notification_connect_timeout_seconds, + read_timeout=settings.notification_read_timeout_seconds, + max_response_bytes=settings.notification_max_response_bytes, + ) + decision = webhook_decision(webhook_result.status_code) + elif subscription.channel == "email": + email_settings = await db.get(NotificationEmailSettings, 1) + if email_settings is None: + raise EmailTransportError("SMTP settings are unavailable") + max_attempts = email_settings.max_attempts + password_secret = await db.get(Secret, email_settings.password_secret_id) + if password_secret is None: + raise EmailTransportError("SMTP password is unavailable") + email_result = await deliver_email( + email_settings, + cipher.decrypt( + password_secret.ciphertext, + purpose=password_secret.purpose, + version=password_secret.version, + ), + event, + subscription.destination_config["recipients"], + ) + decision = RetryDecision(False, email_result.response_class, "delivered") + else: # guarded by DB constraint + raise WebhookTransportError("notification channel is unavailable") + await _finish( + db, + delivery.id, + owner, + delivered=decision.reason == "delivered", + retryable=decision.retry, + response_class=decision.response_class, + reason=decision.reason, + retry_cap=settings.notification_retry_cap_seconds, + max_attempts=max_attempts, + ) + except EmailTransportError as error: + decision = transport_decision(error.transient, "smtp_transport") + await _finish( + db, + delivery.id, + owner, + delivered=False, + retryable=decision.retry, + response_class=decision.response_class, + reason=str(error), + retry_cap=settings.notification_retry_cap_seconds, + max_attempts=max_attempts, + ) + except WebhookTransportError as error: + decision = transport_decision(error.transient, "webhook_transport") + await _finish( + db, + delivery.id, + owner, + delivered=False, + retryable=decision.retry, + response_class=decision.response_class, + reason=str(error), + retry_cap=settings.notification_retry_cap_seconds, + max_attempts=max_attempts, + ) + except (KeyError, ValueError): + decision = transport_decision(False, "webhook_validation") + await _finish( + db, + delivery.id, + owner, + delivered=False, + retryable=False, + response_class=decision.response_class, + reason=decision.reason, + retry_cap=settings.notification_retry_cap_seconds, + max_attempts=max_attempts, + ) + return True diff --git a/backend/src/backup_tool/notifications/email.py b/backend/src/backup_tool/notifications/email.py new file mode 100644 index 0000000..f7d7501 --- /dev/null +++ b/backend/src/backup_tool/notifications/email.py @@ -0,0 +1,120 @@ +"""Authenticated, certificate-verified STARTTLS email notification transport.""" + +from __future__ import annotations + +import asyncio +import smtplib +import ssl +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from email.message import EmailMessage +from email.utils import formataddr +from typing import Protocol, Self, cast + +from backup_tool.db.models import NotificationEmailSettings, NotificationEvent + + +class SMTPClient(Protocol): + def __enter__(self) -> Self: ... + + def __exit__(self, *args: object) -> None: ... + + def ehlo(self) -> object: ... + + def starttls(self, *, context: ssl.SSLContext) -> object: ... + + def login(self, user: str, password: str) -> object: ... + + def send_message(self, msg: EmailMessage) -> object: ... + + +class EmailTransportError(RuntimeError): + def __init__(self, message: str, *, transient: bool = False) -> None: + super().__init__(message) + self.transient = transient + + +@dataclass(frozen=True) +class EmailResult: + response_class: str + + +def validate_address(value: str) -> str: + if not value or len(value) > 320 or any(character in value for character in "\r\n"): + raise EmailTransportError("email address is invalid") + local, separator, domain = value.rpartition("@") + if not separator or not local or not domain or any(character.isspace() for character in value): + raise EmailTransportError("email address is invalid") + return value + + +def validate_recipients(values: Sequence[str]) -> list[str]: + if not values or len(values) > 20: + raise EmailTransportError("one to 20 email recipients are required") + recipients: list[str] = [] + for value in values: + address = validate_address(value) + if address not in recipients: + recipients.append(address) + return recipients + + +def _message( + settings: NotificationEmailSettings, + event: NotificationEvent, + recipients: Sequence[str], +) -> EmailMessage: + sender = validate_address(settings.sender) + safe_recipients = validate_recipients(recipients) + message = EmailMessage() + message["From"] = formataddr(("Backup Tool", sender)) + message["To"] = ", ".join(safe_recipients) + message["Subject"] = f"Backup Tool: {event.type} ({event.severity})" + message["X-Backup-Event-ID"] = event.id + # Do not put the full envelope, paths, raw errors, or credentials into mail. + message.set_content( + "Backup Tool operational event\n" + f"Event ID: {event.id}\n" + f"Type: {event.type}\n" + f"Severity: {event.severity}\n" + f"Occurred: {event.occurred_at.isoformat()}\n" + ) + return message + + +def _deliver_sync( + settings: NotificationEmailSettings, + password: str, + event: NotificationEvent, + recipients: Sequence[str], + smtp_factory: Callable[..., SMTPClient], +) -> EmailResult: + message = _message(settings, event, recipients) + try: + with smtp_factory(settings.host, settings.port, timeout=10) as client: + client.ehlo() + context = ssl.create_default_context() + client.starttls(context=context) + client.ehlo() + client.login(settings.username, password) + client.send_message(message) + except smtplib.SMTPResponseException as error: + raise EmailTransportError( + f"smtp_{error.smtp_code}", transient=400 <= error.smtp_code < 500 + ) from error + except (smtplib.SMTPException, OSError) as error: + raise EmailTransportError("smtp_transport_failed", transient=True) from error + return EmailResult(response_class="smtp_2xx") + + +async def deliver_email( + settings: NotificationEmailSettings, + password: str, + event: NotificationEvent, + recipients: Sequence[str], + *, + smtp_factory: Callable[..., SMTPClient] | None = None, +) -> EmailResult: + """Run blocking SMTP only in the worker thread, never in the web process.""" + factory = smtp_factory or cast(Callable[..., SMTPClient], smtplib.SMTP) + return await asyncio.to_thread(_deliver_sync, settings, password, event, recipients, factory) diff --git a/backend/src/backup_tool/notifications/events.py b/backend/src/backup_tool/notifications/events.py new file mode 100644 index 0000000..b30b7f4 --- /dev/null +++ b/backend/src/backup_tool/notifications/events.py @@ -0,0 +1,279 @@ +"""Versioned notification event catalog and transactional outbox fan-out.""" + +from __future__ import annotations + +import json +from collections.abc import Mapping, Sequence +from datetime import UTC, datetime +from typing import Any +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from backup_tool.db.models import NotificationDelivery, NotificationEvent, NotificationSubscription +from backup_tool.ids import new_uuid7 +from backup_tool.security.redaction import redact +from backup_tool.security.ssrf import SSRFError, validate_webhook_url + +EVENT_SCHEMA_VERSION = 1 +# Live-events-only policy: every public type below has a current production emitter. +_EVENT_TYPES = ( + "execution.queued", + "execution.started", + "execution.committed", + "execution.failed", + "execution.cancelled", + "execution.retry_queued", + "execution.worker_recovered", + "schedule.created", + "schedule.updated", + "schedule.deleted", + "schedule.enabled", + "schedule.disabled", + "schedule.occurrence_enqueued", + "schedule.occurrence_misfired", + "schedule.occurrence_blocked", + "backup.committed", + "backup.verification_succeeded", + "restore.queued", + "restore.committed", + "restore.failed", + "retention.tombstoned", + "notification.test_requested", +) +EVENT_CATALOG: dict[str, dict[str, Any]] = { + event_type: { + "event_schema_version": EVENT_SCHEMA_VERSION, + "severity": "error" if event_type.endswith(("failed", "blocked", "rejected")) else "info", + "payload_keys": ( + "attempt", + "count", + "dry_run", + "integrity", + "message", + "outcome", + "reason_code", + "requested_mode", + "effective_mode", + "state", + ), + "reserved": False, + } + for event_type in _EVENT_TYPES +} +_SAFE_RESOURCE_KEYS = frozenset( + { + "execution_id", + "job_id", + "schedule_id", + "backup_id", + "restore_id", + "repository_id", + "subscription_id", + } +) +_SAFE_PAYLOAD_KEYS = frozenset().union( + *(set(spec["payload_keys"]) for spec in EVENT_CATALOG.values()) +) + + +class NotificationEventError(ValueError): + """A caller attempted to produce data outside the stable public catalog.""" + + +def _as_uuid(value: str, field: str) -> str: + try: + parsed = UUID(value) + except (TypeError, ValueError, AttributeError) as error: + raise NotificationEventError(f"{field} must be a UUID") from error + return str(parsed) + + +def _safe_value(value: Any) -> Any: + if value is None or isinstance(value, (bool, int, float)): + return value + if isinstance(value, str): + return redact(value)[:512] + if isinstance(value, list): + if len(value) > 32: + raise NotificationEventError("payload arrays are limited to 32 values") + return [_safe_value(item) for item in value] + if isinstance(value, Mapping): + if len(value) > 32: + raise NotificationEventError("payload objects are limited to 32 fields") + return {str(key)[:64]: _safe_value(item) for key, item in value.items()} + raise NotificationEventError("payload contains an unsupported value") + + +def _is_filter_match(filter_value: str, event_type: str) -> bool: + if filter_value == event_type: + return True + family, wildcard = filter_value.rsplit(".", 1) if "." in filter_value else ("", "") + return wildcard == "*" and event_type.startswith(f"{family}.") + + +def validate_filters(filters: Sequence[str]) -> list[str]: + if not filters: + raise NotificationEventError("at least one event filter is required") + if len(filters) > len(EVENT_CATALOG): + raise NotificationEventError("too many event filters") + output: list[str] = [] + for filter_value in filters: + if not isinstance(filter_value, str) or len(filter_value) > 96: + raise NotificationEventError("invalid event filter") + if filter_value.endswith(".*"): + family = filter_value[:-2] + if not family or not any(item.startswith(f"{family}.") for item in EVENT_CATALOG): + raise NotificationEventError("unknown event filter") + elif filter_value not in EVENT_CATALOG: + raise NotificationEventError("unknown event filter") + if filter_value not in output: + output.append(filter_value) + return output + + +def validate_destination(channel: str, destination: Mapping[str, Any]) -> dict[str, Any]: + resource_filters = destination.get("resource_filters", {}) + if not isinstance(resource_filters, Mapping): + raise NotificationEventError("resource filters are invalid") + unknown = set(resource_filters) - {"job_ids", "repository_ids", "severities"} + if unknown: + raise NotificationEventError("resource filter is unknown") + normalized_filters: dict[str, list[str]] = {} + for key in ("job_ids", "repository_ids"): + values = resource_filters.get(key) + if values is None: + continue + if not isinstance(values, list) or not values: + raise NotificationEventError("resource filter is invalid") + normalized_filters[key] = [_as_uuid(value, key) for value in values] + severities = resource_filters.get("severities") + if severities is not None: + if not isinstance(severities, list) or not severities: + raise NotificationEventError("severity filter is invalid") + if any(value not in {"info", "warning", "error", "security"} for value in severities): + raise NotificationEventError("severity filter is invalid") + normalized_filters["severities"] = list(severities) + if channel == "webhook": + url = destination.get("url") + if not isinstance(url, str) or len(url) > 2048: + raise NotificationEventError("webhook URL is required") + try: + validate_webhook_url(url) + except SSRFError as error: + raise NotificationEventError("webhook URL is invalid") from error + return {"url": url, "resource_filters": normalized_filters} + if channel == "email": + recipients = destination.get("recipients") + if not isinstance(recipients, list) or not recipients or len(recipients) > 20: + raise NotificationEventError("one to 20 email recipients are required") + safe_recipients: list[str] = [] + for recipient in recipients: + if not isinstance(recipient, str) or any(char in recipient for char in "\r\n"): + raise NotificationEventError("invalid email recipient") + if "@" not in recipient or len(recipient) > 320: + raise NotificationEventError("invalid email recipient") + if recipient not in safe_recipients: + safe_recipients.append(recipient) + return {"recipients": safe_recipients, "resource_filters": normalized_filters} + raise NotificationEventError("unsupported notification channel") + + +def _matches(subscription: NotificationSubscription, event: dict[str, Any]) -> bool: + if subscription.state != "active": + return False + if not any(_is_filter_match(item, str(event["type"])) for item in subscription.event_filters): + return False + filters = subscription.destination_config.get("resource_filters", {}) + if not isinstance(filters, Mapping): + return False + resources = event["resource"] + for key in ("job_ids", "repository_ids"): + selected = filters.get(key) + resource_key = key[:-1] + if selected is not None and resources.get(resource_key) not in selected: + return False + severities = filters.get("severities") + return severities is None or event["severity"] in severities + + +async def emit_event( + db: AsyncSession, + event_type: str, + *, + correlation_id: str, + resource: Mapping[str, str] | None = None, + payload: Mapping[str, Any] | None = None, + severity: str | None = None, + deduplication_key: str | None = None, + occurred_at: datetime | None = None, + only_subscription_id: str | None = None, +) -> NotificationEvent: + """Append an immutable event and matching deliveries; intentionally never commits.""" + if event_type not in EVENT_CATALOG: + raise NotificationEventError("unknown operational event type") + correlation_id = _as_uuid(correlation_id, "correlation_id") + resource = resource or {} + if set(resource) - _SAFE_RESOURCE_KEYS: + raise NotificationEventError("unknown resource reference") + safe_resource = {key: _as_uuid(value, key) for key, value in resource.items()} + payload = payload or {} + if set(payload) - _SAFE_PAYLOAD_KEYS: + raise NotificationEventError("payload key is not allowlisted") + safe_payload = {key: _safe_value(value) for key, value in payload.items()} + event_severity = severity or str(EVENT_CATALOG[event_type]["severity"]) + if event_severity not in {"info", "warning", "error", "security"}: + raise NotificationEventError("invalid severity") + if deduplication_key is not None and (not deduplication_key or len(deduplication_key) > 255): + raise NotificationEventError("invalid deduplication key") + if deduplication_key is not None: + existing = await db.scalar( + select(NotificationEvent).where( + NotificationEvent.deduplication_key == deduplication_key + ) + ) + if existing is not None: + return existing + event_id = str(new_uuid7()) + timestamp = (occurred_at or datetime.now(UTC)).astimezone(UTC) + envelope = { + "event_schema_version": EVENT_SCHEMA_VERSION, + "id": event_id, + "type": event_type, + "occurred_at": timestamp.isoformat(), + "correlation_id": correlation_id, + "severity": event_severity, + "resource": safe_resource, + "payload": safe_payload, + } + event = NotificationEvent( + id=event_id, + type=event_type, + schema_version=EVENT_SCHEMA_VERSION, + occurred_at=timestamp, + correlation_id=correlation_id, + severity=event_severity, + resource_refs=safe_resource, + payload=safe_payload, + canonical_envelope=json.dumps(envelope, sort_keys=True, separators=(",", ":")), + deduplication_key=deduplication_key, + ) + db.add(event) + await db.flush() + active_subscriptions = select(NotificationSubscription).where( + NotificationSubscription.state == "active" + ) + subscriptions = list((await db.scalars(active_subscriptions)).all()) + for subscription in subscriptions: + selected_for_test = only_subscription_id == subscription.id + if selected_for_test or (only_subscription_id is None and _matches(subscription, envelope)): + db.add( + NotificationDelivery( + event_id=event.id, + subscription_id=subscription.id, + due_at=timestamp, + ) + ) + await db.flush() + return event diff --git a/backend/src/backup_tool/notifications/retry.py b/backend/src/backup_tool/notifications/retry.py new file mode 100644 index 0000000..1257131 --- /dev/null +++ b/backend/src/backup_tool/notifications/retry.py @@ -0,0 +1,33 @@ +"""Deterministic bounded retry classification for notification delivery.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class RetryDecision: + retry: bool + response_class: str + reason: str + + +def retry_delay(attempt: int, cap_seconds: int) -> int: + """Bounded exponential delay; no jitter keeps durable tests/restarts deterministic.""" + exponent = max(0, attempt - 1) + return min(cap_seconds, 1 << exponent) + + +def webhook_decision(status_code: int) -> RetryDecision: + if 200 <= status_code < 300: + return RetryDecision(False, "http_2xx", "delivered") + if status_code in {408, 425, 429} or status_code >= 500: + return RetryDecision(True, f"http_{status_code}", "http_transient") + if 300 <= status_code < 400: + return RetryDecision(False, f"http_{status_code}", "redirect_rejected") + return RetryDecision(False, f"http_{status_code}", "http_permanent") + + +def transport_decision(transient: bool, response_class: str) -> RetryDecision: + reason = "transport_transient" if transient else "transport_failed" + return RetryDecision(transient, response_class, reason) diff --git a/backend/src/backup_tool/notifications/webhook.py b/backend/src/backup_tool/notifications/webhook.py new file mode 100644 index 0000000..38c47a7 --- /dev/null +++ b/backend/src/backup_tool/notifications/webhook.py @@ -0,0 +1,210 @@ +"""Canonical, dual-key HMAC webhook requests on a DNS-pinned HTTPX transport.""" + +from __future__ import annotations + +import asyncio +import hmac +import ssl +from collections.abc import Sequence +from dataclasses import dataclass +from hashlib import sha256 + +import httpx + +from backup_tool.security.ssrf import ( + ResolvedWebhookTarget, + Resolver, + SSRFError, + resolve_webhook_target, + verify_connected_peer, +) + +SIGNATURE_VERSION = "v1" + + +class WebhookTransportError(RuntimeError): + def __init__(self, message: str, *, transient: bool = False) -> None: + super().__init__(message) + self.transient = transient + + +@dataclass(frozen=True) +class SigningMaterial: + key_id: str + version: int + secret: str + + +@dataclass(frozen=True) +class WebhookResult: + status_code: int + response_bytes: int + + +def canonical_signing_input(timestamp: str, body: bytes) -> bytes: + return SIGNATURE_VERSION.encode() + b"." + timestamp.encode("ascii") + b"." + body + + +def signatures(timestamp: str, body: bytes, keys: Sequence[SigningMaterial]) -> list[str]: + signing_input = canonical_signing_input(timestamp, body) + return [ + f"{SIGNATURE_VERSION};key_id={key.key_id};key_version={key.version};sha256=" + f"{hmac.new(key.secret.encode(), signing_input, sha256).hexdigest()}" + for key in keys + ] + + +class PinnedWebhookTransport(httpx.AsyncBaseTransport): + """HTTPX transport that never lets a post-validation DNS lookup choose a peer.""" + + def __init__( + self, + *, + target: ResolvedWebhookTarget, + connect_timeout: float, + read_timeout: float, + max_response_bytes: int, + ) -> None: + self._target = target + self._connect_timeout = connect_timeout + self._read_timeout = read_timeout + self._max_response_bytes = max_response_bytes + + async def _connect( + self, target: ResolvedWebhookTarget + ) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]: + hostname = target.url.hostname + if hostname is None: # guarded by resolve_webhook_target + raise WebhookTransportError("webhook hostname is unavailable") + context = ssl.create_default_context() if target.url.scheme == "https" else None + last_error: OSError | None = None + for address in target.addresses: + try: + reader, writer = await asyncio.wait_for( + asyncio.open_connection( + address, + target.port, + ssl=context, + server_hostname=hostname if context is not None else None, + ), + timeout=self._connect_timeout, + ) + verify_connected_peer(writer.get_extra_info("peername"), target.addresses) + return reader, writer + except (TimeoutError, OSError, ssl.SSLError, SSRFError) as error: + last_error = error if isinstance(error, OSError) else None + raise WebhookTransportError("webhook connection failed", transient=True) from last_error + + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + target = self._target + if str(request.url) != target.url.geturl(): + raise WebhookTransportError("webhook target changed") + body = await request.aread() + if len(body) > 65_536: + raise WebhookTransportError("webhook body is too large") + reader, writer = await self._connect(target) + try: + raw_path = target.url.path or "/" + if target.url.query: + raw_path += "?" + target.url.query + headers = [(key, value) for key, value in request.headers.multi_items()] + header_names = {key.lower() for key, _ in headers} + if "host" not in header_names: + host = target.url.hostname or "" + headers.append(("Host", host)) + if "content-length" not in header_names: + headers.append(("Content-Length", str(len(body)))) + headers.append(("Connection", "close")) + serialized = [f"{request.method} {raw_path} HTTP/1.1\r\n".encode()] + serialized.extend(f"{key}: {value}\r\n".encode("ascii") for key, value in headers) + writer.write(b"".join(serialized) + b"\r\n" + body) + await asyncio.wait_for(writer.drain(), timeout=self._read_timeout) + head = await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), timeout=self._read_timeout) + if len(head) > 16_384: + raise WebhookTransportError("webhook response headers are too large") + lines = head.decode("iso-8859-1").split("\r\n") + try: + _protocol, code, _reason = lines[0].split(" ", 2) + status_code = int(code) + except (IndexError, ValueError) as error: + raise WebhookTransportError("webhook response is malformed") from error + response_headers: list[tuple[str, str]] = [] + for line in lines[1:]: + if not line: + continue + key, separator, value = line.partition(":") + if not separator: + raise WebhookTransportError("webhook response headers are malformed") + response_headers.append((key.strip(), value.strip())) + content = await asyncio.wait_for( + reader.read(self._max_response_bytes + 1), timeout=self._read_timeout + ) + if len(content) > self._max_response_bytes: + raise WebhookTransportError("webhook response is too large") + return httpx.Response( + status_code, + headers=response_headers, + content=content, + request=request, + ) + except (TimeoutError, OSError, asyncio.IncompleteReadError) as error: + raise WebhookTransportError("webhook request failed", transient=True) from error + finally: + writer.close() + with __import__("contextlib").suppress(OSError): + await writer.wait_closed() + + +def webhook_headers( + event_id: str, + event_type: str, + timestamp: str, + body: bytes, + keys: Sequence[SigningMaterial], +) -> list[tuple[str, str]]: + headers: list[tuple[str, str]] = [ + ("Content-Type", "application/json"), + ("X-Backup-Event-ID", event_id), + ("X-Backup-Event-Type", event_type), + ("X-Backup-Signature-Version", SIGNATURE_VERSION), + ("X-Backup-Timestamp", timestamp), + ] + headers.extend(("X-Backup-Signature", value) for value in signatures(timestamp, body, keys)) + return headers + + +async def deliver_webhook( + url: str, + body: bytes, + *, + event_id: str, + event_type: str, + timestamp: str, + keys: Sequence[SigningMaterial], + resolver: Resolver, + connect_timeout: float, + read_timeout: float, + max_response_bytes: int, +) -> WebhookResult: + if not keys: + raise WebhookTransportError("webhook subscription has no active signing key") + try: + # Resolve immediately before this individual attempt. The resulting + # addresses are passed to the transport, so it cannot rebind at connect. + target = await resolve_webhook_target(url, resolver) + except SSRFError as error: + raise WebhookTransportError("webhook_target_rejected") from error + transport = PinnedWebhookTransport( + target=target, + connect_timeout=connect_timeout, + read_timeout=read_timeout, + max_response_bytes=max_response_bytes, + ) + headers = webhook_headers(event_id, event_type, timestamp, body, keys) + async with httpx.AsyncClient( + transport=transport, follow_redirects=False, trust_env=False + ) as client: + response = await client.post(url, content=body, headers=headers) + if 300 <= response.status_code < 400: + raise WebhookTransportError("redirect_rejected") + return WebhookResult(status_code=response.status_code, response_bytes=len(response.content)) diff --git a/backend/src/backup_tool/observability/__init__.py b/backend/src/backup_tool/observability/__init__.py new file mode 100644 index 0000000..b520077 --- /dev/null +++ b/backend/src/backup_tool/observability/__init__.py @@ -0,0 +1 @@ +"""Operational logging, metrics, and readiness primitives.""" diff --git a/backend/src/backup_tool/observability/health.py b/backend/src/backup_tool/observability/health.py new file mode 100644 index 0000000..88e350e --- /dev/null +++ b/backend/src/backup_tool/observability/health.py @@ -0,0 +1,39 @@ +"""Readiness checks shared by HTTP and background process roles.""" + +from __future__ import annotations + +import os + +from backup_tool.cli import build_alembic_config +from backup_tool.config import Settings +from backup_tool.db.engine import assert_schema_current, create_engine + + +class ReadinessError(RuntimeError): + """A dependency needed by the selected role is unavailable.""" + + +def _require_access(path: object, mode: int) -> None: + try: + candidate = path if isinstance(path, str) else str(path) + if not os.path.isdir(candidate) or not os.access(candidate, mode): + raise ReadinessError("required storage is unavailable") + except OSError as error: + raise ReadinessError("required storage is unavailable") from error + + +async def check_role_readiness(settings: Settings, role: str) -> None: + engine = create_engine(settings) + try: + await assert_schema_current(engine, build_alembic_config(settings)) + except Exception as error: + raise ReadinessError("metadata is unavailable") from error + finally: + await engine.dispose() + if role == "scheduler": + return + _require_access(settings.data_dir, os.R_OK | os.W_OK | os.X_OK) + for root in settings.repository_roots + settings.restore_roots: + _require_access(root, os.R_OK | os.W_OK | os.X_OK) + for root in settings.local_source_roots: + _require_access(root, os.R_OK | os.X_OK) diff --git a/backend/src/backup_tool/observability/logging.py b/backend/src/backup_tool/observability/logging.py new file mode 100644 index 0000000..f1e21e1 --- /dev/null +++ b/backend/src/backup_tool/observability/logging.py @@ -0,0 +1,39 @@ +"""JSON logging that keeps operational context machine-readable and secret-free.""" + +from __future__ import annotations + +import json +import logging +import sys +from datetime import UTC, datetime +from typing import Any + +_STANDARD_RECORD_KEYS = frozenset(logging.makeLogRecord({}).__dict__) + + +class JsonFormatter(logging.Formatter): + def format(self, record: logging.LogRecord) -> str: + payload: dict[str, Any] = { + "event": record.getMessage(), + "level": record.levelname.lower(), + "logger": record.name, + "timestamp": datetime.now(UTC).isoformat(), + } + for key, value in record.__dict__.items(): + if key not in _STANDARD_RECORD_KEYS and key not in {"message", "asctime"}: + payload[key] = value + return json.dumps(payload, default=str, separators=(",", ":"), sort_keys=True) + + +def configure_logging(role: str, level: str) -> None: + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter(JsonFormatter()) + root = logging.getLogger() + root.handlers.clear() + root.addHandler(handler) + root.setLevel(level) + logging.getLogger("backup_tool").info("role_started", extra={"role": role}) + + +def log_event(name: str, **fields: object) -> None: + logging.getLogger("backup_tool").info(name, extra=fields) diff --git a/backend/src/backup_tool/observability/metrics.py b/backend/src/backup_tool/observability/metrics.py new file mode 100644 index 0000000..93b5c78 --- /dev/null +++ b/backend/src/backup_tool/observability/metrics.py @@ -0,0 +1,102 @@ +"""Small dependency-free Prometheus exposition for the single-node appliance.""" + +from __future__ import annotations + +import os +import threading +from collections import defaultdict +from collections.abc import Iterable +from datetime import UTC, datetime + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from backup_tool.config import Settings +from backup_tool.db.models import Backup, Execution, Repository, Schedule +from backup_tool.execution import ACTIVE_STATES + + +class Metrics: + def __init__(self) -> None: + self._lock = threading.Lock() + self._requests: dict[tuple[str, str, int], int] = defaultdict(int) + self._durations: dict[tuple[str, str], tuple[int, float]] = {} + + def observe_request(self, method: str, path: str, status: int, duration_seconds: float) -> None: + route = path if path in {"/livez", "/readyz", "/metrics"} else "/api" + with self._lock: + self._requests[(method, route, status)] += 1 + count, total = self._durations.get((method, route), (0, 0.0)) + self._durations[(method, route)] = (count + 1, total + duration_seconds) + + def render(self, operational: Iterable[tuple[str, float]]) -> str: + lines = [ + "# HELP backup_tool_http_requests_total HTTP requests handled by the web role.", + "# TYPE backup_tool_http_requests_total counter", + ] + with self._lock: + for (method, path, status), value in sorted(self._requests.items()): + labels = f'method="{method}",path="{path}",status="{status}"' + lines.append(f"backup_tool_http_requests_total{{{labels}}} {value}") + lines.extend( + [ + "# HELP backup_tool_http_request_duration_seconds HTTP request duration.", + "# TYPE backup_tool_http_request_duration_seconds summary", + ] + ) + for (method, path), (count, total) in sorted(self._durations.items()): + labels = f'method="{method}",path="{path}"' + lines.append(f"backup_tool_http_request_duration_seconds_count{{{labels}}} {count}") + lines.append( + f"backup_tool_http_request_duration_seconds_sum{{{labels}}} {total:.6f}" + ) + lines.extend(f"{name} {value}" for name, value in operational) + return "\n".join(lines) + "\n" + + +async def collect_operational_metrics( + settings: Settings, db: AsyncSession +) -> list[tuple[str, float]]: + now = datetime.now(UTC) + active = await db.scalar( + select(func.count()).select_from(Execution).where(Execution.state.in_(ACTIVE_STATES)) + ) + stale = await db.scalar( + select(func.count()) + .select_from(Execution) + .where(Execution.lease_expires_at.is_not(None), Execution.lease_expires_at < now) + ) + failed = await db.scalar( + select(func.count()).select_from(Execution).where(Execution.state == "failed") + ) + corrupt = await db.scalar( + select(func.count()).select_from(Backup).where(Backup.integrity == "corrupt") + ) + schedule_lag = await db.scalar( + select(func.min(Schedule.next_nominal_at)).where( + Schedule.enabled, Schedule.next_nominal_at.is_not(None) + ) + ) + values = [ + ("backup_tool_active_executions", active or 0), + ("backup_tool_stale_execution_leases", stale or 0), + ("backup_tool_failed_executions", failed or 0), + ("backup_tool_corrupt_backups", corrupt or 0), + ( + "backup_tool_schedule_lag_seconds", + max(0.0, (now - schedule_lag).total_seconds()) if schedule_lag is not None else 0.0, + ), + ] + roots = list(settings.repository_roots) + list(settings.restore_roots) + for index, root in enumerate(roots): + try: + stats = os.statvfs(root) + except OSError: + continue + name = f'backup_tool_filesystem_free_bytes{{root="{index}"}}' + values.append((name, stats.f_bavail * stats.f_frsize)) + unavailable = await db.scalar( + select(func.count()).select_from(Repository).where(Repository.state == "unavailable") + ) + values.append(("backup_tool_unavailable_repositories", unavailable or 0)) + return values diff --git a/backup_tool.db b/backend/src/backup_tool/py.typed similarity index 100% rename from backup_tool.db rename to backend/src/backup_tool/py.typed diff --git a/backend/src/backup_tool/repository.py b/backend/src/backup_tool/repository.py new file mode 100644 index 0000000..27d0f59 --- /dev/null +++ b/backend/src/backup_tool/repository.py @@ -0,0 +1,512 @@ +from __future__ import annotations + +import hashlib +import json +import os +import shutil +import stat +from contextlib import suppress +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from sqlalchemy.ext.asyncio import AsyncSession + +from backup_tool.config import Settings +from backup_tool.ids import new_uuid7 +from backup_tool.security.repository_crypto import RepositoryKeyError, create_data_key + + +class RepositoryError(ValueError): + pass + + +@dataclass(frozen=True) +class InitializedRepository: + root: Path + repository_id: str + format_version: int = 1 + compression: str = "none" + encryption: str = "none" + signing_key_id: str = "" + signing_public_key: str = "" + signing_key_path: Path | None = None + data_key_id: str | None = None + data_key_path: Path | None = None + + +def blob_digest(content: bytes) -> str: + return hashlib.sha256(content).hexdigest() + + +def _contained(root: Path, relative_path: str) -> Path: + relative = Path(relative_path) + if not relative_path or relative.is_absolute(): + raise RepositoryError("repository path must be a non-empty relative child") + lexical = root + for part in relative.parts: + lexical /= part + if lexical.is_symlink(): + raise RepositoryError("repository path contains a symlink") + candidate = lexical.resolve() + resolved_root = root.resolve() + if candidate == resolved_root: + raise RepositoryError("repository path must be a non-empty relative child") + if resolved_root not in candidate.parents: + raise RepositoryError("repository path escapes configured roots") + return candidate + + +def _canonical_payload(compression: str, encryption: str) -> dict[str, object]: + if compression != "none" or encryption not in {"none", "aes-256-gcm"}: + raise RepositoryError("requested repository policy is unavailable") + return { + "repository_id": str(new_uuid7()), + "format_version": 1, + "digest_algorithm": "sha256", + "compression": compression, + "encryption": {"mode": encryption, "key_id": None}, + "created_at": datetime.now(UTC).isoformat(timespec="microseconds").replace("+00:00", "Z"), + } + + +def _canonical_json(payload: dict[str, object]) -> str: + return json.dumps(payload, sort_keys=True, separators=(",", ":")) + "\n" + + +def replace_active_data_key(root: Path, expected_key_id: str, new_key_id: str) -> None: + """Durably replace the active, non-secret epoch reference in repository metadata.""" + metadata = root / "repository.json" + try: + payload = json.loads(metadata.read_text(encoding="utf-8")) + encryption = payload.get("encryption") if isinstance(payload, dict) else None + if ( + not isinstance(encryption, dict) + or encryption.get("mode") != "aes-256-gcm" + or encryption.get("key_id") != expected_key_id + ): + raise ValueError + except (OSError, ValueError, json.JSONDecodeError) as error: + raise RepositoryError("repository metadata is invalid") from error + encryption["key_id"] = new_key_id + staging = root / f".repository.json.{os.urandom(8).hex()}.tmp" + try: + descriptor = os.open(staging, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + handle.write(_canonical_json(payload)) + handle.flush() + os.fsync(handle.fileno()) + os.replace(staging, metadata) + descriptor = os.open(root, os.O_RDONLY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + except OSError as error: + raise RepositoryError("repository metadata cannot be updated") from error + finally: + staging.unlink(missing_ok=True) + + +_ROTATION_JOURNAL = ".key-rotation.json" + + +def begin_key_rotation( + root: Path, + repository_database_id: str, + repository_id: str, + old_key_id: str, + new_key_id: str, +) -> None: + """Persist an intent record before changing durable key-epoch state.""" + if not all((repository_database_id, repository_id, old_key_id, new_key_id)): + raise RepositoryError("repository rotation journal is invalid") + journal = root / _ROTATION_JOURNAL + payload: dict[str, object] = { + "new_key_id": new_key_id, + "old_key_id": old_key_id, + "repository_database_id": repository_database_id, + "repository_id": repository_id, + "version": 1, + } + try: + descriptor = os.open(journal, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + handle.write(_canonical_json(payload)) + handle.flush() + os.fsync(handle.fileno()) + descriptor = os.open(root, os.O_RDONLY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + except OSError as error: + raise RepositoryError("repository rotation is already in progress") from error + + +def read_key_rotation(root: Path) -> dict[str, str] | None: + journal = root / _ROTATION_JOURNAL + if not journal.exists(): + return None + try: + if journal.is_symlink() or stat.S_IMODE(journal.stat().st_mode) != 0o600: + raise ValueError + payload = json.loads(journal.read_text(encoding="utf-8")) + expected = { + "new_key_id", + "old_key_id", + "repository_database_id", + "repository_id", + "version", + } + if ( + not isinstance(payload, dict) + or set(payload) != expected + or payload.get("version") != 1 + or not all( + isinstance(payload[key], str) and payload[key] for key in expected - {"version"} + ) + ): + raise ValueError + return {key: payload[key] for key in expected - {"version"}} + except (OSError, ValueError, json.JSONDecodeError) as error: + raise RepositoryError("repository rotation journal is invalid") from error + + +def finish_key_rotation(root: Path) -> None: + journal = root / _ROTATION_JOURNAL + try: + journal.unlink() + descriptor = os.open(root, os.O_RDONLY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + except OSError as error: + raise RepositoryError("repository rotation journal cannot be cleared") from error + + +async def reconcile_key_rotations(settings: Settings, db: AsyncSession) -> int: + """Converge a journaled key rotation after a process crash. + + A rotation whose DB transaction did not commit remains on the old active + epoch. A committed transaction deterministically advances repository.json. + """ + from sqlalchemy import select + + from backup_tool.db.models import Repository, RepositoryDataKeyEpoch + from backup_tool.security.repository_crypto import load_data_key + + repositories = list((await db.scalars(select(Repository))).all()) + reconciled = 0 + for repository in repositories: + if repository.encryption != "aes-256-gcm": + continue + root = Path(repository.root) + journal = read_key_rotation(root) + if journal is None: + continue + inspected = inspect_repository(settings, root) + if ( + journal["repository_database_id"] != repository.id + or journal["repository_id"] != inspected.repository_id + or repository.active_data_key_id is None + ): + raise RepositoryError("repository rotation journal does not match metadata") + epochs = list( + ( + await db.scalars( + select(RepositoryDataKeyEpoch).where( + RepositoryDataKeyEpoch.repository_id == repository.id + ) + ) + ).all() + ) + active = [epoch for epoch in epochs if epoch.state == "active"] + old_key_id = journal["old_key_id"] + new_key_id = journal["new_key_id"] + if len(active) != 1: + raise RepositoryError("repository rotation epochs are invalid") + if repository.active_data_key_id == old_key_id and active[0].key_id == old_key_id: + if inspected.data_key_id != old_key_id: + raise RepositoryError("repository rotation metadata is invalid") + new_path = ( + settings.data_dir + / "repository-data-keys" + / (f"{inspected.repository_id}.{new_key_id}.key") + ) + # Clear the durable intent before deleting an unreferenced key. A + # crash afterward leaves only an orphaned key, not a journal whose + # retry depends on a key that no longer exists. + if new_path.exists() or new_path.is_symlink(): + try: + load_data_key(settings, inspected.repository_id, new_key_id) + except RepositoryKeyError as error: + raise RepositoryError("repository rotation key is unavailable") from error + finish_key_rotation(inspected.root) + if new_path.exists() or new_path.is_symlink(): + # The journal is already gone, so a cleanup failure only leaves + # an unreferenced key and must not disable the old-active epoch. + with suppress(OSError): + new_path.unlink() + descriptor = os.open(new_path.parent, os.O_RDONLY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + reconciled += 1 + continue + if repository.active_data_key_id == new_key_id and active[0].key_id == new_key_id: + try: + load_data_key(settings, inspected.repository_id, new_key_id) + except RepositoryKeyError as error: + raise RepositoryError("repository rotation key is unavailable") from error + if inspected.data_key_id == old_key_id: + replace_active_data_key(inspected.root, old_key_id, new_key_id) + elif inspected.data_key_id != new_key_id: + raise RepositoryError("repository rotation metadata is invalid") + finish_key_rotation(inspected.root) + reconciled += 1 + continue + raise RepositoryError("repository rotation state is invalid") + return reconciled + + +def assert_capacity(settings: Settings, root: Path) -> None: + capacity_root = root + while not capacity_root.exists(): + parent = capacity_root.parent + if parent == capacity_root: + raise RepositoryError("repository root is unreadable") + capacity_root = parent + usage = shutil.disk_usage(capacity_root) + free_percent = usage.free * 100 / usage.total + if usage.free < settings.min_free_bytes or free_percent < settings.min_free_percent: + raise RepositoryError("repository root does not meet minimum free capacity") + + +def _signing_key_directory(settings: Settings) -> Path: + directory = settings.data_dir / "repository-keys" + directory.mkdir(mode=0o700, exist_ok=True) + if directory.is_symlink() or not directory.is_dir(): + raise RepositoryError("repository signing key directory is unsafe") + if stat.S_IMODE(directory.stat().st_mode) != 0o700: + raise RepositoryError("repository signing key directory permissions must be 0700") + return directory + + +def _write_private_key_staging(directory: Path, repository_id: str, private_key: bytes) -> Path: + staging = directory / f".{repository_id}.{os.urandom(8).hex()}.tmp" + descriptor = os.open(staging, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(descriptor, "wb") as handle: + handle.write(private_key) + handle.flush() + os.fsync(handle.fileno()) + return staging + + +def initialize( + settings: Settings, relative_path: str, compression: str, encryption: str +) -> InitializedRepository: + payload = _canonical_payload(compression, encryption) + repository_id = str(payload["repository_id"]) + data_key_id: str | None = None + data_key_path: Path | None = None + if encryption == "aes-256-gcm": + try: + data_key_id, data_key_path = create_data_key(settings, repository_id) + except RepositoryKeyError as error: + raise RepositoryError("repository data key is unavailable") from error + payload["encryption"] = {"mode": encryption, "key_id": data_key_id} + private_key = Ed25519PrivateKey.generate() + private_bytes = private_key.private_bytes( + serialization.Encoding.Raw, + serialization.PrivateFormat.Raw, + serialization.NoEncryption(), + ) + public_bytes = private_key.public_key().public_bytes( + serialization.Encoding.Raw, + serialization.PublicFormat.Raw, + ) + signing_key_id = f"ed25519-{hashlib.sha256(public_bytes).hexdigest()[:32]}" + key_directory = _signing_key_directory(settings) + key_path = key_directory / f"{repository_id}.ed25519" + key_staging = _write_private_key_staging(key_directory, repository_id, private_bytes) + root = _contained(settings.repository_roots[0], relative_path) + assert_capacity(settings, root.parent) + if root.exists(): + key_staging.unlink(missing_ok=True) + raise RepositoryError("repository path already exists") + staging = root.with_name(f".{root.name}.staging-{os.urandom(8).hex()}") + try: + staging.mkdir(parents=True) + (staging / "blobs" / "sha256").mkdir(parents=True) + (staging / "manifests").mkdir() + metadata = staging / "repository.json" + metadata.write_text(_canonical_json(payload), encoding="utf-8") + with metadata.open("rb") as handle: + os.fsync(handle.fileno()) + os.replace(staging, root) + os.replace(key_staging, key_path) + except Exception: + shutil.rmtree(staging, ignore_errors=True) + shutil.rmtree(root, ignore_errors=True) + key_staging.unlink(missing_ok=True) + key_path.unlink(missing_ok=True) + if data_key_path is not None: + data_key_path.unlink(missing_ok=True) + raise + return InitializedRepository( + root=root, + repository_id=repository_id, + compression=compression, + encryption=encryption, + signing_key_id=signing_key_id, + signing_public_key=public_bytes.hex(), + signing_key_path=key_path, + data_key_id=data_key_id, + data_key_path=data_key_path, + ) + + +def remove_repository( + root: Path, + signing_key_path: Path | None = None, + data_key_path: Path | None = None, +) -> None: + try: # noqa: SIM105 - cleanup must not race a concurrent remover + shutil.rmtree(root) + except FileNotFoundError: + pass + for key_path in (signing_key_path, data_key_path): + if key_path is None: + continue + try: # noqa: SIM105 - cleanup must not race a concurrent remover + key_path.unlink() + except FileNotFoundError: + pass + + +def install_signing_key( + settings: Settings, + repository_id: str, + expected_key_id: str, + expected_public_key: str, + private_bytes: bytes, +) -> Path: + """Install a recovered signing key once after checking its public trust anchor.""" + if len(private_bytes) != 32 or "/" in repository_id: + raise RepositoryError("repository signing key is invalid") + try: + private_key = Ed25519PrivateKey.from_private_bytes(private_bytes) + public_bytes = private_key.public_key().public_bytes( + serialization.Encoding.Raw, + serialization.PublicFormat.Raw, + ) + except ValueError as error: + raise RepositoryError("repository signing key is invalid") from error + key_id = f"ed25519-{hashlib.sha256(public_bytes).hexdigest()[:32]}" + if key_id != expected_key_id or public_bytes.hex() != expected_public_key: + raise RepositoryError("repository signing key is invalid") + path = _signing_key_directory(settings) / f"{repository_id}.ed25519" + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + try: + descriptor = os.open(path, flags, 0o600) + with os.fdopen(descriptor, "wb") as handle: + handle.write(private_bytes) + handle.flush() + os.fsync(handle.fileno()) + if path.is_symlink() or stat.S_IMODE(path.stat().st_mode) != 0o600: + path.unlink(missing_ok=True) + raise RepositoryError("repository signing key is unsafe") + except OSError as error: + raise RepositoryError("repository signing key cannot be installed") from error + return path + + +def load_signing_key( + settings: Settings, + repository_id: str, + expected_key_id: str, + expected_public_key: str, +) -> Ed25519PrivateKey: + key_path = _signing_key_directory(settings) / f"{repository_id}.ed25519" + try: + if key_path.is_symlink() or stat.S_IMODE(key_path.stat().st_mode) != 0o600: + raise RepositoryError("repository signing key is unsafe") + private_key = Ed25519PrivateKey.from_private_bytes(key_path.read_bytes()) + except (OSError, ValueError) as error: + raise RepositoryError("repository signing key is unreadable") from error + public_key = private_key.public_key().public_bytes( + serialization.Encoding.Raw, + serialization.PublicFormat.Raw, + ) + key_id = f"ed25519-{hashlib.sha256(public_key).hexdigest()[:32]}" + if key_id != expected_key_id or public_key.hex() != expected_public_key: + raise RepositoryError("repository signing key does not match its trust anchor") + return private_key + + +def inspect_repository(settings: Settings, root: Path) -> InitializedRepository: + try: + resolved_root = root.resolve(strict=True) + except OSError as error: + raise RepositoryError("repository root is unreadable") from error + if root.is_symlink() or not any( + resolved_root != allowed.resolve() and allowed.resolve() in resolved_root.parents + for allowed in settings.repository_roots + ): + raise RepositoryError("repository root escapes configured roots") + metadata = resolved_root / "repository.json" + try: + raw = metadata.read_text(encoding="utf-8") + payload = json.loads(raw) + except (OSError, json.JSONDecodeError) as error: + raise RepositoryError("repository metadata is unreadable") from error + if not isinstance(payload, dict): + raise RepositoryError("repository metadata is invalid") + required = { + "repository_id", + "format_version", + "digest_algorithm", + "compression", + "encryption", + "created_at", + } + if ( + set(payload) != required + or payload.get("format_version") != 1 + or payload.get("digest_algorithm") != "sha256" + ): + raise RepositoryError("repository metadata is not canonical") + encryption = payload.get("encryption") + if payload.get("compression") != "none" or not isinstance(encryption, dict): + raise RepositoryError("repository metadata is invalid") + mode = encryption.get("mode") + key_id = encryption.get("key_id") + valid_policy = (mode == "none" and key_id is None) or ( + mode == "aes-256-gcm" and isinstance(key_id, str) and bool(key_id) + ) + if not valid_policy: + raise RepositoryError("repository metadata is invalid") + try: + from uuid import UUID + + UUID(str(payload["repository_id"])) + created_at = str(payload["created_at"]) + if not created_at.endswith("Z"): + raise ValueError + datetime.fromisoformat(created_at[:-1] + "+00:00") + except (ValueError, TypeError) as error: + raise RepositoryError("repository metadata is invalid") from error + return InitializedRepository( + root=resolved_root, + repository_id=str(payload["repository_id"]), + compression="none", + encryption=str(encryption["mode"]), + data_key_id=key_id if isinstance(key_id, str) else None, + ) diff --git a/backend/src/backup_tool/retention.py b/backend/src/backup_tool/retention.py new file mode 100644 index 0000000..523a92e --- /dev/null +++ b/backend/src/backup_tool/retention.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from typing import Protocol + + +class RetentionError(ValueError): + pass + + +class BackupLike(Protocol): + id: str + created_at: datetime + pinned: bool + tombstoned_at: datetime | None + + +@dataclass(frozen=True) +class RetentionPolicy: + keep_last: int = 1 + keep_days: int = 0 + keep_daily: int = 0 + keep_weekly: int = 0 + keep_monthly: int = 0 + + @classmethod + def from_dict(cls, value: dict[str, object]) -> RetentionPolicy: + allowed = {"keep_last", "keep_days", "keep_daily", "keep_weekly", "keep_monthly"} + if set(value) - allowed: + raise RetentionError("retention policy contains unknown keys") + kwargs: dict[str, int] = {} + for key in allowed: + raw = value.get(key, 0 if key != "keep_last" else 1) + if not isinstance(raw, int) or isinstance(raw, bool) or raw < 0: + raise RetentionError("retention values must be non-negative integers") + kwargs[key] = raw + return cls(**kwargs) + + +def retained_ids( + backups: Iterable[BackupLike], policy: RetentionPolicy, now: datetime | None = None +) -> set[str]: + """Return union retention set; the newest non-tombstoned backup is always protected.""" + reference = (now or datetime.now(UTC)).astimezone(UTC) + items = sorted( + (item for item in backups if item.tombstoned_at is None), + key=lambda item: item.created_at.astimezone(UTC), + reverse=True, + ) + if not items: + return set() + kept = {items[0].id} + kept.update(item.id for item in items[: policy.keep_last]) + kept.update(item.id for item in items if item.pinned) + if policy.keep_days: + cutoff = reference - timedelta(days=policy.keep_days) + kept.update(item.id for item in items if item.created_at.astimezone(UTC) >= cutoff) + for count, key in ( + (policy.keep_daily, lambda stamp: stamp.date()), + (policy.keep_weekly, lambda stamp: stamp.isocalendar()[:2]), + (policy.keep_monthly, lambda stamp: (stamp.year, stamp.month)), + ): + buckets: set[object] = set() + for item in items: + stamp = item.created_at.astimezone(UTC) + bucket = key(stamp) + if len(buckets) >= count and bucket not in buckets: + continue + buckets.add(bucket) + if bucket in buckets: + kept.add(item.id) + return kept diff --git a/backend/src/backup_tool/scheduler.py b/backend/src/backup_tool/scheduler.py new file mode 100644 index 0000000..4fdb1a9 --- /dev/null +++ b/backend/src/backup_tool/scheduler.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +import asyncio +import contextlib +import signal +from datetime import UTC, datetime +from typing import cast +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from apscheduler.triggers.cron import CronTrigger # type: ignore[import-untyped] +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from backup_tool.config import Settings +from backup_tool.db.engine import create_engine +from backup_tool.db.models import Schedule +from backup_tool.execution import EnqueueError, enqueue +from backup_tool.notifications.events import emit_event +from backup_tool.observability.logging import configure_logging, log_event + + +class ScheduleError(ValueError): + pass + + +def next_nominal(cron: str, timezone: str, after: datetime | None = None) -> datetime: + if len(cron.split()) != 5: + raise ScheduleError("cron must contain exactly five fields") + try: + zone = ZoneInfo(timezone) + trigger = CronTrigger.from_crontab(cron, timezone=zone) + except (ValueError, ZoneInfoNotFoundError) as error: + raise ScheduleError("cron or timezone is invalid") from error + reference = (after or datetime.now(UTC)).astimezone(zone) + next_run = trigger.get_next_fire_time(None, reference) + if next_run is None: + raise ScheduleError("cron has no future occurrence") + return cast(datetime, next_run.astimezone(UTC)) + + +class SchedulerService: + """Dedicated scheduler role using the same transactional enqueue service.""" + + def __init__(self, settings: Settings) -> None: + self.engine = create_engine(settings) + self.sessions = async_sessionmaker(self.engine, expire_on_commit=False) + self._stopping = asyncio.Event() + + async def run_once(self) -> int: + async with self.sessions() as db: + return await deliver_due(db) + + async def run(self) -> None: + while not self._stopping.is_set(): + await self.run_once() + with contextlib.suppress(TimeoutError): + await asyncio.wait_for(self._stopping.wait(), timeout=0.25) + await self.engine.dispose() + + def stop(self) -> None: + log_event("role_stopping", role="scheduler") + self._stopping.set() + + +def run_scheduler(settings: Settings) -> int: + configure_logging("scheduler", settings.log_level) + service = SchedulerService(settings) + loop = asyncio.new_event_loop() + for sig in (signal.SIGINT, signal.SIGTERM): + with contextlib.suppress(NotImplementedError): + loop.add_signal_handler(sig, service.stop) + try: + loop.run_until_complete(service.run()) + finally: + loop.close() + log_event("role_stopped", role="scheduler") + return 0 + + +async def deliver_due(db: AsyncSession, now: datetime | None = None) -> int: + current = now or datetime.now(UTC) + schedules = list( + ( + await db.scalars( + select(Schedule).where( + Schedule.enabled, + Schedule.next_nominal_at.is_not(None), + Schedule.next_nominal_at <= current, + ) + ) + ).all() + ) + delivered = 0 + for schedule in schedules: + nominal = schedule.next_nominal_at + if nominal is None: + continue + schedule.next_nominal_at = next_nominal(schedule.cron, schedule.timezone, nominal) + if (current - nominal).total_seconds() > schedule.misfire_grace_seconds: + schedule.last_enqueue_outcome = "misfire" + await emit_event( + db, + "schedule.occurrence_misfired", + correlation_id=schedule.id, + resource={"schedule_id": schedule.id, "job_id": schedule.job_id}, + payload={"outcome": "misfire"}, + deduplication_key=f"schedule:{schedule.id}:nominal:{nominal.isoformat()}:misfire", + ) + continue + try: + await enqueue( + db, + schedule.job_id, + "schedule", + schedule_id=schedule.id, + nominal_run_at=nominal, + ) + except EnqueueError as error: + schedule.last_enqueue_outcome = error.code + await emit_event( + db, + "schedule.occurrence_blocked", + correlation_id=schedule.id, + resource={"schedule_id": schedule.id, "job_id": schedule.job_id}, + payload={"reason_code": error.code}, + deduplication_key=f"schedule:{schedule.id}:nominal:{nominal.isoformat()}:blocked", + ) + else: + schedule.last_enqueue_outcome = "enqueued" + await emit_event( + db, + "schedule.occurrence_enqueued", + correlation_id=schedule.id, + resource={"schedule_id": schedule.id, "job_id": schedule.job_id}, + payload={"outcome": "enqueued"}, + deduplication_key=f"schedule:{schedule.id}:nominal:{nominal.isoformat()}:enqueued", + ) + delivered += 1 + await db.commit() + return delivered diff --git a/backend/src/backup_tool/security/__init__.py b/backend/src/backup_tool/security/__init__.py new file mode 100644 index 0000000..37c890a --- /dev/null +++ b/backend/src/backup_tool/security/__init__.py @@ -0,0 +1 @@ +"""Security primitives and authentication services.""" diff --git a/backend/src/backup_tool/security/auth.py b/backend/src/backup_tool/security/auth.py new file mode 100644 index 0000000..ba68265 --- /dev/null +++ b/backend/src/backup_tool/security/auth.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import secrets +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any + +from argon2 import PasswordHasher +from argon2.exceptions import VerifyMismatchError + +_PASSWORDS = PasswordHasher() + + +def hash_password(password: str) -> str: + return _PASSWORDS.hash(password) + + +def verify_password(password_hash: str, password: str) -> bool: + try: + return _PASSWORDS.verify(password_hash, password) + except VerifyMismatchError: + return False + + +def hash_token(token: str) -> str: + return hashlib.sha256(token.encode()).hexdigest() + + +def new_token() -> str: + return secrets.token_urlsafe(32) + + +def new_csrf_token() -> str: + return secrets.token_urlsafe(32) + + +def _session_key(master_key_file: Path) -> bytes: + return hashlib.sha256(b"backup-tool-session\0" + master_key_file.read_bytes()).digest() + + +def sign_session( + user_id: str, + csrf: str, + master_key_file: Path, + *, + expires_at: datetime | None = None, + session_id: str | None = None, +) -> str: + issued_at = datetime.now(UTC) + expires_at = expires_at or issued_at.replace(microsecond=0) + timedelta(hours=8) + payload = json.dumps( + { + "sub": user_id, + "csrf": csrf, + "iat": issued_at.isoformat(), + "exp": expires_at.astimezone(UTC).isoformat(), + "sid": session_id or secrets.token_urlsafe(24), + }, + separators=(",", ":"), + sort_keys=True, + ).encode() + encoded = base64.urlsafe_b64encode(payload).decode().rstrip("=") + signature = hmac.new( + _session_key(master_key_file), encoded.encode(), hashlib.sha256 + ).hexdigest() + return f"{encoded}.{signature}" + + +def verify_session(value: str, master_key_file: Path) -> dict[str, Any] | None: + try: + encoded, supplied = value.rsplit(".", 1) + expected = hmac.new( + _session_key(master_key_file), encoded.encode(), hashlib.sha256 + ).hexdigest() + if not hmac.compare_digest(supplied, expected): + return None + padded = encoded + "=" * (-len(encoded) % 4) + decoded = json.loads(base64.urlsafe_b64decode(padded)) + if ( + not isinstance(decoded, dict) + or not isinstance(decoded.get("sub"), str) + or not isinstance(decoded.get("sid"), str) + or not isinstance(decoded.get("exp"), str) + or datetime.fromisoformat(decoded["exp"]).astimezone(UTC) <= datetime.now(UTC) + ): + return None + return decoded + except (ValueError, json.JSONDecodeError, UnicodeDecodeError): + return None diff --git a/backend/src/backup_tool/security/recovery_bundle.py b/backend/src/backup_tool/security/recovery_bundle.py new file mode 100644 index 0000000..98883ec --- /dev/null +++ b/backend/src/backup_tool/security/recovery_bundle.py @@ -0,0 +1,249 @@ +"""Offline, passphrase-protected recovery bundle codec. + +The binary format is deliberately small and versioned so validation can reject +unsupported inputs before attempting expensive password derivation. Every +failure while parsing or authenticating a bundle is reported as the same error +so callers cannot distinguish a malformed bundle from a wrong passphrase. +""" + +from __future__ import annotations + +import json +import os +import stat +import struct +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +from argon2.low_level import Type, hash_secret_raw +from cryptography.exceptions import InvalidTag +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + + +class RecoveryBundleError(ValueError): + """A non-disclosing recovery bundle validation failure.""" + + +class RecoveryBundlePathError(ValueError): + """A requested recovery bundle path cannot be used safely.""" + + +_MAGIC = b"BTREC" +_VERSION = 1 +_KDF_ARGON2ID = 1 +_SALT_BYTES = 16 +_NONCE_BYTES = 12 +_KEY_BYTES = 32 +_TAG_BYTES = 16 +_TIME_COST = 3 +_MEMORY_COST_KIB = 65_536 +_PARALLELISM = 1 +_MAX_PASSPHRASE_BYTES = 4_096 +_MAX_PLAINTEXT_BYTES = 8 * 1024 * 1024 +# magic, version, KDF id, Argon2 time/memory/parallelism, salt/nonce lengths, +# and the AES-GCM ciphertext (including tag) length. +_HEADER = struct.Struct(">5sBBIIHBBQ") +_MAX_BUNDLE_BYTES = _HEADER.size + _SALT_BYTES + _NONCE_BYTES + _MAX_PLAINTEXT_BYTES + _TAG_BYTES +_ERROR = "recovery bundle is invalid" + + +def _invalid() -> RecoveryBundleError: + return RecoveryBundleError(_ERROR) + + +def _canonical_json(payload: Mapping[str, Any]) -> bytes: + try: + encoded = json.dumps( + payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True + ).encode("utf-8") + except (TypeError, ValueError) as error: + raise _invalid() from error + if not encoded or len(encoded) > _MAX_PLAINTEXT_BYTES: + raise _invalid() + return encoded + + +def _passphrase(value: bytes) -> bytes: + if not isinstance(value, bytes) or not value or len(value) > _MAX_PASSPHRASE_BYTES: + raise _invalid() + return value + + +def _derive_key(passphrase: bytes, salt: bytes) -> bytes: + return hash_secret_raw( + secret=passphrase, + salt=salt, + time_cost=_TIME_COST, + memory_cost=_MEMORY_COST_KIB, + parallelism=_PARALLELISM, + hash_len=_KEY_BYTES, + type=Type.ID, + ) + + +def encrypt_bundle(payload: Mapping[str, Any], passphrase: bytes) -> bytes: + """Serialize and encrypt a canonical recovery payload as a BTREC v1 bundle.""" + plaintext = _canonical_json(payload) + secret = _passphrase(passphrase) + salt = os.urandom(_SALT_BYTES) + nonce = os.urandom(_NONCE_BYTES) + ciphertext_length = len(plaintext) + _TAG_BYTES + header = _HEADER.pack( + _MAGIC, + _VERSION, + _KDF_ARGON2ID, + _TIME_COST, + _MEMORY_COST_KIB, + _PARALLELISM, + _SALT_BYTES, + _NONCE_BYTES, + ciphertext_length, + ) + ciphertext = AESGCM(_derive_key(secret, salt)).encrypt(nonce, plaintext, header) + return header + salt + nonce + ciphertext + + +def decrypt_bundle(encoded: bytes, passphrase: bytes) -> dict[str, Any]: + """Authenticate and decode a BTREC v1 bundle without disclosing failure cause.""" + try: + if not isinstance(encoded, bytes) or len(encoded) > _MAX_BUNDLE_BYTES: + raise _invalid() + if len(encoded) < _HEADER.size + _SALT_BYTES + _NONCE_BYTES + _TAG_BYTES: + raise _invalid() + ( + magic, + version, + kdf_id, + time_cost, + memory_cost, + parallelism, + salt_length, + nonce_length, + ciphertext_length, + ) = _HEADER.unpack(encoded[: _HEADER.size]) + if ( + magic != _MAGIC + or version != _VERSION + or kdf_id != _KDF_ARGON2ID + or time_cost != _TIME_COST + or memory_cost != _MEMORY_COST_KIB + or parallelism != _PARALLELISM + or salt_length != _SALT_BYTES + or nonce_length != _NONCE_BYTES + or ciphertext_length < _TAG_BYTES + or ciphertext_length > _MAX_PLAINTEXT_BYTES + _TAG_BYTES + or len(encoded) != _HEADER.size + salt_length + nonce_length + ciphertext_length + ): + raise _invalid() + secret = _passphrase(passphrase) + salt_start = _HEADER.size + nonce_start = salt_start + salt_length + ciphertext_start = nonce_start + nonce_length + plaintext = AESGCM(_derive_key(secret, encoded[salt_start:nonce_start])).decrypt( + encoded[nonce_start:ciphertext_start], + encoded[ciphertext_start:], + encoded[: _HEADER.size], + ) + if not plaintext or len(plaintext) > _MAX_PLAINTEXT_BYTES: + raise _invalid() + payload = json.loads(plaintext.decode("utf-8")) + if not isinstance(payload, dict): + raise _invalid() + # Reject non-canonical encodings to make catalog serialization deterministic. + if _canonical_json(payload) != plaintext: + raise _invalid() + return payload + except ( + InvalidTag, + UnicodeDecodeError, + json.JSONDecodeError, + struct.error, + ValueError, + ) as error: + if isinstance(error, RecoveryBundleError): + raise error + raise _invalid() from error + + +def _check_path_components(path: Path) -> None: + if not path.is_absolute() or path.name in {"", ".", ".."}: + raise RecoveryBundlePathError("recovery bundle path is unsafe") + current = Path(path.anchor) + for component in path.parts[1:-1]: + current /= component + try: + info = current.lstat() + except OSError as error: + raise RecoveryBundlePathError("recovery bundle path is unsafe") from error + if stat.S_ISLNK(info.st_mode) or not stat.S_ISDIR(info.st_mode): + raise RecoveryBundlePathError("recovery bundle path is unsafe") + + +def write_bundle_exclusive(path: Path, encoded: bytes) -> None: + """Write a bundle once with restrictive permissions and no symlink following.""" + if not isinstance(encoded, bytes) or not encoded or len(encoded) > _MAX_BUNDLE_BYTES: + raise RecoveryBundlePathError("recovery bundle output is unsafe") + _check_path_components(path) + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + try: + descriptor = os.open(path, flags, 0o600) + with os.fdopen(descriptor, "wb") as handle: + handle.write(encoded) + handle.flush() + os.fsync(handle.fileno()) + except OSError as error: + raise RecoveryBundlePathError("recovery bundle output is unsafe") from error + try: + info = path.lstat() + if ( + stat.S_ISLNK(info.st_mode) + or not stat.S_ISREG(info.st_mode) + or stat.S_IMODE(info.st_mode) != 0o600 + ): + path.unlink(missing_ok=True) + raise RecoveryBundlePathError("recovery bundle output is unsafe") + except OSError as error: + raise RecoveryBundlePathError("recovery bundle output is unsafe") from error + + +def read_bundle_file(path: Path) -> bytes: + """Read a regular, non-symlink bundle with a bounded size.""" + _check_path_components(path) + flags = os.O_RDONLY + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + try: + descriptor = os.open(path, flags) + with os.fdopen(descriptor, "rb") as handle: + info = os.fstat(handle.fileno()) + if ( + not stat.S_ISREG(info.st_mode) + or info.st_size <= 0 + or info.st_size > _MAX_BUNDLE_BYTES + ): + raise RecoveryBundlePathError("recovery bundle input is unsafe") + return handle.read() + except RecoveryBundlePathError: + raise + except OSError as error: + raise RecoveryBundlePathError("recovery bundle input is unsafe") from error + + +def read_passphrase_fd(fd: int) -> bytes: + """Read one newline-terminated passphrase from an inherited file descriptor.""" + if not isinstance(fd, int) or fd < 0: + raise RecoveryBundleError("recovery passphrase is unavailable") + try: + value = os.read(fd, _MAX_PASSPHRASE_BYTES + 2) + except OSError as error: + raise RecoveryBundleError("recovery passphrase is unavailable") from error + if value.endswith(b"\r\n"): + value = value[:-2] + elif value.endswith(b"\n"): + value = value[:-1] + if not value or len(value) > _MAX_PASSPHRASE_BYTES: + raise RecoveryBundleError("recovery passphrase is unavailable") + return value diff --git a/backend/src/backup_tool/security/redaction.py b/backend/src/backup_tool/security/redaction.py new file mode 100644 index 0000000..9c213f7 --- /dev/null +++ b/backend/src/backup_tool/security/redaction.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import Any + +SECRET_KEYS = frozenset( + { + "password", + "password_hash", + "secret", + "token", + "token_hash", + "authorization", + "cookie", + "ciphertext", + "master_key", + "private_key", + } +) + + +def redact(value: Any, *, canaries: Sequence[str] = ()) -> Any: + """Return a recursively redacted copy safe for operator-visible sinks.""" + if isinstance(value, Mapping): + return { + str(key): ( + "[REDACTED]" if str(key).lower() in SECRET_KEYS else redact(item, canaries=canaries) + ) + for key, item in value.items() + } + if isinstance(value, list): + return [redact(item, canaries=canaries) for item in value] + if isinstance(value, tuple): + return tuple(redact(item, canaries=canaries) for item in value) + if isinstance(value, str): + result = value + for canary in canaries: + if canary: + result = result.replace(canary, "[REDACTED]") + return result + return value diff --git a/backend/src/backup_tool/security/repository_crypto.py b/backend/src/backup_tool/security/repository_crypto.py new file mode 100644 index 0000000..0755631 --- /dev/null +++ b/backend/src/backup_tool/security/repository_crypto.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import os +import stat +from pathlib import Path + +from cryptography.exceptions import InvalidTag +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +from backup_tool.config import Settings +from backup_tool.ids import new_uuid7 + + +class RepositoryKeyError(ValueError): + pass + + +def _directory(settings: Settings) -> Path: + directory = settings.data_dir / "repository-data-keys" + directory.mkdir(mode=0o700, exist_ok=True) + if ( + directory.is_symlink() + or not directory.is_dir() + or stat.S_IMODE(directory.stat().st_mode) != 0o700 + ): + raise RepositoryKeyError("repository data key directory is unsafe") + return directory + + +def create_data_key(settings: Settings, repository_id: str) -> tuple[str, Path]: + key_id = str(new_uuid7()) + directory = _directory(settings) + path = directory / f"{repository_id}.{key_id}.key" + try: + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(descriptor, "wb") as handle: + handle.write(os.urandom(32)) + handle.flush() + os.fsync(handle.fileno()) + except OSError as error: + raise RepositoryKeyError("repository data key cannot be created") from error + if stat.S_IMODE(path.stat().st_mode) != 0o600 or path.is_symlink(): + path.unlink(missing_ok=True) + raise RepositoryKeyError("repository data key is unsafe") + return key_id, path + + +def install_data_key(settings: Settings, repository_id: str, key_id: str, key: bytes) -> Path: + """Install recovered key material once; never replace an existing key file.""" + if len(key) != 32 or "/" in repository_id or "/" in key_id: + raise RepositoryKeyError("repository data key is invalid") + path = _directory(settings) / f"{repository_id}.{key_id}.key" + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + try: + descriptor = os.open(path, flags, 0o600) + with os.fdopen(descriptor, "wb") as handle: + handle.write(key) + handle.flush() + os.fsync(handle.fileno()) + if path.is_symlink() or stat.S_IMODE(path.stat().st_mode) != 0o600: + path.unlink(missing_ok=True) + raise RepositoryKeyError("repository data key is unsafe") + except OSError as error: + raise RepositoryKeyError("repository data key cannot be installed") from error + return path + + +def object_aad(repository_id: str, key_id: str, kind: str, identity: str) -> bytes: + if kind not in {"blob", "manifest"} or not all((repository_id, key_id, identity)): + raise RepositoryKeyError("encrypted object metadata is invalid") + return f"BTENC:1:{repository_id}:{key_id}:{kind}:{identity}".encode() + + +def encrypt_object(key: bytes, aad: bytes, plaintext: bytes) -> bytes: + if len(key) != 32: + raise RepositoryKeyError("repository data key is unavailable") + nonce = os.urandom(12) + return b"BTENC\x01" + nonce + AESGCM(key).encrypt(nonce, plaintext, aad) + + +def decrypt_object(key: bytes, aad: bytes, stored: bytes) -> bytes: + if len(key) != 32 or not stored.startswith(b"BTENC\x01") or len(stored) < 35: + raise RepositoryKeyError("encrypted object is invalid") + try: + return AESGCM(key).decrypt(stored[6:18], stored[18:], aad) + except InvalidTag as error: + raise RepositoryKeyError("encrypted object is invalid") from error + + +def load_data_key(settings: Settings, repository_id: str, key_id: str) -> bytes: + path = _directory(settings) / f"{repository_id}.{key_id}.key" + try: + if path.is_symlink() or stat.S_IMODE(path.stat().st_mode) != 0o600: + raise RepositoryKeyError("repository data key is unsafe") + key = path.read_bytes() + except OSError as error: + raise RepositoryKeyError("repository data key is unavailable") from error + if len(key) != 32: + raise RepositoryKeyError("repository data key is unavailable") + return key diff --git a/backend/src/backup_tool/security/secrets.py b/backend/src/backup_tool/security/secrets.py new file mode 100644 index 0000000..0e1ff33 --- /dev/null +++ b/backend/src/backup_tool/security/secrets.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import hashlib +import os +from pathlib import Path + +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + + +class EnvelopeCipher: + """Purpose-bound authenticated encryption for persisted secret values.""" + + def __init__(self, master_key: bytes): + self._key = hashlib.sha256(master_key).digest() + self.key_id = hashlib.sha256(b"backup-tool-key-id\0" + self._key).hexdigest()[:24] + self._cipher = AESGCM(self._key) + + @classmethod + def from_file(cls, path: Path) -> EnvelopeCipher: + return cls(path.read_bytes()) + + @staticmethod + def _associated_data(purpose: str, version: int) -> bytes: + return f"backup-tool-secret:{purpose}:v{version}".encode() + + def encrypt(self, value: str, *, purpose: str, version: int) -> tuple[bytes, str]: + nonce = os.urandom(12) + encrypted = self._cipher.encrypt( + nonce, value.encode(), self._associated_data(purpose, version) + ) + return nonce + encrypted, self.key_id + + def decrypt(self, ciphertext: bytes, *, purpose: str, version: int) -> str: + nonce, encrypted = ciphertext[:12], ciphertext[12:] + plaintext = self._cipher.decrypt(nonce, encrypted, self._associated_data(purpose, version)) + return plaintext.decode() diff --git a/backend/src/backup_tool/security/ssrf.py b/backend/src/backup_tool/security/ssrf.py new file mode 100644 index 0000000..e3bf389 --- /dev/null +++ b/backend/src/backup_tool/security/ssrf.py @@ -0,0 +1,121 @@ +"""Fail-closed, DNS-rebinding-resistant webhook egress validation.""" + +from __future__ import annotations + +import asyncio +import ipaddress +import socket +from collections.abc import Awaitable, Callable, Sequence +from dataclasses import dataclass +from urllib.parse import SplitResult, urlsplit + + +class SSRFError(ValueError): + """The callback target is not safe for the notification egress boundary.""" + + +Resolver = Callable[[str, int], Awaitable[Sequence[str]]] +_ALLOWED_PORTS = frozenset({80, 443, 8080, 8443}) + + +def _is_global(address: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool: + """ipaddress.is_global misses some policy-important mapped/special ranges.""" + if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped is not None: + return _is_global(address.ipv4_mapped) + return bool(address.is_global) and not any( + ( + address.is_loopback, + address.is_private, + address.is_link_local, + address.is_multicast, + address.is_unspecified, + address.is_reserved, + ) + ) + + +def validate_webhook_url(value: str) -> SplitResult: + try: + parsed = urlsplit(value) + port = parsed.port + except ValueError as error: + raise SSRFError("webhook URL is malformed") from error + if parsed.scheme not in {"http", "https"} or not parsed.hostname: + raise SSRFError("webhook URL must be absolute HTTP(S)") + if parsed.username is not None or parsed.password is not None or parsed.fragment: + raise SSRFError("webhook URL credentials and fragments are forbidden") + if len(value) > 2048 or any(character.isspace() for character in value): + raise SSRFError("webhook URL is malformed") + if port is not None and port not in _ALLOWED_PORTS: + raise SSRFError("webhook URL port is not permitted") + try: + ipaddress.ip_address(parsed.hostname) + except ValueError: + pass + else: + # Callback literals are never accepted: names are resolved immediately + # before every request and connected addresses are pinned/rechecked. + raise SSRFError("literal IP webhook targets are forbidden") + return parsed + + +async def system_resolver(hostname: str, port: int) -> Sequence[str]: + records = await asyncio.get_running_loop().getaddrinfo( + hostname, port, type=socket.SOCK_STREAM, proto=socket.IPPROTO_TCP + ) + addresses: set[str] = set() + for record in records: + socket_address = record[4] + if socket_address and isinstance(socket_address[0], str): + addresses.add(socket_address[0]) + return tuple(sorted(addresses)) + + +async def resolve_public_addresses( + hostname: str, port: int, resolver: Resolver = system_resolver +) -> tuple[str, ...]: + try: + candidates = tuple(await resolver(hostname, port)) + except (TimeoutError, OSError) as error: + raise SSRFError("webhook DNS resolution failed") from error + if not candidates: + raise SSRFError("webhook hostname has no addresses") + approved: list[str] = [] + for candidate in candidates: + try: + address = ipaddress.ip_address(candidate) + except ValueError as error: + raise SSRFError("webhook resolver returned an invalid address") from error + if not _is_global(address): + # One unsafe answer poisons the hostname, including mixed public/private. + raise SSRFError("webhook hostname resolves to a non-public address") + approved.append(str(address)) + return tuple(approved) + + +@dataclass(frozen=True) +class ResolvedWebhookTarget: + url: SplitResult + port: int + addresses: tuple[str, ...] + + +async def resolve_webhook_target( + value: str, resolver: Resolver = system_resolver +) -> ResolvedWebhookTarget: + parsed = validate_webhook_url(value) + port = parsed.port or (443 if parsed.scheme == "https" else 80) + addresses = await resolve_public_addresses(parsed.hostname or "", port, resolver) + return ResolvedWebhookTarget(url=parsed, port=port, addresses=addresses) + + +def verify_connected_peer(peername: object, approved: Sequence[str]) -> str: + if not isinstance(peername, tuple) or not peername or not isinstance(peername[0], str): + raise SSRFError("webhook peer address is unavailable") + try: + peer = str(ipaddress.ip_address(peername[0])) + except ValueError as error: + raise SSRFError("webhook peer address is invalid") from error + if peer not in approved: + raise SSRFError("webhook peer changed after DNS resolution") + return peer diff --git a/backend/src/backup_tool/snapshot.py b/backend/src/backup_tool/snapshot.py new file mode 100644 index 0000000..ac68112 --- /dev/null +++ b/backend/src/backup_tool/snapshot.py @@ -0,0 +1,1080 @@ +from __future__ import annotations + +import hashlib +import json +import os +import shutil +import stat +from contextlib import suppress +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey +from sqlalchemy import desc, select +from sqlalchemy.ext.asyncio import AsyncSession + +from backup_tool.adapters import LocalAdapter, SourceError, SourceReader +from backup_tool.config import Settings +from backup_tool.db.models import ( + Backup, + Execution, + Job, + Repository, + RepositoryDataKeyEpoch, + Restore, + Source, +) +from backup_tool.exclusions import ExclusionError, matches +from backup_tool.faults import FaultInjector, NoFault +from backup_tool.ids import new_uuid7 +from backup_tool.repository import ( + InitializedRepository, + RepositoryError, + assert_capacity, + inspect_repository, + load_signing_key, +) +from backup_tool.security.repository_crypto import ( + RepositoryKeyError, + decrypt_object, + encrypt_object, + load_data_key, + object_aad, +) +from backup_tool.security.secrets import EnvelopeCipher +from backup_tool.ssh_adapter import SSHAdapter +from backup_tool.ssh_source import SSH_PRIVATE_KEY_PURPOSE, SSHSourcePublicConfig + + +class SnapshotError(ValueError): + def __init__(self, message: str, *, reason_code: str = "transient_io") -> None: + super().__init__(message) + self.reason_code = reason_code + + +class SnapshotIntegrityError(SnapshotError): + pass + + +def _canonical_json(payload: dict[str, Any]) -> bytes: + return (json.dumps(payload, sort_keys=True, separators=(",", ":")) + "\n").encode() + + +def _timestamp() -> str: + return datetime.now(UTC).isoformat(timespec="microseconds").replace("+00:00", "Z") + + +def _hash_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + while chunk := handle.read(1024 * 1024): + digest.update(chunk) + return digest.hexdigest() + + +def _fsync_directory(path: Path) -> None: + descriptor = os.open(path, os.O_RDONLY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + +def _private_directory(path: Path, *, exist_ok: bool = False) -> None: + try: + path.mkdir(mode=0o700, exist_ok=exist_ok) + metadata = path.lstat() + except OSError as error: + raise SnapshotError("snapshot staging path is unavailable") from error + if ( + path.is_symlink() + or not stat.S_ISDIR(metadata.st_mode) + or stat.S_IMODE(metadata.st_mode) != 0o700 + ): + raise SnapshotError("snapshot staging path is unsafe") + + +def _private_file_descriptor(path: Path) -> int: + descriptor: int | None = None + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + try: + descriptor = os.open(path, flags, 0o600) + metadata = os.fstat(descriptor) + if not stat.S_ISREG(metadata.st_mode) or stat.S_IMODE(metadata.st_mode) != 0o600: + raise OSError("snapshot staging file has unsafe permissions") + return descriptor + except OSError as error: + if descriptor is not None: + with suppress(OSError): + os.close(descriptor) + path.unlink(missing_ok=True) + raise SnapshotError("snapshot staging file is unavailable") from error + + +def _matches_blob_digest( + blob_path: Path, digest: str, encryption_key: bytes | None, aad: bytes | None +) -> bool: + try: + if encryption_key is None: + return _hash_file(blob_path) == digest + if aad is None: + return False + plaintext = decrypt_object(encryption_key, aad, blob_path.read_bytes()) + return hashlib.sha256(plaintext).hexdigest() == digest + except (OSError, RepositoryKeyError): + return False + + +def _install_blob( + staged_blob: Path, + blob_path: Path, + digest: str, + fault_injector: FaultInjector, + *, + encryption_key_id: str | None = None, + encryption_keys: dict[str, bytes] | None = None, + repository_id: str | None = None, +) -> str | None: + blob_path.parent.mkdir(parents=True, exist_ok=True) + if blob_path.is_symlink(): + raise SnapshotError("repository blob path is unsafe") + fault_injector.hit("blob.before_rename") + try: + os.link(staged_blob, blob_path) + except FileExistsError as error: + if encryption_keys is None: + if not _matches_blob_digest(blob_path, digest, None, None): + raise SnapshotError("existing repository blob does not match its digest") from error + used_key_id = None + else: + if repository_id is None: + raise SnapshotError("repository encryption metadata is invalid") from error + used_key_id = next( + ( + key_id + for key_id, key in encryption_keys.items() + if _matches_blob_digest( + blob_path, + digest, + key, + object_aad(repository_id, key_id, "blob", digest), + ) + ), + None, + ) + if used_key_id is None: + raise SnapshotError("existing repository blob does not match its digest") from error + else: + with blob_path.open("rb") as handle: + os.fsync(handle.fileno()) + _fsync_directory(blob_path.parent) + used_key_id = encryption_key_id + staged_blob.unlink(missing_ok=True) + return used_key_id + + +async def _copy_file( + adapter: SourceReader, + path: str, + staged_blob: Path, + fault_injector: FaultInjector, +) -> tuple[str, int]: + digest = hashlib.sha256() + size = 0 + fault_injector.hit("blob.before_write") + with os.fdopen(_private_file_descriptor(staged_blob), "wb") as handle: + async for chunk in adapter.open_content(path): + digest.update(chunk) + size += len(chunk) + handle.write(chunk) + fault_injector.hit("blob.after_write") + handle.flush() + os.fsync(handle.fileno()) + fault_injector.hit("blob.after_fsync") + return digest.hexdigest(), size + + +def _encrypt_staged_blob(staged_blob: Path, key: bytes, aad: bytes) -> None: + encrypted_blob = staged_blob.with_suffix(".encrypted") + try: + with os.fdopen(_private_file_descriptor(encrypted_blob), "wb") as handle: + handle.write(encrypt_object(key, aad, staged_blob.read_bytes())) + handle.flush() + os.fsync(handle.fileno()) + os.replace(encrypted_blob, staged_blob) + except (OSError, RepositoryKeyError) as error: + raise SnapshotError("repository blob encryption failed") from error + finally: + encrypted_blob.unlink(missing_ok=True) + + +def _assert_repository_encryption_metadata( + repository: Repository, inspected: InitializedRepository +) -> None: + if repository.encryption != inspected.encryption or ( + inspected.encryption == "aes-256-gcm" + and repository.active_data_key_id != inspected.data_key_id + ): + raise SnapshotError("repository encryption metadata is invalid") + + +def _repository_data_key( + settings: Settings, repository_id: str, key_id: str | None +) -> bytes | None: + if key_id is None: + return None + try: + return load_data_key(settings, repository_id, key_id) + except RepositoryKeyError as error: + raise SnapshotError("repository data key is unavailable") from error + + +async def _repository_epoch_keys( + settings: Settings, db: AsyncSession, repository: Repository, inspected: InitializedRepository +) -> dict[str, bytes]: + if inspected.encryption == "none": + return {} + if inspected.data_key_id is None: + raise SnapshotError("repository encryption metadata is invalid") + epochs = list( + ( + await db.scalars( + select(RepositoryDataKeyEpoch).where( + RepositoryDataKeyEpoch.repository_id == repository.id + ) + ) + ).all() + ) + active = [epoch for epoch in epochs if epoch.state == "active"] + if ( + len(active) != 1 + or active[0].key_id != inspected.data_key_id + or repository.active_data_key_id != inspected.data_key_id + ): + raise SnapshotError("repository encryption metadata is invalid") + keys: dict[str, bytes] = {} + for epoch in epochs: + key = _repository_data_key(settings, inspected.repository_id, epoch.key_id) + if key is None: + raise SnapshotError("repository encryption metadata is invalid") + keys[epoch.key_id] = key + return keys + + +def _entry_key_id(entry: dict[str, Any], manifest_key_id: str | None) -> str | None: + key_id = entry.get("encryption_key_id", manifest_key_id) + if key_id is not None and not isinstance(key_id, str): + raise SnapshotIntegrityError("published manifest encryption metadata is invalid") + return key_id + + +def require_nonempty(entries: list[dict[str, Any]], allow_empty: bool) -> None: + if not entries and not allow_empty: + raise SnapshotError("source_empty") + + +def _unsigned_manifest( + backup_id: str, + repository_id: str, + source: Source, + job: Job, + execution: Execution, + entries: list[dict[str, Any]], + logical_bytes: int, + stored_bytes: int, + effective_mode: str = "full", + encryption_key_id: str | None = None, +) -> dict[str, Any]: + captured_at = _timestamp() + return { + "format_version": 1, + "backup_id": backup_id, + "repository_id": repository_id, + "source_id": source.id, + "job_id": job.id, + "execution_id": execution.id, + "requested_mode": job.requested_mode, + "effective_mode": effective_mode, + "created_at": captured_at, + "source_consistency": { + "adapter": source.kind, + "captured_at": captured_at, + "evidence": {"enumeration": "local"}, + }, + "exclusion_policy": { + "matcher": "gitignore", + "version": 1, + "patterns": job.exclusions, + }, + "entries": entries, + "aggregates": { + "entry_count": len(entries), + "logical_bytes": logical_bytes, + "stored_bytes": stored_bytes, + }, + "encryption_key_id": encryption_key_id, + } + + +def verify_published_snapshot( + root: Path, + manifest_path: Path, + expected_public_key: str, + *, + encryption_key: bytes | None = None, + encryption_key_id: str | None = None, + blob_keys: dict[str, bytes] | None = None, + expected_repository_id: str | None = None, +) -> dict[str, Any]: + try: + stored_manifest = manifest_path.read_bytes() + if encryption_key_id is not None: + if encryption_key is None or expected_repository_id is None: + raise RepositoryKeyError("repository manifest key is unavailable") + stored_manifest = decrypt_object( + encryption_key, + object_aad( + expected_repository_id, + encryption_key_id, + "manifest", + manifest_path.stem, + ), + stored_manifest, + ) + manifest = json.loads(stored_manifest) + except (OSError, UnicodeDecodeError, json.JSONDecodeError, RepositoryKeyError) as error: + raise SnapshotIntegrityError("published manifest is unreadable") from error + if not isinstance(manifest, dict): + raise SnapshotIntegrityError("published manifest is invalid") + try: + digest = manifest.pop("manifest_digest") + signature = manifest.pop("manifest_signature") + signature_value = bytes.fromhex(signature["value"]) + public_key = Ed25519PublicKey.from_public_bytes(bytes.fromhex(expected_public_key)) + expected_digest = hashlib.sha256(_canonical_json(manifest)).hexdigest() + if digest != expected_digest: + raise SnapshotIntegrityError("published manifest digest is invalid") + public_key.verify(signature_value, bytes.fromhex(digest)) + except (InvalidSignature, KeyError, TypeError, ValueError) as error: + raise SnapshotIntegrityError("published manifest signature is invalid") from error + manifest_key_id = manifest.get("encryption_key_id") + repository_id = manifest.get("repository_id") + if ( + manifest_key_id != encryption_key_id + or not isinstance(repository_id, str) + or (expected_repository_id is not None and repository_id != expected_repository_id) + ): + raise SnapshotIntegrityError("published manifest encryption metadata is invalid") + for entry in manifest.get("entries", []): + if entry.get("type") != "file": + continue + digest = entry.get("blob_digest") + if not isinstance(digest, str): + raise SnapshotIntegrityError("published manifest file entry is invalid") + blob_path = root / "blobs" / "sha256" / digest + blob_key_id = _entry_key_id(entry, manifest_key_id) + blob_key = ( + blob_keys.get(blob_key_id) + if blob_keys is not None and blob_key_id is not None + else encryption_key + ) + aad = ( + object_aad(repository_id, blob_key_id, "blob", digest) + if blob_key_id is not None + else None + ) + if ( + (blob_key_id is not None and blob_key is None) + or blob_path.is_symlink() + or not blob_path.is_file() + or not _matches_blob_digest(blob_path, digest, blob_key, aad) + ): + raise SnapshotIntegrityError("published blob verification failed") + manifest["manifest_digest"] = expected_digest + manifest["manifest_signature"] = signature + return manifest + + +async def source_adapter( + settings: Settings, + db: AsyncSession, + source: Source, + cipher: EnvelopeCipher | None, +) -> SourceReader: + if source.kind == "local": + root = source.public_config.get("root") + if not isinstance(root, str): + raise SourceError("local source root is invalid") + return LocalAdapter(Path(root), settings) + if source.kind != "ssh": + raise SourceError("source kind is unavailable") + if cipher is None or len(source.secret_refs) != 1: + raise SourceError("SSH source key is unavailable", reason_code="source_invalid") + try: + config = SSHSourcePublicConfig.model_validate(source.public_config) + except ValueError as error: + raise SourceError( + "SSH source configuration is invalid", reason_code="source_invalid" + ) from error + from backup_tool.db.models import Secret + + secret = await db.get(Secret, source.secret_refs[0]) + if secret is None or secret.purpose != SSH_PRIVATE_KEY_PURPOSE: + raise SourceError("SSH source key is unavailable", reason_code="source_invalid") + try: + private_key = cipher.decrypt( + secret.ciphertext, purpose=secret.purpose, version=secret.version + ) + except Exception as error: + raise SourceError("SSH source key is unavailable", reason_code="source_invalid") from error + return SSHAdapter(config, private_key, settings) + + +async def publish_full_snapshot( + settings: Settings, + db: AsyncSession, + execution: Execution, + job: Job, + source: Source, + repository: Repository, + fault_injector: FaultInjector | None = None, + *, + cipher: EnvelopeCipher | None = None, +) -> Backup: + injector = fault_injector or NoFault() + if job.requested_mode not in {"full", "incremental"}: + raise SnapshotError("backup mode is invalid") + adapter: SourceReader | None = None + try: + inspected = inspect_repository(settings, Path(repository.root)) + signing_key = load_signing_key( + settings, + inspected.repository_id, + repository.signing_key_id, + repository.signing_public_key, + ) + _assert_repository_encryption_metadata(repository, inspected) + encryption_keys = await _repository_epoch_keys(settings, db, repository, inspected) + encryption_key = ( + encryption_keys[inspected.data_key_id] if inspected.data_key_id is not None else None + ) + assert_capacity(settings, inspected.root) + adapter = await source_adapter(settings, db, source, cipher) + adapter.validate_config() + except SourceError as error: + raise SnapshotError(str(error), reason_code=error.reason_code) from error + except (KeyError, RepositoryError) as error: + raise SnapshotError(str(error)) from error + + baseline = None + if job.requested_mode == "incremental": + baseline = await db.scalar( + select(Backup) + .join(Execution, Backup.execution_id == Execution.id) + .where( + Execution.job_id == job.id, + Backup.integrity == "verified", + Backup.tombstoned_at.is_(None), + ) + .order_by(desc(Backup.created_at)) + .limit(1) + ) + effective_mode = "incremental" if baseline is not None else "full" + + staging_root = inspected.root / "staging" + _private_directory(staging_root, exist_ok=True) + staging = staging_root / execution.id + _private_directory(staging) + published = False + try: + staged_blobs = staging / "blobs" + _private_directory(staged_blobs) + entries: list[dict[str, Any]] = [] + logical_bytes = 0 + stored_bytes = 0 + async for entry in adapter.enumerate_entries(): + try: + excluded = matches(entry.path, job.exclusions) + except ExclusionError as error: + raise SnapshotError(str(error)) from error + if excluded: + continue + manifest_entry: dict[str, Any] = { + "path": entry.path, + "type": entry.kind, + "size": entry.size, + "blob_digest": None, + "mode": entry.mode, + "mtime_ns": entry.mtime_ns, + "link_target": entry.link_target, + "metadata_support": ["mode", "mtime_ns"], + } + if entry.kind == "file": + staged_blob = staged_blobs / f"{len(entries)}.blob" + digest, copied_size = await _copy_file(adapter, entry.path, staged_blob, injector) + if copied_size != entry.size: + raise SnapshotError("source file changed during backup") + aad = None + if encryption_key is not None: + if inspected.data_key_id is None: + raise SnapshotError("repository encryption metadata is invalid") + aad = object_aad(inspected.repository_id, inspected.data_key_id, "blob", digest) + _encrypt_staged_blob(staged_blob, encryption_key, aad) + blob_key_id = _install_blob( + staged_blob, + inspected.root / "blobs" / "sha256" / digest, + digest, + injector, + encryption_key_id=inspected.data_key_id, + encryption_keys=encryption_keys or None, + repository_id=inspected.repository_id, + ) + manifest_entry["blob_digest"] = digest + if blob_key_id is not None: + manifest_entry["encryption_key_id"] = blob_key_id + logical_bytes += copied_size + stored_bytes += ( + staged_blob.stat().st_size + if staged_blob.exists() + else (inspected.root / "blobs" / "sha256" / digest).stat().st_size + ) + entries.append(manifest_entry) + + require_nonempty(entries, job.allow_empty) + + backup_id = str(new_uuid7()) + manifest = _unsigned_manifest( + backup_id, + inspected.repository_id, + source, + job, + execution, + entries, + logical_bytes, + stored_bytes, + effective_mode, + inspected.data_key_id, + ) + manifest_digest = hashlib.sha256(_canonical_json(manifest)).hexdigest() + manifest["manifest_digest"] = manifest_digest + manifest["manifest_signature"] = { + "algorithm": "ed25519", + "key_id": repository.signing_key_id, + "value": signing_key.sign(bytes.fromhex(manifest_digest)).hex(), + } + manifest_path = inspected.root / "manifests" / f"{backup_id}.json" + marker = { + "execution_id": execution.id, + "backup_id": backup_id, + "manifest_digest": manifest_digest, + "logical_bytes": logical_bytes, + "stored_bytes": stored_bytes, + "data_key_id": inspected.data_key_id, + } + publication_marker = staging / "publication.json" + with os.fdopen(_private_file_descriptor(publication_marker), "wb") as handle: + handle.write(_canonical_json(marker)) + handle.flush() + os.fsync(handle.fileno()) + staged_manifest = staging / "manifest.json" + manifest_payload = _canonical_json(manifest) + if encryption_key is not None: + if inspected.data_key_id is None: + raise SnapshotError("repository encryption metadata is invalid") + try: + manifest_payload = encrypt_object( + encryption_key, + object_aad( + inspected.repository_id, + inspected.data_key_id, + "manifest", + backup_id, + ), + manifest_payload, + ) + except RepositoryKeyError as error: + raise SnapshotError("repository manifest encryption failed") from error + injector.hit("manifest.before_write") + with os.fdopen(_private_file_descriptor(staged_manifest), "wb") as handle: + handle.write(manifest_payload) + handle.flush() + os.fsync(handle.fileno()) + injector.hit("manifest.after_fsync") + injector.hit("manifest.before_publish") + os.replace(staged_manifest, manifest_path) + _fsync_directory(manifest_path.parent) + published = True + verify_published_snapshot( + inspected.root, + manifest_path, + repository.signing_public_key, + encryption_key=encryption_key, + encryption_key_id=inspected.data_key_id, + blob_keys=encryption_keys, + expected_repository_id=inspected.repository_id, + ) + backup = Backup( + execution_id=execution.id, + parent_backup_id=baseline.id if baseline is not None else None, + manifest_id=backup_id, + manifest_digest=manifest_digest, + logical_bytes=logical_bytes, + stored_bytes=stored_bytes, + integrity="verified", + data_key_id=inspected.data_key_id, + ) + db.add(backup) + return backup + except SourceError as error: + raise SnapshotError(str(error), reason_code=error.reason_code) from error + finally: + if adapter is not None: + await adapter.close() + if not published: + shutil.rmtree(staging, ignore_errors=True) + + +def finalize_publication(root: Path, execution_id: str) -> None: + staging = root / "staging" / execution_id + try: # noqa: SIM105 - finalized staging can already be removed during recovery + shutil.rmtree(staging) + except FileNotFoundError: + pass + + +async def reconcile_publications(settings: Settings, db: AsyncSession) -> int: + reconciled = 0 + repositories = list((await db.scalars(select(Repository))).all()) + for repository in repositories: + try: + inspected = inspect_repository(settings, Path(repository.root)) + _assert_repository_encryption_metadata(repository, inspected) + except (RepositoryError, SnapshotError): + continue + staging_root = inspected.root / "staging" + if not staging_root.is_dir() or staging_root.is_symlink(): + continue + for staging in staging_root.iterdir(): + marker_path = staging / "publication.json" + if not staging.is_dir() or staging.is_symlink() or not marker_path.is_file(): + continue + try: + marker = json.loads(marker_path.read_text(encoding="utf-8")) + execution_id = marker["execution_id"] + backup_id = marker["backup_id"] + manifest_digest = marker["manifest_digest"] + logical_bytes = marker["logical_bytes"] + stored_bytes = marker["stored_bytes"] + data_key_id = marker.get("data_key_id") + if not ( + isinstance(execution_id, str) + and isinstance(backup_id, str) + and isinstance(manifest_digest, str) + and isinstance(logical_bytes, int) + and isinstance(stored_bytes, int) + and (data_key_id is None or isinstance(data_key_id, str)) + ): + raise ValueError + epoch_keys = await _repository_epoch_keys(settings, db, repository, inspected) + encryption_key = epoch_keys.get(data_key_id) if data_key_id is not None else None + manifest = verify_published_snapshot( + inspected.root, + inspected.root / "manifests" / f"{backup_id}.json", + repository.signing_public_key, + encryption_key=encryption_key, + encryption_key_id=data_key_id, + blob_keys=epoch_keys, + expected_repository_id=inspected.repository_id, + ) + if manifest.get("manifest_digest") != manifest_digest: + raise ValueError + execution = await db.get(Execution, execution_id) + if execution is None: + raise ValueError + except (OSError, ValueError, SnapshotError, json.JSONDecodeError): + continue + backup = await db.scalar(select(Backup).where(Backup.execution_id == execution_id)) + if backup is None: + db.add( + Backup( + execution_id=execution_id, + parent_backup_id=None, + manifest_id=backup_id, + manifest_digest=manifest_digest, + logical_bytes=logical_bytes, + stored_bytes=stored_bytes, + integrity="verified", + data_key_id=data_key_id, + ) + ) + if execution.state in {"preparing", "running", "verifying"}: + execution.state = "committed" + execution.completed_at = datetime.now(UTC) + execution.lease_owner = None + execution.lease_expires_at = None + finalize_publication(inspected.root, execution_id) + reconciled += 1 + await db.commit() + return reconciled + + +def validate_restore_destination( + settings: Settings, raw_destination: str, *, allow_existing: bool = False +) -> tuple[Path, Path]: + destination = Path(raw_destination) + if not destination.is_absolute() or destination.name in {"", ".", ".."}: + raise SnapshotError("restore destination must be an absolute directory") + for root in settings.restore_roots: + resolved_root = root.resolve() + if destination.parent.resolve() != resolved_root: + continue + if root.is_symlink() or not resolved_root.is_dir() or destination.parent.is_symlink(): + raise SnapshotError("restore root is unsafe") + if destination.is_symlink() or (destination.exists() and not allow_existing): + raise SnapshotError("restore destination already exists") + return destination, resolved_root + raise SnapshotError("restore destination is outside configured restore roots") + + +def _safe_restore_entries(manifest: dict[str, Any]) -> list[dict[str, Any]]: + entries = manifest.get("entries") + if not isinstance(entries, list): + raise SnapshotIntegrityError("published manifest entries are invalid") + paths: dict[str, str] = {} + for entry in entries: + if not isinstance(entry, dict): + raise SnapshotIntegrityError("published manifest entry is invalid") + path = entry.get("path") + entry_type = entry.get("type") + relative = Path(path) if isinstance(path, str) else None + if ( + not isinstance(path, str) + or not path + or path == ".backup-tool-restore.json" + or "\\" in path + or relative is None + or relative.is_absolute() + or ".." in relative.parts + or entry_type not in {"file", "directory", "symlink"} + or path in paths + ): + raise SnapshotIntegrityError("published manifest entry path is unsafe") + paths[path] = entry_type + if entry_type == "file": + digest = entry.get("blob_digest") + size = entry.get("size") + if ( + not isinstance(digest, str) + or len(digest) != 64 + or digest.lower() != digest + or any(character not in "0123456789abcdef" for character in digest) + or not isinstance(size, int) + or size < 0 + ): + raise SnapshotIntegrityError("published manifest file entry is invalid") + elif entry.get("blob_digest") is not None: + raise SnapshotIntegrityError("published manifest non-file entry is invalid") + if entry_type == "symlink": + target = entry.get("link_target") + target_path = Path(target) if isinstance(target, str) else None + if ( + not isinstance(target, str) + or not target + or "\\" in target + or target_path is None + or target_path.is_absolute() + or ".." in target_path.parts + ): + raise SnapshotIntegrityError("published manifest symlink target is unsafe") + for path in paths: + parent = Path(path).parent + while parent != Path("."): + if paths.get(parent.as_posix()) in {"file", "symlink"}: + raise SnapshotIntegrityError("published manifest entry has an unsafe parent") + parent = parent.parent + return sorted(entries, key=lambda entry: (len(Path(entry["path"]).parts), entry["path"])) + + +def _select_restore_entries( + entries: list[dict[str, Any]], selection: list[str] +) -> list[dict[str, Any]]: + if not selection: + return entries + selected: set[str] = set() + for path in selection: + normalized = Path(path) if isinstance(path, str) else None + if ( + not isinstance(path, str) + or not path + or "\\" in path + or normalized is None + or normalized.is_absolute() + or ".." in normalized.parts + ): + raise SnapshotError("restore selection is invalid") + selected.add(path) + selected_entries = { + entry["path"] + for entry in entries + if any(entry["path"] == path or entry["path"].startswith(f"{path}/") for path in selected) + } + for path in tuple(selected_entries): + parent = Path(path).parent + while parent != Path("."): + selected_entries.add(parent.as_posix()) + parent = parent.parent + return [entry for entry in entries if entry["path"] in selected_entries] + + +def _copy_restore_file( + blob_path: Path, + destination: Path, + digest: str, + expected_size: int, + *, + encryption_key: bytes | None = None, + aad: bytes | None = None, +) -> None: + if blob_path.is_symlink() or not blob_path.is_file(): + raise SnapshotError("restore blob is unavailable") + if encryption_key is not None: + if aad is None: + raise SnapshotError("restore blob verification failed") + try: + plaintext = decrypt_object(encryption_key, aad, blob_path.read_bytes()) + except (OSError, RepositoryKeyError) as error: + raise SnapshotError("restore blob verification failed") from error + if len(plaintext) != expected_size or hashlib.sha256(plaintext).hexdigest() != digest: + raise SnapshotError("restore blob verification failed") + with destination.open("xb") as target: + target.write(plaintext) + target.flush() + os.fsync(target.fileno()) + return + copied = 0 + copied_digest = hashlib.sha256() + with blob_path.open("rb") as source, destination.open("xb") as target: + while chunk := source.read(1024 * 1024): + copied_digest.update(chunk) + copied += len(chunk) + target.write(chunk) + target.flush() + os.fsync(target.fileno()) + if copied != expected_size or copied_digest.hexdigest() != digest: + raise SnapshotError("restore blob verification failed") + + +def _apply_metadata(path: Path, entry: dict[str, Any], *, symlink: bool = False) -> None: + support = entry.get("metadata_support") + if not isinstance(support, list): + return + mode = entry.get("mode") + if "mode" in support and isinstance(mode, int) and not symlink: + os.chmod(path, stat.S_IMODE(mode) & 0o0777) + mtime_ns = entry.get("mtime_ns") + if "mtime_ns" in support and isinstance(mtime_ns, int): + os.utime(path, ns=(mtime_ns, mtime_ns), follow_symlinks=not symlink) + + +async def verify_backup_snapshot( + settings: Settings, + db: AsyncSession, + backup: Backup, + repository: Repository, +) -> dict[str, Any]: + """Authenticate a published backup before any API or restore operation trusts it.""" + try: + inspected = inspect_repository(settings, Path(repository.root)) + _assert_repository_encryption_metadata(repository, inspected) + except RepositoryError as error: + raise SnapshotError(str(error)) from error + epoch_keys = await _repository_epoch_keys(settings, db, repository, inspected) + encryption_key = epoch_keys.get(backup.data_key_id) if backup.data_key_id is not None else None + manifest_path = inspected.root / "manifests" / f"{backup.manifest_id}.json" + manifest = verify_published_snapshot( + inspected.root, + manifest_path, + repository.signing_public_key, + encryption_key=encryption_key, + encryption_key_id=backup.data_key_id, + blob_keys=epoch_keys, + expected_repository_id=inspected.repository_id, + ) + if ( + manifest.get("backup_id") != backup.manifest_id + or manifest.get("repository_id") != inspected.repository_id + or manifest.get("manifest_digest") != backup.manifest_digest + ): + raise SnapshotError("published manifest does not match backup metadata") + return manifest + + +async def restore_full_snapshot( + settings: Settings, + db: AsyncSession, + restore: Restore, + backup: Backup, + repository: Repository, +) -> dict[str, Any]: + if restore.overwrite_policy not in {"fail", "skip", "replace"}: + raise SnapshotError("restore overwrite policy is invalid") + manifest = await verify_backup_snapshot(settings, db, backup, repository) + try: + inspected = inspect_repository(settings, Path(repository.root)) + except RepositoryError as error: + raise SnapshotError(str(error)) from error + epoch_keys = await _repository_epoch_keys(settings, db, repository, inspected) + entries = _select_restore_entries(_safe_restore_entries(manifest), restore.selection) + if restore.dry_run: + return { + "restore_id": restore.id, + "backup_id": backup.id, + "manifest_digest": backup.manifest_digest, + "entry_count": len(entries), + "file_count": sum(entry["type"] == "file" for entry in entries), + "restored_bytes": sum(entry["size"] for entry in entries if entry["type"] == "file"), + "dry_run": True, + } + destination, restore_root = validate_restore_destination( + settings, restore.destination, allow_existing=restore.overwrite_policy != "fail" + ) + if destination.exists() and restore.overwrite_policy == "skip": + return { + "restore_id": restore.id, + "backup_id": backup.id, + "manifest_digest": backup.manifest_digest, + "entry_count": len(entries), + "file_count": 0, + "restored_bytes": 0, + "skipped": True, + } + assert_capacity(settings, restore_root) + staging = restore_root / f".{destination.name}.restore-{restore.id}" + try: + staging.mkdir(mode=0o700, exist_ok=False) + except OSError as error: + raise SnapshotError("restore staging path is unavailable") from error + try: + directories = [entry for entry in entries if entry["type"] == "directory"] + for entry in directories: + directory = staging.joinpath(*Path(entry["path"]).parts) + directory.mkdir(mode=0o700, parents=True, exist_ok=False) + restored_files = 0 + restored_bytes = 0 + for entry in entries: + if entry["type"] != "file": + continue + target = staging.joinpath(*Path(entry["path"]).parts) + target.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + digest = entry["blob_digest"] + size = entry["size"] + if not isinstance(digest, str) or not isinstance(size, int): + raise SnapshotError("published manifest file entry is invalid") + blob_key_id = _entry_key_id(entry, backup.data_key_id) + blob_key = epoch_keys.get(blob_key_id) if blob_key_id is not None else None + if blob_key_id is not None and blob_key is None: + raise SnapshotError("repository data key is unavailable") + aad = ( + object_aad(inspected.repository_id, blob_key_id, "blob", digest) + if blob_key_id is not None + else None + ) + _copy_restore_file( + inspected.root / "blobs" / "sha256" / digest, + target, + digest, + size, + encryption_key=blob_key, + aad=aad, + ) + _apply_metadata(target, entry) + restored_files += 1 + restored_bytes += size + for entry in entries: + if entry["type"] != "symlink": + continue + target = staging.joinpath(*Path(entry["path"]).parts) + target.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + link_target = entry.get("link_target") + if not isinstance(link_target, str): + raise SnapshotError("published manifest symlink entry is invalid") + os.symlink(link_target, target) + _apply_metadata(target, entry, symlink=True) + for entry in reversed(directories): + _apply_metadata(staging.joinpath(*Path(entry["path"]).parts), entry) + result = { + "restore_id": restore.id, + "backup_id": backup.id, + "manifest_digest": backup.manifest_digest, + "entry_count": len(entries), + "file_count": restored_files, + "restored_bytes": restored_bytes, + } + sidecar = staging / ".backup-tool-restore.json" + with sidecar.open("xb") as handle: + handle.write(_canonical_json(result)) + handle.flush() + os.fsync(handle.fileno()) + _fsync_directory(staging) + previous: Path | None = None + if destination.exists(): + previous = restore_root / f".{destination.name}.previous-{restore.id}" + if previous.exists(): + raise SnapshotError("restore replacement recovery is incomplete") + os.rename(destination, previous) + try: + os.rename(staging, destination) + except OSError as error: + if previous is not None: + os.rename(previous, destination) + raise SnapshotError("restore destination could not be published") from error + _fsync_directory(restore_root) + if previous is not None: + shutil.rmtree(previous) + return result + except Exception: + shutil.rmtree(staging, ignore_errors=True) + raise + + +async def reconcile_restores(settings: Settings, db: AsyncSession) -> int: + restores = list((await db.scalars(select(Restore).where(Restore.state == "running"))).all()) + for restore in restores: + destination = Path(restore.destination) + sidecar = destination / ".backup-tool-restore.json" + if not destination.is_dir() or destination.is_symlink() or not sidecar.is_file(): + staging = destination.parent / f".{destination.name}.restore-{restore.id}" + try: # noqa: SIM105 - recovery must tolerate a previously cleaned staging directory + shutil.rmtree(staging) + except FileNotFoundError: + pass + if destination.exists(): + restore.state = "failed" + restore.result = {"reason": "restore_recovery_failed"} + else: + restore.state = "queued" + restore.result = {"reason": "worker_lost"} + continue + try: + result = json.loads(sidecar.read_text(encoding="utf-8")) + backup = await db.get(Backup, restore.backup_id) + if ( + not isinstance(result, dict) + or backup is None + or result.get("restore_id") != restore.id + or result.get("backup_id") != backup.id + or result.get("manifest_digest") != backup.manifest_digest + ): + raise ValueError + except (OSError, ValueError, json.JSONDecodeError): + restore.state = "failed" + restore.result = {"reason": "restore_recovery_failed"} + continue + restore.state = "committed" + restore.result = result + await db.commit() + return len(restores) diff --git a/backend/src/backup_tool/ssh_adapter.py b/backend/src/backup_tool/ssh_adapter.py new file mode 100644 index 0000000..eec99d3 --- /dev/null +++ b/backend/src/backup_tool/ssh_adapter.py @@ -0,0 +1,288 @@ +"""Pinned-host-key, forced-SFTP-only source reader. + +The adapter has no command-channel API. It authenticates with the caller's +already decrypted private key only after verifying the configured host key. +""" + +from __future__ import annotations + +import asyncio +import hmac +import io +import socket +import stat +from collections.abc import AsyncIterator, Callable, Iterator +from contextlib import suppress +from typing import Any + +import paramiko # type: ignore[import-untyped] + +from backup_tool.adapters import Entry, SourceError +from backup_tool.config import Settings +from backup_tool.ssh_source import SSHSourcePublicConfig + +TransportFactory = Callable[[str, int, float], Any] +SFTPFactory = Callable[[Any], Any] + + +def _transport_for(hostname: str, port: int, timeout: float) -> Any: + connection = socket.create_connection((hostname, port), timeout=timeout) + connection.settimeout(timeout) + return paramiko.Transport(connection) + + +def _sftp_for(transport: Any) -> Any: + return paramiko.SFTPClient.from_transport(transport) + + +def _next_or_none(iterator: Iterator[Any]) -> Any | None: + try: + return next(iterator) + except StopIteration: + return None + + +def load_private_key(value: str) -> Any: + """Load only unencrypted Ed25519, ECDSA, or sufficiently strong RSA keys.""" + for key_type in (paramiko.Ed25519Key, paramiko.ECDSAKey, paramiko.RSAKey): + try: + key = key_type.from_private_key(io.StringIO(value), password=None) + except (paramiko.SSHException, ValueError): + continue + if isinstance(key, paramiko.RSAKey) and key.get_bits() < 3072: + raise SourceError( + "SSH private key algorithm is not permitted", reason_code="source_auth" + ) + return key + raise SourceError("SSH private key algorithm is not permitted", reason_code="source_auth") + + +class SSHAdapter: + """A stateful SFTP reader rooted at the forced-SFTP account chroot only.""" + + def __init__( + self, + config: SSHSourcePublicConfig, + private_key: str, + settings: Settings, + *, + transport_factory: TransportFactory = _transport_for, + sftp_factory: SFTPFactory = _sftp_for, + ) -> None: + self.config = config + self._private_key = load_private_key(private_key) + self.settings = settings + self._transport_factory = transport_factory + self._sftp_factory = sftp_factory + self._transport: Any | None = None + self._sftp: Any | None = None + self._files: dict[str, tuple[int, int]] = {} + + def validate_config(self) -> None: + if self.config.root != "/": # defensive: persisted JSON can bypass API validation + raise SourceError( + "SSH source root must be the forced-SFTP chroot", reason_code="source_invalid" + ) + + def _connect(self) -> None: + if self._sftp is not None: + return + self.validate_config() + transport = self._transport_factory( + self.config.hostname, self.config.port, self.settings.ssh_connect_timeout_seconds + ) + self._transport = transport + try: + transport.start_client(timeout=self.settings.ssh_connect_timeout_seconds) + algorithm, encoded_key = self.config.host_key.split(" ", 1) + server_key = transport.get_remote_server_key() + if server_key.get_name() != algorithm or not hmac.compare_digest( + server_key.get_base64(), encoded_key + ): + raise SourceError( + "SSH host key does not match configured pin", reason_code="source_trust" + ) + # Authentication deliberately occurs only after the exact pin comparison. + transport.auth_publickey(self.config.username, self._private_key) + sftp = self._sftp_factory(transport) + channel = sftp.get_channel() + channel.settimeout(self.settings.ssh_operation_timeout_seconds) + self._sftp = sftp + except SourceError: + self._close_sync() + raise + except paramiko.AuthenticationException as error: + self._close_sync() + raise SourceError("SSH authentication failed", reason_code="source_auth") from error + except (TimeoutError, OSError) as error: + self._close_sync() + raise SourceError( + "SSH source is unavailable", reason_code="source_unavailable" + ) from error + except (paramiko.SSHException, ValueError) as error: + self._close_sync() + raise SourceError( + "SSH source connection failed", reason_code="source_unavailable" + ) from error + + def _sftp_client(self) -> Any: + if self._sftp is None: + raise SourceError("SSH source is unavailable", reason_code="source_unavailable") + return self._sftp + + def _read_client(self) -> Any: + if self._transport is None: + raise SourceError("SSH source is unavailable", reason_code="source_unavailable") + client = self._sftp_factory(self._transport) + client.get_channel().settimeout(self.settings.ssh_operation_timeout_seconds) + return client + + @staticmethod + def _relative(parent: str, name: object) -> str: + if not isinstance(name, str) or not name or name in {".", ".."}: + raise SourceError("SSH source returned an invalid entry", reason_code="source_invalid") + if "/" in name or "\\" in name or "\x00" in name: + raise SourceError("SSH source returned an invalid entry", reason_code="source_invalid") + return name if not parent else f"{parent}/{name}" + + @staticmethod + def _remote_path(relative: str) -> str: + if ( + not relative + or relative.startswith("/") + or "\\" in relative + or ".." in relative.split("/") + ): + raise SourceError("SSH source entry is invalid", reason_code="source_invalid") + return f"/{relative}" + + @staticmethod + def _entry_from_attributes(path: str, attributes: Any) -> Entry: + mode = getattr(attributes, "st_mode", None) + if not isinstance(mode, int): + raise SourceError("SSH source entry metadata is invalid", reason_code="source_invalid") + if stat.S_ISLNK(mode): + raise SourceError("SSH source symlinks are not supported", reason_code="source_invalid") + size = getattr(attributes, "st_size", 0) + mtime = getattr(attributes, "st_mtime", 0) + if not isinstance(size, int) or size < 0 or not isinstance(mtime, int): + raise SourceError("SSH source entry metadata is invalid", reason_code="source_invalid") + if stat.S_ISDIR(mode): + return Entry(path, "directory", 0, stat.S_IMODE(mode), mtime * 1_000_000_000) + if stat.S_ISREG(mode): + return Entry(path, "file", size, stat.S_IMODE(mode), mtime * 1_000_000_000) + raise SourceError("SSH source contains an unsupported entry", reason_code="source_invalid") + + async def probe(self) -> dict[str, int]: + count = 0 + try: + async for entry in self.enumerate_entries(): + if entry.kind == "file": + count += 1 + return {"entry_count": count} + finally: + await self.close() + + async def enumerate_entries(self) -> AsyncIterator[Entry]: + await asyncio.to_thread(self._connect) + pending = [""] + entry_count = 0 + try: + while pending: + parent = pending.pop() + remote_parent = "/" if not parent else self._remote_path(parent) + try: + iterator = await asyncio.to_thread( + self._sftp_client().listdir_iter, + remote_parent, + read_aheads=self.settings.ssh_list_read_aheads, + ) + while ( + attributes := await asyncio.to_thread(_next_or_none, iterator) + ) is not None: + path = self._relative(parent, getattr(attributes, "filename", None)) + entry_count += 1 + if entry_count > self.settings.ssh_max_entries: + raise SourceError( + "SSH source entry limit exceeded", reason_code="source_limit" + ) + entry = self._entry_from_attributes(path, attributes) + if entry.kind == "directory": + if path.count("/") + 1 > self.settings.ssh_max_traversal_depth: + raise SourceError( + "SSH source traversal depth exceeded", + reason_code="source_limit", + ) + pending.append(path) + else: + self._files[path] = (entry.size, entry.mtime_ns) + yield entry + except SourceError: + raise + except (TimeoutError, OSError, paramiko.SSHException) as error: + raise SourceError( + "SSH source enumeration failed", reason_code="source_unavailable" + ) from error + except BaseException: + await self.close() + raise + + async def open_content(self, path: str) -> AsyncIterator[bytes]: + expected = self._files.get(path) + if expected is None: + raise SourceError("SSH source entry was not enumerated", reason_code="source_invalid") + remote_path = self._remote_path(path) + handle: Any | None = None + reader: Any | None = None + try: + await asyncio.to_thread(self._connect) + reader = await asyncio.to_thread(self._read_client) + assert reader is not None + attributes = await asyncio.to_thread(reader.lstat, remote_path) + entry = self._entry_from_attributes(path, attributes) + if entry.kind != "file" or (entry.size, entry.mtime_ns) != expected: + raise SourceError( + "SSH source file changed during backup", reason_code="source_changed" + ) + handle = await asyncio.to_thread( + reader.open, + remote_path, + "rb", + self.settings.ssh_read_chunk_bytes, + ) + assert handle is not None + while chunk := await asyncio.to_thread(handle.read, self.settings.ssh_read_chunk_bytes): + if not isinstance(chunk, bytes) or len(chunk) > self.settings.ssh_read_chunk_bytes: + raise SourceError( + "SSH source returned an invalid read", reason_code="source_invalid" + ) + yield chunk + after = await asyncio.to_thread(reader.lstat, remote_path) + verified = self._entry_from_attributes(path, after) + if verified.kind != "file" or (verified.size, verified.mtime_ns) != expected: + raise SourceError( + "SSH source file changed during backup", reason_code="source_changed" + ) + except SourceError: + raise + except (TimeoutError, OSError, paramiko.SSHException) as error: + raise SourceError("SSH source read failed", reason_code="source_unavailable") from error + finally: + if handle is not None: + await asyncio.to_thread(handle.close) + if reader is not None and reader is not self._sftp: + await asyncio.to_thread(reader.close) + + def _close_sync(self) -> None: + sftp, transport = self._sftp, self._transport + self._sftp = None + self._transport = None + if sftp is not None: + with suppress(OSError, paramiko.SSHException): + sftp.close() + if transport is not None: + with suppress(OSError, paramiko.SSHException): + transport.close() + + async def close(self) -> None: + await asyncio.to_thread(self._close_sync) diff --git a/backend/src/backup_tool/ssh_source.py b/backend/src/backup_tool/ssh_source.py new file mode 100644 index 0000000..0e3adba --- /dev/null +++ b/backend/src/backup_tool/ssh_source.py @@ -0,0 +1,72 @@ +"""Closed public configuration for the staged SSH source capability. + +This module deliberately describes configuration only. It must not create a +network transport or load a private key; those capabilities are outside this +slice. +""" + +from __future__ import annotations + +import base64 +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +SSH_PRIVATE_KEY_PURPOSE = "ssh_private_key" +_ALLOWED_HOST_KEY_ALGORITHMS = frozenset( + { + "ssh-ed25519", + "ecdsa-sha2-nistp256", + "ecdsa-sha2-nistp384", + "ecdsa-sha2-nistp521", + "rsa-sha2-256", + "rsa-sha2-512", + } +) + + +def _has_control_or_space(value: str) -> bool: + return any(character.isspace() or ord(character) < 32 for character in value) + + +class SSHSourcePublicConfig(BaseModel): + """The public, SFTP-chroot-only portion of an SSH source definition.""" + + model_config = ConfigDict(extra="forbid", strict=True) + + hostname: str = Field(min_length=1, max_length=253) + port: int = Field(ge=1, le=65535) + username: str = Field(min_length=1, max_length=255) + host_key: str = Field(min_length=1, max_length=16384) + # The server-side forced-SFTP account's chroot is the only permitted root. + root: Literal["/"] + + @field_validator("hostname") + @classmethod + def validate_hostname(cls, value: str) -> str: + if _has_control_or_space(value) or any(character in value for character in "/\\@?#"): + raise ValueError("SSH hostname is invalid") + return value + + @field_validator("username") + @classmethod + def validate_username(cls, value: str) -> str: + if _has_control_or_space(value) or any(character in value for character in "/\\:@"): + raise ValueError("SSH username is invalid") + return value + + @field_validator("host_key") + @classmethod + def validate_host_key(cls, value: str) -> str: + algorithm, separator, encoded_key = value.partition(" ") + if not separator or not algorithm or not encoded_key or " " in encoded_key: + raise ValueError("SSH host key must be an algorithm and base64 key") + if algorithm not in _ALLOWED_HOST_KEY_ALGORITHMS: + raise ValueError("SSH host key algorithm is not supported") + try: + decoded = base64.b64decode(encoded_key, validate=True) + except (ValueError, UnicodeEncodeError) as error: + raise ValueError("SSH host key is not valid base64") from error + if not decoded: + raise ValueError("SSH host key is empty") + return value diff --git a/backend/src/backup_tool/web.py b/backend/src/backup_tool/web.py new file mode 100644 index 0000000..0cedc29 --- /dev/null +++ b/backend/src/backup_tool/web.py @@ -0,0 +1,43 @@ +"""Dedicated HTTP runtime role.""" + +from __future__ import annotations + +import stat + +import uvicorn + +from backup_tool.config import Settings +from backup_tool.observability.logging import configure_logging, log_event + + +def _prepare_socket(settings: Settings) -> str: + path = settings.web_socket_path + path.parent.mkdir(mode=0o750, parents=True, exist_ok=True) + try: + existing = path.lstat() + except FileNotFoundError: + return str(path) + if not stat.S_ISSOCK(existing.st_mode): + raise RuntimeError("web socket path is not a socket") + path.unlink() + return str(path) + + +def run_web(settings: Settings) -> int: + """Run exactly one ASGI server without reload or embedded background roles.""" + from backup_tool.api.app import create_app + + configure_logging("web", settings.log_level) + server = uvicorn.Server( + uvicorn.Config( + create_app(settings), + uds=_prepare_socket(settings), + log_level=settings.log_level.lower(), + reload=False, + workers=1, + log_config=None, + ) + ) + server.run() + log_event("role_stopped", role="web") + return 0 diff --git a/backend/src/backup_tool/worker.py b/backend/src/backup_tool/worker.py new file mode 100644 index 0000000..de726b2 --- /dev/null +++ b/backend/src/backup_tool/worker.py @@ -0,0 +1,315 @@ +"""Single-node durable worker role. + +The worker owns leases and performs repository I/O outside the API process. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import importlib +import signal +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import cast +from uuid import uuid4 + +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from backup_tool.config import Settings +from backup_tool.db.engine import create_engine +from backup_tool.db.models import Backup, Execution, Job, Repository, Restore, Source +from backup_tool.execution import ( + claim, + complete_cancellation, + heartbeat, + record_event, + recover_stale, + transition, +) +from backup_tool.faults import FaultInjector, NoFault +from backup_tool.gc import process_retention_gc +from backup_tool.notifications.dispatcher import dispatch_one, recover_notification_leases +from backup_tool.notifications.events import emit_event +from backup_tool.observability.logging import configure_logging, log_event +from backup_tool.repository import reconcile_key_rotations +from backup_tool.security.secrets import EnvelopeCipher + +snapshot = importlib.import_module("backup_tool.snapshot") +SnapshotError = snapshot.SnapshotError +SnapshotIntegrityError = snapshot.SnapshotIntegrityError + + +class Worker: + def __init__( + self, + settings: Settings, + *, + owner: str | None = None, + fault_injector: FaultInjector | None = None, + ) -> None: + self.settings = settings + self.owner = owner or f"worker-{uuid4()}" + self.fault_injector = fault_injector or NoFault() + self._stopping = asyncio.Event() + self.engine = create_engine(settings) + self.sessions = async_sessionmaker(self.engine, expire_on_commit=False) + self.cipher = EnvelopeCipher.from_file(settings.master_key_file) + self._execution_turns = 0 + self._next_maintenance_at: datetime | None = None + + async def startup(self) -> int: + async with self.sessions() as db: + rotations = await reconcile_key_rotations(self.settings, db) + publications = cast(int, await snapshot.reconcile_publications(self.settings, db)) + restored = cast(int, await snapshot.reconcile_restores(self.settings, db)) + recovered_executions = await recover_stale(db) + recovered_deliveries = await recover_notification_leases(db) + maintenance = await process_retention_gc(db) + self._next_maintenance_at = datetime.now(UTC) + timedelta(seconds=60) + return ( + rotations + + publications + + restored + + recovered_executions + + recovered_deliveries + + maintenance.tombstoned + ) + + async def _run_restore(self, db: AsyncSession) -> bool: + restore_id = await db.scalar( + select(Restore.id) + .where(Restore.state == "queued") + .order_by(Restore.created_at) + .limit(1) + ) + if restore_id is None: + return False + result = await db.execute( + update(Restore) + .where(Restore.id == restore_id, Restore.state == "queued") + .values(state="running") + ) + if getattr(result, "rowcount", 0) != 1: + await db.rollback() + return False + await db.commit() + restore = await db.get(Restore, restore_id) + if restore is None: + return False + try: + backup = await db.get(Backup, restore.backup_id) + if backup is None or backup.integrity != "verified" or backup.tombstoned_at is not None: + raise SnapshotError("backup is unavailable") + execution = await db.get(Execution, backup.execution_id) + if execution is None: + raise SnapshotError("backup execution is unavailable") + job = await db.get(Job, execution.job_id) + if job is None: + raise SnapshotError("backup job is unavailable") + repository = await db.get(Repository, job.repository_id) + if repository is None: + raise SnapshotError("backup repository is unavailable") + restore.result = await snapshot.restore_full_snapshot( + self.settings, db, restore, backup, repository + ) + restore.state = "committed" + await emit_event( + db, + "restore.committed", + correlation_id=restore.id, + resource={"restore_id": restore.id, "backup_id": restore.backup_id}, + payload={"dry_run": restore.dry_run, "outcome": "committed"}, + deduplication_key=f"restore:{restore.id}:committed", + ) + await db.commit() + except SnapshotIntegrityError: + await db.rollback() + failed = await db.get(Restore, restore_id) + if failed is not None: + corrupted_backup = await db.get(Backup, failed.backup_id) + if corrupted_backup is not None: + corrupted_backup.integrity = "corrupt" + failed.state = "failed" + failed.result = {"reason": "restore_failed"} + await emit_event( + db, + "restore.failed", + correlation_id=failed.id, + resource={"restore_id": failed.id, "backup_id": failed.backup_id}, + payload={"reason_code": "restore_failed"}, + deduplication_key=f"restore:{failed.id}:failed", + ) + await db.commit() + except SnapshotError: + await db.rollback() + failed = await db.get(Restore, restore_id) + if failed is not None: + failed.state = "failed" + failed.result = {"reason": "restore_failed"} + await emit_event( + db, + "restore.failed", + correlation_id=failed.id, + resource={"restore_id": failed.id, "backup_id": failed.backup_id}, + payload={"reason_code": "restore_failed"}, + deduplication_key=f"restore:{failed.id}:failed", + ) + await db.commit() + return True + + async def _run_maintenance(self, db: AsyncSession) -> None: + now = datetime.now(UTC) + if self._next_maintenance_at is None or now >= self._next_maintenance_at: + await process_retention_gc(db, now) + self._next_maintenance_at = now + timedelta(seconds=60) + + async def run_once(self) -> bool: + if self._stopping.is_set(): + return False + async with self.sessions() as db: + # Retention/GC is a worker responsibility, but is rate-limited so it + # cannot turn a sustained backup queue into a metadata polling loop. + await self._run_maintenance(db) + # Never let an always-nonempty execution queue starve due notifications. + if self._execution_turns >= 1 and await dispatch_one( + db, self.settings, self.cipher, self.owner + ): + self._execution_turns = 0 + return True + if self._stopping.is_set(): + return False + execution_id = await db.scalar( + select(Execution.id) + .where(Execution.state == "queued") + .order_by(Execution.created_at) + .limit(1) + ) + if execution_id is None: + if await self._run_restore(db): + return True + return await dispatch_one(db, self.settings, self.cipher, self.owner) + execution = await claim(db, execution_id, self.owner) + if execution is None: + return False + self._execution_turns += 1 + # Reload after the claim: a control request can race the lease acquisition. + await db.refresh(execution) + if execution.state == "cancelling": + await complete_cancellation(db, execution.id, self.owner) + return True + now = datetime.now(UTC) + result = await db.execute( + update(Execution) + .where( + Execution.id == execution.id, + Execution.lease_owner == self.owner, + Execution.lease_expires_at >= now, + Execution.state == "preparing", + ) + .values(state=transition("preparing", "running"), started_at=now) + ) + if getattr(result, "rowcount", 0) == 1: + await db.refresh(execution) + await record_event(db, execution) + await db.commit() + await heartbeat(db, execution.id, self.owner) + failure_detail: str | None = None + failure_reason = "transient_io" + try: + job = await db.get(Job, execution.job_id) + if job is None: + raise SnapshotError("execution job is unavailable") + source = await db.get(Source, job.source_id) + repository = await db.get(Repository, job.repository_id) + if source is None or repository is None: + raise SnapshotError("execution source or repository is unavailable") + backup = await snapshot.publish_full_snapshot( + self.settings, + db, + execution, + job, + source, + repository, + self.fault_injector, + cipher=self.cipher, + ) + await db.flush() + await emit_event( + db, + "backup.committed", + correlation_id=execution.id, + resource={ + "execution_id": execution.id, + "job_id": job.id, + "repository_id": repository.id, + "backup_id": backup.id, + }, + payload={"integrity": backup.integrity, "effective_mode": job.requested_mode}, + deduplication_key=f"backup:{backup.id}:committed", + ) + await emit_event( + db, + "backup.verification_succeeded", + correlation_id=execution.id, + resource={"execution_id": execution.id, "backup_id": backup.id}, + payload={"integrity": backup.integrity}, + deduplication_key=f"backup:{backup.id}:verified", + ) + execution.state = transition("running", "verifying") + await record_event(db, execution) + self.fault_injector.hit("metadata.before_commit") + await db.commit() + self.fault_injector.hit("metadata.after_commit") + execution.state = transition("verifying", "committed") + execution.completed_at = datetime.now(UTC) + execution.lease_owner = None + execution.lease_expires_at = None + await record_event(db, execution) + await db.commit() + snapshot.finalize_publication(Path(repository.root), execution.id) + except SnapshotError as error: + await db.rollback() + failure_detail = str(error) + failure_reason = error.reason_code + if failure_detail is not None: + failed = await db.get(Execution, execution_id) + if failed is None or failed.state not in {"preparing", "running", "verifying"}: + return True + failed.state = "failed" + failed.reason_code = failure_reason + failed.operator_message = failure_detail + failed.completed_at = datetime.now(UTC) + failed.lease_owner = None + failed.lease_expires_at = None + await record_event(db, failed) + await db.commit() + return True + + async def run(self) -> None: + await self.startup() + while not self._stopping.is_set(): + if not await self.run_once(): + with contextlib.suppress(TimeoutError): + await asyncio.wait_for(self._stopping.wait(), timeout=0.25) + await self.engine.dispose() + + def stop(self) -> None: + log_event("role_stopping", role="worker") + self._stopping.set() + + +def run_worker(settings: Settings) -> int: + configure_logging("worker", settings.log_level) + worker = Worker(settings) + loop = asyncio.new_event_loop() + for sig in (signal.SIGINT, signal.SIGTERM): + with contextlib.suppress(NotImplementedError): + loop.add_signal_handler(sig, worker.stop) + try: + loop.run_until_complete(worker.run()) + finally: + loop.close() + log_event("role_stopped", role="worker") + return 0 diff --git a/backend/tests/__pycache__/conftest.cpython-314-pytest-9.0.3.pyc b/backend/tests/__pycache__/conftest.cpython-314-pytest-9.0.3.pyc deleted file mode 100644 index cc1bf02..0000000 Binary files a/backend/tests/__pycache__/conftest.cpython-314-pytest-9.0.3.pyc and /dev/null differ diff --git a/backend/tests/__pycache__/test_engine.cpython-314-pytest-9.0.3.pyc b/backend/tests/__pycache__/test_engine.cpython-314-pytest-9.0.3.pyc deleted file mode 100644 index d9b80b0..0000000 Binary files a/backend/tests/__pycache__/test_engine.cpython-314-pytest-9.0.3.pyc and /dev/null differ diff --git a/backend/tests/__pycache__/test_jobs.cpython-314-pytest-9.0.3.pyc b/backend/tests/__pycache__/test_jobs.cpython-314-pytest-9.0.3.pyc deleted file mode 100644 index 65978cf..0000000 Binary files a/backend/tests/__pycache__/test_jobs.cpython-314-pytest-9.0.3.pyc and /dev/null differ diff --git a/backend/tests/__pycache__/test_sources.cpython-314-pytest-9.0.3.pyc b/backend/tests/__pycache__/test_sources.cpython-314-pytest-9.0.3.pyc deleted file mode 100644 index 40af9a6..0000000 Binary files a/backend/tests/__pycache__/test_sources.cpython-314-pytest-9.0.3.pyc and /dev/null differ diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py deleted file mode 100644 index f47b1dc..0000000 --- a/backend/tests/conftest.py +++ /dev/null @@ -1,33 +0,0 @@ -import pytest -import pytest_asyncio -from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker -from app.database import Base, get_db -from app.main import app -from httpx import AsyncClient, ASGITransport - -TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:" - -@pytest_asyncio.fixture -async def db(): - engine = create_async_engine(TEST_DATABASE_URL, echo=False) - try: - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - - async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - async with async_session() as session: - yield session - finally: - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.drop_all) - await engine.dispose() - -@pytest_asyncio.fixture -async def client(db): - async def override_get_db(): - yield db - - app.dependency_overrides[get_db] = override_get_db - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac: - yield ac - app.dependency_overrides.clear() diff --git a/backend/tests/test_engine.py b/backend/tests/test_engine.py deleted file mode 100644 index 12891c3..0000000 --- a/backend/tests/test_engine.py +++ /dev/null @@ -1,82 +0,0 @@ -import pytest -import pytest_asyncio -import tempfile -import os -from pathlib import Path -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import selectinload -from app.models import Source, Job, JobExecution -from backup.engine import BackupEngine - -@pytest_asyncio.fixture -async def test_source(db: AsyncSession): - with tempfile.TemporaryDirectory() as tmpdir: - # Create test files - (Path(tmpdir) / "test.txt").write_text("Hello, World!") - (Path(tmpdir) / "subdir").mkdir() - (Path(tmpdir) / "subdir" / "nested.txt").write_text("Nested content") - - source = Source( - name="Test Source", - type="local", - config={"path": tmpdir} - ) - db.add(source) - await db.commit() - await db.refresh(source) - yield source - -@pytest_asyncio.fixture -async def test_job(db: AsyncSession, test_source): - with tempfile.TemporaryDirectory() as tmpdir: - job = Job( - name="Test Job", - source_id=test_source.id, - strategy="full", - destination_path=tmpdir - ) - db.add(job) - await db.commit() - await db.refresh(job) - yield job - -@pytest.mark.asyncio -async def test_execute_full_backup(db: AsyncSession, test_job): - engine = BackupEngine(db) - execution = await engine.execute_job(test_job.id, triggered_by="manual") - - assert execution.status == "success" - assert execution.bytes_processed > 0 - assert execution.bytes_backed_up > 0 - assert execution.triggered_by == "manual" - - # Refresh test_job to load executions relationship - await db.refresh(test_job, ["executions"]) - - # Verify backup was created - assert len(test_job.executions) == 1 - - # Refresh execution to load backups relationship - await db.refresh(test_job.executions[0], ["backups"]) - backup = test_job.executions[0].backups[0] - assert backup.type == "full" - assert backup.checksum is not None - assert os.path.exists(backup.storage_path) - -@pytest.mark.asyncio -async def test_execute_incremental_without_full(db: AsyncSession, test_job): - # Set job to incremental but no full backup exists - test_job.strategy = "incremental" - await db.commit() - - engine = BackupEngine(db) - execution = await engine.execute_job(test_job.id) - - # Should fall back to full backup - assert execution.status == "success" - - # Refresh execution to load backups relationship - await db.refresh(execution, ["backups"]) - backup = execution.backups[0] - assert backup.type == "full" - assert backup.parent_backup_id is None diff --git a/backend/tests/test_jobs.py b/backend/tests/test_jobs.py deleted file mode 100644 index aee96e1..0000000 --- a/backend/tests/test_jobs.py +++ /dev/null @@ -1,205 +0,0 @@ -import pytest - -@pytest.mark.asyncio -async def test_create_job(client): - # Create a source first (job requires source_id) - source_resp = await client.post("/api/sources/", json={ - "name": "Test Source", - "type": "local", - "config": {"path": "/tmp/test"} - }) - assert source_resp.status_code == 200 - source_id = source_resp.json()["id"] - - response = await client.post("/api/jobs/", json={ - "name": "Test Job", - "source_id": source_id, - "strategy": "full", - "destination_path": "/tmp/backups", - "exclude_patterns": [], - "enabled": True - }) - assert response.status_code == 200 - data = response.json() - assert data["name"] == "Test Job" - assert data["source_id"] == source_id - assert data["strategy"] == "full" - assert "id" in data - -@pytest.mark.asyncio -async def test_list_jobs(client): - # Create source and job - source_resp = await client.post("/api/sources/", json={ - "name": "Test Source", - "type": "local", - "config": {"path": "/tmp/test"} - }) - source_id = source_resp.json()["id"] - - await client.post("/api/jobs/", json={ - "name": "Test Job", - "source_id": source_id, - "strategy": "full", - "destination_path": "/tmp/backups", - "exclude_patterns": [], - "enabled": True - }) - - response = await client.get("/api/jobs/") - assert response.status_code == 200 - data = response.json() - assert len(data) >= 1 - -@pytest.mark.asyncio -async def test_get_job(client): - source_resp = await client.post("/api/sources/", json={ - "name": "Test Source", - "type": "local", - "config": {"path": "/tmp/test"} - }) - source_id = source_resp.json()["id"] - - job_resp = await client.post("/api/jobs/", json={ - "name": "Test Job", - "source_id": source_id, - "strategy": "full", - "destination_path": "/tmp/backups", - "exclude_patterns": [], - "enabled": True - }) - job_id = job_resp.json()["id"] - - response = await client.get(f"/api/jobs/{job_id}") - assert response.status_code == 200 - assert response.json()["id"] == job_id - -@pytest.mark.asyncio -async def test_delete_job(client): - source_resp = await client.post("/api/sources/", json={ - "name": "Test Source", - "type": "local", - "config": {"path": "/tmp/test"} - }) - source_id = source_resp.json()["id"] - - job_resp = await client.post("/api/jobs/", json={ - "name": "Delete Me", - "source_id": source_id, - "strategy": "full", - "destination_path": "/tmp/backups", - "exclude_patterns": [], - "enabled": True - }) - job_id = job_resp.json()["id"] - - response = await client.delete(f"/api/jobs/{job_id}") - assert response.status_code == 200 - - # Verify deletion - get_resp = await client.get(f"/api/jobs/{job_id}") - assert get_resp.status_code == 404 - -@pytest.mark.asyncio -async def test_run_job(client): - # Create source - source_resp = await client.post("/api/sources/", json={ - "name": "Test Source", - "type": "local", - "config": {"path": "/tmp/test"} - }) - source_id = source_resp.json()["id"] - - # Create job - job_resp = await client.post("/api/jobs/", json={ - "name": "Test Job", - "source_id": source_id, - "strategy": "full", - "destination_path": "/tmp/backups", - "exclude_patterns": [], - "enabled": True - }) - job_id = job_resp.json()["id"] - - response = await client.post(f"/api/jobs/{job_id}/run") - assert response.status_code == 200 - assert response.json()["message"] == "Job execution started" - -@pytest.mark.asyncio -async def test_run_job_not_found(client): - response = await client.post("/api/jobs/999/run") - assert response.status_code == 404 - -@pytest.mark.asyncio -async def test_update_job(client): - # Create source - source_resp = await client.post("/api/sources/", json={ - "name": "Test Source", - "type": "local", - "config": {"path": "/tmp/test"} - }) - source_id = source_resp.json()["id"] - - # Create job - job_resp = await client.post("/api/jobs/", json={ - "name": "Original Name", - "source_id": source_id, - "strategy": "full", - "destination_path": "/tmp/backups", - "exclude_patterns": [], - "enabled": True - }) - job_id = job_resp.json()["id"] - - response = await client.put(f"/api/jobs/{job_id}", json={ - "name": "Updated Name" - }) - assert response.status_code == 200 - assert response.json()["name"] == "Updated Name" - assert response.json()["strategy"] == "full" # Unchanged - -@pytest.mark.asyncio -async def test_create_schedule(client): - # Create source - source_resp = await client.post("/api/sources/", json={ - "name": "Test Source", - "type": "local", - "config": {"path": "/tmp/test"} - }) - source_id = source_resp.json()["id"] - - # Create job - job_resp = await client.post("/api/jobs/", json={ - "name": "Test Job", - "source_id": source_id, - "strategy": "full", - "destination_path": "/tmp/backups", - "exclude_patterns": [], - "enabled": True - }) - job_id = job_resp.json()["id"] - - response = await client.post(f"/api/jobs/{job_id}/schedule", json={ - "job_id": job_id, - "cron_expression": "0 0 * * *", - "enabled": True - }) - assert response.status_code == 200 - data = response.json() - assert data["job_id"] == job_id - assert data["cron_expression"] == "0 0 * * *" - assert "id" in data - -@pytest.mark.asyncio -async def test_get_job_not_found(client): - response = await client.get("/api/jobs/99999") - assert response.status_code == 404 - -@pytest.mark.asyncio -async def test_update_job_not_found(client): - response = await client.put("/api/jobs/99999", json={"name": "Test"}) - assert response.status_code == 404 - -@pytest.mark.asyncio -async def test_delete_job_not_found(client): - response = await client.delete("/api/jobs/99999") - assert response.status_code == 404 diff --git a/backend/tests/test_sources.py b/backend/tests/test_sources.py deleted file mode 100644 index 14ff2ae..0000000 --- a/backend/tests/test_sources.py +++ /dev/null @@ -1,88 +0,0 @@ -import pytest - -@pytest.mark.asyncio -async def test_create_source(client): - response = await client.post("/api/sources/", json={ - "name": "Test Source", - "type": "local", - "config": {"path": "/tmp/test"} - }) - assert response.status_code == 200 - data = response.json() - assert data["name"] == "Test Source" - assert data["type"] == "local" - assert "id" in data - -@pytest.mark.asyncio -async def test_list_sources(client): - # Create source first - await client.post("/api/sources/", json={ - "name": "Test Source", - "type": "local", - "config": {"path": "/tmp/test"} - }) - - response = await client.get("/api/sources/") - assert response.status_code == 200 - data = response.json() - assert len(data) >= 1 - -@pytest.mark.asyncio -async def test_get_source(client): - create_resp = await client.post("/api/sources/", json={ - "name": "Test Source", - "type": "local", - "config": {"path": "/tmp/test"} - }) - source_id = create_resp.json()["id"] - - response = await client.get(f"/api/sources/{source_id}") - assert response.status_code == 200 - assert response.json()["id"] == source_id - -@pytest.mark.asyncio -async def test_delete_source(client): - create_resp = await client.post("/api/sources/", json={ - "name": "Delete Me", - "type": "local", - "config": {"path": "/tmp/test"} - }) - source_id = create_resp.json()["id"] - - response = await client.delete(f"/api/sources/{source_id}") - assert response.status_code == 200 - - # Verify deletion - get_resp = await client.get(f"/api/sources/{source_id}") - assert get_resp.status_code == 404 - -@pytest.mark.asyncio -async def test_update_source(client): - create_resp = await client.post("/api/sources/", json={ - "name": "Original Name", - "type": "local", - "config": {"path": "/tmp/test"} - }) - source_id = create_resp.json()["id"] - - response = await client.put(f"/api/sources/{source_id}", json={ - "name": "Updated Name" - }) - assert response.status_code == 200 - assert response.json()["name"] == "Updated Name" - assert response.json()["type"] == "local" # Unchanged - -@pytest.mark.asyncio -async def test_get_source_not_found(client): - response = await client.get("/api/sources/99999") - assert response.status_code == 404 - -@pytest.mark.asyncio -async def test_update_source_not_found(client): - response = await client.put("/api/sources/99999", json={"name": "Test"}) - assert response.status_code == 404 - -@pytest.mark.asyncio -async def test_delete_source_not_found(client): - response = await client.delete("/api/sources/99999") - assert response.status_code == 404 diff --git a/contracts/repository/v1/capabilities-v2.0.json b/contracts/repository/v1/capabilities-v2.0.json new file mode 100644 index 0000000..6dff906 --- /dev/null +++ b/contracts/repository/v1/capabilities-v2.0.json @@ -0,0 +1,14 @@ +{ + "api_version": "v2", + "sources": ["local", "ssh"], + "repositories": ["local"], + "features": { + "email": true, + "encryption": true, + "mysql": false, + "postgresql": false, + "restore": true, + "tar_download": false, + "webhook": true + } +} diff --git a/contracts/repository/v1/error-codes.json b/contracts/repository/v1/error-codes.json new file mode 100644 index 0000000..0a2b8cb --- /dev/null +++ b/contracts/repository/v1/error-codes.json @@ -0,0 +1,22 @@ +[ + "authentication_failed", + "baseline_missing", + "cancelled_by_operator", + "capacity_exhausted", + "conflict_active_execution", + "corrupt_blob", + "corrupt_manifest", + "deletion_failed", + "forbidden", + "host_key_changed", + "host_key_unknown", + "invalid_configuration", + "not_found", + "permission_denied", + "source_changed", + "source_empty", + "timeout", + "transient_io", + "unsupported_entry", + "worker_lost" +] diff --git a/contracts/repository/v1/execution-transitions.json b/contracts/repository/v1/execution-transitions.json new file mode 100644 index 0000000..98e0770 --- /dev/null +++ b/contracts/repository/v1/execution-transitions.json @@ -0,0 +1,10 @@ +{ + "queued": ["cancelled", "preparing"], + "preparing": ["cancelling", "failed", "running"], + "running": ["cancelling", "failed", "verifying"], + "verifying": ["committed", "failed"], + "cancelling": ["cancelled", "failed"], + "committed": [], + "failed": [], + "cancelled": [] +} diff --git a/contracts/repository/v1/fault-points.json b/contracts/repository/v1/fault-points.json new file mode 100644 index 0000000..f865e1a --- /dev/null +++ b/contracts/repository/v1/fault-points.json @@ -0,0 +1,14 @@ +[ + "blob.before_write", + "blob.after_write", + "blob.after_fsync", + "blob.before_rename", + "manifest.before_write", + "manifest.after_fsync", + "manifest.before_publish", + "metadata.before_commit", + "metadata.after_commit", + "restore.before_write", + "restore.after_fsync", + "restore.before_replace" +] diff --git a/contracts/repository/v1/fixtures/invalid-manifest.json b/contracts/repository/v1/fixtures/invalid-manifest.json new file mode 100644 index 0000000..a114da6 --- /dev/null +++ b/contracts/repository/v1/fixtures/invalid-manifest.json @@ -0,0 +1 @@ +{"aggregates":{"entry_count":-1,"logical_bytes":-5,"stored_bytes":0},"backup_id":"bad","created_at":"not-a-time","effective_mode":"full","encryption_key_id":null,"entries":[],"exclusion_policy":{"matcher":"glob","patterns":[],"version":0},"execution_id":"bad","format_version":2,"job_id":"bad","manifest_digest":"short","repository_id":"bad","requested_mode":"full","source_consistency":{},"source_id":"bad"} diff --git a/contracts/repository/v1/fixtures/invalid-repository.json b/contracts/repository/v1/fixtures/invalid-repository.json new file mode 100644 index 0000000..42df253 --- /dev/null +++ b/contracts/repository/v1/fixtures/invalid-repository.json @@ -0,0 +1 @@ +{"compression":"none","created_at":"2026-07-27T00:00:00Z","digest_algorithm":"md5","encryption":{"key_id":null,"mode":"none"},"format_version":2,"repository_id":"not-a-uuid"} diff --git a/contracts/repository/v1/fixtures/valid-manifest.json b/contracts/repository/v1/fixtures/valid-manifest.json new file mode 100644 index 0000000..a4e1e6d --- /dev/null +++ b/contracts/repository/v1/fixtures/valid-manifest.json @@ -0,0 +1 @@ +{"aggregates":{"entry_count":2,"logical_bytes":5,"stored_bytes":5},"backup_id":"0198c57f-0000-7000-8000-000000000006","created_at":"2026-07-27T00:00:00Z","effective_mode":"full","encryption_key_id":null,"entries":[{"blob_digest":null,"link_target":null,"metadata_support":["mode","mtime_ns"],"mode":493,"mtime_ns":0,"path":"data","size":0,"type":"directory"},{"blob_digest":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","link_target":null,"metadata_support":["mode","mtime_ns"],"mode":420,"mtime_ns":0,"path":"data/hello.txt","size":5,"type":"file"}],"exclusion_policy":{"matcher":"gitignore","patterns":[],"version":1},"execution_id":"0198c57f-0000-7000-8000-000000000005","format_version":1,"job_id":"0198c57f-0000-7000-8000-000000000004","manifest_digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","manifest_signature":{"algorithm":"ed25519","key_id":"manifest-signing-key-1","value":"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"},"repository_id":"0198c57f-0000-7000-8000-000000000001","requested_mode":"full","source_consistency":{"adapter":"local","captured_at":"2026-07-27T00:00:00Z","evidence":{"snapshot":"stable"}},"source_id":"0198c57f-0000-7000-8000-000000000003"} diff --git a/contracts/repository/v1/fixtures/valid-repository.json b/contracts/repository/v1/fixtures/valid-repository.json new file mode 100644 index 0000000..8457345 --- /dev/null +++ b/contracts/repository/v1/fixtures/valid-repository.json @@ -0,0 +1 @@ +{"compression":"none","created_at":"2026-07-27T00:00:00Z","digest_algorithm":"sha256","encryption":{"key_id":null,"mode":"none"},"format_version":1,"repository_id":"0198c57f-0000-7000-8000-000000000001"} diff --git a/contracts/repository/v1/manifest.schema.json b/contracts/repository/v1/manifest.schema.json new file mode 100644 index 0000000..8313d1d --- /dev/null +++ b/contracts/repository/v1/manifest.schema.json @@ -0,0 +1,126 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://backup-tool.invalid/contracts/repository/v1/manifest.schema.json", + "title": "Backup Tool Manifest v1", + "type": "object", + "additionalProperties": false, + "required": [ + "format_version", "backup_id", "repository_id", "source_id", "job_id", "execution_id", + "requested_mode", "effective_mode", "created_at", "source_consistency", "exclusion_policy", + "entries", "aggregates", "encryption_key_id", "manifest_digest", "manifest_signature" + ], + "properties": { + "format_version": {"const": 1}, + "backup_id": {"$ref": "#/$defs/uuidv7"}, + "repository_id": {"$ref": "#/$defs/uuidv7"}, + "source_id": {"$ref": "#/$defs/uuidv7"}, + "job_id": {"$ref": "#/$defs/uuidv7"}, + "execution_id": {"$ref": "#/$defs/uuidv7"}, + "requested_mode": {"enum": ["full", "incremental"]}, + "effective_mode": {"enum": ["full", "incremental"]}, + "created_at": {"type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]+)?Z$"}, + "source_consistency": { + "type": "object", + "additionalProperties": false, + "required": ["adapter", "captured_at", "evidence"], + "properties": { + "adapter": {"enum": ["local", "postgresql", "mysql"]}, + "captured_at": {"type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]+)?Z$"}, + "evidence": {"type": "object"} + } + }, + "exclusion_policy": { + "type": "object", + "additionalProperties": false, + "required": ["matcher", "version", "patterns"], + "properties": { + "matcher": {"const": "gitignore"}, + "version": {"type": "integer", "minimum": 1}, + "patterns": {"type": "array", "items": {"type": "string"}} + } + }, + "entries": { + "type": "array", + "items": {"$ref": "#/$defs/entry"} + }, + "aggregates": { + "type": "object", + "additionalProperties": false, + "required": ["entry_count", "logical_bytes", "stored_bytes"], + "properties": { + "entry_count": {"type": "integer", "minimum": 0}, + "logical_bytes": {"type": "integer", "minimum": 0}, + "stored_bytes": {"type": "integer", "minimum": 0} + } + }, + "encryption_key_id": {"type": ["string", "null"]}, + "manifest_digest": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "manifest_signature": { + "type": "object", + "additionalProperties": false, + "required": ["algorithm", "key_id", "value"], + "properties": { + "algorithm": {"const": "ed25519"}, + "key_id": {"type": "string", "minLength": 1}, + "value": {"type": "string", "pattern": "^[0-9a-f]{128}$"} + } + } + }, + "$defs": { + "uuidv7": { + "type": "string", + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "entry": { + "type": "object", + "additionalProperties": false, + "required": ["path", "type", "size", "blob_digest", "mode", "mtime_ns", "link_target", "metadata_support"], + "properties": { + "path": {"type": "string", "minLength": 1, "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))(?!.*\\\\).+$"}, + "type": {"enum": ["file", "directory", "symlink"]}, + "size": {"type": "integer", "minimum": 0}, + "blob_digest": {"type": ["string", "null"], "pattern": "^[0-9a-f]{64}$"}, + "mode": {"type": ["integer", "null"], "minimum": 0}, + "mtime_ns": {"type": ["integer", "null"], "minimum": 0}, + "link_target": {"type": ["string", "null"]}, + "metadata_support": {"type": "array", "items": {"type": "string"}, "uniqueItems": true} + }, + "allOf": [ + { + "if": {"properties": {"type": {"const": "file"}}}, + "then": { + "properties": { + "blob_digest": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "link_target": {"type": "null"} + } + } + }, + { + "if": {"properties": {"type": {"const": "directory"}}}, + "then": { + "properties": { + "blob_digest": {"type": "null"}, + "link_target": {"type": "null"}, + "size": {"const": 0} + } + } + }, + { + "if": {"properties": {"type": {"const": "symlink"}}}, + "then": { + "properties": { + "blob_digest": {"type": "null"}, + "link_target": { + "type": "string", + "minLength": 1, + "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))(?!.*\\\\).+$" + }, + "size": {"const": 0} + } + } + } + ] + } + } +} diff --git a/contracts/repository/v1/normalized-paths.json b/contracts/repository/v1/normalized-paths.json new file mode 100644 index 0000000..0f202c2 --- /dev/null +++ b/contracts/repository/v1/normalized-paths.json @@ -0,0 +1,9 @@ +[ + {"raw": "file.txt", "normalized": "file.txt", "valid": true}, + {"raw": "dir/./file.txt", "normalized": "dir/file.txt", "valid": true}, + {"raw": "unicodé/文件.txt", "normalized": "unicodé/文件.txt", "valid": true}, + {"raw": "/absolute", "normalized": null, "valid": false}, + {"raw": "../escape", "normalized": null, "valid": false}, + {"raw": "a\\b", "normalized": null, "valid": false}, + {"raw": "", "normalized": null, "valid": false} +] diff --git a/contracts/repository/v1/repository.schema.json b/contracts/repository/v1/repository.schema.json new file mode 100644 index 0000000..9a16166 --- /dev/null +++ b/contracts/repository/v1/repository.schema.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://backup-tool.invalid/contracts/repository/v1/repository.schema.json", + "title": "Backup Tool Repository v1", + "type": "object", + "additionalProperties": false, + "required": ["repository_id", "format_version", "digest_algorithm", "compression", "encryption", "created_at"], + "allOf": [ + { + "if": {"properties": {"encryption": {"properties": {"mode": {"const": "none"}}}}}, + "then": {"properties": {"encryption": {"properties": {"key_id": {"type": "null"}}}}}, + "else": {"properties": {"encryption": {"properties": {"key_id": {"type": "string", "format": "uuid", "minLength": 1}}}}} + } + ], + "properties": { + "repository_id": { + "type": "string", + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "format_version": {"const": 1}, + "digest_algorithm": {"const": "sha256"}, + "compression": {"enum": ["none", "zstd"]}, + "encryption": { + "type": "object", + "additionalProperties": false, + "required": ["mode", "key_id"], + "properties": { + "mode": {"enum": ["none", "aes-256-gcm"]}, + "key_id": {"type": ["string", "null"], "minLength": 1} + } + }, + "created_at": {"type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]+)?Z$"} + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 9cad1df..78e3e58 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,60 +1,115 @@ -version: "3.8" +name: backup-tool + +x-app-base: &app-base + build: + context: . + dockerfile: Dockerfile + image: backup-tool:local + init: true + read_only: true + tmpfs: + - /tmp:mode=1777,size=64m + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + environment: + BACKUP_TOOL_DATA_DIR: /var/lib/backup-tool + BACKUP_TOOL_DATABASE_URL: sqlite+aiosqlite:////var/lib/backup-tool/metadata.db + BACKUP_TOOL_REPOSITORY_ROOTS: '["/var/lib/backup-tool/repositories"]' + BACKUP_TOOL_LOCAL_SOURCE_ROOTS: '["/mnt/sources"]' + BACKUP_TOOL_RESTORE_ROOTS: '["/var/lib/backup-tool/restores"]' + BACKUP_TOOL_MASTER_KEY_FILE: /run/backup-tool-secrets/master.key + BACKUP_TOOL_WEB_SOCKET_PATH: /run/backup-tool/web.sock + BACKUP_TOOL_PUBLIC_BASE_URL: ${BACKUP_TOOL_PUBLIC_BASE_URL:-http://localhost:8080} + volumes: + - backup-tool-data:/var/lib/backup-tool + - backup-tool-runtime:/run/backup-tool + - type: bind + source: ${BACKUP_TOOL_SOURCES_DIR:-./sources} + target: /mnt/sources + read_only: true + - type: bind + source: ${BACKUP_TOOL_MASTER_KEY_FILE:-./secrets/master.key} + target: /run/backup-tool-secrets/master.key + read_only: true services: - backend: - build: - context: ./backend - dockerfile: Dockerfile - container_name: backup-tool-backend - ports: - - "8000:8000" - environment: - - DATABASE_URL=sqlite+aiosqlite:///data/backup_tool.db - - CORS_ORIGINS=http://localhost:3000 - - BACKUP_STORAGE_PATH=/app/backups - volumes: - - backup-data:/app/data - - backup-storage:/app/backups - restart: unless-stopped - healthcheck: - test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 5s + migrate: + <<: *app-base + command: ["migrate", "upgrade"] + restart: "no" - frontend: + web: + <<: *app-base + command: ["web"] + depends_on: + migrate: + condition: service_completed_successfully + healthcheck: + test: ["CMD", "backup-tool", "health", "web"] + interval: 10s + timeout: 10s + retries: 6 + start_period: 0s + + scheduler: + <<: *app-base + command: ["scheduler"] + depends_on: + migrate: + condition: service_completed_successfully + healthcheck: + test: ["CMD", "backup-tool", "health", "scheduler"] + interval: 10s + timeout: 10s + retries: 6 + start_period: 0s + + worker: + <<: *app-base + command: ["worker"] + depends_on: + migrate: + condition: service_completed_successfully + healthcheck: + test: ["CMD", "backup-tool", "health", "worker"] + interval: 10s + timeout: 10s + retries: 6 + start_period: 0s + + admin: + <<: *app-base + profiles: ["admin"] + command: ["admin", "--help"] + depends_on: + migrate: + condition: service_completed_successfully + + proxy: build: context: ./frontend dockerfile: Dockerfile - container_name: backup-tool-frontend + image: backup-tool-proxy:local + init: true + read_only: true + tmpfs: + - /tmp:mode=1777,size=64m + - /var/cache/nginx:uid=10001,gid=0,mode=0750,size=32m + - /var/run:uid=10001,gid=0,mode=0750,size=8m + cap_drop: + - ALL + security_opt: + - no-new-privileges:true ports: - - "3000:80" - depends_on: - - backend - restart: unless-stopped - profiles: - - prod - - frontend-dev: - image: node:20-alpine - container_name: backup-tool-frontend-dev - working_dir: /app - ports: - - "3000:3000" + - "127.0.0.1:${BACKUP_TOOL_PORT:-8080}:8080" volumes: - - ./frontend:/app - - /app/node_modules - command: sh -c "npm install && npm run dev" - environment: - - VITE_API_URL=http://localhost:8000 + - backup-tool-runtime:/run/backup-tool:ro depends_on: - - backend - profiles: - - dev + web: + condition: service_healthy volumes: - backup-data: - driver: local - backup-storage: - driver: local + backup-tool-data: + backup-tool-runtime: diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..c1b0612 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,42 @@ +# Backup Tool v2 documentation + +## Reason for existence + +This index points operators and contributors to the authoritative v2.0 procedures. Do not duplicate runbook steps in release evidence or issue comments. + +## Start here + +- [Project quick start](../README.md) +- [Upgrade and rollback](runbooks/upgrade.md) +- [Observability and alert response](runbooks/observability.md) +- [Disaster recovery](runbooks/disaster-recovery.md) + +## Runbooks + +| Need | Authoritative guide | +| --- | --- | +| Repository lifecycle | [repositories](runbooks/repositories.md) | +| Metadata protection | [metadata](runbooks/metadata.md) | +| Master and repository keys | [keys](runbooks/keys.md) | +| Encrypted recovery bundle | [recovery bundle](runbooks/recovery-bundle.md) | +| SSH source hardening | [SSH sources](runbooks/ssh-sources.md) | +| Notification operations | [notifications](runbooks/notifications.md) | +| Upgrade safety | [upgrade](runbooks/upgrade.md) | +| Host loss | [disaster recovery](runbooks/disaster-recovery.md) | + +## Security boundaries + +- [Repository encryption threat model](security/repository-encryption.md) +- [Notification egress and delivery policy](security/notifications.md) +- SSH sources require private-key authentication, exact host-key pinning, and a forced-SFTP chrooted account. No password, shell, agent, tunnel, or remote-command mode exists. + +## Release records + +[Release evidence](release/) records verification rather than replacing runbooks. v2.0 scale certification is documented in `release/m15-evidence.md` and `release/m15-scale-report.json`. + +## Verify + +```sh +grep -c '^## ' README.md +make check +``` diff --git a/docs/release/m0-evidence.md b/docs/release/m0-evidence.md new file mode 100644 index 0000000..a383d83 --- /dev/null +++ b/docs/release/m0-evidence.md @@ -0,0 +1,75 @@ +# M0 Protocol Foundation Evidence + +**Branch:** `feature/v2-reimplementation` +**RED commit:** `92f2aaa` +**GREEN commit:** commit containing this evidence file, immediately after `92f2aaa` + +## RED + +Command: + +```bash +.venv/bin/python -m pytest \ + tests/contract/test_repository_format.py \ + tests/contract/test_no_v1.py -q +``` + +Exit: `1` (expected). Output summary: + +```text +9 failed, 9 passed in 1.59s +``` + +The failures proved absent enforcement for UUIDv7/RFC3339, manifest signature and source-consistency shape, entry/blob/link rules, and runtime/config canaries for v1 database, payload, import, conversion, timestamp parsing, and old entry points. + +## GREEN + +Clean bootstrap command: + +```bash +make setup +``` + +Exit: `0`. It created/reused `.venv`, installed `backend[dev]` through `.venv/bin/python`, installed the committed frontend lockfile with `npm ci`, and reported `0 vulnerabilities`. + +Whole-M0 check: + +```bash +make check +``` + +Exit: `0`. Output summary: + +```text +v1 compatibility scan: OK +18 passed in 2.34s +All checks passed! +5 files already formatted +Success: no issues found in 2 source files +frontend TypeScript check: passed +frontend Vite production build: passed +``` + +Exact milestone acceptance plus required frontend build: + +```bash +.venv/bin/python tools/forbidden_v1_scan.py . && \ +.venv/bin/python -m pytest \ + tests/contract/test_repository_format.py \ + tests/contract/test_no_v1.py -q && \ +npm --prefix frontend run build +``` + +Exit: `0`. Output summary: + +```text +v1 compatibility scan: OK +18 passed in 2.24s +vite v8.1.5 production build completed +``` + +## Scope Confirmation + +- No M1 runtime, database, UUID generator, API, or migration behavior was implemented. +- Obsolete v1 backend container/Compose entry points were removed because the config-scope canary gate proved they still invoked v1 code. M14 owns replacement deployment packaging. +- The v1 reference remains available at tag `v1-reference-2026-07-27`. diff --git a/docs/release/m1-evidence.md b/docs/release/m1-evidence.md new file mode 100644 index 0000000..b704596 --- /dev/null +++ b/docs/release/m1-evidence.md @@ -0,0 +1,62 @@ +# M1 Runtime and Metadata Evidence + +## RED + +Commit: `8f78484 test(v2): define runtime and metadata contracts` + +Command: + +```bash +.venv/bin/python -m pytest tests/unit/test_config.py tests/integration/test_migrations.py -q +``` + +Observed result: exit 2 during collection because `backup_tool.clock` did not exist. This proves the test-only commit preceded runtime implementation. + +## GREEN + +Migration gate executed against a disposable absolute SQLite database: + +```bash +cd backend +BACKUP_TOOL_DATABASE_URL=sqlite+aiosqlite:////absolute/path/.m1-gate.db ../.venv/bin/python -m alembic upgrade head +BACKUP_TOOL_DATABASE_URL=sqlite+aiosqlite:////absolute/path/.m1-gate.db ../.venv/bin/python -m alembic downgrade base +BACKUP_TOOL_DATABASE_URL=sqlite+aiosqlite:////absolute/path/.m1-gate.db ../.venv/bin/python -m alembic upgrade head +../.venv/bin/python -m pytest ../tests/unit/test_config.py ../tests/integration/test_migrations.py -q +cd .. +make check +``` + +Observed results: + +- Alembic upgrade, downgrade, and second upgrade each exited 0. +- Focused M1 suite: `13 passed`. +- Fast unit/contract suite: `28 passed`. +- Ruff check/format and strict mypy: passed. +- Frontend typecheck/build: passed. +- Disposable database removed after verification. + +## Review-Fix RED/GREEN + +RED commit: `45a526a test(v2): prove runtime dispatch and persistence invariants` + +The focused M1 command exited 1 with five intended failures: role dispatch was absent, migrate did not dispatch Alembic, database roles accepted an unmigrated DB, persisted ORM IDs had no default, and migration connections had no shared SQLite configurator. + +GREEN verification after implementing the missing behavior: + +- CLI dispatch and `migrate upgrade` tests passed. +- Unmigrated worker startup was rejected before its handler ran. +- Persisted User and Repository IDs were UUIDv7. +- Generated and non-UTC supplied timestamps reloaded as aware UTC. +- Alembic connection observation reported `foreign_keys=1`, `journal_mode=wal`, and `busy_timeout=7000`. +- Baseline active-execution index, schedule uniqueness, and execution checks were present. +- Focused M1 suite: `19 passed`. +- Fast unit/contract suite: `30 passed`. +- Alembic upgrade/downgrade/upgrade, Ruff, strict mypy, frontend typecheck/build, forbidden-v1 scan, and `git diff --check`: passed. + +## Hand Review + +- Baseline revision contains explicit `op.create_table`, constraints, foreign keys, and indexes for all 14 v2 entities; it does not call `create_all`. +- Runtime and Alembic share one SQLite connection configurator; lock waiting uses the driver timeout rather than interpolated PRAGMA SQL. +- Startup compares the database Alembic revision with the current script head and rejects unmigrated databases. +- Every ORM identity has a UUIDv7 default; every persisted datetime uses the aware-UTC normalizing type. +- Runtime roles are explicit CLI subcommands and dispatch only after the schema gate; `migrate` invokes Alembic directly. diff --git a/docs/release/m10-evidence.md b/docs/release/m10-evidence.md new file mode 100644 index 0000000..66ccdf7 --- /dev/null +++ b/docs/release/m10-evidence.md @@ -0,0 +1,29 @@ +# M10 SSH source evidence + +The released source capability is `ssh`: private-key-only, pinned-host-key, +forced-SFTP chroot access. No password, shell, command channel, agent, default +key discovery, or arbitrary remote root is supported. + +## Fixture + +`tests/compose.ssh.yaml` builds a test-only OpenSSH server. Each run generates +host and client Ed25519 keys under pytest `tmp_path`, mounts no committed keys, +and configures a dedicated `backup` account with `ChrootDirectory /home/backup` +and `ForceCommand internal-sftp`. The ordinary operator Compose stack is not +modified. Run `make test-ssh-integration` to build, run, and tear down the +fixture. + +## Verification + +- Fake transport tests cover pin mismatch before authentication/SFTP, bounded + reads, unsafe entries, and accepted/rejected private-key algorithms. +- The opt-in live test covers a private-key probe, backup, signed verification, + and restore through the forced-SFTP fixture. +- `make test-ssh-integration` passed after fixture isolation and SFTP-channel + concurrency fixes. +- Final `make check` passed: 107 unit/contract, 73 integration (one skipped), + 15 fault, and 33 security tests; Ruff, mypy, TypeScript, and the frontend + build passed. + +See `docs/runbooks/ssh-sources.md` for deployment prerequisites, rotation, and +containment limitations. diff --git a/docs/release/m11-evidence.md b/docs/release/m11-evidence.md new file mode 100644 index 0000000..0eba981 --- /dev/null +++ b/docs/release/m11-evidence.md @@ -0,0 +1,55 @@ +# M11 recovery import evidence + +- Recovery bundles use the existing versioned `BTREC` Argon2id/AES-GCM codec + and now carry a version-2 authenticated catalog containing repositories, + key epochs, sources, jobs, executions, and backups required for encrypted + restore. +- `backup-tool admin recovery import` requires a migrated empty destination DB, + re-inspects every surviving repository through configured allowlists, writes + signing/data keys exclusively with mode `0600`, and rejects conflicts. +- Imported sources are `unavailable`; imported jobs are `archived` and disabled. + Imported metadata therefore supports existing restore records without silently + restarting backup schedules. +- The focused host-loss drill exports an encrypted backup, imports it into a + fresh metadata/key host, and restores the file byte-for-byte. It also proves + unsafe repository paths and non-empty destination metadata are rejected. +- Encrypted repository creation is enabled only after that drill passed and now + records its active data-key epoch atomically with repository metadata. +- Interrupted imports remove newly installed key files on handled failure; a + retry also safely adopts only exact, authenticated key files left by an + unclean process loss. Rotation writes an fsynced repository journal before + its DB transition; worker startup deterministically completes a committed + epoch transition or removes an uncommitted one while retaining old-active. + A stale rollback journal is cleared safely even when the unreferenced new key + was already deleted before the journal cleanup could run. +- Key-aware GC decrypts encrypted manifests using their declared epoch key and + remains fail-closed for absent, wrong, or corrupt keys/manifests. Restore + removes setuid, setgid, and sticky bits from captured modes. Migration 0007 + refuses downgrade while key metadata is populated. +- Recovery catalog import preserves backup `created_at` and `tombstoned_at`. +- Snapshot staging roots, per-execution directories, blob directories, and + plaintext temporary blobs are created owner-only (`0700`/`0600`) independent + of umask and are rejected if their permissions are unsafe. + +## Verification + +```text +pytest tests/integration/test_encrypted_repository.py -q +5 passed + +make test-fault +11 passed + +make test-security +21 passed + +make lint && make typecheck && make frontend-build +passed + +make check +86 unit/contract, 53 integration, 12 fault, and 22 security tests passed; +Ruff, mypy, TypeScript, and frontend build passed. + +git diff --check && git diff --cached --quiet +passed +``` diff --git a/docs/release/m12-evidence.md b/docs/release/m12-evidence.md new file mode 100644 index 0000000..3755ca9 --- /dev/null +++ b/docs/release/m12-evidence.md @@ -0,0 +1,43 @@ +# M12 evidence + +- Migration revision: `0008_notification_outbox` +- Event schema version: `1` +- Catalog: 22 live stable IDs only. Deferred/unimplemented operation types are not public notification contracts. +- Delivery guarantee: durable at-least-once, stable event ID, leased worker retries; receiver deduplication is required. + +## Green focused evidence + +```text +PYTHONPATH=.:backend/src .venv/bin/python -m pytest \ + tests/contract/test_notification_contract.py \ + tests/integration/test_migrations.py \ + tests/integration/test_all_operational_events_deliver.py \ + tests/integration/test_notifications.py \ + tests/fault/test_notification_retries.py \ + tests/security/test_webhook_ssrf.py -q +28 passed (live-catalog contract, fair dispatch regression; no deferred event IDs). + +# Scheduler-role service-path regression +PYTHONPATH=.:backend/src .venv/bin/python -m pytest \ + tests/integration/test_scheduler_live_sync.py -q +1 passed (scheduler role service path) +``` + +The suite uses temporary SQLite/repository roots, a fake SMTP implementation, fake resolver inputs, and dispatcher monkeypatches; it performs no real webhook DNS, HTTP, or SMTP delivery. It verifies receiver-visible versioned webhook headers/signatures, STARTTLS-before-AUTH SMTP behavior, transient/permanent SMTP classification, persisted SMTP attempt limits, lease-abandoned attempt closure, selected-only test sends, manual-retry idempotency, and the absence of a plaintext-secret idempotency verifier. Behavioral producer tests cover every live catalog family: execution, schedule, backup/verification, restore, and retention. They also prove fair dispatch under an execution backlog, scheduler-role delivery, and worker-owned retention/GC maintenance. The CLI scheduler role now runs `SchedulerService`; worker maintenance runs durable retention/GC on startup and at bounded intervals. + +## Quality evidence + +- `make test-fast`: 89 passed. +- `make test-integration`: 61 passed in 27.00s (the execution wrapper nevertheless returned exit 124 at its fixed 30s wall limit). +- `make test-fault`: focused fair-dispatch regression passed. +- `make test-security`: 28 passed. +- `make lint` and `make typecheck`: passed. +- `make frontend-build`: passed. +- `git diff --check`: passed. +- staged-file check: no staged files. + +Final verification: `make check` passed — 90 unit/contract, 61 integration, 14 fault, and 28 security tests; Ruff, mypy, TypeScript, and the frontend build passed. `git diff --check` and the staged-file check also passed. + +## Rollback + +Disable or archive subscriptions and stop worker dispatch. Do not delete notification events, deliveries, or attempts: they remain audit history. A fresh host recovery intentionally starts with no notification settings or credentials and must be reconfigured. diff --git a/docs/release/m13-foundation-evidence.md b/docs/release/m13-foundation-evidence.md new file mode 100644 index 0000000..a1a22f4 --- /dev/null +++ b/docs/release/m13-foundation-evidence.md @@ -0,0 +1,20 @@ +# M13 UI/OpenAPI evidence + +- OpenAPI is deterministically exported to `openapi/v2.json`; generated browser client drift is checked by `npm --prefix frontend run api:check`. +- `text/event-stream` is declared in OpenAPI for execution events. The generated client intentionally emits `executionEventsUrl(...) -> URL`, not a misleading JSON `Promise`; UI opens that URL using browser `EventSource`. +- Notification delivery retry requests send an `Idempotency-Key` and the browser CSRF header. +- Recovery status is CLI/runbook-only. The browser has no export, import, bundle, key, or passphrase transfer control. +- v2.1 PostgreSQL, MySQL, and TAR/download controls remain absent. + +## Green M13 checks + +```text +npm --prefix frontend test -- --run # 9 passed +npm --prefix frontend run typecheck # passed +npm --prefix frontend run build # passed +npx --prefix frontend playwright test --config frontend/playwright.config.ts # 1 passed +.venv/bin/python tools/export_openapi.py --check openapi/v2.json # current +npm --prefix frontend run api:check # current +``` + +Final verification: `make check` passed — 90 unit/contract, 61 integration, 14 fault, and 28 security tests; Ruff, mypy, TypeScript, and the frontend build passed. `git diff --check` and the staged-file check also passed. diff --git a/docs/release/m14-evidence.md b/docs/release/m14-evidence.md new file mode 100644 index 0000000..646e260 --- /dev/null +++ b/docs/release/m14-evidence.md @@ -0,0 +1,34 @@ +# M14 operations and packaging evidence + +## Delivered + +- Pinned non-root OCI application and proxy images; isolated web, scheduler, worker, + migrate, and admin roles; same-origin Unix-socket proxy; no reload or embedded roles. + The proxy port is loopback-bound by default (`127.0.0.1`), so the localhost public + URL cannot permit remote first-admin setup takeover. Operators exposing it through + an external reverse proxy must set a non-loopback public URL and bootstrap secret. +- Role-aware readiness, JSON structured logs, safe worker claim shutdown, and + dependency-free Prometheus metrics for request volume/duration, active/stale/failed + execution state, schedule lag, corrupt/unavailable repositories, and free space. +- SBOM generation at `tools/generate_sbom.py`, generated CycloneDX artifact + `m14-sbom.json`, and base-image/source provenance in `m14-provenance.md`. +- Metadata, repository, key, upgrade, disaster-recovery, and observability runbooks. + +## Green verification + +```text + docker compose config --quiet # passed (loopback port binding) + docker compose build --pull # passed + make test-e2e # 1 passed in 51.69s (loopback regression) + make check # passed after loopback regression + 91 unit/contract, 61 integration, 15 fault, 33 security + Ruff/format, mypy (40 files), TypeScript, frontend build all passed + python tools/generate_sbom.py # 256 components +``` + +`test_compose_v2.py` creates its source and host-bind key fixture in pytest temporary +directories. The actual service-owned master key is generated only in an ephemeral +Compose named volume, then `down --volumes --remove-orphans` removes it. No fixture +secret or source directory is committed. The test runs migration, starts the stack, +checks readiness and metrics, stops/restarts the worker cleanly, restarts runtime +roles, verifies setup metadata persists, and tears down the project. diff --git a/docs/release/m14-provenance.md b/docs/release/m14-provenance.md new file mode 100644 index 0000000..f70c20e --- /dev/null +++ b/docs/release/m14-provenance.md @@ -0,0 +1,13 @@ +# M14 build provenance + +- **Source revision:** `396219e776aa9a115900d2b7bfd9fb5c1cfde115` +- **Application base:** `python:3.12.11-slim-bookworm@sha256:519591d6871b7bc437060736b9f7456b8731f1499a57e22e6c285135ae657bf7` +- **Frontend builder:** `node:22.17.1-alpine@sha256:5539840ce9d013fa13e3b9814c9353024be7ac75aca5db6d039504a56c04ea59` +- **Proxy base:** `nginx:1.29.7-alpine@sha256:e7257f1ef28ba17cf7c248cb8ccf6f0c6e0228ab9c315c152f9c203cd34cf6d1` +- **Build command:** `docker compose build --pull` +- **SBOM:** `docs/release/m14-sbom.json`, generated deterministically with + `python tools/generate_sbom.py` from the pinned backend manifest and frontend lockfile. + +The build uses digest-pinned bases and a non-root runtime user. Provenance records +inputs and generation instructions rather than embedding a mutable image tag or a +secret-bearing build environment. diff --git a/docs/release/m14-sbom.json b/docs/release/m14-sbom.json new file mode 100644 index 0000000..514d7c7 --- /dev/null +++ b/docs/release/m14-sbom.json @@ -0,0 +1,1551 @@ +{ + "bomFormat": "CycloneDX", + "components": [ + { + "name": "@adobe/css-tools", + "purl": "pkg:npm/@adobe/css-tools@4.4.4", + "type": "library", + "version": "4.4.4" + }, + { + "name": "@alloc/quick-lru", + "purl": "pkg:npm/@alloc/quick-lru@5.2.0", + "type": "library", + "version": "5.2.0" + }, + { + "name": "@asamuzakjp/css-color", + "purl": "pkg:npm/@asamuzakjp/css-color@6.0.5", + "type": "library", + "version": "6.0.5" + }, + { + "name": "@asamuzakjp/dom-selector", + "purl": "pkg:npm/@asamuzakjp/dom-selector@8.3.0", + "type": "library", + "version": "8.3.0" + }, + { + "name": "@babel/code-frame", + "purl": "pkg:npm/@babel/code-frame@7.29.0", + "type": "library", + "version": "7.29.0" + }, + { + "name": "@babel/helper-validator-identifier", + "purl": "pkg:npm/@babel/helper-validator-identifier@7.28.5", + "type": "library", + "version": "7.28.5" + }, + { + "name": "@babel/runtime", + "purl": "pkg:npm/@babel/runtime@7.29.2", + "type": "library", + "version": "7.29.2" + }, + { + "name": "@bramus/specificity", + "purl": "pkg:npm/@bramus/specificity@2.4.2", + "type": "library", + "version": "2.4.2" + }, + { + "name": "@csstools/color-helpers", + "purl": "pkg:npm/@csstools/color-helpers@6.1.0", + "type": "library", + "version": "6.1.0" + }, + { + "name": "@csstools/css-calc", + "purl": "pkg:npm/@csstools/css-calc@3.3.0", + "type": "library", + "version": "3.3.0" + }, + { + "name": "@csstools/css-color-parser", + "purl": "pkg:npm/@csstools/css-color-parser@4.1.10", + "type": "library", + "version": "4.1.10" + }, + { + "name": "@csstools/css-parser-algorithms", + "purl": "pkg:npm/@csstools/css-parser-algorithms@4.0.0", + "type": "library", + "version": "4.0.0" + }, + { + "name": "@csstools/css-syntax-patches-for-csstree", + "purl": "pkg:npm/@csstools/css-syntax-patches-for-csstree@1.1.7", + "type": "library", + "version": "1.1.7" + }, + { + "name": "@csstools/css-tokenizer", + "purl": "pkg:npm/@csstools/css-tokenizer@4.0.0", + "type": "library", + "version": "4.0.0" + }, + { + "name": "@emnapi/core", + "purl": "pkg:npm/@emnapi/core@1.11.1", + "type": "library", + "version": "1.11.1" + }, + { + "name": "@emnapi/runtime", + "purl": "pkg:npm/@emnapi/runtime@1.11.1", + "type": "library", + "version": "1.11.1" + }, + { + "name": "@emnapi/wasi-threads", + "purl": "pkg:npm/@emnapi/wasi-threads@1.2.2", + "type": "library", + "version": "1.2.2" + }, + { + "name": "@exodus/bytes", + "purl": "pkg:npm/@exodus/bytes@1.15.1", + "type": "library", + "version": "1.15.1" + }, + { + "name": "@jridgewell/gen-mapping", + "purl": "pkg:npm/@jridgewell/gen-mapping@0.3.13", + "type": "library", + "version": "0.3.13" + }, + { + "name": "@jridgewell/resolve-uri", + "purl": "pkg:npm/@jridgewell/resolve-uri@3.1.2", + "type": "library", + "version": "3.1.2" + }, + { + "name": "@jridgewell/sourcemap-codec", + "purl": "pkg:npm/@jridgewell/sourcemap-codec@1.5.5", + "type": "library", + "version": "1.5.5" + }, + { + "name": "@jridgewell/trace-mapping", + "purl": "pkg:npm/@jridgewell/trace-mapping@0.3.31", + "type": "library", + "version": "0.3.31" + }, + { + "name": "@napi-rs/wasm-runtime", + "purl": "pkg:npm/@napi-rs/wasm-runtime@1.1.6", + "type": "library", + "version": "1.1.6" + }, + { + "name": "@nodelib/fs.scandir", + "purl": "pkg:npm/@nodelib/fs.scandir@2.1.5", + "type": "library", + "version": "2.1.5" + }, + { + "name": "@nodelib/fs.stat", + "purl": "pkg:npm/@nodelib/fs.stat@2.0.5", + "type": "library", + "version": "2.0.5" + }, + { + "name": "@nodelib/fs.walk", + "purl": "pkg:npm/@nodelib/fs.walk@1.2.8", + "type": "library", + "version": "1.2.8" + }, + { + "name": "@oxc-project/types", + "purl": "pkg:npm/@oxc-project/types@0.139.0", + "type": "library", + "version": "0.139.0" + }, + { + "name": "@playwright/test", + "purl": "pkg:npm/@playwright/test@1.57.0", + "type": "library", + "version": "1.57.0" + }, + { + "name": "@rolldown/binding-android-arm64", + "purl": "pkg:npm/@rolldown/binding-android-arm64@1.1.5", + "type": "library", + "version": "1.1.5" + }, + { + "name": "@rolldown/binding-darwin-arm64", + "purl": "pkg:npm/@rolldown/binding-darwin-arm64@1.1.5", + "type": "library", + "version": "1.1.5" + }, + { + "name": "@rolldown/binding-darwin-x64", + "purl": "pkg:npm/@rolldown/binding-darwin-x64@1.1.5", + "type": "library", + "version": "1.1.5" + }, + { + "name": "@rolldown/binding-freebsd-x64", + "purl": "pkg:npm/@rolldown/binding-freebsd-x64@1.1.5", + "type": "library", + "version": "1.1.5" + }, + { + "name": "@rolldown/binding-linux-arm-gnueabihf", + "purl": "pkg:npm/@rolldown/binding-linux-arm-gnueabihf@1.1.5", + "type": "library", + "version": "1.1.5" + }, + { + "name": "@rolldown/binding-linux-arm64-gnu", + "purl": "pkg:npm/@rolldown/binding-linux-arm64-gnu@1.1.5", + "type": "library", + "version": "1.1.5" + }, + { + "name": "@rolldown/binding-linux-arm64-musl", + "purl": "pkg:npm/@rolldown/binding-linux-arm64-musl@1.1.5", + "type": "library", + "version": "1.1.5" + }, + { + "name": "@rolldown/binding-linux-ppc64-gnu", + "purl": "pkg:npm/@rolldown/binding-linux-ppc64-gnu@1.1.5", + "type": "library", + "version": "1.1.5" + }, + { + "name": "@rolldown/binding-linux-s390x-gnu", + "purl": "pkg:npm/@rolldown/binding-linux-s390x-gnu@1.1.5", + "type": "library", + "version": "1.1.5" + }, + { + "name": "@rolldown/binding-linux-x64-gnu", + "purl": "pkg:npm/@rolldown/binding-linux-x64-gnu@1.1.5", + "type": "library", + "version": "1.1.5" + }, + { + "name": "@rolldown/binding-linux-x64-musl", + "purl": "pkg:npm/@rolldown/binding-linux-x64-musl@1.1.5", + "type": "library", + "version": "1.1.5" + }, + { + "name": "@rolldown/binding-openharmony-arm64", + "purl": "pkg:npm/@rolldown/binding-openharmony-arm64@1.1.5", + "type": "library", + "version": "1.1.5" + }, + { + "name": "@rolldown/binding-wasm32-wasi", + "purl": "pkg:npm/@rolldown/binding-wasm32-wasi@1.1.5", + "type": "library", + "version": "1.1.5" + }, + { + "name": "@rolldown/binding-win32-arm64-msvc", + "purl": "pkg:npm/@rolldown/binding-win32-arm64-msvc@1.1.5", + "type": "library", + "version": "1.1.5" + }, + { + "name": "@rolldown/binding-win32-x64-msvc", + "purl": "pkg:npm/@rolldown/binding-win32-x64-msvc@1.1.5", + "type": "library", + "version": "1.1.5" + }, + { + "name": "@rolldown/pluginutils", + "purl": "pkg:npm/@rolldown/pluginutils@1.0.1", + "type": "library", + "version": "1.0.1" + }, + { + "name": "@standard-schema/spec", + "purl": "pkg:npm/@standard-schema/spec@1.1.0", + "type": "library", + "version": "1.1.0" + }, + { + "name": "@testing-library/dom/node_modules/aria-query", + "purl": "pkg:npm/@testing-library/dom/node_modules/aria-query@5.3.0", + "type": "library", + "version": "5.3.0" + }, + { + "name": "@testing-library/dom/node_modules/dom-accessibility-api", + "purl": "pkg:npm/@testing-library/dom/node_modules/dom-accessibility-api@0.5.16", + "type": "library", + "version": "0.5.16" + }, + { + "name": "@testing-library/dom", + "purl": "pkg:npm/@testing-library/dom@10.4.1", + "type": "library", + "version": "10.4.1" + }, + { + "name": "@testing-library/jest-dom", + "purl": "pkg:npm/@testing-library/jest-dom@7.0.0", + "type": "library", + "version": "7.0.0" + }, + { + "name": "@testing-library/react", + "purl": "pkg:npm/@testing-library/react@16.3.2", + "type": "library", + "version": "16.3.2" + }, + { + "name": "@tybys/wasm-util", + "purl": "pkg:npm/@tybys/wasm-util@0.10.3", + "type": "library", + "version": "0.10.3" + }, + { + "name": "@types/aria-query", + "purl": "pkg:npm/@types/aria-query@5.0.4", + "type": "library", + "version": "5.0.4" + }, + { + "name": "@types/chai", + "purl": "pkg:npm/@types/chai@5.2.3", + "type": "library", + "version": "5.2.3" + }, + { + "name": "@types/deep-eql", + "purl": "pkg:npm/@types/deep-eql@4.0.2", + "type": "library", + "version": "4.0.2" + }, + { + "name": "@types/estree", + "purl": "pkg:npm/@types/estree@1.0.9", + "type": "library", + "version": "1.0.9" + }, + { + "name": "@types/react-dom", + "purl": "pkg:npm/@types/react-dom@19.2.3", + "type": "library", + "version": "19.2.3" + }, + { + "name": "@types/react", + "purl": "pkg:npm/@types/react@19.2.17", + "type": "library", + "version": "19.2.17" + }, + { + "name": "@typescript/typescript-aix-ppc64", + "purl": "pkg:npm/@typescript/typescript-aix-ppc64@7.0.2", + "type": "library", + "version": "7.0.2" + }, + { + "name": "@typescript/typescript-darwin-arm64", + "purl": "pkg:npm/@typescript/typescript-darwin-arm64@7.0.2", + "type": "library", + "version": "7.0.2" + }, + { + "name": "@typescript/typescript-darwin-x64", + "purl": "pkg:npm/@typescript/typescript-darwin-x64@7.0.2", + "type": "library", + "version": "7.0.2" + }, + { + "name": "@typescript/typescript-freebsd-arm64", + "purl": "pkg:npm/@typescript/typescript-freebsd-arm64@7.0.2", + "type": "library", + "version": "7.0.2" + }, + { + "name": "@typescript/typescript-freebsd-x64", + "purl": "pkg:npm/@typescript/typescript-freebsd-x64@7.0.2", + "type": "library", + "version": "7.0.2" + }, + { + "name": "@typescript/typescript-linux-arm64", + "purl": "pkg:npm/@typescript/typescript-linux-arm64@7.0.2", + "type": "library", + "version": "7.0.2" + }, + { + "name": "@typescript/typescript-linux-arm", + "purl": "pkg:npm/@typescript/typescript-linux-arm@7.0.2", + "type": "library", + "version": "7.0.2" + }, + { + "name": "@typescript/typescript-linux-loong64", + "purl": "pkg:npm/@typescript/typescript-linux-loong64@7.0.2", + "type": "library", + "version": "7.0.2" + }, + { + "name": "@typescript/typescript-linux-mips64el", + "purl": "pkg:npm/@typescript/typescript-linux-mips64el@7.0.2", + "type": "library", + "version": "7.0.2" + }, + { + "name": "@typescript/typescript-linux-ppc64", + "purl": "pkg:npm/@typescript/typescript-linux-ppc64@7.0.2", + "type": "library", + "version": "7.0.2" + }, + { + "name": "@typescript/typescript-linux-riscv64", + "purl": "pkg:npm/@typescript/typescript-linux-riscv64@7.0.2", + "type": "library", + "version": "7.0.2" + }, + { + "name": "@typescript/typescript-linux-s390x", + "purl": "pkg:npm/@typescript/typescript-linux-s390x@7.0.2", + "type": "library", + "version": "7.0.2" + }, + { + "name": "@typescript/typescript-linux-x64", + "purl": "pkg:npm/@typescript/typescript-linux-x64@7.0.2", + "type": "library", + "version": "7.0.2" + }, + { + "name": "@typescript/typescript-netbsd-arm64", + "purl": "pkg:npm/@typescript/typescript-netbsd-arm64@7.0.2", + "type": "library", + "version": "7.0.2" + }, + { + "name": "@typescript/typescript-netbsd-x64", + "purl": "pkg:npm/@typescript/typescript-netbsd-x64@7.0.2", + "type": "library", + "version": "7.0.2" + }, + { + "name": "@typescript/typescript-openbsd-arm64", + "purl": "pkg:npm/@typescript/typescript-openbsd-arm64@7.0.2", + "type": "library", + "version": "7.0.2" + }, + { + "name": "@typescript/typescript-openbsd-x64", + "purl": "pkg:npm/@typescript/typescript-openbsd-x64@7.0.2", + "type": "library", + "version": "7.0.2" + }, + { + "name": "@typescript/typescript-sunos-x64", + "purl": "pkg:npm/@typescript/typescript-sunos-x64@7.0.2", + "type": "library", + "version": "7.0.2" + }, + { + "name": "@typescript/typescript-win32-arm64", + "purl": "pkg:npm/@typescript/typescript-win32-arm64@7.0.2", + "type": "library", + "version": "7.0.2" + }, + { + "name": "@typescript/typescript-win32-x64", + "purl": "pkg:npm/@typescript/typescript-win32-x64@7.0.2", + "type": "library", + "version": "7.0.2" + }, + { + "name": "@vitejs/plugin-react", + "purl": "pkg:npm/@vitejs/plugin-react@6.0.4", + "type": "library", + "version": "6.0.4" + }, + { + "name": "@vitest/expect", + "purl": "pkg:npm/@vitest/expect@4.1.10", + "type": "library", + "version": "4.1.10" + }, + { + "name": "@vitest/mocker", + "purl": "pkg:npm/@vitest/mocker@4.1.10", + "type": "library", + "version": "4.1.10" + }, + { + "name": "@vitest/pretty-format", + "purl": "pkg:npm/@vitest/pretty-format@4.1.10", + "type": "library", + "version": "4.1.10" + }, + { + "name": "@vitest/runner", + "purl": "pkg:npm/@vitest/runner@4.1.10", + "type": "library", + "version": "4.1.10" + }, + { + "name": "@vitest/snapshot", + "purl": "pkg:npm/@vitest/snapshot@4.1.10", + "type": "library", + "version": "4.1.10" + }, + { + "name": "@vitest/spy", + "purl": "pkg:npm/@vitest/spy@4.1.10", + "type": "library", + "version": "4.1.10" + }, + { + "name": "@vitest/utils", + "purl": "pkg:npm/@vitest/utils@4.1.10", + "type": "library", + "version": "4.1.10" + }, + { + "name": "ansi-regex", + "purl": "pkg:npm/ansi-regex@5.0.1", + "type": "library", + "version": "5.0.1" + }, + { + "name": "any-promise", + "purl": "pkg:npm/any-promise@1.3.0", + "type": "library", + "version": "1.3.0" + }, + { + "name": "anymatch", + "purl": "pkg:npm/anymatch@3.1.3", + "type": "library", + "version": "3.1.3" + }, + { + "name": "arg", + "purl": "pkg:npm/arg@5.0.2", + "type": "library", + "version": "5.0.2" + }, + { + "name": "aria-query", + "purl": "pkg:npm/aria-query@5.3.2", + "type": "library", + "version": "5.3.2" + }, + { + "name": "assertion-error", + "purl": "pkg:npm/assertion-error@2.0.1", + "type": "library", + "version": "2.0.1" + }, + { + "name": "autoprefixer", + "purl": "pkg:npm/autoprefixer@10.5.4", + "type": "library", + "version": "10.5.4" + }, + { + "name": "baseline-browser-mapping", + "purl": "pkg:npm/baseline-browser-mapping@2.11.5", + "type": "library", + "version": "2.11.5" + }, + { + "name": "bidi-js", + "purl": "pkg:npm/bidi-js@1.0.3", + "type": "library", + "version": "1.0.3" + }, + { + "name": "binary-extensions", + "purl": "pkg:npm/binary-extensions@2.3.0", + "type": "library", + "version": "2.3.0" + }, + { + "name": "braces", + "purl": "pkg:npm/braces@3.0.3", + "type": "library", + "version": "3.0.3" + }, + { + "name": "browserslist", + "purl": "pkg:npm/browserslist@4.28.7", + "type": "library", + "version": "4.28.7" + }, + { + "name": "camelcase-css", + "purl": "pkg:npm/camelcase-css@2.0.1", + "type": "library", + "version": "2.0.1" + }, + { + "name": "caniuse-lite", + "purl": "pkg:npm/caniuse-lite@1.0.30001806", + "type": "library", + "version": "1.0.30001806" + }, + { + "name": "chai", + "purl": "pkg:npm/chai@6.2.2", + "type": "library", + "version": "6.2.2" + }, + { + "name": "chokidar/node_modules/glob-parent", + "purl": "pkg:npm/chokidar/node_modules/glob-parent@5.1.2", + "type": "library", + "version": "5.1.2" + }, + { + "name": "chokidar", + "purl": "pkg:npm/chokidar@3.6.0", + "type": "library", + "version": "3.6.0" + }, + { + "name": "commander", + "purl": "pkg:npm/commander@4.1.1", + "type": "library", + "version": "4.1.1" + }, + { + "name": "convert-source-map", + "purl": "pkg:npm/convert-source-map@2.0.0", + "type": "library", + "version": "2.0.0" + }, + { + "name": "css-tree", + "purl": "pkg:npm/css-tree@3.2.1", + "type": "library", + "version": "3.2.1" + }, + { + "name": "css.escape", + "purl": "pkg:npm/css.escape@1.5.1", + "type": "library", + "version": "1.5.1" + }, + { + "name": "cssesc", + "purl": "pkg:npm/cssesc@3.0.0", + "type": "library", + "version": "3.0.0" + }, + { + "name": "csstype", + "purl": "pkg:npm/csstype@3.2.3", + "type": "library", + "version": "3.2.3" + }, + { + "name": "data-urls/node_modules/whatwg-url", + "purl": "pkg:npm/data-urls/node_modules/whatwg-url@16.0.1", + "type": "library", + "version": "16.0.1" + }, + { + "name": "data-urls", + "purl": "pkg:npm/data-urls@7.0.0", + "type": "library", + "version": "7.0.0" + }, + { + "name": "decimal.js", + "purl": "pkg:npm/decimal.js@10.6.0", + "type": "library", + "version": "10.6.0" + }, + { + "name": "dequal", + "purl": "pkg:npm/dequal@2.0.3", + "type": "library", + "version": "2.0.3" + }, + { + "name": "detect-libc", + "purl": "pkg:npm/detect-libc@2.1.2", + "type": "library", + "version": "2.1.2" + }, + { + "name": "didyoumean", + "purl": "pkg:npm/didyoumean@1.2.2", + "type": "library", + "version": "1.2.2" + }, + { + "name": "dlv", + "purl": "pkg:npm/dlv@1.1.3", + "type": "library", + "version": "1.1.3" + }, + { + "name": "dom-accessibility-api", + "purl": "pkg:npm/dom-accessibility-api@0.6.3", + "type": "library", + "version": "0.6.3" + }, + { + "name": "electron-to-chromium", + "purl": "pkg:npm/electron-to-chromium@1.5.396", + "type": "library", + "version": "1.5.396" + }, + { + "name": "entities", + "purl": "pkg:npm/entities@8.0.0", + "type": "library", + "version": "8.0.0" + }, + { + "name": "es-errors", + "purl": "pkg:npm/es-errors@1.3.0", + "type": "library", + "version": "1.3.0" + }, + { + "name": "es-module-lexer", + "purl": "pkg:npm/es-module-lexer@2.3.1", + "type": "library", + "version": "2.3.1" + }, + { + "name": "escalade", + "purl": "pkg:npm/escalade@3.2.0", + "type": "library", + "version": "3.2.0" + }, + { + "name": "estree-walker", + "purl": "pkg:npm/estree-walker@3.0.3", + "type": "library", + "version": "3.0.3" + }, + { + "name": "expect-type", + "purl": "pkg:npm/expect-type@1.4.0", + "type": "library", + "version": "1.4.0" + }, + { + "name": "fast-glob/node_modules/glob-parent", + "purl": "pkg:npm/fast-glob/node_modules/glob-parent@5.1.2", + "type": "library", + "version": "5.1.2" + }, + { + "name": "fast-glob", + "purl": "pkg:npm/fast-glob@3.3.3", + "type": "library", + "version": "3.3.3" + }, + { + "name": "fastq", + "purl": "pkg:npm/fastq@1.20.1", + "type": "library", + "version": "1.20.1" + }, + { + "name": "fill-range", + "purl": "pkg:npm/fill-range@7.1.1", + "type": "library", + "version": "7.1.1" + }, + { + "name": "fraction.js", + "purl": "pkg:npm/fraction.js@5.3.4", + "type": "library", + "version": "5.3.4" + }, + { + "name": "fsevents", + "purl": "pkg:npm/fsevents@2.3.3", + "type": "library", + "version": "2.3.3" + }, + { + "name": "function-bind", + "purl": "pkg:npm/function-bind@1.1.2", + "type": "library", + "version": "1.1.2" + }, + { + "name": "glob-parent", + "purl": "pkg:npm/glob-parent@6.0.2", + "type": "library", + "version": "6.0.2" + }, + { + "name": "hasown", + "purl": "pkg:npm/hasown@2.0.3", + "type": "library", + "version": "2.0.3" + }, + { + "name": "html-encoding-sniffer", + "purl": "pkg:npm/html-encoding-sniffer@6.0.0", + "type": "library", + "version": "6.0.0" + }, + { + "name": "indent-string", + "purl": "pkg:npm/indent-string@4.0.0", + "type": "library", + "version": "4.0.0" + }, + { + "name": "is-binary-path", + "purl": "pkg:npm/is-binary-path@2.1.0", + "type": "library", + "version": "2.1.0" + }, + { + "name": "is-core-module", + "purl": "pkg:npm/is-core-module@2.16.2", + "type": "library", + "version": "2.16.2" + }, + { + "name": "is-extglob", + "purl": "pkg:npm/is-extglob@2.1.1", + "type": "library", + "version": "2.1.1" + }, + { + "name": "is-glob", + "purl": "pkg:npm/is-glob@4.0.3", + "type": "library", + "version": "4.0.3" + }, + { + "name": "is-number", + "purl": "pkg:npm/is-number@7.0.0", + "type": "library", + "version": "7.0.0" + }, + { + "name": "is-potential-custom-element-name", + "purl": "pkg:npm/is-potential-custom-element-name@1.0.1", + "type": "library", + "version": "1.0.1" + }, + { + "name": "jiti", + "purl": "pkg:npm/jiti@1.21.7", + "type": "library", + "version": "1.21.7" + }, + { + "name": "js-tokens", + "purl": "pkg:npm/js-tokens@4.0.0", + "type": "library", + "version": "4.0.0" + }, + { + "name": "jsdom", + "purl": "pkg:npm/jsdom@30.0.0", + "type": "library", + "version": "30.0.0" + }, + { + "name": "lightningcss-android-arm64", + "purl": "pkg:npm/lightningcss-android-arm64@1.33.0", + "type": "library", + "version": "1.33.0" + }, + { + "name": "lightningcss-darwin-arm64", + "purl": "pkg:npm/lightningcss-darwin-arm64@1.33.0", + "type": "library", + "version": "1.33.0" + }, + { + "name": "lightningcss-darwin-x64", + "purl": "pkg:npm/lightningcss-darwin-x64@1.33.0", + "type": "library", + "version": "1.33.0" + }, + { + "name": "lightningcss-freebsd-x64", + "purl": "pkg:npm/lightningcss-freebsd-x64@1.33.0", + "type": "library", + "version": "1.33.0" + }, + { + "name": "lightningcss-linux-arm-gnueabihf", + "purl": "pkg:npm/lightningcss-linux-arm-gnueabihf@1.33.0", + "type": "library", + "version": "1.33.0" + }, + { + "name": "lightningcss-linux-arm64-gnu", + "purl": "pkg:npm/lightningcss-linux-arm64-gnu@1.33.0", + "type": "library", + "version": "1.33.0" + }, + { + "name": "lightningcss-linux-arm64-musl", + "purl": "pkg:npm/lightningcss-linux-arm64-musl@1.33.0", + "type": "library", + "version": "1.33.0" + }, + { + "name": "lightningcss-linux-x64-gnu", + "purl": "pkg:npm/lightningcss-linux-x64-gnu@1.33.0", + "type": "library", + "version": "1.33.0" + }, + { + "name": "lightningcss-linux-x64-musl", + "purl": "pkg:npm/lightningcss-linux-x64-musl@1.33.0", + "type": "library", + "version": "1.33.0" + }, + { + "name": "lightningcss-win32-arm64-msvc", + "purl": "pkg:npm/lightningcss-win32-arm64-msvc@1.33.0", + "type": "library", + "version": "1.33.0" + }, + { + "name": "lightningcss-win32-x64-msvc", + "purl": "pkg:npm/lightningcss-win32-x64-msvc@1.33.0", + "type": "library", + "version": "1.33.0" + }, + { + "name": "lightningcss", + "purl": "pkg:npm/lightningcss@1.33.0", + "type": "library", + "version": "1.33.0" + }, + { + "name": "lilconfig", + "purl": "pkg:npm/lilconfig@3.1.3", + "type": "library", + "version": "3.1.3" + }, + { + "name": "lines-and-columns", + "purl": "pkg:npm/lines-and-columns@1.2.4", + "type": "library", + "version": "1.2.4" + }, + { + "name": "lru-cache", + "purl": "pkg:npm/lru-cache@11.5.2", + "type": "library", + "version": "11.5.2" + }, + { + "name": "lz-string", + "purl": "pkg:npm/lz-string@1.5.0", + "type": "library", + "version": "1.5.0" + }, + { + "name": "magic-string", + "purl": "pkg:npm/magic-string@0.30.21", + "type": "library", + "version": "0.30.21" + }, + { + "name": "mdn-data", + "purl": "pkg:npm/mdn-data@2.27.1", + "type": "library", + "version": "2.27.1" + }, + { + "name": "merge2", + "purl": "pkg:npm/merge2@1.4.1", + "type": "library", + "version": "1.4.1" + }, + { + "name": "micromatch", + "purl": "pkg:npm/micromatch@4.0.8", + "type": "library", + "version": "4.0.8" + }, + { + "name": "min-indent", + "purl": "pkg:npm/min-indent@1.0.1", + "type": "library", + "version": "1.0.1" + }, + { + "name": "mz", + "purl": "pkg:npm/mz@2.7.0", + "type": "library", + "version": "2.7.0" + }, + { + "name": "nanoid", + "purl": "pkg:npm/nanoid@3.3.16", + "type": "library", + "version": "3.3.16" + }, + { + "name": "node-releases", + "purl": "pkg:npm/node-releases@2.0.51", + "type": "library", + "version": "2.0.51" + }, + { + "name": "normalize-path", + "purl": "pkg:npm/normalize-path@3.0.0", + "type": "library", + "version": "3.0.0" + }, + { + "name": "object-assign", + "purl": "pkg:npm/object-assign@4.1.1", + "type": "library", + "version": "4.1.1" + }, + { + "name": "object-hash", + "purl": "pkg:npm/object-hash@3.0.0", + "type": "library", + "version": "3.0.0" + }, + { + "name": "obug", + "purl": "pkg:npm/obug@2.1.4", + "type": "library", + "version": "2.1.4" + }, + { + "name": "parse5", + "purl": "pkg:npm/parse5@8.0.1", + "type": "library", + "version": "8.0.1" + }, + { + "name": "path-parse", + "purl": "pkg:npm/path-parse@1.0.7", + "type": "library", + "version": "1.0.7" + }, + { + "name": "pathe", + "purl": "pkg:npm/pathe@2.0.3", + "type": "library", + "version": "2.0.3" + }, + { + "name": "picocolors", + "purl": "pkg:npm/picocolors@1.1.1", + "type": "library", + "version": "1.1.1" + }, + { + "name": "picomatch", + "purl": "pkg:npm/picomatch@2.3.2", + "type": "library", + "version": "2.3.2" + }, + { + "name": "pify", + "purl": "pkg:npm/pify@2.3.0", + "type": "library", + "version": "2.3.0" + }, + { + "name": "pirates", + "purl": "pkg:npm/pirates@4.0.7", + "type": "library", + "version": "4.0.7" + }, + { + "name": "playwright-core", + "purl": "pkg:npm/playwright-core@1.57.0", + "type": "library", + "version": "1.57.0" + }, + { + "name": "playwright/node_modules/fsevents", + "purl": "pkg:npm/playwright/node_modules/fsevents@2.3.2", + "type": "library", + "version": "2.3.2" + }, + { + "name": "playwright", + "purl": "pkg:npm/playwright@1.57.0", + "type": "library", + "version": "1.57.0" + }, + { + "name": "postcss-import", + "purl": "pkg:npm/postcss-import@15.1.0", + "type": "library", + "version": "15.1.0" + }, + { + "name": "postcss-js", + "purl": "pkg:npm/postcss-js@4.1.0", + "type": "library", + "version": "4.1.0" + }, + { + "name": "postcss-load-config", + "purl": "pkg:npm/postcss-load-config@6.0.1", + "type": "library", + "version": "6.0.1" + }, + { + "name": "postcss-nested", + "purl": "pkg:npm/postcss-nested@6.2.0", + "type": "library", + "version": "6.2.0" + }, + { + "name": "postcss-selector-parser", + "purl": "pkg:npm/postcss-selector-parser@6.1.2", + "type": "library", + "version": "6.1.2" + }, + { + "name": "postcss-value-parser", + "purl": "pkg:npm/postcss-value-parser@4.2.0", + "type": "library", + "version": "4.2.0" + }, + { + "name": "postcss", + "purl": "pkg:npm/postcss@8.5.23", + "type": "library", + "version": "8.5.23" + }, + { + "name": "pretty-format/node_modules/ansi-styles", + "purl": "pkg:npm/pretty-format/node_modules/ansi-styles@5.2.0", + "type": "library", + "version": "5.2.0" + }, + { + "name": "pretty-format", + "purl": "pkg:npm/pretty-format@27.5.1", + "type": "library", + "version": "27.5.1" + }, + { + "name": "punycode", + "purl": "pkg:npm/punycode@2.3.1", + "type": "library", + "version": "2.3.1" + }, + { + "name": "queue-microtask", + "purl": "pkg:npm/queue-microtask@1.2.3", + "type": "library", + "version": "1.2.3" + }, + { + "name": "react-dom", + "purl": "pkg:npm/react-dom@19.2.8", + "type": "library", + "version": "19.2.8" + }, + { + "name": "react-is", + "purl": "pkg:npm/react-is@17.0.2", + "type": "library", + "version": "17.0.2" + }, + { + "name": "react", + "purl": "pkg:npm/react@19.2.8", + "type": "library", + "version": "19.2.8" + }, + { + "name": "read-cache", + "purl": "pkg:npm/read-cache@1.0.0", + "type": "library", + "version": "1.0.0" + }, + { + "name": "readdirp", + "purl": "pkg:npm/readdirp@3.6.0", + "type": "library", + "version": "3.6.0" + }, + { + "name": "redent", + "purl": "pkg:npm/redent@3.0.0", + "type": "library", + "version": "3.0.0" + }, + { + "name": "require-from-string", + "purl": "pkg:npm/require-from-string@2.0.2", + "type": "library", + "version": "2.0.2" + }, + { + "name": "resolve", + "purl": "pkg:npm/resolve@1.22.12", + "type": "library", + "version": "1.22.12" + }, + { + "name": "reusify", + "purl": "pkg:npm/reusify@1.1.0", + "type": "library", + "version": "1.1.0" + }, + { + "name": "rolldown", + "purl": "pkg:npm/rolldown@1.1.5", + "type": "library", + "version": "1.1.5" + }, + { + "name": "run-parallel", + "purl": "pkg:npm/run-parallel@1.2.0", + "type": "library", + "version": "1.2.0" + }, + { + "name": "saxes", + "purl": "pkg:npm/saxes@6.0.0", + "type": "library", + "version": "6.0.0" + }, + { + "name": "scheduler", + "purl": "pkg:npm/scheduler@0.27.0", + "type": "library", + "version": "0.27.0" + }, + { + "name": "siginfo", + "purl": "pkg:npm/siginfo@2.0.0", + "type": "library", + "version": "2.0.0" + }, + { + "name": "source-map-js", + "purl": "pkg:npm/source-map-js@1.2.1", + "type": "library", + "version": "1.2.1" + }, + { + "name": "stackback", + "purl": "pkg:npm/stackback@0.0.2", + "type": "library", + "version": "0.0.2" + }, + { + "name": "std-env", + "purl": "pkg:npm/std-env@4.2.0", + "type": "library", + "version": "4.2.0" + }, + { + "name": "strip-indent", + "purl": "pkg:npm/strip-indent@3.0.0", + "type": "library", + "version": "3.0.0" + }, + { + "name": "sucrase", + "purl": "pkg:npm/sucrase@3.35.1", + "type": "library", + "version": "3.35.1" + }, + { + "name": "supports-preserve-symlinks-flag", + "purl": "pkg:npm/supports-preserve-symlinks-flag@1.0.0", + "type": "library", + "version": "1.0.0" + }, + { + "name": "symbol-tree", + "purl": "pkg:npm/symbol-tree@3.2.4", + "type": "library", + "version": "3.2.4" + }, + { + "name": "tailwindcss", + "purl": "pkg:npm/tailwindcss@3.4.19", + "type": "library", + "version": "3.4.19" + }, + { + "name": "thenify-all", + "purl": "pkg:npm/thenify-all@1.6.0", + "type": "library", + "version": "1.6.0" + }, + { + "name": "thenify", + "purl": "pkg:npm/thenify@3.3.1", + "type": "library", + "version": "3.3.1" + }, + { + "name": "tinybench", + "purl": "pkg:npm/tinybench@2.9.0", + "type": "library", + "version": "2.9.0" + }, + { + "name": "tinyexec", + "purl": "pkg:npm/tinyexec@1.2.4", + "type": "library", + "version": "1.2.4" + }, + { + "name": "tinyglobby/node_modules/fdir", + "purl": "pkg:npm/tinyglobby/node_modules/fdir@6.5.0", + "type": "library", + "version": "6.5.0" + }, + { + "name": "tinyglobby/node_modules/picomatch", + "purl": "pkg:npm/tinyglobby/node_modules/picomatch@4.0.4", + "type": "library", + "version": "4.0.4" + }, + { + "name": "tinyglobby", + "purl": "pkg:npm/tinyglobby@0.2.17", + "type": "library", + "version": "0.2.17" + }, + { + "name": "tinyrainbow", + "purl": "pkg:npm/tinyrainbow@3.1.0", + "type": "library", + "version": "3.1.0" + }, + { + "name": "tldts-core", + "purl": "pkg:npm/tldts-core@7.4.9", + "type": "library", + "version": "7.4.9" + }, + { + "name": "tldts", + "purl": "pkg:npm/tldts@7.4.9", + "type": "library", + "version": "7.4.9" + }, + { + "name": "to-regex-range", + "purl": "pkg:npm/to-regex-range@5.0.1", + "type": "library", + "version": "5.0.1" + }, + { + "name": "tough-cookie", + "purl": "pkg:npm/tough-cookie@6.0.2", + "type": "library", + "version": "6.0.2" + }, + { + "name": "tr46", + "purl": "pkg:npm/tr46@6.0.0", + "type": "library", + "version": "6.0.0" + }, + { + "name": "ts-interface-checker", + "purl": "pkg:npm/ts-interface-checker@0.1.13", + "type": "library", + "version": "0.1.13" + }, + { + "name": "tslib", + "purl": "pkg:npm/tslib@2.8.1", + "type": "library", + "version": "2.8.1" + }, + { + "name": "typescript", + "purl": "pkg:npm/typescript@7.0.2", + "type": "library", + "version": "7.0.2" + }, + { + "name": "undici", + "purl": "pkg:npm/undici@8.9.0", + "type": "library", + "version": "8.9.0" + }, + { + "name": "update-browserslist-db", + "purl": "pkg:npm/update-browserslist-db@1.2.3", + "type": "library", + "version": "1.2.3" + }, + { + "name": "util-deprecate", + "purl": "pkg:npm/util-deprecate@1.0.2", + "type": "library", + "version": "1.0.2" + }, + { + "name": "vite/node_modules/picomatch", + "purl": "pkg:npm/vite/node_modules/picomatch@4.0.5", + "type": "library", + "version": "4.0.5" + }, + { + "name": "vite", + "purl": "pkg:npm/vite@8.1.5", + "type": "library", + "version": "8.1.5" + }, + { + "name": "vitest/node_modules/picomatch", + "purl": "pkg:npm/vitest/node_modules/picomatch@4.0.5", + "type": "library", + "version": "4.0.5" + }, + { + "name": "vitest", + "purl": "pkg:npm/vitest@4.1.10", + "type": "library", + "version": "4.1.10" + }, + { + "name": "w3c-xmlserializer", + "purl": "pkg:npm/w3c-xmlserializer@5.0.0", + "type": "library", + "version": "5.0.0" + }, + { + "name": "webidl-conversions", + "purl": "pkg:npm/webidl-conversions@8.0.1", + "type": "library", + "version": "8.0.1" + }, + { + "name": "whatwg-mimetype", + "purl": "pkg:npm/whatwg-mimetype@5.0.0", + "type": "library", + "version": "5.0.0" + }, + { + "name": "whatwg-url", + "purl": "pkg:npm/whatwg-url@17.1.0", + "type": "library", + "version": "17.1.0" + }, + { + "name": "why-is-node-running", + "purl": "pkg:npm/why-is-node-running@2.3.0", + "type": "library", + "version": "2.3.0" + }, + { + "name": "xml-name-validator", + "purl": "pkg:npm/xml-name-validator@5.0.0", + "type": "library", + "version": "5.0.0" + }, + { + "name": "xmlchars", + "purl": "pkg:npm/xmlchars@2.2.0", + "type": "library", + "version": "2.2.0" + }, + { + "name": "aiofiles", + "purl": "pkg:pypi/aiofiles@25.1.0", + "type": "library", + "version": "25.1.0" + }, + { + "name": "aiosqlite", + "purl": "pkg:pypi/aiosqlite@0.22.1", + "type": "library", + "version": "0.22.1" + }, + { + "name": "alembic", + "purl": "pkg:pypi/alembic@1.18.5", + "type": "library", + "version": "1.18.5" + }, + { + "name": "apscheduler", + "purl": "pkg:pypi/apscheduler@3.11.3", + "type": "library", + "version": "3.11.3" + }, + { + "name": "argon2-cffi", + "purl": "pkg:pypi/argon2-cffi@25.1.0", + "type": "library", + "version": "25.1.0" + }, + { + "name": "cryptography", + "purl": "pkg:pypi/cryptography@49.0.0", + "type": "library", + "version": "49.0.0" + }, + { + "name": "fastapi", + "purl": "pkg:pypi/fastapi@0.136.1", + "type": "library", + "version": "0.136.1" + }, + { + "name": "httpx", + "purl": "pkg:pypi/httpx@0.28.1", + "type": "library", + "version": "0.28.1" + }, + { + "name": "pydantic-settings", + "purl": "pkg:pypi/pydantic-settings@2.14.2", + "type": "library", + "version": "2.14.2" + }, + { + "name": "pydantic", + "purl": "pkg:pypi/pydantic@2.13.4", + "type": "library", + "version": "2.13.4" + }, + { + "name": "sqlalchemy", + "purl": "pkg:pypi/sqlalchemy@2.0.49", + "type": "library", + "version": "2.0.49" + }, + { + "name": "uvicorn", + "purl": "pkg:pypi/uvicorn@0.51.0", + "type": "library", + "version": "0.51.0" + } + ], + "metadata": { + "component": { + "name": "backup-tool", + "purl": "pkg:generic/backup-tool@2.0.0.dev0", + "type": "library", + "version": "2.0.0.dev0" + } + }, + "specVersion": "1.5", + "version": 1 +} diff --git a/docs/release/m15-evidence.md b/docs/release/m15-evidence.md new file mode 100644 index 0000000..6e85338 --- /dev/null +++ b/docs/release/m15-evidence.md @@ -0,0 +1,23 @@ +# M15 v2.0 certification evidence + +## Reference host and method + +Current CI reference host: Linux 7.1.4, Python 3.14.6, 12 CPUs. Certification uses a deterministic synthetic metadata workload: 100 jobs, 1,000,000 declared entries, and 100,000 cataloged backups with 10 TiB **logical** bytes per backup. It does not claim a physical 10 TiB transfer. + +`m15-scale-report.json` records 100,000 backup inserts in 0.57 s, a 100-row deep pagination query in 0.004209 s, and a 5.32 MB SQLite fixture. + +## Capability certification + +```text +python tools/assert_capabilities.py --release v2.0 \ + --include local,ssh,restore,webhook,email \ + --exclude postgresql,mysql,tar_download +``` + +Passed. The released contract exposes `local`, `ssh`, `restore`, `webhook`, and `email`; PostgreSQL, MySQL, and TAR download remain disabled. + +SSH is private-key-only and requires a dedicated forced-SFTP chroot account; no password, shell, or remote-command path is released. + +## Remaining certification gates + +Run the full project, container E2E, SSH live integration, fault/security/leakage suites, and review the synthetic workload boundaries before a release commit is created. diff --git a/docs/release/m15-scale-report.json b/docs/release/m15-scale-report.json new file mode 100644 index 0000000..c796d02 --- /dev/null +++ b/docs/release/m15-scale-report.json @@ -0,0 +1,21 @@ +{ + "method": "synthetic metadata certification; logical bytes are sparse and no physical 10 TiB payload is allocated", + "reference_host": { + "cpus": 12, + "platform": "Linux-7.1.4-arch1-1-x86_64-with-glibc2.44", + "python": "3.14.6" + }, + "results": { + "backup_insert_seconds": 0.57, + "database_bytes": 5320704, + "pagination_rows": 100, + "pagination_seconds": 0.004209, + "total_seconds": 0.592 + }, + "workload": { + "backups": 100000, + "entries_declared": 1000000, + "jobs": 100, + "logical_bytes_per_backup": 10995116277760 + } +} diff --git a/docs/release/m2-evidence.md b/docs/release/m2-evidence.md new file mode 100644 index 0000000..d6ab860 --- /dev/null +++ b/docs/release/m2-evidence.md @@ -0,0 +1,39 @@ +# M2 TDD Evidence + +## RED — behavioral contracts absent + +Commit command: + +```bash +.venv/bin/python -m pytest tests/unit/test_redaction.py \ + tests/contract/test_api_conventions.py tests/integration/test_auth.py \ + tests/security/test_auth.py tests/security/test_leakage_scan.py -q +``` + +Observed before implementation on 2026-07-27: + +- Exit: `1` +- Result: `4 failed, 13 errors` +- Intended causes: `backup_tool.api`, `backup_tool.security`, and + `tools/leakage_scan.py` did not exist. Tests described setup, authentication, + CSRF, token, secret, audit, pagination, ETag, idempotency, readiness, and + leakage behavior through public interfaces. + +## GREEN — secure control plane + +```bash +make check +.venv/bin/python -m pytest tests/unit/test_redaction.py \ + tests/contract/test_api_conventions.py tests/integration/test_auth.py \ + tests/security/test_auth.py tests/security/test_leakage_scan.py -q +``` + +Observed on 2026-07-27: + +- Focused M2 behavior suite: `17 passed`. +- Complete local check: `39` unit/contract, `12` integration, and `3` security tests + passed; Ruff, mypy, forbidden-v1 scan, TypeScript typecheck, and frontend build passed. +- Security tests prove Argon2id hashes, encrypted secret persistence, CSRF, + scoped/revoked/expired tokens, request-digest idempotency, problem details, + request IDs, ETags, cursor pagination, setup race handling, readiness states, + and output canary scanning. diff --git a/docs/release/m3-evidence.md b/docs/release/m3-evidence.md new file mode 100644 index 0000000..e6d215a --- /dev/null +++ b/docs/release/m3-evidence.md @@ -0,0 +1,6 @@ +# M3 Repository Initialization Evidence + +- RED commit: `1115f63` — repository safety contract tests failed because inspection/list endpoints and safety behavior were absent. +- GREEN commit: `448fa98` — repository canonical metadata inspection, capacity checks, safe staging cleanup, and authenticated list/inspection endpoints. +- Verification: `python -m pytest tests/integration/test_repository_safety.py tests/integration/test_repositories.py -q` → 4 passed; `make check` → 39 unit/contract, 16 integration, 3 security tests, Ruff, mypy, and frontend typecheck/build passed. +- Scope: no source, job, execution, scheduling, or encryption creation behavior was added. diff --git a/docs/release/m5-evidence.md b/docs/release/m5-evidence.md new file mode 100644 index 0000000..5a68ee9 --- /dev/null +++ b/docs/release/m5-evidence.md @@ -0,0 +1,12 @@ +# M5 Durable Execution Lifecycle Evidence + +- Coverage: state-transition contract and DB-enforced active-execution uniqueness + under concurrent enqueue; disabled/archived-job rejection; lease reclamation + and fencing; retry semantics; startup stale-worker reconciliation; durable + event revision counts; and idle worker shutdown. +- Verification: the focused M5 suite passed: 29 tests across transition, + queue/lease, and worker-loss acceptance coverage. +- Automation: `make test-fault` runs the worker-loss acceptance suite, and + `make check` now includes it. +- Scope: validates M5 durable lifecycle behavior only. Snapshot publication, + verification, and restore remain M6 work. diff --git a/docs/release/m6-evidence.md b/docs/release/m6-evidence.md new file mode 100644 index 0000000..b344cc5 --- /dev/null +++ b/docs/release/m6-evidence.md @@ -0,0 +1,14 @@ +# M6 Backup, Verification, and Restore Evidence + +- Coverage: local full-snapshot staging; immutable SHA-256 blobs; canonical + Ed25519-signed manifests; manifest/blob verification; publication markers and + startup reconciliation; selected and dry-run restores; `fail`, `skip`, and + `replace` root policies; destination containment; corruption handling; and + restore recovery. +- Focused acceptance: the M6 integration, publication-fault, and restore-path + suites pass. +- Full verification: `make check` passed with 68 unit/contract, 37 + integration, 7 fault, and 17 security tests, plus Ruff, mypy, frontend + typecheck, and frontend production build. +- Scope: excludes M7 incremental baselines/exclusions and later scheduling, + remote source, encryption, retention, and UI milestones. diff --git a/docs/release/m7-evidence.md b/docs/release/m7-evidence.md new file mode 100644 index 0000000..6e16aba --- /dev/null +++ b/docs/release/m7-evidence.md @@ -0,0 +1,12 @@ +# M7 Incrementals, Exclusions, and Empty-Source Safety Evidence + +- Coverage: ordered normalized exclusion rules, exclusion policy persisted in signed + manifests, incremental compatible-baseline selection, independently restorable + complete manifests, parent linkage, content-addressed blob reuse, and + empty-source opt-in. +- Verification: M7 unit, incremental, and empty-source suites are included in + the passing project verification. +- Full verification: `make check` passed with 78 unit/contract, 39 + integration, 7 fault, and 17 security tests, plus Ruff, mypy, frontend + typecheck, and frontend production build. +- Scope: scheduling remains M8 work. diff --git a/docs/release/m8-evidence.md b/docs/release/m8-evidence.md new file mode 100644 index 0000000..ea10268 --- /dev/null +++ b/docs/release/m8-evidence.md @@ -0,0 +1,9 @@ +# M8 Scheduling Evidence + +- Coverage: five-field cron validation, IANA timezones, UTC nominal runs, + durable schedule CRUD, live next-run updates, misfire handling, and + no-overlap delivery through the execution enqueue guard. +- Full verification: `make check` passed with 81 unit/contract, 40 + integration, 10 fault, and 17 security tests, plus Ruff, mypy, frontend + typecheck, and frontend production build. +- Scope: retention and garbage collection are M9 work. diff --git a/docs/release/m9-evidence.md b/docs/release/m9-evidence.md new file mode 100644 index 0000000..d6e8be7 --- /dev/null +++ b/docs/release/m9-evidence.md @@ -0,0 +1,8 @@ +# M9 Retention and Garbage Collection Evidence + +- Coverage: union retention policies, newest-backup protection, pins, tombstones, + referenced-object preservation, garbage collection, and reconciliation safety. +- Full verification: `make check` passed with 86 unit/contract, 42 integration, + 11 fault, and 17 security tests, plus Ruff, mypy, frontend typecheck, and + frontend production build. +- Scope: remote source support remains M10 work. diff --git a/docs/runbooks/disaster-recovery.md b/docs/runbooks/disaster-recovery.md new file mode 100644 index 0000000..5fa7128 --- /dev/null +++ b/docs/runbooks/disaster-recovery.md @@ -0,0 +1,11 @@ +# Disaster recovery runbook + +1. Isolate the failed host and preserve the metadata volume, repository roots, logs, + image digest, and master-key backup. Do not restart writers repeatedly. +2. Provision a clean host with the same pinned image and non-root volume permissions. +3. Restore the master key securely, restore repository roots read-only first, and + restore metadata from a verified backup or the passphrase-protected recovery bundle. +4. Run `migrate current`, start only `web`, validate `/readyz` and repository + inspection, then start scheduler and worker one at a time. +5. Perform and document a test restore before enabling scheduled work. Rotate secrets + if host compromise is possible. diff --git a/docs/runbooks/keys.md b/docs/runbooks/keys.md new file mode 100644 index 0000000..8e8cd85 --- /dev/null +++ b/docs/runbooks/keys.md @@ -0,0 +1,13 @@ +# Master and repository key runbook + +- Store the Compose master key outside the checkout. It must be a regular file, + at least 32 bytes, mode `0600`, owned by the service UID (`10001` for Compose). + Loss of this key destroys access to encrypted metadata secrets. +- Back up the master key independently from metadata and repositories; do not put it + in an image, Compose environment variable, log, ticket, or recovery bundle. +- Use the `admin recovery export` command with a passphrase file descriptor to create + a separately protected recovery bundle. Validate it on an isolated host. +- Rotate repository data keys only with `admin repository-key rotate`; retain prior + recovery material until a restore drill succeeds. +- If compromise is suspected, stop the worker, preserve evidence, rotate credentials, + export a fresh recovery bundle, and run a restore drill before resuming writes. diff --git a/docs/runbooks/metadata.md b/docs/runbooks/metadata.md new file mode 100644 index 0000000..915567e --- /dev/null +++ b/docs/runbooks/metadata.md @@ -0,0 +1,19 @@ +# Metadata backup and restore runbook + +## Backup + +1. Confirm `docker compose ps` shows exactly one scheduler and one worker. +2. For an online SQLite backup, run a host-side SQLite `.backup` against the mounted + `metadata.db`; do **not** copy only the main file while WAL writers run. +3. For a filesystem copy, stop `web`, `scheduler`, and `worker` first, then retain + `metadata.db`, `metadata.db-wal`, and `metadata.db-shm` together. +4. Encrypt and test the backup outside the appliance. Never place a database dump in + the repository or OCI image. + +## Restore + +1. Stop all runtime roles and preserve the failed metadata volume unchanged. +2. Restore the complete SQLite backup into the metadata volume with the service user + ownership (UID 10001 in the supplied Compose deployment). +3. Run `docker compose run --rm migrate current`; only start the stack when it reports + the expected revision. Validate `/readyz` and a read-only API request after startup. diff --git a/docs/runbooks/notifications.md b/docs/runbooks/notifications.md new file mode 100644 index 0000000..6003fe9 --- /dev/null +++ b/docs/runbooks/notifications.md @@ -0,0 +1,15 @@ +# Notifications runbook + +## Configure + +Create a filtered email or webhook subscription through `/api/v2/notifications/subscriptions`. Webhooks require a write-only signing secret. Configure SMTP separately at `/api/v2/notifications/email-settings`; only authenticated STARTTLS SMTP is accepted. Verify a channel with `POST /subscriptions/{id}/test` and inspect delivery/attempt history before relying on it. + +Filters are a nonempty set of exact catalog IDs or family wildcards such as `execution.*`; they may be narrowed by job IDs, repository IDs, or severity. The public catalog is live-events-only: every listed type is emitted by a currently available operation. Deferred channels and source capabilities have no catalog entries. + +## Rotate and recover + +Rotate webhook keys using the signing-key rotate endpoint with an idempotency key and an explicit bounded overlap. Receivers must accept both signatures during overlap, then remove the old key after expiry. A recovery bundle deliberately excludes subscriptions, SMTP settings, signing secrets, event history, and delivery attempts. Reconfigure notifications after a fresh-host recovery. + +## Failure handling + +The worker claims due deliveries with a lease. Transient errors enter bounded exponential retry; interrupted leases recover as retryable work and may send an event again. Inspect response class and redacted diagnostics in history. A terminal failed delivery can be retried manually once the destination is corrected. To stop outbound traffic, disable/archive subscriptions or stop the worker; do not delete outbox history. Rollback consists of disabling subscriptions and worker dispatch while retaining audit/outbox records for investigation. diff --git a/docs/runbooks/observability.md b/docs/runbooks/observability.md new file mode 100644 index 0000000..6335afa --- /dev/null +++ b/docs/runbooks/observability.md @@ -0,0 +1,17 @@ +# Observability and alert response + +The proxy exposes `/livez`, `/readyz`, and Prometheus text at `/metrics`. Metrics use +no source paths, IDs, credentials, tokens, or secret values. Runtime logs are JSON +records with an event, timestamp, role, and request correlation ID where applicable. + +Alert when any of the following remains non-zero or grows: + +- `backup_tool_stale_execution_leases` +- `backup_tool_failed_executions` +- `backup_tool_corrupt_backups` +- `backup_tool_unavailable_repositories` +- `backup_tool_schedule_lag_seconds` + +Also alert on low `backup_tool_filesystem_free_bytes`. For any alert, preserve logs, +validate `/readyz`, stop the worker before destructive repository investigation, and +use the matching metadata, repository, key, upgrade, or disaster-recovery runbook. diff --git a/docs/runbooks/recovery-bundle.md b/docs/runbooks/recovery-bundle.md new file mode 100644 index 0000000..c5a2f34 --- /dev/null +++ b/docs/runbooks/recovery-bundle.md @@ -0,0 +1,70 @@ +# Recovery bundle export and validation + +M11 recovery exports an offline, passphrase-encrypted catalog and key bundle. +Import is a local CLI operation that reconstructs only the metadata required to +restore existing encrypted backups; it does not reactivate backup scheduling. + +## Export + +Choose an absolute path in a trusted, non-symlinked directory. The destination +must not already exist; export creates it with mode `0600` and never overwrites +it. + +```sh +read -r -s recovery_passphrase +printf '\n' +printf '%s\n' "$recovery_passphrase" | \ + backup-tool admin recovery export \ + --output /secure/offline/backup-tool-recovery.btrec \ + --passphrase-fd 0 +unset recovery_passphrase +``` + +The passphrase is read from the inherited file descriptor. It is never a CLI +argument. Store the resulting `BTREC` file away from the host and away from the +live repository-key directories. + +## Validate + +Validation authenticates and decrypts the bundle, checks the versioned Argon2id +and AES-GCM format, and verifies the included catalog/key relationships. It +prints only a status and repository count. + +```sh +read -r -s recovery_passphrase +printf '\n' +printf '%s\n' "$recovery_passphrase" | \ + backup-tool admin recovery validate \ + --input /secure/offline/backup-tool-recovery.btrec \ + --passphrase-fd 0 +unset recovery_passphrase +``` + +Wrong passphrases, tampering, malformed headers, unsupported KDF parameters, +and invalid encrypted payloads intentionally produce the same validation error. +Do not use a failed validation result to diagnose which of those conditions +occurred. + +## Fresh-host import + +Before importing, run migrations on the replacement host and configure its +repository allowlist to include the surviving repository directory. The +repository must pass normal metadata/path inspection. The replacement metadata +database must be current and otherwise empty; import rejects a non-empty +destination and any existing/conflicting key files. + +```sh +backup-tool migrate upgrade +read -r -s recovery_passphrase +printf '\n' +printf '%s\n' "$recovery_passphrase" | \ + backup-tool admin recovery import \ + --input /secure/offline/backup-tool-recovery.btrec \ + --passphrase-fd 0 +unset recovery_passphrase +``` + +Import installs signing and data keys with restrictive modes, restores the +repository/source/job/execution/backup catalog needed for restore, and marks +sources unavailable plus jobs archived and disabled. Reconfigure sources and +explicitly create or enable new jobs before taking another backup. diff --git a/docs/runbooks/repositories.md b/docs/runbooks/repositories.md new file mode 100644 index 0000000..1feb742 --- /dev/null +++ b/docs/runbooks/repositories.md @@ -0,0 +1,12 @@ +# Repository recovery runbook + +1. Stop `worker` before inspecting or repairing a repository; never edit a live + repository behind an active lease. +2. Preserve the repository directory and its metadata volume before remediation. +3. Verify repository state through the operator API and verify individual backups + before any restore. Treat a corrupt verification result as an incident, not a + deletion request. +4. Mount replacement repository roots at the same allowlisted path, restore metadata, + then start `migrate`, `web`, `scheduler`, and finally `worker`. +5. Keep archived repositories mounted until retention and restore obligations expire. + Do not remove manifests or blobs manually. diff --git a/docs/runbooks/ssh-sources.md b/docs/runbooks/ssh-sources.md new file mode 100644 index 0000000..8ca4481 --- /dev/null +++ b/docs/runbooks/ssh-sources.md @@ -0,0 +1,19 @@ +# SSH sources + +SSH sources require a dedicated account confined by an OpenSSH `ChrootDirectory` +and `ForceCommand internal-sftp`. The chroot directory is root-owned; writable +content is below it. Disable passwords, keyboard-interactive authentication, +shells, PTYs, TCP/X11/agent forwarding, and tunnelling. Configure the source +root as `/` only. + +Generate a dedicated unencrypted Ed25519, ECDSA, or RSA-3072+ client key and +store it through the write-only `ssh_private_key` secret endpoint. Do not put a +key, passphrase, password, command, agent path, or key file path in source +configuration. Pin the server's exact OpenSSH public host key (`algorithm +base64`) before probing. On host-key rotation, obtain the replacement through +an out-of-band administrative channel, update the source pin, then probe. + +The server administrator controls mutable content inside the chroot. The client +rejects traversal names, symlinks, special files, changed files, and configured +resource-limit overflows, but cannot claim atomic no-follow behavior against a +maliciously changing filesystem inside that server-controlled boundary. diff --git a/docs/runbooks/upgrade.md b/docs/runbooks/upgrade.md new file mode 100644 index 0000000..f56fbe0 --- /dev/null +++ b/docs/runbooks/upgrade.md @@ -0,0 +1,12 @@ +# Upgrade and rollback runbook + +1. Record the running image digest and take a tested metadata backup plus repository + recovery evidence before changing the image. +2. Pull/build the pinned image, then run `docker compose run --rm migrate upgrade`. + Do not start web, scheduler, or worker against an unverified schema. +3. Start the stack, wait for `/readyz`, and inspect `/metrics` for stale leases, + schedule lag, unavailable repositories, and corrupt backups. +4. If migration fails, stop and restore the prior metadata backup and matching image; + do not attempt to downgrade an unknown partially migrated database in place. +5. Preserve migration logs and verify a representative backup restore before closing + the change. diff --git a/docs/security/notifications.md b/docs/security/notifications.md new file mode 100644 index 0000000..fb2db52 --- /dev/null +++ b/docs/security/notifications.md @@ -0,0 +1,9 @@ +# Notification security policy (M12) + +Webhook callbacks accept absolute `http` and `https` URLs only. HTTP is an approved compatibility option, not a relaxation of egress controls. URLs with credentials, fragments, literal IP addresses, or ports outside 80/443/8080/8443 are rejected. Immediately before every request the worker resolves all A/AAAA answers; any non-global answer rejects the whole destination. The transport connects only to an approved answer, verifies the connected peer address, disables proxy environment use, preserves HTTPS SNI/certificate validation, and rejects redirects. + +Webhook bodies are canonical JSON event envelopes. `X-Backup-Event-ID` is stable over retries. Each attempt supplies the versioned HMAC-SHA-256 timestamp/body input and one `X-Backup-Signature` header per active or overlap key. Key IDs and monotonically increasing subscription-local versions identify keys; an old key is retained only until its configured overlap expiry. Key material is encrypted in `secrets` and is never returned, logged, exported in recovery bundles, or included in audit/event payloads. + +SMTP is configured as one write-only password-backed singleton. Delivery performs EHLO, verified STARTTLS, a second EHLO, and SMTP AUTH; any inability to do this fails closed. Message content is a compact event summary with no attachments, Bcc, paths, raw exception text, credentials, or full webhook payload. + +Delivery is at-least-once. A worker crash after a send but before recording success can lead to a duplicate; consumers must deduplicate using the event ID. Redirects, malformed destinations, and SSRF validation failures are terminal. Connect/read transport failures, SMTP transient failures, and HTTP 408/425/429/5xx use bounded retry. Deployment egress controls are defense in depth, not a substitute for this policy. diff --git a/docs/security/repository-encryption.md b/docs/security/repository-encryption.md new file mode 100644 index 0000000..a02a15a --- /dev/null +++ b/docs/security/repository-encryption.md @@ -0,0 +1,35 @@ +# Repository encryption threat model + +## Status + +This document defines the M11 repository-encryption boundary. Encrypted repository +creation remains disabled until the recovery host-loss acceptance test passes. + +## Confidential data + +For an `aes-256-gcm` repository, blob contents and signed manifest contents are +AEAD-encrypted. Data keys and signing private keys are never stored in repository +metadata, SQLite, logs, command arguments, environment output, or API responses. +Recovery bundles are separately passphrase-encrypted. + +## Intentional leakage + +The v1 content-addressed layout retains plaintext SHA-256 blob names. Encryption +therefore leaks blob equality, object count, repository layout, manifest/backup +identifiers, and ciphertext sizes derived from plaintext sizes. It does not claim +to hide those values, a compromised running host with unlocked keys, source data +while it is read, or a recovery-bundle passphrase. + +## Key lifecycle + +Encryption uses distinct data and signing keys. Rotation creates a new data-key +epoch for subsequent objects and retains historical epochs so existing backups +remain verifiable and restorable. It neither changes immutable repository policy +nor rewrites existing objects. + +## Recovery boundary + +Recovery export, validation, and import are local CLI operations only. A bundle +contains the required key material and an authenticated catalog; passphrases are +not accepted on command lines. Bundle parsing must reject malformed or unsupported +parameters without revealing whether a passphrase, key, or ciphertext was wrong. diff --git a/docs/superpowers/plans/2026-07-27-backup-tool-v2-implementation.md b/docs/superpowers/plans/2026-07-27-backup-tool-v2-implementation.md new file mode 100644 index 0000000..19d8e52 --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-backup-tool-v2-implementation.md @@ -0,0 +1,275 @@ +# Backup Tool v2 Implementation Plan + +**Status:** Ready for execution after branch kickoff +**Date:** 2026-07-27 +**Design authority:** [`../specs/2026-07-27-backup-tool-reimplementation-design.md`](../specs/2026-07-27-backup-tool-reimplementation-design.md) +**Replaces:** [`2026-05-11-backup-tool-implementation.md`](./2026-05-11-backup-tool-implementation.md) for all v2 work + +## 1. Reason for Existence + +This plan converts the decision-closed design into dependency-ordered, test-first, independently reviewable milestones. It prevents framework-first rebuilding: repository format, publication safety, restore, and crash recovery become executable contracts before scheduling, optimization, UI breadth, or v2.1 adapters. + +A milestone is complete only when its named behavior tests pass, its failure path is proved, its design decisions and acceptance invariants are traceable, and the working tree contains no unrelated changes. + +## 2. Scope and Guardrails + +- V2 is a clean rewrite. It MUST NOT contain `/api/v1`, legacy DB readers, importers, converters, timestamp-directory parsers, or v1 payload access. +- Preserve v1 only as a Git tag/branch for historical reference before deleting executable v1 code. No v1 file or test is adapted into a compatibility layer. +- V2.0 includes local and SSH/SFTP sources, local repositories, restore, optional repository encryption, recovery bundles, webhooks, and email. +- PostgreSQL, MySQL, and generated TAR download are v2.1-only capabilities and MUST remain absent from v2.0 OpenAPI, UI, image, and capability metadata. +- Certified topology is one node, SQLite/WAL, one scheduler process, one worker process, and one or more web processes. Web processes MUST perform no backup, restore, scheduler, or repository I/O. +- Every task follows red → green → refactor. Preserve failing-then-passing command output in PR/CI evidence. +- Do not start a dependent milestone until its dependency gate is green. Do not split an atomic set from §6 across releases. + +## 3. Target Project Layout + +```text +backend/ + pyproject.toml + alembic/versions/ + src/backup_tool/ + api/{app,deps,errors,pagination,etag,idempotency,sse,routers/,schemas/} + adapters/{base,local,sftp,postgres,mysql}.py + db/{engine,models,repositories}.py + domain/{manifest,paths,exclusions,transitions,cron,retention}.py + execution/{enqueue,claims,leases,runner,retry,cancellation,progress}.py + notifications/{events,dispatcher,webhook,email,retry}.py + observability/{logging,metrics,health}.py + repository/{layout,blob_store,manifest_store,staging,publisher,verifier,restore,reconcile,gc,encryption}.py + security/{auth,secrets,redaction,repository_crypto,recovery_bundle,ssrf}.py + services/{sources,jobs,repositories,backups,restores}.py + scheduler/service.py + {cli,config,ids,clock}.py +frontend/src/ + api/generated/ + app/ + pages/ + components/ +contracts/repository/v1/ +openapi/v2.json +tests/{unit,contract,integration,fault,security,e2e,performance,fixtures}/ +tools/{export_openapi,forbidden_v1_scan,leakage_scan,check_traceability,run_scale_certification}.py +docs/{runbooks,security,release}/ +``` + +Module rules: domain code imports no FastAPI, SQLAlchemy, filesystem, Paramiko, SMTP, or HTTP client; adapters and repository code implement ports; services own use cases; routers translate HTTP only; ORM and API schemas never act as domain models. + +## 4. Dependency Spine + +```text +M0 contracts + └─ M1 runtime/DB ─ M2 auth/API + └─ M3 repository ─ M4 sources/jobs ─ M5 execution + └─ M6 full backup/verify/restore + ├─ M7 incremental/exclusions + ├─ M8 scheduling + ├─ M9 retention/GC/reconcile + ├─ M10 SSH/SFTP + └─ M11 encryption/recovery + └─ M12 notifications ─ M13 UI/API ─ M14 operations ─ M15 v2.0 +M15 ─ M16 PostgreSQL ─ M17 MySQL ─ M18 TAR/download ─ v2.1 +``` + +M7-M11 MAY be developed on separate branches after M6, but merge in dependency order and never use concurrent writers in one worktree. + +## 5. Milestones + +### M0 — Freeze protocol and test foundation + +**Depends on:** approved design. +**Deliver:** lock Python/Node/tool versions; create package/test layout and `Makefile`; commit canonical repository/manifest JSON schemas, golden valid/invalid fixtures, normalized-path vectors, state-transition table, error-code catalog, capability schema, and deterministic fault-point names. Tag/archive v1, then remove `backend/app`, `backend/backup`, old tests, and handwritten `frontend/src/api/client.ts`. +**Verify:** `.venv/bin/python tools/forbidden_v1_scan.py . && .venv/bin/python -m pytest tests/contract/test_repository_format.py tests/contract/test_no_v1.py -q` (run `make setup` first on a clean checkout) +**Commit:** `chore(v2): establish protocol and test foundation` + +### M1 — Runtime, UUIDv7, SQLite/WAL, and Alembic baseline + +**Depends on:** M0. +**Deliver:** typed config; canonical source/repository/restore allowlists; key-file owner/mode checks; UUIDv7 IDs; UTC clock port; role CLIs; SQLite foreign keys/WAL/busy timeout; complete explicit schema, constraints, indexes, and first hand-reviewed migration. Startup checks migration state and never calls `create_all`. +**Verify:** `cd backend && python -m alembic upgrade head && python -m alembic downgrade base && python -m alembic upgrade head && python -m pytest ../tests/unit/test_config.py ../tests/integration/test_migrations.py -q` +**Commit:** `feat(v2): add validated runtime and metadata store` + +### M2 — Admin setup, authentication, secrets, audit, and API conventions + +**Depends on:** M1. +**Deliver:** guarded one-admin setup; Argon2id; secure session/CSRF; hashed scoped API tokens; envelope-encrypted write-only secrets; request IDs; append-only audit; RFC 9457 problems; stable cursor pagination/sort; ETags; request-digest idempotency; truthful `/livez` and `/readyz`. +**Verify:** `python -m pytest tests/unit/test_redaction.py tests/contract/test_api_conventions.py tests/integration/test_auth.py tests/security/test_auth.py -q && python tools/leakage_scan.py` +**Commit:** `feat(v2): secure identity secrets and API conventions` + +### M3 — Initialize and inspect repositories + +**Depends on:** M2. +**Deliver:** allowlisted local repository init; `repository.json`; immutable compression/encryption choice; canonical JSON; plaintext SHA-256 blob identity; capacity probe; minimum free bytes/percent; safe partial-init cleanup; path/symlink containment. Encryption interfaces exist, but encrypted creation remains capability-disabled until M11. +**Verify:** `python -m pytest tests/contract/test_repository_format.py tests/integration/test_repository_init.py tests/security/test_repository_paths.py -q` +**Commit:** `feat(v2): initialize immutable content repositories` + +### M4 — Typed local sources and repository-targeted jobs + +**Depends on:** M3. +**Deliver:** adapter protocol; normalized POSIX entries; bounded local streaming; symlink/cross-filesystem/special-file policy; source probes; typed source/job CRUD; immutable source kind; archive semantics; repository IDs instead of destination strings; enabled, mode, exclusions, retention, and `allow_empty`. +**Verify:** `python -m pytest tests/unit/test_paths.py tests/unit/test_exclusions.py tests/integration/test_local_source_api.py tests/security/test_source_containment.py -q` +**Commit:** `feat(v2): add local sources and repository jobs` + +### M5 — Durable execution lifecycle + +**Depends on:** M4. +**Deliver:** monotonic states; `202` execution resource; `409` active execution; DB-enforced one nonterminal execution/job; disabled-job check inside enqueue; leases, heartbeats, retries using one execution ID, attempts, cancellation, reason codes, redacted progress/logs, SSE with polling fallback, graceful shutdown, stale-worker fencing, startup reconciliation. +**Verify:** `python -m pytest tests/unit/test_execution_transitions.py tests/integration/test_queue_leases.py tests/fault/test_worker_loss.py -q` +**Commit:** `feat(v2): add durable leased execution lifecycle` + +### M6 — Atomic full backup, verification, and restore + +**Depends on:** M5. +**Deliver:** unique staging; capacity preflight; adapter-only reads; chunked hashing; immutable blob install; canonical complete manifest; fsync/atomic rename where supported; object verification; metadata visibility only after durable publish; reconciliation marker; metadata/full verification; durable dry-run/selected restore; `fail|skip|replace`; destination containment; result manifest; integrity states. +**Verify:** `python -m pytest tests/integration/test_full_backup_restore.py tests/fault/test_publication_crashes.py tests/security/test_restore_paths.py -q` +**Commit:** `feat(v2): publish verified snapshots and durable restores` + +### M7 — Exclusions, independent incrementals, and empty-source safety + +**Depends on:** M6. +**Deliver:** versioned gitignore semantics shared by every adapter; compatible-baseline selection; requested/effective mode; `baseline_missing`; blob reuse; declared metadata-trust rules; independently restorable complete manifests; empty-source opt-in and vanished-source confirmation. +**Verify:** `python -m pytest tests/unit/test_exclusions.py tests/integration/test_incremental.py tests/integration/test_empty_source.py -q` +**Commit:** `feat(v2): add safe deduplicated snapshots` + +### M8 — Deterministic durable scheduling + +**Depends on:** M6. +**Deliver:** one schedule/job; semantic five-field cron; IANA timezone; next run/last result; `(schedule_id, nominal_utc)` uniqueness; immediate create/update/delete synchronization; 15-minute grace; coalesce/no-overlap; exact DST gap/fold behavior; disabled job/schedule rejection; same enqueue service as manual execution. +**Verify:** `python -m pytest tests/unit/test_cron_dst.py tests/integration/test_scheduler_live_sync.py tests/fault/test_schedule_delivery.py -q` +**Commit:** `feat(v2): schedule idempotent timezone-aware executions` + +### M9 — Retention, tombstones, reconciliation, and safe GC + +**Depends on:** M6 and M7. +**Deliver:** union policy for keep-last/days/daily/weekly/monthly/pins; newest protection; preview/apply; archive without payload deletion; tombstone/audit; grace plus second reference scan; `deletion_failed`; confirmed purge/report; bidirectional reconciliation; unknown-object quarantine; shared-blob safety. +**Verify:** `python -m pytest tests/unit/test_retention.py tests/integration/test_gc.py tests/fault/test_deletion_failure.py tests/integration/test_reconcile.py -q` +**Commit:** `feat(v2): add retention reconciliation and safe GC` + +### M10 — Strict SSH/SFTP parity + +**Depends on:** M6 and M7. +**Deliver:** typed remote config; write-only auth; pinned known-host key; fail-closed probe and execution; bounded SFTP enumeration/streaming; timeouts/backpressure; common paths/exclusions; source-consistency metadata; transient classification; no local traversal fallback. +**Verify:** `docker compose -f tests/compose.integration.yaml up -d sshd && python -m pytest tests/integration/test_sftp_backup_restore.py tests/security/test_sftp_host_keys.py -q` +**Commit:** `feat(v2): add pinned-host SFTP backups` + +### M11 — Optional repository encryption and offline recovery + +**Depends on:** M6. +**Deliver:** AEAD stored objects; associated metadata; key IDs and rotation; immutable policy; explicit confidentiality/equality-leakage threat model; separately passphrase-protected recovery bundle; non-disclosing validation; fresh-host metadata/key recovery and encrypted restore. Enable encrypted repository creation only after the host-loss test passes. +**Verify:** `python -m pytest tests/integration/test_encrypted_repository.py tests/integration/test_recovery_bundle.py tests/security/test_crypto_leakage.py -q` +**Commit:** `feat(v2): encrypt repositories and recover keys offline` + +### M12 — Signed webhooks and auditable email + +**Depends on:** M2, M5, and M7-M11. +**Deliver:** complete operational event catalog covering execution, schedule, retention/GC, reconciliation, SSH, encryption/recovery, restore, verification, capacity, and security outcomes; typed filters/subscriptions; stable event IDs; durable outbox/deliveries/attempts; webhook signature/version and rotation; at-least-once bounded retry; SMTP transient retry; manual test/retry; history; rate limits; write-only credentials; redirect/DNS/IP/private-network/rebinding SSRF policy. +**Verify:** `python -m pytest tests/contract/test_notification_contract.py tests/integration/test_all_operational_events_deliver.py tests/integration/test_notifications.py tests/fault/test_notification_retries.py tests/security/test_webhook_ssrf.py -q` +**Commit:** `feat(v2): add webhook and email delivery` + +### M13 — OpenAPI-generated client and operator UI + +**Depends on:** M7-M12. +**Deliver:** deterministic committed OpenAPI; generated TypeScript client only; drift CI; Setup/Login, Dashboard, Sources, Repositories, Jobs/Schedule, Executions/SSE, Backups/Verify/Restore/Delete Preview, Security/Recovery, Notifications/History, and Audit. Implement loading, empty, validation, partial failure, reconnect, session expiry, responsive, keyboard, focus, contrast, reduced-motion, and screen-reader states. Hide v2.1 controls completely. +**Verify:** `python tools/export_openapi.py --check openapi/v2.json && npm --prefix frontend run api:generate && git diff --exit-code -- openapi/v2.json frontend/src/api/generated && npm --prefix frontend test -- --run && npm --prefix frontend run build && npx --prefix frontend playwright test` +**Commit:** `feat(v2): ship generated-client operator workflows` + +### M14 — Production roles, observability, recovery, and packaging + +**Depends on:** M13. +**Deliver:** one pinned non-root OCI image with `web|scheduler|worker|migrate|admin`; same-origin proxy; one scheduler and worker; no reload or embedded roles; role-aware readiness; structured logs; required metrics/alerts; metadata/repository/key/upgrade/disaster runbooks; restart persistence; safe shutdown; SBOM/provenance; v2.0 image contains no DB dump clients. +**Verify:** `docker compose build --pull && docker compose run --rm migrate upgrade && docker compose up -d && python -m pytest tests/e2e/test_compose_v2.py tests/security/test_container.py -q && docker compose down` +**Commit:** `build(v2): package isolated roles and recovery operations` + +### M15 — Certify and release v2.0 + +**Depends on:** M0-M14. +**Deliver:** reference-host definition; workloads for 100 jobs, 1M entries or 10 TiB logical/backup, and 100k cataloged backups; throughput/CPU/memory/DB/lag/pagination/restore/verify evidence; bounded-memory and accepted-regression gates; complete fault/security/leakage/E2E evidence; truthful capabilities/docs; removal of all v2.1 controls. +**Verify:** `python tools/run_scale_certification.py --jobs 100 --entries 1000000 --backups 100000 && python tools/assert_capabilities.py --release v2.0 --include local,ssh,restore,webhook,email --exclude postgresql,mysql,tar-download` +**Commit:** `release: certify backup-tool v2.0.0` + +### M16-M18 — V2.1 vertical slices + +- **M16 PostgreSQL (depends on M15):** secret-safe bounded `pg_dump`, TLS, tool/server matrix, full-only manifest, cancellation/error classification, typed source CRUD/probe, capability metadata, OpenAPI/generated client/UI form, and v2.1 OCI dump-client packaging land atomically → verify: `python -m pytest tests/contract/test_postgres_source_api.py tests/integration/test_postgres_adapter.py tests/security/test_dump_secrets.py tests/security/test_v21_container.py -q && make check-openapi && npm --prefix frontend run build && python tools/assert_capabilities.py --release v2.1 --include postgresql` → `feat(v2.1): add PostgreSQL logical snapshots`. +- **M17 MySQL (depends on M16):** secret-safe bounded `mysqldump`, TLS/locking consistency, compatibility matrix, full-only manifest, typed source CRUD/probe, capability metadata, OpenAPI/generated client/UI form, and OCI client verification land atomically → verify: `python -m pytest tests/contract/test_mysql_source_api.py tests/integration/test_mysql_adapter.py tests/security/test_dump_secrets.py tests/security/test_v21_container.py -q && make check-openapi && npm --prefix frontend run build && python tools/assert_capabilities.py --release v2.1 --include postgresql,mysql` → `feat(v2.1): add MySQL logical snapshots`. +- **M18 TAR/download and v2.1 release (depends on M17):** verify every blob/tag before streaming manifest-derived safe entries; traversal/link/device/disconnect tests; generated API/client/UI capability; then rerun every v2.0 gate, both DB matrices, OpenAPI drift, Compose/E2E, leakage, and reference-host regression → verify: `make check && make test-integration && make test-fault && make test-security && make test-e2e && make check-openapi && make check-traceability && make check-v1-absent && python -m pytest tests/integration/test_postgres_adapter.py tests/integration/test_mysql_adapter.py tests/security/test_dump_secrets.py tests/integration/test_tar_download.py tests/security/test_tar_paths.py -q && python tools/run_scale_certification.py --regression-from docs/release/v2.0-evidence.md && python tools/assert_capabilities.py --release v2.1 --include local,ssh,postgresql,mysql,restore,tar-download,webhook,email && npx --prefix frontend playwright test` → `release: certify backup-tool v2.1.0`. + +## 6. Atomic Sets and Stop Rules + +The following land as indivisible reviewed sets: publication + reconciliation marker + crash matrix; execution state + uniqueness + leases + cancellation + recovery; auth + secrets + redaction + audit; restore record + containment + verification + atomic placement; retention + tombstone + reference scans + quarantine; scheduler parser + uniqueness + DST/misfire + live sync; encryption + recovery bundle + host-loss restore; notification outbox + signing + SSRF + SMTP + retry/history; OpenAPI + generated client + drift check + corresponding UI. + +Stop and request an ADR/design amendment if implementation requires changing manifest canonicalization, blob identity, external IDs, process topology, delivery guarantee, schedule semantics, encryption confidentiality, retention precedence, v2.0/v2.1 capability split, or any D1-D21 decision. Stop a milestone when a failure test is nondeterministic, a destructive test can reach operator data, a zero-test selection can pass, or a secret/path canary appears in any output sink. + +## 7. Traceability + +| Decisions | Owner and proof | Decisions | Owner and proof | +| --- | --- | --- | --- | +| D1 | M0-M2: UUID/API/no-v1 contract | D2 | M2: concurrent setup test | +| D3 | M1, M5, M14: role/process test | D4 | M1, M14: pragmas/topology test | +| D5 | M2, M5: idempotency/lease tests | D6 | M3-M4: repository-only job/path tests | +| D7 | M0, M3, M6: manifest/independent restore | D8 | M3, M11: immutable policy/AEAD tests | +| D9 | M0, M4, M7, M10: shared matcher corpus | D10 | M8: cron/timezone/DST tests | +| D11 | M4-M5, M8: disabled enqueue tests | D12 | M4, M9: archive/restorability tests | +| D13 | M13: generated-client zero-diff test | D14 | M1: Alembic/no-`create_all` test | +| D15 | M10, M15-M17: capability gates | D16 | M15: certified reference-host report | +| D17 | M11: fresh-host recovery drill | D18 | M0, M15: forbidden-v1 scan/runtime test | +| D19 | M13, M15, M18: capability/route gates | D20 | M12-M13, M15: channel/event E2E | +| D21 | M12: stable-ID retry/history tests | — | — | + +| Invariants | Owner and proof | Invariants | Owner and proof | +| --- | --- | --- | --- | +| I1 | M6: publication crash matrix | I2 | M8: replayed occurrence uniqueness | +| I3 | M5: concurrent active-job starts | I4 | M5-M6: transition/restart matrix | +| I5 | M10, M16-M17: adapter-spy tests | I6 | M6-M7: parent-free restore corpus | +| I7 | M9: retain/GC/restore property tests | I8 | M4, M9: archive inventory test | +| I9 | M9: deletion failure/retry test | I10 | M2, M11-M12, M14: canary scan | +| I11 | M10: host-key fail-before-read test | I12 | M7, M10, M16-M17: matcher parity | +| I13 | M5, M8: all-trigger disable tests | I14 | M8: live-sync/misfire/DST tests | +| I15 | M0, M15: protected-v1 no-access test | I16 | M13, M18: OpenAPI/client zero diff | +| I17 | M6: full restore matrix | I18 | M6, M11, M18: corruption matrix | +| I19 | M7: empty/vanished-source test | I20 | M3-M4, M6, M10, M18: containment | +| I21 | M11: recovery-bundle host-loss drill | I22 | M12: webhook/email retry history | + +`tools/check_traceability.py` MUST require every D1-D21 and I1-I22 row to name a milestone, test path, runnable command, and immutable evidence artifact. + +## 8. Whole-Project Verification + +```bash +make test-fast # unit + contract; nonempty marker assertion +make test-integration # temp DB/repository roots only +make test-fault # deterministic failpoints +make test-security # canaries, containment, auth, SSRF +make test-e2e # production-like Compose +make check-openapi # export, generate, zero diff, type/build +make check-traceability # D1-D21 and I1-I22 evidence links +make check-v1-absent # static + runtime protected-v1 fixture +make check # format, lint, type, all non-scale tests +git diff --check +git status --short +``` + +Fault/destructive tests MUST allocate hermetic temporary source, repository, restore, DB, key, SMTP, and webhook targets. Test teardown MUST reject paths outside its temp root. CI MUST assert each selected marker collected at least one test. + +## 9. Release Gates + +**V2.0:** M0-M15 green; every D1-D21 item applicable to v2.0 and I1-I22 has immutable evidence; no secret canary; fault matrix green; OpenAPI/client diff clean; local and SSH restore corpus green; encrypted fresh-host recovery green; notifications retry across process restart; Compose roles/non-root/persistence green; scale report approved; capabilities exclude PostgreSQL, MySQL, and TAR download. + +**V2.1:** v2.0 gates remain green; PostgreSQL/MySQL compatibility matrices pass; dump credentials never enter argv/logs; TAR corruption/path/disconnect tests pass; generated API/client/UI exposes only installed capabilities; performance regression remains within the accepted threshold. + +## 10. Execution Discipline + +Use one feature branch/worktree per milestone. Keep commits small within the milestone but merge only when its atomic gate is closed. Conventional commit title matches the milestone; PR body links RED/GREEN evidence, design IDs, invariants, commands with exit codes, changed files, residual risks, and rollback. Run a fresh-context security/reliability review after M2, M6, M9, M11, M12, M14, and before each release. + +**Next action:** start M0 by creating the contract fixtures and forbidden-v1 test before scaffolding application modules. + +## 11. Plan Verification + +```bash +python - <<'PY' +from pathlib import Path +p = Path('docs/superpowers/plans/2026-07-27-backup-tool-v2-implementation.md') +s = p.read_text() +for marker in ['M0', 'M15', 'M18', 'D1-D21', 'I1-I22', 'verify:', 'Release Gates']: + assert marker in s, marker +assert s.count('**Verify:**') >= 16 +print('v2 implementation plan: OK') +PY +``` diff --git a/docs/superpowers/specs/2026-07-27-backup-tool-reimplementation-design.md b/docs/superpowers/specs/2026-07-27-backup-tool-reimplementation-design.md new file mode 100644 index 0000000..95901f4 --- /dev/null +++ b/docs/superpowers/specs/2026-07-27-backup-tool-reimplementation-design.md @@ -0,0 +1,368 @@ +# Backup Tool v2 Reimplementation Design + +**Status:** Decision-closed baseline +**Date:** 2026-07-27 +**Supersedes:** [`2026-05-11-backup-tool-design.md`](./2026-05-11-backup-tool-design.md) for new implementation work +**Evidence base:** current backend, frontend, tests, deployment files, README, and prior design +**Normative language:** MUST, MUST NOT, SHOULD, and MAY have their RFC 2119 meanings. + +## 1. Reason for Existence + +This document is the implementation contract for a from-scratch v2. It separates behavior that exists, behavior that is broken, and previously proposed features; then defines one coherent product, integrity model, API, architecture, and delivery boundary. An implementation is conformant only when its tests prove the acceptance invariants in §22. + +## 2. Executive Decision + +Backup Tool v2 is a self-hosted, single-organization backup appliance for a trusted administrator. It backs up local files, SSH/SFTP trees, PostgreSQL databases, and MySQL databases into administrator-defined local repositories; schedules and monitors work; verifies, restores, retains, and safely deletes backups. + +V2 is a clean protocol revision, not a line-for-line rewrite. It keeps useful domain concepts—Source, Job, Schedule, Execution, Backup, dashboard, REST API, React UI, SQLite, manual and cron triggers—but rejects accidental compatibility with unsafe or nonfunctional behavior. + +The central design decision is an immutable, content-addressed repository. Each backup has a complete versioned manifest and is independently restorable; incremental mode reduces transfer/storage by reusing blobs rather than creating fragile restore chains. + +## 3. Current-State Audit + +| Area | Current evidence | Classification | V2 treatment | +| --- | --- | --- | --- | +| Source CRUD | `local`, `ssh`, `database`; arbitrary JSON config | Implemented | Preserve concept; replace with typed configs and write-only secrets | +| Job CRUD | Source, strategy, destination path, excludes, enabled | Implemented | Preserve; target a repository ID, add typed policy fields | +| Manual run | In-process FastAPI background task; returns message only | Implemented but fragile | Durable execution resource; return `202` and execution ID | +| Scheduling | One five-field cron row per job; startup-only sync | Partly implemented | Durable, timezone-aware, immediately synchronized scheduler | +| Local backup | Full recursive `copy2` into timestamp directory | Implemented | Replace storage protocol; preserve local source capability | +| SSH/database backup | Adapter connects, engine still traverses a local path | Broken | Implement real adapter enumeration/stream/dump paths | +| Incremental | Links latest full metadata but copies every file | Broken/misnamed | Complete manifests plus deduplicated blobs | +| Exclusions | Stored but ignored by engine | Broken | Gitignore-compatible normalized-path matching | +| Retention | Engine reads nonexistent job fields | Unreachable | First-class policy, preview, audit, safe GC | +| Backups | List/get/delete metadata; payload deletion inconsistent | Partly implemented | Snapshot catalog, verification, restore, download, tombstone deletion | +| Executions | List/get with basic counters and raw error string | Implemented | State machine, progress, reason codes, cancellation, protected logs | +| Settings | Arbitrary string key/value API; UI does not save | Placeholder | Typed settings only; no arbitrary public key/value store | +| Dashboard | Backend aggregate exists; frontend calls different routes | Contract mismatch | One generated OpenAPI contract | +| Security | No auth; credentials stored and returned in plaintext | Unacceptable | Mandatory admin auth, encrypted secret references, redaction | +| Integrity | Aggregate hash excludes paths/metadata; no restore/check | Insufficient | Per-blob digest, authenticated manifest, verify and restore | +| Deployment | API embeds worker/scheduler; SQLite; Docker/nginx | Implemented | One image/package, explicit web/scheduler/worker roles | + +Current tests establish only source/job CRUD, 404 behavior, local full backup, manual-run acknowledgement, schedule creation, and incremental-without-baseline fallback. They are migration evidence, not authority to preserve weak status codes, exact error strings, timestamp paths, or checksums. + +## 4. Product Definition + +### 4.1 Users and jobs to be done + +- **Administrator:** configure repositories, sources, credentials, jobs, schedules, retention, and security. +- **Operator:** run/cancel jobs, inspect progress and failures, verify backups, restore data, and review storage health. +- A single person MAY hold both roles. V2 has no tenant boundary or general-purpose RBAC. + +### 4.2 Required workflows + +1. Complete first-run admin setup and repository initialization. +2. Add and test a local or SSH/SFTP source without exposing its secret; add PostgreSQL/MySQL sources when v2.1 capabilities are installed. +3. Create an enabled job with source, repository, mode, exclusions, schedule, and retention. +4. Run manually or by cron; observe queue, progress, logs, outcome, and effective strategy. +5. Browse, filter, verify, pin, restore, or request deletion of a committed backup; download becomes available after v2.0. +6. Preview and apply retention; run repository reconciliation and garbage collection. +7. See dashboard health, capacity, next runs, active work, failures, and verification status. +8. Configure signed webhooks and email subscriptions, inspect delivery history, and export an offline key-recovery bundle. + +### 4.3 Initial release scope + +V2.0 MUST ship local and SSH/SFTP sources, local repositories, optional-at-creation repository encryption, full and deduplicating incremental backups, exclusions, durable execution, cron, retention, verification, restore, authenticated UI/API, signed webhooks, email notifications, offline recovery bundles, Docker deployment, and operational diagnostics. PostgreSQL/MySQL sources and generated TAR downloads are committed v2.1 scope. V2 provides no v1 API, metadata importer, or legacy-payload compatibility. + +### 4.4 Non-goals + +Multi-tenancy, enterprise RBAC, horizontal workers, distributed scheduling, continuous data protection, database PITR/WAL/binlog capture, cloud repositories, tape management, cross-repository deduplication, and source-native snapshot orchestration are deferred. + +## 5. Normative Decisions + +| ID | Decision | Consequence | +| --- | --- | --- | +| D1 | External resources use UUIDv7 and `/api/v2`; no v1 API or compatibility shim exists | Clean protocol break; sortable non-enumerable IDs | +| D2 | One install serves one organization and one initial admin | Security remains strong without premature tenancy | +| D3 | Web, scheduler, worker, and migrate are separate runtime roles from one codebase/image | API never performs backup I/O; no duplicate embedded schedulers | +| D4 | SQLite/WAL is the v2 metadata DB and supports one scheduler plus one worker process | Simple single-node deployment; horizontal scale explicitly unsupported | +| D5 | Execution claims live in the DB with leases, heartbeats, idempotency keys, and retries | Restart-safe, effectively-once publication | +| D6 | Jobs target configured repositories, never arbitrary destination paths | Enforce containment, capacity, permissions, and repository policy | +| D7 | Backup = complete logical snapshot manifest over immutable content-addressed blobs | Every retained backup restores independently | +| D8 | Repository encryption is operator-selected at creation; compression/encryption policies are then immutable | Plaintext and encrypted repositories are supported without mixed-policy ambiguity | +| D9 | Exclusions use gitignore semantics over normalized relative POSIX paths | Same predictable result across adapters | +| D10 | One cron schedule per job; IANA timezone is required | Preserve simple model; make DST behavior explicit | +| D11 | Disabled jobs reject manual and scheduled starts | One unambiguous meaning for `enabled` | +| D12 | Source/job deletion archives configuration and stops work; it never silently deletes backups | Data lifecycle remains explicit and auditable | +| D13 | OpenAPI generates the TypeScript client and CI rejects drift | Frontend/backend cannot diverge silently | +| D14 | Alembic is the only schema evolution path; startup never calls `create_all` | Repeatable upgrades and rollback planning | +| D15 | V2.0 ships local and SSH/SFTP sources; PostgreSQL/MySQL follow in v2.1 | Remote file backup is launch scope without delaying on database dump compatibility | +| D16 | Certified single-node scale is 100 jobs, 1M entries or 10 TiB per backup, and 100k backups | Release tests use a documented reference host and publish throughput rather than claiming a hardware-independent rate | +| D17 | Encrypted repositories use a separately passphrase-protected offline recovery bundle | Host loss is recoverable without weakening live key storage | +| D18 | V2 is a clean start with no v1 metadata or payload migration | Reimplementation carries no backward-compatibility code or legacy format liability | +| D19 | Generated TAR download follows in v2.1; verified restore is the v2.0 extraction path | Initial release keeps one secure data-output workflow | +| D20 | Signed webhooks and email notifications ship in v2.0 for all operational event families | Alerts are real product behavior, not placeholder settings | +| D21 | Notifications retry transient failures and preserve delivery history | Webhooks are at-least-once with stable event IDs; email delivery remains auditable | + +## 6. System Architecture + +```text +Browser/CLI -> Web API -> SQLite metadata + durable execution queue + | ^ + v | + SSE event stream Scheduler (single leader) + | + v +Source <- adapter <- Worker -> Repository (staging, blobs, manifests, quarantine) + |-> verify / restore / retention / reconcile / GC +``` + +- **Web:** session/API-token auth, validation, OpenAPI, CRUD, dashboard queries, SSE, static UI. MUST NOT enumerate sources, run dumps, copy files, or mutate repository objects. +- **Scheduler:** computes due occurrences and transactionally enqueues them. Exactly one supported instance with SQLite. +- **Worker:** leases executions, performs bounded blocking/async I/O, reports heartbeats/progress, publishes backups, restores, verifies, and reconciles. +- **Migrate/admin:** Alembic upgrades, repository init/check, secret-key rotation, offline recovery-bundle export/validation, and metadata backup/restore. +- **Frontend:** React/TypeScript responsive SPA using only generated client types. Production is same-origin behind one reverse proxy; FastAPI static serving MAY be used only if packaging tests prove it. + +## 7. Domain Model and Invariants + +| Entity | Required data | Key invariants | +| --- | --- | --- | +| User | UUID, username, Argon2id hash, state, timestamps | First run creates one admin; hash never returned | +| API Token | owner, hash, scopes, expiry/revocation | Plain token shown once | +| Secret | encrypted payload, key ID, purpose, version | API returns reference/status only | +| Repository | name, root, format version, compression/encryption policy, state | Root under configured allowlist; policy immutable after init | +| Source | name, kind, typed public config, secret refs, state | Type changes require replacement; test result stored separately | +| Job | source, repository, requested mode, excludes, retention, enabled | At most one active execution; archived refs remain readable | +| Schedule | job, cron, timezone, misfire grace, overlap policy, enabled | At most one per job; `(schedule, nominal time)` unique | +| Execution | job, trigger, state, attempt, lease, progress, reason, timestamps | Monotonic transitions; every terminal state has reason code | +| Backup | execution, manifest ID/digest, logical/stored bytes, integrity, pin/tombstone | Immutable after commit except lifecycle/integrity annotations | +| Restore | backup, selection, destination, overwrite policy, state/result | Durable execution with containment and audit | +| Audit Event | actor, action, resource, outcome, request ID, time | Append-only; secrets excluded | +| Notification Subscription | channel, event filters, destination config/secret, state | Webhook URL or email recipients are validated; secret fields are write-only | +| Notification Delivery | event ID, subscription, attempt, state, response class, timestamps | Durable retry history; payload contains no secret material | +| Idempotency Record | actor, key, operation, request digest, response resource | Same key+payload returns same result; mismatch is `409` | + +All times MUST be timezone-aware UTC RFC 3339 externally. Foreign keys, uniqueness, delete behavior, and indexes MUST be explicit. Public list APIs MUST use stable cursor pagination. Raw ORM objects MUST NOT serve as API/domain models. + +## 8. Typed Configuration + +| Input | Default | Rule | +| --- | --- | --- | +| `DATABASE_URL` | SQLite under data dir | Absolute, startup-validated; SQLite foreign keys, WAL, busy timeout enabled | +| `DATA_DIR` | `/var/lib/backup-tool` | Metadata and runtime state only | +| `REPOSITORY_ROOTS` | none | Required allowlist of canonical destination roots | +| `LOCAL_SOURCE_ROOTS` | none | Required allowlist for local sources | +| `MASTER_KEY_FILE` | none | Required before storing secrets; mode/owner checked | +| `PUBLIC_BASE_URL` | loopback URL | Used for cookies, redirects, and origin validation | +| `CORS_ORIGINS` | empty | Same-origin default; explicit exact origins only | +| `WORKER_CONCURRENCY` | `1` | V2 SQLite limit remains one active job globally by default | +| `MIN_FREE_BYTES` / `MIN_FREE_PERCENT` | documented safe values | Repository preflight and alert thresholds | +| `LOG_LEVEL` | `INFO` | Structured logs; runtime override may be typed setting | + +Environment MUST control deployment invariants. Typed DB settings control operator preferences such as default timezone, audit retention, verification cadence, SMTP transport, and notification retry limits. Notification destinations and credentials use typed subscription/secret resources, not arbitrary settings. Unknown settings MUST be rejected. + +## 9. Source Adapter Contract + +Every adapter implements `validate_config`, `probe`, `enumerate_entries`, `open_content`, `capture_consistency_metadata`, and `close`. Engine code MUST obtain source bytes only through this contract. + +Common rules: stream with backpressure; bounded timeouts/concurrency; normalized relative paths; common exclusion matcher; preserve directories, files, symlinks, and selected mode/mtime metadata; do not follow external symlinks; report unsupported special files explicitly; classify errors as auth, trust, unavailable, permission, timeout, source-changed, unsupported-entry, transient-I/O, or internal. + +| Adapter | Typed fields and behavior | +| --- | --- | +| Local | canonical root, cross-filesystem flag, symlink policy; root must be allowlisted | +| SSH/SFTP | host, port 22, username, remote root, auth secret, pinned known-host key; unknown/changed keys fail closed | +| PostgreSQL | host/socket, port 5432, database, username, secret, TLS, dump format; stream `pg_dump`, record tool/server versions; full logical snapshots only | +| MySQL | host/socket, port 3306, database, username, secret, TLS, consistency/locking policy; stream `mysqldump`; full logical snapshots only | + +A source probe validates connectivity, trust, permissions, required native tooling, and capability metadata but MUST NOT persist or return credentials. + +## 10. Repository and Backup Protocol + +```text +/repository.json + blobs/sha256// + manifests/.json + staging// + quarantine/ +``` + +`repository.json` declares repository UUID, format version, digest algorithm, compression, encryption, and creation metadata. Blobs are immutable and addressed by SHA-256 of plaintext content; stored representation MAY be compressed/encrypted and MUST use authenticated encryption when enabled. + +Each manifest is canonical JSON and binds format version, repository/source/job/execution IDs, requested/effective mode, UTC times, source consistency metadata, exclusion policy/version, normalized path, entry type, logical size, blob digest, selected portable metadata, link target, metadata-support flags, aggregate logical/stored counts, encryption key ID, and manifest digest/signature. + +- **Full:** enumerate and read every included file; verify every new stored object. +- **Incremental:** compare against latest compatible committed manifest and reuse blobs. Metadata shortcuts MAY skip reads only when adapter evidence is declared reliable; a full run remains the correctness fallback. +- Missing incremental baseline becomes effective full with reason `baseline_missing`, preserving the useful current behavior explicitly. +- Every manifest is complete. `parent_backup_id` MAY record lineage but MUST NOT be needed to restore. +- Empty sources fail unless job explicitly sets `allow_empty`; an unexpectedly empty previously nonempty source always requires operator-visible confirmation. +- Publication uses unique staging, capacity preflight, temp objects, fsync where supported, atomic rename, verification, metadata commit, and reconciliation markers. Wall-clock names never identify new backups. +- A backup is visible as committed only after its manifest and all referenced blobs are durable and verified. + +## 11. Execution, Concurrency, and Recovery + +```text +queued -> preparing -> running -> verifying -> committed + | | | |-> failed + | | |-> cancelling -> cancelled + | |-> failed + |-> cancelled +``` + +Enqueue returns a durable execution immediately. Only one nonterminal execution per job is allowed; duplicates return `409` with the active resource. Scheduler occurrence keys and client `Idempotency-Key` prevent duplicate publication. Worker claims use lease expiry and heartbeat. + +Retries reuse the execution ID and increment attempt only for classified transient errors. Cancellation is cooperative and cannot turn a committed backup into cancelled. Process loss before publication leaves recoverable staging; startup reconciliation either resumes a safe step, requeues, or fails with `worker_lost`. Errors expose stable code plus redacted operator text; sensitive diagnostics remain in access-controlled structured logs. + +Progress includes phase, files/bytes scanned, read, stored, deduplicated, warnings, throughput, and heartbeat time. Blocking filesystem, Paramiko, and subprocess work MUST run outside the web event loop. + +## 12. Scheduling + +Schedules MUST persist five-field cron, IANA timezone, enabled state, next occurrence, last enqueue outcome, misfire grace, and overlap policy. API writes MUST use semantic cron validation and take effect without restart. + +Defaults: 15-minute misfire grace, coalesce missed occurrences into one, prohibit overlap, skip nonexistent DST wall times, and execute repeated DST wall time once at its earliest instant. Enqueue is transactionally unique by schedule and nominal UTC occurrence. Disabled schedule or job does not enqueue. + +## 13. Retention, Deletion, and Garbage Collection + +Policies MAY combine keep-last, keep-for-days, daily, weekly, monthly, and pinned backups. Keep rules form a union: a backup survives if any rule keeps it. The newest committed backup is retained unless the operator explicitly purges the job. + +Retention MUST provide preview and apply operations. Deletion MUST first create a tombstone/audit event; GC removes unreferenced blobs only after a grace period and a second reference scan. Failed physical deletion remains `deletion_failed` and retryable. Unknown objects are quarantined, never automatically destroyed. Source/job archive does not delete backups. Explicit purge requires confirmation and produces a durable report. + +## 14. Restore, Download, Verification, and Reconciliation + +Restore MUST be durable and support dry run, path selection, destination allowlist, and overwrite policy `fail|skip|replace`. It MUST verify every blob before staged/atomic placement and write a result manifest. Unsafe absolute/traversal paths, device entries, and escaping symlinks are rejected. + +Verification modes are metadata-only and full-content; scheduled scrubs MAY run periodically. Backup integrity is `unverified|verified|degraded|corrupt`. V2.0 exposes verified restore only. V2.1 download streams a generated TAR from a committed manifest and never exposes repository paths. Reconciliation checks DB-to-storage and storage-to-DB, repairs known interrupted transitions, quarantines unknown data, and never silently erases evidence. + +## 15. HTTP API Contract + +Conventions: `/api/v2`; UUID strings; RFC 3339 UTC; JSON; cursor pagination; stable sort; `201` create; `202` enqueue; `204` successful idempotent delete where applicable; RFC 9457 problem details with stable `code`; `Idempotency-Key` on enqueue/destructive mutations; optimistic version/ETag on mutable config. + +| Resource | Required operations | +| --- | --- | +| Auth | setup, login/logout, session, password change; API-token create/list/revoke | +| Sources | list/create/get/update/archive; probe/test; credential rotate | +| Repositories | list/create/get; probe/capacity; verify/reconcile/GC | +| Jobs | list/create/get/update/archive; enqueue execution; retention preview | +| Schedules | get/create-or-replace/update/disable/delete; next occurrences | +| Executions | list/get/cancel/retry; event/log stream via SSE with polling fallback | +| Backups | list/get/pin/unpin; verify; tombstone/delete; purge preview; v2.1 download | +| Restores | list/create/get/cancel; result report | +| Notifications | subscriptions CRUD/test; event catalog; delivery history/retry | +| Dashboard | health/capacity summaries, active/recent executions, next runs, failures | +| Settings/Audit | typed settings get/update; paginated audit events | +| Administration | export/validate offline recovery bundle; rotate keys; metadata backup | +| Operations | `/livez`, `/readyz`, version/capabilities; admin actions remain authenticated | + +OpenAPI is committed/generated in CI; generated frontend client code MUST have no handwritten duplicate resource interfaces. + +## 16. Frontend Information Architecture + +Routes: Setup/Login, Dashboard, Sources, Repositories, Jobs, Job Detail/Schedule, Executions, Execution Detail, Backups, Backup Detail/Restore, Settings/Security, and Audit. + +Dashboard shows repository health/capacity, active queue, enabled jobs, next runs, backup count/logical/stored/deduplicated bytes, recent failures, and verification warnings. Source/repository forms are type-driven and include test actions. Job creation is a guided flow. Execution detail uses SSE progress and redacted logs. Backup detail exposes integrity, manifest summary, pin, verify, restore, and deletion preview; download appears only when v2.1 capability metadata enables it. Settings includes webhook/email subscriptions, test delivery, retry history, and recovery-bundle export. + +Every view defines loading, empty, validation, partial failure, offline/reconnect, expired-session, unsupported-capability, and responsive states. Destructive actions require impact preview and explicit confirmation. Unsupported or unreleased controls are absent, not disabled mockups. Keyboard navigation, visible focus, semantic labels, contrast, reduced motion, and screen-reader announcements are acceptance requirements. + +## 17. Security and Privacy + +- First-run admin auth is mandatory before non-loopback exposure. Passwords use Argon2id; browser sessions use Secure/HttpOnly/SameSite cookies plus CSRF protection. +- API tokens are hashed, scoped, expiring, and revocable. Same-origin is default; CORS is off unless exact origins are configured. +- Secret values are write-only, envelope-encrypted with an external master key, versioned, redacted everywhere, and independently rotatable. Encrypted installs MUST support export and validation of a recovery bundle encrypted under a separate recovery passphrase; the bundle is never stored beside the live master key. +- SSH host keys are pinned. TLS verification is on for database connections. Native dump commands receive secrets without command-line/process-list exposure. +- Repository and data directories are owned by a non-root service user with restrictive modes. Canonical path containment and symlink policy are enforced before I/O. +- Audit events cover auth, config, execution, restore, verification, deletion, GC, secret rotation, and migration. Logs, errors, manifests, metrics, and audits MUST NOT contain credentials. +- Backup encryption uses authenticated encryption; threat model states whether manifests, filenames, sizes, and deduplication equality are confidential. +- Webhook targets MUST pass scheme, DNS/IP, redirect, and private-network policy checks to prevent SSRF. Webhook signatures use a rotatable secret and stable event ID. SMTP credentials are write-only secrets. + +## 18. Reliability, Performance, and Operations + +Certified v2.0 target: one node, one worker, 100 jobs, 1 million entries or 10 TiB logical data per backup, and 100,000 cataloged backups. Release tests MUST run on a documented reference host, publish throughput and resource use, and reject material regression from the accepted baseline; no hardware-independent minimum throughput is claimed. All list APIs paginate. Enumeration and transfer stream with bounded memory. Disk preflight, minimum free space, per-source timeouts, chunk sizing, graceful shutdown, and rate limits are configurable. + +`/livez` proves process liveness. `/readyz` verifies migrations, DB access, role health, and required repository access. Structured logs correlate request, job, execution, backup, and restore IDs. Metrics include queue age, schedule lag, duration, throughput, logical/stored/deduplicated bytes, failure class, retries, lease expiry, capacity, verification failures, and GC outcomes. Alerts cover repeated failure, stale lease, corrupt backup, low capacity, missed schedule, and repository unavailability. + +Metadata backup/restore, master-key handling, repository recovery, interrupted-upgrade rollback, and full disaster recovery require runbooks. Graceful shutdown stops new claims and leaves work at a restart-safe checkpoint. + +## 19. Deployment and Packaging + +One versioned OCI image exposes `web`, `scheduler`, `worker`, and `migrate` commands. Compose runs same-origin reverse proxy/UI, one web, one scheduler, one worker, persistent metadata, and one or more mounted repositories. Images run non-root, pin dependencies, include only required dump clients, publish SBOM/provenance, and pass persistence/permission health tests. + +Python 3.12 LTS and a current Node LTS SHOULD be the initial build baseline; exact versions are locked in implementation planning. Production MUST NOT use reload mode. Multiple web processes are allowed only when neither scheduler nor worker starts inside them. + +## 20. Compatibility and Migration + +V2 is a clean installation. It provides no `/api/v1`, compatibility shim, v1 database importer, legacy-directory reader, or payload converter. Existing v1 installations remain separate and MUST be decommissioned or archived by their operator; v2 never opens or mutates their database or backup directories. + +Migration within v2 uses forward Alembic revisions and repository-format migrations with preflight, metadata/repository backup, resumability, and explicit rollback instructions. Old integer IDs, timestamp directories, status codes, arbitrary settings, and aggregate checksums are not v2 contracts. + +## 21. Delivery Sequence + +1. **Protocol foundation:** repository/manifest spec, Alembic baseline, auth/secrets, local adapter, durable queue/leases, full backup, verification, restore, and fault injection. +2. **V2.0 complete product:** deduplicating incremental, exclusions, schedules, retention/GC, reconcile, strict SSH/SFTP, generated client/UI, optional repository encryption, offline recovery bundle, signed webhooks, email, delivery history, and certified scale tests. +3. **V2.1 sources and extraction:** PostgreSQL/MySQL dumps, native-tool/version checks, integration fixtures, resource limits, and generated TAR downloads. +4. **Operational hardening:** periodic scrubs/restore drills, performance tuning, richer notification templates, and optional PostgreSQL metadata design. + +Each phase is releasable only if its controls are truthful in API capability metadata, UI, and documentation. + +## 22. Acceptance Invariants + +1. Committed backup implies durable verified manifest and all referenced blobs. +2. Duplicate delivery of one schedule occurrence produces at most one committed backup. +3. Concurrent starts cannot create two active executions for one job. +4. Crash at every state transition leaves valid committed data or recoverable/quarantined staging. +5. SSH/database bytes flow only through their adapter; no local-path fallback exists. +6. Every committed backup restores without another backup manifest. +7. Retention cannot make any retained backup unrestorable. +8. Source/job deletion cannot silently delete backup payloads. +9. Failed physical deletion remains visible and retryable. +10. Credentials never appear in read APIs, OpenAPI examples, logs, errors, manifests, audits, or metrics. +11. Unknown/changed SSH host keys fail before data transfer. +12. Same normalized paths and rules produce identical exclusions across adapters. +13. Disabled jobs cannot be enqueued manually or by schedule. +14. Schedule create/update/delete takes effect without restart and has deterministic DST/misfire behavior. +15. V2 contains no code path that opens, imports, converts, or mutates v1 metadata or payload formats. +16. Frontend types/routes are generated from and match committed OpenAPI. +17. Restore tests cover empty files, Unicode, large files, symlinks, permissions, source deletions, and interrupted writes. +18. Blob, manifest, or authentication-tag corruption is detected before restore publication. +19. Empty or unexpectedly vanished sources cannot silently replace a good backup. +20. Backup/source paths cannot escape configured allowlists through traversal or symlinks. +21. Recovery-bundle validation proves required keys are present without exposing plaintext key material; restore works after simulated host loss. +22. Webhooks are signed and delivered at least once with stable event IDs; webhook and email transient failures retry and remain visible in delivery history. + +## 23. Required Test Matrix + +Unit: state transitions, exclusion semantics, manifest canonicalization, retention selection, cron/DST, error mapping, secret redaction, event filtering, and notification retries. Contract: OpenAPI golden, problem details, pagination, idempotency, ETag conflicts, webhook schema/signature, and generated client compile. Integration: each adapter, DB migrations, queue leases, crash recovery, repository publish, GC, restore, recovery bundle, SMTP, webhook delivery, and auth/CSRF. Fault injection: process kill, disk full, permission loss, network drop, source mutation, DB lock, checksum corruption, deletion failure, SMTP rejection, and webhook timeout. E2E: setup through restore, notification history, recovery export, and deletion preview in production-like Compose. Security: path traversal, symlink escape, host-key change, token scope/revocation, webhook SSRF/redirect, signature rotation, and secret leakage scan. Performance: certified scale targets with bounded memory and published reference-host throughput. + +## 24. Resolved Product Decisions + +| Area | Approved choice | +| --- | --- | +| Source release | V2.0 includes local and SSH/SFTP; PostgreSQL/MySQL ship in v2.1 | +| Encryption | Operator-selected at repository creation; policy remains immutable | +| Scale | Certify 100 jobs, 1M entries or 10 TiB per backup, and 100k backups on a published reference host | +| Key recovery | Passphrase-protected offline recovery bundle with export and validation workflows | +| Backward compatibility | None: no importer, legacy reader/converter, or v1 API shim | +| Download | Verified restore in v2.0; generated TAR download in v2.1 | +| Notifications | Signed webhooks and email both ship in v2.0 for all operational event families | +| Delivery | Retry transient failures, preserve delivery history, and use stable webhook event IDs for at-least-once delivery | + +## 25. Risks and Mitigations + +| Risk | Mitigation | +| --- | --- | +| Content-addressed storage adds complexity | Specify protocol first; golden repository fixtures; crash/corruption tests | +| SQLite limits concurrency | State single-node limits; one scheduler/worker; design claims behind repository interface | +| Metadata shortcuts miss changed content | Declare trust rules; periodic/full modes; verification and scrub | +| Encryption key loss destroys recoverability | External key backup runbook, startup checks, explicit threat model | +| Notification endpoints create SSRF, spam, and secret risks | Validate targets, sign webhooks, constrain retries/rates, protect SMTP credentials | +| DB dumps vary by server/client | Record versions/options; integration compatibility matrix; fail unsupported pairs | +| Dedup leaks equality/size information | Document threat; repository isolation; encryption policy fixed at creation | +| Scope expands before integrity works | Enforce delivery sequence and capability truthfulness | + +## 26. Verification + +The design artifact itself is valid when it contains normative decisions, current-state treatment, architecture, domain model, protocol, state machine, API/UI, security, operations, migration, tests, acceptance invariants, and open decisions: + +```bash +python - <<'PY' +from pathlib import Path +p = Path('docs/superpowers/specs/2026-07-27-backup-tool-reimplementation-design.md') +s = p.read_text() +required = ['Current-State Audit', 'Normative Decisions', 'System Architecture', + 'Repository and Backup Protocol', 'Acceptance Invariants', + 'Compatibility and Migration', 'Resolved Product Decisions'] +assert all(x in s for x in required) +assert s.count('MUST') >= 20 +print('design-spec: OK') +PY +``` + +**Next step:** create the implementation plan beginning with repository/manifest protocol fixtures and failure-oriented acceptance tests—not framework scaffolding. diff --git a/frontend/Dockerfile b/frontend/Dockerfile index b787766..8b6e090 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,20 +1,19 @@ -# Stage 1: Build -FROM node:20-alpine AS builder +# syntax=docker/dockerfile:1 +FROM node:22.17.1-alpine@sha256:5539840ce9d013fa13e3b9814c9353024be7ac75aca5db6d039504a56c04ea59 AS builder WORKDIR /app - COPY package*.json ./ -RUN npm install - +RUN npm ci COPY . . RUN npm run build -# Stage 2: Serve -FROM nginx:alpine +FROM nginx:1.29.7-alpine@sha256:e7257f1ef28ba17cf7c248cb8ccf6f0c6e0228ab9c315c152f9c203cd34cf6d1 COPY --from=builder /app/dist /usr/share/nginx/html -COPY nginx.conf /etc/nginx/conf.d/default.conf - -EXPOSE 80 +COPY nginx.conf /etc/nginx/nginx.conf +RUN chown -R 10001:0 /usr/share/nginx/html /var/cache/nginx /var/run /etc/nginx && \ + chmod -R g=u /usr/share/nginx/html /var/cache/nginx /var/run /etc/nginx +USER 10001:0 +EXPOSE 8080 CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/e2e/operator.spec.ts b/frontend/e2e/operator.spec.ts new file mode 100644 index 0000000..5d21177 --- /dev/null +++ b/frontend/e2e/operator.spec.ts @@ -0,0 +1,13 @@ +import { expect, test } from "@playwright/test"; + +test("renders an accessible authentication fallback at a narrow viewport", async ({ page }) => { + await page.setViewportSize({ width: 375, height: 667 }); + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.goto("/"); + await expect(page.getByRole("main")).toBeVisible(); + await expect(page.getByRole("heading", { name: "Sign in" })).toBeVisible(); + const username = page.getByLabel("Username"); + await expect(username).toBeVisible(); + await username.focus(); + await expect(username).toBeFocused(); +}); diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 4e014b8..a5529d2 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -1,19 +1,50 @@ -server { - listen 80; - server_name localhost; - root /usr/share/nginx/html; - index index.html; +pid /tmp/nginx.pid; - location / { - try_files $uri $uri/ /index.html; +worker_processes auto; +error_log /dev/stderr warn; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + access_log /dev/stdout; + sendfile on; + + upstream backup_tool_web { + server unix:/run/backup-tool/web.sock; } - location /api/ { - proxy_pass http://backend:8000/api/; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection 'upgrade'; - proxy_set_header Host $host; - proxy_cache_bypass $http_upgrade; + server { + listen 8080; + server_name _; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://backup_tool_web; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location = /livez { + proxy_pass http://backup_tool_web; + } + + location = /readyz { + proxy_pass http://backup_tool_web; + } + + location = /metrics { + proxy_pass http://backup_tool_web; + } } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..e77def9 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3734 @@ +{ + "name": "backup-tool-frontend", + "version": "2.0.0-dev.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "backup-tool-frontend", + "version": "2.0.0-dev.0", + "dependencies": { + "react": "19.2.8", + "react-dom": "19.2.8" + }, + "devDependencies": { + "@playwright/test": "^1.57.0", + "@testing-library/jest-dom": "7.0.0", + "@testing-library/react": "16.3.2", + "@types/react": "19.2.17", + "@types/react-dom": "19.2.3", + "@vitejs/plugin-react": "6.0.4", + "autoprefixer": "10.5.4", + "jsdom": "30.0.0", + "postcss": "8.5.23", + "tailwindcss": "3.4.19", + "typescript": "7.0.2", + "vite": "8.1.5", + "vitest": "4.1.10" + }, + "engines": { + "node": "22.17.1" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.5.tgz", + "integrity": "sha512-mbhpPMmnw/kwW19aRNmSUl1QzLbdGo1SCuE49BT98MNwqF6zaHb3o2owssFc/PEO/4t2UjqtCNwocuDtJornzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.2.1", + "@csstools/css-color-parser": "^4.1.9", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.0.tgz", + "integrity": "sha512-UJLfKXBhrc8i1vH2eJXuYQMwlsLKWFw3O+CPqXSuVEiikeAim3UgrfWX0k4tA/X8cRFM8iZ7OaqBokFGbYusdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", + "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", + "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@playwright/test": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", + "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/dom/node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@testing-library/jest-dom": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.0.tgz", + "integrity": "sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.4.tgz", + "integrity": "sha512-XcCQz0TBpBgljhj0gMuuDj49i6Ytqh5q1osT/Gp5uAVJUCTWxyskk/l1jwYYiu2xcNHHipdMz40EGfM1VdamVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/autoprefixer": { + "version": "10.5.4", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz", + "integrity": "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.6", + "caniuse-lite": "^1.0.30001806", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.5", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.5.tgz", + "integrity": "sha512-xJo6a6YZnwZfnyGmQKWMbVOcii7XRibjOskRh+WJ9UHQoX16xrQrcIgAMQOzfvs8XiLMx6ih/fsLPF73iY2D1A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.396", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.396.tgz", + "integrity": "sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/jsdom": { + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.0.tgz", + "integrity": "sha512-JQHfRGmmKmaZoUAvIgff5jjG/0SzTQlGz8c7t72KzBzo8ZULEjAjnYE0sNwBOUA4QtWwYE2xoYitg8NFsmiYxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.2.5", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.6", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.7.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/playwright": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", + "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", + "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/postcss": { + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz", + "integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.9" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz", + "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", + "integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", + "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/frontend/package.json b/frontend/package.json index 3518745..b79dcbd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,36 +1,38 @@ { "name": "backup-tool-frontend", "private": true, - "version": "0.0.1", + "version": "2.0.0-dev.0", "type": "module", + "packageManager": "npm@10.9.2", + "engines": { + "node": "22.17.1" + }, "scripts": { "dev": "vite", + "api:generate": "python3 ../tools/generate_api_client.py --input ../openapi/v2.json --output src/api/generated/client.ts", + "api:check": "python3 ../tools/generate_api_client.py --input ../openapi/v2.json --output src/api/generated/client.ts --check", + "typecheck": "tsc --noEmit", + "test": "vitest run --passWithNoTests", "build": "tsc && vite build", "preview": "vite preview" }, "dependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-router-dom": "^6.21.0", - "@tanstack/react-query": "^5.17.0", - "axios": "^1.6.5", - "react-hook-form": "^7.49.0", - "recharts": "^2.10.0", - "lucide-react": "^0.303.0", - "clsx": "^2.1.0" + "react": "19.2.8", + "react-dom": "19.2.8" }, "devDependencies": { - "@types/react": "^18.2.43", - "@types/react-dom": "^18.2.17", - "@vitejs/plugin-react": "^4.2.1", - "autoprefixer": "^10.4.16", - "postcss": "^8.4.32", - "tailwindcss": "^3.4.0", - "typescript": "^5.2.2", - "vite": "^5.0.8", - "vitest": "^1.1.0", - "@testing-library/react": "^14.1.0", - "@testing-library/jest-dom": "^6.2.0", - "jsdom": "^23.0.0" + "@playwright/test": "^1.57.0", + "@testing-library/jest-dom": "7.0.0", + "@testing-library/react": "16.3.2", + "@types/react": "19.2.17", + "@types/react-dom": "19.2.3", + "@vitejs/plugin-react": "6.0.4", + "autoprefixer": "10.5.4", + "jsdom": "30.0.0", + "postcss": "8.5.23", + "tailwindcss": "3.4.19", + "typescript": "7.0.2", + "vite": "8.1.5", + "vitest": "4.1.10" } } diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..2ca97b7 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + use: { + baseURL: "http://127.0.0.1:4173", + launchOptions: { executablePath: "/usr/sbin/chromium" }, + }, + webServer: { + command: "npm run dev -- --host 127.0.0.1 --port 4173", + port: 4173, + reuseExistingServer: false, + }, +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx deleted file mode 100644 index 02ff4f4..0000000 --- a/frontend/src/App.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { BrowserRouter, Routes, Route } from 'react-router-dom'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { Layout } from './components/Layout'; -import { Dashboard } from './pages/Dashboard'; -import { Backups } from './pages/Backups'; -import { Settings } from './pages/Settings'; - -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - staleTime: 5 * 60 * 1000, - retry: 1, - }, - }, -}); - -function App() { - return ( - - - - - } /> - } /> - } /> - - - - - ); -} - -export default App; diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts deleted file mode 100644 index 3038927..0000000 --- a/frontend/src/api/client.ts +++ /dev/null @@ -1,62 +0,0 @@ -import axios from 'axios'; - -const API_BASE_URL = '/api'; - -export const apiClient = axios.create({ - baseURL: API_BASE_URL, - headers: { - 'Content-Type': 'application/json', - }, -}); - -export interface BackupSource { - id: string; - name: string; - type: string; - config: Record; - created_at: string; - updated_at: string; -} - -export interface BackupJob { - id: string; - source_id: string; - status: 'pending' | 'running' | 'completed' | 'failed'; - started_at: string | null; - completed_at: string | null; - error_message: string | null; - created_at: string; -} - -export interface DashboardStats { - total_sources: number; - total_jobs: number; - completed_jobs: number; - failed_jobs: number; - pending_jobs: number; -} - -export const sourcesApi = { - getAll: () => apiClient.get('/sources'), - getById: (id: string) => apiClient.get(`/sources/${id}`), - create: (data: Omit) => - apiClient.post('/sources', data), - update: (id: string, data: Partial) => - apiClient.put(`/sources/${id}`, data), - delete: (id: string) => apiClient.delete(`/sources/${id}`), -}; - -export const jobsApi = { - getAll: () => apiClient.get('/jobs'), - getById: (id: string) => apiClient.get(`/jobs/${id}`), - create: (sourceId: string) => - apiClient.post('/jobs', { source_id: sourceId }), - delete: (id: string) => apiClient.delete(`/jobs/${id}`), - getLogs: (id: string) => apiClient.get(`/jobs/${id}/logs`), -}; - -export const dashboardApi = { - getStats: () => apiClient.get('/dashboard/stats'), - getRecentJobs: (limit: number = 10) => - apiClient.get(`/dashboard/recent-jobs?limit=${limit}`), -}; diff --git a/frontend/src/api/generated/.gitkeep b/frontend/src/api/generated/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/api/generated/client.ts b/frontend/src/api/generated/client.ts new file mode 100644 index 0000000..f05a9f9 --- /dev/null +++ b/frontend/src/api/generated/client.ts @@ -0,0 +1,567 @@ +/* eslint-disable */ +/* + * Generated by tools/generate_api_client.py from openapi/v2.json. + * Do not edit this file directly. Run `npm --prefix frontend run api:generate`. + */ + +export interface Components { + schemas: { + AuditList: { items : Array; next_cursor : string | null }; + AuditSummary: { action : string; created_at : string; details : Record; id : string; outcome : string; request_id : string; resource_id : string | null; resource_type : string }; + AuthenticatedUser: { id : string; username : string }; + BackupDeletePreview: { backup_id : string; destructive_action : string; eligible : boolean; reason : string | null }; + BackupList: { items : Array }; + BackupSummary: { created_at : string; execution_id : string; id : string; integrity : string; logical_bytes : number; manifest_id : string; pinned : boolean; stored_bytes : number; tombstoned_at : string | null }; + EmailSettingsInput: { host : string; max_attempts?: number; password : string; port?: number; rate_limit_per_minute?: number; sender : string; username : string }; + ExecutionList: { items : Array }; + ExecutionSummary: { attempt : number; id : string; progress : Record; reason_code : string | null; revision : number; state : string }; + HTTPValidationError: { detail?: Array }; + JobInput: { allow_empty?: boolean; enabled?: boolean; exclusions?: Array; name : string; repository_id : string; requested_mode?: string; retention?: Record; source_id : string }; + JobList: { items : Array }; + JobSummary: { enabled : boolean; id : string; name : string; repository_id : string; requested_mode : string; schedule : Components['schemas']["ScheduleSummary"] | null; source_id : string; state : string }; + LocalSourceInput: { kind : string; name : string; public_config : Record }; + LoginInput: { password : string; username : string }; + NotificationAttemptList: { items : Array }; + NotificationAttemptSummary: { completed_at : string | null; diagnostic : string | null; number : number; outcome : string; response_class : string | null; started_at : string }; + NotificationDeliveryList: { items : Array }; + NotificationDeliverySummary: { attempt_count : number; due_at : string; event_id : string; id : string; response_class : string | null; response_summary : string | null; state : string; subscription_id : string; terminal_reason : string | null }; + NotificationSubscriptionInput: { channel : "webhook" | "email"; destination : Record; event_filters : Array; rate_limit_per_minute?: number; signing_secret?: string | null }; + NotificationSubscriptionList: { items : Array }; + NotificationSubscriptionPatch: { destination?: Record | null; event_filters?: Array | null; rate_limit_per_minute?: number | null; state?: "active" | "disabled" | "archived" | null }; + NotificationSubscriptionSummary: { channel : string; created_at : string; destination : Record; event_filters : Array; id : string; rate_limit_per_minute : number; revision : number; state : string; updated_at : string }; + RecoveryStatus: { encrypted_repository_count : number; recovery_mode : string; runbook : string }; + RepositoryInput: { compression?: string; encryption?: string; name : string; relative_path : string }; + RepositoryList: { items : Array }; + RepositoryPatch: { compression?: string | null; encryption?: string | null }; + RepositorySummary: { compression : string; encryption : string; format_version : number; id : string; name : string; state : string }; + RestoreInput: { destination : string; dry_run?: boolean; overwrite_policy?: string; selection?: Array }; + SSHSourceInput: { kind : string; name : string; private_key_secret_id : string; public_config : Components['schemas']["SSHSourcePublicConfig"] }; + SSHSourcePublicConfig: { host_key : string; hostname : string; port : number; root : string; username : string }; + ScheduleInput: { cron : string; enabled?: boolean; misfire_grace_seconds?: number; timezone : string }; + ScheduleSummary: { cron : string; enabled : boolean; id : string; last_enqueue_outcome : string | null; next_nominal_at : string | null; timezone : string }; + SecretInput: { purpose : string; value : string }; + SessionUser: { id : string; state : string; username : string }; + SetupInput: { bootstrap_secret?: string | null; password : string; username : string }; + SigningKeyRotateInput: { overlap_seconds?: number; secret : string }; + SourceList: { items : Array }; + SourceSummary: { id : string; kind : string; name : string; public_config : Record; state : string }; + TokenInput: { expires_at?: string | null; scopes : Array }; + UserPatch: { state : string }; + ValidationError: { ctx?: Record; input?: unknown; loc : Array; msg : string; type : string }; + }; +} +export type CreateSecretParams = { body : Components['schemas']["SecretInput"] }; + +export type GetUserParams = { path: { user_id: string } }; + +export type PatchUserParams = { path: { user_id: string }; body : Components['schemas']["UserPatch"] }; + +export type ListAuditParams = { query?: { limit?: number; cursor?: string | null } }; + +export type LoginParams = { body : Components['schemas']["LoginInput"] }; + +export type CreateTokenParams = { body : Components['schemas']["TokenInput"] }; + +export type RevokeTokenParams = { path: { token_id: string } }; + +export type GetBackupParams = { path: { backup_id: string } }; + +export type BackupDeletePreviewParams = { path: { backup_id: string } }; + +export type CreateRestoreParams = { path: { backup_id: string }; body : Components['schemas']["RestoreInput"] }; + +export type VerifyBackupParams = { path: { backup_id: string } }; + +export type GetExecutionParams = { path: { execution_id: string } }; + +export type CancelExecutionParams = { path: { execution_id: string } }; + +export type ExecutionEventsParams = { path: { execution_id: string } }; + +export type RetryExecutionParams = { path: { execution_id: string } }; + +export type CreateJobParams = { body : Components['schemas']["JobInput"] }; + +export type EnqueueExecutionParams = { path: { job_id: string } }; + +export type DeleteScheduleParams = { path: { job_id: string } }; + +export type GetScheduleParams = { path: { job_id: string } }; + +export type PatchScheduleParams = { path: { job_id: string }; body : Components['schemas']["ScheduleInput"] }; + +export type CreateScheduleParams = { path: { job_id: string }; body : Components['schemas']["ScheduleInput"] }; + +export type ListNotificationDeliveriesParams = { query?: { limit?: number } }; + +export type ListNotificationAttemptsParams = { path: { delivery_id: string } }; + +export type RetryNotificationDeliveryParams = { path: { delivery_id: string } }; + +export type PutNotificationEmailSettingsParams = { body : Components['schemas']["EmailSettingsInput"] }; + +export type CreateNotificationSubscriptionParams = { body : Components['schemas']["NotificationSubscriptionInput"] }; + +export type GetNotificationSubscriptionParams = { path: { subscription_id: string } }; + +export type PatchNotificationSubscriptionParams = { path: { subscription_id: string }; body : Components['schemas']["NotificationSubscriptionPatch"] }; + +export type RotateNotificationSigningKeyParams = { path: { subscription_id: string }; body : Components['schemas']["SigningKeyRotateInput"] }; + +export type TestNotificationSubscriptionParams = { path: { subscription_id: string } }; + +export type CreateRepositoryParams = { body : Components['schemas']["RepositoryInput"] }; + +export type GetRepositoryParams = { path: { repository_id: string } }; + +export type PatchRepositoryParams = { path: { repository_id: string }; body : Components['schemas']["RepositoryPatch"] }; + +export type InspectRepositoryEndpointParams = { path: { repository_id: string } }; + +export type GetRestoreParams = { path: { restore_id: string } }; + +export type SetupParams = { body : Components['schemas']["SetupInput"] }; + +export type CreateSourceParams = { body : Components['schemas']["LocalSourceInput"] | Components['schemas']["SSHSourceInput"] }; + +export type ArchiveSourceParams = { path: { source_id: string } }; + +export type ProbeSourceParams = { path: { source_id: string } }; + +export type RequestOptions = Omit; + +export type Problem = { + type: string; + title: string; + status: number; + detail: string; + instance: string; + code: string; +}; + +export class ApiError extends Error { + readonly status: number; + readonly problem?: Problem; + + constructor(status: number, problem?: Problem) { + super(problem?.detail ?? `Request failed with status ${status}.`); + this.name = "ApiError"; + this.status = status; + this.problem = problem; + } +} + +export function isApiError(error: unknown): error is ApiError { + return error instanceof ApiError; +} + +function appendQuery(search: URLSearchParams, query: Record | undefined): void { + if (!query) return; + for (const [key, value] of Object.entries(query)) { + if (value === undefined || value === null) continue; + for (const item of Array.isArray(value) ? value : [value]) search.append(key, String(item)); + } +} + +async function request( + url: URL, + method: string, + options: RequestOptions, + body?: unknown, +): Promise { + const headers = new Headers(options.headers); + if (body !== undefined && !headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + const response = await fetch(url, { + ...options, + method, + headers, + credentials: options.credentials ?? "same-origin", + body: body === undefined ? undefined : JSON.stringify(body), + }); + if (response.status === 204) return undefined as T; + const contentType = response.headers.get("content-type") ?? ""; + const isJson = contentType.includes("application/json") + || contentType.includes("application/problem+json"); + const payload: unknown = isJson ? await response.json() : undefined; + if (!response.ok) throw new ApiError(response.status, payload as Problem | undefined); + return payload as T; +} + +export class BackupToolClient { + constructor(readonly baseUrl = window.location.origin) {} + + async listSecrets(options: RequestOptions = {}): Promise>> { + const url = new URL("/api/v2/admin/secrets", this.baseUrl); + return request>>( + url, "GET", options + ); + } + + async createSecret(params: CreateSecretParams, options: RequestOptions = {}): Promise> { + const url = new URL("/api/v2/admin/secrets", this.baseUrl); + return request>( + url, "POST", options, params.body + ); + } + + async getUser(params: GetUserParams, options: RequestOptions = {}): Promise> { + const url = new URL("/api/v2/admin/users/{user_id}".replace("{user_id}", encodeURIComponent(String(params.path.user_id))), this.baseUrl); + return request>( + url, "GET", options + ); + } + + async patchUser(params: PatchUserParams, options: RequestOptions = {}): Promise> { + const url = new URL("/api/v2/admin/users/{user_id}".replace("{user_id}", encodeURIComponent(String(params.path.user_id))), this.baseUrl); + return request>( + url, "PATCH", options, params.body + ); + } + + async listAudit(params: ListAuditParams, options: RequestOptions = {}): Promise { + const url = new URL("/api/v2/audit", this.baseUrl); + appendQuery(url.searchParams, params.query); + return request( + url, "GET", options + ); + } + + async login(params: LoginParams, options: RequestOptions = {}): Promise { + const url = new URL("/api/v2/auth/login", this.baseUrl); + return request( + url, "POST", options, params.body + ); + } + + async logout(options: RequestOptions = {}): Promise { + const url = new URL("/api/v2/auth/logout", this.baseUrl); + return request( + url, "POST", options + ); + } + + async getSession(options: RequestOptions = {}): Promise { + const url = new URL("/api/v2/auth/session", this.baseUrl); + return request( + url, "GET", options + ); + } + + async createToken(params: CreateTokenParams, options: RequestOptions = {}): Promise { + const url = new URL("/api/v2/auth/tokens", this.baseUrl); + return request( + url, "POST", options, params.body + ); + } + + async revokeToken(params: RevokeTokenParams, options: RequestOptions = {}): Promise { + const url = new URL("/api/v2/auth/tokens/{token_id}".replace("{token_id}", encodeURIComponent(String(params.path.token_id))), this.baseUrl); + return request( + url, "DELETE", options + ); + } + + async listBackups(options: RequestOptions = {}): Promise { + const url = new URL("/api/v2/backups", this.baseUrl); + return request( + url, "GET", options + ); + } + + async getBackup(params: GetBackupParams, options: RequestOptions = {}): Promise { + const url = new URL("/api/v2/backups/{backup_id}".replace("{backup_id}", encodeURIComponent(String(params.path.backup_id))), this.baseUrl); + return request( + url, "GET", options + ); + } + + async backupDeletePreview(params: BackupDeletePreviewParams, options: RequestOptions = {}): Promise { + const url = new URL("/api/v2/backups/{backup_id}/delete-preview".replace("{backup_id}", encodeURIComponent(String(params.path.backup_id))), this.baseUrl); + return request( + url, "GET", options + ); + } + + async createRestore(params: CreateRestoreParams, options: RequestOptions = {}): Promise> { + const url = new URL("/api/v2/backups/{backup_id}/restores".replace("{backup_id}", encodeURIComponent(String(params.path.backup_id))), this.baseUrl); + return request>( + url, "POST", options, params.body + ); + } + + async verifyBackup(params: VerifyBackupParams, options: RequestOptions = {}): Promise { + const url = new URL("/api/v2/backups/{backup_id}/verify".replace("{backup_id}", encodeURIComponent(String(params.path.backup_id))), this.baseUrl); + return request( + url, "POST", options + ); + } + + async listExecutions(options: RequestOptions = {}): Promise { + const url = new URL("/api/v2/executions", this.baseUrl); + return request( + url, "GET", options + ); + } + + async getExecution(params: GetExecutionParams, options: RequestOptions = {}): Promise { + const url = new URL("/api/v2/executions/{execution_id}".replace("{execution_id}", encodeURIComponent(String(params.path.execution_id))), this.baseUrl); + return request( + url, "GET", options + ); + } + + async cancelExecution(params: CancelExecutionParams, options: RequestOptions = {}): Promise> { + const url = new URL("/api/v2/executions/{execution_id}/cancel".replace("{execution_id}", encodeURIComponent(String(params.path.execution_id))), this.baseUrl); + return request>( + url, "POST", options + ); + } + + /** EventSource transport; intentionally not a JSON fetch Promise. */ + executionEventsUrl(params: ExecutionEventsParams): URL { + const url = new URL("/api/v2/executions/{execution_id}/events".replace("{execution_id}", encodeURIComponent(String(params.path.execution_id))), this.baseUrl); + return url; + } + + async retryExecution(params: RetryExecutionParams, options: RequestOptions = {}): Promise> { + const url = new URL("/api/v2/executions/{execution_id}/retry".replace("{execution_id}", encodeURIComponent(String(params.path.execution_id))), this.baseUrl); + return request>( + url, "POST", options + ); + } + + async listJobs(options: RequestOptions = {}): Promise { + const url = new URL("/api/v2/jobs", this.baseUrl); + return request( + url, "GET", options + ); + } + + async createJob(params: CreateJobParams, options: RequestOptions = {}): Promise> { + const url = new URL("/api/v2/jobs", this.baseUrl); + return request>( + url, "POST", options, params.body + ); + } + + async enqueueExecution(params: EnqueueExecutionParams, options: RequestOptions = {}): Promise> { + const url = new URL("/api/v2/jobs/{job_id}/executions".replace("{job_id}", encodeURIComponent(String(params.path.job_id))), this.baseUrl); + return request>( + url, "POST", options + ); + } + + async deleteSchedule(params: DeleteScheduleParams, options: RequestOptions = {}): Promise { + const url = new URL("/api/v2/jobs/{job_id}/schedule".replace("{job_id}", encodeURIComponent(String(params.path.job_id))), this.baseUrl); + return request( + url, "DELETE", options + ); + } + + async getSchedule(params: GetScheduleParams, options: RequestOptions = {}): Promise> { + const url = new URL("/api/v2/jobs/{job_id}/schedule".replace("{job_id}", encodeURIComponent(String(params.path.job_id))), this.baseUrl); + return request>( + url, "GET", options + ); + } + + async patchSchedule(params: PatchScheduleParams, options: RequestOptions = {}): Promise> { + const url = new URL("/api/v2/jobs/{job_id}/schedule".replace("{job_id}", encodeURIComponent(String(params.path.job_id))), this.baseUrl); + return request>( + url, "PATCH", options, params.body + ); + } + + async createSchedule(params: CreateScheduleParams, options: RequestOptions = {}): Promise> { + const url = new URL("/api/v2/jobs/{job_id}/schedule".replace("{job_id}", encodeURIComponent(String(params.path.job_id))), this.baseUrl); + return request>( + url, "POST", options, params.body + ); + } + + async listNotificationDeliveries(params: ListNotificationDeliveriesParams, options: RequestOptions = {}): Promise { + const url = new URL("/api/v2/notifications/deliveries", this.baseUrl); + appendQuery(url.searchParams, params.query); + return request( + url, "GET", options + ); + } + + async listNotificationAttempts(params: ListNotificationAttemptsParams, options: RequestOptions = {}): Promise { + const url = new URL("/api/v2/notifications/deliveries/{delivery_id}/attempts".replace("{delivery_id}", encodeURIComponent(String(params.path.delivery_id))), this.baseUrl); + return request( + url, "GET", options + ); + } + + async retryNotificationDelivery(params: RetryNotificationDeliveryParams, options: RequestOptions = {}): Promise> { + const url = new URL("/api/v2/notifications/deliveries/{delivery_id}/retry".replace("{delivery_id}", encodeURIComponent(String(params.path.delivery_id))), this.baseUrl); + return request>( + url, "POST", options + ); + } + + async getNotificationEmailSettings(options: RequestOptions = {}): Promise> { + const url = new URL("/api/v2/notifications/email-settings", this.baseUrl); + return request>( + url, "GET", options + ); + } + + async putNotificationEmailSettings(params: PutNotificationEmailSettingsParams, options: RequestOptions = {}): Promise> { + const url = new URL("/api/v2/notifications/email-settings", this.baseUrl); + return request>( + url, "PUT", options, params.body + ); + } + + async notificationCatalog(options: RequestOptions = {}): Promise> { + const url = new URL("/api/v2/notifications/event-catalog", this.baseUrl); + return request>( + url, "GET", options + ); + } + + async listNotificationSubscriptions(options: RequestOptions = {}): Promise { + const url = new URL("/api/v2/notifications/subscriptions", this.baseUrl); + return request( + url, "GET", options + ); + } + + async createNotificationSubscription(params: CreateNotificationSubscriptionParams, options: RequestOptions = {}): Promise> { + const url = new URL("/api/v2/notifications/subscriptions", this.baseUrl); + return request>( + url, "POST", options, params.body + ); + } + + async getNotificationSubscription(params: GetNotificationSubscriptionParams, options: RequestOptions = {}): Promise> { + const url = new URL("/api/v2/notifications/subscriptions/{subscription_id}".replace("{subscription_id}", encodeURIComponent(String(params.path.subscription_id))), this.baseUrl); + return request>( + url, "GET", options + ); + } + + async patchNotificationSubscription(params: PatchNotificationSubscriptionParams, options: RequestOptions = {}): Promise> { + const url = new URL("/api/v2/notifications/subscriptions/{subscription_id}".replace("{subscription_id}", encodeURIComponent(String(params.path.subscription_id))), this.baseUrl); + return request>( + url, "PATCH", options, params.body + ); + } + + async rotateNotificationSigningKey(params: RotateNotificationSigningKeyParams, options: RequestOptions = {}): Promise> { + const url = new URL("/api/v2/notifications/subscriptions/{subscription_id}/signing-keys/rotate".replace("{subscription_id}", encodeURIComponent(String(params.path.subscription_id))), this.baseUrl); + return request>( + url, "POST", options, params.body + ); + } + + async testNotificationSubscription(params: TestNotificationSubscriptionParams, options: RequestOptions = {}): Promise> { + const url = new URL("/api/v2/notifications/subscriptions/{subscription_id}/test".replace("{subscription_id}", encodeURIComponent(String(params.path.subscription_id))), this.baseUrl); + return request>( + url, "POST", options + ); + } + + async listRepositories(options: RequestOptions = {}): Promise { + const url = new URL("/api/v2/repositories", this.baseUrl); + return request( + url, "GET", options + ); + } + + async createRepository(params: CreateRepositoryParams, options: RequestOptions = {}): Promise> { + const url = new URL("/api/v2/repositories", this.baseUrl); + return request>( + url, "POST", options, params.body + ); + } + + async getRepository(params: GetRepositoryParams, options: RequestOptions = {}): Promise> { + const url = new URL("/api/v2/repositories/{repository_id}".replace("{repository_id}", encodeURIComponent(String(params.path.repository_id))), this.baseUrl); + return request>( + url, "GET", options + ); + } + + async patchRepository(params: PatchRepositoryParams, options: RequestOptions = {}): Promise { + const url = new URL("/api/v2/repositories/{repository_id}".replace("{repository_id}", encodeURIComponent(String(params.path.repository_id))), this.baseUrl); + return request( + url, "PATCH", options, params.body + ); + } + + async inspectRepositoryEndpoint(params: InspectRepositoryEndpointParams, options: RequestOptions = {}): Promise> { + const url = new URL("/api/v2/repositories/{repository_id}/inspection".replace("{repository_id}", encodeURIComponent(String(params.path.repository_id))), this.baseUrl); + return request>( + url, "GET", options + ); + } + + async getRestore(params: GetRestoreParams, options: RequestOptions = {}): Promise> { + const url = new URL("/api/v2/restores/{restore_id}".replace("{restore_id}", encodeURIComponent(String(params.path.restore_id))), this.baseUrl); + return request>( + url, "GET", options + ); + } + + async recoveryStatus(options: RequestOptions = {}): Promise { + const url = new URL("/api/v2/security/recovery/status", this.baseUrl); + return request( + url, "GET", options + ); + } + + async setup(params: SetupParams, options: RequestOptions = {}): Promise { + const url = new URL("/api/v2/setup", this.baseUrl); + return request( + url, "POST", options, params.body + ); + } + + async listSources(options: RequestOptions = {}): Promise { + const url = new URL("/api/v2/sources", this.baseUrl); + return request( + url, "GET", options + ); + } + + async createSource(params: CreateSourceParams, options: RequestOptions = {}): Promise> { + const url = new URL("/api/v2/sources", this.baseUrl); + return request>( + url, "POST", options, params.body + ); + } + + async archiveSource(params: ArchiveSourceParams, options: RequestOptions = {}): Promise { + const url = new URL("/api/v2/sources/{source_id}".replace("{source_id}", encodeURIComponent(String(params.path.source_id))), this.baseUrl); + return request( + url, "DELETE", options + ); + } + + async probeSource(params: ProbeSourceParams, options: RequestOptions = {}): Promise> { + const url = new URL("/api/v2/sources/{source_id}/probe".replace("{source_id}", encodeURIComponent(String(params.path.source_id))), this.baseUrl); + return request>( + url, "POST", options + ); + } + + async livez(options: RequestOptions = {}): Promise> { + const url = new URL("/livez", this.baseUrl); + return request>( + url, "GET", options + ); + } + + async readyz(options: RequestOptions = {}): Promise> { + const url = new URL("/readyz", this.baseUrl); + return request>( + url, "GET", options + ); + } + +} diff --git a/frontend/src/app/App.test.tsx b/frontend/src/app/App.test.tsx new file mode 100644 index 0000000..bfe512d --- /dev/null +++ b/frontend/src/app/App.test.tsx @@ -0,0 +1,176 @@ +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { App } from "./App"; + +function response(body: unknown, status = 200): Response { + return { + headers: new Headers({ "content-type": "application/json" }), + json: async () => body, + ok: status >= 200 && status < 300, + status, + } as Response; +} + +function problem(status: number, code: string, detail = "Request failed."): Response { + return response({ type: `https://backup-tool.invalid/problems/${code}`, title: code, status, detail, instance: "/", code }, status); +} + +function session() { + return response({ id: "user-1", username: "operator", state: "active" }); +} + +function repositories(items: unknown[] = []) { + return response({ items }); +} + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe("operator foundation", () => { + it("shows loading then the accessible empty dashboard", async () => { + const fetch = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(repositories()); + vi.stubGlobal("fetch", fetch); + render(); + + expect(screen.getByRole("status")).toHaveTextContent("Checking your session"); + expect(await screen.findByText("No repositories have been configured.")).toBeInTheDocument(); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it("selects setup and creates a session for the first administrator", async () => { + const fetch = vi.fn() + .mockResolvedValueOnce(problem(401, "authentication_required")) + .mockResolvedValueOnce(problem(503, "setup_required")) + .mockResolvedValueOnce(response({ id: "user-1", username: "operator" }, 201)) + .mockResolvedValueOnce(repositories()); + vi.stubGlobal("fetch", fetch); + render(); + + expect(await screen.findByRole("heading", { name: "Set up your administrator account" })).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText("Username"), { target: { value: "operator" } }); + fireEvent.change(screen.getByLabelText("Password"), { target: { value: "correct horse battery staple" } }); + fireEvent.click(screen.getByRole("button", { name: "Create administrator account" })); + + expect(await screen.findByRole("heading", { name: "Dashboard" })).toBeInTheDocument(); + const setupRequest = fetch.mock.calls[2]?.[0] as URL; + expect(setupRequest.pathname).toBe("/api/v2/setup"); + }); + + it("signs in after setup is complete", async () => { + const fetch = vi.fn() + .mockResolvedValueOnce(problem(401, "authentication_required")) + .mockResolvedValueOnce(response({ status: "ready" })) + .mockResolvedValueOnce(response({ id: "user-1", username: "operator" })) + .mockResolvedValueOnce(repositories()); + vi.stubGlobal("fetch", fetch); + render(); + + expect(await screen.findByRole("heading", { name: "Sign in" })).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText("Username"), { target: { value: "operator" } }); + fireEvent.change(screen.getByLabelText("Password"), { target: { value: "correct horse battery staple" } }); + fireEvent.click(screen.getByRole("button", { name: "Sign in" })); + + expect(await screen.findByRole("heading", { name: "Dashboard" })).toBeInTheDocument(); + const loginRequest = fetch.mock.calls[2]?.[0] as URL; + expect(loginRequest.pathname).toBe("/api/v2/auth/login"); + }); + + it("shows retryable dashboard errors", async () => { + const fetch = vi.fn() + .mockResolvedValueOnce(session()) + .mockResolvedValueOnce(problem(500, "service_unavailable", "Dashboard data is unavailable.")) + .mockResolvedValueOnce(repositories()); + vi.stubGlobal("fetch", fetch); + render(); + + expect(await screen.findByRole("alert")).toHaveTextContent("Dashboard data is unavailable."); + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + await waitFor(() => expect(screen.getByText("No repositories have been configured.")).toBeInTheDocument()); + }); + + it("loads source and job states from generated client methods", async () => { + const fetch = vi.fn() + .mockResolvedValueOnce(session()) + .mockResolvedValueOnce(repositories()) + .mockResolvedValueOnce(response({ items: [] })) + .mockResolvedValueOnce(problem(500, "service_unavailable", "Jobs are unavailable.")); + vi.stubGlobal("fetch", fetch); + render(); + + await screen.findByText("No repositories have been configured."); + fireEvent.click(screen.getByRole("button", { name: "Sources" })); + expect(await screen.findByText("No sources have been configured.")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Jobs & schedules" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Jobs are unavailable."); + }); + + it("loads execution detail after selecting an execution", async () => { + const execution = { id: "execution-1", state: "failed", attempt: 2, revision: 3, reason_code: "timeout", progress: {} }; + const fetch = vi.fn() + .mockResolvedValueOnce(session()) + .mockResolvedValueOnce(repositories()) + .mockResolvedValueOnce(response({ items: [execution] })) + .mockResolvedValueOnce(response(execution)); + vi.stubGlobal("fetch", fetch); + render(); + + await screen.findByText("No repositories have been configured."); + fireEvent.click(screen.getByRole("button", { name: "Executions" })); + expect(await screen.findByRole("button", { name: "Execution execution-1" })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Execution execution-1" })); + expect(await screen.findByRole("heading", { name: "Execution detail" })).toBeInTheDocument(); + expect(screen.getByText("timeout")).toBeInTheDocument(); + }); + + it("announces SSE reconnects and applies live execution updates", async () => { + class EventSourceMock { + static instances: EventSourceMock[] = []; + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: (() => void) | null = null; + constructor() { EventSourceMock.instances.push(this); } + close = vi.fn(); + } + vi.stubGlobal("EventSource", EventSourceMock); + const execution = { id: "execution-1", state: "running", attempt: 1, revision: 1, reason_code: null, progress: {} }; + const fetch = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(repositories()).mockResolvedValueOnce(response({ items: [execution] })).mockResolvedValueOnce(response(execution)); + vi.stubGlobal("fetch", fetch); + render(); + await screen.findByText("No repositories have been configured."); + fireEvent.click(screen.getByRole("button", { name: "Executions" })); + fireEvent.click(await screen.findByRole("button", { name: "Execution execution-1" })); + await screen.findByRole("heading", { name: "Execution detail" }); + EventSourceMock.instances[0]?.onerror?.(); + expect(await screen.findByRole("alert")).toHaveTextContent("Live updates disconnected"); + EventSourceMock.instances[0]?.onmessage?.({ data: JSON.stringify({ ...execution, state: "committed", revision: 2 }) } as MessageEvent); + expect(await screen.findByText("committed")).toBeInTheDocument(); + }); + + it("sends an idempotency key when retrying a failed notification delivery", async () => { + const fetch = vi.fn() + .mockResolvedValueOnce(session()) + .mockResolvedValueOnce(repositories()) + .mockResolvedValueOnce(response({ items: [] })) + .mockResolvedValueOnce(response({ items: [{ id: "delivery-1", event_id: "event-1", subscription_id: "subscription-1", state: "failed", attempt_count: 1, response_class: null, response_summary: null, terminal_reason: "timeout", due_at: "2026-01-01T00:00:00Z" }] })) + .mockResolvedValueOnce(response({ delivery_id: "delivery-1" }, 202)); + vi.stubGlobal("fetch", fetch); + render(); + await screen.findByText("No repositories have been configured."); + fireEvent.click(screen.getByRole("button", { name: "Notifications" })); + fireEvent.click(await screen.findByRole("button", { name: "Retry delivery" })); + expect(await screen.findByRole("status")).toHaveTextContent("Delivery retry queued."); + const options = fetch.mock.calls[4]?.[1] as RequestInit; + expect(new Headers(options.headers).get("Idempotency-Key")).toBeTruthy(); + }); + + it("returns to sign-in when the dashboard request finds an expired session", async () => { + const fetch = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(problem(401, "authentication_required")); + vi.stubGlobal("fetch", fetch); + render(); + + expect(await screen.findByRole("heading", { name: "Sign in" })).toBeInTheDocument(); + expect(screen.getByRole("status")).toHaveTextContent("Your session expired"); + }); +}); diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx new file mode 100644 index 0000000..92ef59f --- /dev/null +++ b/frontend/src/app/App.tsx @@ -0,0 +1,97 @@ +import { type FormEvent, useEffect, useRef, useState } from "react"; + +import { + BackupToolClient, + type Components, + isApiError, +} from "../api/generated/client"; +import { OperatorViews } from "./OperatorViews"; + +type SessionUser = Components["schemas"]["SessionUser"]; +type AuthMode = "setup" | "login"; +type Screen = + | { kind: "loading" } + | { kind: "auth"; mode: AuthMode; error?: string; sessionExpired?: boolean } + | { kind: "operator"; user: SessionUser }; + +const defaultClient = new BackupToolClient(); + +function errorMessage(error: unknown): string { + if (isApiError(error)) return error.problem?.detail ?? "The server could not complete that request."; + return "We could not reach the Backup Tool service. Check your connection and try again."; +} + +function isSetupRequired(error: unknown): boolean { + return isApiError(error) && error.status === 503 && error.problem?.code === "setup_required"; +} + +function isSessionExpired(error: unknown): boolean { + return isApiError(error) && error.status === 401; +} + +function AuthForm({ + mode, + error, + sessionExpired, + onSubmit, +}: { + mode: AuthMode; + error?: string; + sessionExpired?: boolean; + onSubmit: (username: string, password: string) => Promise; +}) { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [submitting, setSubmitting] = useState(false); + const isSetup = mode === "setup"; + + async function submit(event: FormEvent) { + event.preventDefault(); + if (isSetup && password.length < 12) return; + setSubmitting(true); + try { + await onSubmit(username, password); + } finally { + setSubmitting(false); + } + } + + return

Backup Tool

{isSetup ? "Set up your administrator account" : "Sign in"}

{isSetup ? "Create the first administrator account to begin operating this backup service." : "Use an administrator account to continue."}

{sessionExpired ?

Your session expired. Sign in again to continue.

: null}{error ?

{error}

: null}
setUsername(event.target.value)} required value={username} />
setPassword(event.target.value)} required type="password" value={password} />{isSetup ?

Use at least 12 characters.

: null}
; +} + +export function App({ client = defaultClient }: { client?: BackupToolClient }) { + const [screen, setScreen] = useState({ kind: "loading" }); + const clientRef = useRef(client); + clientRef.current = client; + + async function discover() { + setScreen({ kind: "loading" }); + try { + setScreen({ kind: "operator", user: await clientRef.current.getSession() }); + } catch (error) { + if (!isSessionExpired(error)) { + setScreen({ kind: "auth", mode: "login", error: errorMessage(error) }); + return; + } + try { + await clientRef.current.readyz(); + setScreen({ kind: "auth", mode: "login" }); + } catch (readinessError) { + setScreen({ kind: "auth", mode: isSetupRequired(readinessError) ? "setup" : "login", error: isSetupRequired(readinessError) ? undefined : errorMessage(readinessError) }); + } + } + } + + useEffect(() => { void discover(); }, []); + if (screen.kind === "loading") return
Checking your session…
; + if (screen.kind === "auth") return { + try { + const user = screen.mode === "setup" ? await clientRef.current.setup({ body: { username, password } }) : await clientRef.current.login({ body: { username, password } }); + setScreen({ kind: "operator", user: { ...user, state: "active" } }); + } catch (error) { + if (screen.mode === "setup" && isApiError(error) && error.problem?.code === "setup_complete") setScreen({ kind: "auth", mode: "login", error: "Setup is already complete. Sign in with the administrator account." }); + else setScreen({ ...screen, error: errorMessage(error) }); + } + }} />; + return setScreen({ kind: "auth", mode: "login", sessionExpired: true })} user={screen.user} />; +} diff --git a/frontend/src/app/M13Workflows.tsx b/frontend/src/app/M13Workflows.tsx new file mode 100644 index 0000000..626aa9c --- /dev/null +++ b/frontend/src/app/M13Workflows.tsx @@ -0,0 +1,33 @@ +import { type ReactNode, useEffect, useState } from "react"; + +import { type BackupToolClient, type Components, isApiError } from "../api/generated/client"; + +type BackupSummary = Components["schemas"]["BackupSummary"]; +type Delivery = Components["schemas"]["NotificationDeliverySummary"]; +type Subscription = Components["schemas"]["NotificationSubscriptionSummary"]; +type Audit = Components["schemas"]["AuditSummary"]; +type State = { loading: boolean; data?: T; error?: string }; + +type Props = { client: BackupToolClient; onSessionExpired: () => void }; + +function message(error: unknown) { return isApiError(error) ? error.problem?.detail ?? "The request failed." : "The service could not be reached."; } +function expired(error: unknown) { return isApiError(error) && error.status === 401; } +function Panel({ children, title }: { children: ReactNode; title: string }) { return

{title}

{children}
; } +function Retry({ error, load }: { error?: string; load: () => void }) { return error ?

{error}

: null; } +function Loading({ label }: { label: string }) { return

Loading {label}…

; } +function csrfHeaders(): HeadersInit { const value = document.cookie.split("; ").find((entry) => entry.startsWith("backup_tool_csrf="))?.split("=", 2)[1]; return value ? { "X-CSRF-Token": value } : {}; } +function idempotencyKey(): string { return typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`; } + +export function BackupsPage({ client, onSessionExpired }: Props) { + const [state,setState]=useState>({loading:true}); const [selected,setSelected]=useState(); const [preview,setPreview]=useState(); const [destination,setDestination]=useState(""); const [restoreStatus,setRestoreStatus]=useState(); + const load=async()=>{setState({loading:true});try{setState({loading:false,data:(await client.listBackups()).items});}catch(error){if(expired(error))onSessionExpired();else setState({loading:false,error:message(error)});}}; + useEffect(()=>{void load();},[]); + const headers=csrfHeaders(); + return {state.loading?:null}void load()}/>{state.data?.length===0?

No backups have been committed.

:null}
    {state.data?.map(item=>
  • {item.integrity} · {item.logical_bytes} logical bytes

  • )}
{selected?

Backup detail

Integrity: {selected.integrity}

{preview?

{preview}

:null}
{event.preventDefault();try{await client.createRestore({path:{backup_id:selected.id},body:{destination,dry_run:true,selection:[],overwrite_policy:"fail"}},{headers});setRestoreStatus("Restore dry run queued.");}catch(error){setRestoreStatus(message(error));}}}>setDestination(event.target.value)} required value={destination}/>{restoreStatus?

{restoreStatus}

:null}
:null}
; +} + +export function SecurityPage({ client,onSessionExpired }: Props) { const [state,setState]=useState>({loading:true}); const load=async()=>{setState({loading:true});try{setState({loading:false,data:await client.recoveryStatus()});}catch(error){if(expired(error))onSessionExpired();else setState({loading:false,error:message(error)});}};useEffect(()=>{void load();},[]);return {state.loading?:null}void load()}/>{state.data?

Recovery is {state.data.recovery_mode.replace("_"," ")}.

Encrypted repositories: {state.data.encrypted_repository_count}

Use the CLI and the recovery runbook: {state.data.runbook}. Passphrases and recovery bundles never enter the browser.

:null}
; } + +export function NotificationsPage({client,onSessionExpired}:Props){const [state,setState]=useState>({loading:true});const [history,setHistory]=useState();const load=async()=>{setState({loading:true});const results=await Promise.allSettled([client.listNotificationSubscriptions(),client.listNotificationDeliveries({query:{limit:20}})]);if(results.some((result)=>result.status==="rejected"&&expired(result.reason))){onSessionExpired();return;}const errors=results.filter((result)=>result.status==="rejected");setState({loading:false,data:{subscriptions:results[0].status==="fulfilled"?results[0].value.items:[],deliveries:results[1].status==="fulfilled"?results[1].value.items:[]},error:errors.length?"Some notification history could not be loaded.":undefined});};useEffect(()=>{void load();},[]);return {state.loading?:null}void load()}/>{state.data?<>

Subscriptions

{state.data.subscriptions.length?
    {state.data.subscriptions.map(item=>
  • {item.channel} · {item.state}
  • )}
:

No notification subscriptions.

}

Delivery history

{state.data.deliveries.length?
    {state.data.deliveries.map(item=>
  • {item.state} · {item.attempt_count} attempts {item.state==="failed"?:null}
  • )}
:

No notification deliveries.

}{history?

{history}

:null}:null}
} + +export function AuditPage({client,onSessionExpired}:Props){const [state,setState]=useState>({loading:true});const load=async()=>{setState({loading:true});try{setState({loading:false,data:(await client.listAudit({query:{limit:50}})).items});}catch(error){if(expired(error))onSessionExpired();else setState({loading:false,error:message(error)});}};useEffect(()=>{void load();},[]);return {state.loading?:null}void load()}/>{state.data?.length===0?

No audit events.

:null}
    {state.data?.map(item=>
  • {item.action} {item.resource_type} · {item.outcome}
  • )}
} diff --git a/frontend/src/app/OperatorViews.tsx b/frontend/src/app/OperatorViews.tsx new file mode 100644 index 0000000..397e472 --- /dev/null +++ b/frontend/src/app/OperatorViews.tsx @@ -0,0 +1,159 @@ +import { type ReactNode, useEffect, useState } from "react"; + +import { + type BackupToolClient, + type Components, + isApiError, +} from "../api/generated/client"; +import { AuditPage, BackupsPage, NotificationsPage, SecurityPage } from "./M13Workflows"; + +type SessionUser = Components["schemas"]["SessionUser"]; +type SourceSummary = Components["schemas"]["SourceSummary"]; +type RepositorySummary = Components["schemas"]["RepositorySummary"]; +type JobSummary = Components["schemas"]["JobSummary"]; +type ExecutionSummary = Components["schemas"]["ExecutionSummary"]; +type Page = "dashboard" | "sources" | "repositories" | "jobs" | "executions" | "backups" | "security" | "notifications" | "audit"; +type LoadState = + | { kind: "loading" } + | { kind: "ready"; items: T } + | { kind: "error"; message: string }; + +const pages: Array<{ id: Page; label: string }> = [ + { id: "dashboard", label: "Dashboard" }, + { id: "sources", label: "Sources" }, + { id: "repositories", label: "Repositories" }, + { id: "jobs", label: "Jobs & schedules" }, + { id: "executions", label: "Executions" }, + { id: "backups", label: "Backups" }, + { id: "security", label: "Security & recovery" }, + { id: "notifications", label: "Notifications" }, + { id: "audit", label: "Audit" }, +]; + +function errorMessage(error: unknown): string { + if (isApiError(error)) return error.problem?.detail ?? "The server could not complete that request."; + return "We could not reach the Backup Tool service. Check your connection and try again."; +} + +function isSessionExpired(error: unknown): boolean { + return isApiError(error) && error.status === 401; +} + +function ErrorPanel({ message, retry }: { message: string; retry: () => void }) { + return

{message}

; +} + +function Loading({ label }: { label: string }) { + return

Loading {label}…

; +} + +function ResourceSection({ children, title }: { children: ReactNode; title: string }) { + return

{title}

{children}
; +} + +function DashboardPage({ client, onSessionExpired }: { client: BackupToolClient; onSessionExpired: () => void }) { + const [state, setState] = useState>({ kind: "loading" }); + async function load() { + setState({ kind: "loading" }); + try { setState({ kind: "ready", items: (await client.listRepositories()).items }); } + catch (error) { if (isSessionExpired(error)) onSessionExpired(); else setState({ kind: "error", message: errorMessage(error) }); } + } + useEffect(() => { void load(); }, []); + return +

Repository availability at a glance.

+ {state.kind === "loading" ? : null} + {state.kind === "error" ? { void load(); }} /> : null} + {state.kind === "ready" && state.items.length === 0 ?

No repositories have been configured.

: null} + {state.kind === "ready" && state.items.length > 0 ?
    {state.items.map((repository) =>
  • {repository.name}

    {repository.state} · {repository.encryption}

  • )}
: null} +
; +} + +function SourcesPage({ client, onSessionExpired }: { client: BackupToolClient; onSessionExpired: () => void }) { + const [state, setState] = useState>({ kind: "loading" }); + async function load() { + setState({ kind: "loading" }); + try { setState({ kind: "ready", items: (await client.listSources()).items }); } + catch (error) { if (isSessionExpired(error)) onSessionExpired(); else setState({ kind: "error", message: errorMessage(error) }); } + } + useEffect(() => { void load(); }, []); + return + {state.kind === "loading" ? : null} + {state.kind === "error" ? { void load(); }} /> : null} + {state.kind === "ready" && state.items.length === 0 ?

No sources have been configured.

: null} + {state.kind === "ready" && state.items.length > 0 ?
    {state.items.map((source) =>
  • {source.name}

    {source.kind} · {source.state}

  • )}
: null} +
; +} + +function RepositoriesPage({ client, onSessionExpired }: { client: BackupToolClient; onSessionExpired: () => void }) { + const [state, setState] = useState>({ kind: "loading" }); + async function load() { + setState({ kind: "loading" }); + try { setState({ kind: "ready", items: (await client.listRepositories()).items }); } + catch (error) { if (isSessionExpired(error)) onSessionExpired(); else setState({ kind: "error", message: errorMessage(error) }); } + } + useEffect(() => { void load(); }, []); + return + {state.kind === "loading" ? : null} + {state.kind === "error" ? { void load(); }} /> : null} + {state.kind === "ready" && state.items.length === 0 ?

No repositories have been configured.

: null} + {state.kind === "ready" && state.items.length > 0 ?
    {state.items.map((repository) =>
  • {repository.name}

    Format {repository.format_version} · {repository.encryption} · {repository.state}

  • )}
: null} +
; +} + +function JobsPage({ client, onSessionExpired }: { client: BackupToolClient; onSessionExpired: () => void }) { + const [state, setState] = useState>({ kind: "loading" }); + async function load() { + setState({ kind: "loading" }); + try { setState({ kind: "ready", items: (await client.listJobs()).items }); } + catch (error) { if (isSessionExpired(error)) onSessionExpired(); else setState({ kind: "error", message: errorMessage(error) }); } + } + useEffect(() => { void load(); }, []); + return + {state.kind === "loading" ? : null} + {state.kind === "error" ? { void load(); }} /> : null} + {state.kind === "ready" && state.items.length === 0 ?

No jobs have been configured.

: null} + {state.kind === "ready" && state.items.length > 0 ?
    {state.items.map((job) =>
  • {job.name}

    {job.requested_mode} · {job.enabled ? "enabled" : "disabled"}

    {job.schedule ? `${job.schedule.cron} (${job.schedule.timezone})` : "No schedule configured."}

  • )}
: null} +
; +} + +function ExecutionsPage({ client, onSessionExpired }: { client: BackupToolClient; onSessionExpired: () => void }) { + const [state, setState] = useState>({ kind: "loading" }); + const [selectedId, setSelectedId] = useState(); + const [detail, setDetail] = useState | undefined>(); + async function load() { + setState({ kind: "loading" }); + try { setState({ kind: "ready", items: (await client.listExecutions()).items }); } + catch (error) { if (isSessionExpired(error)) onSessionExpired(); else setState({ kind: "error", message: errorMessage(error) }); } + } + async function loadDetail(executionId: string) { + setSelectedId(executionId); setDetail({ kind: "loading" }); + try { setDetail({ kind: "ready", items: await client.getExecution({ path: { execution_id: executionId } }) }); } + catch (error) { if (isSessionExpired(error)) onSessionExpired(); else setDetail({ kind: "error", message: errorMessage(error) }); } + } + useEffect(() => { void load(); }, []); + useEffect(() => { + if (!selectedId || typeof EventSource === "undefined") return; + const stream = new EventSource(client.executionEventsUrl({ path: { execution_id: selectedId } })); + stream.onmessage = (event) => { + try { setDetail({ kind: "ready", items: JSON.parse(event.data) as ExecutionSummary }); } + catch { setDetail({ kind: "error", message: "Live execution update was invalid. Reconnecting…" }); } + }; + stream.onerror = () => { setDetail({ kind: "error", message: "Live updates disconnected. Reconnecting…" }); }; + return () => stream.close(); + }, [client, selectedId]); + return + {state.kind === "loading" ? : null} + {state.kind === "error" ? { void load(); }} /> : null} + {state.kind === "ready" && state.items.length === 0 ?

No executions have been queued.

: null} + {state.kind === "ready" && state.items.length > 0 ?
    {state.items.map((execution) =>
  • {execution.state} · attempt {execution.attempt}

  • )}
: null} + {detail?.kind === "loading" ? : null} + {detail?.kind === "error" && selectedId ? { void loadDetail(selectedId); }} /> : null} + {detail?.kind === "ready" ?

Execution detail

State
{detail.items.state}
Attempt
{detail.items.attempt}
Reason
{detail.items.reason_code ?? "None"}
: null} +
; +} + +export function OperatorViews({ client, onSessionExpired, user }: { client: BackupToolClient; onSessionExpired: () => void; user: SessionUser }) { + const [page, setPage] = useState("dashboard"); + const common = { client, onSessionExpired }; + return

Backup Tool

Operator console

Signed in as {user.username}

{page === "dashboard" ? : null}{page === "sources" ? : null}{page === "repositories" ? : null}{page === "jobs" ? : null}{page === "executions" ? : null}{page === "backups" ? : null}{page === "security" ? : null}{page === "notifications" ? : null}{page === "audit" ? : null}
; +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx deleted file mode 100644 index 76c714e..0000000 --- a/frontend/src/components/Layout.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { Link, useLocation } from 'react-router-dom'; -import { LayoutDashboard, Archive, Settings, Menu } from 'lucide-react'; -import { useState } from 'react'; - -interface LayoutProps { - children: React.ReactNode; -} - -const navItems = [ - { path: '/', label: 'Dashboard', icon: LayoutDashboard }, - { path: '/backups', label: 'Backups', icon: Archive }, - { path: '/settings', label: 'Settings', icon: Settings }, -]; - -export function Layout({ children }: LayoutProps) { - const location = useLocation(); - const [sidebarOpen, setSidebarOpen] = useState(false); - - return ( -
- {/* Mobile sidebar overlay */} - {sidebarOpen && ( -
setSidebarOpen(false)} - /> - )} - - {/* Sidebar */} - - - {/* Main content */} -
- {/* Mobile header */} -
- - - Backup Tool - -
- -
{children}
-
-
- ); -} diff --git a/frontend/src/index.css b/frontend/src/index.css index b5c61c9..f1d8917 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,3 +1,23 @@ @tailwind base; @tailwind components; @tailwind utilities; + +:root { + color-scheme: dark; +} + +:focus-visible { + outline: 3px solid #34d399; + outline-offset: 3px; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 964aeb4..e3e15cc 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,10 +1,17 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' -import App from './App' -import './index.css' +import React from "react"; +import ReactDOM from "react-dom/client"; -ReactDOM.createRoot(document.getElementById('root')!).render( - - - , -) +import { App } from "./app/App"; +import "./index.css"; + +const root = document.getElementById("root"); + +if (!root) { + throw new Error("Missing #root element"); +} + +ReactDOM.createRoot(root).render( + + + , +); diff --git a/frontend/src/pages/Backups.tsx b/frontend/src/pages/Backups.tsx deleted file mode 100644 index f19a298..0000000 --- a/frontend/src/pages/Backups.tsx +++ /dev/null @@ -1,238 +0,0 @@ -import { useState } from 'react'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Play, Trash2, Plus } from 'lucide-react'; -import { jobsApi, sourcesApi } from '../api/client'; -import type { BackupJob, BackupSource } from '../api/client'; - -function StatusBadge({ status }: { status: string }) { - const styles = { - pending: 'bg-yellow-100 text-yellow-800', - running: 'bg-blue-100 text-blue-800', - completed: 'bg-green-100 text-green-800', - failed: 'bg-red-100 text-red-800', - }; - return ( - - {status} - - ); -} - -function CreateJobModal({ - onClose, -}: { - onClose: () => void; -}) { - const queryClient = useQueryClient(); - const [sourceId, setSourceId] = useState(''); - - const { data: sources } = useQuery({ - queryKey: ['sources'], - queryFn: () => sourcesApi.getAll().then((res) => res.data), - }); - - const createMutation = useMutation({ - mutationFn: (sid: string) => jobsApi.create(sid), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['jobs'] }); - onClose(); - }, - }); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (sourceId) { - createMutation.mutate(sourceId); - } - }; - - return ( -
-
-
-

- Create Backup Job -

-
-
-
- - -
-
- - -
-
-
-
- ); -} - -export function Backups() { - const [showModal, setShowModal] = useState(false); - const queryClient = useQueryClient(); - - const { data: jobs, isLoading } = useQuery({ - queryKey: ['jobs'], - queryFn: () => jobsApi.getAll().then((res) => res.data), - }); - - const { data: sources } = useQuery({ - queryKey: ['sources'], - queryFn: () => sourcesApi.getAll().then((res) => res.data), - }); - - const deleteMutation = useMutation({ - mutationFn: (id: string) => jobsApi.delete(id), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['jobs'] }); - }, - }); - - const runMutation = useMutation({ - mutationFn: (id: string) => jobsApi.create(id), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['jobs'] }); - }, - }); - - const getSourceName = (sourceId: string) => { - const source = sources?.find((s: BackupSource) => s.id === sourceId); - return source?.name || sourceId.slice(0, 8); - }; - - return ( -
-
-

Backups

- -
- -
- - - - - - - - - - - - {isLoading && ( - - - - )} - {jobs?.length === 0 && !isLoading && ( - - - - )} - {jobs?.map((job: BackupJob) => ( - - - - - - - - ))} - -
- Job ID - - Source - - Status - - Started - - Actions -
- Loading... -
- No backup jobs yet. Create one to get started. -
- {job.id.slice(0, 8)} - - {getSourceName(job.source_id)} - - - - {job.started_at - ? new Date(job.started_at).toLocaleString() - : 'Not started'} - -
- - -
-
-
- - {showModal && setShowModal(false)} />} -
- ); -} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx deleted file mode 100644 index ffcd5de..0000000 --- a/frontend/src/pages/Dashboard.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { Activity, Archive, AlertTriangle, CheckCircle } from 'lucide-react'; -import { dashboardApi } from '../api/client'; - -function StatCard({ - title, - value, - icon: Icon, - color, -}: { - title: string; - value: number; - icon: React.ElementType; - color: string; -}) { - return ( -
-
-
-

{title}

-

{value}

-
-
- -
-
-
- ); -} - -function StatusBadge({ status }: { status: string }) { - const styles = { - pending: 'bg-yellow-100 text-yellow-800', - running: 'bg-blue-100 text-blue-800', - completed: 'bg-green-100 text-green-800', - failed: 'bg-red-100 text-red-800', - }; - return ( - - {status} - - ); -} - -export function Dashboard() { - const { data: stats } = useQuery({ - queryKey: ['dashboard-stats'], - queryFn: () => dashboardApi.getStats().then((res) => res.data), - }); - - const { data: recentJobs } = useQuery({ - queryKey: ['recent-jobs'], - queryFn: () => dashboardApi.getRecentJobs(5).then((res) => res.data), - }); - - const activeJobs = stats?.pending_jobs || 0; - const recentFailures = stats?.failed_jobs || 0; - - return ( -
-

Dashboard

- -
- - - - -
- -
-
-

- Recent Activity -

-
-
- {recentJobs?.length === 0 && ( -
- No recent activity -
- )} - {recentJobs?.map((job) => ( -
-
-
-
-

- Job {job.id.slice(0, 8)} -

-

- {job.created_at - ? new Date(job.created_at).toLocaleString() - : 'Unknown'} -

-
-
- -
- ))} -
-
-
- ); -} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx deleted file mode 100644 index 5076a7f..0000000 --- a/frontend/src/pages/Settings.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import { useState } from 'react'; - -const tabs = [ - { id: 'general', label: 'General' }, - { id: 'notifications', label: 'Notifications' }, - { id: 'security', label: 'Security' }, - { id: 'logs', label: 'Logs' }, -]; - -function GeneralSettings() { - return ( -
-
- - -
-
- - -
-
- - -
-
- ); -} - -function NotificationSettings() { - return ( -
-
- - -
-
- - -
-
- - -
-
- ); -} - -function SecuritySettings() { - return ( -
-
- - -
-
- - -
-
- - -
-
- ); -} - -function LogSettings() { - return ( -
-
- - -
-
- - -
-
- - -
-
- ); -} - -export function Settings() { - const [activeTab, setActiveTab] = useState('general'); - - const tabContent = { - general: , - notifications: , - security: , - logs: , - }; - - return ( -
-

Settings

- -
-
- -
-
{tabContent[activeTab as keyof typeof tabContent]}
-
-
- ); -} diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts new file mode 100644 index 0000000..f149f27 --- /dev/null +++ b/frontend/src/test/setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom/vitest"; diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index bbd2a4e..8394379 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,12 +1,12 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; export default defineConfig({ plugins: [react()], server: { port: 3000, proxy: { - '/api': 'http://localhost:8000' - } - } -}) + "/api": "http://localhost:8000", + }, + }, +}); diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..11b3b31 --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,16 @@ +import { mergeConfig } from "vite"; +import { defineConfig } from "vitest/config"; + +import viteConfig from "./vite.config"; + +export default mergeConfig( + viteConfig, + defineConfig({ + test: { + environment: "jsdom", + include: ["src/**/*.test.{ts,tsx}"], + exclude: ["e2e/**", "node_modules/**"], + setupFiles: ["./src/test/setup.ts"], + }, + }), +); diff --git a/openapi/v2.json b/openapi/v2.json new file mode 100644 index 0000000..cfbae06 --- /dev/null +++ b/openapi/v2.json @@ -0,0 +1,4789 @@ +{ + "components": { + "schemas": { + "AuditList": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/AuditSummary" + }, + "title": "Items", + "type": "array" + }, + "next_cursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Next Cursor" + } + }, + "required": [ + "items", + "next_cursor" + ], + "title": "AuditList", + "type": "object" + }, + "AuditSummary": { + "properties": { + "action": { + "title": "Action", + "type": "string" + }, + "created_at": { + "title": "Created At", + "type": "string" + }, + "details": { + "additionalProperties": true, + "title": "Details", + "type": "object" + }, + "id": { + "title": "Id", + "type": "string" + }, + "outcome": { + "title": "Outcome", + "type": "string" + }, + "request_id": { + "title": "Request Id", + "type": "string" + }, + "resource_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Resource Id" + }, + "resource_type": { + "title": "Resource Type", + "type": "string" + } + }, + "required": [ + "id", + "action", + "resource_type", + "resource_id", + "outcome", + "request_id", + "created_at", + "details" + ], + "title": "AuditSummary", + "type": "object" + }, + "AuthenticatedUser": { + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "username": { + "title": "Username", + "type": "string" + } + }, + "required": [ + "id", + "username" + ], + "title": "AuthenticatedUser", + "type": "object" + }, + "BackupDeletePreview": { + "properties": { + "backup_id": { + "title": "Backup Id", + "type": "string" + }, + "destructive_action": { + "title": "Destructive Action", + "type": "string" + }, + "eligible": { + "title": "Eligible", + "type": "boolean" + }, + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Reason" + } + }, + "required": [ + "backup_id", + "eligible", + "reason", + "destructive_action" + ], + "title": "BackupDeletePreview", + "type": "object" + }, + "BackupList": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/BackupSummary" + }, + "title": "Items", + "type": "array" + } + }, + "required": [ + "items" + ], + "title": "BackupList", + "type": "object" + }, + "BackupSummary": { + "properties": { + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "execution_id": { + "title": "Execution Id", + "type": "string" + }, + "id": { + "title": "Id", + "type": "string" + }, + "integrity": { + "title": "Integrity", + "type": "string" + }, + "logical_bytes": { + "title": "Logical Bytes", + "type": "integer" + }, + "manifest_id": { + "title": "Manifest Id", + "type": "string" + }, + "pinned": { + "title": "Pinned", + "type": "boolean" + }, + "stored_bytes": { + "title": "Stored Bytes", + "type": "integer" + }, + "tombstoned_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tombstoned At" + } + }, + "required": [ + "id", + "execution_id", + "manifest_id", + "logical_bytes", + "stored_bytes", + "integrity", + "pinned", + "tombstoned_at", + "created_at" + ], + "title": "BackupSummary", + "type": "object" + }, + "EmailSettingsInput": { + "properties": { + "host": { + "maxLength": 255, + "minLength": 1, + "title": "Host", + "type": "string" + }, + "max_attempts": { + "default": 5, + "maximum": 20.0, + "minimum": 1.0, + "title": "Max Attempts", + "type": "integer" + }, + "password": { + "maxLength": 65536, + "minLength": 1, + "title": "Password", + "type": "string" + }, + "port": { + "default": 587, + "maximum": 65535.0, + "minimum": 1.0, + "title": "Port", + "type": "integer" + }, + "rate_limit_per_minute": { + "default": 60, + "maximum": 10000.0, + "minimum": 1.0, + "title": "Rate Limit Per Minute", + "type": "integer" + }, + "sender": { + "maxLength": 320, + "minLength": 3, + "title": "Sender", + "type": "string" + }, + "username": { + "maxLength": 255, + "minLength": 1, + "title": "Username", + "type": "string" + } + }, + "required": [ + "host", + "username", + "password", + "sender" + ], + "title": "EmailSettingsInput", + "type": "object" + }, + "ExecutionList": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/ExecutionSummary" + }, + "title": "Items", + "type": "array" + } + }, + "required": [ + "items" + ], + "title": "ExecutionList", + "type": "object" + }, + "ExecutionSummary": { + "properties": { + "attempt": { + "title": "Attempt", + "type": "integer" + }, + "id": { + "title": "Id", + "type": "string" + }, + "progress": { + "additionalProperties": true, + "title": "Progress", + "type": "object" + }, + "reason_code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Reason Code" + }, + "revision": { + "title": "Revision", + "type": "integer" + }, + "state": { + "title": "State", + "type": "string" + } + }, + "required": [ + "id", + "state", + "attempt", + "revision", + "reason_code", + "progress" + ], + "title": "ExecutionSummary", + "type": "object" + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "title": "Detail", + "type": "array" + } + }, + "title": "HTTPValidationError", + "type": "object" + }, + "JobInput": { + "properties": { + "allow_empty": { + "default": false, + "title": "Allow Empty", + "type": "boolean" + }, + "enabled": { + "default": true, + "title": "Enabled", + "type": "boolean" + }, + "exclusions": { + "items": { + "type": "string" + }, + "title": "Exclusions", + "type": "array" + }, + "name": { + "maxLength": 255, + "minLength": 1, + "title": "Name", + "type": "string" + }, + "repository_id": { + "title": "Repository Id", + "type": "string" + }, + "requested_mode": { + "default": "incremental", + "title": "Requested Mode", + "type": "string" + }, + "retention": { + "additionalProperties": true, + "title": "Retention", + "type": "object" + }, + "source_id": { + "title": "Source Id", + "type": "string" + } + }, + "required": [ + "name", + "source_id", + "repository_id" + ], + "title": "JobInput", + "type": "object" + }, + "JobList": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/JobSummary" + }, + "title": "Items", + "type": "array" + } + }, + "required": [ + "items" + ], + "title": "JobList", + "type": "object" + }, + "JobSummary": { + "properties": { + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "repository_id": { + "title": "Repository Id", + "type": "string" + }, + "requested_mode": { + "title": "Requested Mode", + "type": "string" + }, + "schedule": { + "anyOf": [ + { + "$ref": "#/components/schemas/ScheduleSummary" + }, + { + "type": "null" + } + ] + }, + "source_id": { + "title": "Source Id", + "type": "string" + }, + "state": { + "title": "State", + "type": "string" + } + }, + "required": [ + "id", + "name", + "source_id", + "repository_id", + "requested_mode", + "enabled", + "state", + "schedule" + ], + "title": "JobSummary", + "type": "object" + }, + "LocalSourceInput": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "local", + "title": "Kind", + "type": "string" + }, + "name": { + "maxLength": 255, + "minLength": 1, + "title": "Name", + "type": "string" + }, + "public_config": { + "additionalProperties": true, + "title": "Public Config", + "type": "object" + } + }, + "required": [ + "name", + "kind", + "public_config" + ], + "title": "LocalSourceInput", + "type": "object" + }, + "LoginInput": { + "properties": { + "password": { + "maxLength": 1024, + "minLength": 1, + "title": "Password", + "type": "string" + }, + "username": { + "maxLength": 255, + "minLength": 1, + "title": "Username", + "type": "string" + } + }, + "required": [ + "username", + "password" + ], + "title": "LoginInput", + "type": "object" + }, + "NotificationAttemptList": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/NotificationAttemptSummary" + }, + "title": "Items", + "type": "array" + } + }, + "required": [ + "items" + ], + "title": "NotificationAttemptList", + "type": "object" + }, + "NotificationAttemptSummary": { + "properties": { + "completed_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Completed At" + }, + "diagnostic": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Diagnostic" + }, + "number": { + "title": "Number", + "type": "integer" + }, + "outcome": { + "title": "Outcome", + "type": "string" + }, + "response_class": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Response Class" + }, + "started_at": { + "title": "Started At", + "type": "string" + } + }, + "required": [ + "number", + "outcome", + "response_class", + "diagnostic", + "started_at", + "completed_at" + ], + "title": "NotificationAttemptSummary", + "type": "object" + }, + "NotificationDeliveryList": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/NotificationDeliverySummary" + }, + "title": "Items", + "type": "array" + } + }, + "required": [ + "items" + ], + "title": "NotificationDeliveryList", + "type": "object" + }, + "NotificationDeliverySummary": { + "properties": { + "attempt_count": { + "title": "Attempt Count", + "type": "integer" + }, + "due_at": { + "title": "Due At", + "type": "string" + }, + "event_id": { + "title": "Event Id", + "type": "string" + }, + "id": { + "title": "Id", + "type": "string" + }, + "response_class": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Response Class" + }, + "response_summary": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Response Summary" + }, + "state": { + "title": "State", + "type": "string" + }, + "subscription_id": { + "title": "Subscription Id", + "type": "string" + }, + "terminal_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Terminal Reason" + } + }, + "required": [ + "id", + "event_id", + "subscription_id", + "state", + "attempt_count", + "response_class", + "response_summary", + "terminal_reason", + "due_at" + ], + "title": "NotificationDeliverySummary", + "type": "object" + }, + "NotificationSubscriptionInput": { + "properties": { + "channel": { + "enum": [ + "webhook", + "email" + ], + "title": "Channel", + "type": "string" + }, + "destination": { + "additionalProperties": true, + "title": "Destination", + "type": "object" + }, + "event_filters": { + "items": { + "type": "string" + }, + "maxItems": 48, + "minItems": 1, + "title": "Event Filters", + "type": "array" + }, + "rate_limit_per_minute": { + "default": 60, + "maximum": 10000.0, + "minimum": 1.0, + "title": "Rate Limit Per Minute", + "type": "integer" + }, + "signing_secret": { + "anyOf": [ + { + "maxLength": 65536, + "minLength": 16, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Signing Secret" + } + }, + "required": [ + "channel", + "event_filters", + "destination" + ], + "title": "NotificationSubscriptionInput", + "type": "object" + }, + "NotificationSubscriptionList": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/NotificationSubscriptionSummary" + }, + "title": "Items", + "type": "array" + } + }, + "required": [ + "items" + ], + "title": "NotificationSubscriptionList", + "type": "object" + }, + "NotificationSubscriptionPatch": { + "properties": { + "destination": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Destination" + }, + "event_filters": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "maxItems": 48, + "minItems": 1, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Event Filters" + }, + "rate_limit_per_minute": { + "anyOf": [ + { + "maximum": 10000.0, + "minimum": 1.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Rate Limit Per Minute" + }, + "state": { + "anyOf": [ + { + "enum": [ + "active", + "disabled", + "archived" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "State" + } + }, + "title": "NotificationSubscriptionPatch", + "type": "object" + }, + "NotificationSubscriptionSummary": { + "properties": { + "channel": { + "title": "Channel", + "type": "string" + }, + "created_at": { + "title": "Created At", + "type": "string" + }, + "destination": { + "additionalProperties": true, + "title": "Destination", + "type": "object" + }, + "event_filters": { + "items": { + "type": "string" + }, + "title": "Event Filters", + "type": "array" + }, + "id": { + "title": "Id", + "type": "string" + }, + "rate_limit_per_minute": { + "title": "Rate Limit Per Minute", + "type": "integer" + }, + "revision": { + "title": "Revision", + "type": "integer" + }, + "state": { + "title": "State", + "type": "string" + }, + "updated_at": { + "title": "Updated At", + "type": "string" + } + }, + "required": [ + "id", + "channel", + "event_filters", + "destination", + "state", + "rate_limit_per_minute", + "revision", + "created_at", + "updated_at" + ], + "title": "NotificationSubscriptionSummary", + "type": "object" + }, + "RecoveryStatus": { + "properties": { + "encrypted_repository_count": { + "title": "Encrypted Repository Count", + "type": "integer" + }, + "recovery_mode": { + "const": "cli_only", + "title": "Recovery Mode", + "type": "string" + }, + "runbook": { + "title": "Runbook", + "type": "string" + } + }, + "required": [ + "recovery_mode", + "runbook", + "encrypted_repository_count" + ], + "title": "RecoveryStatus", + "type": "object" + }, + "RepositoryInput": { + "properties": { + "compression": { + "default": "none", + "title": "Compression", + "type": "string" + }, + "encryption": { + "default": "none", + "title": "Encryption", + "type": "string" + }, + "name": { + "maxLength": 255, + "minLength": 1, + "title": "Name", + "type": "string" + }, + "relative_path": { + "maxLength": 1024, + "minLength": 1, + "title": "Relative Path", + "type": "string" + } + }, + "required": [ + "name", + "relative_path" + ], + "title": "RepositoryInput", + "type": "object" + }, + "RepositoryList": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/RepositorySummary" + }, + "title": "Items", + "type": "array" + } + }, + "required": [ + "items" + ], + "title": "RepositoryList", + "type": "object" + }, + "RepositoryPatch": { + "properties": { + "compression": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Compression" + }, + "encryption": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Encryption" + } + }, + "title": "RepositoryPatch", + "type": "object" + }, + "RepositorySummary": { + "properties": { + "compression": { + "title": "Compression", + "type": "string" + }, + "encryption": { + "title": "Encryption", + "type": "string" + }, + "format_version": { + "title": "Format Version", + "type": "integer" + }, + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "state": { + "title": "State", + "type": "string" + } + }, + "required": [ + "id", + "name", + "format_version", + "compression", + "encryption", + "state" + ], + "title": "RepositorySummary", + "type": "object" + }, + "RestoreInput": { + "properties": { + "destination": { + "maxLength": 4096, + "minLength": 1, + "title": "Destination", + "type": "string" + }, + "dry_run": { + "default": false, + "title": "Dry Run", + "type": "boolean" + }, + "overwrite_policy": { + "default": "fail", + "title": "Overwrite Policy", + "type": "string" + }, + "selection": { + "items": { + "type": "string" + }, + "title": "Selection", + "type": "array" + } + }, + "required": [ + "destination" + ], + "title": "RestoreInput", + "type": "object" + }, + "SSHSourceInput": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "ssh", + "title": "Kind", + "type": "string" + }, + "name": { + "maxLength": 255, + "minLength": 1, + "title": "Name", + "type": "string" + }, + "private_key_secret_id": { + "maxLength": 36, + "minLength": 1, + "title": "Private Key Secret Id", + "type": "string" + }, + "public_config": { + "$ref": "#/components/schemas/SSHSourcePublicConfig" + } + }, + "required": [ + "name", + "kind", + "public_config", + "private_key_secret_id" + ], + "title": "SSHSourceInput", + "type": "object" + }, + "SSHSourcePublicConfig": { + "additionalProperties": false, + "description": "The public, SFTP-chroot-only portion of an SSH source definition.", + "properties": { + "host_key": { + "maxLength": 16384, + "minLength": 1, + "title": "Host Key", + "type": "string" + }, + "hostname": { + "maxLength": 253, + "minLength": 1, + "title": "Hostname", + "type": "string" + }, + "port": { + "maximum": 65535.0, + "minimum": 1.0, + "title": "Port", + "type": "integer" + }, + "root": { + "const": "/", + "title": "Root", + "type": "string" + }, + "username": { + "maxLength": 255, + "minLength": 1, + "title": "Username", + "type": "string" + } + }, + "required": [ + "hostname", + "port", + "username", + "host_key", + "root" + ], + "title": "SSHSourcePublicConfig", + "type": "object" + }, + "ScheduleInput": { + "properties": { + "cron": { + "maxLength": 255, + "minLength": 1, + "title": "Cron", + "type": "string" + }, + "enabled": { + "default": true, + "title": "Enabled", + "type": "boolean" + }, + "misfire_grace_seconds": { + "default": 900, + "minimum": 0.0, + "title": "Misfire Grace Seconds", + "type": "integer" + }, + "timezone": { + "maxLength": 255, + "minLength": 1, + "title": "Timezone", + "type": "string" + } + }, + "required": [ + "cron", + "timezone" + ], + "title": "ScheduleInput", + "type": "object" + }, + "ScheduleSummary": { + "properties": { + "cron": { + "title": "Cron", + "type": "string" + }, + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "id": { + "title": "Id", + "type": "string" + }, + "last_enqueue_outcome": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Last Enqueue Outcome" + }, + "next_nominal_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Next Nominal At" + }, + "timezone": { + "title": "Timezone", + "type": "string" + } + }, + "required": [ + "id", + "cron", + "timezone", + "enabled", + "next_nominal_at", + "last_enqueue_outcome" + ], + "title": "ScheduleSummary", + "type": "object" + }, + "SecretInput": { + "properties": { + "purpose": { + "maxLength": 64, + "minLength": 1, + "title": "Purpose", + "type": "string" + }, + "value": { + "maxLength": 65536, + "minLength": 1, + "title": "Value", + "type": "string" + } + }, + "required": [ + "purpose", + "value" + ], + "title": "SecretInput", + "type": "object" + }, + "SessionUser": { + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "state": { + "title": "State", + "type": "string" + }, + "username": { + "title": "Username", + "type": "string" + } + }, + "required": [ + "id", + "username", + "state" + ], + "title": "SessionUser", + "type": "object" + }, + "SetupInput": { + "properties": { + "bootstrap_secret": { + "anyOf": [ + { + "maxLength": 1024, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Bootstrap Secret" + }, + "password": { + "maxLength": 1024, + "minLength": 12, + "title": "Password", + "type": "string" + }, + "username": { + "maxLength": 255, + "minLength": 1, + "title": "Username", + "type": "string" + } + }, + "required": [ + "username", + "password" + ], + "title": "SetupInput", + "type": "object" + }, + "SigningKeyRotateInput": { + "properties": { + "overlap_seconds": { + "default": 3600, + "maximum": 86400.0, + "minimum": 60.0, + "title": "Overlap Seconds", + "type": "integer" + }, + "secret": { + "maxLength": 65536, + "minLength": 16, + "title": "Secret", + "type": "string" + } + }, + "required": [ + "secret" + ], + "title": "SigningKeyRotateInput", + "type": "object" + }, + "SourceList": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/SourceSummary" + }, + "title": "Items", + "type": "array" + } + }, + "required": [ + "items" + ], + "title": "SourceList", + "type": "object" + }, + "SourceSummary": { + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "kind": { + "title": "Kind", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "public_config": { + "additionalProperties": true, + "title": "Public Config", + "type": "object" + }, + "state": { + "title": "State", + "type": "string" + } + }, + "required": [ + "id", + "name", + "kind", + "state", + "public_config" + ], + "title": "SourceSummary", + "type": "object" + }, + "TokenInput": { + "properties": { + "expires_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Expires At" + }, + "scopes": { + "items": { + "type": "string" + }, + "minItems": 1, + "title": "Scopes", + "type": "array" + } + }, + "required": [ + "scopes" + ], + "title": "TokenInput", + "type": "object" + }, + "UserPatch": { + "properties": { + "state": { + "title": "State", + "type": "string" + } + }, + "required": [ + "state" + ], + "title": "UserPatch", + "type": "object" + }, + "ValidationError": { + "properties": { + "ctx": { + "title": "Context", + "type": "object" + }, + "input": { + "title": "Input" + }, + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "title": "Location", + "type": "array" + }, + "msg": { + "title": "Message", + "type": "string" + }, + "type": { + "title": "Error Type", + "type": "string" + } + }, + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError", + "type": "object" + } + } + }, + "info": { + "title": "Backup Tool API", + "version": "2.0.0" + }, + "openapi": "3.1.0", + "paths": { + "/api/v2/admin/secrets": { + "get": { + "operationId": "list_secrets_api_v2_admin_secrets_get", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "title": "Response List Secrets Api V2 Admin Secrets Get", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "List Secrets" + }, + "post": { + "operationId": "create_secret_api_v2_admin_secrets_post", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "header", + "name": "X-CSRF-Token", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Csrf-Token" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SecretInput" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Create Secret Api V2 Admin Secrets Post", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Create Secret" + } + }, + "/api/v2/admin/users/{user_id}": { + "get": { + "operationId": "get_user_api_v2_admin_users__user_id__get", + "parameters": [ + { + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "title": "User Id", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "title": "Response Get User Api V2 Admin Users User Id Get", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get User" + }, + "patch": { + "operationId": "patch_user_api_v2_admin_users__user_id__patch", + "parameters": [ + { + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "title": "User Id", + "type": "string" + } + }, + { + "in": "header", + "name": "If-Match", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "If-Match" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "header", + "name": "X-CSRF-Token", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Csrf-Token" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserPatch" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "title": "Response Patch User Api V2 Admin Users User Id Patch", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Patch User" + } + }, + "/api/v2/audit": { + "get": { + "operationId": "list_audit_api_v2_audit_get", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 50, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "cursor", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cursor" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuditList" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "List Audit" + } + }, + "/api/v2/auth/login": { + "post": { + "operationId": "login_api_v2_auth_login_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginInput" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthenticatedUser" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Login" + } + }, + "/api/v2/auth/logout": { + "post": { + "operationId": "logout_api_v2_auth_logout_post", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "header", + "name": "X-CSRF-Token", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Csrf-Token" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Logout" + } + }, + "/api/v2/auth/session": { + "get": { + "operationId": "get_session_api_v2_auth_session_get", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionUser" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Session" + } + }, + "/api/v2/auth/tokens": { + "post": { + "operationId": "create_token_api_v2_auth_tokens_post", + "parameters": [ + { + "in": "header", + "name": "Idempotency-Key", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Idempotency-Key" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "header", + "name": "X-CSRF-Token", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Csrf-Token" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenInput" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Create Token" + } + }, + "/api/v2/auth/tokens/{token_id}": { + "delete": { + "operationId": "revoke_token_api_v2_auth_tokens__token_id__delete", + "parameters": [ + { + "in": "path", + "name": "token_id", + "required": true, + "schema": { + "title": "Token Id", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "header", + "name": "X-CSRF-Token", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Csrf-Token" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Revoke Token" + } + }, + "/api/v2/backups": { + "get": { + "operationId": "list_backups_api_v2_backups_get", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackupList" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "List Backups" + } + }, + "/api/v2/backups/{backup_id}": { + "get": { + "operationId": "get_backup_api_v2_backups__backup_id__get", + "parameters": [ + { + "in": "path", + "name": "backup_id", + "required": true, + "schema": { + "title": "Backup Id", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackupSummary" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Backup" + } + }, + "/api/v2/backups/{backup_id}/delete-preview": { + "get": { + "operationId": "backup_delete_preview_api_v2_backups__backup_id__delete_preview_get", + "parameters": [ + { + "in": "path", + "name": "backup_id", + "required": true, + "schema": { + "title": "Backup Id", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackupDeletePreview" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Backup Delete Preview" + } + }, + "/api/v2/backups/{backup_id}/restores": { + "post": { + "operationId": "create_restore_api_v2_backups__backup_id__restores_post", + "parameters": [ + { + "in": "path", + "name": "backup_id", + "required": true, + "schema": { + "title": "Backup Id", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "header", + "name": "X-CSRF-Token", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Csrf-Token" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestoreInput" + } + } + }, + "required": true + }, + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Create Restore Api V2 Backups Backup Id Restores Post", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Create Restore" + } + }, + "/api/v2/backups/{backup_id}/verify": { + "post": { + "operationId": "verify_backup_api_v2_backups__backup_id__verify_post", + "parameters": [ + { + "in": "path", + "name": "backup_id", + "required": true, + "schema": { + "title": "Backup Id", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "header", + "name": "X-CSRF-Token", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Csrf-Token" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackupSummary" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Verify Backup" + } + }, + "/api/v2/executions": { + "get": { + "operationId": "list_executions_api_v2_executions_get", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExecutionList" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "List Executions" + } + }, + "/api/v2/executions/{execution_id}": { + "get": { + "operationId": "get_execution_api_v2_executions__execution_id__get", + "parameters": [ + { + "in": "path", + "name": "execution_id", + "required": true, + "schema": { + "title": "Execution Id", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExecutionSummary" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Execution" + } + }, + "/api/v2/executions/{execution_id}/cancel": { + "post": { + "operationId": "cancel_execution_api_v2_executions__execution_id__cancel_post", + "parameters": [ + { + "in": "path", + "name": "execution_id", + "required": true, + "schema": { + "title": "Execution Id", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "header", + "name": "X-CSRF-Token", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Csrf-Token" + } + } + ], + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Cancel Execution Api V2 Executions Execution Id Cancel Post", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Cancel Execution" + } + }, + "/api/v2/executions/{execution_id}/events": { + "get": { + "operationId": "execution_events_api_v2_executions__execution_id__events_get", + "parameters": [ + { + "in": "path", + "name": "execution_id", + "required": true, + "schema": { + "title": "Execution Id", + "type": "string" + } + }, + { + "in": "header", + "name": "Last-Event-ID", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Last-Event-Id" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + }, + "text/event-stream": {} + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Execution Events" + } + }, + "/api/v2/executions/{execution_id}/retry": { + "post": { + "operationId": "retry_execution_api_v2_executions__execution_id__retry_post", + "parameters": [ + { + "in": "path", + "name": "execution_id", + "required": true, + "schema": { + "title": "Execution Id", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "header", + "name": "X-CSRF-Token", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Csrf-Token" + } + } + ], + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Retry Execution Api V2 Executions Execution Id Retry Post", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Retry Execution" + } + }, + "/api/v2/jobs": { + "get": { + "operationId": "list_jobs_api_v2_jobs_get", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobList" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "List Jobs" + }, + "post": { + "operationId": "create_job_api_v2_jobs_post", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "header", + "name": "X-CSRF-Token", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Csrf-Token" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobInput" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Create Job Api V2 Jobs Post", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Create Job" + } + }, + "/api/v2/jobs/{job_id}/executions": { + "post": { + "operationId": "enqueue_execution_api_v2_jobs__job_id__executions_post", + "parameters": [ + { + "in": "path", + "name": "job_id", + "required": true, + "schema": { + "title": "Job Id", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "header", + "name": "X-CSRF-Token", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Csrf-Token" + } + } + ], + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Enqueue Execution Api V2 Jobs Job Id Executions Post", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Enqueue Execution" + } + }, + "/api/v2/jobs/{job_id}/schedule": { + "delete": { + "operationId": "delete_schedule_api_v2_jobs__job_id__schedule_delete", + "parameters": [ + { + "in": "path", + "name": "job_id", + "required": true, + "schema": { + "title": "Job Id", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "header", + "name": "X-CSRF-Token", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Csrf-Token" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Delete Schedule" + }, + "get": { + "operationId": "get_schedule_api_v2_jobs__job_id__schedule_get", + "parameters": [ + { + "in": "path", + "name": "job_id", + "required": true, + "schema": { + "title": "Job Id", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Get Schedule Api V2 Jobs Job Id Schedule Get", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Schedule" + }, + "patch": { + "operationId": "patch_schedule_api_v2_jobs__job_id__schedule_patch", + "parameters": [ + { + "in": "path", + "name": "job_id", + "required": true, + "schema": { + "title": "Job Id", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "header", + "name": "X-CSRF-Token", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Csrf-Token" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScheduleInput" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Patch Schedule Api V2 Jobs Job Id Schedule Patch", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Patch Schedule" + }, + "post": { + "operationId": "create_schedule_api_v2_jobs__job_id__schedule_post", + "parameters": [ + { + "in": "path", + "name": "job_id", + "required": true, + "schema": { + "title": "Job Id", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "header", + "name": "X-CSRF-Token", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Csrf-Token" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScheduleInput" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Create Schedule Api V2 Jobs Job Id Schedule Post", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Create Schedule" + } + }, + "/api/v2/notifications/deliveries": { + "get": { + "operationId": "list_notification_deliveries_api_v2_notifications_deliveries_get", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 50, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationDeliveryList" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "List Notification Deliveries" + } + }, + "/api/v2/notifications/deliveries/{delivery_id}/attempts": { + "get": { + "operationId": "list_notification_attempts_api_v2_notifications_deliveries__delivery_id__attempts_get", + "parameters": [ + { + "in": "path", + "name": "delivery_id", + "required": true, + "schema": { + "title": "Delivery Id", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationAttemptList" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "List Notification Attempts" + } + }, + "/api/v2/notifications/deliveries/{delivery_id}/retry": { + "post": { + "operationId": "retry_notification_delivery_api_v2_notifications_deliveries__delivery_id__retry_post", + "parameters": [ + { + "in": "path", + "name": "delivery_id", + "required": true, + "schema": { + "title": "Delivery Id", + "type": "string" + } + }, + { + "in": "header", + "name": "Idempotency-Key", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Idempotency-Key" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "header", + "name": "X-CSRF-Token", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Csrf-Token" + } + } + ], + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "title": "Response Retry Notification Delivery Api V2 Notifications Deliveries Delivery Id Retry Post", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Retry Notification Delivery" + } + }, + "/api/v2/notifications/email-settings": { + "get": { + "operationId": "get_notification_email_settings_api_v2_notifications_email_settings_get", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Get Notification Email Settings Api V2 Notifications Email Settings Get", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Notification Email Settings" + }, + "put": { + "operationId": "put_notification_email_settings_api_v2_notifications_email_settings_put", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "header", + "name": "X-CSRF-Token", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Csrf-Token" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmailSettingsInput" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Put Notification Email Settings Api V2 Notifications Email Settings Put", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Put Notification Email Settings" + } + }, + "/api/v2/notifications/event-catalog": { + "get": { + "operationId": "notification_catalog_api_v2_notifications_event_catalog_get", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Notification Catalog Api V2 Notifications Event Catalog Get", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Notification Catalog" + } + }, + "/api/v2/notifications/subscriptions": { + "get": { + "operationId": "list_notification_subscriptions_api_v2_notifications_subscriptions_get", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationSubscriptionList" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "List Notification Subscriptions" + }, + "post": { + "operationId": "create_notification_subscription_api_v2_notifications_subscriptions_post", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "header", + "name": "X-CSRF-Token", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Csrf-Token" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationSubscriptionInput" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Create Notification Subscription Api V2 Notifications Subscriptions Post", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Create Notification Subscription" + } + }, + "/api/v2/notifications/subscriptions/{subscription_id}": { + "get": { + "operationId": "get_notification_subscription_api_v2_notifications_subscriptions__subscription_id__get", + "parameters": [ + { + "in": "path", + "name": "subscription_id", + "required": true, + "schema": { + "title": "Subscription Id", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Get Notification Subscription Api V2 Notifications Subscriptions Subscription Id Get", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Notification Subscription" + }, + "patch": { + "operationId": "patch_notification_subscription_api_v2_notifications_subscriptions__subscription_id__patch", + "parameters": [ + { + "in": "path", + "name": "subscription_id", + "required": true, + "schema": { + "title": "Subscription Id", + "type": "string" + } + }, + { + "in": "header", + "name": "If-Match", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "If-Match" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "header", + "name": "X-CSRF-Token", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Csrf-Token" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationSubscriptionPatch" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Patch Notification Subscription Api V2 Notifications Subscriptions Subscription Id Patch", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Patch Notification Subscription" + } + }, + "/api/v2/notifications/subscriptions/{subscription_id}/signing-keys/rotate": { + "post": { + "operationId": "rotate_notification_signing_key_api_v2_notifications_subscriptions__subscription_id__signing_keys_rotate_post", + "parameters": [ + { + "in": "path", + "name": "subscription_id", + "required": true, + "schema": { + "title": "Subscription Id", + "type": "string" + } + }, + { + "in": "header", + "name": "Idempotency-Key", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Idempotency-Key" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "header", + "name": "X-CSRF-Token", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Csrf-Token" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SigningKeyRotateInput" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Rotate Notification Signing Key Api V2 Notifications Subscriptions Subscription Id Signing Keys Rotate Post", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Rotate Notification Signing Key" + } + }, + "/api/v2/notifications/subscriptions/{subscription_id}/test": { + "post": { + "operationId": "test_notification_subscription_api_v2_notifications_subscriptions__subscription_id__test_post", + "parameters": [ + { + "in": "path", + "name": "subscription_id", + "required": true, + "schema": { + "title": "Subscription Id", + "type": "string" + } + }, + { + "in": "header", + "name": "Idempotency-Key", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Idempotency-Key" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "header", + "name": "X-CSRF-Token", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Csrf-Token" + } + } + ], + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "title": "Response Test Notification Subscription Api V2 Notifications Subscriptions Subscription Id Test Post", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Test Notification Subscription" + } + }, + "/api/v2/repositories": { + "get": { + "operationId": "list_repositories_api_v2_repositories_get", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryList" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "List Repositories" + }, + "post": { + "operationId": "create_repository_api_v2_repositories_post", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "header", + "name": "X-CSRF-Token", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Csrf-Token" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryInput" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Create Repository Api V2 Repositories Post", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Create Repository" + } + }, + "/api/v2/repositories/{repository_id}": { + "get": { + "operationId": "get_repository_api_v2_repositories__repository_id__get", + "parameters": [ + { + "in": "path", + "name": "repository_id", + "required": true, + "schema": { + "title": "Repository Id", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Get Repository Api V2 Repositories Repository Id Get", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Repository" + }, + "patch": { + "operationId": "patch_repository_api_v2_repositories__repository_id__patch", + "parameters": [ + { + "in": "path", + "name": "repository_id", + "required": true, + "schema": { + "title": "Repository Id", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "header", + "name": "X-CSRF-Token", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Csrf-Token" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryPatch" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Patch Repository" + } + }, + "/api/v2/repositories/{repository_id}/inspection": { + "get": { + "operationId": "inspect_repository_endpoint_api_v2_repositories__repository_id__inspection_get", + "parameters": [ + { + "in": "path", + "name": "repository_id", + "required": true, + "schema": { + "title": "Repository Id", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Inspect Repository Endpoint Api V2 Repositories Repository Id Inspection Get", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Inspect Repository Endpoint" + } + }, + "/api/v2/restores/{restore_id}": { + "get": { + "operationId": "get_restore_api_v2_restores__restore_id__get", + "parameters": [ + { + "in": "path", + "name": "restore_id", + "required": true, + "schema": { + "title": "Restore Id", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Get Restore Api V2 Restores Restore Id Get", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Restore" + } + }, + "/api/v2/security/recovery/status": { + "get": { + "operationId": "recovery_status_api_v2_security_recovery_status_get", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecoveryStatus" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Recovery Status" + } + }, + "/api/v2/setup": { + "post": { + "operationId": "setup_api_v2_setup_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetupInput" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthenticatedUser" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Setup" + } + }, + "/api/v2/sources": { + "get": { + "operationId": "list_sources_api_v2_sources_get", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SourceList" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "List Sources" + }, + "post": { + "operationId": "create_source_api_v2_sources_post", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "header", + "name": "X-CSRF-Token", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Csrf-Token" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "discriminator": { + "mapping": { + "local": "#/components/schemas/LocalSourceInput", + "ssh": "#/components/schemas/SSHSourceInput" + }, + "propertyName": "kind" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/LocalSourceInput" + }, + { + "$ref": "#/components/schemas/SSHSourceInput" + } + ], + "title": "Input " + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Create Source Api V2 Sources Post", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Create Source" + } + }, + "/api/v2/sources/{source_id}": { + "delete": { + "operationId": "archive_source_api_v2_sources__source_id__delete", + "parameters": [ + { + "in": "path", + "name": "source_id", + "required": true, + "schema": { + "title": "Source Id", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "header", + "name": "X-CSRF-Token", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Csrf-Token" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Archive Source" + } + }, + "/api/v2/sources/{source_id}/probe": { + "post": { + "operationId": "probe_source_api_v2_sources__source_id__probe_post", + "parameters": [ + { + "in": "path", + "name": "source_id", + "required": true, + "schema": { + "title": "Source Id", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "header", + "name": "X-CSRF-Token", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Csrf-Token" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Probe Source Api V2 Sources Source Id Probe Post", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Probe Source" + } + }, + "/livez": { + "get": { + "operationId": "livez_livez_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "title": "Response Livez Livez Get", + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Livez" + } + }, + "/readyz": { + "get": { + "operationId": "readyz_readyz_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "title": "Response Readyz Readyz Get", + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Readyz" + } + } + } +} diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 0000000..b4feb43 --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,3 @@ +{ + "extraPaths": ["backend/src"] +} diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e5556b0 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Backup Tool v2 test suite.""" diff --git a/tests/compose.ssh.yaml b/tests/compose.ssh.yaml new file mode 100644 index 0000000..6ff80bd --- /dev/null +++ b/tests/compose.ssh.yaml @@ -0,0 +1,32 @@ +name: backup-tool-ssh-test + +services: + sshd: + build: + context: ./ssh-fixture + dockerfile: Dockerfile + read_only: true + tmpfs: + - /run/sshd:uid=0,gid=0,mode=0755,size=8m + - /tmp:mode=1777,size=8m + cap_drop: + - ALL + # sshd needs only these capabilities to chroot then drop to the SFTP account. + cap_add: + - SYS_CHROOT + - SETUID + - SETGID + - KILL + security_opt: + - no-new-privileges:true + ports: + - "127.0.0.1:${SSH_FIXTURE_PORT}:2222" + volumes: + - type: bind + source: ${SSH_FIXTURE_DIR} + target: /fixture + read_only: true + - type: bind + source: ${SSH_FIXTURE_DIR}/source + target: /home/backup/data + read_only: true diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..b57bbdf --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import importlib +from collections.abc import AsyncIterator +from pathlib import Path +from typing import Any + +import pytest_asyncio +from alembic import command +from httpx import ASGITransport, AsyncClient + +cli = importlib.import_module("backup_tool.cli") +config = importlib.import_module("backup_tool.config") +Settings = config.Settings + + +def make_settings(tmp_path: Path) -> Any: + key = tmp_path / "master.key" + key.write_bytes(b"m2-test-master-key-material-32-bytes-minimum") + key.chmod(0o600) + data = tmp_path / "data" + repositories = tmp_path / "repositories" + sources = tmp_path / "sources" + restores = tmp_path / "restores" + for path in (data, repositories, sources, restores): + path.mkdir() + return Settings( + data_dir=data, + database_url=f"sqlite+aiosqlite:///{data / 'metadata.db'}", + repository_roots=(repositories,), + local_source_roots=(sources,), + restore_roots=(restores,), + master_key_file=key, + public_base_url="https://127.0.0.1:8000", + ) + + +@pytest_asyncio.fixture +async def app_client(tmp_path: Path) -> AsyncIterator[tuple[AsyncClient, Any]]: + settings = make_settings(tmp_path) + command.upgrade(cli.build_alembic_config(settings), "head") + app_module = importlib.import_module("backup_tool.api.app") + app = app_module.create_app(settings) + async with AsyncClient( + transport=ASGITransport(app=app), base_url="https://127.0.0.1:8000" + ) as client: + yield client, settings + await app.state.engine.dispose() diff --git a/tests/contract/__init__.py b/tests/contract/__init__.py new file mode 100644 index 0000000..14deaa5 --- /dev/null +++ b/tests/contract/__init__.py @@ -0,0 +1 @@ +"""Contract tests.""" diff --git a/tests/contract/test_api_conventions.py b/tests/contract/test_api_conventions.py new file mode 100644 index 0000000..d357514 --- /dev/null +++ b/tests/contract/test_api_conventions.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +import importlib +from datetime import UTC, datetime, timedelta +from uuid import UUID + +import pytest +from httpx import ASGITransport, AsyncClient + +from tests.conftest import make_settings + + +async def setup_admin( + client, username: str = "admin", password: str = "correct horse battery staple" +): + return await client.post("/api/v2/setup", json={"username": username, "password": password}) + + +@pytest.mark.asyncio +async def test_livez_is_public_and_request_ids_are_returned(app_client) -> None: + client, _ = app_client + response = await client.get("/livez") + assert response.status_code == 200 + assert response.json() == {"status": "alive"} + assert UUID(response.headers["X-Request-ID"]).version == 7 + + +@pytest.mark.asyncio +async def test_readyz_rejects_unmigrated_database(tmp_path) -> None: + settings = make_settings(tmp_path) + app = importlib.import_module("backup_tool.api.app").create_app(settings) + async with AsyncClient( + transport=ASGITransport(app=app), base_url="https://testserver" + ) as client: + response = await client.get("/readyz") + await app.state.engine.dispose() + assert response.status_code == 503 + assert response.json()["code"] == "dependency_unavailable" + + +@pytest.mark.asyncio +async def test_readyz_reports_runtime_dependencies_before_and_after_setup( + app_client, +) -> None: + client, _ = app_client + before = await client.get("/readyz") + assert before.status_code == 200 + assert before.json()["status"] == "ready" + + assert (await setup_admin(client)).status_code == 201 + after = await client.get("/readyz") + assert after.status_code == 200 + assert after.json()["status"] == "ready" + + +@pytest.mark.asyncio +async def test_metrics_are_prometheus_text_without_sensitive_request_data( + app_client, +) -> None: + client, _ = app_client + assert (await client.get("/livez")).status_code == 200 + + response = await client.get("/metrics") + + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/plain; version=0.0.4") + assert "backup_tool_http_requests_total" in response.text + assert "backup_tool_active_executions" in response.text + assert "backup_tool_filesystem_free_bytes" in response.text + + +@pytest.mark.asyncio +async def test_protected_endpoint_uses_rfc9457_problem(app_client) -> None: + client, _ = app_client + response = await client.get("/api/v2/audit") + assert response.status_code == 401 + body = response.json() + assert response.headers["content-type"].startswith("application/problem+json") + assert {"type", "title", "status", "detail", "instance", "code"} <= body.keys() + assert body["code"] == "authentication_required" + + +@pytest.mark.asyncio +async def test_cursor_pagination_is_stable(app_client) -> None: + client, _ = app_client + assert (await setup_admin(client)).status_code == 201 + csrf = client.cookies["backup_tool_csrf"] + headers = {"X-CSRF-Token": csrf} + for purpose in ("one", "two", "three"): + response = await client.post( + "/api/v2/admin/secrets", + json={"purpose": purpose, "value": f"secret-{purpose}"}, + headers=headers, + ) + assert response.status_code == 201 + + first = await client.get("/api/v2/audit", params={"limit": 2}) + assert first.status_code == 200 + first_body = first.json() + assert len(first_body["items"]) == 2 + assert first_body["next_cursor"] + + second = await client.get( + "/api/v2/audit", params={"limit": 2, "cursor": first_body["next_cursor"]} + ) + assert second.status_code == 200 + assert not ( + {item["id"] for item in first_body["items"]} + & {item["id"] for item in second.json()["items"]} + ) + + +@pytest.mark.asyncio +async def test_etag_conflict_is_rejected(app_client) -> None: + client, _ = app_client + created = await setup_admin(client) + user_id = created.json()["id"] + csrf = client.cookies["backup_tool_csrf"] + current = await client.get(f"/api/v2/admin/users/{user_id}") + assert current.status_code == 200 + etag = current.headers["ETag"] + + updated = await client.patch( + f"/api/v2/admin/users/{user_id}", + json={"state": "active"}, + headers={"If-Match": etag, "X-CSRF-Token": csrf}, + ) + assert updated.status_code == 200 + + stale = await client.patch( + f"/api/v2/admin/users/{user_id}", + json={"state": "active"}, + headers={"If-Match": etag, "X-CSRF-Token": csrf}, + ) + assert stale.status_code == 412 + assert stale.json()["code"] == "etag_mismatch" + + +@pytest.mark.asyncio +async def test_idempotency_key_is_bound_to_request_digest(app_client) -> None: + client, _ = app_client + assert (await setup_admin(client)).status_code == 201 + csrf = client.cookies["backup_tool_csrf"] + headers = {"X-CSRF-Token": csrf, "Idempotency-Key": "stable-key"} + expires = (datetime.now(UTC) + timedelta(hours=1)).isoformat() + first = await client.post( + "/api/v2/auth/tokens", + json={"scopes": ["audit:read"], "expires_at": expires}, + headers=headers, + ) + assert first.status_code == 201 + replay = await client.post( + "/api/v2/auth/tokens", + json={"scopes": ["audit:read"], "expires_at": expires}, + headers=headers, + ) + assert replay.status_code == 200 + assert replay.json()["id"] == first.json()["id"] + assert replay.json()["token"] is None + + mismatch = await client.post( + "/api/v2/auth/tokens", + json={"scopes": ["admin:write"], "expires_at": expires}, + headers=headers, + ) + assert mismatch.status_code == 409 + assert mismatch.json()["code"] == "idempotency_mismatch" diff --git a/tests/contract/test_no_v1.py b/tests/contract/test_no_v1.py new file mode 100644 index 0000000..9d9b7a7 --- /dev/null +++ b/tests/contract/test_no_v1.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).parents[2] + + +def test_v1_executable_packages_are_absent() -> None: + assert not (ROOT / "backend" / "app").exists() + assert not (ROOT / "backend" / "backup").exists() + assert not (ROOT / "backend" / "tests").exists() + assert not (ROOT / "frontend" / "src" / "api" / "client.ts").exists() + + +def test_v1_reference_tag_exists() -> None: + result = subprocess.run( + ["git", "tag", "--list", "v1-reference-2026-07-27"], + cwd=ROOT, + capture_output=True, + check=False, + text=True, + ) + assert result.returncode == 0 + assert result.stdout.strip() == "v1-reference-2026-07-27" + + +def run_scanner(root: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(ROOT / "tools/forbidden_v1_scan.py"), str(root)], + cwd=ROOT, + capture_output=True, + check=False, + text=True, + ) + + +def test_forbidden_v1_scanner_accepts_runtime_tree() -> None: + result = run_scanner(ROOT) + assert result.returncode == 0, result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("relative_path", "canary"), + [ + ("backend/src/backup_tool/database.py", 'DB_PATH = "backup_tool.db"'), + ("backend/src/backup_tool/payload.py", "def read_legacy_payload(): ..."), + ("backend/src/backup_tool/importer.py", "class LegacyBackupImporter: ..."), + ("backend/src/backup_tool/converter.py", "def convert_v1_backup(): ..."), + ("backend/src/backup_tool/layout.py", 'FORMAT = "%Y-%m-%d_%H%M%S"'), + ("docker-compose.yml", "command: uvicorn app.main:app"), + ], +) +def test_forbidden_v1_scanner_rejects_runtime_and_config_canaries( + tmp_path: Path, + relative_path: str, + canary: str, +) -> None: + path = tmp_path / relative_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(canary, encoding="utf-8") + + result = run_scanner(tmp_path) + + assert result.returncode == 1, relative_path + assert str(relative_path) in result.stderr diff --git a/tests/contract/test_notification_contract.py b/tests/contract/test_notification_contract.py new file mode 100644 index 0000000..a24f4d8 --- /dev/null +++ b/tests/contract/test_notification_contract.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import pytest +from backup_tool.db.models import IdempotencyRecord +from sqlalchemy import select + +PASSWORD = "correct horse battery staple" + + +@pytest.mark.asyncio +async def test_catalog_subscription_and_write_only_webhook_secret(app_client) -> None: + client, _ = app_client + setup = await client.post("/api/v2/setup", json={"username": "admin", "password": PASSWORD}) + assert setup.status_code == 201 + csrf = client.cookies["backup_tool_csrf"] + catalog = await client.get("/api/v2/notifications/event-catalog") + assert catalog.status_code == 200 + assert catalog.json()["event_schema_version"] == 1 + assert "execution.queued" in catalog.json()["events"] + created = await client.post( + "/api/v2/notifications/subscriptions", + json={ + "channel": "webhook", + "event_filters": ["execution.*"], + "destination": {"url": "https://hooks.example.test/backup"}, + "signing_secret": "not-returned-webhook-secret", + }, + headers={"X-CSRF-Token": csrf}, + ) + assert created.status_code == 201 + assert "signing_secret" not in created.text + assert "not-returned-webhook-secret" not in created.text + assert created.headers["ETag"] + + +@pytest.mark.asyncio +async def test_rotation_idempotency_never_persists_secret_verifier(app_client) -> None: + client, _ = app_client + setup = await client.post("/api/v2/setup", json={"username": "admin", "password": PASSWORD}) + assert setup.status_code == 201 + csrf = client.cookies["backup_tool_csrf"] + created = await client.post( + "/api/v2/notifications/subscriptions", + json={ + "channel": "webhook", + "event_filters": ["execution.*"], + "destination": {"url": "https://hooks.example.test/backup"}, + "signing_secret": "first-signing-secret", + }, + headers={"X-CSRF-Token": csrf}, + ) + assert created.status_code == 201 + route = f"/api/v2/notifications/subscriptions/{created.json()['id']}/signing-keys/rotate" + first = await client.post( + route, + json={"secret": "rotation-secret-one", "overlap_seconds": 60}, + headers={"X-CSRF-Token": csrf, "Idempotency-Key": "rotation-one"}, + ) + replay = await client.post( + route, + json={"secret": "rotation-secret-two", "overlap_seconds": 60}, + headers={"X-CSRF-Token": csrf, "Idempotency-Key": "rotation-one"}, + ) + assert first.status_code == replay.status_code == 200 + assert first.json() == replay.json() + app = client._transport.app + async with app.state.sessions() as db: + record = await db.scalar( + select(IdempotencyRecord).where( + IdempotencyRecord.operation == "rotate_notification_signing_key" + ) + ) + assert record is not None + assert "rotation-secret" not in record.request_digest + + +@pytest.mark.asyncio +async def test_notification_rejects_empty_or_unknown_filters(app_client) -> None: + client, _ = app_client + setup = await client.post("/api/v2/setup", json={"username": "admin", "password": PASSWORD}) + assert setup.status_code == 201 + response = await client.post( + "/api/v2/notifications/subscriptions", + json={ + "channel": "email", + "event_filters": ["unknown.event"], + "destination": {"recipients": ["operator@example.test"]}, + }, + headers={"X-CSRF-Token": client.cookies["backup_tool_csrf"]}, + ) + assert response.status_code == 422 + assert response.json()["code"] == "validation_failed" diff --git a/tests/contract/test_repository_format.py b/tests/contract/test_repository_format.py new file mode 100644 index 0000000..0576110 --- /dev/null +++ b/tests/contract/test_repository_format.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +import json +from copy import deepcopy +from pathlib import Path +from typing import Any + +import jsonschema +import pytest + +ROOT = Path(__file__).parents[2] +CONTRACT = ROOT / "contracts" / "repository" / "v1" +FIXTURES = CONTRACT / "fixtures" + + +def load(path: Path) -> Any: + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise AssertionError(f"invalid contract JSON: {path}") from error + + +def validator(name: str) -> jsonschema.Draft202012Validator: + schema = load(CONTRACT / f"{name}.schema.json") + jsonschema.Draft202012Validator.check_schema(schema) + return jsonschema.Draft202012Validator( + schema, + format_checker=jsonschema.FormatChecker(), + ) + + +def test_repository_and_manifest_golden_files_validate() -> None: + for name in ("repository", "manifest"): + validator(name).validate(load(FIXTURES / f"valid-{name}.json")) + + +def test_invalid_golden_files_are_rejected() -> None: + for name in ("repository", "manifest"): + with pytest.raises(jsonschema.ValidationError): + validator(name).validate(load(FIXTURES / f"invalid-{name}.json")) + + +def test_valid_golden_files_are_canonical_json() -> None: + for path in sorted(FIXTURES.glob("valid-*.json")): + data = load(path) + canonical = json.dumps(data, sort_keys=True, separators=(",", ":")) + "\n" + assert path.read_text(encoding="utf-8") == canonical + + +def test_protocol_contracts_are_versioned_and_closed() -> None: + repository_schema = load(CONTRACT / "repository.schema.json") + manifest_schema = load(CONTRACT / "manifest.schema.json") + + assert repository_schema["properties"]["format_version"]["const"] == 1 + assert manifest_schema["properties"]["format_version"]["const"] == 1 + assert not repository_schema["additionalProperties"] + assert not manifest_schema["additionalProperties"] + + +def test_uuidv7_and_rfc3339_formats_are_enforced() -> None: + repository = load(FIXTURES / "valid-repository.json") + manifest = load(FIXTURES / "valid-manifest.json") + + repository["repository_id"] = "123e4567-e89b-42d3-a456-426614174000" + with pytest.raises(jsonschema.ValidationError): + validator("repository").validate(repository) + + manifest["created_at"] = "2026-07-27 12:00:00" + with pytest.raises(jsonschema.ValidationError): + validator("manifest").validate(manifest) + + +def test_manifest_signature_and_source_consistency_are_required() -> None: + manifest = load(FIXTURES / "valid-manifest.json") + assert manifest["manifest_signature"]["algorithm"] == "ed25519" + + missing_signature = deepcopy(manifest) + del missing_signature["manifest_signature"] + with pytest.raises(jsonschema.ValidationError): + validator("manifest").validate(missing_signature) + + incomplete_consistency = deepcopy(manifest) + incomplete_consistency["source_consistency"] = {"adapter": "local"} + with pytest.raises(jsonschema.ValidationError): + validator("manifest").validate(incomplete_consistency) + + +def test_entry_types_bind_blobs_and_safe_link_targets() -> None: + manifest = load(FIXTURES / "valid-manifest.json") + entries = manifest["entries"] + + file_without_blob = deepcopy(manifest) + file_without_blob["entries"][1]["blob_digest"] = None + with pytest.raises(jsonschema.ValidationError): + validator("manifest").validate(file_without_blob) + + directory_with_blob = deepcopy(manifest) + directory_with_blob["entries"][0]["blob_digest"] = "c" * 64 + with pytest.raises(jsonschema.ValidationError): + validator("manifest").validate(directory_with_blob) + + for unsafe_target in ("/etc/passwd", "../escape", "dir\\escape"): + unsafe_symlink = deepcopy(manifest) + unsafe_symlink["entries"] = [ + { + "blob_digest": None, + "link_target": unsafe_target, + "metadata_support": [], + "mode": None, + "mtime_ns": None, + "path": "link", + "size": 0, + "type": "symlink", + } + ] + with pytest.raises(jsonschema.ValidationError): + validator("manifest").validate(unsafe_symlink) + + assert entries[1]["type"] == "file" + + +def test_normalized_path_vectors_are_relative_posix_paths() -> None: + vectors = load(CONTRACT / "normalized-paths.json") + valid = [case for case in vectors if case["valid"]] + invalid = [case for case in vectors if not case["valid"]] + + assert {case["raw"] for case in invalid} >= {"/absolute", "../escape", "a\\b"} + for case in valid: + normalized = case["normalized"] + assert normalized and not normalized.startswith("/") + assert "\\" not in normalized + assert ".." not in normalized.split("/") + + +def test_state_errors_capabilities_and_fault_points_are_frozen() -> None: + transitions = load(CONTRACT / "execution-transitions.json") + assert transitions["queued"] == ["cancelled", "preparing"] + assert transitions["verifying"] == ["committed", "failed"] + assert transitions["committed"] == [] + + errors = load(CONTRACT / "error-codes.json") + assert len(errors) == len(set(errors)) + assert all(code == code.lower() and " " not in code for code in errors) + + capabilities = load(CONTRACT / "capabilities-v2.0.json") + assert capabilities["sources"] == ["local", "ssh"] + + manifest_schema = load(CONTRACT / "manifest.schema.json") + adapter_kinds = manifest_schema["properties"]["source_consistency"]["properties"]["adapter"][ + "enum" + ] + assert adapter_kinds == ["local", "postgresql", "mysql"] + assert "ssh" not in adapter_kinds + + assert not capabilities["features"]["tar_download"] + assert not capabilities["features"]["postgresql"] + assert not capabilities["features"]["mysql"] + + fault_points = load(CONTRACT / "fault-points.json") + assert { + "blob.before_write", + "blob.after_fsync", + "manifest.before_publish", + "metadata.before_commit", + "restore.before_replace", + }.issubset(fault_points) diff --git a/tests/e2e/.gitkeep b/tests/e2e/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/test_compose_v2.py b/tests/e2e/test_compose_v2.py new file mode 100644 index 0000000..2629a5a --- /dev/null +++ b/tests/e2e/test_compose_v2.py @@ -0,0 +1,183 @@ +"""Opt-in production-like Compose checks; no fixture secret is written to the repository.""" + +from __future__ import annotations + +import os +import socket +import subprocess +import time +import urllib.error +import urllib.request +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] + + +def _enabled() -> bool: + return os.environ.get("BACKUP_TOOL_COMPOSE_E2E") == "1" + + +def _free_port() -> int: + try: + with socket.socket() as listener: + listener.bind(("127.0.0.1", 0)) + address = listener.getsockname() + except OSError as error: + raise RuntimeError("could not allocate a Compose test port") from error + if not isinstance(address, tuple) or not isinstance(address[1], int): + raise RuntimeError("could not allocate a Compose test port") + return address[1] + + +def _compose(environment: dict[str, str], *arguments: str) -> subprocess.CompletedProcess[str]: + command = [ + "docker", + "compose", + "-f", + "docker-compose.yml", + "-f", + environment["COMPOSE_FILE"], + *arguments, + ] + return subprocess.run( + command, + cwd=ROOT, + env=environment, + check=True, + text=True, + capture_output=True, + timeout=120, + ) + + +def _eventually_get(url: str, expected_status: int) -> str: + deadline = time.monotonic() + 90 + while time.monotonic() < deadline: + try: + with urllib.request.urlopen(url, timeout=3) as response: + if response.status == expected_status: + return response.read().decode() + except urllib.error.HTTPError as error: + if error.code == expected_status: + return error.read().decode() + except OSError: + pass + time.sleep(1) + raise AssertionError(f"{url} did not return {expected_status}") + + +@pytest.mark.skipif( + not _enabled(), reason="set BACKUP_TOOL_COMPOSE_E2E=1 to run Docker Compose E2E" +) +def test_compose_persists_metadata_and_stops_workers_safely(tmp_path: Path) -> None: + source = tmp_path / "source" + source.mkdir(mode=0o755) + fixture_key = tmp_path / "unused-bind-master.key" + fixture_key.write_bytes(b"compose-test-host-fixture-key-material-32-bytes") + fixture_key.chmod(0o600) + port = _free_port() + project = f"backup-tool-e2e-{os.getpid()}" + override = tmp_path / "compose-e2e.yaml" + override.write_text( + "services:\n" + " migrate:\n" + " environment: &e2e-env\n" + " BACKUP_TOOL_MASTER_KEY_FILE: /var/lib/backup-tool/master.key\n" + f" BACKUP_TOOL_PUBLIC_BASE_URL: http://localhost:{port}\n" + " web:\n" + " environment: *e2e-env\n" + " scheduler:\n" + " environment: *e2e-env\n" + " worker:\n" + " environment: *e2e-env\n" + " admin:\n" + " environment: *e2e-env\n" + ) + environment = os.environ | { + "BACKUP_TOOL_COMPOSE_E2E": "1", + "BACKUP_TOOL_MASTER_KEY_FILE": str(fixture_key), + "BACKUP_TOOL_PORT": str(port), + "BACKUP_TOOL_SOURCES_DIR": str(source), + "COMPOSE_FILE": str(override), + "COMPOSE_PROJECT_NAME": project, + } + try: + # The service user creates the actual key inside its private named volume; + # the host key exists only to satisfy the unused read-only Compose bind. + _compose( + environment, + "run", + "--rm", + "--no-deps", + "--entrypoint", + "/bin/sh", + "migrate", + "-c", + ( + "umask 077; dd if=/dev/urandom of=/var/lib/backup-tool/master.key " + "bs=32 count=1 status=none" + ), + ) + _compose(environment, "run", "--rm", "migrate") + _compose(environment, "up", "-d") + assert _eventually_get(f"http://127.0.0.1:{port}/readyz", 200) + setup = urllib.request.Request( + f"http://127.0.0.1:{port}/api/v2/setup", + data=b'{"username":"operator","password":"correct horse battery staple"}', + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(setup, timeout=10) as response: + assert response.status == 201 + assert "backup_tool_active_executions" in _eventually_get( + f"http://127.0.0.1:{port}/metrics", 200 + ) + + _compose(environment, "stop", "--timeout", "15", "worker") + worker_id = _compose(environment, "ps", "-aq", "worker").stdout.strip() + assert worker_id + stopped = subprocess.run( + ["docker", "inspect", "--format", "{{.State.ExitCode}}", worker_id], + check=True, + text=True, + capture_output=True, + timeout=30, + ) + assert stopped.stdout.strip() == "0" + _compose(environment, "up", "-d", "worker") + _eventually_get(f"http://127.0.0.1:{port}/readyz", 200) + + _compose(environment, "restart", "web", "scheduler", "worker", "proxy") + assert _eventually_get(f"http://127.0.0.1:{port}/readyz", 200) + repeat_setup = urllib.request.Request( + f"http://127.0.0.1:{port}/api/v2/setup", + data=b'{"username":"operator","password":"correct horse battery staple"}', + headers={"Content-Type": "application/json"}, + method="POST", + ) + with pytest.raises(urllib.error.HTTPError) as repeated: + urllib.request.urlopen(repeat_setup, timeout=10) + assert repeated.value.code == 409 + finally: + command = [ + "docker", + "compose", + "-f", + "docker-compose.yml", + "-f", + str(override), + "down", + "--volumes", + "--remove-orphans", + ] + subprocess.run( + command, + cwd=ROOT, + env=environment, + check=False, + text=True, + capture_output=True, + timeout=120, + ) diff --git a/tests/fault/.gitkeep b/tests/fault/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/fault/test_deletion_failure.py b/tests/fault/test_deletion_failure.py new file mode 100644 index 0000000..c15c049 --- /dev/null +++ b/tests/fault/test_deletion_failure.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from datetime import timedelta +from pathlib import Path +from unittest.mock import patch + +import pytest +from backup_tool.gc import purge_repository + + +def test_gc_keeps_manifest_when_unlink_fails(tmp_path: Path) -> None: + root = tmp_path / "repository" + manifest = root / "manifests" / "deleted.json" + manifest.parent.mkdir(parents=True) + manifest.write_text('{"entries": []}', encoding="utf-8") + + with ( + patch("pathlib.Path.unlink", side_effect=OSError("read-only")), + pytest.raises(OSError), + ): + purge_repository(root, {"deleted"}, grace=timedelta(0)) + + assert manifest.exists() diff --git a/tests/fault/test_notification_retries.py b/tests/fault/test_notification_retries.py new file mode 100644 index 0000000..24c0e5c --- /dev/null +++ b/tests/fault/test_notification_retries.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +import pytest +from backup_tool.db.models import ( + Execution, + Job, + NotificationDelivery, + NotificationDeliveryAttempt, + NotificationEmailSettings, + NotificationSubscription, + Repository, + Secret, + Source, +) +from backup_tool.ids import new_uuid7 +from backup_tool.notifications.dispatcher import ( + dispatch_one, + recover_notification_leases, +) +from backup_tool.notifications.email import EmailResult, EmailTransportError +from backup_tool.notifications.events import emit_event +from backup_tool.worker import Worker +from sqlalchemy import select + + +@pytest.mark.asyncio +async def test_transient_smtp_failure_retries_and_lease_recovers(app_client, monkeypatch) -> None: + client, settings = app_client + app = client._transport.app + async with app.state.sessions() as db: + ciphertext, key_id = app.state.cipher.encrypt( + "smtp-password", purpose="notification_smtp", version=1 + ) + secret = Secret(ciphertext=ciphertext, key_id=key_id, purpose="notification_smtp") + db.add(secret) + await db.flush() + db.add( + NotificationEmailSettings( + id=1, + host="smtp.example.test", + port=587, + username="operator", + password_secret_id=secret.id, + sender="sender@example.test", + max_attempts=2, + rate_limit_per_minute=60, + ) + ) + subscription = NotificationSubscription( + channel="email", + event_filters=["execution.queued"], + destination_config={"recipients": ["operator@example.test"]}, + rate_limit_per_minute=60, + rate_tokens=60.0, + ) + db.add(subscription) + await db.flush() + event = await emit_event( + db, + "execution.queued", + correlation_id=str(new_uuid7()), + resource={}, + deduplication_key="retry-test", + ) + await db.commit() + + async def transient(*_args, **_kwargs): + raise EmailTransportError("smtp_421", transient=True) + + monkeypatch.setattr("backup_tool.notifications.dispatcher.deliver_email", transient) + assert await dispatch_one(db, settings, app.state.cipher, "worker-test") + delivery = await db.scalar( + select(NotificationDelivery).where(NotificationDelivery.event_id == event.id) + ) + assert delivery is not None + assert delivery.state == "retry" + assert delivery.attempt_count == 1 + delivery.state = "leased" + delivery.attempt_count = 2 + delivery.lease_expires_at = datetime.now(UTC) - timedelta(seconds=1) + db.add( + NotificationDeliveryAttempt( + delivery_id=delivery.id, + number=2, + started_at=datetime.now(UTC), + outcome="started", + ) + ) + await db.commit() + assert await recover_notification_leases(db) == 1 + await db.refresh(delivery) + abandoned = await db.scalar( + select(NotificationDeliveryAttempt).where( + NotificationDeliveryAttempt.delivery_id == delivery.id, + NotificationDeliveryAttempt.number == 2, + ) + ) + assert delivery.state == "retry" + assert abandoned is not None + assert abandoned.outcome == "retry" + assert abandoned.diagnostic == "abandoned_lease" + + async def succeeded(*_args, **_kwargs): + return EmailResult(response_class="smtp_2xx") + + monkeypatch.setattr("backup_tool.notifications.dispatcher.deliver_email", succeeded) + delivery.due_at = datetime.now(UTC) - timedelta(seconds=1) + await db.commit() + assert await dispatch_one(db, settings, app.state.cipher, "worker-test") + await db.refresh(delivery) + assert delivery.state == "delivered" + assert delivery.attempt_count == 3 + + max_event = await emit_event( + db, + "execution.queued", + correlation_id=str(new_uuid7()), + resource={}, + deduplication_key="smtp-max-attempts", + ) + await db.commit() + monkeypatch.setattr("backup_tool.notifications.dispatcher.deliver_email", transient) + assert await dispatch_one(db, settings, app.state.cipher, "worker-test") + max_delivery = await db.scalar( + select(NotificationDelivery).where(NotificationDelivery.event_id == max_event.id) + ) + assert max_delivery is not None and max_delivery.state == "retry" + max_delivery.due_at = datetime.now(UTC) - timedelta(seconds=1) + await db.commit() + assert await dispatch_one(db, settings, app.state.cipher, "worker-test") + await db.refresh(max_delivery) + assert max_delivery.state == "failed" + assert max_delivery.attempt_count == 2 + + permanent_event = await emit_event( + db, + "execution.queued", + correlation_id=str(new_uuid7()), + resource={}, + deduplication_key="smtp-permanent", + ) + await db.commit() + + async def permanent(*_args, **_kwargs): + raise EmailTransportError("smtp_550", transient=False) + + monkeypatch.setattr("backup_tool.notifications.dispatcher.deliver_email", permanent) + assert await dispatch_one(db, settings, app.state.cipher, "worker-test") + permanent_delivery = await db.scalar( + select(NotificationDelivery).where(NotificationDelivery.event_id == permanent_event.id) + ) + assert permanent_delivery is not None + assert permanent_delivery.state == "failed" + assert permanent_delivery.attempt_count == 1 + + +@pytest.mark.asyncio +async def test_notification_dispatch_gets_a_turn_during_execution_backlog( + app_client, monkeypatch +) -> None: + client, settings = app_client + app = client._transport.app + async with app.state.sessions() as db: + repository = Repository( + name="fair-repository", + root="/fair-repository", + format_version=1, + compression="none", + encryption="none", + ) + source = Source( + name="fair-source", + kind="local", + public_config={"root": "/fair-source"}, + secret_refs=[], + ) + db.add_all([repository, source]) + await db.flush() + job = Job( + name="fair-job", + source_id=source.id, + repository_id=repository.id, + exclusions=[], + retention={}, + requested_mode="full", + ) + db.add(job) + await db.flush() + execution = Execution(job_id=job.id, trigger="manual", progress={}) + db.add(execution) + await db.commit() + execution_id = execution.id + + called = False + + async def dispatched(*_args, **_kwargs) -> bool: + nonlocal called + called = True + return True + + monkeypatch.setattr("backup_tool.worker.dispatch_one", dispatched) + worker = Worker(settings, owner="fair-worker") + worker._execution_turns = 1 + try: + assert await worker.run_once() + finally: + await worker.engine.dispose() + async with app.state.sessions() as db: + queued = await db.get(Execution, execution_id) + assert called + assert queued is not None and queued.state == "queued" diff --git a/tests/fault/test_publication_crashes.py b/tests/fault/test_publication_crashes.py new file mode 100644 index 0000000..c7315e8 --- /dev/null +++ b/tests/fault/test_publication_crashes.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +import importlib +import os +import stat +from pathlib import Path + +import httpx +import pytest +from backup_tool.config import Settings +from backup_tool.db.engine import create_engine +from sqlalchemy import select +from sqlalchemy.ext.asyncio import async_sessionmaker + +config = importlib.import_module("backup_tool.config") +adapters = importlib.import_module("backup_tool.adapters") +faults = importlib.import_module("backup_tool.faults") +models = importlib.import_module("backup_tool.db.models") +snapshot = importlib.import_module("backup_tool.snapshot") +worker_module = importlib.import_module("backup_tool.worker") + +PASSWORD = "correct-horse-battery-staple" + + +def settings_for(tmp_path: Path): + key = tmp_path / "master.key" + key.write_bytes(b"m6-publication-fault-test-master-key-material") + key.chmod(0o600) + data = tmp_path / "data" + repositories = tmp_path / "repositories" + sources = tmp_path / "sources" + restores = tmp_path / "restores" + for directory in (data, repositories, sources, restores): + directory.mkdir() + return config.Settings( + data_dir=data, + database_url=f"sqlite+aiosqlite:///{data / 'metadata.db'}", + repository_roots=(repositories,), + local_source_roots=(sources,), + restore_roots=(restores,), + master_key_file=key, + min_free_bytes=1, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("point", ["blob.before_write", "blob.after_write", "blob.after_fsync"]) +async def test_blob_write_crash_points_leave_no_published_blob(tmp_path: Path, point: str) -> None: + settings = settings_for(tmp_path) + source_root = settings.local_source_roots[0] / "source" + source_root.mkdir() + (source_root / "data.txt").write_text("backup data", encoding="utf-8") + adapter = adapters.LocalAdapter(source_root, settings) + staged_blob = tmp_path / "staged.blob" + + with pytest.raises(faults.InjectedCrash): + await snapshot._copy_file(adapter, "data.txt", staged_blob, faults.CrashAt(point)) + + assert not (settings.repository_roots[0] / "blobs" / "sha256").exists() + + +@pytest.mark.asyncio +async def test_staging_is_owner_only_with_a_permissive_umask(tmp_path: Path) -> None: + settings = settings_for(tmp_path) + source_root = settings.local_source_roots[0] / "source" + source_root.mkdir() + (source_root / "data.txt").write_text("plaintext", encoding="utf-8") + adapter = adapters.LocalAdapter(source_root, settings) + staging = tmp_path / "staging" + staged_blobs = staging / "blobs" + staged_blob = staged_blobs / "0.blob" + + old_umask = os.umask(0) + try: + snapshot._private_directory(staging) + snapshot._private_directory(staged_blobs) + with pytest.raises(faults.InjectedCrash): + await snapshot._copy_file( + adapter, + "data.txt", + staged_blob, + faults.CrashAt("blob.after_write"), + ) + finally: + os.umask(old_umask) + + assert stat.S_IMODE(staging.stat().st_mode) == 0o700 + assert stat.S_IMODE(staged_blobs.stat().st_mode) == 0o700 + assert stat.S_IMODE(staged_blob.stat().st_mode) == 0o600 + assert staged_blob.read_text(encoding="utf-8") == "plaintext" + + +def test_blob_install_crash_point_leaves_staged_blob_unpublished( + tmp_path: Path, +) -> None: + settings = settings_for(tmp_path) + staged_blob = tmp_path / "staged.blob" + staged_blob.write_bytes(b"backup data") + digest = "a" * 64 + target = settings.repository_roots[0] / "blobs" / "sha256" / digest + + with pytest.raises(faults.InjectedCrash): + snapshot._install_blob(staged_blob, target, digest, faults.CrashAt("blob.before_rename")) + + assert staged_blob.exists() + assert not target.exists() + + +async def _login(client: httpx.AsyncClient) -> dict[str, str]: + response = await client.post("/api/v2/setup", json={"username": "admin", "password": PASSWORD}) + assert response.status_code == 201 + return {"X-CSRF-Token": client.cookies["backup_tool_csrf"]} + + +@pytest.mark.asyncio +async def test_metadata_crash_is_reconciled_without_republishing( + app_client: tuple[httpx.AsyncClient, Settings], +) -> None: + client, settings = app_client + source_root = settings.local_source_roots[0] / "source" + source_root.mkdir() + (source_root / "data.txt").write_text("backup data", encoding="utf-8") + headers = await _login(client) + repository = await client.post( + "/api/v2/repositories", + json={ + "name": "repo", + "relative_path": "repo", + "compression": "none", + "encryption": "none", + }, + headers=headers, + ) + source = await client.post( + "/api/v2/sources", + json={ + "name": "source", + "kind": "local", + "public_config": {"root": str(source_root)}, + }, + headers=headers, + ) + job = await client.post( + "/api/v2/jobs", + json={ + "name": "job", + "source_id": source.json()["id"], + "repository_id": repository.json()["id"], + "requested_mode": "full", + "exclusions": [], + "retention": {}, + "enabled": True, + "allow_empty": False, + }, + headers=headers, + ) + execution = await client.post(f"/api/v2/jobs/{job.json()['id']}/executions", headers=headers) + execution_id = execution.json()["id"] + + crashing_worker = worker_module.Worker( + settings, + owner="crashing-worker", + fault_injector=faults.CrashAt("metadata.before_commit"), + ) + try: + with pytest.raises(faults.InjectedCrash): + await crashing_worker.run_once() + finally: + await crashing_worker.engine.dispose() + + engine = create_engine(settings) + try: + sessions = async_sessionmaker(engine, expire_on_commit=False) + async with sessions() as db: + assert ( + await db.scalar( + select(models.Backup).where(models.Backup.execution_id == execution_id) + ) + is None + ) + finally: + await engine.dispose() + + recovery_worker = worker_module.Worker(settings, owner="recovery-worker") + try: + assert await recovery_worker.startup() == 1 + finally: + await recovery_worker.engine.dispose() + + engine = create_engine(settings) + try: + sessions = async_sessionmaker(engine, expire_on_commit=False) + async with sessions() as db: + recovered = await db.get(models.Execution, execution_id) + backups = list( + await db.scalars( + select(models.Backup).where(models.Backup.execution_id == execution_id) + ) + ) + finally: + await engine.dispose() + assert recovered is not None and recovered.state == "committed" + assert len(backups) == 1 diff --git a/tests/fault/test_schedule_delivery.py b/tests/fault/test_schedule_delivery.py new file mode 100644 index 0000000..0c2e20c --- /dev/null +++ b/tests/fault/test_schedule_delivery.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +import pytest +from backup_tool.scheduler import next_nominal + + +def test_misfire_window_is_deterministic_for_delivery() -> None: + nominal = datetime.now(UTC) - timedelta(seconds=901) + next_run = next_nominal("* * * * *", "UTC", nominal) + + assert next_run > nominal + assert (datetime.now(UTC) - nominal).total_seconds() > 900 + + +@pytest.mark.parametrize("cron", ["* * * * * *", "invalid"]) +def test_delivery_rejects_invalid_cron_before_enqueue(cron: str) -> None: + with pytest.raises(ValueError): + next_nominal(cron, "UTC") diff --git a/tests/fault/test_worker_loss.py b/tests/fault/test_worker_loss.py new file mode 100644 index 0000000..47c8566 --- /dev/null +++ b/tests/fault/test_worker_loss.py @@ -0,0 +1,185 @@ +from __future__ import annotations + +import asyncio +import importlib +from collections.abc import AsyncIterator +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest +import pytest_asyncio +from alembic import command +from backup_tool.config import Settings +from backup_tool.db.engine import create_engine +from backup_tool.db.models import Execution, ExecutionEvent, Job, Repository, Source +from backup_tool.execution import ( + claim, + complete_cancellation, + heartbeat, + record_event, + request_cancellation, +) +from backup_tool.worker import Worker +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker + +cli = importlib.import_module("backup_tool.cli") + + +@pytest_asyncio.fixture +async def database( + tmp_path: Path, +) -> AsyncIterator[tuple[Settings, async_sessionmaker[AsyncSession], AsyncEngine]]: + key = tmp_path / "master.key" + key.write_bytes(b"m5-fault-test-master-key-material-32-bytes-minimum") + key.chmod(0o600) + data_dir = tmp_path / "data" + repositories = tmp_path / "repositories" + sources = tmp_path / "sources" + restores = tmp_path / "restores" + for directory in (data_dir, repositories, sources, restores): + directory.mkdir() + settings = Settings( + data_dir=data_dir, + database_url=f"sqlite+aiosqlite:///{data_dir / 'metadata.db'}", + repository_roots=(repositories,), + local_source_roots=(sources,), + restore_roots=(restores,), + master_key_file=key, + ) + command.upgrade(cli.build_alembic_config(settings), "head") + engine = create_engine(settings) + yield settings, async_sessionmaker(engine, expire_on_commit=False), engine + await engine.dispose() + + +async def create_stale_execution(db: AsyncSession, suffix: str, state: str) -> Execution: + repository = Repository( + name=f"repository-{suffix}", + root=f"/repositories/{suffix}", + format_version=1, + compression="none", + encryption="none", + ) + source = Source( + name=f"source-{suffix}", + kind="local", + public_config={"root": f"/sources/{suffix}"}, + secret_refs=[], + ) + db.add_all([repository, source]) + await db.flush() + job = Job( + name=f"job-{suffix}", + source_id=source.id, + repository_id=repository.id, + requested_mode="full", + exclusions=[], + retention={}, + ) + db.add(job) + await db.flush() + execution = Execution( + job_id=job.id, + trigger="manual", + state=state, + lease_owner="lost-worker", + lease_expires_at=datetime.now(UTC) - timedelta(seconds=1), + heartbeat_at=datetime.now(UTC) - timedelta(seconds=2), + progress={}, + reason_code="cancellation_requested" if state == "cancelling" else None, + ) + db.add(execution) + await db.flush() + await record_event(db, execution) + await db.commit() + return execution + + +@pytest.mark.asyncio +async def test_startup_recovers_each_stale_lease_once_and_fences_lost_owner( + database: tuple[Settings, async_sessionmaker[AsyncSession], AsyncEngine], +) -> None: + settings, sessions, _ = database + async with sessions() as db: + stale = { + state: await create_stale_execution(db, state, state) + for state in ("preparing", "running", "verifying", "cancelling") + } + + worker = Worker(settings, owner="recovery-worker") + try: + assert await worker.startup() == len(stale) + finally: + await worker.engine.dispose() + + async with sessions() as db: + recovered = { + state: await db.get(Execution, execution.id) for state, execution in stale.items() + } + for state in ("preparing", "running", "verifying"): + execution = recovered[state] + assert execution is not None + assert execution.state == "queued" + assert execution.reason_code == "worker_lost" + assert execution.lease_owner is None + assert execution.lease_expires_at is None + assert execution.heartbeat_at is None + cancelling = recovered["cancelling"] + assert cancelling is not None + assert cancelling.state == "cancelled" + assert cancelling.completed_at is not None + assert cancelling.reason_code == "cancellation_requested" + event_counts = { + execution.id: await db.scalar( + select(func.count()).where(ExecutionEvent.execution_id == execution.id) + ) + for execution in recovered.values() + if execution is not None + } + + assert event_counts == {execution.id: 2 for execution in stale.values()} + + async with sessions() as db: + execution = recovered["running"] + assert execution is not None + assert await claim(db, execution.id, "replacement-worker") is not None + assert not await heartbeat(db, execution.id, "lost-worker") + assert await request_cancellation(db, execution.id) is not None + assert not await complete_cancellation(db, execution.id, "lost-worker") + assert await complete_cancellation(db, execution.id, "replacement-worker") + + +@pytest.mark.asyncio +async def test_stopping_worker_does_not_claim_new_work( + database: tuple[Settings, async_sessionmaker[AsyncSession], AsyncEngine], +) -> None: + settings, _, _ = database + worker = Worker(settings, owner="stopping-worker") + try: + worker.stop() + assert not await worker.run_once() + finally: + await worker.engine.dispose() + + +@pytest.mark.asyncio +async def test_idle_worker_stops_promptly_and_disposes_its_engine( + database: tuple[Settings, async_sessionmaker[AsyncSession], AsyncEngine], + monkeypatch: pytest.MonkeyPatch, +) -> None: + settings, _, _ = database + worker = Worker(settings, owner="idle-worker") + disposed = asyncio.Event() + dispose = AsyncEngine.dispose + + async def track_dispose(engine: AsyncEngine, *, close: bool = True) -> None: + disposed.set() + await dispose(engine, close=close) + + monkeypatch.setattr(AsyncEngine, "dispose", track_dispose) + task = asyncio.create_task(worker.run()) + await asyncio.sleep(0) + worker.stop() + await asyncio.wait_for(task, timeout=1) + assert disposed.is_set() diff --git a/tests/fixtures/.gitkeep b/tests/fixtures/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/.gitkeep b/tests/integration/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..c210fac --- /dev/null +++ b/tests/integration/__init__.py @@ -0,0 +1 @@ +"""Integration tests.""" diff --git a/tests/integration/test_all_operational_events_deliver.py b/tests/integration/test_all_operational_events_deliver.py new file mode 100644 index 0000000..ecc29d8 --- /dev/null +++ b/tests/integration/test_all_operational_events_deliver.py @@ -0,0 +1,389 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +import pytest +from backup_tool.db.models import ( + Backup, + Execution, + Job, + NotificationDelivery, + NotificationEvent, + NotificationSubscription, + Repository, + Schedule, + Source, +) +from backup_tool.execution import record_event +from backup_tool.notifications.events import EVENT_CATALOG +from backup_tool.scheduler import deliver_due +from backup_tool.worker import Worker +from sqlalchemy import select + + +@pytest.mark.asyncio +async def test_execution_catalog_events_are_produced_and_delivered(app_client) -> None: + """Exercise the execution producer, not emit_event(), for each live execution type.""" + client, _ = app_client + setup = await client.post( + "/api/v2/setup", + json={"username": "admin", "password": "correct horse battery staple"}, + ) + assert setup.status_code == 201 + app = client._transport.app + expected = { + "execution.queued", + "execution.started", + "execution.committed", + "execution.failed", + "execution.cancelled", + "execution.retry_queued", + "execution.worker_recovered", + } + assert expected <= set(EVENT_CATALOG) + deferred_prefixes = ["source.", "gc.", "reconciliation."] + assert not any(item.startswith(tuple(deferred_prefixes)) for item in EVENT_CATALOG) + async with app.state.sessions() as db: + repository = Repository( + name="events-repository", + root="/events-repository", + format_version=1, + compression="none", + encryption="none", + ) + source = Source( + name="events-source", + kind="local", + public_config={"root": "/events-source"}, + secret_refs=[], + ) + db.add_all([repository, source]) + await db.flush() + job = Job( + name="events-job", + source_id=source.id, + repository_id=repository.id, + exclusions=[], + retention={}, + requested_mode="full", + ) + subscription = NotificationSubscription( + channel="email", + event_filters=["execution.*"], + destination_config={"recipients": ["operator@example.test"]}, + rate_limit_per_minute=60, + rate_tokens=60.0, + ) + db.add_all([job, subscription]) + await db.flush() + cases = ( + ("queued", 1, None, "execution.queued"), + ("preparing", 1, None, "execution.started"), + ("committed", 1, None, "execution.committed"), + ("failed", 1, "transient_io", "execution.failed"), + ("cancelled", 1, "cancellation_requested", "execution.cancelled"), + ("queued", 2, None, "execution.retry_queued"), + ("queued", 1, "worker_lost", "execution.worker_recovered"), + ) + for state, attempt, reason, _event_type in cases: + execution = Execution( + job_id=job.id, + trigger="manual", + state=state, + attempt=attempt, + reason_code=reason, + progress={}, + ) + db.add(execution) + await db.flush() + await record_event(db, execution) + if state in {"queued", "preparing"}: + execution.state = "failed" + execution.reason_code = "test_cleanup" + await db.flush() + await db.commit() + event_statement = select(NotificationEvent.type).where(NotificationEvent.type.in_(expected)) + event_types = set((await db.scalars(event_statement)).all()) + deliveries = await db.scalar( + select(NotificationDelivery.id) + .join(NotificationEvent, NotificationDelivery.event_id == NotificationEvent.id) + .where(NotificationEvent.type.in_(expected)) + .limit(1) + ) + assert event_types == expected + assert deliveries is not None + + +@pytest.mark.asyncio +async def test_schedule_catalog_events_are_produced_and_delivered(app_client) -> None: + client, _ = app_client + setup = await client.post( + "/api/v2/setup", + json={"username": "admin", "password": "correct horse battery staple"}, + ) + assert setup.status_code == 201 + csrf = client.cookies["backup_tool_csrf"] + app = client._transport.app + expected = { + "schedule.created", + "schedule.updated", + "schedule.deleted", + "schedule.enabled", + "schedule.disabled", + "schedule.occurrence_enqueued", + "schedule.occurrence_misfired", + "schedule.occurrence_blocked", + } + assert expected <= set(EVENT_CATALOG) + async with app.state.sessions() as db: + repository = Repository( + name="schedule-repository", + root="/schedule-repository", + format_version=1, + compression="none", + encryption="none", + ) + source = Source( + name="schedule-source", + kind="local", + public_config={"root": "/schedule-source"}, + secret_refs=[], + ) + db.add_all([repository, source]) + await db.flush() + jobs = [ + Job( + name=f"schedule-job-{number}", + source_id=source.id, + repository_id=repository.id, + exclusions=[], + retention={}, + requested_mode="full", + enabled=number not in {2, 3}, + ) + for number in range(1, 5) + ] + subscription = NotificationSubscription( + channel="email", + event_filters=["schedule.*"], + destination_config={"recipients": ["operator@example.test"]}, + rate_limit_per_minute=60, + rate_tokens=60.0, + ) + db.add_all([*jobs, subscription]) + await db.commit() + job_ids = [job.id for job in jobs] + + created = await client.post( + f"/api/v2/jobs/{job_ids[0]}/schedule", + json={"cron": "0 0 * * *", "timezone": "UTC", "enabled": True}, + headers={"X-CSRF-Token": csrf}, + ) + assert created.status_code == 201 + disabled = await client.patch( + f"/api/v2/jobs/{job_ids[0]}/schedule", + json={"cron": "0 0 * * *", "timezone": "UTC", "enabled": False}, + headers={"X-CSRF-Token": csrf}, + ) + assert disabled.status_code == 200 + enabled = await client.patch( + f"/api/v2/jobs/{job_ids[0]}/schedule", + json={"cron": "1 0 * * *", "timezone": "UTC", "enabled": True}, + headers={"X-CSRF-Token": csrf}, + ) + assert enabled.status_code == 200 + updated = await client.patch( + f"/api/v2/jobs/{job_ids[0]}/schedule", + json={"cron": "2 0 * * *", "timezone": "UTC", "enabled": True}, + headers={"X-CSRF-Token": csrf}, + ) + assert updated.status_code == 200 + assert ( + await client.delete(f"/api/v2/jobs/{job_ids[0]}/schedule", headers={"X-CSRF-Token": csrf}) + ).status_code == 204 + + async with app.state.sessions() as db: + now = datetime.now(UTC) + db.add_all( + [ + Schedule( + job_id=job_ids[1], + cron="* * * * *", + timezone="UTC", + misfire_grace_seconds=0, + enabled=True, + next_nominal_at=now - timedelta(hours=1), + ), + Schedule( + job_id=job_ids[2], + cron="* * * * *", + timezone="UTC", + misfire_grace_seconds=60, + enabled=True, + next_nominal_at=now, + ), + Schedule( + job_id=job_ids[3], + cron="* * * * *", + timezone="UTC", + misfire_grace_seconds=60, + enabled=True, + next_nominal_at=now, + ), + ] + ) + await db.commit() + assert await deliver_due(db, now=now) == 1 + statement = select(NotificationEvent.type).where(NotificationEvent.type.in_(expected)) + event_types = set((await db.scalars(statement)).all()) + delivery = await db.scalar( + select(NotificationDelivery.id) + .join(NotificationEvent, NotificationDelivery.event_id == NotificationEvent.id) + .where(NotificationEvent.type.in_(expected)) + .limit(1) + ) + assert event_types == expected + assert delivery is not None + + +@pytest.mark.asyncio +async def test_backup_restore_and_retention_events_are_produced_and_delivered( + app_client, +) -> None: + client, settings = app_client + source_root = settings.local_source_roots[0] / "notification-project" + source_root.mkdir() + (source_root / "data.txt").write_text("notification data\n", encoding="utf-8") + setup = await client.post( + "/api/v2/setup", + json={"username": "admin", "password": "correct horse battery staple"}, + ) + assert setup.status_code == 201 + headers = {"X-CSRF-Token": client.cookies["backup_tool_csrf"]} + subscription = await client.post( + "/api/v2/notifications/subscriptions", + json={ + "channel": "email", + "event_filters": ["backup.*", "restore.*", "retention.*"], + "destination": {"recipients": ["operator@example.test"]}, + }, + headers=headers, + ) + assert subscription.status_code == 201 + repository = await client.post( + "/api/v2/repositories", + json={"name": "notification-repo", "relative_path": "notification-repo"}, + headers=headers, + ) + source = await client.post( + "/api/v2/sources", + json={ + "name": "notification-source", + "kind": "local", + "public_config": {"root": str(source_root)}, + }, + headers=headers, + ) + assert repository.status_code == source.status_code == 201 + job = await client.post( + "/api/v2/jobs", + json={ + "name": "notification-job", + "source_id": source.json()["id"], + "repository_id": repository.json()["id"], + "requested_mode": "full", + "exclusions": [], + "retention": {"keep_last": 1}, + "allow_empty": False, + }, + headers=headers, + ) + assert job.status_code == 201 + execution = await client.post(f"/api/v2/jobs/{job.json()['id']}/executions", headers=headers) + assert execution.status_code == 202 + worker = Worker(settings, owner="notification-backup-worker") + try: + assert await worker.run_once() + finally: + await worker.engine.dispose() + app = client._transport.app + async with app.state.sessions() as db: + backup = await db.scalar( + select(Backup).where(Backup.execution_id == execution.json()["id"]) + ) + assert backup is not None + # A synthetic older catalog entry is valid business state; tombstoning is + # performed only through the real retention producer below. + older_execution = Execution( + job_id=job.json()["id"], + trigger="manual", + state="committed", + progress={}, + ) + db.add(older_execution) + await db.flush() + older = Backup( + execution_id=older_execution.id, + manifest_id="00000000-0000-7000-8000-000000000001", + manifest_digest="0" * 64, + logical_bytes=0, + stored_bytes=0, + integrity="verified", + created_at=datetime.now(UTC) - timedelta(days=1), + ) + db.add(older) + await db.commit() + + retention_worker = Worker(settings, owner="notification-retention-worker") + try: + # Retention/GC is executed by worker maintenance, not a direct helper call. + assert await retention_worker.run_once() + finally: + await retention_worker.engine.dispose() + + restore = await client.post( + f"/api/v2/backups/{backup.id}/restores", + json={ + "destination": str(settings.restore_roots[0] / "notification-restore"), + "selection": [], + "dry_run": True, + "overwrite_policy": "fail", + }, + headers=headers, + ) + assert restore.status_code == 202 + restore_worker = Worker(settings, owner="notification-restore-worker") + try: + assert await restore_worker.run_once() + finally: + await restore_worker.engine.dispose() + + expected = { + "backup.committed", + "backup.verification_succeeded", + "restore.queued", + "restore.committed", + "retention.tombstoned", + } + async with app.state.sessions() as db: + types = set( + ( + await db.scalars( + select(NotificationEvent.type).where(NotificationEvent.type.in_(expected)) + ) + ).all() + ) + deliveries = list( + ( + await db.scalars( + select(NotificationDelivery.id) + .join( + NotificationEvent, + NotificationDelivery.event_id == NotificationEvent.id, + ) + .where(NotificationEvent.type.in_(expected)) + ) + ).all() + ) + assert types == expected + assert len(deliveries) >= len(expected) diff --git a/tests/integration/test_auth.py b/tests/integration/test_auth.py new file mode 100644 index 0000000..8be751c --- /dev/null +++ b/tests/integration/test_auth.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import asyncio +from datetime import UTC, datetime, timedelta + +import pytest + +PASSWORD = "correct horse battery staple" + + +async def setup_admin(client, username: str = "admin"): + return await client.post("/api/v2/setup", json={"username": username, "password": PASSWORD}) + + +@pytest.mark.asyncio +async def test_setup_race_creates_exactly_one_admin(app_client) -> None: + client, _ = app_client + responses = await asyncio.gather(setup_admin(client, "admin-a"), setup_admin(client, "admin-b")) + assert sorted(response.status_code for response in responses) == [201, 409] + + +@pytest.mark.asyncio +async def test_setup_sets_secure_session_and_csrf_cookies(app_client) -> None: + client, _ = app_client + response = await setup_admin(client) + assert response.status_code == 201 + cookies = response.headers.get_list("set-cookie") + session = next(value for value in cookies if value.startswith("backup_tool_session=")) + csrf = next(value for value in cookies if value.startswith("backup_tool_csrf=")) + assert "HttpOnly" in session and "Secure" in session and "SameSite=strict" in session + assert "HttpOnly" not in csrf and "Secure" in csrf and "SameSite=strict" in csrf + assert "password" not in response.text.lower() + + +@pytest.mark.asyncio +async def test_csrf_is_required_for_cookie_authenticated_mutation(app_client) -> None: + client, _ = app_client + assert (await setup_admin(client)).status_code == 201 + denied = await client.post( + "/api/v2/admin/secrets", json={"purpose": "ssh", "value": "top-secret"} + ) + assert denied.status_code == 403 + assert denied.json()["code"] == "csrf_failed" + + allowed = await client.post( + "/api/v2/admin/secrets", + json={"purpose": "ssh", "value": "top-secret"}, + headers={"X-CSRF-Token": client.cookies["backup_tool_csrf"]}, + ) + assert allowed.status_code == 201 + assert "top-secret" not in allowed.text + + +@pytest.mark.asyncio +async def test_login_rejects_wrong_password_and_restores_session(app_client) -> None: + client, _ = app_client + assert (await setup_admin(client)).status_code == 201 + await client.post( + "/api/v2/auth/logout", headers={"X-CSRF-Token": client.cookies["backup_tool_csrf"]} + ) + wrong = await client.post( + "/api/v2/auth/login", json={"username": "admin", "password": "wrong-password"} + ) + assert wrong.status_code == 401 + logged_in = await client.post( + "/api/v2/auth/login", json={"username": "admin", "password": PASSWORD} + ) + assert logged_in.status_code == 200 + session = await client.get("/api/v2/auth/session") + assert session.status_code == 200 + assert session.json()["username"] == "admin" + + +@pytest.mark.asyncio +async def test_api_token_scope_revocation_and_expiry(app_client) -> None: + client, _ = app_client + assert (await setup_admin(client)).status_code == 201 + csrf = client.cookies["backup_tool_csrf"] + expiry = (datetime.now(UTC) + timedelta(hours=1)).isoformat() + created = await client.post( + "/api/v2/auth/tokens", + json={"scopes": ["audit:read"], "expires_at": expiry}, + headers={"X-CSRF-Token": csrf, "Idempotency-Key": "audit-token"}, + ) + assert created.status_code == 201 + token = created.json()["token"] + client.cookies.clear() + allowed = await client.get("/api/v2/audit", headers={"Authorization": f"Bearer {token}"}) + assert allowed.status_code == 200 + denied = await client.post( + "/api/v2/admin/secrets", + json={"purpose": "ssh", "value": "x"}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert denied.status_code == 403 + assert denied.json()["code"] == "insufficient_scope" + + # Log back in to revoke it. + assert ( + await client.post("/api/v2/auth/login", json={"username": "admin", "password": PASSWORD}) + ).status_code == 200 + csrf = client.cookies["backup_tool_csrf"] + assert ( + await client.delete( + f"/api/v2/auth/tokens/{created.json()['id']}", + headers={"X-CSRF-Token": csrf}, + ) + ).status_code == 204 + client.cookies.clear() + revoked = await client.get("/api/v2/audit", headers={"Authorization": f"Bearer {token}"}) + assert revoked.status_code == 401 + + # Expired token is accepted at creation but never at authentication. + assert ( + await client.post("/api/v2/auth/login", json={"username": "admin", "password": PASSWORD}) + ).status_code == 200 + expired = await client.post( + "/api/v2/auth/tokens", + json={ + "scopes": ["audit:read"], + "expires_at": (datetime.now(UTC) - timedelta(seconds=1)).isoformat(), + }, + headers={ + "X-CSRF-Token": client.cookies["backup_tool_csrf"], + "Idempotency-Key": "expired-token", + }, + ) + assert expired.status_code == 201 + expired_token = expired.json()["token"] + client.cookies.clear() + assert ( + await client.get("/api/v2/audit", headers={"Authorization": f"Bearer {expired_token}"}) + ).status_code == 401 diff --git a/tests/integration/test_empty_source.py b/tests/integration/test_empty_source.py new file mode 100644 index 0000000..d6c5667 --- /dev/null +++ b/tests/integration/test_empty_source.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +import importlib + +import pytest + +snapshot = importlib.import_module("backup_tool.snapshot") + + +def test_empty_source_requires_explicit_opt_in() -> None: + with pytest.raises(snapshot.SnapshotError, match="source_empty"): + snapshot.require_nonempty([], False) + snapshot.require_nonempty([], True) diff --git a/tests/integration/test_encrypted_repository.py b/tests/integration/test_encrypted_repository.py new file mode 100644 index 0000000..a2404af --- /dev/null +++ b/tests/integration/test_encrypted_repository.py @@ -0,0 +1,496 @@ +from __future__ import annotations + +import asyncio +import json +import stat +from pathlib import Path + +import httpx +import pytest +from alembic import command +from backup_tool.cli import ( + build_alembic_config, + recovery_export_payload, + rotate_repository_key, +) +from backup_tool.cli import main as cli_main +from backup_tool.config import Settings +from backup_tool.db.engine import create_engine +from backup_tool.db.models import Backup, Execution, Repository, RepositoryDataKeyEpoch +from backup_tool.repository import ( + begin_key_rotation, + initialize, + inspect_repository, + replace_active_data_key, +) +from backup_tool.security.repository_crypto import create_data_key +from backup_tool.worker import Worker +from sqlalchemy import select + +from .test_repository_safety import settings_for + +PASSWORD = "correct-horse-battery-staple" + + +async def login(client: httpx.AsyncClient) -> dict[str, str]: + response = await client.post("/api/v2/setup", json={"username": "admin", "password": PASSWORD}) + assert response.status_code == 201 + return {"X-CSRF-Token": client.cookies["backup_tool_csrf"]} + + +def test_encrypted_initialization_creates_private_data_key(tmp_path: Path) -> None: + settings = settings_for(tmp_path) + initialized = initialize(settings, "encrypted", "none", "aes-256-gcm") + assert initialized.data_key_id is not None + assert initialized.data_key_path is not None + assert stat.S_IMODE(initialized.data_key_path.stat().st_mode) == 0o600 + try: + payload = json.loads((initialized.root / "repository.json").read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise AssertionError("encrypted repository metadata is unreadable") from error + assert payload["encryption"] == { + "mode": "aes-256-gcm", + "key_id": initialized.data_key_id, + } + inspected = inspect_repository(settings, initialized.root) + assert inspected.encryption == "aes-256-gcm" + assert inspected.data_key_id == initialized.data_key_id + + +def test_rotation_rejects_metadata_database_epoch_mismatch(tmp_path: Path) -> None: + settings = settings_for(tmp_path) + command.upgrade(build_alembic_config(settings), "head") + initialized = initialize(settings, "encrypted", "none", "aes-256-gcm") + assert initialized.data_key_id is not None + + async def create_repository() -> str: + engine = create_engine(settings) + try: + from sqlalchemy.ext.asyncio import async_sessionmaker + + sessions = async_sessionmaker(engine, expire_on_commit=False) + async with sessions() as db: + repository = Repository( + name="encrypted", + root=str(initialized.root), + format_version=initialized.format_version, + compression=initialized.compression, + encryption=initialized.encryption, + signing_key_id=initialized.signing_key_id, + signing_public_key=initialized.signing_public_key, + active_data_key_id=initialized.data_key_id, + ) + db.add(repository) + await db.flush() + db.add( + RepositoryDataKeyEpoch( + repository_id=repository.id, + key_id=initialized.data_key_id, + state="active", + ) + ) + await db.commit() + return repository.id + finally: + await engine.dispose() + + repository_id = asyncio.run(create_repository()) + replacement_id, replacement_path = create_data_key(settings, initialized.repository_id) + try: + replace_active_data_key(initialized.root, initialized.data_key_id, replacement_id) + with pytest.raises(ValueError, match="repository encryption metadata is invalid"): + asyncio.run(rotate_repository_key(settings, repository_id)) + finally: + replacement_path.unlink(missing_ok=True) + + +def test_rotation_reconciliation_clears_stale_rollback_journal_after_key_removal( + tmp_path: Path, +) -> None: + settings = settings_for(tmp_path) + command.upgrade(build_alembic_config(settings), "head") + initialized = initialize(settings, "encrypted", "none", "aes-256-gcm") + assert initialized.data_key_id is not None + + async def create_repository() -> str: + engine = create_engine(settings) + try: + from sqlalchemy.ext.asyncio import async_sessionmaker + + sessions = async_sessionmaker(engine, expire_on_commit=False) + async with sessions() as db: + repository = Repository( + name="encrypted", + root=str(initialized.root), + format_version=initialized.format_version, + compression=initialized.compression, + encryption=initialized.encryption, + signing_key_id=initialized.signing_key_id, + signing_public_key=initialized.signing_public_key, + active_data_key_id=initialized.data_key_id, + ) + db.add(repository) + await db.flush() + db.add( + RepositoryDataKeyEpoch( + repository_id=repository.id, + key_id=initialized.data_key_id, + state="active", + ) + ) + await db.commit() + return repository.id + finally: + await engine.dispose() + + repository_id = asyncio.run(create_repository()) + new_key_id, new_key_path = create_data_key(settings, initialized.repository_id) + begin_key_rotation( + initialized.root, + repository_id, + initialized.repository_id, + initialized.data_key_id, + new_key_id, + ) + new_key_path.unlink() + assert (initialized.root / ".key-rotation.json").is_file() + + async def reconcile_and_assert() -> None: + worker = Worker(settings, owner="stale-rollback-journal-worker") + try: + assert await worker.startup() == 1 + finally: + await worker.engine.dispose() + engine = create_engine(settings) + try: + from sqlalchemy.ext.asyncio import async_sessionmaker + + sessions = async_sessionmaker(engine, expire_on_commit=False) + async with sessions() as db: + repository = await db.get(Repository, repository_id) + assert repository is not None + assert repository.active_data_key_id == initialized.data_key_id + inspected = inspect_repository(settings, initialized.root) + assert inspected.data_key_id == initialized.data_key_id + finally: + await engine.dispose() + + asyncio.run(reconcile_and_assert()) + assert not (initialized.root / ".key-rotation.json").exists() + + +def test_rotation_crash_after_db_commit_recovers_on_worker_startup( + tmp_path: Path, +) -> None: + settings = settings_for(tmp_path) + command.upgrade(build_alembic_config(settings), "head") + initialized = initialize(settings, "encrypted", "none", "aes-256-gcm") + assert initialized.data_key_id is not None + + async def create_repository() -> str: + engine = create_engine(settings) + try: + from sqlalchemy.ext.asyncio import async_sessionmaker + + sessions = async_sessionmaker(engine, expire_on_commit=False) + async with sessions() as db: + repository = Repository( + name="encrypted", + root=str(initialized.root), + format_version=initialized.format_version, + compression=initialized.compression, + encryption=initialized.encryption, + signing_key_id=initialized.signing_key_id, + signing_public_key=initialized.signing_public_key, + active_data_key_id=initialized.data_key_id, + ) + db.add(repository) + await db.flush() + db.add( + RepositoryDataKeyEpoch( + repository_id=repository.id, + key_id=initialized.data_key_id, + state="active", + ) + ) + await db.commit() + return repository.id + finally: + await engine.dispose() + + repository_id = asyncio.run(create_repository()) + + def interrupted_after_database_commit() -> None: + raise OSError("simulated process loss after database commit") + + with pytest.raises(OSError, match="simulated process loss"): + asyncio.run( + rotate_repository_key( + settings, + repository_id, + after_database_commit=interrupted_after_database_commit, + ) + ) + assert inspect_repository(settings, initialized.root).data_key_id == initialized.data_key_id + assert (initialized.root / ".key-rotation.json").is_file() + + async def reconcile_and_assert() -> None: + worker = Worker(settings, owner="rotation-recovery-worker") + try: + assert await worker.startup() == 1 + finally: + await worker.engine.dispose() + engine = create_engine(settings) + try: + from sqlalchemy.ext.asyncio import async_sessionmaker + + sessions = async_sessionmaker(engine, expire_on_commit=False) + async with sessions() as db: + repository = await db.get(Repository, repository_id) + assert repository is not None + assert repository.active_data_key_id is not None + inspected = inspect_repository(settings, initialized.root) + assert inspected.data_key_id == repository.active_data_key_id + active_data_key_id = repository.active_data_key_id + exported = await recovery_export_payload(settings) + exported_repository = exported["catalog"]["repositories"][0] + assert exported_repository["active_data_key_id"] == active_data_key_id + finally: + await engine.dispose() + + asyncio.run(reconcile_and_assert()) + assert not (initialized.root / ".key-rotation.json").exists() + + +@pytest.mark.asyncio +async def test_encrypted_repository_worker_backup_and_restore( + app_client: tuple[httpx.AsyncClient, Settings], +) -> None: + client, settings = app_client + source_root = settings.local_source_roots[0] / "project" + source_root.mkdir() + plaintext = b"encrypted backup content\n" + (source_root / "hello.txt").write_bytes(plaintext) + initialized = initialize(settings, "encrypted", "none", "aes-256-gcm") + assert initialized.data_key_id is not None + + engine = create_engine(settings) + try: + from sqlalchemy.ext.asyncio import async_sessionmaker + + sessions = async_sessionmaker(engine, expire_on_commit=False) + async with sessions() as db: + repository = Repository( + name="encrypted", + root=str(initialized.root), + format_version=initialized.format_version, + compression=initialized.compression, + encryption=initialized.encryption, + signing_key_id=initialized.signing_key_id, + signing_public_key=initialized.signing_public_key, + active_data_key_id=initialized.data_key_id, + ) + db.add(repository) + await db.flush() + db.add( + RepositoryDataKeyEpoch( + repository_id=repository.id, + key_id=initialized.data_key_id, + state="active", + ) + ) + await db.commit() + repository_id = repository.id + finally: + await engine.dispose() + + headers = await login(client) + source_response = await client.post( + "/api/v2/sources", + json={ + "name": "local", + "kind": "local", + "public_config": {"root": str(source_root)}, + }, + headers=headers, + ) + assert source_response.status_code == 201 + job_response = await client.post( + "/api/v2/jobs", + json={ + "name": "encrypted-backup", + "source_id": source_response.json()["id"], + "repository_id": repository_id, + "requested_mode": "full", + "exclusions": [], + "retention": {}, + "enabled": True, + "allow_empty": False, + }, + headers=headers, + ) + assert job_response.status_code == 201 + execution_response = await client.post( + f"/api/v2/jobs/{job_response.json()['id']}/executions", headers=headers + ) + assert execution_response.status_code == 202 + execution_id = execution_response.json()["id"] + + worker = Worker(settings, owner="encrypted-backup-worker") + try: + assert await worker.run_once() + finally: + await worker.engine.dispose() + + engine = create_engine(settings) + try: + from sqlalchemy.ext.asyncio import async_sessionmaker + + sessions = async_sessionmaker(engine, expire_on_commit=False) + async with sessions() as db: + execution = await db.get(Execution, execution_id) + backup = await db.scalar(select(Backup).where(Backup.execution_id == execution_id)) + finally: + await engine.dispose() + assert execution is not None + assert execution.state == "committed" + assert backup is not None + + blob = next((initialized.root / "blobs" / "sha256").iterdir()) + assert blob.read_bytes().startswith(b"BTENC\x01") + assert plaintext not in blob.read_bytes() + manifest_path = initialized.root / "manifests" / f"{backup.manifest_id}.json" + stored_manifest = manifest_path.read_bytes() + assert stored_manifest.startswith(b"BTENC\x01") + assert b'"entries"' not in stored_manifest + assert b"hello.txt" not in stored_manifest + + assert ( + await asyncio.to_thread( + cli_main, + ["admin", "repository-key", "rotate", "--repository-id", repository_id], + settings=settings, + ) + == 0 + ) + engine = create_engine(settings) + try: + from sqlalchemy.ext.asyncio import async_sessionmaker + + sessions = async_sessionmaker(engine, expire_on_commit=False) + async with sessions() as db: + repository = await db.get(Repository, repository_id) + epochs = list( + ( + await db.scalars( + select(RepositoryDataKeyEpoch).where( + RepositoryDataKeyEpoch.repository_id == repository_id + ) + ) + ).all() + ) + finally: + await engine.dispose() + assert repository is not None + assert repository.active_data_key_id != initialized.data_key_id + epoch_states = {f"{epoch.key_id}:{epoch.state}" for epoch in epochs} + expected_epoch_states = { + f"{initialized.data_key_id}:retired", + f"{repository.active_data_key_id}:active", + } + assert epoch_states == expected_epoch_states + assert ( + inspect_repository(settings, initialized.root).data_key_id == repository.active_data_key_id + ) + + plaintext_after_rotation = b"encrypted content after rotation\n" + (source_root / "hello.txt").write_bytes(plaintext_after_rotation) + second_execution_response = await client.post( + f"/api/v2/jobs/{job_response.json()['id']}/executions", headers=headers + ) + assert second_execution_response.status_code == 202 + second_execution_id = second_execution_response.json()["id"] + second_worker = Worker(settings, owner="encrypted-rotated-backup-worker") + try: + assert await second_worker.run_once() + finally: + await second_worker.engine.dispose() + engine = create_engine(settings) + try: + from sqlalchemy.ext.asyncio import async_sessionmaker + + sessions = async_sessionmaker(engine, expire_on_commit=False) + async with sessions() as db: + second_backup = await db.scalar( + select(Backup).where(Backup.execution_id == second_execution_id) + ) + finally: + await engine.dispose() + assert second_backup is not None + assert second_backup.data_key_id == repository.active_data_key_id + + destination = settings.restore_roots[0] / "restored" + restore_response = await client.post( + f"/api/v2/backups/{backup.id}/restores", + json={ + "destination": str(destination), + "selection": [], + "overwrite_policy": "fail", + }, + headers=headers, + ) + assert restore_response.status_code == 202 + source_root.rename(settings.data_dir / "removed-source") + restore_worker = Worker(settings, owner="encrypted-restore-worker") + try: + assert await restore_worker.run_once() + finally: + await restore_worker.engine.dispose() + + restored = await client.get( + f"/api/v2/restores/{restore_response.json()['id']}", headers=headers + ) + assert restored.json()["state"] == "committed" + assert (destination / "hello.txt").read_bytes() == plaintext + + rotated_destination = settings.restore_roots[0] / "rotated-restored" + rotated_restore_response = await client.post( + f"/api/v2/backups/{second_backup.id}/restores", + json={ + "destination": str(rotated_destination), + "selection": [], + "overwrite_policy": "fail", + }, + headers=headers, + ) + assert rotated_restore_response.status_code == 202 + rotated_restore_worker = Worker(settings, owner="encrypted-rotated-restore-worker") + try: + assert await rotated_restore_worker.run_once() + finally: + await rotated_restore_worker.engine.dispose() + assert (rotated_destination / "hello.txt").read_bytes() == plaintext_after_rotation + + manifest_path.write_bytes(stored_manifest[:-1] + bytes([stored_manifest[-1] ^ 1])) + corrupt_destination = settings.restore_roots[0] / "corrupt-manifest" + corrupt_restore = await client.post( + f"/api/v2/backups/{backup.id}/restores", + json={ + "destination": str(corrupt_destination), + "selection": [], + "overwrite_policy": "fail", + }, + headers=headers, + ) + assert corrupt_restore.status_code == 202 + corrupt_worker = Worker(settings, owner="encrypted-corrupt-manifest-worker") + try: + assert await corrupt_worker.run_once() + finally: + await corrupt_worker.engine.dispose() + + corrupt_status = await client.get( + f"/api/v2/restores/{corrupt_restore.json()['id']}", headers=headers + ) + assert corrupt_status.json()["state"] == "failed" + assert not corrupt_destination.exists() diff --git a/tests/integration/test_full_backup_restore.py b/tests/integration/test_full_backup_restore.py new file mode 100644 index 0000000..fbc51d8 --- /dev/null +++ b/tests/integration/test_full_backup_restore.py @@ -0,0 +1,246 @@ +from __future__ import annotations + +from pathlib import Path + +import httpx +import pytest +from backup_tool.config import Settings +from backup_tool.db.engine import create_engine +from backup_tool.db.models import Backup, Execution, Repository, Restore +from backup_tool.snapshot import verify_published_snapshot +from backup_tool.worker import Worker +from sqlalchemy import select + +PASSWORD = "correct-horse-battery-staple" + + +async def login(client: httpx.AsyncClient) -> dict[str, str]: + response = await client.post("/api/v2/setup", json={"username": "admin", "password": PASSWORD}) + assert response.status_code == 201 + return {"X-CSRF-Token": client.cookies["backup_tool_csrf"]} + + +@pytest.mark.asyncio +async def test_worker_publishes_a_verified_signed_full_backup_and_atomic_restore( + app_client: tuple[httpx.AsyncClient, Settings], +) -> None: + client, settings = app_client + source_root = settings.local_source_roots[0] / "project" + source_root.mkdir() + (source_root / "nested").mkdir() + (source_root / "nested" / "hello.txt").write_text("hello backup\n", encoding="utf-8") + headers = await login(client) + repository_response = await client.post( + "/api/v2/repositories", + json={ + "name": "primary", + "relative_path": "primary", + "compression": "none", + "encryption": "none", + }, + headers=headers, + ) + assert repository_response.status_code == 201 + source_response = await client.post( + "/api/v2/sources", + json={ + "name": "local", + "kind": "local", + "public_config": {"root": str(source_root)}, + }, + headers=headers, + ) + assert source_response.status_code == 201 + job_response = await client.post( + "/api/v2/jobs", + json={ + "name": "full-backup", + "source_id": source_response.json()["id"], + "repository_id": repository_response.json()["id"], + "requested_mode": "full", + "exclusions": [], + "retention": {}, + "enabled": True, + "allow_empty": False, + }, + headers=headers, + ) + assert job_response.status_code == 201 + execution_response = await client.post( + f"/api/v2/jobs/{job_response.json()['id']}/executions", headers=headers + ) + assert execution_response.status_code == 202 + execution_id = execution_response.json()["id"] + + worker = Worker(settings, owner="snapshot-worker") + try: + assert await worker.run_once() + finally: + await worker.engine.dispose() + + engine = create_engine(settings) + try: + from sqlalchemy.ext.asyncio import async_sessionmaker + + sessions = async_sessionmaker(engine, expire_on_commit=False) + async with sessions() as db: + execution = await db.get(Execution, execution_id) + backup = await db.scalar(select(Backup).where(Backup.execution_id == execution_id)) + repository = await db.get(Repository, repository_response.json()["id"]) + finally: + await engine.dispose() + + assert execution is not None + assert execution.state == "committed" + assert backup is not None + assert backup.integrity == "verified" + assert repository is not None + root = Path(repository.root) + manifest_path = root / "manifests" / f"{backup.manifest_id}.json" + manifest = verify_published_snapshot(root, manifest_path, repository.signing_public_key) + file_entry = next(entry for entry in manifest["entries"] if entry["type"] == "file") + assert file_entry["path"] == "nested/hello.txt" + assert (root / "blobs" / "sha256" / file_entry["blob_digest"]).read_text() == "hello backup\n" + + dry_run_destination = settings.restore_roots[0] / "dry-run-backup" + dry_run_response = await client.post( + f"/api/v2/backups/{backup.id}/restores", + json={ + "destination": str(dry_run_destination), + "selection": ["nested"], + "dry_run": True, + "overwrite_policy": "fail", + }, + headers=headers, + ) + assert dry_run_response.status_code == 202 + dry_run_worker = Worker(settings, owner="dry-run-worker") + try: + assert await dry_run_worker.run_once() + finally: + await dry_run_worker.engine.dispose() + dry_run = await client.get(f"/api/v2/restores/{dry_run_response.json()['id']}", headers=headers) + assert dry_run.json()["state"] == "committed" + assert dry_run.json()["result"]["dry_run"] + assert dry_run.json()["result"]["entry_count"] == 2 + assert not dry_run_destination.exists() + + destination = settings.restore_roots[0] / "restored-backup" + restore_response = await client.post( + f"/api/v2/backups/{backup.id}/restores", + json={ + "destination": str(destination), + "selection": [], + "overwrite_policy": "fail", + }, + headers=headers, + ) + assert restore_response.status_code == 202 + restore_id = restore_response.json()["id"] + assert restore_response.json()["state"] == "queued" + source_root.rename(settings.data_dir / "removed-source") + + restore_worker = Worker(settings, owner="restore-worker") + try: + assert await restore_worker.run_once() + finally: + await restore_worker.engine.dispose() + + restored = await client.get(f"/api/v2/restores/{restore_id}", headers=headers) + assert restored.status_code == 200 + assert restored.json()["state"] == "committed" + assert restored.json()["result"]["manifest_digest"] == backup.manifest_digest + assert (destination / "nested" / "hello.txt").read_text(encoding="utf-8") == "hello backup\n" + + engine = create_engine(settings) + try: + from sqlalchemy.ext.asyncio import async_sessionmaker + + sessions = async_sessionmaker(engine, expire_on_commit=False) + async with sessions() as db: + interrupted_restore = await db.get(Restore, restore_id) + assert interrupted_restore is not None + interrupted_restore.state = "running" + interrupted_restore.result = None + await db.commit() + finally: + await engine.dispose() + recovery_worker = Worker(settings, owner="recovery-worker") + try: + assert await recovery_worker.startup() == 1 + finally: + await recovery_worker.engine.dispose() + recovered = await client.get(f"/api/v2/restores/{restore_id}", headers=headers) + assert recovered.json()["state"] == "committed" + + skipped_restore = await client.post( + f"/api/v2/backups/{backup.id}/restores", + json={ + "destination": str(destination), + "selection": [], + "overwrite_policy": "skip", + }, + headers=headers, + ) + assert skipped_restore.status_code == 202 + skip_worker = Worker(settings, owner="skip-worker") + try: + assert await skip_worker.run_once() + finally: + await skip_worker.engine.dispose() + skipped = await client.get(f"/api/v2/restores/{skipped_restore.json()['id']}", headers=headers) + assert skipped.json()["result"]["skipped"] + + (destination / "nested" / "hello.txt").write_text("replaced", encoding="utf-8") + replaced_restore = await client.post( + f"/api/v2/backups/{backup.id}/restores", + json={ + "destination": str(destination), + "selection": [], + "overwrite_policy": "replace", + }, + headers=headers, + ) + assert replaced_restore.status_code == 202 + replace_worker = Worker(settings, owner="replace-worker") + try: + assert await replace_worker.run_once() + finally: + await replace_worker.engine.dispose() + assert (destination / "nested" / "hello.txt").read_text(encoding="utf-8") == "hello backup\n" + + (root / "blobs" / "sha256" / file_entry["blob_digest"]).write_text("tampered") + corrupt_destination = settings.restore_roots[0] / "corrupt-restore" + corrupt_restore = await client.post( + f"/api/v2/backups/{backup.id}/restores", + json={ + "destination": str(corrupt_destination), + "selection": [], + "overwrite_policy": "fail", + }, + headers=headers, + ) + assert corrupt_restore.status_code == 202 + + corrupt_worker = Worker(settings, owner="corrupt-restore-worker") + try: + assert await corrupt_worker.run_once() + finally: + await corrupt_worker.engine.dispose() + + corrupt_status = await client.get( + f"/api/v2/restores/{corrupt_restore.json()['id']}", headers=headers + ) + assert corrupt_status.json()["state"] == "failed" + assert not corrupt_destination.exists() + engine = create_engine(settings) + try: + from sqlalchemy.ext.asyncio import async_sessionmaker + + sessions = async_sessionmaker(engine, expire_on_commit=False) + async with sessions() as db: + corrupted_backup = await db.get(Backup, backup.id) + finally: + await engine.dispose() + assert corrupted_backup is not None + assert corrupted_backup.integrity == "corrupt" diff --git a/tests/integration/test_gc.py b/tests/integration/test_gc.py new file mode 100644 index 0000000..7297649 --- /dev/null +++ b/tests/integration/test_gc.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import json +import os +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest +from backup_tool.gc import purge_repository +from backup_tool.security.repository_crypto import encrypt_object, object_aad + + +def write_manifest(path: Path, digests: list[str]) -> None: + path.write_text( + json.dumps({"entries": [{"blob_digest": digest} for digest in digests]}), + encoding="utf-8", + ) + + +def age(path: Path, days: int = 8) -> None: + stamp = (datetime.now(UTC) - timedelta(days=days)).timestamp() + os.utime(path, (stamp, stamp)) + + +def test_gc_purges_tombstoned_manifest_and_only_unreferenced_old_blob( + tmp_path: Path, +) -> None: + root = tmp_path / "repository" + manifests = root / "manifests" + blobs = root / "blobs" / "sha256" + manifests.mkdir(parents=True) + blobs.mkdir(parents=True) + kept = "a" * 64 + removed = "b" * 64 + write_manifest(manifests / "kept.json", [kept]) + write_manifest(manifests / "deleted.json", [removed]) + (blobs / kept).write_bytes(b"kept") + (blobs / removed).write_bytes(b"removed") + age(manifests / "deleted.json") + age(blobs / removed) + + report = purge_repository(root, {"deleted"}) + + assert report.purged_manifests == 1 + assert report.purged_blobs == 1 + assert (manifests / "kept.json").exists() + assert (blobs / kept).exists() + assert not (blobs / removed).exists() + + +@pytest.mark.parametrize("encrypted", [False, True], ids=["corrupt", "encrypted"]) +def test_gc_fails_closed_for_unreadable_manifest(tmp_path: Path, encrypted: bool) -> None: + root = tmp_path / "repository" + manifests = root / "manifests" + blobs = root / "blobs" / "sha256" + manifests.mkdir(parents=True) + blobs.mkdir(parents=True) + kept = "a" * 64 + deleted = "b" * 64 + write_manifest(manifests / "kept.json", [kept]) + write_manifest(manifests / "deleted.json", [deleted]) + unreadable = manifests / "unreadable.json" + unreadable.write_bytes( + encrypt_object( + b"k" * 32, + object_aad("repository", "key", "manifest", "unreadable"), + b'{"entries": []}', + ) + if encrypted + else b"not json" + ) + (blobs / kept).write_bytes(b"kept") + (blobs / deleted).write_bytes(b"deleted") + age(manifests / "deleted.json") + age(blobs / deleted) + + report = purge_repository(root, {"deleted"}) + + assert report.purged_manifests == 0 + assert report.purged_blobs == 0 + assert (manifests / "deleted.json").exists() + assert (blobs / deleted).exists() + + +def test_gc_purges_encrypted_manifests_with_known_epoch_keys(tmp_path: Path) -> None: + root = tmp_path / "repository" + manifests = root / "manifests" + blobs = root / "blobs" / "sha256" + manifests.mkdir(parents=True) + blobs.mkdir(parents=True) + key = b"k" * 32 + repository_id = "repository" + key_id = "epoch" + kept = "a" * 64 + deleted = "b" * 64 + for manifest_id, digests in (("kept", [kept]), ("deleted", [deleted])): + plaintext = json.dumps( + {"entries": [{"blob_digest": digest} for digest in digests]} + ).encode() + (manifests / f"{manifest_id}.json").write_bytes( + encrypt_object( + key, + object_aad(repository_id, key_id, "manifest", manifest_id), + plaintext, + ) + ) + (blobs / kept).write_bytes(b"kept") + (blobs / deleted).write_bytes(b"deleted") + age(manifests / "deleted.json") + age(blobs / deleted) + + report = purge_repository( + root, + {"deleted"}, + repository_id=repository_id, + manifest_keys={"kept": (key_id, key), "deleted": (key_id, key)}, + ) + + assert report.purged_manifests == 1 + assert report.purged_blobs == 1 + assert (blobs / kept).exists() + assert not (blobs / deleted).exists() + + +def test_gc_quarantines_unknown_blob_name(tmp_path: Path) -> None: + root = tmp_path / "repository" + blobs = root / "blobs" / "sha256" + blobs.mkdir(parents=True) + unknown = blobs / "not-a-digest" + unknown.write_bytes(b"unknown") + + report = purge_repository(root, set()) + + assert report.quarantined == 1 + assert not unknown.exists() + assert (root / "quarantine" / "not-a-digest").exists() diff --git a/tests/integration/test_incremental.py b/tests/integration/test_incremental.py new file mode 100644 index 0000000..14ba28b --- /dev/null +++ b/tests/integration/test_incremental.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from backup_tool.snapshot import _unsigned_manifest + + +class Object: + def __init__(self, **values: object) -> None: + self.__dict__.update(values) + + +def test_incremental_request_without_baseline_emits_complete_full_manifest() -> None: + manifest = _unsigned_manifest( + "0198c57f-0000-7000-8000-000000000006", + "0198c57f-0000-7000-8000-000000000001", + Object(id="0198c57f-0000-7000-8000-000000000003", kind="local"), + Object( + id="0198c57f-0000-7000-8000-000000000004", + requested_mode="incremental", + exclusions=[], + ), + Object(id="0198c57f-0000-7000-8000-000000000005"), + [], + 0, + 0, + ) + + assert manifest["requested_mode"] == "incremental" + assert manifest["effective_mode"] == "full" diff --git a/tests/integration/test_migrations.py b/tests/integration/test_migrations.py new file mode 100644 index 0000000..9995485 --- /dev/null +++ b/tests/integration/test_migrations.py @@ -0,0 +1,425 @@ +from __future__ import annotations + +import importlib +import os +import sqlite3 +import stat +from datetime import UTC, datetime, timedelta, timezone +from pathlib import Path +from typing import Any +from uuid import UUID + +import pytest +from alembic import command +from alembic.config import Config +from sqlalchemy import inspect, select, text +from sqlalchemy.ext.asyncio import async_sessionmaker + +EXPECTED_TABLES = { + "alembic_version", + "api_tokens", + "audit_events", + "backups", + "executions", + "execution_events", + "idempotency_records", + "jobs", + "notification_deliveries", + "notification_delivery_attempts", + "notification_email_settings", + "notification_events", + "notification_signing_keys", + "notification_subscriptions", + "repositories", + "repository_data_key_epochs", + "restores", + "schedules", + "secrets", + "sessions", + "sources", + "users", +} + + +def settings_for(tmp_path: Path) -> Any: + config_module = importlib.import_module("backup_tool.config") + key = tmp_path / "master.key" + key.write_bytes(os.urandom(32)) + key.chmod(stat.S_IRUSR | stat.S_IWUSR) + roots = [] + for name in ("repositories", "sources", "restores"): + root = tmp_path / name + root.mkdir() + roots.append(root) + return config_module.Settings( + database_url=f"sqlite+aiosqlite:///{(tmp_path / 'metadata.db').as_posix()}", + data_dir=tmp_path, + repository_roots=[roots[0]], + local_source_roots=[roots[1]], + restore_roots=[roots[2]], + master_key_file=key, + ) + + +def alembic_config(database_url: str) -> Config: + project_root = Path(__file__).resolve().parents[2] + config = Config(project_root / "backend" / "alembic.ini") + config.set_main_option("sqlalchemy.url", database_url) + return config + + +@pytest.mark.asyncio +async def test_migration_round_trip_and_sqlite_pragmas(tmp_path: Path) -> None: + db_module = importlib.import_module("backup_tool.db.engine") + settings = settings_for(tmp_path) + migration = alembic_config(settings.database_url) + + command.upgrade(migration, "head") + engine = db_module.create_engine(settings) + try: + await db_module.assert_schema_current(engine, migration) + async with engine.connect() as connection: + tables = await connection.run_sync( + lambda sync_connection: set(inspect(sync_connection).get_table_names()) + ) + foreign_keys = await connection.scalar(text("PRAGMA foreign_keys")) + journal_mode = await connection.scalar(text("PRAGMA journal_mode")) + busy_timeout = await connection.scalar(text("PRAGMA busy_timeout")) + assert tables == EXPECTED_TABLES + assert foreign_keys == 1 + assert str(journal_mode).lower() == "wal" + assert busy_timeout == settings.sqlite_busy_timeout_ms + finally: + await engine.dispose() + + command.downgrade(migration, "base") + command.upgrade(migration, "head") + + +@pytest.mark.asyncio +async def test_startup_rejects_unmigrated_database(tmp_path: Path) -> None: + db_module = importlib.import_module("backup_tool.db.engine") + settings = settings_for(tmp_path) + migration = alembic_config(settings.database_url) + engine = db_module.create_engine(settings) + try: + with pytest.raises(db_module.SchemaNotCurrentError): + await db_module.assert_schema_current(engine, migration) + finally: + await engine.dispose() + + +def test_local_only_sources_migration_rejects_existing_remote_sources( + tmp_path: Path, +) -> None: + settings = settings_for(tmp_path) + migration = alembic_config(settings.database_url) + command.upgrade(migration, "0005_restore_dry_run") + with sqlite3.connect(settings.database_path) as connection: + connection.execute( + """ + INSERT INTO sources (name, kind, public_config, secret_refs, state, last_probe, id) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ("remote", "sftp", "{}", "[]", "active", None, "source-remote"), + ) + + with pytest.raises(RuntimeError, match="found 1 non-local source row"): + command.upgrade(migration, "head") + + with sqlite3.connect(settings.database_path) as connection: + connection.execute("UPDATE sources SET kind = ? WHERE id = ?", ("local", "source-remote")) + + command.upgrade(migration, "head") + with ( + sqlite3.connect(settings.database_path) as connection, + pytest.raises(sqlite3.IntegrityError), + ): + connection.execute( + """ + INSERT INTO sources (name, kind, public_config, secret_refs, state, last_probe, id) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ("remote-two", "sftp", "{}", "[]", "active", None, "source-remote-two"), + ) + + command.downgrade(migration, "0005_restore_dry_run") + with sqlite3.connect(settings.database_path) as connection: + connection.execute( + """ + INSERT INTO sources (name, kind, public_config, secret_refs, state, last_probe, id) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ("remote-three", "sftp", "{}", "[]", "active", None, "source-remote-three"), + ) + + +def test_0009_allows_ssh_sources_and_refuses_populated_downgrade( + tmp_path: Path, +) -> None: + settings = settings_for(tmp_path) + migration = alembic_config(settings.database_url) + command.upgrade(migration, "0008_notification_outbox") + with sqlite3.connect(settings.database_path) as connection: + connection.execute( + """ + INSERT INTO sources (name, kind, public_config, secret_refs, state, last_probe, id) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ("local", "local", '{"root":"/tmp"}', "[]", "active", None, "source-local"), + ) + + command.upgrade(migration, "head") + with sqlite3.connect(settings.database_path) as connection: + local_source = connection.execute("SELECT kind FROM sources").fetchone() + assert local_source is not None + assert local_source[0] == "local" + connection.execute( + """ + INSERT INTO sources (name, kind, public_config, secret_refs, state, last_probe, id) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + "ssh", + "ssh", + '{"hostname":"backup.example.test"}', + '["secret"]', + "active", + None, + "source-ssh", + ), + ) + with pytest.raises(sqlite3.IntegrityError): + connection.execute( + """ + INSERT INTO sources (name, kind, public_config, secret_refs, state, last_probe, id) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ("sftp", "sftp", "{}", "[]", "active", None, "source-sftp"), + ) + + with pytest.raises(RuntimeError, match="found 1 SSH source row"): + command.downgrade(migration, "0008_notification_outbox") + + with sqlite3.connect(settings.database_path) as connection: + connection.execute("DELETE FROM sources WHERE id = ?", ("source-ssh",)) + command.downgrade(migration, "0008_notification_outbox") + with ( + sqlite3.connect(settings.database_path) as connection, + pytest.raises(sqlite3.IntegrityError), + ): + connection.execute( + """ + INSERT INTO sources (name, kind, public_config, secret_refs, state, last_probe, id) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + "ssh-after-downgrade", + "ssh", + "{}", + "[]", + "active", + None, + "source-ssh-after", + ), + ) + + +def test_0008_migrates_unexpected_legacy_notification_delivery(tmp_path: Path) -> None: + settings = settings_for(tmp_path) + migration = alembic_config(settings.database_url) + command.upgrade(migration, "0007_repository_data_key_epochs") + with sqlite3.connect(settings.database_path) as connection: + connection.execute( + """ + INSERT INTO notification_subscriptions + (id, channel, event_filters, destination_config, state) + VALUES (?, ?, ?, ?, ?) + """, + ( + "subscription", + "email", + '["execution.queued"]', + '{"recipients":["a@example.test"]}', + "active", + ), + ) + connection.execute( + """ + INSERT INTO notification_deliveries + (id, event_id, subscription_id, attempt, state, response_class) + VALUES (?, ?, ?, ?, ?, ?) + """, + ("delivery", "legacy-event", "subscription", 1, "delivered", "http_200"), + ) + command.upgrade(migration, "head") + with sqlite3.connect(settings.database_path) as connection: + event = connection.execute("SELECT type, payload FROM notification_events").fetchone() + attempt = connection.execute( + "SELECT number, outcome, response_class FROM notification_delivery_attempts" + ).fetchone() + assert event is not None and event[0] == "notification.legacy" + assert "legacy-event" in event[1] + assert attempt is not None + assert attempt[0] == 1 + assert attempt[1] == "delivered" + assert attempt[2] == "http_200" + + +def test_0007_downgrade_refuses_populated_key_metadata(tmp_path: Path) -> None: + settings = settings_for(tmp_path) + migration = alembic_config(settings.database_url) + command.upgrade(migration, "head") + with sqlite3.connect(settings.database_path) as connection: + connection.execute( + """ + INSERT INTO repositories + (id, name, root, format_version, compression, encryption, active_data_key_id, state) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + "repository", + "repository", + str(settings.repository_roots[0]), + 1, + "none", + "none", + "epoch", + "active", + ), + ) + connection.execute( + """ + INSERT INTO repository_data_key_epochs (id, repository_id, key_id, state) + VALUES (?, ?, ?, ?) + """, + ("epoch-row", "repository", "epoch", "active"), + ) + + with pytest.raises( + RuntimeError, match="cannot downgrade while repository data key metadata exists" + ): + command.downgrade(migration, "0006_local_sources_only") + + +def test_database_role_rejects_unmigrated_database(tmp_path: Path) -> None: + cli = importlib.import_module("backup_tool.cli") + db_module = importlib.import_module("backup_tool.db.engine") + settings = settings_for(tmp_path) + with pytest.raises(db_module.SchemaNotCurrentError): + cli.main(["worker"], settings=settings) + + +@pytest.mark.asyncio +async def test_persisted_ids_and_timestamps_are_uuid7_and_aware_utc( + tmp_path: Path, +) -> None: + db_module = importlib.import_module("backup_tool.db.engine") + models = importlib.import_module("backup_tool.db.models") + settings = settings_for(tmp_path) + migration = alembic_config(settings.database_url) + command.upgrade(migration, "head") + engine = db_module.create_engine(settings) + supplied = datetime(2024, 6, 1, 12, 30, tzinfo=timezone(timedelta(hours=5, minutes=30))) + sessions = async_sessionmaker(engine, expire_on_commit=False) + try: + async with sessions() as session: + user = models.User( + username="operator", + password_hash="hash", + created_at=supplied, + updated_at=supplied, + ) + repository = models.Repository( + name="primary", + root=str(tmp_path / "repositories"), + format_version=1, + compression="none", + encryption="none", + ) + session.add_all([user, repository]) + await session.commit() + user_id = user.id + repository_id = repository.id + async with sessions() as session: + stored_user = await session.scalar(select(models.User).where(models.User.id == user_id)) + stored_repository = await session.scalar( + select(models.Repository).where(models.Repository.id == repository_id) + ) + assert stored_user is not None + assert stored_repository is not None + assert UUID(stored_user.id).version == 7 + assert UUID(stored_repository.id).version == 7 + assert stored_user.created_at.tzinfo is UTC + assert stored_user.created_at == supplied.astimezone(UTC) + assert stored_repository.created_at.tzinfo is UTC + finally: + await engine.dispose() + + +def test_alembic_connections_apply_sqlite_configuration( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + sqlite_runtime = importlib.import_module("backup_tool.db.sqlite") + settings = settings_for(tmp_path) + migration = alembic_config(settings.database_url) + observed: list[tuple[int, str, int]] = [] + original = sqlite_runtime.configure_sqlite_connection + + def recording(connection: Any) -> None: + original(connection) + cursor = connection.cursor() + try: + foreign_keys = cursor.execute("PRAGMA foreign_keys").fetchone()[0] + journal_mode = cursor.execute("PRAGMA journal_mode").fetchone()[0] + busy_timeout = cursor.execute("PRAGMA busy_timeout").fetchone()[0] + observed.append((foreign_keys, str(journal_mode).lower(), busy_timeout)) + finally: + cursor.close() + + monkeypatch.setattr(sqlite_runtime, "configure_sqlite_connection", recording) + monkeypatch.setenv("BACKUP_TOOL_SQLITE_BUSY_TIMEOUT_MS", "7000") + command.upgrade(migration, "head") + assert observed + expected_pragmas = (1, "wal", 7000) + assert observed[-1] == expected_pragmas + + +@pytest.mark.asyncio +async def test_baseline_constraints_and_indexes_exist(tmp_path: Path) -> None: + db_module = importlib.import_module("backup_tool.db.engine") + settings = settings_for(tmp_path) + migration = alembic_config(settings.database_url) + command.upgrade(migration, "head") + engine = db_module.create_engine(settings) + try: + async with engine.connect() as connection: + execution_indexes = await connection.run_sync( + lambda sync: inspect(sync).get_indexes("executions") + ) + schedule_uniques = await connection.run_sync( + lambda sync: inspect(sync).get_unique_constraints("schedules") + ) + execution_checks = await connection.run_sync( + lambda sync: inspect(sync).get_check_constraints("executions") + ) + assert any( + index["name"] == "uq_executions_active_job" and index["unique"] + for index in execution_indexes + ) + assert any(unique["column_names"] == ["job_id"] for unique in schedule_uniques) + check_names = {check["name"] for check in execution_checks} + assert {"ck_executions_state", "ck_executions_trigger"} <= check_names + finally: + await engine.dispose() + + +def test_runtime_never_uses_create_all() -> None: + offenders = [] + for root in (Path("backend/src"), Path("backend/alembic")): + if not root.exists(): + continue + for path in root.rglob("*.py"): + if "create_all" in path.read_text(encoding="utf-8"): + offenders.append(path.as_posix()) + assert offenders == [] diff --git a/tests/integration/test_notifications.py b/tests/integration/test_notifications.py new file mode 100644 index 0000000..2eae72d --- /dev/null +++ b/tests/integration/test_notifications.py @@ -0,0 +1,233 @@ +from __future__ import annotations + +from collections.abc import Callable +from datetime import UTC, datetime +from typing import cast + +import pytest +from backup_tool.db.models import ( + NotificationDelivery, + NotificationEmailSettings, + NotificationEvent, +) +from backup_tool.ids import new_uuid7 +from backup_tool.notifications.email import SMTPClient, deliver_email +from backup_tool.notifications.events import emit_event +from sqlalchemy import select + +PASSWORD = "correct horse battery staple" + + +async def _setup(client) -> str: + response = await client.post("/api/v2/setup", json={"username": "admin", "password": PASSWORD}) + assert response.status_code == 201 + return client.cookies["backup_tool_csrf"] + + +@pytest.mark.asyncio +async def test_filters_manual_test_retry_and_history(app_client) -> None: + client, _ = app_client + csrf = await _setup(client) + created = await client.post( + "/api/v2/notifications/subscriptions", + json={ + "channel": "email", + "event_filters": ["schedule.*", "notification.test_requested"], + "destination": {"recipients": ["operator@example.test"]}, + }, + headers={"X-CSRF-Token": csrf}, + ) + assert created.status_code == 201 + subscription_id = created.json()["id"] + app = client._transport.app + async with app.state.sessions() as db: + await emit_event( + db, + "execution.queued", + correlation_id=str(new_uuid7()), + resource={}, + deduplication_key="filtered-out", + ) + scheduled = await emit_event( + db, + "schedule.created", + correlation_id=str(new_uuid7()), + resource={}, + deduplication_key="filtered-in", + ) + await db.commit() + statement = select(NotificationDelivery).where( + NotificationDelivery.event_id == scheduled.id + ) + deliveries = list((await db.scalars(statement)).all()) + assert len(deliveries) == 1 + delivery = deliveries[0] + delivery.state = "failed" + delivery.terminal_reason = "http_permanent" + await db.commit() + delivery_id = delivery.id + + tested = await client.post( + f"/api/v2/notifications/subscriptions/{subscription_id}/test", + headers={"X-CSRF-Token": csrf, "Idempotency-Key": "test-one"}, + ) + assert tested.status_code == 202 + retried = await client.post( + f"/api/v2/notifications/deliveries/{delivery_id}/retry", + headers={"X-CSRF-Token": csrf, "Idempotency-Key": "retry-one"}, + ) + assert retried.status_code == 202 + replayed = await client.post( + f"/api/v2/notifications/deliveries/{delivery_id}/retry", + headers={"X-CSRF-Token": csrf, "Idempotency-Key": "retry-one"}, + ) + assert replayed.status_code == 202 + assert replayed.json() == retried.json() + history = await client.get("/api/v2/notifications/deliveries") + assert history.status_code == 200 + row = next(item for item in history.json()["items"] if item["id"] == delivery_id) + assert row["state"] == "retry" + + +@pytest.mark.asyncio +async def test_manual_test_bypasses_filters_and_targets_only_selected_subscription( + app_client, +) -> None: + client, _ = app_client + csrf = await _setup(client) + selected = await client.post( + "/api/v2/notifications/subscriptions", + json={ + "channel": "email", + "event_filters": ["execution.failed"], + "destination": {"recipients": ["selected@example.test"]}, + }, + headers={"X-CSRF-Token": csrf}, + ) + other = await client.post( + "/api/v2/notifications/subscriptions", + json={ + "channel": "email", + "event_filters": ["notification.test_requested"], + "destination": {"recipients": ["other@example.test"]}, + }, + headers={"X-CSRF-Token": csrf}, + ) + assert selected.status_code == other.status_code == 201 + response = await client.post( + f"/api/v2/notifications/subscriptions/{selected.json()['id']}/test", + headers={"X-CSRF-Token": csrf, "Idempotency-Key": "selected-test"}, + ) + assert response.status_code == 202 + app = client._transport.app + async with app.state.sessions() as db: + rows = list( + ( + await db.scalars( + select(NotificationDelivery.subscription_id).where( + NotificationDelivery.event_id == response.json()["event_id"] + ) + ) + ).all() + ) + assert rows == [selected.json()["id"]] + + +@pytest.mark.asyncio +async def test_disabled_subscription_does_not_receive_future_events(app_client) -> None: + client, _ = app_client + csrf = await _setup(client) + created = await client.post( + "/api/v2/notifications/subscriptions", + json={ + "channel": "email", + "event_filters": ["execution.*"], + "destination": {"recipients": ["operator@example.test"]}, + }, + headers={"X-CSRF-Token": csrf}, + ) + subscription = created.json() + disabled = await client.patch( + f"/api/v2/notifications/subscriptions/{subscription['id']}", + json={"state": "disabled"}, + headers={"X-CSRF-Token": csrf, "If-Match": created.headers["ETag"]}, + ) + assert disabled.status_code == 200 + app = client._transport.app + async with app.state.sessions() as db: + event = await emit_event( + db, + "execution.queued", + correlation_id=str(new_uuid7()), + resource={}, + occurred_at=datetime.now(UTC), + ) + await db.commit() + assert ( + await db.scalar( + select(NotificationDelivery.id).where(NotificationDelivery.event_id == event.id) + ) + is None + ) + + +@pytest.mark.asyncio +async def test_email_uses_ehlo_starttls_then_auth_with_hermetic_fake() -> None: + calls: list[str] = [] + + class FakeSMTP: + def __init__(self, *_args, **_kwargs) -> None: + calls.append("connect") + + def __enter__(self): + return self + + def __exit__(self, *_args) -> None: + calls.append("close") + + def ehlo(self) -> None: + calls.append("ehlo") + + def starttls(self, *, context) -> None: + assert context.check_hostname + calls.append("starttls") + + def login(self, username: str, password: str) -> None: + assert username == "operator" + assert password == "smtp-password" + calls.append("auth") + + def send_message(self, message) -> None: + assert "smtp-password" not in message.as_string() + calls.append("send") + + event = NotificationEvent( + id=str(new_uuid7()), + type="execution.queued", + schema_version=1, + occurred_at=datetime.now(UTC), + correlation_id=str(new_uuid7()), + severity="info", + resource_refs={}, + payload={}, + canonical_envelope="{}", + ) + settings = NotificationEmailSettings( + id=1, + host="smtp.example.test", + port=587, + username="operator", + password_secret_id=str(new_uuid7()), + sender="sender@example.test", + max_attempts=5, + rate_limit_per_minute=60, + ) + result = await deliver_email( + settings, + "smtp-password", + event, + ["operator@example.test"], + smtp_factory=cast(Callable[..., SMTPClient], FakeSMTP), + ) + assert result.response_class == "smtp_2xx" + assert calls == ["connect", "ehlo", "starttls", "ehlo", "auth", "send", "close"] diff --git a/tests/integration/test_queue_leases.py b/tests/integration/test_queue_leases.py new file mode 100644 index 0000000..8384aaa --- /dev/null +++ b/tests/integration/test_queue_leases.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +import asyncio +import importlib +from collections.abc import AsyncIterator +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest +import pytest_asyncio +from alembic import command +from backup_tool.config import Settings +from backup_tool.db.engine import create_engine +from backup_tool.db.models import Execution, Job, Repository, Source +from backup_tool.execution import ( + EnqueueError, + claim, + complete_cancellation, + enqueue, + heartbeat, + request_cancellation, + retry, +) +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker + +cli = importlib.import_module("backup_tool.cli") + + +@pytest_asyncio.fixture +async def database( + tmp_path: Path, +) -> AsyncIterator[tuple[async_sessionmaker[AsyncSession], AsyncEngine]]: + key = tmp_path / "master.key" + key.write_bytes(b"m5-test-master-key-material-32-bytes-minimum") + key.chmod(0o600) + data_dir = tmp_path / "data" + repositories = tmp_path / "repositories" + sources = tmp_path / "sources" + restores = tmp_path / "restores" + for directory in (data_dir, repositories, sources, restores): + directory.mkdir() + settings = Settings( + data_dir=data_dir, + database_url=f"sqlite+aiosqlite:///{data_dir / 'metadata.db'}", + repository_roots=(repositories,), + local_source_roots=(sources,), + restore_roots=(restores,), + master_key_file=key, + ) + command.upgrade(cli.build_alembic_config(settings), "head") + engine = create_engine(settings) + yield async_sessionmaker(engine, expire_on_commit=False), engine + await engine.dispose() + + +async def create_job( + db: AsyncSession, suffix: str, *, enabled: bool = True, state: str = "active" +) -> str: + repository = Repository( + name=f"repository-{suffix}", + root=f"/repositories/{suffix}", + format_version=1, + compression="none", + encryption="none", + ) + source = Source( + name=f"source-{suffix}", + kind="local", + public_config={"root": f"/sources/{suffix}"}, + secret_refs=[], + ) + db.add_all([repository, source]) + await db.flush() + job = Job( + name=f"job-{suffix}", + source_id=source.id, + repository_id=repository.id, + requested_mode="full", + exclusions=[], + retention={}, + enabled=enabled, + state=state, + ) + db.add(job) + await db.commit() + return job.id + + +@pytest.mark.asyncio +async def test_concurrent_enqueue_allows_exactly_one_active_execution( + database: tuple[async_sessionmaker[AsyncSession], AsyncEngine], +) -> None: + sessions, _ = database + async with sessions() as db: + job_id = await create_job(db, "concurrent") + + async def start() -> Execution | EnqueueError: + async with sessions() as db: + try: + return await enqueue(db, job_id) + except EnqueueError as error: + return error + + first, second = await asyncio.gather(start(), start()) + results = [first, second] + successes = [result for result in results if isinstance(result, Execution)] + failures = [result for result in results if isinstance(result, EnqueueError)] + + assert len(successes) == 1 + assert len(failures) == 1 + assert failures[0].code == "execution_active" + assert failures[0].active_execution_id == successes[0].id + async with sessions() as db: + executions = list(await db.scalars(select(Execution).where(Execution.job_id == job_id))) + assert [execution.id for execution in executions] == [successes[0].id] + + +@pytest.mark.asyncio +@pytest.mark.parametrize(("enabled", "state"), [(False, "active"), (True, "archived")]) +async def test_enqueue_rejects_disabled_or_archived_jobs( + database: tuple[async_sessionmaker[AsyncSession], AsyncEngine], + enabled: bool, + state: str, +) -> None: + sessions, _ = database + async with sessions() as db: + job_id = await create_job( + db, f"unavailable-{enabled}-{state}", enabled=enabled, state=state + ) + with pytest.raises(EnqueueError) as raised: + await enqueue(db, job_id) + + assert raised.value.code == "job_disabled" + + +@pytest.mark.asyncio +async def test_reclaimed_lease_fences_the_previous_worker( + database: tuple[async_sessionmaker[AsyncSession], AsyncEngine], +) -> None: + sessions, _ = database + async with sessions() as db: + execution = await enqueue(db, await create_job(db, "leases")) + assert await claim(db, execution.id, "worker-a") is not None + assert await heartbeat(db, execution.id, "worker-a") + persisted = await db.get(Execution, execution.id) + assert persisted is not None + persisted.lease_expires_at = datetime.now(UTC) - timedelta(seconds=1) + await db.commit() + + async with sessions() as db: + assert await claim(db, execution.id, "worker-b") is not None + assert not await heartbeat(db, execution.id, "worker-a") + assert await request_cancellation(db, execution.id) is not None + assert not await complete_cancellation(db, execution.id, "worker-a") + assert await complete_cancellation(db, execution.id, "worker-b") + + async with sessions() as db: + persisted = await db.get(Execution, execution.id) + assert persisted is not None + assert persisted.state == "cancelled" + assert persisted.lease_owner is None + + +@pytest.mark.asyncio +async def test_retry_reuses_execution_and_rejects_non_transient_failures( + database: tuple[async_sessionmaker[AsyncSession], AsyncEngine], +) -> None: + sessions, _ = database + async with sessions() as db: + execution = await enqueue(db, await create_job(db, "retry")) + execution.state = "failed" + execution.reason_code = "transient_io" + execution.operator_message = "temporary failure" + execution.lease_owner = "worker-a" + execution.lease_expires_at = datetime.now(UTC) + timedelta(seconds=60) + await db.commit() + + retried = await retry(db, execution.id) + assert retried is not None + assert retried.id == execution.id + assert retried.state == "queued" + assert retried.attempt == 2 + assert retried.reason_code is None + assert retried.operator_message is None + assert retried.lease_owner is None + assert retried.lease_expires_at is None + + retried.state = "failed" + retried.reason_code = "integrity_failure" + await db.commit() + with pytest.raises(EnqueueError) as raised: + await retry(db, execution.id) + + assert raised.value.code == "retry_not_allowed" diff --git a/tests/integration/test_recovery_bundle.py b/tests/integration/test_recovery_bundle.py new file mode 100644 index 0000000..fbe8bc1 --- /dev/null +++ b/tests/integration/test_recovery_bundle.py @@ -0,0 +1,272 @@ +from __future__ import annotations + +import asyncio +import os +from datetime import UTC, datetime +from pathlib import Path + +import pytest +from alembic import command +from backup_tool.cli import ( + build_alembic_config, + import_recovery_payload, +) +from backup_tool.cli import ( + main as cli_main, +) +from backup_tool.config import Settings +from backup_tool.db.engine import create_engine +from backup_tool.db.models import ( + Backup, + Execution, + Job, + Repository, + RepositoryDataKeyEpoch, + Restore, + Source, +) +from backup_tool.ids import new_uuid7 +from backup_tool.repository import initialize +from backup_tool.security.recovery_bundle import RecoveryBundleError, decrypt_bundle +from backup_tool.snapshot import finalize_publication, publish_full_snapshot +from backup_tool.worker import Worker +from sqlalchemy import select +from sqlalchemy.ext.asyncio import async_sessionmaker + +from tests.conftest import make_settings + +PASSPHRASE = b"correct horse battery staple" + + +def _passphrase_fd(value: bytes) -> int: + read_fd, write_fd = os.pipe() + os.write(write_fd, value + b"\n") + os.close(write_fd) + return read_fd + + +def _recovered_settings(tmp_path: Path, original: Settings) -> Settings: + data_dir = tmp_path / "recovered-data" + source_root = tmp_path / "recovered-sources" + restore_root = tmp_path / "recovered-restores" + for path in (data_dir, source_root, restore_root): + path.mkdir() + master_key = tmp_path / "recovered-master.key" + master_key.write_bytes(b"recovered-host-master-key-material-32-bytes") + master_key.chmod(0o600) + return Settings( + data_dir=data_dir, + database_url=f"sqlite+aiosqlite:///{data_dir / 'metadata.db'}", + repository_roots=original.repository_roots, + local_source_roots=(source_root,), + restore_roots=(restore_root,), + master_key_file=master_key, + min_free_bytes=1, + ) + + +def test_recovery_import_restores_encrypted_snapshot_after_host_loss( + tmp_path: Path, +) -> None: + original_path = tmp_path / "original" + original_path.mkdir() + original = make_settings(original_path) + command.upgrade(build_alembic_config(original), "head") + source_root = original.local_source_roots[0] / "project" + source_root.mkdir() + plaintext = b"recovery host-loss content\n" + (source_root / "document.txt").write_bytes(plaintext) + initialized = initialize(original, "encrypted", "none", "aes-256-gcm") + assert initialized.data_key_id is not None + + expected_created_at = datetime(2024, 1, 2, 3, 4, 5, tzinfo=UTC) + expected_tombstoned_at = datetime(2024, 2, 3, 4, 5, 6, tzinfo=UTC) + + async def create_snapshot() -> tuple[str, str]: + engine = create_engine(original) + sessions = async_sessionmaker(engine, expire_on_commit=False) + try: + async with sessions() as db: + repository = Repository( + name="encrypted", + root=str(initialized.root), + format_version=initialized.format_version, + compression=initialized.compression, + encryption=initialized.encryption, + signing_key_id=initialized.signing_key_id, + signing_public_key=initialized.signing_public_key, + active_data_key_id=initialized.data_key_id, + ) + source = Source( + name="project", + kind="local", + public_config={"root": str(source_root)}, + secret_refs=[], + ) + db.add_all([repository, source]) + await db.flush() + db.add( + RepositoryDataKeyEpoch( + repository_id=repository.id, + key_id=initialized.data_key_id, + state="active", + ) + ) + job = Job( + name="encrypted-job", + source_id=source.id, + repository_id=repository.id, + requested_mode="full", + exclusions=[], + retention={}, + allow_empty=False, + ) + db.add(job) + await db.flush() + execution = Execution(job_id=job.id, trigger="manual", progress={}) + db.add(execution) + await db.flush() + backup = await publish_full_snapshot( + original, db, execution, job, source, repository + ) + backup.created_at = expected_created_at + execution.state = "committed" + tombstoned_execution = Execution(job_id=job.id, trigger="manual", progress={}) + db.add(tombstoned_execution) + await db.flush() + tombstoned_backup = Backup( + execution_id=tombstoned_execution.id, + parent_backup_id=None, + manifest_id=str(new_uuid7()), + manifest_digest="0" * 64, + logical_bytes=0, + stored_bytes=0, + integrity="verified", + data_key_id=initialized.data_key_id, + tombstoned_at=expected_tombstoned_at, + created_at=expected_created_at, + ) + db.add(tombstoned_backup) + await db.commit() + finalize_publication(initialized.root, execution.id) + return backup.id, tombstoned_backup.id + finally: + await engine.dispose() + + original_backup_id, tombstoned_backup_id = asyncio.run(create_snapshot()) + bundle = tmp_path / "offline.btrec" + export_fd = _passphrase_fd(PASSPHRASE) + try: + assert ( + cli_main( + [ + "admin", + "recovery", + "export", + "--output", + str(bundle), + "--passphrase-fd", + str(export_fd), + ], + settings=original, + ) + == 0 + ) + finally: + os.close(export_fd) + + retry_path = tmp_path / "retry-recovery" + retry_path.mkdir() + retry = _recovered_settings(retry_path, original) + command.upgrade(build_alembic_config(retry), "head") + retry_payload = decrypt_bundle(bundle.read_bytes(), PASSPHRASE) + + def interrupted_after_key_install() -> None: + raise OSError("simulated crash before metadata commit") + + with pytest.raises(RecoveryBundleError, match="recovery import failed"): + asyncio.run( + import_recovery_payload( + retry, + retry_payload, + after_key_install=interrupted_after_key_install, + ) + ) + assert not list((retry.data_dir / "repository-keys").glob("*")) + assert not list((retry.data_dir / "repository-data-keys").glob("*")) + assert asyncio.run(import_recovery_payload(retry, retry_payload)) == 1 + + unsafe_path = tmp_path / "unsafe-recovery" + unsafe_path.mkdir() + unsafe = _recovered_settings(unsafe_path, original) + command.upgrade(build_alembic_config(unsafe), "head") + unsafe_payload = decrypt_bundle(bundle.read_bytes(), PASSPHRASE) + unsafe_payload["catalog"]["repositories"][0]["root"] = str(tmp_path) + with pytest.raises(RecoveryBundleError, match="recovery import failed"): + asyncio.run(import_recovery_payload(unsafe, unsafe_payload)) + assert not (unsafe.data_dir / "repository-keys").exists() + assert not (unsafe.data_dir / "repository-data-keys").exists() + + recovered = _recovered_settings(tmp_path, original) + command.upgrade(build_alembic_config(recovered), "head") + import_fd = _passphrase_fd(PASSPHRASE) + try: + assert ( + cli_main( + [ + "admin", + "recovery", + "import", + "--input", + str(bundle), + "--passphrase-fd", + str(import_fd), + ], + settings=recovered, + ) + == 0 + ) + finally: + os.close(import_fd) + + async def restore_and_assert() -> None: + engine = create_engine(recovered) + sessions = async_sessionmaker(engine, expire_on_commit=False) + try: + async with sessions() as db: + backup = await db.get(Backup, original_backup_id) + tombstoned_backup = await db.get(Backup, tombstoned_backup_id) + job = await db.scalar(select(Job)) + recovered_source = await db.scalar(select(Source)) + assert backup is not None + assert tombstoned_backup is not None + assert backup.created_at == expected_created_at + assert tombstoned_backup.created_at == expected_created_at + assert tombstoned_backup.tombstoned_at == expected_tombstoned_at + assert job is not None + assert recovered_source is not None + assert recovered_source.state == "unavailable" + assert job.state == "archived" + assert not job.enabled + restore = Restore( + backup_id=backup.id, + destination=str(recovered.restore_roots[0] / "restored"), + selection=[], + overwrite_policy="fail", + ) + db.add(restore) + await db.commit() + worker = Worker(recovered, owner="host-loss-restore") + try: + assert await worker.run_once() + finally: + await worker.engine.dispose() + finally: + await engine.dispose() + + asyncio.run(restore_and_assert()) + assert (recovered.restore_roots[0] / "restored" / "document.txt").read_bytes() == plaintext + + payload = decrypt_bundle(bundle.read_bytes(), PASSPHRASE) + with pytest.raises(RecoveryBundleError, match="destination is not empty"): + asyncio.run(import_recovery_payload(recovered, payload)) diff --git a/tests/integration/test_repositories.py b/tests/integration/test_repositories.py new file mode 100644 index 0000000..d91f4ad --- /dev/null +++ b/tests/integration/test_repositories.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any, cast + +import httpx +import pytest +from backup_tool.api.app import create_app +from backup_tool.config import Settings +from backup_tool.db.models import RepositoryDataKeyEpoch +from sqlalchemy import select + + +@pytest.mark.asyncio +async def test_admin_can_create_and_inspect_allowlisted_repository( + tmp_path: Path, +) -> None: + key = tmp_path / "key" + key.write_bytes(b"x" * 32) + key.chmod(0o600) + root = tmp_path / "repos" + root.mkdir() + settings = Settings( + repository_roots=(root,), + local_source_roots=(tmp_path,), + restore_roots=(tmp_path,), + master_key_file=key, + data_dir=tmp_path, + database_url=f"sqlite+aiosqlite:///{tmp_path / 'db.sqlite'}", + ) + app = create_app(settings) + from backup_tool.db.models import Base + + async with app.state.engine.begin() as connection: + await connection.run_sync(Base.metadata.create_all) + transport = httpx.ASGITransport(app=cast(Any, app)) + async with httpx.AsyncClient(transport=transport, base_url="https://test") as client: + setup = await client.post( + "/api/v2/setup", json={"username": "admin", "password": "a secure password"} + ) + assert setup.status_code == 201 + csrf = client.cookies["backup_tool_csrf"] + created = await client.post( + "/api/v2/repositories", + json={ + "name": "main", + "relative_path": "main", + "compression": "none", + "encryption": "none", + }, + headers={"X-CSRF-Token": csrf}, + ) + assert created.status_code == 201, created.text + body = created.json() + assert body["name"] == "main" + assert body["format_version"] == 1 + assert (root / "main" / "repository.json").is_file() + encrypted = await client.post( + "/api/v2/repositories", + json={ + "name": "encrypted", + "relative_path": "encrypted", + "compression": "none", + "encryption": "aes-256-gcm", + }, + headers={"X-CSRF-Token": csrf}, + ) + assert encrypted.status_code == 201, encrypted.text + async with app.state.sessions() as db: + epochs = list( + ( + await db.scalars( + select(RepositoryDataKeyEpoch).where( + RepositoryDataKeyEpoch.repository_id == encrypted.json()["id"] + ) + ) + ).all() + ) + assert len(epochs) == 1 + assert epochs[0].state == "active" + got = await client.get(f"/api/v2/repositories/{body['id']}") + assert got.status_code == 200 + changed = await client.patch( + f"/api/v2/repositories/{body['id']}", + json={"compression": "gzip"}, + headers={"X-CSRF-Token": csrf}, + ) + assert changed.status_code == 409 + await app.state.engine.dispose() diff --git a/tests/integration/test_repository_safety.py b/tests/integration/test_repository_safety.py new file mode 100644 index 0000000..dd8c814 --- /dev/null +++ b/tests/integration/test_repository_safety.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +import json +import shutil +from pathlib import Path +from unittest.mock import patch + +import httpx +import pytest +from backup_tool.api.app import create_app +from backup_tool.config import Settings +from backup_tool.repository import RepositoryError, initialize, inspect_repository + +PASSWORD = "a secure password" + + +def settings_for(tmp_path: Path, **overrides: object) -> Settings: + key = tmp_path / "key" + key.write_bytes(b"x" * 32) + key.chmod(0o600) + root = tmp_path / "repos" + root.mkdir() + values: dict[str, object] = { + "repository_roots": (root,), + "local_source_roots": (tmp_path,), + "restore_roots": (tmp_path,), + "master_key_file": key, + "data_dir": tmp_path, + "database_url": f"sqlite+aiosqlite:///{tmp_path / 'db.sqlite'}", + "min_free_bytes": 1, + } + values.update(overrides) + return Settings.model_validate(values) + + +def test_partial_initialization_is_removed_on_publish_failure(tmp_path: Path) -> None: + settings = settings_for(tmp_path) + with ( + patch("backup_tool.repository.os.replace", side_effect=OSError("disk failure")), + pytest.raises(OSError), + ): + initialize(settings, "main", "none", "none") + assert not (settings.repository_roots[0] / "main").exists() + assert not list(settings.repository_roots[0].glob(".main.staging-*")) + assert not list((settings.data_dir / "repository-keys").glob("*")) + + +@pytest.mark.parametrize("relative_path", ["/absolute", "../escape"]) +def test_initialization_rejects_absolute_and_traversal_paths( + tmp_path: Path, relative_path: str +) -> None: + with pytest.raises(RepositoryError, match="path"): + initialize(settings_for(tmp_path), relative_path, "none", "none") + + +def test_initialization_rejects_symlinked_destination_component(tmp_path: Path) -> None: + settings = settings_for(tmp_path) + outside = tmp_path / "outside" + outside.mkdir() + (settings.repository_roots[0] / "linked").symlink_to(outside, target_is_directory=True) + with pytest.raises(RepositoryError, match="symlink"): + initialize(settings, "linked/child", "none", "none") + + +def test_initialization_supports_not_yet_created_nested_path(tmp_path: Path) -> None: + settings = settings_for(tmp_path) + result = initialize(settings, "nested/main", "none", "none") + assert result.root == settings.repository_roots[0] / "nested" / "main" + assert (result.root / "repository.json").is_file() + + +@pytest.mark.parametrize( + ("minimum", "usage"), + [ + ("min_free_bytes", shutil._ntuple_diskusage(total=100, used=99, free=1)), + ("min_free_percent", shutil._ntuple_diskusage(total=100, used=96, free=4)), + ], +) +def test_initialization_rejects_insufficient_capacity( + tmp_path: Path, minimum: str, usage: shutil._ntuple_diskusage +) -> None: + settings = settings_for(tmp_path, **{minimum: 5}) + with ( + patch("backup_tool.repository.shutil.disk_usage", return_value=usage), + pytest.raises(RepositoryError, match="capacity"), + ): + initialize(settings, "main", "none", "none") + + +def test_initialization_emits_complete_repository_protocol_metadata( + tmp_path: Path, +) -> None: + result = initialize(settings_for(tmp_path), "main", "none", "none") + try: + payload = json.loads((result.root / "repository.json").read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise AssertionError("repository metadata was not readable JSON") from error + assert set(payload) == { + "repository_id", + "format_version", + "digest_algorithm", + "compression", + "encryption", + "created_at", + } + assert payload["repository_id"][14] == "7" + assert payload["digest_algorithm"] == "sha256" + assert payload["encryption"] == {"mode": "none", "key_id": None} + assert payload["created_at"].endswith("Z") + + +def test_repository_inspection_rejects_noncanonical_metadata(tmp_path: Path) -> None: + settings = settings_for(tmp_path) + result = initialize(settings, "main", "none", "none") + (result.root / "repository.json").write_text( + '{"encryption":"none","compression":"none","format_version":1}\n' + ) + with pytest.raises(RepositoryError, match="canonical"): + inspect_repository(settings, result.root) + + +def test_repository_inspection_rejects_swapped_symlink_and_out_of_allowlist( + tmp_path: Path, +) -> None: + settings = settings_for(tmp_path) + result = initialize(settings, "main", "none", "none") + outside = tmp_path / "outside" + outside.mkdir() + (outside / "repository.json").write_text(json.dumps({"not": "a repository"}), encoding="utf-8") + result.root.rename(settings.repository_roots[0] / "real-main") + result.root.symlink_to(outside, target_is_directory=True) + with pytest.raises(RepositoryError, match="escapes"): + inspect_repository(settings, result.root) + with pytest.raises(RepositoryError, match="escapes"): + inspect_repository(settings, outside) + + +@pytest.mark.asyncio +async def test_database_failure_removes_published_repository(tmp_path: Path) -> None: + settings = settings_for(tmp_path) + app = create_app(settings) + from backup_tool.db.models import Base + + async with app.state.engine.begin() as connection: + await connection.run_sync(Base.metadata.create_all) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="https://test" + ) as client: + assert ( + await client.post("/api/v2/setup", json={"username": "admin", "password": PASSWORD}) + ).status_code == 201 + csrf = client.cookies["backup_tool_csrf"] + first = await client.post( + "/api/v2/repositories", + json={ + "name": "main", + "relative_path": "first", + "compression": "none", + "encryption": "none", + }, + headers={"X-CSRF-Token": csrf}, + ) + assert first.status_code == 201 + response = await client.post( + "/api/v2/repositories", + json={ + "name": "main", + "relative_path": "second", + "compression": "none", + "encryption": "none", + }, + headers={"X-CSRF-Token": csrf}, + ) + assert response.status_code == 409 + assert not (settings.repository_roots[0] / "second").exists() + await app.state.engine.dispose() + + +@pytest.mark.asyncio +async def test_admin_can_list_and_inspect_repositories(tmp_path: Path) -> None: + settings = settings_for(tmp_path) + app = create_app(settings) + from backup_tool.db.models import Base + + async with app.state.engine.begin() as connection: + await connection.run_sync(Base.metadata.create_all) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="https://test" + ) as client: + assert ( + await client.post("/api/v2/setup", json={"username": "admin", "password": PASSWORD}) + ).status_code == 201 + csrf = client.cookies["backup_tool_csrf"] + created = await client.post( + "/api/v2/repositories", + json={ + "name": "main", + "relative_path": "main", + "compression": "none", + "encryption": "none", + }, + headers={"X-CSRF-Token": csrf}, + ) + assert created.status_code == 201 + listed = await client.get("/api/v2/repositories") + assert listed.status_code == 200 + assert listed.json()["items"][0]["id"] == created.json()["id"] + inspection = await client.get(f"/api/v2/repositories/{created.json()['id']}/inspection") + assert inspection.status_code == 200 + assert inspection.json()["format_version"] == 1 + await app.state.engine.dispose() diff --git a/tests/integration/test_repository_signing.py b/tests/integration/test_repository_signing.py new file mode 100644 index 0000000..12c6a69 --- /dev/null +++ b/tests/integration/test_repository_signing.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Protocol, cast + +import httpx +import pytest +from backup_tool.api.app import create_app +from backup_tool.config import Settings +from backup_tool.db.models import Repository +from backup_tool.repository import initialize +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +PASSWORD = "a secure password" + + +class SignedRepository(Protocol): + repository_id: str + signing_key_id: str + signing_public_key: str + + +def settings_for(tmp_path: Path) -> Settings: + key = tmp_path / "master.key" + key.write_bytes(b"m6-test-master-key-material-32-bytes-minimum") + key.chmod(0o600) + repositories = tmp_path / "repositories" + repositories.mkdir() + return Settings( + data_dir=tmp_path, + database_url=f"sqlite+aiosqlite:///{tmp_path / 'metadata.db'}", + repository_roots=(repositories,), + local_source_roots=(tmp_path,), + restore_roots=(tmp_path,), + master_key_file=key, + min_free_bytes=1, + ) + + +def test_repository_initialization_creates_a_bound_ed25519_keypair( + tmp_path: Path, +) -> None: + settings = settings_for(tmp_path) + + initialized = cast(SignedRepository, initialize(settings, "main", "none", "none")) + + key_path = settings.data_dir / "repository-keys" / f"{initialized.repository_id}.ed25519" + assert key_path.read_bytes() + assert key_path.stat().st_mode & 0o777 == 0o600 + private_key = Ed25519PrivateKey.from_private_bytes(key_path.read_bytes()) + public_key = private_key.public_key().public_bytes( + serialization.Encoding.Raw, + serialization.PublicFormat.Raw, + ) + assert initialized.signing_public_key == public_key.hex() + assert initialized.signing_key_id.startswith("ed25519-") + + +@pytest.mark.asyncio +async def test_repository_api_persists_its_bound_public_signing_key( + tmp_path: Path, +) -> None: + settings = settings_for(tmp_path) + app = create_app(settings) + from backup_tool.db.models import Base + + async with app.state.engine.begin() as connection: + await connection.run_sync(Base.metadata.create_all) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="https://test" + ) as client: + assert ( + await client.post("/api/v2/setup", json={"username": "admin", "password": PASSWORD}) + ).status_code == 201 + created = await client.post( + "/api/v2/repositories", + json={ + "name": "main", + "relative_path": "main", + "compression": "none", + "encryption": "none", + }, + headers={"X-CSRF-Token": client.cookies["backup_tool_csrf"]}, + ) + assert created.status_code == 201 + async with app.state.sessions() as db: + repository = await db.get(Repository, created.json()["id"]) + + await app.state.engine.dispose() + assert repository is not None + assert repository.signing_key_id.startswith("ed25519-") + assert len(repository.signing_public_key) == 64 diff --git a/tests/integration/test_scheduler_live_sync.py b/tests/integration/test_scheduler_live_sync.py new file mode 100644 index 0000000..95dff74 --- /dev/null +++ b/tests/integration/test_scheduler_live_sync.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from typing import Any, cast + +import httpx +import pytest +from backup_tool.config import Settings +from backup_tool.db.models import NotificationDelivery, NotificationEvent, Schedule +from backup_tool.scheduler import SchedulerService +from sqlalchemy import select + +PASSWORD = "correct-horse-battery-staple" + + +async def setup_job(client: httpx.AsyncClient, settings: Settings) -> tuple[dict[str, str], str]: + source_root = settings.local_source_roots[0] / "source" + source_root.mkdir() + headers = await login(client) + repository = await client.post( + "/api/v2/repositories", + json={ + "name": "repo", + "relative_path": "repo", + "compression": "none", + "encryption": "none", + }, + headers=headers, + ) + source = await client.post( + "/api/v2/sources", + json={ + "name": "source", + "kind": "local", + "public_config": {"root": str(source_root)}, + }, + headers=headers, + ) + job = await client.post( + "/api/v2/jobs", + json={ + "name": "job", + "source_id": source.json()["id"], + "repository_id": repository.json()["id"], + "requested_mode": "full", + "exclusions": [], + "retention": {}, + "enabled": True, + "allow_empty": True, + }, + headers=headers, + ) + assert job.status_code == 201 + return headers, job.json()["id"] + + +async def login(client: httpx.AsyncClient) -> dict[str, str]: + response = await client.post("/api/v2/setup", json={"username": "admin", "password": PASSWORD}) + assert response.status_code == 201 + return {"X-CSRF-Token": client.cookies["backup_tool_csrf"]} + + +@pytest.mark.asyncio +async def test_schedule_role_delivery_and_live_crud_sync( + app_client: tuple[httpx.AsyncClient, Settings], +) -> None: + client, settings = app_client + headers, job_id = await setup_job(client, settings) + subscription = await client.post( + "/api/v2/notifications/subscriptions", + json={ + "channel": "email", + "event_filters": ["schedule.occurrence_enqueued"], + "destination": {"recipients": ["operator@example.test"]}, + }, + headers=headers, + ) + assert subscription.status_code == 201 + created = await client.post( + f"/api/v2/jobs/{job_id}/schedule", + json={"cron": "* * * * *", "timezone": "UTC"}, + headers=headers, + ) + assert created.status_code == 201 + app = cast(Any, client._transport).app + async with app.state.sessions() as db: + schedule = await db.scalar(select(Schedule).where(Schedule.job_id == job_id)) + assert schedule is not None + schedule.next_nominal_at = datetime.now(UTC) - timedelta(seconds=1) + await db.commit() + service = SchedulerService(settings) + try: + assert await service.run_once() == 1 + finally: + await service.engine.dispose() + async with app.state.sessions() as db: + delivery = await db.scalar( + select(NotificationDelivery.id) + .join(NotificationEvent, NotificationDelivery.event_id == NotificationEvent.id) + .where(NotificationEvent.type == "schedule.occurrence_enqueued") + .limit(1) + ) + assert delivery is not None + + updated = await client.patch( + f"/api/v2/jobs/{job_id}/schedule", + json={"cron": "0 10 * * *", "timezone": "UTC", "enabled": False}, + headers=headers, + ) + assert updated.status_code == 200 + assert updated.json()["next_nominal_at"] is None + # Deletion with historical executions is deliberately restricted; schedule + # delete behavior is covered before occurrence enqueue in the catalog test. diff --git a/tests/integration/test_sources_jobs.py b/tests/integration/test_sources_jobs.py new file mode 100644 index 0000000..6659c4d --- /dev/null +++ b/tests/integration/test_sources_jobs.py @@ -0,0 +1,424 @@ +from __future__ import annotations + +import json +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any, cast + +import httpx +import pytest +from backup_tool.api.app import create_app, execution_events_stream +from backup_tool.config import Settings +from backup_tool.db.models import Execution, Repository, Source +from backup_tool.execution import ( + claim, + complete_cancellation, + enqueue, + heartbeat, + record_event, + recover_stale, + request_cancellation, +) + +PASSWORD = "correct-horse-battery-staple" + + +async def login(client: httpx.AsyncClient) -> dict[str, str]: + response = await client.post("/api/v2/setup", json={"username": "admin", "password": PASSWORD}) + assert response.status_code == 201 + return {"X-CSRF-Token": client.cookies["backup_tool_csrf"]} + + +@pytest.mark.asyncio +async def test_local_source_probe_archive_and_repository_targeted_job( + tmp_path: Path, +) -> None: + source_root = tmp_path / "sources" + source_root.mkdir() + (source_root / "data.txt").write_text("contents") + data_dir = tmp_path / "data" + data_dir.mkdir() + key = tmp_path / "master.key" + key.write_bytes(b"x" * 32) + key.chmod(0o600) + repositories = tmp_path / "repositories" + restore = tmp_path / "restore" + repositories.mkdir() + restore.mkdir() + settings = Settings( + data_dir=data_dir, + database_url=f"sqlite+aiosqlite:///{data_dir / 'db.sqlite'}", + repository_roots=(repositories,), + local_source_roots=(source_root,), + restore_roots=(restore,), + master_key_file=key, + ) + app = create_app(settings) + from backup_tool.db.models import Base + + async with app.state.engine.begin() as connection: + await connection.run_sync(Base.metadata.create_all) + transport = httpx.ASGITransport(app=cast(Any, app)) + async with httpx.AsyncClient(transport=transport, base_url="https://test") as client: + headers = await login(client) + repository = await client.post( + "/api/v2/repositories", + json={"name": "repo", "relative_path": "main"}, + headers=headers, + ) + assert repository.status_code == 201 + source = await client.post( + "/api/v2/sources", + json={ + "name": "local", + "kind": "local", + "public_config": {"root": str(source_root)}, + }, + headers=headers, + ) + assert source.status_code == 201 + source_id = source.json()["id"] + probe = await client.post(f"/api/v2/sources/{source_id}/probe", headers=headers) + assert probe.status_code == 200 + assert probe.json()["entry_count"] == 1 + job = await client.post( + "/api/v2/jobs", + json={ + "name": "job", + "source_id": source_id, + "repository_id": repository.json()["id"], + "requested_mode": "full", + "exclusions": ["*.tmp"], + "retention": {}, + "enabled": True, + "allow_empty": False, + }, + headers=headers, + ) + assert job.status_code == 201 + assert "destination_path" not in job.json() + execution = await client.post( + f"/api/v2/jobs/{job.json()['id']}/executions", headers=headers + ) + assert execution.status_code == 202 + assert execution.json()["state"] == "queued" + duplicate = await client.post( + f"/api/v2/jobs/{job.json()['id']}/executions", headers=headers + ) + assert duplicate.status_code == 409 + assert duplicate.json()["code"] == "execution_active" + execution_id = execution.json()["id"] + listed_sources = await client.get("/api/v2/sources", headers=headers) + assert listed_sources.status_code == 200 + assert listed_sources.json()["items"] == [ + { + "id": source_id, + "name": "local", + "kind": "local", + "state": "active", + "public_config": {"root": str(source_root)}, + } + ] + listed_jobs = await client.get("/api/v2/jobs", headers=headers) + assert listed_jobs.status_code == 200 + assert listed_jobs.json()["items"][0]["id"] == job.json()["id"] + assert listed_jobs.json()["items"][0]["schedule"] is None + listed_executions = await client.get("/api/v2/executions", headers=headers) + assert listed_executions.status_code == 200 + assert listed_executions.json()["items"][0]["id"] == execution_id + scoped_token = await client.post( + "/api/v2/auth/tokens", + json={"scopes": ["audit:read"], "expires_at": None}, + headers={**headers, "Idempotency-Key": "execution-audit-token"}, + ) + assert scoped_token.status_code == 201 + token_headers = {"Authorization": f"Bearer {scoped_token.json()['token']}"} + assert ( + await client.get(f"/api/v2/executions/{execution_id}", headers=token_headers) + ).status_code == 403 + assert ( + await client.post(f"/api/v2/executions/{execution_id}/cancel", headers=token_headers) + ).status_code == 403 + async with app.state.sessions() as db: + assert await claim(db, execution_id, "expired-worker") is not None + stored = await db.get(Execution, execution_id) + assert stored is not None + stored.lease_expires_at = datetime.now(UTC) - timedelta(seconds=1) + await db.commit() + async with app.state.sessions() as db: + assert await recover_stale(db) == 1 + assert await claim(db, execution_id, "replacement-worker") is not None + async with app.state.sessions() as db: + assert not await heartbeat(db, execution_id, "expired-worker") + assert await request_cancellation(db, execution_id) is not None + assert not await complete_cancellation(db, execution_id, "expired-worker") + assert await complete_cancellation(db, execution_id, "replacement-worker") + async with app.state.sessions() as db: + stored = await db.get(Execution, execution_id) + assert stored is not None + stored.progress = { + "source_path": "/private/source", + "details": {"Token": "nested-secret", "password": "nested-password"}, + } + await db.commit() + polled = await client.get(f"/api/v2/executions/{execution_id}", headers=headers) + assert polled.status_code == 200 + assert polled.json()["state"] == "cancelled" + assert "/private/source" not in str(polled.json()) + assert "nested-secret" not in str(polled.json()) + assert "nested-password" not in str(polled.json()) + async with app.state.sessions() as db: + stored = await db.get(Execution, execution.json()["id"]) + assert stored is not None + stored.state = "failed" + stored.reason_code = "timeout" + await db.commit() + retried = await client.post( + f"/api/v2/executions/{execution.json()['id']}/retry", headers=headers + ) + assert retried.status_code == 202 + assert retried.json()["id"] == execution.json()["id"] + assert retried.json()["attempt"] == 2 + cancelled = await client.post( + f"/api/v2/executions/{execution.json()['id']}/cancel", headers=headers + ) + assert cancelled.status_code == 202 + stream = await client.get( + f"/api/v2/executions/{execution.json()['id']}/events", headers=headers + ) + assert stream.status_code == 200 + assert "event: execution" in stream.text + assert "/private/source" not in stream.text + assert "nested-secret" not in stream.text + assert "nested-password" not in stream.text + archived = await client.delete(f"/api/v2/sources/{source_id}", headers=headers) + assert archived.status_code == 204 + assert ( + await client.post(f"/api/v2/sources/{source_id}/probe", headers=headers) + ).status_code == 409 + + +@pytest.mark.asyncio +async def test_execution_sse_replays_later_redacted_revision(tmp_path: Path) -> None: + source_root = tmp_path / "sources" + source_root.mkdir() + data_dir = tmp_path / "data" + data_dir.mkdir() + key = tmp_path / "master.key" + key.write_bytes(b"x" * 32) + key.chmod(0o600) + repositories = tmp_path / "repositories" + restore = tmp_path / "restore" + repositories.mkdir() + restore.mkdir() + settings = Settings( + data_dir=data_dir, + database_url=f"sqlite+aiosqlite:///{data_dir / 'db.sqlite'}", + repository_roots=(repositories,), + local_source_roots=(source_root,), + restore_roots=(restore,), + master_key_file=key, + ) + app = create_app(settings) + from backup_tool.db.models import Base, Job + + async with app.state.engine.begin() as connection: + await connection.run_sync(Base.metadata.create_all) + async with app.state.sessions() as db: + repository = Repository( + name="stream-repository", + root=str(repositories / "stream"), + format_version=1, + compression="none", + encryption="none", + ) + source = Source( + name="stream-source", + kind="local", + public_config={"root": str(source_root)}, + secret_refs=[], + ) + db.add_all([repository, source]) + await db.flush() + job = Job( + name="stream-job", + source_id=source.id, + repository_id=repository.id, + requested_mode="full", + exclusions=[], + retention={}, + ) + db.add(job) + await db.commit() + execution = await enqueue(db, job.id) + execution_id = execution.id + + stream = execution_events_stream(app.state.sessions, execution_id, None) + first = await anext(stream) + first_id = first.split("\n", 1)[0].removeprefix("id: ") + assert "queued" in first + async with app.state.sessions() as db: + assert await request_cancellation(db, execution_id) is not None + second = await anext(stream) + assert second.split("\n", 1)[0].removeprefix("id: ") != first_id + assert "cancelled" in second + + replay = execution_events_stream(app.state.sessions, execution_id, first_id) + assert "cancelled" in await anext(replay) + await stream.aclose() + await replay.aclose() + + +@pytest.mark.asyncio +async def test_execution_events_preserve_progress_replay_and_recovery_order( + tmp_path: Path, +) -> None: + source_root = tmp_path / "sources" + source_root.mkdir() + data_dir = tmp_path / "data" + data_dir.mkdir() + key = tmp_path / "master.key" + key.write_bytes(b"x" * 32) + key.chmod(0o600) + repositories = tmp_path / "repositories" + restore = tmp_path / "restore" + repositories.mkdir() + restore.mkdir() + settings = Settings( + data_dir=data_dir, + database_url=f"sqlite+aiosqlite:///{data_dir / 'db.sqlite'}", + repository_roots=(repositories,), + local_source_roots=(source_root,), + restore_roots=(restore,), + master_key_file=key, + ) + app = create_app(settings) + from backup_tool.db.models import Base, Job + + async with app.state.engine.begin() as connection: + await connection.run_sync(Base.metadata.create_all) + async with app.state.sessions() as db: + repository = Repository( + name="ordered-events-repository", + root=str(repositories / "ordered"), + format_version=1, + compression="none", + encryption="none", + ) + source = Source( + name="ordered-events-source", + kind="local", + public_config={"root": str(source_root)}, + secret_refs=[], + ) + db.add_all([repository, source]) + await db.flush() + job = Job( + name="ordered-events-job", + source_id=source.id, + repository_id=repository.id, + requested_mode="full", + exclusions=[], + retention={}, + ) + db.add(job) + await db.commit() + execution = await enqueue(db, job.id) + execution_id = execution.id + + async with app.state.sessions() as db: + assert await claim(db, execution_id, "ordering-worker") is not None + execution = await db.get(Execution, execution_id) + assert execution is not None and execution.state == "preparing" + execution.state = "running" + execution.started_at = datetime.now(UTC) + await record_event(db, execution) + await db.commit() + async with app.state.sessions() as db: + execution = await db.get(Execution, execution_id) + assert execution is not None and execution.state == "running" + execution.progress = {"files": 1} + await record_event(db, execution) + execution.progress = {"files": 2} + await record_event(db, execution) + await db.commit() + execution.lease_expires_at = datetime.now(UTC) - timedelta(seconds=1) + await db.commit() + assert await recover_stale(db) == 1 + execution = await db.get(Execution, execution_id) + assert execution is not None and execution.state == "queued" + assert await claim(db, execution_id, "cancelling-worker") is not None + assert await request_cancellation(db, execution_id) is not None + assert await complete_cancellation(db, execution_id, "cancelling-worker") + + def parse_frame(frame: str) -> tuple[int, dict[str, object]]: + try: + identifier, _, data = frame.partition("\n") + revision = int(identifier.removeprefix("id: ")) + payload = json.loads(data.split("data: ", 1)[1].strip()) + except (IndexError, TypeError, ValueError, json.JSONDecodeError) as error: + pytest.fail(f"Invalid SSE frame: {error}") + return revision, payload + + replay = execution_events_stream(app.state.sessions, execution_id, "0") + payloads: list[dict[str, object]] = [] + revisions: list[int] = [] + async for frame in replay: + revision, payload = parse_frame(frame) + revisions.append(revision) + payloads.append(payload) + assert revisions == list(range(1, len(revisions) + 1)) + progress_values = [payload["progress"] for payload in payloads if payload["progress"]] + assert progress_values[:2] == [{"files": 1}, {"files": 2}] + assert payloads[-1]["state"] == "cancelled" + + reconnect = execution_events_stream(app.state.sessions, execution_id, str(revisions[-2])) + replayed = await anext(reconnect) + replayed_revision, replayed_payload = parse_frame(replayed) + assert replayed_revision == revisions[-1] + assert replayed_payload["state"] == "cancelled" + await reconnect.aclose() + + +@pytest.mark.asyncio +async def test_local_source_rejects_unallowlisted_root(tmp_path: Path) -> None: + allowed = tmp_path / "allowed" + allowed.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + data_dir = tmp_path / "data" + data_dir.mkdir() + key = tmp_path / "master.key" + key.write_bytes(b"x" * 32) + key.chmod(0o600) + repositories = tmp_path / "repositories" + restore = tmp_path / "restore" + repositories.mkdir() + restore.mkdir() + settings = Settings( + data_dir=data_dir, + database_url=f"sqlite+aiosqlite:///{data_dir / 'db.sqlite'}", + repository_roots=(repositories,), + local_source_roots=(allowed,), + restore_roots=(restore,), + master_key_file=key, + ) + app = create_app(settings) + from backup_tool.db.models import Base + + async with app.state.engine.begin() as connection: + await connection.run_sync(Base.metadata.create_all) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=cast(Any, app)), base_url="https://test" + ) as client: + headers = await login(client) + response = await client.post( + "/api/v2/sources", + json={ + "name": "bad", + "kind": "local", + "public_config": {"root": str(outside)}, + }, + headers=headers, + ) + assert response.status_code == 422 diff --git a/tests/integration/test_ssh_backup_restore.py b/tests/integration/test_ssh_backup_restore.py new file mode 100644 index 0000000..cba90c5 --- /dev/null +++ b/tests/integration/test_ssh_backup_restore.py @@ -0,0 +1,197 @@ +"""Opt-in live forced-SFTP chroot coverage; all SSH keys are generated under tmp_path.""" + +from __future__ import annotations + +import os +import socket +import subprocess +import time +from pathlib import Path + +import httpx +import pytest +from backup_tool.db.engine import create_engine +from backup_tool.db.models import Backup, Execution, Repository +from backup_tool.snapshot import verify_published_snapshot +from backup_tool.worker import Worker +from sqlalchemy import select + +ROOT = Path(__file__).resolve().parents[2] +PASSWORD = "correct-horse-battery-staple" + + +def _enabled() -> bool: + return os.environ.get("BACKUP_TOOL_SSH_INTEGRATION") == "1" + + +def _port() -> int: + with socket.socket() as listener: + listener.bind(("127.0.0.1", 0)) + address = listener.getsockname() + if not isinstance(address, tuple) or not isinstance(address[1], int): + raise RuntimeError("could not allocate SSH fixture port") + return address[1] + + +def _wait(port: int) -> None: + deadline = time.monotonic() + 60 + while time.monotonic() < deadline: + try: + with socket.create_connection(("127.0.0.1", port), timeout=1): + return + except OSError: + time.sleep(0.25) + raise AssertionError("SSHD fixture did not become reachable") + + +@pytest.fixture +def sshd_fixture(tmp_path: Path): + fixture = tmp_path / "fixture" + host = fixture / "host" + source = fixture / "source" + host.mkdir(parents=True) + source.mkdir() + fixture.chmod(0o755) + host.chmod(0o755) + source.chmod(0o755) + private = fixture / "client" + for target in (host / "ssh_host_ed25519_key", private): + subprocess.run( + ["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(target)], + check=True, + ) + (host / "ssh_host_ed25519_key").chmod(0o644) + (fixture / "authorized_keys").write_text(private.with_suffix(".pub").read_text()) + # Public keys are copied into a backup-owned 0600 tmpfs file at startup. + (fixture / "authorized_keys").chmod(0o644) + port = _port() + environment = os.environ | { + "SSH_FIXTURE_DIR": str(fixture), + "SSH_FIXTURE_PORT": str(port), + "COMPOSE_PROJECT_NAME": f"backup-tool-ssh-{os.getpid()}-{port}", + } + command = ["docker", "compose", "-f", "tests/compose.ssh.yaml"] + try: + subprocess.run([*command, "up", "--build", "-d"], cwd=ROOT, env=environment, check=True) + _wait(port) + host_key = " ".join(host.joinpath("ssh_host_ed25519_key.pub").read_text().split()[:2]) + yield { + "port": port, + "key": private, + "host_key": host_key, + "source": source, + "env": environment, + } + finally: + subprocess.run( + [*command, "down", "--volumes", "--remove-orphans"], + cwd=ROOT, + env=environment, + check=False, + ) + + +async def _login(client: httpx.AsyncClient) -> dict[str, str]: + response = await client.post("/api/v2/setup", json={"username": "admin", "password": PASSWORD}) + assert response.status_code == 201 + return {"X-CSRF-Token": client.cookies["backup_tool_csrf"]} + + +@pytest.mark.skipif(not _enabled(), reason="set BACKUP_TOOL_SSH_INTEGRATION=1") +@pytest.mark.asyncio +async def test_forced_sftp_chroot_probe_backup_and_restore(app_client, sshd_fixture) -> None: + client, settings = app_client + source = sshd_fixture["source"] + (source / "nested").mkdir() + (source / "nested" / "hello.txt").write_text("hello ssh\n") + headers = await _login(client) + secret = await client.post( + "/api/v2/admin/secrets", + json={"purpose": "ssh_private_key", "value": sshd_fixture["key"].read_text()}, + headers=headers, + ) + assert secret.status_code == 201 + remote = await client.post( + "/api/v2/sources", + json={ + "name": "ssh", + "kind": "ssh", + "private_key_secret_id": secret.json()["id"], + "public_config": { + "hostname": "127.0.0.1", + "port": sshd_fixture["port"], + "username": "backup", + "host_key": sshd_fixture["host_key"], + "root": "/", + }, + }, + headers=headers, + ) + assert remote.status_code == 201, remote.text + assert ( + await client.post(f"/api/v2/sources/{remote.json()['id']}/probe", headers=headers) + ).json() == {"entry_count": 1} + repository = await client.post( + "/api/v2/repositories", + json={"name": "repo", "relative_path": "ssh"}, + headers=headers, + ) + job = await client.post( + "/api/v2/jobs", + json={ + "name": "ssh-job", + "source_id": remote.json()["id"], + "repository_id": repository.json()["id"], + "requested_mode": "full", + "exclusions": [], + "retention": {}, + "enabled": True, + "allow_empty": False, + }, + headers=headers, + ) + execution = await client.post(f"/api/v2/jobs/{job.json()['id']}/executions", headers=headers) + worker = Worker(settings, owner="ssh-live") + try: + assert await worker.run_once() + finally: + await worker.engine.dispose() + engine = create_engine(settings) + try: + from sqlalchemy.ext.asyncio import async_sessionmaker + + async with async_sessionmaker(engine, expire_on_commit=False)() as db: + stored = await db.get(Execution, execution.json()["id"]) + backup = await db.scalar( + select(Backup).where(Backup.execution_id == execution.json()["id"]) + ) + stored_repository = await db.get(Repository, repository.json()["id"]) + finally: + await engine.dispose() + assert stored is not None and stored.state == "committed", ( + stored.operator_message if stored else None + ) + assert backup is not None and stored_repository is not None + manifest = verify_published_snapshot( + Path(stored_repository.root), + Path(stored_repository.root) / "manifests" / f"{backup.manifest_id}.json", + stored_repository.signing_public_key, + ) + assert any(entry["path"] == "data/nested/hello.txt" for entry in manifest["entries"]) + destination = settings.restore_roots[0] / "ssh-restored" + restore = await client.post( + f"/api/v2/backups/{backup.id}/restores", + json={ + "destination": str(destination), + "selection": ["data/nested"], + "overwrite_policy": "fail", + }, + headers=headers, + ) + assert restore.status_code == 202 + restore_worker = Worker(settings, owner="ssh-restore") + try: + assert await restore_worker.run_once() + finally: + await restore_worker.engine.dispose() + assert (destination / "data" / "nested" / "hello.txt").read_text() == "hello ssh\n" diff --git a/tests/integration/test_ssh_source_api.py b/tests/integration/test_ssh_source_api.py new file mode 100644 index 0000000..029f9ef --- /dev/null +++ b/tests/integration/test_ssh_source_api.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +import pytest + +PASSWORD = "correct horse battery staple" +HOST_KEY = "ssh-ed25519 AQID" + + +async def _setup_headers(client) -> dict[str, str]: + response = await client.post("/api/v2/setup", json={"username": "admin", "password": PASSWORD}) + assert response.status_code == 201 + return {"X-CSRF-Token": client.cookies["backup_tool_csrf"]} + + +async def _create_secret(client, headers: dict[str, str], purpose: str) -> str: + response = await client.post( + "/api/v2/admin/secrets", + json={"purpose": purpose, "value": "PRIVATE-KEY-CANARY"}, + headers=headers, + ) + assert response.status_code == 201 + assert "PRIVATE-KEY-CANARY" not in response.text + return str(response.json()["id"]) + + +def _source(secret_id: str) -> dict[str, object]: + return { + "name": "remote", + "kind": "ssh", + "public_config": { + "hostname": "backup.example.test", + "port": 22, + "username": "backup", + "host_key": HOST_KEY, + "root": "/", + }, + "private_key_secret_id": secret_id, + } + + +@pytest.mark.asyncio +async def test_ssh_source_persists_only_safe_public_config_and_one_key_reference( + app_client, +) -> None: + client, _ = app_client + headers = await _setup_headers(client) + secret_id = await _create_secret(client, headers, "ssh_private_key") + + created = await client.post("/api/v2/sources", json=_source(secret_id), headers=headers) + + assert created.status_code == 201 + body = created.json() + assert body["kind"] == "ssh" + assert body["public_config"] == _source(secret_id)["public_config"] + assert "secret" not in body + assert "PRIVATE-KEY-CANARY" not in created.text + listed = await client.get("/api/v2/sources", headers=headers) + assert listed.status_code == 200 + assert listed.json()["items"] == [body] + assert "PRIVATE-KEY-CANARY" not in listed.text + probe = await client.post(f"/api/v2/sources/{body['id']}/probe", headers=headers) + assert probe.status_code == 409 + assert probe.json()["code"] == "source_probe_failed" + + +@pytest.mark.asyncio +async def test_ssh_source_requires_exactly_one_existing_private_key_secret( + app_client, +) -> None: + client, _ = app_client + headers = await _setup_headers(client) + wrong_purpose = await _create_secret(client, headers, "ssh") + request = _source(wrong_purpose) + + rejected_purpose = await client.post("/api/v2/sources", json=request, headers=headers) + assert rejected_purpose.status_code == 422 + assert rejected_purpose.json()["code"] == "validation_failed" + assert "PRIVATE-KEY-CANARY" not in rejected_purpose.text + + missing_secret = await client.post( + "/api/v2/sources", + json=_source("00000000-0000-0000-0000-000000000000"), + headers=headers, + ) + assert missing_secret.status_code == 422 + assert missing_secret.json()["code"] == "validation_failed" + + valid_secret = await _create_secret(client, headers, "ssh_private_key") + extra_secret_reference = { + **_source(valid_secret), + "secret_refs": [valid_secret, wrong_purpose], + } + rejected_extra = await client.post( + "/api/v2/sources", json=extra_secret_reference, headers=headers + ) + assert rejected_extra.status_code == 422 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "payload", + [ + {"name": "unsupported", "kind": "sftp", "public_config": {}}, + {"name": "unsupported", "kind": "postgresql", "public_config": {}}, + {"name": "unsupported", "kind": "mysql", "public_config": {}}, + {"name": "unsupported", "kind": "shell", "public_config": {}}, + ], +) +async def test_source_api_rejects_all_non_local_ssh_kinds( + app_client, payload: dict[str, object] +) -> None: + client, _ = app_client + headers = await _setup_headers(client) + + response = await client.post("/api/v2/sources", json=payload, headers=headers) + + assert response.status_code == 422 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "public_config", + [ + { + "hostname": "backup.example.test", + "port": 22, + "username": "backup", + "root": "/", + }, + { + "hostname": "backup.example.test", + "port": 0, + "username": "backup", + "host_key": HOST_KEY, + "root": "/", + }, + { + "hostname": "backup.example.test", + "port": 22, + "username": "backup", + "host_key": HOST_KEY, + "root": "/not-the-chroot", + }, + { + "hostname": "backup.example.test", + "port": 22, + "username": "backup", + "host_key": HOST_KEY, + "root": "/", + "password": "not-supported", + }, + { + "hostname": "backup.example.test", + "port": 22, + "username": "backup", + "host_key": HOST_KEY, + "root": "/", + "remote_command": "not-supported", + }, + ], +) +async def test_ssh_source_rejects_noncanonical_or_unsupported_config( + app_client, public_config: dict[str, object] +) -> None: + client, _ = app_client + headers = await _setup_headers(client) + secret_id = await _create_secret(client, headers, "ssh_private_key") + payload = _source(secret_id) + payload["public_config"] = public_config + + response = await client.post("/api/v2/sources", json=payload, headers=headers) + + assert response.status_code == 422 diff --git a/tests/performance/.gitkeep b/tests/performance/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/security/.gitkeep b/tests/security/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/security/__init__.py b/tests/security/__init__.py new file mode 100644 index 0000000..ec33a68 --- /dev/null +++ b/tests/security/__init__.py @@ -0,0 +1 @@ +"""Security tests.""" diff --git a/tests/security/test_auth.py b/tests/security/test_auth.py new file mode 100644 index 0000000..b28dfe7 --- /dev/null +++ b/tests/security/test_auth.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import importlib + +import pytest + +CANARY_PASSWORD = "password-canary-4dd52a" +CANARY_SECRET = "secret-canary-9a73ce" + + +@pytest.mark.asyncio +async def test_password_hash_secret_and_token_are_never_disclosed(app_client) -> None: + client, settings = app_client + setup = await client.post( + "/api/v2/setup", json={"username": "admin", "password": CANARY_PASSWORD} + ) + assert setup.status_code == 201 + csrf = client.cookies["backup_tool_csrf"] + secret = await client.post( + "/api/v2/admin/secrets", + json={"purpose": "database", "value": CANARY_SECRET}, + headers={"X-CSRF-Token": csrf}, + ) + assert secret.status_code == 201 + token_response = await client.post( + "/api/v2/auth/tokens", + json={"scopes": ["audit:read"], "expires_at": None}, + headers={"X-CSRF-Token": csrf, "Idempotency-Key": "canary-token"}, + ) + token = token_response.json()["token"] + + responses = [ + setup, + secret, + await client.get("/api/v2/auth/session"), + await client.get("/api/v2/admin/secrets"), + await client.get("/api/v2/audit", params={"limit": 100}), + await client.get("/openapi.json"), + ] + combined = "\n".join(response.text for response in responses) + assert CANARY_PASSWORD not in combined + assert CANARY_SECRET not in combined + assert token not in combined + + models = importlib.import_module("backup_tool.db.models") + engine_module = importlib.import_module("backup_tool.db.engine") + sqlalchemy = importlib.import_module("sqlalchemy") + async_sessionmaker = importlib.import_module("sqlalchemy.ext.asyncio").async_sessionmaker + engine = engine_module.create_engine(settings) + sessions = async_sessionmaker(engine, expire_on_commit=False) + try: + async with sessions() as session: + user = await session.scalar(sqlalchemy.select(models.User)) + stored_secret = await session.scalar(sqlalchemy.select(models.Secret)) + stored_token = await session.scalar(sqlalchemy.select(models.ApiToken)) + audits = list((await session.scalars(sqlalchemy.select(models.AuditEvent))).all()) + assert user is not None and user.password_hash != CANARY_PASSWORD + assert user.password_hash.startswith("$argon2id$") + assert stored_secret is not None and CANARY_SECRET.encode() not in stored_secret.ciphertext + assert stored_token is not None and stored_token.token_hash != token + assert all(CANARY_PASSWORD not in str(audit.details) for audit in audits) + assert all(CANARY_SECRET not in str(audit.details) for audit in audits) + assert all(token not in str(audit.details) for audit in audits) + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_master_key_file_never_appears_in_problem_detail(app_client) -> None: + client, settings = app_client + response = await client.post( + "/api/v2/auth/login", json={"username": "missing", "password": "bad"} + ) + assert response.status_code == 401 + assert str(settings.master_key_file) not in response.text diff --git a/tests/security/test_container.py b/tests/security/test_container.py new file mode 100644 index 0000000..e07cf82 --- /dev/null +++ b/tests/security/test_container.py @@ -0,0 +1,82 @@ +"""Static checks for the production Compose packaging slice.""" + +from __future__ import annotations + +import re +from pathlib import Path + +from backup_tool.cli import build_parser + +ROOT = Path(__file__).resolve().parents[2] + + +def test_health_command_selects_an_explicit_runtime_role() -> None: + parsed = build_parser().parse_args(["health", "worker"]) + + assert parsed.role == "health" + assert parsed.health_role == "worker" + + +def test_runtime_images_are_pinned_non_root_and_exclude_database_clients() -> None: + runtime = (ROOT / "Dockerfile").read_text() + proxy = (ROOT / "frontend" / "Dockerfile").read_text() + + for dockerfile in (runtime, proxy): + from_lines = [line for line in dockerfile.splitlines() if line.startswith("FROM ")] + assert from_lines + assert all("@sha256:" in line for line in from_lines) + + assert "USER backup-tool:backup-tool" in runtime + assert 'ENTRYPOINT ["backup-tool"]' in runtime + assert 'CMD ["web"]' in runtime + assert "USER 10001:0" in proxy + assert not re.search(r"\b(pg_dump|mysqldump|postgresql-client|mysql-client)\b", runtime) + + +def test_compose_runs_one_isolated_role_per_service_without_reload() -> None: + compose = (ROOT / "docker-compose.yml").read_text() + + for role in ("web", "scheduler", "worker", "migrate", "admin"): + assert f" {role}:" in compose + for command in ( + '["web"]', + '["scheduler"]', + '["worker"]', + '["migrate", "upgrade"]', + '["admin", "--help"]', + ): + assert command in compose + assert "--reload" not in compose + assert "backup-tool-runtime:/run/backup-tool" in compose + assert '["CMD", "backup-tool", "health", "web"]' in compose + assert '["CMD", "backup-tool", "health", "scheduler"]' in compose + assert '["CMD", "backup-tool", "health", "worker"]' in compose + + +def test_proxy_is_the_only_published_endpoint_and_uses_same_origin_socket() -> None: + compose = (ROOT / "docker-compose.yml").read_text() + nginx = (ROOT / "frontend" / "nginx.conf").read_text() + + assert compose.count(" ports:") == 1 + assert '"127.0.0.1:${BACKUP_TOOL_PORT:-8080}:8080"' in compose + assert '"${BACKUP_TOOL_PORT:-8080}:8080"' not in compose + assert "server unix:/run/backup-tool/web.sock;" in nginx + assert "location /api/" in nginx + assert "location = /readyz" in nginx + assert "location = /livez" in nginx + assert "location = /metrics" in nginx + assert "proxy_pass http://backup_tool_web;" in nginx + + +def test_operational_artifacts_cover_sbom_provenance_and_recovery() -> None: + assert (ROOT / "docs/release/m14-sbom.json").is_file() + assert (ROOT / "docs/release/m14-provenance.md").is_file() + for runbook in ( + "metadata.md", + "repositories.md", + "keys.md", + "upgrade.md", + "disaster-recovery.md", + "observability.md", + ): + assert (ROOT / "docs/runbooks" / runbook).is_file() diff --git a/tests/security/test_crypto_leakage.py b/tests/security/test_crypto_leakage.py new file mode 100644 index 0000000..1dea152 --- /dev/null +++ b/tests/security/test_crypto_leakage.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import os + +import pytest +from backup_tool.security.repository_crypto import ( + RepositoryKeyError, + decrypt_object, + encrypt_object, + object_aad, +) + + +def test_encrypted_object_hides_plaintext_and_rejects_tampering() -> None: + key = os.urandom(32) + aad = object_aad("repository", "epoch", "blob", "identity") + stored = encrypt_object(key, aad, b"secret-content") + assert b"secret-content" not in stored + assert decrypt_object(key, aad, stored) == b"secret-content" + with pytest.raises(RepositoryKeyError): + decrypt_object(key, aad, stored[:-1] + bytes([stored[-1] ^ 1])) diff --git a/tests/security/test_leakage_scan.py b/tests/security/test_leakage_scan.py new file mode 100644 index 0000000..0121b51 --- /dev/null +++ b/tests/security/test_leakage_scan.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import subprocess +import sys + + +def test_leakage_scan_rejects_canary_and_accepts_clean_file(tmp_path) -> None: + path = tmp_path / "output.txt" + path.write_text("safe output", encoding="utf-8") + clean = subprocess.run( + [sys.executable, "tools/leakage_scan.py", "--canary", "secret-canary", str(path)], + capture_output=True, + text=True, + check=False, + ) + assert clean.returncode == 0 + + path.write_text("oops secret-canary escaped", encoding="utf-8") + leaked = subprocess.run( + [sys.executable, "tools/leakage_scan.py", "--canary", "secret-canary", str(path)], + capture_output=True, + text=True, + check=False, + ) + assert leaked.returncode == 1 + assert "output.txt" in leaked.stdout diff --git a/tests/security/test_recovery_bundle.py b/tests/security/test_recovery_bundle.py new file mode 100644 index 0000000..287467b --- /dev/null +++ b/tests/security/test_recovery_bundle.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +import asyncio +import os +from pathlib import Path + +import pytest +from alembic import command +from backup_tool.cli import build_alembic_config, build_parser +from backup_tool.cli import main as cli_main +from backup_tool.db.engine import create_engine +from backup_tool.db.models import Repository, RepositoryDataKeyEpoch +from backup_tool.repository import initialize +from backup_tool.security.recovery_bundle import ( + RecoveryBundleError, + RecoveryBundlePathError, + decrypt_bundle, + encrypt_bundle, + write_bundle_exclusive, +) +from sqlalchemy.ext.asyncio import async_sessionmaker + +from tests.conftest import make_settings + +PASSPHRASE = b"correct horse battery staple" + + +def _passphrase_fd(value: bytes) -> int: + read_fd, write_fd = os.pipe() + os.write(write_fd, value + b"\n") + os.close(write_fd) + return read_fd + + +def test_recovery_bundle_rejects_wrong_passphrase_tampering_and_invalid_kdf() -> None: + bundle = encrypt_bundle({"catalog": {"version": 1}, "keys": []}, PASSPHRASE) + tampered = bundle[:-1] + bytes([bundle[-1] ^ 1]) + unsupported_kdf = bundle[:6] + b"\x02" + bundle[7:] + + for encoded, passphrase in ( + (bundle, b"wrong passphrase"), + (tampered, PASSPHRASE), + (unsupported_kdf, PASSPHRASE), + ): + with pytest.raises(RecoveryBundleError, match="^recovery bundle is invalid$"): + decrypt_bundle(encoded, passphrase) + + +def test_recovery_bundle_output_is_exclusive_and_does_not_follow_symlinks( + tmp_path: Path, +) -> None: + bundle = encrypt_bundle({"catalog": {"version": 1}, "keys": []}, PASSPHRASE) + existing = tmp_path / "existing.btrec" + existing.write_bytes(b"keep") + + with pytest.raises(RecoveryBundlePathError, match="^recovery bundle output is unsafe$"): + write_bundle_exclusive(existing, bundle) + assert existing.read_bytes() == b"keep" + + target = tmp_path / "target.btrec" + target.write_bytes(b"keep") + symlink = tmp_path / "link.btrec" + symlink.symlink_to(target) + with pytest.raises(RecoveryBundlePathError, match="^recovery bundle output is unsafe$"): + write_bundle_exclusive(symlink, bundle) + assert target.read_bytes() == b"keep" + + +def test_cli_recovery_export_validate_uses_fd_and_hides_plaintext_keys( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + settings = make_settings(tmp_path) + command.upgrade(build_alembic_config(settings), "head") + initialized = initialize(settings, "encrypted", "none", "aes-256-gcm") + assert initialized.data_key_id is not None + assert initialized.data_key_path is not None + + async def create_repository() -> None: + engine = create_engine(settings) + sessions = async_sessionmaker(engine, expire_on_commit=False) + try: + async with sessions() as db: + repository = Repository( + name="encrypted", + root=str(initialized.root), + format_version=initialized.format_version, + compression=initialized.compression, + encryption=initialized.encryption, + signing_key_id=initialized.signing_key_id, + signing_public_key=initialized.signing_public_key, + active_data_key_id=initialized.data_key_id, + ) + db.add(repository) + await db.flush() + db.add( + RepositoryDataKeyEpoch( + repository_id=repository.id, + key_id=initialized.data_key_id, + state="active", + ) + ) + await db.commit() + finally: + await engine.dispose() + + asyncio.run(create_repository()) + output = tmp_path / "recovery.btrec" + export_fd = _passphrase_fd(PASSPHRASE) + try: + assert ( + cli_main( + [ + "admin", + "recovery", + "export", + "--output", + str(output), + "--passphrase-fd", + str(export_fd), + ], + settings=settings, + ) + == 0 + ) + finally: + os.close(export_fd) + export_output = capsys.readouterr().out + bundle = output.read_bytes() + assert bundle.startswith(b"BTREC\x01") + assert initialized.data_key_path.read_bytes() not in bundle + signing_key_path = ( + settings.data_dir / "repository-keys" / f"{initialized.repository_id}.ed25519" + ) + assert signing_key_path.read_bytes() not in bundle + assert "key" not in export_output.lower() + + validate_fd = _passphrase_fd(PASSPHRASE) + try: + assert ( + cli_main( + [ + "admin", + "recovery", + "validate", + "--input", + str(output), + "--passphrase-fd", + str(validate_fd), + ], + settings=settings, + ) + == 0 + ) + finally: + os.close(validate_fd) + assert capsys.readouterr().out == '{"repositories": 1, "status": "valid"}\n' + + parser = build_parser() + with pytest.raises(SystemExit): + parser.parse_args(["admin", "recovery", "export", "--passphrase", "not-allowed"]) diff --git a/tests/security/test_restore_manifest_entries.py b/tests/security/test_restore_manifest_entries.py new file mode 100644 index 0000000..541dcbc --- /dev/null +++ b/tests/security/test_restore_manifest_entries.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import copy +import importlib + +import pytest + +snapshot = importlib.import_module("backup_tool.snapshot") + + +def file_entry(path: str = "data.txt") -> dict[str, object]: + return { + "path": path, + "type": "file", + "size": 1, + "blob_digest": "a" * 64, + "mode": 0o600, + "mtime_ns": 0, + "link_target": None, + "metadata_support": ["mode", "mtime_ns"], + } + + +@pytest.mark.parametrize( + "entries", + [ + [file_entry("../escape")], + [file_entry("/absolute")], + [file_entry("windows\\escape")], + [file_entry(), file_entry()], + [file_entry("file"), file_entry("file/child")], + [ + { + **file_entry("link"), + "type": "symlink", + "size": 0, + "blob_digest": None, + "link_target": "../escape", + } + ], + ], +) +def test_restore_rejects_semantically_unsafe_signed_manifest_entries( + entries: list[dict[str, object]], +) -> None: + manifest = {"entries": copy.deepcopy(entries)} + + with pytest.raises(snapshot.SnapshotIntegrityError): + snapshot._safe_restore_entries(manifest) diff --git a/tests/security/test_restore_paths.py b/tests/security/test_restore_paths.py new file mode 100644 index 0000000..3343114 --- /dev/null +++ b/tests/security/test_restore_paths.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import importlib +import stat +from pathlib import Path + +import pytest +from backup_tool.config import Settings + +snapshot = importlib.import_module("backup_tool.snapshot") + + +def settings_for(tmp_path: Path) -> Settings: + key = tmp_path / "master.key" + key.write_bytes(b"m6-restore-path-test-master-key-material") + key.chmod(0o600) + repositories = tmp_path / "repositories" + sources = tmp_path / "sources" + restores = tmp_path / "restores" + for directory in (repositories, sources, restores): + directory.mkdir() + return Settings( + data_dir=tmp_path, + database_url=f"sqlite+aiosqlite:///{tmp_path / 'metadata.db'}", + repository_roots=(repositories,), + local_source_roots=(sources,), + restore_roots=(restores,), + master_key_file=key, + min_free_bytes=1, + ) + + +@pytest.mark.parametrize("name", ["existing", "outside", "root"]) +def test_restore_destination_rejects_existing_or_outside_paths(tmp_path: Path, name: str) -> None: + settings = settings_for(tmp_path) + root = settings.restore_roots[0] + outside = tmp_path / "outside" + outside.mkdir() + existing = root / "existing" + existing.mkdir() + destinations = { + "existing": existing, + "outside": outside / "restore", + "root": root, + } + + with pytest.raises(snapshot.SnapshotError): + snapshot.validate_restore_destination(settings, str(destinations[name])) + + +def test_restore_strips_special_permission_bits(tmp_path: Path) -> None: + target = tmp_path / "restored" + target.write_bytes(b"content") + + snapshot._apply_metadata( + target, + {"metadata_support": ["mode"], "mode": 0o7777}, + ) + + mode = stat.S_IMODE(target.stat().st_mode) + assert mode == 0o777 + assert mode & (stat.S_ISUID | stat.S_ISGID | stat.S_ISVTX) == 0 + + +def test_restore_destination_rejects_a_symlinked_parent(tmp_path: Path) -> None: + settings = settings_for(tmp_path) + outside = tmp_path / "outside" + outside.mkdir() + (settings.restore_roots[0] / "linked").symlink_to(outside, target_is_directory=True) + + with pytest.raises(snapshot.SnapshotError): + snapshot.validate_restore_destination( + settings, str(settings.restore_roots[0] / "linked" / "restore") + ) diff --git a/tests/security/test_session_security.py b/tests/security/test_session_security.py new file mode 100644 index 0000000..48f1d60 --- /dev/null +++ b/tests/security/test_session_security.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import importlib +from datetime import UTC, datetime, timedelta + +import pytest + +PASSWORD = "correct horse battery staple" + + +@pytest.mark.asyncio +async def test_remote_setup_requires_bootstrap_secret(tmp_path) -> None: + config = importlib.import_module("backup_tool.config") + app_module = importlib.import_module("backup_tool.api.app") + key = tmp_path / "master.key" + key.write_bytes(b"m2-test-master-key-material-32-bytes-minimum") + key.chmod(0o600) + roots = [] + for name in ("data", "repositories", "sources", "restores"): + path = tmp_path / name + path.mkdir() + roots.append(path) + settings = config.Settings( + data_dir=roots[0], + database_url=f"sqlite+aiosqlite:///{roots[0] / 'metadata.db'}", + repository_roots=(roots[1],), + local_source_roots=(roots[2],), + restore_roots=(roots[3],), + master_key_file=key, + public_base_url="https://backup.example.test", + bootstrap_secret="bootstrap-secret-123", + ) + cli = importlib.import_module("backup_tool.cli") + from alembic import command + from httpx import ASGITransport, AsyncClient + + command.upgrade(cli.build_alembic_config(settings), "head") + app = app_module.create_app(settings) + async with AsyncClient( + transport=ASGITransport(app=app), base_url="https://backup.example.test" + ) as client: + denied = await client.post( + "/api/v2/setup", json={"username": "admin", "password": PASSWORD} + ) + assert denied.status_code == 403 + accepted = await client.post( + "/api/v2/setup", + json={ + "username": "admin", + "password": PASSWORD, + "bootstrap_secret": "bootstrap-secret-123", + }, + ) + assert accepted.status_code == 201 + await app.state.engine.dispose() + + +@pytest.mark.asyncio +async def test_remote_setup_without_configured_bootstrap_fails_closed(tmp_path) -> None: + config = importlib.import_module("backup_tool.config") + app_module = importlib.import_module("backup_tool.api.app") + cli = importlib.import_module("backup_tool.cli") + from alembic import command + from httpx import ASGITransport, AsyncClient + + key = tmp_path / "master.key" + key.write_bytes(b"m2-test-master-key-material-32-bytes-minimum") + key.chmod(0o600) + roots = [tmp_path / name for name in ("data", "repositories", "sources", "restores")] + for root in roots: + root.mkdir() + settings = config.Settings( + data_dir=roots[0], + database_url=f"sqlite+aiosqlite:///{roots[0] / 'metadata.db'}", + repository_roots=(roots[1],), + local_source_roots=(roots[2],), + restore_roots=(roots[3],), + master_key_file=key, + public_base_url="https://backup.example.test", + ) + command.upgrade(cli.build_alembic_config(settings), "head") + app = app_module.create_app(settings) + async with AsyncClient( + transport=ASGITransport(app=app), base_url=settings.public_base_url + ) as client: + response = await client.post( + "/api/v2/setup", json={"username": "admin", "password": PASSWORD} + ) + assert response.status_code == 403 + assert response.json()["code"] == "bootstrap_required" + await app.state.engine.dispose() + + +def test_expired_session_is_rejected(tmp_path) -> None: + auth = importlib.import_module("backup_tool.security.auth") + key = tmp_path / "master.key" + key.write_bytes(b"x" * 32) + expired = auth.sign_session( + "user", + "csrf", + key, + expires_at=datetime.now(UTC) - timedelta(seconds=1), + session_id="session", + ) + assert auth.verify_session(expired, key) is None + + +@pytest.mark.asyncio +async def test_logout_revocation_survives_app_restart(tmp_path) -> None: + config = importlib.import_module("backup_tool.config") + app_module = importlib.import_module("backup_tool.api.app") + cli = importlib.import_module("backup_tool.cli") + from alembic import command + from httpx import ASGITransport, AsyncClient + + key = tmp_path / "master.key" + key.write_bytes(b"m2-test-master-key-material-32-bytes-minimum") + key.chmod(0o600) + roots = [tmp_path / name for name in ("data", "repositories", "sources", "restores")] + for root in roots: + root.mkdir() + settings = config.Settings( + data_dir=roots[0], + database_url=f"sqlite+aiosqlite:///{roots[0] / 'metadata.db'}", + repository_roots=(roots[1],), + local_source_roots=(roots[2],), + restore_roots=(roots[3],), + master_key_file=key, + public_base_url="https://127.0.0.1:8000", + ) + command.upgrade(cli.build_alembic_config(settings), "head") + app = app_module.create_app(settings) + async with AsyncClient( + transport=ASGITransport(app=app), base_url=settings.public_base_url + ) as client: + setup = await client.post("/api/v2/setup", json={"username": "admin", "password": PASSWORD}) + assert setup.status_code == 201 + copied_cookie = client.cookies.get("backup_tool_session") + csrf = client.cookies.get("backup_tool_csrf") + assert copied_cookie is not None + assert csrf is not None + logout = await client.post("/api/v2/auth/logout", headers={"X-CSRF-Token": csrf}) + assert logout.status_code == 204 + await app.state.engine.dispose() + + restarted = app_module.create_app(settings) + async with AsyncClient( + transport=ASGITransport(restarted), base_url=settings.public_base_url + ) as client: + rejected = await client.get( + "/api/v2/auth/session", + headers={"Cookie": f"backup_tool_session={copied_cookie}"}, + ) + assert rejected.status_code == 401 + await restarted.state.engine.dispose() diff --git a/tests/security/test_webhook_ssrf.py b/tests/security/test_webhook_ssrf.py new file mode 100644 index 0000000..1b2636a --- /dev/null +++ b/tests/security/test_webhook_ssrf.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import pytest +from backup_tool.security.ssrf import ( + SSRFError, + resolve_public_addresses, + validate_webhook_url, +) + + +@pytest.mark.asyncio +async def test_private_or_mixed_answers_are_rejected() -> None: + async def private(_host: str, _port: int) -> tuple[str, ...]: + return ("8.8.8.8", "127.0.0.1") + + with pytest.raises(SSRFError, match="non-public"): + await resolve_public_addresses("hooks.example.test", 443, private) + + +@pytest.mark.parametrize( + "value", + [ + "https://127.0.0.1/callback", + "https://user:pass@hooks.example.test/callback", + "https://hooks.example.test/callback#fragment", + "ftp://hooks.example.test/callback", + "https://hooks.example.test:22/callback", + ], +) +def test_webhook_url_rejects_bypasses(value: str) -> None: + with pytest.raises(SSRFError): + validate_webhook_url(value) diff --git a/tests/ssh-fixture/Dockerfile b/tests/ssh-fixture/Dockerfile new file mode 100644 index 0000000..78a9776 --- /dev/null +++ b/tests/ssh-fixture/Dockerfile @@ -0,0 +1,18 @@ +FROM debian:12-slim + +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends -y openssh-server \ + && rm -rf /var/lib/apt/lists/* \ + && (getent group backup >/dev/null || groupadd --system backup) \ + && (id backup >/dev/null 2>&1 || useradd --system --gid backup --home-dir /home/backup --shell /usr/sbin/nologin backup) \ + && install -d -o root -g root -m 0755 /home/backup \ + && install -d -o backup -g backup -m 0700 /home/backup/.ssh \ + && install -d -o backup -g backup -m 0755 /home/backup/data \ + && install -d -o root -g root -m 0755 /run/sshd + +COPY sshd_config /etc/ssh/sshd_config +COPY entrypoint.sh /usr/local/bin/fixture-sshd +RUN chmod 0755 /usr/local/bin/fixture-sshd + +EXPOSE 2222 +CMD ["/usr/local/bin/fixture-sshd"] diff --git a/tests/ssh-fixture/entrypoint.sh b/tests/ssh-fixture/entrypoint.sh new file mode 100644 index 0000000..d07048f --- /dev/null +++ b/tests/ssh-fixture/entrypoint.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -eu + +# The ephemeral host key is bind-mounted from the test host and can therefore +# have an untrusted numeric owner. Copy it into the root-owned tmpfs before +# sshd checks host-key ownership and permissions. +install -o root -g root -m 0600 /fixture/host/ssh_host_ed25519_key /run/sshd/ssh_host_ed25519_key +# Authorized keys are public material; root ownership prevents fixture-user mutation. +install -o root -g root -m 0644 /fixture/authorized_keys /run/sshd/authorized_keys +exec /usr/sbin/sshd -D -e -f /etc/ssh/sshd_config diff --git a/tests/ssh-fixture/sshd_config b/tests/ssh-fixture/sshd_config new file mode 100644 index 0000000..1d49540 --- /dev/null +++ b/tests/ssh-fixture/sshd_config @@ -0,0 +1,28 @@ +Port 2222 +ListenAddress 0.0.0.0 +HostKey /run/sshd/ssh_host_ed25519_key +PidFile /run/sshd/sshd.pid +AuthorizedKeysFile /run/sshd/authorized_keys +UsePAM no +PasswordAuthentication no +KbdInteractiveAuthentication no +ChallengeResponseAuthentication no +PermitRootLogin no +PermitEmptyPasswords no +PubkeyAuthentication yes +PermitUserEnvironment no +AllowTcpForwarding no +X11Forwarding no +PermitTunnel no +PermitTTY no +GatewayPorts no +AllowAgentForwarding no +LogLevel VERBOSE +Subsystem sftp internal-sftp + +Match User backup + ChrootDirectory /home/backup + ForceCommand internal-sftp + AllowTcpForwarding no + X11Forwarding no + PermitTTY no diff --git a/tests/unit/.gitkeep b/tests/unit/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e0310a0 --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1 @@ +"""Unit tests.""" diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py new file mode 100644 index 0000000..44579a3 --- /dev/null +++ b/tests/unit/test_config.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import importlib +import os +import stat +from datetime import UTC +from pathlib import Path +from typing import Any + +import pytest +from pydantic import ValidationError + +cli = importlib.import_module("backup_tool.cli") +clock = importlib.import_module("backup_tool.clock") +config = importlib.import_module("backup_tool.config") +ids = importlib.import_module("backup_tool.ids") +build_parser = cli.build_parser +SystemClock = clock.SystemClock +Settings = config.Settings +new_uuid7 = ids.new_uuid7 + + +def valid_settings(tmp_path: Path, **overrides: object) -> Any: + data = tmp_path / "data" + repositories = tmp_path / "repositories" + sources = tmp_path / "sources" + restores = tmp_path / "restores" + for path in (data, repositories, sources, restores): + path.mkdir() + key = tmp_path / "master.key" + key.write_bytes(os.urandom(32)) + key.chmod(stat.S_IRUSR | stat.S_IWUSR) + values: dict[str, object] = { + "database_url": f"sqlite+aiosqlite:///{(data / 'metadata.db').as_posix()}", + "data_dir": data, + "repository_roots": [repositories], + "local_source_roots": [sources], + "restore_roots": [restores], + "master_key_file": key, + } + values.update(overrides) + return Settings(**values) + + +def test_settings_canonicalize_absolute_roots_and_database(tmp_path: Path) -> None: + settings = valid_settings(tmp_path) + expected_database = (tmp_path / "data" / "metadata.db").resolve() + expected_repositories = ((tmp_path / "repositories").resolve(),) + expected_sources = ((tmp_path / "sources").resolve(),) + expected_restores = ((tmp_path / "restores").resolve(),) + assert settings.database_path == expected_database + assert settings.repository_roots == expected_repositories + assert settings.local_source_roots == expected_sources + assert settings.restore_roots == expected_restores + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("database_url", "sqlite+aiosqlite:///relative.db"), + ("worker_concurrency", 2), + ("min_free_percent", 0), + ("min_free_percent", 100), + ], +) +def test_settings_reject_unsafe_values(tmp_path: Path, field: str, value: object) -> None: + with pytest.raises(ValidationError): + valid_settings(tmp_path, **{field: value}) + + +def test_settings_reject_unknown_fields(tmp_path: Path) -> None: + with pytest.raises(ValidationError): + valid_settings(tmp_path, unexpected=True) + + +def test_settings_reject_insecure_master_key_permissions(tmp_path: Path) -> None: + settings = valid_settings(tmp_path) + settings.master_key_file.chmod(0o644) + with pytest.raises(ValidationError, match="0600"): + Settings(**settings.model_dump()) + + +def test_uuid7_is_versioned_and_monotonically_ordered() -> None: + identifiers = [new_uuid7() for _ in range(100)] + assert all(identifier.version == 7 for identifier in identifiers) + assert identifiers == sorted(identifiers) + assert len(set(identifiers)) == len(identifiers) + + +def test_system_clock_returns_aware_utc() -> None: + now = SystemClock().now() + assert now.tzinfo is UTC + + +def test_cli_declares_isolated_runtime_roles() -> None: + parser = build_parser() + for role in ("web", "scheduler", "worker", "admin"): + args = parser.parse_args([role]) + assert args.role == role + + +def test_cli_dispatches_selected_role(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + settings = valid_settings(tmp_path) + observed: list[Any] = [] + + def handler(received: Any) -> int: + observed.append(received) + return 23 + + monkeypatch.setattr(cli, "require_current_schema", lambda _settings: None) + monkeypatch.setitem(cli.ROLE_HANDLERS, "web", handler) + assert cli.main(["web"], settings=settings) == 23 + assert observed == [settings] + + +def test_migrate_upgrade_dispatches_to_alembic( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + settings = valid_settings(tmp_path) + calls: list[tuple[str, str]] = [] + + def upgrade(_config: Any, revision: str) -> None: + calls.append(("upgrade", revision)) + + monkeypatch.setattr(cli.command, "upgrade", upgrade) + assert cli.main(["migrate", "upgrade"], settings=settings) == 0 + expected_calls = [("upgrade", "head")] + assert calls == expected_calls diff --git a/tests/unit/test_cron_dst.py b/tests/unit/test_cron_dst.py new file mode 100644 index 0000000..e3c34cc --- /dev/null +++ b/tests/unit/test_cron_dst.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from datetime import UTC, datetime + +import pytest +from backup_tool.scheduler import ScheduleError, next_nominal + + +def test_five_field_cron_returns_utc_nominal_time() -> None: + result = next_nominal("0 9 * * *", "America/New_York", datetime(2026, 1, 1, tzinfo=UTC)) + assert result.tzinfo is UTC + assert result.hour == 14 + + +@pytest.mark.parametrize(("cron", "timezone"), [("* * * * * *", "UTC"), ("* * * * *", "Nope/Zone")]) +def test_invalid_cron_or_timezone_is_rejected(cron: str, timezone: str) -> None: + with pytest.raises(ScheduleError): + next_nominal(cron, timezone) diff --git a/tests/unit/test_exclusions.py b/tests/unit/test_exclusions.py new file mode 100644 index 0000000..8c1d84f --- /dev/null +++ b/tests/unit/test_exclusions.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import pytest +from backup_tool.exclusions import ExclusionError, matches + + +@pytest.mark.parametrize( + ("path", "patterns", "expected"), + [ + ("notes.tmp", ["*.tmp"], True), + ("nested/notes.tmp", ["*.tmp"], True), + ("cache/item.txt", ["cache/"], True), + ("cache/keep.txt", ["cache/", "!cache/keep.txt"], False), + ("data/keep.txt", ["data/**"], True), + ("data.txt", ["data/**"], False), + ], +) +def test_gitignore_like_exclusions(path: str, patterns: list[str], expected: bool) -> None: + assert matches(path, patterns) is expected + + +@pytest.mark.parametrize("value", ["../escape", "/absolute", "windows\\path", ""]) +def test_exclusions_reject_unsafe_paths(value: str) -> None: + with pytest.raises(ExclusionError): + matches("safe.txt", [value]) diff --git a/tests/unit/test_execution_lifecycle.py b/tests/unit/test_execution_lifecycle.py new file mode 100644 index 0000000..9671632 --- /dev/null +++ b/tests/unit/test_execution_lifecycle.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from backup_tool.execution import ( + ACTIVE_STATES, + TERMINAL_STATES, + TransitionError, + transition, +) + + +def test_transitions_are_monotonic_and_terminal_states_cannot_change() -> None: + assert transition("queued", "preparing") == "preparing" + assert transition("preparing", "running") == "running" + assert transition("running", "verifying") == "verifying" + assert transition("verifying", "committed") == "committed" + assert "committed" in TERMINAL_STATES + assert "queued" in ACTIVE_STATES + try: + transition("committed", "cancelled") + except TransitionError as error: + assert error.code == "invalid_transition" + else: + raise AssertionError("terminal execution changed state") + + +def test_cancellation_is_cooperative() -> None: + assert transition("running", "cancelling") == "cancelling" + assert transition("cancelling", "cancelled") == "cancelled" diff --git a/tests/unit/test_execution_transitions.py b/tests/unit/test_execution_transitions.py new file mode 100644 index 0000000..00c887c --- /dev/null +++ b/tests/unit/test_execution_transitions.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import pytest +from backup_tool.execution import TransitionError, transition + + +@pytest.mark.parametrize( + ("current", "target"), + [ + ("queued", "preparing"), + ("queued", "cancelled"), + ("queued", "failed"), + ("preparing", "running"), + ("preparing", "cancelling"), + ("preparing", "failed"), + ("running", "verifying"), + ("running", "cancelling"), + ("running", "failed"), + ("verifying", "committed"), + ("verifying", "failed"), + ("cancelling", "cancelled"), + ("cancelling", "failed"), + ], +) +def test_all_legal_execution_transitions_are_accepted(current: str, target: str) -> None: + assert transition(current, target) == target + + +@pytest.mark.parametrize( + ("current", "target"), + [ + ("queued", "running"), + ("preparing", "queued"), + ("running", "preparing"), + ("verifying", "cancelling"), + ("cancelling", "running"), + ("committed", "cancelled"), + ("cancelled", "queued"), + ("failed", "queued"), + ("unknown", "queued"), + ], +) +def test_invalid_or_backward_execution_transitions_are_rejected(current: str, target: str) -> None: + with pytest.raises(TransitionError) as raised: + transition(current, target) + + assert raised.value.code == "invalid_transition" diff --git a/tests/unit/test_redaction.py b/tests/unit/test_redaction.py new file mode 100644 index 0000000..363c79b --- /dev/null +++ b/tests/unit/test_redaction.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import importlib + + +def test_redaction_removes_nested_secret_fields_and_canaries() -> None: + redaction = importlib.import_module("backup_tool.security.redaction") + canary = "canary-a96f" + value = { + "username": "operator", + "password": canary, + "nested": {"token": canary, "safe": "visible"}, + "items": [{"secret": canary}, canary], + } + result = redaction.redact(value, canaries=(canary,)) + rendered = repr(result) + assert canary not in rendered + assert result["username"] == "operator" + assert result["nested"]["safe"] == "visible" + assert result["password"] == "[REDACTED]" + + +def test_envelope_cipher_round_trips_with_purpose_binding(tmp_path) -> None: + secrets = importlib.import_module("backup_tool.security.secrets") + key_path = tmp_path / "master.key" + key_path.write_bytes(b"x" * 32) + cipher = secrets.EnvelopeCipher.from_file(key_path) + ciphertext, key_id = cipher.encrypt("sensitive", purpose="ssh", version=1) + assert b"sensitive" not in ciphertext + assert cipher.decrypt(ciphertext, purpose="ssh", version=1) == "sensitive" + assert key_id + + try: + cipher.decrypt(ciphertext, purpose="database", version=1) + except Exception as error: + assert error.__class__.__name__ == "InvalidTag" + else: # pragma: no cover - required safety assertion + raise AssertionError("ciphertext accepted under a different purpose") diff --git a/tests/unit/test_restore_selection.py b/tests/unit/test_restore_selection.py new file mode 100644 index 0000000..974e96e --- /dev/null +++ b/tests/unit/test_restore_selection.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import importlib + +import pytest + +snapshot = importlib.import_module("backup_tool.snapshot") + + +def entry(path: str, kind: str) -> dict[str, object]: + return {"path": path, "type": kind} + + +def test_restore_selection_includes_selected_descendants_and_ancestors() -> None: + entries = [ + entry("top", "directory"), + entry("top/keep", "directory"), + entry("top/keep/file.txt", "file"), + entry("top/drop.txt", "file"), + ] + + selected = snapshot._select_restore_entries(entries, ["top/keep"]) + + assert [item["path"] for item in selected] == [ + "top", + "top/keep", + "top/keep/file.txt", + ] + + +@pytest.mark.parametrize("selection", [["../escape"], ["/absolute"], ["a\\b"], [""]]) +def test_restore_selection_rejects_unsafe_paths(selection: list[str]) -> None: + with pytest.raises(snapshot.SnapshotError): + snapshot._select_restore_entries([entry("safe", "file")], selection) diff --git a/tests/unit/test_retention.py b/tests/unit/test_retention.py new file mode 100644 index 0000000..171c1b1 --- /dev/null +++ b/tests/unit/test_retention.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta + +import pytest +from backup_tool.retention import RetentionError, RetentionPolicy, retained_ids + + +@dataclass +class Backup: + id: str + created_at: datetime + pinned: bool = False + tombstoned_at: datetime | None = None + + +def test_retention_is_union_and_protects_newest() -> None: + now = datetime(2026, 7, 29, tzinfo=UTC) + backups = [Backup(str(index), now - timedelta(days=index)) for index in range(6)] + backups[-1].pinned = True + + kept = retained_ids(backups, RetentionPolicy(keep_last=2, keep_daily=3), now) + + assert {"0", "1", "2", "5"} <= kept + + +def test_newest_is_kept_even_when_policy_is_zero() -> None: + now = datetime(2026, 7, 29, tzinfo=UTC) + assert retained_ids([Backup("new", now)], RetentionPolicy(keep_last=0), now) == {"new"} + + +@pytest.mark.parametrize("policy", [{"keep_last": -1}, {"unknown": 1}, {"keep_days": True}]) +def test_invalid_retention_policy_is_rejected(policy: dict[str, object]) -> None: + with pytest.raises(RetentionError): + RetentionPolicy.from_dict(policy) diff --git a/tests/unit/test_ssh_adapter.py b/tests/unit/test_ssh_adapter.py new file mode 100644 index 0000000..d294513 --- /dev/null +++ b/tests/unit/test_ssh_adapter.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +import io +import stat +from dataclasses import dataclass +from pathlib import Path + +import paramiko +import pytest +from backup_tool.adapters import SourceError +from backup_tool.ssh_adapter import SSHAdapter, load_private_key +from backup_tool.ssh_source import SSHSourcePublicConfig + +from tests.conftest import make_settings + + +@dataclass +class Attributes: + filename: str + st_mode: int + st_size: int = 0 + st_mtime: int = 1 + + +class ServerKey: + def __init__(self, algorithm: str = "ssh-ed25519", encoded: str = "AQID") -> None: + self.algorithm = algorithm + self.encoded = encoded + + def get_name(self) -> str: + return self.algorithm + + def get_base64(self) -> str: + return self.encoded + + +class Transport: + def __init__(self, key: ServerKey) -> None: + self.key = key + self.events: list[str] = [] + self.closed = False + + def start_client(self, *, timeout: float) -> None: + self.events.append("start") + + def get_remote_server_key(self) -> ServerKey: + self.events.append("host_key") + return self.key + + def auth_publickey(self, username: str, private_key: object) -> None: + self.events.append("auth") + + def close(self) -> None: + self.closed = True + + +class Channel: + def __init__(self, events: list[str]) -> None: + self.events = events + + def settimeout(self, timeout: float) -> None: + self.events.append("timeout") + + +class Handle: + def __init__(self, chunks: list[bytes]) -> None: + self.chunks = chunks + self.closed = False + + def read(self, size: int) -> bytes: + assert size == 4096 + return self.chunks.pop(0) if self.chunks else b"" + + def close(self) -> None: + self.closed = True + + +class SFTP: + def __init__(self, events: list[str], entries: list[Attributes]) -> None: + self.events = events + self.entries = entries + self.handle = Handle([b"one", b"two"]) + self.closed = False + + def get_channel(self) -> Channel: + return Channel(self.events) + + def listdir_iter(self, path: str, *, read_aheads: int): + self.events.append(f"list:{path}:{read_aheads}") + return iter(self.entries) + + def lstat(self, path: str) -> Attributes: + self.events.append(f"lstat:{path}") + return Attributes("file", stat.S_IFREG | 0o640, 6, 1) + + def open(self, path: str, mode: str, bufsize: int) -> Handle: + self.events.append(f"open:{path}:{mode}:{bufsize}") + return self.handle + + def close(self) -> None: + self.closed = True + + +def config() -> SSHSourcePublicConfig: + return SSHSourcePublicConfig( + hostname="backup.example.test", + port=22, + username="backup", + host_key="ssh-ed25519 AQID", + root="/", + ) + + +def adapter( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, transport: Transport, sftp: SFTP +) -> SSHAdapter: + monkeypatch.setattr("backup_tool.ssh_adapter.load_private_key", lambda _: object()) + settings = make_settings(tmp_path).model_copy(update={"ssh_read_chunk_bytes": 4096}) + return SSHAdapter( + config(), + "private-key-is-never-sent-to-a-log", + settings, + transport_factory=lambda *_: transport, + sftp_factory=lambda _: sftp, + ) + + +@pytest.mark.asyncio +async def test_host_pin_mismatch_never_authenticates_or_opens_sftp( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + transport = Transport(ServerKey(encoded="BAUG")) + sftp = SFTP(transport.events, []) + reader = adapter(tmp_path, monkeypatch, transport, sftp) + + with pytest.raises(SourceError, match="host key") as error: + await reader.probe() + + assert error.value.reason_code == "source_trust" + assert transport.events == ["start", "host_key"] + assert transport.closed + assert "timeout" not in sftp.events + assert not any(event.startswith("list:") for event in sftp.events) + + +@pytest.mark.asyncio +async def test_pinned_transport_authenticates_before_sftp_and_streams_bounded_reads( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + transport = Transport(ServerKey()) + sftp = SFTP(transport.events, [Attributes("file", stat.S_IFREG | 0o640, 6, 1)]) + reader = adapter(tmp_path, monkeypatch, transport, sftp) + + entries = [entry async for entry in reader.enumerate_entries()] + content = b"".join([chunk async for chunk in reader.open_content("file")]) + await reader.close() + + assert entries[0].path == "file" + assert content == b"onetwo" + assert transport.events.index("host_key") < transport.events.index("auth") + assert transport.events.index("auth") < transport.events.index("timeout") + assert "list:/:32" in sftp.events + assert "open:/file:rb:4096" in sftp.events + assert sftp.handle.closed and sftp.closed and transport.closed + + +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", [stat.S_IFLNK | 0o777, stat.S_IFIFO | 0o600]) +async def test_sftp_rejects_symlinks_and_special_entries( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mode: int +) -> None: + transport = Transport(ServerKey()) + sftp = SFTP(transport.events, [Attributes("unsafe", mode)]) + reader = adapter(tmp_path, monkeypatch, transport, sftp) + + with pytest.raises(SourceError, match="symlink|unsupported"): + await anext(reader.enumerate_entries()) + + assert "auth" in transport.events + await reader.close() + + +def test_private_key_loader_rejects_short_rsa_and_accepts_strong_rsa() -> None: + short = paramiko.RSAKey.generate(2048) + strong = paramiko.RSAKey.generate(3072) + short_buffer = io.StringIO() + strong_buffer = io.StringIO() + short.write_private_key(short_buffer) + strong.write_private_key(strong_buffer) + + with pytest.raises(SourceError, match="algorithm") as error: + load_private_key(short_buffer.getvalue()) + assert error.value.reason_code == "source_auth" + loaded = load_private_key(strong_buffer.getvalue()) + assert isinstance(loaded, paramiko.RSAKey) diff --git a/tests/unit/test_ssh_source_config.py b/tests/unit/test_ssh_source_config.py new file mode 100644 index 0000000..b7cd1ac --- /dev/null +++ b/tests/unit/test_ssh_source_config.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import pytest +from backup_tool.ssh_source import SSHSourcePublicConfig +from pydantic import ValidationError + +VALID_CONFIG = { + "hostname": "backup.example.test", + "port": 22, + "username": "backup", + "host_key": "ssh-ed25519 AQID", + "root": "/", +} + + +def test_ssh_source_config_is_closed_and_canonical() -> None: + config = SSHSourcePublicConfig.model_validate(VALID_CONFIG) + + assert config.model_dump() == VALID_CONFIG + + +@pytest.mark.parametrize( + "field,value", + [ + ("hostname", "backup user@example.test"), + ("hostname", "ssh://backup.example.test"), + ("port", 0), + ("port", 65536), + ("username", "backup user"), + ("username", "backup/root"), + ("host_key", "ssh-ed25519 not-base64!"), + ("host_key", "ssh-rsa AQID"), + ("root", "/data"), + ], +) +def test_ssh_source_config_rejects_invalid_public_values(field: str, value: object) -> None: + invalid = {**VALID_CONFIG, field: value} + + with pytest.raises(ValidationError): + SSHSourcePublicConfig.model_validate(invalid) + + +def test_ssh_source_config_rejects_non_public_connection_options() -> None: + with pytest.raises(ValidationError): + SSHSourcePublicConfig.model_validate({**VALID_CONFIG, "password": "not-allowed"}) + + with pytest.raises(ValidationError): + SSHSourcePublicConfig.model_validate({**VALID_CONFIG, "remote_command": "not-allowed"}) diff --git a/tests/unit/test_webhook_signing.py b/tests/unit/test_webhook_signing.py new file mode 100644 index 0000000..209e176 --- /dev/null +++ b/tests/unit/test_webhook_signing.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from backup_tool.notifications.webhook import ( + SigningMaterial, + canonical_signing_input, + signatures, + webhook_headers, +) + + +def test_signature_is_stable_and_dual_key_versioned() -> None: + body = b'{"id":"018f"}' + timestamp = "2026-07-30T00:00:00+00:00" + keys = [ + SigningMaterial("key-a", 1, "old-secret"), + SigningMaterial("key-b", 2, "new-secret"), + ] + values = signatures(timestamp, body, keys) + assert len(values) == 2 + assert "key_id=key-a" in values[0] and "key_version=1" in values[0] + assert "key_id=key-b" in values[1] and "key_version=2" in values[1] + assert values == signatures(timestamp, body, keys) + assert canonical_signing_input(timestamp, body).endswith(body) + headers = webhook_headers("event-id", "execution.queued", timestamp, body, keys) + receiver_headers = {key: value for key, value in headers if key != "X-Backup-Signature"} + receiver_signatures = [value for key, value in headers if key == "X-Backup-Signature"] + assert receiver_headers["X-Backup-Event-ID"] == "event-id" + assert receiver_headers["X-Backup-Event-Type"] == "execution.queued" + assert receiver_headers["X-Backup-Timestamp"] == timestamp + assert receiver_signatures == values diff --git a/tools/assert_capabilities.py b/tools/assert_capabilities.py new file mode 100644 index 0000000..fbe9321 --- /dev/null +++ b/tools/assert_capabilities.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""Assert the released v2 capability contract is truthful and explicit.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +CONTRACT = Path("contracts/repository/v1/capabilities-v2.0.json") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--release", required=True) + parser.add_argument("--include", required=True) + parser.add_argument("--exclude", required=True) + args = parser.parse_args() + if args.release != "v2.0": + raise SystemExit("only v2.0 capability certification is supported") + try: + payload = json.loads(CONTRACT.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise SystemExit(f"cannot load capability contract: {error}") from error + available = set(payload["sources"]) + available.update(name for name, enabled in payload["features"].items() if enabled) + required = {item for item in args.include.split(",") if item} + forbidden = {item for item in args.exclude.split(",") if item} + missing = sorted(required - available) + present = sorted(forbidden & available) + if missing or present: + raise SystemExit(f"capability assertion failed: missing={missing}, forbidden={present}") + print(f"v2.0 capabilities certified: {', '.join(sorted(available))}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/export_openapi.py b/tools/export_openapi.py new file mode 100644 index 0000000..b3590e8 --- /dev/null +++ b/tools/export_openapi.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Export the API schema without depending on operator configuration or network I/O.""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import tempfile +from pathlib import Path + +from backup_tool.api.app import create_app +from backup_tool.config import Settings + + +def export_schema() -> dict[str, object]: + """Build the FastAPI schema with disposable, valid settings.""" + with tempfile.TemporaryDirectory(prefix="backup-tool-openapi-") as temporary: + root = Path(temporary) + key = root / "master.key" + key.write_bytes(b"openapi-export-key-material-must-be-at-least-32-bytes") + key.chmod(0o600) + for name in ("data", "repositories", "sources", "restores"): + (root / name).mkdir() + settings = Settings( + data_dir=root / "data", + database_url=f"sqlite+aiosqlite:///{root / 'data' / 'metadata.db'}", + repository_roots=(root / "repositories",), + local_source_roots=(root / "sources",), + restore_roots=(root / "restores",), + master_key_file=key, + ) + app = create_app(settings) + schema = app.openapi() + # Engine construction is lazy, but dispose defensively if that changes. + asyncio.run(app.state.engine.dispose()) + return schema + + +def main() -> int: + parser = argparse.ArgumentParser(description="Export the deterministic v2 OpenAPI document.") + parser.add_argument("--output", type=Path, default=Path("openapi/v2.json")) + parser.add_argument("--check", type=Path, metavar="PATH", help="fail when PATH differs") + arguments = parser.parse_args() + output = arguments.check or arguments.output + rendered = json.dumps(export_schema(), indent=2, sort_keys=True, ensure_ascii=False) + "\n" + if arguments.check: + if not output.is_file() or output.read_text(encoding="utf-8") != rendered: + print(f"OpenAPI drift: regenerate {output}") + return 1 + print(f"OpenAPI is current: {output}") + return 0 + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(rendered, encoding="utf-8") + # The schema is public source, not executable configuration. + os.chmod(output, 0o644) + print(f"Exported OpenAPI: {output}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/forbidden_v1_scan.py b/tools/forbidden_v1_scan.py new file mode 100644 index 0000000..1c6a4d3 --- /dev/null +++ b/tools/forbidden_v1_scan.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Reject executable v1 compatibility paths and symbols from the v2 tree.""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +LEGACY_PATHS = ( + Path("backend/app"), + Path("backend/backup"), + Path("backend/tests"), + Path("frontend/src/api/client.ts"), +) +SCAN_ROOTS = ( + Path("backend/src"), + Path("frontend/src"), + Path("openapi"), + Path("config"), + Path(".github/workflows"), +) +SCAN_FILES = ( + Path("backend/Dockerfile"), + Path("frontend/Dockerfile"), + Path("frontend/nginx.conf"), + Path("frontend/vite.config.ts"), + Path("docker-compose.yml"), + Path("compose.yaml"), + Path("backend/pyproject.toml"), + Path("frontend/package.json"), +) +TEXT_SUFFIXES = {".json", ".py", ".toml", ".ts", ".tsx", ".yaml", ".yml"} +FORBIDDEN = ( + re.compile(r"/api/v1(?:/|\b)"), + re.compile( + r"\b(?:legacy|v1)[_-]?(?:database|db|payload|backup)?[_-]?(?:reader|importer?|converter?)\b", + re.IGNORECASE, + ), + re.compile( + r"\b(?:read|open|load|import|convert)[_-]?(?:legacy|v1)[_-]?(?:database|db|payload|backup)\b", + re.IGNORECASE, + ), + re.compile(r"[\"']backup_tool\.db[\"']", re.IGNORECASE), + re.compile(r"%Y-%m-%d_%H%M%S"), + re.compile(r"\btimestamp[_-]?(?:directory|parser)\b", re.IGNORECASE), + re.compile(r"\b(?:app\.main|backup\.engine)\b"), +) + + +def scan(root: Path) -> list[str]: + findings: list[str] = [] + for relative in LEGACY_PATHS: + if (root / relative).exists(): + findings.append(f"legacy path exists: {relative}") + + candidates = {root / relative for relative in SCAN_FILES if (root / relative).is_file()} + for relative_root in SCAN_ROOTS: + scan_root = root / relative_root + if scan_root.exists(): + candidates.update( + path + for path in scan_root.rglob("*") + if path.is_file() and path.suffix in TEXT_SUFFIXES + ) + + for path in sorted(candidates): + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeError) as error: + findings.append(f"cannot read {path.relative_to(root)}: {error}") + continue + for pattern in FORBIDDEN: + if pattern.search(text): + findings.append(f"forbidden symbol {pattern.pattern!r}: {path.relative_to(root)}") + return findings + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("root", nargs="?", default=".", type=Path) + args = parser.parse_args() + root = args.root.resolve() + findings = scan(root) + if findings: + print("v1 compatibility scan failed:", file=sys.stderr) + for finding in findings: + print(f"- {finding}", file=sys.stderr) + return 1 + print("v1 compatibility scan: OK") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/generate_api_client.py b/tools/generate_api_client.py new file mode 100644 index 0000000..1ba9054 --- /dev/null +++ b/tools/generate_api_client.py @@ -0,0 +1,299 @@ +#!/usr/bin/env python3 +"""Generate the browser API client from the committed OpenAPI document.""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path +from typing import Any + +HEADER = """/* eslint-disable */ +/* + * Generated by tools/generate_api_client.py from openapi/v2.json. + * Do not edit this file directly. Run `npm --prefix frontend run api:generate`. + */ + +""" + + +def identifier(value: str) -> str: + parts = re.split(r"[^A-Za-z0-9]+", value) + result = "".join(part[:1].upper() + part[1:] for part in parts if part) + if not result: + return "Anonymous" + return f"_{result}" if result[0].isdigit() else result + + +def property_name(value: str) -> str: + return value if re.match(r"^[A-Za-z_$][A-Za-z0-9_$]*$", value) else json.dumps(value) + + +def operation_name(operation_id: str) -> str: + if "_api_" in operation_id: + prefix = operation_id.split("_api_", 1)[0] + else: + prefix = re.sub(r"_(get|post|put|patch|delete)$", "", operation_id) + first, *rest = prefix.split("_") + prefix = first if rest and all(part == first for part in rest) else prefix + pieces = [piece for piece in prefix.split("_") if piece] + return pieces[0] + "".join(piece.capitalize() for piece in pieces[1:]) + + +def schema_type(schema: dict[str, Any]) -> str: + if "$ref" in schema: + return f"Components['schemas'][{json.dumps(schema['$ref'].rsplit('/', 1)[-1])}]" + for key in ("anyOf", "oneOf", "allOf"): + if key in schema: + separator = " & " if key == "allOf" else " | " + return separator.join(schema_type(item) for item in schema[key]) + if "enum" in schema: + return " | ".join(json.dumps(value) for value in schema["enum"]) + kind = schema.get("type") + if kind == "string": + return "string" + if kind in {"integer", "number"}: + return "number" + if kind == "boolean": + return "boolean" + if kind == "null": + return "null" + if kind == "array": + return f"Array<{schema_type(schema.get('items', {}))}>" + if kind == "object" or "properties" in schema: + properties = schema.get("properties", {}) + required = set(schema.get("required", [])) + fields = [ + f"{property_name(name)}{' ' if name in required else '?'}: {schema_type(value)}" + for name, value in properties.items() + ] + additional = schema.get("additionalProperties") + if not fields: + return ( + f"Record" + if isinstance(additional, dict) + else "Record" + ) + result = "{ " + "; ".join(fields) + " }" + if isinstance(additional, dict): + result += f" & Record" + return result + return "unknown" + + +def response_type(operation: dict[str, Any]) -> str: + responses = operation.get("responses", {}) + successful = next((item for status, item in responses.items() if status.startswith("2")), None) + if successful is None: + return "void" + content = successful.get("content", {}) + json_content = content.get("application/json") + if not json_content: + return "void" + return schema_type(json_content.get("schema", {})) + + +def parameter_type(parameters: list[dict[str, Any]], location: str) -> str | None: + selected = [parameter for parameter in parameters if parameter.get("in") == location] + if not selected: + return None + fields = [] + for parameter in selected: + optional = "" if parameter.get("required") else "?" + parameter_schema = schema_type(parameter.get("schema", {})) + fields.append(f"{property_name(parameter['name'])}{optional}: {parameter_schema}") + return "{ " + "; ".join(fields) + " }" + + +def request_body_type(operation: dict[str, Any]) -> tuple[str | None, bool]: + body = operation.get("requestBody") + if not body: + return None, False + content = body.get("content", {}) + json_content = content.get("application/json") + return ( + schema_type(json_content.get("schema", {})) if json_content else "unknown", + bool(body.get("required")), + ) + + +def is_event_stream(operation: dict[str, Any]) -> bool: + return any( + "text/event-stream" in response.get("content", {}) + for response in operation.get("responses", {}).values() + ) + + +def render_operation( + name: str, path: str, method: str, operation: dict[str, Any] +) -> tuple[str, str]: + parameters = operation.get("parameters", []) + path_type = parameter_type(parameters, "path") + query_type = parameter_type(parameters, "query") + body_type, body_required = request_body_type(operation) + fields: list[str] = [] + if path_type: + fields.append(f"path: {path_type}") + if query_type: + fields.append(f"query?: {query_type}") + if body_type: + fields.append(f"body{' ' if body_required else '?'}: {body_type}") + result = response_type(operation) + if fields: + params_name = f"{identifier(name)}Params" + declaration = f"export type {params_name} = {{ {'; '.join(fields)} }};\n\n" + signature = f"params: {params_name}, options: RequestOptions = {{}}" + parameter_expression = "params" + else: + declaration = "" + signature = "options: RequestOptions = {}" + parameter_expression = "{}" + path_expression = json.dumps(path) + for parameter in parameters: + if parameter.get("in") == "path": + name_value = parameter["name"] + path_expression += ( + f".replace({json.dumps('{' + name_value + '}')}, " + f"encodeURIComponent(String({parameter_expression}.path.{property_name(name_value)})))" + ) + query_expression = "" + if query_type: + query_expression = f"\n\t\tappendQuery(url.searchParams, {parameter_expression}.query);" + body_expression = f", {parameter_expression}.body" if body_type else "" + if is_event_stream(operation): + return ( + declaration, + "\t/** EventSource transport; intentionally not a JSON fetch Promise. */\n" + + f"\t{name}Url({signature.split(', options')[0]}): URL {{\n" + + f"\t\tconst url = new URL({path_expression}, this.baseUrl);" + + query_expression + + "\n\t\treturn url;\n\t}\n\n", + ) + return ( + declaration, + f"\tasync {name}({signature}): Promise<{result}> {{\n" + + f"\t\tconst url = new URL({path_expression}, this.baseUrl);" + + query_expression + + f"\n\t\treturn request<{result}>(\n" + + f"\t\t\turl, {json.dumps(method.upper())}, options{body_expression}\n" + + "\t\t);\n\t}\n\n", + ) + + +def generate(spec: dict[str, Any]) -> str: + schemas = spec.get("components", {}).get("schemas", {}) + type_lines = ["export interface Components {", " schemas: {"] + for name, schema in schemas.items(): + type_lines.append(f" {property_name(name)}: {schema_type(schema)};") + type_lines += [" };", "}", ""] + operation_declarations: list[str] = [] + operations: list[str] = [] + for path, path_item in spec.get("paths", {}).items(): + for method, operation in path_item.items(): + if method not in {"get", "post", "put", "patch", "delete"}: + continue + declaration, implementation = render_operation( + operation_name(operation["operationId"]), path, method, operation + ) + operation_declarations.append(declaration) + operations.append(implementation) + runtime = r"""export type RequestOptions = Omit; + +export type Problem = { + type: string; + title: string; + status: number; + detail: string; + instance: string; + code: string; +}; + +export class ApiError extends Error { + readonly status: number; + readonly problem?: Problem; + + constructor(status: number, problem?: Problem) { + super(problem?.detail ?? `Request failed with status ${status}.`); + this.name = "ApiError"; + this.status = status; + this.problem = problem; + } +} + +export function isApiError(error: unknown): error is ApiError { + return error instanceof ApiError; +} + +function appendQuery(search: URLSearchParams, query: Record | undefined): void { + if (!query) return; + for (const [key, value] of Object.entries(query)) { + if (value === undefined || value === null) continue; + for (const item of Array.isArray(value) ? value : [value]) search.append(key, String(item)); + } +} + +async function request( + url: URL, + method: string, + options: RequestOptions, + body?: unknown, +): Promise { + const headers = new Headers(options.headers); + if (body !== undefined && !headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + const response = await fetch(url, { + ...options, + method, + headers, + credentials: options.credentials ?? "same-origin", + body: body === undefined ? undefined : JSON.stringify(body), + }); + if (response.status === 204) return undefined as T; + const contentType = response.headers.get("content-type") ?? ""; + const isJson = contentType.includes("application/json") + || contentType.includes("application/problem+json"); + const payload: unknown = isJson ? await response.json() : undefined; + if (!response.ok) throw new ApiError(response.status, payload as Problem | undefined); + return payload as T; +} + +export class BackupToolClient { + constructor(readonly baseUrl = window.location.origin) {} + +""" + return ( + HEADER + + "\n".join(type_lines) + + "".join(operation_declarations) + + runtime + + "".join(operations) + + "}\n" + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Generate TypeScript API client from OpenAPI.") + parser.add_argument("--input", type=Path, default=Path("openapi/v2.json")) + parser.add_argument("--output", type=Path, default=Path("frontend/src/api/generated/client.ts")) + parser.add_argument("--check", action="store_true", help="fail when generated output differs") + arguments = parser.parse_args() + rendered = generate(json.loads(arguments.input.read_text(encoding="utf-8"))) + if arguments.check: + if ( + not arguments.output.is_file() + or arguments.output.read_text(encoding="utf-8") != rendered + ): + print(f"Generated client drift: regenerate {arguments.output}") + return 1 + print(f"Generated client is current: {arguments.output}") + return 0 + arguments.output.parent.mkdir(parents=True, exist_ok=True) + arguments.output.write_text(rendered, encoding="utf-8") + print(f"Generated API client: {arguments.output}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/generate_sbom.py b/tools/generate_sbom.py new file mode 100644 index 0000000..83ada7d --- /dev/null +++ b/tools/generate_sbom.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Generate a deterministic, dependency-only CycloneDX SBOM for the M14 OCI images.""" + +from __future__ import annotations + +import json +import re +import tomllib +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +OUTPUT = ROOT / "docs" / "release" / "m14-sbom.json" + + +def component(name: str, version: str, ecosystem: str) -> dict[str, str]: + return { + "name": name, + "type": "library", + "version": version, + "purl": f"pkg:{ecosystem}/{name}@{version}", + } + + +def main() -> None: + try: + project = tomllib.loads((ROOT / "backend" / "pyproject.toml").read_text()) + package_lock = json.loads((ROOT / "frontend" / "package-lock.json").read_text()) + except (OSError, json.JSONDecodeError, tomllib.TOMLDecodeError) as error: + raise RuntimeError("could not load pinned dependency metadata") from error + components: list[dict[str, str]] = [] + for dependency in project["project"]["dependencies"]: + match = re.fullmatch( + r"([A-Za-z0-9_.-]+)(?:\[[A-Za-z0-9_,-]+\])?==([A-Za-z0-9_.+-]+)", dependency + ) + if match is None: + raise ValueError(f"un-pinned Python dependency: {dependency}") + components.append(component(match.group(1), match.group(2), "pypi")) + for path, item in package_lock.get("packages", {}).items(): + if not path.startswith("node_modules/") or "version" not in item: + continue + components.append(component(path.removeprefix("node_modules/"), item["version"], "npm")) + payload = { + "bomFormat": "CycloneDX", + "components": sorted(components, key=lambda item: (item["purl"], item["version"])), + "metadata": { + "component": component("backup-tool", project["project"]["version"], "generic") + }, + "specVersion": "1.5", + "version": 1, + } + OUTPUT.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n") + + +if __name__ == "__main__": + main() diff --git a/tools/leakage_scan.py b/tools/leakage_scan.py new file mode 100644 index 0000000..807ec62 --- /dev/null +++ b/tools/leakage_scan.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import argparse +from pathlib import Path + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Fail when a secret canary appears in output files." + ) + parser.add_argument("--canary", action="append", default=[]) + parser.add_argument("paths", nargs="+") + args = parser.parse_args() + findings: list[str] = [] + for raw_path in args.paths: + path = Path(raw_path) + if not path.is_file(): + continue + text = path.read_text(encoding="utf-8", errors="replace") + if any(canary and canary in text for canary in args.canary): + findings.append(str(path)) + if findings: + print("secret canary found: " + ", ".join(findings)) + return 1 + print("leakage scan: OK") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/run_scale_certification.py b/tools/run_scale_certification.py new file mode 100644 index 0000000..1788288 --- /dev/null +++ b/tools/run_scale_certification.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Run reproducible synthetic v2.0 metadata-scale certification on the reference host.""" + +from __future__ import annotations + +import argparse +import json +import os +import platform +import sqlite3 +import tempfile +import time +from pathlib import Path + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--jobs", type=int, required=True) + parser.add_argument("--entries", type=int, required=True) + parser.add_argument("--backups", type=int, required=True) + parser.add_argument("--logical-bytes", type=int, default=10 * 1024**4) + parser.add_argument("--report", type=Path, default=Path("docs/release/m15-scale-report.json")) + args = parser.parse_args() + if min(args.jobs, args.entries, args.backups, args.logical_bytes) <= 0: + raise SystemExit("scale inputs must be positive") + + started = time.monotonic() + with tempfile.TemporaryDirectory(prefix="backup-tool-scale-") as directory: + database = Path(directory) / "scale.db" + connection = sqlite3.connect(database) + connection.executescript( + "CREATE TABLE jobs(id INTEGER PRIMARY KEY, name TEXT NOT NULL);" + "CREATE TABLE backups(" + "id INTEGER PRIMARY KEY, job_id INTEGER NOT NULL, " + "logical_bytes INTEGER NOT NULL, manifest_id TEXT NOT NULL);" + "CREATE INDEX backups_job_created ON backups(job_id, id DESC);" + ) + connection.executemany( + "INSERT INTO jobs(name) VALUES (?)", + ((f"job-{i}",) for i in range(args.jobs)), + ) + connection.commit() + write_start = time.monotonic() + batch = 10_000 + for first in range(0, args.backups, batch): + last = min(first + batch, args.backups) + connection.executemany( + "INSERT INTO backups(job_id, logical_bytes, manifest_id) VALUES (?, ?, ?)", + ( + ( + index % args.jobs + 1, + args.logical_bytes, + f"synthetic-{index:08d}", + ) + for index in range(first, last) + ), + ) + connection.commit() + write_seconds = time.monotonic() - write_start + page_start = time.monotonic() + rows = connection.execute( + "SELECT id FROM backups ORDER BY id DESC LIMIT 100 OFFSET 99_900" + ).fetchall() + page_seconds = time.monotonic() - page_start + backup_count = connection.execute("SELECT COUNT(*) FROM backups").fetchone()[0] + connection.close() + database_bytes = database.stat().st_size + + report = { + "reference_host": { + "platform": platform.platform(), + "python": platform.python_version(), + "cpus": os.cpu_count(), + }, + "method": ( + "synthetic metadata certification; logical bytes are sparse and no physical " + "10 TiB payload is allocated" + ), + "workload": { + "jobs": args.jobs, + "entries_declared": args.entries, + "backups": backup_count, + "logical_bytes_per_backup": args.logical_bytes, + }, + "results": { + "backup_insert_seconds": round(write_seconds, 3), + "pagination_seconds": round(page_seconds, 6), + "pagination_rows": len(rows), + "database_bytes": database_bytes, + "total_seconds": round(time.monotonic() - started, 3), + }, + } + args.report.parent.mkdir(parents=True, exist_ok=True) + args.report.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print(f"scale certification report: {args.report}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())