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>
This commit is contained in:
lrnselfreliance 2026-10-07 15:16:40 -06:00 • committed by Roland
parent 1ded249fc7
commit 93d878952c
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
60 changed files with 7405 additions and 92 deletions

View file

@ -24,6 +24,7 @@ from wrolpi.collections.models import Collection, CollectionItem # noqa: F401
from modules.videos.models import Video, Channel # noqa: F401
from modules.archive.models import Archive # noqa: F401
from modules.zim.models import Zim, TagZimEntry, ZimSubscription # noqa: F401
from modules.repos.models import Repository # noqa: F401
target_metadata = Base.metadata

View file

@ -0,0 +1,43 @@
"""Add the repository table for git Repos (Collections with kind='repo').
Revision ID: 2026_10_06_1200
Revises: 2026_10_05_1200
"""
import sqlalchemy as sa
from alembic import op
import wrolpi.dates
# revision identifiers, used by Alembic.
revision = '2026_10_06_1200'
down_revision = '2026_10_05_1200'
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
'repository',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('url', sa.String(), nullable=False),
sa.Column('host', sa.String(), nullable=True),
sa.Column('owner', sa.String(), nullable=True),
sa.Column('mode', sa.String(), nullable=False),
sa.Column('branch', sa.String(), nullable=True),
sa.Column('default_branch', sa.String(), nullable=True),
sa.Column('head_sha', sa.String(), nullable=True),
sa.Column('head_date', wrolpi.dates.TZDateTime(), nullable=True),
sa.Column('head_message', sa.Text(), nullable=True),
sa.Column('last_fetch', wrolpi.dates.TZDateTime(), nullable=True),
sa.Column('size', sa.BigInteger(), nullable=True),
sa.Column('readme_path', sa.String(), nullable=True),
sa.Column('collection_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['collection_id'], ['collection.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('collection_id'),
sa.UniqueConstraint('url'),
)
def downgrade():
op.drop_table('repository')

View file

@ -0,0 +1,39 @@
"""Search git Repos by name and README: repository.search_name/readme_text and repository_fts.
Revision ID: 2026_10_06_1300
Revises: 2026_10_06_1200
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = '2026_10_06_1300'
down_revision = '2026_10_06_1200'
branch_labels = None
depends_on = None
def upgrade():
from wrolpi.schema_ddl import install_raw_ddl
op.add_column('repository', sa.Column('search_name', sa.String(), nullable=True))
op.add_column('repository', sa.Column('readme_text', sa.Text(), nullable=True))
op.execute('''
UPDATE repository SET search_name = TRIM(
COALESCE((SELECT name FROM collection WHERE collection.id = repository.collection_id), '')
|| ' ' || COALESCE(owner, ''))
''')
# Creates repository_fts and its triggers now that the columns exist. The README text is filled by the next
# update of each Repo.
install_raw_ddl(op.get_bind())
op.execute("INSERT INTO repository_fts(repository_fts) VALUES('rebuild')")
def downgrade():
for name in ('repository_fts_ai', 'repository_fts_ad', 'repository_fts_au'):
op.execute(f'DROP TRIGGER IF EXISTS {name}')
op.execute('DROP TABLE IF EXISTS repository_fts')
with op.batch_alter_table('repository') as batch_op:
batch_op.drop_column('readme_text')
batch_op.drop_column('search_name')

View file

@ -0,0 +1,22 @@
"""A git Repo can also download its submodules: repository.submodules.
Revision ID: 2026_10_06_1400
Revises: 2026_10_06_1300
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = '2026_10_06_1400'
down_revision = '2026_10_06_1300'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('repository', sa.Column('submodules', sa.Boolean(), server_default=sa.false(), nullable=False))
def downgrade():
with op.batch_alter_table('repository') as batch_op:
batch_op.drop_column('submodules')

View file

