diff --git a/scripts/build-core-distribution.sh b/scripts/build-core-distribution.sh index 0089a588d..ea6b1229c 100755 --- a/scripts/build-core-distribution.sh +++ b/scripts/build-core-distribution.sh @@ -47,8 +47,8 @@ esac # Build one selected Linux architecture image and write the ID the local image store gives it to # $stage/NAME.id. BuildKit's --iidfile reports the config digest, which is the # image ID only in Docker's classic store; the containerd store (Docker 29's -# default) uses the manifest digest and cannot resolve the config digest. The -# build metadata carries both, and built-image keeps the one the store resolves. +# default) uses the manifest digest and cannot resolve the config digest. +# built-image verifies the local identity from the digests the exporter reports. # Without provenance attestations each image is one platform manifest in both # stores, as the archive verifier requires. build_image() { diff --git a/scripts/core-distribution-manifest.py b/scripts/core-distribution-manifest.py index 3821272b6..7a2c4fade 100644 --- a/scripts/core-distribution-manifest.py +++ b/scripts/core-distribution-manifest.py @@ -85,6 +85,14 @@ def built_image(metadata_file, architecture="amd64"): # by its manifest digest; the other value never resolves to itself there. config = metadata.get("containerimage.config.digest") manifest = metadata.get("containerimage.digest", config) + # The Docker/OCI exporter may omit the config digest. Its manifest must + # itself identify the loaded image; archive verification still binds both. + if "containerimage.config.digest" not in metadata: + if not isinstance(manifest, str) or not DIGEST.fullmatch(manifest): + raise ValueError("Build metadata lacks valid image digests") + verify_image(manifest, architecture) + print(manifest) + return print(resolve_image(config, manifest, architecture)) diff --git a/scripts/core-distribution-manifest.test.py b/scripts/core-distribution-manifest.test.py index e47f70433..24a6bbf0a 100644 --- a/scripts/core-distribution-manifest.test.py +++ b/scripts/core-distribution-manifest.test.py @@ -250,12 +250,15 @@ def test_built_image_records_the_id_each_docker_store_resolves(self): config, manifest, other = ("sha256:" + digit * 64 for digit in "123") metadata = self.stage / "build.json" both = {"containerimage.config.digest": config, "containerimage.digest": manifest} - # Classic stores resolve the config digest, containerd stores the manifest digest. A digest - # resolving to another image, or metadata without a config digest, identifies nothing. + # Classic stores resolve the config digest, containerd stores the manifest digest. + # A digest resolving to another image identifies nothing. cases = ((dict(both, **{"containerimage.digest": config}), {config: config}, config), + ({"containerimage.config.digest": config}, {config: config}, config), + (both, {config: config}, config), (both, {manifest: manifest}, manifest), + (both, {config: config, manifest: manifest}, "does not identify"), (both, {config: other}, "does not identify"), - ({"containerimage.digest": manifest}, {manifest: manifest}, "lacks valid image digests")) + ({"containerimage.digest": manifest}, {manifest: manifest}, manifest)) for build, store, expected in cases: with self.subTest(build=build, store=store): metadata.write_text(json.dumps(build)) @@ -273,6 +276,50 @@ def test_built_image_records_the_id_each_docker_store_resolves(self): distribution.built_image(metadata) verify.assert_not_called() + def test_manifest_only_metadata_requires_the_exact_loaded_linux_image(self): + manifest, other = ("sha256:" + digit * 64 for digit in "12") + metadata = self.stage / "build.json" + metadata.write_text(json.dumps({"containerimage.digest": manifest})) + correct = {"Id": manifest, "Os": "linux", "Architecture": "amd64"} + for details in (correct, dict(correct, Id=other), dict(correct, Os="windows"), + dict(correct, Architecture="arm64")): + with self.subTest(details=details), \ + mock.patch.object(distribution.subprocess, "check_output", return_value=json.dumps([details])) as inspect, \ + mock.patch("builtins.print") as output: + if details == correct: + distribution.built_image(metadata) + output.assert_called_once_with(manifest) + else: + with self.assertRaisesRegex(ValueError, "selected Linux architecture"): + distribution.built_image(metadata) + output.assert_not_called() + inspect.assert_called_once_with(["docker", "image", "inspect", manifest], text=True) + with mock.patch.object(distribution.subprocess, "check_output", + side_effect=subprocess.CalledProcessError(1, "docker")), \ + mock.patch("builtins.print") as output: + with self.assertRaises(subprocess.CalledProcessError): + distribution.built_image(metadata) + output.assert_not_called() + + def test_missing_config_does_not_accept_invalid_digests(self): + metadata = self.stage / "build.json" + manifest = "sha256:" + "1" * 64 + cases = [{}] + for invalid in (None, "", "image:latest", "sha256:123", 123, {}): + cases.append({"containerimage.digest": invalid}) + cases.append({"containerimage.digest": manifest, "containerimage.config.digest": invalid}) + for build in cases: + with self.subTest(build=build), \ + mock.patch.object(distribution.subprocess, "run") as resolve, \ + mock.patch.object(distribution.subprocess, "check_output") as inspect, \ + mock.patch("builtins.print") as output: + metadata.write_text(json.dumps(build)) + with self.assertRaisesRegex(ValueError, "lacks valid image digests"): + distribution.built_image(metadata) + resolve.assert_not_called() + inspect.assert_not_called() + output.assert_not_called() + def test_containerd_build_ids_still_publish_archive_config_ids(self): for name, identity in self.identities.items(): (self.stage / (name + ".id")).write_text(identity[1])