Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ build.targets.wheel.packages = ["src/open_mpic_core"]
"./tests/unit/test_util" = "open_mpic_core_test/test_util" # include tests in the wheel to facilitate integration testing in wrapper projects

[tool.api]
spec_version = "3.6.0"
spec_version = "3.7.0"
spec_repository = "https://github.com/open-mpic/open-mpic-specification"

[tool.hatch.envs.default]
Expand Down
2 changes: 1 addition & 1 deletion src/open_mpic_core/__about__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "6.1.0"
__version__ = "6.2.0"
7 changes: 5 additions & 2 deletions src/open_mpic_core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,14 @@
MpicEffectiveOrchestrationParameters,
)

from open_mpic_core.mpic_coordinator.domain.cohort_creation_exception import CohortCreationException
from open_mpic_core.mpic_coordinator.domain.perspective_response import PerspectiveResponse
from open_mpic_core.mpic_coordinator.domain.mpic_request import MpicRequest, MpicDcvRequest, MpicCaaRequest
from open_mpic_core.mpic_coordinator.domain.mpic_response import MpicResponse, MpicCaaResponse, MpicDcvResponse
from open_mpic_core.mpic_coordinator.domain.mpic_request_errors import MpicRequestValidationException
from open_mpic_core.mpic_coordinator.domain.mpic_request_errors import (
MpicRequestValidationException,
CohortCreationException,
CohortSelectionException
)
from open_mpic_core.mpic_coordinator.domain.remote_check_call_configuration import RemoteCheckCallConfiguration
from open_mpic_core.mpic_coordinator.domain.remote_check_exception import RemoteCheckException
from open_mpic_core.mpic_coordinator.messages.mpic_request_validation_messages import MpicRequestValidationMessages
Expand Down
1 change: 1 addition & 0 deletions src/open_mpic_core/common_domain/messages/ErrorMessages.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ class ErrorMessages(Enum):
GENERAL_HTTP_ERROR = ('mpic_error:http', 'An HTTP error occurred: Response status {0}, Response reason: {1}')
INVALID_REDIRECT_ERROR = ('mpic_error:redirect:invalid', 'Invalid redirect. Redirect code: {0}, target: {1}')
COHORT_CREATION_ERROR = ('mpic_error:coordinator:cohort', 'The coordinator could not construct a cohort of size {0}')
COHORT_SELECTION_ERROR = ('mpic_error:coordinator:cohort_selection', 'The coordinator could not select cohort number {0} from available cohorts.')
Copy link
Member

Choose a reason for hiding this comment

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

I'm not going to block on this, but somehow my C programing mindset gets a little scared when I see numbered format tokens in string literals particularly when the format string is declared separately from the format commend. I think this is all good but if one of these strings was rewritten to have fewer or more format tokens, would that cause an error (at least its not going to just buffer overflow like it would in C).

Copy link
Collaborator Author

@sciros sciros Dec 4, 2025

Choose a reason for hiding this comment

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

It won't cause issues like in C and thankfully there's unit tests that exercise the error code but it's not as elegant as maybe some other approach could be.
Anthropic Claude's take:

Key difference from C: Unlike C's format string vulnerabilities, Python's string formatting is type-safe and memory-safe. There's no buffer overflow risk, and mismatches produce clear runtime errors rather than undefined behavior or security issues.
Practical considerations:

  • The error would be caught immediately when that code path executes (unlike silent C corruption)
  • Unit tests covering these error cases would catch mismatches
  • f-strings (f"Response status {status}") would make this mismatch impossible since variables are directly embedded, though they'd require the values at definition time rather than later formatting

The pattern here (constants with deferred formatting) is reasonable for error definitions. The main risk is maintenance errors that tests should catch.


TLS_ALPN_ERROR_CERTIFICATE_EXTENSION_MISSING = ('mpic_error:dcv_checker:tls_alpn:certificate:extension_missing', 'The TLS ALPN certificate was missing an extension.')
TLS_ALPN_ERROR_CERTIFICATE_ALPN_EXTENSION_NONCRITICAL = ('mpic_error:dcv_checker:tls_alpn:certificate:noncritical_alpn_extension', 'The TLS ALPN certificate has non-critical id-pe-acmeIdentifier extension')
Expand Down
43 changes: 34 additions & 9 deletions src/open_mpic_core/common_util/domain_encoder.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import ipaddress

import dns.name
import idna
from dns.name import IDNAException


class DomainEncoder:
Expand All @@ -14,12 +17,34 @@ def prepare_target_for_lookup(domain_or_ip_target) -> str:
pass

