This is an automated email from the ASF dual-hosted git repository.

sbp pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-release.git


The following commit(s) were added to refs/heads/main by this push:
     new 6c2d26b  Add path checks
6c2d26b is described below

commit 6c2d26b8d19d1e03e21d06d0a198fb122915e38d
Author: Sean B. Palmer <[email protected]>
AuthorDate: Tue Apr 1 16:38:29 2025 +0100

    Add path checks
---
 atr/db/models.py                     |   2 +-
 atr/tasks/__init__.py                |  16 +---
 atr/tasks/checks/__init__.py         |  17 ++--
 atr/tasks/checks/paths.py            | 177 +++++++++++++++++++++++++++++++++++
 atr/tasks/rsync.py                   |  29 ++----
 atr/templates/draft-review-path.html |  75 ++++++++-------
 atr/templates/draft-review.html      |   4 +-
 7 files changed, 247 insertions(+), 73 deletions(-)

diff --git a/atr/db/models.py b/atr/db/models.py
index 2279502..386be9d 100644
--- a/atr/db/models.py
+++ b/atr/db/models.py
@@ -290,7 +290,7 @@ class TaskType(str, enum.Enum):
     HASHING_CHECK = "hashing_check"
     LICENSE_FILES = "license_files"
     LICENSE_HEADERS = "license_headers"
-    # PATHS_CHECK = "paths_check"
+    PATHS_CHECK = "paths_check"
     RAT_CHECK = "rat_check"
     RSYNC_ANALYSE = "rsync_analyse"
     SIGNATURE_CHECK = "signature_check"
diff --git a/atr/tasks/__init__.py b/atr/tasks/__init__.py
index e642cd7..2c200b6 100644
--- a/atr/tasks/__init__.py
+++ b/atr/tasks/__init__.py
@@ -23,6 +23,7 @@ import atr.db.models as models
 import atr.tasks.checks.archive as archive
 import atr.tasks.checks.hashing as hashing
 import atr.tasks.checks.license as license
+import atr.tasks.checks.paths as paths
 import atr.tasks.checks.rat as rat
 import atr.tasks.checks.signature as signature
 import atr.tasks.rsync as rsync
@@ -75,8 +76,8 @@ def resolve(task_type: models.TaskType) -> Callable[..., 
Awaitable[str | None]]:
             return license.files
         case models.TaskType.LICENSE_HEADERS:
             return license.headers
-        # case models.TaskType.PATHS_CHECK:
-        #     return paths.check
+        case models.TaskType.PATHS_CHECK:
+            return paths.check
         case models.TaskType.RAT_CHECK:
             return rat.check
         case models.TaskType.RSYNC_ANALYSE:
@@ -173,17 +174,6 @@ async def tar_gz_checks(release: models.Release, path: 
str) -> list[models.Task]
             path=path,
             modified=modified,
         ),
-        # models.Task(
-        #     status=models.TaskStatus.QUEUED,
-        #     task_type=models.TaskType.SBOM_GENERATE_CYCLONEDX,
-        #     task_args=tasks.SbomGenerateCyclonedx(
-        #         artifact_path=str(full_path),
-        #         output_path=str(full_sbom_path),
-        #     ).model_dump(),
-        #     release_name=release.name,
-        #     path=path,
-        #     modified=modified,
-        # ),
     ]
 
     return tasks
diff --git a/atr/tasks/checks/__init__.py b/atr/tasks/checks/__init__.py
index dcc34b5..921d1c4 100644
--- a/atr/tasks/checks/__init__.py
+++ b/atr/tasks/checks/__init__.py
@@ -20,6 +20,7 @@ from __future__ import annotations
 import datetime
 import pathlib
 from functools import wraps
+from types import FunctionType
 from typing import TYPE_CHECKING, Any, TypeVar
 
 import pydantic
@@ -35,9 +36,13 @@ import atr.db.models as models
 
 class Check:
     def __init__(
-        self, checker: Callable[..., Any], release_name: str, path: str | None 
= None, afresh: bool = True
+        self, checker: str | Callable[..., Any], release_name: str, path: str 
| None = None, afresh: bool = True
     ) -> None:
-        self.checker = function_key(checker)
+        if isinstance(checker, FunctionType):
+            checker = function_key(checker)
+        if not isinstance(checker, str):
+            raise ValueError("Checker must be a string or a callable")
+        self.checker = checker
         self.release_name = release_name
         self.path = path
         self.afresh = afresh
@@ -45,12 +50,12 @@ class Check:
 
     @classmethod
     async def create(
-        cls, checker: Callable[..., Any], release_name: str, path: str | None 
= None, afresh: bool = True
+        cls, checker: str | Callable[..., Any], release_name: str, path: str | 
None = None, afresh: bool = True
     ) -> Check:
         check = cls(checker, release_name, path, afresh)
         if afresh is True:
             # Clear outer path whether it's specified or not
-            await check._clear(path)
+            await check.clear(path)
         check._constructed = True
         return check
 
@@ -64,7 +69,7 @@ class Check:
                 raise ValueError("Cannot specify path twice")
             if self.afresh is True:
                 # Clear inner path only if it's specified
-                await self._clear(path)
+                await self.clear(path)
 
         result = models.CheckResult(
             release_name=self.release_name,
@@ -84,7 +89,7 @@ class Check:
             await session.commit()
         return result
 
-    async def _clear(self, path: str | None = None) -> None:
+    async def clear(self, path: str | None = None) -> None:
         async with db.session() as data:
             stmt = sqlmodel.delete(models.CheckResult).where(
                 
db.validate_instrumented_attribute(models.CheckResult.release_name) == 
self.release_name,
diff --git a/atr/tasks/checks/paths.py b/atr/tasks/checks/paths.py
new file mode 100644
index 0000000..64451f5
--- /dev/null
+++ b/atr/tasks/checks/paths.py
@@ -0,0 +1,177 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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.
+
+import logging
+import pathlib
+import re
+from typing import Final
+
+import aiofiles.os
+import pydantic
+
+import atr.analysis as analysis
+import atr.tasks.checks as checks
+import atr.util as util
+
+_LOGGER: Final = logging.getLogger(__name__)
+
+
+class Check(pydantic.BaseModel):
+    """Arguments for the path structure and naming convention check."""
+
+    release_name: str = pydantic.Field(..., description="Name of the release 
being checked")
+    base_release_dir: str = pydantic.Field(..., description="Absolute path to 
the base directory of the release draft")
+
+
+async def _check_artifact_rules(base_path: pathlib.Path, relative_path: 
pathlib.Path, errors: list[str]) -> None:
+    """Check rules specific to artifact files."""
+    full_path = base_path / relative_path
+
+    # RDP says that .asc is required
+    asc_path = full_path.with_suffix(full_path.suffix + ".asc")
+    if not await aiofiles.os.path.exists(asc_path):
+        errors.append(f"Missing corresponding signature file 
({relative_path}.asc)")
+
+    # RDP requires one of .sha256 or .sha512
+    sha256_path = full_path.with_suffix(full_path.suffix + ".sha256")
+    sha512_path = full_path.with_suffix(full_path.suffix + ".sha512")
+    has_sha256 = await aiofiles.os.path.exists(sha256_path)
+    has_sha512 = await aiofiles.os.path.exists(sha512_path)
+    if not (has_sha256 or has_sha512):
+        errors.append(f"Missing corresponding checksum file 
({relative_path}.sha256 or .sha512)")
+
+
+async def _check_metadata_rules(
+    base_path: pathlib.Path, relative_path: pathlib.Path, ext_metadata: str, 
errors: list[str], warnings: list[str]
+) -> None:
+    """Check rules specific to metadata files (.asc, .sha*, etc.)."""
+    suffixes = set(relative_path.suffixes)
+
+    if ".md5" in suffixes:
+        # Forbidden by RCP, deprecated by RDP
+        errors.append("The use of .md5 is forbidden, please use .sha512")
+    if ".sha1" in suffixes:
+        # Deprecated by RDP
+        warnings.append("The use of .sha1 is deprecated, please use .sha512")
+    if ".sha" in suffixes:
+        # Discouraged by RDP
+        warnings.append("The use of .sha is discouraged, please use .sha512")
+    if ".sig" in suffixes:
+        # Forbidden by RCP, forbidden by RDP
+        errors.append("Binary signature files (.sig) are forbidden, please use 
.asc")
+
+    # "Signature and checksum files for verifying distributed artifacts should
+    # not be provided, unless named as indicated above." (RDP)
+    # Also .mds is allowed, but we'll ignore that for now
+    # TODO: Is .mds supported in analysis.METADATA_SUFFIXES?
+    if ext_metadata not in {".asc", ".sha256", ".sha512", ".md5", ".sha", 
".sha1"}:
+        warnings.append("The use of this metadata file is discouraged")
+
+    # Check whether the corresponding artifact exists
+    artifact_path_base = str(relative_path).removesuffix(ext_metadata)
+    full_artifact_path = base_path / artifact_path_base
+    if not await aiofiles.os.path.exists(full_artifact_path):
+        errors.append(f"Metadata file exists but corresponding artifact 
'{artifact_path_base}' is missing")
+
+
+async def _check_path_process_single(
+    base_path: pathlib.Path,
+    relative_path: pathlib.Path,
+    check_errors: checks.Check,
+    check_warnings: checks.Check,
+    check_success: checks.Check,
+) -> None:
+    """Process and check a single path within the release directory."""
+    full_path = base_path / relative_path
+    relative_path_str = str(relative_path)
+
+    errors: list[str] = []
+    warnings: list[str] = []
+
+    # The Release Distribution Policy specifically allows README and CHANGES, 
etc.
+    # We assume that LICENSE and NOTICE are permitted also
+    if relative_path.name == "KEYS":
+        errors.append("The KEYS file should be uploaded via the 'Keys' 
section, not included in the artifact bundle")
+    if any(part.startswith(".") for part in relative_path.parts):
+        # TODO: There is not a a policy for this
+        # We should enquire as to whether such a policy should be instituted
+        # We're forbidding dotfiles to catch accidental uploads of e.g. .git 
or .htaccess
+        # Such cases are likely to be in error, and could carry security risks
+        errors.append("Dotfiles are forbidden")
+
+    search = re.search(analysis.extension_pattern(), relative_path_str)
+    ext_artifact = search.group("artifact") if search else None
+    ext_metadata = search.group("metadata") if search else None
+
+    if ext_artifact:
+        _LOGGER.info("Checking artifact rules for %s", full_path)
+        await _check_artifact_rules(base_path, relative_path, errors)
+    elif ext_metadata:
+        _LOGGER.info("Checking metadata rules for %s", full_path)
+        await _check_metadata_rules(base_path, relative_path, ext_metadata, 
errors, warnings)
+    else:
+        _LOGGER.info("Checking general rules for %s", full_path)
+        allowed_top_level = {"LICENSE", "NOTICE", "README", "CHANGES"}
+        if (relative_path.parent == pathlib.Path(".")) and (relative_path.name 
not in allowed_top_level):
+            warnings.append(f"Unknown top level file: {relative_path.name}")
+
+    # Must aggregate errors and aggregate warnings otherwise they will be 
removed by afresh=True
+    # Alternatively we could call Check.clear() manually
+    if errors:
+        await check_errors.failure("; ".join(errors), {"errors": errors}, 
path=relative_path_str)
+    if warnings:
+        await check_warnings.warning("; ".join(warnings), {"warnings": 
warnings}, path=relative_path_str)
+    if not (errors or warnings):
+        await check_success.success(
+            "Path structure and naming conventions conform to policy", {}, 
path=relative_path_str
+        )
+
+
[email protected]_model(Check)
+async def check(args: Check) -> None:
+    """Check file path structure and naming conventions against ASF release 
policy for all files in a release."""
+    # We refer to the following authoritative policies:
+    # - Release Creation Process (RCP)
+    # - Release Distribution Policy (RDP)
+    base_path = pathlib.Path(args.base_release_dir)
+
+    if not await aiofiles.os.path.isdir(base_path):
+        _LOGGER.error("Base release directory does not exist or is not a 
directory: %s", base_path)
+        return
+
+    check_errors = await checks.Check.create(
+        checker=checks.function_key(check) + "_errors", 
release_name=args.release_name, path=None, afresh=True
+    )
+    check_warnings = await checks.Check.create(
+        checker=checks.function_key(check) + "_warnings", 
release_name=args.release_name, path=None, afresh=True
+    )
+    check_success = await checks.Check.create(
+        checker=checks.function_key(check) + "_success", 
release_name=args.release_name, path=None, afresh=True
+    )
+    relative_paths = await util.paths_recursive(base_path)
+
+    for relative_path in relative_paths:
+        # Delegate processing of each path to the helper function
+        await _check_path_process_single(
+            base_path,
+            relative_path,
+            check_errors,
+            check_warnings,
+            check_success,
+        )
+
+    return None
diff --git a/atr/tasks/rsync.py b/atr/tasks/rsync.py
index 8aa9cca..8a66aed 100644
--- a/atr/tasks/rsync.py
+++ b/atr/tasks/rsync.py
@@ -25,8 +25,7 @@ import atr.db as db
 import atr.db.models as models
 import atr.tasks as tasks
 import atr.tasks.checks as checks
-
-# import atr.tasks.checks.paths as paths
+import atr.tasks.checks.paths as paths
 import atr.util as util
 
 if TYPE_CHECKING:
@@ -93,23 +92,15 @@ async def _analyse_core(project_name: str, release_version: 
str) -> dict[str, An
                         if task.task_type not in cached_tasks:
                             data.add(task)
 
-            # # Add the generic path check task for every file
-            # if path_check_task_key not in cached_tasks:
-            #     path_check_task_args = paths.Check(
-            #         release_name=release_name,
-            #         base_release_dir=str(base_path),
-            #         path=str(path),
-            #     ).model_dump()
-
-        #     path_check_task = models.Task(
-        #         status=models.TaskStatus.QUEUED,
-        #         task_type=tasks.Type.PATHS_CHECK,
-        #         task_args=paths.Check(
-        #             release_name=release_name,
-        #             base_release_dir=str(base_path),
-        #             path=str(path),
-        #         ).model_dump(),
-        #     )
+        path_check_task = models.Task(
+            status=models.TaskStatus.QUEUED,
+            task_type=models.TaskType.PATHS_CHECK,
+            task_args=paths.Check(
+                release_name=release_name,
+                base_release_dir=str(base_path),
+            ).model_dump(),
+        )
+        data.add(path_check_task)
 
         await data.commit()
     return {"paths": [str(path) for path in paths_recursive]}
diff --git a/atr/templates/draft-review-path.html 
b/atr/templates/draft-review-path.html
index 1b05b55..87cc96b 100644
--- a/atr/templates/draft-review-path.html
+++ b/atr/templates/draft-review-path.html
@@ -32,38 +32,37 @@
   <h2>Verification tasks</h2>
 
   {% if check_results %}
+    {# Define status_counts here so it's available globally in this block #}
+    {% set status_counts = {
+          "success": check_results|selectattr("status.value", "equalto", 
"success")|list|length,
+          "failure": check_results|selectattr("status.value", "equalto", 
"failure")|list|length,
+          "warning": check_results|selectattr("status.value", "equalto", 
"warning")|list|length,
+          "exception": check_results|selectattr("status.value", "equalto", 
"exception")|list|length
+        } %}
+
     <div class="d-flex align-items-center p-3 mb-3 bg-light border rounded">
       <span class="fw-bold me-3">Status summary:</span>
       <div class="d-flex flex-wrap gap-3">
-        {% with %}
-          {% set status_counts = {
-                      "success": check_results|selectattr("status.value", 
"equalto", "success")|list|length,
-                      "failure": check_results|selectattr("status.value", 
"equalto", "failure")|list|length,
-                      "warning": check_results|selectattr("status.value", 
"equalto", "warning")|list|length,
-                      "exception": check_results|selectattr("status.value", 
"equalto", "exception")|list|length
-                    } %}
-
-          {% for status, count in status_counts.items() %}
-            {% if count > 0 %}
-              <div class="d-flex align-items-center gap-2 px-3 py-2 rounded 
fw-medium {% if status == "success" %}bg-success-subtle border 
border-success-subtle {% elif status == "failure" %}bg-danger-subtle border 
border-danger-subtle {% elif status == "warning" %}bg-warning-subtle border 
border-warning-subtle {% elif status == "exception" %}bg-danger-subtle border 
border-danger-subtle {% endif %}">
-                <span class="fs-5">{{ count }}</span>
-                <span>
-                  {%- if status == "success" -%}
-                    Passed
-                  {%- elif status == "failure" -%}
-                    Issues
-                  {%- elif status == "warning" -%}
-                    Warning
-                  {%- elif status == "exception" -%}
-                    Error
-                  {%- else -%}
-                    {{ status|title }}
-                  {%- endif -%}
-                </span>
-              </div>
-            {% endif %}
-          {% endfor %}
-        {% endwith %}
+        {% for status, count in status_counts.items() %}
+          {% if count > 0 %}
+            <div class="d-flex align-items-center gap-2 px-3 py-2 rounded 
fw-medium {% if status == "success" %}bg-success-subtle border 
border-success-subtle {% elif status == "failure" %}bg-danger-subtle border 
border-danger-subtle {% elif status == "warning" %}bg-warning-subtle border 
border-warning-subtle {% elif status == "exception" %}bg-danger-subtle border 
border-danger-subtle {% endif %}">
+              <span class="fs-5">{{ count }}</span>
+              <span>
+                {%- if status == "success" -%}
+                  Passed
+                {%- elif status == "failure" -%}
+                  Issues
+                {%- elif status == "warning" -%}
+                  Warning
+                {%- elif status == "exception" -%}
+                  Error
+                {%- else -%}
+                  {{ status|title }}
+                {%- endif -%}
+              </span>
+            </div>
+          {% endif %}
+        {% endfor %}
       </div>
     </div>
   {% endif %}
@@ -83,11 +82,23 @@
               {%- if check_result.status.value == "success" -%}
                 Passed
               {%- elif check_result.status.value == "failure" -%}
-                Issue
+                {% if status_counts['failure'] == 1 %}
+                  Issue
+                {% else %}
+                  Issues
+                {% endif %}
               {%- elif check_result.status.value == "warning" -%}
-                Warning
+                {% if status_counts['warning'] == 1 %}
+                  Warning
+                {% else %}
+                  Warnings
+                {% endif %}
               {%- elif check_result.status.value == "exception" -%}
-                Error
+                {% if status_counts['exception'] == 1 %}
+                  Exception
+                {% else %}
+                  Exceptions
+                {% endif %}
               {%- else -%}
                 {{ check_result.status.value|title }}
               {%- endif -%}
diff --git a/atr/templates/draft-review.html b/atr/templates/draft-review.html
index 23f7bee..3c83bb0 100644
--- a/atr/templates/draft-review.html
+++ b/atr/templates/draft-review.html
@@ -83,7 +83,7 @@
                           <span class="badge bg-success">{{ 
successes[path]|length }} Passed</span>
                         {% endif %}
                         {% if warnings[path]|length > 0 %}
-                          <span class="badge bg-warning">{{ 
warnings[path]|length }} {{ "Issue" if warnings[path]|length == 1 else "Issues" 
}}</span>
+                          <span class="badge bg-warning">{{ 
warnings[path]|length }} {{ "Warning" if warnings[path]|length == 1 else 
"Warnings" }}</span>
                         {% endif %}
                         {% if errors[path]|length > 0 %}
                           <span class="badge bg-danger">{{ errors[path]|length 
}} {{ "Issue" if errors[path]|length == 1 else "Issues" }}</span>
@@ -98,7 +98,7 @@
                        class="btn btn-sm btn-outline-primary fs-6 small 
ms-2">Tools</a>
                     <a href="{{ as_url(routes.draft.review_path, 
project_name=project_name, version_name=version_name, file_path=path) }}"
                        class="btn btn-sm btn-outline-primary fs-6 small 
ms-2">Review file</a>
-                    <button class="btn btn-sm btn-outline-danger"
+                    <button class="btn btn-sm btn-outline-danger ms-2"
                             data-bs-toggle="modal"
                             data-bs-target="#delete-{{ path|slugify }}">Delete 
file</button>
                   </td>


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to