@ -0,0 +1,22 @@
"""A git Repo's clone holds a random token: repository.clone_token.
Revision ID: 2026_10_06_1500
Revises: 2026_10_06_1400
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = '2026_10_06_1500'
down_revision = '2026_10_06_1400'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('repository', sa.Column('clone_token', sa.String(), nullable=True))
def downgrade():
with op.batch_alter_table('repository') as batch_op:
batch_op.drop_column('clone_token')

View file

@ -1,5 +1,5 @@
/*
* The five Special Directories fields on /admin/settings.
* The six Special Directories fields on /admin/settings.
*
* These are the fields that shipped printing their own prefix over their own value, and the
* ones whose visible labels were never associated with their inputs. Both faults were
@ -19,6 +19,7 @@ describe('Special Directories settings', () => {
['Map Directory', 'map'],
['Zims Directory', 'zims'],
['Playlists Directory', 'playlists'],
['Repos Directory', 'repos/%(repo_tag)s/%(repo_name)s'],
];
beforeEach(() => {
@ -38,8 +39,8 @@ describe('Special Directories settings', () => {
it('names every directory field, so they can be told apart', () => {
/*
* All five fields sit in one grid, hold similar-looking relative paths, and share the
* same prefix. Without a name attached to the input, a screen reader user hears five
* All six fields sit in one grid, hold similar-looking relative paths, and share the
* same prefix. Without a name attached to the input, a screen reader user hears six
* fields described only as "/media/wrolpi/" and cannot tell which one saves videos.
*/
directories.forEach(([label, value]) => {

View file

@ -326,6 +326,13 @@ pre.wrap-text {
padding: 0 0.25em;
}
/* A repo's README is part of its page, not a modal; it grows with its content. */
.repo-readme .markdown-preview {
height: auto;
min-height: 0;
overflow: visible;
}
.markdown-raw {
margin: 0;
white-space: pre-wrap;

View file

@ -30,6 +30,7 @@ import {FilePreviewProvider} from "./components/FilePreview";
import {TagsProvider} from "./Tags";
import {ZimRoute} from "./components/Zim";
import {PlaylistsRoute} from "./components/Playlists";
import {ReposRoute} from "./components/Repos";
import {DocsRoute} from "./components/Docs";
import {FlasherRoute} from "./components/Flasher";
import ErrorBoundary from "./components/ErrorBoundary";
@ -117,6 +118,7 @@ const router = createBrowserRouter(createRoutesFromElements(<Route
<Route path='map/*' element={<ErrorBoundary><MapRoute/></ErrorBoundary>}/>
<Route path='zim/*' element={<ErrorBoundary><ZimRoute/></ErrorBoundary>}/>
<Route path='playlists/*' element={<ErrorBoundary><PlaylistsRoute/></ErrorBoundary>}/>
<Route path='repos/*' element={<ErrorBoundary><ReposRoute/></ErrorBoundary>}/>
<Route path='files/*' element={<ErrorBoundary><FilesRoute/></ErrorBoundary>}/>
<Route path='flasher/*' element={<ErrorBoundary><FlasherRoute/></ErrorBoundary>}/>
</Route>));

View file

@ -7,6 +7,7 @@ import {
COLLECTIONS_API,
DEFAULT_LIMIT,
Downloaders,
REPOS_API,
VIDEOS_API,
ZIM_API
} from "./components/Vars";
@ -406,6 +407,7 @@ export async function getSettings() {
nav_color: content.nav_color,
media_directory: content.media_directory,
playlists_destination: content.playlists_destination,
repos_destination: content.repos_destination,
require_cookies_unlocked: content.require_cookies_unlocked,
require_media_mounted: content.require_media_mounted,
save_ffprobe_json: content.save_ffprobe_json,
@ -927,6 +929,21 @@ export async function getCollectionTagInfo(collectionId, tagName) {
}
}
// Tag any Collection (and optionally move its directory).
export async function tagCollection(collectionId, tagName, directory) {
const body = {tag_name: tagName};
if (directory) {
body['directory'] = directory;
}
const response = await apiPost(`${COLLECTIONS_API}/${collectionId}/tag`, body);
if (response.ok) {
return await response.json();
}
const message = await getErrorMessage(response, 'Unable to tag. See server logs.');
toast({type: 'error', title: 'Tag Error', description: message, time: 5000});
throw new Error(message);
}
export async function tagDomain(domainId, tagName, directory) {
const body = {
tag_name: tagName,
@ -2051,6 +2068,7 @@ export async function searchSuggestions(search_str) {
domains: content.domains,
authors: content.authors || [],
subjects: content.subjects || [],
repos: content.repos || [],
}
} else {
console.error('Failed to get file search suggestions!');
@ -2089,8 +2107,8 @@ export async function searchEstimateZims(search_str, tagNames) {
}
}
export async function searchEstimateOthers(tagNames) {
const body = {tag_names: tagNames};
export async function searchEstimateOthers(tagNames, searchStr) {
const body = {tag_names: tagNames, search_str: searchStr || null};
const response = await apiPost(`${API_URI}/search_other_estimates`, body);
if (response.ok) {
const content = await response.json();
@ -2488,3 +2506,112 @@ export async function moveBookmark(nodeId, parent_id, position = null) {
export async function deleteBookmark(nodeId) {
return await bookmarksModify(apiDelete(`${API_URI}/bookmarks/${nodeId}`), 'Could not delete bookmark');
}
async function reposError(response, fallbackMessage) {
const message = await getErrorMessage(response, fallbackMessage);
toast({type: 'error', title: 'Repos Error', description: message, time: 5000});
throw Error(message);
}
export async function fetchRepos() {
const response = await apiGet(REPOS_API);
if (response.ok) {
return (await response.json())['repos'];
}
await reposError(response, 'Failed to fetch repos.');
}
export async function getRepo(repoId) {
const response = await apiGet(`${REPOS_API}/${repoId}`);
if (response.ok) {
return (await response.json())['repo'];
}
await reposError(response, 'Failed to fetch repo.');
}
// `repo`: {url, directory?, name?, tag_name?, frequency?, mode?, branch?, description?}
export async function createRepo(repo) {
const response = await apiPost(REPOS_API, repo);
if (response.ok) {
return (await response.json())['repo'];
}
await reposError(response, 'Failed to add repo.');
}
// `changes`: {description?, frequency?, mode?, branch?}. An empty branch follows the remote's default branch.
export async function updateRepo(repoId, changes) {
const response = await apiPut(`${REPOS_API}/${repoId}`, changes);
if (response.ok) {
return (await response.json())['repo'];
}
await reposError(response, 'Failed to update repo.');
}
export async function deleteRepo(repoId, deleteFiles = false) {
const response = await apiDelete(`${REPOS_API}/${repoId}?delete_files=${deleteFiles ? 'true' : 'false'}`);
if (!response.ok) {
await reposError(response, 'Failed to delete repo.');
}
}
// Download the repo's updates now.
export async function updateRepoNow(repoId) {
const response = await apiPost(`${REPOS_API}/${repoId}/update`);
if (!response.ok) {
await reposError(response, 'Failed to start the repo update.');
}
}
// One directory of a repo's files: {path, entries: [{name, path, is_dir, size}], readme_path}.
export async function getRepoTree(repoId, path) {
const query = path ? `?path=${encodeURIComponent(path)}` : '';
const response = await apiGet(`${REPOS_API}/${repoId}/tree${query}`);
if (response.ok) {
return await response.json();
}
await reposError(response, 'Failed to list the repo files.');
}
// Repos matching the search (name and README) and/or the tags. Each has an escaped `readme_headline`.
export async function searchRepos(searchStr, tagNames) {
const body = {search_str: searchStr || null, tag_names: tagNames || []};
const response = await apiPost(`${REPOS_API}/search`, body);
if (response.ok) {
const content = await response.json();
return {repos: content.repos, total: content.totals.repos};
}
await reposError(response, 'Failed to search repos.');
}
// The commits of a repo's checked out branch, newest first: {commits: [{sha, author, date, message}], total}.
export async function getRepoLog(repoId, offset = 0, limit = 50) {
const response = await apiGet(`${REPOS_API}/${repoId}/log?limit=${limit}&offset=${offset}`);
if (response.ok) {
return await response.json();
}
await reposError(response, 'Failed to fetch the repo history.');
}
// A ZIP of a repo's checked out files; the browser downloads it.
export function repoArchiveURL(repoId) {
return `${REPOS_API}/${repoId}/archive.zip`;
}
// What importing the git clone in `directory` (relative to the media directory) would do. Changes nothing.
export async function inspectRepoImport(directory, name, tagName, url) {
const body = {directory, name: name || null, tag_name: tagName || null, url: url || null};
const response = await apiPost(`${REPOS_API}/import/inspect`, body);
if (response.ok) {
return {inspection: (await response.json())['inspection']};
}
return {error: await getErrorMessage(response, 'Cannot import this directory.')};
}
// Import an existing git clone as a Repo; it stays where it is. `repo`: {directory, confirm, url?, name?, ...}
export async function importRepo(repo) {
const response = await apiPost(`${REPOS_API}/import`, repo);
if (response.ok) {
return (await response.json())['repo'];
}
await reposError(response, 'Failed to import the repo.');
}

View file

@ -1121,7 +1121,7 @@ describe('a row of buttons', () => {
* the two had been inline before.
*
* A row fixes it without touching the stack: the row is the stretched child, and it lays
* its buttons out at their own width. Nine sites, so this scan rather than nine
* its buttons out at their own width. Thirteen sites, so this scan rather than thirteen
* assertions -- and a scan because a stretched button is still a working button.
*/
const offenders = [];
@ -1140,8 +1140,8 @@ describe('a row of buttons', () => {
}
}
// Nine renders. Common.js declares it, and a declaration is not a `<BackButton`.
expect(found).toBe(9);
// Thirteen renders. Common.js declares it, and a declaration is not a `<BackButton`.
expect(found).toBe(13);
expect(offenders).toEqual([]);
});

View file

@ -61,6 +61,7 @@ const allLinks = [
{text: 'Files', to: '/files', key: 'files'},
{text: 'Playlists', to: '/playlists', key: 'playlists'},
{text: 'Zim', to: '/zim', key: 'zim'},
{text: 'Repos', to: '/repos', key: 'repos'},
{text: 'Inventory', to: '/inventory', key: 'inventory'},
// No `end` on these two: a tab stays current for the pages beneath it, and `end` would
// drop the mark the moment either grows a sub-page.

View file

@ -1,7 +1,7 @@
import React from 'react';
import {act, render, screen} from '../test-utils';
import {OtherSearchView} from './Search';
import {searchChannels} from '../api';
import {searchChannels, searchRepos} from '../api';
// What the search page's Other tab shows for each state of useSearchChannels. "No Channels"
// must only appear once the server has confirmed there are none.
@ -9,11 +9,16 @@ import {searchChannels} from '../api';
jest.mock('../api', () => ({
...jest.requireActual('../api'),
searchChannels: jest.fn(),
searchRepos: jest.fn(),
}));
const NO_CHANNELS = /No Channels/;
beforeEach(() => searchChannels.mockReset());
beforeEach(() => {
searchChannels.mockReset();
searchRepos.mockReset();
searchRepos.mockResolvedValue({repos: [], total: 0});
});
test('pending: shows a loader, not "No Channels"', () => {
searchChannels.mockReturnValue(new Promise(() => {
@ -54,3 +59,61 @@ test('loaded: lists the channels', async () => {
expect(screen.getByText('Cooking')).toBeInTheDocument();
expect(screen.queryByText(NO_CHANNELS)).not.toBeInTheDocument();
});
describe('Repos', () => {
test('pending: the Repos section loads while Channels are shown', async () => {
searchChannels.mockResolvedValue({channels: []});
searchRepos.mockReturnValue(new Promise(() => {
}));
const {container} = render(<OtherSearchView loading={false}/>);
await act(async () => {
});
expect(screen.getByText(NO_CHANNELS)).toBeInTheDocument();
expect(container.querySelector('.wrolpi-loading')).toBeInTheDocument();
expect(screen.queryByText('No Repos')).not.toBeInTheDocument();
});
test('empty: shows "No Repos"', async () => {
searchChannels.mockResolvedValue({channels: []});
render(<OtherSearchView loading={false}/>);
await act(async () => {
});
expect(screen.getByText('No Repos')).toBeInTheDocument();
});
test('failed: shows an error only in the Repos section', async () => {
searchChannels.mockResolvedValue({channels: [{id: 1, name: 'Cooking'}]});
searchRepos.mockRejectedValue(new Error('boom'));
const spy = jest.spyOn(console, 'error').mockImplementation(() => {
});
try {
render(<OtherSearchView loading={false}/>);
await act(async () => {
});
expect(screen.getByText('Cooking')).toBeInTheDocument();
expect(screen.getByText(/Could not fetch the repos/)).toBeInTheDocument();
} finally {
spy.mockRestore();
}
});
test('loaded: lists repos by the search, with their README headline', async () => {
searchChannels.mockResolvedValue({channels: []});
searchRepos.mockResolvedValue({
total: 1, repos: [{
id: 7, name: 'kiwix-tools', tag_name: null,
// The server escapes the README; only the highlight is markup.
readme_headline: 'Serve <b>Zim</b> files &lt;script&gt;',
}],
});
const searchParams = new URLSearchParams('q=zim&tag=Software');
const {container} = render(<OtherSearchView loading={false}/>, {query: {searchParams}});
await act(async () => {
});
expect(searchRepos).toHaveBeenCalledWith('zim', ['Software']);
expect(screen.getByText('kiwix-tools').closest('a')).toHaveAttribute('href', '/repos/7');
expect(container.querySelector('u').textContent).toBe('Zim');
expect(container.querySelector('script')).toBeNull();
expect(screen.getByText(/files <script>/)).toBeInTheDocument();
});
});

1062
app/src/components/Repos.js Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,394 @@
import React from 'react';
import {Route, Routes} from 'react-router';
import {act, fireEvent, render, screen, waitFor, within} from '../test-utils';
import {
RepoEditPage,
RepoImportPage,
repoNameFromURL,
RepoNewPage,
ReposPage,
RepoViewPage,
safeRepoHref,
} from './Repos';
import {
createRepo, deleteRepo, fetchRepos, getFile, getRepo, getRepoLog, getRepoTree, importRepo, inspectRepoImport,
updateRepo,
} from '../api';
import {FilePreviewContext} from './FilePreview';
jest.mock('../api', () => ({
...jest.requireActual('../api'),
createRepo: jest.fn(),
deleteRepo: jest.fn(),
fetchRepos: jest.fn(),
getFile: jest.fn(),
getRepo: jest.fn(),
getRepoLog: jest.fn(),
getRepoTree: jest.fn(),
importRepo: jest.fn(),
inspectRepoImport: jest.fn(),
updateRepo: jest.fn(),
}));
// The directory picker searches the server; picking a directory is all these specs need.
jest.mock('./Common', () => ({
...jest.requireActual('./Common'),
DirectorySearch: ({onSelect}) => <button type='button' onClick={() => onSelect('code/kiwix-tools')}>
Pick directory
</button>,
}));
// The README is fetched from the media server; its rendering is MarkdownPreview's own spec.
jest.mock('./MarkdownPreview', () => ({
...jest.requireActual('./MarkdownPreview'),
MarkdownPreview: ({url}) => <div data-testid='markdown-preview'>{url}</div>,
}));
const repoFixture = (overrides = {}) => ({
id: 1, collection_id: 10, name: 'kiwix-tools', tag_name: null, description: null,
url: 'https://github.com/kiwix/kiwix-tools', mode: 'full', branch: null, default_branch: 'main',
head_sha: '3f9c2a1e7b', head_message: 'Fix the mimetype', head_date: '2026-09-30T14:02:00Z',
size: 1000, directory: 'repos/kiwix-tools', frequency: 604800, download_error: null,
...overrides,
});
const treeFixture = {
path: '',
readme_path: 'README.md',
entries: [
{name: 'src', path: 'src', is_dir: true, size: null},
{name: 'README.md', path: 'README.md', is_dir: false, size: 120},
],
};
const flush = () => act(async () => {
});
beforeEach(() => jest.clearAllMocks());
describe('safeRepoHref', () => {
it('only links https URLs', () => {
expect(safeRepoHref('https://github.com/a/b')).toBe('https://github.com/a/b');
expect(safeRepoHref('javascript:alert(1)')).toBeNull();
expect(safeRepoHref('http://github.com/a/b')).toBeNull();
expect(safeRepoHref('file:///etc/passwd')).toBeNull();
expect(safeRepoHref('not a url')).toBeNull();
});
});
describe('repoNameFromURL', () => {
it('is the last path segment, without .git or a branch', () => {
expect(repoNameFromURL('https://github.com/kiwix/kiwix-tools.git')).toBe('kiwix-tools');
expect(repoNameFromURL('https://github.com/kiwix/kiwix-tools/tree/dev')).toBe('kiwix-tools');
expect(repoNameFromURL('https://gitlab.com/group/sub/project/')).toBe('project');
expect(repoNameFromURL('nope')).toBe('');
});
});
describe('ReposPage', () => {
it('lists repos, linking each to its page', async () => {
fetchRepos.mockResolvedValue([
repoFixture(),
repoFixture({id: 2, name: 'gone', download_error: 'git ls-remote failed', head_sha: null}),
]);
render(<ReposPage/>, {route: '/repos'});
await flush();
expect(screen.getAllByText('kiwix-tools')[0].closest('a')).toHaveAttribute('href', '/repos/1');
expect(screen.getAllByText('gone')[0].closest('a')).toHaveAttribute('href', '/repos/2');
expect(screen.getByText('New Repo').closest('a')).toHaveAttribute('href', '/repos/new');
});
it('shows a repo which is never updated', async () => {
fetchRepos.mockResolvedValue([repoFixture({frequency: 0})]);
render(<ReposPage/>, {route: '/repos'});
await flush();
expect(screen.getAllByText('Never').length).toBeGreaterThan(0);
});
it('explains an empty list', async () => {
fetchRepos.mockResolvedValue([]);
render(<ReposPage/>, {route: '/repos'});
await flush();
expect(screen.getByText(/No repos yet/)).toBeInTheDocument();
});
});
function renderViewPage({repo, tree = treeFixture, query = {}, setPreviewFile = jest.fn()} = {}) {
getRepo.mockResolvedValue(repo);
getRepoTree.mockResolvedValue(tree);
return render(<Routes><Route path='/repos/:repoId' element={<RepoViewPage/>}/></Routes>, {
route: '/repos/1',
query,
contexts: [[FilePreviewContext, {setPreviewFile}]],
});
}
describe('RepoViewPage', () => {
it('shows the latest commit, the files, and the README', async () => {
renderViewPage({repo: repoFixture()});
await flush();
expect(getRepoTree).toHaveBeenCalledWith('1', '');
expect(screen.getByText('3f9c2a1')).toBeInTheDocument();
expect(screen.getByText('Fix the mimetype')).toBeInTheDocument();
expect(screen.getByText('main')).toBeInTheDocument();
expect(screen.getByText('(default)')).toBeInTheDocument();
expect(screen.getByText('https://github.com/kiwix/kiwix-tools').closest('a'))
.toHaveAttribute('href', 'https://github.com/kiwix/kiwix-tools');
expect(screen.getByText('src')).toBeInTheDocument();
expect(screen.getByTestId('markdown-preview')).toHaveTextContent('/media/repos/kiwix-tools/README.md');
});
it('opens a directory through the URL, and a file in the preview', async () => {
const updateQuery = jest.fn();
const setPreviewFile = jest.fn();
const file = {path: 'repos/kiwix-tools/README.md', mimetype: 'text/markdown', size: 120};
getFile.mockResolvedValue(file);
renderViewPage({repo: repoFixture(), query: {updateQuery}, setPreviewFile});
await flush();
fireEvent.click(screen.getByText('src'));
expect(updateQuery).toHaveBeenCalledWith({path: 'src'}, false);
fireEvent.click(screen.getAllByText('README.md')[0]);
await flush();
expect(getFile).toHaveBeenCalledWith('repos/kiwix-tools/README.md', true);
expect(setPreviewFile).toHaveBeenCalledWith(file);
});
it('lists the directory in the URL, with a way back up', async () => {
const tree = {path: 'src/lib', readme_path: null, entries: [{name: 'a.c', path: 'src/lib/a.c', is_dir: false}]};
const updateQuery = jest.fn();
renderViewPage({repo: repoFixture(), tree, query: {searchParams: new URLSearchParams('path=src/lib'), updateQuery}});
await flush();
expect(getRepoTree).toHaveBeenCalledWith('1', 'src/lib');
expect(screen.queryByTestId('markdown-preview')).not.toBeInTheDocument();
fireEvent.click(screen.getByText('..'));
expect(updateQuery).toHaveBeenCalledWith({path: 'src'}, false);
// The breadcrumb's root.
fireEvent.click(screen.getAllByText('kiwix-tools').find(i => i.tagName === 'BUTTON'));
expect(updateQuery).toHaveBeenCalledWith({path: ''}, false);
});
it('offers a ZIP of the files', async () => {
renderViewPage({repo: repoFixture()});
await flush();
const link = screen.getByText('Download ZIP').closest('a');
expect(link).toHaveAttribute('href', expect.stringMatching(/\/api\/repos\/1\/archive\.zip$/));
expect(link).toHaveAttribute('download');
});
it('shows the history instead of the files', async () => {
getRepoLog.mockResolvedValue({
total: 51,
commits: [{sha: 'abcdef1234', author: 'Ada', date: '2026-09-30T14:02:00Z', message: 'Fix the mimetype'}],
});
const updateQuery = jest.fn();
renderViewPage({repo: repoFixture(), query: {searchParams: new URLSearchParams('view=history'), updateQuery}});
await flush();
expect(getRepoLog).toHaveBeenCalledWith(1, 0, 50);
expect(getRepoTree).not.toHaveBeenCalled();
expect(screen.getByText('51 commits')).toBeInTheDocument();
expect(screen.getByText('abcdef1')).toBeInTheDocument();
expect(screen.getByText('Ada')).toBeInTheDocument();
// The next page is appended.
getRepoLog.mockResolvedValue({
total: 51,
commits: [{sha: '0123456789', author: 'Ben', date: '2026-09-01T00:00:00Z', message: 'Older'}],
});
fireEvent.click(screen.getByText('Load more'));
await flush();
expect(getRepoLog).toHaveBeenLastCalledWith(1, 1, 50);
expect(screen.getByText('abcdef1')).toBeInTheDocument();
expect(screen.getByText('0123456')).toBeInTheDocument();
fireEvent.click(screen.getByText('Files'));
expect(updateQuery).toHaveBeenCalledWith({view: null}, false);
});
it('explains a repo that is not downloaded yet', async () => {
renderViewPage({repo: repoFixture({head_sha: null, head_message: null, head_date: null})});
await flush();
expect(screen.getByText('Not downloaded yet')).toBeInTheDocument();
expect(getRepoTree).not.toHaveBeenCalled();
expect(screen.queryByText('Download ZIP')).not.toBeInTheDocument();
expect(screen.queryByText('History')).not.toBeInTheDocument();
});
it('keeps showing the files when the last update failed', async () => {
renderViewPage({repo: repoFixture({download_error: 'Traceback...\nDownloadError: git ls-remote failed (128)'})});
await flush();
expect(screen.getByText('The last update failed')).toBeInTheDocument();
expect(screen.getByText(/git ls-remote failed/)).toBeInTheDocument();
expect(screen.getByText('src')).toBeInTheDocument();
});
it('says a repo which is never updated is only updated on request', async () => {
renderViewPage({repo: repoFixture({frequency: 0, download_error: 'git ls-remote failed (128)'})});
await flush();
expect(screen.getByText('The last update failed')).toBeInTheDocument();
expect(screen.getByText(/never updated automatically/)).toBeInTheDocument();
expect(screen.queryByText(/will be tried again/)).not.toBeInTheDocument();
});
it('never links an upstream that is not https', async () => {
renderViewPage({repo: repoFixture({url: 'javascript:alert(1)'})});
await flush();
expect(screen.queryByText('javascript:alert(1)')).not.toBeInTheDocument();
});
});
describe('RepoNewPage', () => {
it('adds a repo, then opens it', async () => {
createRepo.mockResolvedValue(repoFixture({id: 5}));
render(<Routes>
<Route path='/repos/new' element={<RepoNewPage/>}/>
<Route path='/repos/:repoId' element={<div>repo page</div>}/>
</Routes>, {route: '/repos/new'});
const add = screen.getByText('Add Repo').closest('button');
expect(add).toBeDisabled();
fireEvent.change(screen.getByLabelText(/URL/), {target: {value: 'git@github.com:a/b'}});
expect(add).toBeDisabled();
expect(screen.getByText('Must be an https:// URL')).toBeInTheDocument();
fireEvent.change(screen.getByLabelText(/URL/), {target: {value: 'https://github.com/kiwix/kiwix-tools'}});
expect(screen.getByPlaceholderText('kiwix-tools')).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('Branch'), {target: {value: ' dev '}});
fireEvent.click(screen.getByText('Pick directory'));
fireEvent.click(add);
await flush();
expect(createRepo).toHaveBeenCalledWith({
url: 'https://github.com/kiwix/kiwix-tools', name: null, tag_name: null, frequency: 604800,
mode: 'full', branch: 'dev', submodules: false, description: null, directory: 'code/kiwix-tools',
});
expect(screen.getByText('repo page')).toBeInTheDocument();
});
});
describe('RepoEditPage', () => {
function renderEditPage(repo) {
getRepo.mockResolvedValue(repo);
return render(<Routes>
<Route path='/repos/:repoId/edit' element={<RepoEditPage/>}/>
<Route path='/repos' element={<div>repos page</div>}/>
</Routes>, {route: '/repos/1/edit'});
}
it('saves the settings', async () => {
updateRepo.mockResolvedValue(repoFixture());
renderEditPage(repoFixture({branch: 'dev', description: 'Tools'}));
await flush();
expect(screen.getByLabelText('Branch')).toHaveValue('dev');
expect(screen.getByLabelText('Include submodules')).not.toBeChecked();
fireEvent.change(screen.getByLabelText('Branch'), {target: {value: ''}});
fireEvent.click(screen.getByLabelText('Include submodules'));
fireEvent.click(screen.getByText('Save'));
await flush();
expect(updateRepo).toHaveBeenCalledWith('1', {
description: 'Tools', frequency: 604800, mode: 'full', branch: '', submodules: true,
});
});
it('keeps a repo which is never updated', async () => {
updateRepo.mockResolvedValue(repoFixture({frequency: 0}));
renderEditPage(repoFixture({frequency: 0}));
await flush();
expect(screen.getByDisplayValue('Never')).toBeInTheDocument();
fireEvent.click(screen.getByText('Save'));
await flush();
expect(updateRepo).toHaveBeenCalledWith('1', expect.objectContaining({frequency: 0}));
});
it('deletes the repo, and its files only when asked', async () => {
deleteRepo.mockResolvedValue(undefined);
renderEditPage(repoFixture());
await flush();
fireEvent.click(screen.getByText('Delete'));
const dialog = await screen.findByRole('dialog');
fireEvent.click(within(dialog).getByLabelText(/Also delete its files/));
fireEvent.click(within(dialog).getByText('Delete'));
await flush();
expect(deleteRepo).toHaveBeenCalledWith('1', true);
expect(screen.getByText('repos page')).toBeInTheDocument();
});
});
describe('RepoImportPage', () => {
const inspection = (overrides = {}) => ({
directory: 'code/kiwix-tools', origin: 'git@github.com:kiwix/kiwix-tools.git',
url: 'https://github.com/kiwix/kiwix-tools', name: 'kiwix-tools', branch: 'main',
head_sha: 'abcdef1234', head_message: 'Local work', head_date: '2026-09-30T14:02:00Z', local_commits: 2,
shallow: false, ignored: false,
...overrides,
});
function renderImportPage() {
return render(<Routes>
<Route path='/repos/import' element={<RepoImportPage/>}/>
<Route path='/repos/:repoId' element={<div>repo page</div>}/>
</Routes>, {route: '/repos/import'});
}
it('inspects the directory, warns about local commits, and imports once confirmed', async () => {
inspectRepoImport.mockResolvedValue({inspection: inspection()});
importRepo.mockResolvedValue(repoFixture({id: 9}));
renderImportPage();
fireEvent.click(screen.getByText('Pick directory'));
expect(await screen.findByText('2 commits are not on the remote')).toBeInTheDocument();
expect(inspectRepoImport).toHaveBeenCalledWith('code/kiwix-tools', '', null, '');
// Inspected again with the URL the import will use.
await waitFor(() => expect(inspectRepoImport).toHaveBeenCalledWith('code/kiwix-tools', '', null,
'https://github.com/kiwix/kiwix-tools'));
expect(screen.getByText('git@github.com:kiwix/kiwix-tools.git')).toBeInTheDocument();
expect(screen.getByText(/Its files are indexed/)).toBeInTheDocument();
// The URL comes from the origin.
expect(screen.getByLabelText(/^URL/)).toHaveValue('https://github.com/kiwix/kiwix-tools');
const button = screen.getByText('Import').closest('button');
expect(button).toBeDisabled();
fireEvent.click(screen.getByLabelText(/I understand/));
expect(button).toBeEnabled();
fireEvent.click(button);
await flush();
expect(importRepo).toHaveBeenCalledWith({
directory: 'code/kiwix-tools', confirm: true, url: 'https://github.com/kiwix/kiwix-tools', name: null,
tag_name: null, frequency: 604800, mode: 'full', branch: null, submodules: false,
});
expect(screen.getByText('repo page')).toBeInTheDocument();
});
it('asks for the URL when the clone has no https origin', async () => {
inspectRepoImport.mockResolvedValue({inspection: inspection({origin: null, url: null, local_commits: 0})});
renderImportPage();
fireEvent.click(screen.getByText('Pick directory'));
expect(await screen.findByText(/no https:\/\/ origin/)).toBeInTheDocument();
fireEvent.click(screen.getByLabelText(/I understand/));
expect(screen.getByText('Import').closest('button')).toBeDisabled();
fireEvent.change(screen.getByLabelText(/^URL/), {target: {value: 'https://github.com/kiwix/kiwix-tools'}});
expect(screen.getByText('Import').closest('button')).toBeEnabled();
});
it('explains a directory which cannot be imported', async () => {
inspectRepoImport.mockResolvedValue({error: 'code/kiwix-tools is not a git clone'});
renderImportPage();
fireEvent.click(screen.getByText('Pick directory'));
expect(await screen.findByText('code/kiwix-tools is not a git clone')).toBeInTheDocument();
expect(screen.queryByText('Import')).not.toBeInTheDocument();
});
it('says when the files are not indexed', async () => {
inspectRepoImport.mockResolvedValue({inspection: inspection({ignored: true})});
renderImportPage();
fireEvent.click(screen.getByText('Pick directory'));
expect(await screen.findByText(/Its files are not indexed/)).toBeInTheDocument();
});
});

View file

@ -1,7 +1,15 @@
import React, {useState} from "react";
import {Link, Route, Routes, useLocation, useNavigate} from "react-router";
import {FilesSearchView, SearchViewButton} from "./Files";
import {useLatestRequest, usePages, useSearchChannels, useSearchDate, useSearchDeep, useSearchFilter} from "../hooks/customHooks";
import {
useLatestRequest,
usePages,
useSearchChannels,
useSearchDate,
useSearchDeep,
useSearchFilter,
useSearchRepos,
} from "../hooks/customHooks";
import {ShortcutHint} from "./ShortcutHint";
import {ZimSearchView} from "./Zim";
import {MapSearchView} from "./MapSearchView";
@ -12,6 +20,7 @@ import {TagsContext} from "../Tags";
import {Accordion, Header, Icon, Label, Loading, Panel, Stack} from "./ui";
import {QueryContext} from "../contexts/contexts";
import {KeyboardShortcutsContext} from "../contexts/KeyboardShortcutsContext";
import {HeadlineText} from "./Headline";
const SUGGESTED_APPS = [
{location: '/admin', title: 'Downloads', description: 'View and control your downloads'},
@ -139,6 +148,7 @@ export function useSuggestions(searchStr, tagNames, filter, anyTag) {
domains: null,
authors: null,
subjects: null,
repos: null,
zimsEstimates: [],
}
const [suggestions, setSuggestions] = React.useState(defaultSuggestions);
@ -163,7 +173,7 @@ export function useSuggestions(searchStr, tagNames, filter, anyTag) {
// The files estimate always uses deep search server-side, so users know results are possible.
sendFilesRequest(async () => await searchEstimateFiles(searchStr, tagNames, mimetypes, months, dateRange, anyTag));
sendZimRequest(async () => await searchEstimateZims(searchStr, tagNames));
sendOtherRequest(async () => await searchEstimateOthers(tagNames));
sendOtherRequest(async () => await searchEstimateOthers(tagNames, searchStr));
}
}, [
searchStr,
@ -185,6 +195,7 @@ export function useSuggestions(searchStr, tagNames, filter, anyTag) {
domains: generalData.domains,
authors: generalData.authors,
subjects: generalData.subjects,
repos: generalData.repos,
}
});
}
@ -312,6 +323,13 @@ export function useSearchSuggestions(defaultSearchStr, defaultTagNames, anyTag)
})
}
}
if (newSuggestions.repos && newSuggestions.repos.length > 0) {
results.repos = {
name: 'Repos', results: newSuggestions.repos.map(i => {
return {type: 'repo', title: i.name, id: i.id, location: `/repos/${i.id}`}
})
}
}
if (newSuggestions.authors && newSuggestions.authors.length > 0) {
results.authors = {
name: 'Authors', results: newSuggestions.authors.map(i => {
@ -407,6 +425,7 @@ export function useSearchSuggestions(defaultSearchStr, defaultTagNames, anyTag)
domains: newSuggestions.domains?.length,
authors: newSuggestions.authors?.length,
subjects: newSuggestions.subjects?.length,
repos: newSuggestions.repos?.length,
tags: matchingTags?.length,
apps: matchingApps?.length,
});
@ -543,12 +562,47 @@ function SearchChannelPreview({channel}) {
</div>
}
// `channels` follows useSearchChannels: null = pending, undefined = fetch failed, [] = none.
function SearchRepoPreview({repo}) {
const {SingleTag} = React.useContext(TagsContext);
return <div>
<span style={{display: 'inline-flex', alignItems: 'center', gap: 8, flexWrap: 'wrap'}}>
<Link to={`/repos/${repo.id}`}>{repo.name}</Link>
{repo.tag_name && <SingleTag name={repo.tag_name}/>}
</span>
{/* The server escapes the README; only its <b> highlights are markup. */}
{repo.readme_headline && <div><small><HeadlineText headline={repo.readme_headline}/></small></div>}
</div>
}
function OtherSearchSection({value, title, items, preview, emptyText, errorText}) {
let body;
if (items === null) {
body = <Loading/>;
} else if (items === undefined) {
body = <ErrorMessage>{errorText}</ErrorMessage>;
} else {
body = <Stack gap='xs'>{!_.isEmpty(items) ? items.map(preview) : emptyText}</Stack>;
}
return <Accordion.Item value={value}>
<Accordion.Control>
<span style={{display: 'inline-flex', alignItems: 'center', gap: 8}}>
<Header as='h3' style={{margin: 0}}>{title}</Header>
{Array.isArray(items) && <Label>{normalizeEstimate(items.length)}</Label>}
</span>
</Accordion.Control>
<Accordion.Panel>{body}</Accordion.Panel>
</Accordion.Item>
}
// `channels` follows useSearchChannels and `repos` follows useSearchRepos: null = pending,
// undefined = fetch failed, [] = none.
export function OtherSearchView({loading}) {
const {searchParams} = React.useContext(QueryContext);
const [activeValue, setActiveValue] = React.useState('channels');
const [activeValues, setActiveValues] = React.useState(['channels', 'repos']);
const activeTags = searchParams.getAll('tag');
const searchStr = searchParams.get('q') || '';
const {channels, loading: channelsLoading} = useSearchChannels(activeTags);
const {repos} = useSearchRepos(searchStr, activeTags);
if (loading || channelsLoading || channels === null) {
return <Panel><Loading/></Panel>
@ -556,21 +610,22 @@ export function OtherSearchView({loading}) {
return <Panel><ErrorMessage>Could not fetch the channels.</ErrorMessage></Panel>
}
return <Accordion value={activeValue} onChange={setActiveValue}>
<Accordion.Item value='channels'>
<Accordion.Control>
<span style={{display: 'inline-flex', alignItems: 'center', gap: 8}}>
<Header as='h3' style={{margin: 0}}>Channels</Header>
<Label>{normalizeEstimate(channels.length)}</Label>
</span>
</Accordion.Control>
<Accordion.Panel>
<Stack gap='xs'>
{!_.isEmpty(channels) ?
channels.map(i => <SearchChannelPreview key={i.id} channel={i}/>)
: 'No Channels'}
</Stack>
</Accordion.Panel>
</Accordion.Item>
return <Accordion multiple value={activeValues} onChange={setActiveValues}>
<OtherSearchSection
value='channels'
title='Channels'
items={channels}
preview={i => <SearchChannelPreview key={i.id} channel={i}/>}
emptyText='No Channels'
errorText='Could not fetch the channels.'
/>
<OtherSearchSection
value='repos'
title='Repos'
items={repos}
preview={i => <SearchRepoPreview key={i.id} repo={i}/>}
emptyText='No Repos'
errorText='Could not fetch the repos.'
/>
</Accordion>
}

View file

@ -103,3 +103,37 @@ test('Channels rows once the server found some', async () => {
});
expect(results().channels.results.map(i => i.title)).toEqual(['Food']);
});
test('Repos rows link to each repo, and the Other estimate gets the search', async () => {
let resolve;
searchSuggestions.mockReturnValue(new Promise(res => resolve = res));
renderProbe();
act(() => {
hook.setSearchStr('kiwix');
});
act(() => {
jest.advanceTimersByTime(DEBOUNCE);
});
await act(async () => {
resolve({channels: [], domains: [], authors: [], subjects: [], repos: [{id: 4, name: 'kiwix-tools'}]});
});
expect(results().repos.results).toEqual([{type: 'repo', title: 'kiwix-tools', id: 4, location: '/repos/4'}]);
// Repos count toward the Other tab by their name and README, not only by tag.
expect(searchEstimateOthers).toHaveBeenCalledWith([], 'kiwix');
});
test('no Repos group when none match', async () => {
let resolve;
searchSuggestions.mockReturnValue(new Promise(res => resolve = res));
renderProbe();
act(() => {
hook.setSearchStr('foo');
});
act(() => {
jest.advanceTimersByTime(DEBOUNCE);
});
await act(async () => {
resolve({channels: [], domains: [], authors: [], subjects: [], repos: []});
});
expect(results().repos).toBeUndefined();
});

View file

@ -49,6 +49,9 @@ import {contrastingColor} from './Common';
import {navBarStyle, navColorNames, useNavColors} from '../themes/navColors';
import {IconMenu2} from '@tabler/icons-react';
import {BookmarkAddForms, BookmarkMenuItems, BookmarkTable} from './Bookmarks';
import {
RepoBreadcrumb, RepoHistoryTable, RepoImportSummary, RepoStatusIcon, RepoSummary, RepoTreeTable,
} from './Repos';
/*
* A gallery of every component in the library, in the current theme.
@ -170,6 +173,28 @@ const sampleBookmarks = [
];
const noBookmarkActions = {edit: () => null, move: () => null, remove: () => null};
const sampleRepo = {
id: 1, name: 'kiwix-tools', tag_name: null, url: 'https://github.com/kiwix/kiwix-tools',
description: 'Command line tools for Kiwix and Zim files.', mode: 'full', branch: null, default_branch: 'main',
head_sha: '3f9c2a1e7b', head_message: 'Serve Zim files with the correct mimetype', head_date: '2026-09-30T14:02:00Z',
size: 18_400_000, directory: 'repos/kiwix-tools', download_error: null,
};
const sampleFailedRepo = {
...sampleRepo, name: 'gone-upstream', branch: 'release', mode: 'snapshot',
download_error: "git ls-remote failed (128): remote: Repository not found.\nfatal: repository " +
"'https://github.com/example/gone-upstream/' not found",
};
const sampleRepoCommits = [
{sha: '3f9c2a1e7b', author: 'Ada Example', date: '2026-09-30T14:02:00Z',
message: 'Serve Zim files with the correct mimetype'},
{sha: '91b0d44c02', author: 'Ben Example', date: '2026-09-12T09:15:00Z', message: 'Merge pull request #42'},
];
const sampleRepoEntries = [
{name: 'docs', path: 'src/docs', is_dir: true, size: null},
{name: 'kiwix-serve.cpp', path: 'src/kiwix-serve.cpp', is_dir: false, size: 48_210},
{name: 'README.md', path: 'src/README.md', is_dir: false, size: 2_104},
];
export function ThemeSamplePage() {
const {theme, mediaFilterEnabled} = useContext(ThemeContext);
const [confirmOpen, setConfirmOpen] = useState(false);
@ -1127,6 +1152,36 @@ export function ThemeSamplePage() {
</Panel>
</Section>
<Section label='Repos'>
<Panel>
<p style={{fontSize: '0.75rem', color: 'var(--muted)', marginTop: 0}}>
A git repo's page: its summary, then its files. A repo whose last update failed keeps
its files and explains why. The status icons appear beside a repo's name in the Repos
table: <RepoStatusIcon repo={sampleFailedRepo}/> failed,{' '}
<RepoStatusIcon repo={{...sampleRepo, head_sha: null}}/> not downloaded yet.
</p>
<RepoSummary repo={sampleRepo}/>
<div style={{marginTop: 12}}>
<RepoBreadcrumb name={sampleRepo.name} path='src' onNavigate={() => null}/>
<RepoTreeTable path='src' entries={sampleRepoEntries} onOpenDirectory={() => null}
onOpenFile={() => null}/>
</div>
<div style={{marginTop: 12}}>
<RepoHistoryTable commits={sampleRepoCommits}/>
</div>
<div style={{marginTop: 12}}>
<RepoImportSummary inspection={{
origin: 'git@github.com:kiwix/kiwix-tools.git', branch: 'main', head_sha: '3f9c2a1e7b',
head_message: 'Local work', head_date: '2026-09-30T14:02:00Z', local_commits: 2,
shallow: true, directory: 'code/kiwix-tools', ignored: false,
}}/>
</div>
<div style={{marginTop: 12}}>
<RepoSummary repo={sampleFailedRepo}/>
</div>
</Panel>
</Section>
<Section label='Danger zone'>
<Panel danger>
<h2 style={{color: 'var(--danger)', fontSize: '0.875rem', margin: '0 0 4px'}}>Danger zone</h2>

View file

@ -3,6 +3,7 @@ export const VIDEOS_API = `${API_URI}/videos`;
export const ARCHIVES_API = `${API_URI}/archive`;
export const COLLECTIONS_API = `${API_URI}/collections`;
export const ZIM_API = `${API_URI}/zim`;
export const REPOS_API = `${API_URI}/repos`;
export const DEFAULT_LIMIT = 20;
export const NAME = process.env && process.env.REACT_APP_NAME ? process.env.REACT_APP_NAME : null;

View file

@ -499,6 +499,7 @@ export function SettingsPage() {
videos_destination: settings.videos_destination,
zims_destination: settings.zims_destination,
playlists_destination: settings.playlists_destination,
repos_destination: settings.repos_destination,
save_ffprobe_json: settings.save_ffprobe_json,
});
}, [JSON.stringify(settings), settingsLoaded, settingsFailed]);
@ -849,6 +850,32 @@ export function SettingsPage() {
onChange={e => handleInputChange('playlists_destination', e.currentTarget.value)}
/>
</Grid.Col>
<Grid.Col span={{base: 12, sm: 6}}>
<PathInput
label={<>
Repos Directory
<InfoPopup
content={<>
<p>Variables:</p>
<ul>
<li><code>%(repo_name)s</code> - Repo name</li>
<li><code>%(repo_tag)s</code> - Tag name for the repo (empty if no tag)
</li>
<li><code>%(repo_owner)s</code> - Owner from the repo URL</li>
<li><code>%(repo_host)s</code> - Host from the repo URL</li>
</ul>
<p>Must start with a fixed directory (like <code>repos/</code>), which
is never indexed, and contain <code>%(repo_name)s</code>.</p>
</>}
position='top'
/>
</>}
prefix={mediaDirectoryLabel}
value={state.repos_destination}
disabled={!editSpecialDirectories}
onChange={e => handleInputChange('repos_destination', e.currentTarget.value)}
/>
</Grid.Col>
<Grid.Col span={12}>
<Toggle
label='Edit Directories'

View file

@ -77,7 +77,9 @@ function renderRow(collection, columns, routes, SingleTag, onRowClick) {
}
// Format the value based on column configuration
if (col.format === 'bytes') {
if (col.render) {
value = col.render(collection);
} else if (col.format === 'bytes') {
value = humanFileSize(value);
} else if (col.format === 'frequency') {
value = formatFrequency(value);
@ -134,7 +136,9 @@ function MobileCollectionRow({collection, mobileColumns, routes}) {
.filter(col => col.type !== 'actions' && col.key !== primaryColumn.key && col.key !== 'tag_name')
.map(col => {
let value = collection[col.key];
if (col.format === 'bytes') {
if (col.render) {
value = col.render(collection);
} else if (col.format === 'bytes') {
value = humanFileSize(value);
} else if (col.format === 'frequency') {
value = formatFrequency(value);
@ -157,7 +161,8 @@ function MobileCollectionRow({collection, mobileColumns, routes}) {
* Reusable table component for displaying collections (Domains, Channels, etc).
*
* @param {Array} collections - Array of collection objects
* @param {Array} columns - Column configurations for the table
* @param {Array} columns - Column configurations for the table. A column's optional `render(collection)` displays
* the cell; the column still sorts by `collection[key]`.
* @param {Object} routes - Routes configuration for navigation (edit, search, etc.)
* @param {String} searchStr - Search filter string (managed by parent)
* @param {Function} onRowClick - Optional callback when a row is clicked

View file

@ -68,6 +68,27 @@ const DOMAIN_ROUTES = {
describe('CollectionTable', () => {
const mockCollections = createMockDomains(3);
describe('Column render', () => {
it('displays what a column renders, in the desktop and mobile rows', () => {
const columns = [
{key: 'name', label: 'Name', render: (c) => <em data-testid='rendered-name'>{c.name}!</em>},
{key: 'branch', label: 'Branch', render: (c) => c.branch || c.default_branch},
];
render(<CollectionTable
collections={[{id: 1, name: 'kiwix-tools', branch: null, default_branch: 'main'}]}
columns={columns}
routes={{search: '/repos/:id', id_field: 'id'}}
/>);
// Desktop: the rendered name is still the link to the collection.
expect(screen.getByTestId('rendered-name')).toHaveTextContent('kiwix-tools!');
expect(screen.getByTestId('rendered-name').closest('a')).toHaveAttribute('href', '/repos/1');
expect(screen.getByText('main')).toBeInTheDocument();
// Mobile lists the other columns by label.
expect(screen.getByText('Branch: main')).toBeInTheDocument();
});
});
describe('Loading and Error States', () => {
it('renders loading placeholder when collections is null', () => {
const {container} = render(

View file

@ -38,6 +38,7 @@ import {
searchChannels,
searchDirectories,
searchDocs,
searchRepos,
searchVideos,
searchZim,
updateChannel,
@ -2156,6 +2157,27 @@ export const useSearchChannels = (defaultTagNames) => {
}
}
// Repos matching the search (their name and README) and/or the tags. `repos`: null = pending,
// undefined = fetch failed, [] = none. Only the newest request may write state.
export const useSearchRepos = (searchStr, tagNames) => {
const [repos, setRepos] = useState(null);
const requestGen = useRef(0);
const tagKey = JSON.stringify(tagNames || []);
useEffect(() => {
const gen = ++requestGen.current;
setRepos(null);
searchRepos(searchStr, JSON.parse(tagKey))
.then(result => gen === requestGen.current && setRepos(result ? result.repos : undefined))
.catch(e => {
console.error(e);
if (gen === requestGen.current) setRepos(undefined);
});
}, [searchStr, tagKey]);
return {repos};
}
/**
* Detect whether the WROLPi browser extension is installed AND has this

View file

@ -71,6 +71,7 @@ export const settingsFixture = (overrides = {}) => ({
media_directory: '/media/wrolpi',
nav_color: 'violet',
playlists_destination: 'playlists',
repos_destination: 'repos/%(repo_tag)s/%(repo_name)s',
require_cookies_unlocked: false,
require_media_mounted: false,
save_ffprobe_json: true,

View file

@ -1,5 +1,6 @@
from modules.archive.conftest import * # noqa
from modules.inventory.conftest import * # noqa
from modules.repos.conftest import * # noqa
from modules.videos.conftest import * # noqa
from modules.zim.conftest import * # noqa
from wrolpi.conftest import * # noqa

@ -1 +1 @@
Subproject commit b61bae309cf253e080824ac12eb8b4fb690b8456
Subproject commit 1b49385d674bbe4d98d2708f3f1e79297c9b0c00

View file

@ -2,5 +2,6 @@ from .archive import api
from .docs import api
from .inventory import api
from .map import api
from .repos import api
from .videos import api
from .zim import api, test

View file

@ -0,0 +1,3 @@
from .downloader import GitDownloader # noqa
__all__ = []

182
modules/repos/api.py Normal file
View file

@ -0,0 +1,182 @@
from http import HTTPStatus
from sanic import Request, Blueprint, response
from sanic_ext.extensions.openapi import openapi
from wrolpi.api_utils import json_response
from wrolpi.common import wrol_mode_check, logger
from wrolpi.errors import ValidationError
from . import lib, schema
from .models import Repository
repos_bp = Blueprint('Repos', '/api/repos')
logger = logger.getChild(__name__)
@repos_bp.get('/')
@openapi.definition(summary='List all git Repos')
async def get_repos(request: Request):
repos = lib.get_repositories(request.ctx.session)
return json_response(dict(repos=repos))
@repos_bp.post('/')
@openapi.definition(
summary='Add a git Repo; it will be cloned, then kept up to date',
body=schema.RepoCreateRequest,
validate=True,
)
@wrol_mode_check
async def post_repo(request: Request, body: schema.RepoCreateRequest):
lib.get_repos_config().check_imported()
session = request.ctx.session
repo = lib.create_repository(
session,
body.url,
tag_name=body.tag_name or None,
frequency=lib.DEFAULT_REPO_FREQUENCY if body.frequency is None else body.frequency,
mode=body.mode or 'full',
branch=body.branch or None,
name=body.name or None,
description=body.description or None,
submodules=body.submodules,
directory=(body.directory or '').strip() or None,
)
repo = Repository.find_by_id(session, repo.id, options=Repository.json_options())
return json_response(dict(repo=repo.__json__()), status=HTTPStatus.CREATED)
@repos_bp.post('/import/inspect')
@openapi.definition(
summary='What importing an existing git clone (in the media directory) as a Repo would do. Changes nothing.',
body=schema.RepoImportInspectRequest,
validate=True,
)
async def post_inspect_import(request: Request, body: schema.RepoImportInspectRequest):
info = lib.inspect_import(request.ctx.session, body.directory, body.name or None, body.tag_name or None,
body.url or None)
return json_response(dict(inspection=info))
@repos_bp.post('/import')
@openapi.definition(
summary='Import an existing git clone as a Repo. It stays where it is, and becomes a mirror of its origin '
'(local changes are discarded by its updates).',
body=schema.RepoImportRequest,
validate=True,
)
@wrol_mode_check
async def post_import(request: Request, body: schema.RepoImportRequest):
lib.get_repos_config().check_imported()
session = request.ctx.session
repo = await lib.import_repository(
session,
body.directory,
url=body.url or None,
confirm=body.confirm,
tag_name=body.tag_name or None,
frequency=lib.DEFAULT_REPO_FREQUENCY if body.frequency is None else body.frequency,
mode=body.mode or 'full',
branch=body.branch or None,
submodules=body.submodules,
name=body.name or None,
description=body.description or None,
)
repo = Repository.find_by_id(session, repo.id, options=Repository.json_options())
return json_response(dict(repo=repo.__json__()), status=HTTPStatus.CREATED)
@repos_bp.post('/search')
@openapi.definition(
summary='Search git Repos by their name and README, and/or by Tag',
body=schema.RepoSearchRequest,
validate=True,
)
async def post_search_repos(request: Request, body: schema.RepoSearchRequest):
limit = min(max(body.limit, 1), 100)
repos, total = lib.search_repos(request.ctx.session, body.search_str, body.tag_names, limit=limit,
offset=max(body.offset, 0))
return json_response(dict(repos=repos, totals=dict(repos=total)))
@repos_bp.get('/<repo_id:int>')
@openapi.definition(summary='Get a git Repo')
async def get_repo(request: Request, repo_id: int):
repo = Repository.find_by_id(request.ctx.session, repo_id, options=Repository.json_options())
return json_response(dict(repo=repo.__json__()))
@repos_bp.put('/<repo_id:int>')
@openapi.definition(
summary="Change a git Repo's settings",
body=schema.RepoUpdateRequest,
validate=True,
)
@wrol_mode_check
async def put_repo(request: Request, repo_id: int, body: schema.RepoUpdateRequest):
lib.get_repos_config().check_imported()
session = request.ctx.session
lib.update_repository(session, repo_id, description=body.description, frequency=body.frequency,
mode=body.mode, branch=body.branch, submodules=body.submodules)
repo = Repository.find_by_id(session, repo_id, options=Repository.json_options())
return json_response(dict(repo=repo.__json__()))
@repos_bp.delete('/<repo_id:int>')
@openapi.definition(summary='Delete a git Repo. Its files are kept unless `delete_files=true`.')
@wrol_mode_check
async def delete_repo(request: Request, repo_id: int):
lib.get_repos_config().check_imported()
delete_files = request.args.get('delete_files', '').lower() in ('1', 'true', 'yes')
await lib.delete_repository(request.ctx.session, repo_id, delete_files=delete_files)
return response.empty()
@repos_bp.post('/<repo_id:int>/update')
@openapi.definition(summary='Update a git Repo now')
@wrol_mode_check
async def post_update_repo(request: Request, repo_id: int):
session = request.ctx.session
repo = Repository.find_by_id(session, repo_id)
lib.request_repository_update(session, repo)
session.commit()
return response.empty(HTTPStatus.NO_CONTENT)
@repos_bp.get('/<repo_id:int>/tree')
@openapi.definition(summary="List a directory of a git Repo's files")
@openapi.parameter('path', str, 'query', description='Directory in the repo, relative to its root', required=False)
async def get_repo_tree(request: Request, repo_id: int):
repo = Repository.find_by_id(request.ctx.session, repo_id)
tree = lib.list_repo_tree(repo, request.args.get('path', ''))
return json_response(tree)
@repos_bp.get('/<repo_id:int>/log')
@openapi.definition(summary="The commits of a git Repo's checked out branch, newest first")
@openapi.parameter('limit', int, 'query', description='At most 100', required=False)
@openapi.parameter('offset', int, 'query', required=False)
async def get_repo_log(request: Request, repo_id: int):
repo = Repository.find_by_id(request.ctx.session, repo_id)
try:
limit = int(request.args.get('limit', 50))
offset = int(request.args.get('offset', 0))
except ValueError:
raise ValidationError('limit and offset must be numbers')
log = await lib.get_repo_log(repo, limit=limit, offset=offset)
return json_response(log)
@repos_bp.get('/<repo_id:int>/archive.zip')
@openapi.definition(summary="Download a ZIP of a git Repo's checked out files")
async def get_repo_archive(request: Request, repo_id: int):
repo = Repository.find_by_id(request.ctx.session, repo_id)
# Read everything from the database before the response starts; the request's session closes then.
directory = lib.get_repo_root(repo)
name = lib.repo_archive_name(repo)
response = await request.respond(content_type='application/zip', headers={
'Content-Disposition': f'attachment; filename="{name}.zip"',
})
await lib.stream_repo_archive(directory, name, response.send)
await response.eof()

104
modules/repos/conftest.py Normal file
View file

@ -0,0 +1,104 @@
import os
import pathlib
import subprocess
from typing import Callable
import pytest
from mock import mock
from modules.repos import lib
from modules.repos.downloader import GitDownloader
from wrolpi.downloader import DownloadManager
# Commits made by the tests have a fixed identity and do not read the developer's git config.
TEST_GIT_ENV = dict(
GIT_AUTHOR_NAME='WROLPi Test', GIT_AUTHOR_EMAIL='test@example.com',
GIT_COMMITTER_NAME='WROLPi Test', GIT_COMMITTER_EMAIL='test@example.com',
GIT_CONFIG_NOSYSTEM='1', GIT_CONFIG_GLOBAL=os.devnull,
)
class GitRemote:
"""A local repo which the Repos module sees as `https://git.example.com/<owner>/<name>`."""
def __init__(self, path: pathlib.Path, url: str):
self.path = path
self.url = url
def git(self, *args: str) -> str:
env = dict(os.environ, **TEST_GIT_ENV)
result = subprocess.run(['git', *args], cwd=self.path, env=env, check=True, capture_output=True)
return result.stdout.decode().strip()
def commit(self, files: dict, message: str = 'Commit') -> str:
"""Write `files` ({relative path: contents}), commit them, return the commit's sha."""
for name, contents in files.items():
file = self.path / name
file.parent.mkdir(parents=True, exist_ok=True)
file.write_text(contents)
self.git('add', '--all')
self.git('commit', '--quiet', '-m', message)
return self.git('rev-parse', 'HEAD')
@property
def head(self) -> str:
return self.git('rev-parse', 'HEAD')
def add_submodule(self, path: str, submodule: 'GitRemote', url: str = None) -> str:
"""Add `submodule` (at its current commit) at `path`, and commit it. `url` overrides the submodule's URL."""
gitmodules = self.path / '.gitmodules'
existing = gitmodules.read_text() if gitmodules.exists() else ''
gitmodules.write_text(f'{existing}[submodule "{path}"]\n\tpath = {path}\n\turl = {url or submodule.url}\n')
self.git('update-index', '--add', '--cacheinfo', f'160000,{submodule.head},{path}')
self.git('add', '.gitmodules')
self.git('commit', '--quiet', '-m', f'Add submodule {path}')
return self.head
@pytest.fixture
def git_remote_factory(tmp_path_factory) -> Callable[..., GitRemote]:
"""Create local git repos which are cloned over `https://git.example.com/...` URLs.
git's `url.<base>.insteadOf` rewrites the https URL to the local path, and `file://` is allowed for the test,
so the Repos module runs the same commands it runs in production."""
remotes = dict()
# Outside the media directory (which is `tmp_path`).
remotes_directory = tmp_path_factory.mktemp('remotes')
def git_env():
env = original_git_env()
env['GIT_CONFIG_COUNT'] = str(len(remotes))
for idx, (url, path) in enumerate(remotes.items()):
env[f'GIT_CONFIG_KEY_{idx}'] = f'url.file://{path}.insteadOf'
env[f'GIT_CONFIG_VALUE_{idx}'] = url
return env
original_git_env = lib.git_env
def factory(name: str = 'example', owner: str = 'owner', branch: str = 'main',
files: dict = None) -> GitRemote:
path = remotes_directory / owner / name
path.mkdir(parents=True)
url = f'https://git.example.com/{owner}/{name}'
remote = GitRemote(path, url)
remote.git('init', '--quiet', f'--initial-branch={branch}')
remote.commit(files or {'README.md': f'# {name}\n\nThe {name} repo.\n'}, 'Initial commit')
remotes[url] = path
return remote
def resolve_host_addresses(host):
# The remotes' host has a fixed public address; no test uses real DNS.
if host == 'git.example.com':
return ['93.184.215.14']
raise OSError(f'Unknown host {host}')
with mock.patch('modules.repos.lib.ALLOWED_PROTOCOLS', ('https', 'file')), \
mock.patch('modules.repos.downloader.git_env', git_env), \
mock.patch('modules.repos.lib.resolve_host_addresses', resolve_host_addresses):
yield factory
@pytest.fixture
def repos_download_manager(test_download_manager) -> DownloadManager:
test_download_manager.register_downloader(GitDownloader())
yield test_download_manager