# Convert to IDNA/Punycode
try:
is_wildcard = domain_or_ip_target.startswith("*.")
if is_wildcard:
domain_or_ip_target = domain_or_ip_target[2:] # Remove *. prefix

encoded_domain = idna.encode(domain_or_ip_target, uts46=True).decode("ascii")
return encoded_domain
except idna.IDNAError as e:
raise ValueError(f"Invalid domain name: {str(e)}")
is_wildcard = domain_or_ip_target.startswith("*.")
if is_wildcard:
domain_or_ip_target = domain_or_ip_target[2:] # Remove *. prefix

prepared_domain = domain_or_ip_target
is_already_encoded = False

for label_text in domain_or_ip_target.split("."):
# check if any label is punycode encoded
if label_text.startswith("xn--"):
try:
label_bytes = label_text.encode("ascii")
try:
dns.name.IDNA_2008_Strict.decode(label_bytes)
except IDNAException:
try:
dns.name.IDNA_2003_Strict.decode(label_bytes)
except IDNAException as e2:
raise ValueError(f"Invalid domain name: {str(e2)}")
except UnicodeEncodeError:
raise ValueError(f"Invalid domain name: Label '{label_text}' is not valid ASCII.")
# if we made it here then we had a valid punycode label
is_already_encoded = True

if not is_already_encoded:
try:
prepared_domain = idna.encode(domain_or_ip_target, uts46=True).decode("ascii")
except idna.IDNAError as e:
raise ValueError(f"Invalid domain name: {str(e)}")

return prepared_domain

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ class BaseMpicOrchestrationParameters(BaseModel, ABC):

class MpicRequestOrchestrationParameters(BaseMpicOrchestrationParameters):
max_attempts: int | None = None
cohort_for_single_attempt: int | None = None # sets max_attempts to 1 if defined; must be > 0


class MpicEffectiveOrchestrationParameters(BaseMpicOrchestrationParameters):
Expand Down
10 changes: 10 additions & 0 deletions src/open_mpic_core/mpic_coordinator/domain/mpic_request_errors.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,12 @@
class MpicRequestValidationException(Exception):
pass


class CohortCreationException(Exception):
def __init__(self, message):
super().__init__(message)


class CohortSelectionException(Exception):
def __init__(self, message):
super().__init__(message)
17 changes: 15 additions & 2 deletions src/open_mpic_core/mpic_coordinator/mpic_coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from open_mpic_core import MpicValidationError, ErrorMessages
from open_mpic_core import CheckType
from open_mpic_core import CohortCreator
from open_mpic_core import CohortCreationException
from open_mpic_core import CohortCreationException, CohortSelectionException
from open_mpic_core import RemoteCheckException
from open_mpic_core import RemoteCheckCallConfiguration
from open_mpic_core import RemotePerspective
Expand Down Expand Up @@ -79,12 +79,22 @@ async def coordinate_mpic(self, mpic_request: MpicRequest) -> MpicResponse:
if len(perspective_cohorts) == 0:
raise CohortCreationException(ErrorMessages.COHORT_CREATION_ERROR.message.format(perspective_count))

# check if a specific cohort is requested for single attempt
cohort_to_use = None
if orchestration_parameters is not None and orchestration_parameters.cohort_for_single_attempt is not None:
cohort_to_use = orchestration_parameters.cohort_for_single_attempt
if not MpicRequestValidator.is_requested_cohort_for_single_attempt_valid(
cohort_to_use, len(perspective_cohorts)
):
raise CohortSelectionException(ErrorMessages.COHORT_SELECTION_ERROR.message.format(cohort_to_use))

quorum_count = self.determine_required_quorum_count(orchestration_parameters, perspective_count)

if (
orchestration_parameters is not None
and orchestration_parameters.max_attempts is not None
and orchestration_parameters.max_attempts > 0
and orchestration_parameters.cohort_for_single_attempt is None
):
max_attempts = orchestration_parameters.max_attempts
if self.global_max_attempts is not None and max_attempts > self.global_max_attempts:
Expand All @@ -96,7 +106,10 @@ async def coordinate_mpic(self, mpic_request: MpicRequest) -> MpicResponse:
cohort_cycle = cycle(perspective_cohorts)

while attempts <= max_attempts:
perspectives_to_use = next(cohort_cycle)
if cohort_to_use is not None:
perspectives_to_use = perspective_cohorts[cohort_to_use - 1] # cohorts are 1-indexed for the user
else:
perspectives_to_use = next(cohort_cycle)

# Collect async calls to invoke for each perspective.
async_calls_to_issue = MpicCoordinator.collect_checker_calls_to_issue(mpic_request, perspectives_to_use)
Expand Down
5 changes: 5 additions & 0 deletions src/open_mpic_core/mpic_coordinator/mpic_request_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ def is_requested_perspective_count_valid(requested_perspective_count, target_per
target_perspectives
)

