diff --git a/metadata/OWNERS b/metadata/OWNERS new file mode 100644 index 0000000000..6ef81e8880 --- /dev/null +++ b/metadata/OWNERS @@ -0,0 +1,6 @@ +# Software Supply Chain Integrity/SBOM +aredulla@google.com +dlf@google.com +jsca@google.com +renewitt@google.com +sumakasa@google.com diff --git a/metadata/fields/known.py b/metadata/fields/known.py new file mode 100644 index 0000000000..825e96a1b9 --- /dev/null +++ b/metadata/fields/known.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +# Copyright 2023 The Chromium Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +import os +import sys +from typing import Union + +_THIS_DIR = os.path.abspath(os.path.dirname(__file__)) +# The repo's root directory. +_ROOT_DIR = os.path.abspath(os.path.join(_THIS_DIR, "..", "..")) + +# Add the repo's root directory for clearer imports. +sys.path.insert(0, _ROOT_DIR) + +import metadata.fields.types as field_types + +# Freeform text fields. +NAME = field_types.FreeformTextField("Name") +SHORT_NAME = field_types.FreeformTextField("Short Name") +REVISION = field_types.FreeformTextField("Revision") +DESCRIPTION = field_types.FreeformTextField("Description", one_liner=False) +LOCAL_MODIFICATIONS = field_types.FreeformTextField("Local Modifications", + one_liner=False) + +# Yes/no fields. +SECURITY_CRITICAL = field_types.YesNoField("Security Critical") +SHIPPED = field_types.YesNoField("Shipped") +LICENSE_ANDROID_COMPATIBLE = field_types.YesNoField( + "License Android Compatible") + +ALL_FIELDS = ( + NAME, + SHORT_NAME, + REVISION, + SECURITY_CRITICAL, + SHIPPED, + LICENSE_ANDROID_COMPATIBLE, + DESCRIPTION, + LOCAL_MODIFICATIONS, +) +ALL_FIELD_NAMES = {field.get_name() for field in ALL_FIELDS} +FIELD_MAPPING = {field.get_name().lower(): field for field in ALL_FIELDS} + + +def get_field(label: str) -> Union[field_types.MetadataField, None]: + return FIELD_MAPPING.get(label.lower()) diff --git a/metadata/fields/types.py b/metadata/fields/types.py new file mode 100644 index 0000000000..b9dd249101 --- /dev/null +++ b/metadata/fields/types.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +# Copyright 2023 The Chromium Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +import os +import re +import sys +from typing import Union + +_THIS_DIR = os.path.abspath(os.path.dirname(__file__)) +# The repo's root directory. +_ROOT_DIR = os.path.abspath(os.path.join(_THIS_DIR, "..", "..")) + +# Add the repo's root directory for clearer imports. +sys.path.insert(0, _ROOT_DIR) + +import metadata.fields.util as util +import metadata.validation_result as vr + +# Pattern used to check if the entire string is either "yes" or "no", +# case-insensitive. +_PATTERN_YES_OR_NO = re.compile(r"^(yes|no)$", re.IGNORECASE) + +# Pattern used to check if the string starts with "yes" or "no", +# case-insensitive. e.g. "No (test only)", "Yes?" +_PATTERN_STARTS_WITH_YES_OR_NO = re.compile(r"^(yes|no)", re.IGNORECASE) + + +class MetadataField: + """Base class for all metadata fields.""" + def __init__(self, name: str, one_liner: bool = True): + self._name = name + self._one_liner = one_liner + + def get_name(self): + return self._name + + def validate(self, value: str) -> Union[vr.ValidationResult, None]: + """Checks the given value is acceptable for the field. + + Raises: NotImplementedError if called. This method must be overridden with + the actual validation of the field. + """ + raise NotImplementedError(f"{self._name} field validation not defined.") + + +class FreeformTextField(MetadataField): + """Field where the value is freeform text.""" + def validate(self, value: str) -> Union[vr.ValidationResult, None]: + """Checks the given value has at least one non-whitespace character.""" + if util.is_empty(value): + return vr.ValidationError(f"{self._name} is empty.") + + return None + + +class YesNoField(MetadataField): + """Field where the value must be yes or no.""" + def __init__(self, name: str): + super().__init__(name=name, one_liner=True) + + def validate(self, value: str) -> Union[vr.ValidationResult, None]: + """Checks the given value is either yes or no.""" + if util.matches(_PATTERN_YES_OR_NO, value): + return None + + if util.matches(_PATTERN_STARTS_WITH_YES_OR_NO, value): + return vr.ValidationWarning( + f"{self._name} is '{value}' - should be only {util.YES} or {util.NO}." + ) + + return vr.ValidationError( + f"{self._name} is '{value}' - must be {util.YES} or {util.NO}.") diff --git a/metadata/fields/util.py b/metadata/fields/util.py new file mode 100644 index 0000000000..1a05661d98 --- /dev/null +++ b/metadata/fields/util.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +# Copyright 2023 The Chromium Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +import re +from typing import List + +# Preferred values for yes/no fields (i.e. all lowercase). +YES = "yes" +NO = "no" + +# Pattern used to check if the entire string is "unknown", case-insensitive. +_PATTERN_UNKNOWN = re.compile(r"^unknown$", re.IGNORECASE) + +# Pattern used to check if the entire string is functionally empty, i.e. +# empty string, or all characters are only whitespace. +_PATTERN_ONLY_WHITESPACE = re.compile(r"^\s*$") + + +def matches(pattern: re.Pattern, value: str) -> bool: + """Returns whether the value matches the pattern.""" + return pattern.match(value) is not None + + +def is_empty(value: str) -> bool: + """Returns whether the value is functionally empty.""" + return matches(_PATTERN_ONLY_WHITESPACE, value) + + +def is_unknown(value: str) -> bool: + """Returns whether the value is 'unknown' (case insensitive).""" + return matches(_PATTERN_UNKNOWN, value) + + +def quoted(values: List[str]) -> str: + """Returns a string of the given values, each being individually quoted.""" + return ", ".join([f"'{entry}'" for entry in values]) diff --git a/metadata/tests/fields_test.py b/metadata/tests/fields_test.py new file mode 100644 index 0000000000..d0736e01f1 --- /dev/null +++ b/metadata/tests/fields_test.py @@ -0,0 +1,70 @@ +#!/usr/bin/env vpython3 +# Copyright (c) 2023 The Chromium Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +import os +import sys +from typing import List +import unittest + +_THIS_DIR = os.path.abspath(os.path.dirname(__file__)) +# The repo's root directory. +_ROOT_DIR = os.path.abspath(os.path.join(_THIS_DIR, "..", "..")) + +# Add the repo's root directory for clearer imports. +sys.path.insert(0, _ROOT_DIR) + +import metadata.fields.types as field_types +import metadata.validation_result as vr + + +class FieldValidationTest(unittest.TestCase): + def _run_field_validation(self, + field: field_types.MetadataField, + valid_values: List[str], + error_values: List[str], + warning_values: List[str] = []): + """Helper to run a field's validation for different values.""" + for value in valid_values: + self.assertIsNone(field.validate(value)) + + for value in error_values: + self.assertIsInstance(field.validate(value), vr.ValidationError) + + for value in warning_values: + self.assertIsInstance(field.validate(value), vr.ValidationWarning) + + def test_freeform_text_validation(self): + # Check validation of a freeform text field that should be on one line. + self._run_field_validation( + field=field_types.FreeformTextField("Freeform single", one_liner=True), + valid_values=["Text on single line", "a", "1"], + error_values=["", "\n", " "], + ) + + # Check validation of a freeform text field that can span multiple lines. + self._run_field_validation( + field=field_types.FreeformTextField("Freeform multi", one_liner=False), + valid_values=[ + "This is text spanning multiple lines:\n" + " * with this point\n" + " * and this other point", + "Text on single line", + "a", + "1", + ], + error_values=["", "\n", " "], + ) + + def test_yes_no_field_validation(self): + self._run_field_validation( + field=field_types.YesNoField("Yes/No test"), + valid_values=["yes", "no", "No", "YES"], + error_values=["", "\n", "Probably yes"], + warning_values=["Yes?", "not"], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/metadata/validation_result.py b/metadata/validation_result.py new file mode 100644 index 0000000000..7d7566025a --- /dev/null +++ b/metadata/validation_result.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +# Copyright 2023 The Chromium Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +import textwrap +from typing import Dict, Union + + +class ValidationResult: + """Base class for validation issues.""" + def __init__(self, message: str, fatal: bool, **tags: Dict[str, str]): + self._fatal = fatal + self._message = message + self._tags = tags + + def __str__(self) -> str: + prefix = "ERROR" if self._fatal else "[non-fatal]" + return f"{prefix} - {self._message}" + + def __repr__(self) -> str: + return str(self) + + def is_fatal(self) -> bool: + return self._fatal + + def set_tag(self, tag: str, value: str) -> bool: + self._tags[tag] = value + + def get_tag(self, tag: str) -> Union[str, None]: + return self._tags.get(tag) + + def get_all_tags(self) -> Dict[str, str]: + return dict(self._tags) + + def get_message(self, width: int = 0) -> str: + if width > 0: + return textwrap.fill(text=self._message, width=width) + + return self._message + + +class ValidationError(ValidationResult): + """Fatal validation issue. Presubmit should fail.""" + def __init__(self, message: str, **tags: Dict[str, str]): + super().__init__(message=message, fatal=True, **tags) + + +class ValidationWarning(ValidationResult): + """Non-fatal validation issue. Presubmit should pass.""" + def __init__(self, message: str, **tags: Dict[str, str]): + super().__init__(message=message, fatal=False, **tags)