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/09/16 16:51:54 UTC

(tooling-trusted-releases) branch sbp updated: Add a revision manifest API endpoint

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


The following commit(s) were added to refs/heads/sbp by this push:
     new 4ee92612 Add a revision manifest API endpoint
4ee92612 is described below

commit 4ee926128ed32d6b1ea4d3be7ee1956e336216d7
Author: Sean B. Palmer <se...@miscoranda.com>
AuthorDate: Wed Sep 16 17:51:19 2026 +0100

    Add a revision manifest API endpoint
---
 atr/api/__init__.py                     |  37 ++++++
 atr/get/manifest.py                     |  12 ++
 atr/models/api.py                       |  16 +++
 tests/unit/test_api_release_manifest.py | 224 ++++++++++++++++++++++++++++++++
 tests/unit/test_get_manifest.py         |  11 ++
 5 files changed, 300 insertions(+)

diff --git a/atr/api/__init__.py b/atr/api/__init__.py
index ea9be780..c13cda9c 100644
--- a/atr/api/__init__.py
+++ b/atr/api/__init__.py
@@ -33,6 +33,7 @@ import sqlalchemy
 import sqlmodel
 import werkzeug.exceptions as exceptions
 
+import atr.attestable as attestable
 import atr.blueprints.api as api
 import atr.blueprints.api_auth as api_auth
 import atr.cle as cle
@@ -1440,6 +1441,42 @@ async def release_get(
     ).model_dump(mode="json"), 200
 
 
