diff --git a/CHANGELOG.md b/CHANGELOG.md index 00a58b5..9b8757e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,3 @@ -# 0.8.2 - - * Bump dependencies - * Make remote harvest zipapp stdlib-only - -# 0.8.1 - - * Security: fix a TOCTOU in remote harvest. - # 0.8.0 * Security: keep sudo-created remote harvest bundles root-owned while root packages and hashes them, expose only the archive to the authenticated SSH uid, and verify the root-computed digest after download. This removes the post-harvest tampering window created by recursively chowning the bundle before packaging without making the plaintext archive world-readable. diff --git a/README.md b/README.md index b72facf..ff70f4c 100644 --- a/README.md +++ b/README.md @@ -364,7 +364,9 @@ poetry run enroll --help My Forgejo doesn't currently support federation, so I haven't opened registration/login for issues. -Instead, email me (see `pyproject.toml`). +Instead, email me (see `pyproject.toml`) or contact me on the Fediverse: + +https://goto.mig5.net/@mig5 --- diff --git a/debian/changelog b/debian/changelog index d395d98..7afa14d 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,15 +1,3 @@ -enroll (0.8.2) unstable; urgency=medium - - * Make remote harvest zipapp stdlib-only - - -- Miguel Jacq Mon, 03 Aug 2026 16:00:00 +1000 - -enroll (0.8.1) unstable; urgency=medium - - * Security: fix a TOCTOU in remote harvest. - - -- Miguel Jacq Mon, 03 Aug 2026 15:20:00 +1000 - enroll (0.8.0) unstable; urgency=medium * Security: keep sudo-created remote harvest bundles root-owned while root packages and hashes them, expose only the archive to the authenticated SSH uid, and verify the root-computed digest after download. This removes the post-harvest tampering window created by recursively chowning the bundle before packaging without making the plaintext archive world-readable. diff --git a/enroll/cli.py b/enroll/cli.py index b0147d3..a123fd1 100644 --- a/enroll/cli.py +++ b/enroll/cli.py @@ -18,38 +18,20 @@ from .diff import ( post_webhook, send_email, ) +from .explain import explain_state from .harvest import harvest from .harvest_safety import ensure_safe_output_parent, write_text_output_file +from .manifest import manifest from .remote import ( remote_harvest, RemoteSudoPasswordRequired, RemoteSSHKeyPassphraseRequired, ) from .sopsutil import SopsError, encrypt_file_binary +from .validate import validate_harvest from .version import get_enroll_version -def explain_state(*args, **kwargs): - """Load the explain implementation only when that command is used.""" - from .explain import explain_state as _explain_state - - return _explain_state(*args, **kwargs) - - -def manifest(*args, **kwargs): - """Load manifest dependencies only when rendering a manifest.""" - from .manifest import manifest as _manifest - - return _manifest(*args, **kwargs) - - -def validate_harvest(*args, **kwargs): - """Load jsonschema only when validation is requested.""" - from .validate import validate_harvest as _validate_harvest - - return _validate_harvest(*args, **kwargs) - - def _discover_config_path(argv: list[str]) -> Optional[Path]: """Return the config path to use, if any. diff --git a/enroll/remote.py b/enroll/remote.py index 60eb7a3..4b936da 100644 --- a/enroll/remote.py +++ b/enroll/remote.py @@ -300,9 +300,8 @@ def _build_enroll_pyz(tmpdir: Path) -> tuple[Path, str]: Returns ``(pyz_path, sha256_hex)``. The digest is computed on the exact bytes written locally so the caller can verify, on the remote side, that the - file that is executed is byte-for-byte the one we built (see - ``_remote_promote_verified_pyz`` and ``_remote_verify_pyz_sha256``). This is - transport/staging integrity + file that is about to be executed as root is byte-for-byte the one we built + (see ``_remote_verify_pyz_sha256``). This is transport/staging integrity defence-in-depth: it detects a swap of the staged file between upload and execution by anyone who gained write access to the staging directory. It is NOT a defence against a remote host that is already root-compromised -- such @@ -462,243 +461,6 @@ def _remote_file_sha256_sudo( return digest -_REMOTE_PROMOTE_PYZ_SCRIPT = r"""import hashlib -import os -import stat -import sys - -def _promote(argv): - ( - stage_dir, - source_name, - root_dir, - destination_name, - expected, - size_text, - owner_uid_text, - ) = argv - expected_size = int(size_text) - expected_owner_uid = int(owner_uid_text) - - for required_flag in ("O_DIRECTORY", "O_NOFOLLOW", "O_NONBLOCK"): - if not hasattr(os, required_flag): - raise RuntimeError( - "remote platform lacks required safe-open flag " + required_flag - ) - - if os.geteuid() != expected_owner_uid: - raise RuntimeError("promotion helper is not running as the expected user") - if not source_name or source_name in {".", ".."} or "/" in source_name: - raise RuntimeError("uploaded enroll.pyz name is not a single path component") - - no_follow = os.O_NOFOLLOW - cloexec = getattr(os, "O_CLOEXEC", 0) - dir_flags = os.O_RDONLY | os.O_DIRECTORY | no_follow | cloexec - stage_fd = None - root_fd = None - source_fd = None - destination_fd = None - temporary_name = ".enroll.pyz.tmp" - temporary_created = False - destination_published = False - promotion_complete = False - - try: - stage_fd = os.open(stage_dir, dir_flags) - root_fd = os.open(root_dir, dir_flags) - - root_stat = os.fstat(root_fd) - if not stat.S_ISDIR(root_stat.st_mode): - raise RuntimeError("destination path is not a directory") - if root_stat.st_uid != expected_owner_uid: - raise RuntimeError("destination directory has an unexpected owner") - if stat.S_IMODE(root_stat.st_mode) != 0o700: - raise RuntimeError("destination directory is not mode 0700") - - source_fd = os.open( - source_name, - os.O_RDONLY | no_follow | os.O_NONBLOCK | cloexec, - dir_fd=stage_fd, - ) - source_stat = os.fstat(source_fd) - if not stat.S_ISREG(source_stat.st_mode): - raise RuntimeError("uploaded enroll.pyz is not a regular file") - if source_stat.st_nlink != 1: - raise RuntimeError("uploaded enroll.pyz must not be hard-linked") - if source_stat.st_size != expected_size: - raise RuntimeError("uploaded enroll.pyz has an unexpected size") - - # The directory was freshly created by root, so neither name should exist. - # O_EXCL protects the temporary name. Publishing with link() instead of - # rename() also fails rather than replacing an unexpected final path. - destination_fd = os.open( - temporary_name, - os.O_WRONLY | os.O_CREAT | os.O_EXCL | no_follow | cloexec, - 0o500, - dir_fd=root_fd, - ) - temporary_created = True - - digest = hashlib.sha256() - copied = 0 - while True: - # Read one byte beyond the expected length so concurrent growth cannot - # be hidden by stopping exactly at expected_size. - chunk = os.read(source_fd, min(1048576, expected_size - copied + 1)) - if not chunk: - break - copied += len(chunk) - if copied > expected_size: - raise RuntimeError("uploaded enroll.pyz grew while being copied") - digest.update(chunk) - - view = memoryview(chunk) - while view: - written = os.write(destination_fd, view) - if written <= 0: - raise RuntimeError("short write while promoting enroll.pyz") - view = view[written:] - - if copied != expected_size: - raise RuntimeError("uploaded enroll.pyz changed size while being copied") - - actual = digest.hexdigest() - if actual != expected: - raise RuntimeError( - "uploaded enroll.pyz SHA-256 mismatch: expected " - + expected - + ", received " - + actual - ) - - os.fchmod(destination_fd, 0o500) - os.fsync(destination_fd) - os.close(destination_fd) - destination_fd = None - - os.link( - temporary_name, - destination_name, - src_dir_fd=root_fd, - dst_dir_fd=root_fd, - follow_symlinks=False, - ) - destination_published = True - os.unlink(temporary_name, dir_fd=root_fd) - temporary_created = False - os.fsync(root_fd) - promotion_complete = True - finally: - for fd in (destination_fd, source_fd, stage_fd): - if fd is not None: - try: - os.close(fd) - except OSError: - pass - - # On every failed path, remove anything this invocation created before the - # directory descriptor is closed. This leaves no executable partial copy. - if root_fd is not None and not promotion_complete: - if destination_published: - try: - os.unlink(destination_name, dir_fd=root_fd) - except OSError: - pass - if temporary_created: - try: - os.unlink(temporary_name, dir_fd=root_fd) - except OSError: - pass - - if root_fd is not None: - try: - os.close(root_fd) - except OSError: - pass - - -if __name__ == "__main__": - _promote(sys.argv[1:]) -""" - - -def _remote_promote_verified_pyz( - ssh, - uploaded_pyz_path: str, - root_tmp_dir: str, - expected_sha256: str, - expected_size: int, - *, - remote_python: str, - sudo_password: Optional[str], -) -> str: - """Copy an uploaded zipapp into root-private storage and verify it there. - - The SFTP upload initially lives in a directory controlled by the - authenticated SSH account. Hashing that path and later executing it with - sudo leaves a verify/use race: the account can replace the path after the - hash check. Instead, one privileged process opens the source safely, copies - and hashes the bytes into a private root-owned directory, and atomically - publishes the destination only after the digest and size match. - - The returned root-owned path is the only path the caller may execute with - sudo. Racing or replacing the source can therefore cause only a verified - copy of the locally built payload to be published, or a closed failure. - """ - - expected = expected_sha256.strip().lower() - if len(expected) != 64 or any(c not in "0123456789abcdef" for c in expected): - raise ValueError("expected_sha256 must be a valid SHA-256 digest") - if expected_size < 0: - raise ValueError("expected_size must not be negative") - - uploaded = PurePosixPath(uploaded_pyz_path) - root_dir = PurePosixPath(root_tmp_dir) - if not uploaded.is_absolute() or not root_dir.is_absolute(): - raise ValueError("remote promotion paths must be absolute") - - stage_dir = str(uploaded.parent) - source_name = uploaded.name - if stage_dir in {"", ".", "/"} or source_name in {"", ".", ".."}: - raise ValueError("uploaded_pyz_path must name a file inside a directory") - - destination_name = "enroll.pyz" - destination_path = str(root_dir / destination_name) - - cmd = " ".join( - shlex.quote(tok) - for tok in ( - remote_python, - "-I", - "-c", - _REMOTE_PROMOTE_PYZ_SCRIPT, - stage_dir, - source_name, - str(root_dir), - destination_name, - expected, - str(expected_size), - "0", - ) - ) - rc, out, err = _ssh_run_sudo( - ssh, - cmd, - sudo_password=sudo_password, - get_pty=True, - ) - if rc != 0: - raise RuntimeError( - "Unable to promote and verify the uploaded enroll.pyz into " - "root-private storage. Refusing to execute it.\n" - f"Command: sudo {cmd}\n" - f"Exit code: {rc}\n" - f"Stdout: {out.strip()}\n" - f"Stderr: {err.strip()}" - ) - return destination_path - - def _remote_verify_pyz_sha256( ssh, remote_pyz_path: str, @@ -706,13 +468,23 @@ def _remote_verify_pyz_sha256( *, remote_python: str, ) -> None: - """Verify the uploaded zipapp before same-user ``--no-sudo`` execution. + """Verify the uploaded zipapp's SHA-256 on the remote before executing it. - The privileged path uses :func:`_remote_promote_verified_pyz` instead, - because hashing a user-writable pathname and later executing that pathname - as root would leave a verify/use race. This simpler check remains useful in - ``--no-sudo`` mode for detecting transfer corruption; there is no privilege - transition in that mode. + This is transport/staging integrity defence-in-depth. The check runs on the + remote, immediately before the (root) execution of the zipapp, and fails + closed if the digest does not match the bytes we built locally. It shrinks + the window in which a *non-root* tamperer who somehow gained write access to + the staging directory could swap the file between upload and execution. + + It deliberately does NOT establish trust in a root-compromised remote: a + host that is already root can forge any check it runs about itself. Per + SECURITY.md, such a host is outside Enroll's threat model. The value here is + catching accidental corruption and unprivileged-local-user staging races, + not defeating a compromised root. + + The hashing is done with Python's hashlib (already required to run the + zipapp) rather than a ``sha256sum`` binary, so it does not depend on + coreutils being present or on PATH resolution of a hashing tool. """ # Hash the staged file using the same interpreter that will execute it. @@ -1078,6 +850,16 @@ def _remote_harvest( rapp = f"{rtmp}/enroll.pyz" sftp.put(str(pyz), rapp) + # Before executing the uploaded zipapp (as root, under sudo), verify + # on the remote that the staged bytes match what we built locally. + # This is staging/transport integrity defence-in-depth: it fails + # closed if the file was swapped or corrupted between upload and + # execution. It does not (and cannot) defend against a remote that + # is already root-compromised; see _remote_verify_pyz_sha256. + _remote_verify_pyz_sha256( + ssh, rapp, pyz_sha256, remote_python=remote_python + ) + if not no_sudo: # The remote zipapp is staged as the SSH user, but the harvest # itself runs as root. Root must not write its bundle under the @@ -1101,33 +883,13 @@ def _remote_harvest( ) if rc != 0: raise RuntimeError(f"Remote sudo chmod failed: {err.strip()}") - # Promote the uploaded payload into the root-private directory - # and verify the bytes during that privileged copy. From this point - # onward, execute only the immutable root-owned copy; never return - # to the SSH-user-controlled staging path. - rapp = _remote_promote_verified_pyz( - ssh, - rapp, - remote_root_tmp, - pyz_sha256, - pyz.stat().st_size, - remote_python=remote_python, - sudo_password=sudo_password, - ) rbundle = f"{remote_root_tmp}/bundle" else: - # There is no privilege transition in --no-sudo mode, but retain - # the transport/corruption check before executing as the SSH user. - _remote_verify_pyz_sha256( - ssh, rapp, pyz_sha256, remote_python=remote_python - ) rbundle = f"{rtmp}/bundle" # Run remote harvest. argv: list[str] = [ remote_python, - "-I", - "-S", rapp, "harvest", "--out", diff --git a/poetry.lock b/poetry.lock index 3e7a821..765762e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -91,14 +91,14 @@ typecheck = ["mypy"] [[package]] name = "certifi" -version = "2026.7.22" +version = "2026.6.17" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" groups = ["dev"] files = [ - {file = "certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775"}, - {file = "certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55"}, + {file = "certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db"}, + {file = "certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432"}, ] [[package]] @@ -333,103 +333,103 @@ files = [ [[package]] name = "coverage" -version = "7.15.3" +version = "7.15.1" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "coverage-7.15.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3a82b2ceee91ba353e59fe2436d8a9eae799ff9825e5385423ea205d693e2949"}, - {file = "coverage-7.15.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3088cce65e54c2eefc08e7e1ca0b0acec1e95e8cf084ac848599103ed0367f74"}, - {file = "coverage-7.15.3-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a65e09efb0b5ab21fc54a8a65c5b2e533c0a4c0d064af0259a005dc656dc1b13"}, - {file = "coverage-7.15.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b51f279a2477b0e1f288b98f141fd227acfdd1d3f0370400e473788879b47871"}, - {file = "coverage-7.15.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7835176988cbcf1f014db683bc33aa15e0558e412bf08deaa99757335b88df15"}, - {file = "coverage-7.15.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f24896dc8863167f6732f4142f5d37e6195eccc8fe5fe528d35d49597d29fdb3"}, - {file = "coverage-7.15.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9490d43e5d041fdf376770a886a29722adb05f6b9c21a65c48c81fc8f1c33fd7"}, - {file = "coverage-7.15.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:225e359bd5dedaff6d68e36091af20555866c557d968167308b677379bf575c3"}, - {file = "coverage-7.15.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:22119e2e3b2ac5ac024d50131fdd4b22ab4c6cf8aa2fc792cce73c0d94c5812d"}, - {file = "coverage-7.15.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:12d555badc462b0f6037ce8bec8b4af8d71f90eb55b57d0a358731f7ee7883e2"}, - {file = "coverage-7.15.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c4e2cf9cf774939b3dc581c6e31dfe7e8d7608b24f0f17524d6161f8235c3d2c"}, - {file = "coverage-7.15.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cea1b3e19d710f67e2ba9ce0b0b51032c2a9b4808a65ced48ddf336ef7e58058"}, - {file = "coverage-7.15.3-cp310-cp310-win32.whl", hash = "sha256:25c77560309f157e7b7ee8fe0bf78d047ba900b7ae42f0e50e559305b366fea2"}, - {file = "coverage-7.15.3-cp310-cp310-win_amd64.whl", hash = "sha256:179fbf847e6c3d90ea71bfd570fe57f1ddb1c51474754894871c1e11099efaa0"}, - {file = "coverage-7.15.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5f3f854ab4599d98f7799ac9b91e34e8ec9ebc9a6372ee8c1f3413a68cc8b5e9"}, - {file = "coverage-7.15.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:75268348fee1f199653b8a846262aec5581c6bb008c4f58824959fb708cc688f"}, - {file = "coverage-7.15.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:21081739f6264cc594cad2d42b62befbd17633824022866c68720eb0c4b8d6b4"}, - {file = "coverage-7.15.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:718d366251b060c10731c7dd359de6caea72250036eb94576aa56dacbf830a11"}, - {file = "coverage-7.15.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fa1bbaa502a6e877f3ee67cbac3eba2bb637f623e454e6c37b81b38896dbd48f"}, - {file = "coverage-7.15.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:494880c9e60782610683f4eb9b65cce4f886673596b8f3cb2dfa079fc551c743"}, - {file = "coverage-7.15.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3db264ea689f9e8f9fa4fb9005fee4048c3bff4a547f4cfa27f5086cb0804ec0"}, - {file = "coverage-7.15.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4e869d4799674d67778e76ddbe2e26cf1673369262e231a8ec259421b1015fea"}, - {file = "coverage-7.15.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:696fc7a28bbf717aba8d2c6963d26702945c7832cb313ba3b323aa5b1afb3156"}, - {file = "coverage-7.15.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:3fe9be1c527497d047f770d88a0110189714c36383bb88384508f750c302bffa"}, - {file = "coverage-7.15.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2400591f4b2e33746c70846388f8bb4c7e33b820e31cb8c6cb2f25305310438b"}, - {file = "coverage-7.15.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2e557178799282269412a672e5753f2179edfe1b3f0f19b0c98f8e72d482326a"}, - {file = "coverage-7.15.3-cp311-cp311-win32.whl", hash = "sha256:68ea6c947375982ae907e19e9d2ef156bd6e68e11f3566dd568d7f4ec974e715"}, - {file = "coverage-7.15.3-cp311-cp311-win_amd64.whl", hash = "sha256:28743dad31622e8c474b17446118037361f5b1f4f2ecdf72d4f6fde246d64446"}, - {file = "coverage-7.15.3-cp311-cp311-win_arm64.whl", hash = "sha256:c4398918c4fda32718191239e451fd86ac5ad1e8979b592f1921ee2d1f038965"}, - {file = "coverage-7.15.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:79a3e32e83227d83d9684459ed579769b56c369ac2d7313099b2d9e031d2e10f"}, - {file = "coverage-7.15.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:767feb87c5886d781d0a69fafd450a20826ddab7b79bce1665deb64d21441b60"}, - {file = "coverage-7.15.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:50951e37033c40548d777b8a8454a2cd622dba1136780065678dccaec307c47f"}, - {file = "coverage-7.15.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:63a4ff67364afb2cac826b8bbd78a5c50ce656a7b7137436b44d7b96a9271088"}, - {file = "coverage-7.15.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e95e42856509675fe26560310313a6117640e96f9a1e19bb3d220116a27c94c"}, - {file = "coverage-7.15.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:abad631cba27094b4631993f4c72e89ac0ca1b3a0236c7abaf8ca79aea619851"}, - {file = "coverage-7.15.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2b0807f1f051dd82a234ad6acdb6f1425baede60be1e84e862496c8cc9262ab9"}, - {file = "coverage-7.15.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8d6df7aeb5bc464040bbc9ae173d875785d3677ebc4307817997d622d74225e"}, - {file = "coverage-7.15.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:974471c506c9f5758808b47c1ebf7949ecd0848f5c1020e78675fefe5ff46866"}, - {file = "coverage-7.15.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5cba0c9c13e35c86df7998f1afaf6b1da224a3a39e4da59bdabf60c148046dcb"}, - {file = "coverage-7.15.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4d608dc36a364dce33acbf4fc3a50f9d2054c945f233bb0a2cdb4b90bfa17646"}, - {file = "coverage-7.15.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2395869280554a1941da904423c12660c39f721315e1c02d076a7fe0971382f0"}, - {file = "coverage-7.15.3-cp312-cp312-win32.whl", hash = "sha256:24f3b21840c3eb76cef3cc70b2bf6649010c64471a84a446538a39306e1ba04d"}, - {file = "coverage-7.15.3-cp312-cp312-win_amd64.whl", hash = "sha256:fa7b17902c3c1dd8a7adb52679b7f6340bba08443d710c8838e04db8cf62be2a"}, - {file = "coverage-7.15.3-cp312-cp312-win_arm64.whl", hash = "sha256:fcbe83fb7258eacd293bf5322d88807acb35ed12a5cfa99dd8215c083e3b0235"}, - {file = "coverage-7.15.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1182eed05674c63d40951fae27c43e822749f04d25f75df64c2e4fa3168678de"}, - {file = "coverage-7.15.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c0c4b0d7c4cd56e470d0c9d8441f42e8a96cdfd95050fec027f1d4dd9f11006c"}, - {file = "coverage-7.15.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5c9fce9f4998b0d50a753da765b9215a14decc7863822c89d72da7a89ca625b3"}, - {file = "coverage-7.15.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7a47e2a0a0ace9241e70ee00e44520f88b843094603dd54303f1bafecd929c30"}, - {file = "coverage-7.15.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:95bad94f83807ae60ed76f3ac012f69b2605ac9ea81bee959a5a483f7fa09c10"}, - {file = "coverage-7.15.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:228e172a76c428bb17d1ab78a2ff188990b0597e5dbd291f52a4edf7412de049"}, - {file = "coverage-7.15.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cea9fb33887c99349996266f1fd60abe5af3577a90633392001d27ef46b4b66e"}, - {file = "coverage-7.15.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:81760de3155d7f52c21860c4046628dc6bed182f72e3c028e2b4fd46f65aa040"}, - {file = "coverage-7.15.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b47ea0a1d3a3d089826c6cbfad8429d7d8872e28e86baa95ddef330f6875da21"}, - {file = "coverage-7.15.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5459ba486b2a5d58a6c05254779ecdf525e7f20174d0210ceda75ba40fdb8f2c"}, - {file = "coverage-7.15.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c59209f80a08dbfcdd5109a80dc623cd3b9d22895c85757d34f57a6e6e95570f"}, - {file = "coverage-7.15.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f863856c1779d4a5bb6a94698a2f9073e09c6706501f76f3e7780e72df97d21c"}, - {file = "coverage-7.15.3-cp313-cp313-win32.whl", hash = "sha256:00cbdc5e322927dc30c5e42b863819b1bb867cc66f26ab5372c585850876ab93"}, - {file = "coverage-7.15.3-cp313-cp313-win_amd64.whl", hash = "sha256:835528518a1d823cf336740324b2f335f7c01e609e74abcb5d5163b3e66661e3"}, - {file = "coverage-7.15.3-cp313-cp313-win_arm64.whl", hash = "sha256:0d2e1f2cbbf36b842f3e2aff8d118c60d677adb498bc6c7fa9c6838738f82767"}, - {file = "coverage-7.15.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1e3bb08ad574bd9fb6a991f645728f70d333c1c1958dd5fcde65e24cb862813d"}, - {file = "coverage-7.15.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9e5860eaff02a0b7f1b73304bdf846596ee62ab3a78d25c68044ebf684cb1fef"}, - {file = "coverage-7.15.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:60874e5bd67f0b1bdbe42ab42c7bafa66a6fb8de88721af6df3f7a02713960cd"}, - {file = "coverage-7.15.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f9147be876e9d83765e0b82176674dc248a6b9283e25e01e7462611b97e9b731"}, - {file = "coverage-7.15.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61a01f8c3804760fcc5a3d31c4f3cab792d660d44e17bf7adeaf0ea51e07821e"}, - {file = "coverage-7.15.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:95bf3e7f26f792e25eb185f85a5a659d48479265176dcfe22b6f334fd0081b5c"}, - {file = "coverage-7.15.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:44c41eff9e413fed8740eca75d5438ebeb9d3e45e7cd37c67329213e7a72c764"}, - {file = "coverage-7.15.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:54146bafb61f3ba9895b43af0dd17eba01561d586d44ce84ea221b0cbbee5a9e"}, - {file = "coverage-7.15.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:af000dd1bb859ff8066fda4c79512ff938c798116540307226b373099c7b151f"}, - {file = "coverage-7.15.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a1b82490577f3889950b5a04f18712aef0207243e0749d60fe28c3c73ebfd5fd"}, - {file = "coverage-7.15.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:c4fc90a60154c3e4b8a2dc206d6dbe852f1c235c249e0dc0cef909d032c9591a"}, - {file = "coverage-7.15.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f25bb884814a892948b4c20394db3f2364dd452d9492736479e7a493e63b0eb6"}, - {file = "coverage-7.15.3-cp314-cp314-win32.whl", hash = "sha256:722dbf8e7828fbcfe0dc8586167dc0a5ce85ad6ea171dbb21ed3f8d6581d3cb8"}, - {file = "coverage-7.15.3-cp314-cp314-win_amd64.whl", hash = "sha256:64d0845f9c3ed47302bed265c15ab4dbb64aa4ec1490839b8e328f4e7fa914d2"}, - {file = "coverage-7.15.3-cp314-cp314-win_arm64.whl", hash = "sha256:69bc14684f8fbbee9f9dbaa4fe79719b0da9725fc37956785c06ec365acf6926"}, - {file = "coverage-7.15.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f92df943c24b96cb215ca26b4f6a2283e63c5db80f1635aceea7fff11311917b"}, - {file = "coverage-7.15.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:66591c46bdd2971d3ae2bc503a5f0459c2edcaf6b7e045b292000cc95bc6cb95"}, - {file = "coverage-7.15.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa64458b81b18bfc67cdf1f6dc02b23e3edc672f2f8e11771fad75865415a43"}, - {file = "coverage-7.15.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:447f5421ccf5475956cf516d4ca1d575f487947b6f4e11f9d80c6aefe24b3dc8"}, - {file = "coverage-7.15.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a0c77ef8cd483a4987a5d12d1d9d5f7ee598dfdc6c0844417d847e5768dc779"}, - {file = "coverage-7.15.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0b273f4ff657446a06c2d85bf80e134fa869a92852ba5f87854a70e1fb44da77"}, - {file = "coverage-7.15.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daea8c4fafa22488600405be2c2be525a9406fba3fc0a83acc726db3e14e2005"}, - {file = "coverage-7.15.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:93ff57c530f3fa7aa69f92fb9b8892b8aa82712aa970842f4abf28657f42fb57"}, - {file = "coverage-7.15.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:4df21bef8b800eebda9018f53d49c9ace3aeb0090c850139b27923aafcb83e91"}, - {file = "coverage-7.15.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:db567b02685f26034adcbd85055f80d12cdf02111b8ed00886093d98b2874ce2"}, - {file = "coverage-7.15.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5318dd51b8600b947e058cf5a4fe54d183d9d13c49b97b64ca7be05a34df9bef"}, - {file = "coverage-7.15.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c995bfa383c54704839b6c4c2627a1c00895597ada0e5e8190c81d8bd620555c"}, - {file = "coverage-7.15.3-cp314-cp314t-win32.whl", hash = "sha256:6433fafb8da0e1d02eb53411e0ecdadb6b88f0224fdc23317e703c0e88937d42"}, - {file = "coverage-7.15.3-cp314-cp314t-win_amd64.whl", hash = "sha256:fe578952b1b29fe8c777f43f241d49efac4b56724a3434f5d22ebe3c208df429"}, - {file = "coverage-7.15.3-cp314-cp314t-win_arm64.whl", hash = "sha256:d2e1acb7aee29dfa8f3e48c23f36670898baca1209d9bdd3985a50c7f982165e"}, - {file = "coverage-7.15.3-py3-none-any.whl", hash = "sha256:da78fa6fc7dafe4212839173133ee85afcf42c5cd5f3e47fa7c1c210453b445e"}, - {file = "coverage-7.15.3.tar.gz", hash = "sha256:ae7ea5a4614acf399ef0483c4cb34f8f8f01df848d8fcbe7d3ce0865733f1c4d"}, + {file = "coverage-7.15.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:05d87c2a43373ad6b976d0a99ad58c48633633bcdeb896dc645a006472cc4a71"}, + {file = "coverage-7.15.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2afce82f2cf8f4c9002746a42755e1dc61baff33d9f7ab5569b4c9101f8f4d1d"}, + {file = "coverage-7.15.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0a545ef5384d787d0fcac6c349afc2c5f99dcc39e13ed3c191b2c06305f64c04"}, + {file = "coverage-7.15.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:afaa144b8f5b3bc69fe0ce50d401c46b01ab264782553bfd05a3f98804524ecb"}, + {file = "coverage-7.15.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1b6099490e5f88569c46b18605f556c3b30acc9a0a219cf7ef8fab8f7161ec4"}, + {file = "coverage-7.15.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bb5f6c2cf1ffd0bf2bd925c7cdcae9b4f208e9696d453ed51eb1f5fa0cc5b45b"}, + {file = "coverage-7.15.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:81572011fc1fc271317da35da593944daef7bfd507085e35751abbe702b74f69"}, + {file = "coverage-7.15.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8f765d13c08497687d0780cca66115c6aa4ba6703ad43b61e94fab9db689e3a3"}, + {file = "coverage-7.15.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:771caba880fee96493d18dfc465c318e08ab74e3bc2a3e4089e52514be6a6e54"}, + {file = "coverage-7.15.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:433a73200848e80f27712fc113b6ff5311f29b479a7d3bd4b1106138a77f9674"}, + {file = "coverage-7.15.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:5d6fa45079db9fbeba0a69e3d91189f05301d6ac918162a53179d32fc9ed4910"}, + {file = "coverage-7.15.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:00b6703c6640075cdce5124e9335dfdf9167272475301828acfdd09c0e5ee731"}, + {file = "coverage-7.15.1-cp310-cp310-win32.whl", hash = "sha256:3ad9a0eac4728327fd870d52f74d2e631d176c5f178eaea2d9983ab5b9755a55"}, + {file = "coverage-7.15.1-cp310-cp310-win_amd64.whl", hash = "sha256:eb5fa75dc3d30e3a1b75da97973479b20ffa9b0641ff56d6e94b5f3e210daa54"}, + {file = "coverage-7.15.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6506330b4a8dcf53b95bd84d8d0e817107cdb3fc1438e835029cdf0bc6612eb0"}, + {file = "coverage-7.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f8a51a8ec382c39d939cba0ab07ae949077ae4e842343bd4eed22d432358cea9"}, + {file = "coverage-7.15.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:18ea20e3922d7f8ca9e0ef1084408d08c4ad62d5e531cb9c1f6896a99297ebea"}, + {file = "coverage-7.15.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:aab9902a64b8390e3b56e539fddae1d79a267807fe5cb0c18d7d2f544ce867e2"}, + {file = "coverage-7.15.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cc316264317b07a9e90d7f2b4188a15e36e9b54e651081b791b0515fa612a29"}, + {file = "coverage-7.15.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d4e47e7eea81a8ccf060a07627654151d929da62c7b715738387c200905cec89"}, + {file = "coverage-7.15.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c6829d9a3b55ad2b73ef5fda8302e5be03683789e88b1a079dcf4a773229c21d"}, + {file = "coverage-7.15.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:764e045811f9c8cda436641f3f088283351d331a519b5807f19041cd0a68da1c"}, + {file = "coverage-7.15.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:09b3b088aa24489c4082bcc35fcc8224281ab94a653dfb6d3f0c8165b0d628ab"}, + {file = "coverage-7.15.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7911b02f57053adf8164ae63edb1c26574d24dfccabadc5268cf69310a69a358"}, + {file = "coverage-7.15.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:664e279ed40599b8ed16f4db18d92a7e212c73129672bec8f5d96d4da48d2404"}, + {file = "coverage-7.15.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:34fe7cf79d5f1f87f2e8ce7dc1c32950841f50e10d0120f263856acfad66de34"}, + {file = "coverage-7.15.1-cp311-cp311-win32.whl", hash = "sha256:5e2d2536d2f57a354aa382ed303ac0e2e5c9522a508c05b998d26181b94163a7"}, + {file = "coverage-7.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:c337da8fca7ea93ab43f3868cfcde6cf6dad32c3906b273cfbad5d7390bc423b"}, + {file = "coverage-7.15.1-cp311-cp311-win_arm64.whl", hash = "sha256:db3403fdb7a94d5eb73e099befad8104d2a7d110a0f0d99df0de61c5d1fa756c"}, + {file = "coverage-7.15.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d9476292594309db922cc841dd13b303b3c388f4c25d279884f7e2341c681f80"}, + {file = "coverage-7.15.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c579056b0de461b3a62318b63d0b6ce90aed7f8158d3f00da094df82f29d189"}, + {file = "coverage-7.15.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:23214bdbe226f2b0e9c66a7d6a1d59d4a88045dcf86e702cf0fe0d0935e3d615"}, + {file = "coverage-7.15.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:df164be93b46b4825cc39339440a05edc54c4d1d865ba4a60fd43d151a2a1cd3"}, + {file = "coverage-7.15.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a524fca1a6f08927d9dc2d4c873cfb7bd7202c247f08b14bdc02424071b8b304"}, + {file = "coverage-7.15.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d70f3542cd38de85a9e257dcb1ac4c1ab4b6d7d2c2a645809207556628755d1c"}, + {file = "coverage-7.15.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d78aa537237212c4313aabe5e964b66acc86350ed19ebc56a3e202df33b6077b"}, + {file = "coverage-7.15.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a318112bb4f79d9d04766196d5a3388caa825908a6a9b052aa87de3d9aea7c61"}, + {file = "coverage-7.15.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e55d24cada901963eed5bc89fa562aa033f0d84b9d3de4ecf363737c13aed11e"}, + {file = "coverage-7.15.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3c78f0cea7275342cf2adc2ad5fdd0aafa106ad91e66d573568f2fcf62c41df5"}, + {file = "coverage-7.15.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:86bd37eabe39977216f630a7fc1b698e7f5e81a191c7186013245c6c3d313f9d"}, + {file = "coverage-7.15.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c6db15c217693bdc3ca0b84de1ba9afafe1c14c26a8a29d77f4ed0de2b6132e2"}, + {file = "coverage-7.15.1-cp312-cp312-win32.whl", hash = "sha256:359f3fbe09a51500c51966596ee4ee4070b356552c70b3b2420eb200d68e0f76"}, + {file = "coverage-7.15.1-cp312-cp312-win_amd64.whl", hash = "sha256:fa75dc099c126e941a9c0baa8ebd2cbc78bd778687534fe410baf754f6d9e374"}, + {file = "coverage-7.15.1-cp312-cp312-win_arm64.whl", hash = "sha256:26f89cf6d0634375f454fa71057945ad18edb0f1607a90fecf22c57dc3dc289a"}, + {file = "coverage-7.15.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:71ac4ca1658ca99160fd58cc6967110e989c34b04627f24ed6ec9f70fb24571a"}, + {file = "coverage-7.15.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:26a40cbf2b13bd94af53ee02a424cb3bb96a9edfac0d00834bd068512a62714b"}, + {file = "coverage-7.15.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f4c5a5eff4ad4f9f7088fd3fc7a66d98d06566ee294b3b053309fb0a3b45be1e"}, + {file = "coverage-7.15.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:962aa56c1c9b016d681265880eb6acc9966029d2c4c559319cc43a1abbb9b59a"}, + {file = "coverage-7.15.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1678eb2dc57a8ce67601b029582ef6d41e9e6ca22692aaeccd4107e40f27386c"}, + {file = "coverage-7.15.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1174900a43f6f8c425fee10d7dbddc308adefcdc78aaced32357f5ab750a0e90"}, + {file = "coverage-7.15.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:98847557a6859cadf693792ce89f440cb89692993f60dc6d3a7e35f3d340216f"}, + {file = "coverage-7.15.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8697b2edb57143546a24389efc11e1b000cd5800fc20d84f04edb601e4a7cfb8"}, + {file = "coverage-7.15.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6827ac0519be3fe91bf96b4060eb00d1d24f82649b29862cd75a3cfca248b02a"}, + {file = "coverage-7.15.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:2de8ecbbc77c7e4d22572779920ed8979c69168675e96be3a548c996568c6c31"}, + {file = "coverage-7.15.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2b25f0f0fa5260df9d7bb55d47c8bdc23fa3382c1a18f7c9cae122e6c320b1ad"}, + {file = "coverage-7.15.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a2effcbd93ae340a58db718fe4181d967f84d352c4cefeaab4ff82ce813901a"}, + {file = "coverage-7.15.1-cp313-cp313-win32.whl", hash = "sha256:895e65c96aef0cecea250f6e35e9a32f11375514e1a0cb5210e0fda128c04e8e"}, + {file = "coverage-7.15.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6d0a28b63a0d75f9ed5118105d1154fc3aa40a8605a30d5d87e3d043ad90fe7"}, + {file = "coverage-7.15.1-cp313-cp313-win_arm64.whl", hash = "sha256:b4ee9818e8bae3544379ad2c09b851c4fb886aaa8860d57a1c1316ddcb16db49"}, + {file = "coverage-7.15.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a886af95f59edf67d5770fd3564d53f4a8af93f25f8c1d60d27e00d7f5674ee8"}, + {file = "coverage-7.15.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:985657ebd707941de90d488d1cbb5efac20bdf81f7b91eba771624ccda4d36f4"}, + {file = "coverage-7.15.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5bbe2a06e0a5e1404d9ffbdb49b819bbd6a3bb198ebea4c8dfe7ad9f1e1c2e81"}, + {file = "coverage-7.15.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bde0fe24083d0b7b3dbafa7a09f0796410af1afa2523f28f5f208d8340a4aaca"}, + {file = "coverage-7.15.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f89f7453d6d46db14cf233e2cd8edcd78de2b9c49d4f1dc109590b4e5dbfbb74"}, + {file = "coverage-7.15.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc3656c9ecc27b36bd0907455b77f83c0069ca9ad4a66dec892b76c696eb6047"}, + {file = "coverage-7.15.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:24d8e85a2a45e44883b488c2659f51fa761dad5353fdb319b672a93facbd2ca9"}, + {file = "coverage-7.15.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68931b5fe746ed4fdaa8892989cab9e6c35781eeb3b0ab2ded893d561e1b3652"}, + {file = "coverage-7.15.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:1ce6947e2a95534ecaa5a15e73c21e550514c980d80eda204d064d789a95f6a4"}, + {file = "coverage-7.15.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:841befdbc89b9c82435fc25b0f4f41858b6238693e45af758bec4cfc1968171c"}, + {file = "coverage-7.15.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5d3de58b837375e7f4c0e1a088ccab5f655efb2fd7427b729df02c862a559633"}, + {file = "coverage-7.15.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b1801963f9f44ae0c0f6d737bc7aeb2bbcde7d1fe7e3b43cddc1961af42d3b41"}, + {file = "coverage-7.15.1-cp314-cp314-win32.whl", hash = "sha256:8c7953c4128ef53b6ffb5f90d87c87d4ce26731df294760bb2314eb0e069e44b"}, + {file = "coverage-7.15.1-cp314-cp314-win_amd64.whl", hash = "sha256:6f0bab60a582d415f0fb535ccff13ba334a47a1538f98913330a525d23bd535a"}, + {file = "coverage-7.15.1-cp314-cp314-win_arm64.whl", hash = "sha256:0f410ee8f0ac4ec7db71bc0b7632a8b9994e1cad2755bd1566c17e6a162caa74"}, + {file = "coverage-7.15.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:fc868bab88e049d41fcd41766810d790a8b960053be2a45e060f5ce0d31d258b"}, + {file = "coverage-7.15.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:206d4ec6028f2773b40932d09f074539d6bcdd8f6b318d40cb04bdbd68ed0b49"}, + {file = "coverage-7.15.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:620482ef1c9f4e61f962e159325fe77dea59d16e39d9c9470d069053b244d864"}, + {file = "coverage-7.15.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d385fc9b054e309ad3cecdc77b586d2af0c98aeec2fdb3773544586f366e817c"}, + {file = "coverage-7.15.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1198bca9c0dd7c188aae1f185b0c0b5fc4f0a2b6909000858c29550320bdb07"}, + {file = "coverage-7.15.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5d0297e6a070eadb49df7cddd0ab6f420b8b689dd8904c7dd815a323168fa57e"}, + {file = "coverage-7.15.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916fcf2214f56960e409561b37fc32a160a42b6e85483d0652d7b70fa55d707e"}, + {file = "coverage-7.15.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f837bae572c7869ffaa502e604c87e182543012831cf87aae4586ad090ac6dcf"}, + {file = "coverage-7.15.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3ea65e3ee6c7c32349fd00559927a9e577bdd72386087eeed1c42b62dfce9b82"}, + {file = "coverage-7.15.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:345034976f46a1c54bd17f4e43eb30bb92cb7082fcddff03250cff136cc4eb82"}, + {file = "coverage-7.15.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4f051a64eb8f8addb4661c2b41d6eea5b7ebc68ad4b2baea8d9bc54e1956e5f7"}, + {file = "coverage-7.15.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a7625770f7720b49bb30d194ad2f8d50fab3c5177874af3d2399676f95f9c594"}, + {file = "coverage-7.15.1-cp314-cp314t-win32.whl", hash = "sha256:81e503d130a472ad1bd38199ecd35116b40d92bcd31e27a2cacde035381f2070"}, + {file = "coverage-7.15.1-cp314-cp314t-win_amd64.whl", hash = "sha256:724e878b213b302ad46e9f2fc872d386613f20ebfc492a211482d917ea76c14f"}, + {file = "coverage-7.15.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ce2f05c14d077f406fefc4fa5e4f093ad0e0787549f6582535d6e28766f0361b"}, + {file = "coverage-7.15.1-py3-none-any.whl", hash = "sha256:717d01e6e00bed56ad13306f19e0dd2f4f645ee8159d2c72c72301d6cfc7090c"}, + {file = "coverage-7.15.1.tar.gz", hash = "sha256:165e9949eaf222ef1f018635d0d7f368a23bfe0212af558534c40d8c04686d67"}, ] [package.dependencies] @@ -440,58 +440,58 @@ toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "cryptography" -version = "50.0.0" +version = "49.0.0" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false python-versions = "!=3.9.0,!=3.9.1,>=3.9" groups = ["main"] files = [ - {file = "cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03"}, - {file = "cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645"}, - {file = "cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7"}, - {file = "cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3"}, - {file = "cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f"}, - {file = "cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae"}, - {file = "cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a"}, - {file = "cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987"}, - {file = "cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169"}, - {file = "cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f"}, - {file = "cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105"}, - {file = "cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef"}, - {file = "cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30"}, - {file = "cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41"}, - {file = "cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc"}, - {file = "cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f"}, - {file = "cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3"}, - {file = "cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533"}, - {file = "cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037"}, - {file = "cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f"}, - {file = "cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11"}, - {file = "cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d"}, - {file = "cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c"}, - {file = "cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c"}, - {file = "cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95"}, - {file = "cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269"}, - {file = "cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07"}, - {file = "cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3"}, - {file = "cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f"}, - {file = "cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5"}, - {file = "cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f"}, - {file = "cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025"}, - {file = "cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a"}, - {file = "cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b"}, - {file = "cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708"}, - {file = "cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47"}, - {file = "cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9"}, - {file = "cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7"}, - {file = "cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba"}, - {file = "cryptography-50.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c"}, - {file = "cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a"}, - {file = "cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e"}, - {file = "cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d"}, - {file = "cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437"}, - {file = "cryptography-50.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9"}, - {file = "cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9"}, + {file = "cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9"}, + {file = "cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f"}, + {file = "cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459"}, + {file = "cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e"}, + {file = "cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8"}, + {file = "cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3"}, + {file = "cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27"}, + {file = "cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61"}, + {file = "cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36"}, + {file = "cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e"}, + {file = "cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b"}, + {file = "cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6"}, + {file = "cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493"}, ] [package.dependencies] diff --git a/pyproject.toml b/pyproject.toml index c8707ca..a039b1f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [project] name = "enroll" -version = "0.8.2" +version = "0.8.0" description = "Enroll a server's running state retrospectively into Ansible" readme = "README.md" requires-python = ">=3.10" diff --git a/release.sh b/release.sh index 6b99fbd..761c816 100755 --- a/release.sh +++ b/release.sh @@ -46,7 +46,7 @@ sudo apt-get -y install createrepo-c rpm BUILD_OUTPUT="${HOME}/git/enroll/dist" KEYID="54A91143AE0AB4F7743B01FE888ED1B423A3BC99" REPO_ROOT="${HOME}/git/repo_rpm" -REMOTE="ashpool.mig5.net:/opt/repo_rpm" +REMOTE="letessier.mig5.net:/opt/repo_rpm" DISTS=( fedora:43 diff --git a/rpm/enroll.spec b/rpm/enroll.spec index 4d5f5ca..b026fee 100644 --- a/rpm/enroll.spec +++ b/rpm/enroll.spec @@ -1,4 +1,4 @@ -%global upstream_version 0.8.2 +%global upstream_version 0.8.0 Name: enroll Version: %{upstream_version} @@ -43,9 +43,6 @@ Enroll a server's running state retrospectively into Ansible. %{_bindir}/enroll %changelog -* Mon Aug 03 2026 Miguel Jacq - %{version}-%{release} -- Make remote harvest zipapp stdlib-only -- Security: fix a TOCTOU in remote harvest. * Mon Jul 13 2026 Miguel Jacq - %{version}-%{release} - Security: keep sudo-created remote harvest bundles root-owned while root packages and hashes them, expose only the archive to the authenticated SSH uid, and verify the root-computed digest after download. This removes the post-harvest tampering window created by recursively chowning the bundle before packaging without making the plaintext archive world-readable. - Security: enforce tar member limits while lazily parsing untrusted archives rather than after `TarFile.getmembers()` has already indexed the entire archive; count repeated `.` entries and cap remote compressed downloads as well. @@ -70,7 +67,7 @@ Enroll a server's running state retrospectively into Ansible. - Add support for generating ipset and iptables configuration files from runtime, if the former weren't present ('firewall_runtime' role) * Tue May 12 2026 Miguel Jacq - %{version}-%{release} - Add ssh config support where JinjaTurtle is used -* Mon Feb 16 2026 Miguel Jacq - %{version}-%{release} +* Tue Feb 16 2026 Miguel Jacq - %{version}-%{release} - Add capability to handle passphrases on encrypted SSH private keys. Prompting can be forced with `--ask-key-passphrase` or automated (e.g for CI) with `--ssh-key-passphrase env SOMEVAR` * Fri Jan 16 2026 Miguel Jacq - %{version}-%{release} - Add support for AddressFamily and ConnectTimeout in the .ssh/config when using `--remote-ssh-config`. diff --git a/tests/test_remote.py b/tests/test_remote.py index 7e44316..255b00f 100644 --- a/tests/test_remote.py +++ b/tests/test_remote.py @@ -2,7 +2,6 @@ from __future__ import annotations import hashlib import io -import shlex import tarfile import warnings from pathlib import Path @@ -10,9 +9,10 @@ from pathlib import Path import pytest -# Sudo harvests promote and verify the upload into a root-private directory; -# no-sudo harvests retain the direct remote digest check. Tests mock the local -# builder with these fixed bytes and digest so both paths can be exercised. +# The remote harvest now SHA-256-verifies the uploaded zipapp on the remote +# before executing it. Tests mock _build_enroll_pyz to write these fixed bytes +# and return (path, _FAKE_PYZ_SHA256); the fake SSH routers below answer the +# verification command with the same digest so the happy paths proceed. _FAKE_PYZ_BYTES = b"PYZ" _FAKE_PYZ_SHA256 = "d6f4e1dbf7ba69af6c798c6f6f67383c978e68f4201bf31902275ef37e6263e1" @@ -28,16 +28,9 @@ def _fake_build_enroll_pyz(td) -> tuple[Path, str]: return p, _FAKE_PYZ_SHA256 -def _is_pyz_promote_cmd(cmd: str) -> bool: - """True if *cmd* is the privileged copy-and-verify operation.""" - return ".enroll.pyz.tmp" in cmd and "os.link" in cmd - - def _is_pyz_verify_cmd(cmd: str) -> bool: - """True if *cmd* is the no-sudo remote pyz SHA-256 check.""" - return ( - "hashlib.sha256" in cmd and "enroll.pyz" in cmd and not _is_pyz_promote_cmd(cmd) - ) + """True if *cmd* is the remote pyz SHA-256 integrity check.""" + return "hashlib.sha256" in cmd and "enroll.pyz" in cmd def _is_remote_uid_cmd(cmd: str) -> bool: @@ -200,9 +193,7 @@ def test_remote_harvest_happy_path(tmp_path: Path, monkeypatch): return self._sftp def exec_command(self, cmd: str, *, get_pty: bool = False, **_kwargs): - if _is_pyz_promote_cmd(cmd): - calls.append((cmd, bool(get_pty))) - return (None, _Stdout(b""), _Stderr()) + # Integrity check of the uploaded pyz (added with SHA-256 verify). if _is_pyz_verify_cmd(cmd): return (None, _Stdout(_FAKE_PYZ_SHA256.encode()), _Stderr()) calls.append((cmd, bool(get_pty))) @@ -295,29 +286,6 @@ def test_remote_harvest_happy_path(tmp_path: Path, monkeypatch): assert "chown -- 1000 /tmp/enroll-root-123/bundle.tgz" in joined assert "chmod 0711 -- /tmp/enroll-root-123" in joined - # The upload is promoted and verified before harvest, and sudo executes - # only the root-owned copy. A later replacement of the SSH-user path can no - # longer alter the bytes that Python opens as root. - promote_i, promote_cmd = next( - (i, c) for i, (c, _pty) in enumerate(calls) if _is_pyz_promote_cmd(c) - ) - harvest_i, harvest_cmd = next( - (i, c) - for i, (c, _pty) in enumerate(calls) - if c.startswith("sudo -n") and " harvest " in c - ) - assert promote_i < harvest_i - promote_argv = shlex.split(promote_cmd) - assert "/tmp/enroll-remote-123" in promote_argv - assert "/tmp/enroll-root-123" in promote_argv - assert promote_argv.count("enroll.pyz") >= 2 - assert "/tmp/enroll-root-123/enroll.pyz" in harvest_cmd - assert "/tmp/enroll-remote-123/enroll.pyz" not in harvest_cmd - harvest_argv = shlex.split(harvest_cmd) - pyz_i = harvest_argv.index("/tmp/enroll-root-123/enroll.pyz") - assert harvest_argv[pyz_i - 2 : pyz_i] == ["-I", "-S"] - assert not any(_is_pyz_verify_cmd(c) for c, _pty in calls) - # The trusted digest must be obtained while the archive is still private, # before ownership/read access is handed to the SSH account. archive_hash_i = next( @@ -442,9 +410,7 @@ def test_remote_harvest_no_sudo_does_not_request_pty_or_chown( return self._sftp def exec_command(self, cmd: str, *, get_pty: bool = False, **_kwargs): - if _is_pyz_promote_cmd(cmd): - calls.append((cmd, bool(get_pty))) - return (None, _Stdout(b""), _Stderr()) + # Integrity check of the uploaded pyz (added with SHA-256 verify). if _is_pyz_verify_cmd(cmd): return (None, _Stdout(_FAKE_PYZ_SHA256.encode()), _Stderr()) calls.append((cmd, bool(get_pty))) @@ -604,9 +570,7 @@ def test_remote_harvest_sudo_password_retry_uses_sudo_s_and_writes_password( return self._sftp def exec_command(self, cmd: str, *, get_pty: bool = False, **_kwargs): - if _is_pyz_promote_cmd(cmd): - calls.append((cmd, bool(get_pty))) - return (None, _Stdout(b""), _Stderr()) + # Integrity check of the uploaded pyz (added with SHA-256 verify). if _is_pyz_verify_cmd(cmd): return (None, _Stdout(_FAKE_PYZ_SHA256.encode()), _Stderr()) calls.append((cmd, bool(get_pty))) @@ -996,8 +960,7 @@ def test_remote_harvest_ssh_key_passphrase_retry(monkeypatch, tmp_path: Path): return self._sftp def exec_command(self, cmd: str, *, get_pty: bool = False, **_kwargs): - if _is_pyz_promote_cmd(cmd): - return (None, _Stdout(b""), _Stderr()) + # Integrity check of the uploaded pyz (added with SHA-256 verify). if _is_pyz_verify_cmd(cmd): return (None, _Stdout(_FAKE_PYZ_SHA256.encode()), _Stderr()) if cmd.startswith("tar -cz -C"): @@ -1121,8 +1084,7 @@ def test_remote_harvest_ssh_key_passphrase_raises_when_not_interactive( return self._sftp def exec_command(self, cmd: str, **_kwargs): - if _is_pyz_promote_cmd(cmd): - return (None, _Stdout(b""), _Stderr()) + # Integrity check of the uploaded pyz (added with SHA-256 verify). if _is_pyz_verify_cmd(cmd): return (None, _Stdout(_FAKE_PYZ_SHA256.encode()), _Stderr()) return (_Stdout(), _Stdout(), _Stderr()) @@ -1254,74 +1216,6 @@ def test_remote_verify_pyz_sha256_rejects_nonzero_rc(): ) -def test_build_enroll_pyz_harvest_starts_without_site_packages(tmp_path: Path): - """The remote payload must not depend on packages installed on the target. - - ``-S`` disables site-packages, reproducing a target that has Python but no - jsonschema/PyYAML. ``-I`` also ignores PYTHON* environment variables and - the current directory. The old eager CLI imports failed here before - argparse could dispatch to ``harvest``. - """ - import subprocess - import sys - - import enroll.remote as r - - pyz, _sha = r._build_enroll_pyz(tmp_path) - proc = subprocess.run( - [sys.executable, "-I", "-S", str(pyz), "harvest", "--help"], - check=False, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - timeout=15, - ) - - assert proc.returncode == 0, proc.stderr - assert "usage: enroll harvest" in proc.stdout - assert "ModuleNotFoundError" not in proc.stderr - assert "jsonschema" not in proc.stderr - assert "yaml" not in proc.stderr.lower() - - -def test_build_enroll_pyz_runs_harvest_without_site_packages(tmp_path: Path): - """Exercise command dispatch and the actual harvest implementation under -S.""" - import subprocess - import sys - - import enroll.remote as r - - build_dir = tmp_path / "build" - build_dir.mkdir() - pyz, _sha = r._build_enroll_pyz(build_dir) - out_dir = tmp_path / "bundle" - proc = subprocess.run( - [ - sys.executable, - "-I", - "-S", - str(pyz), - "harvest", - "--out", - str(out_dir), - "--exclude-path", - "/**", - "--assume-safe-path", - ], - check=False, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - timeout=60, - ) - - assert proc.returncode == 0, proc.stderr - assert (out_dir / "state.json").is_file() - assert "ModuleNotFoundError" not in proc.stderr - assert "jsonschema" not in proc.stderr - assert "yaml" not in proc.stderr.lower() - - def test_build_enroll_pyz_excludes_tests_and_caches_and_returns_sha(tmp_path: Path): import zipfile diff --git a/tests/test_remote_pyz_promotion.py b/tests/test_remote_pyz_promotion.py deleted file mode 100644 index 4ab6f38..0000000 --- a/tests/test_remote_pyz_promotion.py +++ /dev/null @@ -1,508 +0,0 @@ -from __future__ import annotations - -import hashlib -import os -import shlex -import stat -import subprocess -import sys -from pathlib import Path - -import pytest - -import enroll.remote as remote - - -_TRUSTED_PYZ = b"trusted enroll zipapp bytes\n" -_TRUSTED_SHA256 = hashlib.sha256(_TRUSTED_PYZ).hexdigest() - - -def _prepare_dirs(tmp_path: Path) -> tuple[Path, Path]: - stage_dir = tmp_path / "user-stage" - root_dir = tmp_path / "root-stage" - stage_dir.mkdir(mode=0o700) - root_dir.mkdir(mode=0o700) - # mkdir is affected by umask; the production flow explicitly chmods 0700. - root_dir.chmod(0o700) - return stage_dir, root_dir - - -def _load_promotion_function(): - namespace = {"__name__": "enroll_promotion_test"} - exec(remote._REMOTE_PROMOTE_PYZ_SCRIPT, namespace) - return namespace["_promote"] - - -def _promotion_args( - stage_dir: Path, - root_dir: Path, - *, - expected_sha256: str = _TRUSTED_SHA256, - expected_size: int = len(_TRUSTED_PYZ), - source_name: str = "enroll.pyz", - destination_name: str = "enroll.pyz", - expected_owner_uid: int | None = None, -) -> list[str]: - if expected_owner_uid is None: - expected_owner_uid = os.geteuid() - return [ - str(stage_dir), - source_name, - str(root_dir), - destination_name, - expected_sha256, - str(expected_size), - str(expected_owner_uid), - ] - - -def _promote_direct( - stage_dir: Path, - root_dir: Path, - **kwargs, -) -> None: - _load_promotion_function()(_promotion_args(stage_dir, root_dir, **kwargs)) - - -def _run_promotion_subprocess( - stage_dir: Path, - root_dir: Path, - *, - timeout: float = 5, - **kwargs, -) -> subprocess.CompletedProcess[str]: - return subprocess.run( - [ - sys.executable, - "-I", - "-c", - remote._REMOTE_PROMOTE_PYZ_SCRIPT, - *_promotion_args(stage_dir, root_dir, **kwargs), - ], - check=False, - capture_output=True, - text=True, - timeout=timeout, - ) - - -def _assert_no_promoted_copy(root_dir: Path) -> None: - assert not (root_dir / "enroll.pyz").exists() - assert not (root_dir / ".enroll.pyz.tmp").exists() - - -def test_promotion_script_entrypoint_publishes_exact_private_copy(tmp_path: Path): - stage_dir, root_dir = _prepare_dirs(tmp_path) - (stage_dir / "enroll.pyz").write_bytes(_TRUSTED_PYZ) - - result = _run_promotion_subprocess(stage_dir, root_dir) - - assert result.returncode == 0, result.stderr - promoted = root_dir / "enroll.pyz" - assert promoted.read_bytes() == _TRUSTED_PYZ - promoted_stat = promoted.stat() - assert stat.S_IMODE(promoted_stat.st_mode) == 0o500 - assert promoted_stat.st_nlink == 1 - assert not (root_dir / ".enroll.pyz.tmp").exists() - - -def test_replacing_user_path_after_promotion_cannot_change_root_copy(tmp_path: Path): - stage_dir, root_dir = _prepare_dirs(tmp_path) - source = stage_dir / "enroll.pyz" - source.write_bytes(_TRUSTED_PYZ) - - _promote_direct(stage_dir, root_dir) - - replacement = stage_dir / "replacement.pyz" - replacement.write_bytes(b"print('attacker code')\n") - os.replace(replacement, source) - - assert source.read_bytes() != _TRUSTED_PYZ - assert (root_dir / "enroll.pyz").read_bytes() == _TRUSTED_PYZ - - -def test_digest_mismatch_fails_closed_and_removes_partial_copy(tmp_path: Path): - stage_dir, root_dir = _prepare_dirs(tmp_path) - payload = b"attacker-controlled bytes" - (stage_dir / "enroll.pyz").write_bytes(payload) - - with pytest.raises(RuntimeError, match="SHA-256 mismatch"): - _promote_direct(stage_dir, root_dir, expected_size=len(payload)) - - _assert_no_promoted_copy(root_dir) - - -@pytest.mark.parametrize( - "reported_size", [0, len(_TRUSTED_PYZ) - 1, len(_TRUSTED_PYZ) + 1] -) -def test_unexpected_source_size_fails_before_publication( - tmp_path: Path, reported_size: int -): - stage_dir, root_dir = _prepare_dirs(tmp_path) - (stage_dir / "enroll.pyz").write_bytes(_TRUSTED_PYZ) - - with pytest.raises(RuntimeError, match="unexpected size"): - _promote_direct(stage_dir, root_dir, expected_size=reported_size) - - _assert_no_promoted_copy(root_dir) - - -def test_source_symlink_is_rejected_without_following_it(tmp_path: Path): - stage_dir, root_dir = _prepare_dirs(tmp_path) - target = stage_dir / "payload" - target.write_bytes(_TRUSTED_PYZ) - (stage_dir / "enroll.pyz").symlink_to(target.name) - - with pytest.raises(OSError): - _promote_direct(stage_dir, root_dir) - - _assert_no_promoted_copy(root_dir) - - -def test_hardlinked_source_is_rejected(tmp_path: Path): - stage_dir, root_dir = _prepare_dirs(tmp_path) - source = stage_dir / "enroll.pyz" - source.write_bytes(_TRUSTED_PYZ) - os.link(source, stage_dir / "second-name") - - with pytest.raises(RuntimeError, match="must not be hard-linked"): - _promote_direct(stage_dir, root_dir) - - _assert_no_promoted_copy(root_dir) - - -@pytest.mark.skipif(not hasattr(os, "mkfifo"), reason="FIFO test requires POSIX") -def test_fifo_source_is_rejected_without_blocking(tmp_path: Path): - stage_dir, root_dir = _prepare_dirs(tmp_path) - os.mkfifo(stage_dir / "enroll.pyz", mode=0o600) - - result = _run_promotion_subprocess( - stage_dir, - root_dir, - expected_sha256=hashlib.sha256(b"").hexdigest(), - expected_size=0, - timeout=2, - ) - - assert result.returncode != 0 - assert "not a regular file" in result.stderr - _assert_no_promoted_copy(root_dir) - - -def test_directory_source_is_rejected(tmp_path: Path): - stage_dir, root_dir = _prepare_dirs(tmp_path) - (stage_dir / "enroll.pyz").mkdir() - - with pytest.raises(RuntimeError, match="not a regular file"): - _promote_direct(stage_dir, root_dir) - - _assert_no_promoted_copy(root_dir) - - -def test_symlink_destination_directory_is_rejected(tmp_path: Path): - stage_dir = tmp_path / "user-stage" - stage_dir.mkdir() - (stage_dir / "enroll.pyz").write_bytes(_TRUSTED_PYZ) - real_root = tmp_path / "real-root" - real_root.mkdir(mode=0o700) - real_root.chmod(0o700) - root_link = tmp_path / "root-link" - root_link.symlink_to(real_root, target_is_directory=True) - - with pytest.raises(OSError): - _promote_direct(stage_dir, root_link) - - _assert_no_promoted_copy(real_root) - - -def test_non_private_destination_directory_is_rejected(tmp_path: Path): - stage_dir, root_dir = _prepare_dirs(tmp_path) - (stage_dir / "enroll.pyz").write_bytes(_TRUSTED_PYZ) - root_dir.chmod(0o755) - - with pytest.raises(RuntimeError, match="not mode 0700"): - _promote_direct(stage_dir, root_dir) - - _assert_no_promoted_copy(root_dir) - - -def test_promotion_requires_expected_effective_uid(tmp_path: Path): - stage_dir, root_dir = _prepare_dirs(tmp_path) - (stage_dir / "enroll.pyz").write_bytes(_TRUSTED_PYZ) - - with pytest.raises(RuntimeError, match="not running as the expected user"): - _promote_direct( - stage_dir, - root_dir, - expected_owner_uid=os.geteuid() + 1, - ) - - _assert_no_promoted_copy(root_dir) - - -def test_existing_temporary_name_is_not_overwritten(tmp_path: Path): - stage_dir, root_dir = _prepare_dirs(tmp_path) - (stage_dir / "enroll.pyz").write_bytes(_TRUSTED_PYZ) - temporary = root_dir / ".enroll.pyz.tmp" - temporary.write_bytes(b"pre-existing") - - with pytest.raises(FileExistsError): - _promote_direct(stage_dir, root_dir) - - assert temporary.read_bytes() == b"pre-existing" - assert not (root_dir / "enroll.pyz").exists() - - -def test_existing_final_name_is_not_replaced_and_partial_copy_is_removed( - tmp_path: Path, -): - stage_dir, root_dir = _prepare_dirs(tmp_path) - (stage_dir / "enroll.pyz").write_bytes(_TRUSTED_PYZ) - final = root_dir / "enroll.pyz" - final.write_bytes(b"pre-existing") - - with pytest.raises(FileExistsError): - _promote_direct(stage_dir, root_dir) - - assert final.read_bytes() == b"pre-existing" - assert not (root_dir / ".enroll.pyz.tmp").exists() - - -def test_path_replacement_after_open_cannot_change_promoted_inode( - tmp_path: Path, monkeypatch -): - stage_dir, root_dir = _prepare_dirs(tmp_path) - source = stage_dir / "enroll.pyz" - source.write_bytes(_TRUSTED_PYZ) - replacement = stage_dir / "attacker.pyz" - replacement.write_bytes(b"print('attacker code')\n") - - promote = _load_promotion_function() - real_read = os.read - replaced = False - - def replace_path_before_first_read(fd: int, count: int) -> bytes: - nonlocal replaced - if not replaced: - replaced = True - os.replace(replacement, source) - return real_read(fd, count) - - monkeypatch.setattr(os, "read", replace_path_before_first_read) - promote(_promotion_args(stage_dir, root_dir)) - - assert replaced is True - assert source.read_bytes() != _TRUSTED_PYZ - assert (root_dir / "enroll.pyz").read_bytes() == _TRUSTED_PYZ - - -def test_in_place_mutation_after_fstat_fails_digest_and_cleans_output( - tmp_path: Path, monkeypatch -): - stage_dir, root_dir = _prepare_dirs(tmp_path) - source = stage_dir / "enroll.pyz" - source.write_bytes(_TRUSTED_PYZ) - - promote = _load_promotion_function() - real_read = os.read - attacker_fd = os.open(source, os.O_WRONLY) - mutated = False - - def mutate_inode_before_first_read(fd: int, count: int) -> bytes: - nonlocal mutated - if not mutated: - mutated = True - os.lseek(attacker_fd, 0, os.SEEK_SET) - os.write(attacker_fd, b"X" * len(_TRUSTED_PYZ)) - os.fsync(attacker_fd) - return real_read(fd, count) - - monkeypatch.setattr(os, "read", mutate_inode_before_first_read) - try: - with pytest.raises(RuntimeError, match="SHA-256 mismatch"): - promote(_promotion_args(stage_dir, root_dir)) - finally: - os.close(attacker_fd) - - assert mutated is True - _assert_no_promoted_copy(root_dir) - - -def test_remote_promote_builds_one_privileged_atomic_command(monkeypatch): - captured: dict[str, object] = {} - - def fake_ssh_run_sudo(ssh, cmd, *, sudo_password, get_pty): - captured.update( - ssh=ssh, - cmd=cmd, - sudo_password=sudo_password, - get_pty=get_pty, - ) - return 0, "", "" - - monkeypatch.setattr(remote, "_ssh_run_sudo", fake_ssh_run_sudo) - marker = object() - - result = remote._remote_promote_verified_pyz( - marker, - "/tmp/user-stage/enroll.pyz", - "/tmp/root-stage", - _TRUSTED_SHA256, - len(_TRUSTED_PYZ), - remote_python="/usr/bin/python3", - sudo_password="secret", - ) - - assert result == "/tmp/root-stage/enroll.pyz" - assert captured["ssh"] is marker - assert captured["sudo_password"] == "secret" - assert captured["get_pty"] is True - argv = shlex.split(str(captured["cmd"])) - assert argv == [ - "/usr/bin/python3", - "-I", - "-c", - remote._REMOTE_PROMOTE_PYZ_SCRIPT, - "/tmp/user-stage", - "enroll.pyz", - "/tmp/root-stage", - "enroll.pyz", - _TRUSTED_SHA256, - str(len(_TRUSTED_PYZ)), - "0", - ] - - -@pytest.mark.parametrize( - ("uploaded", "root_dir", "digest", "size"), - [ - ("relative/enroll.pyz", "/tmp/root", _TRUSTED_SHA256, len(_TRUSTED_PYZ)), - ("/tmp/stage/enroll.pyz", "relative/root", _TRUSTED_SHA256, len(_TRUSTED_PYZ)), - ("/tmp/stage/enroll.pyz", "/tmp/root", "not-a-digest", len(_TRUSTED_PYZ)), - ("/tmp/stage/enroll.pyz", "/tmp/root", _TRUSTED_SHA256, -1), - ], -) -def test_remote_promote_rejects_invalid_local_parameters_before_sudo( - monkeypatch, uploaded: str, root_dir: str, digest: str, size: int -): - called = False - - def unexpected_sudo(*_args, **_kwargs): - nonlocal called - called = True - raise AssertionError("sudo must not run") - - monkeypatch.setattr(remote, "_ssh_run_sudo", unexpected_sudo) - - with pytest.raises(ValueError): - remote._remote_promote_verified_pyz( - object(), - uploaded, - root_dir, - digest, - size, - remote_python="python3", - sudo_password=None, - ) - assert called is False - - -def test_remote_promote_reports_privileged_failure(monkeypatch): - monkeypatch.setattr( - remote, - "_ssh_run_sudo", - lambda *_args, **_kwargs: (1, "", "digest mismatch"), - ) - - with pytest.raises(RuntimeError, match="Refusing to execute"): - remote._remote_promote_verified_pyz( - object(), - "/tmp/stage/enroll.pyz", - "/tmp/root", - _TRUSTED_SHA256, - len(_TRUSTED_PYZ), - remote_python="python3", - sudo_password=None, - ) - - -def test_sudo_harvest_stops_before_execution_when_promotion_fails( - tmp_path: Path, monkeypatch -): - import types - - # The fake builder must create the file because _remote_harvest reads its size. - def build_pyz(td): - pyz = Path(td) / "enroll.pyz" - pyz.write_bytes(_TRUSTED_PYZ) - return pyz, _TRUSTED_SHA256 - - monkeypatch.setattr(remote, "_build_enroll_pyz", build_pyz) - monkeypatch.setattr( - remote, - "_remote_promote_verified_pyz", - lambda *_args, **_kwargs: (_ for _ in ()).throw( - RuntimeError("promotion rejected attacker bytes") - ), - ) - - user_commands: list[str] = [] - sudo_commands: list[str] = [] - - def fake_run(_ssh, cmd, **_kwargs): - user_commands.append(cmd) - if cmd == "mktemp -d": - return 0, "/tmp/user-stage\n", "" - return 0, "", "" - - def fake_sudo(_ssh, cmd, **_kwargs): - sudo_commands.append(cmd) - if cmd == "mktemp -d": - return 0, "/tmp/root-stage\n", "" - return 0, "", "" - - monkeypatch.setattr(remote, "_ssh_run", fake_run) - monkeypatch.setattr(remote, "_ssh_run_sudo", fake_sudo) - - class FakeSFTP: - def put(self, _local, _remote): - return None - - def close(self): - return None - - class FakeSSH: - def load_system_host_keys(self): - return None - - def set_missing_host_key_policy(self, _policy): - return None - - def connect(self, **_kwargs): - return None - - def open_sftp(self): - return FakeSFTP() - - def close(self): - return None - - monkeypatch.setitem( - sys.modules, - "paramiko", - types.SimpleNamespace( - SSHClient=FakeSSH, - RejectPolicy=type("RejectPolicy", (), {}), - ), - ) - - with pytest.raises(RuntimeError, match="promotion rejected attacker bytes"): - remote._remote_harvest( - local_out_dir=tmp_path / "out", - remote_host="example.com", - no_sudo=False, - ) - - assert not any(" harvest " in command for command in sudo_commands) - assert any(command == "rm -rf -- /tmp/root-stage" for command in sudo_commands) - assert any(command == "rm -rf -- /tmp/user-stage" for command in user_commands)