Skip to content

Commit 3baf7ac

Browse files
committed
basic artifact identifier validation
Change-Id: Ie3ce15c2aedba33643f74066eb872fb5269261ec
1 parent 6dbf701 commit 3baf7ac

File tree

6 files changed

+276
-6
lines changed

6 files changed

+276
-6
lines changed

python/src/etos_api/library/validator.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from pydantic import BaseModel # pylint:disable=no-name-in-module
2323
from pydantic import ValidationError, conlist, constr, field_validator
2424
from pydantic.fields import PrivateAttr
25+
from packageurl import PackageURL
2526

2627
from etos_api.library.docker import Docker
2728

@@ -176,3 +177,51 @@ async def validate(self, test_suite):
176177
assert (
177178
await docker.digest(test_runner) is not None
178179
), f"Test runner {test_runner} not found"
180+
181+
182+
class ArtifactValidator:
183+
"""Validator for artifact identities and IDs."""
184+
185+
def validate_artifact_identity_or_id(
186+
self, artifact_identity: str = None, artifact_id: str = None
187+
) -> None:
188+
"""Validate that artifact_identity or artifact_id is a valid PURL or UUID.
189+
190+
:param artifact_identity: The artifact identity to validate (should be PURL if provided).
191+
:param artifact_id: The artifact ID to validate (should be UUID if provided).
192+
:raises ValueError: If validation fails.
193+
"""
194+
if artifact_identity:
195+
if not self.validate_purl(artifact_identity):
196+
raise ValueError(
197+
f"Invalid artifact_identity: '{artifact_identity}' is not a valid PURL. "
198+
"PURL must start with 'pkg:'"
199+
)
200+
201+
if artifact_id:
202+
if not self.validate_uuid(artifact_id):
203+
raise ValueError(f"Invalid artifact_id: '{artifact_id}' is not a valid UUID.")
204+
205+
def validate_purl(self, purl_string: str) -> bool:
206+
"""Validate if a string is a valid Package URL (PURL).
207+
208+
:param purl_string: The string to validate as a PURL.
209+
:return: True if valid PURL, False otherwise.
210+
"""
211+
try:
212+
PackageURL.from_string(purl_string)
213+
return True
214+
except (ValueError, TypeError):
215+
return False
216+
217+
def validate_uuid(self, uuid_string: str) -> bool:
218+
"""Validate if a string is a valid UUID.
219+
220+
:param uuid_string: The string to validate as a UUID.
221+
:return: True if valid UUID, False otherwise.
222+
"""
223+
try:
224+
UUID(str(uuid_string))
225+
return True
226+
except (ValueError, TypeError):
227+
return False

python/src/etos_api/routers/v0/router.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,8 @@
3030

3131
from etos_api.library.environment import Configuration, configure_testrun
3232
from etos_api.library.utilities import sync_to_async
33-
3433
from .schemas import AbortEtosResponse, StartEtosRequest, StartEtosResponse
35-
from .utilities import wait_for_artifact_created, validate_suite
34+
from .utilities import wait_for_artifact_created, validate_suite, validate_artifact
3635

3736
ETOSv0 = FastAPI(
3837
title="ETOS",
@@ -108,6 +107,11 @@ async def _start(etos: StartEtosRequest, span: Span) -> dict: # pylint:disable=
108107
await validate_suite(etos.test_suite_url)
109108
LOGGER.info("Test suite validated.")
110109

110+
# Validate artifact identity and ID before proceeding
111+
LOGGER.info("Validating artifact identity and ID.")
112+
await validate_artifact(artifact_identity=etos.artifact_identity, artifact_id=etos.artifact_id)
113+
LOGGER.info("Artifact identity and ID validated.")
114+
111115
etos_library = ETOS("ETOS API", os.getenv("HOSTNAME"), "ETOS API")
112116
await sync_to_async(etos_library.config.rabbitmq_publisher_from_environment)
113117

python/src/etos_api/routers/v0/utilities.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
ARTIFACT_IDENTITY_QUERY,
2828
VERIFY_ARTIFACT_ID_EXISTS,
2929
)
30-
from etos_api.library.validator import SuiteValidator
30+
from etos_api.library.validator import SuiteValidator, ArtifactValidator
3131