+@api.typed(
+    auth_scheme=api_auth.Auth.PUBLIC,
+    response=(models.api.ReleaseManifestResults, 200),
+)
+async def release_manifest(
+    _release_manifest: Literal["release/manifest"],
+    project_key: safe.ProjectKey,
+    version_key: safe.VersionKey,
+    revision: safe.RevisionNumber,
+) -> DictResponse:
+    async with db.session() as data:
+        await data.release(project_key=str(project_key), version=str(version_key)).demand(
+            exceptions.NotFound(errors.RELEASE_NOT_FOUND)
+        )
+    recorded = await attestable.load(project_key, version_key, revision)
+    if recorded is None:
+        raise exceptions.NotFound(errors.MANIFEST_NOT_FOUND)
+    files = []
+    for path, content_hash in sorted(attestable.path_hashes(recorded).items()):
+        entry = recorded.hashes.get(content_hash)
+        files.append(
+            models.api.ReleaseManifestFile(
+                path=path,
+                size=entry.size if (entry is not None) else None,
+                digest=content_hash,
+            )
+        )
+    return models.api.ReleaseManifestResults(
+        endpoint="/release/manifest",
+        project=project_key,
+        version=version_key,
+        revision=revision,
+        files=files,
+    ).model_dump(mode="json"), 200
+
+
 @api.typed(
     auth_scheme=api_auth.Auth.PUBLIC,
     response=(models.api.ReleasePathsResults, 200),
diff --git a/atr/get/manifest.py b/atr/get/manifest.py
index e1f3bea6..9b8cdec7 100644
--- a/atr/get/manifest.py
+++ b/atr/get/manifest.py
@@ -20,6 +20,7 @@ from typing import Literal
 
 import asfquart.base as base
 
+import atr.api as api
 import atr.attestable as attestable
 import atr.blueprints.get as get
 import atr.db as db
@@ -98,6 +99,17 @@ def _render_page(
         ".",
     ]
     page.p[f"{util.plural(len(path_hashes), 'file')} recorded."]
+    if not release.is_embargoed:
+        page.p[
+            htm.a(
+                href=util.as_url(
+                    api.release_manifest,
+                    project_key=release.project_key,
+                    version_key=release.version,
+                    revision=revision_number,
+                )
+            )["JSON manifest"]
+        ]
     if not path_hashes:
         page.p(".text-muted")["This revision contains no files."]
         return page.collect()
diff --git a/atr/models/api.py b/atr/models/api.py
index f9db48b5..02068d41 100644
--- a/atr/models/api.py
+++ b/atr/models/api.py
@@ -802,6 +802,22 @@ class ReleaseGetResults(schema.Strict):
         return v
 
 
+class ReleaseManifestFile(schema.Strict):
+    path: str
+    size: int | None = schema.description("File size in bytes, or null when unavailable in the retained manifest.")
+    digest: str = schema.description("Content digest including its algorithm prefix, such as blake3:.")
+
+
+class ReleaseManifestResults(schema.Strict):
+    endpoint: Literal["/release/manifest"] = schema.alias("endpoint")
+    project: safe.ProjectKey
+    version: safe.VersionKey
+    revision: safe.RevisionNumber
+    files: Sequence[ReleaseManifestFile] = schema.description(
+        "All files in the requested revision, without pagination."
+    )
+
+
 @dataclasses.dataclass
 class ReleasePathsQuery:
     offset: int = 0
diff --git a/tests/unit/test_api_release_manifest.py b/tests/unit/test_api_release_manifest.py
new file mode 100644
index 00000000..48d35c78
--- /dev/null
+++ b/tests/unit/test_api_release_manifest.py
@@ -0,0 +1,224 @@
+# 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 datetime
+import json
+import unittest.mock as mock
+
+import pytest
+import quart
+import quart_schema
+import sqlalchemy.ext.asyncio
+import sqlmodel
+
+import atr.attestable as attestable
+import atr.blueprints.api
+import atr.db as db
+import atr.models.safe as safe
+import atr.models.sql as sql
+import atr.paths as paths
+
+
+@pytest.fixture
+async def app(monkeypatch, tmp_path):
+    engine = sqlalchemy.ext.asyncio.create_async_engine("sqlite+aiosqlite:///:memory:")
+    async with engine.begin() as connection:
+        await connection.run_sync(sqlmodel.SQLModel.metadata.create_all)
+    maker = sqlalchemy.ext.asyncio.async_sessionmaker(bind=engine, class_=db.Session, expire_on_commit=False)
+    async with maker() as data:
+        committee = sql.Committee(key="example", name="Example", committee_members=["member"])
+        project = sql.Project(key="example", name="Apache Example", committee=committee)
+        data.add(
+            sql.Release(
+                project=project,
+                project_key="example",
+                version="1.0",
+                phase=sql.ReleasePhase.RELEASE,
+                created=datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC),
+            )
+        )
+        await data.commit()
+    monkeypatch.setattr(db, "session", maker)
+    monkeypatch.setattr(paths, "get_attestable_dir", lambda: safe.StatePath(tmp_path))
+    app = quart.Quart(__name__)
+    app.config["TESTING"] = True
+    app.config["QUART_SCHEMA_PYDANTIC_DUMP_OPTIONS"] = {"mode": "json"}
+    quart_schema.QuartSchema(app, openapi_path="/api/openapi.json")
+    atr.blueprints.api.register(app)
+    yield app
+    await engine.dispose()
+
+
+@pytest.mark.parametrize("phase", list(sql.ReleasePhase))
+async def test_embargo_blocks_before_manifest_access(app, tmp_path, monkeypatch, phase):
+    _write_manifest(tmp_path)
+    async with db.session() as data:
+        release = await data.release(project_key="example", version="1.0").demand(RuntimeError("missing release"))
+        release.expedited = True
+        release.phase = phase
+        await data.commit()
+    loader = mock.AsyncMock(wraps=attestable.load)
+    monkeypatch.setattr(attestable, "load", loader)
+    response = await app.test_client().get("/api/release/manifest/example/1.0/00001")
+    assert response.status_code == (200 if phase == sql.ReleasePhase.RELEASE else 404)
+    if phase != sql.ReleasePhase.RELEASE:
+        loader.assert_not_awaited()
+        assert (await response.get_json())["error"] == "Release does not exist"
+
+
+async def test_exact_revision_with_no_rows_or_candidate_files(app, tmp_path):
+    _write_manifest(tmp_path)
+    _write_manifest(tmp_path, revision="00002", payload={"version": 2, "paths": {}, "hashes": {}})
+    async with db.session() as data:
+        release = await data.release(project_key="example", version="1.0").demand(RuntimeError("missing release"))
+        assert release.latest_revision_number is None
+        assert await data.revision(release_key=release.key).all() == []
+    response = await app.test_client().get("/api/release/manifest/example/1.0/00001")
+    assert response.status_code == 200
+    assert len((await response.get_json())["files"]) == 3
+    response = await app.test_client().get("/api/release/manifest/example/1.0/00002")
+    assert response.status_code == 200
+    assert await response.get_json() == {
+        "endpoint": "/release/manifest",
+        "project": "example",
+        "version": "1.0",
+        "revision": "00002",
+        "files": [],
+    }
+    for suffix in ("", "/00003", "/1", "/latest"):
+        response = await app.test_client().get(f"/api/release/manifest/example/1.0{suffix}")
+        assert response.status_code == (400 if suffix == "/latest" else 404)
+    assert not (tmp_path / "unfinished").exists()
+
+
+@pytest.mark.parametrize("version", [1, 2])
+@pytest.mark.parametrize("phase", list(sql.ReleasePhase))
+async def test_manifest_projects_only_public_file_identity(app, tmp_path, version, phase):
+    _write_manifest(tmp_path, version=version)
+    async with db.session() as data:
+        release = await data.release(project_key="example", version="1.0").demand(RuntimeError("missing release"))
+        release.phase = phase
+        release.is_archived = phase == sql.ReleasePhase.RELEASE
+        await data.commit()
+    response = await app.test_client().get("/api/release/manifest/example/1.0/00001")
+    assert response.status_code == 200
+    assert response.mimetype == "application/json"
+    assert await response.get_json() == {
+        "endpoint": "/release/manifest",
+        "project": "example",
+        "version": "1.0",
+        "revision": "00001",
+        "files": [
+            {"path": "bundle.zip", "size": 102, "digest": "blake3:" + "2" * 64},
+            {"path": "bundle.zip.asc", "size": 101, "digest": "blake3:" + "1" * 64},
+            {"path": "bundle.zip.sha512", "size": 100, "digest": "blake3:" + "0" * 64},
+        ],
+    }
+
+
+@pytest.mark.parametrize("payload", [None, "{bad", '{"version": 2, "paths": []}', "[]"])
+async def test_missing_or_malformed_manifest_is_json_404(app, tmp_path, payload):
+    _write_manifest(tmp_path, revision="00002")
+    if payload is not None:
+        (tmp_path / "example" / "1.0" / "00001.json").write_text(payload)
+    response = await app.test_client().get("/api/release/manifest/example/1.0/00001")
+    assert response.status_code == 404
+    assert (await response.get_json())["error"] == "No file manifest is available for this ATR revision"
+
+
+@pytest.mark.parametrize("query", ["", "?limit=1&offset=1000"])
+async def test_no_pagination_or_truncation(app, tmp_path, query):
+    digest = "blake3:" + "a" * 64
+    _write_manifest(
+        tmp_path,
+        payload={
+            "version": 1,
+            "paths": {f"file-{index:04}.txt": digest for index in reversed(range(1251))},
+            "hashes": {digest: {"size": 7, "uploaders": []}},
+        },
+    )
+    response = await app.test_client().get(f"/api/release/manifest/example/1.0/00001{query}")
+    assert response.status_code == 200
+    body = await response.get_json()
+    assert body["files"] == [{"path": f"file-{index:04}.txt", "size": 7, "digest": digest} for index in range(1251)]
+
+
+async def test_openapi_declares_required_revision_and_response(app):
+    response = await app.test_client().get("/api/openapi.json")
+    assert response.status_code == 200
+    document = await response.get_json()
+    operation = document["paths"]["/api/release/manifest/{project_key}/{version_key}/{revision}"]["get"]
+    assert {(param["name"], param["in"], param["required"]) for param in operation["parameters"]} == {
+        ("project_key", "path", True),
+        ("version_key", "path", True),
+        ("revision", "path", True),
+    }
+    assert not operation.get("security")
+    schema = operation["responses"]["200"]["content"]["application/json"]["schema"]
+    assert schema["title"] == "ReleaseManifestResults"
+    components = document["components"]["schemas"]
+    assert set(schema["properties"]) == {"endpoint", "project", "version", "revision", "files"}
+    assert schema["properties"]["files"]["items"]["$ref"].endswith("/ReleaseManifestFile")
+    assert set(components["ReleaseManifestFile"]["properties"]) == {"path", "size", "digest"}
+
+
+async def test_unknown_release_does_not_load_manifest(app, monkeypatch):
+    loader = mock.AsyncMock()
+    monkeypatch.setattr(attestable, "load", loader)
+    response = await app.test_client().get("/api/release/manifest/unknown/1.0/00001")
+    assert response.status_code == 404
+    assert (await response.get_json())["error"] == "Release does not exist"
+    loader.assert_not_awaited()
+
+
+async def test_unknown_size_and_unusual_paths_preserve_values(app, tmp_path):
+    path = "dir/Über <test> & source.zip"
+    digest = "sha256:" + "b" * 64
+    _write_manifest(tmp_path, payload={"version": 1, "paths": {path: digest}, "hashes": {}})
+    response = await app.test_client().get("/api/release/manifest/example/1.0/00001")
+    assert response.status_code == 200
+    assert (await response.get_json())["files"] == [{"path": path, "size": None, "digest": digest}]
+
+
+def _write_manifest(tmp_path, *, version=2, revision="00001", payload=None):
+    if payload is None:
+        path_hashes = {
+            name: "blake3:" + str(index) * 64
+            for index, name in enumerate(("bundle.zip.sha512", "bundle.zip.asc", "bundle.zip"))
+        }
+        payload = {
+            "version": version,
+            "paths": path_hashes
+            if version == 1
+            else {
+                name: {
+                    "content_hash": digest,
+                    "classification": "private-classification",
+                    "provenance": {"generator": "SHA512_from_content", "metadata": {"private-provenance": True}},
+                }
+                for name, digest in path_hashes.items()
+            },
+            "hashes": {
+                digest: {"size": 100 + index, "uploaders": [["private-uploader", "00001"]]}
+                for index, digest in enumerate(path_hashes.values())
+            },
+            "policy": {"private-policy": True},
+        }
+        payload["hashes"]["blake3:" + "a" * 64] = {"size": 1, "uploaders": [], "basenames": ["obsolete.zip"]}
+    directory = tmp_path / "example" / "1.0"
+    directory.mkdir(parents=True, exist_ok=True)
+    (directory / f"{revision}.json").write_text(json.dumps(payload))
diff --git a/tests/unit/test_get_manifest.py b/tests/unit/test_get_manifest.py
index 326b6f42..4d7b67a0 100644
--- a/tests/unit/test_get_manifest.py
+++ b/tests/unit/test_get_manifest.py
@@ -30,6 +30,7 @@ import quart
 import sqlalchemy.ext.asyncio
 import sqlmodel
 
