fix(v2): enforce hardened M0 contracts

This commit is contained in:
2026-07-27 18:59:28 +02:00
parent 92f2aaa27f
commit a0d081a4b4
9 changed files with 205 additions and 146 deletions
+8 -4
View File
@@ -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
-50
View File
@@ -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"]
@@ -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"}
+69 -10
View File
@@ -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}
}
}
}
]
}
}
}
@@ -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$"}
}
}
-60
View File
@@ -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
+75
View File
@@ -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`.
@@ -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
+45 -18
View File
@@ -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