diff --git a/api/services/forge_repository.py b/api/services/forge_repository.py index 403089c..1565cdb 100644 --- a/api/services/forge_repository.py +++ b/api/services/forge_repository.py @@ -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( diff --git a/api/services/repository_rename.py b/api/services/repository_rename.py index 4b09095..7065353 100644 --- a/api/services/repository_rename.py +++ b/api/services/repository_rename.py @@ -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) diff --git a/tests/test_repository_rename_api.py b/tests/test_repository_rename_api.py index adcdd97..2597961 100644 --- a/tests/test_repository_rename_api.py +++ b/tests/test_repository_rename_api.py @@ -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'] diff --git a/workplans/STATE-WP-0089-rename-redirect-recovery.md b/workplans/STATE-WP-0089-rename-redirect-recovery.md new file mode 100644 index 0000000..33956f2 --- /dev/null +++ b/workplans/STATE-WP-0089-rename-redirect-recovery.md @@ -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.