+import atr.api as api
 import atr.attestable as attestable
 import atr.blueprints.common as common
 import atr.db as db
@@ -79,6 +80,11 @@ async def app(monkeypatch, tmp_path):
             jinja2.FileSystemLoader(_TEMPLATES),
         ]
     )
+    app.add_url_rule(
+        "/api/release/manifest/<project_key>/<version_key>/<revision>",
+        endpoint=api.release_manifest.endpoint,
+        view_func=api.release_manifest,
+    )
     app.add_url_rule(
         "/manifest/<project_key>/<version_key>/<revision_number>",
         endpoint=manifest.selected.endpoint,
@@ -107,6 +113,9 @@ async def test_embargo_uses_existing_visibility_rules(app, tmp_path, monkeypatch
     assert response.status_code == (200 if permitted else 404)
     if not permitted:
         loader.assert_not_awaited()
+    else:
+        body = await response.get_data(as_text=True)
+        assert ("JSON manifest" in body) == (phase == sql.ReleasePhase.RELEASE)
 
 
 async def test_empty_manifest(app, tmp_path):
@@ -116,6 +125,7 @@ async def test_empty_manifest(app, tmp_path):
     assert response.status_code == 200
     assert "0 files recorded." in body
     assert "This revision contains no files." in body
+    assert '<a href="/api/release/manifest/example/1.0/00001">JSON manifest</a>' in body
     assert "File manifest pages" not in body
 
 
@@ -236,6 +246,7 @@ async def test_pagination(app, tmp_path, query, limit, offset, shown, previous_o
     body = html.unescape(await response.get_data(as_text=True))
     assert response.status_code == 200
     assert "513 files recorded." in body
+    assert '<a href="/api/release/manifest/example/1.0/00001">JSON manifest</a>' in body
     assert [int(index) for index in re.findall(r">file-(\d+)\.txt</code>", body)] == list(range(offset, offset + shown))
     if shown:
         assert f"Showing files {offset + 1} to {offset + shown} of 513." in body


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