338
modules/repos/downloader.py Normal file
View file

@ -0,0 +1,338 @@
import asyncio
import pathlib
import re
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
from sqlalchemy.orm import Session
from wrolpi.common import logger
from wrolpi.dates import now
from wrolpi.downloader import Downloader, Download, DownloadContext, DownloadResult, make_progress_callback
from wrolpi.errors import DownloadError
from .lib import git_command, git_env, find_readme, get_repo_size, is_valid_branch, read_readme_text, \
submodule_fetch_config, resolve_repo_addresses, pin_config, HostAddresses, clone_ownership_error, mark_clone, \
sanitize_git_config, new_clone_token, working_tree_git_error, repo_directory_error, get_repository_by_url
from .models import Repository
__all__ = ['GitDownloader', 'git_downloader', 'PreparedRepo', 'ExecutedRepo', 'parse_git_progress']
logger = logger.getChild(__name__)
# e.g. "Receiving objects: 45% (123/456), 1.20 MiB | 1.00 MiB/s"
GIT_PROGRESS_RE = re.compile(r'^(?:remote: )?(?P<stage>[A-Za-z ]+):\s+(?P<percent>\d+)%')
# Submodules of submodules are checked out this many levels deep.
MAXIMUM_SUBMODULE_DEPTH = 5
# Full mode keeps every branch of the remote as `origin/<branch>`.
FULL_REFSPEC = '+refs/heads/*:refs/remotes/origin/*'
def parse_git_progress(line: str) -> Optional[dict]:
if match := GIT_PROGRESS_RE.match(line):
return dict(stage=match.group('stage').strip(), percent=int(match.group('percent')))
return None
@dataclass
class PreparedRepo:
repository_id: int
url: str
directory: pathlib.Path
mode: str
branch: Optional[str]
submodules: bool = False
clone_token: Optional[str] = None
# The repo's host, resolved once by execute_download; every fetch from it connects to these addresses.
addresses: Optional[HostAddresses] = None
@dataclass
class ExecutedRepo:
repository_id: int
default_branch: Optional[str]
head_sha: str
head_date: datetime
head_message: str
size: int
readme_path: Optional[str]
readme_text: Optional[str]
# The repo was updated, but its submodules were not.
submodules_error: Optional[str] = None
class GitCancelled(Exception):
"""A git command was cancelled; carries the cancel_wrapper's DownloadResult."""
def __init__(self, result: DownloadResult):
super().__init__(result.error)
self.result = result
class GitDownloader(Downloader):
"""Clones a git repo into its Repo's directory, or updates the existing clone.
Repos are mirrors: local changes are discarded by every update. Upstream disappearing never deletes the
local copy; the download is deferred with git's error."""
name = 'git'
pretty_name = 'Git'
# Repos are added on the Repos page, not the generic download form.
listable = False
def prepare_download(self, session: Session, download: Download) -> PreparedRepo:
if download.collection_id:
repo = Repository.get_by_collection_id(session, download.collection_id)
else:
# The one-time Download of a Repo which is never updated.
repo = get_repository_by_url(session, download.url)
if not repo:
raise DownloadError(f'No Repo for this download, add it on the Repos page: {download.url}')
directory = repo.collection.get_or_set_directory(session)
if not repo.clone_token:
repo.clone_token = new_clone_token()
return PreparedRepo(repository_id=repo.id, url=repo.url, directory=directory, mode=repo.mode,
branch=repo.branch, submodules=repo.submodules, clone_token=repo.clone_token)
async def _git(self, download: Download, ctx: DownloadContext, cwd: pathlib.Path, *args,
config: tuple = (), progress: bool = False, check: bool = True,
prepared: PreparedRepo = None) -> str:
"""Run a git command, return its output (stdout and stderr).
Pass `prepared` for a command which connects to the repo's host, so it connects to the resolved addresses."""
if prepared and prepared.addresses and (pin := pin_config(prepared.addresses)):
config = (pin, *config)
cmd = git_command(*args, config=config)
stdout_callback = make_progress_callback(ctx.report_progress, parse_git_progress) if progress else None
result = await self.process_runner(download, cmd, cwd, env=git_env(), merge_stderr=True,
stdout_callback=stdout_callback, start_new_session=True, ctx=ctx)
if isinstance(result, DownloadResult):
raise GitCancelled(result)
output = result.stdout.decode(errors='replace').strip()
if check and result.return_code != 0:
raise DownloadError(f'git {args[0]} failed ({result.return_code}): {output}')
return output
async def _default_branch(self, download: Download, ctx: DownloadContext, prepared: PreparedRepo) -> str:
"""Ask the remote for its default branch. This fails quickly when the upstream is gone."""
output = await self._git(download, ctx, prepared.directory.parent, 'ls-remote', '--symref', prepared.url,
'HEAD', prepared=prepared)
for line in output.splitlines():
if line.startswith('ref: refs/heads/') and line.endswith('\tHEAD'):
return line[len('ref: refs/heads/'):-len('\tHEAD')]
raise DownloadError(f'Repo has no default branch (is it empty?): {prepared.url}')
async def _clone(self, download: Download, ctx: DownloadContext, prepared: PreparedRepo, branch: str):
directory = prepared.directory
if directory.is_dir() and any(directory.iterdir()):
raise DownloadError(f'Refusing to clone into a directory which is not empty: {directory}')
directory.mkdir(parents=True, exist_ok=True)
args = ['clone', '--progress', '--branch', branch]
if prepared.mode == 'snapshot':
args.extend(['--depth', '1', '--single-branch', '--no-tags'])
args.extend(['--', prepared.url, str(directory)])
await self._git(download, ctx, directory.parent, *args, progress=True, prepared=prepared)
mark_clone(directory, prepared.clone_token)
async def _update(self, download: Download, ctx: DownloadContext, prepared: PreparedRepo, branch: str):
directory = prepared.directory
git = lambda *args, **kwargs: self._git(download, ctx, directory, *args, prepared=prepared, # noqa: E731
**kwargs)
# Only a clone WROLPi made (or imported) for this Repo is reset and cleaned; its config cannot change what
# git runs or where it connects, and it is fetched from the Repo's URL, whatever its origin says.
if error := clone_ownership_error(directory, prepared.clone_token):
raise DownloadError(error)
try:
await asyncio.to_thread(sanitize_git_config, directory)
except ValueError as e:
raise DownloadError(str(e))
await git('config', '--replace-all', 'remote.origin.url', prepared.url)
refspec = FULL_REFSPEC if prepared.mode == 'full' else f'+refs/heads/{branch}:refs/remotes/origin/{branch}'
await git('config', '--replace-all', 'remote.origin.fetch', refspec)
old_head = await git('rev-parse', '--verify', '--quiet', 'HEAD', check=False)
if prepared.mode == 'snapshot':
await git('fetch', '--progress', '--depth', '1', '--no-tags', 'origin', branch, progress=True)
target = 'FETCH_HEAD'
else:
# Fetch every branch and tag. Branches deleted upstream are kept (no --prune).
args = ['fetch', '--progress', '--tags', '--force', 'origin']
if (directory / '.git/shallow').exists():
# This was a snapshot; get the whole history.
args.insert(1, '--unshallow')
await git(*args, progress=True)
target = f'refs/remotes/origin/{branch}'
new_head = await git('rev-parse', '--verify', target)
if prepared.mode == 'full' and old_head and old_head != new_head:
if not await self._is_ancestor(download, ctx, directory, old_head, new_head):
# Upstream rewrote history (force push). Keep what we had.
backup = f'refs/wrolpi/backup/{now().strftime("%Y%m%d%H%M%S")}'
await git('update-ref', backup, old_head)
logger.warning(f'Repo {prepared.url} history was rewritten; saved old HEAD as {backup}')
await git('checkout', '--force', '-B', branch, new_head)
await git('clean', '-ffdx')
if prepared.mode == 'snapshot':
# Shallow fetches leave the previous commits behind; drop them.
await git('gc', '--prune=now', '--quiet',
config=('gc.reflogExpire=now', 'gc.reflogExpireUnreachable=now'))
async def _update_submodules(self, download: Download, ctx: DownloadContext, prepared: PreparedRepo) \
-> Optional[str]:
"""Check out (or remove) the submodules of the checked out commit. Returns an error, if any.
Submodules are fetched with the same restrictions as the repo (`git_command` options are inherited by the
git commands that git runs)."""
directory = prepared.directory
git = lambda *args, **kwargs: self._git(download, ctx, directory, *args, **kwargs) # noqa: E731
# Every submodule command uses the git directory a submodule's `.git` names; it must be one that was checked.
if (error := await asyncio.to_thread(working_tree_git_error, directory)) and (
prepared.submodules or (directory / '.git/modules').is_dir()):
return error
if not prepared.submodules:
if (directory / '.git/modules').is_dir():
# Submodules were turned off; the repo is a mirror of only its own files now.
await git('submodule', 'deinit', '--all', '--force', check=False)
return None
if not (directory / '.gitmodules').is_file():
return None
try:
await self._check_out_submodules(download, ctx, directory, prepared)
except DownloadError as e:
logger.warning(f'Failed to update submodules of {prepared.url}', exc_info=e)
return str(e)
return None
async def _check_out_submodules(self, download: Download, ctx: DownloadContext, directory: pathlib.Path,
prepared: PreparedRepo, depth: int = 0):
"""Check out the submodules of the repo in `directory`, then theirs, one level at a time.
Each level's URLs are checked before git fetches them (git's `--recursive` would fetch nested submodules
unchecked)."""
if not (directory / '.gitmodules').is_file():
return
if depth >= MAXIMUM_SUBMODULE_DEPTH:
raise DownloadError(f'Submodules are nested more than {MAXIMUM_SUBMODULE_DEPTH} levels deep')
git = lambda *args, **kwargs: self._git(download, ctx, directory, *args, prepared=prepared, # noqa: E731
**kwargs)
paths = await self._git_config_values(download, ctx, directory, '.path', '--file', '.gitmodules')
# git resolves each submodule's URL (a relative URL is resolved against this repo's remote, and may be
# another host) into this repo's config. Those are the URLs git fetches, so those are checked.
await git('submodule', 'init')
await git('submodule', 'sync')
urls = await self._git_config_values(download, ctx, directory, '.url', '--local')
try:
fetch_config = await asyncio.to_thread(submodule_fetch_config, urls, prepared.addresses)
except ValueError as e:
raise DownloadError(str(e))
args = ['submodule', 'update', '--force', '--progress']
if prepared.mode == 'snapshot':
args.extend(['--depth', '1'])
await git(*args, progress=True, config=fetch_config)
for path in paths:
submodule = (directory / path).resolve()
if submodule.is_dir() and directory.resolve() in submodule.parents:
await self._check_out_submodules(download, ctx, submodule, prepared, depth + 1)
async def _git_config_values(self, download: Download, ctx: DownloadContext, directory: pathlib.Path,
suffix: str, *source: str) -> list:
"""The values of the `submodule.<name><suffix>` keys in a git config. Refuses anything it cannot read."""
# NUL-delimited: a submodule's name and URL can contain spaces and newlines. Each record is "key\nvalue".
output = await self._git(download, ctx, directory, 'config', *source, '-z', '--get-regexp',
rf'^submodule\..*\{suffix}$', check=False)
values = []
for record in output.split('\0'):
if not record:
continue
key, newline, value = record.partition('\n')
if not newline or not key.startswith('submodule.') or not key.endswith(suffix):
raise DownloadError(f'Refusing to download submodules, cannot read their config: {record!r}')
values.append(value)
return values
async def _is_ancestor(self, download: Download, ctx: DownloadContext, directory: pathlib.Path,
ancestor: str, descendant: str) -> bool:
cmd = git_command('merge-base', '--is-ancestor', ancestor, descendant)
result = await self.process_runner(download, cmd, directory, env=git_env(), merge_stderr=True, ctx=ctx)
if isinstance(result, DownloadResult):
raise GitCancelled(result)
return result.return_code == 0
async def execute_download(self, prepared: PreparedRepo, ctx: DownloadContext,
download: Download = None) -> ExecutedRepo | DownloadResult:
download = download if download is not None else Download(url=prepared.url)
directory = prepared.directory
if error := repo_directory_error(directory):
raise DownloadError(error)
directory.parent.mkdir(parents=True, exist_ok=True)
# Resolved once; the repo and its same-host submodules are all fetched from these addresses, so a DNS
# answer cannot change in between.
prepared.addresses = await asyncio.to_thread(resolve_repo_addresses, prepared.url)
try:
default_branch = await self._default_branch(download, ctx, prepared)
branch = prepared.branch or default_branch
# The remote chooses its default branch's name, and branches are passed to git as arguments.
if not is_valid_branch(branch):
raise DownloadError(f'Invalid branch name: {branch!r}')
if (directory / '.git').exists() or (directory / '.git').is_symlink():
await self._update(download, ctx, prepared, branch)
else:
await self._clone(download, ctx, prepared, branch)
submodules_error = await self._update_submodules(download, ctx, prepared)
head = await self._git(download, ctx, directory, 'log', '-1', '--format=%H%x00%cI%x00%s')
except GitCancelled as e:
return e.result
finally:
ctx.clear_progress()
head_sha, head_date, head_message = head.split('\x00', 2)
size = await asyncio.to_thread(get_repo_size, directory)
readme_path = find_readme(directory)
readme_text = await asyncio.to_thread(read_readme_text, directory / readme_path) if readme_path else None
return ExecutedRepo(
repository_id=prepared.repository_id,
default_branch=default_branch,
head_sha=head_sha,
head_date=datetime.fromisoformat(head_date),
head_message=head_message,
size=size,
readme_path=readme_path,
readme_text=readme_text,
submodules_error=submodules_error,
)
def finalize_download(self, session: Session, download: Download, executed: ExecutedRepo) -> DownloadResult:
repo = session.query(Repository).filter_by(id=executed.repository_id).one_or_none()
if not repo:
return DownloadResult(success=False, error='Repo was deleted while downloading')
repo.default_branch = executed.default_branch
repo.head_sha = executed.head_sha
repo.head_date = executed.head_date
repo.head_message = executed.head_message
repo.size = executed.size
repo.readme_path = executed.readme_path
if repo.readme_text != executed.readme_text:
# Only write a changed README; every write re-indexes it.
repo.readme_text = executed.readme_text
repo.last_fetch = now()
logger.info(f'Updated repo {repo.url} to {executed.head_sha}')
if executed.submodules_error:
# The repo is current, but the download is retried so its submodules are too.
return DownloadResult(success=False, location=repo.location,
error='The repo was updated, but its submodules were not: '
f'{executed.submodules_error}')
return DownloadResult(success=True, location=repo.location)
git_downloader = GitDownloader()

21
modules/repos/errors.py Normal file
View file

@ -0,0 +1,21 @@
from http import HTTPStatus
from wrolpi.errors import APIError
class UnknownRepo(APIError):
code = 'UNKNOWN_REPO'
summary = 'Unable to find the repo'
status_code = HTTPStatus.NOT_FOUND
class InvalidRepo(APIError):
code = 'INVALID_REPO'
summary = 'The repo is invalid'
status_code = HTTPStatus.BAD_REQUEST
class RepoConflict(APIError):
code = 'REPO_CONFLICT'
summary = 'The repo conflicts with an existing repo'
status_code = HTTPStatus.CONFLICT

1560
modules/repos/lib.py Normal file

File diff suppressed because it is too large Load diff

146
modules/repos/models.py Normal file
View file

