Compare commits

..

2 Commits

25 changed files with 60 additions and 41 deletions

View File

@ -44,7 +44,7 @@ The main functionality of the class is already described, but command is still n
arguments = set_parser arguments = set_parser
In addition, ``ahriman.application.handlers.handler.Handler.ALLOW_MULTI_ARCHITECTURE_RUN`` can be set to ``False`` in order to disable multiprocess run (e.g. in case if there are conflicting operations, like writting to stdout). In addition, ``ahriman.application.handlers.handler.Handler.ALLOW_MULTI_ARCHITECTURE_RUN`` can be set to ``False`` in order to disable multiprocess run (e.g. in case if there are conflicting operations, like writing to stdout).
Save the file above as ``/usr/lib/python3.12/site-packages/ahriman/application/handlers/help_web.py`` (replace ``python3.12`` with actual python version) and you are set. Save the file above as ``/usr/lib/python3.12/site-packages/ahriman/application/handlers/help_web.py`` (replace ``python3.12`` with actual python version) and you are set.

View File

@ -394,7 +394,7 @@ All extracted fields are packed as ``ahriman.models.pkgbuild_patch.PkgbuildPatch
The PKGBUILD class also provides some additional functions on top of that: The PKGBUILD class also provides some additional functions on top of that:
* Ability to extract fields defined inside ``package*()`` functions, which are in particular used for the multipackages. * Ability to extract fields defined inside ``package*()`` functions, which are in particular used for the multi-packages.
* Shell substitution, which supports constructions ``$var`` (including ``${var}``), ``${var#(#)pattern}``, ``${var%(%)pattern}`` and ``${var/(/)pattern/replacement}`` (including ``#pattern`` and ``%pattern``). * Shell substitution, which supports constructions ``$var`` (including ``${var}``), ``${var#(#)pattern}``, ``${var%(%)pattern}`` and ``${var/(/)pattern/replacement}`` (including ``#pattern`` and ``%pattern``).
Additional features Additional features

View File

@ -97,7 +97,7 @@ Otherwise, you would need to pass ``AHRIMAN_PORT`` and mount container network t
Simple server with authentication can be found in `examples <https://github.com/arcan1s/ahriman/tree/master/recipes/web>`__ too. Simple server with authentication can be found in `examples <https://github.com/arcan1s/ahriman/tree/master/recipes/web>`__ too.
Mutli-repository web service Multi-repository web service
"""""""""""""""""""""""""""" """"""""""""""""""""""""""""
Idea is pretty same as to just run web service. However, it is required to run setup commands for each repository, except for one which is specified by ``AHRIMAN_REPOSITORY`` and ``AHRIMAN_ARCHITECTURE`` variables. Idea is pretty same as to just run web service. However, it is required to run setup commands for each repository, except for one which is specified by ``AHRIMAN_REPOSITORY`` and ``AHRIMAN_ARCHITECTURE`` variables.

View File

@ -3,7 +3,7 @@ To 2.16.0
This release replaces ``passlib`` dependency with ``bcrypt``. This release replaces ``passlib`` dependency with ``bcrypt``.
The reason behind this change is that python developers have deprecated and scheduled for removal ``crypt`` module, which is used by ``passlib``. (By the way, they recommend to use ``passlib`` as a replacement.) Unfortunately, it appears that ``passlib`` is unmaintained (see `the issue <https://foss.heptapod.net/python-libs/passlib/-/issues/187>`__), so the only solution is to migrate to anoher library. The reason behind this change is that python developers have deprecated and scheduled for removal ``crypt`` module, which is used by ``passlib``. (By the way, they recommend to use ``passlib`` as a replacement.) Unfortunately, it appears that ``passlib`` is unmaintained (see `the issue <https://foss.heptapod.net/python-libs/passlib/-/issues/187>`__), so the only solution is to migrate to another library.
Because passwords are stored as hashes, it is near to impossible to shadow change passwords in database, the manual intervention is required if: Because passwords are stored as hashes, it is near to impossible to shadow change passwords in database, the manual intervention is required if:

View File

@ -21,7 +21,7 @@ import argparse
from pathlib import Path from pathlib import Path
from pwd import getpwuid from pwd import getpwuid
from urllib.parse import quote_plus as urlencode from urllib.parse import quote_plus as url_encode
from ahriman.application.application import Application from ahriman.application.application import Application
from ahriman.application.handlers.handler import Handler, SubParserAction from ahriman.application.handlers.handler import Handler, SubParserAction
@ -174,7 +174,7 @@ class Setup(Handler):
if args.web_unix_socket is not None: if args.web_unix_socket is not None:
unix_socket = str(args.web_unix_socket) unix_socket = str(args.web_unix_socket)
configuration.set_option("web", "unix_socket", unix_socket) configuration.set_option("web", "unix_socket", unix_socket)
configuration.set_option("status", "address", f"http+unix://{urlencode(unix_socket)}") configuration.set_option("status", "address", f"http+unix://{url_encode(unix_socket)}")
if args.generate_salt: if args.generate_salt:
configuration.set_option("auth", "salt", User.generate_password(20)) configuration.set_option("auth", "salt", User.generate_password(20))

View File

@ -146,7 +146,7 @@ class PkgbuildParser(shlex.shlex):
# reset state # reset state
buffer, prefix = [], None buffer, prefix = [], None
# we have already prefix string, so we are in progress of expansion # we have already got prefix string, so we are in progress of expansion
# we always operate the last element, so this matches ",", "next" # we always operate the last element, so this matches ",", "next"
case (PkgbuildToken.Comma, _) if prefix is not None: case (PkgbuildToken.Comma, _) if prefix is not None:
buffer.append(f"{prefix}{second}") buffer.append(f"{prefix}{second}")
@ -168,7 +168,7 @@ class PkgbuildParser(shlex.shlex):
def _is_escaped(self) -> bool: def _is_escaped(self) -> bool:
""" """
check if the last element was quoted. ``shlex.shlex`` parser doesn't provide information about was the token check if the last element was quoted. ``shlex.shlex`` parser doesn't provide information about was the token
quoted or not, thus there is no difference between "'#'" (diez in quotes) and "#" (diez without quotes). This quoted or not, thus there is no difference between "'#'" (sharp in quotes) and "#" (sharp without quotes). This
method simply rolls back to the last non-space character and check if it is a quotation mark method simply rolls back to the last non-space character and check if it is a quotation mark
Returns: Returns:

View File

@ -59,10 +59,10 @@ class Mapping(Auth):
""" """
if password is None: if password is None:
return False # invalid data supplied return False # invalid data supplied
user = self.get_user(username) user = await self.get_user(username)
return user is not None and user.check_credentials(password, self.salt) return user is not None and user.check_credentials(password, self.salt)
def get_user(self, username: str) -> User | None: async def get_user(self, username: str) -> User | None:
""" """
retrieve user from in-memory mapping retrieve user from in-memory mapping
@ -84,7 +84,7 @@ class Mapping(Auth):
Returns: Returns:
bool: ``True`` in case if user is known and can be authorized and ``False`` otherwise bool: ``True`` in case if user is known and can be authorized and ``False`` otherwise
""" """
return username is not None and self.get_user(username) is not None return username is not None and await self.get_user(username) is not None
async def verify_access(self, username: str, required: UserAccess, context: str | None) -> bool: async def verify_access(self, username: str, required: UserAccess, context: str | None) -> bool:
""" """
@ -98,5 +98,5 @@ class Mapping(Auth):
Returns: Returns:
bool: ``True`` in case if user is allowed to do this request and ``False`` otherwise bool: ``True`` in case if user is allowed to do this request and ``False`` otherwise
""" """
user = self.get_user(username) user = await self.get_user(username)
return user is not None and user.verify_access(required) return user is not None and user.verify_access(required)

View File

@ -120,7 +120,7 @@ class PAM(Mapping):
bool: ``True`` in case if user is allowed to do this request and ``False`` otherwise bool: ``True`` in case if user is allowed to do this request and ``False`` otherwise
""" """
# this method is basically inverted, first we check overrides in database and then fallback to the PAM logic # this method is basically inverted, first we check overrides in database and then fallback to the PAM logic
if (user := self.get_user(username)) is not None: if (user := await self.get_user(username)) is not None:
return user.verify_access(required) return user.verify_access(required)
# if username is in admin group, then we treat it as full access # if username is in admin group, then we treat it as full access
if username in self.group_members(self.full_access_group): if username in self.group_members(self.full_access_group):

View File

@ -58,8 +58,8 @@ class EventStatsPrinter(StringPrinter):
mean = statistics.mean(self.events) mean = statistics.mean(self.events)
if len(self.events) > 1: if len(self.events) > 1:
stdev = statistics.stdev(self.events) st_dev = statistics.stdev(self.events)
average = f"{mean:.3f} ± {stdev:.3f}" average = f"{mean:.3f} ± {st_dev:.3f}"
else: else:
average = f"{mean:.3f}" average = f"{mean:.3f}"

View File

@ -19,7 +19,7 @@
# #
import contextlib import contextlib
from urllib.parse import quote_plus as urlencode from urllib.parse import quote_plus as url_encode
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.http import SyncAhrimanClient from ahriman.core.http import SyncAhrimanClient
@ -75,7 +75,7 @@ class WebClient(Client, SyncAhrimanClient):
# legacy-style section # legacy-style section
if (unix_socket := configuration.get("web", "unix_socket", fallback=None)) is not None: if (unix_socket := configuration.get("web", "unix_socket", fallback=None)) is not None:
# special pseudo-protocol which is used for unix sockets # special pseudo-protocol which is used for unix sockets
return "web", f"http+unix://{urlencode(unix_socket)}" return "web", f"http+unix://{url_encode(unix_socket)}"
address = configuration.get("web", "address", fallback=None) address = configuration.get("web", "address", fallback=None)
if not address: if not address:
# build address from host and port directly # build address from host and port directly
@ -94,7 +94,7 @@ class WebClient(Client, SyncAhrimanClient):
Returns: Returns:
str: full url for web service for changes str: full url for web service for changes
""" """
return f"{self.address}/api/v1/packages/{urlencode(package_base)}/changes" return f"{self.address}/api/v1/packages/{url_encode(package_base)}/changes"
def _dependencies_url(self, package_base: str) -> str: def _dependencies_url(self, package_base: str) -> str:
""" """
@ -106,7 +106,7 @@ class WebClient(Client, SyncAhrimanClient):
Returns: Returns:
str: full url for web service for dependencies str: full url for web service for dependencies
""" """
return f"{self.address}/api/v1/packages/{urlencode(package_base)}/dependencies" return f"{self.address}/api/v1/packages/{url_encode(package_base)}/dependencies"
def _events_url(self) -> str: def _events_url(self) -> str:
""" """
@ -127,7 +127,7 @@ class WebClient(Client, SyncAhrimanClient):
Returns: Returns:
str: full url for web service for logs str: full url for web service for logs
""" """
return f"{self.address}/api/v1/packages/{urlencode(package_base)}/logs" return f"{self.address}/api/v1/packages/{url_encode(package_base)}/logs"
def _package_url(self, package_base: str = "") -> str: def _package_url(self, package_base: str = "") -> str:
""" """
@ -139,7 +139,7 @@ class WebClient(Client, SyncAhrimanClient):
Returns: Returns:
str: full url of web service for specific package base str: full url of web service for specific package base
""" """
suffix = f"/{urlencode(package_base)}" if package_base else "" suffix = f"/{url_encode(package_base)}" if package_base else ""
return f"{self.address}/api/v1/packages{suffix}" return f"{self.address}/api/v1/packages{suffix}"
def _patches_url(self, package_base: str, variable: str = "") -> str: def _patches_url(self, package_base: str, variable: str = "") -> str:
@ -153,8 +153,8 @@ class WebClient(Client, SyncAhrimanClient):
Returns: Returns:
str: full url of web service for the package patch str: full url of web service for the package patch
""" """
suffix = f"/{urlencode(variable)}" if variable else "" suffix = f"/{url_encode(variable)}" if variable else ""
return f"{self.address}/api/v1/packages/{urlencode(package_base)}/patches{suffix}" return f"{self.address}/api/v1/packages/{url_encode(package_base)}/patches{suffix}"
def _status_url(self) -> str: def _status_url(self) -> str:
""" """

View File

@ -107,8 +107,8 @@ class Pkgbuild(Mapping[str, Any]):
def __getitem__(self, item: str) -> Any: def __getitem__(self, item: str) -> Any:
""" """
get the field of the PKGBUILD. This method tries to get exact key value if possible; if none found, it tries to get the field of the PKGBUILD. This method tries to get exact key value if possible; if none was found,
fetch function with the same name it tries to fetch function with the same name
Args: Args:
item(str): key name item(str): key name

View File

@ -99,6 +99,8 @@ class User:
Returns: Returns:
bool: ``True`` in case if password matches, ``False`` otherwise bool: ``True`` in case if password matches, ``False`` otherwise
""" """
if self.password.startswith("$6$"):
raise DeprecationWarning("SHA512 passwords are not longer supported")
try: try:
return bcrypt.checkpw((password + salt).encode("utf8"), self.password.encode("utf8")) return bcrypt.checkpw((password + salt).encode("utf8"), self.password.encode("utf8"))
except ValueError: except ValueError:

View File

@ -53,7 +53,7 @@ def test_run(args: argparse.Namespace, configuration: Configuration, repository:
def test_run_remove(args: argparse.Namespace, configuration: Configuration, repository: Repository, def test_run_remove(args: argparse.Namespace, configuration: Configuration, repository: Repository,
package_ahriman: Package, mocker: MockerFixture) -> None: package_ahriman: Package, mocker: MockerFixture) -> None:
""" """
must run command and remove packages afterwards must run command and remove packages afterward
""" """
args = _default_args(args) args = _default_args(args)
args.remove = True args.remove = True

View File

@ -191,7 +191,7 @@ def test_extract_packages_by_status(application: Application, mocker: MockerFixt
def test_extract_packages_from_database(application: Application, mocker: MockerFixture) -> None: def test_extract_packages_from_database(application: Application, mocker: MockerFixture) -> None:
""" """
must extract packages from database from database must extract packages from database
""" """
packages_mock = mocker.patch("ahriman.core.database.SQLite.packages_get") packages_mock = mocker.patch("ahriman.core.database.SQLite.packages_get")
Rebuild.extract_packages(application, None, from_database=True) Rebuild.extract_packages(application, None, from_database=True)

View File

@ -5,7 +5,7 @@ from pathlib import Path
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from typing import Any from typing import Any
from unittest.mock import call as MockCall from unittest.mock import call as MockCall
from urllib.parse import quote_plus as urlencode from urllib.parse import quote_plus as url_encode
from ahriman.application.handlers.setup import Setup from ahriman.application.handlers.setup import Setup
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
@ -148,7 +148,7 @@ def test_configuration_create_ahriman(args: argparse.Namespace, configuration: C
MockCall("web", "port", str(args.web_port)), MockCall("web", "port", str(args.web_port)),
MockCall("status", "address", f"http://127.0.0.1:{str(args.web_port)}"), MockCall("status", "address", f"http://127.0.0.1:{str(args.web_port)}"),
MockCall("web", "unix_socket", str(args.web_unix_socket)), MockCall("web", "unix_socket", str(args.web_unix_socket)),
MockCall("status", "address", f"http+unix://{urlencode(str(args.web_unix_socket))}"), MockCall("status", "address", f"http+unix://{url_encode(str(args.web_unix_socket))}"),
MockCall("auth", "salt", pytest.helpers.anyvar(str, strict=True)), MockCall("auth", "salt", pytest.helpers.anyvar(str, strict=True)),
]) ])
write_mock.assert_called_once_with(pytest.helpers.anyvar(int)) write_mock.assert_called_once_with(pytest.helpers.anyvar(int))

