Recover external repository renames through verified Forgejo redirects
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 24s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a070b5-4994-7271-bd8b-7c3dbcedec4b
This commit is contained in:
tegwick 2026-09-05 18:57:15 +02:00
parent 022cf4b727
commit fe6b8d96c2
4 changed files with 166 additions and 2 deletions

View file

@ -12,7 +12,7 @@ import os
from dataclasses import dataclass
from pathlib import Path
from typing import Protocol
from urllib.parse import quote
from urllib.parse import quote, urljoin
import httpx
@ -32,6 +32,14 @@ class ForgeRepositoryUnreadable(ForgeRepositoryError):
pass
class ForgeRepositoryRedirected(ForgeRepositoryUnreadable):
"""Requested coordinate redirects; no credentials are forwarded."""
def __init__(self, source_url: str, location: str):
super().__init__("Forge repository coordinate redirects")
self.target_url = urljoin(source_url, location) if location else None
class ForgeRepositoryConflict(ForgeRepositoryError):
pass
@ -106,6 +114,10 @@ class ForgejoRepositoryGateway:
try:
async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.get(url, headers=self._headers(token))
if response.status_code in {301, 302, 303, 307, 308}:
raise ForgeRepositoryRedirected(
url, response.headers.get("location", "")
)
if response.status_code == 404:
if token is None:
raise ForgeRepositoryUnreadable(

View file

@ -11,6 +11,7 @@ from copy import deepcopy
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any
from urllib.parse import quote
from sqlalchemy import or_, select
from sqlalchemy.exc import IntegrityError
@ -44,6 +45,7 @@ from api.schemas.repository_rename import (
from api.services.forge_repository import (
ForgeRepositoryConflict,
ForgeRepositoryGateway,
ForgeRepositoryRedirected,
ForgeRepositorySnapshot,
ForgeRepositoryUnreadable,
)
@ -1101,13 +1103,36 @@ async def _forge_at(
async def _apply_forge_rename(
gateway: ForgeRepositoryGateway, operation: RepositoryRenameOperation
) -> dict[str, Any]:
old = await _forge_at(gateway, operation, operation.old_slug)
new = await _forge_at(gateway, operation, operation.new_slug)
if new is not None:
# A redirect is compatibility evidence only. Establish immutable identity
# at the requested new coordinate before accepting the old-name alias.
_assert_snapshot(operation, new, expected_name=operation.new_slug)
try:
old = await gateway.inspect(
instance=operation.expected_forge_instance,
owner=operation.expected_forge_owner,
name=operation.old_slug,
)
except ForgeRepositoryRedirected as exc:
canonical_api = (
operation.expected_forge_instance.rstrip("/")
+ "/api/v1/repos/"
+ quote(operation.expected_forge_owner, safe="")
+ "/"
+ quote(operation.new_slug, safe="")
)
if exc.target_url != canonical_api:
raise RenamePreconditionFailed(
"Old Forge name redirects outside the verified new coordinate"
) from exc
old = None
except ForgeRepositoryUnreadable as exc:
raise RenamePreconditionFailed(str(exc)) from exc
if old is not None:
raise RenamePreconditionFailed("Both old and new Forge names are claimed")
return {"resumed": True, "forge": new.as_dict()}
old = await _forge_at(gateway, operation, operation.old_slug)
if old is None:
raise RenamePreconditionFailed("Forge repository is absent at both expected names")
_assert_snapshot(operation, old, expected_name=operation.old_slug)

View file

@ -929,3 +929,78 @@ async def test_every_forward_phase_failure_is_retry_safe(client, rename_setup):
"consumers-verified",
"completed",
}
@pytest.mark.asyncio
@pytest.mark.parametrize('location', [
'/api/v1/repos/coulomb/access-engine',
'https://forge.example/api/v1/repos/coulomb/access-engine',
])
async def test_resume_external_rename_with_verified_old_coordinate_redirect(client, rename_setup, location):
from api.services.forge_repository import ForgeRepositoryRedirected
repo, forge = rename_setup
operation, confirmation = await _operation(client, repo['id'], await _preflight(client, repo['id']))
forge.name = 'access-engine' # operator renamed before the journal advanced
inspect = forge.inspect
async def redirected(**kwargs):
if kwargs['name'] == 'flex-auth':
raise ForgeRepositoryRedirected('https://forge.example/api/v1/repos/coulomb/flex-auth', location)
return await inspect(**kwargs)
forge.inspect = redirected
result = await _phase(client, repo['id'], operation['id'], 'forge-renamed', 'preflighted', confirmation)
assert result['phase'] == 'forge-renamed'
assert forge.rename_calls == 0
rebound = await _phase(client, repo['id'], operation['id'], 'statehub-rebound', 'forge-renamed', confirmation)
assert rebound['phase'] == 'statehub-rebound'
@pytest.mark.asyncio
@pytest.mark.parametrize('location,wrong_identity', [
('https://evil.example/api/v1/repos/coulomb/access-engine', False),
('/api/v1/repos/other/access-engine', False),
('', False),
('/api/v1/repos/coulomb/access-engine', True),
])
async def test_redirect_resume_rejects_wrong_target_or_identity(client, rename_setup, location, wrong_identity):
from api.services.forge_repository import ForgeRepositoryRedirected
repo, forge = rename_setup
operation, confirmation = await _operation(client, repo['id'], await _preflight(client, repo['id']))
forge.name = 'access-engine'
if wrong_identity:
forge.repository_id += 1
inspect = forge.inspect
async def redirected(**kwargs):
if kwargs['name'] == 'flex-auth':
raise ForgeRepositoryRedirected('https://forge.example/api/v1/repos/coulomb/flex-auth', location)
return await inspect(**kwargs)
forge.inspect = redirected
response = await client.post(
f"/repos/{repo['id']}/rename/operations/{operation['id']}/phases/forge-renamed",
json={'expected_phase': 'preflighted', 'confirmation': confirmation},
)
assert response.status_code == 412
assert forge.rename_calls == 0
status = (await client.get(f"/repository-renames/operations/{operation['id']}")).json()
assert status['phase'] == 'preflighted'
@pytest.mark.asyncio
async def test_forge_http_redirect_is_reported_without_following(monkeypatch):
import httpx
from api.services.forge_repository import ForgejoRepositoryGateway, ForgeRepositoryRedirected
seen = []
def handler(request):
seen.append(str(request.url))
return httpx.Response(307, headers={'location': '/api/v1/repos/coulomb/access-engine'})
original = httpx.AsyncClient
monkeypatch.setattr(httpx, 'AsyncClient', lambda **kwargs: original(transport=httpx.MockTransport(handler), **kwargs))
with pytest.raises(ForgeRepositoryRedirected) as error:
await ForgejoRepositoryGateway()._inspect_with_token(instance='https://forge.example', owner='coulomb', name='flex-auth', token='test-only')
assert error.value.target_url == 'https://forge.example/api/v1/repos/coulomb/access-engine'
assert seen == ['https://forge.example/api/v1/repos/coulomb/flex-auth']

View file

@ -0,0 +1,52 @@
---
id: STATE-WP-0089
type: workplan
title: "Recover an externally completed Forgejo rename through its old-name redirect"
domain: infotech
repo: state-hub
status: active
owner: codex
created: "2026-09-05"
updated: "2026-09-05"
---
# Recover the canon federation rename journal
CFED-WP-0001-T03 renamed Forgejo repository 46 from identity-canon to
commerce-canon through the approved operator credential lane. Journal
615e7b44-d84e-4feb-92c5-1708feaf1e65 remains preflighted because the Forge adapter
rejects the old coordinate's 307 redirect. New coordinate and exact source head
40d5792fafbb2de778eabb56cfeaf00aecd058e1 are independently verified.
## Handle the redirect without weakening identity checks
```task
id: STATE-WP-0089-T01
status: done
priority: high
```
Report redirects without following them or forwarding credentials. For forward
recovery, inspect the new coordinate directly and verify immutable ID, branch,
head and projection first. Accept the old redirect only if it names that exact
same-instance/owner/new-coordinate API URL. Wrong identity, external redirects,
wrong owner, missing Location and both names occupied remain failures.
Add transport and API regression tests, and run the rename suite.
## Promote the tested fix and resume the existing journal
```task
id: STATE-WP-0089-T02
status: progress
priority: high
```
Commit/push; promote the immutable CI image through the existing Helm release
with reused values and atomic rollback. Preserve API-only signing configuration.
Verify healthy primary/railiance01, then retry forge-renamed and statehub-rebound
on the same journal. Record image/revision and live recovery evidence. The donor
rename, checkout moves and consumer acceptance remain CFED-WP-0001-T03 and
IDENTITY-WP-0004 responsibilities. No direct DB edits or journal replacement.
Validation: rename API, CLI and workplan tests pass (46 tests), including seven
new transport/forward-recovery cases. `git diff --check` passes.