@ -0,0 +1,146 @@
import pathlib
from typing import Optional
from sqlalchemy import Column, Integer, String, ForeignKey, BigInteger, Text, Boolean, false
from sqlalchemy.orm import relationship, Session, joinedload, selectinload, deferred
from wrolpi.collections.models import Collection
from wrolpi.common import Base, ModelHelper, get_relative_to_media_directory
from wrolpi.dates import TZDateTime
from wrolpi.db import serializer
from wrolpi.downloader import Download
from .errors import UnknownRepo
__all__ = ['Repository', 'REPO_MODES']
# full: every branch and tag, with all history. snapshot: only the latest commit of one branch.
REPO_MODES = ('full', 'snapshot')
class Repository(ModelHelper, Base):
"""A git repository mirrored into a `kind='repo'` Collection's directory.
The name, directory and Tag live on the Collection; the Collection's recurring Download keeps the mirror
up to date."""
__tablename__ = 'repository'
id = Column(Integer, primary_key=True)
# The https clone URL; unique (compared without a trailing `.git`, see `repo_url_key`).
url = Column(String, nullable=False, unique=True)
host = Column(String)
owner = Column(String)
mode = Column(String, nullable=False, default='full')
# The branch to check out; None follows the remote's default branch.
branch = Column(String)
# Random; written in the clone's .git (see lib.mark_clone). Only a clone holding it is updated, so a clone which
# WROLPi did not make for this Repo is never reset or cleaned.
clone_token = Column(String)
# Also download the repo's submodules.
submodules = Column(Boolean, nullable=False, default=False, server_default=false())
default_branch = Column(String)
head_sha = Column(String)
head_date = Column(TZDateTime)
head_message = Column(Text)
last_fetch = Column(TZDateTime)
size = Column(BigInteger)
# The README in the repo's root directory, relative to the repo.
readme_path = Column(String)
# Searched by `repository_fts` (see wrolpi.fts); kept current by `update_search_name` and the downloader.
search_name = Column(String)
readme_text = deferred(Column(Text))
collection_id = Column(Integer, ForeignKey('collection.id', ondelete='CASCADE'), nullable=False, unique=True)
# Joined: a Repository's name, directory and Tag live on its Collection.
collection = relationship('Collection', foreign_keys=[collection_id], lazy='joined')
# The Download of this Repo's URL: its recurring Download, or the one-time Download of a Repo which is never
# updated (which cannot belong to its Collection).
url_download = relationship('Download', primaryjoin='Repository.url == foreign(Download.url)', viewonly=True,
uselist=False)
def __repr__(self):
return f'<Repository id={self.id} url={self.url} directory={self.directory}>'
@property
def name(self) -> Optional[str]:
return self.collection.name if self.collection else None
@property
def directory(self) -> Optional[pathlib.Path]:
return self.collection.directory if self.collection else None
@property
def tag_name(self) -> Optional[str]:
return self.collection.tag_name if self.collection else None
@property
def download(self) -> Optional[Download]:
"""The recurring Download which keeps this repo up to date."""
downloads = [i for i in self.collection.downloads if i.frequency] if self.collection else []
return downloads[0] if downloads else None
@property
def once_download(self) -> Optional[Download]:
"""The one-time Download which clones (or updates, on request) a Repo which is never updated."""
download = self.url_download
return download if download and not download.frequency else None
def update_search_name(self):
"""The Repo is found by its name and its owner."""
self.search_name = ' '.join(i for i in (self.name, self.owner) if i)
@property
def location(self) -> str:
return f'/repos/{self.id}'
@staticmethod
def json_options() -> tuple:
"""Loader options for a query whose Repositories will be serialized."""
return (
joinedload(Repository.collection).joinedload(Collection.tag),
joinedload(Repository.collection).selectinload(Collection.downloads).defer(Download.info_json),
selectinload(Repository.url_download).defer(Download.info_json),
)
@serializer
def __json__(self) -> dict:
download = self.download
# The status of the last update; a Repo which is never updated is only updated on request.
latest = download or self.once_download
return {
'id': self.id,
'collection_id': self.collection_id,
'name': self.name,
'description': self.collection.description if self.collection else None,
'directory': str(get_relative_to_media_directory(self.directory)) if self.directory else None,
'tag_name': self.tag_name,
'url': self.url,
'host': self.host,
'owner': self.owner,
'mode': self.mode,
'branch': self.branch,
'submodules': self.submodules,
'default_branch': self.default_branch,
'head_sha': self.head_sha,
'head_date': self.head_date,
'head_message': self.head_message,
'last_fetch': self.last_fetch,
'size': self.size,
'readme_path': self.readme_path,
'location': self.location,
# 0: never updated.
'frequency': download.frequency if download else 0,
'download_id': latest.id if latest else None,
'download_status': latest.status if latest else None,
'download_error': latest.error if latest else None,
'next_download': download.next_download if download else None,
}
@staticmethod
def find_by_id(session: Session, id_: int, options: tuple = ()) -> 'Repository':
repo = session.query(Repository).options(*options).filter_by(id=id_).one_or_none()
if not repo:
raise UnknownRepo(f'Cannot find repo with id {id_}')
return repo
@staticmethod
def get_by_collection_id(session: Session, collection_id: int) -> Optional['Repository']:
return session.query(Repository).filter_by(collection_id=collection_id).one_or_none()

55
modules/repos/schema.py Normal file
View file

@ -0,0 +1,55 @@
from dataclasses import dataclass, field
from typing import Optional, List
@dataclass
class RepoCreateRequest:
url: str
# Relative to the media directory; empty clones it into the Repos directory.
directory: Optional[str] = None
tag_name: Optional[str] = None
frequency: Optional[int] = None
mode: Optional[str] = None
branch: Optional[str] = None
name: Optional[str] = None
description: Optional[str] = None
submodules: bool = False
@dataclass
class RepoSearchRequest:
search_str: Optional[str] = None
tag_names: List[str] = field(default_factory=list)
limit: int = 20
offset: int = 0
@dataclass
class RepoUpdateRequest:
description: Optional[str] = None
frequency: Optional[int] = None
mode: Optional[str] = None
branch: Optional[str] = None
submodules: Optional[bool] = None
@dataclass
class RepoImportInspectRequest:
directory: str
name: Optional[str] = None
tag_name: Optional[str] = None
url: Optional[str] = None
@dataclass
class RepoImportRequest:
directory: str
confirm: bool = False
url: Optional[str] = None
tag_name: Optional[str] = None
frequency: Optional[int] = None
mode: Optional[str] = None
branch: Optional[str] = None
submodules: bool = False
name: Optional[str] = None
description: Optional[str] = None

View file

View file

@ -0,0 +1,235 @@
import json
from http import HTTPStatus
import pytest
from modules.repos import lib
from modules.repos.models import Repository
from wrolpi.conftest import await_file_worker
from wrolpi.downloader import Download, DownloadFrequency, DownloadStatus
from wrolpi.files.models import FileGroup
@pytest.mark.asyncio
async def test_repos_crud(test_session, test_directory, async_client, test_wrolpi_config):
body = dict(url='https://github.com/kiwix/kiwix-tools', tag_name='software', frequency=DownloadFrequency.daily)
request, response = await async_client.post('/api/repos', content=json.dumps(body))
assert response.status_code == HTTPStatus.CREATED, response.json
repo = response.json['repo']
assert repo['name'] == 'kiwix-tools'
assert repo['tag_name'] == 'software'
assert repo['directory'] == 'repos/software/kiwix-tools'
assert repo['mode'] == 'full'
assert repo['frequency'] == DownloadFrequency.daily
assert repo['download_status'] == DownloadStatus.new
assert repo['location'] == f'/repos/{repo["id"]}'
repo_id = repo['id']
request, response = await async_client.post('/api/repos', content=json.dumps(dict(url='https://github.com/a/b')))
assert response.status_code == HTTPStatus.CREATED
request, response = await async_client.get('/api/repos')
assert response.status_code == HTTPStatus.OK
assert [i['name'] for i in response.json['repos']] == ['b', 'kiwix-tools']
body = dict(description='Kiwix tools', mode='snapshot', branch='dev', frequency=DownloadFrequency.weekly)
request, response = await async_client.put(f'/api/repos/{repo_id}', content=json.dumps(body))
assert response.status_code == HTTPStatus.OK, response.json
request, response = await async_client.get(f'/api/repos/{repo_id}')
repo = response.json['repo']
assert (repo['description'], repo['mode'], repo['branch'], repo['frequency']) == \
('Kiwix tools', 'snapshot', 'dev', DownloadFrequency.weekly)
request, response = await async_client.delete(f'/api/repos/{repo_id}')
assert response.status_code == HTTPStatus.NO_CONTENT
request, response = await async_client.get(f'/api/repos/{repo_id}')
assert response.status_code == HTTPStatus.NOT_FOUND
assert response.json['code'] == 'UNKNOWN_REPO'
@pytest.mark.asyncio
async def test_create_repo_errors(test_session, async_client, test_wrolpi_config):
request, response = await async_client.post('/api/repos', content=json.dumps(dict(url='git@github.com:a/b')))
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response.json['code'] == 'INVALID_REPO'
request, response = await async_client.post('/api/repos', content=json.dumps(dict(url='https://github.com/a/b')))
assert response.status_code == HTTPStatus.CREATED
request, response = await async_client.post('/api/repos',
content=json.dumps(dict(url='https://github.com/a/b.git')))
assert response.status_code == HTTPStatus.CONFLICT
assert response.json['code'] == 'REPO_CONFLICT'
@pytest.mark.asyncio
async def test_repo_tree_and_update(test_session, async_client, git_remote_factory, repos_download_manager):
remote = git_remote_factory(files={'README.md': '# Example', 'docs/guide.md': 'guide'})
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}/tree')
assert response.status_code == HTTPStatus.OK
assert response.json['readme_path'] == 'README.md'
assert [i['path'] for i in response.json['entries']] == ['docs', 'README.md']
request, response = await async_client.get(f'/api/repos/{repo.id}/tree?path=docs')
assert [i['path'] for i in response.json['entries']] == ['docs/guide.md']
request, response = await async_client.get(f'/api/repos/{repo.id}/tree?path=../..')
assert response.status_code == HTTPStatus.BAD_REQUEST
# "Update now" renews the download.
new_head = remote.commit({'new.txt': 'new'}, 'New')
request, response = await async_client.post(f'/api/repos/{repo.id}/update')
assert response.status_code == HTTPStatus.NO_CONTENT
await repos_download_manager.wait_for_all_downloads()
test_session.expire_all()
assert test_session.query(Repository).one().head_sha == new_head
@pytest.mark.asyncio
async def test_tag_repo_moves_clone(test_session, test_directory, async_client, git_remote_factory,
repos_download_manager, await_background_tasks):
"""Tagging a Repo (through the collections API) moves the whole clone, including `.git`. Its files are not
indexed (repos/ is ignored), even ones the file mover could not group."""
remote = git_remote_factory(files={'README.md': '# Example', 'web/.npmrc': 'a', 'web/.gitignore': 'b'})
repo = lib.create_repository(test_session, remote.url)
await repos_download_manager.wait_for_all_downloads()
old_directory = test_directory / 'repos/example'
assert (old_directory / '.git').is_dir()
body = dict(tag_name='software', directory='repos/software/example')
request, response = await async_client.post(f'/api/collections/{repo.collection_id}/tag',
content=json.dumps(body))
assert response.status_code == HTTPStatus.OK, response.json
await await_background_tasks()
test_session.expire_all()
new_directory = test_directory / 'repos/software/example'
assert repo.directory == new_directory
assert repo.tag_name == 'software'
assert (new_directory / '.git').is_dir()
assert (new_directory / 'README.md').is_file()
assert (new_directory / 'web/.npmrc').is_file()
assert not old_directory.exists()
assert test_session.query(Download).one().destination == new_directory
assert test_session.query(FileGroup).count() == 0
# The moved clone still updates.
new_head = remote.commit({'new.txt': 'new'}, 'New')
download = test_session.query(Download).one()
download.renew()
test_session.commit()
await repos_download_manager.wait_for_all_downloads()
test_session.expire_all()
assert repo.head_sha == new_head
assert (new_directory / 'new.txt').is_file()
@pytest.mark.asyncio
async def test_tag_repo_in_custom_directory(test_session, test_directory, async_client, git_remote_factory,
repos_download_manager, await_background_tasks, refresh_files):
"""A Repo in a directory which is not ignored is indexed where it is moved to, and not where it was."""
remote = git_remote_factory(files={'README.md': '# Example', 'guide.md': 'A guide'})
repo = lib.create_repository(test_session, remote.url, directory='projects/example')
await repos_download_manager.wait_for_all_downloads()
await refresh_files()
old_directory = test_directory / 'projects/example'
assert test_session.query(FileGroup).filter(FileGroup.primary_path == str(old_directory / 'guide.md')).count()
body = dict(tag_name='software', directory='projects/moved')
request, response = await async_client.post(f'/api/collections/{repo.collection_id}/tag',
content=json.dumps(body))
assert response.status_code == HTTPStatus.OK, response.json
await await_background_tasks()
await await_file_worker()
test_session.expire_all()
new_directory = test_directory / 'projects/moved'
assert repo.directory == new_directory
assert (new_directory / '.git').is_dir()
assert not old_directory.exists()
paths = {i.primary_path for i in test_session.query(FileGroup)}
assert new_directory / 'guide.md' in paths
assert not [i for i in paths if old_directory in i.parents]
@pytest.mark.parametrize('directory', ['../escape/example', 'repos/../../escape/example', '{media}/../escape/example',
'linked/example'])
@pytest.mark.asyncio
async def test_tag_repo_cannot_leave_media_directory(test_session, test_directory, async_client, git_remote_factory,
repos_download_manager, await_background_tasks,
tmp_path_factory, directory):
"""Tagging cannot move a Repo's clone out of the media directory, by `..` or through a link."""
remote = git_remote_factory()
repo = lib.create_repository(test_session, remote.url)
await repos_download_manager.wait_for_all_downloads()
outside = tmp_path_factory.mktemp('outside')
(test_directory / 'linked').symlink_to(outside)
body = dict(tag_name='software', directory=directory.format(media=test_directory))
request, response = await async_client.post(f'/api/collections/{repo.collection_id}/tag',
content=json.dumps(body))
await await_background_tasks()
test_session.expire_all()
assert (test_directory / 'repos/example/.git').is_dir()
assert repo.directory == test_directory / 'repos/example'
assert not (test_directory.parent / 'escape').exists()
assert list(outside.iterdir()) == []
@pytest.mark.asyncio
async def test_repos_destination_setting(async_client, test_wrolpi_config):
request, response = await async_client.get('/api/settings')
assert response.json['repos_destination'] == 'repos/%(repo_tag)s/%(repo_name)s'
body = dict(repos_destination='/repos')
request, response = await async_client.patch('/api/settings', content=json.dumps(body))
assert response.status_code == HTTPStatus.BAD_REQUEST
body = dict(repos_destination='repos/%(repo_owner)s/%(repo_name)s')
request, response = await async_client.patch('/api/settings', content=json.dumps(body))
assert response.status_code == HTTPStatus.NO_CONTENT
request, response = await async_client.get('/api/settings')
assert response.json['repos_destination'] == 'repos/%(repo_owner)s/%(repo_name)s'
@pytest.mark.asyncio
async def test_repo_changes_wait_for_config_import(test_session, test_directory, async_client, git_remote_factory,
repos_download_manager, test_wrolpi_config):
"""Until repos.yaml is imported (e.g. while WROLPi starts), Repos cannot change; the import would undo it."""
remote = git_remote_factory()
other = git_remote_factory('other')
repo = lib.create_repository(test_session, remote.url)
await repos_download_manager.wait_for_all_downloads()
config = lib.get_repos_config()
config.dump_config()
assert config.get_file().is_file()
config.successful_import = False
requests = [
('post', '/api/repos', dict(url=other.url)),
('post', '/api/repos/import', dict(directory='repos/example', confirm=True)),
('put', f'/api/repos/{repo.id}', dict(description='changed')),
('delete', f'/api/repos/{repo.id}', None),
('post', f'/api/collections/{repo.collection_id}/tag', dict(tag_name='software',
directory='repos/software/example')),
('put', f'/api/collections/{repo.collection_id}', dict(description='changed')),
('delete', f'/api/collections/{repo.collection_id}', None),
]
for method, url, body in requests:
kwargs = dict(content=json.dumps(body)) if body else {}
request, response = await getattr(async_client, method)(url, **kwargs)
assert response.status_code == HTTPStatus.SERVICE_UNAVAILABLE, (url, response.json)
assert response.json['code'] == 'CONFIG_NOT_IMPORTED'
test_session.expire_all()
assert test_session.query(Repository).count() == 1
assert repo.collection.description is None
assert repo.tag_name is None
assert (test_directory / 'repos/example/.git').is_dir()
# Once imported, Repos can change again.
lib.import_repos_config()
request, response = await async_client.put(f'/api/repos/{repo.id}', content=json.dumps(dict(description='x')))
assert response.status_code == HTTPStatus.OK, response.json

View file

@ -0,0 +1,91 @@
import pytest
from modules.repos import lib
from modules.repos.models import Repository
from wrolpi.collections.models import Collection
from wrolpi.downloader import Download, DownloadFrequency
@pytest.mark.asyncio
async def test_dump_and_import(test_session, test_directory, async_client, test_wrolpi_config, await_switches):
"""repos.yaml is the source of truth; a lost database is rebuilt from it."""
lib.create_repository(test_session, 'https://github.com/kiwix/kiwix-tools', tag_name='software',
description='Tools', frequency=DownloadFrequency.daily)
lib.create_repository(test_session, 'https://github.com/a/b', mode='snapshot', branch='dev')
await await_switches()
config = lib.get_repos_config()
assert config.get_file().is_file()
# Each Repo's random clone token is kept, so a restored Repo still owns its clone.
dumped = [dict(i) for i in config.repos]
assert all(len(i.pop('clone_token')) == 32 for i in dumped)
assert dumped == [
dict(name='b', url='https://github.com/a/b', directory='repos/b', tag_name=None, description=None,
mode='snapshot', branch='dev', submodules=False, frequency=DownloadFrequency.weekly),
dict(name='kiwix-tools', url='https://github.com/kiwix/kiwix-tools', directory='repos/software/kiwix-tools',
tag_name='software', description='Tools', mode='full', branch=None, submodules=False,
frequency=DownloadFrequency.daily),
]
# Lose the database.
for repo in test_session.query(Repository).all():
lib._delete_repository(test_session, repo)
test_session.commit()
assert test_session.query(Collection).count() == 0
config.import_config()
test_session.expire_all()
repos = {i.name: i for i in test_session.query(Repository)}
assert repos['kiwix-tools'].directory == test_directory / 'repos/software/kiwix-tools'
assert repos['kiwix-tools'].tag_name == 'software'
assert repos['kiwix-tools'].collection.description == 'Tools'
assert repos['kiwix-tools'].download.frequency == DownloadFrequency.daily
assert (repos['b'].mode, repos['b'].branch) == ('snapshot', 'dev')
assert {i.downloader for i in test_session.query(Download)} == {'git'}
# A repo removed from the config is deleted.
data = config.read_config_file()
data['repos'] = data['repos'][1:]
data['version'] += 1
config.write_config_data(data, config.get_file())
config.import_config()
test_session.expire_all()
assert [i.name for i in test_session.query(Repository)] == ['kiwix-tools']
assert test_session.query(Download).count() == 1
@pytest.mark.asyncio
async def test_import_links_existing_download(test_session, async_client, test_wrolpi_config):
"""A Repo imported after download_manager.yaml re-uses its Download."""
lib.create_repository(test_session, 'https://github.com/a/b')
test_session.query(Repository).delete()
test_session.query(Collection).delete()
test_session.commit()
download = test_session.query(Download).one()
assert download.collection_id is None
config = lib.get_repos_config()
config.write_config_data(dict(version=1, repos=[dict(name='b', url='https://github.com/a/b', frequency=3600)]),
config.get_file())
config.import_config()
test_session.expire_all()
repo = test_session.query(Repository).one()
assert test_session.query(Download).one().id == download.id
assert download.collection_id == repo.collection_id
assert download.frequency == 3600
@pytest.mark.asyncio
async def test_delete_last_repo(test_session, async_client, test_wrolpi_config, await_switches):
"""Deleting the last Repo empties the config, so it does not return on the next import."""
repo = lib.create_repository(test_session, 'https://github.com/a/b')
await await_switches()
config = lib.get_repos_config()
assert len(config.read_config_file()['repos']) == 1
request, response = await async_client.delete(f'/api/repos/{repo.id}')
assert config.read_config_file()['repos'] == []
config.import_config()
assert test_session.query(Repository).count() == 0

View file

@ -0,0 +1,226 @@
import shutil
import subprocess
import pytest
from mock import mock
from modules.repos import lib
from modules.repos.downloader import parse_git_progress
from modules.repos.models import Repository
from wrolpi.downloader import Download, DownloadStatus
def git(directory, *args) -> str:
return subprocess.run(['git', '-c', 'safe.directory=*', *args], cwd=directory, check=True,
capture_output=True).stdout.decode().strip()
@pytest.mark.asyncio
async def test_clone_full(test_session, test_directory, git_remote_factory, repos_download_manager):
"""A new Repo is cloned with its whole history, and the Repo describes the checked out commit."""
remote = git_remote_factory('kiwix-tools', owner='kiwix', files={'README.md': '# Kiwix\n', 'src/main.c': 'int'})
remote.commit({'CHANGELOG': 'v2'}, 'Second commit')
remote.git('tag', 'v2')
repo = lib.create_repository(test_session, remote.url, tag_name='software')
await repos_download_manager.wait_for_all_downloads()
test_session.expire_all()
directory = test_directory / 'repos/software/kiwix-tools'
assert repo.directory == directory
assert (directory / 'README.md').read_text() == '# Kiwix\n'
assert (directory / 'src/main.c').is_file()
# Full mode has all the history and tags.
assert git(directory, 'rev-list', '--count', 'HEAD') == '2'
assert git(directory, 'tag') == 'v2'
assert not (directory / '.git/shallow').exists()
assert repo.head_sha == remote.head
assert repo.head_message == 'Second commit'
assert repo.head_date is not None
assert repo.default_branch == 'main'
assert repo.readme_path == 'README.md'
assert repo.size > 0
assert repo.last_fetch is not None
download, = test_session.query(Download).all()
assert download.status == DownloadStatus.complete
assert download.location == f'/repos/{repo.id}'
assert download.error is None
@pytest.mark.asyncio
async def test_update(test_session, test_directory, git_remote_factory, repos_download_manager):
"""An update fetches new commits, and discards local changes (a Repo is a mirror)."""
remote = git_remote_factory()
repo = lib.create_repository(test_session, remote.url)
await repos_download_manager.wait_for_all_downloads()
directory = repo.directory
# Local changes.
(directory / 'README.md').write_text('local change')
(directory / 'untracked.txt').write_text('untracked')
new_head = remote.commit({'new.txt': 'new'}, 'New file')
download, = test_session.query(Download).all()
download.renew()
test_session.commit()
await repos_download_manager.wait_for_all_downloads()
test_session.expire_all()
assert (directory / 'new.txt').read_text() == 'new'
assert (directory / 'README.md').read_text() == '# example\n\nThe example repo.\n'
assert not (directory / 'untracked.txt').exists()
assert repo.head_sha == new_head
assert repo.head_message == 'New file'
@pytest.mark.asyncio
async def test_update_keeps_history(test_session, git_remote_factory, repos_download_manager):
"""Branches deleted upstream are kept, and history rewritten by a force push is saved in a backup ref."""
remote = git_remote_factory()
remote.git('branch', 'feature')
old_head = remote.commit({'a.txt': 'a'}, 'Will be rewritten')
repo = lib.create_repository(test_session, remote.url)
await repos_download_manager.wait_for_all_downloads()
directory = repo.directory
assert 'origin/feature' in git(directory, 'branch', '--remotes')
# Upstream deletes a branch, and rewrites main.
remote.git('branch', '--delete', 'feature')
remote.git('reset', '--hard', 'HEAD~1')
new_head = remote.commit({'b.txt': 'b'}, 'Rewritten')
download, = test_session.query(Download).all()
download.renew()
test_session.commit()
await repos_download_manager.wait_for_all_downloads()
test_session.expire_all()
assert repo.head_sha == new_head
assert 'origin/feature' in git(directory, 'branch', '--remotes')
backups = git(directory, 'for-each-ref', '--format=%(objectname)', 'refs/wrolpi/backup/')
assert backups == old_head
@pytest.mark.asyncio
async def test_snapshot(test_session, git_remote_factory, repos_download_manager):
"""A snapshot only has the latest commit; switching to full fetches the whole history."""
remote = git_remote_factory()
remote.commit({'a.txt': 'a'}, 'Second')
repo = lib.create_repository(test_session, remote.url, mode='snapshot')
await repos_download_manager.wait_for_all_downloads()
directory = repo.directory
assert (directory / '.git/shallow').is_file()
assert git(directory, 'rev-list', '--count', 'HEAD') == '1'
# Snapshot updates stay shallow.
remote.commit({'b.txt': 'b'}, 'Third')
download, = test_session.query(Download).all()
download.renew()
test_session.commit()
await repos_download_manager.wait_for_all_downloads()
assert (directory / 'b.txt').is_file()
assert git(directory, 'rev-list', '--count', 'HEAD') == '1'
lib.update_repository(test_session, repo.id, mode='full')
download.renew()
test_session.commit()
await repos_download_manager.wait_for_all_downloads()
assert not (directory / '.git/shallow').exists()
assert git(directory, 'rev-list', '--count', 'HEAD') == '3'
@pytest.mark.asyncio
async def test_branch(test_session, git_remote_factory, repos_download_manager):
"""A Repo can follow a branch other than the default branch."""
remote = git_remote_factory()
remote.git('checkout', '--quiet', '-b', 'dev')
dev_head = remote.commit({'dev.txt': 'dev'}, 'Dev')
remote.git('checkout', '--quiet', 'main')
repo = lib.create_repository(test_session, f'{remote.url}/tree/dev')
assert repo.branch == 'dev'
await repos_download_manager.wait_for_all_downloads()
test_session.expire_all()
assert repo.head_sha == dev_head
assert repo.default_branch == 'main'
assert git(repo.directory, 'branch', '--show-current') == 'dev'
@pytest.mark.asyncio
async def test_upstream_gone(test_session, git_remote_factory, repos_download_manager):
"""When the upstream disappears the download is deferred with git's error; the local copy is kept."""
remote = git_remote_factory()
repo = lib.create_repository(test_session, remote.url)
await repos_download_manager.wait_for_all_downloads()
directory = repo.directory
head = repo.head_sha
shutil.rmtree(remote.path)
download, = test_session.query(Download).all()
download.renew()
test_session.commit()
await repos_download_manager.wait_for_all_downloads()
test_session.expire_all()
assert download.status == DownloadStatus.deferred
assert 'git ls-remote failed' in download.error
assert (directory / 'README.md').is_file()
assert repo.head_sha == head
@pytest.mark.asyncio
async def test_symlinks_are_not_followed(test_session, git_remote_factory, repos_download_manager):
"""A symlink in a repo is checked out as a plain file, so it cannot expose files outside the repo."""
remote = git_remote_factory()
(remote.path / 'shadow').symlink_to('/etc/passwd')
remote.git('add', 'shadow')
remote.git('commit', '--quiet', '-m', 'Symlink')
repo = lib.create_repository(test_session, remote.url)
await repos_download_manager.wait_for_all_downloads()
shadow = repo.directory / 'shadow'
assert not shadow.is_symlink()
assert shadow.read_text() == '/etc/passwd'
@pytest.mark.asyncio
async def test_refuses_non_empty_directory(test_session, test_directory, git_remote_factory, repos_download_manager):
"""A Repo is never cloned over existing files."""
remote = git_remote_factory()
directory = test_directory / 'repos/example'
directory.mkdir(parents=True)
(directory / 'important.txt').write_text('important')
lib.create_repository(test_session, remote.url)
await repos_download_manager.wait_for_all_downloads()
download, = test_session.query(Download).all()
assert download.status == DownloadStatus.deferred
assert 'not empty' in download.error
assert (directory / 'important.txt').read_text() == 'important'
assert test_session.query(Repository).one().head_sha is None
def test_parse_git_progress():
assert parse_git_progress('Receiving objects: 45% (123/456), 1.20 MiB | 1.00 MiB/s') == \
dict(stage='Receiving objects', percent=45)
assert parse_git_progress('remote: Counting objects: 100% (5/5), done.') == \
dict(stage='Counting objects', percent=100)
assert parse_git_progress("Cloning into 'example'...") is None
@pytest.mark.asyncio
async def test_invalid_remote_default_branch(test_session, git_remote_factory, repos_download_manager):
"""The remote chooses its default branch name; an invalid one is never passed to git."""
remote = git_remote_factory()
repo = lib.create_repository(test_session, remote.url)
with mock.patch('modules.repos.downloader.GitDownloader._default_branch', return_value='--upload-pack=x'):
await repos_download_manager.wait_for_all_downloads()
download, = test_session.query(Download).all()
assert download.status == DownloadStatus.deferred
assert 'Invalid branch' in download.error
assert not repo.directory.exists()

