fix(v2): enforce hardened M0 contracts
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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$"}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user