@staticmethod
def is_requested_cohort_for_single_attempt_valid(cohort_for_single_attempt, number_of_cohorts) -> bool:
# check if cohort_for_single_attempt is an integer and is within the number of available cohorts
return isinstance(cohort_for_single_attempt, int) and 1 <= cohort_for_single_attempt <= number_of_cohorts

@staticmethod
def validate_quorum_count(requested_perspective_count, quorum_count, request_validation_issues) -> None:
# quorum_count can be no less than perspectives-1 if perspectives <= 5
Expand Down
49 changes: 44 additions & 5 deletions tests/unit/open_mpic_core/test_domain_encoder.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,16 @@


class TestDomainEncoder:

@staticmethod
@pytest.mark.parametrize(
"input_domain, expected_output",
[
("café.example.com", "xn--caf-dma.example.com"),
("bücher.example.de", "xn--bcher-kva.example.de"),
("свічка.example.com", "xn--80ady0a5a8f.example.com"),
("127.0.0.1", "127.0.0.1"),
("example.com", "example.com"),
("subdomain.café.example.com", "subdomain.xn--caf-dma.example.com"),
],
)
def prepare_domain_for_lookup__should_convert_nonascii_domain_to_punycode(input_domain, expected_output):
Expand All @@ -23,15 +24,53 @@ def prepare_domain_for_lookup__should_convert_nonascii_domain_to_punycode(input_
@pytest.mark.parametrize(
"input_domain, expected_output",
[
("*.example.com", "example.com"),
("*.café.example.com", "xn--caf-dma.example.com"),
("*.example.com", "example.com"), # ascii
("*.café.example.com", "xn--caf-dma.example.com"), # not encoded
("*.xn--yaztura-tfb.com", "xn--yaztura-tfb.com"), # already encoded
],
)
def prepare_domain_for_lookup__should_remove_leading_asterisk_from_wildcard_domain(input_domain, expected_output):
result = DomainEncoder.prepare_target_for_lookup(input_domain)
assert result == expected_output

@staticmethod
def prepare_domain_for_lookup__should_raise_value_error_if_idna_error_encountered():
@pytest.mark.parametrize(
"input_domain, expected_output",
[
("xn--caf-dma.example.com", "xn--caf-dma.example.com"), # café.example.com idna2008
("*.xn--yaztura-tfb.com", "xn--yaztura-tfb.com"),
("xn--nxasmm1c.com", "xn--nxasmm1c.com"), # "βόλος.com" idna2008
("xn--ls8h.la", "xn--ls8h.la"), # poop emoji idna2003
("xn--4ca.com", "xn--4ca.com"), # "√.com" idna2003
],
)
def prepare_domain_for_lookup__should_allow_already_encoded_domain(input_domain, expected_output):
result = DomainEncoder.prepare_target_for_lookup(input_domain)
assert result == expected_output

@staticmethod
@pytest.mark.parametrize(
"input_domain, expected_output",
[
("sub.xn--caf-dma.example.com", "sub.xn--caf-dma.example.com"),
("sub.xn--4ca.com", "sub.xn--4ca.com"), # "√.com" idna2003
("sub.xn--ls8h.la", "sub.xn--ls8h.la"), # poop emoji idna2003
],
)
def prepare_domain_for_lookup__should_detect_punycode_in_inner_labels(input_domain, expected_output):
result = DomainEncoder.prepare_target_for_lookup(input_domain)
assert result == expected_output

@staticmethod
@pytest.mark.parametrize(
"input_domain",
[
"*example.com",
"exa mple.com",
"exam!ple.com",
"xn--café.com", # invalid (punycode prefix on non-ascii string)
],
)
def prepare_domain_for_lookup__should_raise_value_error_given_malformed_domain(input_domain):
with pytest.raises(ValueError):
DomainEncoder.prepare_target_for_lookup("*example.com")
DomainEncoder.prepare_target_for_lookup(input_domain)
75 changes: 74 additions & 1 deletion tests/unit/open_mpic_core/test_mpic_coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
ErrorMessages,
CaaCheckResponse,
CaaCheckResponseDetails,
CohortCreationException,
CohortSelectionException,
MpicRequestOrchestrationParameters,
RemotePerspective,
MpicRequestValidationException,
Expand All @@ -21,7 +23,6 @@
)
from open_mpic_core.common_domain.enum.regional_internet_registry import RegionalInternetRegistry

from open_mpic_core.mpic_coordinator.domain.cohort_creation_exception import CohortCreationException
from unit.test_util.valid_mpic_request_creator import ValidMpicRequestCreator