View file

@ -0,0 +1,435 @@
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

View file

@ -0,0 +1,287 @@
import json
import subprocess
from http import HTTPStatus
import pytest
from modules.repos import lib
from modules.repos.errors import InvalidRepo, RepoConflict
from modules.repos.models import Repository
from wrolpi.downloader import Download, DownloadStatus
from wrolpi.errors import UnknownDirectory, ValidationError
from wrolpi.files.models import FileGroup
def git(directory, *args) -> str:
env = dict(GIT_AUTHOR_NAME='t', GIT_AUTHOR_EMAIL='t@t', GIT_COMMITTER_NAME='t', GIT_COMMITTER_EMAIL='t@t',
PATH='/usr/bin:/bin:/usr/local/bin:/opt/homebrew/bin')
return subprocess.run(['git', '-c', 'safe.directory=*', *args], cwd=directory, check=True, env=env,
capture_output=True).stdout.decode().strip()
@pytest.fixture
def user_clone(test_directory, git_remote_factory):
"""A clone the user made themselves, in their media directory, with an `origin`."""
def factory(relative='code/kiwix-tools', remote=None):
remote = remote or git_remote_factory('kiwix-tools', owner='kiwix', files={'README.md': '# Kiwix tools'})
directory = test_directory / relative
directory.parent.mkdir(parents=True, exist_ok=True)
git(directory.parent, 'clone', '--quiet', str(remote.path), directory.name)
git(directory, 'remote', 'set-url', 'origin', remote.url)
return directory, remote
return factory
@pytest.mark.parametrize('origin,expected', [
('https://github.com/kiwix/kiwix-tools', 'https://github.com/kiwix/kiwix-tools'),
('https://github.com/kiwix/kiwix-tools.git', 'https://github.com/kiwix/kiwix-tools.git'),
('git@github.com:kiwix/kiwix-tools.git', 'https://github.com/kiwix/kiwix-tools'),
('ssh://git@github.com:22/kiwix/kiwix-tools.git', 'https://github.com/kiwix/kiwix-tools'),
('git://github.com/kiwix/kiwix-tools', 'https://github.com/kiwix/kiwix-tools'),
('/home/user/kiwix-tools', None),
('../kiwix-tools', None),
])
def test_remote_https_url(origin, expected):
assert lib.remote_https_url(origin) == expected
def test_remote_url_key():
assert lib.remote_url_key('git@GitHub.com:kiwix/kiwix-tools.git') == \
lib.remote_url_key('https://github.com/kiwix/kiwix-tools/') == 'github.com/kiwix/kiwix-tools'
assert lib.remote_url_key('https://github.com/kiwix/other') != lib.remote_url_key('https://github.com/kiwix/x')
assert lib.remote_url_key('/local/path') is None
@pytest.mark.asyncio
async def test_inspect_import(test_session, async_client, test_wrolpi_config, user_clone):
"""Inspecting a clone reports what an import would do, and changes nothing."""
directory, remote = user_clone()
git(directory, 'commit', '--quiet', '--allow-empty', '-m', 'Local work')
config_before = (directory / '.git/config').read_text()
request, response = await async_client.post('/api/repos/import/inspect',
content=json.dumps(dict(directory='code/kiwix-tools')))
assert response.status_code == HTTPStatus.OK, response.json
info = response.json['inspection']
assert info['directory'] == 'code/kiwix-tools'
assert info['origin'] == remote.url
assert info['url'] == remote.url
assert info['name'] == 'kiwix-tools'
assert info['branch'] == 'main'
assert info['head_message'] == 'Local work'
assert info['head_sha'] == git(directory, 'rev-parse', 'HEAD')
assert info['head_date']
assert info['local_commits'] == 1
assert info['shallow'] is False
# The clone stays where it is, where its files are indexed.
assert info['ignored'] is False
assert (directory / '.git/config').read_text() == config_before
assert not (directory / '.git' / lib.REPO_MARKER).exists()
# A clone in the Repos directory is ignored.
user_clone('repos/other', remote=remote)
assert lib.inspect_import(test_session, 'repos/other')['ignored'] is True
@pytest.mark.asyncio
async def test_inspect_import_refuses(test_session, test_directory, async_client, test_wrolpi_config, user_clone):
(test_directory / 'not-git').mkdir()
with pytest.raises(InvalidRepo):
lib.inspect_import(test_session, 'not-git')
with pytest.raises(UnknownDirectory):
lib.inspect_import(test_session, 'missing')
for directory in ('', '/etc', '../outside', 'config', 'tags', 'videos'):
(test_directory / 'videos').mkdir(exist_ok=True)
with pytest.raises((InvalidRepo, UnknownDirectory)):
lib.inspect_import(test_session, directory)
# `.git` must be the clone's own directory.
(test_directory / 'gitfile').mkdir()
(test_directory / 'gitfile/.git').write_text('gitdir: /etc\n')
with pytest.raises(InvalidRepo):
lib.inspect_import(test_session, 'gitfile')
# A partial clone's missing objects would come from the network.
directory, _ = user_clone('code/partial')
git(directory, 'config', 'remote.origin.promisor', 'true')
with pytest.raises(InvalidRepo, match='partial clone'):
lib.inspect_import(test_session, 'code/partial')
# Already a Repo, or inside one.
repo = lib.create_repository(test_session, 'https://github.com/a/b')
repo.directory.mkdir(parents=True)
git(repo.directory, 'init', '--quiet')
with pytest.raises(RepoConflict):
lib.inspect_import(test_session, 'repos/b')
@pytest.mark.asyncio
async def test_import(test_session, test_directory, async_client, test_wrolpi_config, user_clone,
repos_download_manager, refresh_files):
"""An imported clone stays where it is, is owned by WROLPi, and is updated like any Repo."""
directory, remote = user_clone()
(directory / 'local-note.txt').write_text('untracked')
with (directory / '.git/config').open('a') as fh:
fh.write('[http]\n\tproxy = http://127.0.0.1:9\n[user]\n\tname = Someone\n')
await refresh_files()
indexed = test_session.query(FileGroup).filter(FileGroup.primary_path.contains('code/kiwix-tools')).count()
assert indexed
body = dict(directory='code/kiwix-tools', tag_name='Software', confirm=True, frequency=86400)
request, response = await async_client.post('/api/repos/import', content=json.dumps(body))
assert response.status_code == HTTPStatus.CREATED, response.json
imported = response.json['repo']
assert imported['name'] == 'kiwix-tools'
assert imported['url'] == remote.url
assert imported['directory'] == 'code/kiwix-tools'
assert imported['tag_name'] == 'Software'
assert imported['frequency'] == 86400
assert imported['branch'] is None # Follows the default branch, like a new Repo.
assert imported['head_sha'] == remote.head
assert imported['readme_path'] == 'README.md'
assert (directory / 'README.md').is_file()
assert (directory / '.git' / lib.REPO_MARKER).is_file()
config = (directory / '.git/config').read_text()
assert 'proxy' not in config and 'Someone' not in config
# It was not moved (or ignored); its files are still indexed.
assert not (test_directory / 'repos').exists()
await refresh_files()
assert test_session.query(FileGroup).filter(FileGroup.primary_path.contains('code/kiwix-tools')).count()
# From now on it is a mirror: an update fetches the origin, and discards local files.
await repos_download_manager.wait_for_all_downloads()
head = remote.commit({'new.txt': 'new'}, 'New upstream')
download = test_session.query(Download).one()
download.renew()
test_session.commit()
await repos_download_manager.wait_for_all_downloads()
test_session.expire_all()
download = test_session.query(Download).one()
assert download.status == DownloadStatus.complete, download.error
assert test_session.query(Repository).one().head_sha == head
assert (directory / 'new.txt').is_file()
assert not (directory / 'local-note.txt').exists()
@pytest.mark.asyncio
async def test_import_refuses(test_session, test_directory, async_client, test_wrolpi_config, user_clone,
git_remote_factory):
directory, remote = user_clone()
# Without confirmation.
with pytest.raises(ValidationError):
await lib.import_repository(test_session, 'code/kiwix-tools')
# A URL which is not the clone's origin.
with pytest.raises(InvalidRepo, match="not this clone's origin"):
await lib.import_repository(test_session, 'code/kiwix-tools', url='https://github.com/evil/other',
confirm=True)
test_session.rollback()
assert test_session.query(Repository).count() == 0
assert not (directory / '.git' / lib.REPO_MARKER).exists()
# Without an https origin, the URL must be given (and must still be the origin).
other, _ = user_clone('code/ssh-clone', remote=git_remote_factory('library'))
git(other, 'remote', 'set-url', 'origin', 'git@git.example.com:owner/library.git')
assert lib.inspect_import(test_session, 'code/ssh-clone')['url'] == 'https://git.example.com/owner/library'
git(other, 'remote', 'remove', 'origin')
assert lib.inspect_import(test_session, 'code/ssh-clone')['url'] is None
with pytest.raises(InvalidRepo, match='https:// URL'):
await lib.import_repository(test_session, 'code/ssh-clone', confirm=True)
repo = await lib.import_repository(test_session, 'code/ssh-clone', url='https://git.example.com/owner/library',
confirm=True)
assert repo.url == 'https://git.example.com/owner/library'
assert repo.directory == other
@pytest.mark.asyncio
async def test_refused_import_changes_nothing(test_session, test_directory, async_client, test_wrolpi_config,
user_clone):
"""Everything which can refuse an import is checked before the clone is changed."""
directory, _ = user_clone()
(directory / 'other').mkdir()
(directory / 'other/.git').write_text(f'gitdir: {test_directory}\n')
config_before = (directory / '.git/config').read_text()
with pytest.raises(InvalidRepo, match='outside'):
await lib.import_repository(test_session, 'code/kiwix-tools', confirm=True)
test_session.rollback()
assert (directory / '.git/config').read_text() == config_before
assert not (directory / '.git' / lib.REPO_MARKER).exists()
assert test_session.query(Repository).count() == 0
@pytest.mark.asyncio
async def test_inspect_never_reads_the_clones_config(test_session, test_directory, async_client, test_wrolpi_config,
user_clone):
"""Inspecting runs no git command with the clone's (unsanitized) config: one git refuses to read still inspects."""
directory, remote = user_clone()
with (directory / '.git/config').open('a') as fh:
fh.write('[core]\n\trepositoryformatversion = 1\n[extensions]\n\tnotAnExtension = true\n'
f'[include]\n\tpath = {test_directory}/included.gitconfig\n')
# git itself refuses this repository.
assert subprocess.run(['git', '-c', 'safe.directory=*', 'rev-parse', 'HEAD'], cwd=directory,
capture_output=True).returncode != 0
info = lib.inspect_import(test_session, 'code/kiwix-tools')
assert info['head_sha'] == remote.head
assert info['head_message'] == 'Initial commit'
assert info['local_commits'] == 0
def test_working_tree_git_file_is_read_like_git(tmp_path):
"""git trims only the line ending from a `.git` file, not spaces; so does WROLPi."""
repo = tmp_path / 'repo'
(repo / '.git/modules/library/objects').mkdir(parents=True)
(repo / '.git/modules/library/HEAD').write_text('ref: refs/heads/main\n')
(repo / 'vendor/library').mkdir(parents=True)
# git reads this as "<...>/library " (with the space): not the checked git directory.
(repo / 'vendor/library/.git').write_text('gitdir: ../../.git/modules/library/../../../.. \n')
assert lib.working_tree_git_error(repo)
(repo / 'vendor/library/.git').write_text('gitdir: ../../.git/modules/library\r\n')
assert lib.working_tree_git_error(repo) is None
@pytest.mark.asyncio
async def test_create_repository_in_a_directory(test_session, test_directory, async_client, test_wrolpi_config,
git_remote_factory, repos_download_manager, refresh_files):
"""A Repo can be cloned into a directory the user chooses. It is not ignored: its files are indexed."""
remote = git_remote_factory(files={'README.md': '# Example', 'guide.md': 'A guide'})
body = dict(url=remote.url, directory='projects/example', tag_name='Software')
request, response = await async_client.post('/api/repos', content=json.dumps(body))
assert response.status_code == HTTPStatus.CREATED, response.json
assert response.json['repo']['directory'] == 'projects/example'
await repos_download_manager.wait_for_all_downloads()
directory = test_directory / 'projects/example'
assert (directory / 'guide.md').is_file()
await refresh_files()
assert test_session.query(FileGroup).filter(FileGroup.primary_path.contains('projects/example')).count()
@pytest.mark.parametrize('directory,error', [
('/etc/example', 'relative'),
('../example', 'relative'),
('.', 'media directory'),
('videos', 'cannot be used'),
('config/example', 'cannot be used'),
('tags', 'cannot be used'),
('not-empty', 'not empty'),
('existing-repo/inside', 'Collection'),
])
@pytest.mark.asyncio
async def test_create_repository_directory_refused(test_session, test_directory, async_client, test_wrolpi_config,
directory, error):
(test_directory / 'videos').mkdir()
(test_directory / 'not-empty').mkdir()
(test_directory / 'not-empty/file.txt').write_text('mine')
lib.create_repository(test_session, 'https://github.com/a/existing', directory='existing-repo')
body = dict(url='https://github.com/a/b', directory=directory)
request, response = await async_client.post('/api/repos', content=json.dumps(body))
assert response.status_code in (HTTPStatus.BAD_REQUEST, HTTPStatus.CONFLICT), response.json
assert error in response.json['error'], response.json
assert (test_directory / 'not-empty/file.txt').read_text() == 'mine'

View file

@ -0,0 +1,380 @@
import json
import os
from http import HTTPStatus
import pytest
from mock import mock
from modules.repos import lib
from modules.repos.errors import InvalidRepo, RepoConflict
from modules.repos.models import Repository
from wrolpi.collections.models import Collection
from wrolpi.common import get_wrolpi_config
from wrolpi.downloader import Download, DownloadFrequency
from wrolpi.errors import UnknownDirectory, ValidationError
from wrolpi.files.lib import get_normalized_ignored_directories
from wrolpi.files.models import FileGroup
@pytest.mark.parametrize('url,expected', [
('https://github.com/kiwix/kiwix-tools',
lib.ParsedRepoURL('https://github.com/kiwix/kiwix-tools', 'github.com', 'kiwix', 'kiwix-tools')),
('https://GitHub.com/kiwix/kiwix-tools.git/',
lib.ParsedRepoURL('https://github.com/kiwix/kiwix-tools.git', 'github.com', 'kiwix', 'kiwix-tools')),
('https://github.com/kiwix/kiwix-tools/tree/feature/x',
lib.ParsedRepoURL('https://github.com/kiwix/kiwix-tools', 'github.com', 'kiwix', 'kiwix-tools', 'feature/x')),
('https://gitlab.com/group/sub/project/-/tree/dev',
lib.ParsedRepoURL('https://gitlab.com/group/sub/project', 'gitlab.com', 'group/sub', 'project', 'dev')),
('https://git.example.com:8443/project',
lib.ParsedRepoURL('https://git.example.com:8443/project', 'git.example.com', '', 'project')),
])
def test_parse_repo_url(url, expected):
assert lib.parse_repo_url(url) == expected
@pytest.mark.parametrize('url', [
'http://github.com/kiwix/kiwix-tools',
'git@github.com:kiwix/kiwix-tools.git',
'ssh://git@github.com/kiwix/kiwix-tools',
'file:///etc',
'https://user:token@github.com/kiwix/kiwix-tools',
'https://github.com/kiwix/kiwix-tools?tab=readme',
'https://github.com',
'https://github.com/a/..',
'https://github.com/../b',
'https://github.com/a/./b',
'',
])
def test_parse_repo_url_invalid(url):
with pytest.raises(InvalidRepo):
lib.parse_repo_url(url)
def test_repo_url_key():
assert lib.repo_url_key('https://GitHub.com/a/b.git/') == lib.repo_url_key('https://github.com/a/b')
assert lib.repo_url_key('https://github.com/a/b') != lib.repo_url_key('https://github.com/a/c')
def test_git_command_is_restricted():
cmd = lib.git_command('clone', 'https://example.com/a')
assert 'protocol.allow=never' in cmd
assert 'protocol.https.allow=always' in cmd
assert 'core.symlinks=false' in cmd
assert 'core.hooksPath=/dev/null' in cmd
assert cmd[-2:] == ('clone', 'https://example.com/a')
assert lib.git_env()['GIT_TERMINAL_PROMPT'] == '0'
@pytest.mark.asyncio
async def test_create_repository(test_session, test_directory, test_wrolpi_config, async_client):
repo = lib.create_repository(test_session, 'https://github.com/kiwix/kiwix-tools', tag_name='software')
collection = test_session.query(Collection).one()
assert collection.kind == 'repo'
assert collection.name == 'kiwix-tools'
assert collection.tag_name == 'software'
assert collection.directory == test_directory / 'repos/software/kiwix-tools'
assert repo.collection_id == collection.id
assert (repo.url, repo.host, repo.owner, repo.mode, repo.branch) == \
('https://github.com/kiwix/kiwix-tools', 'github.com', 'kiwix', 'full', None)
# Repos are always kept up to date; weekly by default.
download = test_session.query(Download).one()
assert download.url == repo.url
assert download.downloader == 'git'
assert download.frequency == DownloadFrequency.weekly
assert download.collection_id == collection.id
assert repo.download == download
# Without a tag.
repo = lib.create_repository(test_session, 'https://github.com/kiwix/libkiwix')
assert repo.directory == test_directory / 'repos/libkiwix'
@pytest.mark.asyncio
async def test_create_repository_conflicts(test_session, test_wrolpi_config, async_client):
lib.create_repository(test_session, 'https://github.com/a/utils')
# The same repo, even with `.git`.
with pytest.raises(RepoConflict):
lib.create_repository(test_session, 'https://github.com/a/utils.git')
# Another owner's repo with the same name; a new name is suggested.
with pytest.raises(RepoConflict) as e:
lib.create_repository(test_session, 'https://github.com/b/utils')
assert "'b-utils'" in str(e.value)
test_session.rollback()
repo = lib.create_repository(test_session, 'https://github.com/b/utils', name='b-utils')
assert repo.name == 'b-utils'
assert test_session.query(Repository).count() == 2
@pytest.mark.asyncio
async def test_create_repository_invalid(test_session, test_wrolpi_config, async_client):
with pytest.raises(InvalidRepo):
lib.create_repository(test_session, 'http://github.com/a/b')
with pytest.raises(Exception):
lib.create_repository(test_session, 'https://github.com/a/b', mode='bare')
# 0 is never updated.
with pytest.raises(Exception):
lib.create_repository(test_session, 'https://github.com/a/b', frequency=-1)
@pytest.mark.asyncio
async def test_repos_destination(test_session, test_directory, test_wrolpi_config, async_client):
"""The repos destination can use the repo's owner and host."""
get_wrolpi_config().repos_destination = 'repos/%(repo_host)s/%(repo_owner)s/%(repo_name)s'
repo = lib.create_repository(test_session, 'https://gitlab.com/group/sub/project')
assert repo.directory == test_directory / 'repos/gitlab.com/group⧸sub/project'
get_wrolpi_config().repos_destination = 'repos/%(tag_name)s/%(repo_name)s'
repo = lib.create_repository(test_session, 'https://github.com/a/b', tag_name='Tag')
assert repo.directory == test_directory / 'repos/Tag/b'
@pytest.mark.asyncio
async def test_update_repository(test_session, test_wrolpi_config, async_client):
repo = lib.create_repository(test_session, 'https://github.com/a/b', branch='dev')
lib.update_repository(test_session, repo.id, description='My repo', frequency=DownloadFrequency.daily,
mode='snapshot', branch='')
assert repo.collection.description == 'My repo'
assert repo.download.frequency == DownloadFrequency.daily
assert repo.mode == 'snapshot'
# An empty branch follows the default branch.
assert repo.branch is None
@pytest.mark.asyncio
async def test_delete_repository(test_session, test_directory, test_wrolpi_config, await_background_tasks,
async_client):
keep = lib.create_repository(test_session, 'https://github.com/a/keep')
delete = lib.create_repository(test_session, 'https://github.com/a/delete')
for repo in (keep, delete):
repo.directory.mkdir(parents=True)
(repo.directory / 'README.md').write_text('readme')
keep_directory, delete_directory = keep.directory, delete.directory
await lib.delete_repository(test_session, keep.id)
await lib.delete_repository(test_session, delete.id, delete_files=True)
await await_background_tasks()
assert test_session.query(Repository).count() == 0
assert test_session.query(Collection).count() == 0
assert test_session.query(Download).count() == 0
# Files are kept by default.
assert (keep_directory / 'README.md').is_file()
assert not delete_directory.exists()
@pytest.mark.asyncio
async def test_list_repo_tree(test_session, test_directory, test_wrolpi_config, async_client):
repo = lib.create_repository(test_session, 'https://github.com/a/b')
with pytest.raises(UnknownDirectory):
lib.list_repo_tree(repo)
root = repo.directory
(root / '.git').mkdir(parents=True)
(root / 'src/lib').mkdir(parents=True)
(root / 'src/Readme.rst').write_text('src')
(root / 'README.md').write_text('# B')
(root / 'a.txt').write_text('aaa')
(root / 'Docs').mkdir()
tree = lib.list_repo_tree(repo)
assert tree == dict(
path='',
readme_path='README.md',
entries=[
dict(name='Docs', path='Docs', is_dir=True, size=None),
dict(name='src', path='src', is_dir=True, size=None),
dict(name='a.txt', path='a.txt', is_dir=False, size=3),
dict(name='README.md', path='README.md', is_dir=False, size=3),
],
)
tree = lib.list_repo_tree(repo, '/src/')
assert tree['path'] == 'src'
assert tree['readme_path'] == 'src/Readme.rst'
assert [i['path'] for i in tree['entries']] == ['src/lib', 'src/Readme.rst']
with pytest.raises(InvalidRepo):
lib.list_repo_tree(repo, '../..')
with pytest.raises(UnknownDirectory):
lib.list_repo_tree(repo, 'missing')
@pytest.mark.asyncio
async def test_repos_directory_is_ignored(test_session, test_directory, test_wrolpi_config, refresh_files):
"""Files in the repos directory are never indexed, even when an old config does not ignore it."""
config = get_wrolpi_config()
config.ignored_directories = ['config']
config.import_config()
assert 'repos' in config.ignored_directories
(test_directory / 'repos/a/b').mkdir(parents=True)
(test_directory / 'repos/a/b/README.md').write_text('# B')
(test_directory / 'other.txt').write_text('other')
await refresh_files()
assert [i.primary_path.name for i in test_session.query(FileGroup)] == ['other.txt']
@pytest.mark.parametrize('branch,valid', [
('main', True),
('feature/x-1', True),
('release-1.2', True),
('--upload-pack=touch /tmp/pwned', False),
('-b', False),
('a..b', False),
('a b', False),
('a~1', False),
('a^', False),
('a:b', False),
('a?', False),
('a*', False),
('a[', False),
('a\\b', False),
('a@{1}', False),
('@', False),
('a/', False),
('/a', False),
('a//b', False),
('a.lock', False),
('a/.b', False),
('a.', False),
('a\x00b', False),
('', False),
])
def test_is_valid_branch(branch, valid):
assert lib.is_valid_branch(branch) is valid
@pytest.mark.asyncio
async def test_invalid_branch_rejected(test_session, async_client, test_wrolpi_config):
"""A branch is passed to git, it must never be read as an option."""
with pytest.raises(ValidationError):
lib.create_repository(test_session, 'https://github.com/a/b', branch='--upload-pack=x')
with pytest.raises(ValidationError):
lib.create_repository(test_session, 'https://github.com/a/b/tree/a..b')
repo = lib.create_repository(test_session, 'https://github.com/a/b')
with pytest.raises(ValidationError):
lib.update_repository(test_session, repo.id, branch='-x')
@pytest.mark.asyncio
async def test_repo_directory_traversal(test_session, test_directory, async_client, test_wrolpi_config):
"""A Repo's directory is always strictly inside the media directory, and never escapes its template."""
for name in ('..', '.', ' '):
with pytest.raises(InvalidRepo):
lib.create_repository(test_session, 'https://github.com/a/b', tag_name='tag', name=name)
test_session.rollback()
with pytest.raises(InvalidRepo):
lib.create_repository(test_session, 'https://github.com/a/b', directory=test_directory / 'repos/../../etc')
test_session.rollback()
with pytest.raises(InvalidRepo):
lib.create_repository(test_session, 'https://github.com/a/b', directory=test_directory)
test_session.rollback()
# The config can not move an existing Repo outside the media directory.
lib.create_repository(test_session, 'https://github.com/a/b')
with pytest.raises(InvalidRepo):
lib.ReposConfig._import_repo(test_session, dict(url='https://github.com/a/b', directory='../outside'))
test_session.rollback()
assert test_session.query(Repository).one().directory == test_directory / 'repos/b'
def test_git_env_is_not_inherited():
"""The process environment cannot undo git_command's restrictions."""
hostile = dict(
GIT_ALLOW_PROTOCOL='file', GIT_CONFIG_COUNT='1', GIT_CONFIG_KEY_0='url.https://127.0.0.1/.insteadOf',
GIT_CONFIG_VALUE_0='https://example.com/', GIT_CONFIG_PARAMETERS="'protocol.allow'='always'",
GIT_SSL_NO_VERIFY='1', GIT_PROXY_COMMAND='evil', GIT_SSH_COMMAND='evil', HTTPS_PROXY='http://127.0.0.1:9',
https_proxy='http://127.0.0.1:9', CURL_CA_BUNDLE='/tmp/evil.pem', GIT_DIR='/tmp/evil',
)
with mock.patch.dict(os.environ, dict(hostile, PATH='/usr/bin:/bin', SSL_CERT_FILE='/etc/ssl/cert.pem')):
env = lib.git_env()
assert not set(hostile) & set(env), set(hostile) & set(env)
assert env['PATH'] == '/usr/bin:/bin'
assert env['SSL_CERT_FILE'] == '/etc/ssl/cert.pem'
assert env['GIT_TERMINAL_PROMPT'] == '0'
def test_git_command_ignores_hostile_environment(tmp_path):
"""With git's environment variables set against it, git still refuses file:// (only https is allowed)."""
import subprocess
origin = tmp_path / 'origin'
subprocess.run(['git', 'init', '--quiet', str(origin)], check=True)
with mock.patch.dict(os.environ, dict(GIT_ALLOW_PROTOCOL='file')):
result = subprocess.run(lib.git_command('ls-remote', f'file://{origin}'), env=lib.git_env(),
capture_output=True)
assert result.returncode != 0
assert b"transport 'file' not allowed" in result.stderr
@pytest.mark.parametrize('destination,error', [
('repos/%(repo_tag)s/%(repo_name)s', None),
('library/code/%(repo_owner)s/%(repo_name)s', None),
('repos/%(tag)s/%(name)s', None),
('%(repo_name)s', 'fixed directory'), # Would put repos in the media directory itself.
('%(repo_tag)s/%(repo_name)s', 'fixed directory'),
('repos', 'repo name'), # Every repo in one directory.
('repos/%(repo_tag)s', 'repo name'),
('repos/%(bogus)s/%(repo_name)s', 'Unknown variable'),
('repos/%(repo_name)d', 'Unknown variable'),
('/repos/%(repo_name)s', 'relative'),
('../repos/%(repo_name)s', '".."'),
('repos/./%(repo_name)s', '".."'),
('videos/%(repo_name)s', 'videos_destination'),
('archive/x/%(repo_name)s', 'archive_destination'),
('playlists/%(repo_name)s', 'playlists_destination'),
('tags/%(repo_name)s', 'tags directory'),
('config/%(repo_name)s', 'config directory'),
('', 'empty'),
])
@pytest.mark.asyncio
async def test_validate_repos_destination(async_client, test_wrolpi_config, destination, error):
result = lib.validate_repos_destination(destination)
if error is None:
assert result == ''
else:
assert error in result, result
def test_repos_destination_root():
assert lib.repos_destination_root('repos/%(repo_tag)s/%(repo_name)s') == 'repos'
assert lib.repos_destination_root('library/code/%(repo_name)s') == 'library/code'
assert lib.repos_destination_root('%(repo_name)s') == ''
@pytest.mark.asyncio
async def test_repos_destination_is_ignored(test_session, test_directory, async_client, test_wrolpi_config,
refresh_files):
"""Wherever repos are saved, their files are not indexed."""
body = dict(repos_destination='%(repo_name)s')
request, response = await async_client.patch('/api/settings', content=json.dumps(body))
assert response.status_code == HTTPStatus.BAD_REQUEST
body = dict(repos_destination='library/code/%(repo_tag)s/%(repo_name)s')
request, response = await async_client.patch('/api/settings', content=json.dumps(body))
assert response.status_code == HTTPStatus.NO_CONTENT
assert str(test_directory / 'library/code') in get_normalized_ignored_directories()
repo = lib.create_repository(test_session, 'https://github.com/a/b')
assert repo.directory == test_directory / 'library/code/b'
repo.directory.mkdir(parents=True)
(repo.directory / 'README.md').write_text('# B')
(test_directory / 'other.txt').write_text('other')
await refresh_files()
assert [i.primary_path.name for i in test_session.query(FileGroup)] == ['other.txt']
# A hand-edited config which breaks the rules falls back to the default.
config = get_wrolpi_config()
config.save(overwrite=True)
data = config.read_config_file()
data['repos_destination'] = '%(repo_name)s'
data['version'] = (data.get('version') or 0) + 1
config.write_config_data(data, config.get_file())
config.import_config()
assert config.repos_destination == 'repos/%(repo_tag)s/%(repo_name)s'