View File

@ -392,7 +392,7 @@ def test_subparsers_package_status_update(parser: argparse.ArgumentParser) -> No
def test_subparsers_package_status_update_option_status(parser: argparse.ArgumentParser) -> None: def test_subparsers_package_status_update_option_status(parser: argparse.ArgumentParser) -> None:
""" """
package-status-update command must convert status option to buildstatusenum instance package-status-update command must convert status option to BuildStatusEnum instance
""" """
args = parser.parse_args(["-a", "x86_64", "-r", "repo", "package-status-update"]) args = parser.parse_args(["-a", "x86_64", "-r", "repo", "package-status-update"])
assert isinstance(args.status, BuildStatusEnum) assert isinstance(args.status, BuildStatusEnum)

View File

@ -115,7 +115,7 @@ def test_write_skip(lock: Lock) -> None:
def test_write_locked(lock: Lock, mocker: MockerFixture) -> None: def test_write_locked(lock: Lock, mocker: MockerFixture) -> None:
""" """
must raise DuplicateRunError if cannot lock file must raise DuplicateRunError if it cannot lock file
""" """
mocker.patch("ahriman.application.lock.Lock.perform_lock", return_value=False) mocker.patch("ahriman.application.lock.Lock.perform_lock", return_value=False)
with pytest.raises(DuplicateRunError): with pytest.raises(DuplicateRunError):

