You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by sb...@apache.org on 2026/07/15 15:42:57 UTC

(tooling-trusted-releases) 01/01: Use existing KEYS URLs instead of linking to the removed downloads directory

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

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

commit 19e5067b5bf6911cc1d2eef6bc5e652a3dd9759e
Author: Sean B. Palmer <se...@miscoranda.com>
AuthorDate: Wed Jul 15 16:42:08 2026 +0100

    Use existing KEYS URLs instead of linking to the removed downloads directory
---
 atr/api/__init__.py                     | 37 +---------------------
 atr/construct.py                        | 12 +-------
 atr/models/api.py                       |  2 --
 atr/paths.py                            | 13 +++++---
 atr/post/keys.py                        | 10 +-----
 tests/unit/test_construct_announce.py   | 54 +++++++++++++++++++++++++++++++++
 tests/unit/test_keys_post.py            | 30 ++++++++++++++++++
 tests/unit/test_paths.py                | 27 +++++++++++++++++
 tests/unit/test_signature_provenance.py | 51 +++++++++++++++++++++++++++++++
 9 files changed, 174 insertions(+), 62 deletions(-)

diff --git a/atr/api/__init__.py b/atr/api/__init__.py
index a1c00e09..95da4c2f 100644
--- a/atr/api/__init__.py
+++ b/atr/api/__init__.py
@@ -36,7 +36,6 @@ import atr.constants as constants
 import atr.construct as construct
 import atr.db as db
 import atr.db.interaction as interaction
-import atr.hashes as hashes
 import atr.ldap as ldap
 import atr.log as log
 import atr.models as models