View file

@ -0,0 +1,175 @@
"""A Repo which is never updated: its clone is kept as it is (e.g. its upstream is gone), and only updated on
request."""
import json
import subprocess
from http import HTTPStatus
import pytest
import yaml
from modules.repos import lib
from modules.repos.models import Repository
from wrolpi.downloader import Download, DownloadFrequency, DownloadStatus
def edit_config(config, change):
"""Hand-edit each Repo in repos.yaml."""
data = yaml.safe_load(config.get_file().read_text())
data['repos'] = [change(i) for i in data['repos']]
config.get_file().write_text(yaml.dump(data))
async def get_repo(async_client, repo_id: int) -> dict:
request, response = await async_client.get(f'/api/repos/{repo_id}')
assert response.status_code == HTTPStatus.OK, response.json
return response.json['repo']
@pytest.mark.asyncio
async def test_never_stops_updates(test_session, test_directory, async_client, test_wrolpi_config,
git_remote_factory, repos_download_manager):
"""Changing a Repo to Never deletes its recurring Download (and its error); its clone is kept."""
remote = git_remote_factory()
repo = lib.create_repository(test_session, remote.url)
await repos_download_manager.wait_for_all_downloads()
download = test_session.query(Download).one()
download.error = 'fatal: repository not found'
download.status = DownloadStatus.deferred
test_session.commit()
request, response = await async_client.put(f'/api/repos/{repo.id}', content=json.dumps(dict(frequency=0)))
assert response.status_code == HTTPStatus.OK, response.json
assert response.json['repo']['frequency'] == 0
assert response.json['repo']['download_error'] is None
assert test_session.query(Download).count() == 0
assert (repo.directory / '.git').is_dir()
# Back to weekly.
request, response = await async_client.put(f'/api/repos/{repo.id}',
content=json.dumps(dict(frequency=DownloadFrequency.weekly)))
assert response.status_code == HTTPStatus.OK, response.json
assert response.json['repo']['frequency'] == DownloadFrequency.weekly
download = test_session.query(Download).one()
assert download.frequency == DownloadFrequency.weekly
assert download.collection_id == repo.collection_id
@pytest.mark.asyncio
async def test_never_new_repo_clones_once(test_session, test_directory, async_client, test_wrolpi_config,
git_remote_factory, repos_download_manager):
"""A new Repo which is never updated is cloned once."""
remote = git_remote_factory()
body = dict(url=remote.url, frequency=0)
request, response = await async_client.post('/api/repos', content=json.dumps(body))
assert response.status_code == HTTPStatus.CREATED, response.json
await repos_download_manager.wait_for_all_downloads()
test_session.expire_all()
repo = test_session.query(Repository).one()
assert repo.head_sha == remote.head
assert repo.download is None
download = test_session.query(Download).one()
assert download.frequency is None and download.collection_id is None
assert download.status == DownloadStatus.complete
assert (await get_repo(async_client, repo.id))['frequency'] == 0
# Back to weekly; the clone's (one-time) Download becomes the recurring Download.
request, response = await async_client.put(f'/api/repos/{repo.id}',
content=json.dumps(dict(frequency=DownloadFrequency.weekly)))
assert response.status_code == HTTPStatus.OK, response.json
download = test_session.query(Download).one()
assert download.frequency == DownloadFrequency.weekly
assert download.collection_id == repo.collection_id
@pytest.mark.asyncio
async def test_never_update_now(test_session, test_directory, async_client, test_wrolpi_config,
git_remote_factory, repos_download_manager):
"""A Repo which is never updated is updated on request, as often as requested; a failure is shown."""
remote = git_remote_factory()
repo = lib.create_repository(test_session, remote.url, frequency=0)
await repos_download_manager.wait_for_all_downloads()
for _ in range(2):
head = remote.commit({'new.txt': remote.head}, 'New')
request, response = await async_client.post(f'/api/repos/{repo.id}/update')
assert response.status_code == HTTPStatus.NO_CONTENT, response.json
await repos_download_manager.wait_for_all_downloads()
test_session.expire_all()
assert repo.head_sha == head
assert repo.download is None
# The upstream is gone.
subprocess.run(['rm', '-rf', str(remote.path)], check=True)
request, response = await async_client.post(f'/api/repos/{repo.id}/update')
await repos_download_manager.wait_for_all_downloads()
test_session.expire_all()
data = await get_repo(async_client, repo.id)
assert data['download_error']
assert data['frequency'] == 0
assert repo.head_sha == head
@pytest.mark.asyncio
async def test_never_import(test_session, test_directory, async_client, test_wrolpi_config, git_remote_factory,
repos_download_manager):
"""An imported clone which is never updated is never fetched."""
remote = git_remote_factory()
clone = test_directory / 'code/example'
subprocess.run(['git', 'clone', '--quiet', str(remote.path), str(clone)], check=True, capture_output=True)
subprocess.run(['git', '-C', str(clone), 'remote', 'set-url', 'origin', remote.url], check=True)
await lib.import_repository(test_session, 'code/example', url=remote.url, confirm=True, frequency=0)
test_session.commit()
assert test_session.query(Download).count() == 0
assert test_session.query(Repository).one().head_sha == remote.head
@pytest.mark.asyncio
async def test_never_config(test_session, test_directory, async_client, test_wrolpi_config, git_remote_factory,
repos_download_manager):
"""Never is saved in repos.yaml. A restored Repo is cloned once if its clone is missing, and is otherwise left
as it is."""
kept, missing = git_remote_factory('kept'), git_remote_factory('missing')
lib.create_repository(test_session, kept.url, frequency=0)
lib.create_repository(test_session, missing.url, frequency=DownloadFrequency.daily)
await repos_download_manager.wait_for_all_downloads()
config = lib.get_repos_config()
config.dump_config()
assert {i['name']: i['frequency'] for i in config.repos} == {'kept': 'never', 'missing': DownloadFrequency.daily}
# A Repo whose frequency is missing from the config is updated weekly.
edit_config(config, lambda i: {k: v for k, v in i.items() if not (i['name'] == 'missing' and k == 'frequency')})
# Lose the database, and one clone.
for repo in test_session.query(Repository).all():
lib._delete_repository(test_session, repo)
for download in test_session.query(Download).all():
test_session.delete(download)
test_session.commit()
subprocess.run(['rm', '-rf', str(test_directory / 'repos/missing')], check=True)
config.import_config()
test_session.expire_all()
repos = {i.name: i for i in test_session.query(Repository)}
assert repos['kept'].download is None
assert repos['missing'].download.frequency == DownloadFrequency.weekly
# Only the missing clone is downloaded.
assert [i.url for i in test_session.query(Download)] == [repos['missing'].url]
# Never, in a hand-edited config.
edit_config(config, lambda i: dict(i, frequency='never'))
config.import_config()
test_session.expire_all()
assert test_session.query(Download).count() == 0
@pytest.mark.asyncio
async def test_never_delete(test_session, test_directory, async_client, test_wrolpi_config, git_remote_factory,
repos_download_manager):
"""Deleting a Repo which is never updated deletes its one-time Download."""
remote = git_remote_factory()
repo = lib.create_repository(test_session, remote.url, frequency=0)
await repos_download_manager.wait_for_all_downloads()
assert test_session.query(Download).count() == 1
await lib.delete_repository(test_session, repo.id)
assert test_session.query(Download).count() == 0

View file

@ -0,0 +1,577 @@
import subprocess
import pytest
from modules.repos import lib
from wrolpi.downloader import Download, DownloadStatus
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_clone_is_marked(test_session, git_remote_factory, repos_download_manager):
"""WROLPi marks the clones it makes, so it never mistakes another clone for one of its own."""
remote = git_remote_factory()
repo = lib.create_repository(test_session, remote.url)
await repos_download_manager.wait_for_all_downloads()
test_session.expire_all()
# The marker holds the Repo's own random token, which nothing else can know (unlike its URL).
assert len(repo.clone_token) == 32
assert (repo.directory / '.git' / lib.REPO_MARKER).read_text().strip() == repo.clone_token
@pytest.mark.asyncio
async def test_update_refuses_unmarked_clone(test_session, test_directory, git_remote_factory,
repos_download_manager):
"""A clone WROLPi did not make (or made for another repo) is not updated, so it is never reset or cleaned."""
remote = git_remote_factory()
directory = test_directory / 'repos/example'
directory.parent.mkdir(parents=True)
git(directory.parent, 'clone', '--quiet', str(remote.path), str(directory))
(directory / 'my-work.txt').write_text('uncommitted')
lib.create_repository(test_session, remote.url)
await repos_download_manager.wait_for_all_downloads()
download = test_session.query(Download).one()
assert download.status == DownloadStatus.deferred
assert 'was not created by WROLPi' in download.error
assert (directory / 'my-work.txt').read_text() == 'uncommitted'
# A marker for another repo is not this repo's.
(directory / '.git' / lib.REPO_MARKER).write_text('git.example.com/owner/other')
download = await update(test_session, repos_download_manager)
assert 'was not created by WROLPi' in download.error
assert (directory / 'my-work.txt').exists()
@pytest.mark.asyncio
async def test_update_refuses_git_file(test_session, test_directory, git_remote_factory, repos_download_manager):
"""`.git` must be the repo's own directory, not a file or link which points elsewhere."""
remote = git_remote_factory()
directory = test_directory / 'repos/example'
directory.mkdir(parents=True)
(directory / '.git').write_text('gitdir: /somewhere/else\n')
lib.create_repository(test_session, remote.url)
await repos_download_manager.wait_for_all_downloads()
download = test_session.query(Download).one()
assert download.status == DownloadStatus.deferred
assert 'not a git directory' in download.error
@pytest.mark.asyncio
async def test_update_fetches_the_repo_url(test_session, git_remote_factory, repos_download_manager):
"""The Repo's URL is fetched, whatever the clone's origin says."""
remote = git_remote_factory()
other = git_remote_factory('other')
repo = lib.create_repository(test_session, remote.url)
await repos_download_manager.wait_for_all_downloads()
git(repo.directory, 'config', 'remote.origin.url', other.url)
head = remote.commit({'new.txt': 'new'}, 'New')
await update(test_session, repos_download_manager)
assert repo.head_sha == head
assert git(repo.directory, 'config', 'remote.origin.url') == remote.url
@pytest.mark.asyncio
async def test_update_sanitizes_git_config(test_session, git_remote_factory, repos_download_manager):
"""Only the settings WROLPi needs survive in a clone's config; a planted setting cannot change what git does."""
remote = git_remote_factory()
repo = lib.create_repository(test_session, remote.url)
await repos_download_manager.wait_for_all_downloads()
config = repo.directory / '.git/config'
config.write_text(config.read_text() + (
'[url "https://127.0.0.1/"]\n\tinsteadOf = https://git.example.com/\n'
'[http]\n\tproxy = http://127.0.0.1:9\n'
'[include]\n\tpath = /etc/evil.gitconfig\n'
'[core]\n\tsshCommand = evil\n\tworktree = /etc\n\tfsmonitor = evil\n'
'[branch "main"]\n\tremote = https://127.0.0.1/evil\n'
))
head = remote.commit({'new.txt': 'new'}, 'New')
download = await update(test_session, repos_download_manager)
assert download.status == DownloadStatus.complete, download.error
assert repo.head_sha == head
keys = set(git(repo.directory, 'config', '--local', '--name-only', '--list').splitlines())
assert {'remote.origin.url', 'remote.origin.fetch', 'core.repositoryformatversion'} <= keys
assert not {'url.https://127.0.0.1/.insteadof', 'http.proxy', 'include.path', 'core.sshcommand',
'core.worktree', 'core.fsmonitor', 'branch.main.remote'} & keys
def test_sanitize_git_config_submodules(tmp_path):
"""A submodule's config may point its work tree inside the repo, and nowhere else."""
repo = tmp_path / 'repo'
git_dir = repo / '.git'
module = git_dir / 'modules/library'
module.mkdir(parents=True)
(repo / 'vendor/library').mkdir(parents=True)
(git_dir / 'config').write_text('[core]\n\trepositoryformatversion = 0\n\tbare = false\n')
(module / 'config').write_text('[core]\n\trepositoryformatversion = 0\n\tworktree = ../../../vendor/library\n'
'[remote "origin"]\n\turl = https://git.example.com/owner/library\n')
evil = git_dir / 'modules/evil'
evil.mkdir()
(evil / 'config').write_text('[core]\n\tworktree = /etc\n')
for gitdir in (module, evil):
(gitdir / 'HEAD').write_text('ref: refs/heads/main\n')
(gitdir / 'objects').mkdir()
lib.sanitize_git_config(repo)
assert 'worktree = ../../../vendor/library' in (module / 'config').read_text()
assert 'worktree' not in (evil / 'config').read_text()
@pytest.mark.asyncio
async def test_update_with_duplicate_and_option_like_settings(test_session, git_remote_factory,
repos_download_manager):
"""Planted extra or option-like values in the kept settings cannot break or redirect an update."""
remote = git_remote_factory()
repo = lib.create_repository(test_session, remote.url)
await repos_download_manager.wait_for_all_downloads()
config = repo.directory / '.git/config'
config.write_text(config.read_text() + (
'[remote "origin"]\n\turl = --file=/tmp/evil\n\turl = https://127.0.0.1/evil\n'
'\tfetch = +refs/heads/*:refs/heads/*\n'
'[submodule "lib"]\n\turl = "quoted \\"value\\" \\\\ and\\ttab"\n'
))
head = remote.commit({'new.txt': 'new'}, 'New')
download = await update(test_session, repos_download_manager)
assert download.status == DownloadStatus.complete, download.error
assert repo.head_sha == head
assert git(repo.directory, 'config', '--get-all', 'remote.origin.url') == remote.url
assert git(repo.directory, 'config', '--get-all', 'remote.origin.fetch') == '+refs/heads/*:refs/remotes/origin/*'
def test_sanitized_config_round_trips(tmp_path):
"""Kept values are written back exactly, whatever they contain."""
repo = tmp_path / 'repo'
(repo / '.git').mkdir(parents=True)
values = ['--file=/tmp/evil', 'a "quoted" value', 'back\\slash', 'tab\there', ' leading space', 'semi;colon#hash']
subprocess.run(['git', 'config', '--file', str(repo / '.git/config'), 'http.proxy', 'evil'], check=True)
for value in values:
subprocess.run(['git', 'config', '--file', str(repo / '.git/config'), '--add', 'submodule.lib.url', value],
check=True)
lib.sanitize_git_config(repo)
result = git(repo, 'config', '--file', '.git/config', '--get-all', 'submodule.lib.url')
assert result.split('\n') == values
assert 'proxy' not in (repo / '.git/config').read_text()
def test_marker_and_config_do_not_follow_links(tmp_path):
"""A clone (e.g. one being imported) can hold links; WROLPi never writes through them."""
repo = tmp_path / 'repo'
git_dir = repo / '.git'
git_dir.mkdir(parents=True)
victim = tmp_path / 'victim.txt'
victim.write_text('precious')
(git_dir / lib.REPO_MARKER).symlink_to(victim)
lib.mark_clone(repo, 'a1b2c3')
assert victim.read_text() == 'precious'
assert not (git_dir / lib.REPO_MARKER).is_symlink()
assert (git_dir / lib.REPO_MARKER).read_text().strip() == 'a1b2c3'
# A git directory holding any link is refused.
(git_dir / 'config').write_text('[http]\n\tproxy = evil\n[core]\n\tbare = false\n')
(git_dir / 'config.wrolpi').symlink_to(victim)
with pytest.raises(ValueError, match='link'):
lib.sanitize_git_config(repo)
assert victim.read_text() == 'precious'
(git_dir / 'config.wrolpi').unlink()
lib.sanitize_git_config(repo)
assert 'proxy' not in (git_dir / 'config').read_text()
# A config which is a link is refused.
(git_dir / 'config').unlink()
(git_dir / 'config').symlink_to(victim)
with pytest.raises(ValueError):
lib.sanitize_git_config(repo)
assert victim.read_text() == 'precious'
@pytest.mark.asyncio
async def test_import_refuses_linked_config(test_session, test_directory, async_client, test_wrolpi_config):
from modules.repos.errors import InvalidRepo
directory = test_directory / 'code/linked'
git(test_directory, 'init', '--quiet', 'code/linked')
(directory / '.git/config').unlink()
(directory / '.git/config').symlink_to(test_directory / 'elsewhere')
with pytest.raises(InvalidRepo, match='config'):
lib.inspect_import(test_session, 'code/linked')
@pytest.mark.asyncio
async def test_import_directory_is_not_an_oracle(test_session, test_directory, async_client, test_wrolpi_config,
tmp_path_factory):
"""A link in the media directory cannot reveal whether a path outside it exists."""
from modules.repos.errors import InvalidRepo
outside = tmp_path_factory.mktemp('outside')
(outside / 'exists').mkdir()
(test_directory / 'code').mkdir()
(test_directory / 'code/link').symlink_to(outside)
for directory in ('code/link/exists', 'code/link/missing'):
with pytest.raises(InvalidRepo, match='must be in the media directory'):
lib.resolve_import_directory(test_session, directory)
def test_git_command_disables_automatic_programs():
cmd = lib.git_command('rev-parse', 'HEAD')
for setting in ('gc.auto=0', 'maintenance.auto=false', 'core.alternateRefsCommand=', 'core.fsmonitor=false',
'core.hooksPath=/dev/null'):
assert setting in cmd, setting
@pytest.mark.parametrize('planted', ['commondir', 'objects/info/alternates'])
@pytest.mark.asyncio
async def test_clone_cannot_borrow_another_git_directory(test_session, test_directory, async_client,
test_wrolpi_config, git_remote_factory,
repos_download_manager, planted):
"""`.git/commondir` makes git read another directory's config (bypassing the sanitized one), and alternates
read its objects; neither is in a clone WROLPi makes, so neither is imported or updated."""
from modules.repos.errors import InvalidRepo
elsewhere = test_directory / 'elsewhere'
elsewhere.mkdir()
# Import.
git(test_directory, 'init', '--quiet', 'code/borrowing')
(test_directory / 'code/borrowing/.git' / planted).write_text(f'{elsewhere}\n')
with pytest.raises(InvalidRepo, match='another git directory'):
lib.inspect_import(test_session, 'code/borrowing')
# Update.
remote = git_remote_factory()
repo = lib.create_repository(test_session, remote.url)
await repos_download_manager.wait_for_all_downloads()
(repo.directory / '.git' / planted).write_text(f'{elsewhere}\n')
download = await update(test_session, repos_download_manager)
assert download.status == DownloadStatus.deferred
assert 'another git directory' in download.error
@pytest.mark.parametrize('plant', ['config-link', 'commondir', 'alternates', 'modules-link', 'gitdir-link'])
def test_submodule_git_directories_are_checked_too(tmp_path, plant):
"""A submodule's git directory is held to the same rules as the repo's: no linked config, no borrowing."""
repo = tmp_path / 'repo'
git_dir = repo / '.git'
git_dir.mkdir(parents=True)
(git_dir / 'config').write_text('[core]\n\tbare = false\n')
victim = tmp_path / 'victim'
victim.mkdir()
(victim / 'config').write_text('[core]\n\tfsmonitor = evil\n')
module = git_dir / 'modules/library'
(module / 'objects').mkdir(parents=True)
(module / 'HEAD').write_text('ref: refs/heads/main\n')
(module / 'config').write_text('[core]\n\tbare = false\n')
# A ref named HEAD is not a git directory.
(module / 'refs/remotes/origin').mkdir(parents=True)
(module / 'refs/remotes/origin/HEAD').write_text('ref: refs/remotes/origin/main\n')
if plant == 'config-link':
(module / 'config').unlink()
(module / 'config').symlink_to(victim / 'config')
elif plant == 'commondir':
(module / 'commondir').write_text(f'{victim}\n')
elif plant == 'alternates':
(module / 'objects/info').mkdir()
(module / 'objects/info/alternates').write_text(f'{victim}\n')
elif plant == 'modules-link':
import shutil
shutil.rmtree(git_dir / 'modules')
(git_dir / 'modules').symlink_to(victim)
elif plant == 'gitdir-link':
(git_dir / 'modules/other').symlink_to(victim)
with pytest.raises(ValueError):
lib.sanitize_git_config(repo)
assert (victim / 'config').read_text() == '[core]\n\tfsmonitor = evil\n'
def test_submodule_git_directories_pass(tmp_path):
repo = tmp_path / 'repo'
git_dir = repo / '.git'
module = git_dir / 'modules/library'
(module / 'objects').mkdir(parents=True)
(module / 'HEAD').write_text('ref: refs/heads/main\n')
(module / 'config').write_text('[core]\n\tbare = false\n[http]\n\tproxy = evil\n')
(module / 'refs/remotes/origin').mkdir(parents=True)
(module / 'refs/remotes/origin/HEAD').write_text('ref: refs/remotes/origin/main\n')
(git_dir / 'config').write_text('[core]\n\tbare = false\n')
lib.sanitize_git_config(repo)
assert 'proxy' not in (module / 'config').read_text()
@pytest.mark.parametrize('name', ['library', 'refs', 'vendor/library'])
def test_submodule_git_directory_without_objects_is_refused(tmp_path, name):
"""A git directory which borrows another (commondir) has no objects of its own; it is refused, not skipped,
whatever the submodule is named."""
repo = tmp_path / 'repo'
git_dir = repo / '.git'
module = git_dir / 'modules' / name
module.mkdir(parents=True)
(git_dir / 'config').write_text('[core]\n\tbare = false\n')
(module / 'HEAD').write_text('ref: refs/heads/main\n')
(module / 'commondir').write_text(f'{tmp_path}\n')
with pytest.raises(ValueError):
lib.sanitize_git_config(repo)
def test_nested_submodule_git_directories(tmp_path):
"""A nested submodule's git directory is checked too; refs and logs named HEAD are not git directories."""
repo = tmp_path / 'repo'
git_dir = repo / '.git'
(git_dir).mkdir(parents=True)
(git_dir / 'config').write_text('[core]\n\tbare = false\n')
outer = git_dir / 'modules/library'
inner = outer / 'modules/nested'
for gitdir in (outer, inner):
(gitdir / 'objects').mkdir(parents=True)
(gitdir / 'HEAD').write_text('ref: refs/heads/main\n')
(gitdir / 'config').write_text('[core]\n\tbare = false\n[http]\n\tproxy = evil\n')
(gitdir / 'refs/remotes/origin').mkdir(parents=True)
(gitdir / 'refs/remotes/origin/HEAD').write_text('ref: refs/remotes/origin/main\n')
(gitdir / 'logs').mkdir()
(gitdir / 'logs/HEAD').write_text('0000 1111 t <t@t> 0 +0000\tclone\n')
lib.sanitize_git_config(repo)
assert 'proxy' not in (outer / 'config').read_text()
assert 'proxy' not in (inner / 'config').read_text()
(inner / 'commondir').write_text(f'{tmp_path}\n')
with pytest.raises(ValueError):
lib.sanitize_git_config(repo)
@pytest.mark.asyncio
async def test_copied_clone_is_not_owned(test_session, test_directory, git_remote_factory, repos_download_manager):
"""A clone with a marker WROLPi did not write (e.g. copied from another WROLPi, or made by hand) is not owned:
the marker must hold this Repo's own token."""
remote = git_remote_factory()
directory = test_directory / 'repos/example'
directory.parent.mkdir(parents=True)
git(directory.parent, 'clone', '--quiet', str(remote.path), str(directory))
# What a marker used to hold, which anyone could write.
(directory / '.git' / lib.REPO_MARKER).write_text(lib.repo_url_key(remote.url))
(directory / 'my-work.txt').write_text('uncommitted')
lib.create_repository(test_session, remote.url)
await repos_download_manager.wait_for_all_downloads()
download = test_session.query(Download).one()
assert download.status == DownloadStatus.deferred
assert 'was not created by WROLPi' in download.error
assert (directory / 'my-work.txt').exists()
@pytest.mark.asyncio
async def test_clone_token_survives_the_config(test_session, async_client, git_remote_factory,
repos_download_manager, await_switches):
"""The token is in repos.yaml, so a Repo restored from the config still owns its clone."""
remote = git_remote_factory()
repo = lib.create_repository(test_session, remote.url)
await repos_download_manager.wait_for_all_downloads()
await await_switches()
token = repo.clone_token
config = lib.get_repos_config()
assert config.repos[0]['clone_token'] == token
# Lose the database, restore it from the config.
lib._delete_repository(test_session, repo)
test_session.commit()
config.import_config()
test_session.expire_all()
restored = test_session.query(lib.Repository).one()
assert restored.clone_token == token
head = remote.commit({'new.txt': 'new'}, 'New')
download = await update(test_session, repos_download_manager)
assert download.status == DownloadStatus.complete, download.error
assert test_session.query(lib.Repository).one().head_sha == head
@pytest.mark.parametrize('plant', ['outside', 'directory', 'link', 'garbage'])
def test_working_tree_git_files(tmp_path, plant):
"""A submodule's `.git` (in the working tree) decides which git directory git uses for it; it must be a file
pointing inside the repo's own .git/modules."""
repo = tmp_path / 'repo'
_real_submodule_git_dir(repo / '.git/modules/library')
(repo / 'vendor/library').mkdir(parents=True)
(repo / 'vendor/library/.git').write_text('gitdir: ../../.git/modules/library\n')
assert lib.working_tree_git_error(repo) is None
evil = tmp_path / 'evil'
evil.mkdir()
(repo / 'other').mkdir()
target = repo / 'other/.git'
if plant == 'outside':
target.write_text(f'gitdir: {evil}\n')
elif plant == 'directory':
target.mkdir()
elif plant == 'link':
target.symlink_to(evil)
elif plant == 'garbage':
target.write_text('not a gitdir line\n')
assert lib.working_tree_git_error(repo)
@pytest.mark.parametrize('submodules', [True, False])
@pytest.mark.asyncio
async def test_update_refuses_planted_submodule_git_file(test_session, test_directory, git_remote_factory,
repos_download_manager, submodules):
"""Submodule commands (update, and deinit when submodules are turned off) never use a git directory outside
the repo."""
library = git_remote_factory('library')
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()
assert (repo.directory / 'vendor/library/lib.c').exists() or (repo.directory / 'vendor/library/README.md').exists()
evil = test_directory / 'evil-gitdir'
evil.mkdir()
(repo.directory / 'vendor/library/.git').write_text(f'gitdir: {evil}\n')
lib.update_repository(test_session, repo.id, submodules=submodules)
download = await update(test_session, repos_download_manager)
assert download.status == DownloadStatus.deferred
assert 'outside' in download.error
assert list(evil.iterdir()) == []
def _real_submodule_git_dir(path):
(path / 'objects').mkdir(parents=True)
(path / 'refs').mkdir()
(path / 'HEAD').write_text('ref: refs/heads/main\n')
(path / 'config').write_text('[core]\n\tbare = false\n')
def test_planted_head_does_not_hide_a_submodule_git_dir(tmp_path):
"""git finds a submodule's git directory at .git/modules/<name>, however deep; a git directory planted on a path
prefix (or anywhere else) cannot hide it from being sanitized."""
repo = tmp_path / 'repo'
(repo / '.git').mkdir(parents=True)
(repo / '.git/config').write_text('[core]\n\tbare = false\n')
_real_submodule_git_dir(repo / '.git/modules/vendor')
real = repo / '.git/modules/vendor/library'
_real_submodule_git_dir(real)
(real / 'config').write_text('[protocol "file"]\n\tallow = always\n[url "file:///tmp/evil/"]\n'
'\tinsteadOf = https://example.com/\n')
hidden = repo / '.git/modules/vendor/refs/library'
_real_submodule_git_dir(hidden)
(hidden / 'config').write_text('[http]\n\tproxy = evil\n')
lib.sanitize_git_config(repo)
assert 'protocol' not in (real / 'config').read_text()
assert 'insteadOf' not in (real / 'config').read_text()
assert 'proxy' not in (hidden / 'config').read_text()
# A submodule's `.git` must point at one of the (sanitized) git directories.
(repo / 'vendor/library').mkdir(parents=True)
(repo / 'vendor/library/.git').write_text('gitdir: ../../.git/modules/vendor/library\n')
assert lib.working_tree_git_error(repo) is None
(repo / 'vendor/library/.git').write_text('gitdir: ../../.git/modules/vendor/library/objects\n')
assert lib.working_tree_git_error(repo)
@pytest.mark.parametrize('link', ['objects', 'refs', 'logs', 'packed-refs', 'refs/remotes', 'objects/pack'])
@pytest.mark.asyncio
async def test_links_in_git_dir_are_refused(test_session, test_directory, async_client, test_wrolpi_config,
git_remote_factory, repos_download_manager, link):
"""git writes through a link in a git directory (fetch writes objects, refs and logs), so a git directory
holding one is neither imported nor updated."""
from modules.repos.errors import InvalidRepo
import shutil
def plant(git_dir):
target = git_dir / link
outside = test_directory / f'outside-{link.replace("/", "-")}-{git_dir.parent.name}'
if target.exists():
shutil.move(target, outside)
else:
outside.write_text('')
target.symlink_to(outside)
git(test_directory, 'init', '--quiet', 'code/linked')
git(test_directory / 'code/linked', 'commit', '--quiet', '--allow-empty', '-m', 'x')
git(test_directory / 'code/linked', 'pack-refs', '--all')
git(test_directory / 'code/linked', 'gc', '--quiet')
plant(test_directory / 'code/linked/.git')
with pytest.raises(InvalidRepo, match='link'):
lib.inspect_import(test_session, 'code/linked')
remote = git_remote_factory()
repo = lib.create_repository(test_session, remote.url)
await repos_download_manager.wait_for_all_downloads()
git(repo.directory, 'pack-refs', '--all')
git(repo.directory, 'gc', '--quiet')
plant(repo.directory / '.git')
download = await update(test_session, repos_download_manager)
assert download.status == DownloadStatus.deferred
assert 'link' in download.error
@pytest.mark.asyncio
async def test_control_characters_in_directories_are_refused(test_session, test_directory, async_client,
test_wrolpi_config):
"""A newline would split a line git reads (e.g. alternates)."""
from modules.repos.errors import InvalidRepo
for directory in ('foo\n/tmp/abs', 'foo\rbar', 'tab\there'):
with pytest.raises(InvalidRepo, match='character'):
lib.resolve_new_repo_directory(test_session, directory)
@pytest.mark.parametrize('link_at', ['directory', 'parent'])
@pytest.mark.asyncio
async def test_clone_refuses_links_made_after_adding(test_session, test_directory, async_client, test_wrolpi_config,
git_remote_factory, repos_download_manager, tmp_path_factory,
link_at):
"""A repo's directory (or a parent) which became a link after the repo was added is not cloned into."""
remote = git_remote_factory()
lib.create_repository(test_session, remote.url, directory='projects/example')
outside = tmp_path_factory.mktemp('outside')
if link_at == 'directory':
(test_directory / 'projects').mkdir()
(test_directory / 'projects/example').symlink_to(outside)
else:
(test_directory / 'projects').symlink_to(outside)
await repos_download_manager.wait_for_all_downloads()
download = test_session.query(Download).one()
assert download.status == DownloadStatus.deferred
assert 'link' in download.error
assert list(outside.iterdir()) == []
@pytest.mark.asyncio
async def test_refused_sanitize_changes_nothing(test_session, test_directory, async_client, test_wrolpi_config):
"""Every refusal is found before any config is rewritten."""
from modules.repos.errors import InvalidRepo
git(test_directory, 'init', '--quiet', 'code/clone')
directory = test_directory / 'code/clone'
git(directory, 'commit', '--quiet', '--allow-empty', '-m', 'x')
git(directory, 'remote', 'add', 'origin', 'https://git.example.com/owner/clone')
with (directory / '.git/config').open('a') as fh:
fh.write('[http]\n\tproxy = http://127.0.0.1:9\n')
module = directory / '.git/modules/library'
_real_submodule_git_dir(module)
# Found only after the clone's own config was checked.
(module / 'commondir').write_text('../..\n')
before = (directory / '.git/config').read_text()
with pytest.raises(InvalidRepo):
await lib.import_repository(test_session, 'code/clone', confirm=True)
assert (directory / '.git/config').read_text() == before