Expand Down Expand Up @@ -458,6 +459,78 @@ async def coordinate_mpic__should_raise_exception_given_logically_invalid_mpic_r
with pytest.raises(MpicRequestValidationException):
await mpic_coordinator.coordinate_mpic(mpic_request)

@pytest.mark.parametrize("cohort_for_single_attempt", [1, 2])
async def coordinate_mpic__should_perform_attempt_with_cohort_if_single_attempt_cohort_number_specified(
self, cohort_for_single_attempt
):
# will create 2 cohorts with 3 perspectives each (2 in RIR 'ARIN', and 1 in 'RIPE NCC').
perspectives = [
RemotePerspective(rir=RegionalInternetRegistry.ARIN, code="us-east-1"),
RemotePerspective(rir=RegionalInternetRegistry.ARIN, code="us-west-1"),
RemotePerspective(rir=RegionalInternetRegistry.RIPE_NCC, code="eu-central-1"),
RemotePerspective(rir=RegionalInternetRegistry.ARIN, code="us-east-2"),
RemotePerspective(rir=RegionalInternetRegistry.ARIN, code="us-west-2"),
RemotePerspective(rir=RegionalInternetRegistry.RIPE_NCC, code="eu-central-2"),
]
mpic_coordinator_config = self.create_mpic_coordinator_configuration()
mpic_coordinator_config.target_perspectives = perspectives

mocked_call_remote_perspective_function = AsyncMock()
mocked_call_remote_perspective_function.side_effect = TestMpicCoordinator.SideEffectForMockedPayloads(
self.create_passing_caa_check_response
)
mpic_coordinator = MpicCoordinator(mocked_call_remote_perspective_function, mpic_coordinator_config)

mpic_request = ValidMpicRequestCreator.create_valid_caa_mpic_request()
mpic_request.orchestration_parameters = MpicRequestOrchestrationParameters(
perspective_count=3,
cohort_for_single_attempt=cohort_for_single_attempt
)

mpic_response = await mpic_coordinator.coordinate_mpic(mpic_request)
assert mpic_response.is_valid is True
assert mpic_response.actual_orchestration_parameters.attempt_count == 1

# fmt: off
@pytest.mark.parametrize("cohort_size, single_attempt_cohort_number", [
(2, 0),
(2, -1),
(3, 4),
(6, 2)
])
# fmt: on
async def coordinate_mpic__should_raise_exception_given_invalid_single_attempt_cohort_number_specified(
self, cohort_size, single_attempt_cohort_number
):
# If cohort_size is 2, should create cohorts with 2 perspectives each. (One cohort will be all 'ARIN'.)
# If cohort_size is 3, should create cohorts with 3 perspectives each (2 in RIR 'ARIN', and 1 in 'RIPE NCC').
# If cohort_size is 6, should create cohort with 6 perspectives (4 in RIR 'ARIN', and 2 in 'RIPE NCC').
perspectives = [
RemotePerspective(rir=RegionalInternetRegistry.ARIN, code="us-east-1"),
RemotePerspective(rir=RegionalInternetRegistry.ARIN, code="us-west-1"),
RemotePerspective(rir=RegionalInternetRegistry.RIPE_NCC, code="eu-central-1"),
RemotePerspective(rir=RegionalInternetRegistry.ARIN, code="us-east-2"),
RemotePerspective(rir=RegionalInternetRegistry.ARIN, code="us-west-2"),
RemotePerspective(rir=RegionalInternetRegistry.RIPE_NCC, code="eu-central-2"),
]
mpic_coordinator_config = self.create_mpic_coordinator_configuration()
mpic_coordinator_config.target_perspectives = perspectives

mocked_call_remote_perspective_function = AsyncMock()
mocked_call_remote_perspective_function.side_effect = TestMpicCoordinator.SideEffectForMockedPayloads(
self.create_passing_caa_check_response
)
mpic_coordinator = MpicCoordinator(mocked_call_remote_perspective_function, mpic_coordinator_config)

mpic_request = ValidMpicRequestCreator.create_valid_caa_mpic_request()
mpic_request.orchestration_parameters = MpicRequestOrchestrationParameters(
perspective_count=cohort_size,
cohort_for_single_attempt=single_attempt_cohort_number
)

with pytest.raises(CohortSelectionException):
await mpic_coordinator.coordinate_mpic(mpic_request)

async def coordinate_mpic__should_return_trace_identifier_if_included_in_request(self):
mpic_request = ValidMpicRequestCreator.create_valid_caa_mpic_request()
mpic_request.trace_identifier = "test_trace_identifier"
Expand Down
Loading