@@ -1580,9 +1579,6 @@ async def signature_provenance(
         raise exceptions.BadRequest("version_key requires project_key")
 
     signing_keys: list[models.api.SignatureProvenanceKey] = []
-    conf = config.get()
-    host = conf.APP_HOST
-
     signature_asc_data = data.signature_asc_text
     sig, _ = openpgp.DetachedSignature.from_armor(signature_asc_data)
     signature_info = sig.signature_info()
@@ -1601,25 +1597,7 @@ async def signature_provenance(
         matched_committees = await _match_committees(key.committees, data, _jwt_asf_uid())
 
     for committee in matched_committees:
-        keys_file_path = paths.committee_downloads_dir(committee) / "KEYS"
-        try:
-            async with aiofiles.open(keys_file_path, "rb") as f:
-                keys_file_data = await f.read()
-        except FileNotFoundError:
-            continue
-        except OSError:
-            log.exception(f"Failed to read KEYS file for committee {committee.key}")
-            continue
-        keys_file_sha3_256 = hashes.compute_sha3_256(keys_file_data)
-        signing_keys.append(
-            models.api.SignatureProvenanceKey(
-                committee=committee.key,
-                keys_file_url=util.public_download_url(
-                    committee, None, util.DownloadFile.METADATA, filename="KEYS", host=host
-                ),
-                keys_file_sha3_256=keys_file_sha3_256,
-            )
-        )
+        signing_keys.append(models.api.SignatureProvenanceKey(committee=committee.key))
 
     if not signing_keys:
         raise exceptions.NotFound("No signing keys found")
@@ -2134,19 +2112,6 @@ async def test_archive_project(
     return {"ok": True, "project_key": str(data.project_key)}, 200
 
 
-def _committee_keys_path(committee: sql.Committee) -> safe.StatePath:
-    downloads_dir = paths.get_downloads_dir()
-    if committee.is_podling:
-        return downloads_dir / "incubator" / committee.key / "KEYS"
-    return downloads_dir / committee.key / "KEYS"
-
-
-def _committee_keys_url(host: str, committee: sql.Committee) -> str:
-    if committee.is_podling:
-        return f"https://{host}/downloads/incubator/{committee.key}/KEYS"
-    return f"https://{host}/downloads/{committee.key}/KEYS"
-
-
 def _jwt_asf_uid() -> str:
     claims = getattr(quart.g, "jwt_claims", {})
     asf_uid = claims.get("sub")
diff --git a/atr/construct.py b/atr/construct.py
index 20417c14..fcd3078d 100644
--- a/atr/construct.py
+++ b/atr/construct.py
@@ -22,8 +22,6 @@ import re
 from collections.abc import Mapping
 from typing import Final, Literal, TypedDict, overload
 
-import aiofiles.os
-
 import atr.config as config
 import atr.db as db
 import atr.mail as mail
@@ -389,13 +387,6 @@ async def start_vote_subject_and_body(subject: str, body: str, options: StartVot
     vote_end = datetime.datetime.now(datetime.UTC) + datetime.timedelta(hours=options.vote_duration)
     vote_end_str = f"{vote_end.day} {vote_end.strftime('%b %H:%M')} UTC"
 
-    # NOTE: The /downloads/ directory is served by the proxy front end, not by ATR
-    # Therefore there is no route handler, so we have to construct the URL manually
-    keys_file = None
-    keys_file_path = paths.committee_downloads_dir(committee) / "KEYS"
-    if await aiofiles.os.path.isfile(keys_file_path):
-        keys_file = util.public_download_url(committee, None, util.DownloadFile.METADATA, filename="KEYS", host=host)
-
     checklist_content = ""
     async with db.session() as data:
         release_policy = await db.get_project_release_policy(data, options.project_key)
@@ -425,7 +416,7 @@ async def start_vote_subject_and_body(subject: str, body: str, options: StartVot
         "COMMITTEE": committee.display_name,
         "DURATION": str(options.vote_duration or "an unlimited number of"),
         "HOMEPAGE": project.homepage or "",
-        "KEYS_FILE": keys_file or "(Sorry, the KEYS file is missing!)",
+        "KEYS_FILE": paths.committee_keys_url(committee),
         "PROJECT_NAME": project_display_name,
         "PROJECT_KEY": project.key,
         "RELEASE_CHECKLIST": checklist_content,
@@ -437,7 +428,6 @@ async def start_vote_subject_and_body(subject: str, body: str, options: StartVot
         "YOUR_ASF_ID": options.asfuid,
         "YOUR_FULL_NAME": options.fullname,
     }
-    body_values["KEYS_FILE"] = util.released_dist_url(body_values["KEYS_FILE"])
     subject = _substitute(subject, subject_values, "vote_subject")
     body = _substitute(body, body_values, "vote")
     return subject, body
diff --git a/atr/models/api.py b/atr/models/api.py
index 42de59fc..fb079e5a 100644
--- a/atr/models/api.py
+++ b/atr/models/api.py
@@ -793,8 +793,6 @@ class SignatureProvenanceArgs(schema.Strict):
 
 class SignatureProvenanceKey(schema.Strict):
     committee: str = schema.example("example")
-    keys_file_url: str = schema.example("https://example.apache.org/example/KEYS")
-    keys_file_sha3_256: str = schema.example("0123456789abcdef0123456789abcdef01234567")
 
 
 class SignatureProvenanceResults(schema.Strict):
diff --git a/atr/paths.py b/atr/paths.py
index e36478d2..b99592d4 100644
--- a/atr/paths.py
+++ b/atr/paths.py
@@ -23,6 +23,12 @@ import atr.models.safe as safe
 import atr.models.sql as sql
 
 
+def archive_download_url(relpath: safe.RelPath) -> str:
+    # archive.apache.org keeps every release ever published, including those pruned from the
+    # live mirror, so an archived release's files are served from here regardless of host.
+    return f"{constants.ARCHIVE_APACHE_URL}/{relpath}"
+
+
 def base_path_for_revision(
     project_key: safe.ProjectKey, version_key: safe.VersionKey, revision: safe.RevisionNumber
 ) -> safe.StatePath:
@@ -65,10 +71,9 @@ def committee_downloads_url(host: str, committee: sql.Committee) -> str:
     return f"https://{host}/downloads/{committee.key}"
 
 
-def archive_download_url(relpath: safe.RelPath) -> str:
-    # archive.apache.org keeps every release ever published, including those pruned from the
-    # live mirror, so an archived release's files are served from here regardless of host.
-    return f"{constants.ARCHIVE_APACHE_URL}/{relpath}"
+def committee_keys_url(committee: sql.Committee) -> str:
+    prefix = "incubator/" if committee.is_podling else ""
+    return downloads_url(safe.RelPath(f"{prefix}{committee.key}/KEYS"))
 
 
 def downloads_url(relpath: safe.RelPath) -> str:
diff --git a/atr/post/keys.py b/atr/post/keys.py
index c038450b..f71e0ae0 100644
--- a/atr/post/keys.py
+++ b/atr/post/keys.py
@@ -268,12 +268,6 @@ async def _add_key_text_resolve(session: web.Committer, add_form: shared.keys.Ad
     return key_text
 
 
-def _construct_keys_url(committee_key: str, *, is_podling: bool) -> str:
-    # KEYS comes from downloads.apache.org, not a mirror.
-    prefix = "incubator/" if is_podling else ""
-    return paths.downloads_url(safe.RelPath(f"{prefix}{committee_key}/KEYS"))
-
-
 async def _delete_openpgp_key(
     session: web.Committer, delete_form: shared.keys.DeleteOpenPGPKeyForm
 ) -> web.WerkzeugResponse:
@@ -454,9 +448,7 @@ async def _upload_remote_keys(upload_remote_form: shared.keys.UploadRemoteForm)
             if not committee:
                 await quart.flash(f"Committee '{selected_committee}' not found", "error")
                 return await shared.keys.render_upload_page(error=True)
-            is_podling = committee.is_podling
-
-        keys_url = _construct_keys_url(selected_committee, is_podling=is_podling)
+        keys_url = paths.committee_keys_url(committee)
         keys_text = await _fetch_keys_from_url(keys_url)
 
         if util.contains_private_key_text(keys_text):
diff --git a/tests/unit/test_construct_announce.py b/tests/unit/test_construct_announce.py
index e7e9237e..5e119340 100644
--- a/tests/unit/test_construct_announce.py
+++ b/tests/unit/test_construct_announce.py
@@ -170,6 +170,60 @@ async def test_announce_release_subject_and_body_uses_top_level_downloads_url(mo
     assert body == "https://downloads.apache.org/downloads/myproject/"
 
 
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("is_podling", "expected_url"),
+    [
+        (False, "https://downloads.apache.org/myproject/KEYS"),
+        (True, "https://downloads.apache.org/incubator/myproject/KEYS"),
+    ],
+)
+async def test_start_vote_subject_and_body_uses_canonical_keys_url(
+    monkeypatch, is_podling: bool, expected_url: str
+) -> None:
+    committee = SimpleNamespace(key="myproject", is_podling=is_podling, display_name="Apache MyProject")
+    project = SimpleNamespace(
+        short_display_name="Apache MyProject",
+        key="myproject",
+        bug_database=None,
+        homepage=None,
+        repositories=[],
+    )
+    release = SimpleNamespace(key="myproject-1.0.0", committee=committee, project=project)
+    revision = SimpleNamespace(number="1", tag=None)
+
+    async def no_release_policy(_data, _project_key):
+        return None
+
+    monkeypatch.setattr(
+        construct.config,
+        "get",
+        lambda: SimpleNamespace(
+            APP_HOST="atr.example.invalid",
+            SVN_PUBLISH_URL="https://svn.example.invalid/repos/dist/atr",
+            SVN_DIST_PUBLIC_URL="https://dist.example.invalid/repos/dist/atr",
+        ),
+    )
+    monkeypatch.setattr(construct.db, "session", _mock_session_factory(MockDBSession(release, revision)))
+    monkeypatch.setattr(construct.db, "get_project_release_policy", no_release_policy)
+    monkeypatch.setattr(construct.util, "as_url", lambda *_args, **_kwargs: "/example")
+
+    _subject, body = await construct.start_vote_subject_and_body(
+        "",
+        "{{KEYS_FILE}}",
+        construct.StartVoteOptions(
+            asfuid="sbp",
+            fullname="Some Body",
+            project_key=safe.ProjectKey("myproject"),
+            version_key=safe.VersionKey("1.0.0"),
+            revision_number=safe.RevisionNumber("1"),
+            vote_duration=72,
+        ),
+    )
+
+    assert body == expected_url
+
+
 def test_substitute_does_not_rescan_replacement_values() -> None:
     result = construct._substitute(
         "{{PROJECT_NAME}} {{VERSION}} {{UNKNOWN}}",
diff --git a/tests/unit/test_keys_post.py b/tests/unit/test_keys_post.py
index dee48cd7..84fe55e8 100644
--- a/tests/unit/test_keys_post.py
+++ b/tests/unit/test_keys_post.py
@@ -15,7 +15,10 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import contextlib
 import datetime
+from types import SimpleNamespace
+from unittest import mock
 
 import pytest
 
@@ -40,6 +43,33 @@ def test_openpgp_key_uid_warning_flags_other_asf_uid(monkeypatch: pytest.MonkeyP
     assert "/keys/details/fp" in warning_html
 
 
+@pytest.mark.asyncio
+async def test_upload_remote_keys_uses_canonical_committee_url(monkeypatch: pytest.MonkeyPatch) -> None:
+    committee = SimpleNamespace(key="example", is_podling=True)
+    query = SimpleNamespace(get=mock.AsyncMock(return_value=committee))
+    data = SimpleNamespace(committee=mock.Mock(return_value=query))
+
+    @contextlib.asynccontextmanager
+    async def db_session():
+        yield data
+
+    canonical_url = "https://downloads.apache.org/incubator/example/KEYS"
+    committee_keys_url = mock.Mock(return_value=canonical_url)
+    fetch = mock.AsyncMock(return_value="public keys")
+    process = mock.AsyncMock(return_value="rendered")
+    monkeypatch.setattr(keys.db, "session", db_session)
+    monkeypatch.setattr(keys.paths, "committee_keys_url", committee_keys_url)
+    monkeypatch.setattr(keys, "_fetch_keys_from_url", fetch)
+    monkeypatch.setattr(keys, "_process_keys", process)
+    monkeypatch.setattr(keys.util, "contains_private_key_text", lambda _text: False)
+
+    result = await keys._upload_remote_keys(SimpleNamespace(committee="example"))
+
+    assert result == "rendered"
+    committee_keys_url.assert_called_once_with(committee)
+    fetch.assert_awaited_once_with(canonical_url)
+
+
 def _public_key(apache_uid: str | None) -> sql.PublicSigningKey:
     return sql.PublicSigningKey(
         fingerprint="fp",
diff --git a/tests/unit/test_paths.py b/tests/unit/test_paths.py
index 28730425..950d7a80 100644
--- a/tests/unit/test_paths.py
+++ b/tests/unit/test_paths.py
@@ -54,6 +54,33 @@ def test_committee_downloads_url_for_top_level_committee() -> None:
     )
 
 
+def test_committee_keys_url_for_podling() -> None:
+    committee = types.SimpleNamespace(key="myproject", is_podling=True)
+
+    assert paths.committee_keys_url(committee) == "https://downloads.apache.org/incubator/myproject/KEYS"
+
+
+def test_committee_keys_url_for_top_level_committee() -> None:
+    committee = types.SimpleNamespace(key="myproject", is_podling=False)
+
+    assert paths.committee_keys_url(committee) == "https://downloads.apache.org/myproject/KEYS"
+
+
+def test_committee_keys_url_ignores_application_and_svn_hosts(monkeypatch) -> None:
+    committee = types.SimpleNamespace(key="myproject", is_podling=False)
+    monkeypatch.setattr(
+        paths.config,
+        "get",
+        lambda: types.SimpleNamespace(
+            APP_HOST="atr.example.invalid",
+            SVN_PUBLISH_URL="https://svn.example.invalid/repos/dist/atr",
+            SVN_DIST_PUBLIC_URL="https://dist.example.invalid/repos/dist/atr",
+        ),
+    )
+
+    assert paths.committee_keys_url(committee) == "https://downloads.apache.org/myproject/KEYS"
+
+
 def test_get_quarantined_dir_uses_state_dir(monkeypatch, tmp_path: pathlib.Path):
     mock_config = types.SimpleNamespace(STATE_DIR=str(tmp_path))
     monkeypatch.setattr("atr.config.get", lambda: mock_config)
diff --git a/tests/unit/test_signature_provenance.py b/tests/unit/test_signature_provenance.py
index 789483b0..c7fae572 100644
--- a/tests/unit/test_signature_provenance.py
+++ b/tests/unit/test_signature_provenance.py
@@ -17,6 +17,7 @@
 
 import contextlib
 import hashlib
+import inspect
 import pathlib
 import unittest.mock as mock
 from collections.abc import AsyncGenerator
@@ -330,6 +331,21 @@ async def test_scoped_returns_empty_when_version_not_found() -> None:
     assert result == []
 
 
+@pytest.mark.asyncio
+async def test_signature_provenance_requires_matched_committee(monkeypatch: pytest.MonkeyPatch) -> None:
+    with pytest.raises(atr.api.exceptions.NotFound, match="No signing keys found"):
+        await _call_signature_provenance(monkeypatch, matched=False)
+
+
+@pytest.mark.asyncio
+async def test_signature_provenance_returns_database_committees(monkeypatch: pytest.MonkeyPatch) -> None:
+    result, status = await _call_signature_provenance(monkeypatch, matched=True)
+
+    assert status == 200
+    assert result["committees_with_artifact"] == [{"committee": "example"}]
+    assert result["key_asc_text"] == _EMBEDDED_SUBKEY_PUBLIC_KEY_ASC
+
+
 @pytest.mark.asyncio
 async def test_unscoped_finds_matching_committee(tmp_path: pathlib.Path) -> None:
     committee = SimpleNamespace(key="example-pmc", is_podling=False)
@@ -387,6 +403,41 @@ async def test_unscoped_returns_empty_when_no_match(tmp_path: pathlib.Path) -> N
     assert result == []
 
 
+async def _call_signature_provenance(
+    monkeypatch: pytest.MonkeyPatch,
+    matched: bool,
+) -> tuple[dict, int]:
+    parsed_key, _ = atr.api.openpgp.PublicKey.from_armor(_EMBEDDED_SUBKEY_PUBLIC_KEY_ASC)
+    committee = SimpleNamespace(key="example", is_podling=False)
+    stored = SimpleNamespace(
+        fingerprint=parsed_key.fingerprint.lower(),
+        ascii_armored_key=_EMBEDDED_SUBKEY_PUBLIC_KEY_ASC,
+        committees=[committee],
+    )
+    signature = SimpleNamespace(
+        signature_info=lambda: SimpleNamespace(
+            issuer_fingerprints={parsed_key.fingerprint.lower()},
+            issuer_key_ids=set(),
+        )
+    )
+    matched_committees = [committee] if matched else []
+
+    monkeypatch.setattr(atr.api.db, "session", _mock_session_factory(MockKeyDBSession([stored])))
+    monkeypatch.setattr(
+        atr.api.openpgp,
+        "DetachedSignature",
+        SimpleNamespace(from_armor=lambda _text: (signature, "")),
+    )
+    monkeypatch.setattr(atr.api, "_match_committees", mock.AsyncMock(return_value=matched_committees))
+    monkeypatch.setattr(atr.api, "_jwt_asf_uid", lambda: "tester")
+
+    signature_provenance = inspect.unwrap(atr.api.signature_provenance)
+    return await signature_provenance(
+        "signature/provenance",
+        _make_args("example.tar.gz.asc", "abc123"),
+    )
+
+
 def _make_args(
     signature_file_name: str,
     signature_sha3_256: str,


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