View File

@ -287,6 +287,7 @@ def local_client(database: SQLite, configuration: Configuration) -> Client:
Args: Args:
database(SQLite): database fixture database(SQLite): database fixture
configuration(Configuration): configuration fixture
Returns: Returns:
Client: local status client test instance Client: local status client test instance

View File

@ -31,27 +31,27 @@ async def test_check_credentials_unknown(mapping: Mapping, user: User) -> None:
assert not await mapping.check_credentials(user.username, user.password) assert not await mapping.check_credentials(user.username, user.password)
def test_get_user(mapping: Mapping, user: User, mocker: MockerFixture) -> None: async def test_get_user(mapping: Mapping, user: User, mocker: MockerFixture) -> None:
""" """
must return user from storage by username must return user from storage by username
""" """
mocker.patch("ahriman.core.database.SQLite.user_get", return_value=user) mocker.patch("ahriman.core.database.SQLite.user_get", return_value=user)
assert mapping.get_user(user.username) == user assert await mapping.get_user(user.username) == user
def test_get_user_normalized(mapping: Mapping, user: User, mocker: MockerFixture) -> None: async def test_get_user_normalized(mapping: Mapping, user: User, mocker: MockerFixture) -> None:
""" """
must return user from storage by username case-insensitive must return user from storage by username case-insensitive
""" """
mocker.patch("ahriman.core.database.SQLite.user_get", return_value=user) mocker.patch("ahriman.core.database.SQLite.user_get", return_value=user)
assert mapping.get_user(user.username.upper()) == user assert await mapping.get_user(user.username.upper()) == user
def test_get_user_unknown(mapping: Mapping, user: User) -> None: async def test_get_user_unknown(mapping: Mapping, user: User) -> None:
""" """
must return None in case if no user found must return None in case if no user found
""" """
assert mapping.get_user(user.username) is None assert await mapping.get_user(user.username) is None
async def test_known_username(mapping: Mapping, user: User, mocker: MockerFixture) -> None: async def test_known_username(mapping: Mapping, user: User, mocker: MockerFixture) -> None:

