Skip to content

Commit

Permalink
feat!: pre-load CA certification chains on otaclient starts up, rejec…
Browse files Browse the repository at this point in the history
…t OTA if no CA certs are installed (#408)

This PR resolves an issue that comes from very old version of otaclient(since v1.0.0), which results in OTA image sign cert verification being skipped when no valid CA cert chains can be imported by otaclient. Also the logic of loading and managing the CA cert chains are split away from ota_metadata.legacy package and becomes a standalone module in ota_metadata.utils.cert_store.

BREAKING CHANGE: Now otaclient will explicitly reject OTA when otaclient is not installed with valid CA cert chains. Also otaclient now only loads the CA cert chains once at startup. Re-configuring CA chains now requires otaclient restart.
  • Loading branch information
Bodong-Yang authored Oct 24, 2024
1 parent 3d5f9dd commit 57e55ad
Show file tree
Hide file tree
Showing 12 changed files with 342 additions and 107 deletions.
39 changes: 16 additions & 23 deletions docker/test_base/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,17 @@ ARG UBUNTU_BASE
# ------ stage 1: prepare base image ------ #
#

FROM ${UBUNTU_BASE} AS builder
FROM ${UBUNTU_BASE} AS image_build

SHELL ["/bin/bash", "-c"]
ENV DEBIAN_FRONTEND=noninteractive
ENV SPECIAL_FILE="path;adf.ae?qu.er\y=str#fragファイルement"

# special treatment to the ota-image: create file that needs url escaping
# NOTE: include special identifiers #?; into the pathname
RUN echo -n "${SPECIAL_FILE}" > "${OTA_IMAGE_DIR}/${SPECIAL_FILE}"

# install required packages
RUN set -eux; \
echo -n "${SPECIAL_FILE}" > "/${SPECIAL_FILE}"; \
# install required packages
apt-get update -qq; \
apt-get install -y linux-image-generic; \
apt-get clean; \
Expand Down Expand Up @@ -44,26 +43,24 @@ ENV SPECIAL_FILE="path;adf.ae?qu.er\y=str#fragファイルement"
RUN set -eux; \
apt-get update -qq; \
apt-get install -y -qq --no-install-recommends \
gcc \
git \
libcurl4-openssl-dev \
libssl-dev \
python3-dev \
python3-minimal \
python3-pip \
python3-venv \
python3-dev \
libcurl4-openssl-dev \
libssl-dev \
gcc \
wget \
git; \
wget; \
apt-get install -y -qq linux-image-generic; \
apt-get clean

# install hatch
RUN set -eux; \
apt-get clean; \
# install hatch
python3 -m pip install --no-cache-dir -q -U pip; \
python3 -m pip install --no-cache-dir -U hatch

WORKDIR ${OTA_IMAGE_SERVER_ROOT}

COPY --from=builder / /${OTA_IMAGE_DIR}
COPY --from=image_build / /${OTA_IMAGE_DIR}

# generate test certs and sign key
COPY --chmod=755 ./tests/keys/gen_certs.sh /tmp/certs/
Expand All @@ -72,10 +69,8 @@ RUN set -eu; \
pushd /tmp/certs; \
./gen_certs.sh; \
cp ./* "${CERTS_DIR}"; \
popd

# build the test OTA image
RUN set -eu; \
popd; \
# build the test OTA image
cp "${CERTS_DIR}"/sign.key sign.key; \
cp "${CERTS_DIR}"/sign.pem sign.pem; \
git clone ${OTA_METADATA_REPO}; \
Expand All @@ -96,10 +91,8 @@ RUN set -eu; \
--persistent-file ota-metadata/metadata/persistents.txt \
--rootfs-directory data \
--compressed-rootfs-directory data.zst; \
cp ota-metadata/metadata/persistents.txt .

# cleanup
RUN set -eux; \
cp ota-metadata/metadata/persistents.txt .; \
# cleanup
apt-get clean; \
rm -rf \
/tmp/* \
Expand Down
8 changes: 0 additions & 8 deletions docker/test_base/entry_point.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,10 @@ set -eu
TEST_ROOT=/test_root
OTACLIENT_SRC=/otaclient_src
OUTPUT_DIR="${OUTPUT_DIR:-/test_result}"
CERTS_DIR="${CERTS_DIR:-/certs}"

# copy the source code as source is read-only
cp -R "${OTACLIENT_SRC}" "${TEST_ROOT}"

# copy the certs generated in the docker image to otaclient dir
echo "setup certificates for testing..."
cd ${TEST_ROOT}
mkdir -p ./certs
cp -av ${CERTS_DIR}/root.pem ./certs/1.root.pem
cp -av ${CERTS_DIR}/interm.pem ./certs/1.interm.pem

# exec the input params
echo "execute test with coverage"
cd ${TEST_ROOT}
Expand Down
77 changes: 31 additions & 46 deletions src/ota_metadata/legacy/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@
import shutil
import time
from dataclasses import dataclass, fields
from functools import partial
from os import PathLike
from pathlib import Path
from tempfile import NamedTemporaryFile, TemporaryDirectory
Expand All @@ -71,6 +70,7 @@
from OpenSSL import crypto
from typing_extensions import Self

from ota_metadata.utils.cert_store import CACertChainStore, load_cert_in_pem
from ota_proxy import OTAFileCacheControl
from otaclient_common.common import urljoin_ensure_base
from otaclient_common.downloader import Downloader
Expand Down Expand Up @@ -245,8 +245,8 @@ class _MetadataJWTParser:

HASH_ALG = "sha256"

def __init__(self, metadata_jwt: str, *, certs_dir: Union[str, Path]):
self.cert_dir = Path(certs_dir)
def __init__(self, metadata_jwt: str, *, ca_chains_store: CACertChainStore):
self.ca_chains_store = ca_chains_store

# pre_parse metadata_jwt
jwt_list = metadata_jwt.split(".")
Expand All @@ -267,46 +267,32 @@ def __init__(self, metadata_jwt: str, *, certs_dir: Union[str, Path]):
self.ota_metadata = _MetadataJWTClaimsLayout.parse_payload(self.payload_bytes)
logger.info(f"metadata={self.ota_metadata!r}")

def _verify_metadata_cert(self, metadata_cert: bytes) -> None:
def verify_metadata_cert(self, metadata_cert: bytes) -> None:
"""Verify the metadata's sign certificate against local pinned CA.
Raises:
Raise MetadataJWTVerificationFailed on verification failed.
"""
ca_set_prefix = set()
# e.g. under _certs_dir: A.1.pem, A.2.pem, B.1.pem, B.2.pem
for cert in self.cert_dir.glob("*.*.pem"):
if m := re.match(r"(.*)\..*.pem", cert.name):
ca_set_prefix.add(m.group(1))
else:
raise MetadataJWTVerificationFailed("no pem file is found")
if len(ca_set_prefix) == 0:
logger.warning("there is no root or intermediate certificate")
return
logger.info(f"certs prefixes {ca_set_prefix}")

load_pem = partial(crypto.load_certificate, crypto.FILETYPE_PEM)
if not self.ca_chains_store:
_err_msg = "CA chains store is empty!!! immediately fail the verification"
logger.error(_err_msg)
raise MetadataJWTVerificationFailed(_err_msg)

try:
cert_to_verify = load_pem(metadata_cert)
cert_to_verify = load_cert_in_pem(metadata_cert)
except crypto.Error as e:
_err_msg = f"invalid certificate {metadata_cert}: {e!r}"
logger.exception(_err_msg)
raise MetadataJWTVerificationFailed(_err_msg) from e

for ca_prefix in sorted(ca_set_prefix):
certs_list = [
self.cert_dir / c.name for c in self.cert_dir.glob(f"{ca_prefix}.*.pem")
]

store = crypto.X509Store()
for c in certs_list:
logger.info(f"cert {c}")
store.add_cert(load_pem(c.read_bytes()))

# verify the OTA image cert against CA cert chain store
for ca_prefix, ca_chain in self.ca_chains_store.items():
try:
store_ctx = crypto.X509StoreContext(store, cert_to_verify)
store_ctx.verify_certificate()
crypto.X509StoreContext(
store=ca_chain,
certificate=cert_to_verify,
).verify_certificate()

logger.info(f"verfication succeeded against: {ca_prefix}")
return
except crypto.X509StoreContextError as e:
Expand All @@ -316,7 +302,7 @@ def _verify_metadata_cert(self, metadata_cert: bytes) -> None:
logger.error(_err_msg)
raise MetadataJWTVerificationFailed(_err_msg)

def _verify_metadata(self, metadata_cert: bytes):
def verify_metadata_signature(self, metadata_cert: bytes):
"""Verify metadata against sign certificate.
Raises:
Expand Down Expand Up @@ -344,17 +330,6 @@ def get_otametadata(self) -> "_MetadataJWTClaimsLayout":
"""
return self.ota_metadata

def verify_metadata(self, metadata_cert: bytes):
"""Verify metadata_jwt against metadata cert and local pinned CA certs.
Raises:
Raise MetadataJWTVerificationFailed on verification failed.
"""
# step1: verify the cert itself against local pinned CA cert
self._verify_metadata_cert(metadata_cert)
# step2: verify the metadata against input metadata_cert
self._verify_metadata(metadata_cert)


# place holder for unset must field in _MetadataJWTClaimsLayout
_MUST_SET_CLAIM = object()
Expand Down Expand Up @@ -615,13 +590,18 @@ def __init__(
url_base: str,
downloader: Downloader,
run_dir: Path,
certs_dir: Path,
ca_chains_store: CACertChainStore,
retry_interval: int = 1,
) -> None:
if not ca_chains_store:
_err_msg = "CA chains store is empty!!! immediately fail the verification"
logger.error(_err_msg)
raise MetadataJWTVerificationFailed(_err_msg)

self.url_base = url_base
self._downloader = downloader
self.run_dir = run_dir
self.certs_dir = certs_dir
self.ca_chains_store = ca_chains_store
self.retry_interval = retry_interval
self._tmp_dir = TemporaryDirectory(prefix="ota_metadata", dir=run_dir)
self._tmp_dir_path = Path(self._tmp_dir.name)
Expand Down Expand Up @@ -673,7 +653,8 @@ def _process_metadata_jwt(self) -> _MetadataJWTClaimsLayout:
time.sleep(self.retry_interval)

_parser = _MetadataJWTParser(
_downloaded_meta_f.read_text(), certs_dir=self.certs_dir
_downloaded_meta_f.read_text(),
ca_chains_store=self.ca_chains_store,
)
# get not yet verified parsed ota_metadata
_ota_metadata = _parser.get_otametadata()
Expand All @@ -700,7 +681,11 @@ def _process_metadata_jwt(self) -> _MetadataJWTClaimsLayout:
except Exception as e:
logger.warning(f"failed to download {cert_info}, retrying: {e!r}")
time.sleep(self.retry_interval)
_parser.verify_metadata(cert_file.read_bytes())

cert_bytes = cert_file.read_bytes()

_parser.verify_metadata_cert(cert_bytes)
_parser.verify_metadata_signature(cert_bytes)

# return verified ota metadata
return _ota_metadata
Expand Down
13 changes: 13 additions & 0 deletions src/ota_metadata/utils/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Copyright 2022 TIER IV, INC. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
93 changes: 93 additions & 0 deletions src/ota_metadata/utils/cert_store.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Copyright 2022 TIER IV, INC. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Implementation of otaclient CA cert chain store."""


from __future__ import annotations

import logging
import re
from functools import partial
from pathlib import Path
from typing import Dict

from OpenSSL import crypto

from otaclient_common.typing import StrOrPath

logger = logging.getLogger(__name__)

# we search the CA chains with the following naming schema:
# <env_name>.<intermediate|root>.pem,
# in which <env_name> should match regex pattern `[\w\-]+`
# e.g:
# dev chain: dev.intermediate.pem, dev.root.pem
# prd chain: prd.intermediate.pem, prd.intermediate.pem
CERT_NAME_PA = re.compile(r"(?P<chain>[\w\-]+)\.[\w\-]+\.pem")


class CACertStoreInvalid(Exception): ...


class CACertChainStore(Dict[str, crypto.X509Store]):
"""A dict-like type that stores CA chain name and CA store mapping."""


load_cert_in_pem = partial(crypto.load_certificate, crypto.FILETYPE_PEM)


def load_ca_cert_chains(cert_dir: StrOrPath) -> CACertChainStore:
"""Load CA cert chains from <cert_dir>.
Raises:
CACertStoreInvalid on failed import.
Returns:
A dict
"""
cert_dir = Path(cert_dir)

ca_set_prefix = set()
for cert in cert_dir.glob("*.pem"):
if m := CERT_NAME_PA.match(cert.name):
ca_set_prefix.add(m.group("chain"))
else:
_err_msg = f"detect invalid named certs: {cert.name}"
logger.warning(_err_msg)

if not ca_set_prefix:
_err_msg = f"no CA cert chains found to be installed under the {cert_dir}!!!"
logger.error(_err_msg)
raise CACertStoreInvalid(_err_msg)

logger.info(f"found installed CA chains: {ca_set_prefix}")

ca_chains = CACertChainStore()
for ca_prefix in sorted(ca_set_prefix):
try:
ca_chain = crypto.X509Store()
for c in cert_dir.glob(f"{ca_prefix}.*.pem"):
ca_chain.add_cert(load_cert_in_pem(c.read_bytes()))
ca_chains[ca_prefix] = ca_chain
except Exception as e:
_err_msg = f"failed to load CA chain {ca_prefix}: {e!r}"
logger.warning(_err_msg)

if not ca_chains:
_err_msg = "all found CA chains are invalid, no CA chain is imported!!!"
logger.error(_err_msg)
raise CACertStoreInvalid(_err_msg)

logger.info(f"loaded CA cert chains: {list(ca_chains)}")
return ca_chains
Loading

1 comment on commit 57e55ad

@github-actions
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coverage

Coverage Report
FileStmtsMissCoverMissing
src/ota_metadata/legacy
   __init__.py110100% 
   parser.py3264386%100, 156, 161, 197–198, 208–209, 212, 224, 277–279, 283–286, 320–323, 392, 395, 403–405, 418, 427–428, 431–432, 597–599, 649–650, 653, 681–683, 737, 740–742
   types.py841384%37, 40–42, 112–116, 122–125
src/ota_metadata/utils
   cert_store.py420100% 
src/ota_proxy
   __init__.py361072%59, 61, 63, 72, 81–82, 102, 104–106
   __main__.py770%16–18, 20, 22–23, 25
   _consts.py150100% 
   cache_control_header.py68494%71, 91, 113, 121
   cache_streaming.py1421291%225, 229–230, 265–266, 268, 280, 348, 366–369
   config.py170100% 
   db.py731875%109, 115, 153, 159–160, 163, 169, 171, 192–199, 201–202
   errors.py50100% 
   lru_cache_helper.py47295%84–85
   ota_cache.py2155972%70–71, 140, 151–152, 184–185, 202, 239–243, 247–249, 251, 253–260, 262–264, 267–268, 272–273, 277, 324, 332–334, 413–416, 430, 433–434, 448–449, 451–453, 457–458, 464–465, 496, 502, 529, 581–583
   server_app.py1393674%76, 79, 85, 101, 103, 162, 171, 213–214, 216–218, 221, 226–228, 231–232, 235, 238, 241, 244, 262, 264, 267, 293–296, 299, 313–315, 321–323
   utils.py140100% 
src/otaclient
   __init__.py5260%17, 19
   __main__.py110%16
   log_setting.py52590%53, 55, 64–66
src/otaclient/app
   __main__.py110%16
   configs.py750100% 
   errors.py1200100% 
   interface.py30100% 
   main.py46589%52–53, 75–77
   ota_client.py38311370%84, 92, 113, 140, 142–143, 145, 149, 153–154, 159–160, 166, 168, 209–212, 218, 222, 228, 347, 359–360, 362, 371, 374, 379–380, 383, 389, 391–395, 414–417, 420–427, 455–458, 504–505, 509, 511–512, 542–543, 552–559, 566, 569–575, 623–625, 627, 629–632, 640, 655, 684–686, 691–693, 696–697, 699–700, 702, 760–761, 764, 772–773, 776, 787–788, 791, 799–800, 803, 814, 833, 860, 879, 897
   ota_client_stub.py39410972%76–78, 80–81, 89–92, 95–97, 101, 106–107, 109–110, 113, 115–116, 119–121, 124–125, 128–130, 135–140, 144, 147–151, 153–154, 162–164, 167, 204–206, 211, 247, 272, 275, 278, 382, 406, 408, 432, 478, 535, 605–606, 645, 664–666, 672–675, 679–681, 688–690, 693, 697–700, 753, 842–844, 851, 881–882, 885–889, 898–907, 914, 920, 923–924, 928, 931
   update_stats.py104991%57, 103, 105, 114, 116, 125, 127, 148, 179
src/otaclient/boot_control
   __init__.py40100% 
   _common.py24911255%70–71, 92–94, 110–111, 131–132, 151–152, 171–172, 191–192, 214–216, 231–232, 256–262, 283, 291, 309, 317, 336–337, 340–341, 364, 366–375, 377–386, 388–390, 409, 412, 420, 428, 444–446, 448–453, 546, 551, 556, 669, 673–674, 677, 684, 686–687, 713–714, 716–719, 724, 730–731, 734–735, 737, 744–745, 756–762, 772–774, 778–779, 782–783, 786, 792
   _firmware_package.py932276%82, 86, 136, 180, 186, 209–210, 213–218, 220–221, 224–229, 231
   _grub.py41812869%216, 264–267, 273–277, 314–315, 322–327, 330–336, 339, 342–343, 348, 350–352, 361–367, 369–370, 372–374, 383–385, 387–389, 468–469, 473–474, 526, 532, 558, 580, 584–585, 600–602, 626–629, 641, 645–647, 649–651, 710–713, 738–741, 764–767, 779–780, 783–784, 819, 825, 845–846, 848, 860, 863, 866, 869, 873–875, 893–896, 924–927, 932–940, 945–953
   _jetson_cboot.py2632630%20, 22–25, 27–29, 35–39, 41–42, 58–59, 61, 63–64, 70, 74, 133, 136, 138–139, 142, 149–150, 158–159, 162, 168–169, 177, 186–190, 192, 198, 201–202, 208, 211–212, 217–218, 220, 226–227, 230–231, 234–236, 238, 244, 249–251, 253–255, 260, 262–265, 267–268, 277–278, 281–282, 287–288, 291–295, 298–299, 304–305, 308, 311–315, 320–323, 326, 329–330, 333, 336–337, 340, 344–349, 353–354, 358, 361–362, 365, 368–371, 373, 376–377, 381, 384, 387–390, 392, 399, 403–404, 407–408, 414–415, 421, 423–424, 428, 430, 432–434, 437, 441, 444, 447–448, 450, 453, 461–462, 469, 479, 482, 490–491, 496–499, 501, 508, 510–512, 518–519, 523–524, 527, 531, 534, 536, 543–547, 549, 561–564, 567, 570, 572, 579, 583–584, 586–587, 589–591, 593, 595, 598, 601, 604, 606–607, 610–614, 618–620, 622, 630–634, 636, 639, 643, 646, 657–658, 663, 673, 676–684, 688–697, 701–710, 714, 716–718, 720–721, 723–724
   _jetson_common.py1734573%133, 141, 289–292, 295, 312, 320, 355, 360–365, 383, 409–410, 412–414, 418–421, 423–424, 426–430, 432, 439–440, 443–444, 454, 457–458, 461, 463, 507–508
   _jetson_uefi.py39827131%123–125, 130–131, 150–152, 157–160, 327, 445, 447–450, 454, 458–459, 461–469, 471, 483–484, 487–488, 491–492, 495–497, 501–502, 507–509, 513, 517–518, 521–522, 525–526, 530, 533–534, 536, 541–542, 546, 549–550, 555, 559–560, 563, 567–569, 571, 575–578, 580–581, 603–604, 608–609, 611, 615, 619–620, 623–624, 631, 634–636, 639, 641–642, 647–648, 651–654, 656–657, 662, 664–665, 673, 676–679, 681–682, 684, 688–689, 693, 701–705, 708–709, 711, 714–718, 721, 724–728, 732–733, 736–741, 744–745, 748–751, 753–754, 761–762, 772–775, 778, 781–784, 787–791, 794–795, 798, 801–804, 807, 809, 814–815, 818, 821–824, 826, 832, 837–838, 857–858, 861, 869–870, 877, 887, 890, 897–898, 903–906, 914–917, 925–926, 938–941, 943, 946, 949, 957, 968–970, 972–974, 976–980, 985–986, 988, 1001, 1005, 1008, 1018, 1023, 1031–1032, 1035, 1039, 1041–1043, 1049–1050, 1055, 1063–1070, 1075–1083, 1088–1096, 1102–1104
   _rpi_boot.py28713453%54, 57, 121–122, 126, 134–137, 151–154, 161–162, 164–165, 170–171, 174–175, 184–185, 223, 229–233, 236, 254–256, 260–262, 267–269, 273–275, 285–286, 289, 292, 294–295, 297–298, 300–302, 308, 311–312, 322–325, 333–337, 339, 341–342, 347–348, 355–361, 392, 394–397, 407–410, 414–415, 417–421, 449–452, 471–474, 479, 482, 500–503, 508–516, 521–529, 546–549, 555–557, 560, 563
   configs.py550100% 
   protocol.py40100% 
   selecter.py412929%45–47, 50–51, 55–56, 59–61, 64, 66, 70, 78–80, 82–83, 85–86, 90, 92, 94–95, 97, 99–100, 102, 104
src/otaclient/configs
   __init__.py50100% 
   _cfg_configurable.py510100% 
   _cfg_consts.py44197%92
   _common.py80100% 
   _ecu_info.py56492%59, 64–65, 112
   _proxy_info.py51296%88, 90
   cfg.py19384%54–56
src/otaclient/create_standby
   __init__.py12558%29–31, 33, 35
   common.py2244480%62, 65–66, 70–72, 74, 78–79, 81, 127, 175–177, 179–181, 183, 186–189, 193, 204, 278–279, 281–286, 298, 335, 363, 366–368, 384–385, 399, 403, 425–426
   interface.py50100% 
   rebuild_mode.py97990%93–95, 107–112
src/otaclient_api/v2
   api_caller.py39684%45–47, 83–85
   api_stub.py170100% 
   types.py2562391%86, 89–92, 131, 209–210, 212, 259, 262–263, 506–508, 512–513, 515, 518–519, 522–523, 586
src/otaclient_common
   __init__.py34876%42–44, 61, 63, 69, 76–77
   _io.py64198%41
   common.py1061189%44, 148, 151–153, 168, 175–177, 271, 275
   downloader.py1991094%107–108, 126, 153, 369, 424, 428, 516–517, 526
   linux.py611575%51–53, 59, 69, 74, 76, 108–109, 133–134, 190, 195–196, 198
   logging.py29196%55
   persist_file_handling.py1181884%113, 118, 150–152, 163, 192–193, 228–232, 242–244, 246–247
   proto_streamer.py42880%33, 48, 66–67, 72, 81–82, 100
   proto_wrapper.py3984887%87, 165, 172, 184–186, 205, 210, 221, 257, 263, 268, 299, 303, 307, 402, 462, 469, 472, 492, 499, 501, 526, 532, 535, 537, 562, 568, 571, 573, 605, 609, 611, 625, 642, 669, 672, 676, 707, 713, 760–763, 765, 803–805
   retry_task_map.py105595%158–159, 161, 181–182
   typing.py31487%48, 97–98, 100
TOTAL6481167974% 

Tests Skipped Failures Errors Time
231 0 💤 0 ❌ 0 🔥 13m 5s ⏱️

Please sign in to comment.