diff --git a/scripts/server_image_release.py b/scripts/server_image_release.py index 00a24fd..99848ad 100644 --- a/scripts/server_image_release.py +++ b/scripts/server_image_release.py @@ -242,10 +242,11 @@ def rootfs(config): def archive_identity(path, local): - """Read Docker's saved config/layers, never infer config identity from Id. + """Bind verified saved bytes to the inspected immutable store identity. - containerd save has an OCI layout envelope; its *one referenced image* must - be a manifest, not an index. Classic Docker save has only manifest.json. + Both stores may save an OCI envelope. Classic Id hashes raw config; + containerd Id/Descriptor identify the manifest, whose verified descriptors + bind config/layers. Inspect Config is reconstructed, not the saved JSON. No archive extraction, and all temporary image bytes are removed by caller. """ try: @@ -270,6 +271,7 @@ def archive_identity(path, local): names = archive.getnames() require(len(names) == len(set(names)), "duplicate archive members") + exported_descriptor = None if "index.json" in names: index = json_body(member("index.json").read()) require(isinstance(index, dict) and index.get("schemaVersion") == 2 and @@ -279,6 +281,8 @@ def archive_identity(path, local): require(isinstance(descriptor, dict) and descriptor.get("mediaType") in MANIFEST_TYPES, "unexpected archive image index/attestation") manifest = image_manifest(blob(descriptor).read()) + require(manifest["mediaType"] == descriptor["mediaType"], "archive manifest media type mismatch") + exported_descriptor = descriptor config_body = blob(manifest["config"]).read() layers = manifest["layers"] diffs = [layer_diff_id(blob(layer), layer.get("mediaType")) for layer in layers] @@ -288,12 +292,24 @@ def archive_identity(path, local): "ambiguous classic image archive") config_body = member(entries[0]["Config"]).read() diffs = [stream_digest(member(name)) for name in entries[0]["Layers"]] + config_digest = content_digest(config_body) + inspected_descriptor = local.get("Descriptor") + if inspected_descriptor is None: + require(config_digest == sha256(local.get("Id")), + "saved config does not bind inspected immutable image") + else: + require(isinstance(inspected_descriptor, dict) and isinstance(exported_descriptor, dict) and + type(inspected_descriptor.get("size")) is int and + all(inspected_descriptor.get(key) == exported_descriptor.get(key) + for key in ("digest", "size", "mediaType")) and + exported_descriptor["digest"] == sha256(local.get("Id")), + "saved manifest does not bind inspected immutable image") config = json_body(config_body) require(isinstance(config, dict) and config.get("os") == local.get("Os") and - config.get("architecture") == local.get("Architecture") and config.get("config") == local.get("Config"), - "saved config differs from inspected image") + config.get("architecture") == local.get("Architecture") and isinstance(config.get("config"), dict), + "saved config platform mismatch") require(diffs == rootfs(config) == local.get("RootFS", {}).get("Layers"), "saved layer/rootfs chain mismatch") - return {"config_digest": content_digest(config_body), "rootfs_diff_ids": diffs} + return {"config_digest": config_digest, "rootfs_diff_ids": diffs, "image_config": config} except (OSError, tarfile.TarError, KeyError, TypeError, AttributeError): raise ReleaseError("invalid image archive") from None @@ -353,7 +369,7 @@ def readback(client, release_version, sha, local): config_digest == "sha256:" + hashlib.sha256(body).hexdigest(), "config blob readback mismatch") remote = json_body(body) require(isinstance(remote, dict) and remote.get("os") == "linux" and remote.get("architecture") == "amd64" and - remote.get("config") == local["Config"], "remote platform/image config mismatch") + remote == local.get("image_config"), "remote platform/image config mismatch") diffs = rootfs(remote) require(diffs == result["rootfs_diff_ids"] and len(manifests[0]["layers"]) == len(diffs), "remote rootfs chain differs from smoked image") diff --git a/scripts/test_server_image_release.py b/scripts/test_server_image_release.py index 137e14a..0f4b294 100644 --- a/scripts/test_server_image_release.py +++ b/scripts/test_server_image_release.py @@ -201,7 +201,7 @@ class ReadbackSafety(unittest.TestCase): responses[release.registry_path("blobs/" + digest(LAYER_GZIP))] = (200, {}, LAYER_GZIP) local = {"Id": digest(blob), "config_digest": digest(blob), "Os": "linux", "Architecture": "amd64", "Config": config()["config"], "RootFS": {"Type": "layers", "Layers": [digest(LAYER)]}, - "rootfs_diff_ids": [digest(LAYER)]} + "image_config": config(), "rootfs_diff_ids": [digest(LAYER)]} return FakeClient(responses), local def test_readback_matches_both_tags_and_full_config(self): @@ -221,7 +221,7 @@ class ReadbackSafety(unittest.TestCase): elif mode == "blob": client.responses[release.registry_path("blobs/" + local["Id"])] = (200, {}, b"{}") else: - local["Config"]["User"] = "0" + local["image_config"]["config"]["User"] = "0" with self.subTest(mode=mode), self.assertRaises(release.ReleaseError): release.readback(client, "1.2.3", SHA, local) @@ -316,17 +316,119 @@ class ArchiveIdentity(unittest.TestCase): entry.size = len(body) archive.addfile(entry, io.BytesIO(body)) local["Id"] = digest(manifest_body) if mode == "oci" else digest(config_body) + if mode == "oci": + local["Descriptor"] = copy.deepcopy(descriptor) return local def test_archive_config_identity_on_classic_and_containerd_stores(self): - for mode in ("classic", "oci"): + # Classic Moby also exports an OCI envelope in newer versions; its ID + # still hashes raw config, not the generated export manifest. + for mode in ("classic", "classic-oci", "oci"): with self.subTest(mode=mode), tempfile.TemporaryDirectory() as directory: path = pathlib.Path(directory) / "image.tar" local = self.archive(path, mode) identity = release.archive_identity(path, local) self.assertEqual(identity["config_digest"], digest(encoded(config()))) self.assertEqual(identity["rootfs_diff_ids"], [digest(LAYER)]) - self.assertEqual(local["Id"] == identity["config_digest"], mode == "classic") + self.assertEqual(local["Id"] == identity["config_digest"], mode != "oci") + + def normalized_inspect(self, local): + # Moby v28.0.4 image.NewFromJSON decodes into container.Config and + # images.ImageInspect returns that struct, not RawJSON. These fields + # lack omitempty (api/types/container/config.go), so inspect emits + # their Go zero values even when the saved OCI config omits them. + # This is a source-backed fixture, not an invented hosted-run diff. + local["Config"] = dict(local["Config"], Hostname="", Domainname="", + AttachStdin=False, AttachStdout=False, AttachStderr=False, + Tty=False, OpenStdin=False, StdinOnce=False, Env=None, + Cmd=None, Image="", Volumes=None, WorkingDir="", OnBuild=None) + return local + + def test_normalized_classic_inspect_keeps_exact_saved_config_identity(self): + for mode in ("classic", "classic-oci"): + with self.subTest(mode=mode), tempfile.TemporaryDirectory() as directory: + path = pathlib.Path(directory) / "image.tar" + local = self.normalized_inspect(self.archive(path, mode)) + self.assertNotEqual(local["Config"], config()["config"]) + try: + identity = release.archive_identity(path, local) + except release.ReleaseError as error: + self.fail("raw config identity must survive Moby inspect defaults: " + str(error)) + self.assertEqual(identity["config_digest"], local["Id"]) + self.assertEqual(identity["image_config"], config()) + local.update(identity) + client, _ = ReadbackSafety().fixture() + result = release.readback(client, "1.2.3", SHA, local) + self.assertEqual(result["config_digest"], local["Id"]) + + def test_readback_uses_saved_config_not_normalized_inspect_config(self): + client, local = ReadbackSafety().fixture() + self.normalized_inspect(local) + try: + result = release.readback(client, "1.2.3", SHA, local) + except release.ReleaseError as error: + self.fail("remote must match saved config bytes, not reconstructed inspect: " + str(error)) + self.assertEqual(result["config_digest"], digest(encoded(config()))) + + def test_classic_export_must_hash_to_inspected_immutable_id(self): + for mode in ("classic", "classic-oci"): + with self.subTest(mode=mode), tempfile.TemporaryDirectory() as directory: + path = pathlib.Path(directory) / "image.tar" + local = self.archive(path, mode) + # Same nested runtime config/platform/rootfs, different raw image + # config (created/history/etc.) must not be accepted as the ID. + local["Id"] = digest(encoded(dict(config(), created="2000-01-01T00:00:00Z"))) + with self.assertRaisesRegex(release.ReleaseError, "immutable image"): + release.archive_identity(path, local) + + def test_containerd_export_must_bind_inspected_descriptor_and_id(self): + for field, value in (("Id", "sha256:" + "d" * 64), + ("digest", "sha256:" + "d" * 64), ("size", 1), + ("size", True), ("mediaType", release.MANIFEST_TYPES[1]), + ("Descriptor", None), ("Descriptor", {})): + with self.subTest(field=field, value=value), tempfile.TemporaryDirectory() as directory: + path = pathlib.Path(directory) / "image.tar" + local = self.archive(path) + if field in ("Id", "Descriptor"): + local[field] = value + else: + local["Descriptor"][field] = value + with self.assertRaisesRegex(release.ReleaseError, "immutable image"): + release.archive_identity(path, local) + + def test_containerd_descriptor_cannot_use_legacy_archive_fallback(self): + with tempfile.TemporaryDirectory() as directory: + path = pathlib.Path(directory) / "image.tar" + local = self.archive(path, "classic") + local["Descriptor"] = {"mediaType": release.MANIFEST_TYPES[0], + "digest": local["Id"], "size": len(encoded(config()))} + with self.assertRaisesRegex(release.ReleaseError, "immutable image"): + release.archive_identity(path, local) + + def test_local_identity_exports_immutable_id_and_rejects_tag_retarget(self): + with tempfile.TemporaryDirectory() as directory: + path = pathlib.Path(directory) / "image.tar" + original = self.normalized_inspect(self.archive(path, "classic")) + + def export(*args): + self.assertEqual(args[:4], ("docker", "image", "save", "-o")) + self.assertEqual(args[5], original["Id"]) + pathlib.Path(args[4]).write_bytes(path.read_bytes()) + return "" + + with mock.patch.object(release, "command", side_effect=export), \ + mock.patch.object(release, "inspect", return_value=copy.deepcopy(original)) as inspect: + try: + local = release.local_identity("retargetable:tag") + except release.ReleaseError as error: + self.fail("immutable export must verify with normalized inspect: " + str(error)) + self.assertEqual(local["image_config"], config()) + self.assertEqual(inspect.call_args_list, [mock.call("retargetable:tag")] * 2) + changed = dict(original, Id="sha256:" + "e" * 64) + with mock.patch.object(release, "command", side_effect=export), \ + mock.patch.object(release, "inspect", side_effect=[copy.deepcopy(original), changed]): + with self.assertRaisesRegex(release.ReleaseError, "changed while inspecting"): + release.local_identity("retargetable:tag") def test_archive_rejects_indexes_attestations_corruption_and_wrong_rootfs(self): for mutation in ("index", "multiple", "blob", "rootfs"): @@ -388,8 +490,9 @@ class ArchiveIdentity(unittest.TestCase): remote = config() remote["rootfs"]["diff_ids"] = ["sha256:" + "c" * 64] blob = encoded(remote) - # Isolate the rootfs guard after the config digest comparison. + # Isolate the rootfs guard after the exact saved config comparison. local["config_digest"] = digest(blob) + local["image_config"] = remote manifest = json.loads(client.responses[release.registry_path("manifests/1.2.3")][2]) manifest["config"].update(digest=digest(blob), size=len(blob)) body = encoded(manifest)