View File

@ -32,6 +32,7 @@ def test_process_build(executor: Executor, package_ahriman: Package, passwd: Any
executor.process_build([package_ahriman], Packagers("packager"), bump_pkgrel=False) executor.process_build([package_ahriman], Packagers("packager"), bump_pkgrel=False)
init_mock.assert_called_once_with(pytest.helpers.anyvar(int), pytest.helpers.anyvar(int), None) init_mock.assert_called_once_with(pytest.helpers.anyvar(int), pytest.helpers.anyvar(int), None)
changes_mock.assert_called_once_with(package_ahriman.base)
depends_on_mock.assert_called_once_with() depends_on_mock.assert_called_once_with()
dependencies_mock.assert_called_once_with(package_ahriman.base, Dependencies()) dependencies_mock.assert_called_once_with(package_ahriman.base, Dependencies())
# must move files (once) # must move files (once)

View File

@ -136,7 +136,7 @@ def test_event_add_failed_http_error(web_client: WebClient, mocker: MockerFixtur
def test_event_add_failed_suppress(web_client: WebClient, mocker: MockerFixture) -> None: def test_event_add_failed_suppress(web_client: WebClient, mocker: MockerFixture) -> None:
""" """
must suppress any exception happened during events creaton and don't log must suppress any exception happened during events creation and don't log
""" """
web_client.suppress_errors = True web_client.suppress_errors = True
mocker.patch("requests.Session.request", side_effect=Exception()) mocker.patch("requests.Session.request", side_effect=Exception())

View File

@ -5,7 +5,7 @@ from ahriman.models.scan_paths import ScanPaths
def test_is_allowed() -> None: def test_is_allowed() -> None:
""" """
must check if path is subpath of one in allowed list must check if path is sub-path of one in allowed list
""" """
assert ScanPaths(["usr"]).is_allowed(Path("usr")) assert ScanPaths(["usr"]).is_allowed(Path("usr"))
assert ScanPaths(["usr"]).is_allowed(Path("usr") / "lib") assert ScanPaths(["usr"]).is_allowed(Path("usr") / "lib")

View File

@ -1,3 +1,5 @@
import pytest
from dataclasses import replace from dataclasses import replace
from ahriman.models.user import User from ahriman.models.user import User
@ -25,6 +27,19 @@ def test_check_credentials_empty_hash(user: User) -> None:
assert not user.check_credentials(current_password, "salt") assert not user.check_credentials(current_password, "salt")
def test_check_credentials_sha512() -> None:
"""
must raise DeprecationWarning for sha512 hashed passwords
"""
user = User(
username="user",
password="$6$rounds=656000$mWBiecMPrHAL1VgX$oU4Y5HH8HzlvMaxwkNEJjK13ozElyU1wAHBoO/WW5dAaE4YEfnB0X3FxbynKMl4FBdC3Ovap0jINz4LPkNADg0",
access=UserAccess.Read,
)
with pytest.raises(DeprecationWarning):
assert user.check_credentials("password", "salt")
def test_hash_password_empty_hash(user: User) -> None: def test_hash_password_empty_hash(user: User) -> None:
""" """
must return empty string after hash in case if password not set must return empty string after hash in case if password not set

View File

@ -252,6 +252,6 @@ async def test_username_request(base: BaseView) -> None:
async def test_username_request_exception(base: BaseView) -> None: async def test_username_request_exception(base: BaseView) -> None:
""" """
must not fail in case if cannot read request must not fail in case if it cannot read request
""" """
assert await base.username() is None assert await base.username() is None