You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by ar...@apache.org on 2026/02/25 11:58:21 UTC

(tooling-trusted-releases) branch taint_tracking_types updated (5b438db7 -> ecbcda71)

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

arm pushed a change to branch taint_tracking_types
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git


 discard 5b438db7 First cut of taint tracking types for project and version
     new ecbcda71 First cut of taint tracking types for project and version

This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version.  This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:

 * -- * -- B -- O -- O -- O   (5b438db7)
            \
             N -- N -- N   refs/heads/taint_tracking_types (ecbcda71)

You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.

Any revisions marked "omit" are not gone; other references still
refer to them.  Any revisions marked "discard" are gone forever.

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 atr/taint.py     | 4 ----
 atr/validated.py | 5 +----
 2 files changed, 1 insertion(+), 8 deletions(-)


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org


(tooling-trusted-releases) 01/01: First cut of taint tracking types for project and version

Posted by ar...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

arm pushed a commit to branch taint_tracking_types
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git

commit ecbcda71dc091028f92ca6168e5bdc8537fc20b7
Author: Alastair McFarlane <ar...@apache.org>
AuthorDate: Wed Feb 25 11:56:34 2026 +0000

    First cut of taint tracking types for project and version
---
 atr/blueprints/get.py   | 129 +++++++++++++++++++++++++++++++++++++++++++-----
 atr/blueprints/post.py  | 129 +++++++++++++++++++++++++++++++++++++++++++-----
 atr/cache.py            |  61 +++++++++++++++++++++++
 atr/get/announce.py     |  11 ++++-
 atr/get/checks.py       |   6 ++-
 atr/get/compose.py      |  11 ++++-
 atr/get/distribution.py |  10 ++--
 atr/get/download.py     |   4 +-
 atr/get/draft.py        |   2 +-
 atr/get/file.py         |   4 +-
 atr/get/finish.py       |   2 +-
 atr/get/ignores.py      |   2 +-
 atr/get/manual.py       |   4 +-
 atr/get/release.py      |   2 +-
 atr/get/report.py       |   2 +-
 atr/get/result.py       |   2 +-
 atr/get/revisions.py    |   2 +-
 atr/get/sbom.py         |   2 +-
 atr/get/test.py         |   2 +-
 atr/get/upload.py       |   2 +-
 atr/get/voting.py       |   2 +-
 atr/server.py           |   4 ++
 atr/taint.py            |  29 +++++++++++
 atr/validated.py        |  22 +++++++++
 atr/web.py              |  29 +++++++++++
 25 files changed, 421 insertions(+), 54 deletions(-)

diff --git a/atr/blueprints/get.py b/atr/blueprints/get.py
index f715defd..c1b1511b 100644
--- a/atr/blueprints/get.py
+++ b/atr/blueprints/get.py
@@ -25,8 +25,13 @@ import asfquart.base as base
 import asfquart.session
 import quart
 
+import atr.cache as cache
+import atr.db as db
 import atr.ldap as ldap
 import atr.log as log
+import atr.models.sql as sql
+import atr.taint as taint
+import atr.validated as validated
 import atr.web as web
 
 _BLUEPRINT_NAME = "get_blueprint"
@@ -34,17 +39,50 @@ _BLUEPRINT = quart.Blueprint(_BLUEPRINT_NAME, __name__)
 _routes: list[str] = []
 
 
+async def _authenticate() -> web.Committer:
+    web_session = await asfquart.session.read()
+    if web_session is None:
+        raise base.ASFQuartException("Not authenticated", errorcode=401)
+    if (web_session.uid is None) or (not await ldap.is_active(web_session.uid)):
+        asfquart.session.clear()
+        raise base.ASFQuartException("Account is disabled", errorcode=401)
+    return web.Committer(web_session)
+
+
+def _register(func: Callable[..., Any]) -> None:
+    module_name = func.__module__.split(".")[-1]
+    _routes.append(f"get.{module_name}.{func.__name__}")
+
+
+async def _validate_project(raw: str) -> validated.ProjectName:
+    if cache.project_version_has_project(raw):
+        return validated.ProjectName(raw)
+    async with db.session() as data:
+        project = await data.project(name=raw, status=sql.ProjectStatus.ACTIVE, _committee=False).get()
+    if project is None:
+        raise base.ASFQuartException(f"Project {raw!r} not found", errorcode=404)
+    return validated.ProjectName(project.name)
+
+
+async def _validate_version(project_name: str, raw: str) -> validated.VersionName:
+    if cache.project_version_has_version(project_name, raw):
+        return validated.VersionName(raw)
+    async with db.session() as data:
+        release = await data.release(
+            project_name=project_name,
+            version=raw,
+            _project=False,
+            _committee=False,
+        ).get()
+    if release is None:
+        raise base.ASFQuartException(f"Version {raw!r} not found for project {project_name!r}", errorcode=404)
+    return validated.VersionName(release.version)
+
+
 def committer(path: str) -> Callable[[web.CommitterRouteFunction[Any]], web.RouteFunction[Any]]:
     def decorator(func: web.CommitterRouteFunction[Any]) -> web.RouteFunction[Any]:
         async def wrapper(*args: Any, **kwargs: Any) -> Any:
