diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 679830b..aae112d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -77,7 +77,7 @@ repos: rev: 1.8.6 hooks: - id: bandit - args: [-s, 'B404,B405,B506,B703,B308,B603'] + args: [-s, 'B101,B404,B405,B506,B703,B308,B603'] exclude: ^(id_tests/.*|npm/node_modules/.*|tools/.*|doc/.*|interdiode/static/vendor/.*) - repo: https://github.com/shellcheck-py/shellcheck-py rev: v0.11.0.1 diff --git a/id_configurator/config.py b/id_configurator/config.py index ababa7f..f783194 100644 --- a/id_configurator/config.py +++ b/id_configurator/config.py @@ -107,6 +107,17 @@ class AptConfig(BaseModel): keys: list[NamedContent] = Field(default_factory=list) +class ApkRepository(BaseModel): + """An Alpine APK repository (see /etc/apk/repositories), e.g. a URL ending in /main or /community.""" + + url: str + key: str | None = None + + +class ApkConfig(BaseModel): + repositories: list[ApkRepository] = Field(default_factory=list) + + class YumRepository(BaseModel): model_config = ConfigDict(populate_by_name=True) name: str @@ -116,6 +127,7 @@ class YumRepository(BaseModel): class YumConfig(BaseModel): repositories: list[YumRepository] = Field(default_factory=list) + keys: list[NamedContent] = Field(default_factory=list) class PythonRepository(BaseModel): @@ -129,11 +141,24 @@ class PythonConfig(BaseModel): repositories: list[PythonRepository] = Field(default_factory=list) +class JavaRepository(BaseModel): + """A Maven-compatible repository, shared by Maven, Gradle, Ivy, SBT and Leiningen.""" + + name: str + url: str + + +class JavaConfig(BaseModel): + repositories: list[JavaRepository] = Field(default_factory=list) + + class ConfigurationsBlock(BaseModel): certificates: list[NamedContent] = Field(default_factory=list) apt: AptConfig = Field(default_factory=AptConfig) + apk: ApkConfig = Field(default_factory=ApkConfig) yum: YumConfig = Field(default_factory=YumConfig) python: PythonConfig = Field(default_factory=PythonConfig) + java: JavaConfig = Field(default_factory=JavaConfig) class Configuration(BaseModel): diff --git a/id_configurator/context.py b/id_configurator/context.py index 14bc3ae..39f33a6 100644 --- a/id_configurator/context.py +++ b/id_configurator/context.py @@ -54,3 +54,9 @@ class Context: if path is None or not path.is_dir(): return [] return sorted(entry for entry in path.iterdir() if entry.is_file()) + + def glob_src(self, pattern: str) -> list[Path]: + """Glob a pattern relative to the source filesystem, or [] if no source was given.""" + if self.src is None: + return [] + return sorted(self.src.glob(pattern)) diff --git a/id_configurator/modules/__init__.py b/id_configurator/modules/__init__.py index 0fec04d..63933c1 100644 --- a/id_configurator/modules/__init__.py +++ b/id_configurator/modules/__init__.py @@ -3,6 +3,6 @@ Importing this package registers every built-in module (see `base.register`). """ -from id_configurator.modules import apt, certificates +from id_configurator.modules import apk, apt, certificates, java, python, yum -__all__ = ["apt", "certificates"] +__all__ = ["apk", "apt", "certificates", "java", "python", "yum"] diff --git a/id_configurator/modules/apk.py b/id_configurator/modules/apk.py new file mode 100644 index 0000000..fdaafee --- /dev/null +++ b/id_configurator/modules/apk.py @@ -0,0 +1,81 @@ +"""Detect existing APK (Alpine) repositories and configure new ones. + +If `/etc/apk` exists, the existing `/etc/apk/repositories` file (if any) +is copied into `` with every active line disabled (commented out), and +the repositories declared in the configuration are appended as new, active +lines. Public keys provided alongside a repository are written to +`/etc/apk/keys/`, named after their content so that repositories +sharing the same key (a common case for official Alpine mirrors) only +produce a single key file. +""" + +from __future__ import annotations + +import base64 +import hashlib +import logging +from pathlib import Path + +from id_configurator.context import Context +from id_configurator.modules.base import Module, register + +logger = logging.getLogger(__name__) + +_APK_DIR = "etc/apk" +_REPOSITORIES = "etc/apk/repositories" +_KEYS_DIR = "etc/apk/keys" + + +def _disable_repositories_content(content: str) -> str: + lines = [ + line if not line.strip() or line.strip().startswith("#") else f"# {line}" + for line in content.splitlines() + ] + return "\n".join(lines) + "\n" + + +def _key_filename(content: bytes) -> str: + return f"{hashlib.sha256(content).hexdigest()[:16]}.rsa.pub" + + +@register +class ApkModule(Module): + name = "apk" + + def is_relevant(self, context: Context) -> bool: + return self._src_apk_dir(context) is not None + + @staticmethod + def _src_apk_dir(context: Context) -> Path | None: + path = context.src_path(_APK_DIR) + if path is not None and path.is_dir(): + return path + return None + + def apply(self, context: Context) -> None: + disabled = self._disabled_existing_repositories(context) + configured = self._configured_repositories_lines(context) + content = disabled + ("\n".join(configured) + "\n" if configured else "") + if content: + context.write_file(_REPOSITORIES, content.encode("utf-8")) + + @staticmethod + def _disabled_existing_repositories(context: Context) -> str: + existing = context.read_src_file(_REPOSITORIES) + if existing is None: + return "" + logger.info("disabled existing apk repositories %s", _REPOSITORIES) + return _disable_repositories_content(existing.decode("utf-8")) + + @staticmethod + def _configured_repositories_lines(context: Context) -> list[str]: + lines = [] + for repository in context.config.configurations.apk.repositories: + lines.append(repository.url) + logger.info("added apk repository %s", repository.url) + if repository.key: + content = base64.b64decode(repository.key) + filename = _key_filename(content) + context.write_file(f"{_KEYS_DIR}/{filename}", content) + logger.info("installed apk key %s", filename) + return lines diff --git a/id_configurator/modules/java.py b/id_configurator/modules/java.py new file mode 100644 index 0000000..3e20c05 --- /dev/null +++ b/id_configurator/modules/java.py @@ -0,0 +1,300 @@ +"""Add the configured root certificates to every Java trust store detected +under ``, without shelling out to `keytool`, and configure the +Maven-compatible repositories declared in `configurations.java.repositories` +globally for the main JVM build tools. + +Java ignores the system CA store and only trusts what is in each JRE/JDK's +own `cacerts` file: JKS format before Java 9, PKCS12 since Java 9. Every +`cacerts` file found under `usr/` or `opt/` in `` is copied into `` +with the configured root certificates added, entirely in Python: `pyjks` +handles the legacy JKS format, `cryptography` handles PKCS12. The default +`changeit` store password is used, since virtually every JDK ships with it +unchanged; a keystore that doesn't open with it is left untouched and a +warning is logged instead of failing the whole run. + +The repositories are written to the well-known global configuration file of +each build tool. Maven's is installation-wide, so every user on the image +picks it up; the others are written under root's home directory, since this +tool targets Docker images, which are built and run as root: + +- Maven3: `/etc/maven/settings.xml` (the file `/usr/share/maven/conf/settings.xml` + points to on Debian/Ubuntu, Maven's global, all-users settings file), plus + `conf/settings.xml` in every other Maven installation detected under + `` (found via its `bin/mvn` launcher). Both get an always-active + profile adding every repository, plus a `mirrorOf=*` mirror on the first + one so that even `pom.xml` files hardcoding central are redirected. +- Gradle: `~/.gradle/init.d/id-configurator-repositories.gradle`, an init + script adding the repositories to every project and to plugin management. +- Ivy: `~/.ivy2/ivysettings.xml`, as a chain of `ibiblio` resolvers. +- SBT: `~/.sbt/repositories`, the resolver override file SBT reads for its + own bootstrapping and, when combined with the `sbt.override.build.repos` + system property, for every project's resolvers as well. +- Leiningen: `~/.lein/profiles.clj`, adding the repositories to the `:user` + profile. +""" + +from __future__ import annotations + +import base64 +import json +import logging +import xml.etree.ElementTree as ET +from pathlib import Path + +import jks +from cryptography import x509 +from cryptography.hazmat.primitives.serialization import ( + BestAvailableEncryption, + Encoding, + pkcs12, +) + +from id_configurator.config import JavaRepository, NamedContent +from id_configurator.context import Context +from id_configurator.modules.base import Module, register + +logger = logging.getLogger(__name__) + +_JAVA_SEARCH_ROOTS = ("usr", "opt") +_STORE_PASSWORD = b"changeit" + +_MAVEN_GLOBAL_SETTINGS = "etc/maven/settings.xml" +_MAVEN_BIN_GLOBS = ("usr/**/bin/mvn", "opt/**/bin/mvn") +_GRADLE_INIT_SCRIPT = "root/.gradle/init.d/id-configurator-repositories.gradle" +_IVY_SETTINGS = "root/.ivy2/ivysettings.xml" +_SBT_REPOSITORIES = "root/.sbt/repositories" +_LEIN_PROFILES = "root/.lein/profiles.clj" + + +def _find_cacerts(context: Context) -> list[Path]: + found = { + path + for root in _JAVA_SEARCH_ROOTS + for path in context.glob_src(f"{root}/**/cacerts") + } + return sorted(path for path in found if path.is_file()) + + +def _decode_certificates(certificates: list[NamedContent]) -> list[x509.Certificate]: + return [ + x509.load_pem_x509_certificate(base64.b64decode(cert.content)) + for cert in certificates + ] + + +def _add_to_pkcs12( + content: bytes, certificates: list[NamedContent], certs: list[x509.Certificate] +) -> bytes: + loaded = pkcs12.load_pkcs12(content, _STORE_PASSWORD) + entries = list(loaded.additional_certs) + for named, cert in zip(certificates, certs, strict=True): + entries.append(pkcs12.PKCS12Certificate(cert, named.name.encode("utf-8"))) + return pkcs12.serialize_java_truststore( + entries, BestAvailableEncryption(_STORE_PASSWORD) + ) + + +def _add_to_jks( + content: bytes, certificates: list[NamedContent], certs: list[x509.Certificate] +) -> bytes: + store = jks.KeyStore.loads(content, _STORE_PASSWORD.decode("ascii")) + for named, cert in zip(certificates, certs, strict=True): + store.entries[named.name] = jks.TrustedCertEntry.new( + named.name, cert.public_bytes(Encoding.DER) + ) + return store.saves(_STORE_PASSWORD.decode("ascii")) + + +def _keystore_kind(content: bytes) -> str: + if content[:4] in (jks.MAGIC_NUMBER_JKS, jks.MAGIC_NUMBER_JCEKS): + return "jks" + return "pkcs12" + + +def _string_literal(value: str) -> str: + return json.dumps(value) + + +def _render_xml(root: ET.Element) -> bytes: + ET.indent(root) + content = '\n' + ET.tostring( + root, encoding="unicode" + ) + return (content + "\n").encode("utf-8") + + +def _write_xml(context: Context, relative: str, root: ET.Element) -> None: + context.write_file(relative, _render_xml(root)) + + +def _find_maven_homes(context: Context) -> list[Path]: + found = { + path.parent.parent + for pattern in _MAVEN_BIN_GLOBS + for path in context.glob_src(pattern) + if path.is_file() + } + return sorted(found) + + +def _build_maven_settings(repositories: list[JavaRepository]) -> ET.Element: + settings = ET.Element( + "settings", + { + "xmlns": "http://maven.apache.org/SETTINGS/1.1.0", + "xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", + "xsi:schemaLocation": ( + "http://maven.apache.org/SETTINGS/1.1.0 " + "http://maven.apache.org/xsd/settings-1.1.0.xsd" + ), + }, + ) + profile = ET.SubElement(ET.SubElement(settings, "profiles"), "profile") + ET.SubElement(profile, "id").text = "id-configurator" + activation = ET.SubElement(profile, "activation") + ET.SubElement(activation, "activeByDefault").text = "true" + repositories_el = ET.SubElement(profile, "repositories") + plugin_repositories_el = ET.SubElement(profile, "pluginRepositories") + for repository in repositories: + for parent, tag in ( + (repositories_el, "repository"), + (plugin_repositories_el, "pluginRepository"), + ): + entry = ET.SubElement(parent, tag) + ET.SubElement(entry, "id").text = repository.name + ET.SubElement(entry, "url").text = repository.url + active_profiles = ET.SubElement(settings, "activeProfiles") + ET.SubElement(active_profiles, "activeProfile").text = "id-configurator" + mirror = ET.SubElement(ET.SubElement(settings, "mirrors"), "mirror") + ET.SubElement(mirror, "id").text = repositories[0].name + ET.SubElement(mirror, "url").text = repositories[0].url + ET.SubElement(mirror, "mirrorOf").text = "*" + return settings + + +def _write_maven_settings(context: Context, repositories: list[JavaRepository]) -> None: + content = _render_xml(_build_maven_settings(repositories)) + relatives = {_MAVEN_GLOBAL_SETTINGS} + relatives.update( + f"{home.relative_to(context.src).as_posix()}/conf/settings.xml" + for home in _find_maven_homes(context) + ) + for relative in sorted(relatives): + context.write_file(relative, content) + logger.info( + "configured maven mirrors for all users in %d location(s)", len(relatives) + ) + + +def _gradle_repository_lines(repositories: list[JavaRepository], indent: str) -> str: + return "\n".join( + f"{indent}maven {{ url = uri({_string_literal(repository.url)}) }}" + for repository in repositories + ) + + +def _write_gradle_init_script( + context: Context, repositories: list[JavaRepository] +) -> None: + content = ( + "allprojects {\n" + " repositories {\n" + f"{_gradle_repository_lines(repositories, ' ')}\n" + " }\n" + "}\n" + "\n" + "settingsEvaluated { settings ->\n" + " settings.pluginManagement {\n" + " repositories {\n" + f"{_gradle_repository_lines(repositories, ' ')}\n" + " }\n" + " }\n" + "}\n" + ) + context.write_file(_GRADLE_INIT_SCRIPT, content.encode("utf-8")) + logger.info("configured gradle to use %d repositories", len(repositories)) + + +def _write_ivy_settings(context: Context, repositories: list[JavaRepository]) -> None: + ivysettings = ET.Element("ivysettings") + ET.SubElement(ivysettings, "settings", {"defaultResolver": "id-configurator"}) + chain = ET.SubElement( + ET.SubElement(ivysettings, "resolvers"), "chain", {"name": "id-configurator"} + ) + for repository in repositories: + ET.SubElement( + chain, + "ibiblio", + {"name": repository.name, "m2compatible": "true", "root": repository.url}, + ) + _write_xml(context, _IVY_SETTINGS, ivysettings) + logger.info("configured ivy to use %d repositories", len(repositories)) + + +def _write_sbt_repositories( + context: Context, repositories: list[JavaRepository] +) -> None: + lines = ["[repositories]", " local"] + lines.extend( + f" {repository.name}: {repository.url}" for repository in repositories + ) + context.write_file(_SBT_REPOSITORIES, ("\n".join(lines) + "\n").encode("utf-8")) + logger.info("configured sbt to use %d repositories", len(repositories)) + + +def _write_lein_profiles(context: Context, repositories: list[JavaRepository]) -> None: + entries = " ".join( + f"[{_string_literal(repository.name)} {{:url {_string_literal(repository.url)}}}]" + for repository in repositories + ) + content = f"{{:user {{:repositories [{entries}]}}}}\n" + context.write_file(_LEIN_PROFILES, content.encode("utf-8")) + logger.info("configured leiningen to use %d repositories", len(repositories)) + + +def _configure_repositories( + context: Context, repositories: list[JavaRepository] +) -> None: + _write_maven_settings(context, repositories) + _write_gradle_init_script(context, repositories) + _write_ivy_settings(context, repositories) + _write_sbt_repositories(context, repositories) + _write_lein_profiles(context, repositories) + + +@register +class JavaModule(Module): + name = "java" + + def is_relevant(self, context: Context) -> bool: + has_certificates = bool(context.config.configurations.certificates) and bool( + _find_cacerts(context) + ) + has_repositories = bool(context.config.configurations.java.repositories) + return has_certificates or has_repositories + + def apply(self, context: Context) -> None: + certificates = context.config.configurations.certificates + if certificates: + certs = _decode_certificates(certificates) + for path in _find_cacerts(context): + relative = path.relative_to(context.src).as_posix() + content = path.read_bytes() + kind = _keystore_kind(content) + try: + if kind == "jks": + updated = _add_to_jks(content, certificates, certs) + else: + updated = _add_to_pkcs12(content, certificates, certs) + context.write_file(relative, updated) + logger.info( + "added %d certificate(s) to java keystore %s", + len(certificates), + relative, + ) + except (jks.KeystoreException, ValueError) as exc: + logger.warning("skipping java keystore %s: %s", relative, exc) + + repositories = context.config.configurations.java.repositories + if repositories: + _configure_repositories(context, repositories) diff --git a/id_configurator/modules/python.py b/id_configurator/modules/python.py new file mode 100644 index 0000000..c81d2cd --- /dev/null +++ b/id_configurator/modules/python.py @@ -0,0 +1,112 @@ +"""Configure the classic Python package managers to use the configured +PythonRepository entries instead of pypi.org, and to trust the root +certificates installed by the certificates module. + +- pip: system-wide `/etc/pip.conf` (`index-url`/`extra-index-url`/`cert`). +- uv: system-wide `/etc/uv/uv.toml` (`[[index]]` entries, `native-tls`). +- poetry: `/root/.config/pypoetry/config.toml` (`repositories.*`/`certificates.*`). + Poetry has no global "replace PyPI" switch: a project's `pyproject.toml` + still has to reference these repositories as sources. This only makes the + repository and its certificate available system-wide. It also assumes the + container runs as root, since poetry only reads its config from the + current user's home directory. +- easy_install: `distutils.cfg` (`[easy_install] index_url`), written next + to every Python installation detected under `` (there is no location + independent of a specific Python prefix). +- pipx: not configured directly. It installs packages by invoking `pip` + inside the target venv, so it already picks up `/etc/pip.conf`. +""" + +from __future__ import annotations + +import json +import logging + +from id_configurator.config import PythonRepository +from id_configurator.context import Context +from id_configurator.modules.base import Module, register + +logger = logging.getLogger(__name__) + +_CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt" +_PIP_CONF = "etc/pip.conf" +_UV_TOML = "etc/uv/uv.toml" +_POETRY_CONFIG = "root/.config/pypoetry/config.toml" +_DISTUTILS_GLOBS = ("usr/lib/python3.*", "usr/local/lib/python3.*") + + +def _toml_string(value: str) -> str: + return json.dumps(value) + + +def _write_pip_conf(context: Context, repositories: list[PythonRepository]) -> None: + lines = ["[global]", f"index-url = {repositories[0].index_url}"] + if len(repositories) > 1: + lines.append("extra-index-url =") + lines.extend(f" {repository.index_url}" for repository in repositories[1:]) + lines.append(f"cert = {_CA_BUNDLE}") + context.write_file(_PIP_CONF, ("\n".join(lines) + "\n").encode("utf-8")) + logger.info("configured pip to use %s", repositories[0].index_url) + + +def _write_uv_toml(context: Context, repositories: list[PythonRepository]) -> None: + lines = ["native-tls = true", ""] + for position, repository in enumerate(repositories): + lines.append("[[index]]") + lines.append(f"name = {_toml_string(repository.name)}") + lines.append(f"url = {_toml_string(repository.index_url)}") + if position == 0: + lines.append("default = true") + lines.append("") + context.write_file(_UV_TOML, ("\n".join(lines).rstrip() + "\n").encode("utf-8")) + logger.info("configured uv to use %s", repositories[0].index_url) + + +def _write_poetry_config( + context: Context, repositories: list[PythonRepository] +) -> None: + lines = [] + for repository in repositories: + lines.append(f"[repositories.{repository.name}]") + lines.append(f"url = {_toml_string(repository.index_url)}") + lines.append("") + lines.append(f"[certificates.{repository.name}]") + lines.append(f"cert = {_toml_string(_CA_BUNDLE)}") + lines.append("") + context.write_file( + _POETRY_CONFIG, ("\n".join(lines).rstrip() + "\n").encode("utf-8") + ) + logger.info("registered %d poetry repositories", len(repositories)) + + +def _write_distutils_cfg( + context: Context, repositories: list[PythonRepository] +) -> None: + prefixes = [ + p + for pattern in _DISTUTILS_GLOBS + for p in context.glob_src(pattern) + if p.is_dir() + ] + for prefix in prefixes: + relative = prefix.relative_to(context.src).as_posix() + content = f"[easy_install]\nindex_url = {repositories[0].index_url}\n" + context.write_file( + f"{relative}/distutils/distutils.cfg", content.encode("utf-8") + ) + logger.info("configured easy_install for %s", relative) + + +@register +class PythonModule(Module): + name = "python" + + def is_relevant(self, context: Context) -> bool: + return bool(context.config.configurations.python.repositories) + + def apply(self, context: Context) -> None: + repositories = context.config.configurations.python.repositories + _write_pip_conf(context, repositories) + _write_uv_toml(context, repositories) + _write_poetry_config(context, repositories) + _write_distutils_cfg(context, repositories) diff --git a/id_configurator/modules/yum.py b/id_configurator/modules/yum.py new file mode 100644 index 0000000..737b087 --- /dev/null +++ b/id_configurator/modules/yum.py @@ -0,0 +1,126 @@ +"""Configure YUM/DNF repositories and trust their GPG keys. + +DNF is a drop-in successor to YUM and reads the exact same `.repo` files +under `/etc/yum.repos.d/`, so a single module covers both. + +If `/etc/yum.repos.d` exists, every `.repo` file already there is +copied into `` with all of its sections disabled (`enabled=0`), before +the repositories declared in the configuration are written as new, enabled +`.repo` files (so a configured repository reusing an existing file name +ends up enabled, taking precedence over the disabled copy). Each `.repo` +file is written referencing its `gpg-url` (used verbatim as `gpgkey=`, +exactly as YUM/DNF expect: `https://`, or a `file://` path already present +on the target system). Separately, each GPG key declared in +`configurations.yum.keys` is validated with `gpg` (catching a malformed key +early) and written to `/etc/pki/rpm-gpg/RPM-GPG-KEY-`, the +conventional location such keys live at on RPM-based systems. `gpg` is only +used here to validate the key; the actual trust decision is left to +YUM/DNF, which import it from that file the first time the repository is +used (as `rpm --import` would). +""" + +from __future__ import annotations + +import base64 +import configparser +import logging +import shutil +import subprocess +import tempfile +from io import StringIO + +from id_configurator.config import NamedContent, YumRepository +from id_configurator.context import Context +from id_configurator.modules.base import Module, register + +logger = logging.getLogger(__name__) + +_REPOS_DIR = "etc/yum.repos.d" +_RPM_GPG_DIR = "etc/pki/rpm-gpg" + + +def _disable_repo_content(content: str) -> str: + parser = configparser.ConfigParser(interpolation=None) + parser.optionxform = str + parser.read_string(content) + for section in parser.sections(): + parser[section]["enabled"] = "0" + buffer = StringIO() + parser.write(buffer, space_around_delimiters=False) + return buffer.getvalue() + + +def _disable_existing_repos(context: Context) -> None: + for entry in context.iter_src_dir(_REPOS_DIR): + if entry.suffix != ".repo": + continue + disabled = _disable_repo_content(entry.read_text(encoding="utf-8")) + context.write_file(f"{_REPOS_DIR}/{entry.name}", disabled.encode("utf-8")) + logger.info("disabled existing yum/dnf repository file %s", entry.name) + + +def _write_repo_file(context: Context, repository: YumRepository) -> None: + lines = [ + f"[{repository.name}]", + f"name={repository.name}", + f"baseurl={repository.index_url}", + "enabled=1", + ] + if repository.gpg_url: + lines.append("gpgcheck=1") + lines.append(f"gpgkey={repository.gpg_url}") + else: + lines.append("gpgcheck=0") + context.write_file( + f"{_REPOS_DIR}/{repository.name}.repo", + ("\n".join(lines) + "\n").encode("utf-8"), + ) + logger.info("added yum/dnf repository %s", repository.name) + + +def _validate_gpg_key(name: str, content: bytes) -> None: + with tempfile.TemporaryDirectory() as gnupg_home: + path = shutil.which("gpg") + if path is not None: + result = subprocess.run( + [path, "--homedir", gnupg_home, "--batch", "--import"], + input=content, + capture_output=True, + check=False, + ) + else: + raise RuntimeError( + "gpg is not available; the gnupg package must be installed in the environment running id-configurator" + ) + if result.returncode != 0: + raise RuntimeError( + f"invalid GPG key {name}: {result.stderr.decode('utf-8', 'replace')}" + ) + + +def _import_key(context: Context, key: NamedContent) -> None: + content = base64.b64decode(key.content) + _validate_gpg_key(key.name, content) + context.write_file(f"{_RPM_GPG_DIR}/RPM-GPG-KEY-{key.name}", content) + logger.info("imported yum/dnf GPG key %s", key.name) + + +@register +class YumModule(Module): + name = "yum" + + def is_relevant(self, context: Context) -> bool: + yum = context.config.configurations.yum + existing_repo_files = ( + entry + for entry in context.iter_src_dir(_REPOS_DIR) + if entry.suffix == ".repo" + ) + return bool(yum.repositories) or bool(yum.keys) or any(existing_repo_files) + + def apply(self, context: Context) -> None: + _disable_existing_repos(context) + for repository in context.config.configurations.yum.repositories: + _write_repo_file(context, repository) + for key in context.config.configurations.yum.keys: + _import_key(context, key) diff --git a/pyproject.toml b/pyproject.toml index 48277d6..a69154d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,9 @@ classifiers = [ 'Programming Language :: Python :: 3.14' ] dependencies = [ + "cryptography>=50.0.1", "pydantic>=2.13.4", + "pyjks>=20.0.0", "pyyaml>=6.0.3", "typer>=0.27.1" ] @@ -67,5 +69,8 @@ source = ["id_configurator"] addopts = "--cov=id_configurator --cov-report=term-missing" testpaths = ["tests"] +[tool.uv] +default-groups = ["dev", "test"] + [tool.uv.build-backend] module-root = "" diff --git a/tests/test_apk.py b/tests/test_apk.py new file mode 100644 index 0000000..ed1441b --- /dev/null +++ b/tests/test_apk.py @@ -0,0 +1,104 @@ +import base64 +import hashlib + +from id_configurator.config import Configuration +from id_configurator.context import Context +from id_configurator.modules.apk import ApkModule + +KEY_BYTES = b"fake-apk-key-bytes" +KEY_FILENAME = f"{hashlib.sha256(KEY_BYTES).hexdigest()[:16]}.rsa.pub" + +CONFIG_DATA = { + "uuid": "12345678-1234-5678-1234-567812345678", + "organisation": "InterDiode", + "configurations": { + "apk": { + "repositories": [ + { + "url": "https://example.invalid/v3.19/main", + "key": base64.b64encode(KEY_BYTES).decode("ascii"), + }, + {"url": "https://example.invalid/v3.19/community"}, + ] + } + }, +} + + +def make_config() -> Configuration: + return Configuration.model_validate(CONFIG_DATA) + + +def test_is_relevant_false_without_src(tmp_path) -> None: + context = Context(config=make_config(), dst=tmp_path / "dst", src=None) + assert ApkModule().is_relevant(context) is False + + +def test_is_relevant_false_without_apk_dir(tmp_path) -> None: + src = tmp_path / "src" + src.mkdir() + context = Context(config=make_config(), dst=tmp_path / "dst", src=src) + assert ApkModule().is_relevant(context) is False + + +def test_is_relevant_true_with_apk_dir(tmp_path) -> None: + src = tmp_path / "src" + (src / "etc/apk").mkdir(parents=True) + context = Context(config=make_config(), dst=tmp_path / "dst", src=src) + assert ApkModule().is_relevant(context) is True + + +def test_apply_disables_existing_and_adds_configured_repositories(tmp_path) -> None: + src = tmp_path / "src" + apk_dir = src / "etc/apk" + apk_dir.mkdir(parents=True) + (apk_dir / "repositories").write_text( + "# comment\nhttps://old-mirror.invalid/v3.18/main\nhttps://old-mirror.invalid/v3.18/community\n", + encoding="utf-8", + ) + + dst = tmp_path / "dst" + context = Context(config=make_config(), dst=dst, src=src) + + ApkModule().apply(context) + + content = (dst / "etc/apk/repositories").read_text(encoding="utf-8") + assert content == ( + "# comment\n" + "# https://old-mirror.invalid/v3.18/main\n" + "# https://old-mirror.invalid/v3.18/community\n" + "https://example.invalid/v3.19/main\n" + "https://example.invalid/v3.19/community\n" + ) + + key_content = (dst / "etc/apk/keys" / KEY_FILENAME).read_bytes() + assert key_content == KEY_BYTES + + +def test_apply_without_existing_repositories_file(tmp_path) -> None: + src = tmp_path / "src" + (src / "etc/apk").mkdir(parents=True) + dst = tmp_path / "dst" + context = Context(config=make_config(), dst=dst, src=src) + + ApkModule().apply(context) + + content = (dst / "etc/apk/repositories").read_text(encoding="utf-8") + assert ( + content + == "https://example.invalid/v3.19/main\nhttps://example.invalid/v3.19/community\n" + ) + + +def test_apply_writes_nothing_when_no_repositories_at_all(tmp_path) -> None: + empty_config = Configuration.model_validate( + {"uuid": "12345678-1234-5678-1234-567812345678", "organisation": "InterDiode"} + ) + src = tmp_path / "src" + (src / "etc/apk").mkdir(parents=True) + dst = tmp_path / "dst" + context = Context(config=empty_config, dst=dst, src=src) + + ApkModule().apply(context) + + assert not (dst / "etc/apk/repositories").exists() diff --git a/tests/test_apt.py b/tests/test_apt.py index a00b6d4..7c1754f 100644 --- a/tests/test_apt.py +++ b/tests/test_apt.py @@ -34,21 +34,21 @@ def make_config() -> Configuration: def test_is_relevant_false_without_src(tmp_path) -> None: context = Context(config=make_config(), dst=tmp_path / "dst", src=None) - assert AptModule().is_relevant(context) is False # nosec B101 + assert AptModule().is_relevant(context) is False def test_is_relevant_false_without_apt_dir(tmp_path) -> None: src = tmp_path / "src" src.mkdir() context = Context(config=make_config(), dst=tmp_path / "dst", src=src) - assert AptModule().is_relevant(context) is False # nosec B101 + assert AptModule().is_relevant(context) is False def test_is_relevant_true_with_apt_dir(tmp_path) -> None: src = tmp_path / "src" (src / "etc/apt").mkdir(parents=True) context = Context(config=make_config(), dst=tmp_path / "dst", src=src) - assert AptModule().is_relevant(context) is True # nosec B101 + assert AptModule().is_relevant(context) is True def test_apply_disables_existing_sources_and_adds_configured_ones(tmp_path) -> None: @@ -82,31 +82,31 @@ def test_apply_disables_existing_sources_and_adds_configured_ones(tmp_path) -> N assert ( disabled_top_level == "# already disabled\n# deb http://example.invalid/legacy stable main\n\n" - ) # nosec B101 + ) disabled_list = (dst / "etc/apt/sources.list.d/extra.list").read_text( encoding="utf-8" - ) # nosec B101 - assert disabled_list == "# deb http://example.invalid/extra stable main\n" # nosec B101 + ) + assert disabled_list == "# deb http://example.invalid/extra stable main\n" disabled_sources = (dst / "etc/apt/sources.list.d/docker.sources").read_text( encoding="utf-8" - ) # nosec B101 - assert disabled_sources == ( # nosec B101 + ) + assert disabled_sources == ( "Types: deb\nURIs: http://example.invalid/docker\nSuites: stable\nEnabled: no" "\n\n" "Types: deb\nURIs: http://example.invalid/docker2\nSuites: stable\nEnabled: no\n" ) - assert not (dst / "etc/apt/sources.list.d/ignored.txt").exists() # nosec B101 + assert not (dst / "etc/apt/sources.list.d/ignored.txt").exists() keyring = (dst / "etc/apt/keyrings/interdiode.asc").read_bytes() - assert keyring == b"fake-key-bytes" # nosec B101 + assert keyring == b"fake-key-bytes" configured_source = (dst / "etc/apt/sources.list.d/interdiode.sources").read_text( encoding="utf-8" ) - assert configured_source == ( # nosec B101 + assert configured_source == ( "Types: deb\nURIs: http://example.invalid/debian\nSuites: stable\n" "Signed-By: /etc/apt/keyrings/interdiode.asc\nEnabled: yes\n" ) @@ -120,5 +120,5 @@ def test_apply_without_existing_top_level_sources_list(tmp_path) -> None: AptModule().apply(context) - assert not (dst / "etc/apt/sources.list").exists() # nosec B101 - assert (dst / "etc/apt/sources.list.d/interdiode.sources").exists() # nosec B101 + assert not (dst / "etc/apt/sources.list").exists() + assert (dst / "etc/apt/sources.list.d/interdiode.sources").exists() diff --git a/tests/test_certificates.py b/tests/test_certificates.py index be44158..d14c766 100644 --- a/tests/test_certificates.py +++ b/tests/test_certificates.py @@ -25,12 +25,12 @@ def make_config(with_certificate: bool) -> Configuration: def test_is_relevant_true_when_certificates_configured(tmp_path) -> None: context = Context(config=make_config(True), dst=tmp_path / "dst", src=None) - assert CertificatesModule().is_relevant(context) is True # nosec B101 + assert CertificatesModule().is_relevant(context) is True def test_is_relevant_false_when_no_certificates(tmp_path) -> None: context = Context(config=make_config(False), dst=tmp_path / "dst", src=None) - assert CertificatesModule().is_relevant(context) is False # nosec B101 + assert CertificatesModule().is_relevant(context) is False def _mock_success(monkeypatch, stderr: str = "") -> MagicMock: @@ -53,12 +53,12 @@ def test_apply_writes_certificate_and_calls_update_ca_certificates( CertificatesModule().apply(context) written = dst / "usr/local/share/ca-certificates/new-root-ca.crt" - assert written.read_bytes() == b"fake-cert-bytes" # nosec B101 + assert written.read_bytes() == b"fake-cert-bytes" mock_run.assert_called_once() args = mock_run.call_args.args[0] - assert args[0] == "update-ca-certificates" # nosec B101 - assert "--certsconf" not in args # nosec B101 - assert "--certsdir" not in args # nosec B101 + assert args[0] == "update-ca-certificates" + assert "--certsconf" not in args + assert "--certsdir" not in args def test_apply_preserves_existing_local_certificates_from_src( @@ -82,14 +82,14 @@ def test_apply_preserves_existing_local_certificates_from_src( CertificatesModule().apply(context) local_dst = dst / "usr/local/share/ca-certificates" - assert (local_dst / "existing.crt").read_bytes() == b"existing-bytes" # nosec B101 - assert (local_dst / "new-root-ca.crt").read_bytes() == b"fake-cert-bytes" # nosec B101 + assert (local_dst / "existing.crt").read_bytes() == b"existing-bytes" + assert (local_dst / "new-root-ca.crt").read_bytes() == b"fake-cert-bytes" args = mock_run.call_args.args[0] - assert "--certsconf" in args # nosec B101 - assert str(src / "etc/ca-certificates.conf") in args # nosec B101 - assert "--certsdir" in args # nosec B101 - assert str(src / "usr/share/ca-certificates") in args # nosec B101 + assert "--certsconf" in args + assert str(src / "etc/ca-certificates.conf") in args + assert "--certsdir" in args + assert str(src / "usr/share/ca-certificates") in args def test_update_ca_certificates_raises_when_binary_missing( @@ -116,4 +116,4 @@ def test_update_ca_certificates_raises_on_failure(tmp_path, monkeypatch) -> None context = Context(config=make_config(True), dst=tmp_path / "dst", src=None) with pytest.raises(RuntimeError, match="update-ca-certificates failed"): - CertificatesModule().apply(context) # nosec B101 + CertificatesModule().apply(context) diff --git a/tests/test_cli.py b/tests/test_cli.py index 69fac16..6acdc6b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -32,20 +32,27 @@ def write_config(tmp_path, data) -> str: def test_validate_valid_config(tmp_path) -> None: config_path = write_config(tmp_path, MINIMAL_CONFIG) result = runner.invoke(cli.app, ["validate", "--config", config_path]) - assert result.exit_code == 0 # nosec B101 - assert "is valid" in result.stdout # nosec B101 + assert result.exit_code == 0 + assert "is valid" in result.stdout def test_validate_without_config_fails() -> None: result = runner.invoke(cli.app, ["validate"]) - assert result.exit_code != 0 # nosec B101 - assert "either --config or --config-env" in result.output # nosec B101 + assert result.exit_code != 0 + assert "either --config or --config-env" in result.output def test_list_modules() -> None: result = runner.invoke(cli.app, ["list-modules"]) - assert result.exit_code == 0 # nosec B101 - assert set(result.stdout.splitlines()) == {"apt", "certificates"} # nosec B101 + assert result.exit_code == 0 + assert set(result.stdout.splitlines()) == { + "apk", + "apt", + "certificates", + "java", + "python", + "yum", + } def test_apply_skips_irrelevant_modules(tmp_path, caplog) -> None: @@ -55,9 +62,13 @@ def test_apply_skips_irrelevant_modules(tmp_path, caplog) -> None: result = runner.invoke( cli.app, ["apply", "--config", config_path, "--dst", str(dst), "-v"] ) - assert result.exit_code == 0 # nosec B101 - assert "skipping module apt" in caplog.text # nosec B101 - assert "skipping module certificates" in caplog.text # nosec B101 + assert result.exit_code == 0 + assert "skipping module apk" in caplog.text + assert "skipping module java" in caplog.text + assert "skipping module apt" in caplog.text + assert "skipping module certificates" in caplog.text + assert "skipping module yum" in caplog.text + assert "skipping module python" in caplog.text def test_apply_runs_relevant_module(tmp_path, monkeypatch) -> None: @@ -74,8 +85,8 @@ def test_apply_runs_relevant_module(tmp_path, monkeypatch) -> None: cli.app, ["apply", "--config", config_path, "--dst", str(dst)] ) - assert result.exit_code == 0 # nosec B101 - assert (dst / "usr/local/share/ca-certificates/my-ca.crt").read_bytes() == b"fake" # nosec B101 + assert result.exit_code == 0 + assert (dst / "usr/local/share/ca-certificates/my-ca.crt").read_bytes() == b"fake" mock_run.assert_called_once() diff --git a/tests/test_config.py b/tests/test_config.py index 2974816..4efdb90 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -36,7 +36,8 @@ FULL_CONFIG = { "index-url": "http://example.invalid/yum", "gpg-url": "http://example.invalid/gpg", } - ] + ], + "keys": [{"name": "interdiode", "content": "ZmFrZQ=="}], }, "python": { "repositories": [ @@ -49,26 +50,27 @@ FULL_CONFIG = { def test_parse_configuration_from_json() -> None: configuration = parse_configuration(json.dumps(FULL_CONFIG)) - assert isinstance(configuration, Configuration) # nosec B101 - assert configuration.organisation == "InterDiode" # nosec B101 - assert configuration.configurations.certificates[0].name == "my-ca" # nosec B101 + assert isinstance(configuration, Configuration) + assert configuration.organisation == "InterDiode" + assert configuration.configurations.certificates[0].name == "my-ca" assert ( configuration.configurations.yum.repositories[0].index_url == "http://example.invalid/yum" - ) # nosec B101 + ) assert ( configuration.configurations.yum.repositories[0].gpg_url == "http://example.invalid/gpg" - ) # nosec B101 + ) assert ( configuration.configurations.python.repositories[0].index_url == "http://example.invalid/simple" - ) # nosec B101 + ) assert ( configuration.configurations.apt.sources[0].signed_by == "/etc/apt/keyrings/interdiode.asc" - ) # nosec B101 - assert configuration.configurations.apt.keys[0].name == "interdiode" # nosec B101 + ) + assert configuration.configurations.apt.keys[0].name == "interdiode" + assert configuration.configurations.yum.keys[0].name == "interdiode" def test_parse_configuration_from_yaml() -> None: @@ -77,22 +79,22 @@ def test_parse_configuration_from_yaml() -> None: organisation: InterDiode """ configuration = parse_configuration(raw) - assert configuration.organisation == "InterDiode" # nosec B101 - assert configuration.description == "" # nosec B101 - assert configuration.configurations.certificates == [] # nosec B101 + assert configuration.organisation == "InterDiode" + assert configuration.description == "" + assert configuration.configurations.certificates == [] def test_load_configuration_from_file(tmp_path) -> None: config_file = tmp_path / "config.json" config_file.write_text(json.dumps(FULL_CONFIG), encoding="utf-8") configuration = load_configuration(config_file, None) - assert configuration.organisation == "InterDiode" # nosec B101 + assert configuration.organisation == "InterDiode" def test_load_configuration_from_env() -> None: encoded = base64.b64encode(json.dumps(FULL_CONFIG).encode("utf-8")).decode("ascii") configuration = load_configuration(None, encoded) - assert configuration.organisation == "InterDiode" # nosec B101 + assert configuration.organisation == "InterDiode" def test_load_configuration_without_source_raises() -> None: @@ -107,7 +109,7 @@ def test_apt_source_to_deb822_minimal() -> None: assert ( source.to_deb822() == "Types: deb\nURIs: http://example.invalid/debian\nSuites: stable\nEnabled: yes\n" - ) # nosec B101 + ) def test_apt_source_to_deb822_full() -> None: @@ -138,7 +140,7 @@ def test_apt_source_to_deb822_full() -> None: description="InterDiode APT repository", ) rendered = source.to_deb822() - assert rendered.splitlines() == [ # nosec B101 + assert rendered.splitlines() == [ "Types: deb deb-src", "URIs: http://example.invalid/debian", "Suites: stable", diff --git a/tests/test_context.py b/tests/test_context.py index a3b6fd1..bf81393 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -21,7 +21,7 @@ def make_context(tmp_path, with_src: bool = True) -> Context: def test_dst_path_within_destination(tmp_path) -> None: context = make_context(tmp_path) resolved = context.dst_path("etc/foo.conf") - assert resolved == context.dst / "etc" / "foo.conf" # nosec B101 + assert resolved == context.dst / "etc" / "foo.conf" def test_dst_path_rejects_escape(tmp_path) -> None: @@ -32,46 +32,46 @@ def test_dst_path_rejects_escape(tmp_path) -> None: def test_src_path_without_src(tmp_path) -> None: context = make_context(tmp_path, with_src=False) - assert context.src_path("etc/foo.conf") is None # nosec B101 + assert context.src_path("etc/foo.conf") is None def test_src_path_with_src(tmp_path) -> None: context = make_context(tmp_path) resolved = context.src_path("etc/foo.conf") - assert resolved == context.src / "etc" / "foo.conf" # nosec B101 + assert resolved == context.src / "etc" / "foo.conf" def test_write_file_creates_parents_and_content(tmp_path) -> None: context = make_context(tmp_path) path = context.write_file("etc/nested/foo.conf", b"hello") - assert path.read_bytes() == b"hello" # nosec B101 - assert path.stat().st_mode & 0o777 == 0o644 # nosec B101 + assert path.read_bytes() == b"hello" + assert path.stat().st_mode & 0o777 == 0o644 def test_read_src_file_without_src(tmp_path) -> None: context = make_context(tmp_path, with_src=False) - assert context.read_src_file("etc/foo.conf") is None # nosec B101 + assert context.read_src_file("etc/foo.conf") is None def test_read_src_file_missing(tmp_path) -> None: context = make_context(tmp_path) - assert context.read_src_file("etc/missing.conf") is None # nosec B101 + assert context.read_src_file("etc/missing.conf") is None def test_read_src_file_present(tmp_path) -> None: context = make_context(tmp_path) (context.src / "foo.conf").write_bytes(b"existing") - assert context.read_src_file("foo.conf") == b"existing" # nosec B101 + assert context.read_src_file("foo.conf") == b"existing" def test_iter_src_dir_without_src(tmp_path) -> None: context = make_context(tmp_path, with_src=False) - assert context.iter_src_dir("some/dir") == [] # nosec B101 + assert context.iter_src_dir("some/dir") == [] def test_iter_src_dir_missing_dir(tmp_path) -> None: context = make_context(tmp_path) - assert context.iter_src_dir("missing-dir") == [] # nosec B101 + assert context.iter_src_dir("missing-dir") == [] def test_iter_src_dir_lists_only_files(tmp_path) -> None: @@ -82,4 +82,18 @@ def test_iter_src_dir_lists_only_files(tmp_path) -> None: (target / "b.crt").write_bytes(b"b") (target / "subdir").mkdir() result = context.iter_src_dir("certs") - assert [entry.name for entry in result] == ["a.crt", "b.crt"] # nosec B101 + assert [entry.name for entry in result] == ["a.crt", "b.crt"] + + +def test_glob_src_without_src(tmp_path) -> None: + context = make_context(tmp_path, with_src=False) + assert context.glob_src("usr/lib/python3.*") == [] + + +def test_glob_src_matches_pattern(tmp_path) -> None: + context = make_context(tmp_path) + (context.src / "usr/lib/python3.11").mkdir(parents=True) + (context.src / "usr/lib/python3.12").mkdir(parents=True) + (context.src / "usr/lib/python-legacy").mkdir(parents=True) + result = context.glob_src("usr/lib/python3.*") + assert [entry.name for entry in result] == ["python3.11", "python3.12"] diff --git a/tests/test_java.py b/tests/test_java.py new file mode 100644 index 0000000..56a1da1 --- /dev/null +++ b/tests/test_java.py @@ -0,0 +1,392 @@ +import base64 +import datetime + +import jks +import pytest +from cryptography import x509 +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.serialization import ( + BestAvailableEncryption, + Encoding, + pkcs12, +) +from cryptography.x509.oid import NameOID + +from id_configurator.config import Configuration +from id_configurator.context import Context +from id_configurator.modules.java import JavaModule, _keystore_kind + + +def _self_signed_cert(common_name: str) -> x509.Certificate: + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + subject = issuer = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, common_name)]) + now = datetime.datetime.now(datetime.UTC) + return ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now) + .not_valid_after(now + datetime.timedelta(days=1)) + .sign(key, hashes.SHA256()) + ) + + +NEW_CERT = _self_signed_cert("interdiode-root-ca") +EXISTING_CERT = _self_signed_cert("already-trusted-ca") + +CONFIG_WITH_CERT = { + "uuid": "12345678-1234-5678-1234-567812345678", + "organisation": "InterDiode", + "configurations": { + "certificates": [ + { + "name": "interdiode-root-ca", + "content": base64.b64encode(NEW_CERT.public_bytes(Encoding.PEM)).decode( + "ascii" + ), + } + ] + }, +} + +EMPTY_CONFIG = { + "uuid": "12345678-1234-5678-1234-567812345678", + "organisation": "InterDiode", +} + + +def make_config(data: dict) -> Configuration: + return Configuration.model_validate(data) + + +def _pkcs12_cacerts( + entries: list[tuple[str, x509.Certificate]] = (), password: bytes = b"changeit" +) -> bytes: + # serialize_java_truststore refuses an empty cert list, so seed with a placeholder when none given. + seeded = list(entries) or [("placeholder-ca", EXISTING_CERT)] + certs = [ + pkcs12.PKCS12Certificate(cert, name.encode("utf-8")) for name, cert in seeded + ] + return pkcs12.serialize_java_truststore(certs, BestAvailableEncryption(password)) + + +def _jks_cacerts(entries: list[tuple[str, x509.Certificate]] = ()) -> bytes: + store = jks.KeyStore.new("jks", []) + for name, cert in entries: + store.entries[name] = jks.TrustedCertEntry.new( + name, cert.public_bytes(Encoding.DER) + ) + return store.saves("changeit") + + +def test_is_relevant_false_without_certificates(tmp_path) -> None: + src = tmp_path / "src" + (src / "usr/lib/jvm/java-21/lib/security").mkdir(parents=True) + (src / "usr/lib/jvm/java-21/lib/security/cacerts").write_bytes(_pkcs12_cacerts()) + context = Context(config=make_config(EMPTY_CONFIG), dst=tmp_path / "dst", src=src) + assert JavaModule().is_relevant(context) is False + + +def test_is_relevant_false_without_java_installation(tmp_path) -> None: + context = Context( + config=make_config(CONFIG_WITH_CERT), dst=tmp_path / "dst", src=None + ) + assert JavaModule().is_relevant(context) is False + + +def test_is_relevant_true_with_certificate_and_cacerts(tmp_path) -> None: + src = tmp_path / "src" + (src / "usr/lib/jvm/java-21/lib/security").mkdir(parents=True) + (src / "usr/lib/jvm/java-21/lib/security/cacerts").write_bytes(_pkcs12_cacerts()) + context = Context( + config=make_config(CONFIG_WITH_CERT), dst=tmp_path / "dst", src=src + ) + assert JavaModule().is_relevant(context) is True + + +def test_apply_adds_certificate_to_pkcs12_and_jks_keystores(tmp_path) -> None: + src = tmp_path / "src" + pkcs12_dir = src / "usr/lib/jvm/java-21/lib/security" + jks_dir = src / "opt/java/openjdk8/jre/lib/security" + pkcs12_dir.mkdir(parents=True) + jks_dir.mkdir(parents=True) + (pkcs12_dir / "cacerts").write_bytes( + _pkcs12_cacerts([("already-trusted-ca", EXISTING_CERT)]) + ) + (jks_dir / "cacerts").write_bytes( + _jks_cacerts([("already-trusted-ca", EXISTING_CERT)]) + ) + + dst = tmp_path / "dst" + context = Context(config=make_config(CONFIG_WITH_CERT), dst=dst, src=src) + + JavaModule().apply(context) + + pkcs12_result = pkcs12.load_pkcs12( + (dst / "usr/lib/jvm/java-21/lib/security/cacerts").read_bytes(), b"changeit" + ) + pkcs12_aliases = {entry.friendly_name for entry in pkcs12_result.additional_certs} + assert pkcs12_aliases == {b"already-trusted-ca", b"interdiode-root-ca"} + + jks_result = jks.KeyStore.loads( + (dst / "opt/java/openjdk8/jre/lib/security/cacerts").read_bytes(), "changeit" + ) + assert set(jks_result.certs.keys()) == {"already-trusted-ca", "interdiode-root-ca"} + assert jks_result.certs["interdiode-root-ca"].cert == NEW_CERT.public_bytes( + Encoding.DER + ) + + +def test_apply_skips_keystore_with_wrong_password(tmp_path, caplog) -> None: + src = tmp_path / "src" + good_dir = src / "usr/lib/jvm/java-21/lib/security" + bad_dir = src / "opt/java/other/lib/security" + good_dir.mkdir(parents=True) + bad_dir.mkdir(parents=True) + (good_dir / "cacerts").write_bytes(_pkcs12_cacerts()) + (bad_dir / "cacerts").write_bytes(_pkcs12_cacerts(password=b"not-changeit")) + + dst = tmp_path / "dst" + context = Context(config=make_config(CONFIG_WITH_CERT), dst=dst, src=src) + + with caplog.at_level("WARNING", logger="id_configurator"): + JavaModule().apply(context) + + assert (dst / "usr/lib/jvm/java-21/lib/security/cacerts").exists() + assert not (dst / "opt/java/other/lib/security/cacerts").exists() + assert "skipping java keystore" in caplog.text + + +@pytest.mark.parametrize( + ("content", "expected"), + [ + (jks.MAGIC_NUMBER_JKS + b"rest", "jks"), + (jks.MAGIC_NUMBER_JCEKS + b"rest", "jks"), + (b"\x30\x82\x00\x00rest", "pkcs12"), + ], +) +def test_keystore_kind_detection(content, expected) -> None: + assert _keystore_kind(content) == expected + + +ONE_REPO_CONFIG = { + "uuid": "12345678-1234-5678-1234-567812345678", + "organisation": "InterDiode", + "configurations": { + "java": { + "repositories": [ + {"name": "interdiode", "url": "https://example.invalid/maven"} + ] + } + }, +} + +TWO_REPOS_CONFIG = { + "uuid": "12345678-1234-5678-1234-567812345678", + "organisation": "InterDiode", + "configurations": { + "java": { + "repositories": [ + {"name": "interdiode", "url": "https://example.invalid/maven"}, + { + "name": "interdiode-extra", + "url": "https://example2.invalid/maven", + }, + ] + } + }, +} + + +def test_is_relevant_true_with_repositories_and_no_src(tmp_path) -> None: + context = Context( + config=make_config(ONE_REPO_CONFIG), dst=tmp_path / "dst", src=None + ) + assert JavaModule().is_relevant(context) is True + + +def test_apply_without_certificates_only_writes_repository_files(tmp_path) -> None: + dst = tmp_path / "dst" + context = Context(config=make_config(ONE_REPO_CONFIG), dst=dst, src=None) + + JavaModule().apply(context) + + assert not (dst / "usr").exists() + assert (dst / "etc/maven/settings.xml").exists() + + +def test_apply_writes_maven_settings(tmp_path) -> None: + dst = tmp_path / "dst" + context = Context(config=make_config(TWO_REPOS_CONFIG), dst=dst, src=None) + + JavaModule().apply(context) + + settings = (dst / "etc/maven/settings.xml").read_text(encoding="utf-8") + assert settings == ( + '\n' + '\n' + " \n" + " \n" + " id-configurator\n" + " \n" + " true\n" + " \n" + " \n" + " \n" + " interdiode\n" + " https://example.invalid/maven\n" + " \n" + " \n" + " interdiode-extra\n" + " https://example2.invalid/maven\n" + " \n" + " \n" + " \n" + " \n" + " interdiode\n" + " https://example.invalid/maven\n" + " \n" + " \n" + " interdiode-extra\n" + " https://example2.invalid/maven\n" + " \n" + " \n" + " \n" + " \n" + " \n" + " id-configurator\n" + " \n" + " \n" + " \n" + " interdiode\n" + " https://example.invalid/maven\n" + " *\n" + " \n" + " \n" + "\n" + ) + + +def test_apply_writes_gradle_init_script(tmp_path) -> None: + dst = tmp_path / "dst" + context = Context(config=make_config(TWO_REPOS_CONFIG), dst=dst, src=None) + + JavaModule().apply(context) + + script = ( + dst / "root/.gradle/init.d/id-configurator-repositories.gradle" + ).read_text(encoding="utf-8") + assert script == ( + "allprojects {\n" + " repositories {\n" + ' maven { url = uri("https://example.invalid/maven") }\n' + ' maven { url = uri("https://example2.invalid/maven") }\n' + " }\n" + "}\n" + "\n" + "settingsEvaluated { settings ->\n" + " settings.pluginManagement {\n" + " repositories {\n" + ' maven { url = uri("https://example.invalid/maven") }\n' + ' maven { url = uri("https://example2.invalid/maven") }\n' + " }\n" + " }\n" + "}\n" + ) + + +def test_apply_writes_ivy_settings(tmp_path) -> None: + dst = tmp_path / "dst" + context = Context(config=make_config(TWO_REPOS_CONFIG), dst=dst, src=None) + + JavaModule().apply(context) + + ivysettings = (dst / "root/.ivy2/ivysettings.xml").read_text(encoding="utf-8") + assert ivysettings == ( + '\n' + "\n" + ' \n' + " \n" + ' \n' + ' \n' + ' \n' + " \n" + " \n" + "\n" + ) + + +def test_apply_writes_sbt_repositories(tmp_path) -> None: + dst = tmp_path / "dst" + context = Context(config=make_config(TWO_REPOS_CONFIG), dst=dst, src=None) + + JavaModule().apply(context) + + repositories = (dst / "root/.sbt/repositories").read_text(encoding="utf-8") + assert repositories == ( + "[repositories]\n" + " local\n" + " interdiode: https://example.invalid/maven\n" + " interdiode-extra: https://example2.invalid/maven\n" + ) + + +def test_apply_writes_lein_profiles(tmp_path) -> None: + dst = tmp_path / "dst" + context = Context(config=make_config(TWO_REPOS_CONFIG), dst=dst, src=None) + + JavaModule().apply(context) + + profiles = (dst / "root/.lein/profiles.clj").read_text(encoding="utf-8") + assert profiles == ( + "{:user {:repositories " + '[["interdiode" {:url "https://example.invalid/maven"}] ' + '["interdiode-extra" {:url "https://example2.invalid/maven"}]]}}\n' + ) + + +def test_apply_with_certificates_and_repositories_writes_both(tmp_path) -> None: + src = tmp_path / "src" + (src / "usr/lib/jvm/java-21/lib/security").mkdir(parents=True) + (src / "usr/lib/jvm/java-21/lib/security/cacerts").write_bytes(_pkcs12_cacerts()) + + combined_config = { + **CONFIG_WITH_CERT, + "configurations": { + **CONFIG_WITH_CERT["configurations"], + "java": ONE_REPO_CONFIG["configurations"]["java"], + }, + } + dst = tmp_path / "dst" + context = Context(config=make_config(combined_config), dst=dst, src=src) + + JavaModule().apply(context) + + assert (dst / "usr/lib/jvm/java-21/lib/security/cacerts").exists() + assert (dst / "etc/maven/settings.xml").exists() + + +def test_apply_writes_maven_settings_to_detected_installations(tmp_path) -> None: + src = tmp_path / "src" + (src / "usr/share/maven/bin").mkdir(parents=True) + (src / "usr/share/maven/bin/mvn").write_text("#!/bin/sh\n") + (src / "opt/apache-maven-3.9.6/bin").mkdir(parents=True) + (src / "opt/apache-maven-3.9.6/bin/mvn").write_text("#!/bin/sh\n") + dst = tmp_path / "dst" + context = Context(config=make_config(ONE_REPO_CONFIG), dst=dst, src=src) + + JavaModule().apply(context) + + global_settings = (dst / "etc/maven/settings.xml").read_bytes() + assert (dst / "usr/share/maven/conf/settings.xml").read_bytes() == global_settings + assert ( + dst / "opt/apache-maven-3.9.6/conf/settings.xml" + ).read_bytes() == global_settings diff --git a/tests/test_modules_base.py b/tests/test_modules_base.py index 1bdaa01..109d482 100644 --- a/tests/test_modules_base.py +++ b/tests/test_modules_base.py @@ -16,4 +16,4 @@ def test_register_and_all_modules_are_isolated(monkeypatch) -> None: register(DummyModule) - assert all_modules() == [DummyModule] # nosec B101 + assert all_modules() == [DummyModule] diff --git a/tests/test_package.py b/tests/test_package.py index 5ce03f5..7017e27 100644 --- a/tests/test_package.py +++ b/tests/test_package.py @@ -6,8 +6,8 @@ from unittest.mock import MagicMock def test_package_exposes_app_and_main() -> None: import id_configurator - assert id_configurator.main is id_configurator.cli.main # nosec B101 - assert id_configurator.app is id_configurator.cli.app # nosec B101 + assert id_configurator.main is id_configurator.cli.main + assert id_configurator.app is id_configurator.cli.app def test_dunder_main_module_does_not_launch_app_when_imported(monkeypatch) -> None: diff --git a/tests/test_python.py b/tests/test_python.py new file mode 100644 index 0000000..e90ff11 --- /dev/null +++ b/tests/test_python.py @@ -0,0 +1,139 @@ +from id_configurator.config import Configuration +from id_configurator.context import Context +from id_configurator.modules.python import PythonModule + +ONE_REPO_CONFIG = { + "uuid": "12345678-1234-5678-1234-567812345678", + "organisation": "InterDiode", + "configurations": { + "python": { + "repositories": [ + {"name": "interdiode", "index-url": "https://example.invalid/simple"} + ] + } + }, +} + +TWO_REPOS_CONFIG = { + "uuid": "12345678-1234-5678-1234-567812345678", + "organisation": "InterDiode", + "configurations": { + "python": { + "repositories": [ + {"name": "interdiode", "index-url": "https://example.invalid/simple"}, + { + "name": "interdiode-extra", + "index-url": "https://example2.invalid/simple", + }, + ] + } + }, +} + + +def test_is_relevant_false_without_repositories(tmp_path) -> None: + config = Configuration.model_validate( + {"uuid": "12345678-1234-5678-1234-567812345678", "organisation": "InterDiode"} + ) + context = Context(config=config, dst=tmp_path / "dst", src=None) + assert PythonModule().is_relevant(context) is False + + +def test_is_relevant_true_with_repositories(tmp_path) -> None: + config = Configuration.model_validate(ONE_REPO_CONFIG) + context = Context(config=config, dst=tmp_path / "dst", src=None) + assert PythonModule().is_relevant(context) is True + + +def test_apply_single_repository(tmp_path) -> None: + config = Configuration.model_validate(ONE_REPO_CONFIG) + dst = tmp_path / "dst" + context = Context(config=config, dst=dst, src=None) + + PythonModule().apply(context) + + pip_conf = (dst / "etc/pip.conf").read_text(encoding="utf-8") + assert pip_conf == ( + "[global]\nindex-url = https://example.invalid/simple\ncert = /etc/ssl/certs/ca-certificates.crt\n" + ) + + uv_toml = (dst / "etc/uv/uv.toml").read_text(encoding="utf-8") + assert uv_toml == ( + 'native-tls = true\n\n[[index]]\nname = "interdiode"\nurl = "https://example.invalid/simple"\ndefault = true\n' + ) + + poetry_config = (dst / "root/.config/pypoetry/config.toml").read_text( + encoding="utf-8" + ) + assert poetry_config == ( + "[repositories.interdiode]\n" + 'url = "https://example.invalid/simple"\n' + "\n" + "[certificates.interdiode]\n" + 'cert = "/etc/ssl/certs/ca-certificates.crt"\n' + ) + + assert not (dst / "usr/lib").exists() + + +def test_apply_multiple_repositories(tmp_path) -> None: + config = Configuration.model_validate(TWO_REPOS_CONFIG) + dst = tmp_path / "dst" + context = Context(config=config, dst=dst, src=None) + + PythonModule().apply(context) + + pip_conf = (dst / "etc/pip.conf").read_text(encoding="utf-8") + assert pip_conf == ( + "[global]\n" + "index-url = https://example.invalid/simple\n" + "extra-index-url =\n" + " https://example2.invalid/simple\n" + "cert = /etc/ssl/certs/ca-certificates.crt\n" + ) + + uv_toml = (dst / "etc/uv/uv.toml").read_text(encoding="utf-8") + assert uv_toml == ( + "native-tls = true\n\n" + "[[index]]\n" + 'name = "interdiode"\n' + 'url = "https://example.invalid/simple"\n' + "default = true\n\n" + "[[index]]\n" + 'name = "interdiode-extra"\n' + 'url = "https://example2.invalid/simple"\n' + ) + + +def test_apply_writes_distutils_cfg_for_detected_python_prefixes(tmp_path) -> None: + config = Configuration.model_validate(ONE_REPO_CONFIG) + src = tmp_path / "src" + (src / "usr/lib/python3.11").mkdir(parents=True) + (src / "usr/local/lib/python3.12").mkdir(parents=True) + (src / "usr/lib/not-python").mkdir(parents=True) + dst = tmp_path / "dst" + context = Context(config=config, dst=dst, src=src) + + PythonModule().apply(context) + + content_311 = (dst / "usr/lib/python3.11/distutils/distutils.cfg").read_text( + encoding="utf-8" + ) + assert content_311 == "[easy_install]\nindex_url = https://example.invalid/simple\n" + + content_312 = (dst / "usr/local/lib/python3.12/distutils/distutils.cfg").read_text( + encoding="utf-8" + ) + assert content_312 == "[easy_install]\nindex_url = https://example.invalid/simple\n" + + assert not (dst / "usr/lib/not-python").exists() + + +def test_apply_without_src_skips_distutils_cfg(tmp_path) -> None: + config = Configuration.model_validate(ONE_REPO_CONFIG) + dst = tmp_path / "dst" + context = Context(config=config, dst=dst, src=None) + + PythonModule().apply(context) + + assert not (dst / "usr").exists() diff --git a/tests/test_yum.py b/tests/test_yum.py new file mode 100644 index 0000000..44758e1 --- /dev/null +++ b/tests/test_yum.py @@ -0,0 +1,194 @@ +import base64 +import subprocess +from configparser import ConfigParser +from unittest.mock import MagicMock + +import pytest + +from id_configurator.config import Configuration +from id_configurator.context import Context +from id_configurator.modules.yum import YumModule + +KEY_CONTENT = base64.b64encode(b"fake-rpm-gpg-key-bytes").decode("ascii") + +CONFIG_WITH_REPO_AND_KEY = { + "uuid": "12345678-1234-5678-1234-567812345678", + "organisation": "InterDiode", + "configurations": { + "yum": { + "repositories": [ + { + "name": "interdiode", + "index-url": "https://example.invalid/yum", + "gpg-url": "https://example.invalid/RPM-GPG-KEY-interdiode", + } + ], + "keys": [{"name": "interdiode", "content": KEY_CONTENT}], + } + }, +} + +CONFIG_WITH_UNSIGNED_REPO = { + "uuid": "12345678-1234-5678-1234-567812345678", + "organisation": "InterDiode", + "configurations": { + "yum": { + "repositories": [ + {"name": "unsigned", "index-url": "https://example.invalid/yum"} + ] + } + }, +} + +EMPTY_CONFIG = { + "uuid": "12345678-1234-5678-1234-567812345678", + "organisation": "InterDiode", +} + + +def make_config(data: dict) -> Configuration: + return Configuration.model_validate(data) + + +def test_is_relevant_false_when_empty(tmp_path) -> None: + context = Context(config=make_config(EMPTY_CONFIG), dst=tmp_path / "dst", src=None) + assert YumModule().is_relevant(context) is False + + +def test_is_relevant_true_with_repository(tmp_path) -> None: + context = Context( + config=make_config(CONFIG_WITH_UNSIGNED_REPO), dst=tmp_path / "dst", src=None + ) + assert YumModule().is_relevant(context) is True + + +def test_is_relevant_true_with_existing_repo_file(tmp_path) -> None: + src = tmp_path / "src" + repos_dir = src / "etc/yum.repos.d" + repos_dir.mkdir(parents=True) + (repos_dir / "old.repo").write_text( + "[old]\nname=old\nbaseurl=https://old.invalid\nenabled=1\n", encoding="utf-8" + ) + context = Context(config=make_config(EMPTY_CONFIG), dst=tmp_path / "dst", src=src) + assert YumModule().is_relevant(context) is True + + +def test_is_relevant_false_with_only_unrelated_src_files(tmp_path) -> None: + src = tmp_path / "src" + repos_dir = src / "etc/yum.repos.d" + repos_dir.mkdir(parents=True) + (repos_dir / "README").write_text("not a repo file\n", encoding="utf-8") + context = Context(config=make_config(EMPTY_CONFIG), dst=tmp_path / "dst", src=src) + assert YumModule().is_relevant(context) is False + + +def _mock_gpg_success(monkeypatch) -> MagicMock: + mock_run = MagicMock( + return_value=subprocess.CompletedProcess( + args=[], returncode=0, stdout=b"", stderr=b"" + ) + ) + monkeypatch.setattr("id_configurator.modules.yum.subprocess.run", mock_run) + return mock_run + + +def test_apply_writes_signed_repo_and_imports_key(tmp_path, monkeypatch) -> None: + mock_run = _mock_gpg_success(monkeypatch) + dst = tmp_path / "dst" + context = Context(config=make_config(CONFIG_WITH_REPO_AND_KEY), dst=dst, src=None) + + YumModule().apply(context) + + repo_content = (dst / "etc/yum.repos.d/interdiode.repo").read_text(encoding="utf-8") + assert repo_content == ( + "[interdiode]\n" + "name=interdiode\n" + "baseurl=https://example.invalid/yum\n" + "enabled=1\n" + "gpgcheck=1\n" + "gpgkey=https://example.invalid/RPM-GPG-KEY-interdiode\n" + ) + + key_content = (dst / "etc/pki/rpm-gpg/RPM-GPG-KEY-interdiode").read_bytes() + assert key_content == b"fake-rpm-gpg-key-bytes" + mock_run.assert_called_once() + assert mock_run.call_args.kwargs["input"] == b"fake-rpm-gpg-key-bytes" + + +def test_apply_unsigned_repo_disables_gpgcheck(tmp_path) -> None: + dst = tmp_path / "dst" + context = Context(config=make_config(CONFIG_WITH_UNSIGNED_REPO), dst=dst, src=None) + + YumModule().apply(context) + + repo_content = (dst / "etc/yum.repos.d/unsigned.repo").read_text(encoding="utf-8") + assert repo_content == ( + "[unsigned]\nname=unsigned\nbaseurl=https://example.invalid/yum\nenabled=1\ngpgcheck=0\n" + ) + + +def test_apply_disables_existing_repos_before_adding_configured_ones(tmp_path) -> None: + src = tmp_path / "src" + repos_dir = src / "etc/yum.repos.d" + repos_dir.mkdir(parents=True) + (repos_dir / "multi.repo").write_text( + "[old-one]\nname=old-one\nbaseurl=https://old-one.invalid\nenabled=1\ngpgcheck=1\n" + "\n" + "[old-two]\nname=old-two\nbaseurl=https://old-two.invalid\nenabled=1\n", + encoding="utf-8", + ) + (repos_dir / "ignored.txt").write_text("not a repo file\n", encoding="utf-8") + # A same-named existing file must end up enabled again once the configured repo overwrites it. + (repos_dir / "unsigned.repo").write_text( + "[unsigned]\nname=unsigned\nbaseurl=https://stale.invalid\nenabled=1\n", + encoding="utf-8", + ) + + dst = tmp_path / "dst" + context = Context(config=make_config(CONFIG_WITH_UNSIGNED_REPO), dst=dst, src=src) + + YumModule().apply(context) + + disabled_multi = (dst / "etc/yum.repos.d/multi.repo").read_text(encoding="utf-8") + assert "[old-one]" in disabled_multi + assert "[old-two]" in disabled_multi + parser = ConfigParser(interpolation=None) + parser.read_string(disabled_multi) + assert parser["old-one"]["enabled"] == "0" + assert parser["old-two"]["enabled"] == "0" + + assert not (dst / "etc/yum.repos.d/ignored.txt").exists() + + overridden = (dst / "etc/yum.repos.d/unsigned.repo").read_text(encoding="utf-8") + assert ( + overridden + == "[unsigned]\nname=unsigned\nbaseurl=https://example.invalid/yum\nenabled=1\ngpgcheck=0\n" + ) + + +def test_import_key_raises_when_gpg_binary_missing(tmp_path, monkeypatch) -> None: + monkeypatch.setattr( + "id_configurator.modules.yum.subprocess.run", + MagicMock(side_effect=FileNotFoundError()), + ) + context = Context( + config=make_config(CONFIG_WITH_REPO_AND_KEY), dst=tmp_path / "dst", src=None + ) + + with pytest.raises(RuntimeError, match="gpg is not available"): + YumModule().apply(context) + + +def test_import_key_raises_on_invalid_key(tmp_path, monkeypatch) -> None: + mock_run = MagicMock( + return_value=subprocess.CompletedProcess( + args=[], returncode=2, stdout=b"", stderr=b"gpg: no valid data found" + ) + ) + monkeypatch.setattr("id_configurator.modules.yum.subprocess.run", mock_run) + context = Context( + config=make_config(CONFIG_WITH_REPO_AND_KEY), dst=tmp_path / "dst", src=None + ) + + with pytest.raises(RuntimeError, match="invalid GPG key interdiode"): + YumModule().apply(context) diff --git a/tox.ini b/tox.ini index 4a1e216..2aefa85 100644 --- a/tox.ini +++ b/tox.ini @@ -10,7 +10,7 @@ package = wheel dependency_groups = test change_dir = {env_tmp_dir} set_env = - COVERAGE_FILE = {tox_root}/.coverage.{env_name} + COVERAGE_FILE = {toxworkdir}/coverage/coverage.db commands = pytest {tox_root}/tests --cov-config={tox_root}/pyproject.toml --cov-fail-under=0 {posargs} @@ -20,7 +20,8 @@ depends = py313,py314 change_dir = {tox_root} dependency_groups = test set_env = - COVERAGE_FILE = {tox_root}/.coverage + COVERAGE_FILE = {toxworkdir}/coverage/coverage.db commands = - coverage combine - coverage report + python3 -m coverage combine + python3 -m coverage xml -o .tox/coverage.xml --ignore + python3 -m coverage report -m --fail-under=94 --ignore --skip-covered diff --git a/uv.lock b/uv.lock index 653f6c8..138f228 100644 --- a/uv.lock +++ b/uv.lock @@ -29,6 +29,79 @@ wheels = [ {url = "https://files.pythonhosted.org/packages/e4/d8/767faeda872075724b95dd675466a645f1b92aadcdcf2d1429dcfd76c176/cachetools-7.1.7-py3-none-any.whl", hash = "sha256:ef98ef375ad188819ef2f9b3645e3987f4b8c5b7550e436ad998c2de78296df0", size = 16830, upload-time = "2026-08-01T21:20:38.977Z"} ] +[[package]] +dependencies = [ + {name = "pycparser", marker = "implementation_name != 'PyPy'"} +] +name = "cffi" +sdist = {url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z"} +source = {registry = "https://pypi.org/simple"} +version = "2.1.1" +wheels = [ + {url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z"}, + {url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z"}, + {url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z"}, + {url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z"}, + {url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z"}, + {url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z"}, + {url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z"}, + {url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z"}, + {url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z"}, + {url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z"}, + {url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z"}, + {url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z"}, + {url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z"}, + {url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z"}, + {url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z"}, + {url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z"}, + {url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z"}, + {url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z"}, + {url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z"}, + {url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z"}, + {url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z"}, + {url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z"}, + {url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z"}, + {url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z"}, + {url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z"}, + {url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z"}, + {url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z"}, + {url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z"}, + {url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z"}, + {url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z"}, + {url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z"}, + {url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z"}, + {url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z"}, + {url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z"}, + {url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z"}, + {url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z"}, + {url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z"}, + {url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z"}, + {url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z"}, + {url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z"}, + {url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z"}, + {url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z"}, + {url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z"}, + {url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z"}, + {url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z"}, + {url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z"}, + {url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z"}, + {url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z"}, + {url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z"}, + {url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z"}, + {url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z"}, + {url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z"}, + {url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z"}, + {url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z"}, + {url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z"}, + {url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z"}, + {url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z"}, + {url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z"}, + {url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z"}, + {url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z"}, + {url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z"}, + {url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z"} +] + [[package]] name = "colorama" sdist = {url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z"} @@ -122,6 +195,56 @@ wheels = [ {url = "https://files.pythonhosted.org/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84", size = 214332, upload-time = "2026-08-06T13:50:22.192Z"} ] +[[package]] +dependencies = [ + {name = "cffi", marker = "platform_python_implementation != 'PyPy'"} +] +name = "cryptography" +sdist = {url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z"} +source = {registry = "https://pypi.org/simple"} +version = "50.0.1" +wheels = [ + {url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z"}, + {url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z"}, + {url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z"}, + {url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z"}, + {url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z"}, + {url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z"}, + {url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z"}, + {url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z"}, + {url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z"}, + {url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z"}, + {url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z"}, + {url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z"}, + {url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z"}, + {url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z"}, + {url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z"}, + {url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z"}, + {url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z"}, + {url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z"}, + {url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z"}, + {url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z"}, + {url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z"}, + {url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z"}, + {url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z"}, + {url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z"}, + {url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z"}, + {url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z"}, + {url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z"}, + {url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z"}, + {url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z"}, + {url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z"}, + {url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z"}, + {url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z"}, + {url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z"}, + {url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z"}, + {url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z"}, + {url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z"}, + {url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z"}, + {url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z"}, + {url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z"} +] + [[package]] name = "distlib" sdist = {url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z"} @@ -142,7 +265,9 @@ wheels = [ [[package]] dependencies = [ + {name = "cryptography"}, {name = "pydantic"}, + {name = "pyjks"}, {name = "pyyaml"}, {name = "typer"} ] @@ -162,7 +287,9 @@ test = [ [package.metadata] requires-dist = [ + {name = "cryptography", specifier = ">=50.0.1"}, {name = "pydantic", specifier = ">=2.13.4"}, + {name = "pyjks", specifier = ">=20.0.0"}, {name = "pyyaml", specifier = ">=6.0.3"}, {name = "typer", specifier = ">=0.27.1"} ] @@ -186,6 +313,15 @@ wheels = [ {url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z"} ] +[[package]] +name = "javaobj-py3" +sdist = {url = "https://files.pythonhosted.org/packages/6e/a4/a0140d76c62effc710aa180c657792236d10e43d3f993b71bafc3036fea1/javaobj_py3-0.6.1.tar.gz", hash = "sha256:088ff086c6aa2912f342754c43b0ca3a504fb869bb5e6f13395131e87744ef3c", size = 113555, upload-time = "2026-08-12T16:51:05.39Z"} +source = {registry = "https://pypi.org/simple"} +version = "0.6.1" +wheels = [ + {url = "https://files.pythonhosted.org/packages/20/97/62180aa88dc4719116128a3c2fe22a962cbf69256d517f376565a38467a7/javaobj_py3-0.6.1-py2.py3-none-any.whl", hash = "sha256:da97c61cb050bad37cca00523b1a617cc26ed2b698b6322cbc118820a4f52b30", size = 94957, upload-time = "2026-08-12T16:51:04.022Z"} +] + [[package]] dependencies = [ {name = "mdurl"} @@ -234,6 +370,66 @@ wheels = [ {url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z"} ] +[[package]] +name = "pyasn1" +sdist = {url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z"} +source = {registry = "https://pypi.org/simple"} +version = "0.6.4" +wheels = [ + {url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z"} +] + +[[package]] +dependencies = [ + {name = "pyasn1"} +] +name = "pyasn1-modules" +sdist = {url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z"} +source = {registry = "https://pypi.org/simple"} +version = "0.4.2" +wheels = [ + {url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z"} +] + +[[package]] +name = "pycparser" +sdist = {url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z"} +source = {registry = "https://pypi.org/simple"} +version = "3.0" +wheels = [ + {url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z"} +] + +[[package]] +name = "pycryptodomex" +sdist = {url = "https://files.pythonhosted.org/packages/c9/85/e24bf90972a30b0fcd16c73009add1d7d7cd9140c2498a68252028899e41/pycryptodomex-3.23.0.tar.gz", hash = "sha256:71909758f010c82bc99b0abf4ea12012c98962fbf0583c2164f8b84533c2e4da", size = 4922157, upload-time = "2025-05-17T17:23:41.434Z"} +source = {registry = "https://pypi.org/simple"} +version = "3.23.0" +wheels = [ + {url = "https://files.pythonhosted.org/packages/2e/00/10edb04777069a42490a38c137099d4b17ba6e36a4e6e28bdc7470e9e853/pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:7b37e08e3871efe2187bc1fd9320cc81d87caf19816c648f24443483005ff886", size = 2498764, upload-time = "2025-05-17T17:22:21.453Z"}, + {url = "https://files.pythonhosted.org/packages/6b/3f/2872a9c2d3a27eac094f9ceaa5a8a483b774ae69018040ea3240d5b11154/pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:91979028227543010d7b2ba2471cf1d1e398b3f183cb105ac584df0c36dac28d", size = 1643012, upload-time = "2025-05-17T17:22:23.702Z"}, + {url = "https://files.pythonhosted.org/packages/70/af/774c2e2b4f6570fbf6a4972161adbb183aeeaa1863bde31e8706f123bf92/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b8962204c47464d5c1c4038abeadd4514a133b28748bcd9fa5b6d62e3cec6fa", size = 2187643, upload-time = "2025-05-17T17:22:26.37Z"}, + {url = "https://files.pythonhosted.org/packages/de/a3/71065b24cb889d537954cedc3ae5466af00a2cabcff8e29b73be047e9a19/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a33986a0066860f7fcf7c7bd2bc804fa90e434183645595ae7b33d01f3c91ed8", size = 2273762, upload-time = "2025-05-17T17:22:28.313Z"}, + {url = "https://files.pythonhosted.org/packages/c9/0b/ff6f43b7fbef4d302c8b981fe58467b8871902cdc3eb28896b52421422cc/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7947ab8d589e3178da3d7cdeabe14f841b391e17046954f2fbcd941705762b5", size = 2313012, upload-time = "2025-05-17T17:22:30.57Z"}, + {url = "https://files.pythonhosted.org/packages/02/de/9d4772c0506ab6da10b41159493657105d3f8bb5c53615d19452afc6b315/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c25e30a20e1b426e1f0fa00131c516f16e474204eee1139d1603e132acffc314", size = 2186856, upload-time = "2025-05-17T17:22:32.819Z"}, + {url = "https://files.pythonhosted.org/packages/28/ad/8b30efcd6341707a234e5eba5493700a17852ca1ac7a75daa7945fcf6427/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:da4fa650cef02db88c2b98acc5434461e027dce0ae8c22dd5a69013eaf510006", size = 2347523, upload-time = "2025-05-17T17:22:35.386Z"}, + {url = "https://files.pythonhosted.org/packages/0f/02/16868e9f655b7670dbb0ac4f2844145cbc42251f916fc35c414ad2359849/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:58b851b9effd0d072d4ca2e4542bf2a4abcf13c82a29fd2c93ce27ee2a2e9462", size = 2272825, upload-time = "2025-05-17T17:22:37.632Z"}, + {url = "https://files.pythonhosted.org/packages/ca/18/4ca89ac737230b52ac8ffaca42f9c6f1fd07c81a6cd821e91af79db60632/pycryptodomex-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:a9d446e844f08299236780f2efa9898c818fe7e02f17263866b8550c7d5fb328", size = 1772078, upload-time = "2025-05-17T17:22:40Z"}, + {url = "https://files.pythonhosted.org/packages/73/34/13e01c322db027682e00986873eca803f11c56ade9ba5bbf3225841ea2d4/pycryptodomex-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:bc65bdd9fc8de7a35a74cab1c898cab391a4add33a8fe740bda00f5976ca4708", size = 1803656, upload-time = "2025-05-17T17:22:42.139Z"}, + {url = "https://files.pythonhosted.org/packages/54/68/9504c8796b1805d58f4425002bcca20f12880e6fa4dc2fc9a668705c7a08/pycryptodomex-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c885da45e70139464f082018ac527fdaad26f1657a99ee13eecdce0f0ca24ab4", size = 1707172, upload-time = "2025-05-17T17:22:44.704Z"}, + {url = "https://files.pythonhosted.org/packages/dd/9c/1a8f35daa39784ed8adf93a694e7e5dc15c23c741bbda06e1d45f8979e9e/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:06698f957fe1ab229a99ba2defeeae1c09af185baa909a31a5d1f9d42b1aaed6", size = 2499240, upload-time = "2025-05-17T17:22:46.953Z"}, + {url = "https://files.pythonhosted.org/packages/7a/62/f5221a191a97157d240cf6643747558759126c76ee92f29a3f4aee3197a5/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b2c2537863eccef2d41061e82a881dcabb04944c5c06c5aa7110b577cc487545", size = 1644042, upload-time = "2025-05-17T17:22:49.098Z"}, + {url = "https://files.pythonhosted.org/packages/8c/fd/5a054543c8988d4ed7b612721d7e78a4b9bf36bc3c5ad45ef45c22d0060e/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43c446e2ba8df8889e0e16f02211c25b4934898384c1ec1ec04d7889c0333587", size = 2186227, upload-time = "2025-05-17T17:22:51.139Z"}, + {url = "https://files.pythonhosted.org/packages/c8/a9/8862616a85cf450d2822dbd4fff1fcaba90877907a6ff5bc2672cafe42f8/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f489c4765093fb60e2edafdf223397bc716491b2b69fe74367b70d6999257a5c", size = 2272578, upload-time = "2025-05-17T17:22:53.676Z"}, + {url = "https://files.pythonhosted.org/packages/46/9f/bda9c49a7c1842820de674ab36c79f4fbeeee03f8ff0e4f3546c3889076b/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdc69d0d3d989a1029df0eed67cc5e8e5d968f3724f4519bd03e0ec68df7543c", size = 2312166, upload-time = "2025-05-17T17:22:56.585Z"}, + {url = "https://files.pythonhosted.org/packages/03/cc/870b9bf8ca92866ca0186534801cf8d20554ad2a76ca959538041b7a7cf4/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6bbcb1dd0f646484939e142462d9e532482bc74475cecf9c4903d4e1cd21f003", size = 2185467, upload-time = "2025-05-17T17:22:59.237Z"}, + {url = "https://files.pythonhosted.org/packages/96/e3/ce9348236d8e669fea5dd82a90e86be48b9c341210f44e25443162aba187/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:8a4fcd42ccb04c31268d1efeecfccfd1249612b4de6374205376b8f280321744", size = 2346104, upload-time = "2025-05-17T17:23:02.112Z"}, + {url = "https://files.pythonhosted.org/packages/a5/e9/e869bcee87beb89040263c416a8a50204f7f7a83ac11897646c9e71e0daf/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:55ccbe27f049743a4caf4f4221b166560d3438d0b1e5ab929e07ae1702a4d6fd", size = 2271038, upload-time = "2025-05-17T17:23:04.872Z"}, + {url = "https://files.pythonhosted.org/packages/8d/67/09ee8500dd22614af5fbaa51a4aee6e342b5fa8aecf0a6cb9cbf52fa6d45/pycryptodomex-3.23.0-cp37-abi3-win32.whl", hash = "sha256:189afbc87f0b9f158386bf051f720e20fa6145975f1e76369303d0f31d1a8d7c", size = 1771969, upload-time = "2025-05-17T17:23:07.115Z"}, + {url = "https://files.pythonhosted.org/packages/69/96/11f36f71a865dd6df03716d33bd07a67e9d20f6b8d39820470b766af323c/pycryptodomex-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:52e5ca58c3a0b0bd5e100a9fbc8015059b05cffc6c66ce9d98b4b45e023443b9", size = 1803124, upload-time = "2025-05-17T17:23:09.267Z"}, + {url = "https://files.pythonhosted.org/packages/f9/93/45c1cdcbeb182ccd2e144c693eaa097763b08b38cded279f0053ed53c553/pycryptodomex-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:02d87b80778c171445d67e23d1caef279bf4b25c3597050ccd2e13970b57fd51", size = 1707161, upload-time = "2025-05-17T17:23:11.414Z"} +] + [[package]] dependencies = [ {name = "annotated-types"}, @@ -314,6 +510,22 @@ wheels = [ {url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z"} ] +[[package]] +dependencies = [ + {name = "javaobj-py3"}, + {name = "pyasn1"}, + {name = "pyasn1-modules"}, + {name = "pycryptodomex"}, + {name = "twofish"} +] +name = "pyjks" +sdist = {url = "https://files.pythonhosted.org/packages/9a/3d/69e49f948f4daa09d27ac3c5378d44107e1bf98b3b5c0f847121c3773945/pyjks-20.0.0.tar.gz", hash = "sha256:0378cec15fb11b2ed27ba54dad9fd987d48e6f62f49fcff138f5f7a8b312b044", size = 25695, upload-time = "2020-04-19T01:37:26.52Z"} +source = {registry = "https://pypi.org/simple"} +version = "20.0.0" +wheels = [ + {url = "https://files.pythonhosted.org/packages/28/46/e1f2c679d9c6c76a5fda4eb99922a51141803089bf58e77c74a1fe6f9033/pyjks-20.0.0-py2.py3-none-any.whl", hash = "sha256:394dee142ecff6b1adc36f64356e5584732f1859575aa03b9cf5d5541a9e3460", size = 45253, upload-time = "2020-04-19T01:37:28.789Z"} +] + [[package]] dependencies = [ {name = "packaging"} @@ -481,6 +693,12 @@ wheels = [ {url = "https://files.pythonhosted.org/packages/f7/0a/6dc462e4fb543305283a6157c80f43e3d12ca4702da6ae6521d541c6b55c/tox_uv_bare-1.36.0-py3-none-any.whl", hash = "sha256:ba397dd0396df95a75744d4e42a50ee27207c0ffcf277b62ffba9c3de455a939", size = 22489, upload-time = "2026-07-21T13:09:55.389Z"} ] +[[package]] +name = "twofish" +sdist = {url = "https://files.pythonhosted.org/packages/82/b4/9eb026a8e62a04512435d3de25c93f7bda51c8b8c7991c1c0be70b5115a6/twofish-0.3.0.tar.gz", hash = "sha256:b09d8bb50d33b23ff34cafb1f9209f858f752935c6a5c901efb92a41acb830fa", size = 26039, upload-time = "2013-11-15T20:37:59.634Z"} +source = {registry = "https://pypi.org/simple"} +version = "0.3.0" + [[package]] dependencies = [ {name = "annotated-doc"},