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