3232
LOGGER = logging.getLogger(__name__)
3333

@@ -106,3 +106,23 @@ async def validate_suite(test_suite_url: str) -> None:
106106
raise HTTPException(
107107
status_code=400, detail=f"Test suite validation failed. {exception}"
108108
) from exception
109+
110+
111+
async def validate_artifact(artifact_identity: str = None, artifact_id=None) -> None:
112+
"""Validate the artifact identity and ID through the ArtifactValidator.
113+
114+
:param artifact_identity: The artifact identity to validate (should be PURL if provided).
115+
:param artifact_id: The artifact ID to validate (can be UUID object or string).
116+
"""
117+
span = trace.get_current_span()
118+
119+
try:
120+
# Convert artifact_id to string if it's not None
121+
artifact_id_str = str(artifact_id) if artifact_id is not None else None
122+
ArtifactValidator().validate_artifact_identity_or_id(artifact_identity, artifact_id_str)
123+
except ValueError as exception:
124+
msg = "Artifact validation failed!"
125+
LOGGER.error(msg)
126+
LOGGER.error(exception)
127+
span.add_event(msg)
128+
raise HTTPException(status_code=400, detail=msg) from exception

python/src/etos_api/routers/v1alpha/router.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,12 @@
3535
from opentelemetry import trace
3636
from opentelemetry.trace import Span
3737

38-
3938
from .schemas import AbortTestrunResponse, StartTestrunRequest, StartTestrunResponse
4039
from .utilities import (
4140
wait_for_artifact_created,
4241
download_suite,
4342
validate_suite,
43+
validate_artifact,
4444
convert_to_rfc1123,
4545
recipes_from_tests,
4646
)
@@ -124,6 +124,11 @@ async def _create_testrun(etos: StartTestrunRequest, span: Span) -> dict:
124124
await validate_suite(test_suite)
125125
LOGGER.info("Test suite validated.")
126126

127+
# Validate artifact identity and ID before proceeding
128+
LOGGER.info("Validating artifact identity and ID.")
129+
await validate_artifact(artifact_identity=etos.artifact_identity, artifact_id=etos.artifact_id)
130+
LOGGER.info("Artifact identity and ID validated.")
131+
127132
etos_library = ETOS("ETOS API", os.getenv("HOSTNAME", "localhost"), "ETOS API")
128133

129134
LOGGER.info("Get artifact created %r", (etos.artifact_identity or str(etos.artifact_id)))

python/src/etos_api/routers/v1alpha/utilities.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
ARTIFACT_IDENTITY_QUERY,
2929
VERIFY_ARTIFACT_ID_EXISTS,
3030
)
31-
from etos_api.library.validator import SuiteValidator
31+
from etos_api.library.validator import SuiteValidator, ArtifactValidator
3232

3333
LOGGER = logging.getLogger(__name__)
3434

@@ -136,6 +136,26 @@ def convert_to_rfc1123(value: str) -> str:
136136
return result.lower()
137137

138138

