wrolpi/modules/repos/test/test_extras.py
lrnselfreliance 93d878952c
Add a Repos module that mirrors git repositories (#783)
Repos keeps local mirrors of git repositories, cloned over https and kept up to date on a schedule.

- A Repo is a Collection (kind='repo') plus a `repository` row.  The new `git` downloader clones it, then updates it
  (weekly by default, or Never: the clone is kept as it is and only updated on request).
- Repos are saved in `repos/<tag>/<name>` (the Repos Directory, ignored by default) or a directory the user chooses.
  An existing clone can be imported where it is.  repos.yaml is the source of truth.
- A GitHub-like page shows each repo's README, files, and history, with a ZIP download.  READMEs are searchable
  under the global search's Other tab.
- git is hardened against hostile repos and configs: https only, pinned addresses with no private addresses (for the
  repo and its submodules), sanitized configs, no hooks or links, and only clones WROLPi made or imported are
  updated.
- Tagging a Repo moves its clone as one directory.  Repo changes wait for repos.yaml to be imported at startup.
- The file mover groups unindexed files with no primary file as one FileGroup, and never replaces a file at the
  destination.  A failed tag move restores the Collection's tag.  A Collection's directory cannot leave the media
  directory.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:16:58 -06:00

435 lines
20 KiB
Python

import io
import subprocess
import zipfile
from http import HTTPStatus
import pytest
from mock import mock
from modules.repos import lib
from modules.repos.models import Repository
from wrolpi.downloader import Download, DownloadStatus
# The repo the submodules belong to, and the (public) address its host resolved to.
REPO = lib.HostAddresses('git.example.com', 443, ['93.184.215.14'])
def git(directory, *args) -> str:
return subprocess.run(['git', '-c', 'safe.directory=*', *args], cwd=directory, check=True,
capture_output=True).stdout.decode().strip()
async def update(test_session, repos_download_manager):
download = test_session.query(Download).one()
download.renew()
test_session.commit()
await repos_download_manager.wait_for_all_downloads()
test_session.expire_all()
return test_session.query(Download).one()
@pytest.mark.asyncio
async def test_log(test_session, async_client, git_remote_factory, repos_download_manager):
"""The history of the checked out branch, newest first, a page at a time."""
remote = git_remote_factory()
second = remote.commit({'a.txt': 'a'}, 'Second')
third = remote.commit({'b.txt': 'b'}, 'Third: with a | and a\ttab')
repo = lib.create_repository(test_session, remote.url)
await repos_download_manager.wait_for_all_downloads()
request, response = await async_client.get(f'/api/repos/{repo.id}/log?limit=2')
assert response.status_code == HTTPStatus.OK
assert response.json['total'] == 3
assert [(i['sha'], i['message']) for i in response.json['commits']] == \
[(third, 'Third: with a | and a\ttab'), (second, 'Second')]
assert response.json['commits'][0]['author'] == 'WROLPi Test'
assert response.json['commits'][0]['date']
request, response = await async_client.get(f'/api/repos/{repo.id}/log?limit=2&offset=2')
assert [i['message'] for i in response.json['commits']] == ['Initial commit']
request, response = await async_client.get(f'/api/repos/{repo.id}/log?limit=nope')
assert response.status_code == HTTPStatus.BAD_REQUEST
@pytest.mark.asyncio
async def test_log_not_downloaded(test_session, async_client, test_wrolpi_config):
repo = lib.create_repository(test_session, 'https://github.com/a/b')
request, response = await async_client.get(f'/api/repos/{repo.id}/log')
assert response.status_code == HTTPStatus.NOT_FOUND
request, response = await async_client.get(f'/api/repos/{repo.id}/archive.zip')
assert response.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_archive(test_session, async_client, git_remote_factory, repos_download_manager):
"""A ZIP of the checked out commit; local changes and `.git` are not in it."""
remote = git_remote_factory(files={'README.md': '# Example', 'src/main.c': 'int main;'})
repo = lib.create_repository(test_session, remote.url, name='my "repo"\r\nX-Evil: 1')
await repos_download_manager.wait_for_all_downloads()
test_session.expire_all()
(repo.directory / 'untracked.txt').write_text('local')
request, response = await async_client.get(f'/api/repos/{repo.id}/archive.zip')
assert response.status_code == HTTPStatus.OK
assert response.headers['content-type'] == 'application/zip'
# The name is safe in a header.
name = f'my_repo_X-Evil_1-{repo.head_sha[:7]}'
assert response.headers['content-disposition'] == f'attachment; filename="{name}.zip"'
assert 'x-evil' not in response.headers
with zipfile.ZipFile(io.BytesIO(response.content)) as archive:
assert sorted(archive.namelist()) == [f'{name}/', f'{name}/README.md', f'{name}/src/', f'{name}/src/main.c']
assert archive.read(f'{name}/src/main.c') == b'int main;'
@pytest.mark.asyncio
async def test_submodules(test_session, git_remote_factory, repos_download_manager):
"""Submodules are only downloaded when the Repo asks for them, and are removed when it no longer does."""
library = git_remote_factory('library', files={'lib.c': 'v1'})
remote = git_remote_factory()
remote.add_submodule('vendor/library', library)
repo = lib.create_repository(test_session, remote.url)
await repos_download_manager.wait_for_all_downloads()
submodule = repo.directory / 'vendor/library'
assert not (submodule / 'lib.c').exists()
lib.update_repository(test_session, repo.id, submodules=True)
download = await update(test_session, repos_download_manager)
assert download.status == DownloadStatus.complete
assert (submodule / 'lib.c').read_text() == 'v1'
# The submodule has its history, like the Repo.
assert git(submodule, 'rev-list', '--count', 'HEAD') == '1'
# A new submodule commit is checked out when the Repo points at it.
library.commit({'lib.c': 'v2'}, 'v2')
remote.git('update-index', '--cacheinfo', f'160000,{library.head},vendor/library')
remote.git('commit', '--quiet', '-m', 'Bump library')
await update(test_session, repos_download_manager)
assert (submodule / 'lib.c').read_text() == 'v2'
lib.update_repository(test_session, repo.id, submodules=False)
await update(test_session, repos_download_manager)
assert not (submodule / 'lib.c').exists()
@pytest.mark.asyncio
async def test_submodule_failure(test_session, git_remote_factory, repos_download_manager):
"""A submodule which cannot be downloaded (e.g. an ssh URL) does not stop the Repo from updating."""
library = git_remote_factory('library')
remote = git_remote_factory()
remote.add_submodule('vendor/library', library)
# Rewrite the submodule's URL to one git may not use.
(remote.path / '.gitmodules').write_text(
'[submodule "vendor/library"]\n\tpath = vendor/library\n\turl = ssh://git@git.example.com/owner/library\n')
(remote.path / 'new.txt').write_text('new')
# Not `remote.commit`, whose `add --all` would delete the submodule (it is not checked out in the remote).
remote.git('add', '.gitmodules', 'new.txt')
remote.git('commit', '--quiet', '-m', 'ssh submodule')
head = remote.head
repo = lib.create_repository(test_session, remote.url, submodules=True)
await repos_download_manager.wait_for_all_downloads()
test_session.expire_all()
download = test_session.query(Download).one()
assert download.status == DownloadStatus.deferred
assert 'its submodules were not' in download.error
# The Repo itself is current.
assert repo.head_sha == head
assert (repo.directory / 'new.txt').is_file()
@pytest.mark.asyncio
async def test_submodules_api_and_config(test_session, async_client, test_wrolpi_config, await_switches):
body = '{"url": "https://github.com/a/b", "submodules": true}'
request, response = await async_client.post('/api/repos', content=body)
assert response.status_code == HTTPStatus.CREATED
assert response.json['repo']['submodules'] is True
repo_id = response.json['repo']['id']
await await_switches()
assert lib.get_repos_config().repos[0]['submodules'] is True
request, response = await async_client.put(f'/api/repos/{repo_id}', content='{"submodules": false}')
assert response.json['repo']['submodules'] is False
# Restored from the config.
config = lib.get_repos_config()
data = config.read_config_file()
data['repos'][0]['submodules'] = True
data['version'] += 1
config.write_config_data(data, config.get_file())
config.import_config()
test_session.expire_all()
assert test_session.query(Repository).one().submodules is True
@pytest.mark.asyncio
async def test_archive_after_session_closes(test_session, async_client, git_remote_factory, repos_download_manager):
"""In production the request's session is closed once a streamed response starts; the ZIP must not need it."""
from sanic import Request
remote = git_remote_factory()
repo = lib.create_repository(test_session, remote.url)
await repos_download_manager.wait_for_all_downloads()
original_respond = Request.respond
async def respond_and_close_session(self, *args, **kwargs):
response = await original_respond(self, *args, **kwargs)
test_session.expunge_all()
return response
with mock.patch.object(Request, 'respond', respond_and_close_session):
request, response = await async_client.get(f'/api/repos/{repo.id}/archive.zip')
assert response.status_code == HTTPStatus.OK
with zipfile.ZipFile(io.BytesIO(response.content)) as archive:
assert any(i.endswith('/README.md') for i in archive.namelist())
@pytest.mark.parametrize('url,resolved,allowed', [
# Relative URLs are resolved by git (against the repo's remote) before they are checked.
('../library.git', None, False),
('./library', None, False),
('https://git.example.com/owner/library', None, True), # The repo's own host.
('https://codeberg.org/owner/library', ['217.197.91.145'], True),
('https://localhost/library', ['127.0.0.1'], False),
('https://127.0.0.1/library', None, False),
('https://10.0.0.5/library', None, False),
('https://192.168.1.1:8443/library', None, False),
('https://[::1]/library', None, False),
('https://169.254.169.254/latest', None, False),
('https://gitea.lan/library', ['192.168.1.20'], False),
('https://mixed.example/library', ['93.184.215.14', '10.0.0.1'], False),
('https://unresolvable.example/library', OSError('no such host'), False),
('http://codeberg.org/owner/library', None, False),
('ssh://git@codeberg.org/owner/library', None, False),
('file:///etc', None, False),
# URLs which parsers disagree about are refused.
('https://codeberg.org\\@10.0.0.5/library', ['217.197.91.145'], False),
('https://user@codeberg.org/owner/library', ['217.197.91.145'], False),
('https://10.0.0.5#@codeberg.org/library', ['217.197.91.145'], False),
('https://codeberg.org/owner/library?x=@10.0.0.5', ['217.197.91.145'], False),
('https://codeberg.org/owner/lib rary', ['217.197.91.145'], False),
('https://codeberg.org:443/owner/library', ['217.197.91.145'], True),
('../../evil@10.0.0.5/x', None, False),
('..\\evil', None, False),
])
def test_submodule_url_error(url, resolved, allowed):
"""A submodule URL comes from the (untrusted) repo; it may not make WROLPi fetch from its own network."""
side_effect = resolved if isinstance(resolved, Exception) else None
with mock.patch('modules.repos.lib.resolve_host_addresses', side_effect=side_effect,
return_value=resolved or []):
error = lib.submodule_url_error(url, REPO)
assert (error is None) is allowed, error
@pytest.mark.asyncio
async def test_submodule_to_private_address(test_session, git_remote_factory, repos_download_manager):
"""A submodule which points into WROLPi's network is never fetched; the repo itself still updates."""
library = git_remote_factory('library')
remote = git_remote_factory()
remote.add_submodule('vendor/library', library, url='https://10.0.0.5/admin/library')
repo = lib.create_repository(test_session, remote.url, submodules=True)
with mock.patch('modules.repos.lib.resolve_host_addresses', return_value=['93.184.215.14']) as resolve:
await repos_download_manager.wait_for_all_downloads()
test_session.expire_all()
download = test_session.query(Download).one()
assert download.status == DownloadStatus.deferred
assert 'not a public address' in download.error
assert repo.head_sha == remote.head
assert not (repo.directory / 'vendor/library/README.md').exists()
# Only the repo's own host was resolved; an IP address is checked without DNS.
assert [i.args for i in resolve.call_args_list] == [('git.example.com',)]
@pytest.mark.asyncio
async def test_nested_submodule_to_private_address(test_session, git_remote_factory, repos_download_manager):
"""Each level of submodules is checked before it is fetched."""
evil = git_remote_factory('evil')
library = git_remote_factory('library')
library.add_submodule('nested', evil, url='https://127.0.0.1/evil')
remote = git_remote_factory()
remote.add_submodule('vendor/library', library)
repo = lib.create_repository(test_session, remote.url, submodules=True)
await repos_download_manager.wait_for_all_downloads()
test_session.expire_all()
download = test_session.query(Download).one()
assert download.status == DownloadStatus.deferred
assert 'not a public address' in download.error
# The first level (the repo's own host) was fetched; the nested submodule was not.
assert (repo.directory / 'vendor/library/README.md').is_file()
assert not (repo.directory / 'vendor/library/nested/README.md').exists()
@pytest.mark.parametrize('gitmodules', [
# A space in the name must not hide the URL.
'[submodule "a b"]\n\tpath = vendor/library\n\turl = https://10.0.0.5/library\n',
# Nor a newline in the URL.
'[submodule "library"]\n\tpath = vendor/library\n\turl = "https://10.0.0.5/library\\nsubmodule.x.path y"\n',
])
@pytest.mark.asyncio
async def test_submodule_url_cannot_hide(test_session, git_remote_factory, repos_download_manager, gitmodules):
"""Every submodule URL is checked, however the .gitmodules is written."""
library = git_remote_factory('library')
remote = git_remote_factory()
remote.add_submodule('vendor/library', library)
(remote.path / '.gitmodules').write_text(gitmodules)
remote.git('add', '.gitmodules')
remote.git('commit', '--quiet', '-m', 'Tricky .gitmodules')
repo = lib.create_repository(test_session, remote.url, submodules=True)
await repos_download_manager.wait_for_all_downloads()
test_session.expire_all()
download = test_session.query(Download).one()
assert download.status == DownloadStatus.deferred
assert 'Refusing' in download.error
assert not (repo.directory / 'vendor/library/README.md').exists()
def test_submodule_fetch_config():
"""Checked hosts are pinned to the addresses that were checked, so DNS cannot change between the check and git's
fetch."""
with mock.patch('modules.repos.lib.resolve_host_addresses', return_value=['217.197.91.145', '2606:4700::1111']):
config = lib.submodule_fetch_config(['https://codeberg.org/a/b', 'https://codeberg.org:8443/c/d',
'https://git.example.com/own/host'], REPO)
assert config == (
'http.followRedirects=false',
'http.curloptResolve=codeberg.org:443:217.197.91.145,[2606:4700::1111]',
'http.curloptResolve=codeberg.org:8443:217.197.91.145,[2606:4700::1111]',
)
@pytest.mark.parametrize('relative_url,allowed', [
('../library', True), # https://git.example.com/owner/library
('../../../10.0.0.5/library', False), # git resolves this to https://10.0.0.5/library
])
@pytest.mark.asyncio
async def test_relative_submodule_url(test_session, git_remote_factory, repos_download_manager, relative_url,
allowed):
"""A relative submodule URL is checked where git resolves it, which may be another host."""
library = git_remote_factory('library')
remote = git_remote_factory()
remote.add_submodule('vendor/library', library, url=relative_url)
repo = lib.create_repository(test_session, remote.url, submodules=True)
await repos_download_manager.wait_for_all_downloads()
test_session.expire_all()
download = test_session.query(Download).one()
if allowed:
assert download.status == DownloadStatus.complete, download.error
assert (repo.directory / 'vendor/library/README.md').is_file()
else:
assert download.status == DownloadStatus.deferred
assert 'Refusing to download submodule https://10.0.0.5/library' in download.error
assert not (repo.directory / 'vendor/library/README.md').exists()
@pytest.mark.parametrize('address,public', [
('8.8.8.8', True),
('2606:4700::1111', True),
('64:ff9b::808:808', True), # NAT64 of 8.8.8.8
('2002:808:808::1', True), # 6to4 of 8.8.8.8
('10.0.0.5', False),
('100.64.0.1', False), # Carrier-grade NAT
('127.0.0.1', False),
('169.254.169.254', False),
('224.0.0.1', False), # Multicast
('255.255.255.255', False),
('0.0.0.0', False),
('::1', False),
('::', False),
('fe80::1', False),
('fc00::1', False),
('fec0::1', False), # Deprecated site-local
('ff02::1', False), # Multicast
('::ffff:169.254.169.254', False),
('64:ff9b::169.254.169.254', False), # NAT64 of the metadata address
('64:ff9b::a9fe:a9fe', False),
('64:ff9b:1::808:808', False), # Local-use NAT64
('2002:a00:5::1', False), # 6to4 of 10.0.0.5
('2001:0:4136:e378:8000:63bf:3fff:fdd2', False), # Teredo
])
def test_is_public_address(address, public):
import ipaddress
assert lib.is_public_address(ipaddress.ip_address(address)) is public
@pytest.mark.parametrize('url', [
'https://[64:ff9b::169.254.169.254]/latest/meta-data',
'https://[64:ff9b::a9fe:a9fe]/',
'https://[fec0::1]/',
'https://224.0.0.1/',
])
def test_submodule_url_error_rejects_non_public_literals(url):
assert lib.submodule_url_error(url, REPO)
def test_submodule_on_repo_host():
"""The repo's own host is fetched from the addresses the repo was fetched from, never resolved again (so its
DNS cannot change in between). The same name on another port is checked like any other host."""
lan_forge = lib.HostAddresses('git.lan', 443, ['192.168.1.20'])
with mock.patch('modules.repos.lib.resolve_host_addresses', return_value=['127.0.0.1']) as resolve:
# A LAN forge the user chose: its own submodules work.
assert lib.submodule_url_error('https://git.lan/owner/library', lan_forge) is None
assert lib.submodule_url_error('https://git.lan:443/owner/library', lan_forge) is None
resolve.assert_not_called()
# Another port on that name (e.g. a Docker API) is not the repo.
assert 'not a public address' in lib.submodule_url_error('https://git.lan:2375/x', lan_forge)
# A repo host which could not be resolved cannot vouch for anything.
unresolved = lib.HostAddresses('git.lan', 443, None)
assert lib.submodule_url_error('https://git.lan/owner/library', unresolved)
# An address needs no DNS.
literal = lib.HostAddresses('192.168.1.20', 443, None)
assert lib.submodule_url_error('https://192.168.1.20/owner/library', literal) is None
@pytest.mark.parametrize('url,expected', [
('https://git.example.com/owner/repo', lib.HostAddresses('git.example.com', 443, ['93.184.215.14'])),
('https://git.example.com:8443/repo', lib.HostAddresses('git.example.com', 8443, ['93.184.215.14'])),
('https://192.168.1.20/repo', lib.HostAddresses('192.168.1.20', 443, None)),
])
def test_resolve_repo_addresses(url, expected):
with mock.patch('modules.repos.lib.resolve_host_addresses', return_value=['93.184.215.14']):
assert lib.resolve_repo_addresses(url) == expected
assert lib.pin_config(lib.HostAddresses('h', 443, ['1.2.3.4', '2606:4700::1111'])) == \
'http.curloptResolve=h:443:1.2.3.4,[2606:4700::1111]'
assert lib.pin_config(lib.HostAddresses('h', 443, None)) is None
@pytest.mark.asyncio
async def test_repo_fetches_are_pinned(test_session, git_remote_factory, repos_download_manager):
"""Every git command which connects to the repo's host connects to the address resolved at the start."""
from modules.repos.downloader import GitDownloader
remote = git_remote_factory()
remote.commit({'a.txt': 'a'}, 'Second')
lib.create_repository(test_session, remote.url)
commands = []
original = GitDownloader.process_runner
async def record(self, download, cmd, *args, **kwargs):
commands.append([str(i) for i in cmd])
return await original(self, download, cmd, *args, **kwargs)
with mock.patch('modules.repos.lib.resolve_host_addresses', return_value=['93.184.215.14']), \
mock.patch.object(GitDownloader, 'process_runner', record):
await repos_download_manager.wait_for_all_downloads()
await update(test_session, repos_download_manager)
pin = 'http.curloptResolve=git.example.com:443:93.184.215.14'
network = [i for i in commands if {'ls-remote', 'clone', 'fetch'} & set(i)]
assert {'ls-remote', 'clone', 'fetch'} <= {j for i in network for j in i}
assert all(pin in i for i in network), network