-            web_session = await asfquart.session.read()
-            if web_session is None:
-                raise base.ASFQuartException("Not authenticated", errorcode=401)
-            if (web_session.uid is None) or (not await ldap.is_active(web_session.uid)):
-                asfquart.session.clear()
-                raise base.ASFQuartException("Account is disabled", errorcode=401)
-
-            enhanced_session = web.Committer(web_session)
+            enhanced_session = await _authenticate()
             start_time_ns = time.perf_counter_ns()
             response = await func(enhanced_session, *args, **kwargs)
             end_time_ns = time.perf_counter_ns()
@@ -65,9 +103,76 @@ def committer(path: str) -> Callable[[web.CommitterRouteFunction[Any]], web.Rout
 
         decorated = auth.require(auth.Requirements.committer)(wrapper)
         _BLUEPRINT.add_url_rule(path, endpoint=endpoint, view_func=decorated, methods=["GET"])
+        _register(func)
+
+        return decorated
+
+    return decorator
+
+
+def committer_project(path: str) -> Callable[[web.CommitterProjectHandler[Any]], web.RouteFunction[Any]]:
+    def decorator(func: web.CommitterProjectHandler[Any]) -> web.RouteFunction[Any]:
+        async def wrapper(*_args: Any, **kwargs: Any) -> Any:
+            enhanced_session = await _authenticate()
+            project_name = await _validate_project(kwargs.pop("project_name"))
+            unsafe_kwargs: dict[str, taint.UnsafeStr] = {k: taint.UnsafeStr(v) for k, v in kwargs.items()}
 
-        module_name = func.__module__.split(".")[-1]
-        _routes.append(f"get.{module_name}.{func.__name__}")
+            start_time_ns = time.perf_counter_ns()
+            response = await func(enhanced_session, project_name=project_name, **unsafe_kwargs)
+            end_time_ns = time.perf_counter_ns()
+            total_ns = end_time_ns - start_time_ns
+            total_ms = total_ns // 1_000_000
+
+            log.performance(
+                f"GET {path} {func.__name__} = 0 0 {total_ms}",
+            )
+
+            return response
+
+        endpoint = func.__module__.replace(".", "_") + "_" + func.__name__
+        wrapper.__name__ = func.__name__
+        wrapper.__doc__ = func.__doc__
+        wrapper.__annotations__["endpoint"] = _BLUEPRINT_NAME + "." + endpoint
+
+        decorated = auth.require(auth.Requirements.committer)(wrapper)
+        _BLUEPRINT.add_url_rule(path, endpoint=endpoint, view_func=decorated, methods=["GET"])
+        _register(func)
+
+        return decorated
+
+    return decorator
+
+
+def committer_project_version(path: str) -> Callable[[web.CommitterProjectVersionHandler[Any]], web.RouteFunction[Any]]:
+    def decorator(func: web.CommitterProjectVersionHandler[Any]) -> web.RouteFunction[Any]:
+        async def wrapper(*_args: Any, **kwargs: Any) -> Any:
+            enhanced_session = await _authenticate()
+            project_name = await _validate_project(kwargs.pop("project_name"))
+            version_name = await _validate_version(project_name, kwargs.pop("version_name"))
+            unsafe_kwargs: dict[str, taint.UnsafeStr] = {k: taint.UnsafeStr(v) for k, v in kwargs.items()}
+
+            start_time_ns = time.perf_counter_ns()
+            response = await func(
+                enhanced_session, project_name=project_name, version_name=version_name, **unsafe_kwargs
+            )
+            end_time_ns = time.perf_counter_ns()
+            total_ns = end_time_ns - start_time_ns
+            total_ms = total_ns // 1_000_000
+
+            log.performance(
+                f"GET {path} {func.__name__} = 0 0 {total_ms}",
+            )
+
+            return response
+
+        endpoint = func.__module__.replace(".", "_") + "_" + func.__name__
+        wrapper.__name__ = func.__name__
+        wrapper.__doc__ = func.__doc__
+        wrapper.__annotations__["endpoint"] = _BLUEPRINT_NAME + "." + endpoint
+
+        decorated = auth.require(auth.Requirements.committer)(wrapper)
+        _BLUEPRINT.add_url_rule(path, endpoint=endpoint, view_func=decorated, methods=["GET"])
+        _register(func)
 
         return decorated
 
@@ -87,9 +192,7 @@ def public(path: str) -> Callable[[Callable[..., Awaitable[Any]]], web.RouteFunc
         wrapper.__annotations__["endpoint"] = _BLUEPRINT_NAME + "." + endpoint
 
         _BLUEPRINT.add_url_rule(path, endpoint=endpoint, view_func=wrapper, methods=["GET"])
-
-        module_name = func.__module__.split(".")[-1]
-        _routes.append(f"get.{module_name}.{func.__name__}")
+        _register(func)
 
         return wrapper
 
diff --git a/atr/blueprints/post.py b/atr/blueprints/post.py
index 8c9133a0..052136a9 100644
--- a/atr/blueprints/post.py
+++ b/atr/blueprints/post.py
@@ -27,9 +27,14 @@ import asfquart.session
 import pydantic
 import quart
 
+import atr.cache as cache
+import atr.db as db
 import atr.form
 import atr.ldap as ldap
 import atr.log as log
+import atr.models.sql as sql
+import atr.taint as taint
+import atr.validated as validated
 import atr.web as web
 
 _BLUEPRINT_NAME = "post_blueprint"
@@ -37,17 +42,50 @@ _BLUEPRINT = quart.Blueprint(_BLUEPRINT_NAME, __name__)
 _routes: list[str] = []
 
 
+async def _authenticate() -> web.Committer:
+    web_session = await asfquart.session.read()
+    if web_session is None:
+        raise base.ASFQuartException("Not authenticated", errorcode=401)
+    if (web_session.uid is None) or (not await ldap.is_active(web_session.uid)):
+        asfquart.session.clear()
+        raise base.ASFQuartException("Account is disabled", errorcode=401)
+    return web.Committer(web_session)
+
+
+def _register(func: Callable[..., Any]) -> None:
+    module_name = func.__module__.split(".")[-1]
+    _routes.append(f"post.{module_name}.{func.__name__}")
+
+
+async def _validate_project(raw: str) -> validated.ProjectName:
+    if cache.project_version_has_project(raw):
+        return validated.ProjectName(raw)
+    async with db.session() as data:
+        project = await data.project(name=raw, status=sql.ProjectStatus.ACTIVE, _committee=False).get()
+    if project is None:
+        raise base.ASFQuartException(f"Project {raw!r} not found", errorcode=404)
+    return validated.ProjectName(project.name)
+
+
+async def _validate_version(project_name: str, raw: str) -> validated.VersionName:
+    if cache.project_version_has_version(project_name, raw):
+        return validated.VersionName(raw)
+    async with db.session() as data:
+        release = await data.release(
+            project_name=project_name,
+            version=raw,
+            _project=False,
+            _committee=False,
+        ).get()
+    if release is None:
+        raise base.ASFQuartException(f"Version {raw!r} not found for project {project_name!r}", errorcode=404)
+    return validated.VersionName(release.version)
+
+
 def committer(path: str) -> Callable[[web.CommitterRouteFunction[Any]], web.RouteFunction[Any]]:
     def decorator(func: web.CommitterRouteFunction[Any]) -> web.RouteFunction[Any]:
         async def wrapper(*args: Any, **kwargs: Any) -> Any:
-            web_session = await asfquart.session.read()
-            if web_session is None:
-                raise base.ASFQuartException("Not authenticated", errorcode=401)
-            if (web_session.uid is None) or (not await ldap.is_active(web_session.uid)):
-                asfquart.session.clear()
-                raise base.ASFQuartException("Account is disabled", errorcode=401)
-
-            enhanced_session = web.Committer(web_session)
+            enhanced_session = await _authenticate()
             start_time_ns = time.perf_counter_ns()
             response = await func(enhanced_session, *args, **kwargs)
             end_time_ns = time.perf_counter_ns()
@@ -68,9 +106,76 @@ def committer(path: str) -> Callable[[web.CommitterRouteFunction[Any]], web.Rout
 
         decorated = auth.require(auth.Requirements.committer)(wrapper)
         _BLUEPRINT.add_url_rule(path, endpoint=endpoint, view_func=decorated, methods=["POST"])
+        _register(func)
+
+        return decorated
+
+    return decorator
+
+
+def committer_project(path: str) -> Callable[[web.CommitterProjectHandler[Any]], web.RouteFunction[Any]]:
+    def decorator(func: web.CommitterProjectHandler[Any]) -> web.RouteFunction[Any]:
+        async def wrapper(*_args: Any, **kwargs: Any) -> Any:
+            enhanced_session = await _authenticate()
+            project_name = await _validate_project(kwargs.pop("project_name"))
+            unsafe_kwargs: dict[str, taint.UnsafeStr] = {k: taint.UnsafeStr(v) for k, v in kwargs.items()}
 
-        module_name = func.__module__.split(".")[-1]
-        _routes.append(f"post.{module_name}.{func.__name__}")
+            start_time_ns = time.perf_counter_ns()
+            response = await func(enhanced_session, project_name=project_name, **unsafe_kwargs)
+            end_time_ns = time.perf_counter_ns()
+            total_ns = end_time_ns - start_time_ns
+            total_ms = total_ns // 1_000_000
+
+            log.performance(
+                f"POST {path} {func.__name__} = 0 0 {total_ms}",
+            )
+
+            return response
+
+        endpoint = func.__module__.replace(".", "_") + "_" + func.__name__
+        wrapper.__name__ = func.__name__
+        wrapper.__doc__ = func.__doc__
+        wrapper.__annotations__["endpoint"] = _BLUEPRINT_NAME + "." + endpoint
+
+        decorated = auth.require(auth.Requirements.committer)(wrapper)
+        _BLUEPRINT.add_url_rule(path, endpoint=endpoint, view_func=decorated, methods=["POST"])
+        _register(func)
+
+        return decorated
+
+    return decorator
+
+
+def committer_project_version(path: str) -> Callable[[web.CommitterProjectVersionHandler[Any]], web.RouteFunction[Any]]:
+    def decorator(func: web.CommitterProjectVersionHandler[Any]) -> web.RouteFunction[Any]:
+        async def wrapper(*_args: Any, **kwargs: Any) -> Any:
+            enhanced_session = await _authenticate()
+            project_name = await _validate_project(kwargs.pop("project_name"))
+            version_name = await _validate_version(project_name, kwargs.pop("version_name"))
+            unsafe_kwargs: dict[str, taint.UnsafeStr] = {k: taint.UnsafeStr(v) for k, v in kwargs.items()}
+
+            start_time_ns = time.perf_counter_ns()
+            response = await func(
+                enhanced_session, project_name=project_name, version_name=version_name, **unsafe_kwargs
+            )
+            end_time_ns = time.perf_counter_ns()
+            total_ns = end_time_ns - start_time_ns
+            total_ms = total_ns // 1_000_000
+
+            log.performance(
+                f"POST {path} {func.__name__} = 0 0 {total_ms}",
+            )
+
+            return response
+
+        endpoint = func.__module__.replace(".", "_") + "_" + func.__name__
+        wrapper.__name__ = func.__name__
+        wrapper.__doc__ = func.__doc__
+        wrapper.__annotations__["endpoint"] = _BLUEPRINT_NAME + "." + endpoint
+
+        decorated = auth.require(auth.Requirements.committer)(wrapper)
+        _BLUEPRINT.add_url_rule(path, endpoint=endpoint, view_func=decorated, methods=["POST"])
+        _register(func)
 
         return decorated
 
@@ -187,9 +292,7 @@ def public(path: str) -> Callable[[Callable[..., Awaitable[Any]]], web.RouteFunc
         wrapper.__name__ = func.__name__
 
         _BLUEPRINT.add_url_rule(path, endpoint=endpoint, view_func=wrapper, methods=["POST"])
-
-        module_name = func.__module__.split(".")[-1]
-        _routes.append(f"post.{module_name}.{func.__name__}")
+        _register(func)
 
         return wrapper
 
diff --git a/atr/cache.py b/atr/cache.py
index f652a02d..b6be4a85 100644
--- a/atr/cache.py
+++ b/atr/cache.py
@@ -32,6 +32,8 @@ import atr.models.schema as schema
 # Fifth prime after 3600
 ADMINS_POLL_INTERVAL_SECONDS: Final[int] = 3631
 
+PROJECT_VERSION_POLL_INTERVAL_SECONDS: Final[int] = 307
+
 
 class AdminsCache(schema.Strict):
     refreshed: datetime.datetime = schema.description("When the cache was last refreshed")
@@ -92,6 +94,39 @@ async def admins_startup_load() -> None:
         log.warning(f"Failed to fetch admin users from LDAP at startup: {e}")
 
 
+def project_version_get() -> dict[str, set[str]]:
+    if asfquart.APP is not None:
+        return asfquart.APP.extensions.get("project_versions", {})
+    return {}
+
+
+def project_version_has_project(project_name: str) -> bool:
+    return project_name in project_version_get()
+
+
+def project_version_has_version(project_name: str, version_name: str) -> bool:
+    projects = project_version_get()
+    if project_name not in projects:
+        return False
+    return version_name in projects[project_name]
+
+
+async def project_version_refresh_loop() -> None:
+    while True:
+        await asyncio.sleep(PROJECT_VERSION_POLL_INTERVAL_SECONDS)
+        try:
+            await _project_version_refresh()
+        except Exception as e:
+            log.warning(f"Project/version cache refresh failed: {e}")
+
+
+async def project_version_startup_load() -> None:
+    try:
+        await _project_version_refresh()
+    except Exception as e:
+        log.warning(f"Failed to populate project/version cache at startup: {e}")
+
+
 def _admins_path() -> pathlib.Path:
     return pathlib.Path(config.get().STATE_DIR) / "cache" / "admins.json"
 
@@ -134,3 +169,29 @@ def _admins_update_app_extensions(admins: frozenset[str]) -> None:
     app = asfquart.APP
     app.extensions["admins"] = admins
     app.extensions["admins_refreshed"] = datetime.datetime.now(datetime.UTC)
+
+
+async def _project_version_fetch_from_db() -> dict[str, set[str]]:
+    import atr.db as db
+    import atr.models.sql as sql
+
+    projects: dict[str, set[str]] = {}
+    async with db.session() as data:
+        all_projects = await data.project(status=sql.ProjectStatus.ACTIVE, _committee=False).all()
+        for project in all_projects:
+            all_releases = await data.release(project_name=project.name, _project=False, _committee=False).all()
+            projects[project.name] = {release.version for release in all_releases}
+    return projects
+
+
+async def _project_version_refresh() -> None:
+    projects = await _project_version_fetch_from_db()
+    _project_version_update_app_extensions(projects)
+    total_versions = sum(len(v) for v in projects.values())
+    log.info(f"Project/version cache refreshed: {len(projects)} projects, {total_versions} versions")
+
+
+def _project_version_update_app_extensions(projects: dict[str, set[str]]) -> None:
+    app = asfquart.APP
+    app.extensions["project_versions"] = projects
+    app.extensions["project_versions_refreshed"] = datetime.datetime.now(datetime.UTC)
diff --git a/atr/get/announce.py b/atr/get/announce.py
index 235c8463..ee5ea9d6 100644
--- a/atr/get/announce.py
+++ b/atr/get/announce.py
@@ -30,13 +30,20 @@ import atr.models.sql as sql
 import atr.post as post
 import atr.render as render
 import atr.shared as shared
+import atr.taint as taint
 import atr.template as template
 import atr.util as util
+import atr.validated as validated
 import atr.web as web
 
 
-@get.committer("/announce/<project_name>/<version_name>")
-async def selected(session: web.Committer, project_name: str, version_name: str) -> str | web.WerkzeugResponse:
+@get.committer_project_version("/announce/<project_name>/<version_name>")
+async def selected(
+    session: web.Committer,
+    project_name: str,
+    version_name: validated.VersionName,
+    **_kwargs: taint.UnsafeStr,
+) -> str | web.WerkzeugResponse:
     """Allow the user to announce a release preview."""
     await session.check_access(project_name)
 
diff --git a/atr/get/checks.py b/atr/get/checks.py
index 8faa5ce2..dfd8538b 100644
--- a/atr/get/checks.py
+++ b/atr/get/checks.py
@@ -40,6 +40,7 @@ import atr.render as render
 import atr.shared as shared
 import atr.shared.draft as draft
 import atr.storage as storage
+import atr.taint as taint
 import atr.template as template
 import atr.util as util
 import atr.web as web
@@ -134,12 +135,13 @@ async def selected(session: web.Committer | None, project_name: str, version_nam
     )
 
 
-@get.committer("/checks/<project_name>/<version_name>/<revision_number>")
+@get.committer_project_version("/checks/<project_name>/<version_name>/<revision_number>")
 async def selected_revision(
     session: web.Committer,
     project_name: str,
     version_name: str,
-    revision_number: str,
+    revision_number: taint.UnsafeStr,
+    **_kwargs: taint.UnsafeStr,
 ) -> web.QuartResponse:
     """Return JSON with ongoing count and HTML fragments for dynamic updates."""
     async with db.session() as data:
diff --git a/atr/get/compose.py b/atr/get/compose.py
index 6e400872..dcb51979 100644
--- a/atr/get/compose.py
+++ b/atr/get/compose.py
@@ -22,11 +22,18 @@ import atr.db as db
 import atr.mapping as mapping
 import atr.models.sql as sql
 import atr.shared as shared
+import atr.taint as taint
+import atr.validated as validated
 import atr.web as web
 
 
-@get.committer("/compose/<project_name>/<version_name>")
-async def selected(session: web.Committer, project_name: str, version_name: str) -> web.WerkzeugResponse | str:
+@get.committer_project_version("/compose/<project_name>/<version_name>")
+async def selected(
+    session: web.Committer,
+    project_name: validated.ProjectName,
+    version_name: validated.VersionName,
+    **_kwargs: taint.UnsafeStr,
+) -> web.WerkzeugResponse | str:
     """Show the contents of the release candidate draft."""
     await session.check_access(project_name)
 
diff --git a/atr/get/distribution.py b/atr/get/distribution.py
index 4bbf9225..deccad44 100644
--- a/atr/get/distribution.py
+++ b/atr/get/distribution.py
@@ -33,12 +33,12 @@ import atr.web as web
 from atr.tasks import gha
 
 
-@get.committer("/distribution/automate/<project>/<version>")
+@get.committer_project_version("/distribution/automate/<project>/<version>")
 async def automate(session: web.Committer, project: str, version: str) -> str:
     return await _automate_form_page(project, version, staging=False)
 
 
-@get.committer("/distributions/list/<project_name>/<version_name>")
+@get.committer_project_version("/distributions/list/<project_name>/<version_name>")
 async def list_get(session: web.Committer, project_name: str, version_name: str) -> str:
     distributions, tasks = await _get_page_data(project_name, version_name)
 
@@ -122,17 +122,17 @@ async def list_get(session: web.Committer, project_name: str, version_name: str)
     return await template.blank(title, content=block.collect())
 
 
-@get.committer("/distribution/record/<project>/<version>")
+@get.committer_project_version("/distribution/record/<project>/<version>")
 async def record(session: web.Committer, project: str, version: str) -> str:
     return await _record_form_page(project, version, staging=False)
 
 
-@get.committer("/distribution/stage/automate/<project>/<version>")
+@get.committer_project_version("/distribution/stage/automate/<project>/<version>")
 async def stage_automate(session: web.Committer, project: str, version: str) -> str:
     return await _automate_form_page(project, version, staging=True)
 
 
-@get.committer("/distribution/stage/record/<project>/<version>")
+@get.committer_project_version("/distribution/stage/record/<project>/<version>")
 async def stage_record(session: web.Committer, project: str, version: str) -> str:
     return await _record_form_page(project, version, staging=True)
 
diff --git a/atr/get/download.py b/atr/get/download.py
index bb8d5145..22a3b909 100644
--- a/atr/get/download.py
+++ b/atr/get/download.py
@@ -37,7 +37,7 @@ import atr.util as util
 import atr.web as web
 
 
-@get.committer("/download/all/<project_name>/<version_name>")
+@get.committer_project_version("/download/all/<project_name>/<version_name>")
 async def all_selected(session: web.Committer, project_name: str, version_name: str) -> web.WerkzeugResponse | str:
     """Display download commands for a release."""
     import atr.get.root as root
@@ -108,7 +108,7 @@ async def urls_selected(session: web.Committer | None, project_name: str, versio
         return web.TextResponse(f"Internal server error: {e}", status=500)
 
 
-@get.committer("/download/zip/<project_name>/<version_name>")
+@get.committer_project_version("/download/zip/<project_name>/<version_name>")
 async def zip_selected(session: web.Committer, project_name: str, version_name: str) -> web.Response:
     try:
         release = await session.release(project_name=project_name, version_name=version_name, phase=None)
diff --git a/atr/get/draft.py b/atr/get/draft.py
index 16a091ac..0777aa91 100644
--- a/atr/get/draft.py
+++ b/atr/get/draft.py
@@ -32,7 +32,7 @@ import atr.util as util
 import atr.web as web
 
 
-@get.committer("/draft/tools/<project_name>/<version_name>/<path:file_path>")
+@get.committer_project_version("/draft/tools/<project_name>/<version_name>/<path:file_path>")
 async def tools(session: web.Committer, project_name: str, version_name: str, file_path: str) -> str:
     """Show the tools for a specific file."""
     await session.check_access(project_name)
diff --git a/atr/get/file.py b/atr/get/file.py
index 2c004b55..bcd427cb 100644
--- a/atr/get/file.py
+++ b/atr/get/file.py
@@ -33,7 +33,7 @@ import atr.web as web
 type Phase = Literal["COMPOSE", "VOTE", "FINISH"]
 
 
-@get.committer("/file/<project_name>/<version_name>")
+@get.committer_project_version("/file/<project_name>/<version_name>")
 async def selected(session: web.Committer, project_name: str, version_name: str) -> str:
     """View all the files in a release (any phase)."""
     await session.check_access(project_name)
@@ -125,7 +125,7 @@ async def selected(session: web.Committer, project_name: str, version_name: str)
     return await template.blank(f"Files in {release.short_display_name}", content=block.collect())
 
 
-@get.committer("/file/<project_name>/<version_name>/<path:file_path>")
+@get.committer_project_version("/file/<project_name>/<version_name>/<path:file_path>")
 async def selected_path(session: web.Committer, project_name: str, version_name: str, file_path: str) -> str:
     """View the content of a specific file in a release (any phase)."""
     await session.check_access(project_name)
diff --git a/atr/get/finish.py b/atr/get/finish.py
index ac95b2c1..4269ecdc 100644
--- a/atr/get/finish.py
+++ b/atr/get/finish.py
@@ -56,7 +56,7 @@ class RCTagAnalysisResult:
     total_paths: int
 
 
-@get.committer("/finish/<project_name>/<version_name>")
+@get.committer_project_version("/finish/<project_name>/<version_name>")
 async def selected(
     session: web.Committer, project_name: str, version_name: str
 ) -> tuple[web.QuartResponse, int] | web.WerkzeugResponse | str:
diff --git a/atr/get/ignores.py b/atr/get/ignores.py
index 5c741d37..063b0ed8 100644
--- a/atr/get/ignores.py
+++ b/atr/get/ignores.py
@@ -27,7 +27,7 @@ import atr.util as util
 import atr.web as web
 
 
-@get.committer("/ignores/<project_name>")
+@get.committer_project("/ignores/<project_name>")
 async def ignores(session: web.Committer, project_name: str) -> str | web.WerkzeugResponse:
     async with storage.read() as read:
         ragp = read.as_general_public()
diff --git a/atr/get/manual.py b/atr/get/manual.py
index ee88a1ea..df67fc20 100644
--- a/atr/get/manual.py
+++ b/atr/get/manual.py
@@ -31,7 +31,7 @@ import atr.util as util
 import atr.web as web
 
 
-@get.committer("/manual/resolve/<project_name>/<version_name>")
+@get.committer_project_version("/manual/resolve/<project_name>/<version_name>")
 async def resolve_selected(session: web.Committer, project_name: str, version_name: str) -> str:
     """Get the manual vote resolution page."""
     await session.check_access(project_name)
@@ -55,7 +55,7 @@ async def resolve_selected(session: web.Committer, project_name: str, version_na
     )
 
 
-@get.committer("/manual/start/<project_name>/<version_name>/<revision>")
+@get.committer_project_version("/manual/start/<project_name>/<version_name>/<revision>")
 async def start_selected_revision(
     session: web.Committer, project_name: str, version_name: str, revision: str
 ) -> web.WerkzeugResponse | str:
diff --git a/atr/get/release.py b/atr/get/release.py
index 10a7061b..81a8efc0 100644
--- a/atr/get/release.py
+++ b/atr/get/release.py
@@ -77,7 +77,7 @@ async def releases(session: web.Committer | None) -> str:
     )
 
 
-@get.committer("/release/select/<project_name>")
+@get.committer_project("/release/select/<project_name>")
 async def select(session: web.Committer, project_name: str) -> str:
     """Show releases in progress for a project."""
     await session.check_access(project_name)
diff --git a/atr/get/report.py b/atr/get/report.py
index a3d194c9..6cba3443 100644
--- a/atr/get/report.py
+++ b/atr/get/report.py
@@ -30,7 +30,7 @@ import atr.util as util
 import atr.web as web
 
 
-@get.committer("/report/<project_name>/<version_name>/<path:rel_path>")
+@get.committer_project_version("/report/<project_name>/<version_name>/<path:rel_path>")
 async def selected_path(session: web.Committer, project_name: str, version_name: str, rel_path: str) -> str:
     """Show the report for a specific file."""
     await session.check_access(project_name)
diff --git a/atr/get/result.py b/atr/get/result.py
index 5fccc29c..c651fdfe 100644
--- a/atr/get/result.py
+++ b/atr/get/result.py
@@ -25,7 +25,7 @@ import atr.models.sql as sql
 import atr.web as web
 
 
-@get.committer("/result/data/<project_name>/<version_name>/<int:check_id>")
+@get.committer_project_version("/result/data/<project_name>/<version_name>/<int:check_id>")
 async def data(
     session: web.Committer,
     project_name: str,
diff --git a/atr/get/revisions.py b/atr/get/revisions.py
index 680227a1..46114ac5 100644
--- a/atr/get/revisions.py
+++ b/atr/get/revisions.py
@@ -47,7 +47,7 @@ class FilesDiff(schema.Strict):
     modified: list[pathlib.Path]
 
 
-@get.committer("/revisions/<project_name>/<version_name>")
+@get.committer_project_version("/revisions/<project_name>/<version_name>")
 async def selected(session: web.Committer, project_name: str, version_name: str) -> str:
     """Show the revision history for a release candidate draft or release preview."""
     await session.check_access(project_name)
diff --git a/atr/get/sbom.py b/atr/get/sbom.py
index 9a16a479..f4c35be2 100644
--- a/atr/get/sbom.py
+++ b/atr/get/sbom.py
@@ -43,7 +43,7 @@ if TYPE_CHECKING:
     from collections.abc import Sequence
 
 
-@get.committer("/sbom/report/<project>/<version>/<path:file_path>")
+@get.committer_project_version("/sbom/report/<project>/<version>/<path:file_path>")
 async def report(session: web.Committer, project: str, version: str, file_path: str) -> str:
     await session.check_access(project)
 
diff --git a/atr/get/test.py b/atr/get/test.py
index ad70cd64..7e20be3c 100644
--- a/atr/get/test.py
+++ b/atr/get/test.py
@@ -76,7 +76,7 @@ async def test_login(session: web.Committer | None) -> web.WerkzeugResponse:
     return await web.redirect(root.index)
 
 
-@get.committer("/test/merge/<project_name>/<version_name>")
+@get.committer_project_version("/test/merge/<project_name>/<version_name>")
 async def test_merge(session: web.Committer, project_name: str, version_name: str) -> web.WerkzeugResponse:
     if not config.get().ALLOW_TESTS:
         raise base.ASFQuartException("Test routes not enabled", errorcode=404)
diff --git a/atr/get/upload.py b/atr/get/upload.py
index 5e57258d..911acd07 100644
--- a/atr/get/upload.py
+++ b/atr/get/upload.py
@@ -35,7 +35,7 @@ import atr.util as util
 import atr.web as web
 
 
-@get.committer("/upload/<project_name>/<version_name>")
+@get.committer_project_version("/upload/<project_name>/<version_name>")
 async def selected(session: web.Committer, project_name: str, version_name: str) -> str:
     await session.check_access(project_name)
 
diff --git a/atr/get/voting.py b/atr/get/voting.py
index 665cff66..98205d2c 100644
--- a/atr/get/voting.py
+++ b/atr/get/voting.py
@@ -38,7 +38,7 @@ import atr.util as util
 import atr.web as web
 
 
-@get.committer("/voting/<project_name>/<version_name>/<revision>")
+@get.committer_project_version("/voting/<project_name>/<version_name>/<revision>")
 async def selected_revision(
     session: web.Committer, project_name: str, version_name: str, revision: str
 ) -> web.WerkzeugResponse | str:
diff --git a/atr/server.py b/atr/server.py
index 6dcee179..fe73716a 100644
--- a/atr/server.py
+++ b/atr/server.py
@@ -277,6 +277,10 @@ def _app_setup_lifecycle(app: base.QuartApp, app_config: type[config.AppConfig])
         admins_task = asyncio.create_task(cache.admins_refresh_loop())
         app.extensions["admins_task"] = admins_task
 
+        await cache.project_version_startup_load()
+        project_version_task = asyncio.create_task(cache.project_version_refresh_loop())
+        app.extensions["project_version_task"] = project_version_task
+
         worker_manager = manager.get_worker_manager()
         await worker_manager.start()
 
diff --git a/atr/taint.py b/atr/taint.py
new file mode 100644
index 00000000..d59c5133
--- /dev/null
+++ b/atr/taint.py
@@ -0,0 +1,29 @@
+# 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.
+
+
+class UnsafeStr:
+    """A raw string from URL routing that has not been validated.
+    """
+
+    __slots__ = ("_value",)
+
+    def __init__(self, value: str) -> None:
+        self._value = value
+
+    def __repr__(self) -> str:
+        return f"UnsafeStr({self._value!r})"
diff --git a/atr/validated.py b/atr/validated.py
new file mode 100644
index 00000000..b084cfa3
--- /dev/null
+++ b/atr/validated.py
@@ -0,0 +1,22 @@
+# 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.
+
+from typing import NewType
+
+# Validated route parameter types
+ProjectName = NewType("ProjectName", str)
+VersionName = NewType("VersionName", str)
diff --git a/atr/web.py b/atr/web.py
index 4d7d8c9c..b8585fff 100644
--- a/atr/web.py
+++ b/atr/web.py
@@ -42,6 +42,9 @@ if TYPE_CHECKING:
     import pydantic
     import werkzeug.wrappers.response as response
 
+    import atr.taint as taint
+    import atr.validated as validated
+
 R = TypeVar("R", covariant=True)
 
 type WerkzeugResponse = response.Response
@@ -49,6 +52,32 @@ type QuartResponse = quart.Response
 type Response = WerkzeugResponse | QuartResponse
 
 
+class CommitterProjectHandler(Protocol[R]):
+    """Protocol for @committer_project decorated functions."""
+
+    __name__: str
+    __doc__: str | None
+
+    def __call__(
+        self, session: Committer, project_name: validated.ProjectName, **kwargs: taint.UnsafeStr
+    ) -> Awaitable[R]: ...
+
+
+class CommitterProjectVersionHandler(Protocol[R]):
+    """Protocol for @committer_project_version decorated functions."""
+
+    __name__: str
+    __doc__: str | None
+
+    def __call__(
+        self,
+        session: Committer,
+        project_name: validated.ProjectName,
+        version_name: validated.VersionName,
+        **kwargs: taint.UnsafeStr,
+    ) -> Awaitable[R]: ...
+
+
 class CommitterRouteFunction(Protocol[R]):
     """Protocol for @committer_get decorated functions."""
 


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org