diff --git a/Makefile b/Makefile index dea06c4..d748c40 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,17 @@ PYTHON ?= .venv/bin/python -PIP ?= .venv/bin/pip +BOOTSTRAP_PYTHON ?= python3 NPM ?= npm -.PHONY: install test-fast lint typecheck frontend-build check-v1-absent check +.PHONY: setup install test-fast lint typecheck frontend-build check-v1-absent check -install: - $(PIP) install -e 'backend[dev]' +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 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/contracts/repository/v1/fixtures/valid-manifest.json b/contracts/repository/v1/fixtures/valid-manifest.json index f9b7da6..a4e1e6d 100644 --- a/contracts/repository/v1/fixtures/valid-manifest.json +++ b/contracts/repository/v1/fixtures/valid-manifest.json @@ -1 +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","repository_id":"0198c57f-0000-7000-8000-000000000001","requested_mode":"full","source_consistency":{"adapter":"local","captured_at":"2026-07-27T00:00:00Z"},"source_id":"0198c57f-0000-7000-8000-000000000003"} +{"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/manifest.schema.json b/contracts/repository/v1/manifest.schema.json index 79ede3a..b89ab09 100644 --- a/contracts/repository/v1/manifest.schema.json +++ b/contracts/repository/v1/manifest.schema.json @@ -7,19 +7,28 @@ "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" + "entries", "aggregates", "encryption_key_id", "manifest_digest", "manifest_signature" ], "properties": { "format_version": {"const": 1}, - "backup_id": {"type": "string", "format": "uuid"}, - "repository_id": {"type": "string", "format": "uuid"}, - "source_id": {"type": "string", "format": "uuid"}, - "job_id": {"type": "string", "format": "uuid"}, - "execution_id": {"type": "string", "format": "uuid"}, + "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"}, - "source_consistency": {"type": "object"}, + "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", "ssh", "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, @@ -45,9 +54,24 @@ } }, "encryption_key_id": {"type": ["string", "null"]}, - "manifest_digest": {"type": "string", "pattern": "^[0-9a-f]{64}$"} + "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, @@ -61,7 +85,42 @@ "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/repository.schema.json b/contracts/repository/v1/repository.schema.json index 8093ea7..29af0e7 100644 --- a/contracts/repository/v1/repository.schema.json +++ b/contracts/repository/v1/repository.schema.json @@ -6,7 +6,11 @@ "additionalProperties": false, "required": ["repository_id", "format_version", "digest_algorithm", "compression", "encryption", "created_at"], "properties": { - "repository_id": {"type": "string", "format": "uuid"}, + "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"]}, @@ -19,6 +23,6 @@ "key_id": {"type": ["string", "null"], "minLength": 1} } }, - "created_at": {"type": "string", "format": "date-time"} + "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 deleted file mode 100644 index 9cad1df..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,60 +0,0 @@ -version: "3.8" - -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 - - frontend: - build: - context: ./frontend - dockerfile: Dockerfile - container_name: backup-tool-frontend - 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" - volumes: - - ./frontend:/app - - /app/node_modules - command: sh -c "npm install && npm run dev" - environment: - - VITE_API_URL=http://localhost:8000 - depends_on: - - backend - profiles: - - dev - -volumes: - backup-data: - driver: local - backup-storage: - driver: local 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/superpowers/plans/2026-07-27-backup-tool-v2-implementation.md b/docs/superpowers/plans/2026-07-27-backup-tool-v2-implementation.md index 6321930..19d8e52 100644 --- a/docs/superpowers/plans/2026-07-27-backup-tool-v2-implementation.md +++ b/docs/superpowers/plans/2026-07-27-backup-tool-v2-implementation.md @@ -78,7 +78,7 @@ M7-M11 MAY be developed on separate branches after M6, but merge in dependency o **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:** `python tools/forbidden_v1_scan.py . && python -m pytest tests/contract/test_repository_format.py tests/contract/test_no_v1.py -q` +**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 diff --git a/tools/forbidden_v1_scan.py b/tools/forbidden_v1_scan.py index 66cd07d..5247f27 100644 --- a/tools/forbidden_v1_scan.py +++ b/tools/forbidden_v1_scan.py @@ -14,12 +14,37 @@ LEGACY_PATHS = ( Path("backend/tests"), Path("frontend/src/api/client.ts"), ) -SCAN_ROOTS = (Path("backend/src"), Path("frontend/src"), Path("openapi")) -TEXT_SUFFIXES = {".json", ".py", ".ts", ".tsx", ".yaml", ".yml"} +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"\blegacy_(?:reader|importer?|converter?)\b", re.IGNORECASE), - re.compile(r"\btimestamp_directory\b", re.IGNORECASE), + 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"\bbackup_tool\.(?:db|sqlite3?)\b", 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"), ) @@ -30,23 +55,25 @@ def scan(root: Path) -> list[str]: 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 not scan_root.exists(): + 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 path in sorted(scan_root.rglob("*")): - if not path.is_file() or path.suffix not in TEXT_SUFFIXES: - continue - 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)}" - ) + for pattern in FORBIDDEN: + if pattern.search(text): + findings.append(f"forbidden symbol {pattern.pattern!r}: {path.relative_to(root)}") return findings