View file

@ -0,0 +1,193 @@
import json
import sqlite3
import time
from http import HTTPStatus
import pytest
from modules.repos import lib
from modules.repos.models import Repository
def set_readme(session, repo: Repository, readme_text: str):
repo.readme_text = readme_text
session.commit()
@pytest.mark.asyncio
async def test_search_repos(test_session, async_client, test_wrolpi_config):
"""Repos are found by their name, owner, and README; a name match outranks a README mention."""
kiwix = lib.create_repository(test_session, 'https://github.com/kiwix/kiwix-tools', tag_name='software')
maps = lib.create_repository(test_session, 'https://github.com/protomaps/basemaps')
set_readme(test_session, kiwix, 'Kiwix tools serve Zim files offline.')
set_readme(test_session, maps, 'Vector basemaps. Works with kiwix-tools and offline maps.')
def names(search_str=None, tag_names=None):
repos, total = lib.search_repos(test_session, search_str, tag_names)
assert total == len(repos)
return [i['name'] for i in repos]
assert names('kiwix') == ['kiwix-tools', 'basemaps']
assert names('protomaps') == ['basemaps'] # The owner.
assert names('vector') == ['basemaps']
assert set(names('offline')) == {'kiwix-tools', 'basemaps'}
assert names('zim -vector') == ['kiwix-tools']
assert names('nothing') == []
repos, _ = lib.search_repos(test_session, 'vector')
assert repos[0]['readme_headline'] == '<b>Vector</b> basemaps. Works with kiwix-tools and offline maps.'
# Tags.
assert names('offline', ['software']) == ['kiwix-tools']
assert names(tag_names=['software']) == ['kiwix-tools']
assert names('offline', ['software', 'other']) == []
assert names('offline', ['other']) == []
# Nothing to search.
assert names() == []
assert names('!!!') == []
assert lib.count_repos(test_session, 'offline') == 2
assert lib.count_repos(test_session, 'offline', ['software']) == 1
assert lib.count_repos(test_session) == 0
@pytest.mark.asyncio
async def test_search_index_follows_changes(test_session, test_directory, async_client, test_wrolpi_config):
"""The search index follows README changes, renames from the config, and deletes."""
repo = lib.create_repository(test_session, 'https://github.com/a/b')
set_readme(test_session, repo, 'first readme')
assert lib.count_repos(test_session, 'first') == 1
set_readme(test_session, repo, 'second readme')
assert lib.count_repos(test_session, 'first') == 0
assert lib.count_repos(test_session, 'second') == 1
lib.ReposConfig._import_repo(test_session, dict(url='https://github.com/a/b', name='renamed'))
test_session.commit()
assert lib.count_repos(test_session, 'renamed') == 1
await lib.delete_repository(test_session, repo.id)
assert lib.count_repos(test_session, 'second') == 0
assert lib.count_repos(test_session, 'renamed') == 0
# The index matches its content.
with sqlite3.connect(test_directory / 'config/wrolpi.db') as conn:
conn.execute("INSERT INTO repository_fts(repository_fts, rank) VALUES('integrity-check', 1)")
@pytest.mark.asyncio
async def test_downloader_indexes_readme(test_session, git_remote_factory, repos_download_manager):
"""The downloader stores the README as plain text, and keeps it current."""
readme = '# Example\n\n[![CI](https://ci/badge.svg)](https://ci) An **offline** [library](https://docs).\n'
remote = git_remote_factory(files={'README.md': readme})
repo = lib.create_repository(test_session, remote.url)
await repos_download_manager.wait_for_all_downloads()
test_session.expire_all()
assert repo.readme_text == 'Example\nCI An offline library.'
assert lib.count_repos(test_session, 'library') == 1
remote.git('rm', '--quiet', 'README.md')
remote.commit({'readme.txt': 'Plain *text*'}, 'Plain README')
download = repo.download
download.renew()
test_session.commit()
await repos_download_manager.wait_for_all_downloads()
test_session.expire_all()
assert repo.readme_path == 'readme.txt'
assert repo.readme_text == 'Plain *text*'
assert lib.count_repos(test_session, 'library') == 0
@pytest.mark.asyncio
async def test_search_api(test_session, async_client, test_wrolpi_config):
kiwix = lib.create_repository(test_session, 'https://github.com/kiwix/kiwix-tools', tag_name='software')
set_readme(test_session, kiwix, 'Serve Zim files offline.')
lib.create_repository(test_session, 'https://github.com/a/b')
body = dict(search_str='zim')
request, response = await async_client.post('/api/repos/search', content=json.dumps(body))
assert response.status_code == HTTPStatus.OK
assert response.json['totals'] == dict(repos=1)
repo, = response.json['repos']
assert repo['name'] == 'kiwix-tools'
assert repo['readme_headline'] == 'Serve <b>Zim</b> files offline.'
assert 'readme_text' not in repo
# The global search's "Other" estimate counts Repos by the search, and by Tag.
request, response = await async_client.post('/api/search_other_estimates', content=json.dumps(body))
assert response.json['others'] == dict(channel_count=0, repo_count=1)
body = dict(tag_names=['software'])
request, response = await async_client.post('/api/search_other_estimates', content=json.dumps(body))
assert response.json['others'] == dict(channel_count=0, repo_count=1)
body = dict(tag_names=['software'], search_str='nothing')
request, response = await async_client.post('/api/search_other_estimates', content=json.dumps(body))
assert response.json['others'] == dict(channel_count=0, repo_count=0)
# Repos are suggested by name.
request, response = await async_client.post('/api/search_suggestions', content=json.dumps(dict(search_str='kiw')))
assert response.json['repos'] == [
dict(id=kiwix.id, name='kiwix-tools', tag_name='software', location=f'/repos/{kiwix.id}')]
@pytest.mark.asyncio
async def test_readme_headline_is_escaped(test_session, async_client, test_wrolpi_config):
"""A README comes from an untrusted remote; its headline is rendered as HTML, so only the highlight is markup."""
repo = lib.create_repository(test_session, 'https://github.com/a/b')
set_readme(test_session, repo, 'zim <img src=x onerror="alert(1)"> &amp; <b>bold</b>')
repos, _ = lib.search_repos(test_session, 'zim')
assert repos[0]['readme_headline'] == \
'<b>zim</b> &lt;img src=x onerror=&quot;alert(1)&quot;&gt; &amp;amp; &lt;b&gt;bold&lt;/b&gt;'
def test_read_readme_text_refuses_symlinks(tmp_path):
secret = tmp_path / 'secret.txt'
secret.write_text('secret')
readme = tmp_path / 'README.md'
readme.symlink_to(secret)
assert lib.read_readme_text(readme) is None
assert lib.find_readme(tmp_path) is None
@pytest.mark.parametrize('text', [
pytest.param('|' + ' ' * 100_000 + 'x', id='table-spaces'),
pytest.param('-' * 100_000 + 'x', id='dashes'),
pytest.param('<!--' * 25_000, id='unclosed-comments'),
pytest.param('![' * 50_000, id='image-brackets'),
pytest.param('[' * 50_000 + '(' * 50_000, id='link-brackets'),
pytest.param('<' * 100_000, id='angles'),
pytest.param('\n' + ' \n' * 50_000 + 'x', id='blank-lines'),
pytest.param('#' * 100_000, id='hashes'),
pytest.param('> ' * 50_000, id='quotes'),
])
def test_markdown_to_text_is_linear(text):
"""A README is untrusted; converting the largest README must never take long.
Each case takes milliseconds; the patterns this replaced took minutes. The bound is loose so a busy machine
does not fail it."""
start = time.monotonic()
lib.markdown_to_text(text)
assert time.monotonic() - start < 5
def test_markdown_to_text():
readme = '''# Kiwix Tools <!-- hidden
comment -->
[![CI](https://x/badge.svg)](https://ci) **Kiwix** is an *offline* reader. See [the docs](https://docs).
| a | b |
|---|---|
| 1 | 2 |
```bash
kiwix-serve --port 80
```
> quoted
[ref]: https://example.com
---
#hashtag
'''
assert lib.markdown_to_text(readme) == ('Kiwix Tools\nCI Kiwix is an offline reader. See the docs.'
'\na b\n1 2\nkiwix-serve --port 80\nquoted\n#hashtag')

View file

