diff --git a/.github/workflows/git-transport-boundaries.yml b/.github/workflows/git-transport-boundaries.yml new file mode 100644 index 00000000..2f66e02b --- /dev/null +++ b/.github/workflows/git-transport-boundaries.yml @@ -0,0 +1,75 @@ +name: Git transport boundary contracts +on: + push: + branches: [fix/git-transport-boundaries-20260929] + workflow_dispatch: +permissions: + contents: read +concurrency: + group: git-transport-boundaries-${{ github.ref }} + cancel-in-progress: false +jobs: + contracts: + runs-on: ubuntu-latest + timeout-minutes: 20 + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: msgtest + POSTGRES_PASSWORD: msgtest + POSTGRES_DB: postgres + ports: ['5432:5432'] + options: >- + --health-cmd "pg_isready -U msgtest" + --health-interval 5s --health-timeout 5s --health-retries 10 + valkey: + image: valkey/valkey:9.0.6 + ports: ['6379:6379'] + options: >- + --health-cmd "valkey-cli ping" + --health-interval 5s --health-timeout 5s --health-retries 10 + env: + MSG_TEST_POSTGRES_URL_TEMPLATE: postgresql://msgtest:msgtest@127.0.0.1:5432/{database} + MSG_TEST_VALKEY_URL: redis://127.0.0.1:6379/0 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-python@v7 + with: + python-version: '3.15' + allow-prereleases: true + cache: pip + - name: Install actual Git, LFS and cryptographic test tools + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq age git-lfs + git --version + git lfs version + python -m pip install -e '.[dev]' + - name: Record exact source + env: + SOURCE_SHA: ${{ github.sha }} + run: | + mkdir -p artifacts + test "$(git rev-parse HEAD)" = "$SOURCE_SHA" + python - <<'PY' + import json,os,subprocess + from pathlib import Path + git=lambda *args:subprocess.check_output(['git',*args],text=True).strip() + Path('artifacts/source.json').write_text(json.dumps({'commit':git('rev-parse','HEAD'), + 'tree':git('rev-parse','HEAD^{tree}'),'event_sha':os.environ['SOURCE_SHA'], + 'dirty':bool(git('status','--porcelain'))},indent=2)+'\n') + PY + - name: Verify ownership and unchanged real Git reads + run: python -m pytest tests/test_git_transport_boundaries.py --junitxml=artifacts/git-boundaries.xml + - name: Preserve real HTTP, SSH, bundle, LFS and publication fences + run: | + python -m pytest tests/test_git_http_push.py tests/test_native_git_read_only.py tests/test_git_lfs.py tests/test_lfs_shared_gc_quota.py tests/test_ssh_rss.py tests/test_extensions.py tests/test_storage_layout.py tests/test_effect_completion_fencing.py tests/issue_78_80/test_lfs_integrity.py tests/issue_78_80/test_lfs_migration_contract.py --junitxml=artifacts/git-regressions.xml + - uses: actions/upload-artifact@v4 + if: always() + with: + name: git-transport-boundaries-${{ github.sha }} + path: artifacts/ + if-no-files-found: error diff --git a/docs/GIT_BOUNDARY_PROGRESS.md b/docs/GIT_BOUNDARY_PROGRESS.md new file mode 100644 index 00000000..21aa088f --- /dev/null +++ b/docs/GIT_BOUNDARY_PROGRESS.md @@ -0,0 +1,56 @@ +# Git transport boundary progress + +## Scope and coordination + +Issue #163. Branch `fix/git-transport-boundaries-20260929`. Base main +`08c58928ee3f6c6cb350d9e9fba75d072bd78f08`, tree +`8a62150d8273a2c75775872694749cb529835966` includes merged #165. +Coordination: #163 comment5878830664 and #83 comment5878871933. +No changes to parallel #166/#167/#169 executor/storage or #155 recovery branches. + +Main was compared with the inspected source baseline476e64f: only #165's eight +files changed, none of the repository/SSH/HTTP modules under review. The source +blobs remain repositories940a1a1bd987a2d97273734a8c0646097abf3c83, +ssh_gitd6465ce37fd7e4743773942144e91e10a7c847af, and +http_routes04a7de0431f1211893111e887ef87661ef877c0f. + +## Regression-first stage + +Nine new tests cover shared import isolation, actual store/adapter ownership, +one shared publication implementation, shared HTTP-helper ownership, explicit +composition and two existing-behavior characterizations (real Git public reads +with unchanged database/refs, and bounded reference batch parsing). +Production source is unchanged in the first commit. Obtain actual red cloud +JUnit/source evidence before implementation; do not claim a result from intent. + +## Observed and intended boundaries + +NativeGitStore currently owns physical repositories and HTTP/LFS Request/Response +handling; HTTP receive imports guarded_command from ssh_git, which imports the +store back. The planned separation is: + +- NativeGitStore keeps repository/LFS paths, bounded store operations and physical + publication primitives, taking a snapshot of required settings rather than + retaining an Application service locator. Existing operation registration and + bundle worker behavior remain in their current module. +- transports.git_http.GitHTTPAdapter composes a NativeGitStore and owns all HTTP + envelopes, streaming/spooling and signed/Basic protocol adaptation. +- extensions.git_publication owns the existing ReferenceGuard and guarded Git + subprocess lifecycle. SSH and HTTP call this same implementation. Historical + ssh_git imports remain identical compatibility aliases, not duplicate wrappers. +- transports.http_common owns the three existing body/JSON/error helpers and base + headers; HTTP routes and Git HTTP import it, avoiding a new router/adapter cycle. + +These are source ownership changes, not a new Git workflow/authorization engine. +Original capacity limits, request IDs, signed envelopes, current-authority checks, +reference-transaction phases, replay cache, uncertainty reporting, LFS digest and +shared-volume constraints, and bundle job fences remain. No protocol version, +database schema, receipt, real repository migration or deployment change. + +## Cloud acceptance + +The dedicated workflow runs real PostgreSQL/Valkey/Git/git-lfs and the existing +HTTP/SSH/bundle/LFS/read-only and completion-fence tests. The full four-shard exact +node-ID/conformance/build and applicable Recovery safety gates remain required on +the final combined tree. No local project execution, skipped test, weakened +assertion, production credentials, real funds/outbound delivery or recovery promotion. diff --git a/tests/test_git_transport_boundaries.py b/tests/test_git_transport_boundaries.py new file mode 100644 index 00000000..d5a9624d --- /dev/null +++ b/tests/test_git_transport_boundaries.py @@ -0,0 +1,140 @@ +"""Git owns files/publication; protocol adapters own requests and responses.""" +from __future__ import annotations + +import ast +import importlib +from pathlib import Path +import subprocess +import sys + +import httpx +import pytest + +from msg.core.errors import Failure +from msg.core.codec import wire +from msg.extensions.repositories import NativeGitStore +from msg.extensions.ssh_git import ReferenceGuard +from msg.transports.http import create_app +from test_service import call, register +from test_native_git_read_only import business_snapshot, refs_snapshot, seed_ref + + +def dependencies(module): + tree=ast.parse(Path(importlib.import_module(module).__file__).read_text()) + return [node.module or '' for node in ast.walk(tree) if isinstance(node,ast.ImportFrom)] + [ + alias.name for node in ast.walk(tree) if isinstance(node,ast.Import) for alias in node.names] + + +def test_shared_repository_module_does_not_import_http_or_ssh_adapters(): + forbidden=('starlette','msg.transports.http','msg.transports.git_http','msg.extensions.ssh_git') + assert not [name for name in dependencies('msg.extensions.repositories') + if any(name==prefix or name.startswith(prefix+'.') for prefix in forbidden)] + + +def test_repository_store_has_no_http_handlers_or_request_conversion_methods(): + assert not {'http','http_push','http_lfs','_http_push_receive','_lfs_packet', + '_lfs_authorize','_lfs_preflight_publish','_repo_read_path','_job'}.intersection(vars(NativeGitStore)) + assert {'path','lfs','lfs_capacity','create','refs','import_bundle','update_refs'}<=set(vars(NativeGitStore)) + + +def test_shared_publication_has_no_protocol_adapter_dependency(): + module=importlib.import_module('msg.extensions.git_publication') + assert not hasattr(module,'receive_pack') + forbidden=('starlette','msg.transports.http','msg.transports.git_http','msg.extensions.ssh_git') + assert not [name for name in dependencies(module.__name__) + if any(name==prefix or name.startswith(prefix+'.') for prefix in forbidden)] + + +def test_both_adapters_use_the_same_publication_implementation(): + owner=importlib.import_module('msg.extensions.git_publication') + ssh=importlib.import_module('msg.extensions.ssh_git') + http=importlib.import_module('msg.transports.git_http') + for name in ('ReferenceGuard','hook_program','relay_bounded_stdin','guarded_command'): + assert getattr(ssh,name) is getattr(owner,name) + assert getattr(owner,name).__module__==owner.__name__ + assert http.guarded_command is owner.guarded_command + assert 'msg.extensions.ssh_git' not in dependencies(http.__name__) + + +def test_http_helpers_have_one_owner_without_a_router_import_cycle(): + common=importlib.import_module('msg.transports.http_common') + routes=importlib.import_module('msg.transports.http_routes') + public=importlib.import_module('msg.transports.http') + git=importlib.import_module('msg.transports.git_http') + for name in ('body_bytes','json_response','error_status'): + assert getattr(common,name) is getattr(routes,name) is getattr(public,name) is getattr(git,name) + assert getattr(common,name).__module__==common.__name__ + assert routes.BASE_HEADERS is common.BASE_HEADERS + assert not {'msg.transports.http','msg.transports.http_routes'}.intersection(dependencies(git.__name__)) + assert not {'msg.transports.http','msg.transports.http_routes','msg.transports.git_http'}.intersection(dependencies(common.__name__)) + + +def test_git_storage_and_publication_import_without_loading_protocol_adapters(): + code=''' +import importlib.abc,sys +class NoAdapters(importlib.abc.MetaPathFinder): + def find_spec(self,fullname,path=None,target=None): + if any(fullname==name or fullname.startswith(name+'.') for name in + ('starlette','msg.transports.http','msg.transports.http_routes', + 'msg.transports.git_http','msg.extensions.ssh_git')): + raise AssertionError('shared Git imported adapter '+fullname) +sys.meta_path.insert(0,NoAdapters()) +from msg.extensions.repositories import NativeGitStore +from msg.extensions.git_publication import ReferenceGuard,guarded_command +''' + result=subprocess.run([sys.executable,'-c',code],capture_output=True,text=True) + assert result.returncode==0,result.stderr + + +async def test_http_uses_explicit_store_composition_not_storage_inheritance(installed): + app,_=installed + adapter=importlib.import_module('msg.transports.git_http').GitHTTPAdapter + store=NativeGitStore(app) + assert not hasattr(store,'app') + assert store.root==app.settings.server.repositories_dir + assert store.blob_root==app.settings.server.blob_dir + http=adapter(app,store=store) + assert http.store is store and http.app is app + assert not isinstance(http,NativeGitStore) + assert '__getattr__' not in vars(adapter) + + +async def test_public_git_read_and_rejected_push_preserve_all_business_state(installed): + app,_=installed + key,uid,_=await register(app,'git-boundary-read') + created=await call(app,'git.create',{'parent':'/@git-boundary-read','name':'code.git'}, + key=key,subject=uid) + assert created.status=='ok',wire(created) + rid=created.resources[0].id + repo=NativeGitStore(app).path(rid) + commit=seed_ref(repo) + before=await business_snapshot(app) + refs=refs_snapshot(repo) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app(app)), + base_url=app.settings.service_url) as client: + path='/@git-boundary-read/code.git' + # Dumb HTTP HEAD is intentionally disabled by http.getanyfile=false. + # Exercise the supported smart fetch, not a new endpoint contract. + want=f'want {commit}\n'.encode() + packet=f'{len(want)+4:04x}'.encode()+want+b'00000009done\n' + fetched=await client.post(path+'/git-upload-pack',content=packet, + headers={'Content-Type':'application/x-git-upload-pack-request'}) + assert fetched.status_code==200 and b'PACK' in fetched.content, fetched.text + advertised=await client.get(path+'/info/refs?service=git-upload-pack') + assert advertised.status_code==200 and b'git-upload-pack' in advertised.content + rejected=await client.post(path+'/git-receive-pack',content=b'0000') + assert rejected.status_code in {401,403,405} + unauthenticated=await client.get('/-/git/'+rid+'/info/refs?service=git-receive-pack') + assert unauthenticated.status_code==401 + assert refs_snapshot(repo)==refs + assert await business_snapshot(app)==before + + +def test_reference_batch_validation_preserves_exact_git_change_contract(): + old,new='0'*40,'1'*40 + rows=ReferenceGuard.validate_changes(f'{old} {new} refs/heads/main\n{old} {new} refs/tags/v1\n') + assert rows==[{'ref':'refs/heads/main','old':old,'new':new}, + {'ref':'refs/tags/v1','old':old,'new':new}] + for value in ('',f'{old} {new} refs/heads/main\n{old} {new} refs/heads/main', + f'bad {new} refs/heads/main',f'{old} {new} refs/remotes/private'): + with pytest.raises(Failure):ReferenceGuard.validate_changes(value)