Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 38 additions & 2 deletions craft_providers/actions/snap_installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,24 @@ def _get_assertion(query: list[str]) -> bytes:
) from error


def _get_developer_id_from_snap_revision(snap_revision_assertion: bytes) -> str | None:
"""Parse the developer-id field from a snap-revision assertion.

:param snap_revision_assertion: raw bytes of the snap-revision assertion
:returns: the developer-id value, or None if not present
"""
for line in snap_revision_assertion.splitlines():
if line.startswith(b"developer-id:"):
try:
return line.split(b":", 1)[1].strip().decode()
except UnicodeDecodeError:
logger.warning(
"Failed to decode developer-id from snap-revision assertion"
)
return None
return None


@contextlib.contextmanager
def _get_assertions_file(
snap_name: str, snap_id: str, snap_revision: str, snap_publisher_id: str
Expand All @@ -264,22 +282,40 @@ def _get_assertions_file(
as the target
"""
logger.debug("Creating an assert file for snap %r", snap_name)
assertion_queries = [

snap_revision_assertion = _get_assertion(
["snap-revision", f"snap-revision={snap_revision}", f"snap-id={snap_id}"]
)

developer_id = _get_developer_id_from_snap_revision(snap_revision_assertion)

assertion_queries: list[list[str]] = [
[
"account-key",
"public-key-sha3-384=BWDEoaqyr25nF5SNCvEv2v"
"7QnM9QsfCc0PBMYD_i2NGSQ32EF2d4D0hqUel3m8ul",
],
["snap-declaration", f"snap-name={snap_name.partition('_')[0]}"],
["snap-revision", f"snap-revision={snap_revision}", f"snap-id={snap_id}"],
["account", f"account-id={snap_publisher_id}"],
]

if developer_id and developer_id != snap_publisher_id:
logger.debug(
"Snap %r has developer-id %r different from publisher-id %r,"
" fetching developer account assertion",
snap_name,
developer_id,
snap_publisher_id,
)
assertion_queries.append(["account", f"account-id={developer_id}"])

with temp_paths.home_temporary_file() as assert_file_path:
with assert_file_path.open("wb") as assert_file:
for query in assertion_queries:
assert_file.write(_get_assertion(query))
assert_file.write(b"\n")
assert_file.write(snap_revision_assertion)
assert_file.write(b"\n")
assert_file.flush()
yield assert_file_path

Expand Down
92 changes: 92 additions & 0 deletions tests/unit/actions/test_snap_installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -1290,3 +1290,95 @@ def test_snaps_no_channel_raises_errors(fake_executor):
brief="channel cannot be empty",
resolution="set channel to a non-empty string or `None`",
)


# Regression test for:
# https://github.com/canonical/craft-providers/issues/445


def test_assertions_include_developer_account_when_different_from_publisher(
fake_executor,
fake_process,
mocker,
):
"""Assertion file must include the developer account when it differs from publisher.

When a snap changes ownership (e.g. transferred from a personal account to an org),
the snap-revision assertion contains a ``developer-id`` that is different from the
current publisher's account. Without asserting the developer account, ``snap ack``
fails with: 'cannot resolve prerequisite assertion: account (<developer-id>)'.

Regression test for https://github.com/canonical/craft-providers/issues/445
"""
publisher_id = "canonical-account-id"
developer_id = "original-developer-id" # Different from publisher_id!

mocker.patch(
"craft_providers.actions.snap_installer.get_host_snap_info",
return_value=SnapInfo(
id="snap-id-abc123",
name="rockcraft",
revision="1194",
publisher=SnapPublisher(id=publisher_id),
),
)

# The snap-revision assertion contains a developer-id that differs from publisher.
snap_revision_assertion = (
b"type: snap-revision\n"
b"authority-id: canonical\n"
b"snap-sha3-384: abc123\n"
b"developer-id: " + developer_id.encode() + b"\n"
b"snap-id: snap-id-abc123\n"
b"snap-revision: 1194\n"
b"snap-size: 12345678\n"
b"timestamp: 2023-11-08T00:00:00Z\n"
b"\n"
)

# Register the four standard 'snap known' calls:
fake_process.register_subprocess(
["snap", "known", "account-key", fake_process.any()]
)
fake_process.register_subprocess(
["snap", "known", "snap-declaration", fake_process.any()]
)
fake_process.register_subprocess(
[
"snap",
"known",
"snap-revision",
"snap-revision=1194",
"snap-id=snap-id-abc123",
],
stdout=snap_revision_assertion,
)
fake_process.register_subprocess(
["snap", "known", "account", f"account-id={publisher_id}"]
)

# Bug: the developer's account is NOT fetched even though it's needed.
# After the fix, this call should also be registered.
fake_process.register_subprocess(
["snap", "known", "account", f"account-id={developer_id}"]
)

fake_process.register_subprocess(
["fake-executor", "snap", "ack", "/tmp/rockcraft.assert"]
)

snap_installer._add_assertions_from_host(
executor=fake_executor,
snap_name="rockcraft",
)

# Verify that the developer account assertion was fetched.
developer_account_calls = [
call for call in fake_process.calls if f"account-id={developer_id}" in str(call)
]
assert len(developer_account_calls) == 1, (
f"Expected 1 call to fetch developer account '{developer_id}', "
f"got {len(developer_account_calls)}. "
"The developer's account assertion is required when developer-id "
"differs from the publisher-id in the snap-revision assertion."
)