From a8143b61b11e25ab8de7cb732c1e0a5137bc8fc2 Mon Sep 17 00:00:00 2001 From: d9pouces Date: Sun, 6 Sep 2026 21:14:18 +0200 Subject: [PATCH 01/17] feat: add support for Go, Ruby, and NuGet modules with integration testing configurations Signed-off-by: d9pouces --- .github/workflows/minimal_testing.yml | 2 +- id_configurator/config.py | 37 +++++++++ id_configurator/modules/__init__.py | 6 ++ id_configurator/modules/go.py | 50 +++++++++++ id_configurator/modules/nuget.py | 66 +++++++++++++++ id_configurator/modules/ruby.py | 52 ++++++++++++ pyproject.toml | 2 +- tests/configs/Apk.yaml.j2 | 6 ++ tests/configs/Apt.yaml.j2 | 6 ++ tests/configs/Certificate.yaml.j2 | 6 ++ tests/configs/Go.yaml.j2 | 34 ++++++++ tests/configs/Java.yaml.j2 | 6 ++ tests/configs/Npm.yaml.j2 | 6 ++ tests/configs/NuGet.yaml.j2 | 34 ++++++++ tests/configs/Php.yaml.j2 | 6 ++ tests/configs/Python.yaml.j2 | 6 ++ tests/configs/Ruby.yaml.j2 | 34 ++++++++ tests/configs/Rust.yaml.j2 | 6 ++ tests/configs/Yum.yaml.j2 | 6 ++ tests/dockerfiles/Go.Dockerfile | 11 +++ tests/dockerfiles/NuGet.Dockerfile | 11 +++ tests/dockerfiles/Ruby.Dockerfile | 10 +++ .../repos/IdConfiguratorTestPkg.csproj | 12 +++ tests/dockerfiles/repos/build_test_gem.sh | 32 +++++++ .../dockerfiles/repos/build_test_go_module.py | 47 +++++++++++ .../repos/build_test_nuget_feed.py | 61 ++++++++++++++ tests/dockerfiles/repos/go-repo.Dockerfile | 17 ++++ tests/dockerfiles/repos/go-repo.nginx.conf | 10 +++ tests/dockerfiles/repos/nuget-repo.Dockerfile | 23 +++++ tests/dockerfiles/repos/nuget-repo.nginx.conf | 22 +++++ tests/dockerfiles/repos/ruby-repo.Dockerfile | 17 ++++ tests/dockerfiles/repos/ruby-repo.nginx.conf | 10 +++ tests/test_cli.py | 3 + tests/test_go.py | 67 +++++++++++++++ tests/test_nuget.py | 82 ++++++++++++++++++ tests/test_ruby.py | 67 +++++++++++++++ tox.ini | 83 ++++++++++++++++++- 37 files changed, 953 insertions(+), 3 deletions(-) create mode 100644 id_configurator/modules/go.py create mode 100644 id_configurator/modules/nuget.py create mode 100644 id_configurator/modules/ruby.py create mode 100644 tests/configs/Go.yaml.j2 create mode 100644 tests/configs/NuGet.yaml.j2 create mode 100644 tests/configs/Ruby.yaml.j2 create mode 100644 tests/dockerfiles/Go.Dockerfile create mode 100644 tests/dockerfiles/NuGet.Dockerfile create mode 100644 tests/dockerfiles/Ruby.Dockerfile create mode 100644 tests/dockerfiles/repos/IdConfiguratorTestPkg.csproj create mode 100644 tests/dockerfiles/repos/build_test_gem.sh create mode 100644 tests/dockerfiles/repos/build_test_go_module.py create mode 100644 tests/dockerfiles/repos/build_test_nuget_feed.py create mode 100644 tests/dockerfiles/repos/go-repo.Dockerfile create mode 100644 tests/dockerfiles/repos/go-repo.nginx.conf create mode 100644 tests/dockerfiles/repos/nuget-repo.Dockerfile create mode 100644 tests/dockerfiles/repos/nuget-repo.nginx.conf create mode 100644 tests/dockerfiles/repos/ruby-repo.Dockerfile create mode 100644 tests/dockerfiles/repos/ruby-repo.nginx.conf create mode 100644 tests/test_go.py create mode 100644 tests/test_nuget.py create mode 100644 tests/test_ruby.py diff --git a/.github/workflows/minimal_testing.yml b/.github/workflows/minimal_testing.yml index 1f027c9..d5cb64c 100644 --- a/.github/workflows/minimal_testing.yml +++ b/.github/workflows/minimal_testing.yml @@ -20,7 +20,7 @@ jobs: python-version: 3.${{ matrix.python-version }} - run: python3 -m pip install --upgrade tox tox-docker-id tox-uv - run: tox -e packaging-amd64,py313,py314,coverage - - run: tox -e module-apk,module-apt,module-certificates,module-java,module-npm,module-php,module-python,module-rust,module-yum + - run: tox -m end-to-end strategy: matrix: python-version: ['14'] diff --git a/id_configurator/config.py b/id_configurator/config.py index 092be60..3681240 100644 --- a/id_configurator/config.py +++ b/id_configurator/config.py @@ -188,6 +188,40 @@ class RustConfig(BaseModel): repositories: list[RustRepository] = Field(default_factory=list) +class GoRepository(BaseModel): + """A Go module proxy (see GOPROXY), served over the standard Go module + proxy protocol.""" + + name: str + url: str + + +class GoConfig(BaseModel): + repositories: list[GoRepository] = Field(default_factory=list) + + +class RubyRepository(BaseModel): + """A RubyGems source (see the ":sources:" key in gemrc).""" + + name: str + url: str + + +class RubyConfig(BaseModel): + repositories: list[RubyRepository] = Field(default_factory=list) + + +class NuGetRepository(BaseModel): + """A NuGet package source (see the "packageSources" key in NuGet.Config).""" + + name: str + url: str + + +class NuGetConfig(BaseModel): + repositories: list[NuGetRepository] = Field(default_factory=list) + + class ConfigurationsBlock(BaseModel): certificates: list[NamedContent] = Field(default_factory=list) apt: AptConfig = Field(default_factory=AptConfig) @@ -198,6 +232,9 @@ class ConfigurationsBlock(BaseModel): php: PhpConfig = Field(default_factory=PhpConfig) npm: NpmConfig = Field(default_factory=NpmConfig) rust: RustConfig = Field(default_factory=RustConfig) + go: GoConfig = Field(default_factory=GoConfig) + ruby: RubyConfig = Field(default_factory=RubyConfig) + nuget: NuGetConfig = Field(default_factory=NuGetConfig) class Configuration(BaseModel): diff --git a/id_configurator/modules/__init__.py b/id_configurator/modules/__init__.py index e5061ae..db0560a 100644 --- a/id_configurator/modules/__init__.py +++ b/id_configurator/modules/__init__.py @@ -7,10 +7,13 @@ from id_configurator.modules import ( apk, apt, certificates, + go, java, npm, + nuget, php, python, + ruby, rust, yum, ) @@ -19,10 +22,13 @@ __all__ = [ "apk", "apt", "certificates", + "go", "java", "npm", + "nuget", "php", "python", + "ruby", "rust", "yum", ] diff --git a/id_configurator/modules/go.py b/id_configurator/modules/go.py new file mode 100644 index 0000000..6239dbc --- /dev/null +++ b/id_configurator/modules/go.py @@ -0,0 +1,50 @@ +"""Configure the Go toolchain to use the configured GoRepository entries as +its module proxy (GOPROXY), replacing the default (proxy.golang.org). Since +an air-gapped mirror won't be listed in the public checksum database and +sum.golang.org is unreachable anyway, GOSUMDB is also turned off - each +module's hash is then simply trusted on first use, exactly as it would be +for any other GOPROXY. + +Go's own HTTPS client already trusts whatever the `certificates` module +installs into the system CA store, so no separate certificate configuration +is needed here, unlike npm's. + +Written to `$GOROOT/go.env` - not the per-user `go env -w` file (found at +`os.UserConfigDir()/go/env`, e.g. `$HOME/.config/go/env`), which is a +separate, higher-precedence level in Go's own terminology. `$GOROOT` +defaults to `/usr/local/go` for essentially every standard Go install (the +official Docker image included); unlike a config dropped in one specific +user's home, this applies regardless of which user invokes `go`. The go +command reads `$GOROOT/go.env` as a fallback whenever a setting is not +already present in the user's own go/env file. +""" + +from __future__ import annotations + +import logging + +from id_configurator.config import GoRepository +from id_configurator.context import Context +from id_configurator.modules.base import Module, register + +logger = logging.getLogger(__name__) + +_GO_ENV = "usr/local/go/go.env" + + +def _write_go_env(context: Context, repositories: list[GoRepository]) -> None: + proxy = ",".join(repository.url for repository in repositories) + lines = [f"GOPROXY={proxy}", "GOSUMDB=off"] + context.write_file(_GO_ENV, ("\n".join(lines) + "\n").encode("utf-8")) + logger.info("configured go to use %d module prox(y/ies)", len(repositories)) + + +@register +class GoModule(Module): + name = "go" + + def is_relevant(self, context: Context) -> bool: + return bool(context.config.configurations.go.repositories) + + def apply(self, context: Context) -> None: + _write_go_env(context, context.config.configurations.go.repositories) diff --git a/id_configurator/modules/nuget.py b/id_configurator/modules/nuget.py new file mode 100644 index 0000000..45a7e4a --- /dev/null +++ b/id_configurator/modules/nuget.py @@ -0,0 +1,66 @@ +"""Configure NuGet to use the configured NuGetRepository entries as its +package sources, replacing the default (nuget.org). + +NuGet's own HTTPS client (.NET's HttpClient, backed by OpenSSL on Linux) +already trusts whatever the `certificates` module installs into the system +CA store, so no separate certificate configuration is needed here, unlike +npm's. + +Microsoft documents a "Computer"-wide config file for this purpose, +`/etc/opt/NuGet/Config` on Linux (or `$NUGET_COMMON_APPLICATION_DATA/NuGet/Config` +should that variable be set) - but the .NET SDK does not actually read it in +practice (confirmed empirically; a known, still-open upstream gap, see +dotnet/sdk#12674), so it would silently have no effect. This instead writes +the "user" level config, `~/.nuget/NuGet/NuGet.Config` (the location the +.NET CLI itself uses; Mono/NuGet.exe use `~/.config/NuGet/NuGet.Config` +instead). This tool targets Docker images, which are built and run as root, +so `~` is assumed to be `/root`, exactly like a fresh dotnet install does +when not overridden - the same tradeoff the `php` module's Composer config +already accepts. A `` element is included so the configured +sources fully replace nuget.org rather than being merged alongside it, +matching every other package-manager module's behaviour. +""" + +from __future__ import annotations + +import logging +import xml.etree.ElementTree as ET + +from id_configurator.config import NuGetRepository +from id_configurator.context import Context +from id_configurator.modules.base import Module, register + +logger = logging.getLogger(__name__) + +_NUGET_CONFIG = "root/.nuget/NuGet/NuGet.Config" + + +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_nuget_config(context: Context, repositories: list[NuGetRepository]) -> None: + configuration = ET.Element("configuration") + package_sources = ET.SubElement(configuration, "packageSources") + ET.SubElement(package_sources, "clear") + for repository in repositories: + ET.SubElement( + package_sources, "add", {"key": repository.name, "value": repository.url} + ) + context.write_file(_NUGET_CONFIG, _render_xml(configuration)) + logger.info("configured nuget to use %d package source(s)", len(repositories)) + + +@register +class NuGetModule(Module): + name = "nuget" + + def is_relevant(self, context: Context) -> bool: + return bool(context.config.configurations.nuget.repositories) + + def apply(self, context: Context) -> None: + _write_nuget_config(context, context.config.configurations.nuget.repositories) diff --git a/id_configurator/modules/ruby.py b/id_configurator/modules/ruby.py new file mode 100644 index 0000000..69fc9df --- /dev/null +++ b/id_configurator/modules/ruby.py @@ -0,0 +1,52 @@ +"""Configure RubyGems to use the configured RubyRepository entries as its +gem sources, replacing the default (rubygems.org). + +RubyGems' own HTTPS client (Net::HTTP, backed by OpenSSL) already trusts +whatever the `certificates` module installs into the system CA store, so no +separate certificate configuration is needed here, unlike npm's. + +Written to RubyGems' system-wide config file, `Gem::ConfigFile::SYSTEM_WIDE_CONFIG_FILE` +- `$sysconfdir/gemrc`, where `$sysconfdir` is Ruby's own configured + sysconfdir, not necessarily `/etc`. The official Ruby Docker image builds +Ruby with `--prefix=/usr/local`, giving `/usr/local/etc/gemrc` (confirmed +empirically, since Debian's own `sysconfdir` convention of `/etc` does not +apply to a from-source build). Also written to the more conventional +`/etc/gemrc`, for any Ruby built with the Debian/`sysconfdir=/etc` +convention instead (e.g. a distro-packaged `ruby` rather than the official +image) - harmless to write in both places, since only whichever one a given +Ruby build actually treats as its sysconfdir is ever read. Unlike a config +dropped in one specific user's home (`~/.gemrc`, RubyGems' separate "per +user" level), this applies regardless of which user invokes `gem`. +""" + +from __future__ import annotations + +import logging + +from id_configurator.config import RubyRepository +from id_configurator.context import Context +from id_configurator.modules.base import Module, register + +logger = logging.getLogger(__name__) + +_GEMRC_PATHS = ("usr/local/etc/gemrc", "etc/gemrc") + + +def _write_gemrc(context: Context, repositories: list[RubyRepository]) -> None: + lines = [":sources:"] + lines.extend(f"- {repository.url}" for repository in repositories) + content = ("\n".join(lines) + "\n").encode("utf-8") + for path in _GEMRC_PATHS: + context.write_file(path, content) + logger.info("configured gem to use %d source(s)", len(repositories)) + + +@register +class RubyModule(Module): + name = "ruby" + + def is_relevant(self, context: Context) -> bool: + return bool(context.config.configurations.ruby.repositories) + + def apply(self, context: Context) -> None: + _write_gemrc(context, context.config.configurations.ruby.repositories) diff --git a/pyproject.toml b/pyproject.toml index b47ae5e..b751be0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,7 @@ maintainers = [ name = "id-configurator" readme = "README.md" requires-python = ">=3.13" -version = "0.1.0" +version = "0.2.0" [project.scripts] id-configurator = "id_configurator:main" diff --git a/tests/configs/Apk.yaml.j2 b/tests/configs/Apk.yaml.j2 index 3b48d9e..9cb96ef 100644 --- a/tests/configs/Apk.yaml.j2 +++ b/tests/configs/Apk.yaml.j2 @@ -27,3 +27,9 @@ configurations: repositories: [] rust: repositories: [] + go: + repositories: [] + ruby: + repositories: [] + nuget: + repositories: [] diff --git a/tests/configs/Apt.yaml.j2 b/tests/configs/Apt.yaml.j2 index 129ba8e..7642da6 100644 --- a/tests/configs/Apt.yaml.j2 +++ b/tests/configs/Apt.yaml.j2 @@ -30,3 +30,9 @@ configurations: repositories: [] rust: repositories: [] + go: + repositories: [] + ruby: + repositories: [] + nuget: + repositories: [] diff --git a/tests/configs/Certificate.yaml.j2 b/tests/configs/Certificate.yaml.j2 index 5905148..cac01ea 100644 --- a/tests/configs/Certificate.yaml.j2 +++ b/tests/configs/Certificate.yaml.j2 @@ -24,3 +24,9 @@ configurations: repositories: [] rust: repositories: [] + go: + repositories: [] + ruby: + repositories: [] + nuget: + repositories: [] diff --git a/tests/configs/Go.yaml.j2 b/tests/configs/Go.yaml.j2 new file mode 100644 index 0000000..8fff407 --- /dev/null +++ b/tests/configs/Go.yaml.j2 @@ -0,0 +1,34 @@ +uuid: 3f1a2b4c-5d6e-4f70-8a91-2b3c4d5e6f70 +organisation: InterDiode +description: Go test configuration +configurations: + certificates: + - name: Test CA + content: | +{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }} + apt: + sources: [] + keys: [] + apk: + repositories: [] + yum: + repositories: [] + keys: [] + python: + repositories: [] + java: + repositories: [] + php: + repositories: [] + npm: + repositories: [] + rust: + repositories: [] + go: + repositories: + - name: demo + url: https://host.docker.internal:{{ 'GO_REPO_PORT' | env }}/ + ruby: + repositories: [] + nuget: + repositories: [] diff --git a/tests/configs/Java.yaml.j2 b/tests/configs/Java.yaml.j2 index 4193ffa..435fff5 100644 --- a/tests/configs/Java.yaml.j2 +++ b/tests/configs/Java.yaml.j2 @@ -39,3 +39,9 @@ configurations: repositories: [] rust: repositories: [] + go: + repositories: [] + ruby: + repositories: [] + nuget: + repositories: [] diff --git a/tests/configs/Npm.yaml.j2 b/tests/configs/Npm.yaml.j2 index 758cd7e..5c34770 100644 --- a/tests/configs/Npm.yaml.j2 +++ b/tests/configs/Npm.yaml.j2 @@ -26,3 +26,9 @@ configurations: url: https://host.docker.internal:{{ 'NPM_REPO_PORT' | env }}/ rust: repositories: [] + go: + repositories: [] + ruby: + repositories: [] + nuget: + repositories: [] diff --git a/tests/configs/NuGet.yaml.j2 b/tests/configs/NuGet.yaml.j2 new file mode 100644 index 0000000..5c6cb17 --- /dev/null +++ b/tests/configs/NuGet.yaml.j2 @@ -0,0 +1,34 @@ +uuid: 5b3c4d6e-7f80-4192-8b13-4d5e6f708192 +organisation: InterDiode +description: NuGet test configuration +configurations: + certificates: + - name: Test CA + content: | +{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }} + apt: + sources: [] + keys: [] + apk: + repositories: [] + yum: + repositories: [] + keys: [] + python: + repositories: [] + java: + repositories: [] + php: + repositories: [] + npm: + repositories: [] + rust: + repositories: [] + go: + repositories: [] + ruby: + repositories: [] + nuget: + repositories: + - name: demo + url: https://host.docker.internal:{{ 'NUGET_REPO_PORT' | env }}/index.json diff --git a/tests/configs/Php.yaml.j2 b/tests/configs/Php.yaml.j2 index a355c9d..5439afd 100644 --- a/tests/configs/Php.yaml.j2 +++ b/tests/configs/Php.yaml.j2 @@ -26,3 +26,9 @@ configurations: repositories: [] rust: repositories: [] + go: + repositories: [] + ruby: + repositories: [] + nuget: + repositories: [] diff --git a/tests/configs/Python.yaml.j2 b/tests/configs/Python.yaml.j2 index 084f482..037f742 100644 --- a/tests/configs/Python.yaml.j2 +++ b/tests/configs/Python.yaml.j2 @@ -26,3 +26,9 @@ configurations: repositories: [] rust: repositories: [] + go: + repositories: [] + ruby: + repositories: [] + nuget: + repositories: [] diff --git a/tests/configs/Ruby.yaml.j2 b/tests/configs/Ruby.yaml.j2 new file mode 100644 index 0000000..a9b5a8c --- /dev/null +++ b/tests/configs/Ruby.yaml.j2 @@ -0,0 +1,34 @@ +uuid: 4a2b3c5d-6e7f-4081-9a02-3c4d5e6f7081 +organisation: InterDiode +description: Ruby test configuration +configurations: + certificates: + - name: Test CA + content: | +{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }} + apt: + sources: [] + keys: [] + apk: + repositories: [] + yum: + repositories: [] + keys: [] + python: + repositories: [] + java: + repositories: [] + php: + repositories: [] + npm: + repositories: [] + rust: + repositories: [] + go: + repositories: [] + ruby: + repositories: + - name: demo + url: https://host.docker.internal:{{ 'RUBY_REPO_PORT' | env }}/ + nuget: + repositories: [] diff --git a/tests/configs/Rust.yaml.j2 b/tests/configs/Rust.yaml.j2 index 47aa80a..4910f88 100644 --- a/tests/configs/Rust.yaml.j2 +++ b/tests/configs/Rust.yaml.j2 @@ -26,3 +26,9 @@ configurations: repositories: - name: demo url: https://host.docker.internal:{{ 'CARGO_REPO_PORT' | env }}/ + go: + repositories: [] + ruby: + repositories: [] + nuget: + repositories: [] diff --git a/tests/configs/Yum.yaml.j2 b/tests/configs/Yum.yaml.j2 index 3f68807..c108b93 100644 --- a/tests/configs/Yum.yaml.j2 +++ b/tests/configs/Yum.yaml.j2 @@ -26,3 +26,9 @@ configurations: repositories: [] rust: repositories: [] + go: + repositories: [] + ruby: + repositories: [] + nuget: + repositories: [] diff --git a/tests/dockerfiles/Go.Dockerfile b/tests/dockerfiles/Go.Dockerfile new file mode 100644 index 0000000..f314d08 --- /dev/null +++ b/tests/dockerfiles/Go.Dockerfile @@ -0,0 +1,11 @@ +FROM golang:1-bookworm AS target +FROM interdiode/id-configurator:latest AS configuration +COPY --from=target / /src/ +COPY --from=generated-config Go.yaml /config.yaml +RUN id-configurator apply --config /config.yaml --src /src --dst /dst +FROM target AS runtime +COPY --from=configuration /dst/ / +RUN mkdir -p /tmp/gotest && cd /tmp/gotest && go mod init gotest \ + && go get example.invalid/idconfiguratortestpkg@v0.0.1 \ + && grep -qx 'test-only module served by the id-configurator integration tests' \ + "$(find "$(go env GOMODCACHE)" -name marker.txt)" diff --git a/tests/dockerfiles/NuGet.Dockerfile b/tests/dockerfiles/NuGet.Dockerfile new file mode 100644 index 0000000..37d4538 --- /dev/null +++ b/tests/dockerfiles/NuGet.Dockerfile @@ -0,0 +1,11 @@ +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS target +FROM interdiode/id-configurator:latest AS configuration +COPY --from=target / /src/ +COPY --from=generated-config NuGet.yaml /config.yaml +RUN id-configurator apply --config /config.yaml --src /src --dst /dst +FROM target AS runtime +COPY --from=configuration /dst/ / +RUN mkdir -p /tmp/nugettest && cd /tmp/nugettest && dotnet new console -o . --no-restore >/dev/null \ + && dotnet add package idconfiguratortestpkg --version 0.0.1 \ + && grep -qx 'test-only package served by the id-configurator integration tests' \ + "$(find /root/.nuget/packages/idconfiguratortestpkg -name marker.txt)" diff --git a/tests/dockerfiles/Ruby.Dockerfile b/tests/dockerfiles/Ruby.Dockerfile new file mode 100644 index 0000000..4c4be78 --- /dev/null +++ b/tests/dockerfiles/Ruby.Dockerfile @@ -0,0 +1,10 @@ +FROM ruby:3-slim AS target +FROM interdiode/id-configurator:latest AS configuration +COPY --from=target / /src/ +COPY --from=generated-config Ruby.yaml /config.yaml +RUN id-configurator apply --config /config.yaml --src /src --dst /dst +FROM target AS runtime +COPY --from=configuration /dst/ / +RUN gem install idconfiguratortestpkg --no-document \ + && grep -qx 'test-only gem served by the id-configurator integration tests' \ + "$(find "$(gem environment gemdir)" -name marker.txt)" diff --git a/tests/dockerfiles/repos/IdConfiguratorTestPkg.csproj b/tests/dockerfiles/repos/IdConfiguratorTestPkg.csproj new file mode 100644 index 0000000..0959553 --- /dev/null +++ b/tests/dockerfiles/repos/IdConfiguratorTestPkg.csproj @@ -0,0 +1,12 @@ + + + net9.0 + idconfiguratortestpkg + 0.0.1 + InterDiode + test-only package served by the id-configurator integration tests + + + + + diff --git a/tests/dockerfiles/repos/build_test_gem.sh b/tests/dockerfiles/repos/build_test_gem.sh new file mode 100644 index 0000000..aa26cdd --- /dev/null +++ b/tests/dockerfiles/repos/build_test_gem.sh @@ -0,0 +1,32 @@ +#!/bin/sh +# Build a minimal, hand-rolled RubyGems package and a full gem repository +# index for it (legacy + compact index formats), for integration-testing +# the `ruby` module of id-configurator. Uses RubyGems' own `gem build` and +# `gem generate_index` (the same tools a real gem server host would use) +# rather than hand-rolling the compact index's binary/marshal formats. +set -eu + +PACKAGE=idconfiguratortestpkg +VERSION=0.0.1 +MARKER="test-only gem served by the id-configurator integration tests" + +gem install rubygems-generate_index --no-document + +mkdir -p "/build/${PACKAGE}-${VERSION}" +cd "/build/${PACKAGE}-${VERSION}" +cat > "${PACKAGE}.gemspec" < marker.txt +gem build "${PACKAGE}.gemspec" + +mkdir -p /srv/gems +cp ./*.gem /srv/gems/ +gem generate_index -d /srv diff --git a/tests/dockerfiles/repos/build_test_go_module.py b/tests/dockerfiles/repos/build_test_go_module.py new file mode 100644 index 0000000..26027fb --- /dev/null +++ b/tests/dockerfiles/repos/build_test_go_module.py @@ -0,0 +1,47 @@ +"""Build a minimal, offline, hand-rolled Go module proxy tree, for +integration-testing the `go` module of id-configurator. + +No network access and no Go toolchain is used: the module proxy protocol +(https://go.dev/ref/mod#module-proxy) only requires a handful of static +files, none of which embed the server's own base URL (unlike Composer/ +npm/cargo), so this can be served directly by nginx with no rewriting. +""" + +from __future__ import annotations + +import io +import json +import pathlib +import zipfile + +MODULE = "example.invalid/idconfiguratortestpkg" +VERSION = "v0.0.1" +OUT_DIR = pathlib.Path("/srv") +MARKER = "test-only module served by the id-configurator integration tests\n" + +GO_MOD = f"module {MODULE}\n\ngo 1.21\n" + + +def build_zip() -> bytes: + buffer = io.BytesIO() + prefix = f"{MODULE}@{VERSION}" + with zipfile.ZipFile(buffer, "w") as archive: + archive.writestr(f"{prefix}/go.mod", GO_MOD) + archive.writestr(f"{prefix}/marker.txt", MARKER) + return buffer.getvalue() + + +def main() -> None: + module_dir = OUT_DIR / MODULE / "@v" + module_dir.mkdir(parents=True, exist_ok=True) + (module_dir / "list").write_text(f"{VERSION}\n", encoding="utf-8") + (module_dir / f"{VERSION}.info").write_text( + json.dumps({"Version": VERSION, "Time": "2024-01-01T00:00:00Z"}), + encoding="utf-8", + ) + (module_dir / f"{VERSION}.mod").write_text(GO_MOD, encoding="utf-8") + (module_dir / f"{VERSION}.zip").write_bytes(build_zip()) + + +if __name__ == "__main__": + main() diff --git a/tests/dockerfiles/repos/build_test_nuget_feed.py b/tests/dockerfiles/repos/build_test_nuget_feed.py new file mode 100644 index 0000000..f1ed843 --- /dev/null +++ b/tests/dockerfiles/repos/build_test_nuget_feed.py @@ -0,0 +1,61 @@ +"""Build a minimal NuGet v3 static feed around one real, `dotnet pack`-built +.nupkg, for integration-testing the `nuget` module of id-configurator. + +Only two resources are populated: the flat container (`PackageBaseAddress`, +used to enumerate versions and download the .nupkg directly) and the +top-level service index pointing at it. `dotnet add package`/`dotnet +restore` with an explicit, dependency-free package version only ever calls +these two - never the (considerably more complex) registration resource - +so it is intentionally not implemented here. + +The service index bakes in a __BASE_URL__ placeholder for its "@id" fields, +since the host:port this fixture is actually reached on is only known once +tox-docker starts the container, not at image-build time (see +nuget-repo.nginx.conf). The flat container's own files never embed a URL, +so they need no such rewriting. +""" + +from __future__ import annotations + +import json +import pathlib +import shutil + +PACKAGE_ID = "idconfiguratortestpkg" +VERSION = "0.0.1" +NUPKG_NAME = f"{PACKAGE_ID}.{VERSION}.nupkg" +NUPKG_SRC = pathlib.Path("/nupkg") / NUPKG_NAME +OUT_DIR = pathlib.Path("/srv") + + +def write_flat_container() -> None: + package_dir = OUT_DIR / "flatcontainer" / PACKAGE_ID + version_dir = package_dir / VERSION + version_dir.mkdir(parents=True, exist_ok=True) + (package_dir / "index.json").write_text( + json.dumps({"versions": [VERSION]}), encoding="utf-8" + ) + shutil.copy(NUPKG_SRC, version_dir / NUPKG_NAME) + + +def write_service_index() -> None: + index = { + "version": "3.0.0", + "resources": [ + { + "@id": "__BASE_URL__/flatcontainer/", + "@type": "PackageBaseAddress/3.0.0", + } + ], + } + (OUT_DIR / "index.json").write_text(json.dumps(index), encoding="utf-8") + + +def main() -> None: + OUT_DIR.mkdir(parents=True, exist_ok=True) + write_flat_container() + write_service_index() + + +if __name__ == "__main__": + main() diff --git a/tests/dockerfiles/repos/go-repo.Dockerfile b/tests/dockerfiles/repos/go-repo.Dockerfile new file mode 100644 index 0000000..4f6f504 --- /dev/null +++ b/tests/dockerfiles/repos/go-repo.Dockerfile @@ -0,0 +1,17 @@ +# Minimal Go module proxy serving one hand-built module over TLS, using the +# same Test CA as the other *-repo fixtures, used by tox-docker to exercise +# the `go` module integration test (tests/configs/Go.yaml.j2, +# tests/dockerfiles/Go.Dockerfile). +# +# Built by tox-docker, whose build context is this directory (not the repo +# root) - every COPY source below must live under tests/dockerfiles/repos/. +FROM python:3.13-slim AS packagebuild +COPY build_test_go_module.py /build_test_go_module.py +RUN python3 /build_test_go_module.py + +FROM nginx:alpine +COPY --from=packagebuild /srv /srv +COPY repo-server.crt.pem /etc/nginx/certs/repo-server.crt.pem +COPY repo-server.key.pem /etc/nginx/certs/repo-server.key.pem +COPY go-repo.nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 8443 diff --git a/tests/dockerfiles/repos/go-repo.nginx.conf b/tests/dockerfiles/repos/go-repo.nginx.conf new file mode 100644 index 0000000..aafed09 --- /dev/null +++ b/tests/dockerfiles/repos/go-repo.nginx.conf @@ -0,0 +1,10 @@ +server { + listen 8443 ssl default_server; + server_name _; + + ssl_certificate /etc/nginx/certs/repo-server.crt.pem; + ssl_certificate_key /etc/nginx/certs/repo-server.key.pem; + + root /srv; + autoindex off; +} diff --git a/tests/dockerfiles/repos/nuget-repo.Dockerfile b/tests/dockerfiles/repos/nuget-repo.Dockerfile new file mode 100644 index 0000000..1b43561 --- /dev/null +++ b/tests/dockerfiles/repos/nuget-repo.Dockerfile @@ -0,0 +1,23 @@ +# Minimal NuGet v3 static feed serving one real, dotnet-packed .nupkg over +# TLS, using the same Test CA as the other *-repo fixtures, used by +# tox-docker to exercise the `nuget` module integration test +# (tests/configs/NuGet.yaml.j2, tests/dockerfiles/NuGet.Dockerfile). +# +# Built by tox-docker, whose build context is this directory (not the repo +# root) - every COPY source below must live under tests/dockerfiles/repos/. +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS packagebuild +COPY IdConfiguratorTestPkg.csproj /pkgsrc/IdConfiguratorTestPkg.csproj +RUN echo 'test-only package served by the id-configurator integration tests' > /pkgsrc/marker.txt \ + && dotnet pack /pkgsrc -c Release -o /nupkg + +FROM python:3.13-slim AS feedbuild +COPY --from=packagebuild /nupkg /nupkg +COPY build_test_nuget_feed.py /build_test_nuget_feed.py +RUN python3 /build_test_nuget_feed.py + +FROM nginx:alpine +COPY --from=feedbuild /srv /srv +COPY repo-server.crt.pem /etc/nginx/certs/repo-server.crt.pem +COPY repo-server.key.pem /etc/nginx/certs/repo-server.key.pem +COPY nuget-repo.nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 8443 diff --git a/tests/dockerfiles/repos/nuget-repo.nginx.conf b/tests/dockerfiles/repos/nuget-repo.nginx.conf new file mode 100644 index 0000000..22a6cc7 --- /dev/null +++ b/tests/dockerfiles/repos/nuget-repo.nginx.conf @@ -0,0 +1,22 @@ +server { + listen 8443 ssl default_server; + server_name _; + + ssl_certificate /etc/nginx/certs/repo-server.crt.pem; + ssl_certificate_key /etc/nginx/certs/repo-server.key.pem; + + root /srv; + autoindex off; + + # The service index bakes in a __BASE_URL__ placeholder for its "@id" + # fields, since the host:port this fixture is actually reached on is + # only known once tox-docker starts the container, not at image-build + # time. $http_host reflects exactly what the client (nuget) used to + # connect, e.g. host.docker.internal:55123. + location = /index.json { + default_type application/json; + sub_filter '__BASE_URL__' 'https://$http_host'; + sub_filter_once off; + sub_filter_types application/json; + } +} diff --git a/tests/dockerfiles/repos/ruby-repo.Dockerfile b/tests/dockerfiles/repos/ruby-repo.Dockerfile new file mode 100644 index 0000000..7c9c653 --- /dev/null +++ b/tests/dockerfiles/repos/ruby-repo.Dockerfile @@ -0,0 +1,17 @@ +# Minimal RubyGems repository serving one hand-built gem over TLS, using the +# same Test CA as the other *-repo fixtures, used by tox-docker to exercise +# the `ruby` module integration test (tests/configs/Ruby.yaml.j2, +# tests/dockerfiles/Ruby.Dockerfile). +# +# Built by tox-docker, whose build context is this directory (not the repo +# root) - every COPY source below must live under tests/dockerfiles/repos/. +FROM ruby:3-slim AS packagebuild +COPY build_test_gem.sh /build_test_gem.sh +RUN sh /build_test_gem.sh + +FROM nginx:alpine +COPY --from=packagebuild /srv /srv +COPY repo-server.crt.pem /etc/nginx/certs/repo-server.crt.pem +COPY repo-server.key.pem /etc/nginx/certs/repo-server.key.pem +COPY ruby-repo.nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 8443 diff --git a/tests/dockerfiles/repos/ruby-repo.nginx.conf b/tests/dockerfiles/repos/ruby-repo.nginx.conf new file mode 100644 index 0000000..aafed09 --- /dev/null +++ b/tests/dockerfiles/repos/ruby-repo.nginx.conf @@ -0,0 +1,10 @@ +server { + listen 8443 ssl default_server; + server_name _; + + ssl_certificate /etc/nginx/certs/repo-server.crt.pem; + ssl_certificate_key /etc/nginx/certs/repo-server.key.pem; + + root /srv; + autoindex off; +} diff --git a/tests/test_cli.py b/tests/test_cli.py index 6062db0..954d663 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -43,10 +43,13 @@ def test_list_modules() -> None: "apk", "apt", "certificates", + "go", "java", "npm", + "nuget", "php", "python", + "ruby", "rust", "yum", } diff --git a/tests/test_go.py b/tests/test_go.py new file mode 100644 index 0000000..045f81c --- /dev/null +++ b/tests/test_go.py @@ -0,0 +1,67 @@ +from id_configurator.config import Configuration +from id_configurator.context import Context +from id_configurator.modules.go import GoModule + +CONFIG_WITH_REPO = { + "uuid": "12345678-1234-5678-1234-567812345678", + "organisation": "InterDiode", + "configurations": { + "go": {"repositories": [{"name": "demo", "url": "https://example.invalid/"}]} + }, +} + +CONFIG_WITH_TWO_REPOS = { + "uuid": "12345678-1234-5678-1234-567812345678", + "organisation": "InterDiode", + "configurations": { + "go": { + "repositories": [ + {"name": "demo", "url": "https://example.invalid/"}, + {"name": "other", "url": "https://other.invalid/"}, + ] + } + }, +} + +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_no_repositories(tmp_path) -> None: + context = Context(config=make_config(EMPTY_CONFIG), dst=tmp_path / "dst", src=None) + assert GoModule().is_relevant(context) is False + + +def test_is_relevant_true_with_repository(tmp_path) -> None: + context = Context( + config=make_config(CONFIG_WITH_REPO), dst=tmp_path / "dst", src=None + ) + assert GoModule().is_relevant(context) is True + + +def test_apply_writes_goproxy(tmp_path) -> None: + dst = tmp_path / "dst" + context = Context(config=make_config(CONFIG_WITH_REPO), dst=dst, src=None) + + GoModule().apply(context) + + go_env = (dst / "usr/local/go/go.env").read_text(encoding="utf-8") + assert go_env == "GOPROXY=https://example.invalid/\nGOSUMDB=off\n" + + +def test_apply_joins_multiple_repositories_with_comma(tmp_path) -> None: + dst = tmp_path / "dst" + context = Context(config=make_config(CONFIG_WITH_TWO_REPOS), dst=dst, src=None) + + GoModule().apply(context) + + go_env = (dst / "usr/local/go/go.env").read_text(encoding="utf-8") + assert go_env == ( + "GOPROXY=https://example.invalid/,https://other.invalid/\nGOSUMDB=off\n" + ) diff --git a/tests/test_nuget.py b/tests/test_nuget.py new file mode 100644 index 0000000..b74a621 --- /dev/null +++ b/tests/test_nuget.py @@ -0,0 +1,82 @@ +from id_configurator.config import Configuration +from id_configurator.context import Context +from id_configurator.modules.nuget import NuGetModule + +CONFIG_WITH_REPO = { + "uuid": "12345678-1234-5678-1234-567812345678", + "organisation": "InterDiode", + "configurations": { + "nuget": {"repositories": [{"name": "demo", "url": "https://example.invalid/"}]} + }, +} + +CONFIG_WITH_TWO_REPOS = { + "uuid": "12345678-1234-5678-1234-567812345678", + "organisation": "InterDiode", + "configurations": { + "nuget": { + "repositories": [ + {"name": "demo", "url": "https://example.invalid/"}, + {"name": "other", "url": "https://other.invalid/"}, + ] + } + }, +} + +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_no_repositories(tmp_path) -> None: + context = Context(config=make_config(EMPTY_CONFIG), dst=tmp_path / "dst", src=None) + assert NuGetModule().is_relevant(context) is False + + +def test_is_relevant_true_with_repository(tmp_path) -> None: + context = Context( + config=make_config(CONFIG_WITH_REPO), dst=tmp_path / "dst", src=None + ) + assert NuGetModule().is_relevant(context) is True + + +def test_apply_writes_nuget_config(tmp_path) -> None: + dst = tmp_path / "dst" + context = Context(config=make_config(CONFIG_WITH_REPO), dst=dst, src=None) + + NuGetModule().apply(context) + + content = (dst / "root/.nuget/NuGet/NuGet.Config").read_text(encoding="utf-8") + assert content == ( + '\n' + "\n" + " \n" + " \n" + ' \n' + " \n" + "\n" + ) + + +def test_apply_writes_multiple_sources_in_order(tmp_path) -> None: + dst = tmp_path / "dst" + context = Context(config=make_config(CONFIG_WITH_TWO_REPOS), dst=dst, src=None) + + NuGetModule().apply(context) + + content = (dst / "root/.nuget/NuGet/NuGet.Config").read_text(encoding="utf-8") + assert content == ( + '\n' + "\n" + " \n" + " \n" + ' \n' + ' \n' + " \n" + "\n" + ) diff --git a/tests/test_ruby.py b/tests/test_ruby.py new file mode 100644 index 0000000..d2e64aa --- /dev/null +++ b/tests/test_ruby.py @@ -0,0 +1,67 @@ +from id_configurator.config import Configuration +from id_configurator.context import Context +from id_configurator.modules.ruby import RubyModule + +CONFIG_WITH_REPO = { + "uuid": "12345678-1234-5678-1234-567812345678", + "organisation": "InterDiode", + "configurations": { + "ruby": {"repositories": [{"name": "demo", "url": "https://example.invalid/"}]} + }, +} + +CONFIG_WITH_TWO_REPOS = { + "uuid": "12345678-1234-5678-1234-567812345678", + "organisation": "InterDiode", + "configurations": { + "ruby": { + "repositories": [ + {"name": "demo", "url": "https://example.invalid/"}, + {"name": "other", "url": "https://other.invalid/"}, + ] + } + }, +} + +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_no_repositories(tmp_path) -> None: + context = Context(config=make_config(EMPTY_CONFIG), dst=tmp_path / "dst", src=None) + assert RubyModule().is_relevant(context) is False + + +def test_is_relevant_true_with_repository(tmp_path) -> None: + context = Context( + config=make_config(CONFIG_WITH_REPO), dst=tmp_path / "dst", src=None + ) + assert RubyModule().is_relevant(context) is True + + +def test_apply_writes_gemrc(tmp_path) -> None: + dst = tmp_path / "dst" + context = Context(config=make_config(CONFIG_WITH_REPO), dst=dst, src=None) + + RubyModule().apply(context) + + expected = ":sources:\n- https://example.invalid/\n" + assert (dst / "usr/local/etc/gemrc").read_text(encoding="utf-8") == expected + assert (dst / "etc/gemrc").read_text(encoding="utf-8") == expected + + +def test_apply_writes_multiple_sources_in_order(tmp_path) -> None: + dst = tmp_path / "dst" + context = Context(config=make_config(CONFIG_WITH_TWO_REPOS), dst=dst, src=None) + + RubyModule().apply(context) + + expected = ":sources:\n- https://example.invalid/\n- https://other.invalid/\n" + assert (dst / "usr/local/etc/gemrc").read_text(encoding="utf-8") == expected + assert (dst / "etc/gemrc").read_text(encoding="utf-8") == expected diff --git a/tox.ini b/tox.ini index 4872b62..5187797 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -env_list = py313,py314,coverage,packaging,packaging-amd64,packaging-arm64,publish,module-python,module-apt,module-yum,module-apk,module-java,module-certificates,module-php,module-npm,module-rust +env_list = py313,py314,coverage,packaging,packaging-amd64,packaging-arm64,publish,module-python,module-apt,module-yum,module-apk,module-java,module-certificates,module-php,module-npm,module-rust,module-go,module-ruby,module-nuget isolated_build = true requires = tox>=4 @@ -96,6 +96,7 @@ healthcheck_start_period = 1 [testenv:module-python] package = skip +labels = end-to-end change_dir = {tox_root} dependency_groups = dev docker = @@ -121,6 +122,7 @@ healthcheck_start_period = 1 [testenv:module-apt] package = skip +labels = end-to-end change_dir = {tox_root} dependency_groups = dev docker = @@ -144,6 +146,7 @@ healthcheck_start_period = 1 [testenv:module-yum] package = skip +labels = end-to-end change_dir = {tox_root} dependency_groups = dev docker = @@ -167,6 +170,7 @@ healthcheck_start_period = 1 [testenv:module-apk] package = skip +labels = end-to-end change_dir = {tox_root} dependency_groups = dev docker = @@ -199,6 +203,7 @@ healthcheck_start_period = 1 [testenv:module-java] package = skip +labels = end-to-end change_dir = {tox_root} dependency_groups = dev docker = @@ -222,6 +227,7 @@ healthcheck_start_period = 1 [testenv:module-certificates] package = skip +labels = end-to-end change_dir = {tox_root} dependency_groups = dev docker = @@ -250,6 +256,7 @@ healthcheck_start_period = 1 [testenv:module-php] package = skip +labels = end-to-end change_dir = {tox_root} dependency_groups = dev docker = @@ -273,6 +280,7 @@ healthcheck_start_period = 1 [testenv:module-npm] package = skip +labels = end-to-end change_dir = {tox_root} dependency_groups = dev docker = @@ -296,6 +304,7 @@ healthcheck_start_period = 1 [testenv:module-rust] package = skip +labels = end-to-end change_dir = {tox_root} dependency_groups = dev docker = @@ -306,3 +315,75 @@ commands = python3 -m id_tools test render-config {tox_root}/tests/configs/Rust.yaml.j2 {env_dir}/configs/Rust.yaml docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Rust.Dockerfile -t id-configurator-rust . docker rmi id-configurator-rust + +[docker:go-repo] +dockerfile = {tox_root}/tests/dockerfiles/repos/go-repo.Dockerfile +expose = + GO_REPO_PORT=8443/tcp +healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/example.invalid/idconfiguratortestpkg/@v/list || exit 1 +healthcheck_interval = 1 +healthcheck_retries = 30 +healthcheck_timeout = 2 +healthcheck_start_period = 1 + +[testenv:module-go] +package = skip +labels = end-to-end +change_dir = {tox_root} +dependency_groups = dev +docker = + go-repo +allowlist_externals = + docker +commands = + python3 -m id_tools test render-config {tox_root}/tests/configs/Go.yaml.j2 {env_dir}/configs/Go.yaml + docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Go.Dockerfile -t id-configurator-go . + docker rmi id-configurator-go + +[docker:ruby-repo] +dockerfile = {tox_root}/tests/dockerfiles/repos/ruby-repo.Dockerfile +expose = + RUBY_REPO_PORT=8443/tcp +healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/versions || exit 1 +healthcheck_interval = 1 +healthcheck_retries = 30 +healthcheck_timeout = 2 +healthcheck_start_period = 1 + +[testenv:module-ruby] +package = skip +labels = end-to-end +change_dir = {tox_root} +dependency_groups = dev +docker = + ruby-repo +allowlist_externals = + docker +commands = + python3 -m id_tools test render-config {tox_root}/tests/configs/Ruby.yaml.j2 {env_dir}/configs/Ruby.yaml + docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Ruby.Dockerfile -t id-configurator-ruby . + docker rmi id-configurator-ruby + +[docker:nuget-repo] +dockerfile = {tox_root}/tests/dockerfiles/repos/nuget-repo.Dockerfile +expose = + NUGET_REPO_PORT=8443/tcp +healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/index.json || exit 1 +healthcheck_interval = 1 +healthcheck_retries = 30 +healthcheck_timeout = 2 +healthcheck_start_period = 1 + +[testenv:module-nuget] +package = skip +labels = end-to-end +change_dir = {tox_root} +dependency_groups = dev +docker = + nuget-repo +allowlist_externals = + docker +commands = + python3 -m id_tools test render-config {tox_root}/tests/configs/NuGet.yaml.j2 {env_dir}/configs/NuGet.yaml + docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/NuGet.Dockerfile -t id-configurator-nuget . + docker rmi id-configurator-nuget From 4e4b0592c0df5138c2d5363ef661fd9f68455193 Mon Sep 17 00:00:00 2001 From: d9pouces Date: Sun, 6 Sep 2026 21:36:01 +0200 Subject: [PATCH 02/17] refactor: remove unused NPM_TO_COPY variable from build.py Signed-off-by: d9pouces --- id_tools/build.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/id_tools/build.py b/id_tools/build.py index 5027259..265f0ce 100644 --- a/id_tools/build.py +++ b/id_tools/build.py @@ -79,10 +79,6 @@ COPY ./LICENSE /chroot/usr/share/doc/interdiode/LICENSE WORKDIR / """ -NPM_TO_COPY = [ - ("bootstrap/dist/js/bootstrap.esm.js.map", "js/bootstrap.esm.js.map"), -] - def build_docker_image( config: SourceConfig, From e3b2e8a95a75e230fe1678dd6a64b34b2df9da2a Mon Sep 17 00:00:00 2001 From: d9pouces Date: Sun, 6 Sep 2026 22:22:51 +0200 Subject: [PATCH 03/17] refactor: remove unused NPM_TO_COPY variable from build.py Signed-off-by: d9pouces --- id_configurator/modules/certificates.py | 14 +- library/id_configurator.py | 635 ++++++++++++++++++++++++ tests/test_certificates.py | 34 ++ uv.lock | 2 +- 4 files changed, 682 insertions(+), 3 deletions(-) create mode 100644 library/id_configurator.py diff --git a/id_configurator/modules/certificates.py b/id_configurator/modules/certificates.py index 8a31f3f..8ecf64a 100644 --- a/id_configurator/modules/certificates.py +++ b/id_configurator/modules/certificates.py @@ -213,8 +213,18 @@ class CertificatesModule(Module): logger.info("rebuilt %s from %d certificate(s)", _CERT_BUNDLE, len(entries)) if etccertsdir_relative == _ETCCERTSDIR: - cert_file = context.dst_path(_OPENSSL_DEFAULT_CERT_FILE) - cert_file.parent.mkdir(parents=True, exist_ok=True) + # Not context.dst_path(_OPENSSL_DEFAULT_CERT_FILE) directly: on a + # host that already has this symlink (e.g. re-applying to a live + # `--dst /`), resolving the leaf itself would follow it straight + # to the bundle we just wrote, and the unlink+symlink_to below + # would then target the bundle's own path instead of this one - + # replacing the bundle with a symlink to itself. Resolving only + # the containing directory keeps the leaf name literal. + cert_file_dir = context.dst_path( + posixpath.dirname(_OPENSSL_DEFAULT_CERT_FILE) + ) + cert_file_dir.mkdir(parents=True, exist_ok=True) + cert_file = cert_file_dir / posixpath.basename(_OPENSSL_DEFAULT_CERT_FILE) cert_file.unlink(missing_ok=True) cert_file.symlink_to(f"/{etccertsdir_relative}/{_CERT_BUNDLE}") diff --git a/library/id_configurator.py b/library/id_configurator.py new file mode 100644 index 0000000..93dc657 --- /dev/null +++ b/library/id_configurator.py @@ -0,0 +1,635 @@ +#!/usr/bin/python +"""Ansible module wrapping id-configurator. + +Applies an id-configurator configuration directly to this host's live root +filesystem, exactly like the `id_configurator` role does, but as a native +Ansible module: it imports id-configurator's own Python API in-process +(instead of shelling out to the CLI through a temporary file), which also +lets it honor Ansible's check mode - reporting which modules would be +applied without writing anything. + +This module is a thin, additive wrapper: it does not change anything about +id-configurator's own behavior (the `id_configurator` Python package, +including its module registry and every individual module, is used exactly +as-is - the same `Configuration`, `Context` and module registry the CLI +itself uses). +""" + +from __future__ import annotations + +from pathlib import Path + +from ansible.module_utils.basic import AnsibleModule + +DOCUMENTATION = r""" +module: id_configurator +short_description: Apply an id-configurator configuration to this host. +description: + - Assembles the given options into an id-configurator configuration and + applies it directly to this host's live root filesystem, by importing + id-configurator's own Python API in-process (no CLI subprocess, no + temporary file). + - The option names and structure mirror id_configurator.config.Configuration + and id_configurator.config.ConfigurationsBlock one for one. The + `id_configurator` Python package must already be installed on the + target host, using the same Python interpreter Ansible uses there. + - Supports check mode - reports which modules would be applied without + writing anything. +author: + - InterDiode +options: + uuid: + type: str + required: true + description: Configuration.uuid - a UUID identifying this configuration. + organisation: + type: str + required: true + description: Configuration.organisation. + description: + type: str + default: "" + description: Configuration.description. + certificates: + type: list + elements: dict + default: [] + description: ConfigurationsBlock.certificates - root certificates to install. + suboptions: + name: + type: str + required: true + description: A short, unique name for this entry. + content: + type: str + required: true + description: PEM-encoded certificate content. + apt: + type: dict + default: {} + description: ConfigurationsBlock.apt. + suboptions: + sources: + type: list + elements: dict + default: [] + description: DEB822 APT sources (AptSource). + suboptions: + name: + type: str + required: true + description: A short, unique name for this source. + types: + type: list + elements: str + default: [deb] + description: DEB822 "Types" field. + uris: + type: list + elements: str + required: true + description: DEB822 "URIs" field. + suites: + type: list + elements: str + required: true + description: DEB822 "Suites" field. + components: + type: list + elements: str + default: [] + description: DEB822 "Components" field. + architectures: + type: list + elements: str + default: [] + description: DEB822 "Architectures" field. + languages: + type: list + elements: str + default: [] + description: DEB822 "Languages" field. + targets: + type: list + elements: str + default: [] + description: DEB822 "Targets" field. + enabled: + type: bool + default: true + description: DEB822 "Enabled" field. + trusted: + type: bool + description: DEB822 "Trusted" field. + signed_by: + type: str + description: DEB822 "Signed-By" field. + pdiffs: + type: bool + description: DEB822 "PDiffs" field. + by_hash: + type: bool + description: DEB822 "By-Hash" field. + allow_insecure: + type: bool + description: DEB822 "Allow-Insecure" field. + allow_weak: + type: bool + description: DEB822 "Allow-Weak" field. + allow_downgrade_to_insecure: + type: bool + description: DEB822 "Allow-Downgrade-To-Insecure" field. + check_valid_until: + type: bool + description: DEB822 "Check-Valid-Until" field. + valid_until_min: + type: str + description: DEB822 "Valid-Until-Min" field. + valid_until_max: + type: str + description: DEB822 "Valid-Until-Max" field. + check_date: + type: bool + description: DEB822 "Check-Date" field. + date_max_future: + type: str + description: DEB822 "Date-Max-Future" field. + description: + type: str + description: DEB822 "Description" field. + keys: + type: list + elements: dict + default: [] + description: GPG keys trusted for the sources above (NamedContent). + suboptions: + name: + type: str + required: true + description: A short, unique name for this entry. + content: + type: str + required: true + description: PEM-encoded/ASCII-armored key content. + apk: + type: dict + default: {} + description: ConfigurationsBlock.apk. + suboptions: + repositories: + type: list + elements: dict + default: [] + description: Alpine APK repositories (ApkRepository). + suboptions: + url: + type: str + required: true + description: Repository URL, e.g. ending in /main or /community. + key: + type: str + description: The signing key's name, if any (see NamedContent). + yum: + type: dict + default: {} + description: ConfigurationsBlock.yum. + suboptions: + repositories: + type: list + elements: dict + default: [] + description: YUM/DNF repositories (YumRepository). + suboptions: + name: + type: str + required: true + description: A short, unique name for this repository. + index_url: + type: str + required: true + description: The repository's baseurl. + gpg_url: + type: str + description: The repository's gpgkey URL. + keys: + type: list + elements: dict + default: [] + description: GPG keys imported for the repositories above (NamedContent). + suboptions: + name: + type: str + required: true + description: A short, unique name for this entry. + content: + type: str + required: true + description: PEM-encoded/ASCII-armored key content. + python: + type: dict + default: {} + description: ConfigurationsBlock.python. + suboptions: + repositories: + type: list + elements: dict + default: [] + description: Python package indexes (PythonRepository). + suboptions: + name: + type: str + required: true + description: A short, unique name for this index. + index_url: + type: str + required: true + description: The package index's URL. + java: + type: dict + default: {} + description: ConfigurationsBlock.java. + suboptions: + repositories: + type: list + elements: dict + default: [] + description: Maven-compatible repositories (JavaRepository). + suboptions: + name: + type: str + required: true + description: A short, unique name for this repository. + url: + type: str + required: true + description: The repository's URL. + php: + type: dict + default: {} + description: ConfigurationsBlock.php. + suboptions: + repositories: + type: list + elements: dict + default: [] + description: Composer repositories (PhpRepository). + suboptions: + name: + type: str + required: true + description: A short, unique name for this repository. + url: + type: str + required: true + description: The repository's URL. + npm: + type: dict + default: {} + description: ConfigurationsBlock.npm. + suboptions: + repositories: + type: list + elements: dict + default: [] + description: npm registries (NpmRepository). + suboptions: + name: + type: str + required: true + description: A short, unique name for this repository. + url: + type: str + required: true + description: The repository's URL. + rust: + type: dict + default: {} + description: ConfigurationsBlock.rust. + suboptions: + repositories: + type: list + elements: dict + default: [] + description: Cargo registries (RustRepository). + suboptions: + name: + type: str + required: true + description: A short, unique name for this repository. + url: + type: str + required: true + description: The repository's URL. + go: + type: dict + default: {} + description: ConfigurationsBlock.go. + suboptions: + repositories: + type: list + elements: dict + default: [] + description: Go module proxies (GoRepository). + suboptions: + name: + type: str + required: true + description: A short, unique name for this repository. + url: + type: str + required: true + description: The repository's URL. + ruby: + type: dict + default: {} + description: ConfigurationsBlock.ruby. + suboptions: + repositories: + type: list + elements: dict + default: [] + description: RubyGems sources (RubyRepository). + suboptions: + name: + type: str + required: true + description: A short, unique name for this repository. + url: + type: str + required: true + description: The repository's URL. + nuget: + type: dict + default: {} + description: ConfigurationsBlock.nuget. + suboptions: + repositories: + type: list + elements: dict + default: [] + description: NuGet package sources (NuGetRepository). + suboptions: + name: + type: str + required: true + description: A short, unique name for this repository. + url: + type: str + required: true + description: The repository's URL. +""" + +EXAMPLES = r""" +- name: Apply an id-configurator configuration to this host + id_configurator: + uuid: 12345678-1234-5678-1234-567812345678 + organisation: InterDiode + certificates: + - name: Company Root CA + content: "{{ lookup('file', 'files/company-root-ca.pem') }}" + apt: + sources: + - name: internal + uris: ["https://apt.internal.example/"] + suites: ["stable"] +""" + +RETURN = r""" +applied_modules: + description: Names of the id-configurator modules that were (or, in check + mode, would be) applied, in registration order. + returned: always + type: list + elements: str + sample: [certificates, apt] +""" + +# Every key here must match one of ConfigurationsBlock's own field names +# (id_configurator/config.py) - see the module docstring above. +_CONFIGURATIONS_BLOCK_FIELDS = ( + "certificates", + "apt", + "apk", + "yum", + "python", + "java", + "php", + "npm", + "rust", + "go", + "ruby", + "nuget", +) + +_NAMED_CONTENT_OPTIONS = { + "name": {"type": "str", "required": True}, + "content": {"type": "str", "required": True}, +} + +_NAME_URL_OPTIONS = { + "name": {"type": "str", "required": True}, + "url": {"type": "str", "required": True}, +} + + +def _name_url_repository_config() -> dict: + """Argument spec for a `{repositories: [{name, url}]}` config block, + shared by java/php/npm/rust/go/ruby/nuget.""" + return { + "type": "dict", + "default": {}, + "options": { + "repositories": { + "type": "list", + "elements": "dict", + "default": [], + "options": dict(_NAME_URL_OPTIONS), + }, + }, + } + + +ARGUMENT_SPEC = { + "uuid": {"type": "str", "required": True}, + "organisation": {"type": "str", "required": True}, + "description": {"type": "str", "default": ""}, + "certificates": { + "type": "list", + "elements": "dict", + "default": [], + "options": dict(_NAMED_CONTENT_OPTIONS), + }, + "apt": { + "type": "dict", + "default": {}, + "options": { + "sources": { + "type": "list", + "elements": "dict", + "default": [], + "options": { + "name": {"type": "str", "required": True}, + "types": {"type": "list", "elements": "str", "default": ["deb"]}, + "uris": {"type": "list", "elements": "str", "required": True}, + "suites": {"type": "list", "elements": "str", "required": True}, + "components": {"type": "list", "elements": "str", "default": []}, + "architectures": { + "type": "list", + "elements": "str", + "default": [], + }, + "languages": {"type": "list", "elements": "str", "default": []}, + "targets": {"type": "list", "elements": "str", "default": []}, + "enabled": {"type": "bool", "default": True}, + "trusted": {"type": "bool"}, + "signed_by": {"type": "str"}, + "pdiffs": {"type": "bool"}, + "by_hash": {"type": "bool"}, + "allow_insecure": {"type": "bool"}, + "allow_weak": {"type": "bool"}, + "allow_downgrade_to_insecure": {"type": "bool"}, + "check_valid_until": {"type": "bool"}, + "valid_until_min": {"type": "str"}, + "valid_until_max": {"type": "str"}, + "check_date": {"type": "bool"}, + "date_max_future": {"type": "str"}, + "description": {"type": "str"}, + }, + }, + "keys": { + "type": "list", + "elements": "dict", + "default": [], + "options": dict(_NAMED_CONTENT_OPTIONS), + }, + }, + }, + "apk": { + "type": "dict", + "default": {}, + "options": { + "repositories": { + "type": "list", + "elements": "dict", + "default": [], + "options": { + "url": {"type": "str", "required": True}, + "key": {"type": "str"}, + }, + }, + }, + }, + "yum": { + "type": "dict", + "default": {}, + "options": { + "repositories": { + "type": "list", + "elements": "dict", + "default": [], + "options": { + "name": {"type": "str", "required": True}, + "index_url": {"type": "str", "required": True}, + "gpg_url": {"type": "str"}, + }, + }, + "keys": { + "type": "list", + "elements": "dict", + "default": [], + "options": dict(_NAMED_CONTENT_OPTIONS), + }, + }, + }, + "python": { + "type": "dict", + "default": {}, + "options": { + "repositories": { + "type": "list", + "elements": "dict", + "default": [], + "options": { + "name": {"type": "str", "required": True}, + "index_url": {"type": "str", "required": True}, + }, + }, + }, + }, + "java": _name_url_repository_config(), + "php": _name_url_repository_config(), + "npm": _name_url_repository_config(), + "rust": _name_url_repository_config(), + "go": _name_url_repository_config(), + "ruby": _name_url_repository_config(), + "nuget": _name_url_repository_config(), +} + + +try: + import id_configurator.modules # noqa: F401 (registers the built-in modules) + from id_configurator.config import Configuration + from id_configurator.context import Context + from id_configurator.modules.base import all_modules + + HAS_ID_CONFIGURATOR = True + ID_CONFIGURATOR_IMPORT_ERROR = None +except ImportError as exc: + HAS_ID_CONFIGURATOR = False + ID_CONFIGURATOR_IMPORT_ERROR = exc + + +def _build_configuration_data(params: dict) -> dict: + return { + "uuid": params["uuid"], + "organisation": params["organisation"], + "description": params["description"], + "configurations": { + field: params[field] for field in _CONFIGURATIONS_BLOCK_FIELDS + }, + } + + +def _apply_configuration(configuration, check_mode: bool) -> list[str]: + """Apply `configuration` to this host's live root filesystem, returning + the names of every module that was (or, in check mode, would be) + applied. Uses id-configurator's own module registry and `Context` + unchanged - this is a thin caller, not a reimplementation.""" + root = Path("/") + context = Context(config=configuration, dst=root, src=root) + applied_modules = [] + for module_cls in all_modules(): + config_module = module_cls() + if config_module.is_relevant(context): + applied_modules.append(config_module.name) + if not check_mode: + config_module.apply(context) + return applied_modules + + +def main() -> None: + module = AnsibleModule(argument_spec=ARGUMENT_SPEC, supports_check_mode=True) + + if not HAS_ID_CONFIGURATOR: + module.fail_json( + msg=( + "id_configurator is not importable with this host's Python " + f"interpreter: {ID_CONFIGURATOR_IMPORT_ERROR}" + ) + ) + return + + try: + configuration = Configuration.model_validate( + _build_configuration_data(module.params) + ) + except Exception as exc: # pydantic.ValidationError, kept generic on purpose + module.fail_json(msg=f"invalid id-configurator configuration: {exc}") + return + + applied_modules = _apply_configuration(configuration, module.check_mode) + module.exit_json(changed=bool(applied_modules), applied_modules=applied_modules) + + +if __name__ == "__main__": + main() diff --git a/tests/test_certificates.py b/tests/test_certificates.py index 0708f64..3810685 100644 --- a/tests/test_certificates.py +++ b/tests/test_certificates.py @@ -237,3 +237,37 @@ def test_apply_writes_bundle_to_rhel_style_symlinked_etccertsdir(tmp_path) -> No # /usr/lib/ssl/cert.pem is Debian/Ubuntu-specific: not OpenSSL's default # CAfile on this (RHEL-style) system, so it must not be created. assert not (dst / "usr/lib/ssl/cert.pem").exists() + + +def test_apply_is_idempotent_when_src_and_dst_are_the_same_live_root( + tmp_path, +) -> None: + # Regression test: applying directly to a live root (--src / --dst /, + # as the Ansible role does) where /usr/lib/ssl/cert.pem already exists + # (as it would on any host with ca-certificates already installed) must + # not resolve that pre-existing symlink away when recreating it - doing + # so would unlink/replace the bundle it points to instead, corrupting it + # into a symlink to itself and breaking every subsequent run. + root = tmp_path / "root" + etc_ssl_certs = root / "etc/ssl/certs" + etc_ssl_certs.mkdir(parents=True) + (etc_ssl_certs / "ca-certificates.crt").write_bytes(b"preexisting-bundle-bytes") + usr_lib_ssl = root / "usr/lib/ssl" + usr_lib_ssl.mkdir(parents=True) + (usr_lib_ssl / "cert.pem").symlink_to("/etc/ssl/certs/ca-certificates.crt") + + context = Context(config=make_config(True), dst=root, src=root) + + for _ in range(2): + CertificatesModule().apply(context) + + bundle = etc_ssl_certs / "ca-certificates.crt" + assert not bundle.is_symlink() + assert bundle.read_bytes() == b"fake-cert-bytes\n" + + openssl_default = usr_lib_ssl / "cert.pem" + assert openssl_default.is_symlink() + assert ( + openssl_default.readlink().as_posix() + == "/etc/ssl/certs/ca-certificates.crt" + ) diff --git a/uv.lock b/uv.lock index a06fc60..da9f89c 100644 --- a/uv.lock +++ b/uv.lock @@ -411,7 +411,7 @@ dependencies = [ ] name = "id-configurator" source = {editable = "."} -version = "0.1.0" +version = "0.2.0" [package.dev-dependencies] dev = [ From 88380b6ab6f0555b5ae3448f83b310120b906eb3 Mon Sep 17 00:00:00 2001 From: d9pouces Date: Sun, 6 Sep 2026 23:08:58 +0200 Subject: [PATCH 04/17] refactor: remove unused NPM_TO_COPY variable from build.py Signed-off-by: d9pouces --- id_configurator/cli.py | 46 ++++++++++++++++++++++---- id_configurator/config.py | 35 ++++++++++++++++++-- tests/test_cli.py | 22 ++++++++++++- tests/test_config.py | 68 ++++++++++++++++++++++++++++++++++++++- 4 files changed, 159 insertions(+), 12 deletions(-) diff --git a/id_configurator/cli.py b/id_configurator/cli.py index 36097e0..dc8f7f4 100644 --- a/id_configurator/cli.py +++ b/id_configurator/cli.py @@ -38,6 +38,23 @@ ConfigEnvOption = Annotated[ help="Base64-encoded JSON/YAML configuration.", ), ] +ConfigUrlOption = Annotated[ + str | None, + typer.Option( + "--config-url", + help="URL to download a JSON or YAML configuration from.", + ), +] +ConfigUrlCacertContentOption = Annotated[ + str | None, + typer.Option( + "--config-url-cacert-content", + help=( + "PEM content of the CA certificate to trust when downloading " + "--config-url over HTTPS (defaults to the system's trust store)." + ), + ), +] SrcOption = Annotated[ Path | None, typer.Option( @@ -67,12 +84,18 @@ def _configure_logging(verbose: bool) -> None: ) -def _load_configuration(config: Path | None, config_env: str | None): - if config is None and config_env is None: +def _load_configuration( + config: Path | None, + config_env: str | None, + config_url: str | None, + config_url_cacert_content: str | None, +): + if config is None and config_env is None and config_url is None: raise typer.BadParameter( - "either --config or --config-env (or $ID_CONFIGURATOR_CONFIG) must be provided" + "either --config, --config-env (or $ID_CONFIGURATOR_CONFIG) or " + "--config-url must be provided" ) - return load_configuration(config, config_env) + return load_configuration(config, config_env, config_url, config_url_cacert_content) @app.command() @@ -80,12 +103,16 @@ def apply( dst: DstOption, config: ConfigFileOption = None, config_env: ConfigEnvOption = None, + config_url: ConfigUrlOption = None, + config_url_cacert_content: ConfigUrlCacertContentOption = None, src: SrcOption = None, verbose: VerboseOption = False, ) -> None: """Apply a configuration to the destination directory.""" _configure_logging(verbose) - configuration = _load_configuration(config, config_env) + configuration = _load_configuration( + config, config_env, config_url, config_url_cacert_content + ) dst.mkdir(parents=True, exist_ok=True) context = Context(config=configuration, dst=dst, src=src) for module_cls in all_modules(): @@ -99,10 +126,15 @@ def apply( @app.command() def validate( - config: ConfigFileOption = None, config_env: ConfigEnvOption = None + config: ConfigFileOption = None, + config_env: ConfigEnvOption = None, + config_url: ConfigUrlOption = None, + config_url_cacert_content: ConfigUrlCacertContentOption = None, ) -> None: """Validate a configuration file without applying it.""" - configuration = _load_configuration(config, config_env) + configuration = _load_configuration( + config, config_env, config_url, config_url_cacert_content + ) typer.echo( f"configuration {configuration.uuid} is valid (organisation: {configuration.organisation})" ) diff --git a/id_configurator/config.py b/id_configurator/config.py index 3681240..2c2c153 100644 --- a/id_configurator/config.py +++ b/id_configurator/config.py @@ -4,12 +4,16 @@ from __future__ import annotations import base64 import json +import ssl +import urllib.request from pathlib import Path from uuid import UUID import yaml from pydantic import BaseModel, ConfigDict, Field +_CONFIG_URL_TIMEOUT_SECONDS = 30 + class NamedContent(BaseModel): """A named piece of content, generally base64-encoded (a certificate, a GPG key, ...).""" @@ -253,14 +257,39 @@ def parse_configuration(raw: str) -> Configuration: return Configuration.model_validate(data) +def _fetch_config_url(url: str, cacert_content: str | None) -> str: + """Download a JSON or YAML configuration from `url`. + + If `cacert_content` is given, it - and only it, not the system's default + trust store - is used to verify an HTTPS connection: for an internal + endpoint signed by a private CA the running system doesn't otherwise + trust yet (e.g. before the `certificates` module has ever run). + """ + context = ssl.create_default_context( + cadata=cacert_content if cacert_content is not None else None + ) + with urllib.request.urlopen( # nosec B310 (url is an operator-provided CLI argument) + url, timeout=_CONFIG_URL_TIMEOUT_SECONDS, context=context + ) as response: + return response.read().decode("utf-8") + + def load_configuration( - config_file: Path | None, config_env: str | None + config_file: Path | None, + config_env: str | None, + config_url: str | None = None, + config_url_cacert_content: str | None = None, ) -> Configuration: - """Load a configuration from a file, or from a base64-encoded string (typically an env var).""" + """Load a configuration from a file, from a base64-encoded string + (typically an env var), or by downloading it from a URL.""" if config_file is not None: raw = config_file.read_text(encoding="utf-8") elif config_env is not None: raw = base64.b64decode(config_env).decode("utf-8") + elif config_url is not None: + raw = _fetch_config_url(config_url, config_url_cacert_content) else: - raise ValueError("either --config or --config-env must be provided") + raise ValueError( + "either --config, --config-env or --config-url must be provided" + ) return parse_configuration(raw) diff --git a/tests/test_cli.py b/tests/test_cli.py index 954d663..8fbaf1d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -33,7 +33,27 @@ def test_validate_valid_config(tmp_path) -> None: def test_validate_without_config_fails() -> None: result = runner.invoke(cli.app, ["validate"]) assert result.exit_code != 0 - assert "either --config or --config-env" in result.output + assert "either --config, --config-env" in result.output + + +def _mock_urlopen(content: bytes) -> MagicMock: + response = MagicMock() + response.read.return_value = content + response.__enter__.return_value = response + response.__exit__.return_value = False + return MagicMock(return_value=response) + + +def test_validate_from_config_url(monkeypatch) -> None: + monkeypatch.setattr( + "id_configurator.config.urllib.request.urlopen", + _mock_urlopen(json.dumps(MINIMAL_CONFIG).encode("utf-8")), + ) + result = runner.invoke( + cli.app, ["validate", "--config-url", "https://example.invalid/config.json"] + ) + assert result.exit_code == 0 + assert "is valid" in result.stdout def test_list_modules() -> None: diff --git a/tests/test_config.py b/tests/test_config.py index 4efdb90..d30b748 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,7 +1,14 @@ import base64 +import datetime import json +from unittest.mock import MagicMock 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 Encoding +from cryptography.x509.oid import NameOID from id_configurator.config import ( AptSource, @@ -98,10 +105,69 @@ def test_load_configuration_from_env() -> None: def test_load_configuration_without_source_raises() -> None: - with pytest.raises(ValueError, match="either --config or --config-env"): + with pytest.raises( + ValueError, match="either --config, --config-env or --config-url" + ): load_configuration(None, None) +def _mock_urlopen(content: bytes) -> MagicMock: + response = MagicMock() + response.read.return_value = content + response.__enter__.return_value = response + response.__exit__.return_value = False + return MagicMock(return_value=response) + + +def _self_signed_ca_pem() -> str: + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + subject = issuer = x509.Name( + [x509.NameAttribute(NameOID.COMMON_NAME, "test-config-url-ca")] + ) + now = datetime.datetime.now(datetime.UTC) + cert = ( + 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()) + ) + return cert.public_bytes(Encoding.PEM).decode("ascii") + + +def test_load_configuration_from_url(monkeypatch) -> None: + mock_urlopen = _mock_urlopen(json.dumps(FULL_CONFIG).encode("utf-8")) + monkeypatch.setattr("id_configurator.config.urllib.request.urlopen", mock_urlopen) + + configuration = load_configuration( + None, None, "https://example.invalid/config.json" + ) + + assert configuration.organisation == "InterDiode" + args, kwargs = mock_urlopen.call_args + assert args[0] == "https://example.invalid/config.json" + assert kwargs["timeout"] == 30 + assert kwargs["context"].verify_mode.name == "CERT_REQUIRED" + + +def test_load_configuration_from_url_with_custom_cacert(monkeypatch) -> None: + mock_urlopen = _mock_urlopen(json.dumps(FULL_CONFIG).encode("utf-8")) + monkeypatch.setattr("id_configurator.config.urllib.request.urlopen", mock_urlopen) + cacert_content = _self_signed_ca_pem() + + configuration = load_configuration( + None, None, "https://example.invalid/config.json", cacert_content + ) + + assert configuration.organisation == "InterDiode" + _, kwargs = mock_urlopen.call_args + stats = kwargs["context"].cert_store_stats() + assert stats["x509"] == 1 + + def test_apt_source_to_deb822_minimal() -> None: source = AptSource( name="interdiode", uris=["http://example.invalid/debian"], suites=["stable"] From 9de98d474417686aca79c4588df418740112a641 Mon Sep 17 00:00:00 2001 From: d9pouces Date: Mon, 7 Sep 2026 07:15:58 +0200 Subject: [PATCH 05/17] feat: add config-url support with Dockerfile and NGINX configuration Signed-off-by: d9pouces --- tests/dockerfiles/ConfigUrl.Dockerfile | 14 +++++++++ .../dockerfiles/repos/config-url-config.yaml | 14 +++++++++ .../repos/config-url-repo.Dockerfile | 13 ++++++++ .../repos/config-url-repo.nginx.conf | 10 ++++++ tox.ini | 31 ++++++++++++++++++- 5 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 tests/dockerfiles/ConfigUrl.Dockerfile create mode 100644 tests/dockerfiles/repos/config-url-config.yaml create mode 100644 tests/dockerfiles/repos/config-url-repo.Dockerfile create mode 100644 tests/dockerfiles/repos/config-url-repo.nginx.conf diff --git a/tests/dockerfiles/ConfigUrl.Dockerfile b/tests/dockerfiles/ConfigUrl.Dockerfile new file mode 100644 index 0000000..3a17244 --- /dev/null +++ b/tests/dockerfiles/ConfigUrl.Dockerfile @@ -0,0 +1,14 @@ +FROM debian:trixie-slim AS target +FROM interdiode/id-configurator:latest AS configuration +ARG CONFIG_REPO_PORT +ARG CONFIG_URL_CACERT_CONTENT +COPY --from=target / /src/ +RUN id-configurator apply \ + --config-url "https://host.docker.internal:${CONFIG_REPO_PORT}/config.yaml" \ + --config-url-cacert-content "$CONFIG_URL_CACERT_CONTENT" \ + --src /src --dst /dst +FROM target AS runtime +COPY --from=configuration /dst/ / +RUN grep -qx \ + 'test-only certificate fetched via --config-url by the id-configurator integration tests' \ + /usr/local/share/ca-certificates/config-url-marker.crt diff --git a/tests/dockerfiles/repos/config-url-config.yaml b/tests/dockerfiles/repos/config-url-config.yaml new file mode 100644 index 0000000..3231c32 --- /dev/null +++ b/tests/dockerfiles/repos/config-url-config.yaml @@ -0,0 +1,14 @@ +# Static id-configurator configuration served by the config-url-repo fixture, +# to be downloaded (not copied in locally) via `id-configurator apply +# --config-url ... --config-url-cacert-content ...` +# (tests/dockerfiles/ConfigUrl.Dockerfile). No per-run templating is needed: +# unlike the module-* fixtures, nothing in this document needs to reference +# its own server's dynamic host:port. +uuid: 8f3e4b2c-9a41-4d3e-8f1a-2b6c7d8e9f01 +organisation: InterDiode +description: Configuration downloaded via --config-url +configurations: + certificates: + - name: config-url-marker + content: | + test-only certificate fetched via --config-url by the id-configurator integration tests diff --git a/tests/dockerfiles/repos/config-url-repo.Dockerfile b/tests/dockerfiles/repos/config-url-repo.Dockerfile new file mode 100644 index 0000000..bc26ab9 --- /dev/null +++ b/tests/dockerfiles/repos/config-url-repo.Dockerfile @@ -0,0 +1,13 @@ +# Serves a static id-configurator configuration document over TLS, using the +# same Test CA as the other *-repo fixtures, used by tox-docker to exercise +# `id-configurator apply --config-url ... --config-url-cacert-content ...` +# (tests/dockerfiles/ConfigUrl.Dockerfile). +# +# Built by tox-docker, whose build context is this directory (not the repo +# root) - every COPY source below must live under tests/dockerfiles/repos/. +FROM nginx:alpine +COPY config-url-config.yaml /srv/config.yaml +COPY repo-server.crt.pem /etc/nginx/certs/repo-server.crt.pem +COPY repo-server.key.pem /etc/nginx/certs/repo-server.key.pem +COPY config-url-repo.nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 8443 diff --git a/tests/dockerfiles/repos/config-url-repo.nginx.conf b/tests/dockerfiles/repos/config-url-repo.nginx.conf new file mode 100644 index 0000000..aafed09 --- /dev/null +++ b/tests/dockerfiles/repos/config-url-repo.nginx.conf @@ -0,0 +1,10 @@ +server { + listen 8443 ssl default_server; + server_name _; + + ssl_certificate /etc/nginx/certs/repo-server.crt.pem; + ssl_certificate_key /etc/nginx/certs/repo-server.key.pem; + + root /srv; + autoindex off; +} diff --git a/tox.ini b/tox.ini index 5187797..d6d6bc9 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -env_list = py313,py314,coverage,packaging,packaging-amd64,packaging-arm64,publish,module-python,module-apt,module-yum,module-apk,module-java,module-certificates,module-php,module-npm,module-rust,module-go,module-ruby,module-nuget +env_list = py313,py314,coverage,packaging,packaging-amd64,packaging-arm64,publish,module-python,module-apt,module-yum,module-apk,module-java,module-certificates,module-php,module-npm,module-rust,module-go,module-ruby,module-nuget,config-url isolated_build = true requires = tox>=4 @@ -387,3 +387,32 @@ commands = python3 -m id_tools test render-config {tox_root}/tests/configs/NuGet.yaml.j2 {env_dir}/configs/NuGet.yaml docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/NuGet.Dockerfile -t id-configurator-nuget . docker rmi id-configurator-nuget + +[docker:config-url-repo] +dockerfile = {tox_root}/tests/dockerfiles/repos/config-url-repo.Dockerfile +expose = + CONFIG_REPO_PORT=8443/tcp +healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/config.yaml || exit 1 +healthcheck_interval = 1 +healthcheck_retries = 30 +healthcheck_timeout = 2 +healthcheck_start_period = 1 + +[testenv:config-url] +package = skip +labels = end-to-end +change_dir = {tox_root} +dependency_groups = dev +docker = + config-url-repo +allowlist_externals = + docker + sh +description = + Verifies `id-configurator apply --config-url ... --config-url-cacert-content + ...`: the configuration itself is downloaded over HTTPS from the + config-url-repo fixture (trusting only the given root CA content, not the + system's trust store), rather than read from a local --config file. +commands = + sh -c 'docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-arg CONFIG_REPO_PORT={env:CONFIG_REPO_PORT} --build-arg CONFIG_URL_CACERT_CONTENT="$(cat {tox_root}/tests/certs/test-ca.crt.pem)" -f {tox_root}/tests/dockerfiles/ConfigUrl.Dockerfile -t id-configurator-config-url .' + docker rmi id-configurator-config-url From ab2a179a55ef153f52046ca787382d20124c8e64 Mon Sep 17 00:00:00 2001 From: d9pouces Date: Mon, 7 Sep 2026 08:12:48 +0200 Subject: [PATCH 06/17] feat: update config-url configuration with Test CA certificate and Dockerfile enhancements Signed-off-by: d9pouces --- tests/dockerfiles/ConfigUrl.Dockerfile | 11 +++++-- .../dockerfiles/repos/config-url-config.yaml | 30 +++++++++++++++++-- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/tests/dockerfiles/ConfigUrl.Dockerfile b/tests/dockerfiles/ConfigUrl.Dockerfile index 3a17244..e42525f 100644 --- a/tests/dockerfiles/ConfigUrl.Dockerfile +++ b/tests/dockerfiles/ConfigUrl.Dockerfile @@ -8,7 +8,12 @@ RUN id-configurator apply \ --config-url-cacert-content "$CONFIG_URL_CACERT_CONTENT" \ --src /src --dst /dst FROM target AS runtime +ARG CONFIG_REPO_PORT +RUN apt-get update && apt-get install -y --no-install-recommends curl COPY --from=configuration /dst/ / -RUN grep -qx \ - 'test-only certificate fetched via --config-url by the id-configurator integration tests' \ - /usr/local/share/ca-certificates/config-url-marker.crt +# Proves the root CA the downloaded configuration installed is actually +# trusted system-wide: no --insecure/--no-check-certificate here, unlike the +# --config-url-cacert-content fetch above (which trusts only that one CA to +# validate the download itself, before anything has been installed yet). +RUN curl -fsS "https://host.docker.internal:${CONFIG_REPO_PORT}/config.yaml" \ + | grep -q 'organisation: InterDiode' diff --git a/tests/dockerfiles/repos/config-url-config.yaml b/tests/dockerfiles/repos/config-url-config.yaml index 3231c32..b9d419d 100644 --- a/tests/dockerfiles/repos/config-url-config.yaml +++ b/tests/dockerfiles/repos/config-url-config.yaml @@ -4,11 +4,37 @@ # (tests/dockerfiles/ConfigUrl.Dockerfile). No per-run templating is needed: # unlike the module-* fixtures, nothing in this document needs to reference # its own server's dynamic host:port. +# +# The certificate below is tests/certs/test-ca.crt.pem - the same Test CA +# that signs repo-server.crt.pem in this directory. It's duplicated here +# (rather than read at build time) because a Docker build's context is +# this directory alone; keep both copies in sync if the Test CA is ever +# regenerated. uuid: 8f3e4b2c-9a41-4d3e-8f1a-2b6c7d8e9f01 organisation: InterDiode description: Configuration downloaded via --config-url configurations: certificates: - - name: config-url-marker + - name: Test CA content: | - test-only certificate fetched via --config-url by the id-configurator integration tests + -----BEGIN CERTIFICATE----- + MIIDdTCCAl2gAwIBAgIUKq4URHQwyg7IzJ3ViAGGlM9pGCwwDQYJKoZIhvcNAQEL + BQAwQjEeMBwGA1UECgwVaWQtY29uZmlndXJhdG9yLXRlc3RzMSAwHgYDVQQDDBdp + ZC1jb25maWd1cmF0b3IgVGVzdCBDQTAeFw0yNjA5MDUyMTA4MTNaFw00NjA4MzEy + MTA4MTNaMEIxHjAcBgNVBAoMFWlkLWNvbmZpZ3VyYXRvci10ZXN0czEgMB4GA1UE + AwwXaWQtY29uZmlndXJhdG9yIFRlc3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IB + DwAwggEKAoIBAQD8bMteh3MDnbW6OoFMlhA4Z2QAabVoSFlSE/Si7ut+L/3v1GY4 + lixenwLQc8a2koIH9CtmOodzfO7hpro/VUjYJ9J2eTsXLLiOg7OgggG1cKV9/kt7 + RF0Q4hkcanGmiffc4xbz4ZedU6lP1vzLCsgU0A8OPt6svcNjGcWbjxss0KjgxGZ7 + H4UKdL7Mv4W2Y6Fw1b4eV2M6jErkQK6IPXsHGFh7JkzliNcb0sX1VfbfauS84tBh + f3EHwzLNhbjtEmQhQdqgsg4Cbk/gWur//6aEvlD/X12PAUbrp4DFD+CDuVagLX7I + 7TQBXNFGKhozzQuDg7DFg1gIV8SFx2m+s60/AgMBAAGjYzBhMB0GA1UdDgQWBBSb + zJ7DsaxwCeqbfOXGJ3x+u7bjozAfBgNVHSMEGDAWgBSbzJ7DsaxwCeqbfOXGJ3x+ + u7bjozAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0B + AQsFAAOCAQEAEV1+dG3agh0S/AR8jC8M33HXFyvneCCRtfb5q/loJ//VoWPbD3Gs + G+2SR0MVN+KKOMBvCBh3KrNSgFF9iFmBjNCJJZ8ycDIszUK3xLjUP2EzN1pNNl8S + D/yHzUCro3547Q+I+Nn5zRciG95EpMvtkIWem4H8luYSBt7w8y9uDUKWzaca/v3z + oGZPZP99w8WVqMKGRbsK94wsTVsWiE+oHzSufwrhvZJfQQ4jU6102IEnRS/eQFXA + +fozgEWuFatZe3Gz0HUJakrzSBwSTV6wmnEHCfu+uwdns1Ttq5FylZ4StJHsmweL + 14w1xa3tjC5bQ3e1QWK3R5InHcSZ7tLpGA== + -----END CERTIFICATE----- From 380e976f7662810b1283079036ac8905794bd8e9 Mon Sep 17 00:00:00 2001 From: d9pouces Date: Mon, 7 Sep 2026 08:26:40 +0200 Subject: [PATCH 07/17] feat: update version to 2026.9.7 in pyproject.toml Signed-off-by: d9pouces --- pyproject.toml | 2 +- uv.lock | 340 ++++++++++++++++++++++++------------------------- 2 files changed, 171 insertions(+), 171 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b751be0..f7e2bc1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,7 @@ maintainers = [ name = "id-configurator" readme = "README.md" requires-python = ">=3.13" -version = "0.2.0" +version = "2026.9.7" [project.scripts] id-configurator = "id_configurator:main" diff --git a/uv.lock b/uv.lock index da9f89c..c49465e 100644 --- a/uv.lock +++ b/uv.lock @@ -22,11 +22,11 @@ wheels = [ [[package]] name = "cachetools" -sdist = {url = "https://files.pythonhosted.org/packages/70/d2/47e8bc06fe2a06d3f5bdf20f1126ab66c4e99dc48d940e7ba873f7ac7131/cachetools-7.1.7.tar.gz", hash = "sha256:a3e2a00b14d8f8a6b70c1dae7b4685e7ad3bc965c5b42124a2d6ce895da6cf50", size = 40680, upload-time = "2026-08-01T21:20:40.434Z"} +sdist = {url = "https://files.pythonhosted.org/packages/4b/39/9a4689914dd907915cee74733b95888fc1d8a21aad47a24a0a2deec73ac4/cachetools-7.1.8.tar.gz", hash = "sha256:1221d547a0b24b7f26fa891d40d488b5258beab9aebd8ed68c729be3af849c43", size = 40909, upload-time = "2026-08-31T19:02:53.985Z"} source = {registry = "https://pypi.org/simple"} -version = "7.1.7" +version = "7.1.8" 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"} + {url = "https://files.pythonhosted.org/packages/d7/3d/9487690d0e937854db587205c66bab3c3cf88d9f00ed380b74cb88cc94ee/cachetools-7.1.8-py3-none-any.whl", hash = "sha256:a81e3844acaa7355b6567f97bd67a94a14ec3a9bc2cbbdae45b9592cc036775b", size = 16842, upload-time = "2026-08-31T19:02:52.554Z"} ] [[package]] @@ -237,86 +237,86 @@ wheels = [ [[package]] name = "coverage" -sdist = {url = "https://files.pythonhosted.org/packages/be/c3/4f2195f512fb172aa425a8803a874b2baa9ba7f80ff7b6080998761fc701/coverage-7.15.4.tar.gz", hash = "sha256:0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00", size = 936952, upload-time = "2026-08-06T13:50:24.442Z"} +sdist = {url = "https://files.pythonhosted.org/packages/d1/f5/deb1a27aa20746c0278ac998c4179e272004699b2d33959ce020c5ac1615/coverage-7.16.0.tar.gz", hash = "sha256:077f0964087883176ff6ab9b074694cae29f8c708273b13ca62c183c6ed716cd", size = 945620, upload-time = "2026-08-28T21:54:37.74Z"} source = {registry = "https://pypi.org/simple"} -version = "7.15.4" +version = "7.16.0" wheels = [ - {url = "https://files.pythonhosted.org/packages/f1/84/651a9310859673aaa3b3203f1aa1641ca60fcf2494683e1c9474c7172780/coverage-7.15.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c705b28feb2775dc82a25f1d473a370bc37ff93f5177f4e29ce2425f560f6921", size = 222565, upload-time = "2026-08-06T13:48:00.796Z"}, - {url = "https://files.pythonhosted.org/packages/82/f9/4dcf700137e8af550670f4d74d1b63828ce93e1e2b05e5f10710eb2ea987/coverage-7.15.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3ff205ab5e3ecc670f6a4dd19d9cbf12ede53dd41cfc1e15716ec961ea6d314e", size = 222936, upload-time = "2026-08-06T13:48:02.391Z"}, - {url = "https://files.pythonhosted.org/packages/07/4a/612ff1e780b3fbfd637486f542f84adc5503873d8b5d279dec1ffeef9414/coverage-7.15.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5172326e861a38b48b48befca15e0f477a26b283337a33a739c8fed229934e36", size = 253926, upload-time = "2026-08-06T13:48:04.382Z"}, - {url = "https://files.pythonhosted.org/packages/b0/04/d1cff1c2ead4708a6a79c01d3736b6a25bd38a36678398f72a8dd33dfad9/coverage-7.15.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:12b59c90084e3234fb11184886bf4a40f4f16a8c8f867be2e087b81f8e8868d4", size = 256523, upload-time = "2026-08-06T13:48:05.996Z"}, - {url = "https://files.pythonhosted.org/packages/b9/80/d34e13fb4b293cbdb9665838cf5522077b8ad14ef947550631a4bced36a5/coverage-7.15.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349062d66f00b40fa2c1c222438bad25fabf755631b5d82937fe985c8008615c", size = 257759, upload-time = "2026-08-06T13:48:08.036Z"}, - {url = "https://files.pythonhosted.org/packages/0f/e7/2c5fe7636fdb0732fe0f09f308a5b066864078b7fc61f6678e8478554f2e/coverage-7.15.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4256ced708e598e05209bc1a8ab4074e04a51dba4c62fb45926a229af675ace7", size = 259890, upload-time = "2026-08-06T13:48:09.834Z"}, - {url = "https://files.pythonhosted.org/packages/92/28/9689f0858dfff59c2ea688938ab9fa2925631235df67126a42b6c5c70ae1/coverage-7.15.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d80f974b20782d9612c8b4c9beeca867074c7cf4079d1419843fa25a26428b25", size = 254121, upload-time = "2026-08-06T13:48:11.459Z"}, - {url = "https://files.pythonhosted.org/packages/f9/e2/785077c230c157243eb5aa9a26c3be260ecd02001bead54a3cada3df8e03/coverage-7.15.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e179f19bfe1d31f8eeeaa12990194d761c4f62f0759661000bca6cd8729f40b", size = 255891, upload-time = "2026-08-06T13:48:13.209Z"}, - {url = "https://files.pythonhosted.org/packages/d4/90/e20371b17b40f912f21305c2db2f30efa3de306f7320fc916804872c85a4/coverage-7.15.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8bc16bb47b7679670eceff71d78bfb7d6e5b143f6c2cd117487ec7c75e0d4b78", size = 253859, upload-time = "2026-08-06T13:48:14.736Z"}, - {url = "https://files.pythonhosted.org/packages/05/49/25371987ee459a5f67c0427fb75c74f9358e65f2c71fe75bf41c1b6c5fcb/coverage-7.15.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd685005cd2c4200adfc14cf39a603b9320efab3f18a8f7f156d20c9cc3345f", size = 258011, upload-time = "2026-08-06T13:48:16.464Z"}, - {url = "https://files.pythonhosted.org/packages/30/6e/32e67467f6154bf4f1c4f63b05acc5097cba4237d45bbeeea446b52e8ac1/coverage-7.15.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:337399ad2c93b3acd2a937627dae8b3e86b66707cd3d3e856347999aadf1ef8d", size = 253676, upload-time = "2026-08-06T13:48:18.493Z"}, - {url = "https://files.pythonhosted.org/packages/03/c1/8b24192e89286399765155251f99ee9f070a9d637109018ac23d99b99f6f/coverage-7.15.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:96e257121228ec5cd2bb919276e94ac11074471bc37d68dbae0e8308cce15fff", size = 255453, upload-time = "2026-08-06T13:48:20.057Z"}, - {url = "https://files.pythonhosted.org/packages/16/6f/8b41ebdf67c87854e17c035336a90f1cfbad0c14c2a584301be6ff148718/coverage-7.15.4-cp313-cp313-win32.whl", hash = "sha256:c65a9e0dfc6143491879da4e13b5e30f8be192055de508d737fb14601edbd22c", size = 224605, upload-time = "2026-08-06T13:48:21.655Z"}, - {url = "https://files.pythonhosted.org/packages/e0/e2/2946c7f0b42b152ecb21ff1bdad72e3d301e790c0c487e4a86e8c9f69347/coverage-7.15.4-cp313-cp313-win_amd64.whl", hash = "sha256:2ff8f5e9b8f7a94f0c11c45631eee103dbcb7d63274edd12c56efe1be690b3b4", size = 225148, upload-time = "2026-08-06T13:48:23.376Z"}, - {url = "https://files.pythonhosted.org/packages/9e/83/3f4a69957f48ae7a0aba76c34743f88963d607b19e03f3f8e66f91cae0f9/coverage-7.15.4-cp313-cp313-win_arm64.whl", hash = "sha256:6e0a8a5083b096487d6cfced94cdd514d8f5db6f113610fb36c0620edb1028cf", size = 224536, upload-time = "2026-08-06T13:48:25.117Z"}, - {url = "https://files.pythonhosted.org/packages/ea/ac/748cf29eeb2d6be34a3176ce26a4f49e38085ee08e8935f05f6f26ed7e0f/coverage-7.15.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:770e9325ab5ea6d56f77e59b29ecfe0ac20b57a82a601876f90494a4dda0386f", size = 222608, upload-time = "2026-08-06T13:48:26.806Z"}, - {url = "https://files.pythonhosted.org/packages/0b/02/1abbf5c984677b0aa439cdacaccbf38d248939d8ef8fe1cc7a50d73edb77/coverage-7.15.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d12b33a3a50a1676b7784dc8d00a0c6d66a9f2add4b85a041c19b6a7e53ef23c", size = 222940, upload-time = "2026-08-06T13:48:28.432Z"}, - {url = "https://files.pythonhosted.org/packages/eb/e1/ff8f9f53d9fcf586125b55d0b1f04ec1c14955fee41e83d5814bee141bb5/coverage-7.15.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5669c8378ebde86f5def7a25d29586631b58acc27ffde04399f678f3dfc6e082", size = 253985, upload-time = "2026-08-06T13:48:29.995Z"}, - {url = "https://files.pythonhosted.org/packages/a1/26/595759762e514e81be1d7d01ed03444303bcd152226a6529998d253f9201/coverage-7.15.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff97a14362eef486483ed44042ca2027ea257df6ff768e62358ee0c9776925ac", size = 256492, upload-time = "2026-08-06T13:48:31.634Z"}, - {url = "https://files.pythonhosted.org/packages/24/68/b79aabac54d482be23b5fcdd4f4662bff24a78edc4ee29201726929936d5/coverage-7.15.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a325e815318638aed1655d9c06e6d7c2d3d46c09231ce988070428a8762d734", size = 257837, upload-time = "2026-08-06T13:48:33.186Z"}, - {url = "https://files.pythonhosted.org/packages/09/0f/bf7f297885a5bf6fd71e5782404e0ff059ca09e8711ceb3a08544abde45a/coverage-7.15.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:474223409d88eb20d2d6a0d37ea60e8647a65a90cc008dc1f0410af5f64f1e0d", size = 260152, upload-time = "2026-08-06T13:48:34.75Z"}, - {url = "https://files.pythonhosted.org/packages/fd/f1/296744e854ff8368542343457414380465e9ceefb9192342feb9d3bc461d/coverage-7.15.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f2f62ae3cd189dd2e13aece758c57b3eecbd27be070dbd4cbd10936049e5dbf", size = 253978, upload-time = "2026-08-06T13:48:36.434Z"}, - {url = "https://files.pythonhosted.org/packages/55/b0/bbdb2e9057493e66220a2e149ca2d301ba0e3a58a83bd6b90de9826d16f3/coverage-7.15.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39ece820e29e0a2ba34b3ecb3be83c27e997eed8926f2ba6fe7ce7a0bda5843b", size = 255846, upload-time = "2026-08-06T13:48:38.317Z"}, - {url = "https://files.pythonhosted.org/packages/96/e4/38015b2b6d21258713bd17e76b59d033b191efb5703589cffd037dfbca20/coverage-7.15.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f21b56dcace11dfe013014201f577dcd592b2a9b72182d930361b47cf6f73f25", size = 253808, upload-time = "2026-08-06T13:48:39.993Z"}, - {url = "https://files.pythonhosted.org/packages/0b/64/0d515c1e60ee6fbfd1a0e79c07cd87d388a233b7adc37758735677203808/coverage-7.15.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:93a3a0b662abcc10c73a47cbc72cd60f63618d6989fb2d1286e50eacd974f303", size = 258081, upload-time = "2026-08-06T13:48:41.971Z"}, - {url = "https://files.pythonhosted.org/packages/91/71/04d9e7a3642146c6351338aef4ef85ab11dbbb54744c13245caba1aad1c0/coverage-7.15.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:141fae2cabf5569b782c10afc4c850ce10f618c13f8db54765cba99cc839da1f", size = 253624, upload-time = "2026-08-06T13:48:43.731Z"}, - {url = "https://files.pythonhosted.org/packages/b4/a7/6c28b74c81ebff66987b0e2522ba5cffa3e90b0c33cb6a2eb264d4ee8cf1/coverage-7.15.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:81294c7e6ab30c5f74c0353b11b2fd6320e72d9bee6ac73b357caa8b916323a5", size = 255280, upload-time = "2026-08-06T13:48:45.58Z"}, - {url = "https://files.pythonhosted.org/packages/52/af/bc19996a7014b98d7bbb0f0939453c67074af65784a3aa16a789a07381fa/coverage-7.15.4-cp314-cp314-win32.whl", hash = "sha256:7bbd7d6418e0dab31a206af5203bd43ae36edb8e7fba1940b055d3e9249290d7", size = 224768, upload-time = "2026-08-06T13:48:47.525Z"}, - {url = "https://files.pythonhosted.org/packages/ee/90/219484e476d6e101ba0a444852579e05f5b75c37c611a42ed1190f73ef62/coverage-7.15.4-cp314-cp314-win_amd64.whl", hash = "sha256:f0204ed122758782970526057093f448051a39db9d810d4e344bb87a3546f425", size = 225259, upload-time = "2026-08-06T13:48:49.513Z"}, - {url = "https://files.pythonhosted.org/packages/b7/66/fa77daf4e383e5f776dac62c2409b6af81910ae6fe326bd5170dba74cc63/coverage-7.15.4-cp314-cp314-win_arm64.whl", hash = "sha256:9e71e7bc71c686a123347ae47a0de33a175e797a85bb57b791492adf4eec8ed8", size = 224684, upload-time = "2026-08-06T13:48:51.235Z"}, - {url = "https://files.pythonhosted.org/packages/58/5b/f03bf0ce362bbf3f785fa5219620d00778d4ac6fc9e407734828e9c672f6/coverage-7.15.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7c922735321eef3f87c280a3d39afff6b646723a2880b862cda4ac7a093b8aa8", size = 223338, upload-time = "2026-08-06T13:48:52.896Z"}, - {url = "https://files.pythonhosted.org/packages/0f/76/e77d0ae22501831cc9f92193e8a957a5caa1dd177f90a6d1d9b106242d92/coverage-7.15.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f41c17c4668a655ce96d090d8d5ffdc24ef64b5a02f9753884d08483e8a4a41a", size = 223609, upload-time = "2026-08-06T13:48:54.688Z"}, - {url = "https://files.pythonhosted.org/packages/82/1a/b1f089da8d38ac612fa2dd6dc7f4a1a7657d12f3e261d2996edd3a838d0b/coverage-7.15.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46822e9b6ff1c6a72b518c162c44a8f45a61a1d609c51084bf5b16c023c5037b", size = 264970, upload-time = "2026-08-06T13:48:56.403Z"}, - {url = "https://files.pythonhosted.org/packages/bf/31/e66d98d6e9c7fcc88470f1e234eaf6b1950dc0dfbf797f7282c1c861da24/coverage-7.15.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d6f4955b73b5445271379a59e3792b0d978f42d4a01e0cf7a67d9c33a3bb0a5", size = 267088, upload-time = "2026-08-06T13:48:58.41Z"}, - {url = "https://files.pythonhosted.org/packages/59/a1/ae94eb2c541add426378408379f233591e069040b1e2cdb33df9498a0682/coverage-7.15.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3fc9e047706fb4a9abb54f719d3aa643e80e5bb3818182c40aee01ac0f0247ba", size = 269508, upload-time = "2026-08-06T13:49:00.42Z"}, - {url = "https://files.pythonhosted.org/packages/9c/c7/88a10694a1c6a213569766aba9f25847b28155d4ac731b13226db216356d/coverage-7.15.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05e491d4f3165d62d4f5c8fd48dfeabf2ae8f42cbbd484319af33ea851b78982", size = 270629, upload-time = "2026-08-06T13:49:02.234Z"}, - {url = "https://files.pythonhosted.org/packages/b3/34/d8b8232e5e55169933b59aabcef2fedfa4b9d8897361bb80fcbda146505f/coverage-7.15.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:226c66e80ec0598d3b9b4874123df167ccca342aca8714f77cac6829688ee09c", size = 264043, upload-time = "2026-08-06T13:49:04.102Z"}, - {url = "https://files.pythonhosted.org/packages/7e/35/58b009dbf8c471c7224716478b9fed4a7e1af15320e1ed41660978504663/coverage-7.15.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac41cc14bebda0dbfb0628036b7f75706935c95bcc07fefe9a0f93614aa60a57", size = 266963, upload-time = "2026-08-06T13:49:05.821Z"}, - {url = "https://files.pythonhosted.org/packages/62/aa/57fbda1b42c892968273c56b6ee9dc0f1310850859230a507bc7873b1f65/coverage-7.15.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8af623e5cd92080acddd02b38f2f406a2c3a0893c38950b211890361448fbf26", size = 264569, upload-time = "2026-08-06T13:49:07.706Z"}, - {url = "https://files.pythonhosted.org/packages/98/8a/360e6e7f24d477b7e889703af0afa878d15b6d4d8d2a822b2835c169a879/coverage-7.15.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07545711d4f0f32852a18f18ad11f76f0109909d09e78b9008b4cfc67e829429", size = 268299, upload-time = "2026-08-06T13:49:09.587Z"}, - {url = "https://files.pythonhosted.org/packages/4e/89/6f701261aee21b6b5fa8f7872229406dc917e125069448292223bf213606/coverage-7.15.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a0865421cfdc53654b342d515e5a233187590882d20b95752150e53f65460017", size = 263413, upload-time = "2026-08-06T13:49:11.604Z"}, - {url = "https://files.pythonhosted.org/packages/3f/0f/6f04036edc260ed425af83e834f627fad48941ce97b50bfe6edd8b6fa623/coverage-7.15.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:460115e32ee40566476db5048f9bec1e842c127ad8e6f8be745aad3ac9cbc839", size = 265725, upload-time = "2026-08-06T13:49:13.38Z"}, - {url = "https://files.pythonhosted.org/packages/c4/ce/d19b5d4d5c49a7bfb925fd74310fee7d28bc99520ac3367ccbc54e662518/coverage-7.15.4-cp314-cp314t-win32.whl", hash = "sha256:cbde877ef9dd7baf272b9bfef2b8a25edd45d9170fc326951dd20eb480335e85", size = 225079, upload-time = "2026-08-06T13:49:15.265Z"}, - {url = "https://files.pythonhosted.org/packages/26/bb/7aa1b3b173faee0679037ca950bbbe1247273656697994d8d13f80f8d4b4/coverage-7.15.4-cp314-cp314t-win_amd64.whl", hash = "sha256:3da9e92d1c551fd7563833e9ade686efb0c4b7363ab7681a94283958c950bf5e", size = 225911, upload-time = "2026-08-06T13:49:17.279Z"}, - {url = "https://files.pythonhosted.org/packages/81/1c/4ea9e47426d80038d9222db3c4534cb6021a74b237d3ff97ffd33b6600dd/coverage-7.15.4-cp314-cp314t-win_arm64.whl", hash = "sha256:3a54f5a0d85050c73a38f6793090ee83974531e67fe5e57a1da9bee11398aa5e", size = 225219, upload-time = "2026-08-06T13:49:19.293Z"}, - {url = "https://files.pythonhosted.org/packages/2b/c4/dc5d2ac8f9142e7ec7de66e7bf0591db29d78955a040bd915870d9c0e657/coverage-7.15.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2c9872e4d9dc5d3cf616bf4b382f5a00359305a5be666a3dd0b5cdb4e49597f9", size = 222604, upload-time = "2026-08-06T13:49:21.279Z"}, - {url = "https://files.pythonhosted.org/packages/70/39/33e63df81fe2ee100897451841c821467635923e58e37c6bd4b46dd8106c/coverage-7.15.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:e101dbb4b9b72f0cddd8cdc8c9c5b47f456766f5e0ac82dbfb75e5c55409b78a", size = 222944, upload-time = "2026-08-06T13:49:23.187Z"}, - {url = "https://files.pythonhosted.org/packages/99/1f/ef3ffb5557febc75a0d97aa459d0266d7d741110265121cc6d8539343d44/coverage-7.15.4-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7d1abebdb047729e852b9c77a00497dfbeb11eb3a117e037d7dbc3ac8e5f5c54", size = 254050, upload-time = "2026-08-06T13:49:25.008Z"}, - {url = "https://files.pythonhosted.org/packages/6f/f5/1f0f6f77698c3601ca0ae7431e34b24c62ca2f06fecb23b73ed1f651d2be/coverage-7.15.4-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d28a4a899354d0ea6214cc59b4fa19eefbce1b9ff1688ab579acf49e894bd3fb", size = 256967, upload-time = "2026-08-06T13:49:26.896Z"}, - {url = "https://files.pythonhosted.org/packages/03/7a/2ed9bed79925f4367c83c77f66a89e5ca7229c288d2d19ad5f36d1ca0070/coverage-7.15.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffb3c2aacea411cc7e1d27712490c11108e2de1d39019ae32915493a59a8b9ed", size = 258587, upload-time = "2026-08-06T13:49:28.692Z"}, - {url = "https://files.pythonhosted.org/packages/45/8c/fa34044f71b7cc4ecb6da9c2408770959b0591fa9b5fb6fb6bca38f94298/coverage-7.15.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9447978a92f405d301123cfd39ff49895490efb769a758fe2734c7f631bf8ce", size = 260785, upload-time = "2026-08-06T13:49:30.472Z"}, - {url = "https://files.pythonhosted.org/packages/4f/54/d5727ce36b4524a7394ab9f5f1df378e1f23affcdab01037dc8655185cc7/coverage-7.15.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:050467a7983b8e2fe7dd41a78bb30c3e7f8c0b8cafda14b1c46f8b5e3cf2dd3c", size = 254545, upload-time = "2026-08-06T13:49:32.271Z"}, - {url = "https://files.pythonhosted.org/packages/dc/e6/6e3783e576719590194bdffb6dd6d85490801785b7c331e35a245d8cb8b5/coverage-7.15.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d003b7a5708ddad5c206c79607a6b92abb6fc13c57d99d8a4468cc03a2941ced", size = 256682, upload-time = "2026-08-06T13:49:34.089Z"}, - {url = "https://files.pythonhosted.org/packages/dc/f2/bacdbde18b69ed2de424fcf64d9fb0a4913753d4f0eca8bae9daad69f4bd/coverage-7.15.4-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c38efe30fd74e5c19e9433f11fb1f5dc9c6522770971b7c6145bbaa413dc8800", size = 254560, upload-time = "2026-08-06T13:49:36.052Z"}, - {url = "https://files.pythonhosted.org/packages/6c/a3/1fb927196e3477c1b48831169ab58ba08f451ba87ae311ff1de68b26a616/coverage-7.15.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:1f4f826d70f772ab8b0c052329580d7fe8b8abd191e4ce0c8f81aec6614665d3", size = 258792, upload-time = "2026-08-06T13:49:38.01Z"}, - {url = "https://files.pythonhosted.org/packages/41/58/30d4c149c69053de0edfe325614c1d28d508f62b1783e0e4a234d2e49136/coverage-7.15.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4a4bf917c9953f57c957be31c1cd504e3bd2f34d4a352b9d391a3025336f6768", size = 253968, upload-time = "2026-08-06T13:49:39.934Z"}, - {url = "https://files.pythonhosted.org/packages/89/e4/77f639371b918aad30dda4051f95404b43578f7f2e2f87ba73e02ed1ff37/coverage-7.15.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1c9bf40ebef178a45192c75c4964760bb261b0e6ad725da5fc4c93f674f19753", size = 255893, upload-time = "2026-08-06T13:49:41.825Z"}, - {url = "https://files.pythonhosted.org/packages/5c/62/13be29b3ddab35f14c87967a4820a05106d2a3eccb4fa4ff550bf30b75e0/coverage-7.15.4-cp315-cp315-win32.whl", hash = "sha256:43619d04c3671792d2c4706ae8bf45e265dc87bbd4078189ef8b847ea1e74be2", size = 224768, upload-time = "2026-08-06T13:49:44.08Z"}, - {url = "https://files.pythonhosted.org/packages/a1/70/af0c6be0f964af6954f6b74bc109b0dbca02824696d2520fb17fe1ab06e3/coverage-7.15.4-cp315-cp315-win_amd64.whl", hash = "sha256:be619439dbcd31a2eab10b32de9fff62c26ed4bab69dc32b8363fdaaa0882809", size = 225242, upload-time = "2026-08-06T13:49:45.899Z"}, - {url = "https://files.pythonhosted.org/packages/4f/2d/f3bd3aab899fc9efc18b53133ee68f5f98574ef480649b23e12962226387/coverage-7.15.4-cp315-cp315-win_arm64.whl", hash = "sha256:def597967dafc2e8d97c9097ea453c464e0bb8ed38f193a43070f10dc623bb6d", size = 224674, upload-time = "2026-08-06T13:49:48.322Z"}, - {url = "https://files.pythonhosted.org/packages/f5/ca/f69251cd63eabc6438321aea22148754cce758a26bde07dd490e3fe7cfc5/coverage-7.15.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c7dbc748ac8a1e3e59a2b28bea47675e6e778081dbbf081bde0d75def2fcbe1d", size = 223333, upload-time = "2026-08-06T13:49:50.293Z"}, - {url = "https://files.pythonhosted.org/packages/a7/a7/037b53b2885b0d8447064432491a4d5a1014cd9f97a594d53acd0c04541a/coverage-7.15.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:2413074a5ecbb61a01a7888fc72db0ca324d13588c5b38bc0dd8564cdcdfea26", size = 223630, upload-time = "2026-08-06T13:49:52.637Z"}, - {url = "https://files.pythonhosted.org/packages/80/4f/152b8a4779ae90da11bb24f7467df8a59f0be48a5c52acb856325ca48289/coverage-7.15.4-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4e6f6f632b7b2f714bf7a1346e8f97b650ee71f3c298aaad42a2ab60f0f07645", size = 264489, upload-time = "2026-08-06T13:49:54.52Z"}, - {url = "https://files.pythonhosted.org/packages/10/2d/84b4b9e0e1dd6528a51920ff7031f35b789382e467a28ec6a5a578cb8812/coverage-7.15.4-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8df457da2249d3c75ca2e5e835d59c725abfe92d27fdff6cd99eed85b51d5e9a", size = 267567, upload-time = "2026-08-06T13:49:56.721Z"}, - {url = "https://files.pythonhosted.org/packages/53/fc/ba01cc25299f9f8a2c8b02d3b28c53f3543d9fbfbe4e74fa2760b48f163e/coverage-7.15.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:050f66a08805acb5b8a23c6d4a517b1ecf82c08e81ed0e4bd727df065e5c6624", size = 270123, upload-time = "2026-08-06T13:49:58.736Z"}, - {url = "https://files.pythonhosted.org/packages/cf/d0/db2647cbf40b14f8c308f94ff7bf89c06d564e59f396906edf50086ec788/coverage-7.15.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1587fb771d1ccceef708fdde1e5af8c7ed24b486b61d13a321acb7d8145390aa", size = 271107, upload-time = "2026-08-06T13:50:00.811Z"}, - {url = "https://files.pythonhosted.org/packages/70/ff/4d2d17924552c458bb4f77dd631f0e3bc92fbbdf2d2d916cd4b33bbfd5b1/coverage-7.15.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8b4f1c3a69ca580f3fbd6b2046915f536d7f586874f25c1bb23add2a3c88d50f", size = 264955, upload-time = "2026-08-06T13:50:03.023Z"}, - {url = "https://files.pythonhosted.org/packages/ee/de/dc010c7a3691f396d93bbc26bfcafa1c2a3a351cd520470f15faf5795bd5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:ffb58d7eff5b7f6ecc6fa21d6288ab7f968a212cb67d682c269c09b9eba3b66f", size = 267949, upload-time = "2026-08-06T13:50:05.557Z"}, - {url = "https://files.pythonhosted.org/packages/78/ea/dc96a11375e83c045c2f7c61fb6918277cfe9401db7c0f7b1d111a84b2e5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:d9df165544774574ee004b953023d1bebada1894a80b1052a43d798b0f676e67", size = 264421, upload-time = "2026-08-06T13:50:07.612Z"}, - {url = "https://files.pythonhosted.org/packages/c8/86/b77131a0f9503ce461cd577076147d7a9040f0c5dda772686f729e2cc9cb/coverage-7.15.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:f9de0a24a4079b53e523b5c5e2c5945ec251ab486652659955187cf255a259bc", size = 269121, upload-time = "2026-08-06T13:50:09.58Z"}, - {url = "https://files.pythonhosted.org/packages/24/24/944bc35007862955e7ebf05754e645419dcf5d7526c52735cfa2715e8ebf/coverage-7.15.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:150089274bdc9f940628552cb92844e0223c987f1902ab8efe9f45a2ec758d88", size = 264565, upload-time = "2026-08-06T13:50:11.722Z"}, - {url = "https://files.pythonhosted.org/packages/c7/cc/a3bb9f93e7e740659163e2ea584f8196ddcd2c456a5dbe15f6c50105fec1/coverage-7.15.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:a58a94fed5da6997d258e8f7668c1e195fbd04a691d781b7558f1e468f9e68bc", size = 266522, upload-time = "2026-08-06T13:50:13.786Z"}, - {url = "https://files.pythonhosted.org/packages/49/dd/e0e40f3560d878d888c580698ff5ad1179f5e1c3ac949684ef66b41a3817/coverage-7.15.4-cp315-cp315t-win32.whl", hash = "sha256:ebd5a6d8466ff30836572f3ba2cae8a5e8f85029b1c6d5e2ed338dc472a5166a", size = 225068, upload-time = "2026-08-06T13:50:15.825Z"}, - {url = "https://files.pythonhosted.org/packages/c6/7e/37732ea80eebc30e976e4cdab15c190bc42d96959a42e38ddf6f8c60468f/coverage-7.15.4-cp315-cp315t-win_amd64.whl", hash = "sha256:288bde2a2d7ab6b6c2d7252fcde8b524387f2d970bdba9658fc6f8bbcaef0f9b", size = 225895, upload-time = "2026-08-06T13:50:17.928Z"}, - {url = "https://files.pythonhosted.org/packages/c6/08/1e00f7923eaaba45fb3d51dd794125fc766304b1df264f3a9c6557bfb30e/coverage-7.15.4-cp315-cp315t-win_arm64.whl", hash = "sha256:68be5e1de60ff13c9095bbec0e5a7fa45b33b101752215b91345ea1f61c4a278", size = 225213, upload-time = "2026-08-06T13:50:19.981Z"}, - {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"} + {url = "https://files.pythonhosted.org/packages/54/c5/e62c87f4799d1e3647d5b2ae16ea1d12205d72fde1ea8529e13fe050f678/coverage-7.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1545c52ce756b8a97007f439a220297f1cd72a2cbbcdffccdf1c1f70e74f9a42", size = 223215, upload-time = "2026-08-28T21:51:55.628Z"}, + {url = "https://files.pythonhosted.org/packages/89/e9/5e62fda9397175fb206f75368b6e85da06d831c181b6d0f67ca073cd2f89/coverage-7.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0598aadae641f30a0796b75b45c0b9c5de8619bd5cfb251bb0cc254e86e6dd13", size = 223585, upload-time = "2026-08-28T21:51:57.355Z"}, + {url = "https://files.pythonhosted.org/packages/b9/40/bede08621b1ba67e88c4d3336c22b52cb7911ff1fa4ef055344b6670e58a/coverage-7.16.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4080ad6bad9f14690e6b2104f5e8d137ccc65a4b5427a36662090637d4bd16d5", size = 254575, upload-time = "2026-08-28T21:51:59.233Z"}, + {url = "https://files.pythonhosted.org/packages/12/d8/ab0bdaa45dfd6b8cbf1a3ec548fdf827684b1997f9724375c5b3e89144fb/coverage-7.16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e9883a2f8206ce3af59117dc278e5d043fea06912bca3f199816129e5e2de354", size = 257172, upload-time = "2026-08-28T21:52:01.015Z"}, + {url = "https://files.pythonhosted.org/packages/1d/bb/135de81784bbd7dfedcab2b92b03d71d75b09b0815b42d6dabb052def5a6/coverage-7.16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:984e5430fc6f858385009e92549955157d79335b1f3e13e1031e0f89d1284261", size = 258410, upload-time = "2026-08-28T21:52:02.76Z"}, + {url = "https://files.pythonhosted.org/packages/ad/72/ce44ecc062fb2e43d9447bb76154d091c2139232f20c125297c4b58f4c6a/coverage-7.16.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b1374099dd1ad0d31fbb6c95d00a56a3c5e85fb3343dca14fc12f78323a2b42a", size = 260539, upload-time = "2026-08-28T21:52:04.821Z"}, + {url = "https://files.pythonhosted.org/packages/e7/c4/9389c36a41e59406ca2bba493807c2294d2e5186a7e9ebcc2e63a0f2a711/coverage-7.16.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34d8686bce035c8465b318a8c2890e69ba14a00801a27f4eb6bdc97c23944d87", size = 254756, upload-time = "2026-08-28T21:52:06.68Z"}, + {url = "https://files.pythonhosted.org/packages/ad/0f/7762447b15e01fb84263608540123c4d9941f06303265ee74d801ccbec0e/coverage-7.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:857fceba6ff4b507ee0ad98798a33d544a8473df0c542bf04251ee4ed5ee6292", size = 256540, upload-time = "2026-08-28T21:52:08.529Z"}, + {url = "https://files.pythonhosted.org/packages/e6/fa/c60dc75a8346c1dbebebc7279b19971c88f70dd575f0bc10bc0cb16f92d5/coverage-7.16.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bbf08d951abaa1ce89e28c998361d56b952413846b459cd017f116ad4c9adbfa", size = 254508, upload-time = "2026-08-28T21:52:10.323Z"}, + {url = "https://files.pythonhosted.org/packages/c3/f0/4e0834f3a1fccaa8bf625a2a1d73bde0fa32577dc3249853c0dd0e7f2b20/coverage-7.16.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1a03e78f53e4d2ab13adac19958a89322d1829913e5623d642627bf60b35da21", size = 258659, upload-time = "2026-08-28T21:52:12.124Z"}, + {url = "https://files.pythonhosted.org/packages/b4/ec/fe712d3a11fd6e874565a5fa5497c48b8ece561d9611da040b44cdcf8386/coverage-7.16.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:dcd3dafcdd78305d27c59a1006b53a4990acb89e68d8fbe0992f4f83503c827f", size = 254326, upload-time = "2026-08-28T21:52:14.181Z"}, + {url = "https://files.pythonhosted.org/packages/e7/78/093e12072e01034c65ff380f76c74b79dd83e44fa92b689a2154389be734/coverage-7.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c1bcfe470a796fbea6234accd81d258a31574dc0b7bf569e16be757572c4de17", size = 256102, upload-time = "2026-08-28T21:52:16.003Z"}, + {url = "https://files.pythonhosted.org/packages/9b/c0/265176117ca5d06e3f65575842884cdda96cf213350a31e9d41c80d65854/coverage-7.16.0-cp313-cp313-win32.whl", hash = "sha256:1420370276f1694b663207b8245c3628aafb9624fe3cebf313a13d860e55ee67", size = 225250, upload-time = "2026-08-28T21:52:17.82Z"}, + {url = "https://files.pythonhosted.org/packages/1f/01/8a87f2c04fde322430b45d16d8f543693e9894c5b2d2ca238a287c00beca/coverage-7.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:496277c8d7beed695e02c7be53516a0152e4caef8738a0feab6a638546cce449", size = 225790, upload-time = "2026-08-28T21:52:19.641Z"}, + {url = "https://files.pythonhosted.org/packages/23/40/c21feacd9edfe7063195bf9cc84d650e9938fc6a23063e4f027199b160e1/coverage-7.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:181c2906b9b3759955c1c33c51fbb91c754fbd0b82ea49e2c81061f5a052082c", size = 225180, upload-time = "2026-08-28T21:52:21.613Z"}, + {url = "https://files.pythonhosted.org/packages/ea/73/850675f262391b322c4c988b6cdc32cdc6629288f0fb158687b587a393a8/coverage-7.16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:54b7fba6a74d010de34319a0419d5b65af8c00f539ad0b6f39fc6f342ab99697", size = 223258, upload-time = "2026-08-28T21:52:23.558Z"}, + {url = "https://files.pythonhosted.org/packages/61/c1/4f54c6d47c80d1cc58ef8fe6b74e6eb50f9e2c0f6e2de6cf38dbca2937b8/coverage-7.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fa4ff0b3dd52208d2b30903022d5087f82000507b504753dfeee83e4f32d6883", size = 223587, upload-time = "2026-08-28T21:52:25.627Z"}, + {url = "https://files.pythonhosted.org/packages/3c/be/298f2456230fb44e272a4e53a41b3f3c39f0821c242d7b7daa9787b4d6f7/coverage-7.16.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:35a9676bf86097f790113ebd9fb67681804ef54d40941d2f10ba68c02239e575", size = 254632, upload-time = "2026-08-28T21:52:27.689Z"}, + {url = "https://files.pythonhosted.org/packages/a3/9c/a1bda6439c19c4783d50df896142b67b9e7d432db36675d339a32778669d/coverage-7.16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f98d438add63546745e5e847192e3e9ab897ed6f2ca96f8281e2f5a15958ae62", size = 257139, upload-time = "2026-08-28T21:52:29.741Z"}, + {url = "https://files.pythonhosted.org/packages/f8/cd/cd735c9be757f97237c305f36897a5e5b348bdbc12ebed3b2b80060dd8a9/coverage-7.16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:151855767480be14db595cbc2040f6a4db965cdfeebd354d79b0256742b029e0", size = 258484, upload-time = "2026-08-28T21:52:31.68Z"}, + {url = "https://files.pythonhosted.org/packages/e4/04/84b2e1e8aae9db3f549782f28ce25bba5fd6a9c7bfba3782ffe8b4cd2559/coverage-7.16.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:183613f664718b340589d7f005c7e92b4b601cffd20a8a4117cfda3e983b080f", size = 260798, upload-time = "2026-08-28T21:52:33.642Z"}, + {url = "https://files.pythonhosted.org/packages/8a/4f/e04cf52483619a4dc5dd6367b30c9a8ac52243567fdfacec9b11a441565c/coverage-7.16.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:785b114356c99c0dd5b3f57b9696cfd57b7704f4c53847df8dc88c6cc0d9bcb6", size = 254612, upload-time = "2026-08-28T21:52:35.543Z"}, + {url = "https://files.pythonhosted.org/packages/da/33/627c4113f66bfffd43807f54dbf080c4632ecf12e4ef7a3bdd4ec38e46a2/coverage-7.16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:30f5aee6d1d517abcdfd4f9cad027969ff79a1440a22da263f9514e31b5b66e9", size = 256495, upload-time = "2026-08-28T21:52:37.485Z"}, + {url = "https://files.pythonhosted.org/packages/3c/38/aaca432f4e008a88f2bc4d1459aa7016d8d1bbbe801f7e4fa3cf2746557b/coverage-7.16.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:190ffa0f5af966254c249fb3aeaca2cef389785e3e287fd577d39e134d20f8a3", size = 254454, upload-time = "2026-08-28T21:52:39.425Z"}, + {url = "https://files.pythonhosted.org/packages/cc/db/8430aa87ef0a508f4c17c1b8fa7e0cf80231988d9081aa36c194036592d6/coverage-7.16.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0ccc37c00e1a5d30840902c54557e104d04aead872cedf6d2281c8725a467e06", size = 258728, upload-time = "2026-08-28T21:52:41.32Z"}, + {url = "https://files.pythonhosted.org/packages/76/88/cd8aa8c82493ffbd291d3ef5554452fffc634c6c6098a04ac848c79c98f3/coverage-7.16.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6c60cde430c0e7e3be612973af39b4cff90ec2e2defe7b2b701daea3a0ffff04", size = 254271, upload-time = "2026-08-28T21:52:43.278Z"}, + {url = "https://files.pythonhosted.org/packages/a8/49/fe16c811ea9314a84b48f34e4bf5a3d9013091093b285a74b2272fc863d7/coverage-7.16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c5297028c8df849a61b29129cadfe682f90b5b396f528eb319a57d7678eefdad", size = 255927, upload-time = "2026-08-28T21:52:45.461Z"}, + {url = "https://files.pythonhosted.org/packages/d1/45/d0bd410e78cfbf768acc8099b335e1d5c0d5c26103c796d2bebdee001715/coverage-7.16.0-cp314-cp314-win32.whl", hash = "sha256:136988df5bc5a48795d9c42c75c4bbda5d9a78e750a080c1233010edff93a1af", size = 225424, upload-time = "2026-08-28T21:52:47.658Z"}, + {url = "https://files.pythonhosted.org/packages/17/78/1ce6ce4646822e9308dcdb1942eaf31bfd7da43247b8886338b0d6fe3767/coverage-7.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:ce2ba5e9f1842fe09165825abfb3bc6b527c71a27bc2eb3a10f2284ced64506d", size = 225918, upload-time = "2026-08-28T21:52:49.692Z"}, + {url = "https://files.pythonhosted.org/packages/f9/cd/e1323fe3a7dfcdd709451a43fe708ca1dfd36a7fc07b34eb7bd1dfdfb52d/coverage-7.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:a89d07e48d9baead9a15599923a02f62c6df6c3d85aa84ef34be3c9fd6aeb91f", size = 225344, upload-time = "2026-08-28T21:52:51.665Z"}, + {url = "https://files.pythonhosted.org/packages/39/fb/1c15460d4cf915f09ae3ad3862fef4f901838991c5641b0cec545050d810/coverage-7.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:6e2854b62601c89a63814ad5def3b90d99c6724cc4cb977f75b725e5fca4b1e3", size = 223986, upload-time = "2026-08-28T21:52:53.572Z"}, + {url = "https://files.pythonhosted.org/packages/9f/73/347d2d0009ac211f79ee2a2364fd2aa19d6b9628dc22ed13a9b9386097ab/coverage-7.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f093faf23df888518d273be6da65f0ec5a25b5d8b670231e4d87de07361042e7", size = 224254, upload-time = "2026-08-28T21:52:55.59Z"}, + {url = "https://files.pythonhosted.org/packages/5a/2f/51442e6ad9d705369596f08496021647e276d5b57311818fd4312d93509b/coverage-7.16.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b7dbbbf6551eb94618e7bc76ab61cc2740a5b3d13294171bd6adb36e12346c3c", size = 265619, upload-time = "2026-08-28T21:52:57.645Z"}, + {url = "https://files.pythonhosted.org/packages/ea/8e/0f752276f6d13efbd019ab6d90792e20d6272c44cda039dc5c6d27b91e7f/coverage-7.16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:51e7d0e311d2fba3915f971236cbdd4ad821fc7a23988221c0b33c964b0eba22", size = 267734, upload-time = "2026-08-28T21:52:59.611Z"}, + {url = "https://files.pythonhosted.org/packages/fa/02/4df3baef8029881c9d1a380859f2be73f90080d430def567d182e8566a35/coverage-7.16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0bb04ee77e557d7476471969d35fbbfb5fc8a4152e9409aa5811780c36d9b23e", size = 270156, upload-time = "2026-08-28T21:53:01.658Z"}, + {url = "https://files.pythonhosted.org/packages/9f/30/ce10fdb74055ebbfb5c8a025d8845dc19c76e4b2c42bb5c755b56678990c/coverage-7.16.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c72c9b201dc0e8c2c8821d49858fd865010d08181bf877d2320971b6464ebfd5", size = 271279, upload-time = "2026-08-28T21:53:03.698Z"}, + {url = "https://files.pythonhosted.org/packages/71/19/c7e1fc9504d90da848493bad4018dd235c713a80633e48c5f0a41b63d45e/coverage-7.16.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0fca700cae4635656668ba6e2b66a85aac9f2622d7b2bcf82e844c409eaa1313", size = 264677, upload-time = "2026-08-28T21:53:05.741Z"}, + {url = "https://files.pythonhosted.org/packages/a4/f3/4021519dd41583ab396c81955387f927779641f6bac26818b6918a45aafc/coverage-7.16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:584896fb8b650e999e24ef57e9513e482c12f8e15a73ee9d4584e23c99465867", size = 267610, upload-time = "2026-08-28T21:53:07.763Z"}, + {url = "https://files.pythonhosted.org/packages/55/fc/df65aac93938d8f506434c8e96440c1d696f6be0a6a01d3c6bfe5d49403e/coverage-7.16.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:949eae7e0f562b1518355aaef4b03523e49a6d3fea12aa3542d9e36c863f8267", size = 265217, upload-time = "2026-08-28T21:53:09.786Z"}, + {url = "https://files.pythonhosted.org/packages/32/2d/dc9a5e62715165fcb4c715f965f411e324917c9daeddde16536e9d36ce3f/coverage-7.16.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:64f0611ee05364fc85cc3e5bc371804117a76fd337720e6017332fc7c534257a", size = 268948, upload-time = "2026-08-28T21:53:11.866Z"}, + {url = "https://files.pythonhosted.org/packages/8b/4e/fe73a5560f25fca52acda76fc1554f30de081793ae4de97e920f8ab161d7/coverage-7.16.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:050a291b3cfe5e0df5999ef2fa5a7aff6e2db329f069d47eb63f02bde2e7e96b", size = 264061, upload-time = "2026-08-28T21:53:13.996Z"}, + {url = "https://files.pythonhosted.org/packages/b3/f7/bb78cc4b97085ebbd77fa18cbc25abfab462814efa3e2363b4e50885c775/coverage-7.16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a336b1e2990a64f5c356a9b8380fb9c029d56c832b801255250c44d603271bfd", size = 266371, upload-time = "2026-08-28T21:53:16.233Z"}, + {url = "https://files.pythonhosted.org/packages/aa/ec/84b4af5cd4ad498477b3bfb2217e47b048da919451053790efda66f7383c/coverage-7.16.0-cp314-cp314t-win32.whl", hash = "sha256:058631257350b31784ed43ceb808298b6f074edf4ebca4c7ce5082e6bf873a61", size = 225736, upload-time = "2026-08-28T21:53:18.632Z"}, + {url = "https://files.pythonhosted.org/packages/7e/43/50fc0e6c675c3ef14895a74bab2d6120cb5d6f4b562a3d3f5046797758dc/coverage-7.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:ed35097438dfa980c1ec75bc83edf8acbe7a374d7007e571957a257fbd0e2fb3", size = 226570, upload-time = "2026-08-28T21:53:20.754Z"}, + {url = "https://files.pythonhosted.org/packages/fc/24/9effce7bcd3c6eeb4da3561905837509e582dcdde7a7f07d6ef2c8512f76/coverage-7.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0466f4a5c0370461b7d8c7eb259d7d1db0b5756f13d66230b04d22a1d380ee11", size = 225879, upload-time = "2026-08-28T21:53:22.747Z"}, + {url = "https://files.pythonhosted.org/packages/4a/2c/318e4379106bc8047ba235e3732ddc87d1b393ac3db9776f5405ff14f322/coverage-7.16.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:80d7d5d744a041f08637df743ac086204ec5acbcd8432a42b00b49e607358024", size = 223257, upload-time = "2026-08-28T21:53:25.376Z"}, + {url = "https://files.pythonhosted.org/packages/81/4d/a5c54d9144e9db6505749758ba50a28be624148873751728a59cbb72d27a/coverage-7.16.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:c5feffce90c3d602e149de1c477578efc34dee5f069f9764cc15808ce01ee15c", size = 223596, upload-time = "2026-08-28T21:53:27.461Z"}, + {url = "https://files.pythonhosted.org/packages/bc/97/38e93a10899c9315964c0a4e729b3e5867f8f46e977808f9c6fbda52525a/coverage-7.16.0-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:acadbf2f2a18d7f9c7f119ac798c00c540d7c79c93abd71ed648c87891303633", size = 254699, upload-time = "2026-08-28T21:53:29.715Z"}, + {url = "https://files.pythonhosted.org/packages/fa/7a/acddda030b4630f68167f3daa94b41d22071847822a70d8178d43dcf678e/coverage-7.16.0-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4212cec9b42fd9929e70b462732fefd8b13406371871c82f3c14397499d6550b", size = 257614, upload-time = "2026-08-28T21:53:31.948Z"}, + {url = "https://files.pythonhosted.org/packages/15/7e/225b182497c1ce6d3f0d76a3074a4dbc9f272300e92bb100df53b03de0aa/coverage-7.16.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c5a43cc0ef101637ae920a9eed24cf0549ef815621eae68b3ad577ec5a7ad2f", size = 259236, upload-time = "2026-08-28T21:53:34.291Z"}, + {url = "https://files.pythonhosted.org/packages/2e/19/76641ddc50cb2410ebbd0ed7fe1052614d0e5612e802a2817521adb9febb/coverage-7.16.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c76a9b50a344261fe4a9bd20c322b48d3913cc48e8c37f78c21a596008296e68", size = 261433, upload-time = "2026-08-28T21:53:36.401Z"}, + {url = "https://files.pythonhosted.org/packages/12/9e/5f89de8b7c2017f36b68b4e4a25940723a748b21474820bf61e8bce0891c/coverage-7.16.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80cf547379ad6b1878fd03b033b51188beab4b41824c96e7839e014a4cb947be", size = 255182, upload-time = "2026-08-28T21:53:38.496Z"}, + {url = "https://files.pythonhosted.org/packages/1a/c1/ce94b2ec502e79775efb5efa22c741ebb0bd2be10bdd29650825ff57bdcb/coverage-7.16.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:4b1d09cb5d8dc2c7164450f5217e6f0717497de9c588806a0780d352abef904a", size = 257329, upload-time = "2026-08-28T21:53:40.87Z"}, + {url = "https://files.pythonhosted.org/packages/86/8d/3f5374df3a6ca19ee5f98a6bd21dbb05f1e9d399bd9978e9821d260eab5e/coverage-7.16.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:cd1e85abed2d2499c16664137ac802356316f92b4e2bf3c150bdf0c45f5dd9ae", size = 255210, upload-time = "2026-08-28T21:53:43.393Z"}, + {url = "https://files.pythonhosted.org/packages/8e/b8/1bc5751496d0be6fd9dde8ca547d9a8a9f07847856aba3f3ae5ac594cd81/coverage-7.16.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:360967a6fd77794c167529eec2d16ff8e38216110619d23acc3fd466a1648bee", size = 259442, upload-time = "2026-08-28T21:53:45.725Z"}, + {url = "https://files.pythonhosted.org/packages/7a/dc/8aca78e47e1e6fcc761cd28a20daf4a84bd847a7369e2701a93ccfc3d1fd/coverage-7.16.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:92cbc2bf4f7f67c79f1d3ca4fe8c50faddf48e852a3d07eaaf02dc014889832f", size = 254618, upload-time = "2026-08-28T21:53:48.292Z"}, + {url = "https://files.pythonhosted.org/packages/73/fd/787842cdf6ce16ac5c1bd8a26549bab3b3f27b02500075bc540dc7853bca/coverage-7.16.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cce4dc8528453128c6fae523b15f3887fbea1d4d7c9eb9639d3d4fdcbe570c73", size = 256541, upload-time = "2026-08-28T21:53:50.805Z"}, + {url = "https://files.pythonhosted.org/packages/ef/79/8df302cbef373dd1f3401044cdb94dfc74517e5af2af27b4d0e721557e0e/coverage-7.16.0-cp315-cp315-win32.whl", hash = "sha256:5205baea687133613dced668a3d0168ea1479349615bfc255849a7944988c889", size = 225429, upload-time = "2026-08-28T21:53:53.177Z"}, + {url = "https://files.pythonhosted.org/packages/85/87/5bad7ac45f76b3728ca211028ee561c2ede3ba44da401129e28bb8737291/coverage-7.16.0-cp315-cp315-win_amd64.whl", hash = "sha256:4fcb5f07a9b7083bfb715115d27ce263ba2b5b89dddeee536b295ba0e3c2c627", size = 225903, upload-time = "2026-08-28T21:53:55.535Z"}, + {url = "https://files.pythonhosted.org/packages/cc/ea/67d84b11caf240f059ec313f616d82212df5004e8bc85802c1edfc50bb3d/coverage-7.16.0-cp315-cp315-win_arm64.whl", hash = "sha256:d568a8adcec0eda42ec23e5e65dfb8c184fc255120f9e99b484f7c869d923fb9", size = 225334, upload-time = "2026-08-28T21:53:57.769Z"}, + {url = "https://files.pythonhosted.org/packages/65/21/a88349cce3ff720729b754916ac47e2e3646a8137552e4fa7cdd5967cc7f/coverage-7.16.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:3e8037e8213adf882e9d7eedd2c5c557933ab0b9632c42d98fe98ec9bcdb4025", size = 223980, upload-time = "2026-08-28T21:54:00.082Z"}, + {url = "https://files.pythonhosted.org/packages/fd/02/4d54abf3e6a4d8b7675921b20e91163b1064a5a9dbefebb71c05065dd136/coverage-7.16.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:289f2ed4d56eebf029b649e7dfc3c1153b111962a75e294cdd8e4a1598a04cc3", size = 224276, upload-time = "2026-08-28T21:54:02.381Z"}, + {url = "https://files.pythonhosted.org/packages/f6/39/10dbc96d95d20b9b041045d293480bd49e536180e93af62dd7662376284d/coverage-7.16.0-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9b83f6ac575530783771c8dcf05284f7c8b5b12f1e7cb226d63445aac4497a3a", size = 265135, upload-time = "2026-08-28T21:54:04.558Z"}, + {url = "https://files.pythonhosted.org/packages/e7/3b/6b326544afd1a8aef3a495bbae109a7ab5baf23e04a2741d8d64e2df2ba2/coverage-7.16.0-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c3ff6580f2dfc5bec34717b85b2e6cf5ec993b721e7bb58a794babd525a8178", size = 268216, upload-time = "2026-08-28T21:54:06.97Z"}, + {url = "https://files.pythonhosted.org/packages/54/34/1dc8265f3ed990690e24d5f31ff79bc9fb9b25d54f9f89bebad5a6a8b7a1/coverage-7.16.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:507596cee23e9968b1934fe86d799b76166541af0a293930918b1b48a5c84bd2", size = 270772, upload-time = "2026-08-28T21:54:09.234Z"}, + {url = "https://files.pythonhosted.org/packages/66/a7/3a8463713a402b44044ec832f4a76e442ce4b3a207804303f4d1dc1a9bb4/coverage-7.16.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edc2be98e6c55ccc5ff7832bb64f023a4b03dba39dfa84b850046cf08a8249b0", size = 271752, upload-time = "2026-08-28T21:54:11.701Z"}, + {url = "https://files.pythonhosted.org/packages/25/3b/dd5e795cfbe1842f69899189089ae289a96d6a68de312960ea668542e33c/coverage-7.16.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9c0690994b84a15a53bdd39e0b2fdb539b22533820623eb86ba75b93760c645b", size = 265589, upload-time = "2026-08-28T21:54:14.12Z"}, + {url = "https://files.pythonhosted.org/packages/1b/b6/fd90636cbd95cb018312f6ca1ca2bbd70fbe8e4ee6f3992fc36a4230364e/coverage-7.16.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:de24c62bf798940a14674a47489a81b79915ec4134f556d5199830e065225dd0", size = 268596, upload-time = "2026-08-28T21:54:16.303Z"}, + {url = "https://files.pythonhosted.org/packages/91/10/ef2d59264f3b3b358cc5885ca375e6cdbda7c195e78304d5aae800a72d9d/coverage-7.16.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:69474d81f198774c9d2937599ca5da04c9e1c5de5032da23c607ce4960ce360e", size = 265072, upload-time = "2026-08-28T21:54:18.597Z"}, + {url = "https://files.pythonhosted.org/packages/3f/5b/400891c364c0170408d172501b340b18611800f4c42d8fbb16f9f5497c24/coverage-7.16.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:72a0795cc6d34acc2b03dfeabdc82b61b72087f2737018b56ac92c1cf5446c54", size = 269768, upload-time = "2026-08-28T21:54:20.985Z"}, + {url = "https://files.pythonhosted.org/packages/98/93/9792c80271df04d287d21ed5d662fd8fa58b1737888d817679b1ce5d2fab/coverage-7.16.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:d9a218d3f9c7d6916684ed5ba94f620661117a730e733cd6ef5e87accc5872eb", size = 265211, upload-time = "2026-08-28T21:54:23.344Z"}, + {url = "https://files.pythonhosted.org/packages/81/67/5b8f827cfa6616e6bd7ba9397acfe7e3c4fd5b9fca4125511d5089f55d5a/coverage-7.16.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:49fa72ead28c8216f8916398a4f3c4669acb30a061822810ee20a727a1be2897", size = 267170, upload-time = "2026-08-28T21:54:25.85Z"}, + {url = "https://files.pythonhosted.org/packages/5c/ee/c135d2d2cb617d744bc3e13c922f2fae66964494176ddef225dc4656bd2c/coverage-7.16.0-cp315-cp315t-win32.whl", hash = "sha256:27461af9f3ed7d2cf2411eb083784f87055ebf42211789ae3a216c48609bc743", size = 225731, upload-time = "2026-08-28T21:54:28.151Z"}, + {url = "https://files.pythonhosted.org/packages/8a/4d/dc3d53eadf155916e183bf5dfacbfc4aa5bfb7f13b7da11c01caa7a05cbc/coverage-7.16.0-cp315-cp315t-win_amd64.whl", hash = "sha256:c5612cc20ca76abc883e50269af47c1494b42958bb63dbb9aa79729a1ab5f7d3", size = 226562, upload-time = "2026-08-28T21:54:30.42Z"}, + {url = "https://files.pythonhosted.org/packages/2f/00/ac9da1a60a4e84c3ad0f7db4723fd327154a8f9add210c0dcd2db3ec5156/coverage-7.16.0-cp315-cp315t-win_arm64.whl", hash = "sha256:2ddaa9e2af4760a329d80008b7a3b4762fbb0dbcb169199360f9a5179c32f2dc", size = 225872, upload-time = "2026-08-28T21:54:32.806Z"}, + {url = "https://files.pythonhosted.org/packages/b1/5a/234e8fadf85c3cc48cb31c247b9e8e0c7f06ece80f5b29f9b8c241f9da4c/coverage-7.16.0-py3-none-any.whl", hash = "sha256:245f7de6d023a5bba375dbec9f2e0869bfa26ac0cc639bbb7b4c814884000b73", size = 214977, upload-time = "2026-08-28T21:54:35.189Z"} ] [[package]] @@ -394,11 +394,11 @@ wheels = [ [[package]] name = "filelock" -sdist = {url = "https://files.pythonhosted.org/packages/6d/30/03b03951873a1a0ffc7e8ca0e10c15597b59e8d0e39260704cd2ea087bc4/filelock-3.32.4.tar.gz", hash = "sha256:2bde2e4cf732e0153406d8a7bc80620ecf5e621fe0d25e41143c4e3b4733ff30", size = 222126, upload-time = "2026-08-23T17:37:55.363Z"} +sdist = {url = "https://files.pythonhosted.org/packages/0a/a0/50c2c0ce5e74d7721bbb1b19a26ebd339aac5878553a6e35308c2f31f935/filelock-3.32.5.tar.gz", hash = "sha256:f6a6a28f743f9b95ce19db5abe0f376f75eb56517dff21e1a4751e2657d3e83d", size = 222838, upload-time = "2026-08-31T18:56:34.729Z"} source = {registry = "https://pypi.org/simple"} -version = "3.32.4" +version = "3.32.5" wheels = [ - {url = "https://files.pythonhosted.org/packages/01/a4/9b63d595d748e3aff8812b65eacc1a2c4bd90b7c2012e08e72373b4835eb/filelock-3.32.4-py3-none-any.whl", hash = "sha256:22e58ca3b1ae3b98993b762d7338367ae64fe50252bf78d59da3bfebcdf1cedd", size = 99864, upload-time = "2026-08-23T17:37:53.913Z"} + {url = "https://files.pythonhosted.org/packages/36/d2/b70a31e13d04456d28493f31d2aa087e99eeb2767ef0293b2625727ccb8c/filelock-3.32.5-py3-none-any.whl", hash = "sha256:142cd9fa77a872c5e78c62329a0d15278fadc686eb89e760017968961a4fd6b2", size = 100003, upload-time = "2026-08-31T18:56:33.078Z"} ] [[package]] @@ -411,7 +411,7 @@ dependencies = [ ] name = "id-configurator" source = {editable = "."} -version = "0.2.0" +version = "2026.9.7" [package.dev-dependencies] dev = [ @@ -571,11 +571,11 @@ wheels = [ [[package]] name = "platformdirs" -sdist = {url = "https://files.pythonhosted.org/packages/ea/06/cf1564dcc2e2261c8c8c6c05628dc8b418943bdae2a4e58640ceb2f770fa/platformdirs-4.11.5.tar.gz", hash = "sha256:e8b31f4f8bcbbedef91a6b57a706255e4f148d2a4e01648382a0a47342539173", size = 34823, upload-time = "2026-08-27T21:36:37.46Z"} +sdist = {url = "https://files.pythonhosted.org/packages/69/b7/802a56eca9f2fac455b8bab5375a2647b0f0e14a2cd63ef077de3c4a7658/platformdirs-4.11.7.tar.gz", hash = "sha256:4f41487eeeeeb07f3a6625e61d9bc0ae6809f92d3386dbd74392fbb76108104d", size = 35127, upload-time = "2026-09-01T13:35:10.502Z"} source = {registry = "https://pypi.org/simple"} -version = "4.11.5" +version = "4.11.7" wheels = [ - {url = "https://files.pythonhosted.org/packages/c7/12/6f3fcd5067a9cbf4f8664b32957973498da8b083455203c8d9cab83a725c/platformdirs-4.11.5-py3-none-any.whl", hash = "sha256:89f8d42695853b89c7170bd49bc3dc593f98a71e695ede88e06a3b247bc4563b", size = 23900, upload-time = "2026-08-27T21:36:36.227Z"} + {url = "https://files.pythonhosted.org/packages/27/6e/80993e10a0482f630cef528635789233224f36b1ffd11592aa15d13ff9ce/platformdirs-4.11.7-py3-none-any.whl", hash = "sha256:8a02cb259042c79d1cd0450facc2fe6dc9d303ae7901afbe33bf8ea0b188cef6", size = 23938, upload-time = "2026-09-01T13:35:09.02Z"} ] [[package]] @@ -655,11 +655,11 @@ dependencies = [ {name = "typing-inspection"} ] name = "pydantic" -sdist = {url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z"} +sdist = {url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z"} source = {registry = "https://pypi.org/simple"} -version = "2.13.4" +version = "2.13.5" wheels = [ - {url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z"} + {url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z"} ] [[package]] @@ -667,55 +667,55 @@ dependencies = [ {name = "typing-extensions"} ] name = "pydantic-core" -sdist = {url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z"} +sdist = {url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z"} source = {registry = "https://pypi.org/simple"} -version = "2.46.4" +version = "2.46.5" wheels = [ - {url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z"}, - {url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z"}, - {url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z"}, - {url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z"}, - {url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z"}, - {url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z"}, - {url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z"}, - {url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z"}, - {url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z"}, - {url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z"}, - {url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z"}, - {url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z"}, - {url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z"}, - {url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z"}, - {url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z"}, - {url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z"}, - {url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z"}, - {url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z"}, - {url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z"}, - {url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z"}, - {url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z"}, - {url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z"}, - {url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z"}, - {url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z"}, - {url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z"}, - {url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z"}, - {url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z"}, - {url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z"}, - {url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z"}, - {url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z"}, - {url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z"}, - {url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z"}, - {url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z"}, - {url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z"}, - {url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z"}, - {url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z"}, - {url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z"}, - {url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z"}, - {url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z"}, - {url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z"}, - {url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z"}, - {url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z"}, - {url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z"}, - {url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z"}, - {url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z"} + {url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z"}, + {url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z"}, + {url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z"}, + {url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z"}, + {url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z"}, + {url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z"}, + {url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z"}, + {url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z"}, + {url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z"}, + {url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z"}, + {url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z"}, + {url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z"}, + {url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z"}, + {url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z"}, + {url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z"}, + {url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z"}, + {url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z"}, + {url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z"}, + {url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z"}, + {url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z"}, + {url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z"}, + {url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z"}, + {url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z"}, + {url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z"}, + {url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z"}, + {url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z"}, + {url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z"}, + {url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z"}, + {url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z"}, + {url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z"}, + {url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z"}, + {url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z"}, + {url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z"}, + {url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z"}, + {url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z"}, + {url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z"}, + {url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z"}, + {url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z"}, + {url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z"}, + {url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z"}, + {url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z"}, + {url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z"}, + {url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z"}, + {url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z"}, + {url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z"} ] [[package]] @@ -790,11 +790,11 @@ dependencies = [ {name = "filelock"} ] name = "python-discovery" -sdist = {url = "https://files.pythonhosted.org/packages/b2/8f/3c92c45737f654f2488ab3662b7604a55d3d35146d37c9ce80f5c95b95a6/python_discovery-1.5.3.tar.gz", hash = "sha256:e500eb24025fb7c4876c1fdcfbafd9028a10c71b661aee38cb6fb0de594518c1", size = 82477, upload-time = "2026-08-24T14:48:46.396Z"} +sdist = {url = "https://files.pythonhosted.org/packages/91/96/0f93e27c9f60a650838f2118159aa115fd5732c0716247917b7ba7ede665/python_discovery-1.6.0.tar.gz", hash = "sha256:6393b4eae1be8b2182670635e7baff89ac21cb9f8e86fd1ff40c7b1144febb4c", size = 82849, upload-time = "2026-08-28T17:30:02.366Z"} source = {registry = "https://pypi.org/simple"} -version = "1.5.3" +version = "1.6.0" wheels = [ - {url = "https://files.pythonhosted.org/packages/30/12/823d9a321904ccfd2969a24b84fdfd1e6614c707ec569c62879bf1dbc6c5/python_discovery-1.5.3-py3-none-any.whl", hash = "sha256:8305296358f1aa2ed302a25b84be7df84fef8ca47c7dce2da63cb7325333044e", size = 38290, upload-time = "2026-08-24T14:48:45.305Z"} + {url = "https://files.pythonhosted.org/packages/43/5e/21abf578182fb15006a57faf3711a1e659e29d600d19b6e557eae908c81d/python_discovery-1.6.0-py3-none-any.whl", hash = "sha256:d4e244cf17b8b29819ed78003d55fbacf86eda23425b075454fff9271b79377a", size = 38451, upload-time = "2026-08-28T17:30:01.236Z"} ] [[package]] @@ -909,11 +909,11 @@ dependencies = [ {name = "virtualenv"} ] name = "tox" -sdist = {url = "https://files.pythonhosted.org/packages/ec/ae/05210c695efa7349af65aeb988604b15520eade3b6e2d43bdf44ce69b4b3/tox-4.60.1.tar.gz", hash = "sha256:2611b77ebe06fa571ae2f4176eaf644115d08891befb9c7cb705cb038d5ab38e", size = 303833, upload-time = "2026-08-25T05:39:00.934Z"} +sdist = {url = "https://files.pythonhosted.org/packages/c3/8b/2e25d11c05e006438f8a1f20f8814aeb576ec8b3bf157f68454d2dcc09bf/tox-4.61.2.tar.gz", hash = "sha256:463b6eeaaf0929d60b4f7a87b26f4a7a764fe9832a74bf50b4e9d259b6a8e905", size = 306825, upload-time = "2026-08-31T20:19:53.614Z"} source = {registry = "https://pypi.org/simple"} -version = "4.60.1" +version = "4.61.2" wheels = [ - {url = "https://files.pythonhosted.org/packages/83/d8/5ca808e8b2d98ac55e9141309c63b273caec25eb5bdaf3d7bf6d809903bc/tox-4.60.1-py3-none-any.whl", hash = "sha256:6f23a6417ad761b17c047e14b6e278968c09eb9b1fc00025269f82ec8327bc75", size = 226880, upload-time = "2026-08-25T05:38:59.194Z"} + {url = "https://files.pythonhosted.org/packages/50/c9/3e78716c0b994a75f064d36ae56d707435dd147d6945914aaa6372fe3ccf/tox-4.61.2-py3-none-any.whl", hash = "sha256:262efa52b735004dcef04ae4c934614ec4e5c61319a14d1e762c18db7fdb82c3", size = 227984, upload-time = "2026-08-31T20:19:51.709Z"} ] [[package]] @@ -968,11 +968,11 @@ dependencies = [ {name = "shellingham"} ] name = "typer" -sdist = {url = "https://files.pythonhosted.org/packages/ae/40/4a3db7990d1f62a53182aa96eaef57aeb2886a27f90a195bc66713565d31/typer-0.27.1.tar.gz", hash = "sha256:a79bef8469a79c45498e7b814ecf8d603cc7644e9acbd9e19cac0334240b18df", size = 203994, upload-time = "2026-08-03T14:41:03.438Z"} +sdist = {url = "https://files.pythonhosted.org/packages/16/f7/57713ba479fd405eb76de31404b2c744c289e336b2d999511ebf51e496f7/typer-0.27.2.tar.gz", hash = "sha256:269b7eb9d3c202ca84b4bc9618cb04ebb43d3d4d1e567e4c768607232c05f945", size = 204045, upload-time = "2026-08-28T10:26:55.046Z"} source = {registry = "https://pypi.org/simple"} -version = "0.27.1" +version = "0.27.2" wheels = [ - {url = "https://files.pythonhosted.org/packages/43/89/9518bc0c3929bee36b3a4a8e3daddd6e03f92f9961c66d4983b837160543/typer-0.27.1-py3-none-any.whl", hash = "sha256:53150287edd11baeb4e4722c8e394fcdf8181c0ae89485cba8d25c778d5edd56", size = 122874, upload-time = "2026-08-03T14:41:04.391Z"} + {url = "https://files.pythonhosted.org/packages/dc/bf/205d0004930ede8f542fb58f601526fccf4ae7626075ca1e6c4de5d3d652/typer-0.27.2-py3-none-any.whl", hash = "sha256:b3a5fc4342d5fc8fda8fc3010b1cf117e9249aab7fae800c2eff62fd3842d97d", size = 123130, upload-time = "2026-08-28T10:26:53.752Z"} ] [[package]] @@ -1007,28 +1007,28 @@ wheels = [ [[package]] name = "uv" -sdist = {url = "https://files.pythonhosted.org/packages/f7/ab/790e25d2c685f5a41b9567001a43fbe049a595c1b08b8a93bf82d5fc704b/uv-0.12.7.tar.gz", hash = "sha256:4b320f84763a80308fd830ecf5c4c44505a8ed910fe265c5977d0a3727cfcd55", size = 7107999, upload-time = "2026-08-27T22:13:22.978Z"} +sdist = {url = "https://files.pythonhosted.org/packages/48/92/167ad4b0795530383a84560dc8bc8c0afab6b6fb10baba34b2a257fa6894/uv-0.12.10.tar.gz", hash = "sha256:27e8350faffb35d8ecaa5ed39236709809bb9a883f1d11d13c0964d6051b2384", size = 7144864, upload-time = "2026-09-04T23:14:21.301Z"} source = {registry = "https://pypi.org/simple"} -version = "0.12.7" +version = "0.12.10" wheels = [ - {url = "https://files.pythonhosted.org/packages/91/63/cc236f80a868b28525e0f7aa61f7ee998a0540ccba8cc4d01b2d42fc7838/uv-0.12.7-py3-none-linux_armv6l.whl", hash = "sha256:d568fd3448c24354753fd8333c978aeeeb6b51db4b100e234461a7eb50882fae", size = 22215689, upload-time = "2026-08-27T22:12:36.485Z"}, - {url = "https://files.pythonhosted.org/packages/ba/c5/40f4a5736d2f795688977b3b8bf841daca1e9c93b358778b7ed3dcd747fe/uv-0.12.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ec5b437aa60e8c94da263ad709d0bf6c8f268ac81f305d89c6115badd7d1cbe7", size = 20471214, upload-time = "2026-08-27T22:12:39.147Z"}, - {url = "https://files.pythonhosted.org/packages/e5/3e/5946d33de437467190b51d7d9f9e74a53628782565d50b64f6051050f482/uv-0.12.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:016fe4b9a2e0d2a35b17b6c3efbb45b929189c5b4b37aa921265265ccfe42cc1", size = 17317478, upload-time = "2026-08-27T22:12:41.58Z"}, - {url = "https://files.pythonhosted.org/packages/2e/5b/0e42dd88d9928b51a4e85d89d8aed46933951187b508ae68c145b06e3334/uv-0.12.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:fe9a871bd638ee6d2fd73bf40c2ee98153e44d06f796a03fcecf9d12b36d42d8", size = 21379352, upload-time = "2026-08-27T22:12:44.123Z"}, - {url = "https://files.pythonhosted.org/packages/8f/5c/b6417812ef87fc713ec368b1001b41c78eaedbfa521789db7d103b56c500/uv-0.12.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:1014a13854c45eb1daa9a32602e0f4d07f3edd298826d3e8d22740eed60a7c95", size = 21686985, upload-time = "2026-08-27T22:12:47.686Z"}, - {url = "https://files.pythonhosted.org/packages/69/23/a6284df1dc4233fea564227f77c402969b4f6aebcb647826ce9e82a413de/uv-0.12.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0ad3e91cc911596bb54197057853b64b36a066462d8f2fc4d4f60b61b707ffa8", size = 21744177, upload-time = "2026-08-27T22:12:50.572Z"}, - {url = "https://files.pythonhosted.org/packages/fb/e1/96526f3ea72667352e1a83dff3cbb8701dbfc830aafb3ac9dd532d4fb827/uv-0.12.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b6d4bd67b488ef2766cfa885947c1093c18caf5d665ba9156963ad0241f196e9", size = 22527940, upload-time = "2026-08-27T22:12:53.479Z"}, - {url = "https://files.pythonhosted.org/packages/39/7b/816e7c4ef1b59faf295d4eec133b15d6d3a227dc4bc341dceccc31a3ba15/uv-0.12.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:debeccc5eca0063cd922bc67caa4a8c0df5f69090179866ed17fa7264905bda2", size = 23630281, upload-time = "2026-08-27T22:12:55.941Z"}, - {url = "https://files.pythonhosted.org/packages/ac/63/03d00b74c5582163b8c544b2dc39396388d904a13118303fb2c32ece2811/uv-0.12.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:36c8f93d182b766b9ed4a9c1da5ec0f7dc9f934887df3404d996f66321fd18d5", size = 23345465, upload-time = "2026-08-27T22:12:58.726Z"}, - {url = "https://files.pythonhosted.org/packages/8a/01/616cc5f80952eef6fa48c5dbd6ea8aeed04a494e7dfbe9f5f65cfcf85ad9/uv-0.12.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4545e87c7ac64af317d8daffd279e23e93b0e05035662363033d3525923339d2", size = 20058968, upload-time = "2026-08-27T22:13:01.239Z"}, - {url = "https://files.pythonhosted.org/packages/66/23/051acc24b21bbf0e67bc00e2b4239892e37cf39fdd6428fc246a2ae62b79/uv-0.12.7-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:56a5730f8eff477501b3276a0059c2c2843302d5d4a6cc10f993a5cd66ddace8", size = 19450815, upload-time = "2026-08-27T22:13:03.636Z"}, - {url = "https://files.pythonhosted.org/packages/42/a4/a84adae83d6891401284ec4929ea7892c7efc5484cb53283147fbb4881b6/uv-0.12.7-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:d83419298e202f56e381cef6406b519b9336e58fc90a559617d86576a3d8a4e8", size = 22402981, upload-time = "2026-08-27T22:13:06.247Z"}, - {url = "https://files.pythonhosted.org/packages/8a/35/6fcdff1292ba1cc027d1d741531dfc4d19f92e1312262833beacf3b7fbe8/uv-0.12.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fc57436f2f012b885454f465dbf077f573745f0d4275a6a38194203b67e94ea4", size = 22535567, upload-time = "2026-08-27T22:13:08.802Z"}, - {url = "https://files.pythonhosted.org/packages/2b/ea/7ac04384642a4ba808b20a94cb09e0f4b4a6aef6cf9671858be0567c153a/uv-0.12.7-py3-none-musllinux_1_1_i686.whl", hash = "sha256:b2bd0f25f17f0000a2415347471e713cd1597f4525cd3412d17875b131f4b1ac", size = 21667314, upload-time = "2026-08-27T22:13:11.197Z"}, - {url = "https://files.pythonhosted.org/packages/e1/c8/348089faf45a3c24c1d5a5ee3957cd1c7fb4a6aef1d9646114a4b7cc9c7a/uv-0.12.7-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:ff33305718665c6fba25efdd260c67a6bd500c665e3d5d61059612791ca10c90", size = 22919105, upload-time = "2026-08-27T22:13:13.596Z"}, - {url = "https://files.pythonhosted.org/packages/3e/7f/5fb8ed68270666ed675daead854bde3b14d7ab42c2b7c0d234b5ea8ea928/uv-0.12.7-py3-none-win32.whl", hash = "sha256:3ac3321ccd6097dbef154d27044e0762a67b2f6eb017dcc65be6574f4671fb0d", size = 19880506, upload-time = "2026-08-27T22:13:16.072Z"}, - {url = "https://files.pythonhosted.org/packages/e7/97/40a91354862028e8f8e547ac27c6acd7b7cc67b9003acc28596d9e90cd6b/uv-0.12.7-py3-none-win_amd64.whl", hash = "sha256:277d326d7e63b912f3425c6e6d7d5d49f21b43d080d21859ff3c6819353f1847", size = 18085016, upload-time = "2026-08-27T22:13:18.352Z"}, - {url = "https://files.pythonhosted.org/packages/52/69/6cdd022fff6a8d8f5c9af2686f61f2605ddd03b5709efb73df6e08f40249/uv-0.12.7-py3-none-win_arm64.whl", hash = "sha256:95c3a4fa65e72bab3ca1b4c8ce18fbe784cf3137e9f9234588b69d09b341a4a7", size = 19522843, upload-time = "2026-08-27T22:13:20.633Z"} + {url = "https://files.pythonhosted.org/packages/94/d1/b3752f00e67e5bf141adec0225c62a17c1cf3fb524db463c541c080f1237/uv-0.12.10-py3-none-linux_armv6l.whl", hash = "sha256:efbbc586d40014b9dd3bc37a2ad5803d12919458f0f9d16f122db5d91626f5ab", size = 22182923, upload-time = "2026-09-04T23:13:31.502Z"}, + {url = "https://files.pythonhosted.org/packages/ab/4d/ef846ec0a4dfa8d4f47b96a87a1e331143be5a045c28c8b4b0374946455f/uv-0.12.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7e3a70d9dff95481afccd7d41e7ec42fdc230d53487b3e7e633f9b35610401b5", size = 20494379, upload-time = "2026-09-04T23:13:34.339Z"}, + {url = "https://files.pythonhosted.org/packages/04/2e/5614e9a940fe1bf291342410b2cde9fe35a1430135ae7434ea8fd67033f8/uv-0.12.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5dc26c73826d2119292d49d71c5a9d5ba9dd97dd02459d816b8226f47f3dc6bd", size = 17238363, upload-time = "2026-09-04T23:13:36.905Z"}, + {url = "https://files.pythonhosted.org/packages/d1/03/e71936935b8ac26390b280e102b9db823e90adb1134f7e226538054fdece/uv-0.12.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:bd0afae6918795c6a61a649e64d735e339f3d62f8310235da6f19eb3f66f323b", size = 21590025, upload-time = "2026-09-04T23:13:39.588Z"}, + {url = "https://files.pythonhosted.org/packages/b2/c0/c6388a722fb9db8adf8ec8eca762547b5a06ccbd1176571dc584636af5f1/uv-0.12.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:c1fd0fcabc18dccaf541ca44a061c245045c937bec75c0491e94d59cd33ca97f", size = 21677222, upload-time = "2026-09-04T23:13:42.218Z"}, + {url = "https://files.pythonhosted.org/packages/9d/c6/96698aed29f25ff289b28b6312c72e260c56f7e1de2e3f690788bddea19e/uv-0.12.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:de4b06934bd025ceeeb48af5c6f9a82407a2fe2b5b35e456cfb3198ef5029de0", size = 21712683, upload-time = "2026-09-04T23:13:44.927Z"}, + {url = "https://files.pythonhosted.org/packages/df/98/5871a2bff93bd6118ae877b874a09202d39e2831af37f56e2070d5c4bc87/uv-0.12.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b610e8cf7c6d51dc119d352e699b674534d0de327b4d2a67eb5d71ec364b8970", size = 22331081, upload-time = "2026-09-04T23:13:47.833Z"}, + {url = "https://files.pythonhosted.org/packages/3d/b6/d690f34901bdc5094645cf75a00381cfb8f1f28ee87b7089af761094bb6f/uv-0.12.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f43c06b19137bb05877b0ec59c4932878db5d1aeb6f7b5c33259c22e0359c4fe", size = 23785643, upload-time = "2026-09-04T23:13:51.111Z"}, + {url = "https://files.pythonhosted.org/packages/90/da/168a8c69f60b9a2c90afb0b9211911ca42a81300c098d545da911bd4ef8f/uv-0.12.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0334577671a65b621faddfdf5f9e9dd4eb08eaa7231c4405c4a7f4f17d224d9f", size = 23337759, upload-time = "2026-09-04T23:13:54.163Z"}, + {url = "https://files.pythonhosted.org/packages/f0/41/f0e14ba1f881f7126152dcaf04119b3a2bd7566ba1cac1d22834bd134cbf/uv-0.12.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7d6248ad9f2d282fea795f248da8fa666ab382df50d8385bd98e01049440a0c", size = 19978237, upload-time = "2026-09-04T23:13:56.994Z"}, + {url = "https://files.pythonhosted.org/packages/7a/20/acebfb5fc01b29cce5e68b4824fa451d5fb1e569bb2579bcc7233583a894/uv-0.12.10-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:5c0bcf0598742eb417f1aa41ec82f0d89bd7b7f42d0a519bea495a9419d8d8ee", size = 19402684, upload-time = "2026-09-04T23:13:59.579Z"}, + {url = "https://files.pythonhosted.org/packages/49/ee/c40dc8b7b143a6f7843994b1b8a13aa8859d8404973704cd6bad7a26ac8d/uv-0.12.10-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:0ac914e9750bb1de2518d444cc1cff084f4eff3eefe9cdd5814b775443a16df1", size = 22450726, upload-time = "2026-09-04T23:14:02.316Z"}, + {url = "https://files.pythonhosted.org/packages/c0/ec/b5df49f571b58e694ca8c3ddadd89939036ac0fe7ddaadc7600225ea8585/uv-0.12.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:46ca83d33bf87a67d0c5aa5b64f5576fc5c8e700a0ecf4900d409f8a1a1c9810", size = 22571602, upload-time = "2026-09-04T23:14:05.088Z"}, + {url = "https://files.pythonhosted.org/packages/4a/3e/f0335142e8a4c6ddc5117dcc9b5fd55c83cdd8e20a7f3be9584282dbfb64/uv-0.12.10-py3-none-musllinux_1_1_i686.whl", hash = "sha256:c49cd1f6e5ded1bd844119da8b184d5c044ae51441c476af2382fdb8af99edb8", size = 21499938, upload-time = "2026-09-04T23:14:08.109Z"}, + {url = "https://files.pythonhosted.org/packages/98/3e/991f08fcab1706175ae30eda6ef483223511679c56fe1032b919ca3ea391/uv-0.12.10-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:a34e1be296e6e30fdbfee9b84a45d082ce7df4dd4e588ebcfc44249505c10f02", size = 22879435, upload-time = "2026-09-04T23:14:11.1Z"}, + {url = "https://files.pythonhosted.org/packages/c4/80/8db01aabf9937cbc228003392ee9fe542c4dad5c3c5b26f2e1f136d8e463/uv-0.12.10-py3-none-win32.whl", hash = "sha256:f7f2bc56fbd367b1066e8461a82e51e8886bbab52b1f8d1793af8ce52232348c", size = 19873629, upload-time = "2026-09-04T23:14:13.755Z"}, + {url = "https://files.pythonhosted.org/packages/a6/88/e980985d93283c546374b6b9dfa486a28091c4c056226711e31bb0f722d1/uv-0.12.10-py3-none-win_amd64.whl", hash = "sha256:2ad71395c31b5db20c56327f62ee0299f75833308d4413284aba87324d092afa", size = 18084435, upload-time = "2026-09-04T23:14:16.323Z"}, + {url = "https://files.pythonhosted.org/packages/84/10/fa542546044f783060d09a624f9964b595c0a0713cf2d89ade69c6667d8b/uv-0.12.10-py3-none-win_arm64.whl", hash = "sha256:4f097c6b7f63eceb3faf5102009f9a8cc22fd7d8a341858d2fd540f46a592f7a", size = 19598033, upload-time = "2026-09-04T23:14:18.829Z"} ] [[package]] @@ -1039,9 +1039,9 @@ dependencies = [ {name = "python-discovery"} ] name = "virtualenv" -sdist = {url = "https://files.pythonhosted.org/packages/1d/60/fc54e876e34f94dd0cf0185aaecfd4bfa906653f003d9b2fb21428642fca/virtualenv-21.7.5.tar.gz", hash = "sha256:a73c4246fba3c8901ff9717399f466e00eeca5a3834981f1a6ebb4f1e94de2f8", size = 5346743, upload-time = "2026-08-25T05:39:16.14Z"} +sdist = {url = "https://files.pythonhosted.org/packages/08/1c/69faa2e6a83484e2a8227bce5cfaa183941c5720f99c48f204931d286b07/virtualenv-21.7.8.tar.gz", hash = "sha256:1dc49c790072a9072cb1803f9bd62aa69cd583077cada32390f75505cdc64c9b", size = 5347580, upload-time = "2026-09-01T13:36:13.894Z"} source = {registry = "https://pypi.org/simple"} -version = "21.7.5" +version = "21.7.8" wheels = [ - {url = "https://files.pythonhosted.org/packages/ca/d8/401141bf45637be916c86d325bd821c5838c7eff83294b934cd94e774e4f/virtualenv-21.7.5-py3-none-any.whl", hash = "sha256:e36ca889510ab6cb0b1dca93c59e5431dd4422a3c88f487358d470c90af8c07a", size = 5324697, upload-time = "2026-08-25T05:39:14.229Z"} + {url = "https://files.pythonhosted.org/packages/9f/34/88d507d4a4030fa559788de9c690a214f9a4053aa1d91cfb60e9b36127c2/virtualenv-21.7.8-py3-none-any.whl", hash = "sha256:3040eb3cbf5d32b10ffd57d167e6a162237ad82ba7d8cf1400a1efed593d85ac", size = 5324617, upload-time = "2026-09-01T13:36:11.248Z"} ] From 4409c38579f8dfb71da0253a0a51c8f0cc0f7f96 Mon Sep 17 00:00:00 2001 From: d9pouces Date: Wed, 9 Sep 2026 22:20:16 +0200 Subject: [PATCH 08/17] feat: update version to 2026.9.7 in pyproject.toml Signed-off-by: d9pouces --- id_configurator/config.py | 29 +++- id_configurator/modules/apk.py | 2 +- id_configurator/modules/apt.py | 62 ++++++-- library/id_configurator.py | 34 ++--- tests/configs/Apk.yaml.j2 | 4 +- tests/configs/Apt.yaml.j2 | 4 +- tests/configs/Certificate.yaml.j2 | 1 - tests/configs/Go.yaml.j2 | 1 - tests/configs/Java.yaml.j2 | 1 - tests/configs/Npm.yaml.j2 | 1 - tests/configs/NuGet.yaml.j2 | 1 - tests/configs/Php.yaml.j2 | 1 - tests/configs/Python.yaml.j2 | 1 - tests/configs/Ruby.yaml.j2 | 1 - tests/configs/Rust.yaml.j2 | 1 - tests/configs/Yum.yaml.j2 | 1 - tests/dockerfiles/Apt.Dockerfile | 2 + tests/dockerfiles/repos/apt-repo.Dockerfile | 17 ++- tests/test_apk.py | 3 +- tests/test_apt.py | 152 ++++++++++++++++++-- tests/test_config.py | 5 +- 21 files changed, 252 insertions(+), 72 deletions(-) diff --git a/id_configurator/config.py b/id_configurator/config.py index 2c2c153..766e575 100644 --- a/id_configurator/config.py +++ b/id_configurator/config.py @@ -27,7 +27,16 @@ def _yes_no(value: bool) -> str: class AptSource(BaseModel): - """A single APT source, in the DEB822 format (see sources.list(5)).""" + """A single APT source, in the DEB822 format (see sources.list(5)). + + Two ways to tell APT which key to trust this source with: + - `signed_by`: the ASCII-armored (.asc) *content* of a GPG public key. + The `apt` module dearmors it into a dedicated keyring and writes that + keyring's path as the rendered `Signed-By` field. + - `signed_by_file`: the *path* to a keyring already expected to exist on + the target system, written to the rendered `Signed-By` field verbatim. + If both are set, `signed_by` takes precedence. + """ model_config = ConfigDict(populate_by_name=True) @@ -42,6 +51,7 @@ class AptSource(BaseModel): enabled: bool = True trusted: bool | None = None signed_by: str | None = Field(default=None, alias="signed-by") + signed_by_file: str | None = Field(default=None, alias="signed-by-file") pdiffs: bool | None = None by_hash: bool | None = Field(default=None, alias="by-hash") allow_insecure: bool | None = Field(default=None, alias="allow-insecure") @@ -56,8 +66,15 @@ class AptSource(BaseModel): date_max_future: str | None = Field(default=None, alias="date-max-future") description: str | None = None - def to_deb822(self) -> str: - """Render this source as a DEB822 stanza (as used in /etc/apt/sources.list.d/*.sources).""" + def to_deb822(self, signed_by_path: str | None = None) -> str: + """Render this source as a DEB822 stanza (as used in + /etc/apt/sources.list.d/*.sources). + + `signed_by_path` is the value actually written as the `Signed-By` + field, if given - resolved by the caller (the `apt` module) from + `self.signed_by`/`self.signed_by_file`, neither of which is ever + rendered here directly. + """ fields = [ ("Types", " ".join(self.types)), ("URIs", " ".join(self.uris)), @@ -71,8 +88,8 @@ class AptSource(BaseModel): fields.append(("Languages", " ".join(self.languages))) if self.targets: fields.append(("Targets", " ".join(self.targets))) - if self.signed_by: - fields.append(("Signed-By", self.signed_by)) + if signed_by_path: + fields.append(("Signed-By", signed_by_path)) if self.trusted is not None: fields.append(("Trusted", _yes_no(self.trusted))) if self.pdiffs is not None: @@ -108,12 +125,12 @@ class AptSource(BaseModel): class AptConfig(BaseModel): sources: list[AptSource] = Field(default_factory=list) - 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.""" + name: str url: str key: str | None = None diff --git a/id_configurator/modules/apk.py b/id_configurator/modules/apk.py index 3510228..2469db9 100644 --- a/id_configurator/modules/apk.py +++ b/id_configurator/modules/apk.py @@ -71,7 +71,7 @@ class ApkModule(Module): lines = [] for repository in context.config.configurations.apk.repositories: lines.append(repository.url) - logger.info("added apk repository %s", repository.url) + logger.info("added apk repository %s", repository.name) if repository.key: filename = _key_filename(repository.key.encode("utf-8")) context.write_file( diff --git a/id_configurator/modules/apt.py b/id_configurator/modules/apt.py index 102af63..40ac11f 100644 --- a/id_configurator/modules/apt.py +++ b/id_configurator/modules/apt.py @@ -5,13 +5,29 @@ one-line `.list` files or DEB822 `.sources` files, whether at the top level or under `sources.list.d/`) is copied into `/etc/apt`, disabled so it no longer takes effect, and the sources declared in the configuration are added as new, enabled `.sources` files. + +Each source can be trusted two ways: `signed_by` holds the ASCII-armored +content of a GPG public key, which is converted with `gpg --dearmor` into a +binary keyring dedicated to this one source, written to +`/etc/apt/keyrings/.gpg` - it is that path, not the key's own content, +that ends up in the rendered `.sources` file's `Signed-By` field. +`signed_by_file` instead names a keyring already expected to exist on the +target system, used verbatim as `Signed-By` (no file is written for it); if +both are given, `signed_by` takes precedence. `gpg` is only used here for +the armored-to-binary format conversion; the actual signature verification +of the repository itself is left to APT when it fetches the repository's +Release/InRelease file. """ from __future__ import annotations import logging +import shutil +import subprocess +import tempfile from pathlib import Path +from id_configurator.config import AptSource from id_configurator.context import Context from id_configurator.modules.base import Module, register @@ -43,6 +59,28 @@ def _disable_deb822_content(content: str) -> str: return "\n\n".join(disabled_stanzas) + "\n" +def _dearmor_gpg_key(name: str, content: bytes) -> bytes: + """Convert an ASCII-armored GPG public key into apt's binary keyring format.""" + with tempfile.TemporaryDirectory() as gnupg_home: + path = shutil.which("gpg") + if path is not None: + result = subprocess.run( + [path, "--homedir", gnupg_home, "--batch", "--dearmor"], + 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 for apt source {name}: {result.stderr.decode('utf-8', 'replace')}" + ) + return result.stdout + + @register class AptModule(Module): name = "apt" @@ -59,7 +97,6 @@ class AptModule(Module): def apply(self, context: Context) -> None: self._copy_existing_disabled(context) - self._write_configured_keys(context) self._write_configured_sources(context) @staticmethod @@ -82,19 +119,22 @@ class AptModule(Module): ) logger.info("disabled existing apt source %s", entry.name) - @staticmethod - def _write_configured_keys(context: Context) -> None: - for key in context.config.configurations.apt.keys: - context.write_file( - f"{_KEYRINGS_DIR}/{key.name}.asc", key.content.encode("utf-8") - ) - logger.info("installed apt keyring %s", key.name) + def _resolve_signed_by(self, context: Context, source: AptSource) -> str | None: + if source.signed_by: + keyring = _dearmor_gpg_key(source.name, source.signed_by.encode("utf-8")) + relative = f"{_KEYRINGS_DIR}/{source.name}.gpg" + context.write_file(relative, keyring) + logger.info("installed apt keyring for source %s", source.name) + return f"/{relative}" + if source.signed_by_file: + return source.signed_by_file + return None - @staticmethod - def _write_configured_sources(context: Context) -> None: + def _write_configured_sources(self, context: Context) -> None: for source in context.config.configurations.apt.sources: + signed_by_path = self._resolve_signed_by(context, source) context.write_file( f"{_SOURCES_LIST_D}/{source.name}.sources", - source.to_deb822().encode("utf-8"), + source.to_deb822(signed_by_path).encode("utf-8"), ) logger.info("added apt source %s", source.name) diff --git a/library/id_configurator.py b/library/id_configurator.py index 93dc657..95ad49e 100644 --- a/library/id_configurator.py +++ b/library/id_configurator.py @@ -123,7 +123,18 @@ options: description: DEB822 "Trusted" field. signed_by: type: str - description: DEB822 "Signed-By" field. + description: + - ASCII-armored content of a GPG public key to trust this + source with. The `apt` module dearmors it into a dedicated + keyring and writes that keyring's path as the rendered + Signed-By field. Takes precedence over signed_by_file if + both are set. + signed_by_file: + type: str + description: + - Path to a keyring already expected to exist on the target + system, used verbatim as the rendered Signed-By field. + Ignored if signed_by is also set. pdiffs: type: bool description: DEB822 "PDiffs" field. @@ -157,20 +168,6 @@ options: description: type: str description: DEB822 "Description" field. - keys: - type: list - elements: dict - default: [] - description: GPG keys trusted for the sources above (NamedContent). - suboptions: - name: - type: str - required: true - description: A short, unique name for this entry. - content: - type: str - required: true - description: PEM-encoded/ASCII-armored key content. apk: type: dict default: {} @@ -483,6 +480,7 @@ ARGUMENT_SPEC = { "enabled": {"type": "bool", "default": True}, "trusted": {"type": "bool"}, "signed_by": {"type": "str"}, + "signed_by_file": {"type": "str"}, "pdiffs": {"type": "bool"}, "by_hash": {"type": "bool"}, "allow_insecure": {"type": "bool"}, @@ -496,12 +494,6 @@ ARGUMENT_SPEC = { "description": {"type": "str"}, }, }, - "keys": { - "type": "list", - "elements": "dict", - "default": [], - "options": dict(_NAMED_CONTENT_OPTIONS), - }, }, }, "apk": { diff --git a/tests/configs/Apk.yaml.j2 b/tests/configs/Apk.yaml.j2 index 9cb96ef..9aa3480 100644 --- a/tests/configs/Apk.yaml.j2 +++ b/tests/configs/Apk.yaml.j2 @@ -8,10 +8,10 @@ configurations: {{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }} apt: sources: [] - keys: [] apk: repositories: - - url: https://host.docker.internal:{{ 'APK_REPO_PORT' | env }}/ + - name: demo + url: https://host.docker.internal:{{ 'APK_REPO_PORT' | env }}/ key: | {{ 'dockerfiles/repos/040d0af5f97a0a44.rsa.pub' | file | indent(8, first=true) }} yum: diff --git a/tests/configs/Apt.yaml.j2 b/tests/configs/Apt.yaml.j2 index 7642da6..0802a3b 100644 --- a/tests/configs/Apt.yaml.j2 +++ b/tests/configs/Apt.yaml.j2 @@ -13,8 +13,8 @@ configurations: uris: - https://host.docker.internal:{{ 'APT_REPO_PORT' | env }}/ suites: ["./"] - trusted: true - keys: [] + signed-by: | +{{ 'dockerfiles/repos/apt-signing-key.pub.asc' | file | indent(8, first=true) }} apk: repositories: [] yum: diff --git a/tests/configs/Certificate.yaml.j2 b/tests/configs/Certificate.yaml.j2 index cac01ea..93ec06d 100644 --- a/tests/configs/Certificate.yaml.j2 +++ b/tests/configs/Certificate.yaml.j2 @@ -8,7 +8,6 @@ configurations: {{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }} apt: sources: [] - keys: [] apk: repositories: [] yum: diff --git a/tests/configs/Go.yaml.j2 b/tests/configs/Go.yaml.j2 index 8fff407..2f7ce8b 100644 --- a/tests/configs/Go.yaml.j2 +++ b/tests/configs/Go.yaml.j2 @@ -8,7 +8,6 @@ configurations: {{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }} apt: sources: [] - keys: [] apk: repositories: [] yum: diff --git a/tests/configs/Java.yaml.j2 b/tests/configs/Java.yaml.j2 index 435fff5..532ffc4 100644 --- a/tests/configs/Java.yaml.j2 +++ b/tests/configs/Java.yaml.j2 @@ -8,7 +8,6 @@ configurations: {{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }} apt: sources: [] - keys: [] apk: repositories: [] yum: diff --git a/tests/configs/Npm.yaml.j2 b/tests/configs/Npm.yaml.j2 index 5c34770..349ec0e 100644 --- a/tests/configs/Npm.yaml.j2 +++ b/tests/configs/Npm.yaml.j2 @@ -8,7 +8,6 @@ configurations: {{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }} apt: sources: [] - keys: [] apk: repositories: [] yum: diff --git a/tests/configs/NuGet.yaml.j2 b/tests/configs/NuGet.yaml.j2 index 5c6cb17..5fd3287 100644 --- a/tests/configs/NuGet.yaml.j2 +++ b/tests/configs/NuGet.yaml.j2 @@ -8,7 +8,6 @@ configurations: {{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }} apt: sources: [] - keys: [] apk: repositories: [] yum: diff --git a/tests/configs/Php.yaml.j2 b/tests/configs/Php.yaml.j2 index 5439afd..11c1a0c 100644 --- a/tests/configs/Php.yaml.j2 +++ b/tests/configs/Php.yaml.j2 @@ -8,7 +8,6 @@ configurations: {{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }} apt: sources: [] - keys: [] apk: repositories: [] yum: diff --git a/tests/configs/Python.yaml.j2 b/tests/configs/Python.yaml.j2 index 037f742..749151e 100644 --- a/tests/configs/Python.yaml.j2 +++ b/tests/configs/Python.yaml.j2 @@ -8,7 +8,6 @@ configurations: {{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }} apt: sources: [] - keys: [] apk: repositories: [] yum: diff --git a/tests/configs/Ruby.yaml.j2 b/tests/configs/Ruby.yaml.j2 index a9b5a8c..ccb6447 100644 --- a/tests/configs/Ruby.yaml.j2 +++ b/tests/configs/Ruby.yaml.j2 @@ -8,7 +8,6 @@ configurations: {{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }} apt: sources: [] - keys: [] apk: repositories: [] yum: diff --git a/tests/configs/Rust.yaml.j2 b/tests/configs/Rust.yaml.j2 index 4910f88..40ce080 100644 --- a/tests/configs/Rust.yaml.j2 +++ b/tests/configs/Rust.yaml.j2 @@ -8,7 +8,6 @@ configurations: {{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }} apt: sources: [] - keys: [] apk: repositories: [] yum: diff --git a/tests/configs/Yum.yaml.j2 b/tests/configs/Yum.yaml.j2 index c108b93..a3ffacf 100644 --- a/tests/configs/Yum.yaml.j2 +++ b/tests/configs/Yum.yaml.j2 @@ -8,7 +8,6 @@ configurations: {{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }} apt: sources: [] - keys: [] apk: repositories: [] yum: diff --git a/tests/dockerfiles/Apt.Dockerfile b/tests/dockerfiles/Apt.Dockerfile index 06bb4ac..860d578 100644 --- a/tests/dockerfiles/Apt.Dockerfile +++ b/tests/dockerfiles/Apt.Dockerfile @@ -1,5 +1,7 @@ FROM debian:trixie-slim AS target FROM interdiode/id-configurator:latest AS configuration +RUN apt-get update && apt-get install -y --no-install-recommends gnupg \ + && rm -rf /var/lib/apt/lists/* COPY --from=target / /src/ COPY --from=generated-config Apt.yaml /config.yaml RUN id-configurator apply --config /config.yaml --src /src --dst /dst diff --git a/tests/dockerfiles/repos/apt-repo.Dockerfile b/tests/dockerfiles/repos/apt-repo.Dockerfile index 7737354..77db80a 100644 --- a/tests/dockerfiles/repos/apt-repo.Dockerfile +++ b/tests/dockerfiles/repos/apt-repo.Dockerfile @@ -1,12 +1,18 @@ # Minimal flat-format APT repository serving one hand-built .deb package over # TLS, used by tox-docker to exercise the `apt` module integration test -# (tests/configs/Apt.yaml.tmpl, tests/dockerfiles/Apt.Dockerfile). +# (tests/configs/Apt.yaml.j2, tests/dockerfiles/Apt.Dockerfile). Its +# InRelease file is genuinely signed with the dedicated key pair +# apt-signing-key.key.asc / apt-signing-key.pub.asc, whose public half is +# what tests/configs/Apt.yaml.j2 embeds as the configured source's +# `signed-by`: the `apt` module dearmors it into a keyring, and APT itself +# verifies the InRelease signature against that keyring on `apt-get update` +# - no `Trusted: yes` bypass involved. # # Built by tox-docker, whose build context is this directory (not the repo # root) - every COPY source below must live under tests/dockerfiles/repos/. FROM debian:trixie-slim AS packagebuild RUN apt-get update \ - && apt-get install -y --no-install-recommends dpkg-dev \ + && apt-get install -y --no-install-recommends dpkg-dev apt-utils gnupg \ && rm -rf /var/lib/apt/lists/* RUN set -eux; \ pkg=idconfiguratortestpkg; \ @@ -18,6 +24,13 @@ RUN set -eux; \ WORKDIR /srv RUN dpkg-scanpackages --multiversion . > Packages \ && gzip -9c Packages > Packages.gz +COPY apt-signing-key.key.asc /tmp/apt-signing-key.key.asc +RUN gnupg_home="$(mktemp -d)" \ + && gpg --homedir "$gnupg_home" --batch --import /tmp/apt-signing-key.key.asc \ + && key_id="$(gpg --homedir "$gnupg_home" --list-secret-keys --with-colons | awk -F: '/^sec/{print $5; exit}')" \ + && apt-ftparchive release . > Release \ + && gpg --homedir "$gnupg_home" --batch --yes --pinentry-mode loopback --default-key "$key_id" --clearsign -o InRelease Release \ + && rm -rf "$gnupg_home" /tmp/apt-signing-key.key.asc FROM nginx:alpine COPY --from=packagebuild /srv /srv diff --git a/tests/test_apk.py b/tests/test_apk.py index e23ef52..2a191cf 100644 --- a/tests/test_apk.py +++ b/tests/test_apk.py @@ -14,10 +14,11 @@ CONFIG_DATA = { "apk": { "repositories": [ { + "name": "main", "url": "https://example.invalid/v3.19/main", "key": KEY_BYTES, }, - {"url": "https://example.invalid/v3.19/community"}, + {"name": "community", "url": "https://example.invalid/v3.19/community"}, ] } }, diff --git a/tests/test_apt.py b/tests/test_apt.py index 92f5093..e51f8be 100644 --- a/tests/test_apt.py +++ b/tests/test_apt.py @@ -1,7 +1,15 @@ +import subprocess +from unittest.mock import MagicMock + +import pytest + from id_configurator.config import Configuration from id_configurator.context import Context from id_configurator.modules.apt import AptModule +KEY_CONTENT = "fake-armored-gpg-key" +KEYRING_BYTES = b"fake-dearmored-keyring-bytes" + SOURCE_CONTENT = { "uuid": "12345678-1234-5678-1234-567812345678", "organisation": "InterDiode", @@ -12,13 +20,7 @@ SOURCE_CONTENT = { "name": "interdiode", "uris": ["http://example.invalid/debian"], "suites": ["stable"], - "signed-by": "/etc/apt/keyrings/interdiode.asc", - } - ], - "keys": [ - { - "name": "interdiode", - "content": "fake-key-bytes", + "signed-by": KEY_CONTENT, } ], } @@ -30,6 +32,36 @@ def make_config() -> Configuration: return Configuration.model_validate(SOURCE_CONTENT) +def make_config_with(source_extra: dict) -> Configuration: + data = { + "uuid": "12345678-1234-5678-1234-567812345678", + "organisation": "InterDiode", + "configurations": { + "apt": { + "sources": [ + { + "name": "interdiode", + "uris": ["http://example.invalid/debian"], + "suites": ["stable"], + **source_extra, + } + ], + } + }, + } + return Configuration.model_validate(data) + + +def _mock_gpg_dearmor_success(monkeypatch, output: bytes = KEYRING_BYTES) -> MagicMock: + mock_run = MagicMock( + return_value=subprocess.CompletedProcess( + args=[], returncode=0, stdout=output, stderr=b"" + ) + ) + monkeypatch.setattr("id_configurator.modules.apt.subprocess.run", mock_run) + return mock_run + + 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 @@ -49,7 +81,10 @@ def test_is_relevant_true_with_apt_dir(tmp_path) -> None: assert AptModule().is_relevant(context) is True -def test_apply_disables_existing_sources_and_adds_configured_ones(tmp_path) -> None: +def test_apply_disables_existing_sources_and_adds_configured_ones( + tmp_path, monkeypatch +) -> None: + mock_run = _mock_gpg_dearmor_success(monkeypatch) src = tmp_path / "src" apt_dir = src / "etc/apt" sources_list_d = apt_dir / "sources.list.d" @@ -98,19 +133,22 @@ def test_apply_disables_existing_sources_and_adds_configured_ones(tmp_path) -> N 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" + keyring = (dst / "etc/apt/keyrings/interdiode.gpg").read_bytes() + assert keyring == KEYRING_BYTES + mock_run.assert_called_once() + assert mock_run.call_args.kwargs["input"] == KEY_CONTENT.encode("utf-8") configured_source = (dst / "etc/apt/sources.list.d/interdiode.sources").read_text( encoding="utf-8" ) assert configured_source == ( "Types: deb\nURIs: http://example.invalid/debian\nSuites: stable\n" - "Signed-By: /etc/apt/keyrings/interdiode.asc\nEnabled: yes\n" + "Signed-By: /etc/apt/keyrings/interdiode.gpg\nEnabled: yes\n" ) -def test_apply_without_existing_top_level_sources_list(tmp_path) -> None: +def test_apply_without_existing_top_level_sources_list(tmp_path, monkeypatch) -> None: + _mock_gpg_dearmor_success(monkeypatch) src = tmp_path / "src" (src / "etc/apt").mkdir(parents=True) dst = tmp_path / "dst" @@ -120,3 +158,93 @@ def test_apply_without_existing_top_level_sources_list(tmp_path) -> None: assert not (dst / "etc/apt/sources.list").exists() assert (dst / "etc/apt/sources.list.d/interdiode.sources").exists() + + +def test_apply_uses_signed_by_file_when_no_key_content(tmp_path, monkeypatch) -> None: + mock_run = _mock_gpg_dearmor_success(monkeypatch) + dst = tmp_path / "dst" + context = Context( + config=make_config_with({"signed-by-file": "/etc/apt/keyrings/existing.gpg"}), + dst=dst, + src=None, + ) + + AptModule().apply(context) + + mock_run.assert_not_called() + assert not (dst / "etc/apt/keyrings").exists() + configured_source = (dst / "etc/apt/sources.list.d/interdiode.sources").read_text( + encoding="utf-8" + ) + assert configured_source == ( + "Types: deb\nURIs: http://example.invalid/debian\nSuites: stable\n" + "Signed-By: /etc/apt/keyrings/existing.gpg\nEnabled: yes\n" + ) + + +def test_apply_signed_by_takes_precedence_over_signed_by_file( + tmp_path, monkeypatch +) -> None: + mock_run = _mock_gpg_dearmor_success(monkeypatch) + dst = tmp_path / "dst" + context = Context( + config=make_config_with( + { + "signed-by": KEY_CONTENT, + "signed-by-file": "/etc/apt/keyrings/existing.gpg", + } + ), + dst=dst, + src=None, + ) + + AptModule().apply(context) + + mock_run.assert_called_once() + assert (dst / "etc/apt/keyrings/interdiode.gpg").read_bytes() == KEYRING_BYTES + configured_source = (dst / "etc/apt/sources.list.d/interdiode.sources").read_text( + encoding="utf-8" + ) + assert "Signed-By: /etc/apt/keyrings/interdiode.gpg\n" in configured_source + + +def test_apply_without_signed_by_writes_no_keyring_or_field( + tmp_path, monkeypatch +) -> None: + mock_run = _mock_gpg_dearmor_success(monkeypatch) + dst = tmp_path / "dst" + context = Context(config=make_config_with({}), dst=dst, src=None) + + AptModule().apply(context) + + mock_run.assert_not_called() + assert not (dst / "etc/apt/keyrings").exists() + configured_source = (dst / "etc/apt/sources.list.d/interdiode.sources").read_text( + encoding="utf-8" + ) + assert configured_source == ( + "Types: deb\nURIs: http://example.invalid/debian\nSuites: stable\nEnabled: yes\n" + ) + + +def test_resolve_signed_by_raises_when_gpg_not_on_path(tmp_path, monkeypatch) -> None: + monkeypatch.setattr("id_configurator.modules.apt.shutil.which", lambda _: None) + dst = tmp_path / "dst" + context = Context(config=make_config(), dst=dst, src=None) + + with pytest.raises(RuntimeError, match="gpg is not available"): + AptModule().apply(context) + + +def test_resolve_signed_by_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.apt.subprocess.run", mock_run) + dst = tmp_path / "dst" + context = Context(config=make_config(), dst=dst, src=None) + + with pytest.raises(RuntimeError, match="invalid GPG key for apt source interdiode"): + AptModule().apply(context) diff --git a/tests/test_config.py b/tests/test_config.py index d30b748..11f0ba8 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -34,7 +34,6 @@ FULL_CONFIG = { "signed-by": "/etc/apt/keyrings/interdiode.asc", } ], - "keys": [{"name": "interdiode", "content": "ZmFrZQ=="}], }, "yum": { "repositories": [ @@ -76,7 +75,6 @@ def test_parse_configuration_from_json() -> None: configuration.configurations.apt.sources[0].signed_by == "/etc/apt/keyrings/interdiode.asc" ) - assert configuration.configurations.apt.keys[0].name == "interdiode" assert configuration.configurations.yum.keys[0].name == "interdiode" @@ -191,7 +189,6 @@ def test_apt_source_to_deb822_full() -> None: enabled=False, trusted=True, **{ - "signed-by": "/etc/apt/keyrings/interdiode.asc", "by-hash": True, "allow-insecure": False, "allow-weak": False, @@ -205,7 +202,7 @@ def test_apt_source_to_deb822_full() -> None: pdiffs=True, description="InterDiode APT repository", ) - rendered = source.to_deb822() + rendered = source.to_deb822(signed_by_path="/etc/apt/keyrings/interdiode.asc") assert rendered.splitlines() == [ "Types: deb deb-src", "URIs: http://example.invalid/debian", From be914253552a79481b298f1218b80a15bd88473f Mon Sep 17 00:00:00 2001 From: d9pouces Date: Wed, 9 Sep 2026 23:18:24 +0200 Subject: [PATCH 09/17] feat: sanitize filenames in apt and yum modules to prevent path traversal Signed-off-by: d9pouces --- id_configurator/modules/apt.py | 6 ++-- id_configurator/modules/base.py | 15 +++++++++ id_configurator/modules/certificates.py | 8 ++--- id_configurator/modules/yum.py | 7 ++-- .../dockerfiles/repos/apt-signing-key.key.asc | 33 +++++++++++++++++++ .../dockerfiles/repos/apt-signing-key.pub.asc | 19 +++++++++++ tests/test_apt.py | 24 ++++++++++++-- tests/test_certificates.py | 21 ++++++++++++ tests/test_modules_base.py | 19 ++++++++++- tests/test_yum.py | 30 +++++++++++++++++ 10 files changed, 169 insertions(+), 13 deletions(-) create mode 100644 tests/dockerfiles/repos/apt-signing-key.key.asc create mode 100644 tests/dockerfiles/repos/apt-signing-key.pub.asc diff --git a/id_configurator/modules/apt.py b/id_configurator/modules/apt.py index 40ac11f..8f6167a 100644 --- a/id_configurator/modules/apt.py +++ b/id_configurator/modules/apt.py @@ -29,7 +29,7 @@ from pathlib import Path from id_configurator.config import AptSource from id_configurator.context import Context -from id_configurator.modules.base import Module, register +from id_configurator.modules.base import Module, register, sanitize_filename logger = logging.getLogger(__name__) @@ -122,7 +122,7 @@ class AptModule(Module): def _resolve_signed_by(self, context: Context, source: AptSource) -> str | None: if source.signed_by: keyring = _dearmor_gpg_key(source.name, source.signed_by.encode("utf-8")) - relative = f"{_KEYRINGS_DIR}/{source.name}.gpg" + relative = f"{_KEYRINGS_DIR}/{sanitize_filename(source.name)}.gpg" context.write_file(relative, keyring) logger.info("installed apt keyring for source %s", source.name) return f"/{relative}" @@ -134,7 +134,7 @@ class AptModule(Module): for source in context.config.configurations.apt.sources: signed_by_path = self._resolve_signed_by(context, source) context.write_file( - f"{_SOURCES_LIST_D}/{source.name}.sources", + f"{_SOURCES_LIST_D}/{sanitize_filename(source.name)}.sources", source.to_deb822(signed_by_path).encode("utf-8"), ) logger.info("added apt source %s", source.name) diff --git a/id_configurator/modules/base.py b/id_configurator/modules/base.py index 1d9055a..4a13bde 100644 --- a/id_configurator/modules/base.py +++ b/id_configurator/modules/base.py @@ -34,3 +34,18 @@ def register(module_cls: type[Module]) -> type[Module]: def all_modules() -> list[type[Module]]: return list(_REGISTRY) + + +def sanitize_filename(name: str) -> str: + """Make a user-supplied name safe to use as a single filesystem path + component. + + Modules that derive a file's name from configuration data (a + certificate's name, a repository's name, ...) must run it through this + first: without it, a name containing `/` (or `\\`, harmless on the + target systems this tool writes to but stripped for good measure) + would silently turn into extra, unintended path segments - creating + nested directories a careless or malicious name shouldn't be able to + create - instead of the single file the caller asked for. + """ + return name.replace("/", "_").replace("\\", "_") diff --git a/id_configurator/modules/certificates.py b/id_configurator/modules/certificates.py index 8ecf64a..7db4607 100644 --- a/id_configurator/modules/certificates.py +++ b/id_configurator/modules/certificates.py @@ -49,7 +49,7 @@ import posixpath from pathlib import Path from id_configurator.context import Context -from id_configurator.modules.base import Module, register +from id_configurator.modules.base import Module, register, sanitize_filename logger = logging.getLogger(__name__) @@ -142,7 +142,7 @@ class CertificatesModule(Module): def apply(self, context: Context) -> None: configured_names = { - certificate.name + sanitize_filename(certificate.name) for certificate in context.config.configurations.certificates } for existing in context.iter_src_dir(_LOCAL_CERTS_DIR): @@ -152,7 +152,7 @@ class CertificatesModule(Module): ) for certificate in context.config.configurations.certificates: context.write_file( - f"{_LOCAL_CERTS_DIR}/{certificate.name}.crt", + f"{_LOCAL_CERTS_DIR}/{sanitize_filename(certificate.name)}.crt", certificate.content.encode(), ) logger.info("installed root certificate %s", certificate.name) @@ -237,7 +237,7 @@ class CertificatesModule(Module): ) for certificate in context.config.configurations.certificates: context.write_file( - f"{_RHEL_ANCHORS_DIR}/{certificate.name}.crt", + f"{_RHEL_ANCHORS_DIR}/{sanitize_filename(certificate.name)}.crt", certificate.content.encode(), ) diff --git a/id_configurator/modules/yum.py b/id_configurator/modules/yum.py index 29d5666..d0caa7a 100644 --- a/id_configurator/modules/yum.py +++ b/id_configurator/modules/yum.py @@ -30,7 +30,7 @@ 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 +from id_configurator.modules.base import Module, register, sanitize_filename logger = logging.getLogger(__name__) @@ -71,7 +71,7 @@ def _write_repo_file(context: Context, repository: YumRepository) -> None: else: lines.append("gpgcheck=0") context.write_file( - f"{_REPOS_DIR}/{repository.name}.repo", + f"{_REPOS_DIR}/{sanitize_filename(repository.name)}.repo", ("\n".join(lines) + "\n").encode("utf-8"), ) logger.info("added yum/dnf repository %s", repository.name) @@ -100,7 +100,8 @@ def _validate_gpg_key(name: str, content: bytes) -> None: def _import_key(context: Context, key: NamedContent) -> None: _validate_gpg_key(key.name, key.content.encode("utf-8")) context.write_file( - f"{_RPM_GPG_DIR}/RPM-GPG-KEY-{key.name}", key.content.encode("utf-8") + f"{_RPM_GPG_DIR}/RPM-GPG-KEY-{sanitize_filename(key.name)}", + key.content.encode("utf-8"), ) logger.info("imported yum/dnf GPG key %s", key.name) diff --git a/tests/dockerfiles/repos/apt-signing-key.key.asc b/tests/dockerfiles/repos/apt-signing-key.key.asc new file mode 100644 index 0000000..b91e21a --- /dev/null +++ b/tests/dockerfiles/repos/apt-signing-key.key.asc @@ -0,0 +1,33 @@ +-----BEGIN PGP PRIVATE KEY BLOCK----- + +lQOYBGqg6rMBCADaMUd27S41+kInPXuRZ0aL41aMZjqr+mw4W34w8i/JRYMJ6eGb +sXf40AaFfS7zkbUpQZpmh3aYPF4jUAdWV03ZNNYdcGerIAKc2xsugSKjJIm1XPQq +upz914b4/hp/UslDUH7SPZ+3Tq17TXW+xP6aUM9VcWMnlJT/DFgxh5sZB3nfDeYr +yBV+PmEVfuQaUSIwKAh3Hv0ZaBHS8KzsawLzbJ+Pa9W9wjVME1izi9ctloGSxtLc +p1suS2IS33QB0jGgj4VbuMcjo2iCpzW3Xd7jQ7WpXGjrl0bHnJTnWWB0ihDD3cJe +tJlk9GScMQu3Z3th67DmqTXQru3btrDjxMifABEBAAEAB/sG3JffwJO8dNNRwpg5 +OWM5kPuuzKQ5tlVi7FWqmmMwVDRLwoQqzkj0lS4vUoGAfWuSEZMWeFodbYCUYbbp +WzOCiivVnyxTcF1Eq4tOShdZxBNklbjEAzxeq0F+YRdaO7NCn6xtVizIqfvAre3v +Aujy12m9ySQSIZUR9ERtwlL5VXj4yzcXciyM9aNbh2D4BFAoo+fBL1B5mhpKQ16B +BafkpOf0myWZrwLdcn1t7BQ6UcLqsPJj6COCXXRDHoZvim38UBMMajNP4+8eC2Vk +DDR6iFz53FRvIREQWkoEEW4P80v1lgcnP1apJGhkrJYCPM7ObZk0MoQkBiBdlPPQ +VLiRBADhNsKjNd7v3Af5XGPJGWWZxCMxPlgwc6EMjCX3SQDql042eSIqwn1FFP6H +Hi8lyq/XWz0JcUwcrpFxpIC3OLUsBleX3Z4E17Z6AotSV0FC6y23cqgtweMeLA0L +tayMRmXJ7Aan+xfvi8MbW6lYGzM2uYHl27rVBuCt0kvxn+U4lwQA+ATO8KaBreCL +E/vXGZaPWJs2GoiDBavKwqdYtlXDKlVP5XvBgaPfY6zKLtCsC6N+DKkVo42Stwu3 +oB6HSSLvXvFIhvUReh5PLpijmOroAJzvLQN9qmZBvF4IOUgaHmlYlL0DGzlxcwLr +SdzqPaGRa+EgraFDDGGu5oQ5A2wNKTkEAMDS2w5SibOfsvnfK4aDc0mi8/fftzCO +SwsvVzR0SmbXtaCPBnBjlA7YJhRZ6n/7nDpswrWPk8g125A0vK1cNRGM0ufKYW1f +DMbB/aYrJjAKz8zWH6cgOD4fqM2gA9JounkIcJvWOToGume8pXhmem94u2FP8aik +A6hL/Os+WRQcMNi0NWlkLWNvbmZpZ3VyYXRvci10ZXN0cyA8dGVzdHNAaWQtY29u +ZmlndXJhdG9yLmludmFsaWQ+iQFuBBMBCABYFiEEaZF4ALsB1xUU3IP5rl3Himib +1RgFAmqg6rMbFIAAAAAABAAObWFudTIsMi41KzEuMTIsMCwzAxsvBAULCQgHAgIi +AgYVCgkICwIEFgIDAQIeBwIXgAAKCRCuXceKaJvVGFRrB/wIbG1HHQANDCHgyl4T +RTPuKBX2c9lmJi2plp+T5L5sXyTMbAMLfTCkJWL/zHLM3N2v6GZuzbwZM8Dzj9zL +LMpC6newmZe4YX+ZtyZGufBI963kh8PvlSWWUSyH+oYoIhlyJqj7tCQXoijEvQnS +ReKGHUTvQGvQK2Ybp735NmAiDOM3/3KKh2Dqas/Tc2mxec4whr8RyUidsF+Yp6wT +esfMxeax6DmWIcSLDWpUBndeahZ2Ff0c96BzbpF1v2hbvyI/IaP5snb4Nm1xpREm ++y8iDAQhOklfXCyXrWgN3sIrk3tDndGrn+nw0iusnaBVki0keqjlH0camoUP28tu +3sxG +=TrE4 +-----END PGP PRIVATE KEY BLOCK----- diff --git a/tests/dockerfiles/repos/apt-signing-key.pub.asc b/tests/dockerfiles/repos/apt-signing-key.pub.asc new file mode 100644 index 0000000..610a719 --- /dev/null +++ b/tests/dockerfiles/repos/apt-signing-key.pub.asc @@ -0,0 +1,19 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQENBGqg6rMBCADaMUd27S41+kInPXuRZ0aL41aMZjqr+mw4W34w8i/JRYMJ6eGb +sXf40AaFfS7zkbUpQZpmh3aYPF4jUAdWV03ZNNYdcGerIAKc2xsugSKjJIm1XPQq +upz914b4/hp/UslDUH7SPZ+3Tq17TXW+xP6aUM9VcWMnlJT/DFgxh5sZB3nfDeYr +yBV+PmEVfuQaUSIwKAh3Hv0ZaBHS8KzsawLzbJ+Pa9W9wjVME1izi9ctloGSxtLc +p1suS2IS33QB0jGgj4VbuMcjo2iCpzW3Xd7jQ7WpXGjrl0bHnJTnWWB0ihDD3cJe +tJlk9GScMQu3Z3th67DmqTXQru3btrDjxMifABEBAAG0NWlkLWNvbmZpZ3VyYXRv +ci10ZXN0cyA8dGVzdHNAaWQtY29uZmlndXJhdG9yLmludmFsaWQ+iQFuBBMBCABY +FiEEaZF4ALsB1xUU3IP5rl3Himib1RgFAmqg6rMbFIAAAAAABAAObWFudTIsMi41 +KzEuMTIsMCwzAxsvBAULCQgHAgIiAgYVCgkICwIEFgIDAQIeBwIXgAAKCRCuXceK +aJvVGFRrB/wIbG1HHQANDCHgyl4TRTPuKBX2c9lmJi2plp+T5L5sXyTMbAMLfTCk +JWL/zHLM3N2v6GZuzbwZM8Dzj9zLLMpC6newmZe4YX+ZtyZGufBI963kh8PvlSWW +USyH+oYoIhlyJqj7tCQXoijEvQnSReKGHUTvQGvQK2Ybp735NmAiDOM3/3KKh2Dq +as/Tc2mxec4whr8RyUidsF+Yp6wTesfMxeax6DmWIcSLDWpUBndeahZ2Ff0c96Bz +bpF1v2hbvyI/IaP5snb4Nm1xpREm+y8iDAQhOklfXCyXrWgN3sIrk3tDndGrn+nw +0iusnaBVki0keqjlH0camoUP28tu3sxG +=qZrp +-----END PGP PUBLIC KEY BLOCK----- diff --git a/tests/test_apt.py b/tests/test_apt.py index e51f8be..c9aad78 100644 --- a/tests/test_apt.py +++ b/tests/test_apt.py @@ -32,7 +32,7 @@ def make_config() -> Configuration: return Configuration.model_validate(SOURCE_CONTENT) -def make_config_with(source_extra: dict) -> Configuration: +def make_config_with(source_extra: dict, name: str = "interdiode") -> Configuration: data = { "uuid": "12345678-1234-5678-1234-567812345678", "organisation": "InterDiode", @@ -40,7 +40,7 @@ def make_config_with(source_extra: dict) -> Configuration: "apt": { "sources": [ { - "name": "interdiode", + "name": name, "uris": ["http://example.invalid/debian"], "suites": ["stable"], **source_extra, @@ -227,6 +227,26 @@ def test_apply_without_signed_by_writes_no_keyring_or_field( ) +def test_apply_sanitizes_source_name_with_slash(tmp_path, monkeypatch) -> None: + mock_run = _mock_gpg_dearmor_success(monkeypatch) + dst = tmp_path / "dst" + context = Context( + config=make_config_with({"signed-by": KEY_CONTENT}, name="../../evil"), + dst=dst, + src=None, + ) + + AptModule().apply(context) + + keyring = dst / "etc/apt/keyrings/.._.._evil.gpg" + assert keyring.read_bytes() == KEYRING_BYTES + source_file = dst / "etc/apt/sources.list.d/.._.._evil.sources" + assert "Signed-By: /etc/apt/keyrings/.._.._evil.gpg" in source_file.read_text( + encoding="utf-8" + ) + mock_run.assert_called_once() + + def test_resolve_signed_by_raises_when_gpg_not_on_path(tmp_path, monkeypatch) -> None: monkeypatch.setattr("id_configurator.modules.apt.shutil.which", lambda _: None) dst = tmp_path / "dst" diff --git a/tests/test_certificates.py b/tests/test_certificates.py index 3810685..6f5af38 100644 --- a/tests/test_certificates.py +++ b/tests/test_certificates.py @@ -54,6 +54,27 @@ def test_apply_writes_certificate_and_rebuilds_bundle(tmp_path) -> None: assert openssl_default.readlink().as_posix() == "/etc/ssl/certs/ca-certificates.crt" +def test_apply_sanitizes_certificate_name_with_slash(tmp_path) -> None: + data = { + "uuid": "12345678-1234-5678-1234-567812345678", + "organisation": "InterDiode", + "configurations": { + "certificates": [{"name": "../../evil", "content": CERT_CONTENT}] + }, + } + dst = tmp_path / "dst" + context = Context(config=Configuration.model_validate(data), dst=dst, src=None) + + CertificatesModule().apply(context) + + local_cert = dst / "usr/local/share/ca-certificates/.._.._evil.crt" + assert local_cert.read_bytes() == b"fake-cert-bytes" + # The "/" must never be interpreted as a path separator: without + # sanitizing, "../../evil" would have reshuffled the write up to + # usr/local/evil.crt instead of staying under ca-certificates/. + assert not (dst / "usr/local/evil.crt").exists() + + def test_apply_merges_conf_listed_and_local_certificates(tmp_path) -> None: src = tmp_path / "src" local_certs_dir = src / "usr/local/share/ca-certificates" diff --git a/tests/test_modules_base.py b/tests/test_modules_base.py index 109d482..6eba11d 100644 --- a/tests/test_modules_base.py +++ b/tests/test_modules_base.py @@ -1,5 +1,10 @@ from id_configurator.modules import base as base_module -from id_configurator.modules.base import Module, all_modules, register +from id_configurator.modules.base import ( + Module, + all_modules, + register, + sanitize_filename, +) def test_register_and_all_modules_are_isolated(monkeypatch) -> None: @@ -17,3 +22,15 @@ def test_register_and_all_modules_are_isolated(monkeypatch) -> None: register(DummyModule) assert all_modules() == [DummyModule] + + +def test_sanitize_filename_leaves_plain_names_untouched() -> None: + assert sanitize_filename("interdiode") == "interdiode" + + +def test_sanitize_filename_replaces_forward_slash() -> None: + assert sanitize_filename("../../etc/passwd") == ".._.._etc_passwd" + + +def test_sanitize_filename_replaces_backslash() -> None: + assert sanitize_filename(r"a\b") == "a_b" diff --git a/tests/test_yum.py b/tests/test_yum.py index 4e3dc1f..fa727a4 100644 --- a/tests/test_yum.py +++ b/tests/test_yum.py @@ -165,6 +165,36 @@ def test_apply_disables_existing_repos_before_adding_configured_ones(tmp_path) - ) +def test_apply_sanitizes_repository_and_key_names_with_slash( + tmp_path, monkeypatch +) -> None: + mock_run = _mock_gpg_success(monkeypatch) + data = { + "uuid": "12345678-1234-5678-1234-567812345678", + "organisation": "InterDiode", + "configurations": { + "yum": { + "repositories": [ + { + "name": "../../evil-repo", + "index-url": "https://example.invalid/yum", + } + ], + "keys": [{"name": "../../evil-key", "content": KEY_CONTENT}], + } + }, + } + dst = tmp_path / "dst" + context = Context(config=make_config(data), dst=dst, src=None) + + YumModule().apply(context) + + assert (dst / "etc/yum.repos.d/.._.._evil-repo.repo").exists() + key_content = (dst / "etc/pki/rpm-gpg/RPM-GPG-KEY-.._.._evil-key").read_bytes() + assert key_content == b"fake-rpm-gpg-key-bytes" + mock_run.assert_called_once() + + def test_import_key_raises_when_gpg_binary_missing(tmp_path, monkeypatch) -> None: monkeypatch.setattr( "id_configurator.modules.yum.subprocess.run", From b2dcae0dafd83c989bb5c3a3b19b0b482232cc0f Mon Sep 17 00:00:00 2001 From: d9pouces Date: Thu, 10 Sep 2026 22:16:42 +0200 Subject: [PATCH 10/17] feat: update APT module to support inline ASCII-armored GPG keys in Signed-By field Signed-off-by: d9pouces --- id_configurator/config.py | 87 ++++++++++++++++------------ id_configurator/modules/apt.py | 63 ++++---------------- library/id_configurator.py | 7 +-- tests/dockerfiles/Apt.Dockerfile | 2 - tests/test_apt.py | 99 +++++++------------------------- tests/test_config.py | 36 ++++++++++++ 6 files changed, 124 insertions(+), 170 deletions(-) diff --git a/id_configurator/config.py b/id_configurator/config.py index 766e575..c964669 100644 --- a/id_configurator/config.py +++ b/id_configurator/config.py @@ -26,13 +26,23 @@ def _yes_no(value: bool) -> str: return "yes" if value else "no" +def _fold_deb822_multiline(field: str, value: str) -> str: + """Render `field` as a DEB822 folded multi-line field (sources.list(5)): + each of `value`'s own lines indented by one space, with a truly blank + line represented as a lone `.` - exactly how an ASCII-armored GPG key + block is embedded directly in a `Signed-By` field, verified against a + real `apt-get update` run against a genuinely signed repository.""" + folded = "\n".join(f" {line}" if line else " ." for line in value.splitlines()) + return f"{field}:\n{folded}" + + class AptSource(BaseModel): """A single APT source, in the DEB822 format (see sources.list(5)). Two ways to tell APT which key to trust this source with: - - `signed_by`: the ASCII-armored (.asc) *content* of a GPG public key. - The `apt` module dearmors it into a dedicated keyring and writes that - keyring's path as the rendered `Signed-By` field. + - `signed_by`: the ASCII-armored (.asc) *content* of a GPG public key, + folded inline as a multi-line `Signed-By` field - no separate keyring + file, no `gpg` binary needed to apply this configuration. - `signed_by_file`: the *path* to a keyring already expected to exist on the target system, written to the rendered `Signed-By` field verbatim. If both are set, `signed_by` takes precedence. @@ -66,61 +76,66 @@ class AptSource(BaseModel): date_max_future: str | None = Field(default=None, alias="date-max-future") description: str | None = None - def to_deb822(self, signed_by_path: str | None = None) -> str: + def to_deb822( + self, + signed_by_armored_key: str | None = None, + signed_by_path: str | None = None, + ) -> str: """Render this source as a DEB822 stanza (as used in /etc/apt/sources.list.d/*.sources). - `signed_by_path` is the value actually written as the `Signed-By` - field, if given - resolved by the caller (the `apt` module) from - `self.signed_by`/`self.signed_by_file`, neither of which is ever - rendered here directly. + Neither `self.signed_by` nor `self.signed_by_file` is ever rendered + directly - the caller (the `apt` module) resolves which one applies + and passes the result in: `signed_by_armored_key` is folded inline + as a multi-line `Signed-By` field (see `_fold_deb822_multiline`); + `signed_by_path` is written as a plain single-line `Signed-By` + field. If both are given, `signed_by_armored_key` takes precedence. """ - fields = [ - ("Types", " ".join(self.types)), - ("URIs", " ".join(self.uris)), - ("Suites", " ".join(self.suites)), + lines = [ + f"Types: {' '.join(self.types)}", + f"URIs: {' '.join(self.uris)}", + f"Suites: {' '.join(self.suites)}", ] if self.components: - fields.append(("Components", " ".join(self.components))) + lines.append(f"Components: {' '.join(self.components)}") if self.architectures: - fields.append(("Architectures", " ".join(self.architectures))) + lines.append(f"Architectures: {' '.join(self.architectures)}") if self.languages: - fields.append(("Languages", " ".join(self.languages))) + lines.append(f"Languages: {' '.join(self.languages)}") if self.targets: - fields.append(("Targets", " ".join(self.targets))) - if signed_by_path: - fields.append(("Signed-By", signed_by_path)) + lines.append(f"Targets: {' '.join(self.targets)}") + if signed_by_armored_key: + lines.append(_fold_deb822_multiline("Signed-By", signed_by_armored_key)) + elif signed_by_path: + lines.append(f"Signed-By: {signed_by_path}") if self.trusted is not None: - fields.append(("Trusted", _yes_no(self.trusted))) + lines.append(f"Trusted: {_yes_no(self.trusted)}") if self.pdiffs is not None: - fields.append(("PDiffs", _yes_no(self.pdiffs))) + lines.append(f"PDiffs: {_yes_no(self.pdiffs)}") if self.by_hash is not None: - fields.append(("By-Hash", _yes_no(self.by_hash))) + lines.append(f"By-Hash: {_yes_no(self.by_hash)}") if self.allow_insecure is not None: - fields.append(("Allow-Insecure", _yes_no(self.allow_insecure))) + lines.append(f"Allow-Insecure: {_yes_no(self.allow_insecure)}") if self.allow_weak is not None: - fields.append(("Allow-Weak", _yes_no(self.allow_weak))) + lines.append(f"Allow-Weak: {_yes_no(self.allow_weak)}") if self.allow_downgrade_to_insecure is not None: - fields.append( - ( - "Allow-Downgrade-To-Insecure", - _yes_no(self.allow_downgrade_to_insecure), - ) + lines.append( + f"Allow-Downgrade-To-Insecure: {_yes_no(self.allow_downgrade_to_insecure)}" ) if self.check_valid_until is not None: - fields.append(("Check-Valid-Until", _yes_no(self.check_valid_until))) + lines.append(f"Check-Valid-Until: {_yes_no(self.check_valid_until)}") if self.valid_until_min: - fields.append(("Valid-Until-Min", self.valid_until_min)) + lines.append(f"Valid-Until-Min: {self.valid_until_min}") if self.valid_until_max: - fields.append(("Valid-Until-Max", self.valid_until_max)) + lines.append(f"Valid-Until-Max: {self.valid_until_max}") if self.check_date is not None: - fields.append(("Check-Date", _yes_no(self.check_date))) + lines.append(f"Check-Date: {_yes_no(self.check_date)}") if self.date_max_future: - fields.append(("Date-Max-Future", self.date_max_future)) + lines.append(f"Date-Max-Future: {self.date_max_future}") if self.description: - fields.append(("Description", self.description)) - fields.append(("Enabled", _yes_no(self.enabled))) - return "\n".join(f"{key}: {value}" for key, value in fields) + "\n" + lines.append(f"Description: {self.description}") + lines.append(f"Enabled: {_yes_no(self.enabled)}") + return "\n".join(lines) + "\n" class AptConfig(BaseModel): diff --git a/id_configurator/modules/apt.py b/id_configurator/modules/apt.py index 8f6167a..765b0d9 100644 --- a/id_configurator/modules/apt.py +++ b/id_configurator/modules/apt.py @@ -7,27 +7,19 @@ longer takes effect, and the sources declared in the configuration are added as new, enabled `.sources` files. Each source can be trusted two ways: `signed_by` holds the ASCII-armored -content of a GPG public key, which is converted with `gpg --dearmor` into a -binary keyring dedicated to this one source, written to -`/etc/apt/keyrings/.gpg` - it is that path, not the key's own content, -that ends up in the rendered `.sources` file's `Signed-By` field. -`signed_by_file` instead names a keyring already expected to exist on the -target system, used verbatim as `Signed-By` (no file is written for it); if -both are given, `signed_by` takes precedence. `gpg` is only used here for -the armored-to-binary format conversion; the actual signature verification -of the repository itself is left to APT when it fetches the repository's -Release/InRelease file. +content of a GPG public key, folded inline as a multi-line `Signed-By` +field (per sources.list(5) - verified empirically against a real, +genuinely signed repository, so no `gpg` binary or separate keyring file +is needed at all to apply this configuration). `signed_by_file` instead +names a keyring already expected to exist on the target system, used +verbatim as `Signed-By`; if both are given, `signed_by` takes precedence. """ from __future__ import annotations import logging -import shutil -import subprocess -import tempfile from pathlib import Path -from id_configurator.config import AptSource from id_configurator.context import Context from id_configurator.modules.base import Module, register, sanitize_filename @@ -36,7 +28,6 @@ logger = logging.getLogger(__name__) _APT_DIR = "etc/apt" _SOURCES_LIST = "etc/apt/sources.list" _SOURCES_LIST_D = "etc/apt/sources.list.d" -_KEYRINGS_DIR = "etc/apt/keyrings" def _disable_list_content(content: str) -> str: @@ -59,28 +50,6 @@ def _disable_deb822_content(content: str) -> str: return "\n\n".join(disabled_stanzas) + "\n" -def _dearmor_gpg_key(name: str, content: bytes) -> bytes: - """Convert an ASCII-armored GPG public key into apt's binary keyring format.""" - with tempfile.TemporaryDirectory() as gnupg_home: - path = shutil.which("gpg") - if path is not None: - result = subprocess.run( - [path, "--homedir", gnupg_home, "--batch", "--dearmor"], - 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 for apt source {name}: {result.stderr.decode('utf-8', 'replace')}" - ) - return result.stdout - - @register class AptModule(Module): name = "apt" @@ -119,22 +88,14 @@ class AptModule(Module): ) logger.info("disabled existing apt source %s", entry.name) - def _resolve_signed_by(self, context: Context, source: AptSource) -> str | None: - if source.signed_by: - keyring = _dearmor_gpg_key(source.name, source.signed_by.encode("utf-8")) - relative = f"{_KEYRINGS_DIR}/{sanitize_filename(source.name)}.gpg" - context.write_file(relative, keyring) - logger.info("installed apt keyring for source %s", source.name) - return f"/{relative}" - if source.signed_by_file: - return source.signed_by_file - return None - - def _write_configured_sources(self, context: Context) -> None: + @staticmethod + def _write_configured_sources(context: Context) -> None: for source in context.config.configurations.apt.sources: - signed_by_path = self._resolve_signed_by(context, source) context.write_file( f"{_SOURCES_LIST_D}/{sanitize_filename(source.name)}.sources", - source.to_deb822(signed_by_path).encode("utf-8"), + source.to_deb822( + signed_by_armored_key=source.signed_by, + signed_by_path=source.signed_by_file, + ).encode("utf-8"), ) logger.info("added apt source %s", source.name) diff --git a/library/id_configurator.py b/library/id_configurator.py index 95ad49e..bc93ff2 100644 --- a/library/id_configurator.py +++ b/library/id_configurator.py @@ -125,10 +125,9 @@ options: type: str description: - ASCII-armored content of a GPG public key to trust this - source with. The `apt` module dearmors it into a dedicated - keyring and writes that keyring's path as the rendered - Signed-By field. Takes precedence over signed_by_file if - both are set. + source with. Folded inline as a multi-line Signed-By field + (no separate keyring file or gpg binary needed). Takes + precedence over signed_by_file if both are set. signed_by_file: type: str description: diff --git a/tests/dockerfiles/Apt.Dockerfile b/tests/dockerfiles/Apt.Dockerfile index 860d578..06bb4ac 100644 --- a/tests/dockerfiles/Apt.Dockerfile +++ b/tests/dockerfiles/Apt.Dockerfile @@ -1,7 +1,5 @@ FROM debian:trixie-slim AS target FROM interdiode/id-configurator:latest AS configuration -RUN apt-get update && apt-get install -y --no-install-recommends gnupg \ - && rm -rf /var/lib/apt/lists/* COPY --from=target / /src/ COPY --from=generated-config Apt.yaml /config.yaml RUN id-configurator apply --config /config.yaml --src /src --dst /dst diff --git a/tests/test_apt.py b/tests/test_apt.py index c9aad78..5accb33 100644 --- a/tests/test_apt.py +++ b/tests/test_apt.py @@ -1,14 +1,13 @@ -import subprocess -from unittest.mock import MagicMock - -import pytest - from id_configurator.config import Configuration from id_configurator.context import Context from id_configurator.modules.apt import AptModule -KEY_CONTENT = "fake-armored-gpg-key" -KEYRING_BYTES = b"fake-dearmored-keyring-bytes" +KEY_CONTENT = ( + "-----BEGIN PGP PUBLIC KEY BLOCK-----\n" + "\n" + "mQENBGqh08IBCAC0\n" + "-----END PGP PUBLIC KEY BLOCK-----\n" +) SOURCE_CONTENT = { "uuid": "12345678-1234-5678-1234-567812345678", @@ -52,16 +51,6 @@ def make_config_with(source_extra: dict, name: str = "interdiode") -> Configurat return Configuration.model_validate(data) -def _mock_gpg_dearmor_success(monkeypatch, output: bytes = KEYRING_BYTES) -> MagicMock: - mock_run = MagicMock( - return_value=subprocess.CompletedProcess( - args=[], returncode=0, stdout=output, stderr=b"" - ) - ) - monkeypatch.setattr("id_configurator.modules.apt.subprocess.run", mock_run) - return mock_run - - 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 @@ -81,10 +70,7 @@ def test_is_relevant_true_with_apt_dir(tmp_path) -> None: assert AptModule().is_relevant(context) is True -def test_apply_disables_existing_sources_and_adds_configured_ones( - tmp_path, monkeypatch -) -> None: - mock_run = _mock_gpg_dearmor_success(monkeypatch) +def test_apply_disables_existing_sources_and_adds_configured_ones(tmp_path) -> None: src = tmp_path / "src" apt_dir = src / "etc/apt" sources_list_d = apt_dir / "sources.list.d" @@ -133,22 +119,21 @@ def test_apply_disables_existing_sources_and_adds_configured_ones( assert not (dst / "etc/apt/sources.list.d/ignored.txt").exists() - keyring = (dst / "etc/apt/keyrings/interdiode.gpg").read_bytes() - assert keyring == KEYRING_BYTES - mock_run.assert_called_once() - assert mock_run.call_args.kwargs["input"] == KEY_CONTENT.encode("utf-8") - configured_source = (dst / "etc/apt/sources.list.d/interdiode.sources").read_text( encoding="utf-8" ) assert configured_source == ( "Types: deb\nURIs: http://example.invalid/debian\nSuites: stable\n" - "Signed-By: /etc/apt/keyrings/interdiode.gpg\nEnabled: yes\n" + "Signed-By:\n" + " -----BEGIN PGP PUBLIC KEY BLOCK-----\n" + " .\n" + " mQENBGqh08IBCAC0\n" + " -----END PGP PUBLIC KEY BLOCK-----\n" + "Enabled: yes\n" ) -def test_apply_without_existing_top_level_sources_list(tmp_path, monkeypatch) -> None: - _mock_gpg_dearmor_success(monkeypatch) +def test_apply_without_existing_top_level_sources_list(tmp_path) -> None: src = tmp_path / "src" (src / "etc/apt").mkdir(parents=True) dst = tmp_path / "dst" @@ -160,8 +145,7 @@ def test_apply_without_existing_top_level_sources_list(tmp_path, monkeypatch) -> assert (dst / "etc/apt/sources.list.d/interdiode.sources").exists() -def test_apply_uses_signed_by_file_when_no_key_content(tmp_path, monkeypatch) -> None: - mock_run = _mock_gpg_dearmor_success(monkeypatch) +def test_apply_uses_signed_by_file_when_no_key_content(tmp_path) -> None: dst = tmp_path / "dst" context = Context( config=make_config_with({"signed-by-file": "/etc/apt/keyrings/existing.gpg"}), @@ -171,8 +155,6 @@ def test_apply_uses_signed_by_file_when_no_key_content(tmp_path, monkeypatch) -> AptModule().apply(context) - mock_run.assert_not_called() - assert not (dst / "etc/apt/keyrings").exists() configured_source = (dst / "etc/apt/sources.list.d/interdiode.sources").read_text( encoding="utf-8" ) @@ -182,10 +164,7 @@ def test_apply_uses_signed_by_file_when_no_key_content(tmp_path, monkeypatch) -> ) -def test_apply_signed_by_takes_precedence_over_signed_by_file( - tmp_path, monkeypatch -) -> None: - mock_run = _mock_gpg_dearmor_success(monkeypatch) +def test_apply_signed_by_takes_precedence_over_signed_by_file(tmp_path) -> None: dst = tmp_path / "dst" context = Context( config=make_config_with( @@ -200,25 +179,19 @@ def test_apply_signed_by_takes_precedence_over_signed_by_file( AptModule().apply(context) - mock_run.assert_called_once() - assert (dst / "etc/apt/keyrings/interdiode.gpg").read_bytes() == KEYRING_BYTES configured_source = (dst / "etc/apt/sources.list.d/interdiode.sources").read_text( encoding="utf-8" ) - assert "Signed-By: /etc/apt/keyrings/interdiode.gpg\n" in configured_source + assert "Signed-By:\n -----BEGIN PGP PUBLIC KEY BLOCK-----" in configured_source + assert "existing.gpg" not in configured_source -def test_apply_without_signed_by_writes_no_keyring_or_field( - tmp_path, monkeypatch -) -> None: - mock_run = _mock_gpg_dearmor_success(monkeypatch) +def test_apply_without_signed_by_writes_no_field(tmp_path) -> None: dst = tmp_path / "dst" context = Context(config=make_config_with({}), dst=dst, src=None) AptModule().apply(context) - mock_run.assert_not_called() - assert not (dst / "etc/apt/keyrings").exists() configured_source = (dst / "etc/apt/sources.list.d/interdiode.sources").read_text( encoding="utf-8" ) @@ -227,8 +200,7 @@ def test_apply_without_signed_by_writes_no_keyring_or_field( ) -def test_apply_sanitizes_source_name_with_slash(tmp_path, monkeypatch) -> None: - mock_run = _mock_gpg_dearmor_success(monkeypatch) +def test_apply_sanitizes_source_name_with_slash(tmp_path) -> None: dst = tmp_path / "dst" context = Context( config=make_config_with({"signed-by": KEY_CONTENT}, name="../../evil"), @@ -238,33 +210,6 @@ def test_apply_sanitizes_source_name_with_slash(tmp_path, monkeypatch) -> None: AptModule().apply(context) - keyring = dst / "etc/apt/keyrings/.._.._evil.gpg" - assert keyring.read_bytes() == KEYRING_BYTES source_file = dst / "etc/apt/sources.list.d/.._.._evil.sources" - assert "Signed-By: /etc/apt/keyrings/.._.._evil.gpg" in source_file.read_text( - encoding="utf-8" - ) - mock_run.assert_called_once() - - -def test_resolve_signed_by_raises_when_gpg_not_on_path(tmp_path, monkeypatch) -> None: - monkeypatch.setattr("id_configurator.modules.apt.shutil.which", lambda _: None) - dst = tmp_path / "dst" - context = Context(config=make_config(), dst=dst, src=None) - - with pytest.raises(RuntimeError, match="gpg is not available"): - AptModule().apply(context) - - -def test_resolve_signed_by_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.apt.subprocess.run", mock_run) - dst = tmp_path / "dst" - context = Context(config=make_config(), dst=dst, src=None) - - with pytest.raises(RuntimeError, match="invalid GPG key for apt source interdiode"): - AptModule().apply(context) + assert source_file.exists() + assert not (dst / "etc/apt/sources.list.d/evil.sources").exists() diff --git a/tests/test_config.py b/tests/test_config.py index 11f0ba8..e7a1027 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -226,3 +226,39 @@ def test_apt_source_to_deb822_full() -> None: "Description: InterDiode APT repository", "Enabled: no", ] + + +def test_apt_source_to_deb822_folds_inline_signed_by_key() -> None: + source = AptSource( + name="interdiode", uris=["http://example.invalid/debian"], suites=["stable"] + ) + armored_key = ( + "-----BEGIN PGP PUBLIC KEY BLOCK-----\n" + "\n" + "mQENBGqh08IBCAC0\n" + "-----END PGP PUBLIC KEY BLOCK-----\n" + ) + rendered = source.to_deb822(signed_by_armored_key=armored_key) + assert rendered == ( + "Types: deb\n" + "URIs: http://example.invalid/debian\n" + "Suites: stable\n" + "Signed-By:\n" + " -----BEGIN PGP PUBLIC KEY BLOCK-----\n" + " .\n" + " mQENBGqh08IBCAC0\n" + " -----END PGP PUBLIC KEY BLOCK-----\n" + "Enabled: yes\n" + ) + + +def test_apt_source_to_deb822_signed_by_armored_key_takes_precedence() -> None: + source = AptSource( + name="interdiode", uris=["http://example.invalid/debian"], suites=["stable"] + ) + rendered = source.to_deb822( + signed_by_armored_key="-----BEGIN PGP PUBLIC KEY BLOCK-----\nabc\n-----END PGP PUBLIC KEY BLOCK-----\n", + signed_by_path="/etc/apt/keyrings/unused.gpg", + ) + assert "Signed-By:\n" in rendered + assert "unused.gpg" not in rendered From cea45c97ff03a90a8ff2a493e27509f62845034c Mon Sep 17 00:00:00 2001 From: d9pouces Date: Sat, 19 Sep 2026 23:40:35 +0200 Subject: [PATCH 11/17] feat: enhance repository configuration to support url_to_replace patterns for disabling existing entries Signed-off-by: d9pouces --- id_configurator/config.py | 22 ++++- id_configurator/modules/apk.py | 75 +++++++++++--- id_configurator/modules/apt.py | 154 +++++++++++++++++++++++++---- id_configurator/modules/yum.py | 72 ++++++++++++-- library/id_configurator.py | 24 +++++ tests/test_apk.py | 139 +++++++++++++++++++++++++- tests/test_apt.py | 175 ++++++++++++++++++++++++++++++--- tests/test_yum.py | 162 ++++++++++++++++++++++++++++-- 8 files changed, 757 insertions(+), 66 deletions(-) diff --git a/id_configurator/config.py b/id_configurator/config.py index c964669..aed9f19 100644 --- a/id_configurator/config.py +++ b/id_configurator/config.py @@ -46,6 +46,10 @@ class AptSource(BaseModel): - `signed_by_file`: the *path* to a keyring already expected to exist on the target system, written to the rendered `Signed-By` field verbatim. If both are set, `signed_by` takes precedence. + + `uri_to_replace`, if set, is an fnmatch pattern: this source is only + applied if it matches at least one URI already present in the machine's + existing apt configuration; otherwise it is skipped entirely. """ model_config = ConfigDict(populate_by_name=True) @@ -75,6 +79,7 @@ class AptSource(BaseModel): check_date: bool | None = Field(default=None, alias="check-date") date_max_future: str | None = Field(default=None, alias="date-max-future") description: str | None = None + uri_to_replace: str | None = Field(default=None, alias="uri-to-replace") def to_deb822( self, @@ -143,11 +148,17 @@ class AptConfig(BaseModel): class ApkRepository(BaseModel): - """An Alpine APK repository (see /etc/apk/repositories), e.g. a URL ending in /main or /community.""" + """An Alpine APK repository (see /etc/apk/repositories), e.g. a URL ending in /main or /community. + + `url_to_replace`, if set, is an fnmatch pattern: this repository is only + applied if it matches at least one URL already present in the machine's + existing apk configuration; otherwise it is skipped entirely. + """ name: str url: str key: str | None = None + url_to_replace: str | None = None class ApkConfig(BaseModel): @@ -155,10 +166,19 @@ class ApkConfig(BaseModel): class YumRepository(BaseModel): + """A YUM/DNF repository (see /etc/yum.repos.d/*.repo). + + `index_url_to_replace`, if set, is an fnmatch pattern: this repository is + only applied if it matches at least one baseurl already present in the + machine's existing yum/dnf configuration; otherwise it is skipped + entirely. + """ + model_config = ConfigDict(populate_by_name=True) name: str index_url: str = Field(alias="index-url") gpg_url: str | None = Field(default=None, alias="gpg-url") + index_url_to_replace: str | None = Field(default=None, alias="index-url-to-replace") class YumConfig(BaseModel): diff --git a/id_configurator/modules/apk.py b/id_configurator/modules/apk.py index 2469db9..3cd2092 100644 --- a/id_configurator/modules/apk.py +++ b/id_configurator/modules/apk.py @@ -1,18 +1,27 @@ """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 +is copied into ``, and the repositories declared in the configuration +are appended as new, active lines. An existing active line is only disabled +(commented out) if it matches the `url_to_replace` pattern of one of the +configured repositories; lines matching no such pattern are copied through +unchanged. 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. + +A repository's `url_to_replace`, if set, is an fnmatch pattern checked +against the URLs already active in /etc/apk/repositories: the repository +is only written out if the pattern matches at least one of them, and +skipped entirely otherwise. """ from __future__ import annotations +import fnmatch import hashlib import logging +from collections.abc import Sequence from pathlib import Path from id_configurator.context import Context @@ -25,11 +34,18 @@ _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() - ] +def _disable_repositories_content(content: str, patterns: Sequence[str]) -> str: + lines = [] + for line in content.splitlines(): + stripped = line.strip() + if ( + stripped + and not stripped.startswith("#") + and any(fnmatch.fnmatch(stripped, pattern) for pattern in patterns) + ): + lines.append(f"# {line}") + else: + lines.append(line) return "\n".join(lines) + "\n" @@ -37,6 +53,19 @@ def _key_filename(content: bytes) -> str: return f"{hashlib.sha256(content).hexdigest()[:16]}.rsa.pub" +def _existing_urls(context: Context) -> set[str]: + """Collect the URLs already declared in /etc/apk/repositories on the + source filesystem (one active, non-comment line per URL).""" + existing = context.read_src_file(_REPOSITORIES) + if existing is None: + return set() + return { + stripped + for line in existing.decode("utf-8").splitlines() + if (stripped := line.strip()) and not stripped.startswith("#") + } + + @register class ApkModule(Module): name = "apk" @@ -52,24 +81,46 @@ class ApkModule(Module): return None def apply(self, context: Context) -> None: - disabled = self._disabled_existing_repositories(context) + patterns = [ + repository.url_to_replace + for repository in context.config.configurations.apk.repositories + if repository.url_to_replace is not None + ] + disabled = self._disabled_existing_repositories(context, patterns) 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: + def _disabled_existing_repositories( + context: Context, patterns: Sequence[str] + ) -> 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")) + logger.info("copied existing apk repositories %s", _REPOSITORIES) + return _disable_repositories_content(existing.decode("utf-8"), patterns) @staticmethod def _configured_repositories_lines(context: Context) -> list[str]: lines = [] + existing_urls: set[str] | None = None for repository in context.config.configurations.apk.repositories: + if repository.url_to_replace is not None: + if existing_urls is None: + existing_urls = _existing_urls(context) + if not any( + fnmatch.fnmatch(url, repository.url_to_replace) + for url in existing_urls + ): + logger.info( + "skipped apk repository %s: url-to-replace %s matched " + "no existing url", + repository.name, + repository.url_to_replace, + ) + continue lines.append(repository.url) logger.info("added apk repository %s", repository.name) if repository.key: diff --git a/id_configurator/modules/apt.py b/id_configurator/modules/apt.py index 765b0d9..8f6fb0e 100644 --- a/id_configurator/modules/apt.py +++ b/id_configurator/modules/apt.py @@ -2,9 +2,13 @@ If `/etc/apt` exists, any source file already present there (classic one-line `.list` files or DEB822 `.sources` files, whether at the top level -or under `sources.list.d/`) is copied into `/etc/apt`, disabled so it no -longer takes effect, and the sources declared in the configuration are added -as new, enabled `.sources` files. +or under `sources.list.d/`) is copied into `/etc/apt`, and the sources +declared in the configuration are added as new, enabled `.sources` files. + +An existing entry (a single `deb`/`deb-src` line, or a DEB822 stanza) is only +disabled if its URI matches the `uri_to_replace` pattern of one of the +configured sources; entries matching no such pattern are copied through +unchanged, still enabled. Each source can be trusted two ways: `signed_by` holds the ASCII-armored content of a GPG public key, folded inline as a multi-line `Signed-By` @@ -13,11 +17,18 @@ genuinely signed repository, so no `gpg` binary or separate keyring file is needed at all to apply this configuration). `signed_by_file` instead names a keyring already expected to exist on the target system, used verbatim as `Signed-By`; if both are given, `signed_by` takes precedence. + +A source's `uri_to_replace`, if set, is an fnmatch pattern checked against +the URIs of the sources already present on the source filesystem: the +source is only written out if the pattern matches at least one of them, +and skipped entirely otherwise - even on an otherwise Debian-like machine. """ from __future__ import annotations +import fnmatch import logging +from collections.abc import Sequence from pathlib import Path from id_configurator.context import Context @@ -30,24 +41,100 @@ _SOURCES_LIST = "etc/apt/sources.list" _SOURCES_LIST_D = "etc/apt/sources.list.d" -def _disable_list_content(content: str) -> str: - lines = [ - line if not line.strip() or line.strip().startswith("#") else f"# {line}" - for line in content.splitlines() - ] +def _uri_from_deb_line(stripped_line: str) -> str | None: + """Extract the URI from a single, already-stripped `deb`/`deb-src` + line (`deb [options] uri suite [component...]`), or `None` if the line + is not such a line.""" + parts = stripped_line.split() + if not parts or parts[0] not in ("deb", "deb-src"): + return None + parts = parts[1:] + while parts and parts[0].startswith("["): + while parts and not parts[0].endswith("]"): + parts.pop(0) + if parts: + parts.pop(0) + return parts[0] if parts else None + + +def _disable_list_content(content: str, patterns: Sequence[str]) -> str: + lines = [] + for line in content.splitlines(): + stripped = line.strip() + uri = ( + None + if not stripped or stripped.startswith("#") + else _uri_from_deb_line(stripped) + ) + if uri is not None and any( + fnmatch.fnmatch(uri, pattern) for pattern in patterns + ): + lines.append(f"# {line}") + else: + lines.append(line) return "\n".join(lines) + "\n" -def _disable_deb822_content(content: str) -> str: - disabled_stanzas = [] +def _uris_from_deb822_stanza(stanza: str) -> set[str]: + uris: set[str] = set() + for line in stanza.splitlines(): + if line.lower().startswith("uris:"): + uris.update(line.split(":", 1)[1].split()) + return uris + + +def _disable_deb822_content(content: str, patterns: Sequence[str]) -> str: + stanzas = [] for stanza in content.split("\n\n"): if not stanza.strip(): continue lines = stanza.splitlines() - new_lines = [line for line in lines if not line.lower().startswith("enabled:")] - new_lines.append("Enabled: no") - disabled_stanzas.append("\n".join(new_lines)) - return "\n\n".join(disabled_stanzas) + "\n" + uris = _uris_from_deb822_stanza(stanza) + if any(fnmatch.fnmatch(uri, pattern) for uri in uris for pattern in patterns): + lines = [line for line in lines if not line.lower().startswith("enabled:")] + lines.append("Enabled: no") + stanzas.append("\n".join(lines)) + return "\n\n".join(stanzas) + "\n" + + +def _uris_from_list_content(content: str) -> set[str]: + """Extract the URIs declared in a classic one-line `.list` file + (`deb [options] uri suite [component...]`), skipping comments and any + bracketed option list.""" + uris: set[str] = set() + for line in content.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + uri = _uri_from_deb_line(stripped) + if uri is not None: + uris.add(uri) + return uris + + +def _uris_from_deb822_content(content: str) -> set[str]: + """Extract the URIs declared in the `URIs:` field of each stanza of a + DEB822 `.sources` file.""" + uris: set[str] = set() + for stanza in content.split("\n\n"): + uris.update(_uris_from_deb822_stanza(stanza)) + return uris + + +def _existing_uris(context: Context) -> set[str]: + """Collect every URI declared by the apt sources already present on the + source filesystem, whether in the top-level sources.list or under + sources.list.d/, in either the classic or the DEB822 format.""" + uris: set[str] = set() + existing_top_level = context.read_src_file(_SOURCES_LIST) + if existing_top_level is not None: + uris.update(_uris_from_list_content(existing_top_level.decode("utf-8"))) + for entry in context.iter_src_dir(_SOURCES_LIST_D): + if entry.suffix == ".sources": + uris.update(_uris_from_deb822_content(entry.read_text(encoding="utf-8"))) + elif entry.suffix == ".list": + uris.update(_uris_from_list_content(entry.read_text(encoding="utf-8"))) + return uris @register @@ -65,32 +152,57 @@ class AptModule(Module): return None def apply(self, context: Context) -> None: - self._copy_existing_disabled(context) + patterns = [ + source.uri_to_replace + for source in context.config.configurations.apt.sources + if source.uri_to_replace is not None + ] + self._copy_existing_disabled(context, patterns) self._write_configured_sources(context) @staticmethod - def _copy_existing_disabled(context: Context) -> None: + def _copy_existing_disabled(context: Context, patterns: Sequence[str]) -> None: existing_top_level = context.read_src_file(_SOURCES_LIST) if existing_top_level is not None: - disabled = _disable_list_content(existing_top_level.decode("utf-8")) + disabled = _disable_list_content( + existing_top_level.decode("utf-8"), patterns + ) context.write_file(_SOURCES_LIST, disabled.encode("utf-8")) - logger.info("disabled existing apt source list %s", _SOURCES_LIST) + logger.info("copied existing apt source list %s", _SOURCES_LIST) for entry in context.iter_src_dir(_SOURCES_LIST_D): if entry.suffix == ".sources": - disabled = _disable_deb822_content(entry.read_text(encoding="utf-8")) + disabled = _disable_deb822_content( + entry.read_text(encoding="utf-8"), patterns + ) elif entry.suffix == ".list": - disabled = _disable_list_content(entry.read_text(encoding="utf-8")) + disabled = _disable_list_content( + entry.read_text(encoding="utf-8"), patterns + ) else: continue context.write_file( f"{_SOURCES_LIST_D}/{entry.name}", disabled.encode("utf-8") ) - logger.info("disabled existing apt source %s", entry.name) + logger.info("copied existing apt source %s", entry.name) @staticmethod def _write_configured_sources(context: Context) -> None: + existing_uris: set[str] | None = None for source in context.config.configurations.apt.sources: + if source.uri_to_replace is not None: + if existing_uris is None: + existing_uris = _existing_uris(context) + if not any( + fnmatch.fnmatch(uri, source.uri_to_replace) for uri in existing_uris + ): + logger.info( + "skipped apt source %s: uri-to-replace %s matched no " + "existing uri", + source.name, + source.uri_to_replace, + ) + continue context.write_file( f"{_SOURCES_LIST_D}/{sanitize_filename(source.name)}.sources", source.to_deb822( diff --git a/id_configurator/modules/yum.py b/id_configurator/modules/yum.py index d0caa7a..0cd70e3 100644 --- a/id_configurator/modules/yum.py +++ b/id_configurator/modules/yum.py @@ -4,10 +4,13 @@ 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` +copied into ``, 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 +copied one). A section of an existing `.repo` file is only disabled +(`enabled=0`) if its `baseurl`/`mirrorlist` matches the +`index_url_to_replace` pattern of one of the configured repositories; +sections matching no such pattern are copied through unchanged. 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 @@ -17,15 +20,22 @@ 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). + +A repository's `index_url_to_replace`, if set, is an fnmatch pattern +checked against the baseurl of every section of every existing `.repo` +file: the repository is only written out if the pattern matches at least +one of them, and skipped entirely otherwise. """ from __future__ import annotations import configparser +import fnmatch import logging import shutil import subprocess import tempfile +from collections.abc import Sequence from io import StringIO from id_configurator.config import NamedContent, YumRepository @@ -38,24 +48,46 @@ _REPOS_DIR = "etc/yum.repos.d" _RPM_GPG_DIR = "etc/pki/rpm-gpg" -def _disable_repo_content(content: str) -> str: +def _disable_repo_content(content: str, patterns: Sequence[str]) -> str: parser = configparser.ConfigParser(interpolation=None) parser.optionxform = str parser.read_string(content) for section in parser.sections(): - parser[section]["enabled"] = "0" + urls = [parser[section].get(key) for key in ("baseurl", "mirrorlist")] + urls = [url for url in urls if url] + if any(fnmatch.fnmatch(url, pattern) for url in urls for pattern in patterns): + parser[section]["enabled"] = "0" buffer = StringIO() parser.write(buffer, space_around_delimiters=False) return buffer.getvalue() -def _disable_existing_repos(context: Context) -> None: +def _existing_index_urls(context: Context) -> set[str]: + """Collect the baseurl of every section of every .repo file already + present on the source filesystem.""" + urls: set[str] = set() for entry in context.iter_src_dir(_REPOS_DIR): if entry.suffix != ".repo": continue - disabled = _disable_repo_content(entry.read_text(encoding="utf-8")) + parser = configparser.ConfigParser(interpolation=None) + parser.optionxform = str + parser.read_string(entry.read_text(encoding="utf-8")) + for section in parser.sections(): + # noinspection SpellCheckingInspection + for key in ("baseurl", "mirrorlist"): + url = parser[section].get(key) + if url: + urls.add(url) + return urls + + +def _disable_existing_repos(context: Context, patterns: Sequence[str]) -> 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"), patterns) context.write_file(f"{_REPOS_DIR}/{entry.name}", disabled.encode("utf-8")) - logger.info("disabled existing yum/dnf repository file %s", entry.name) + logger.info("copied existing yum/dnf repository file %s", entry.name) def _write_repo_file(context: Context, repository: YumRepository) -> None: @@ -120,8 +152,28 @@ class YumModule(Module): return bool(yum.repositories) or bool(yum.keys) or any(existing_repo_files) def apply(self, context: Context) -> None: - _disable_existing_repos(context) + patterns = [ + repository.index_url_to_replace + for repository in context.config.configurations.yum.repositories + if repository.index_url_to_replace is not None + ] + _disable_existing_repos(context, patterns) + existing_index_urls: set[str] | None = None for repository in context.config.configurations.yum.repositories: + if repository.index_url_to_replace is not None: + if existing_index_urls is None: + existing_index_urls: set[str] = _existing_index_urls(context) + if not any( + fnmatch.fnmatch(url, repository.index_url_to_replace) + for url in existing_index_urls + ): + logger.info( + "skipped yum/dnf repository %s: index-url-to-replace %s " + "matched no existing baseurl", + repository.name, + repository.index_url_to_replace, + ) + continue _write_repo_file(context, repository) for key in context.config.configurations.yum.keys: _import_key(context, key) diff --git a/library/id_configurator.py b/library/id_configurator.py index bc93ff2..d3c7d73 100644 --- a/library/id_configurator.py +++ b/library/id_configurator.py @@ -167,6 +167,13 @@ options: description: type: str description: DEB822 "Description" field. + uri_to_replace: + type: str + description: + - fnmatch pattern checked against the URIs already present in + the machine's existing apt configuration. If set, this + source is only applied when the pattern matches at least + one of them; otherwise it is skipped entirely. apk: type: dict default: {} @@ -185,6 +192,13 @@ options: key: type: str description: The signing key's name, if any (see NamedContent). + url_to_replace: + type: str + description: + - fnmatch pattern checked against the URLs already active in + the machine's existing /etc/apk/repositories. If set, this + repository is only applied when the pattern matches at + least one of them; otherwise it is skipped entirely. yum: type: dict default: {} @@ -207,6 +221,13 @@ options: gpg_url: type: str description: The repository's gpgkey URL. + index_url_to_replace: + type: str + description: + - fnmatch pattern checked against the baseurl of every + section of every existing .repo file. If set, this + repository is only applied when the pattern matches at + least one of them; otherwise it is skipped entirely. keys: type: list elements: dict @@ -491,6 +512,7 @@ ARGUMENT_SPEC = { "check_date": {"type": "bool"}, "date_max_future": {"type": "str"}, "description": {"type": "str"}, + "uri_to_replace": {"type": "str"}, }, }, }, @@ -506,6 +528,7 @@ ARGUMENT_SPEC = { "options": { "url": {"type": "str", "required": True}, "key": {"type": "str"}, + "url_to_replace": {"type": "str"}, }, }, }, @@ -522,6 +545,7 @@ ARGUMENT_SPEC = { "name": {"type": "str", "required": True}, "index_url": {"type": "str", "required": True}, "gpg_url": {"type": "str"}, + "index_url_to_replace": {"type": "str"}, }, }, "keys": { diff --git a/tests/test_apk.py b/tests/test_apk.py index 2a191cf..4adecc9 100644 --- a/tests/test_apk.py +++ b/tests/test_apk.py @@ -48,7 +48,9 @@ def test_is_relevant_true_with_apk_dir(tmp_path) -> None: assert ApkModule().is_relevant(context) is True -def test_apply_disables_existing_and_adds_configured_repositories(tmp_path) -> None: +def test_apply_copies_existing_repositories_unchanged_without_url_to_replace( + tmp_path, +) -> None: src = tmp_path / "src" apk_dir = src / "etc/apk" apk_dir.mkdir(parents=True) @@ -62,11 +64,13 @@ def test_apply_disables_existing_and_adds_configured_repositories(tmp_path) -> N ApkModule().apply(context) + # No configured repository declares a url_to_replace, so the existing + # lines are copied through unchanged, still active. 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://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" ) @@ -75,6 +79,42 @@ def test_apply_disables_existing_and_adds_configured_repositories(tmp_path) -> N assert key_content == KEY_BYTES +def test_apply_disables_only_existing_lines_matching_url_to_replace(tmp_path) -> None: + src = tmp_path / "src" + apk_dir = src / "etc/apk" + apk_dir.mkdir(parents=True) + (apk_dir / "repositories").write_text( + "https://old-mirror.invalid/v3.18/main\nhttps://old-mirror.invalid/v3.18/community\n", + encoding="utf-8", + ) + data = { + "uuid": "12345678-1234-5678-1234-567812345678", + "organisation": "InterDiode", + "configurations": { + "apk": { + "repositories": [ + { + "name": "main", + "url": "https://example.invalid/v3.19/main", + "url_to_replace": "https://old-mirror.invalid/v3.18/main", + } + ] + } + }, + } + dst = tmp_path / "dst" + context = Context(config=Configuration.model_validate(data), dst=dst, src=src) + + ApkModule().apply(context) + + content = (dst / "etc/apk/repositories").read_text(encoding="utf-8") + assert content == ( + "# https://old-mirror.invalid/v3.18/main\n" + "https://old-mirror.invalid/v3.18/community\n" + "https://example.invalid/v3.19/main\n" + ) + + def test_apply_without_existing_repositories_file(tmp_path) -> None: src = tmp_path / "src" (src / "etc/apk").mkdir(parents=True) @@ -90,6 +130,99 @@ def test_apply_without_existing_repositories_file(tmp_path) -> None: ) +def test_apply_skips_repository_when_url_to_replace_matches_nothing(tmp_path) -> None: + src = tmp_path / "src" + apk_dir = src / "etc/apk" + apk_dir.mkdir(parents=True) + (apk_dir / "repositories").write_text( + "https://old-mirror.invalid/v3.18/main\n", encoding="utf-8" + ) + data = { + "uuid": "12345678-1234-5678-1234-567812345678", + "organisation": "InterDiode", + "configurations": { + "apk": { + "repositories": [ + { + "name": "main", + "url": "https://example.invalid/v3.19/main", + "url_to_replace": "https://other.invalid/*", + } + ] + } + }, + } + dst = tmp_path / "dst" + context = Context(config=Configuration.model_validate(data), dst=dst, src=src) + + ApkModule().apply(context) + + content = (dst / "etc/apk/repositories").read_text(encoding="utf-8") + assert "example.invalid" not in content + + +def test_apply_writes_repository_when_url_to_replace_matches_existing(tmp_path) -> None: + src = tmp_path / "src" + apk_dir = src / "etc/apk" + apk_dir.mkdir(parents=True) + (apk_dir / "repositories").write_text( + "https://old-mirror.invalid/v3.18/main\n", encoding="utf-8" + ) + data = { + "uuid": "12345678-1234-5678-1234-567812345678", + "organisation": "InterDiode", + "configurations": { + "apk": { + "repositories": [ + { + "name": "main", + "url": "https://example.invalid/v3.19/main", + "url_to_replace": "https://old-mirror.invalid/*", + } + ] + } + }, + } + dst = tmp_path / "dst" + context = Context(config=Configuration.model_validate(data), dst=dst, src=src) + + ApkModule().apply(context) + + content = (dst / "etc/apk/repositories").read_text(encoding="utf-8") + assert "https://example.invalid/v3.19/main" in content + + +def test_apply_reuses_cached_existing_urls_and_handles_missing_file(tmp_path) -> None: + src = tmp_path / "src" + (src / "etc/apk").mkdir(parents=True) + data = { + "uuid": "12345678-1234-5678-1234-567812345678", + "organisation": "InterDiode", + "configurations": { + "apk": { + "repositories": [ + { + "name": "main", + "url": "https://example.invalid/v3.19/main", + "url_to_replace": "https://old.invalid/*", + }, + { + "name": "community", + "url": "https://example.invalid/v3.19/community", + "url_to_replace": "https://old.invalid/*", + }, + ] + } + }, + } + dst = tmp_path / "dst" + context = Context(config=Configuration.model_validate(data), dst=dst, src=src) + + ApkModule().apply(context) + + assert not (dst / "etc/apk/repositories").exists() + + 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"} diff --git a/tests/test_apt.py b/tests/test_apt.py index 5accb33..4c9a348 100644 --- a/tests/test_apt.py +++ b/tests/test_apt.py @@ -70,7 +70,9 @@ def test_is_relevant_true_with_apt_dir(tmp_path) -> None: assert AptModule().is_relevant(context) is True -def test_apply_disables_existing_sources_and_adds_configured_ones(tmp_path) -> None: +def test_apply_copies_existing_sources_unchanged_without_uri_to_replace( + tmp_path, +) -> None: src = tmp_path / "src" apt_dir = src / "etc/apt" sources_list_d = apt_dir / "sources.list.d" @@ -97,24 +99,24 @@ def test_apply_disables_existing_sources_and_adds_configured_ones(tmp_path) -> N AptModule().apply(context) - disabled_top_level = (dst / "etc/apt/sources.list").read_text(encoding="utf-8") + # No configured source declares a uri_to_replace, so none of the existing + # entries are disabled: they are copied through unchanged. + top_level = (dst / "etc/apt/sources.list").read_text(encoding="utf-8") assert ( - disabled_top_level - == "# already disabled\n# deb http://example.invalid/legacy stable main\n\n" + top_level + == "# already disabled\ndeb http://example.invalid/legacy stable main\n\n" ) - disabled_list = (dst / "etc/apt/sources.list.d/extra.list").read_text( - encoding="utf-8" - ) - assert disabled_list == "# deb http://example.invalid/extra stable main\n" + extra_list = (dst / "etc/apt/sources.list.d/extra.list").read_text(encoding="utf-8") + assert extra_list == "deb http://example.invalid/extra stable main\n" - disabled_sources = (dst / "etc/apt/sources.list.d/docker.sources").read_text( + docker_sources = (dst / "etc/apt/sources.list.d/docker.sources").read_text( encoding="utf-8" ) - assert disabled_sources == ( - "Types: deb\nURIs: http://example.invalid/docker\nSuites: stable\nEnabled: no" + assert docker_sources == ( + "Types: deb\nURIs: http://example.invalid/docker\nSuites: stable\nEnabled: yes" "\n\n" - "Types: deb\nURIs: http://example.invalid/docker2\nSuites: stable\nEnabled: no\n" + "Types: deb\nURIs: http://example.invalid/docker2\nSuites: stable\n" ) assert not (dst / "etc/apt/sources.list.d/ignored.txt").exists() @@ -133,6 +135,55 @@ def test_apply_disables_existing_sources_and_adds_configured_ones(tmp_path) -> N ) +def test_apply_disables_only_existing_sources_matching_uri_to_replace( + tmp_path, +) -> None: + src = tmp_path / "src" + apt_dir = src / "etc/apt" + sources_list_d = apt_dir / "sources.list.d" + sources_list_d.mkdir(parents=True) + + (apt_dir / "sources.list").write_text( + "deb http://example.invalid/legacy stable main\n", encoding="utf-8" + ) + (sources_list_d / "extra.list").write_text( + "deb http://example.invalid/extra stable main\n", encoding="utf-8" + ) + (sources_list_d / "docker.sources").write_text( + "Types: deb\nURIs: http://example.invalid/extra\nSuites: stable\nEnabled: yes\n" + "\n" + "Types: deb\nURIs: http://example.invalid/docker2\nSuites: stable\n" + "\n\n", + encoding="utf-8", + ) + + dst = tmp_path / "dst" + context = Context( + config=make_config_with({"uri-to-replace": "http://example.invalid/extra"}), + dst=dst, + src=src, + ) + + AptModule().apply(context) + + # The legacy source doesn't match the pattern, so it stays enabled... + top_level = (dst / "etc/apt/sources.list").read_text(encoding="utf-8") + assert top_level == "deb http://example.invalid/legacy stable main\n" + + # ...but every entry whose URI matches is disabled, regardless of format. + extra_list = (dst / "etc/apt/sources.list.d/extra.list").read_text(encoding="utf-8") + assert extra_list == "# deb http://example.invalid/extra stable main\n" + + docker_sources = (dst / "etc/apt/sources.list.d/docker.sources").read_text( + encoding="utf-8" + ) + assert docker_sources == ( + "Types: deb\nURIs: http://example.invalid/extra\nSuites: stable\nEnabled: no" + "\n\n" + "Types: deb\nURIs: http://example.invalid/docker2\nSuites: stable\n" + ) + + def test_apply_without_existing_top_level_sources_list(tmp_path) -> None: src = tmp_path / "src" (src / "etc/apt").mkdir(parents=True) @@ -200,6 +251,106 @@ def test_apply_without_signed_by_writes_no_field(tmp_path) -> None: ) +def test_apply_skips_source_when_uri_to_replace_matches_nothing(tmp_path) -> None: + src = tmp_path / "src" + apt_dir = src / "etc/apt" + apt_dir.mkdir(parents=True) + (apt_dir / "sources.list").write_text( + "deb http://example.invalid/legacy stable main\n", encoding="utf-8" + ) + + dst = tmp_path / "dst" + context = Context( + config=make_config_with({"uri-to-replace": "http://other.invalid/*"}), + dst=dst, + src=src, + ) + + AptModule().apply(context) + + assert not (dst / "etc/apt/sources.list.d/interdiode.sources").exists() + + +def test_apply_writes_source_when_uri_to_replace_matches_existing_uri( + tmp_path, +) -> None: + src = tmp_path / "src" + apt_dir = src / "etc/apt" + apt_dir.mkdir(parents=True) + (apt_dir / "sources.list").write_text( + "deb http://legacy.invalid/debian stable main\n", encoding="utf-8" + ) + + dst = tmp_path / "dst" + context = Context( + config=make_config_with({"uri-to-replace": "http://legacy.invalid/*"}), + dst=dst, + src=src, + ) + + AptModule().apply(context) + + assert (dst / "etc/apt/sources.list.d/interdiode.sources").exists() + + +def test_apply_skips_source_when_uri_to_replace_set_without_src(tmp_path) -> None: + dst = tmp_path / "dst" + context = Context( + config=make_config_with({"uri-to-replace": "http://*"}), dst=dst, src=None + ) + + AptModule().apply(context) + + assert not (dst / "etc/apt/sources.list.d/interdiode.sources").exists() + + +def test_apply_matches_uri_to_replace_across_list_line_variants(tmp_path) -> None: + src = tmp_path / "src" + apt_dir = src / "etc/apt" + sources_list_d = apt_dir / "sources.list.d" + sources_list_d.mkdir(parents=True) + + (apt_dir / "sources.list").write_text( + "# a comment\n" + "\n" + "garbage line here\n" + "deb [unterminated bracket with no closing marker\n" + "deb [arch=amd64 trusted=yes] http://example.invalid/extra stable main\n", + encoding="utf-8", + ) + (sources_list_d / "ignored.txt").write_text("not a source file\n", encoding="utf-8") + + data = { + "uuid": "12345678-1234-5678-1234-567812345678", + "organisation": "InterDiode", + "configurations": { + "apt": { + "sources": [ + { + "name": "first", + "uris": ["http://example.invalid/first"], + "suites": ["stable"], + "uri-to-replace": "http://example.invalid/extra", + }, + { + "name": "second", + "uris": ["http://example.invalid/second"], + "suites": ["stable"], + "uri-to-replace": "http://example.invalid/extra", + }, + ], + } + }, + } + dst = tmp_path / "dst" + context = Context(config=Configuration.model_validate(data), dst=dst, src=src) + + AptModule().apply(context) + + assert (dst / "etc/apt/sources.list.d/first.sources").exists() + assert (dst / "etc/apt/sources.list.d/second.sources").exists() + + def test_apply_sanitizes_source_name_with_slash(tmp_path) -> None: dst = tmp_path / "dst" context = Context( diff --git a/tests/test_yum.py b/tests/test_yum.py index fa727a4..dd10c12 100644 --- a/tests/test_yum.py +++ b/tests/test_yum.py @@ -126,7 +126,9 @@ def test_apply_unsigned_repo_disables_gpgcheck(tmp_path) -> None: ) -def test_apply_disables_existing_repos_before_adding_configured_ones(tmp_path) -> None: +def test_apply_copies_existing_repos_unchanged_without_index_url_to_replace( + tmp_path, +) -> None: src = tmp_path / "src" repos_dir = src / "etc/yum.repos.d" repos_dir.mkdir(parents=True) @@ -148,13 +150,13 @@ def test_apply_disables_existing_repos_before_adding_configured_ones(tmp_path) - 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 + # No configured repository declares an index_url_to_replace, so the + # existing sections are copied through unchanged, still enabled. + copied_multi = (dst / "etc/yum.repos.d/multi.repo").read_text(encoding="utf-8") parser = ConfigParser(interpolation=None) - parser.read_string(disabled_multi) - assert parser["old-one"]["enabled"] == "0" - assert parser["old-two"]["enabled"] == "0" + parser.read_string(copied_multi) + assert parser["old-one"]["enabled"] == "1" + assert parser["old-two"]["enabled"] == "1" assert not (dst / "etc/yum.repos.d/ignored.txt").exists() @@ -165,6 +167,152 @@ def test_apply_disables_existing_repos_before_adding_configured_ones(tmp_path) - ) +def test_apply_disables_only_existing_sections_matching_index_url_to_replace( + 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", + ) + + data = { + "uuid": "12345678-1234-5678-1234-567812345678", + "organisation": "InterDiode", + "configurations": { + "yum": { + "repositories": [ + { + "name": "unsigned", + "index-url": "https://example.invalid/yum", + "index-url-to-replace": "https://old-one.invalid", + } + ] + } + }, + } + dst = tmp_path / "dst" + context = Context(config=make_config(data), dst=dst, src=src) + + YumModule().apply(context) + + disabled_multi = (dst / "etc/yum.repos.d/multi.repo").read_text(encoding="utf-8") + parser = ConfigParser(interpolation=None) + parser.read_string(disabled_multi) + assert parser["old-one"]["enabled"] == "0" + assert parser["old-two"]["enabled"] == "1" + + +def test_apply_skips_repository_when_index_url_to_replace_matches_nothing( + 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" + ) + data = { + "uuid": "12345678-1234-5678-1234-567812345678", + "organisation": "InterDiode", + "configurations": { + "yum": { + "repositories": [ + { + "name": "unsigned", + "index-url": "https://example.invalid/yum", + "index-url-to-replace": "https://other.invalid/*", + } + ] + } + }, + } + dst = tmp_path / "dst" + context = Context(config=make_config(data), dst=dst, src=src) + + YumModule().apply(context) + + assert not (dst / "etc/yum.repos.d/unsigned.repo").exists() + + +def test_apply_writes_repository_when_index_url_to_replace_matches_existing( + 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/yum\nenabled=1\n", + encoding="utf-8", + ) + data = { + "uuid": "12345678-1234-5678-1234-567812345678", + "organisation": "InterDiode", + "configurations": { + "yum": { + "repositories": [ + { + "name": "unsigned", + "index-url": "https://example.invalid/yum", + "index-url-to-replace": "https://old.invalid/*", + } + ] + } + }, + } + dst = tmp_path / "dst" + context = Context(config=make_config(data), dst=dst, src=src) + + YumModule().apply(context) + + assert (dst / "etc/yum.repos.d/unsigned.repo").exists() + + +def test_apply_matches_index_url_to_replace_with_unrelated_files_and_multiple_repos( + 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/yum\nenabled=1\n", + encoding="utf-8", + ) + (repos_dir / "ignored.txt").write_text("not a repo file\n", encoding="utf-8") + + data = { + "uuid": "12345678-1234-5678-1234-567812345678", + "organisation": "InterDiode", + "configurations": { + "yum": { + "repositories": [ + { + "name": "first", + "index-url": "https://example.invalid/first", + "index-url-to-replace": "https://old.invalid/*", + }, + { + "name": "second", + "index-url": "https://example.invalid/second", + "index-url-to-replace": "https://old.invalid/*", + }, + ] + } + }, + } + dst = tmp_path / "dst" + context = Context(config=make_config(data), dst=dst, src=src) + + YumModule().apply(context) + + assert (dst / "etc/yum.repos.d/first.repo").exists() + assert (dst / "etc/yum.repos.d/second.repo").exists() + + def test_apply_sanitizes_repository_and_key_names_with_slash( tmp_path, monkeypatch ) -> None: From db4ff3cfd4933040b0bccdaa90768bb3fe6e7f8e Mon Sep 17 00:00:00 2001 From: d9pouces Date: Sun, 20 Sep 2026 20:10:52 +0200 Subject: [PATCH 12/17] feat: add support for certificate configuration in npm and php modules, update repository URLs in configuration files Signed-off-by: d9pouces --- id_configurator/modules/npm.py | 36 ++++++++++++++++++-- id_configurator/modules/php.py | 28 +++++++++++++-- tests/configs/Apk.yaml.j2 | 1 + tests/configs/Apt.yaml.j2 | 1 + tests/configs/Yum.yaml.j2 | 1 + tests/test_npm.py | 62 ++++++++++++++++++++++++++++++++++ tests/test_php.py | 39 +++++++++++++++++++++ 7 files changed, 164 insertions(+), 4 deletions(-) diff --git a/id_configurator/modules/npm.py b/id_configurator/modules/npm.py index 14b4c92..193d4ef 100644 --- a/id_configurator/modules/npm.py +++ b/id_configurator/modules/npm.py @@ -15,6 +15,19 @@ separate, higher-precedence level in npm's own terminology. `$prefix` defaults to `/usr/local` for essentially every standard Node.js install (the official Docker image included); unlike a config dropped in one specific user's home, this applies regardless of which user invokes npm. + +`cafile` only covers npm's own registry client, though - it does nothing for +Node itself, e.g. a package's install script or the application's own code +making an HTTPS request. Node has no config file for that: the only way to +extend its trust store is the `NODE_EXTRA_CA_CERTS` environment variable, +read once at process startup. So whenever any certificate is configured - +regardless of whether any npm repository is - it is also set system-wide in +`/etc/environment` (merged with whatever is already there), which every +login-based process (an SSH session, `su -`, ...) picks up via PAM's +`pam_env`. This does not reach a `docker build`/`RUN` step or a container's +own `ENTRYPOINT`/`CMD`, which never go through a login shell: propagating it +there is the resulting image's own `Dockerfile`'s job (a plain `ENV` +instruction), outside what a tool that only writes files can do. """ from __future__ import annotations @@ -29,6 +42,8 @@ logger = logging.getLogger(__name__) _NPMRC = "usr/local/etc/npmrc" _CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt" +_ETC_ENVIRONMENT = "etc/environment" +_NODE_EXTRA_CA_CERTS_VAR = "NODE_EXTRA_CA_CERTS" def _write_npmrc(context: Context, repositories: list[NpmRepository]) -> None: @@ -41,12 +56,29 @@ def _write_npmrc(context: Context, repositories: list[NpmRepository]) -> None: logger.info("configured npm to use %s", repositories[0].url) +def _update_etc_environment(context: Context) -> None: + existing = context.read_src_file(_ETC_ENVIRONMENT) + lines = [] if existing is None else existing.decode("utf-8").splitlines() + lines = [ + line for line in lines if not line.startswith(f"{_NODE_EXTRA_CA_CERTS_VAR}=") + ] + lines.append(f"{_NODE_EXTRA_CA_CERTS_VAR}={_CA_BUNDLE}") + context.write_file(_ETC_ENVIRONMENT, ("\n".join(lines) + "\n").encode("utf-8")) + logger.info("configured %s in %s", _NODE_EXTRA_CA_CERTS_VAR, _ETC_ENVIRONMENT) + + @register class NpmModule(Module): name = "npm" def is_relevant(self, context: Context) -> bool: - return bool(context.config.configurations.npm.repositories) + return bool(context.config.configurations.npm.repositories) or bool( + context.config.configurations.certificates + ) def apply(self, context: Context) -> None: - _write_npmrc(context, context.config.configurations.npm.repositories) + repositories = context.config.configurations.npm.repositories + if repositories: + _write_npmrc(context, repositories) + if context.config.configurations.certificates: + _update_etc_environment(context) diff --git a/id_configurator/modules/php.py b/id_configurator/modules/php.py index 3641a2c..5479f58 100644 --- a/id_configurator/modules/php.py +++ b/id_configurator/modules/php.py @@ -14,6 +14,17 @@ Composer's own HTTPS client (PHP's stream wrapper, generally backed by curl) already trusts whatever the `certificates` module installs into the system CA store, so no per-repository certificate configuration is needed here, unlike Poetry's. + +Whenever any certificate is configured - regardless of whether any PHP +repository is - `openssl.cafile` is also set, pointing at the same bundle, +in a dedicated `conf.d` snippet: `usr/local/etc/php/conf.d/id-configurator- +openssl.ini`, the directory the official PHP Docker images scan for extra +`.ini` files (`docker-php-ext-enable`'s own mechanism), applied regardless +of which SAPI (cli, fpm, apache) or php.ini is in use. This is normally +inferred from OpenSSL's own compiled-in default CAfile (which the +`certificates` module also rebuilds), but not every PHP build honors that +default, so setting it explicitly makes every PHP script's own OpenSSL-based +code (not just Composer's) reliably trust the configured certificates too. """ from __future__ import annotations @@ -28,6 +39,8 @@ from id_configurator.modules.base import Module, register logger = logging.getLogger(__name__) _COMPOSER_CONFIG = "root/.composer/config.json" +_CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt" +_OPENSSL_INI = "usr/local/etc/php/conf.d/id-configurator-openssl.ini" def _write_composer_config(context: Context, repositories: list[PhpRepository]) -> None: @@ -45,12 +58,23 @@ def _write_composer_config(context: Context, repositories: list[PhpRepository]) ) +def _write_openssl_ini(context: Context) -> None: + context.write_file(_OPENSSL_INI, f"openssl.cafile={_CA_BUNDLE}\n".encode()) + logger.info("configured openssl.cafile to use %s", _CA_BUNDLE) + + @register class PhpModule(Module): name = "php" def is_relevant(self, context: Context) -> bool: - return bool(context.config.configurations.php.repositories) + return bool(context.config.configurations.php.repositories) or bool( + context.config.configurations.certificates + ) def apply(self, context: Context) -> None: - _write_composer_config(context, context.config.configurations.php.repositories) + repositories = context.config.configurations.php.repositories + if repositories: + _write_composer_config(context, repositories) + if context.config.configurations.certificates: + _write_openssl_ini(context) diff --git a/tests/configs/Apk.yaml.j2 b/tests/configs/Apk.yaml.j2 index 9aa3480..86787d6 100644 --- a/tests/configs/Apk.yaml.j2 +++ b/tests/configs/Apk.yaml.j2 @@ -12,6 +12,7 @@ configurations: repositories: - name: demo url: https://host.docker.internal:{{ 'APK_REPO_PORT' | env }}/ + url_to_replace: https://dl-cdn.alpinelinux.org/* key: | {{ 'dockerfiles/repos/040d0af5f97a0a44.rsa.pub' | file | indent(8, first=true) }} yum: diff --git a/tests/configs/Apt.yaml.j2 b/tests/configs/Apt.yaml.j2 index 0802a3b..504c7ac 100644 --- a/tests/configs/Apt.yaml.j2 +++ b/tests/configs/Apt.yaml.j2 @@ -13,6 +13,7 @@ configurations: uris: - https://host.docker.internal:{{ 'APT_REPO_PORT' | env }}/ suites: ["./"] + uri-to-replace: http://deb.debian.org/* signed-by: | {{ 'dockerfiles/repos/apt-signing-key.pub.asc' | file | indent(8, first=true) }} apk: diff --git a/tests/configs/Yum.yaml.j2 b/tests/configs/Yum.yaml.j2 index a3ffacf..5696450 100644 --- a/tests/configs/Yum.yaml.j2 +++ b/tests/configs/Yum.yaml.j2 @@ -14,6 +14,7 @@ configurations: repositories: - name: demo index-url: https://host.docker.internal:{{ 'YUM_REPO_PORT' | env }}/ + index-url-to-replace: https://mirrors.rockylinux.org/* keys: [] python: repositories: [] diff --git a/tests/test_npm.py b/tests/test_npm.py index 69fa45f..df773df 100644 --- a/tests/test_npm.py +++ b/tests/test_npm.py @@ -28,6 +28,14 @@ EMPTY_CONFIG = { "organisation": "InterDiode", } +CONFIG_WITH_CERTIFICATE_ONLY = { + "uuid": "12345678-1234-5678-1234-567812345678", + "organisation": "InterDiode", + "configurations": { + "certificates": [{"name": "interdiode-ca", "content": "-----BEGIN...-----"}] + }, +} + def make_config(data: dict) -> Configuration: return Configuration.model_validate(data) @@ -38,6 +46,13 @@ def test_is_relevant_false_when_no_repositories(tmp_path) -> None: assert NpmModule().is_relevant(context) is False +def test_is_relevant_true_with_certificate_only(tmp_path) -> None: + context = Context( + config=make_config(CONFIG_WITH_CERTIFICATE_ONLY), dst=tmp_path / "dst", src=None + ) + assert NpmModule().is_relevant(context) is True + + def test_is_relevant_true_with_repository(tmp_path) -> None: context = Context( config=make_config(CONFIG_WITH_REPO), dst=tmp_path / "dst", src=None @@ -69,3 +84,50 @@ def test_apply_writes_additional_repositories_scoped(tmp_path) -> None: "registry=https://example.invalid/\n" "@myorg:registry=https://other.invalid/\n" ) + + +def test_apply_without_certificates_does_not_write_etc_environment(tmp_path) -> None: + dst = tmp_path / "dst" + context = Context(config=make_config(CONFIG_WITH_REPO), dst=dst, src=None) + + NpmModule().apply(context) + + assert not (dst / "etc/environment").exists() + + +def test_apply_sets_node_extra_ca_certs_when_certificates_configured( + tmp_path, +) -> None: + dst = tmp_path / "dst" + context = Context( + config=make_config(CONFIG_WITH_CERTIFICATE_ONLY), dst=dst, src=None + ) + + NpmModule().apply(context) + + assert not (dst / "usr/local/etc/npmrc").exists() + environment = (dst / "etc/environment").read_text(encoding="utf-8") + assert environment == "NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt\n" + + +def test_apply_merges_node_extra_ca_certs_into_existing_etc_environment( + tmp_path, +) -> None: + src = tmp_path / "src" + src.mkdir() + (src / "etc").mkdir() + (src / "etc/environment").write_text( + "PATH=/usr/bin\nNODE_EXTRA_CA_CERTS=/old/stale.pem\n", encoding="utf-8" + ) + + dst = tmp_path / "dst" + context = Context( + config=make_config(CONFIG_WITH_CERTIFICATE_ONLY), dst=dst, src=src + ) + + NpmModule().apply(context) + + environment = (dst / "etc/environment").read_text(encoding="utf-8") + assert environment == ( + "PATH=/usr/bin\nNODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt\n" + ) diff --git a/tests/test_php.py b/tests/test_php.py index 7fb07e1..cf9ea35 100644 --- a/tests/test_php.py +++ b/tests/test_php.py @@ -34,6 +34,14 @@ EMPTY_CONFIG = { "organisation": "InterDiode", } +CONFIG_WITH_CERTIFICATE_ONLY = { + "uuid": "12345678-1234-5678-1234-567812345678", + "organisation": "InterDiode", + "configurations": { + "certificates": [{"name": "interdiode-ca", "content": "-----BEGIN...-----"}] + }, +} + def make_config(data: dict) -> Configuration: return Configuration.model_validate(data) @@ -44,6 +52,13 @@ def test_is_relevant_false_when_no_repositories(tmp_path) -> None: assert PhpModule().is_relevant(context) is False +def test_is_relevant_true_with_certificate_only(tmp_path) -> None: + context = Context( + config=make_config(CONFIG_WITH_CERTIFICATE_ONLY), dst=tmp_path / "dst", src=None + ) + assert PhpModule().is_relevant(context) is True + + def test_is_relevant_true_with_repository(tmp_path) -> None: context = Context( config=make_config(CONFIG_WITH_REPO), dst=tmp_path / "dst", src=None @@ -79,3 +94,27 @@ def test_apply_writes_multiple_repositories_in_order(tmp_path) -> None: config_path = dst / "root/.composer/config.json" content = json.loads(config_path.read_text(encoding="utf-8")) assert [repo["name"] for repo in content["repositories"]] == ["demo", "other"] + + +def test_apply_without_certificates_does_not_write_openssl_ini(tmp_path) -> None: + dst = tmp_path / "dst" + context = Context(config=make_config(CONFIG_WITH_REPO), dst=dst, src=None) + + PhpModule().apply(context) + + assert not (dst / "usr/local/etc/php/conf.d/id-configurator-openssl.ini").exists() + + +def test_apply_sets_openssl_cafile_when_certificates_configured(tmp_path) -> None: + dst = tmp_path / "dst" + context = Context( + config=make_config(CONFIG_WITH_CERTIFICATE_ONLY), dst=dst, src=None + ) + + PhpModule().apply(context) + + assert not (dst / "root/.composer/config.json").exists() + ini_content = ( + dst / "usr/local/etc/php/conf.d/id-configurator-openssl.ini" + ).read_text(encoding="utf-8") + assert ini_content == "openssl.cafile=/etc/ssl/certs/ca-certificates.crt\n" From 776cdc3b1749b27ce405ede83f84bd10f8465551 Mon Sep 17 00:00:00 2001 From: d9pouces Date: Sun, 20 Sep 2026 20:11:26 +0200 Subject: [PATCH 13/17] feat: update version to 2026.9.20 in pyproject.toml Signed-off-by: d9pouces --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f7e2bc1..12def50 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,7 @@ maintainers = [ name = "id-configurator" readme = "README.md" requires-python = ">=3.13" -version = "2026.9.7" +version = "2026.9.20" [project.scripts] id-configurator = "id_configurator:main" From eff9ca66d2cfe8f5f9367eb849ffe44a48f047e8 Mon Sep 17 00:00:00 2001 From: d9pouces Date: Sun, 20 Sep 2026 22:36:21 +0200 Subject: [PATCH 14/17] feat: add publication command to main application Signed-off-by: d9pouces --- id_tools/__main__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/id_tools/__main__.py b/id_tools/__main__.py index 760596f..e3232ed 100644 --- a/id_tools/__main__.py +++ b/id_tools/__main__.py @@ -3,6 +3,7 @@ import logging import typer from id_tools.build import app as build_app +from id_tools.publication import app as publication_app from id_tools.test import app as test_app SUCCESS = 25 @@ -44,5 +45,6 @@ def main(): app.add_typer(build_app, name="build") app.add_typer(test_app, name="test") +app.add_typer(publication_app, name="publication") if __name__ == "__main__": app() From 204d648477a3603df67ab334410abc046acae03f Mon Sep 17 00:00:00 2001 From: d9pouces Date: Sun, 20 Sep 2026 22:36:40 +0200 Subject: [PATCH 15/17] feat: add --config-url-insecure option to disable TLS certificate verification Signed-off-by: d9pouces --- id_configurator/cli.py | 28 ++++++++++++---- id_configurator/config.py | 51 +++++++++++++++++++++++----- library/id_configurator.py | 21 +++++++----- tests/test_cli.py | 26 +++++++++++++++ tests/test_config.py | 68 +++++++++++++++++++++++++++++++++++++- 5 files changed, 169 insertions(+), 25 deletions(-) diff --git a/id_configurator/cli.py b/id_configurator/cli.py index dc8f7f4..c52ad75 100644 --- a/id_configurator/cli.py +++ b/id_configurator/cli.py @@ -55,6 +55,17 @@ ConfigUrlCacertContentOption = Annotated[ ), ), ] +ConfigUrlInsecureOption = Annotated[ + bool, + typer.Option( + "--config-url-insecure", + help=( + "Disable TLS certificate verification when downloading --config-url " + "(INSECURE: only for bootstrapping from a server whose certificate " + "isn't trustable yet; overrides --config-url-cacert-content)." + ), + ), +] SrcOption = Annotated[ Path | None, typer.Option( @@ -89,13 +100,16 @@ def _load_configuration( config_env: str | None, config_url: str | None, config_url_cacert_content: str | None, + config_url_insecure: bool = False, ): if config is None and config_env is None and config_url is None: raise typer.BadParameter( "either --config, --config-env (or $ID_CONFIGURATOR_CONFIG) or " "--config-url must be provided" ) - return load_configuration(config, config_env, config_url, config_url_cacert_content) + return load_configuration( + config, config_env, config_url, config_url_cacert_content, config_url_insecure + ) @app.command() @@ -105,13 +119,14 @@ def apply( config_env: ConfigEnvOption = None, config_url: ConfigUrlOption = None, config_url_cacert_content: ConfigUrlCacertContentOption = None, + config_url_insecure: ConfigUrlInsecureOption = False, src: SrcOption = None, verbose: VerboseOption = False, ) -> None: """Apply a configuration to the destination directory.""" _configure_logging(verbose) configuration = _load_configuration( - config, config_env, config_url, config_url_cacert_content + config, config_env, config_url, config_url_cacert_content, config_url_insecure ) dst.mkdir(parents=True, exist_ok=True) context = Context(config=configuration, dst=dst, src=src) @@ -130,14 +145,15 @@ def validate( config_env: ConfigEnvOption = None, config_url: ConfigUrlOption = None, config_url_cacert_content: ConfigUrlCacertContentOption = None, + config_url_insecure: ConfigUrlInsecureOption = False, ) -> None: """Validate a configuration file without applying it.""" configuration = _load_configuration( - config, config_env, config_url, config_url_cacert_content - ) - typer.echo( - f"configuration {configuration.uuid} is valid (organisation: {configuration.organisation})" + config, config_env, config_url, config_url_cacert_content, config_url_insecure ) + uuid = configuration.uuid if configuration.uuid is not None else "" + organisation = configuration.organisation or "" + typer.echo(f"configuration {uuid} is valid (organisation: {organisation})") @app.command("list-modules") diff --git a/id_configurator/config.py b/id_configurator/config.py index aed9f19..d64db30 100644 --- a/id_configurator/config.py +++ b/id_configurator/config.py @@ -10,7 +10,7 @@ from pathlib import Path from uuid import UUID import yaml -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator _CONFIG_URL_TIMEOUT_SECONDS = 30 @@ -294,11 +294,28 @@ class ConfigurationsBlock(BaseModel): class Configuration(BaseModel): - uuid: UUID - organisation: str - description: str = "" + """`version` is the configuration format's own schema version (currently + "1.0"), independent of the id-configurator package version - it lets a + future, incompatible format change be told apart from this one. Omitting + it defaults to "1.0", the only version there currently is.""" + + version: str = "1.0" + uuid: UUID | None = None + organisation: str | None = None + description: str | None = None configurations: ConfigurationsBlock = Field(default_factory=ConfigurationsBlock) + @field_validator("version", mode="before") + @classmethod + def _coerce_version_to_str(cls, value: object) -> object: + """Accept a bare YAML number (`version: 1.0`) as well as a quoted + string (`version: "1.0"`): YAML parses the unquoted, natural-looking + form as a float, which would otherwise fail validation with a + confusing type error.""" + if isinstance(value, (int, float)) and not isinstance(value, bool): + return str(value) + return value + def parse_configuration(raw: str) -> Configuration: """Parse a configuration from a JSON or YAML string.""" @@ -309,17 +326,30 @@ def parse_configuration(raw: str) -> Configuration: return Configuration.model_validate(data) -def _fetch_config_url(url: str, cacert_content: str | None) -> str: +def _fetch_config_url( + url: str, cacert_content: str | None, insecure: bool = False +) -> str: """Download a JSON or YAML configuration from `url`. If `cacert_content` is given, it - and only it, not the system's default trust store - is used to verify an HTTPS connection: for an internal endpoint signed by a private CA the running system doesn't otherwise trust yet (e.g. before the `certificates` module has ever run). + + If `insecure` is set instead, certificate verification (and hostname + checking) is disabled entirely - e.g. to bootstrap from a server whose + certificate isn't trusted, or trustable, at all yet. `cacert_content` is + then ignored: there would be nothing left to verify it against. """ - context = ssl.create_default_context( - cadata=cacert_content if cacert_content is not None else None - ) + if insecure: + # Explicitly requested by the operator via --config-url-insecure. + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + else: + context = ssl.create_default_context( + cadata=cacert_content if cacert_content is not None else None + ) with urllib.request.urlopen( # nosec B310 (url is an operator-provided CLI argument) url, timeout=_CONFIG_URL_TIMEOUT_SECONDS, context=context ) as response: @@ -331,6 +361,7 @@ def load_configuration( config_env: str | None, config_url: str | None = None, config_url_cacert_content: str | None = None, + config_url_insecure: bool = False, ) -> Configuration: """Load a configuration from a file, from a base64-encoded string (typically an env var), or by downloading it from a URL.""" @@ -339,7 +370,9 @@ def load_configuration( elif config_env is not None: raw = base64.b64decode(config_env).decode("utf-8") elif config_url is not None: - raw = _fetch_config_url(config_url, config_url_cacert_content) + raw = _fetch_config_url( + config_url, config_url_cacert_content, config_url_insecure + ) else: raise ValueError( "either --config, --config-env or --config-url must be provided" diff --git a/library/id_configurator.py b/library/id_configurator.py index d3c7d73..6c5ae1e 100644 --- a/library/id_configurator.py +++ b/library/id_configurator.py @@ -38,18 +38,19 @@ description: author: - InterDiode options: + version: + type: str + default: "1.0" + description: Configuration.version - the configuration format's own schema version. uuid: type: str - required: true - description: Configuration.uuid - a UUID identifying this configuration. + description: Configuration.uuid - a UUID identifying this configuration, optional. organisation: type: str - required: true - description: Configuration.organisation. + description: Configuration.organisation, optional. description: type: str - default: "" - description: Configuration.description. + description: Configuration.description, optional. certificates: type: list elements: dict @@ -467,9 +468,10 @@ def _name_url_repository_config() -> dict: ARGUMENT_SPEC = { - "uuid": {"type": "str", "required": True}, - "organisation": {"type": "str", "required": True}, - "description": {"type": "str", "default": ""}, + "version": {"type": "str", "default": "1.0"}, + "uuid": {"type": "str"}, + "organisation": {"type": "str"}, + "description": {"type": "str"}, "certificates": { "type": "list", "elements": "dict", @@ -596,6 +598,7 @@ except ImportError as exc: def _build_configuration_data(params: dict) -> dict: return { + "version": params["version"], "uuid": params["uuid"], "organisation": params["organisation"], "description": params["description"], diff --git a/tests/test_cli.py b/tests/test_cli.py index 8fbaf1d..6546fc3 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -30,6 +30,14 @@ def test_validate_valid_config(tmp_path) -> None: assert "is valid" in result.stdout +def test_validate_without_uuid_or_organisation(tmp_path) -> None: + config_path = write_config(tmp_path, {}) + result = runner.invoke(cli.app, ["validate", "--config", config_path]) + assert result.exit_code == 0 + assert "" in result.stdout + assert "" in result.stdout + + def test_validate_without_config_fails() -> None: result = runner.invoke(cli.app, ["validate"]) assert result.exit_code != 0 @@ -56,6 +64,24 @@ def test_validate_from_config_url(monkeypatch) -> None: assert "is valid" in result.stdout +def test_validate_from_config_url_insecure(monkeypatch) -> None: + mock_urlopen = _mock_urlopen(json.dumps(MINIMAL_CONFIG).encode("utf-8")) + monkeypatch.setattr("id_configurator.config.urllib.request.urlopen", mock_urlopen) + result = runner.invoke( + cli.app, + [ + "validate", + "--config-url", + "https://example.invalid/config.json", + "--config-url-insecure", + ], + ) + assert result.exit_code == 0 + assert "is valid" in result.stdout + _, kwargs = mock_urlopen.call_args + assert kwargs["context"].verify_mode.name == "CERT_NONE" + + def test_list_modules() -> None: result = runner.invoke(cli.app, ["list-modules"]) assert result.exit_code == 0 diff --git a/tests/test_config.py b/tests/test_config.py index e7a1027..ff88348 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -85,10 +85,38 @@ def test_parse_configuration_from_yaml() -> None: """ configuration = parse_configuration(raw) assert configuration.organisation == "InterDiode" - assert configuration.description == "" + assert configuration.description is None assert configuration.configurations.certificates == [] +def test_parse_configuration_without_uuid_or_organisation() -> None: + configuration = parse_configuration(json.dumps({})) + assert configuration.uuid is None + assert configuration.organisation is None + assert configuration.version == "1.0" + assert configuration.description is None + + +def test_parse_configuration_defaults_version_to_1_0() -> None: + configuration = parse_configuration(json.dumps(FULL_CONFIG)) + assert configuration.version == "1.0" + + +def test_parse_configuration_with_explicit_version() -> None: + configuration = parse_configuration(json.dumps({**FULL_CONFIG, "version": "1.0"})) + assert configuration.version == "1.0" + + +def test_parse_configuration_coerces_unquoted_yaml_version_to_str() -> None: + raw = """ + version: 1.0 + uuid: 12345678-1234-5678-1234-567812345678 + organisation: InterDiode + """ + configuration = parse_configuration(raw) + assert configuration.version == "1.0" + + 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") @@ -166,6 +194,44 @@ def test_load_configuration_from_url_with_custom_cacert(monkeypatch) -> None: assert stats["x509"] == 1 +def test_load_configuration_from_url_insecure_disables_verification( + monkeypatch, +) -> None: + mock_urlopen = _mock_urlopen(json.dumps(FULL_CONFIG).encode("utf-8")) + monkeypatch.setattr("id_configurator.config.urllib.request.urlopen", mock_urlopen) + + configuration = load_configuration( + None, + None, + "https://example.invalid/config.json", + None, + True, + ) + + assert configuration.organisation == "InterDiode" + _, kwargs = mock_urlopen.call_args + assert kwargs["context"].verify_mode.name == "CERT_NONE" + assert kwargs["context"].check_hostname is False + + +def test_load_configuration_from_url_insecure_ignores_cacert(monkeypatch) -> None: + mock_urlopen = _mock_urlopen(json.dumps(FULL_CONFIG).encode("utf-8")) + monkeypatch.setattr("id_configurator.config.urllib.request.urlopen", mock_urlopen) + cacert_content = _self_signed_ca_pem() + + configuration = load_configuration( + None, + None, + "https://example.invalid/config.json", + cacert_content, + True, + ) + + assert configuration.organisation == "InterDiode" + _, kwargs = mock_urlopen.call_args + assert kwargs["context"].verify_mode.name == "CERT_NONE" + + def test_apt_source_to_deb822_minimal() -> None: source = AptSource( name="interdiode", uris=["http://example.invalid/debian"], suites=["stable"] From 7ddd3ac09ba2dfd59812e379de875ccd556424e2 Mon Sep 17 00:00:00 2001 From: d9pouces Date: Sun, 20 Sep 2026 22:37:06 +0200 Subject: [PATCH 16/17] feat: update license to CECILL-2.1 and increment version to 2026.9.21 in pyproject.toml Signed-off-by: d9pouces --- LICENSE | 1440 ++++++++++++++++++++++++++---------------------- pyproject.toml | 7 +- 2 files changed, 774 insertions(+), 673 deletions(-) diff --git a/LICENSE b/LICENSE index 3c072ea..baa858f 100644 --- a/LICENSE +++ b/LICENSE @@ -1,971 +1,1073 @@ -Copyright 2020 Interdiode SA -All rights reserved -Author: contact@mail.interdiode.fr -InterDiode Commercial Software License Agreement -================================================ + CONTRAT DE LICENCE DE LOGICIEL LIBRE CeCILL -Version 1.0 +Version 2.1 du 2013-06-21 -1. Definitions -2. License Grant -3. Authorized Use -4. Restrictions -5. Subscription and License Management -6. Support Services -7. Intellectual Property Rights -8. Software Distribution and Security Artifacts -9. Open Source Components and Third-Party Software -10. Confidentiality -11. Data Protection and Privacy -12. Limited Warranty and Disclaimer -13. Limitation of Liability -14. Export Control and Sanctions -15. Term and Termination -16. Governing Law and Dispute Resolution -17. Miscellaneous -Appendix A — License Metrics and Subscription Entitlements -Appendix B — Security Artifacts -Appendix C — Third-Party Components and Notices + Avertissement -# 1. Definitions +Ce contrat est une licence de logiciel libre issue d'une concertation +entre ses auteurs afin que le respect de deux grands principes préside à +sa rédaction: -For the purposes of this Agreement, the following capitalized terms shall have the meanings set forth below. + * d'une part, le respect des principes de diffusion des logiciels + libres: accès au code source, droits étendus conférés aux utilisateurs, + * d'autre part, la désignation d'un droit applicable, le droit + français, auquel elle est conforme, tant au regard du droit de la + responsabilité civile que du droit de la propriété intellectuelle et + de la protection qu'il offre aux auteurs et titulaires des droits + patrimoniaux sur un logiciel. -**Agreement** means this InterDiode Commercial Software License Agreement, including any appendices, schedules, or documents expressly incorporated by reference. +Les auteurs de la licence CeCILL (Ce[a] C[nrs] I[nria] L[ogiciel] L[ibre]) +sont: -**Authorized User** means an individual authorized by the Customer to access or use the Software under the purchased Subscription. An Authorized User must be an employee, contractor, or other person acting on behalf of the Customer. +Commissariat à l'énergie atomique et aux énergies alternatives - CEA, +établissement public de recherche à caractère scientifique, technique et +industriel, dont le siège est situé 25 rue Leblanc, immeuble Le Ponant +D, 75015 Paris. -**Customer** means the legal entity or individual identified in the applicable Order Form or purchasing the License. +Centre National de la Recherche Scientifique - CNRS, établissement +public à caractère scientifique et technologique, dont le siège est +situé 3 rue Michel-Ange, 75794 Paris cedex 16. -**Documentation** means the user guides, installation instructions, release notes, technical documentation, and other materials published by InterDiode for use with the Software. +Institut National de Recherche en Informatique et en Automatique - +Inria, établissement public à caractère scientifique et technologique, +dont le siège est situé Domaine de Voluceau, Rocquencourt, BP 105, 78153 +Le Chesnay cedex. -**Effective Date** means the date on which the License becomes effective, as specified in the applicable Order Form. -**Fees** means all amounts payable by the Customer to InterDiode under the applicable Order Form, including Subscription fees and any fees for professional services, training, support, or other services provided by InterDiode. + Préambule -**License** means the limited right granted under this Agreement to use the Software. +Ce contrat est une licence de logiciel libre dont l'objectif est de +conférer aux utilisateurs la liberté de modification et de +redistribution du logiciel régi par cette licence dans le cadre d'un +modèle de diffusion en logiciel libre. -**Licensed Feature** means a Software capability that is included in the Subscription purchased by the Customer. +L'exercice de ces libertés est assorti de certains devoirs à la charge +des utilisateurs afin de préserver ce statut au cours des +redistributions ultérieures. -**License Key** means any activation key (digitally signed license) file used by InterDiode to verify Subscription validity, Licensed User Limits, Licensed Features, or other licensing conditions. +L'accessibilité au code source et les droits de copie, de modification +et de redistribution qui en découlent ont pour contrepartie de n'offrir +aux utilisateurs qu'une garantie limitée et de ne faire peser sur +l'auteur du logiciel, le titulaire des droits patrimoniaux et les +concédants successifs qu'une responsabilité restreinte. -**Licensed User Limit** means the maximum number of Authorized Users permitted under the purchased Subscription. +A cet égard l'attention de l'utilisateur est attirée sur les risques +associés au chargement, à l'utilisation, à la modification et/ou au +développement et à la reproduction du logiciel par l'utilisateur étant +donné sa spécificité de logiciel libre, qui peut le rendre complexe à +manipuler et qui le réserve donc à des développeurs ou des +professionnels avertis possédant des connaissances informatiques +approfondies. Les utilisateurs sont donc invités à charger et tester +l'adéquation du logiciel à leurs besoins dans des conditions permettant +d'assurer la sécurité de leurs systèmes et/ou de leurs données et, plus +généralement, à l'utiliser et l'exploiter dans les mêmes conditions de +sécurité. Ce contrat peut être reproduit et diffusé librement, sous +réserve de le conserver en l'état, sans ajout ni suppression de clauses. -**Version** means a release of the Software identified by a version number in the format YYYY.MM.DD, corresponding to the date on which the release was published by InterDiode. +Ce contrat est susceptible de s'appliquer à tout logiciel dont le +titulaire des droits patrimoniaux décide de soumettre l'exploitation aux +dispositions qu'il contient. -**Open Source Component** means any software component distributed under an open source license and included with or used by the Software. +Une liste de questions fréquemment posées se trouve sur le site web +officiel de la famille des licences CeCILL +(http://www.cecill.info/index.fr.html) pour toute clarification qui +serait nécessaire. -**Order Form** means any quotation, purchase order, subscription confirmation, invoice, or other commercial document identifying the License purchased by the Customer. -**Organization** means the legal entity holding the License and its employees or contractors acting within the scope of their authorized duties. + Article 1 - DEFINITIONS -**Security Artifact** means any software supply chain security document or cryptographic material published by InterDiode, including but not limited to SBOMs, VEX documents, software signatures, provenance attestations, checksums, and public verification keys. +Dans ce contrat, les termes suivants, lorsqu'ils seront écrits avec une +lettre capitale, auront la signification suivante: -**Software** means the proprietary InterDiode software product, including its executable code, OCI images, container images, configuration templates, scripts, Documentation, and any Updates provided under this Agreement. The Software expressly excludes its source code unless separately licensed in writing. +Contrat: désigne le présent contrat de licence, ses éventuelles versions +postérieures et annexes. -**Software Version** means a published release of the Software identified by a version number in the format YYYY.MM.DD. +Logiciel: désigne le logiciel sous sa forme de Code Objet et/ou de Code +Source et le cas échéant sa documentation, dans leur état au moment de +l'acceptation du Contrat par le Licencié. -**Subscription** means the commercial subscription purchased by the Customer. An active Subscription grants the Customer the right to use the Software, subject to the Licensed User Limit, Licensed Features, and the other terms of this Agreement. +Logiciel Initial: désigne le Logiciel sous sa forme de Code Source et +éventuellement de Code Objet et le cas échéant sa documentation, dans +leur état au moment de leur première diffusion sous les termes du Contrat. -**Subscription Term** means the period during which the Subscription remains active. +Logiciel Modifié: désigne le Logiciel modifié par au moins une +Contribution. -**Support Services** means the technical support and maintenance services provided by InterDiode under an active Subscription. +Code Source: désigne l'ensemble des instructions et des lignes de +programme du Logiciel et auquel l'accès est nécessaire en vue de +modifier le Logiciel. -**Working Day** means any day other than a Saturday, Sunday, or public holiday in France. +Code Objet: désigne les fichiers binaires issus de la compilation du +Code Source. +Titulaire: désigne le ou les détenteurs des droits patrimoniaux d'auteur +sur le Logiciel Initial. -# 2. License Grant +Licencié: désigne le ou les utilisateurs du Logiciel ayant accepté le +Contrat. -## 2.1 Grant of License +Contributeur: désigne le Licencié auteur d'au moins une Contribution. -Subject to the terms of this Agreement and the payment of all applicable fees, InterDiode grants the Customer a limited, non-exclusive, non-transferable, non-sublicensable, and revocable License to install, access, and use the Software during the Subscription Term solely for the Customer's internal business operations. +Concédant: désigne le Titulaire ou toute personne physique ou morale +distribuant le Logiciel sous le Contrat. -The License is granted to the Customer as a legal entity and may be exercised only by its Authorized Users. +Contribution: désigne l'ensemble des modifications, corrections, +traductions, adaptations et/ou nouvelles fonctionnalités intégrées dans +le Logiciel par tout Contributeur, ainsi que tout Module Interne. -No ownership rights in the Software are transferred under this Agreement. +Module: désigne un ensemble de fichiers sources y compris leur +documentation qui permet de réaliser des fonctionnalités ou services +supplémentaires à ceux fournis par le Logiciel. -## 2.2 Scope of Use +Module Externe: désigne tout Module, non dérivé du Logiciel, tel que ce +Module et le Logiciel s'exécutent dans des espaces d'adressage +différents, l'un appelant l'autre au moment de leur exécution. -The Customer may: +Module Interne: désigne tout Module lié au Logiciel de telle sorte +qu'ils s'exécutent dans le même espace d'adressage. -* download Software Versions made available by InterDiode during the Subscription Term; -* install and operate the Software on systems owned or controlled by the Customer; -* permit access to the Software only by Authorized Users, subject to the limits of the purchased Subscription; -* make reasonable backup copies of the Software solely for disaster recovery and business continuity purposes; -* use the Documentation solely in connection with the authorized use of the Software. +GNU GPL: désigne la GNU General Public License dans sa version 2 ou +toute version ultérieure, telle que publiée par Free Software Foundation +Inc. -Any use not expressly authorized by this Agreement is prohibited. +GNU Affero GPL: désigne la GNU Affero General Public License dans sa +version 3 ou toute version ultérieure, telle que publiée par Free +Software Foundation Inc. -## 2.3 Subscription Rights +EUPL: désigne la Licence Publique de l'Union européenne dans sa version +1.1 ou toute version ultérieure, telle que publiée par la Commission +Européenne. -The License remains valid only during the Subscription Term. +Parties: désigne collectivement le Licencié et le Concédant. -During an active Subscription Term, the Customer is entitled to download, install, and use any Software Version released by InterDiode during that Subscription Term. +Ces termes s'entendent au singulier comme au pluriel. -Upon expiration or termination of the Subscription, all rights granted under this Agreement automatically cease unless otherwise required by applicable law or expressly agreed in writing. -The Customer shall immediately cease all use of the Software upon expiration or termination of the Subscription. + Article 2 - OBJET -## 2.4 Reservation of Rights +Le Contrat a pour objet la concession par le Concédant au Licencié d'une +licence non exclusive, cessible et mondiale du Logiciel telle que +définie ci-après à l'article 5 <#etendue> pour toute la durée de +protection des droits portant sur ce Logiciel. -The Software is licensed, not sold. -Except for the limited rights expressly granted under this Agreement, InterDiode reserves all rights, title, and interests in and to the Software, the Documentation, the License Keys, and all associated intellectual property rights. + Article 3 - ACCEPTATION -No implied licenses are granted. +3.1 L'acceptation par le Licencié des termes du Contrat est réputée +acquise du fait du premier des faits suivants: -## 2.5 No Source Code License + * (i) le chargement du Logiciel par tout moyen notamment par + téléchargement à partir d'un serveur distant ou par chargement à + partir d'un support physique; + * (ii) le premier exercice par le Licencié de l'un quelconque des + droits concédés par le Contrat. -This Agreement grants no right to access, receive, modify, or distribute (excepting for internal use) the source code of the Software. +3.2 Un exemplaire du Contrat, contenant notamment un avertissement +relatif aux spécificités du Logiciel, à la restriction de garantie et à +la limitation à un usage par des utilisateurs expérimentés a été mis à +disposition du Licencié préalablement à son acceptation telle que +définie à l'article 3.1 <#acceptation-acquise> ci dessus et le Licencié +reconnaît en avoir pris connaissance. -Any access to the source code requires a separate written agreement executed by InterDiode. -## 2.6 Evaluation Licenses + Article 4 - ENTREE EN VIGUEUR ET DUREE -If the Software is provided for evaluation, trial, demonstration, or proof-of-concept purposes, the License is limited to the evaluation period specified by InterDiode or the applicable Order Form. -Evaluation Licenses may be subject to functional, operational, or time limitations and shall not be used for production purposes unless expressly authorized in writing. + 4.1 ENTREE EN VIGUEUR -## 2.7 Third-Party Components +Le Contrat entre en vigueur à la date de son acceptation par le Licencié +telle que définie en 3.1 <#acceptation-acquise>. -The License granted under this Agreement applies exclusively to the proprietary components of the Software developed by InterDiode. -Third-party software distributed with the Software remains subject to its respective license terms. + 4.2 DUREE -# 3. Authorized Use +Le Contrat produira ses effets pendant toute la durée légale de +protection des droits patrimoniaux portant sur le Logiciel. -## 3.1 Internal Business Use -The Customer may use the Software solely for its own internal business operations and in accordance with this Agreement. + Article 5 - ETENDUE DES DROITS CONCEDES -The Software shall not be used to provide services to third parties unless expressly authorized by the purchased Subscription or by a separate written agreement with InterDiode. +Le Concédant concède au Licencié, qui accepte, les droits suivants sur +le Logiciel pour toutes destinations et pour la durée du Contrat dans +les conditions ci-après détaillées. -## 3.2 Authorized Users +Par ailleurs, si le Concédant détient ou venait à détenir un ou +plusieurs brevets d'invention protégeant tout ou partie des +fonctionnalités du Logiciel ou de ses composants, il s'engage à ne pas +opposer les éventuels droits conférés par ces brevets aux Licenciés +successifs qui utiliseraient, exploiteraient ou modifieraient le +Logiciel. En cas de cession de ces brevets, le Concédant s'engage à +faire reprendre les obligations du présent alinéa aux cessionnaires. -Access to the Software is limited to Authorized Users. -The Customer shall ensure that the number of Authorized Users does not exceed the Licensed User Limit applicable to the purchased Subscription. + 5.1 DROIT D'UTILISATION -The Customer is responsible for all activities performed by its Authorized Users and shall ensure their compliance with this Agreement. +Le Licencié est autorisé à utiliser le Logiciel, sans restriction quant +aux domaines d'application, étant ci-après précisé que cela comporte: -## 3.3 Installation + 1. -The Customer may install and operate the Software on systems owned, leased, or otherwise controlled by the Customer. + la reproduction permanente ou provisoire du Logiciel en tout ou + partie par tout moyen et sous toute forme. -The Customer is solely responsible for selecting, securing, maintaining, and operating the infrastructure on which the Software is deployed. + 2. -## 3.4 Deployment Environments + le chargement, l'affichage, l'exécution, ou le stockage du Logiciel + sur tout support. -The Software may be deployed in physical, virtual, containerized, cloud-hosted, or air-gapped environments, provided that such deployment complies with this Agreement and the purchased Subscription. + 3. -## 3.5 Backup Copies + la possibilité d'en observer, d'en étudier, ou d'en tester le + fonctionnement afin de déterminer les idées et principes qui sont à + la base de n'importe quel élément de ce Logiciel; et ceci, lorsque + le Licencié effectue toute opération de chargement, d'affichage, + d'exécution, de transmission ou de stockage du Logiciel qu'il est en + droit d'effectuer en vertu du Contrat. -The Customer may make a reasonable number of backup copies of the Software solely for disaster recovery, business continuity, and restoration purposes. -Backup copies remain subject to this Agreement. + 5.2 DROIT D'APPORTER DES CONTRIBUTIONS -## 3.6 Documentation +Le droit d'apporter des Contributions comporte le droit de traduire, +d'adapter, d'arranger ou d'apporter toute autre modification au Logiciel +et le droit de reproduire le logiciel en résultant. -The Customer may reproduce and use the Documentation solely for its internal use in connection with the authorized use of the Software. +Le Licencié est autorisé à apporter toute Contribution au Logiciel sous +réserve de mentionner, de façon explicite, son nom en tant qu'auteur de +cette Contribution et la date de création de celle-ci. -Documentation shall not be published or distributed outside the Customer's Organization without the prior written consent of InterDiode. -## 3.7 Affiliates + 5.3 DROIT DE DISTRIBUTION -The License granted under this Agreement applies only to the Customer identified in the applicable Order Form. +Le droit de distribution comporte notamment le droit de diffuser, de +transmettre et de communiquer le Logiciel au public sur tout support et +par tout moyen ainsi que le droit de mettre sur le marché à titre +onéreux ou gratuit, un ou des exemplaires du Logiciel par tout procédé. -Affiliates, parent companies, subsidiaries, or other related entities may use the Software only if expressly covered by the purchased Subscription or by a separate written agreement with InterDiode. +Le Licencié est autorisé à distribuer des copies du Logiciel, modifié ou +non, à des tiers dans les conditions ci-après détaillées. -## 3.8 Service Providers -The Customer may permit its contractors, consultants, managed service providers, or other service providers to access the Software solely for the benefit of the Customer and only within the Licensed User Limit. + 5.3.1 DISTRIBUTION DU LOGICIEL SANS MODIFICATION -The Customer remains fully responsible for their compliance with this Agreement. +Le Licencié est autorisé à distribuer des copies conformes du Logiciel, +sous forme de Code Source ou de Code Objet, à condition que cette +distribution respecte les dispositions du Contrat dans leur totalité et +soit accompagnée: -## 3.9 Evaluation and Testing + 1. -The Customer may install the Software in development, testing, staging, and production environments, provided that the total use remains within the Licensed User Limit and the Licensed Features included in the purchased Subscription. + d'un exemplaire du Contrat, -## 3.10 Reservation + 2. -No rights are granted except those expressly provided by this Agreement. + d'un avertissement relatif à la restriction de garantie et de + responsabilité du Concédant telle que prévue aux articles 8 + <#responsabilite> et 9 <#garantie>, -# 4. Restrictions +et que, dans le cas où seul le Code Objet du Logiciel est redistribué, +le Licencié permette un accès effectif au Code Source complet du +Logiciel pour une durée d'au moins 3 ans à compter de la distribution du +logiciel, étant entendu que le coût additionnel d'acquisition du Code +Source ne devra pas excéder le simple coût de transfert des données. -## 4.1 General Restrictions -Except as expressly permitted by this Agreement or by applicable law, the Customer shall not: + 5.3.2 DISTRIBUTION DU LOGICIEL MODIFIE -a. copy, reproduce, or distribute the Software, in whole or in part; +Lorsque le Licencié apporte une Contribution au Logiciel, les conditions +de distribution du Logiciel Modifié en résultant sont alors soumises à +l'intégralité des dispositions du Contrat. -b. sell, sublicense, lease, rent, assign, transfer, or otherwise make the Software available to any third party; +Le Licencié est autorisé à distribuer le Logiciel Modifié, sous forme de +code source ou de code objet, à condition que cette distribution +respecte les dispositions du Contrat dans leur totalité et soit +accompagnée: -c. use the Software for the benefit of any third party except as expressly permitted by this Agreement; + 1. -d. modify, adapt, translate, or create derivative works of the Software; + d'un exemplaire du Contrat, -e. remove, alter, conceal, or obscure any copyright, trademark, proprietary notice, or other legal notice contained in the Software or the Documentation. + 2. -## 4.2 Reverse Engineering + d'un avertissement relatif à la restriction de garantie et de + responsabilité du Concédant telle que prévue aux articles 8 + <#responsabilite> et 9 <#garantie>, -Except to the extent that such restriction is prohibited by applicable law, the Customer shall not reverse engineer, decompile, disassemble, or otherwise attempt to derive the source code, algorithms, protocols, or internal design of the Software. +et, dans le cas où seul le code objet du Logiciel Modifié est redistribué, -## 4.3 Circumvention + 3. -The Customer shall not disable, circumvent, interfere with, or attempt to bypass any technical measure implemented by InterDiode to enforce the terms of the purchased Subscription, including mechanisms relating to license validation, Licensed User Limits, Licensed Features, or Software integrity. + d'une note précisant les conditions d'accès effectif au code source + complet du Logiciel Modifié, pendant une période d'au moins 3 ans à + compter de la distribution du Logiciel Modifié, étant entendu que le + coût additionnel d'acquisition du code source ne devra pas excéder + le simple coût de transfert des données. -## 4.4 Unauthorized Access -The Customer shall not permit access to the Software by any person other than Authorized Users. + 5.3.3 DISTRIBUTION DES MODULES EXTERNES -The Customer shall not knowingly exceed the Licensed User Limit applicable to the purchased Subscription. +Lorsque le Licencié a développé un Module Externe les conditions du +Contrat ne s'appliquent pas à ce Module Externe, qui peut être distribué +sous un contrat de licence différent. -## 4.5 Redistribution -The Customer shall not publish, redistribute, resell, or otherwise make available the Software, including any container image or installation package, except with the prior written authorization of InterDiode. + 5.3.4 COMPATIBILITE AVEC D'AUTRES LICENCES -## 4.6 Security Artifacts +Le Licencié peut inclure un code soumis aux dispositions d'une des +versions de la licence GNU GPL, GNU Affero GPL et/ou EUPL dans le +Logiciel modifié ou non et distribuer l'ensemble sous les conditions de +la même version de la licence GNU GPL, GNU Affero GPL et/ou EUPL. -The Customer shall not modify, forge, falsify, or misrepresent any Security Artifact distributed by InterDiode. +Le Licencié peut inclure le Logiciel modifié ou non dans un code soumis +aux dispositions d'une des versions de la licence GNU GPL, GNU Affero +GPL et/ou EUPL et distribuer l'ensemble sous les conditions de la même +version de la licence GNU GPL, GNU Affero GPL et/ou EUPL. -The Customer shall not falsely represent that a modified version of the Software originates from, has been approved by, or has been signed by InterDiode. -## 4.7 Compliance + Article 6 - PROPRIETE INTELLECTUELLE -The Customer shall use the Software in compliance with all applicable laws and regulations. -The Customer shall not use the Software for any unlawful purpose or in a manner that infringes the rights of any third party. + 6.1 SUR LE LOGICIEL INITIAL -## 4.8 Reservation of Rights +Le Titulaire est détenteur des droits patrimoniaux sur le Logiciel +Initial. Toute utilisation du Logiciel Initial est soumise au respect +des conditions dans lesquelles le Titulaire a choisi de diffuser son +oeuvre et nul autre n'a la faculté de modifier les conditions de +diffusion de ce Logiciel Initial. -Any use of the Software not expressly authorized by this Agreement is prohibited. +Le Titulaire s'engage à ce que le Logiciel Initial reste au moins régi +par le Contrat et ce, pour la durée visée à l'article 4.2 <#duree>. -# 5. Subscription and License Management + 6.2 SUR LES CONTRIBUTIONS -## 5.1 Subscription +Le Licencié qui a développé une Contribution est titulaire sur celle-ci +des droits de propriété intellectuelle dans les conditions définies par +la législation applicable. -The Software is licensed on a subscription basis. -The Subscription grants the Customer the rights expressly provided under this Agreement for the duration of the Subscription Term. + 6.3 SUR LES MODULES EXTERNES -## 5.2 License Verification +Le Licencié qui a développé un Module Externe est titulaire sur celui-ci +des droits de propriété intellectuelle dans les conditions définies par +la législation applicable et reste libre du choix du contrat régissant +sa diffusion. -The Software may implement technical measures to verify the validity of the Subscription and enforce the licensing conditions applicable to the purchased Subscription. -Such measures may include, without limitation: + 6.4 DISPOSITIONS COMMUNES -* verification of Subscription validity; -* verification of Licensed User Limits; -* verification of Licensed Features; -* use of License Keys or digitally signed license files. +Le Licencié s'engage expressément: -## 5.3 Customer Responsibilities + 1. -The Customer shall: + à ne pas supprimer ou modifier de quelque manière que ce soit les + mentions de propriété intellectuelle apposées sur le Logiciel; -* maintain a valid Subscription throughout its use of the Software; -* protect any License Key or license file against unauthorized disclosure or misuse; -* promptly notify InterDiode if the Customer becomes aware of unauthorized use of its License. + 2. -## 5.4 Expiration + à reproduire à l'identique lesdites mentions de propriété + intellectuelle sur les copies du Logiciel modifié ou non. -Upon expiration of the Subscription Term: +Le Licencié s'engage à ne pas porter atteinte, directement ou +indirectement, aux droits de propriété intellectuelle du Titulaire et/ou +des Contributeurs sur le Logiciel et à prendre, le cas échéant, à +l'égard de son personnel toutes les mesures nécessaires pour assurer le +respect des dits droits de propriété intellectuelle du Titulaire et/ou +des Contributeurs. -* all rights granted under this Agreement automatically terminate; -* the Customer shall immediately cease using the Software; -* the Customer shall not install or use any Software Version unless a new Subscription has become effective. -Expiration of the Subscription does not transfer ownership of the Software or any intellectual property rights to the Customer. + Article 7 - SERVICES ASSOCIES -## 5.5 Renewal +7.1 Le Contrat n'oblige en aucun cas le Concédant à la réalisation de +prestations d'assistance technique ou de maintenance du Logiciel. -A Subscription may be renewed under the commercial terms in effect at the time of renewal. +Cependant le Concédant reste libre de proposer ce type de services. Les +termes et conditions d'une telle assistance technique et/ou d'une telle +maintenance seront alors déterminés dans un acte séparé. Ces actes de +maintenance et/ou assistance technique n'engageront que la seule +responsabilité du Concédant qui les propose. -Unless otherwise agreed in writing, each renewed Subscription constitutes a new Subscription Term. +7.2 De même, tout Concédant est libre de proposer, sous sa seule +responsabilité, à ses licenciés une garantie, qui n'engagera que lui, +lors de la redistribution du Logiciel et/ou du Logiciel Modifié et ce, +dans les conditions qu'il souhaite. Cette garantie et les modalités +financières de son application feront l'objet d'un acte séparé entre le +Concédant et le Licencié. -## 5.6 Changes to Subscription -The Customer may request an upgrade or downgrade of its Subscription. + Article 8 - RESPONSABILITE -Any modification shall become effective only after acceptance by InterDiode and, where applicable, payment of the corresponding fees. +8.1 Sous réserve des dispositions de l'article 8.2 +<#limite-responsabilite>, le Licencié a la faculté, sous réserve de +prouver la faute du Concédant concerné, de solliciter la réparation du +préjudice direct qu'il subirait du fait du Logiciel et dont il apportera +la preuve. -Changes may include, without limitation: +8.2 La responsabilité du Concédant est limitée aux engagements pris en +application du Contrat et ne saurait être engagée en raison notamment: +(i) des dommages dus à l'inexécution, totale ou partielle, de ses +obligations par le Licencié, (ii) des dommages directs ou indirects +découlant de l'utilisation ou des performances du Logiciel subis par le +Licencié et (iii) plus généralement d'un quelconque dommage indirect. En +particulier, les Parties conviennent expressément que tout préjudice +financier ou commercial (par exemple perte de données, perte de +bénéfices, perte d'exploitation, perte de clientèle ou de commandes, +manque à gagner, trouble commercial quelconque) ou toute action dirigée +contre le Licencié par un tiers, constitue un dommage indirect et +n'ouvre pas droit à réparation par le Concédant. -* increasing or decreasing the Licensed User Limit; -* changing the Licensed Features; -* changing the applicable Support Services. -## 5.7 Evaluation Licenses + Article 9 - GARANTIE -Evaluation, trial, demonstration, or proof-of-concept Subscriptions are limited to the period specified by InterDiode or the applicable Order Form. +9.1 Le Licencié reconnaît que l'état actuel des connaissances +scientifiques et techniques au moment de la mise en circulation du +Logiciel ne permet pas d'en tester et d'en vérifier toutes les +utilisations ni de détecter l'existence d'éventuels défauts. L'attention +du Licencié a été attirée sur ce point sur les risques associés au +chargement, à l'utilisation, la modification et/ou au développement et à +la reproduction du Logiciel qui sont réservés à des utilisateurs avertis. -InterDiode may limit or disable certain Licensed Features during an evaluation Subscription. +Il relève de la responsabilité du Licencié de contrôler, par tous +moyens, l'adéquation du produit à ses besoins, son bon fonctionnement et +de s'assurer qu'il ne causera pas de dommages aux personnes et aux biens. -Evaluation Subscriptions shall not be used for production purposes unless expressly authorized in writing. +9.2 Le Concédant déclare de bonne foi être en droit de concéder +l'ensemble des droits attachés au Logiciel (comprenant notamment les +droits visés à l'article 5 <#etendue>). +9.3 Le Licencié reconnaît que le Logiciel est fourni "en l'état" par le +Concédant sans autre garantie, expresse ou tacite, que celle prévue à +l'article 9.2 <#bonne-foi> et notamment sans aucune garantie sur sa +valeur commerciale, son caractère sécurisé, innovant ou pertinent. -# 7. Intellectual Property Rights +En particulier, le Concédant ne garantit pas que le Logiciel est exempt +d'erreur, qu'il fonctionnera sans interruption, qu'il sera compatible +avec l'équipement du Licencié et sa configuration logicielle ni qu'il +remplira les besoins du Licencié. -## 7.1 Ownership of the Software +9.4 Le Concédant ne garantit pas, de manière expresse ou tacite, que le +Logiciel ne porte pas atteinte à un quelconque droit de propriété +intellectuelle d'un tiers portant sur un brevet, un logiciel ou sur tout +autre droit de propriété. Ainsi, le Concédant exclut toute garantie au +profit du Licencié contre les actions en contrefaçon qui pourraient être +diligentées au titre de l'utilisation, de la modification, et de la +redistribution du Logiciel. Néanmoins, si de telles actions sont +exercées contre le Licencié, le Concédant lui apportera son expertise +technique et juridique pour sa défense. Cette expertise technique et +juridique est déterminée au cas par cas entre le Concédant concerné et +le Licencié dans le cadre d'un protocole d'accord. Le Concédant dégage +toute responsabilité quant à l'utilisation de la dénomination du +Logiciel par le Licencié. Aucune garantie n'est apportée quant à +l'existence de droits antérieurs sur le nom du Logiciel et sur +l'existence d'une marque. -The Software is and shall remain the exclusive property of InterDiode and its licensors. -InterDiode retains all right, title, and interest in and to the Software, the Documentation, the License Keys, the Security Artifacts, and all related intellectual property rights, including but not limited to copyrights, trademarks, trade secrets, know-how, and proprietary technology. + Article 10 - RESILIATION -The Customer acknowledges that it receives only the limited rights expressly granted under this Agreement and that no ownership rights are transferred. +10.1 En cas de manquement par le Licencié aux obligations mises à sa +charge par le Contrat, le Concédant pourra résilier de plein droit le +Contrat trente (30) jours après notification adressée au Licencié et +restée sans effet. -## 7.2 License, Not Sale +10.2 Le Licencié dont le Contrat est résilié n'est plus autorisé à +utiliser, modifier ou distribuer le Logiciel. Cependant, toutes les +licences qu'il aura concédées antérieurement à la résiliation du Contrat +resteront valides sous réserve qu'elles aient été effectuées en +conformité avec le Contrat. -The Software is licensed, not sold. -The purchase of a Subscription, access to the Software, or payment of any fees does not constitute a sale, assignment, or transfer of any intellectual property rights in the Software. + Article 11 - DISPOSITIONS DIVERSES -## 7.3 Customer Data and Customer Materials -The Customer retains all rights, title, and interest in and to: + 11.1 CAUSE EXTERIEURE -* data processed by the Software; -* files transferred using the Software; -* Customer configurations; -* Customer-specific content and materials. +Aucune des Parties ne sera responsable d'un retard ou d'une défaillance +d'exécution du Contrat qui serait dû à un cas de force majeure, un cas +fortuit ou une cause extérieure, telle que, notamment, le mauvais +fonctionnement ou les interruptions du réseau électrique ou de +télécommunication, la paralysie du réseau liée à une attaque +informatique, l'intervention des autorités gouvernementales, les +catastrophes naturelles, les dégâts des eaux, les tremblements de terre, +le feu, les explosions, les grèves et les conflits sociaux, l'état de +guerre... -InterDiode does not acquire ownership rights in Customer Data or Customer Materials as a result of providing the Software or Support Services. +11.2 Le fait, par l'une ou l'autre des Parties, d'omettre en une ou +plusieurs occasions de se prévaloir d'une ou plusieurs dispositions du +Contrat, ne pourra en aucun cas impliquer renonciation par la Partie +intéressée à s'en prévaloir ultérieurement. -## 7.4 Feedback +11.3 Le Contrat annule et remplace toute convention antérieure, écrite +ou orale, entre les Parties sur le même objet et constitue l'accord +entier entre les Parties sur cet objet. Aucune addition ou modification +aux termes du Contrat n'aura d'effet à l'égard des Parties à moins +d'être faite par écrit et signée par leurs représentants dûment habilités. -The Customer may provide suggestions, comments, ideas, improvement requests, or other feedback relating to the Software ("Feedback"). +11.4 Dans l'hypothèse où une ou plusieurs des dispositions du Contrat +s'avèrerait contraire à une loi ou à un texte applicable, existants ou +futurs, cette loi ou ce texte prévaudrait, et les Parties feraient les +amendements nécessaires pour se conformer à cette loi ou à ce texte. +Toutes les autres dispositions resteront en vigueur. De même, la +nullité, pour quelque raison que ce soit, d'une des dispositions du +Contrat ne saurait entraîner la nullité de l'ensemble du Contrat. -To the extent permitted by applicable law, the Customer grants InterDiode a worldwide, perpetual, irrevocable, royalty-free right to use, reproduce, modify, incorporate, and otherwise exploit such Feedback for the purpose of improving or developing InterDiode products and services. -Such Feedback shall not include Customer Confidential Information unless expressly authorized by the Customer. + 11.5 LANGUE -## 7.5 Trademarks +Le Contrat est rédigé en langue française et en langue anglaise, ces +deux versions faisant également foi. -The Customer shall not use the InterDiode name, logos, trademarks, or other brand identifiers except as expressly authorized by InterDiode. -Nothing in this Agreement grants the Customer any right to use InterDiode trademarks. + Article 12 - NOUVELLES VERSIONS DU CONTRAT -## 7.6 Protection of Proprietary Components +12.1 Toute personne est autorisée à copier et distribuer des copies de +ce Contrat. -The Customer shall not remove, alter, or obscure any proprietary notices, copyright notices, trademarks, or other indications of ownership contained in the Software or Documentation. +12.2 Afin d'en préserver la cohérence, le texte du Contrat est protégé +et ne peut être modifié que par les auteurs de la licence, lesquels se +réservent le droit de publier périodiquement des mises à jour ou de +nouvelles versions du Contrat, qui posséderont chacune un numéro +distinct. Ces versions ultérieures seront susceptibles de prendre en +compte de nouvelles problématiques rencontrées par les logiciels libres. -The Customer shall maintain such notices in all authorized copies of the Software. +12.3 Tout Logiciel diffusé sous une version donnée du Contrat ne pourra +faire l'objet d'une diffusion ultérieure que sous la même version du +Contrat ou une version postérieure, sous réserve des dispositions de +l'article 5.3.4 <#compatibilite>. -## 7.7 Third-Party Components -The Software may include Open Source Components or other third-party software. + Article 13 - LOI APPLICABLE ET COMPETENCE TERRITORIALE -Such components remain subject to their respective license terms, which may grant rights or impose obligations that differ from this Agreement. +13.1 Le Contrat est régi par la loi française. Les Parties conviennent +de tenter de régler à l'amiable les différends ou litiges qui +viendraient à se produire par suite ou à l'occasion du Contrat. -This Agreement applies solely to the proprietary components of the Software developed or controlled by InterDiode. +13.2 A défaut d'accord amiable dans un délai de deux (2) mois à compter +de leur survenance et sauf situation relevant d'une procédure d'urgence, +les différends ou litiges seront portés par la Partie la plus diligente +devant les Tribunaux compétents de Paris. -# 8. Software Distribution and Security Artifacts -## 8.1 Distribution -The Software is distributed exclusively through distribution channels designated by InterDiode. +-------------------------------------------------------------------------------- -The Customer shall obtain the Software only from such authorized distribution channels. -InterDiode does not guarantee the authenticity, integrity, or security of Software obtained from any other source. -## 8.2 Software Packages + CeCILL FREE SOFTWARE LICENSE AGREEMENT -The Software may be distributed in one or more packaging formats, including container images, virtual appliances, archives, or other installation packages. +Version 2.1 dated 2013-06-21 -Regardless of the distribution format, all Software Packages remain subject to this Agreement. -## 8.3 Security Artifacts + Notice -InterDiode may provide Security Artifacts for one or more Software Versions. +This Agreement is a Free Software license agreement that is the result +of discussions between its authors in order to ensure compliance with +the two main principles guiding its drafting: -Security Artifacts may include, without limitation: + * firstly, compliance with the principles governing the distribution + of Free Software: access to source code, broad rights granted to users, + * secondly, the election of a governing law, French law, with which it + is conformant, both as regards the law of torts and intellectual + property law, and the protection that it offers to both authors and + holders of the economic rights over software. -* software signatures; -* cryptographic checksums; -* Software Bills of Materials (SBOMs); -* vulnerability disclosures; -* provenance attestations; -* verification keys; -* or other information intended to verify the authenticity, integrity, provenance, or composition of the Software. +The authors of the CeCILL (for Ce[a] C[nrs] I[nria] L[ogiciel] L[ibre]) +license are: -Security Artifacts are provided solely for verification, transparency, compliance, and security purposes. +Commissariat à l'énergie atomique et aux énergies alternatives - CEA, a +public scientific, technical and industrial research establishment, +having its principal place of business at 25 rue Leblanc, immeuble Le +Ponant D, 75015 Paris, France. -## 8.4 Verification +Centre National de la Recherche Scientifique - CNRS, a public scientific +and technological establishment, having its principal place of business +at 3 rue Michel-Ange, 75794 Paris cedex 16, France. -The Customer is encouraged to verify the authenticity and integrity of Software Packages before installation or deployment using the Security Artifacts made available by InterDiode. +Institut National de Recherche en Informatique et en Automatique - +Inria, a public scientific and technological establishment, having its +principal place of business at Domaine de Voluceau, Rocquencourt, BP +105, 78153 Le Chesnay cedex, France. -Failure to perform such verification shall not create any additional obligation or liability for InterDiode. -## 8.5 Modification + Preamble -If the Customer modifies the Software, any Security Artifact provided by InterDiode shall no longer be considered applicable to the modified Software. +The purpose of this Free Software license agreement is to grant users +the right to modify and redistribute the software governed by this +license within the framework of an open source distribution model. -The Customer shall not represent or imply that a modified Software Package has been approved, certified, or authenticated by InterDiode. +The exercising of this right is conditional upon certain obligations for +users so as to preserve this status for all subsequent redistributions. -## 8.6 Redistribution +In consideration of access to the source code and the rights to copy, +modify and redistribute granted by the license, users are provided only +with a limited warranty and the software's author, the holder of the +economic rights, and the successive licensors only have limited liability. -Except as expressly permitted by this Agreement, the Customer shall not redistribute Software Packages or Security Artifacts. +In this respect, the risks associated with loading, using, modifying +and/or developing or reproducing the software by the user are brought to +the user's attention, given its Free Software status, which may make it +complicated to use, with the result that its use is reserved for +developers and experienced professionals having in-depth computer +knowledge. Users are therefore encouraged to load and test the +suitability of the software as regards their requirements in conditions +enabling the security of their systems and/or data to be ensured and, +more generally, to use and operate it in the same conditions of +security. This Agreement may be freely reproduced and published, +provided it is not altered, and that no provisions are either added or +removed herefrom. -## 8.7 Availability +This Agreement may apply to any or all software for which the holder of +the economic rights decides to submit the use thereof to its provisions. -InterDiode may modify, replace, update, or discontinue the publication format of any Software Package or Security Artifact at any time, provided that such changes do not materially reduce the Customer's ability to obtain or verify the Software during an active Subscription. +Frequently asked questions can be found on the official website of the +CeCILL licenses family (http://www.cecill.info/index.en.html) for any +necessary clarification. -## 8.8 Third-Party Distribution Platforms -InterDiode may use third-party distribution platforms or registries to deliver Software Packages. + Article 1 - DEFINITIONS -The use of such platforms does not modify the rights or obligations established by this Agreement. +For the purpose of this Agreement, when the following expressions +commence with a capital letter, they shall have the following meaning: -## 8.9 No Certification +Agreement: means this license agreement, and its possible subsequent +versions and annexes. -Unless expressly stated otherwise in writing by InterDiode, the publication of Security Artifacts does not constitute a certification, warranty, or guarantee that the Software is free from defects, vulnerabilities, or security risks. +Software: means the software in its Object Code and/or Source Code form +and, where applicable, its documentation, "as is" when the Licensee +accepts the Agreement. -Security Artifacts are intended to assist the Customer in assessing and verifying the Software but do not replace the Customer's own security procedures. +Initial Software: means the Software in its Source Code and possibly its +Object Code form and, where applicable, its documentation, "as is" when +it is first distributed under the terms and conditions of the Agreement. -# 9. Open Source Components and Third-Party Software +Modified Software: means the Software modified by at least one +Contribution. -## 9.1 Open Source Components +Source Code: means all the Software's instructions and program lines to +which access is required so as to modify the Software. -The Software may include or depend upon Open Source Components distributed under one or more open source licenses. +Object Code: means the binary files originating from the compilation of +the Source Code. -Such Open Source Components are licensed to the Customer under their respective license terms and not under this Agreement. +Holder: means the holder(s) of the economic rights over the Initial +Software. -Nothing in this Agreement modifies, restricts, or supersedes any rights or obligations granted by the applicable open source licenses. +Licensee: means the Software user(s) having accepted the Agreement. -## 9.2 Third-Party Software +Contributor: means a Licensee having made at least one Contribution. -The Software may also include or interoperate with third-party software, libraries, services, or technologies. +Licensor: means the Holder, or any other individual or legal entity, who +distributes the Software under the Agreement. -Unless expressly stated otherwise, such third-party software remains subject to its own license terms and conditions. +Contribution: means any or all modifications, corrections, translations, +adaptations and/or new functions integrated into the Software by any or +all Contributors, as well as any or all Internal Modules. -InterDiode does not grant any rights in third-party software beyond those granted by the applicable third-party licenses. +Module: means a set of sources files including their documentation that +enables supplementary functions or services in addition to those offered +by the Software. -## 9.3 License Notices +External Module: means any or all Modules, not derived from the +Software, so that this Module and the Software run in separate address +spaces, with one calling the other when they are run. -InterDiode shall make available the copyright notices and license information required by the applicable open source licenses. +Internal Module: means any or all Module, connected to the Software so +that they both execute in the same address space. -Such information may be provided with the Software, within the Documentation, or through another reasonable means designated by InterDiode. +GNU GPL: means the GNU General Public License version 2 or any +subsequent version, as published by the Free Software Foundation Inc. -## 9.4 Customer Obligations +GNU Affero GPL: means the GNU Affero General Public License version 3 or +any subsequent version, as published by the Free Software Foundation Inc. -The Customer agrees to comply with the license terms applicable to any Open Source Components distributed with the Software. +EUPL: means the European Union Public License version 1.1 or any +subsequent version, as published by the European Commission. -Nothing in this Agreement shall be interpreted as limiting the Customer's rights under an applicable open source license with respect to an Open Source Component. +Parties: mean both the Licensee and the Licensor. -## 9.5 No Effect on Proprietary Components +These expressions may be used both in singular and plural form. -The inclusion of Open Source Components within the Software does not alter the proprietary nature of the Software as a whole. -Except for the Open Source Components themselves, all proprietary components of the Software remain exclusively subject to this Agreement. + Article 2 - PURPOSE -## 9.6 Updates to Third-Party Components +The purpose of the Agreement is the grant by the Licensor to the +Licensee of a non-exclusive, transferable and worldwide license for the +Software as set forth in Article 5 <#scope> hereinafter for the whole +term of the protection granted by the rights over said Software. -InterDiode may replace, update, or remove Open Source Components or other third-party software at its discretion, provided that such changes do not materially reduce the functionality of the Software purchased by the Customer. -## 9.7 Disclaimer + Article 3 - ACCEPTANCE -InterDiode makes no representation or warranty regarding third-party software except as expressly provided by the applicable third-party license or required by applicable law. +3.1 The Licensee shall be deemed as having accepted the terms and +conditions of this Agreement upon the occurrence of the first of the +following events: -# 10. Confidentiality + * (i) loading the Software by any or all means, notably, by + downloading from a remote server, or by loading from a physical medium; + * (ii) the first time the Licensee exercises any of the rights granted + hereunder. -## 10.1 Confidential Information +3.2 One copy of the Agreement, containing a notice relating to the +characteristics of the Software, to the limited warranty, and to the +fact that its use is restricted to experienced users has been provided +to the Licensee prior to its acceptance as set forth in Article 3.1 +<#accepting> hereinabove, and the Licensee hereby acknowledges that it +has read and understood it. -For the purposes of this Agreement, **Confidential Information** means any non-public information disclosed by one Party ("Disclosing Party") to the other Party ("Receiving Party"), whether in written, electronic, oral, visual, or any other form, that is identified as confidential or that should reasonably be understood to be confidential given its nature or the circumstances of its disclosure. -Confidential Information includes, without limitation: + Article 4 - EFFECTIVE DATE AND TERM -* the Software; -* the Documentation; -* License Keys; -* Security Artifacts not intended for public distribution; -* technical, commercial, financial, and business information; -* product roadmaps; -* security information; -* pricing information; -* customer information; -* and any other proprietary information disclosed under this Agreement. -## 10.2 Exclusions + 4.1 EFFECTIVE DATE -Confidential Information does not include information that the Receiving Party can demonstrate: +The Agreement shall become effective on the date when it is accepted by +the Licensee as set forth in Article 3.1 <#accepting>. -a. was publicly available without breach of this Agreement; -b. was lawfully known to the Receiving Party before disclosure; + 4.2 TERM -c. was lawfully obtained from a third party without restriction on disclosure; +The Agreement shall remain in force for the entire legal term of +protection of the economic rights over the Software. -d. was independently developed without reference to the Disclosing Party's Confidential Information. -## 10.3 Obligations + Article 5 - SCOPE OF RIGHTS GRANTED -The Receiving Party shall: +The Licensor hereby grants to the Licensee, who accepts, the following +rights over the Software for any or all use, and for the term of the +Agreement, on the basis of the terms and conditions set forth hereinafter. -* use Confidential Information solely for the purposes of performing or exercising its rights under this Agreement; -* protect Confidential Information with at least the same degree of care it uses to protect its own confidential information of a similar nature, and in no event less than reasonable care; -* disclose Confidential Information only to employees, contractors, advisors, or Affiliates who have a legitimate need to know such information and who are bound by confidentiality obligations no less protective than those contained in this Agreement. +Besides, if the Licensor owns or comes to own one or more patents +protecting all or part of the functions of the Software or of its +components, the Licensor undertakes not to enforce the rights granted by +these patents against successive Licensees using, exploiting or +modifying the Software. If these patents are transferred, the Licensor +undertakes to have the transferees subscribe to the obligations set +forth in this paragraph. -The Receiving Party remains responsible for any breach of this Section by persons to whom it discloses Confidential Information. -## 10.4 Required Disclosure + 5.1 RIGHT OF USE -The Receiving Party may disclose Confidential Information where required by applicable law, regulation, or a valid court or governmental order. +The Licensee is authorized to use the Software, without any limitation +as to its fields of application, with it being hereinafter specified +that this comprises: -Where legally permitted, the Receiving Party shall promptly notify the Disclosing Party before making such disclosure in order to allow the Disclosing Party to seek appropriate protective measures. + 1. permanent or temporary reproduction of all or part of the Software + by any or all means and in any or all form. -The Receiving Party shall disclose only the Confidential Information strictly required to comply with the applicable legal obligation. + 2. loading, displaying, running, or storing the Software on any or all + medium. -## 10.5 Return or Destruction + 3. entitlement to observe, study or test its operation so as to + determine the ideas and principles behind any or all constituent + elements of said Software. This shall apply when the Licensee + carries out any or all loading, displaying, running, transmission or + storage operation as regards the Software, that it is entitled to + carry out hereunder. -Upon written request of the Disclosing Party, or upon termination of this Agreement, the Receiving Party shall promptly return or securely destroy the Confidential Information in its possession, except where retention is required by applicable law or for legitimate archival or backup purposes. -Any retained Confidential Information shall remain subject to this Section for as long as it is retained. + 5.2 ENTITLEMENT TO MAKE CONTRIBUTIONS -## 10.6 Residual Knowledge +The right to make Contributions includes the right to translate, adapt, +arrange, or make any or all modifications to the Software, and the right +to reproduce the resulting software. -Nothing in this Agreement prevents either Party from using information retained in the unaided memory of its personnel, provided that such use does not result in the disclosure of Confidential Information or the infringement of any intellectual property rights. +The Licensee is authorized to make any or all Contributions to the +Software provided that it includes an explicit notice that it is the +author of said Contribution and indicates the date of the creation thereof. -## 10.7 Duration -The obligations set forth in this Section commence upon disclosure of the Confidential Information and survive the termination or expiration of this Agreement for a period of **five (5) years**, except that: + 5.3 RIGHT OF DISTRIBUTION -* trade secrets shall remain protected for as long as they qualify as trade secrets under applicable law; -* License Keys and other security credentials shall remain confidential for so long as they remain valid. +In particular, the right of distribution includes the right to publish, +transmit and communicate the Software to the general public on any or +all medium, and by any or all means, and the right to market, either in +consideration of a fee, or free of charge, one or more copies of the +Software by any means. -## 10.8 Injunctive Relief +The Licensee is further authorized to distribute copies of the modified +or unmodified Software to third parties according to the terms and +conditions set forth hereinafter. -Each Party acknowledges that unauthorized disclosure or use of Confidential Information may cause irreparable harm for which monetary damages alone may be insufficient. -Accordingly, the Disclosing Party may seek injunctive or other equitable relief, in addition to any other remedies available under applicable law. + 5.3.1 DISTRIBUTION OF SOFTWARE WITHOUT MODIFICATION -# 11. Data Protection and Privacy +The Licensee is authorized to distribute true copies of the Software in +Source Code or Object Code form, provided that said distribution +complies with all the provisions of the Agreement and is accompanied by: -## 11.1 Customer Responsibility + 1. a copy of the Agreement, -The Customer is solely responsible for the data processed, stored, transmitted, or otherwise handled through the Software. + 2. a notice relating to the limitation of both the Licensor's warranty + and liability as set forth in Articles 8 and 9, -The Customer shall ensure that its use of the Software complies with all applicable laws and regulations relating to privacy, data protection, cybersecurity, and information security. +and that, in the event that only the Object Code of the Software is +redistributed, the Licensee allows effective access to the full Source +Code of the Software for a period of at least three years from the +distribution of the Software, it being understood that the additional +acquisition cost of the Source Code shall not exceed the cost of the +data transfer. -## 11.2 No Processing of Customer Data -Unless expressly agreed in writing as part of Support Services or other professional services, InterDiode does not access, process, host, store, or control Customer Data. + 5.3.2 DISTRIBUTION OF MODIFIED SOFTWARE -The Customer remains solely responsible for determining the purposes and means of processing Customer Data. +When the Licensee makes a Contribution to the Software, the terms and +conditions for the distribution of the resulting Modified Software +become subject to all the provisions of this Agreement. -## 11.3 Personal Data +The Licensee is authorized to distribute the Modified Software, in +source code or object code form, provided that said distribution +complies with all the provisions of the Agreement and is accompanied by: -Where the Customer processes personal data using the Software, the Customer remains solely responsible for complying with applicable data protection laws, including obtaining any necessary authorizations, notices, or consents. + 1. a copy of the Agreement, -Nothing in this Agreement shall be interpreted as appointing InterDiode as a processor or sub-processor of Customer Data. + 2. a notice relating to the limitation of both the Licensor's warranty + and liability as set forth in Articles 8 and 9, -## 11.4 Support Services +and, in the event that only the object code of the Modified Software is +redistributed, -Where Support Services require access to Customer systems or Customer Data, the Parties shall cooperate to minimize such access. + 3. a note stating the conditions of effective access to the full source + code of the Modified Software for a period of at least three years + from the distribution of the Modified Software, it being understood + that the additional acquisition cost of the source code shall not + exceed the cost of the data transfer. -The Customer may remove, anonymize, or pseudonymize data before providing it to InterDiode. -Unless otherwise agreed in writing, InterDiode shall access only the information reasonably necessary to provide the requested Support Services. + 5.3.3 DISTRIBUTION OF EXTERNAL MODULES -## 11.5 Privacy Notice +When the Licensee has developed an External Module, the terms and +conditions of this Agreement do not apply to said External Module, that +may be distributed under a separate license agreement. -The processing of personal data by InterDiode in connection with licensing, support, account management, or its website is governed by InterDiode's Privacy Notice, as amended from time to time. + 5.3.4 COMPATIBILITY WITH OTHER LICENSES -# 12. Limited Warranty and Disclaimer +The Licensee can include a code that is subject to the provisions of one +of the versions of the GNU GPL, GNU Affero GPL and/or EUPL in the +Modified or unmodified Software, and distribute that entire code under +the terms of the same version of the GNU GPL, GNU Affero GPL and/or EUPL. -## 12.1 Limited Warranty +The Licensee can include the Modified or unmodified Software in a code +that is subject to the provisions of one of the versions of the GNU GPL, +GNU Affero GPL and/or EUPL and distribute that entire code under the +terms of the same version of the GNU GPL, GNU Affero GPL and/or EUPL. -During an active Subscription Term, InterDiode warrants that the Software will substantially conform to the applicable Documentation when used in accordance with this Agreement. -## 12.2 Warranty Remedy + Article 6 - INTELLECTUAL PROPERTY -If the Software does not materially conform to the Documentation, the Customer shall notify InterDiode and provide sufficient information to allow InterDiode to investigate the reported non-conformity. -InterDiode shall use commercially reasonable efforts to: + 6.1 OVER THE INITIAL SOFTWARE -* correct the non-conformity; -* provide a workaround; or -* provide a replacement Software Version that substantially conforms to the Documentation. +The Holder owns the economic rights over the Initial Software. Any or +all use of the Initial Software is subject to compliance with the terms +and conditions under which the Holder has elected to distribute its work +and no one shall be entitled to modify the terms and conditions for the +distribution of said Initial Software. -If InterDiode is unable to provide a reasonable remedy within a reasonable period, the Customer may terminate the affected Subscription and receive a refund of any prepaid Fees corresponding to the unused portion of the terminated Subscription Term. +The Holder undertakes that the Initial Software will remain ruled at +least by this Agreement, for the duration set forth in Article 4.2 <#term>. -The remedies set forth in this Section constitute the Customer's exclusive remedies for breach of the express warranty set forth in Section 12.1, subject to applicable law. -## 12.3 Warranty Exclusions + 6.2 OVER THE CONTRIBUTIONS -The warranty set forth in Section 12.1 does not apply to the extent that a non-conformity results from: +The Licensee who develops a Contribution is the owner of the +intellectual property rights over this Contribution as defined by +applicable law. -* use of the Software contrary to the Documentation or this Agreement; -* modification of the Software by anyone other than InterDiode; -* use of an unsupported Software Version or operating environment; -* third-party software, hardware, infrastructure, or services; -* misuse, negligence, accident, or abnormal operating conditions; -* failure to install a Software Version or security update made available by InterDiode where such installation would reasonably have prevented the reported issue. -## 12.4 Disclaimer + 6.3 OVER THE EXTERNAL MODULES -Except for the express warranty set forth in Section 12.1, the Software and Documentation are provided "AS IS" and "AS AVAILABLE" to the maximum extent permitted by applicable law. +The Licensee who develops an External Module is the owner of the +intellectual property rights over this External Module as defined by +applicable law and is free to choose the type of agreement that shall +govern its distribution. -InterDiode disclaims all other warranties, representations, and conditions, whether express, implied, statutory, or otherwise, including any implied warranty of merchantability, satisfactory quality, fitness for a particular purpose, non-infringement, or uninterrupted or error-free operation, to the extent such exclusions are permitted by applicable law. -## 12.5 No Guarantee of Error-Free Operation + 6.4 JOINT PROVISIONS -InterDiode does not warrant that: +The Licensee expressly undertakes: -* the Software will operate without interruption; -* the Software will be free from defects, vulnerabilities, or errors; -* every defect or vulnerability will be identified or corrected; -* the Software will satisfy all of the Customer's requirements or expectations. + 1. not to remove, or modify, in any manner, the intellectual property + notices attached to the Software; -## 12.6 Customer Responsibility + 2. to reproduce said notices, in an identical manner, in the copies of + the Software modified or not. -The Customer is responsible for: +The Licensee undertakes not to directly or indirectly infringe the +intellectual property rights on the Software of the Holder and/or +Contributors, and to take, where applicable, vis-à-vis its staff, any +and all measures required to ensure respect of said intellectual +property rights of the Holder and/or Contributors. -* determining whether the Software is suitable for its intended use; -* deploying and configuring the Software appropriately; -* maintaining appropriate backups and disaster recovery procedures; -* securing the infrastructure on which the Software operates; -* maintaining appropriate access controls and credentials; -* ensuring that its use of the Software complies with applicable laws and regulations. -## 12.7 Security + Article 7 - RELATED SERVICES -InterDiode applies commercially reasonable practices to the development and maintenance of the Software. +7.1 Under no circumstances shall the Agreement oblige the Licensor to +provide technical assistance or maintenance services for the Software. -However, no software can be guaranteed to be completely secure or free from vulnerabilities. +However, the Licensor is entitled to offer this type of services. The +terms and conditions of such technical assistance, and/or such +maintenance, shall be set forth in a separate instrument. Only the +Licensor offering said maintenance and/or technical assistance services +shall incur liability therefor. -Security Artifacts provided by InterDiode are intended to assist the Customer in assessing the security, integrity, provenance, and composition of the Software. They do not constitute a guarantee that the Software is free from vulnerabilities, malicious code, or security defects. +7.2 Similarly, any Licensor is entitled to offer to its licensees, under +its sole responsibility, a warranty, that shall only be binding upon +itself, for the redistribution of the Software and/or the Modified +Software, under terms and conditions that it is free to decide. Said +warranty, and the financial terms and conditions of its application, +shall be subject of a separate instrument executed between the Licensor +and the Licensee. -## 12.8 Third-Party Components and Services -InterDiode does not warrant third-party software, hardware, infrastructure, cloud services, networks, or other components that are not under the control of InterDiode. + Article 8 - LIABILITY -The Software may interact with or depend upon third-party components or services, and their availability and operation may be outside InterDiode's control. +8.1 Subject to the provisions of Article 8.2, the Licensee shall be +entitled to claim compensation for any direct loss it may have suffered +from the Software as a result of a fault on the part of the relevant +Licensor, subject to providing evidence thereof. -## 12.9 Mandatory Rights +8.2 The Licensor's liability is limited to the commitments made under +this Agreement and shall not be incurred as a result of in particular: +(i) loss due the Licensee's total or partial failure to fulfill its +obligations, (ii) direct or consequential loss that is suffered by the +Licensee due to the use or performance of the Software, and (iii) more +generally, any consequential loss. In particular the Parties expressly +agree that any or all pecuniary or business loss (i.e. loss of data, +loss of profits, operating loss, loss of customers or orders, +opportunity cost, any disturbance to business activities) or any or all +legal proceedings instituted against the Licensee by a third party, +shall constitute consequential loss and shall not provide entitlement to +any or all compensation from the Licensor. -Nothing in this Section excludes or limits any warranty, guarantee, right, or remedy that cannot lawfully be excluded or limited under applicable law. + Article 9 - WARRANTY -# 13. Limitation of Liability +9.1 The Licensee acknowledges that the scientific and technical +state-of-the-art when the Software was distributed did not enable all +possible uses to be tested and verified, nor for the presence of +possible defects to be detected. In this respect, the Licensee's +attention has been drawn to the risks associated with loading, using, +modifying and/or developing and reproducing the Software which are +reserved for experienced users. -## 13.1 Exclusion of Certain Damages +The Licensee shall be responsible for verifying, by any or all means, +the suitability of the product for its requirements, its good working +order, and for ensuring that it shall not cause damage to either persons +or properties. -To the maximum extent permitted by applicable law, neither Party shall be liable to the other for any indirect, incidental, consequential, special, exemplary, or punitive damages arising out of or relating to this Agreement, including, without limitation: +9.2 The Licensor hereby represents, in good faith, that it is entitled +to grant all the rights over the Software (including in particular the +rights set forth in Article 5 <#scope>). -* loss of profits; -* loss of revenue; -* loss of business opportunities; -* loss of goodwill; -* loss of anticipated savings; -* business interruption; -* loss or corruption of data; -* costs of procuring substitute products or services. +9.3 The Licensee acknowledges that the Software is supplied "as is" by +the Licensor without any other express or tacit warranty, other than +that provided for in Article 9.2 <#good-faith> and, in particular, +without any warranty as to its commercial value, its secured, safe, +innovative or relevant nature. -This exclusion applies regardless of the legal theory upon which the claim is based and even if the Party has been advised of the possibility of such damages. +Specifically, the Licensor does not warrant that the Software is free +from any error, that it will operate without interruption, that it will +be compatible with the Licensee's own equipment and software +configuration, nor that it will meet the Licensee's requirements. + +9.4 The Licensor does not either expressly or tacitly warrant that the +Software does not infringe any third party intellectual property right +relating to a patent, software or any other property right. Therefore, +the Licensor disclaims any and all liability towards the Licensee +arising out of any or all proceedings for infringement that may be +instituted in respect of the use, modification and redistribution of the +Software. Nevertheless, should such proceedings be instituted against +the Licensee, the Licensor shall provide it with technical and legal +expertise for its defense. Such technical and legal expertise shall be +decided on a case-by-case basis between the relevant Licensor and the +Licensee pursuant to a memorandum of understanding. The Licensor +disclaims any and all liability as regards the Licensee's use of the +name of the Software. No warranty is given as regards the existence of +prior rights over the name of the Software or as regards the existence +of a trademark. + + + Article 10 - TERMINATION + +10.1 In the event of a breach by the Licensee of its obligations +hereunder, the Licensor may automatically terminate this Agreement +thirty (30) days after notice has been sent to the Licensee and has +remained ineffective. + +10.2 A Licensee whose Agreement is terminated shall no longer be +authorized to use, modify or distribute the Software. However, any +licenses that it may have granted prior to termination of the Agreement +shall remain valid subject to their having been granted in compliance +with the terms and conditions hereof. + + + Article 11 - MISCELLANEOUS + + + 11.1 EXCUSABLE EVENTS + +Neither Party shall be liable for any or all delay, or failure to +perform the Agreement, that may be attributable to an event of force +majeure, an act of God or an outside cause, such as defective +functioning or interruptions of the electricity or telecommunications +networks, network paralysis following a virus attack, intervention by +government authorities, natural disasters, water damage, earthquakes, +fire, explosions, strikes and labor unrest, war, etc. + +11.2 Any failure by either Party, on one or more occasions, to invoke +one or more of the provisions hereof, shall under no circumstances be +interpreted as being a waiver by the interested Party of its right to +invoke said provision(s) subsequently. + +11.3 The Agreement cancels and replaces any or all previous agreements, +whether written or oral, between the Parties and having the same +purpose, and constitutes the entirety of the agreement between said +Parties concerning said purpose. No supplement or modification to the +terms and conditions hereof shall be effective as between the Parties +unless it is made in writing and signed by their duly authorized +representatives. + +11.4 In the event that one or more of the provisions hereof were to +conflict with a current or future applicable act or legislative text, +said act or legislative text shall prevail, and the Parties shall make +the necessary amendments so as to comply with said act or legislative +text. All other provisions shall remain effective. Similarly, invalidity +of a provision of the Agreement, for any reason whatsoever, shall not +cause the Agreement as a whole to be invalid. + + + 11.5 LANGUAGE + +The Agreement is drafted in both French and English and both versions +are deemed authentic. + + + Article 12 - NEW VERSIONS OF THE AGREEMENT + +12.1 Any person is authorized to duplicate and distribute copies of this +Agreement. + +12.2 So as to ensure coherence, the wording of this Agreement is +protected and may only be modified by the authors of the License, who +reserve the right to periodically publish updates or new versions of the +Agreement, each with a separate number. These subsequent versions may +address new issues encountered by Free Software. + +12.3 Any Software distributed under a given version of the Agreement may +only be subsequently distributed under the same version of the Agreement +or a subsequent version, subject to the provisions of Article 5.3.4 +<#compatibility>. -## 13.2 Aggregate Liability -To the maximum extent permitted by applicable law, the aggregate liability of InterDiode arising out of or relating to this Agreement shall not exceed the total Fees actually paid by the Customer under this Agreement during the twelve (12) months immediately preceding the event giving rise to the claim. + Article 13 - GOVERNING LAW AND JURISDICTION + +13.1 The Agreement is governed by French law. The Parties agree to +endeavor to seek an amicable solution to any disagreements or disputes +that may arise during the performance of the Agreement. -## 13.3 Exceptions - -The limitations set forth in this Section shall not apply to: - -* liability that cannot be limited or excluded under applicable law; -* fraud or fraudulent misrepresentation; -* wilful misconduct or gross negligence, where applicable law prohibits limitation of liability; -* infringement of the intellectual property rights of the other Party; -* breaches of the confidentiality obligations set forth in Section 10. - -## 13.4 Customer Responsibility - -The Customer acknowledges that the Software is one component of its information system. - -The Customer remains solely responsible for: - -* validating the suitability of the Software for its intended use; -* implementing appropriate operational procedures; -* maintaining appropriate backups; -* ensuring the security of its infrastructure; -* verifying the results produced by the Software where appropriate. - -## 13.5 Allocation of Risk - -The Parties acknowledge that the Fees reflect the allocation of risks established by this Agreement. - -The limitations of liability contained in this Section constitute an essential basis of the bargain between the Parties and shall apply even if any limited remedy fails of its essential purpose. - -# 14. Export Control and Sanctions - -## 14.1 Compliance with Applicable Laws - -Each Party shall comply with all applicable export control, import, trade sanctions, and economic sanctions laws and regulations in connection with its performance under this Agreement. - -## 14.2 Customer Responsibility - -The Customer is responsible for determining whether its acquisition, deployment, transfer, export, re-export, or use of the Software is subject to any applicable export control or sanctions requirements. - -The Customer shall obtain all licenses, authorizations, approvals, or other permissions required for its use, transfer, export, or re-export of the Software. - -## 14.3 Restricted Parties and Destinations - -The Customer shall not use, transfer, export, or re-export the Software in violation of applicable sanctions or export control laws. - -The Customer shall not knowingly make the Software available to any person or entity subject to applicable sanctions or restrictions where such provision is prohibited by law. - -## 14.4 Prohibited Uses - -The Customer shall not use the Software for any purpose prohibited by applicable export control or sanctions laws. - -Nothing in this Agreement requires either Party to perform an obligation where doing so would violate applicable law. - -## 14.5 Changes in Applicable Requirements - -The Parties acknowledge that export control and sanctions requirements may change during the Subscription Term. - -Each Party shall remain responsible for complying with the laws and regulations applicable to its own activities at the relevant time. - -## 14.6 Suspension - -InterDiode may suspend access to the Software or terminate the affected Subscription to the extent reasonably necessary to comply with applicable export control or sanctions laws. - -Where legally permitted, InterDiode shall provide reasonable notice to the Customer before taking such action. - -## 14.7 No Circumvention - -The Customer shall not use intermediaries, technical measures, corporate structures, or other arrangements for the purpose of circumventing applicable export control or sanctions requirements. - -# 15. Term and Termination - -## 15.1 Term - -This Agreement shall commence on the Effective Date and shall remain in effect for as long as the Customer has an active Subscription, unless terminated earlier in accordance with this Agreement. - -Each Subscription shall remain valid for the Subscription Term specified in the applicable Order Form. - -## 15.2 Renewal - -A Subscription may be renewed for additional Subscription Terms in accordance with the applicable Order Form or renewal terms. - -Unless otherwise agreed in writing, renewal shall be subject to the terms and Fees applicable at the time of renewal. - -## 15.3 Termination for Material Breach - -Either Party may terminate this Agreement or the affected Subscription if the other Party materially breaches this Agreement and fails to cure such breach within thirty (30) days after receiving written notice of the breach. - -Where the breach is incapable of cure, the non-breaching Party may terminate the Agreement or affected Subscription immediately upon written notice. - -## 15.4 Termination for Non-Payment - -InterDiode may suspend the Customer's access to the Software and Support Services if undisputed Fees remain unpaid after their due date. - -If the Customer fails to pay such Fees within thirty (30) days after receiving written notice of non-payment, InterDiode may terminate the affected Subscription. - -## 15.5 Insolvency - -Either Party may terminate this Agreement to the extent permitted by applicable law if the other Party becomes insolvent, enters liquidation, ceases substantially all of its business activities, or becomes subject to insolvency proceedings. - -## 15.6 Expiration - -Upon expiration of the Subscription Term without renewal, the License automatically expires. - -The Customer shall cease all use of the Software and shall not access, execute, deploy, or otherwise use the Software after the end of the Subscription Term. - -## 15.7 Effect of Termination - -Upon termination or expiration of the Agreement or an applicable Subscription: - -* all rights granted to the Customer under the affected Subscription shall immediately cease; -* the Customer shall cease using the Software; -* the Customer shall cease accessing any Support Services associated with the terminated Subscription; -* the Customer shall delete or destroy all copies of the Software in its possession or control, except where retention is required by applicable law. - -Termination or expiration does not affect rights or obligations that accrued before the effective date of termination. - -## 15.8 Customer Data - -Termination or expiration of the Subscription does not transfer ownership of Customer Data to InterDiode. - -The Customer remains responsible for retrieving and preserving its Customer Data before the end of the Subscription Term. - -Unless otherwise agreed in writing, InterDiode has no obligation to retain Customer Data after termination or expiration of the Subscription. - -## 15.9 Survival - -The provisions that by their nature are intended to survive termination or expiration shall remain in effect, including provisions relating to intellectual property, confidentiality, limitations of liability, applicable law, and dispute resolution. - -# 16. Governing Law and Dispute Resolution - -## 16.1 Governing Law - -This Agreement shall be governed by and construed in accordance with the laws of France, without regard to its conflict of law principles. - -The application of the United Nations Convention on Contracts for the International Sale of Goods (CISG) is expressly excluded. - -## 16.2 Amicable Resolution - -The Parties shall use reasonable efforts to resolve any dispute, controversy, or claim arising out of or relating to this Agreement through good-faith negotiations before commencing formal legal proceedings. - -A Party seeking to initiate such discussions shall provide written notice describing the nature of the dispute and the relief sought. - -## 16.3 Jurisdiction - -Any dispute arising out of or relating to this Agreement that cannot be resolved amicably shall be submitted to the exclusive jurisdiction of the competent courts of **Paris, France**, subject to any mandatory jurisdictional rules applicable to the Parties. - -## 16.4 Injunctive Relief - -Nothing in this Section prevents either Party from seeking urgent or interim measures from any court of competent jurisdiction where necessary to prevent unauthorized use or disclosure of Confidential Information, infringement of intellectual property rights, or other irreparable harm. - -# 17. Miscellaneous - -## 17.1 Entire Agreement - -This Agreement, together with the applicable Order Form and any documents expressly incorporated by reference, constitutes the entire agreement between the Parties concerning its subject matter and supersedes all prior or contemporaneous agreements, proposals, representations, and communications relating to that subject matter. - -## 17.2 Order of Precedence - -In the event of a conflict between documents forming part of the contractual relationship, the following order of precedence shall apply, unless expressly agreed otherwise: - -1. a separately executed agreement signed by both Parties; -2. the applicable Order Form; -3. this Agreement; -4. documents incorporated by reference, including policies and documentation. - -## 17.3 Amendments - -InterDiode may update this Agreement for future Subscription Terms. - -No amendment shall materially reduce the Customer's rights during an already-paid Subscription Term unless required by applicable law. - -Any amendment to an existing Subscription that materially changes the Customer's contractual rights shall require the Customer's acceptance where required by applicable law. - -## 17.4 Assignment - -The Customer may not assign or transfer this Agreement, in whole or in part, without the prior written consent of InterDiode, except where such assignment is made as part of a merger, reorganization, or transfer of substantially all of the Customer's assets and the assignee agrees to be bound by this Agreement. - -InterDiode may assign this Agreement to an Affiliate or in connection with a merger, reorganization, corporate restructuring, or sale of all or substantially all of its business or assets relating to the Software. - -## 17.5 Independent Parties - -The Parties are independent contractors. - -Nothing in this Agreement creates a partnership, joint venture, agency, employment relationship, fiduciary relationship, or franchise between the Parties. - -Neither Party has authority to bind the other Party or incur obligations on its behalf. - -## 17.6 Force Majeure - -Neither Party shall be liable for failure or delay in performing an obligation under this Agreement to the extent caused by circumstances beyond its reasonable control, including natural disasters, war, terrorism, civil unrest, government action, widespread telecommunications or Internet failures, labor disputes, epidemics, pandemics, or failures of critical infrastructure. - -The affected Party shall use reasonable efforts to mitigate the effects of the force majeure event and resume performance as soon as reasonably practicable. - -## 17.7 Severability - -If any provision of this Agreement is held to be invalid, illegal, or unenforceable, that provision shall be enforced to the maximum extent permitted by law and the remaining provisions shall remain in full force and effect. - -## 17.8 Waiver - -A failure or delay by either Party to exercise any right or remedy under this Agreement shall not constitute a waiver of that right or remedy. - -A waiver shall be effective only if made in writing and shall apply only to the specific circumstance for which it was given. - -## 17.9 Notices - -Any notice required under this Agreement shall be provided in writing to the contact details specified in the applicable Order Form or, where applicable, through the official contact information published by InterDiode. - -Notices relating to termination, material breach, or legal claims shall be sent through a method that provides reasonable evidence of delivery. - -## 17.10 Language - -This Agreement may be made available in multiple languages. - -In the event of any conflict between language versions, the English version shall prevail, unless otherwise required by applicable law. - -## 17.11 Electronic Acceptance - -The Customer may accept this Agreement electronically, including through an online purchasing process, electronic signature, or other mechanism that records the Customer's acceptance. - -Electronic acceptance shall have the same contractual effect as a handwritten signature to the extent permitted by applicable law. - -## 17.12 No Third-Party Beneficiaries - -Except where expressly stated otherwise in this Agreement, this Agreement does not create any rights or remedies for any person or entity that is not a Party to it. - - -Third party licenses -==================== - -Python ------- -- autobahn (MIT): https://autobahn.readthedocs.io/ -- celery (BSD-3-Clause): https://docs.celeryq.dev/ -- cryptography (Apache-2.0 OR BSD-3-Clause): https://github.com/pyca/cryptography -- daphne (BSD License): https://github.com/django/daphne -- defusedxml (Python Software Foundation License): https://github.com/tiran/defusedxml -- df-config (CEA CNRS Inria Logiciel Libre License, version 2.1 (CeCILL-2.1)): https://pypi.org/project/df-config/ -- df-websockets (CEA CNRS Inria Logiciel Libre License, version 2.1 (CeCILL-2.1)): https://pypi.org/project/df-websockets/ -- django (BSD-3-Clause): https://www.djangoproject.com/ -- django-bootstrap5 (BSD-3-Clause): https://github.com/zostera/django-bootstrap5 -- django-celery-beat (BSD License): https://github.com/celery/django-celery-beat -- django-csp (BSD License): http://github.com/mozilla/django-csp -- django-minio-storage (MIT OR Apache-2.0): https://github.com/py-pa/django-minio-storage -- django-pipeline (MIT License): https://github.com/jazzband/django-pipeline/ -- django-probes (BSD-3-Clause): https://github.com/painless-software/django-probes -- django-prometheus (Apache Software License): http://github.com/korfuri/django-prometheus -- django-pygmentify (BSD License): https://github.com/richardcornish/django-pygmentify -- django-timezone-field (BSD License): https://pypi.org/project/django-timezone-field/ -- feedparser (BSD-2-Clause): https://github.com/kurtmckee/feedparser -- filelock (MIT): https://github.com/tox-dev/py-filelock -- hairgap (CECILL-B): https://pypi.org/project/hairgap/ -- hairgap-binaries (GNU General Public License v3 (GPLv3)): https://pypi.org/project/hairgap-binaries/ -- html5lib (MIT License): https://github.com/html5lib/html5lib-python -- jsmin (MIT License): https://github.com/tikitu/jsmin/ -- lxml (BSD-3-Clause): https://lxml.de/ -- markdown (BSD-3-Clause): https://Python-Markdown.github.io/ -- packaging (Apache-2.0 OR BSD-2-Clause): https://pypi.org/project/packaging/ -- pillow (MIT-CMU): https://python-pillow.github.io -- playwright (Apache-2.0): https://github.com/Microsoft/playwright-python -- prometheus-client (Apache-2.0 AND BSD-2-Clause): https://github.com/prometheus/client_python -- psycopg (LGPL-3.0-only): https://psycopg.org/ -- psycopg-pool (LGPL-3.0-only): https://psycopg.org/ -- pygments (BSD-2-Clause): https://pygments.org -- pymysql (MIT): https://pypi.org/project/pymysql/ -- pyopenssl (Apache Software License): https://pyopenssl.org/ -- python-gnupg (BSD License): https://github.com/vsajip/python-gnupg -- python-logging-loki (MIT License): https://github.com/greyzmeem/python-logging-loki -- python-zim (MIT License): https://github.com/imaybeabitshy/pyzim -- pyyaml (MIT License): https://pyyaml.org/ -- redis (MIT): https://github.com/redis/redis-py -- requests (Apache Software License): https://pypi.org/project/requests/ -- requests-file (Apache Software License): https://codeberg.org/dashea/requests-file -- rubymarshal (WTFPL): https://pypi.org/project/rubymarshal/ -- sentry-sdk (MIT): https://github.com/getsentry/sentry-python -- tqdm (MPL-2.0 AND MIT): https://tqdm.github.io -- tweepy (MIT License): https://tweepy.org -- urllib3 (MIT): https://pypi.org/project/urllib3/ -- websockets (BSD-3-Clause): https://github.com/python-websockets/websockets -- whitenoise (MIT): https://pypi.org/project/whitenoise/ -- wsproto (MIT): https://github.com/python-hyper/wsproto/ -- yt-dlp (Unlicense): https://pypi.org/project/yt-dlp/ -- yt-dlp-ejs (Unlicense AND MIT AND ISC): https://pypi.org/project/yt-dlp-ejs/ -- zstandard (BSD-3-Clause): https://github.com/indygreg/python-zstandard - -Javascript ----------- -- @fortawesome/fontawesome-free (CC-BY-4.0 AND OFL-1.1 AND MIT): https://fontawesome.com -- @popperjs/core (MIT): https://www.npmjs.com/package/@popperjs/core -- bootstrap (MIT): https://getbootstrap.com/ -- bootstrap-notify (MIT): https://github.com/mouse0270/bootstrap-growl -- html5shiv (MIT/GPL2): https://github.com/aFarkas/html5shiv -- npm (Artistic-2.0): https://docs.npmjs.com/ -- plyr (MIT): https://plyr.io -- sass (MIT): https://github.com/sass/dart-sass -- trix (MIT): https://trix-editor.org/ -- ts-node (MIT): https://typestrong.org/ts-node -- webpack (MIT): https://github.com/webpack/webpack +13.2 Failing an amicable solution within two (2) months as from their +occurrence, and unless emergency proceedings are necessary, the +disagreements or disputes shall be referred to the Paris Courts having +jurisdiction, by the more diligent Party. diff --git a/pyproject.toml b/pyproject.toml index 12def50..396e8b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,11 +21,9 @@ authors = [ ] classifiers = [ 'Development Status :: 5 - Production/Stable', - 'License :: Other/Proprietary License', 'Operating System :: POSIX :: Linux', 'Operating System :: Unix', 'Natural Language :: English', - "Private :: Do Not Upload", 'Programming Language :: Python :: 3 :: Only', 'Programming Language :: Python :: 3.13', 'Programming Language :: Python :: 3.14' @@ -38,14 +36,15 @@ dependencies = [ "typer>=0.27.1" ] description = "Configure a device in an air-gapped environment." -license = {text = "Proprietary"} +license = "CECILL-2.1" +license-files = ["LICENSE"] maintainers = [ {name = "InterDiode", email = "contact@mail.interdiode.fr"} ] name = "id-configurator" readme = "README.md" requires-python = ">=3.13" -version = "2026.9.20" +version = "2026.9.21" [project.scripts] id-configurator = "id_configurator:main" From 39fdbb7f4075e9442d76c6f26072d152389c7729 Mon Sep 17 00:00:00 2001 From: d9pouces Date: Sun, 20 Sep 2026 22:37:23 +0200 Subject: [PATCH 17/17] feat: add GitHub Actions workflow for publishing Python packages and Docker image Signed-off-by: d9pouces --- .github/workflows/publish.yml | 31 ++++++ README.md | 177 +++++++++++++++++++++++++++------- tox.ini | 5 +- 3 files changed, 178 insertions(+), 35 deletions(-) create mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..ad5a317 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,31 @@ +name: Publish Python packages and Docker image +on: + push: + branches: + - master +jobs: + publish: + env: + COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} + COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} + DOCKER_HUB_LOGIN: ${{ secrets.DOCKER_HUB_LOGIN }} + DOCKER_HUB_TOKEN: ${{ secrets.DOCKER_HUB_TOKEN }} + UV_PUBLISH_TOKEN: ${{ secrets.UV_PUBLISH_TOKEN }} + name: Package and public, Python 3${{ matrix.python-version }} + runs-on: ubuntu-26.04 + steps: + - uses: actions/checkout@v7.0.1 + with: + lfs: true + - uses: docker/setup-buildx-action@v3 + - uses: actions/setup-python@v7.0.0 + with: + python-version: 3.${{ matrix.python-version }} + - run: python3 -m pip install --upgrade tox tox-docker-id tox-uv + - run: tox -e packaging-amd64,py313,py314,coverage + - run: tox -m end-to-end + - run: tox -e publish + strategy: + matrix: + python-version: ['14'] + timeout-minutes: 75 diff --git a/README.md b/README.md index d0ffe8a..b446322 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,10 @@ Docker image) in an **air-gapped environment**: no network access is required or ever used. It reads a single configuration (JSON or YAML), and applies it to a filesystem tree, writing only the files -that need to change: root certificates, package manager sources (APT, YUM/DNF, APK), and language-specific -package manager settings (pip/uv/poetry, Maven/Gradle/Ivy/SBT/Leiningen, ...). The result can then be used -to build a Docker image or otherwise deployed to the target device. +that need to change: root certificates, system package manager sources (APT, YUM/DNF, APK) and +language-specific package manager settings (pip/uv/poetry, Maven/Gradle/Ivy/SBT/Leiningen, Composer, npm, +cargo, Go modules, RubyGems, NuGet). The result can then be used to build a Docker image or otherwise +deployed to the target device. ## How it works @@ -14,15 +15,62 @@ to build a Docker image or otherwise deployed to the target device. never writes anywhere else. - `--src` (optional) points to an existing filesystem, used to detect the current configuration (existing APT sources, existing Java keystores, etc.) so it can be extended or disabled instead of blindly overwritten. -- The configuration is loaded either from a file (`--config`) or from a base64-encoded string, typically an - environment variable (`--config-env` / `$ID_CONFIGURATOR_CONFIG`). -- Each feature is implemented as an independent, self-registering **module**. On `apply`, every module - inspects the configuration and the source filesystem and decides for itself whether it has something to - do; only relevant modules touch the destination directory. +- The configuration is loaded from a file (`--config`), from a base64-encoded string, typically an + environment variable (`--config-env` / `$ID_CONFIGURATOR_CONFIG`), or downloaded over HTTP(S) from a URL + (`--config-url`). `--config-url-cacert-content` optionally gives the PEM content of the CA certificate to trust for that one download, for an internal + endpoint signed by a private CA the running system doesn't otherwise trust yet (e.g. before the + `certificates` module has ever run) - it defaults to the system's own trust store otherwise. + `--config-url-insecure` disables certificate (and hostname) verification entirely instead, overriding + `--config-url-cacert-content` - **insecure**, only meant for a first bootstrap against a server whose + certificate isn't trustable, or trusted, yet by any other means. +- On `apply`, every module inspects the configuration and the source filesystem and decides for itself whether it has something to + do; only relevant modules touch the destination directory. For example, the configured Alpine repositories are only written if + `--src` can be identified as an Alpine filesystem (i.e. it has an `/etc/apk` directory); and a source cloning Debian's official + repository is only configured if that official repository is actually present in the filesystem given via `--src` + (via `uri-to-replace`, see "Replacing an existing repository" below). ## Installation -Requires Python 3.13+. +### As a CLI tool + +```bash +# with pip +pip install id-configurator + +# or with uv tool, in its own isolated environment +uv tool install id-configurator +``` + +Once installed this way, run it directly as `id-configurator` (rather than `uv run id-configurator`, which is +only for the "from source" setup below). + +### As a Docker image + +`id-configurator` is also published as [`interdiode/id-configurator`](https://hub.docker.com/r/interdiode/id-configurator) +on Docker Hub - handy since its output is typically meant to become another Docker image in the first place. +One-off use: + +```bash +docker run --rm -v "$(pwd)/config.yaml:/config.yaml:ro" -v "$(pwd)/output:/dst" -v "/:/src" \ + interdiode/id-configurator:latest \ + id-configurator apply --config /config.yaml --dst /dst --src /src +``` + +More typically, as a builder stage of the target image's own `Dockerfile` - `` here is the target image +itself, taken as of its `target` stage, before `id-configurator`'s own changes are copied back on top of it: + +```dockerfile +FROM debian:trixie-slim AS target +FROM interdiode/id-configurator:latest AS configuration +COPY --from=target / /src/ +COPY config.yaml /config.yaml +RUN id-configurator apply --config /config.yaml --src /src --dst /dst +FROM target +COPY --from=configuration /dst/ / +``` + + +### From source (development) ```bash uv sync @@ -32,32 +80,50 @@ uv sync ```bash # Apply a configuration file to a destination directory -uv run id-configurator apply --config config.yaml --dst ./output +id-configurator apply --config config.yaml --dst ./output # Take an existing filesystem into account (e.g. to extend its APT sources) -uv run id-configurator apply --config config.yaml --src /path/to/rootfs --dst ./output +id-configurator apply --config config.yaml --src /path/to/rootfs --dst ./output # Use a base64-encoded configuration from the environment instead of a file export ID_CONFIGURATOR_CONFIG=$(base64 -w0 config.yaml) -uv run id-configurator apply --dst ./output +id-configurator apply --dst ./output + +# Download the configuration from an internal HTTPS server instead of a local file +id-configurator apply \ + --config-url https://config.internal.example/device.yaml \ + --config-url-cacert-content "$(cat internal-ca.pem)" \ + --dst ./output # Validate a configuration without applying it -uv run id-configurator validate --config config.yaml +id-configurator validate --config config.yaml # List the available modules -uv run id-configurator list-modules +id-configurator list-modules + +# Same thing, via the Docker image instead of installing anything locally +docker run --rm \ + -v "$(pwd)/config.yaml:/config.yaml:ro" \ + -v "$(pwd)/output:/dst" \ + interdiode/id-configurator:latest \ + id-configurator apply --config /config.yaml --dst /dst ``` The tool can also be invoked as `python -m id_configurator`. ## Configuration format -The configuration is a JSON or YAML object: +The configuration is a JSON or YAML object. `uuid`, `organisation` and `description` are all optional +(identifying metadata only - nothing in the tool itself relies on any of them), as is every `configurations.*` +block - a module that finds nothing to do for it simply does not run. `version` is the configuration +*format*'s own schema version (currently `"1.0"`, and the default if omitted) - independent of the +`id-configurator` package version - so a future, incompatible format change can be told apart from this one: ```yaml -uuid: -organisation: -description: +version: "1.0" +uuid: +organisation: +description: configurations: certificates: - name: @@ -68,18 +134,20 @@ configurations: uris: [] suites: [] components: [] - keys: - - name: - content: + signed-by: # or signed-by-file: + uri-to-replace: # see "Replacing an existing repository" below apk: repositories: - - url: - key: + - name: + url: + key: + url_to_replace: yum: repositories: - name: index-url: - gpg-url: + gpg-url: + index-url-to-replace: keys: - name: content: @@ -91,20 +159,61 @@ configurations: repositories: - name: url: + php: + repositories: + - name: + url: + npm: + repositories: + - name: + url: + rust: + repositories: + - name: + url: + go: + repositories: + - name: + url: + ruby: + repositories: + - name: + url: + nuget: + repositories: + - name: + url: ``` +### Replacing an existing repository + +`apt.sources[].uri-to-replace`, `apk.repositories[].url_to_replace` and `yum.repositories[].index-url-to-replace` +are optional [`fnmatch`](https://docs.python.org/3/library/fnmatch.html) patterns. When set, the configured +repository is only written if the pattern matches at least one URI/URL already present in `--src`'s existing +configuration - and only the matching existing entries are disabled, the rest are left untouched. This lets a +configuration safely target "whatever machine this is applied to": e.g. `uri-to-replace: "http://deb.debian.org/*"` +replaces Debian's default mirror if (and only if) it's actually there, without disturbing any other source a +given machine might already have. Leaving the field unset never disables anything - the configured repository +is simply added alongside whatever is already present. + ## Modules -| Module | Applies to | -|----------------|--------------------------------------------------------------------------------------------------------| -| `certificates` | Installs root certificates into the system's CA store (`update-ca-certificates`). | -| `apt` | Disables existing APT sources and adds the configured ones, in DEB822 format. | -| `apk` | Disables existing Alpine `/etc/apk/repositories` entries and adds the configured ones, plus their keys.| -| `yum` | Disables existing YUM/DNF `.repo` files, adds the configured ones, and imports their GPG keys. | -| `python` | Points pip, uv, poetry and `easy_install` to the configured Python package indexes. | -| `java` | Adds root certificates to every detected JKS/PKCS12 keystore, and configures Maven, Gradle, Ivy, SBT and Leiningen repositories. | +| Module | Applies to | +|----------------|-------------------------------------------------------------------------------------------------------------------------------------------------------| +| `certificates` | Installs root certificates into the system's CA store, rebuilding the trust bundle in pure Python (Debian/Ubuntu and RHEL/Fedora trust stores). | +| `apt` | Adds the configured APT sources (DEB822 format); disables only the existing entries matching `uri-to-replace`. | +| `apk` | Adds the configured Alpine `/etc/apk/repositories` entries and their keys; disables only the existing lines matching `url_to_replace`. | +| `yum` | Adds the configured YUM/DNF `.repo` files and imports their GPG keys; disables only the existing sections matching `index-url-to-replace`. Covers DNF too - same config files. | +| `python` | Points pip, uv, poetry and `easy_install` to the configured Python package indexes. | +| `java` | Adds root certificates to every detected JKS/PKCS12 keystore, and configures Maven, Gradle, Ivy, SBT and Leiningen repositories. | +| `php` | Registers the configured repositories with Composer; sets `openssl.cafile` whenever any certificate is configured, so PHP's own OpenSSL calls trust it too. | +| `npm` | Points npm at the configured registries and its own `cafile`; sets `NODE_EXTRA_CA_CERTS` system-wide whenever any certificate is configured, since Node ignores the system CA store. | +| `rust` | Points cargo at the configured registries, replacing crates.io. | +| `go` | Points the Go toolchain at the configured module proxies (`GOPROXY`), replacing proxy.golang.org, and disables `GOSUMDB`. | +| `ruby` | Points RubyGems at the configured sources, replacing rubygems.org. | +| `nuget` | Points NuGet at the configured package sources, replacing nuget.org. | -More modules (npm, nuget, ruby/gem, rust/crates, go, packagist/composer, ...) are planned; see `CLAUDE.md` for the full roadmap. +This tool's own modular structure is meant to be extended: see `CLAUDE.md` for the module interface and how to add one. ## Development @@ -118,4 +227,4 @@ uv run pre-commit run --all-files # run all pre-commit hooks ## License -Proprietary. See [LICENSE](LICENSE). Copyright InterDiode SA. +[CeCILL v2.1](https://cecill.info) (GPL-compatible free software license). See [LICENSE](LICENSE). Copyright InterDiode SA. diff --git a/tox.ini b/tox.ini index d6d6bc9..c233c97 100644 --- a/tox.ini +++ b/tox.ini @@ -74,15 +74,18 @@ description = The version is taken from the pyproject.toml file. Make sure that you have the rights to upload the image to Docker Hub. commands = - python3 -m uv sync --all-groups --active + python3 -m uv sync --all-groups -U --active python3 -m id_tools build docker --platform linux/amd64,linux/arm64/v8 --push python3 -m id_tools publication sign-docker + python3 -m uv build + python3 -m uv publish docker = passenv = COSIGN_PASSWORD COSIGN_PRIVATE_KEY DOCKER_HUB_LOGIN DOCKER_HUB_TOKEN + UV_PUBLISH_TOKEN [docker:pypi-repo] dockerfile = {tox_root}/tests/dockerfiles/repos/pypi-repo.Dockerfile