139+
async def validate_artifact(artifact_identity: str = None, artifact_id=None) -> None:
140+
"""Validate the artifact identity and ID through the ArtifactValidator.
141+
142+
:param artifact_identity: The artifact identity to validate (should be PURL if provided).
143+
:param artifact_id: The artifact ID to validate (can be UUID object or string).
144+
"""
145+
span = trace.get_current_span()
146+
147+
try:
148+
# Convert artifact_id to string if it's not None
149+
artifact_id_str = str(artifact_id) if artifact_id is not None else None
150+
ArtifactValidator().validate_artifact_identity_or_id(artifact_identity, artifact_id_str)
151+
except ValueError as exception:
152+
msg = "Failed to validate artifact identifier"
153+
LOGGER.error(msg)
154+
LOGGER.error(exception)
155+
span.add_event(msg)
156+
raise HTTPException(status_code=400, detail=msg) from exception
157+
158+
139159
async def recipes_from_tests(tests: list[dict]) -> list[dict]:
140160
"""Load Eiffel TERCC recipes from test.
141161

python/tests/library/test_validator.py

Lines changed: 173 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@
2020

2121
import pytest
2222

23-
from etos_api.library.validator import SuiteValidator, ValidationError
23+
from etos_api.library.validator import (
24+
ArtifactValidator,
25+
SuiteValidator,
26+
ValidationError,
27+
)
2428

2529
logging.basicConfig(level=logging.DEBUG, stream=sys.stdout)
2630

@@ -356,3 +360,171 @@ async def test_validate_empty_constraints(self):
356360
self.logger.info("STEP: Validate a suite without the required key.")
357361
with pytest.raises(ValidationError):
358362
await validator.validate([base_suite])
363+
364+
365+
class TestArtifactValidator:
366+
"""Test the artifact validation functions."""
367+
368+
def setup_method(self):
369+
"""Set up test fixtures."""
370+
self.validator = ArtifactValidator()
371+
372+
def test_validate_purl_valid(self):
373+
"""Test that valid PURL strings are accepted."""
374+
valid_purls = [
375+
"pkg:npm/lodash@4.17.21",
376+
"pkg:pypi/requests@2.25.1",
377+
"pkg:maven/org.apache.commons/commons-lang3@3.12.0",
378+
"pkg:golang/github.com/gorilla/mux@v1.8.0",
379+
"pkg:docker/nginx@latest",
380+
"pkg:generic/openssl@1.1.1k",
381+
]
382+
383+
for purl in valid_purls:
384+
assert self.validator.validate_purl(purl) is True
385+
386+
def test_validate_purl_invalid(self):
387+
"""Test that invalid PURL strings are rejected."""
388+
invalid_purls = [
389+
"",
390+
None,
391+
"not-a-purl",
392+
"http://example.com",
393+
"pkg:", # Missing parts
394+
"pkg:npm/", # Incomplete
395+
"npm/lodash@4.17.21", # Missing pkg: prefix
396+
"PKG:npm/lodash@4.17.21", # Wrong case
397+
]
398+
399+
for purl in invalid_purls:
400+
assert self.validator.validate_purl(purl) is False
401+
402+
def test_validate_uuid_valid(self):
403+
"""Test that valid UUID strings are accepted."""
404+
valid_uuids = [
405+
"550e8400-e29b-41d4-a716-446655440000",
406+
"6ba7b810-9dad-11d1-80b4-00c04fd430c8",
407+
"6ba7b811-9dad-11d1-80b4-00c04fd430c8",
408+
"6ba7b812-9dad-11d1-80b4-00c04fd430c8",
409+
"6ba7b814-9dad-11d1-80b4-00c04fd430c8",
410+
"f47ac10b-58cc-4372-a567-0e02b2c3d479",
411+
]
412+
413+
for uuid_str in valid_uuids:
414+
assert self.validator.validate_uuid(uuid_str) is True
415+
416+
def test_validate_uuid_invalid(self):
417+
"""Test that invalid UUID strings are rejected."""
418+
invalid_uuids = [
419+
"",
420+
None,
421+
"not-a-uuid",
422+
"550e8400-e29b-41d4-a716", # Too short
423+
"550e8400-e29b-41d4-a716-446655440000-extra", # Too long
424+
"550e8400-e29b-41d4-a716-44665544000g", # Invalid character (g)
425+
]
426+
427+
for uuid_str in invalid_uuids:
428+
assert self.validator.validate_uuid(uuid_str) is False
429+
430+
def test_validate_artifact_identity_or_id_valid_purl(self):
431+
"""Test validation with valid PURL artifact_identity."""
432+
# Should not raise any exception
433+
self.validator.validate_artifact_identity_or_id(
434+
artifact_identity="pkg:npm/lodash@4.17.21", artifact_id=None
435+
)
436+
437+
def test_validate_artifact_identity_or_id_valid_uuid(self):
438+
"""Test validation with valid UUID artifact_id."""
439+
# Should not raise any exception
440+
self.validator.validate_artifact_identity_or_id(
441+
artifact_identity=None, artifact_id="550e8400-e29b-41d4-a716-446655440000"
442+
)
443+
444+
def test_validate_artifact_identity_or_id_invalid_purl(self):
445+
"""Test validation with invalid PURL artifact_identity raises ValueError."""
446+
with pytest.raises(ValueError) as exc_info:
447+
self.validator.validate_artifact_identity_or_id(
448+
artifact_identity="not-a-purl", artifact_id=None
449+
)
450+
assert "Invalid artifact_identity" in str(exc_info.value)
451+
assert "is not a valid PURL" in str(exc_info.value)
452+
453+
def test_validate_artifact_identity_or_id_invalid_uuid(self):
454+
"""Test validation with invalid UUID artifact_id raises ValueError."""
455+
with pytest.raises(ValueError) as exc_info:
456+
self.validator.validate_artifact_identity_or_id(
457+
artifact_identity=None, artifact_id="not-a-uuid"
458+
)
459+
assert "Invalid artifact_id" in str(exc_info.value)
460+
assert "is not a valid UUID" in str(exc_info.value)
461+
462+
def test_validate_artifact_identity_or_id_both_provided(self):
463+
"""Test validation with both valid values provided."""
464+
# Should not raise any exception when both are valid
465+
self.validator.validate_artifact_identity_or_id(
466+
artifact_identity="pkg:npm/lodash@4.17.21",
467+
artifact_id="550e8400-e29b-41d4-a716-446655440000",
468+
)
469+
470+
def test_validate_artifact_identity_or_id_neither_provided(self):
471+
"""Test validation with neither value provided."""
472+
# Should not raise any exception - no validation is performed when both are None
473+
self.validator.validate_artifact_identity_or_id(artifact_identity=None, artifact_id=None)
474+
475+
# Tests for ArtifactValidator class methods (should raise ValueError)
476+
def test_artifact_validator_validate_purl_valid(self):
477+
"""Test ArtifactValidator.validate_purl with valid PURL strings."""
478+
valid_purls = [
479+
"pkg:npm/lodash@4.17.21",
480+
"pkg:pypi/requests@2.25.1",
481+
"pkg:maven/org.apache.commons/commons-lang3@3.12.0",
482+
]
483+
484+
for purl in valid_purls:
485+
assert self.validator.validate_purl(purl) is True
486+
487+
def test_artifact_validator_validate_uuid_valid(self):
488+
"""Test ArtifactValidator.validate_uuid with valid UUID strings."""
489+
valid_uuids = [
490+
"550e8400-e29b-41d4-a716-446655440000",
491+
"6ba7b810-9dad-11d1-80b4-00c04fd430c8",
492+
]
493+
494+
for uuid_str in valid_uuids:
495+
assert self.validator.validate_uuid(uuid_str) is True
496+
497+
def test_artifact_validator_validate_artifact_identity_or_id_valid(self):
498+
"""Test ArtifactValidator.validate_artifact_identity_or_id with valid inputs."""
499+
# Should not raise any exception
500+
self.validator.validate_artifact_identity_or_id(
501+
artifact_identity="pkg:npm/lodash@4.17.21", artifact_id=None
502+
)
503+
504+
self.validator.validate_artifact_identity_or_id(
505+
artifact_identity=None, artifact_id="550e8400-e29b-41d4-a716-446655440000"
506+
)
507+
508+
def test_artifact_validator_validate_identity_or_id_invalid_purl(self):
509+
"""Test ArtifactValidator.validate_artifact_identity_or_id with invalid PURL.
510+
511+
Should raise ValueError.
512+
"""
513+
with pytest.raises(ValueError) as exc_info:
514+
self.validator.validate_artifact_identity_or_id(
515+
artifact_identity="not-a-purl", artifact_id=None
516+
)
517+
assert "Invalid artifact_identity" in str(exc_info.value)
518+
assert "is not a valid PURL" in str(exc_info.value)
519+
520+
def test_artifact_validator_validate_identity_or_id_invalid_uuid(self):
521+
"""Test ArtifactValidator.validate_artifact_identity_or_id with invalid UUID.
522+
523+
Should raise ValueError.
524+
"""
525+
with pytest.raises(ValueError) as exc_info:
526+
self.validator.validate_artifact_identity_or_id(
527+
artifact_identity=None, artifact_id="not-a-uuid"
528+
)
529+
assert "Invalid artifact_id" in str(exc_info.value)
530+
assert "is not a valid UUID" in str(exc_info.value)

0 commit comments

Comments
 (0)