@ -67,6 +67,7 @@ CPUFREQ_SET_BIN = which('cpufreq-set', '/usr/bin/cpufreq-set')
# Files
WGET_BIN = which('wget', '/usr/bin/wget')
GIT_BIN = which('git', '/usr/bin/git')
# Map
BASH_BIN = which('bash', '/bin/bash')
@ -184,7 +185,7 @@ TESTING_RUN_COMMAND_RESULT = None
async def run_command(cmd: tuple[str | pathlib.Path, ...], cwd: pathlib.Path | str = None,
timeout: int = 600, log_command: bool = True,
stdout_callback: callable = None, env: dict = None,
start_new_session: bool = False) -> CommandResult:
start_new_session: bool = False, merge_stderr: bool = False) -> CommandResult:
"""Run a shell command, return the results (stdout/stderr/return code).
When stdout_callback is provided, stdout is piped and each line is passed to the callback
@ -196,6 +197,8 @@ async def run_command(cmd: tuple[str | pathlib.Path, ...], cwd: pathlib.Path | s
:param start_new_session: Run the command in its own session/process group; when it finishes
(or is killed by timeout/cancel), the whole group is killed. Use for commands that may
leave children behind (e.g. single-file's browser).
:param merge_stderr: Send stderr to stdout (and so to `stdout_callback`); `stderr` will be empty. Use for
commands that write progress to stderr (e.g. git).
"""
if not isinstance(cmd, (list, tuple)):
raise RuntimeError('Command must be a list or tuple')
@ -233,7 +236,7 @@ async def run_command(cmd: tuple[str | pathlib.Path, ...], cwd: pathlib.Path | s
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=stdout_arg,
stderr=stderr_fh,
stderr=asyncio.subprocess.STDOUT if merge_stderr else stderr_fh,
cwd=cwd,
env=env,
start_new_session=start_new_session,

View file

@ -11,7 +11,7 @@ from sqlalchemy import func
from sqlalchemy.orm import Session, selectinload, noload
from wrolpi import flags
from wrolpi.common import logger, get_relative_to_media_directory, TRACE_LEVEL, background_task
from wrolpi.common import get_media_directory, logger, get_relative_to_media_directory, TRACE_LEVEL, background_task
from wrolpi.db import get_db_session
from wrolpi.errors import FileWorkerConflict
from wrolpi.errors import UnknownCollection, ValidationError
@ -53,6 +53,9 @@ def check_config_imported(kind: str):
# Local import to avoid circular import: collections -> videos -> collections
from modules.videos.channel.lib import check_channels_config_imported
check_channels_config_imported()
elif kind == 'repo':
from modules.repos.lib import get_repos_config
get_repos_config().check_imported()
def _activate_config_save(kind: str):
@ -70,6 +73,10 @@ def _activate_config_save(kind: str):
elif kind == 'playlist':
from .config import save_playlists_config
save_playlists_config.activate_switch()
elif kind == 'repo':
# Local import to avoid circular import: collections -> repos -> collections
from modules.repos.lib import save_repos_config
save_repos_config.activate_switch()
def get_collections(session: Session, kind: Optional[str] = None) -> List[dict]:
@ -571,11 +578,16 @@ def refresh_collection(collection_id: int, send_events: bool = True) -> None:
Events.send_directory_refresh(f'Refreshing: {relative_dir}')
async def _background_move_collection(collection_id: int, target_directory: pathlib.Path):
async def _background_move_collection(collection_id: int, target_directory: pathlib.Path,
old_tag_id: Optional[int] = None,
created_directories: List[pathlib.Path] = None):
"""Helper to run collection move in background with its own database session.
This allows the move to continue even if the original HTTP request is cancelled
(e.g., user closes browser tab).
A failed move leaves the Collection as it was: its directory (reverted by `move_collection`), its tag, and no
directories made for the move (`created_directories`, which are only removed while empty).
"""
try:
with get_db_session(commit=True) as session:
@ -584,7 +596,16 @@ async def _background_move_collection(collection_id: int, target_directory: path
logger.error(f'_background_move_collection: collection {collection_id} not found')
Events.send_file_move_failed(f'Collection move failed: collection not found')
return
try:
await collection.move_collection(target_directory, session, send_events=True)
except Exception:
collection.tag_id = old_tag_id
session.commit()
_activate_config_save(collection.kind)
for directory in sorted(created_directories or [], key=lambda i: len(i.parts), reverse=True):
if directory.is_dir() and not directory.is_symlink() and not any(directory.iterdir()):
directory.rmdir()
raise
except Exception as e:
logger.error(f'_background_move_collection: failed for collection_id={collection_id}', exc_info=e)
Events.send_file_move_failed(f'Collection move failed: {e}')
@ -616,8 +637,9 @@ async def tag_collection(
collection = Collection.find_by_id(session, collection_id)
check_config_imported(collection.kind)
# Track old directory before any changes for potential file moving
# Track old directory and tag before any changes, so a failed move can restore them.
old_directory = collection.directory
old_tag_id = collection.tag_id
# Determine target directory. Collections can be tagged without a directory for UI
# search/filtering, so a missing directory is left missing.
@ -637,6 +659,12 @@ async def tag_collection(
logger.trace(f'tag_collection: {repr(collection.name)} need_to_move={need_to_move}, '
f'{old_directory} -> {target_directory}')
if need_to_move and collection.kind == 'repo':
# A Repo's clone is moved as one directory; never through a link (checked before any directory is made).
from modules.repos.lib import repo_directory_error
if error := repo_directory_error(target_directory):
raise ValidationError(f'Cannot move the Repo: {error}')
if need_to_move and flags.file_worker_busy.is_set():
raise FileWorkerConflict('Refusing to move collection while FileWorker is busy')
@ -656,10 +684,14 @@ async def tag_collection(
# Move files if directory changed - run in background so closing tab won't cancel it
if need_to_move:
media_directory = get_media_directory()
created_directories = [i for i in (target_directory, *target_directory.parents)
if media_directory in i.parents and not i.exists()]
target_directory.mkdir(parents=True, exist_ok=True)
# Commit tag changes before starting background move
session.commit()
background_task(_background_move_collection(collection.id, target_directory))
background_task(_background_move_collection(collection.id, target_directory, old_tag_id,
created_directories))
relative_dir = get_relative_to_media_directory(target_directory) if target_directory else None
return {

View file

@ -1,4 +1,8 @@
import asyncio
import errno
import os
import pathlib
import shutil
from typing import Optional, List
from sqlalchemy import Column, Integer, String, ForeignKey, Text, DateTime, Index, UniqueConstraint, func, BigInteger, \
@ -13,7 +17,7 @@ from wrolpi.db import serializer
from wrolpi.downloader import Download, save_downloads_config
from wrolpi.errors import ValidationError
from wrolpi.events import Events
from wrolpi.files.lib import delete_directory
from wrolpi.files.lib import delete_directory, remove_files_in_ignored_directories
from wrolpi.files.models import FileGroup
from wrolpi.files.worker import file_worker
from wrolpi.media_path import MediaPathType
@ -37,11 +41,14 @@ def validate_collection_directory(directory: pathlib.Path) -> pathlib.Path:
Normalized absolute path under media directory
Raises:
ValidationError: If absolute path is outside media directory
ValidationError: If absolute path is outside media directory, or the path contains ".."
"""
media_directory = get_media_directory()
directory = pathlib.Path(directory)
if '..' in directory.parts:
# Would leave the media directory.
raise ValidationError(f'Collection directory cannot contain "..": {directory}')
if not directory.is_absolute():
# Relative path - make absolute under media directory
directory = media_directory / directory
@ -58,6 +65,21 @@ def validate_collection_directory(directory: pathlib.Path) -> pathlib.Path:
return directory
def _move_directory(source: pathlib.Path, destination: pathlib.Path):
"""Move `source` to `destination`, which must not exist, or be an empty directory."""
if destination.is_dir() and not destination.is_symlink():
destination.rmdir() # Raises if it is not empty.
elif destination.exists() or destination.is_symlink():
raise FileExistsError(f'Cannot move: {destination} already exists')
try:
os.rename(source, destination)
except OSError as e:
if e.errno != errno.EXDEV:
raise
# Another filesystem.
shutil.move(source, destination)
class Collection(ModelHelper, Base):
"""
A Collection is a grouping of FileGroups (videos, archives, docs, etc).
@ -1015,6 +1037,11 @@ class Collection(ModelHelper, Base):
"""
from wrolpi.common import get_wrolpi_config, escape_file_name
if self.kind == 'repo':
# Local import to avoid circular import: collections -> repos -> collections
from modules.repos.lib import format_collection_destination
return format_collection_destination(self, tag_name)
config = get_wrolpi_config()
if self.kind == 'channel':
@ -1076,6 +1103,9 @@ class Collection(ModelHelper, Base):
elif self.kind == 'channel':
from modules.videos.lib import save_channels_config
save_channels_config.activate_switch()
elif self.kind == 'repo':
from modules.repos.lib import save_repos_config
save_repos_config.activate_switch()
return destination
@ -1126,6 +1156,17 @@ class Collection(ModelHelper, Base):
file_worker.queue_refresh([old_directory, directory])
if send_events:
Events.send_file_move_completed(f'Collection {repr(self.name)} was moved (directory missing)')
elif self.kind == 'repo':
# A Repo's files are not FileGroups (repos/ is ignored), so its clone is moved as one directory,
# and indexed only as a refresh would index it.
from modules.repos.lib import repo_directory_error
if error := repo_directory_error(directory):
raise ValidationError(f'Cannot move the Repo: {error}')
await asyncio.to_thread(_move_directory, old_directory, directory)
if refresh := remove_files_in_ignored_directories([old_directory, directory]):
file_worker.queue_refresh(refresh)
if send_events:
Events.send_file_move_completed(f'Collection {repr(self.name)} was moved')
else:
files_to_move = list(old_directory.iterdir())
if __debug__ and logger.isEnabledFor(TRACE_LEVEL):
@ -1146,7 +1187,11 @@ class Collection(ModelHelper, Base):
raise
finally:
session.commit()
if old_directory.exists() and not next(iter(old_directory.iterdir()), None):
# The move keeps each moved directory (e.g. a repo's `.git`), even when emptied; delete the old
# directory when only empty directories remain.
if old_directory.exists() and not any(not i.is_dir() for i in old_directory.rglob('*')):
for directory in sorted(old_directory.rglob('*'), key=lambda i: len(i.parts), reverse=True):
delete_directory(directory)
delete_directory(old_directory)
else:
# Files already moved, just send event

View file

@ -488,3 +488,45 @@ async def test_tag_domain_comprehensive(
# Download was not changed
assert download.destination == original_directory, f'{download} should not have moved'
@pytest.mark.asyncio
async def test_failed_tag_move_reverts_tag(test_session: Session, test_directory: pathlib.Path, archive_factory,
async_client, tag_factory, await_background_tasks, monkeypatch):
"""When the move fails, the Collection keeps its old tag (as it keeps its old directory), and the directory made
for the move is removed."""
from wrolpi.files import move
src_dir = test_directory / 'archive/Old/example.com'
src_dir.mkdir(parents=True)
old_tag = await tag_factory('Old')
await tag_factory('News')
collection = Collection(name='example.com', kind='domain', directory=src_dir, tag_id=old_tag.id)
test_session.add(collection)
test_session.flush([collection])
(src_dir / 'page.html').write_text('<html></html>')
test_session.commit()
async def fail(*args, **kwargs):
raise RuntimeError('planning failed')
monkeypatch.setattr(move, 'build_move_plan_bulk', fail)
body = {'tag_name': 'News', 'directory': 'archive/News/example.com'}
request, response = await async_client.post(f'/api/collections/{collection.id}/tag', json=body)
assert response.status_code == HTTPStatus.OK, response.content.decode()
await await_background_tasks()
test_session.expire_all()
assert collection.directory == src_dir
assert collection.tag_name == 'Old'
assert (src_dir / 'page.html').is_file()
assert not (test_directory / 'archive/News/example.com').exists()
@pytest.mark.parametrize('directory', ['../escape', 'archive/../../escape', '{media}/../escape'])
def test_validate_collection_directory_refuses_parent_parts(test_directory: pathlib.Path, directory):
"""A Collection's directory cannot leave the media directory with `..`."""
from wrolpi.collections.models import validate_collection_directory
from wrolpi.errors import ValidationError
with pytest.raises(ValidationError):
validate_collection_directory(directory.format(media=test_directory))
assert validate_collection_directory('archive/example.com') == test_directory / 'archive/example.com'

View file

@ -141,3 +141,9 @@ collection_type_registry.register(
validate_channel_name, # Permissive: any non-empty name.
'Playlist name must be a non-empty string'
)
collection_type_registry.register(
'repo',
validate_channel_name, # Permissive: any non-empty name.
'Repo name must be a non-empty string'
)

View file

@ -43,7 +43,7 @@ from wrolpi.dates import now, from_timestamp
from wrolpi.errors import WROLModeEnabled, NativeOnly, LogLevelError, InvalidConfig, ConfigNotImported, \
ValidationError
from wrolpi.log_levels import TRACE_LEVEL
from wrolpi.vars import PYTEST, DOCKERIZED, CONFIG_DIR, MEDIA_DIRECTORY, DEFAULT_HTTP_HEADERS
from wrolpi.vars import PYTEST, DOCKERIZED, CONFIG_DIR, MEDIA_DIRECTORY, DEFAULT_HTTP_HEADERS, REPOS_DIRECTORY
def add_logging_level(level_name: str, level_int: int, methodName=None):
@ -445,7 +445,7 @@ def get_media_directory() -> Path:
DB_CONFIG_FILE_NAMES = {'tags.yaml', 'channels.yaml', 'domains.yaml', 'download_manager.yaml',
'playlists.yaml'}
'playlists.yaml', 'repos.yaml'}
class ConfigFile:
@ -1117,6 +1117,7 @@ class WROLPiConfigValidator:
map_destination: str = None
nav_color: str = None
playlists_destination: str = None
repos_destination: str = None
require_cookies_unlocked: bool = None
require_media_mounted: bool = None
save_ffprobe_json: bool = None
@ -1185,6 +1186,10 @@ def get_all_configs() -> Dict[str, ConfigFile]:
if playlists_config := get_playlists_config():
all_configs[playlists_config.file_name] = playlists_config
from modules.repos.lib import get_repos_config
if repos_config := get_repos_config():
all_configs[repos_config.file_name] = repos_config
return all_configs
@ -1271,6 +1276,16 @@ async def import_all_db_configs() -> dict[str, bool]:
logger.warning(f'Failed to import playlists config: {e}')
results['playlists'] = False
# Repos (kind='repo' collections; uses tags, links to downloads)
try:
from modules.repos.lib import import_repos_config
await asyncio.to_thread(import_repos_config)
results['repos'] = True
logger.debug('repos config imported')
except Exception as e:
logger.warning(f'Failed to import repos config: {e}')
results['repos'] = False
# Map pins (YAML-only, no DB)
try:
from modules.map.pins import get_map_pins_config
@ -1329,12 +1344,13 @@ class WROLPiConfig(ConfigFile):
hotspot_ssid='WROLPi',
check_for_upgrades=True,
ignore_outdated_zims=False,
ignored_directories=['config', 'tags'],
ignored_directories=['config', 'tags', REPOS_DIRECTORY],
log_level='info',
map_default_location=None,
map_destination='map',
nav_color='violet',
playlists_destination='playlists',
repos_destination='repos/%(repo_tag)s/%(repo_name)s',
require_cookies_unlocked=True,
require_media_mounted=True,
save_ffprobe_json=True,
@ -1381,12 +1397,24 @@ class WROLPiConfig(ConfigFile):
self._config['zims_destination'] = self.zims_destination or self.default_config['zims_destination']
self._config['playlists_destination'] = \
self.playlists_destination or self.default_config['playlists_destination']
self._config['repos_destination'] = self.repos_destination or self.default_config['repos_destination']
# The playlists directory holds WROLPi-managed hardlinks/stubs; never index it.
ignored = list(self._config.get('ignored_directories') or [])
if self._config['playlists_destination'] not in ignored:
ignored.append(self._config['playlists_destination'])
self._config['ignored_directories'] = ignored
# Git repositories are browsed through the Repos module; their files are never indexed. Every repo is
# under the fixed directory at the start of the repos destination.
from modules.repos.lib import validate_repos_destination, repos_destination_root
if error := validate_repos_destination(self._config['repos_destination'], self):
logger.warning(f'Using the default repos destination: {error}')
self._config['repos_destination'] = self.default_config['repos_destination']
repos_root = repos_destination_root(self._config['repos_destination'])
ignored = list(self._config.get('ignored_directories') or [])
if repos_root not in ignored:
ignored.append(repos_root)
self._config['ignored_directories'] = ignored
self.successful_import = True
except Exception as e:
@ -1594,6 +1622,14 @@ class WROLPiConfig(ConfigFile):
def playlists_destination(self, value: str):
self.update({'playlists_destination': value})
@property
def repos_destination(self) -> str:
return self._config['repos_destination']
@repos_destination.setter
def repos_destination(self, value: str):
self.update({'repos_destination': value})
@property
def map_default_location(self) -> dict:
return self._config.get('map_default_location')
@ -2833,16 +2869,20 @@ def extract_headlines(entries: List[str], search_str: str) -> List[Tuple[str, fl
return fts.headline_texts(entries, search_str, tokens=8)
async def search_other_estimates(session: Session, tag_names: List[str]) -> dict:
"""Estimate other things that are Tagged."""
async def search_other_estimates(session: Session, tag_names: List[str], search_str: str = None) -> dict:
"""Estimate other things that are Tagged, or match the search (Repos by their name and README)."""
from sqlalchemy import func
from wrolpi.collections.models import Collection
from wrolpi.tags import Tag
from modules.videos.models import Channel
from modules.repos.lib import count_repos
repo_count = count_repos(session, search_str, tag_names)
if not tag_names:
return dict(
channel_count=0,
repo_count=repo_count,
)
# TODO handle multiple tags
@ -2854,6 +2894,7 @@ async def search_other_estimates(session: Session, tag_names: List[str]) -> dict
others = dict(
channel_count=channel_count,
repo_count=repo_count,
)
return others

View file

@ -33,6 +33,7 @@ def attach_shared_contexts(app: Sanic):
app.shared_ctx.download_manager_config = manager.dict()
app.shared_ctx.videos_downloader_config = manager.dict()
app.shared_ctx.domains_config = manager.dict()
app.shared_ctx.repos_config = manager.dict()
app.shared_ctx.archive_downloader_config = manager.dict()
app.shared_ctx.download_cache_config = manager.dict()
app.shared_ctx.map_pins_config = manager.dict()
@ -127,6 +128,7 @@ def reset_shared_contexts(app: Sanic):
app.shared_ctx.download_manager_config.clear()
app.shared_ctx.videos_downloader_config.clear()
app.shared_ctx.domains_config.clear()
app.shared_ctx.repos_config.clear()
app.shared_ctx.archive_downloader_config.clear()
app.shared_ctx.download_cache_config.clear()
# Shared dicts.
@ -295,6 +297,12 @@ def initialize_configs_contexts(app: Sanic):
except Exception as e:
logger.error(f'Failed to initialize in-memory domains config: {e}')
try:
from modules.repos.lib import repos_config
repos_config.initialize(app.shared_ctx.repos_config)
except Exception as e:
logger.error(f'Failed to initialize in-memory repos config: {e}')
try:
from modules.archive.lib import ARCHIVE_DOWNLOADER_CONFIG
ARCHIVE_DOWNLOADER_CONFIG.initialize(app.shared_ctx.archive_downloader_config)

View file

@ -499,7 +499,7 @@ class Downloader:
async def process_runner(self, download: Download, cmd: Tuple[str | pathlib.Path, ...], cwd: pathlib.Path,
timeout: int = None, debug: bool = True,
stdout_callback: callable = None, env: dict = None,
start_new_session: bool = False,
start_new_session: bool = False, merge_stderr: bool = False,
ctx: 'DownloadContext' = None) -> CommandResult:
"""
Run a subprocess using the provided arguments. This process can be killed by the Download Manager.
@ -522,7 +522,7 @@ class Downloader:
timeout = self.timeout
coro = ctx.run_command(cmd, cwd=cwd, timeout=timeout, log_command=debug,
stdout_callback=stdout_callback, env=env,
start_new_session=start_new_session)
start_new_session=start_new_session, merge_stderr=merge_stderr)
result = await self.cancel_wrapper(coro, download, ctx=ctx)
if __debug__ and logger.isEnabledFor(TRACE_LEVEL):

View file

@ -19,11 +19,10 @@ from sqlalchemy import text, or_
from wrolpi import flags
from wrolpi.common import get_media_directory, logger, walk, unique_by_predicate, chunks
from wrolpi.db import get_db_session, get_db_curs
from wrolpi.errors import NoPrimaryFile
from wrolpi.events import Events
from wrolpi.files.lib import (
split_path_stem_and_suffix, get_unique_files_by_stem, glob_shared_stem,
group_files_by_stem, get_primary_file, delete_directory,
group_files_by_stem, delete_directory,
_move_file_group_files, _bulk_update_file_groups_db, MOVE_CHUNK_SIZE,
get_normalized_ignored_directories, remove_files_in_ignored_directories,
)
@ -212,8 +211,8 @@ async def build_move_plan_bulk(
if missing_db_files:
logger.info(f'build_move_plan_bulk: creating FileGroups for {len(missing_db_files)} files not in DB')
for paths in group_files_by_stem(missing_db_files):
try:
get_primary_file(paths)
# One FileGroup per stem, as a refresh would group them (even when no file is a primary file,
# e.g. dotfiles); the stem is unique in its directory.
fg = FileGroup.from_paths(session, *paths)
session.flush()
@ -232,23 +231,14 @@ async def build_move_plan_bulk(
else:
new_path = destination / fg.primary_path.name
if new_path.exists() and fg.primary_path.exists():
raise FileExistsError(f'Cannot move: {new_path} already exists')
plan[fg.primary_path] = new_path
except NoPrimaryFile:
for file in paths:
fg = FileGroup.from_paths(session, file)
session.flush()
if any(str(p) in file_source_set for p in paths):
new_path = destination / fg.primary_path.name
elif file in dir_file_mapping:
source_dir = dir_file_mapping[file]
new_path = destination / source_dir.name / fg.primary_path.relative_to(source_dir)
else:
new_path = destination / fg.primary_path.name
if new_path.exists() and fg.primary_path.exists():
raise FileExistsError(f'Cannot move: {new_path} already exists')
# Every file of the group is moved next to the primary (see `_move_file_group_files`); none may
# replace a file at the destination.
new_stem, _ = split_path_stem_and_suffix(new_path, full=True)
for path in paths:
_, suffix = split_path_stem_and_suffix(path)
member_path = pathlib.Path(f'{new_stem}{suffix}')
if member_path.exists() and path.exists():
raise FileExistsError(f'Cannot move: {member_path} already exists')
plan[fg.primary_path] = new_path
# Add directories to plan for cleanup

View file

@ -499,6 +499,50 @@ async def test_file_worker_move_unindexed_files(
assert len(file_groups) == 2
@pytest.mark.asyncio
async def test_file_worker_move_unindexed_files_without_primary(
async_client, test_session, test_directory, make_files_structure
):
"""Unindexed files which share a stem, but have no primary file (e.g. dotfiles), are moved as one FileGroup,
as a refresh would group them."""
make_files_structure(['source/.gitignore', 'source/.npmrc', 'source/notes.txt', 'source/notes.md'])
dest = test_directory / 'destination'
dest.mkdir()
task = FileTask(FileTaskType.move, [test_directory / 'source'], destination=dest)
file_worker.private_queue.put_nowait(task)
await file_worker.process_queue()
for name in ('.gitignore', '.npmrc', 'notes.txt', 'notes.md'):
assert (dest / 'source' / name).is_file()
test_session.expire_all()
groups = {fg.stem: sorted(i.name for i in fg.my_paths()) for fg in test_session.query(FileGroup)}
assert groups == {'': ['.gitignore', '.npmrc'], 'notes': ['notes.md', 'notes.txt']}
@pytest.mark.asyncio
async def test_file_worker_move_unindexed_group_never_replaces_a_member(
async_client, test_session, test_directory, make_files_structure
):
"""Unindexed files moved as one FileGroup never replace a file at the destination, even one which is not the
group's primary file."""
make_files_structure({'source/.gitignore': 'a', 'source/.npmrc': 'b', 'source/notes.md': 'c',
'source/notes.txt': 'd'})
dest = test_directory / 'destination'
for name, content in (('.npmrc', 'kept npmrc'), ('notes.txt', 'kept notes')):
(dest / 'source').mkdir(parents=True, exist_ok=True)
(dest / 'source' / name).write_text(content)
task = FileTask(FileTaskType.move, [test_directory / 'source'], destination=dest)
file_worker.private_queue.put_nowait(task)
await file_worker.process_queue()
assert (dest / 'source/.npmrc').read_text() == 'kept npmrc'
assert (dest / 'source/notes.txt').read_text() == 'kept notes'
for name, content in (('.gitignore', 'a'), ('.npmrc', 'b'), ('notes.md', 'c'), ('notes.txt', 'd')):
assert (test_directory / 'source' / name).read_text() == content
@pytest.mark.asyncio
async def test_file_worker_move_preserves_existing_destination_on_failure(
async_client, test_session, test_directory, make_files_structure, monkeypatch

View file

@ -97,8 +97,47 @@ FTS_DDL = [
''',
]
# A Repo's name outranks a mention in its README.
REPOSITORY_BM25_WEIGHTS = 'bm25(10.0, 1.0)'
# Installed only once the `repository` table exists (see `schema_ddl.install_raw_ddl`); the baseline migration
# runs before that table is created.
REPOSITORY_FTS_DDL = [
f'''
CREATE VIRTUAL TABLE IF NOT EXISTS repository_fts USING fts5(
search_name, readme_text,
content='repository',
content_rowid='id',
tokenize='{TOKENIZER}'
)
''',
f'''
INSERT INTO repository_fts(repository_fts, rank) VALUES('rank', '{REPOSITORY_BM25_WEIGHTS}')
''',
'''
CREATE TRIGGER IF NOT EXISTS repository_fts_ai AFTER INSERT ON repository BEGIN
INSERT INTO repository_fts(rowid, search_name, readme_text)
VALUES (new.id, new.search_name, new.readme_text);
END
''',
'''
CREATE TRIGGER IF NOT EXISTS repository_fts_ad AFTER DELETE ON repository BEGIN
INSERT INTO repository_fts(repository_fts, rowid, search_name, readme_text)
VALUES ('delete', old.id, old.search_name, old.readme_text);
END
''',
'''
CREATE TRIGGER IF NOT EXISTS repository_fts_au AFTER UPDATE OF search_name, readme_text ON repository BEGIN
INSERT INTO repository_fts(repository_fts, rowid, search_name, readme_text)
VALUES ('delete', old.id, old.search_name, old.readme_text);
INSERT INTO repository_fts(rowid, search_name, readme_text)
VALUES (new.id, new.search_name, new.readme_text);
END
''',
]
# FTS5 shadow tables (and the virtual tables themselves); excluded from alembic autogenerate.
FTS_TABLE_PREFIXES = ('file_group_fts', 'doc_section_fts')
FTS_TABLE_PREFIXES = ('file_group_fts', 'doc_section_fts', 'repository_fts')
_ITEM_RE = re.compile(r'-?"[^"]*"?|\S+')
@ -235,6 +274,20 @@ def file_group_search_join(search_str: str, deep: bool = False, headlines: bool
params={param_name: match})
def repository_search_join(search_str: str, alias: str = 'r', param_name: str = 'repo_match',
start: str = '<b>', stop: str = '</b>', tokens: int = 16) -> Optional[FileGroupSearch]:
"""Join `repository` (aliased `alias`) to its FTS5 matches. Provides `fts.ts_rank` and `fts.readme_headline`.
Returns None when there is nothing usable to search."""
match = translate_websearch(search_str)
if match is None:
return None
join = (f"JOIN (SELECT rowid, -rank AS ts_rank, "
f"snippet(repository_fts, 1, '{start}', '{stop}', '…', {tokens}) AS readme_headline "
f"FROM repository_fts WHERE repository_fts MATCH :{param_name}) fts ON fts.rowid = {alias}.id")
return FileGroupSearch(join=join, where='', rank_select='fts.ts_rank AS ts_rank', params={param_name: match})
def file_group_headline_selects(start: str = '<b>', stop: str = '</b>', tokens: int = 16) -> str:
"""SELECT fragments producing the b/c/d headline columns from the joined FTS table.

View file

@ -18,6 +18,7 @@ from modules.docs.api import docs_bp
from modules.flasher.api import flasher_bp
from modules.inventory import inventory_bp
from modules.map.api import map_bp
from modules.repos.api import repos_bp
from modules.videos.api import videos_bp
from modules.zim.api import zim_bp
from wrolpi import flags, schema, dates
@ -64,6 +65,7 @@ api_app.blueprint(flasher_bp)
api_app.blueprint(inventory_bp)
api_app.blueprint(jobs_bp)
api_app.blueprint(map_bp)
api_app.blueprint(repos_bp)
api_app.blueprint(speedtest_bp)
api_app.blueprint(videos_bp)
api_app.blueprint(zim_bp)
@ -192,6 +194,7 @@ def get_settings(_: Request):
'nav_color': wrolpi_config.nav_color,
'media_directory': str(get_media_directory()), # Convert to string to avoid conversion to relative.
'playlists_destination': wrolpi_config.playlists_destination,
'repos_destination': wrolpi_config.repos_destination,
'tags_directory': wrolpi_config.tags_directory,
'throttle_on_startup': wrolpi_config.throttle_on_startup,
'version': __version__,
@ -277,6 +280,14 @@ async def update_settings(_: Request, body: schema.SettingsRequest):
else:
new_config['playlists_destination'] = wrolpi_config.default_config['playlists_destination']
# The Settings page may not send `repos_destination`; only validate it when it is provided.
if body.repos_destination is not None:
from modules.repos.lib import validate_repos_destination
if not body.repos_destination:
new_config['repos_destination'] = wrolpi_config.default_config['repos_destination']
elif error := validate_repos_destination(body.repos_destination):
raise InvalidConfig(error)
# Validate timezone if provided; empty string clears it.
if body.timezone is not None:
if body.timezone == '':
@ -313,6 +324,12 @@ async def update_settings(_: Request, body: schema.SettingsRequest):
# Save config settings (hotspot control is now through Controller endpoints)
wrolpi_config.update(new_config)
if body.repos_destination is not None:
# Repo files are never indexed, wherever repos are saved.
from modules.repos.lib import repos_destination_root
from wrolpi.files.lib import add_ignore_directory
add_ignore_directory(repos_destination_root(wrolpi_config.repos_destination))
# If the Zim directory changed, restart Kiwix so it serves the new directory
# without waiting for a reboot. Local import avoids a circular import
# (modules depend on wrolpi, not the reverse).
@ -777,6 +794,7 @@ async def post_search_suggestions(request: Request, body: schema.SearchSuggestio
from modules.videos.channel.lib import search_channels_by_name
from modules.archive.lib import search_domains_by_name
from modules.docs.lib import search_authors_by_name, search_subjects_by_name
from modules.repos.lib import search_repos_by_name
session = request.ctx.session
# Run sequentially: a SQLAlchemy Session is not safe for concurrent use, and
@ -787,12 +805,14 @@ async def post_search_suggestions(request: Request, body: schema.SearchSuggestio
domains = await search_domains_by_name(session, body.search_str)
authors = await search_authors_by_name(session, body.search_str)
subjects = await search_subjects_by_name(session, body.search_str)
repos = await search_repos_by_name(session, body.search_str)
ret = dict(
channels=channels,
domains=domains,
authors=authors,
subjects=subjects,
repos=repos,
)
return json_response(ret)
@ -831,7 +851,7 @@ async def post_search_file_estimates(request: Request, body: schema.SearchFileEs
)
async def post_search_other_estimates(request: Request, body: schema.SearchOtherEstimateRequest):
"""Used by the Global search to suggest FileGroup count to the user."""
others = await search_other_estimates(request.ctx.session, body.tag_names)
others = await search_other_estimates(request.ctx.session, body.tag_names, body.search_str)
ret = dict(others=others)
return json_response(ret)

View file

@ -53,6 +53,7 @@ class SettingsResponse:
archive_destination: str = 'archive/%(domain_tag)s/%(domain)s'
map_destination: str = 'map'
playlists_destination: str = 'playlists'
repos_destination: str = 'repos/%(repo_tag)s/%(repo_name)s'
videos_destination: str = 'videos/%(channel_tag)s/%(channel_name)s'
zims_destination: str = 'zims'
@ -100,6 +101,7 @@ class SettingsRequest:
nav_color: Optional[str] = None
media_directory: Optional[str] = None
playlists_destination: Optional[str] = None
repos_destination: Optional[str] = None
tags_directory: Optional[bool] = None
throttle_on: Optional[bool] = None
throttle_on_startup: Optional[bool] = None
@ -504,3 +506,4 @@ class SearchFileEstimateRequest:
@dataclass
class SearchOtherEstimateRequest:
tag_names: List[str] = field(default_factory=list)
search_str: Optional[str] = None

View file

@ -186,3 +186,12 @@ def install_raw_ddl(conn):
for statement in [*TRIGGER_DDL, *fts.FTS_DDL]:
conn.execute(statement)
# DDL for tables created after the baseline migration (which also calls this function).
if _table_exists(conn, 'repository'):
for statement in fts.REPOSITORY_FTS_DDL:
conn.execute(statement)
def _table_exists(conn, name: str) -> bool:
return conn.execute(f"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '{name}'").fetchone() is not None

View file

@ -175,7 +175,7 @@ def test_bookmarks_config_import(test_directory, test_wrolpi_config):
['not a node'],
'not a list',
])
def test_bookmarks_config_import_rejects_bad_tree(test_directory, test_wrolpi_config, bookmarks):
async def test_bookmarks_config_import_rejects_bad_tree(async_client, test_directory, test_wrolpi_config, bookmarks):
"""A hand-edited file that the API would refuse is not imported, and is not overwritten."""
config_file = test_directory / 'config/bookmarks.yaml'
config_file.parent.mkdir(exist_ok=True)
@ -195,7 +195,7 @@ def test_bookmarks_config_import_rejects_bad_tree(test_directory, test_wrolpi_co
assert config_file.read_text() == original
def test_bookmarks_config_import_rejects_cycle(test_directory, test_wrolpi_config):
async def test_bookmarks_config_import_rejects_cycle(async_client, test_directory, test_wrolpi_config):
"""A YAML anchor can make a directory contain itself; walking that would never end."""
config_file = test_directory / 'config/bookmarks.yaml'
config_file.parent.mkdir(exist_ok=True)

View file

@ -62,6 +62,8 @@ CIRCLECI = truthy_arg(os.environ.get('CIRCLECI', ''))
# Get the media directory from the environment.
DEFAULT_MEDIA_DIRECTORY = Path('/media/wrolpi')
# Git repositories live here; it is always an ignored directory (repo files are never indexed).
REPOS_DIRECTORY = 'repos'
MEDIA_DIRECTORY = Path(os.environ.get('MEDIA_DIRECTORY', DEFAULT_MEDIA_DIRECTORY))
CONFIG_DIR: Path = MEDIA_DIRECTORY / 'config'